跳到正文
前端知识库
Vue3

从 mini-vue 理解编译与运行时边界

用教学实现追踪模板编译、响应式、组件调度和宿主渲染,识别容易被省略的正确性边界。

约 12 分钟Vue3 · 编译器 · 响应式 · 渲染器 · 源码 · 面试

阅读 mini-vue 时,先解释每个模块的输入、输出和不变量,再读函数名。本文整理《Vue 面试全流程解析》第 4–27 页的独立源码问题;示例用于验证局部机制,不是可替换 Vue 的框架,也不代表某个当前版本的完整源码。

1. 一次更新经过哪些模块

模块 输入 → 输出 应负责的事情
compiler-core 模板 → AST → 转换后 AST → render 代码 解析、表达式转换、运行时 helper 收集、代码生成
reactivity 状态读写 → 依赖关系及通知 代理缓存、effect 生命周期、ref 与 computed
runtime-core 组件/VNode → 宿主操作调用 实例、props/slots、调度、挂载、更新与卸载
runtime-dom 宿主操作 → 浏览器 DOM 元素、文本、属性、事件及节点插入删除
测试宿主 宿主操作 → 内存节点树 验证渲染行为,不依赖浏览器 DOM
shared 基础输入 → 工具结果 类型判断、命名转换、标记等共享契约

典型链路是:模板预编译为 render;组件 render 在响应式 effect 中读取状态并产生 VNode;状态变化后把组件更新任务排队;新旧 VNode 比较后调用宿主操作。compiler-core 不直接更新 DOM,响应式系统本身也不要求浏览器存在。Vue 的 DOM 编译扩展、SFC 编译以及 SSR 编译另有模块,不能把上表当成整个仓库目录。

已有 模板与 keyed diff、Proxy 机制 和 框架自身的编译优化 继续作为主解释;这里关注教学代码没有展开的接口边界。

2. 编译器为什么要分成三步

parse 必须前进,也必须识别错误

parse 把模板转换为结构化节点;transform 在遍历中理解插值、元素和指令,并准备生成代码所需信息;generate 输出 JavaScript 代码和相应 helper 引用。只说“编译器把字符串变 AST”少了后两步。

以 <p>Hello {{ name }}</p> 为例,解析得到元素、文本和插值;转换阶段处理表达式作用域与文本拼接;生成阶段才得到调用创建节点、字符串转换等 helper 的 render 代码。真实输出依赖编译器版本、构建模式和优化选项,不应把概念输出当快照断言。

写教学 parser 时先固定以下不变量:

  • 每轮循环必须消费至少一个字符,或者明确结束/抛错;遇到无法识别的 < 时返回空文本会造成死循环。
  • 开始标签压入祖先栈;结束标签必须与当前结构匹配。<div><span></div>、孤立的 </p> 都需要明确报错或恢复策略,不能把结束标签直接吃掉后继续。
  • 插值必须找到结束分隔符;空模板、多根节点、属性、注释、实体和自闭合标签分别定义支持范围。
  • 正则可以识别受限 token,但不能靠一个正则完成任意嵌套模板的解析。错误位置、命名空间与浏览器 HTML 解析规则属于额外复杂度。

源资料中的 parser 片段未展示完整结束判断和错误恢复,因此不直接复制成“完整 Vue parser”。同理,只使用 root.children[0] 的代码生成会遗漏多根节点,也会在空根节点时访问 undefined;真实 Vue 3 支持 Fragment。

transform 的退出回调为什么逆序执行

节点转换经常需要先处理子节点,再生成父节点代码。进入时登记回调,遍历子树后逆序退出,使同一节点上的转换形成嵌套关系:先进入 A、再进入 B,退出时先 B、再 A。

下面只演示遍历契约,不支持替换/删除节点,也不模拟 Vue 编译器的全部转换插件:

function traverse(node, transforms) {
  const exits = []
  for (const transform of transforms) {
    const onExit = transform(node)
    if (onExit) exits.push(onExit)
  }
  for (const child of node.children ?? []) traverse(child, transforms)
  for (let index = exits.length - 1; index >= 0; index--) exits[index]()
}

helper 集合用于让生成代码只引用需要的运行时函数;转换过程中如果会撤销节点,可能需要计数来决定何时移除 helper。module 模式通常生成导入语句,函数模式通过约定的运行时对象取得 helper。生成器还必须处理字符串转义与合法表达式,不能把用户输入拼接进可执行函数。

资料用 Object.assign(options, defaults) 合并编译选项,会修改调用者对象,且 options 为 undefined 时抛错。更清楚的教学接口是默认参数加新对象,并明确用户转换与内置转换的顺序。逆序退出的理由是依赖关系,不能推广为“while 一定比 for 快”。

