技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

小白也能看懂的 Vue 渲染过程(整理与更新版)

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/75-小白都能看懂的Vue渲染过程(4000+字,建议收藏!)
本文目录9 个章节
  1. 一、Vue 的整体渲染流程
  2. 二、模板解析和编译
  3. 三、render function 和 VNode
  4. 四、执行渲染和响应式更新
  5. 五、对比新旧 VNode:Diff / patch
  6. 六、一个列表更新的例子
  7. 七、patch 的简化模型
  8. 八、总结
  9. 参考资料

小白也能看懂的 Vue 渲染过程(整理与更新版)

原文用“模板 → AST → render function → VNode → Diff → DOM”的路线介绍 Vue 渲染。本文保留这条主线,并修正模板解析器、Vue 2/3 render 写法、Diff 算法和更新时机等问题。

一、Vue 的整体渲染流程

在使用单文件组件时,模板通常在构建阶段被编译成 render function;运行时执行 render function 产生 VNode,renderer 再把 VNode 挂载或更新到 DOM。

可以先记住这条链路:

<template> / render function
          ↓ 编译或直接执行
VNode(虚拟节点树)
          ↓ mount
真实 DOM
          ↓ 响应式依赖变化
重新执行组件渲染 effect
          ↓ patch / diff
更新必要的 DOM

Vue 渲染流程示意图(已本地化)

Vue 官方把运行时过程概括为三个阶段:

  1. Compile:把模板编译为返回 VNode 树的 render function;
  2. Mount:首次执行 render,遍历 VNode 创建真实节点;
  3. Patch:响应式依赖变化后再次 render,比较新旧 VNode 并提交更新。

这里的 Virtual DOM 更像一种“用对象描述 UI,再由 renderer 同步到宿主环境”的模式,不是一种唯一固定的算法。

一个帮助记忆的比喻

可以把模板看成菜单,把编译器看成翻译菜单的服务员,把 render function 看成烹饪步骤,把 VNode 看成已经摆好的菜品描述,把真实 DOM 看成端到客人面前的菜。状态变化时,不是把整家餐厅推倒重建,而是重新计算需要变化的部分,再由 renderer 更新界面。

这个比喻有助于入门,但不要据此认为 Vue 每次都只修改一个 DOM 节点,或认为 Diff 必然产生数学意义上的最小操作数。

二、模板解析和编译

1. Vue 2 的编译链路

Vue 2 Runtime + Compiler 构建在运行时接收到字符串模板时,大致会经历:

parse → AST
optimize → 标记静态节点
generate → render function 字符串

Vue 2 的 AST 是内部结构,包含元素、文本、表达式、指令和条件分支等信息。下面是简化示意,不是可直接喂给 Vue 的完整 AST:

const ast = {
  type: 1,
  tag: 'div',
  attrsList: [{ name: 'class', value: 'container' }],
  children: [
    {
      type: 1,
      tag: 'h1',
      children: [
        {
          type: 2,
          text: '{{ title }}',
          expression: '_s(title)',
        },
      ],
    },
    {
      type: 1,
      tag: 'p',
      if: 'showText',
      children: [{ type: 2, text: '{{ text }}' }],
    },
  ],
}

2. Vue 3 的编译链路

Vue 3 compiler-core/compiler-dom 的概念流程是:

parse → template AST
transform → 指令转换、静态分析、生成编译提示
codegen → JavaScript render function

Vue 3 不依赖通用的 htmlparser2 来完成模板编译;它使用 Vue 自己的编译器包和 AST 转换流程。不同构建工具、SFC 编译器和运行时版本的内部函数名可能变化,因此文章中的 helper 名称只能作为说明。

例如:

<template>
  <div class="container">
    <h1>{{ title }}</h1>
    <p v-if="showText">{{ text }}</p>
  </div>
</template>

在 Vue 3 中,编译后的结果可能包含类似下面的逻辑:

import {
  createElementVNode as _createElementVNode,
  openBlock as _openBlock,
  createElementBlock as _createElementBlock,
  createCommentVNode as _createCommentVNode,
  toDisplayString as _toDisplayString,
} from 'vue'

function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock('div', { class: 'container' }, [
    _createElementVNode('h1', null, _toDisplayString(_ctx.title), 1),
    _ctx.showText
      ? (_openBlock(), _createElementBlock('p', { key: 0 }, _toDisplayString(_ctx.text), 1))
      : _createCommentVNode('v-if', true),
  ]))
}

