真实面经题目 · 原创解析
手写:用 Vue 写一个登录组件,考验组件封装能力
这道题不是让你画一个账号密码框,而是考察登录表单组件的封装边界。高质量回答要说清 LoginForm 负责 UI、输入状态、校验、错误展示和提交契约,业务页面负责登录接口、token/session、路由跳转、风控和埋点。
真实面经题目 · 原创解析
这道题不是让你画一个账号密码框,而是考察登录表单组件的封装边界。高质量回答要说清 LoginForm 负责 UI、输入状态、校验、错误展示和提交契约,业务页面负责登录接口、token/session、路由跳转、风控和埋点。
我会把登录组件设计成一个不绑定具体业务接口的 LoginForm。它通过 props 接收 modelValue、字段配置、校验规则、loading/disabled、服务端错误;通过 emit 输出 update:modelValue、submit、invalid、reset;通过 slots 扩展标题、验证码、记住我、忘记密码和第三方登录入口。组件内部维护 touched、fieldErrors、formError、localSubmitting 等交互状态。提交链路是点击或 Enter 后先做本地校验,校验失败标记字段错误并阻止提交;校验成功后进入 submitting,禁用重复提交,向父组件抛出 payload。通用组件不写死 login API、token 存储和路由跳转,这些由页面层处理,接口错误再作为 serverErrors 或 formError 回填到组件。
LoginForm 的职责是表单 UI、输入同步、校验触发、loading 展示、错误展示、键盘交互和可访问性。LoginPage 或组合函数负责调用登录接口、保存 token/session、处理路由跳转、风控验证码、埋点和业务错误翻译。
核心 props 可以是 modelValue、rules、submitting、disabled、serverErrors、submitText 和 fieldLabels;emits 至少包括 update:modelValue、submit、invalid、reset。slots 用来放 extraFields、footer、thirdParty、field-extra 等扩展内容,验证码、记住我和短信登录不应该靠改组件内部 if else 堆出来。
状态可以拆成 idle、editing、validating、submitting、succeeded、failed。输入时更新 modelValue 并清理对应字段服务端错误;blur 后标记 touched;submit 时先 validating,失败则聚焦第一个错误字段,成功校验后进入 submitting;父组件返回失败后把 fieldErrors 或 formError 回填,组件恢复到可编辑状态。
本地校验负责必填、格式、长度、两端空格等即时反馈;服务端负责账号不存在、密码错误、账号冻结、验证码过期、风险拦截等业务结果。组件只展示标准化错误,不猜业务语义。
如果只用 emit('submit', payload),组件不能假设自己能 await 父组件,因此 loading 应由父组件通过 submitting prop 受控;如果希望组件内部包住异步流程,可以显式传入 Promise 型 onSubmit。无论哪种方式,handleSubmit 都要先判断 submitting/localSubmitting,防止鼠标连点和 Enter 重复触发。
每个输入要有 label 或 aria-label,错误要通过 aria-invalid、aria-describedby 和 role='alert' 关联,form 支持 Enter 提交,按钮暴露 aria-busy,账号和密码使用合适的 autocomplete。组件不能把密码写入 localStorage、URL、日志或埋点;前端自定义加密不能替代 HTTPS、HttpOnly Cookie、CSRF 防护和后端风控。
<script setup>
const props = defineProps({
modelValue: { type: Object, required: true },
rules: { type: Object, default: () => ({}) },
submitting: Boolean,
serverErrors: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["update:modelValue", "submit", "invalid", "reset"]);
const localErrors = reactive({});
function updateField(name, value) {
emit("update:modelValue", { ...props.modelValue, [name]: value });
}
function validate() {
Object.keys(localErrors).forEach((key) => delete localErrors[key]);
if (!props.modelValue.account) localErrors.account = "请输入账号";
if (!props.modelValue.password) localErrors.password = "请输入密码";
return Object.keys(localErrors).length === 0;
}
function handleSubmit() {
if (props.submitting) return;
if (!validate()) {
emit("invalid", { ...localErrors });
return;
}
emit("submit", { ...props.modelValue });
}
</script>
<template>
<form novalidate @submit.prevent="handleSubmit">
<label>
账号
<input
:value="modelValue.account"
autocomplete="username"
:aria-invalid="Boolean(localErrors.account || serverErrors.account)"
@input="updateField('account', $event.target.value)"
/>
</label>
<label>
密码
<input
type="password"
:value="modelValue.password"
autocomplete="current-password"
:aria-invalid="Boolean(localErrors.password || serverErrors.password)"
@input="updateField('password', $event.target.value)"
/>
</label>
<slot name="extraFields" :model="modelValue" />
<button type="submit" :disabled="submitting" :aria-busy="submitting">登录</button>
<slot name="footer" />
</form>
</template> 这些是业务流程,不是表单组件能力。不同页面可能有不同接口、二次验证、跳转来源、session 策略和错误文案。写进组件会让复用场景被迫带着某个业务流程。
emit 不是可靠的 Promise 提交契约。常见做法是父组件维护 loading 并通过 submitting prop 传回 LoginForm;或者明确传入 Promise 型 onSubmit,由组件内部 await 并维护 localSubmitting。
不够。按钮 disabled 只能挡部分点击,Enter 或异步状态延迟仍可能进 handleSubmit。函数入口要先判断 submitting/localSubmitting,后端仍需要频控或幂等。
先分字段级和表单级。密码错误落 password 字段,验证码过期落 captcha,账号冻结通常是 formError 或顶部 alert。错误码到文案的映射由业务页面或适配层完成。
抽出共享 FormShell、字段配置和状态机,登录方式作为 mode 或 tabs,由 slots/field config 注入不同字段。loading、错误展示、可访问性和防重都复用,业务差异留在页面层或 mode 配置。