3. 响应式教学代码需要守住什么

代理缓存、模式和依赖清理

同一原对象的重复 reactive 调用应复用代理;可变、只读、浅只读模式不能错误共用同一个结果。深响应通常在读取嵌套对象时按需代理,仍有嵌套处理,并非“Proxy 后完全无需递归”。数组和集合还需要专门的依赖与方法处理。

isRef、isReactive、isReadonly 等判断应能接收 null、undefined 和原始值。资料直接读取 value.__v_isRef 等字段会在空值上抛错;这些内部标志也不是验证任意输入可信性的安全机制。业务代码使用公开 API,不自行依赖私有字段。toRaw 用于临时获取原始对象,不应成为绕开更新的长期写入入口。

effect 的核心还包括运行前清理旧依赖、嵌套执行时恢复外层 effect,以及 stop 后解除订阅。例如 flag ? a : b 切换到另一个分支后,旧分支不能持续触发它;只向 Set 添加函数的实现无法完成这件事。停止侦听器也要执行清理函数,并让已排队但已停止的任务安全跳过。作用域细节复用 组合式函数生命周期。

proxyRefs 的 set 不能返回赋进去的值

浅解包代理读取 ref 时返回 .value;原字段是 ref、新值不是 ref 时更新旧 ref;赋入新 ref 时替换字段。Proxy 的 set 陷阱应返回布尔成功标记。return oldRef.value = value 遇到 0、false 或空字符串会返回假值,在严格模式下抛出 TypeError。

以下只接受普通、可写对象,并注入 isRef 判断,避免把教学标记当作正式 Vue 的实现:

function createShallowUnwrapper(target, isRef) {
  return new Proxy(target, {
    get(object, key, receiver) {
      const value = Reflect.get(object, key, receiver)
      return isRef(value) ? value.value : value
    },
    set(object, key, value, receiver) {
      const previous = Reflect.get(object, key, receiver)
      if (isRef(previous) && !isRef(value)) {
        previous.value = value
        return true
      }
      return Reflect.set(object, key, value, receiver)
    },
  })
}

这不是任意嵌套位置的自动解包器。真实 Vue 对响应式数组元素、集合内 ref、模板顶层变量和组件 setup 返回对象有不同规则,不能用一个 Proxy 概括。toRefs 是把对象属性连接成 refs,也不是把整个 reactive 对象转成单个 ref。

computed 的缓存与异常边界

经典教学模型是“第一次读取求值并缓存 → 依赖变化标记失效 → 下一次读取重新求值”。computed 自己也要收集消费者,否则内部依赖改变后,读取 computed 的组件不会被通知。

资料在执行 getter 之前把 _dirty 设为 false;如果 getter 抛错,而失败路径没有恢复状态,下一次读取可能返回无效缓存。写教学版本时只能在成功求值后提交缓存,失败时保持可重试,并用 finally 恢复外层 effect。正式 Vue 的 computed 数据结构、版本计数和失效优化会演进,不能断言所有版本都使用一个 _dirty 布尔字段。公开语义及使用方式见 computed 与 watch。

4. 组件实例、依赖注入与 VNode

组件实例保存组件类型、当前/下一份 VNode、props、slots、setup 状态、父实例、render effect 与卸载状态。createApp 将根组件包装为 VNode,render 根据类型选择元素、组件、文本或 Fragment 的处理路径;更新已有元素时继承旧 el,再比较 props 和 children。教学代码只有 mount 入口,并不意味着正式应用不需要 unmount、插件配置、应用上下文和错误处理。

emit('save', payload) 最终按组件事件契约调用父级提供的监听器。事件名规范化、监听器数组、once 和错误处理都可能影响实现;普通 DOM 的 addEventListener 不能替代组件 emit。

provide/inject 为什么按需建立新对象

子实例可以先复用祖先的 provides;第一次 provide 时再用 Object.create(parentProvides) 创建自己的层。这样子节点覆盖同名 key 时,不会改掉祖先值或影响兄弟组件;读取沿原型链找到最近的祖先提供者。

资料的 key in provides 在没有父实例时可能访问 undefined;else if (defaultValue) 丢失 0、false、空字符串;默认函数也不能一律立即执行,Vue 的 inject 有显式的工厂标志。下面只验证“沿链查找及默认值”这一局部契约,应用上下文与当前实例由调用者处理:

function resolveInjection(provides, key, fallback, useFactory = false) {
  if (provides != null && key in provides) return provides[key]
  if (useFactory && typeof fallback === 'function') return fallback()
  return fallback
}