真实编译结果会根据版本和配置包含更多 helper、Patch Flags、静态提升和 block 信息。.vue 文件一般在构建阶段完成编译,因此生产环境通常不需要把模板字符串交给浏览器编译;包含 Runtime + Compiler 的构建则可以支持运行时模板编译,但包体积更大。

三、render function 和 VNode

render function 是一个返回 VNode 的函数,不是直接操作 DOM 的函数。VNode 通常是描述元素、组件、属性和子节点的普通对象:

const vnode = {
  type: 'div',
  props: { id: 'hello' },
  children: [
    { type: 'span', children: 'Vue' },
  ],
}

renderer 会根据 VNode 创建真实节点。第一次挂载可以理解为:

patch(null, vnode, container)

更新时则是:

patch(oldVNode, newVNode, container)

Vue 3 的 render function 示例

不要在同一个组件中同时把 template 和 render 当成两个独立渲染入口。下面是一个不使用 template 的 Vue 3 render 组件:

import { defineComponent, h, ref } from 'vue'

export default defineComponent({
  setup() {
    const count = ref(0)

    function increment() {
      count.value++
    }

    return () => h('div', [
      h(
        'button',
        { onClick: increment },
        `点击次数:${count.value}`,
      ),
    ])
  },
})

组件首次执行返回的 VNode 被挂载到 DOM;点击按钮修改 count.value 后,组件的渲染 effect 被调度,再次执行返回函数,renderer 将新旧 VNode 进行 patch。

Vue 2 的 render 写法通常是:

export default {
  data: () => ({ count: 0 }),
  methods: {
    increment() {
      this.count++
    },
  },
  render(h) {
    return h('button', {
      on: { click: this.increment },
    }, String(this.count))
  },
}

Vue 2 使用 hon 等 VNode 数据格式,Vue 3 使用 onClick 等 prop/事件格式,不能直接混用。

四、执行渲染和响应式更新

Vue 组件并不是每次状态变化都重新执行 setup()。在 Vue 3 中:

  1. setup() 在组件创建阶段执行一次;
  2. setup 返回的状态和 render function 被用于创建组件渲染 effect;
  3. 首次执行 render 记录读取到的响应式依赖;
  4. 依赖变化后,scheduler 安排组件更新;
  5. 更新时主要重新执行 render function,再 patch 新旧 VNode。

Vue 2 也会创建渲染 watcher,在 render 期间收集依赖,数据变化后将 watcher 放入更新队列。两者的内部类名和调度实现不同,但“渲染读取依赖、依赖变化触发更新”的主线相近。

读取 state.title
       ↓
建立 effect / watcher 依赖
       ↓
state.title = 'new value'
       ↓
调度组件更新
       ↓
render → new VNode → patch

Vue 会批量处理同一轮中的多次状态修改,因此在需要读取更新后的 DOM 时使用 nextTick(),而不是假设每次赋值都会立即更新 DOM。

五、对比新旧 VNode:Diff / patch

Diff 是 renderer 用来比较新旧 VNode 并提交 DOM 更新的过程。通常会遵循以下启发式规则:

  1. 主要进行同层比较,不做任意跨层树编辑;
  2. 节点类型不同,通常会卸载旧节点并创建新节点;
  3. 节点类型和 key 相同,才有机会复用同一个节点;
  4. 同类型节点继续比较 props、文本和 children;
  5. keyed children 使用 key 表达节点身份,帮助处理插入、删除和移动。

class 或其他 props 不决定节点身份;节点是否可复用主要由类型、key 以及组件/元素的相关条件决定。也不能笼统地说 Diff 一定得到“最小 DOM 操作”,因为框架使用了启发式算法,编译信息和具体宿主操作也会影响结果。

1. Vue 2 的双端 Diff

Vue 2 的 updateChildren 使用双端指针,常见比较顺序包括:

  • 旧头与新头;
  • 旧尾与新尾;
  • 旧头与新尾;
  • 旧尾与新头;
  • 都不匹配时,使用 key map 查找旧节点,移动或创建节点。

Vue 2 双端 Diff 示意图(已本地化)

简化伪代码:

function sameVNode(oldVNode, newVNode) {
  return oldVNode.type === newVNode.type
    && oldVNode.key === newVNode.key
}

