真实面经题目 · 原创解析

手写:用 Vue 写一个登录组件,考验组件封装能力

这道题不是让你画一个账号密码框,而是考察登录表单组件的封装边界。高质量回答要说清 LoginForm 负责 UI、输入状态、校验、错误展示和提交契约,业务页面负责登录接口、token/session、路由跳转、风控和埋点。

出现于:百度 · 前端

60 秒回答模板

我会把登录组件设计成一个不绑定具体业务接口的 LoginForm。它通过 props 接收 modelValue、字段配置、校验规则、loading/disabled、服务端错误;通过 emit 输出 update:modelValue、submit、invalid、reset;通过 slots 扩展标题、验证码、记住我、忘记密码和第三方登录入口。组件内部维护 touched、fieldErrors、formError、localSubmitting 等交互状态。提交链路是点击或 Enter 后先做本地校验,校验失败标记字段错误并阻止提交;校验成功后进入 submitting,禁用重复提交,向父组件抛出 payload。通用组件不写死 login API、token 存储和路由跳转,这些由页面层处理,接口错误再作为 serverErrors 或 formError 回填到组件。

考点 核心机制与工程取舍
难度 中高频面试题
回答目标 按定义、机制、场景讲清楚

深入解析

01

先划清组件职责

LoginForm 的职责是表单 UI、输入同步、校验触发、loading 展示、错误展示、键盘交互和可访问性。LoginPage 或组合函数负责调用登录接口、保存 token/session、处理路由跳转、风控验证码、埋点和业务错误翻译。

02

再设计 props、emits 和 slots

核心 props 可以是 modelValue、rules、submitting、disabled、serverErrors、submitText 和 fieldLabels;emits 至少包括 update:modelValue、submit、invalid、reset。slots 用来放 extraFields、footer、thirdParty、field-extra 等扩展内容,验证码、记住我和短信登录不应该靠改组件内部 if else 堆出来。

03

把表单当状态机处理

状态可以拆成 idle、editing、validating、submitting、succeeded、failed。输入时更新 modelValue 并清理对应字段服务端错误;blur 后标记 touched;submit 时先 validating,失败则聚焦第一个错误字段,成功校验后进入 submitting;父组件返回失败后把 fieldErrors 或 formError 回填,组件恢复到可编辑状态。

04

校验分本地规则和服务端错误

本地校验负责必填、格式、长度、两端空格等即时反馈;服务端负责账号不存在、密码错误、账号冻结、验证码过期、风险拦截等业务结果。组件只展示标准化错误,不猜业务语义。

05

异步提交和防重复提交要有契约

如果只用 emit('submit', payload),组件不能假设自己能 await 父组件,因此 loading 应由父组件通过 submitting prop 受控;如果希望组件内部包住异步流程,可以显式传入 Promise 型 onSubmit。无论哪种方式,handleSubmit 都要先判断 submitting/localSubmitting,防止鼠标连点和 Enter 重复触发。

06

补上可访问性和安全边界

每个输入要有 label 或 aria-label,错误要通过 aria-invalid、aria-describedby 和 role='alert' 关联,form 支持 Enter 提交,按钮暴露 aria-busy,账号和密码使用合适的 autocomplete。组件不能把密码写入 localStorage、URL、日志或埋点;前端自定义加密不能替代 HTTPS、HttpOnly Cookie、CSRF 防护和后端风控。

vue

最小 Vue 登录组件骨架

<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>
  • 组件只输出 submit payload,不在内部调用 loginApi、写 token 或 router.push。
  • button disabled 之外,handleSubmit 入口也必须 guard submitting。

易错点

  • LoginForm 内部直接 import loginApi、localStorage.setItem 和 router.push,导致表单组件和具体业务登录流程强绑定。
  • 只写两个 input 和一个 button,没有 modelValue/update:modelValue、submit payload、错误输入输出,也没有说明父组件如何接入。
  • 以为 button disabled 就能防重复提交,handleSubmit 入口没有 submitting guard。
  • 把所有错误都塞进一个 message,导致账号字段错误、密码字段错误、验证码错误和账号冻结无法正确定位。
  • 在组件里把密码写入 localStorage、URL 查询参数、console 或埋点字段。
  • 没有考虑 slots/字段配置扩展,新增验证码、记住我、忘记密码、短信登录时只能继续改组件内部结构。
  • 忽略 label、autocomplete、aria-invalid、aria-describedby 和 Enter 提交。

面试官追问

为什么登录接口、token 存储和路由跳转不应该写在 LoginForm 里?

这些是业务流程,不是表单组件能力。不同页面可能有不同接口、二次验证、跳转来源、session 策略和错误文案。写进组件会让复用场景被迫带着某个业务流程。

如果用 emit('submit'),组件怎么知道异步登录结束?

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 配置。