provide 传递 ref/reactive 时保留其响应性,不会自动把普通对象转换成响应式对象。Vue 3 注入 ref 后通常仍访问 .value;推荐由提供者暴露只读状态和修改命令。更多选型见 状态架构。

VNode 标记不是任意非字符串都等于组件

VNode 的 type、key、props、children、宿主节点 el 与组件实例 component 分别承担不同职责。shapeFlag 用独立二进制位组合形状信息,flag & MASK 检查某一位;它与编译器告诉运行时“哪些内容可能变化”的 patch flag 不同。

typeof type === 'string' ? ELEMENT : STATEFUL_COMPONENT 只适用于极小教学子集。Text/Fragment 的 Symbol、函数组件、特殊内置类型都需要单独处理。children 规范化也要区分数组、文本、slots、null 和布尔占位,不能因 typeof null === 'object' 就把空 children 当 slots。

5. 调度器等待的是哪一次刷新

资料第 3 页展示的 nextTick 会返回 currentFlushPromise || resolvedPromise;第 19 页的教学片段却始终返回已解决 Promise。后者只能说明“增加一个微任务”,不能完整表达“等待当前刷新结束”。nextTick 的业务用法与 watcher 时机见 调度队列与 DOM 更新时间。

写调度器至少要回答:任务如何去重;执行中新任务如何处理;pre 队列何时清空;任务抛错会不会把后续刷新卡住。资料中的 pre 数组刷新后未清空,会把旧 watcher 带到后续无关刷新。

下面是一份可运行的教学队列:pre 任务优先,普通任务 FIFO;同一个待执行函数去重;执行中的函数不能把自己立即重新入队;一次刷新期间新增任务继续被处理。错误集中到本次刷新 Promise,排队任务仍会完成。

function createTeachingScheduler() {
  const pre = new Set()
  const jobs = new Set()
  const resolved = Promise.resolve()
  let currentFlushPromise = null

  function run(job, queue, errors) {
    try {
      job()
    } catch (error) {
      errors.push(error)
    } finally {
      queue.delete(job)
    }
  }

  function flush() {
    const errors = []
    try {
      while (pre.size || jobs.size) {
        const queue = pre.size ? pre : jobs
        run(queue.values().next().value, queue, errors)
      }
      if (errors.length) throw new AggregateError(errors, '刷新任务失败')
    } finally {
      currentFlushPromise = null
    }
  }

  function enqueue(queue, job) {
    if (typeof job !== 'function') throw new TypeError('任务必须是函数')
    queue.add(job)
    currentFlushPromise ??= resolved.then(flush)
  }

  return {
    queueJob: (job) => enqueue(jobs, job),
    queuePre: (job) => enqueue(pre, job),
    nextTick: (callback) => {
      const pending = currentFlushPromise ?? resolved
      return callback ? pending.then(callback) : pending
    },
  }
}

这个队列没有组件 ID 排序、post 阶段、卸载跳过、递归更新检测和 Vue 错误处理体系,不是 Vue 调度器源码。两个不同任务相互无限排队仍会阻塞线程;任务返回的 Promise 也不会被等待,所以它只接受同步刷新任务。调用者需要处理 nextTick() 的拒绝;业务异步流程继续使用原生 Promise。

默认 watcher 的 pre 是相对所属组件的 DOM 更新而言,不能解读为所有组件更新之前。watchEffect 还应提供停止句柄及副作用清理;资料中只有 effect.run() 的包装没有这些能力。nextTick 结束也不保证浏览器已经绘制、网络请求完成或后续独立微任务不再修改状态。

6. DOM 事件为什么需要稳定 invoker

源资料将业务函数本身注册成监听器,更新时却只改它的 .value 字段;浏览器仍调用原函数,新的业务回调不会生效。正确的局部模型是注册一个稳定包装函数,每次触发时读取包装函数上的最新业务回调。

下面限定为单个函数、显式 DOM 事件名和默认监听选项;完整 Vue 还处理事件修饰符、选项、回调数组、时间戳及错误边界:

const eventInvokers = new WeakMap()

function patchEvent(element, eventName, nextHandler) {
  if (nextHandler != null && typeof nextHandler !== 'function') {
    throw new TypeError('监听器必须是函数或空值')
  }
  let handlers = eventInvokers.get(element)
  if (!handlers) eventInvokers.set(element, (handlers = new Map()))
  const existing = handlers.get(eventName)

  if (nextHandler == null) {
    if (existing) element.removeEventListener(eventName, existing)
    handlers.delete(eventName)
  } else if (existing) {
    existing.value = nextHandler
  } else {
    const invoker = (event) => invoker.value(event)
    invoker.value = nextHandler
    handlers.set(eventName, invoker)
    element.addEventListener(eventName, invoker)
  }
}

DOM 属性也不能全部落到 setAttribute:class、style、DOM property、普通 attribute 和事件有不同规则。空字符串不代表删除,例如 <input disabled=""> 的布尔属性仍生效;删除通常要区分 null/undefined 与合法空值。文本节点更新用 nodeValue,元素文本更新用 textContent,后者会替换原有子节点;VNode 卸载还需要清理子组件和副作用。

7. 测试宿主怎样证明渲染行为

资料称 runtime-test “提供 DOM 环境”,但给出的 createElement 返回 { tag, props, children, parentNode } 普通对象。它是内存宿主,目的是把 runtime-core 的操作变成可检查的数据,不是浏览器模拟器。

宿主的 insert(child, parent, anchor) 必须处理新插入、移动已有节点、跨父节点移动以及 anchor;只写 parent.children.push(child) 会产生重复节点,无法验证 keyed diff 的移动。

function insertMemoryNode(child, parent, anchor = null) {
  if (anchor !== null && anchor.parentNode !== parent) {
    throw new Error('anchor 必须属于目标父节点')
  }
  if (child === anchor) return
  if (child.parentNode) {
    const oldChildren = child.parentNode.children
    oldChildren.splice(oldChildren.indexOf(child), 1)
  }
  const index = anchor === null ? parent.children.length : parent.children.indexOf(anchor)
  parent.children.splice(index, 0, child)
  child.parentNode = parent
}

这里假定输入是宿主自己创建、父子关系一致且不会形成祖先环的节点;真实宿主应维护这些不变量。删除节点要同时移除父数组条目、清空 parent 引用;setElementText 替换 children 时也要断开旧节点关系。测试既比较序列化结果,也检查节点身份与插入/移动次数,否则“全部重建”也可能通过字符串快照。

资料的 serialize 遍历的是渲染后的宿主树,不是原 VNode。为了测试输出而拼接的字符串没有完整 HTML 转义、布尔属性和命名空间处理,不能拿来当 SSR 实现;内存宿主也无法验证布局、焦点、真实事件传播或 hydration,需要补浏览器层测试。

8. 面试追问与验证边界

  • 为什么编译转换需要退出回调?父节点生成代码依赖已转换的子节点,多个转换按嵌套顺序退出。
  • 为什么对 ref 赋 0 会抛错?检查 Proxy set 是否错误返回了赋值结果,成功应该返回 true。
  • 为什么监听器更新后仍执行旧函数?检查实际注册的是 invoker 还是业务函数,以及调用路径是否读取最新 .value。
  • 为什么 nextTick 之后还可能发生更新?它等待当前刷新;后续异步任务可以再次写状态,浏览器绘制也属于另一层时机。
  • 如何测试跨平台渲染器?用内存宿主验证树结构、节点复用、移动与卸载,再用真实平台测试宿主特有行为。

shared 的小工具同样有语义:Object.is 区分 +0/-0 且把 NaN 视为相同;自有属性判断应调用 Object.prototype.hasOwnProperty.call 或 Object.hasOwn;String(value) 只是教学插值转换,不能等同于正式 Vue 对对象/集合的显示格式。不要因为工具只有一行就跳过边界。

本文的遍历、浅解包、注入、调度、事件 invoker 和内存插入示例由 scripts/pdf-import/verify-s03-vue.mjs 读取正文代码块验证。测试覆盖局部行为,不代表验证了完整 Vue 源码或浏览器集成。

9. 来源与取舍

  • 《Vue 面试全流程解析》物理页 4–10:模块、parse/transform/generate;补解析前进与错误边界,保留逆序退出机制。
  • 物理页 11–15:reactive/ref/effect/computed;修正空值判断、浅解包 set、异常缓存,区分教学模型与版本实现。
  • 物理页 15–23:组件、注入、watch、VNode、调度、DOM 适配;修正默认值、队列残留、事件 invoker 与属性空值。
  • 物理页 24–27:内存测试宿主、序列化和 shared;补移动、身份验证与 SSR 边界。
  • 文中不依赖资料附带且不在 PDF 内的源码 ZIP,也不假定其代码与 Vue 官方某个 tag 一致。核对正式实现时先固定项目版本,再阅读 Vue core packages 对应 tag。
  • API 契约参考:响应式基础、侦听器、依赖注入、自定义渲染器。链接作为进一步阅读入口,本文没有宣称在线核验了某个最新源码版本。