function updateChildren(oldChildren, newChildren) {
  // 真实实现会维护 oldStart/oldEnd/newStart/newEnd
  // 先处理双端匹配,再使用 key map 处理剩余节点
}

2. Vue 3 的 keyed children 和 LIS

Vue 3 对 keyed children 的更新通常会:

  1. 先处理相同前缀和后缀;
  2. 建立新节点 key 到索引的映射;
  3. 删除旧节点中不再存在的项目;
  4. 创建新增节点;
  5. 对需要移动的节点计算最长递增子序列(LIS),尽量保留已经按新顺序排列的节点。

Vue 3 keyed Diff 的匹配示意图(已本地化)

LIS 是移动优化的一部分,不是“Vue 3 的整个 Diff 算法就是动态规划”。当节点类型、key、插槽、组件边界或编译标记发生变化时,还会进入不同的 patch 分支。

3. 编译器提供的优化

Vue 3 不只依赖运行时 Diff,还因为 compiler 和 renderer 紧密配合而获得优化:

  • 静态节点提升,避免每次重新创建;
  • Patch Flags,提示节点只需要更新 class、style、text 等哪类动态内容;
  • Block Tree,追踪动态后代,减少遍历静态子树;
  • 稳定 fragment 和 keyed children 的专门路径;
  • scheduler 对组件更新 job 去重和批量处理。

Vue 渲染与编译优化示意图(已本地化)

所以更准确的说法是:Vue 通过声明式模板、响应式依赖、编译优化和 VNode patch,在很多动态 UI 场景中减少不必要工作;不能绝对说 Virtual DOM 在所有场景都比手写 DOM 快。

六、一个列表更新的例子

<script setup>
import { ref } from 'vue'

const title = ref('Todo List')
const items = ref([
  { id: 1, text: 'Learn Vue' },
  { id: 2, text: 'Build an app' },
  { id: 3, text: 'Deploy to production' },
])

function addItem() {
  items.value.push({ id: Date.now(), text: 'Test' })
}
</script>

<template>
  <section>
    <h1>{{ title }}</h1>
    <button @click="addItem">添加</button>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.text }}
      </li>
    </ul>
  </section>
</template>

点击按钮后,Vue 不需要重新创建整个应用的 DOM。组件重新渲染得到新的 children,renderer 发现原有 key 对应的节点仍然存在,并创建新增节点。但“只新建一个节点”是这个具体更新的结果,不是所有列表更新的保证;如果项目被删除、排序、key 改变,可能还需要移动、卸载或更新其他节点。

七、patch 的简化模型

function patch(oldVNode, newVNode, container) {
  if (oldVNode == null) {
    mount(newVNode, container)
    return
  }

  if (!sameVNode(oldVNode, newVNode)) {
    const next = mount(newVNode, container, oldVNode.el)
    unmount(oldVNode)
    return next
  }

  patchProps(oldVNode, newVNode)
  patchChildren(oldVNode, newVNode)
  return newVNode
}

真实 Vue renderer 还要处理:

  • 元素、组件、文本、注释和 Fragment;
  • 事件、class、style、property 和 attribute;
  • 组件生命周期、slots、异步组件和 KeepAlive;
  • Teleport、Transition、Suspense;
  • SSR hydration 和自定义 renderer。

因此,网上常见的 patch(oldVnode, vnode) 片段只能作为思路示意,不能直接当成 Vue 2 或 Vue 3 当前源码。

八、总结

Vue 的渲染过程可以概括为:

  1. 模板在构建阶段或运行时被 Vue compiler 解析和转换;
  2. compiler 生成 render function;
  3. render function 执行后返回 VNode;
  4. renderer 首次 mount VNode,创建真实 DOM;
  5. 响应式依赖变化后调度组件更新;
  6. 新旧 VNode 经过 patch/Diff,提交必要的 DOM 操作。

Vue 2 重点可以回答“AST、静态优化、render watcher、双端 Diff”;Vue 3 还应补充“parse/transform/codegen、Proxy/effect、Patch Flags、Block Tree、LIS 和 hydration”。

参考资料

原文作者:Quixn。原文关于模板、AST、render function、VNode、Diff 和 patch 的学习主线予以保留;错误的 htmlparser2 归因、损坏代码块、Vue 2/3 API 混用、误称“动态规划”的 Diff 描述及抓取推广内容已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS