06_第_3_章_Vue.js_3_的设计思路
约 14088 字大约 47 分钟
2026-10-05
开场白:先看 Vue.js 3 的“骨架”
第 1 章我们讲了“框架设计是权衡的艺术” —— 性能与可维护性之间的取舍、运行时与编译时之间的取舍。
第 2 章我们详细讨论了框架设计的几个核心要素 —— 有些是必须要考虑的,有些是从专业角度考虑的。
框架设计讲究全局视角。一个项目再大,也有一条核心思路,并围绕它展开。
本章我们就从全局视角了解 Vue.js 3 的设计思路、工作机制和重要组成部分。我们可以把这些组成部分当作独立的“功能模块”,看看它们之间是如何相互配合的。
在后续的章节中,我们会深入各个模块内部,钻研它们的具体实现。
3.1 声明式地描述 UI
Vue.js 是一个声明式 UI 框架
Vue.js 3 是一个声明式的 UI 框架 —— 你在使用 Vue.js 3 开发页面时,是声明式地描述 UI 的。
这两个词第一次出现,我们得先掰开揉碎讲清楚,因为后面每一节都要用到它们。
命令式(imperative):
你一步一步指挥电脑干活的写法 —— “先找到这个按钮,再把按钮上的文字改成'已提交'”。每一步都得你亲手写出来,漏掉一步,功能就不对。
生活里的比喻:自己下厨炒菜。先热锅、再放油、再下料、火候几分、翻几次铲 —— 全得你指挥,一步都不能含糊。
声明式(declarative):
你只描述你想要的结果的写法 —— “我要一个按钮,上面写'已提交'”。至于怎么找到那个按钮、怎么改文字、要不要重画整个页面,是框架自己的事。
生活里的比喻:点外卖。你只报菜名(“一份黄焖鸡米饭”),厨房自己会安排先炒什么后送什么,你不用管流程。
结论: 所以一句话对比,命令式 = 你说“怎么做”;声明式 = 你说“要什么”。
思考一下:如果让你设计一个声明式 UI 框架,你会怎么设计?
要回答这个问题,我们得先想清楚 —— 编写前端页面到底涉及哪些内容?
编写页面要涉及的内容
归纳一下,编写一个前端页面至少要考虑这些东西。打个比方:写一个网页就像给一间房子做装修。
| 要考虑的东西 | 举例 | 装修里的比喻 |
|---|---|---|
| DOM 元素 | 是 div 标签还是 a 标签? | 用什么材料 —— 这面墙是刷乳胶漆(div),还是铺瓷砖(a) |
| 属性 | 比如 a 标签的 href、id、class 等通用属性 | 装修的规格 —— 门是刷成白色的(id="app")、地面铺木纹(class="wood") |
| 事件 | 比如点击 click、键盘按下 keydown 等 | 房子里的机关 —— 按一下门铃(click)、按遥控器(keydown) |
| 元素的层级结构 | DOM 树 —— 谁是谁的子节点,谁是谁的父节点 | 房间怎么套起来 —— 客厅里有一间卧室,卧室里有一张床 |
那么,怎么“声明式”地描述这些东西呢?这就是框架设计者要回答的问题。
Vue.js 的解决方案:跟 HTML 长得几乎一样
Vue.js 3 选择的方案特别直观 —— 和 HTML 标签保持一致:
| 要描述的东西 | Vue.js 里的写法 |
|---|---|
一个 div 标签 | 使用 <div></div> |
| 属性 | 使用 <div id="app"></div> |
| 动态绑定的属性 | 使用 : 或 v-bind,比如 <div :id="dynamicId"></div> |
| 事件 | 使用 @ 或 v-on,比如 <div @click="handler"></div> |
| 层级结构 | 和 HTML 一样,比如 <div><span></span></div> |
可以看到,哪怕是事件,都有对应的描述方式。用户完全不需要手写任何命令式代码,这就是“声明式地描述 UI”。
不止一种“声明式”:用 JavaScript 对象描述
除了上面这种用模板来声明式描述 UI 之外,我们还可以用 JavaScript 对象来描述。
const title = {
// 标签名称
tag: 'h1',
// 标签属性
props: {
onClick: handler
},
// 子节点
children: [
{ tag: 'span' }
]
}我们逐行看:
const title = {:用const声明一个叫title的变量(const表示“这个名字一旦定下就不许再改指向别的东西”),它的值是一个对象(object)。对象就是一堆“名字 : 值”的清单,等价于一张填好的表格// 标签名称:这是一行注释(两个斜杠开头),只给人看,浏览器会直接跳过它。它的意思是“下面这个字段说的是标签叫什么”tag: 'h1':tag这个字段记录标签名称,值是字符串'h1'—— 也就是说,我们要的是网页里的一级标题<h1>// 标签属性:同样是注释,告诉读者下面这个props字段是用来装“属性和事件”的props: {:props是 properties(属性)的缩写,它本身又是一个对象,用来装这个元素上的所有附加信息。为什么单独开一个字段?因为属性可以有无数种(id、class、href、各种事件……),没法像tag那样用一个字段名写死,所以干脆用一整个对象来装onClick: handler:属性对象的第一个键值对。键名是onClick,意思是“点击的时候”,值handler是事件处理函数(event handler)—— 一段“事情发生了就执行”的代码。⚠️ 记住这个onXxx命名习惯,本章后面渲染器识别事件全靠它}:属性对象到这里结束// 子节点:注释,说明下一个字段管的是“元素里面的东西”children: [:children(孩子、子节点)字段。值是中括号[开头的数组(array)—— 数组就是“按顺序排好的一列东西”。这里的“一列东西”就是“我有哪些子节点”{ tag: 'span' }:数组里的第一个子节点。注意它又是一个虚拟 DOM 对象,只不过这次是个空壳:只说了“我是个<span>标签”,还没说它里面有什么]:children数组结束}:title这个对象整体结束
对照着看,这个对象想说的话就是:一个 <h1>,带一个点击事件,里面装着一个 <span>。
对应到 Vue.js 模板,其实就是:
<h1 @click="handler"><span></span></h1>这一行就是上面那个对象的“人话版”:<h1> 对应 tag: 'h1',@click="handler" 对应 props.onClick,里面的 <span></span> 对应 children 数组里那个对象。两种写法描述的是同一件事,只是给人看的和给机器看的不同。
模板 vs 对象:各有千秋
那这两种方式有什么区别呢?
用 JavaScript 对象来描述 UI 更灵活。举个简单的例子:
假如我们要表示一个标题,根据标题级别不同,分别采用 h1~h6 这几个标签。如果用 JavaScript 对象来描述,我们只需要用一个变量:
// h 标签的级别
let level = 3
const title = {
tag: `h${level}`, // h3 标签
}// h 标签的级别:注释,说明下面这个变量是用来控制标题级别的let level = 3:用let声明变量level,值是3。这里用let而不是const,是因为后面还要改它(比如改成 1、2、4)。两者的区别:const定下就不许改,let允许改const title = {:还是那个描述界面的对象,这次只填了tag一个字段 —— 因为一个只有文字、没有子元素的标题,不需要写props和childrentag: `h${level}`:这是本章第一个模板字符串(template literal,反引号包起来的那种字符串)。${}是它的“插值口” —— 写${level}的意思是“把level这个变量的值填进这个字符串”。level是3,所以这个字符串拼出来的结果就是'h3'。就像填表格时在横线上写“第 3 页”,页码变了,整句话就跟着变}:对象结束
当变量 level 的值改变时,对应的标签名就会在 h1 到 h6 之间变化。
但是用模板来描述,就不得不穷举所有情况:
<h1 v-if="level === 1"></h1>
<h2 v-else-if="level === 2"></h2>
<h3 v-else-if="level === 3"></h3>
<h4 v-else-if="level === 4"></h4>
<h5 v-else-if="level === 5"></h5>
<h6 v-else-if="level === 6"></h6>这一大段在说什么?逐行看:
v-if="level === 1":条件渲染(conditional rendering)的写法 —— “如果level等于 1,就渲染这个<h1>”v-else-if="level === 2":if的“否则,如果”分支 —— 上一条不成立时,再看这条。这里就是“如果level等于 2,渲染<h2>”- 后面四行结构完全一样,分别对应
level等于 3、4、5、6 - 三个等号
===是 JavaScript 的严格相等判断(注意是两个等号==的“严格版”,它不做类型转换,更可靠)
这就像点菜时按编号点菜:想要一号菜?写一行;二号菜?再写一行……一共六种可能,你得写六遍。要是有一天支持“一号到六号”之外的菜式,你还得回来加第七遍。
这远没有 JavaScript 对象灵活。
虚拟 DOM(Virtual DOM,简称 vdom):
用 JavaScript 对象来描述真实 DOM 的结构和内容。
打个生活化的比方:你要让装修队盖一栋房子,虚拟 DOM 就相当于你先画好的一张施工图纸。图纸上写着“这里是一面承重墙、那里有一扇窗”,但墙和窗都还不存在。等到真要动工时,才照着图纸把真东西盖出来。
那张“图纸”(vdom)和图纸上的“一面墙”(vnode)是两个东西,后面第 7、8 章会正式用到 vnode 这个词,本章先只说虚拟 DOM。
看到这里,你应该会觉得:虚拟 DOM 其实也没那么神秘了吧?
正是因为虚拟 DOM 的灵活性,Vue.js 3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。其实我们在 Vue.js 组件中手写的渲染函数就是用虚拟 DOM 描述 UI 的:
import { h } from 'vue'
export default {
render() {
return h('h1', { onClick: handler }) // 虚拟 DOM
}
}渲染函数(render function):
组件“要画什么”的那段说明书。组件每次要显示内容时,就执行一次这个函数,它返回的东西就是“这次要画的虚拟 DOM”。
生活里的比喻:厨师手里的菜谱。客人(渲染器)每来一单,厨师(渲染函数)就照菜谱现做一道菜(虚拟 DOM)端出去。菜谱上写“一个 h1 主菜、配一个 click 甜点”,厨房照单做。
我们逐行看:
import { h } from 'vue':从vue这个包里只取出h这一个工具(花括号叫“解构”,意思是“只要其中这一件”)。为什么要专门引?因为我们得靠h来快速造虚拟 DOMexport default { ... }:把一个对象作为这个文件的“默认导出内容”交出去。{}里通常会放组件的各种配置render() { ... }:定义了一个叫render的方法(方法 = 挂在对象上的函数)。它就是上面说的渲染函数:调用它,就得到“要渲染的内容”return h('h1', { onClick: handler }):这一行是整段代码的核心。h(...)调用h函数,用三个参数造出一棵虚拟 DOM:- 第一个参数
'h1':造什么标签 →<h1> - 第二个参数
{ onClick: handler }:造什么属性和事件 → 带一个点击事件 - 第三个参数这里省略了:表示“没有子节点”
return的意思是“把这个结果交出去”。渲染器拿到的就是它- 第一个参数
}:对象结束
有的读者可能想问:“这里明明是 h 函数调用呀,也不是 JavaScript 对象啊?”
其实 h 函数的返回值就是一个对象,它的作用是让我们编写虚拟 DOM 变得更加轻松。如果把上面 h 调用的代码改成手写对象,就需要写更多内容:
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}export default { ... }:还是那个“默认导出的对象”,里面依然有render方法render() { ... }:还是那个渲染函数,一执行就交出“要渲染的内容”return { ... }:这里不用h了,直接手写一个对象返回 —— 这个对象就是虚拟 DOMtag: 'h1':标签是<h1>—— 和h('h1', ...)的第一个参数是一回事props: { onClick: handler }:属性事件对象 —— 和h('h1', { ... })的第二个参数是一回事
看出来了吧:h('h1', { onClick: handler }) 和手写这个对象,描述的是同一棵树。h 只是把“搭积木”的动作替你做了。
如果还有子节点,要写的内容就更多。所以 h 函数就是一个辅助创建虚拟 DOM 的工具函数,仅此而已。
顺便解释一下什么是组件的渲染函数。一个组件要渲染的内容是通过渲染函数来描述的,也就是上面代码中的 render 函数。Vue.js 会根据组件 render 函数的返回值拿到虚拟 DOM,然后把组件的内容渲染出来。
3.2 初识渲染器
渲染器是干啥的?
现在我们已经了解了什么是虚拟 DOM —— 它就是用 JavaScript 对象来描述真实 DOM 结构。
那么,虚拟 DOM 是怎么变成真实 DOM 并渲染到浏览器页面里的呢? 这就要介绍我们接下来要讲的重要角色 —— 渲染器。
渲染器(renderer):
把虚拟 DOM 渲染为真实 DOM 的程序。它是 Vue.js 中非常重要的角色 —— 我们平时写的所有 Vue 组件,最终都要靠它来把虚拟 DOM 变成屏幕上能看到的页面。
打个比方:虚拟 DOM 是施工图纸,渲染器就是那个“照着图纸施工、把墙真的砌起来”的施工队。图纸上写一百面墙,施工队就得真的砌一百面墙 —— 它是图纸和真实房子之间唯一的桥梁。
渲染器的作用可以用图 3-1 来直观表示:

图 3-1 渲染器的作用
下面我们动手写一个最简单的渲染器,看看它到底是怎么工作的。
一段虚拟 DOM 长啥样?
假设我们有如下虚拟 DOM:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}先简单解释一下这段代码,我们逐行看:
const vnode = {:声明一个变量vnode。vnode这个名字是 “virtual node”(虚拟节点)的缩写 —— 记住它,本章剩下的代码里它会到处出现,它就是“一个虚拟 DOM 节点”。开头这个v别忘了tag: 'div':标签名称是div,也就是我们打算要一个<div>标签props: { ... }:属性对象,装着这个标签的属性和事件。这里只写了一项onClickonClick: () => alert('hello'):键名onClick表示“点击时”,值是() => alert('hello')—— 一个箭头函数(arrow function,JavaScript 里的简写函数),“调用它就弹一个 hello 弹窗”。这里整段直接写成函数,是因为处理函数本身就是一段代码children: 'click me':children(子节点)字段。注意它的值不是对象,而是字符串'click me'。字符串就是一段纯文字,它表示“这个 div 里面放一段文字click me”,不是另一个标签。这种“纯文字的子节点”在真实 DOM 里叫文本节点(text node)
所以这段虚拟 DOM 想说的是:一个 div,带点击事件,里面的文字是 click me。渲染完就会变成 <div>click me</div>。
小细节:虚拟 DOM 的结构完全可以由你自己设计,比如你可以用 tagName 代替 tag —— 它本身就是一个 JavaScript 对象,没什么特殊的。
编写渲染器
接下来我们编写一个渲染器,把上面这段虚拟 DOM 渲染为真实 DOM:
function renderer(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}这是本章最重要的一段代码,我们一行一行把它拆开看。
先说两个必须认识的东西:
document:浏览器送给 JavaScript 的一扇“大门”,门后面就是整张网页。document.createElement(...)意思是“在这张网页上造一个元素出来”。注意它造出来的元素还不在页面上,得最后“挂”上去- DOM API:浏览器提供的一堆操作网页元素的方法(
createElement、addEventListener、appendChild……)。你可以理解成工具箱:框架自己不造零件,零件全在浏览器这个工具箱里
我们逐行看:
function renderer(vnode, container) {:定义一个叫renderer的函数(function = 一段可以反复调用的代码)。小括号里的vnode, container是参数(argument)—— 调用时要你传进来的“原料”。注意最后那个{表示函数体开始const el = document.createElement(vnode.tag):创建元素。让浏览器照着vnode.tag(也就是'div')造一个真正的<div>元素,结果存进变量el(el是 element 的缩写)- ⚠️ 重点:此刻这个
el是一块“还没砌进墙里的砖”,页面上看不见它。得等最后一步才挂上去
- ⚠️ 重点:此刻这个
for (const key in vnode.props) {:开始一个循环,把vnode.props里的键一个一个取出来for...in就像点名册:一次念一个名字。这里的“名字”就是props里的键名(比如onClick)- 每念到一个名字,
key就是当前这个名字,所以第一轮key是'onClick'
if (/^on/.test(key)) {:判断这个键是不是事件/^on/是正则表达式(regular expression,JavaScript 里检查字符串长相的一种迷你语言),读作“以on开头”:^表示“开头”,on是要找的字样.test(key)是拿这条规则去“考”一下key这个人,通过返回true,不通过返回false- 也就是说:只要属性名以
on打头,我们就认为它描述的是一个事件。这正是 3.1 里onClick、onInput这套命名习惯的用处
el.addEventListener(...):绑定事件。addEventListener的意思是“加一个事件监听器” —— 告诉浏览器“这件事发生的时候,就调用这个函数”。它要两个参数:事件名和处理函数key.substr(2).toLowerCase(),:算出真正的事件名称substr(2):截掉开头 2 个字符(on正好 2 个),'onClick'→'Click'.toLowerCase():转成小写,'Click'→'click'- 这一串写法的意思就是:
onClick→click、onKeydown→keydown。这样拿到的名字才是浏览器addEventListener认识的合法事件名
vnode.props[key] // 事件处理函数:第二个参数 —— 拿“事件真发生时要执行的那段代码”。注意用变量key去取值(vnode.props[key]这种写法叫“计算属性名”),这正是循环的意义:一次拿到名字、一次拿到对应的函数}:内层的if结束 —— 这个键不是事件就不管它}:外层的for结束 —— 所有属性都过了一遍if (typeof vnode.children === 'string') {:开始处理子节点。typeof是“看看它是什么类型”的工具,这里判断children是不是字符串- 为什么必须分情况?因为
children有两种可能:纯文字('click me')或者一串别的标签([{...}, {...}])。处理方式完全不同
- 为什么必须分情况?因为
el.appendChild(document.createTextNode(vnode.children)):创建并挂上文本节点document.createTextNode('click me')造出一个“只装着这段文字”的小节点el.appendChild(...)把它塞进刚创建的那个 div 里。appendChild的意思是“追加一个子节点”(append = 追加,child = 子节点)—— 这个appendChild后面会一直用,它是本章最忙的一个 DOM 方法- 注意:
document.createElement造标签、document.createTextNode造文字,网页上这两种“零件”是分开的
} else if (Array.isArray(vnode.children)) {:不是字符串?那就检查它是不是数组。Array.isArray(...)判断“这是不是一个数组”,返回true/falsevnode.children.forEach(child => renderer(child, el)):逐个渲染子节点。这是全段代码最关键的一行forEach是数组的“挨个过一遍”的方法,回调里拿到的那一个子节点起名叫child- 箭头函数
child => renderer(child, el)的意思是:“对每个child,调用一次renderer” - ⚠️ 这里的
el传成了挂载点 —— 也就是“把子节点建在我这个 div 的肚子里”。所以外层 div 会自动包住所有子节点,层级关系就这么一层层长出来了 renderer在自己的函数体里调用了自己,这叫递归(recursion)
container.appendChild(el):把元素挂上去- 前面说过,
el造好时还“飘在空气里”,这一行才真的把它放进container里面,从此页面上才看得见 - 为什么非要放最后?因为要先确保“砖砌好了、漆刷好了、里面的房间也装好了”,再整块搬进房子 —— 不然半成品就露出来了
- 前面说过,
}:函数结束
这个 renderer 函数接收两个参数:
vnode:虚拟 DOM 对象 —— “要画什么”container:一个真实 DOM 元素,作为挂载点(mount point,框架术语:组件内容最终挂靠的那个真实 DOM 元素)—— “画到哪儿”
打个比方:vnode 是购物清单,container 是冰箱。renderer 照着清单一样一样往冰箱里放。清单上写了“三盒牛奶”,那它就照样买三盒放进去 —— 放完你打开冰箱,就看到页面上的东西了。
调用方式:
renderer(vnode, document.body) // body 作为挂载点document.body:<body>是网页的正文容器(<head>里放标题、图标这些不显示的东西,<body>里才是页面正文)。我们把它当挂载点,意思是“画到这个正文的底下”// body 作为挂载点:注释,提醒我们第二个参数的作用
所以完整的一次调用是:“照着这份虚拟 DOM 清单,往 <body> 里画。”
那“递归”到底是怎么跑起来的?我们用一个小点的例子画一下。假设虚拟 DOM 是这样的:
{ tag: 'div', children: [
{ tag: 'h1', children: '标题' },
{ tag: 'p', children: '正文' }
] }renderer 的执行过程是这样的([ ] 里是当前正在处理的那一层):
运行后,浏览器里会出现 click me 文本,点击它会弹窗 hello。效果如图 3-2 所示。

图 3-2 运行结果
渲染器的工作思路(三步)
我们回过头来分析渲染器的实现思路。总体来说分成三步:
- 创建元素:用
vnode.tag作为标签名创建 DOM 元素(document.createElement(vnode.tag)) - 为元素添加属性和事件:遍历
vnode.props对象。如果key以on开头,说明它是事件 —— 把on截掉再调用toLowerCase小写化,得到合法的事件名(如onClick→click),最后用addEventListener绑定事件处理函数 - 处理 children:
- 如果
children是字符串,用createTextNode创建文本节点,加到新元素里 - 如果
children是数组,递归调用renderer渲染子节点。注意此时要把刚刚创建的元素作为“挂载点(父节点)”传入
- 如果
这三步做完,元素造好了、内容也装好了,最后还差一句 container.appendChild(el) —— 把元素挂到挂载点下,页面上才真的看得见。(原书把这一步算在整体流程里,不单列为第四步。)
怎么样,是不是感觉渲染器没有想象中那么神秘?
真正的难点在“更新”
别高兴得太早 —— 我们现在做的还只是“创建节点”,这其实是渲染器最简单的工作。
⚠️ 渲染器的精髓,都在“更新节点”的阶段。
假设我们把 vnode 稍微改一下:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click again' // 从 click me 改成 click again
}对比着上一段看,只有最后一行的 children 变了('click me' → 'click again'),tag 和 props 一个字都没动。
这就是虚拟 DOM 存在的最大理由:因为整个界面被写成了一份“可以随时重写一遍”的数据(这里是普通对象,后面响应式之后它还能自动变),所以只要拿新的和旧的比一比,就知道哪儿改了。反过来,如果一开始就是命令式地一行行改 DOM,框架根本无从比较。
对于渲染器来说,它需要精确地找到变更点,并且只更新变更的内容。
就这个例子来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的“创建元素”流程。
打个比方:你要把墙上的“1 楼”改成“2 楼”。聪明的做法是拿把刷子把“1”涂掉、写成“2”;不聪明的做法是把整面墙铲了重刷。两种做法结果一样,代价差着好几个数量级 —— 浏览器里的元素越多,这个差距越大。
这就引出了后面几章要重点讲的 Diff 算法(第 9、10、11 章)。
这部分内容后文会重点讲解。但无论如何,希望大家记住:
结论: 渲染器的工作原理其实很简单,归根结底,都是用一些我们熟悉的 DOM 操作 API 来完成渲染工作。
3.3 组件的本质
一个核心问题
我们已经初步了解了虚拟 DOM 和渲染器。知道虚拟 DOM 是用普通 JavaScript 对象来描述真实 DOM 的,渲染器会把这个对象渲染为真实 DOM 元素。
那么,组件又是什么呢?组件和虚拟 DOM 是什么关系?渲染器怎么渲染组件?
一句话回答:组件就是一组 DOM 元素的封装
组件(component):
一块可以反复复用、名字固定的界面积木。它把“一组 DOM 元素 + 自己的行为”打包成一个整体,别人只要引用这个整体就能用。
生活里的比喻:宜家的柜子模块。你不用自己研究怎么造柜体,只要报型号(MyComponent),就能拿回家拼。同一型号在客厅、卧室、书房都能出现好几个 —— 用同一个组件做出来的东西,每一个都是独立的实例(后面讲“状态”时会再回来说这一点)。
虚拟 DOM 除了能够描述真实 DOM 之外,还能描述组件。例如 { tag: 'div' } 用来描述 <div> 标签。
但是组件不是真实的 DOM 元素,那怎么用虚拟 DOM 来描述它呢?
⚠️ 一个容易搞混的词:
组件(Component)和浏览器的自定义元素(Custom Element,如<my-button>)不是一回事。自定义元素是 Web 平台标准提供的能力,写法是class+customElements.define;而 Vue 的组件是框架内部的一个约定,跟浏览器标准无关,甚至根本不生成自定义元素标签。你不需要预先了解它,本章也不会用到 —— 只是别把这两个词搞混就好。
想弄明白这个问题,就需要先搞清楚组件的本质是什么。一句话总结:
结论: 组件就是一组 DOM 元素的封装 —— 这组 DOM 元素就是组件要渲染的内容。
因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}const MyComponent = function () { ... }:把一个匿名函数(没起名字的函数)赋给变量MyComponent。因为赋给了名字,我们后面就能通过MyComponent这个名字来调用它return { ... }:函数体里直接返回一个虚拟 DOM 对象。这个对象就是MyComponent要渲染的全部内容tag: 'div':最外层是<div>props: { onClick: ... }:带一个点击事件children: 'click me':里面是文字click me
组件的返回值也是虚拟 DOM,代表组件要渲染的内容。
用虚拟 DOM 描述组件
搞清楚了组件的本质,就可以定义用虚拟 DOM 描述组件的方法了。很简单 —— 让虚拟 DOM 对象中的 tag 属性存储组件函数:
const vnode = {
tag: MyComponent
}tag: MyComponent:这是整个 3.3 节的关键一行。前面tag装的一直是标签名字符串'div',现在它装的是一个函数。框架看到这个“函数”,就知道“这不是普通标签,这是个组件,得交给组件那套逻辑处理”- ⚠️ 换个角度看:
tag这个字段从标签名变成了“要渲染的东西(标签 or 组件)”。名字还是那个名字,含义被“撑大”了一格 —— 这在编程里很常见,一个字段会随着需求慢慢长胖 - 另外注意这个对象只有
tag一个字段,props、children都没有。因为组件的内容由组件函数自己说,外层不需要再描述
就像 tag: 'div' 用来描述 <div> 标签一样,tag: MyComponent 用来描述组件,只不过此时的 tag 属性不是标签名,而是组件函数。
为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}我们逐行看:
function renderer(vnode, container) {:还是原来那个渲染器,参数也一样。注意它变短了 —— 建元素、绑事件那些细节全被搬走了if (typeof vnode.tag === 'string') {:先问一句:“这个tag是不是字符串?”(typeof是查类型的,=== 'string'是判断它等于字符串类型)mountElement(vnode, container):mount是“挂载”(mount = 挂上去,把东西装到某个位置上),mountElement顾名思义就是“把元素挂上去”。这一步把原来建元素、绑事件、处理 children、挂载的活全包了。注意它把vnode和container原封不动传了进去 —— 细节怎么处理,是它自己的事} else if (typeof vnode.tag === 'function') {:不是字符串?再看是不是函数mountComponent(vnode, container):是函数 → 说明tag装的是组件 → 调用“挂载组件”的专门函数}:条件判断结束
一句话概括这个新 renderer 的角色:它变成了一个“分诊台” —— 拿到一个 vnode,先看 tag 是什么类型,再决定“送去看Element科”还是“送去挂 Component 科”。
解释一下:
- 如果
vnode.tag是字符串,说明它描述的是普通标签元素,调用mountElement函数完成渲染 - 如果
vnode.tag是函数,说明它描述的是组件,调用mountComponent函数完成渲染
⚠️ 有人会问:既然
tag现在有两种可能,那“标签”和“组件”的界线到底在哪?界线就是typeof的结果:字符串走标签那条路,函数走组件那条路。渲染器不需要理解组件内部,它只认类型。这种“只按类型分派”的做法,能让渲染器保持简单 —— 加新东西时,只要类型分支加一条就行。
mountElement 函数的内容和上文的 renderer 几乎一样:
function mountElement(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以字符串 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}这一段的骨架和上面的 renderer 一模一样(就是上面那 5 步),我们不重复抄写,但按步骤对照一遍,你才知道它被“搬”到了哪里:
function mountElement(vnode, container) {:函数名从renderer变成了mountElement。改动虽小,含义却是“我不再当总调度了,我只管元素这一摊”。名字就是职责说明书 —— 名字取得好,代码就自带注释- 第 1 步
const el = document.createElement(vnode.tag):建元素。一个字没改,从renderer原样搬过来 - 第 2 步
for (const key in vnode.props) { if (/^on/.test(key)) { ... } }:遍历属性、认事件、绑事件。也是一个字没改 - 第 3 步
if (typeof vnode.children === 'string'):处理文本子节点。同样没改 - 第 4 步
vnode.children.forEach(child => renderer(child, el)):递归渲染子节点。同样没改 —— ⚠️ 注意它调用的仍然是renderer,不是mountElement。因为子节点可能是标签也可能是组件,得回到总调度renderer去做类型分派 - 第 5 步
container.appendChild(el):挂到挂载点上。同样没改
结论: 27 行代码整体搬家,renderer 瘦身为 9 行。这种“把一段长逻辑单独拎出来起个名字”的做法叫抽取函数,它不改变任何逻辑,但让每个函数只管一件事。
另外还有一处代码里的细节值得你停下来看一眼:
- 第 5 行的注释和上面
renderer里的不完全一样:这里写的是“如果 key 以字符串on开头”,上面只说“以on开头”。多出来的“字符串”三个字不影响代码运行 ——on是拿正则去匹配键名字符串的,加不加这个词结果都一样
而这个 mountElement 被调用的时机也变了:现在的 renderer 遇到字符串 tag 就调它,遇到函数 tag 根本不进来。同一段建元素的代码,被复用了两次,渲染器自己却越来越短 —— 这就是“分诊”的收益。
再来看 mountComponent 是怎么实现的:
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}很简单,我们逐行看:
function mountComponent(vnode, container) {:定义“挂载组件”的函数。它拿到的参数和mountElement一模一样 —— 渲染器对这两个函数一视同仁,这就是“分诊台”的好处const subtree = vnode.tag():调用组件函数- 这里的
vnode.tag不是字符串'div',而是上一节里存进去的MyComponent这个函数本身。后面那对小括号()就是“现在,把你叫起来跑一遍” - 跑完拿到的返回值就是组件要渲染的内容 —— 又一棵虚拟 DOM
- 我们给它起名叫
subtree(子树,subtree)。为什么叫子树?因为它是挂在这棵 vnode 大树下面的一小棵树;一个组件的内部结构,往往比外层复杂得多
- 这里的
renderer(subtree, container):递归调用渲染器,把这棵子树按普通虚拟 DOM 的流程渲染掉- ⚠️ 注意传的是原来的
container,不是新建的元素 —— 因为组件函数返回的是一棵完整的树,它整体要挂到同一个位置上去 - 这一行也说明:组件对渲染器来说并不神秘,它只是“先展开成一棵虚拟 DOM,再照常渲染”
- ⚠️ 注意传的是原来的
}:函数结束
所以组件化(componentization)这件事,本质上就是“把一棵大树拆成一堆小树,再把小树拼回去”。开发一个大页面时,你把它拆成 Header、List、Item 十几个组件,改一处只改那一处 —— 就像把一栋大楼的预制板件编号管理:改门牌不用砸承重墙。
本书第四篇《组件化》会专门讲 Vue.js 真实组件系统(props、emit、生命周期等)是怎么回事,本章先把“组件 = 一组 DOM 元素的封装”这个地基打好。
举一反三:组件还可以用对象表达
组件一定得是函数吗?
当然不是。我们完全可以用一个 JavaScript 对象 来表达组件:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}// MyComponent 是一个对象:注释,先把结论说出来,方便读者带着结论看代码const MyComponent = { ... }:这次不是函数了,是一个对象。把变量MyComponent从“一个函数”换成了“一个对象” —— 除此之外,用法几乎不变render() { ... }:对象里的render方法。注意它写的是方法简写(对象里直接写render() {},不用再写render: function () {}),这是对象字面量里的常用简写return { tag: 'div', props: {...}, children: 'click me' }:和函数版一字不差的虚拟 DOM
生活里的比喻:函数版是“一个人站在门口,你叫一声他就报出自己的内容”;对象版是“一张名片,名片上写着'有事按这个方法找我'”。前者靠“喊”,后者靠“查”。
这里我们用对象代表组件,对象里有一个函数 render,它的返回值代表组件要渲染的内容。
为了支持这种写法,我们需要修改渲染器和 mountComponent。
首先,修改渲染器的判断条件:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}注意我们把 typeof vnode.tag === 'function' 改成了 typeof vnode.tag === 'object'。
- 第一行
if (typeof vnode.tag === 'string'):没变,还是“字符串就是标签” else if (typeof vnode.tag === 'object'):只有这里改了 —— 判断条件从'function'(函数)换成了'object'(对象)。因为MyComponent现在是个对象,不是函数了mountComponent(vnode, container):调用没变
这就是**“举一反三”的实际收益**:想支持一种新的组件写法,改动的只有 typeof 后面那一个词,渲染器的骨架、参数、调用方式全都不用动。
⚠️ 顺便说个坑:JavaScript 里
typeof对数组也返回'object'。所以真写框架时,判断条件还要更严谨。本书里先保持这个简化版本,目的是让你看清主流程,而不是纠结边角情况。
接着修改 mountComponent:
function mountComponent(vnode, container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}vnode.tag.render():vnode.tag现在是对象,对象本身不能像函数那样直接()调用,得点名它里面的render方法再调用- 写成“先
.render取方法、再()调用”这两步,和函数版的vnode.tag()相比,只是多了“找到方法”这一步,返回值依然是那个虚拟 DOM
- 写成“先
renderer(subtree, container):完全没变,还是递归渲染
这里的 vnode.tag 是表达组件的对象,我们调用它的 render 函数得到组件要渲染的虚拟 DOM。
可以看到,只做了很小的修改,就满足了用对象表达组件的需求。
其实 Vue.js 中的有状态组件就是使用对象结构来表达的!
到这里,3.3 节的全部逻辑就闭环了:
3.4 模板的工作原理
模板和虚拟 DOM 的关系
无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI。Vue.js 同时支持这两种方式。
我们已经讲了虚拟 DOM 是如何渲染成真实 DOM 的。那么,模板是如何工作的呢?
这就要提到 Vue.js 框架中的另一个重要组成部分 —— 编译器。
编译器(compiler):
把模板编译为渲染函数的程序。它和渲染器一样,也是一段程序而已,只不过干的活不一样 —— 输入是模板字符串,输出是渲染函数。
生活里的比喻:译者。模板是一种语言(HTML + Vue 专有写法),渲染函数是另一种语言(JavaScript)。浏览器只认后者,所以得有人把前者“翻译”成后者。译完之后,浏览器就再也不知道你当初写的是模板了 —— 它只看到渲染函数。
模板变渲染函数
假设我们有这样一个模板:
<div @click="handler">
click me
</div>这四行是模板,三部分各司其职:
<div @click="handler">:开标签,@click="handler"是事件绑定(等价于 3.1 说的v-bind那套on开头写法),意思是“点一下就执行handler”click me:div 里面的文字内容</div>:闭标签。HTML 标签是成对出现的(有一个开就必有一个闭),像括号一样
对于编译器来说,模板就是一个普通的字符串。它会分析这个字符串,并生成一个功能与之相同的渲染函数:
render() {
return h('div', { onClick: handler }, 'click me')
}render() { ... }:和 3.1 那个渲染函数一模一样的形状 —— 这正是关键:不管你写的是模板还是手写渲染函数,Vue.js 内部拿到的永远是同一种东西return h('div', { onClick: handler }, 'click me'):这次h带了三个参数- 第一个
'div':标签 - 第二个
{ onClick: handler }:属性和事件 - 第三个
'click me':子节点内容。对比一下 3.1 的h('h1', { onClick: handler })—— 那里省略了这个参数,意思就是“没有子节点”;这里给了字符串,意思就是“里面放这段文字”
- 第一个
一句对照表帮你彻底分清:
| 模板里写的 | 编译成 h() 的哪个参数 |
|---|---|
<div> | 第一个:标签名 'div' |
@click="handler" | 第二个:属性对象 { onClick: handler } |
click me | 第三个:子节点 'click me' |
.vue 文件背后的故事
以我们熟悉的 .vue 文件为例。一个 .vue 文件就是一个组件:
<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
}
}
</script>逐段看:
<template> ... </template>:一个专门的区块,用来放“模板”。里面是刚才那个<div @click="handler">click me</div><script> ... </script>:另一个区块,用来放普通的 JavaScript 代码export default { ... }:导出一个组件配置对象data() {/* ... */}:data方法,用函数的形式声明“这个组件要用哪些数据”。注释/* ... */(斜杠加星号包起来)表示“这里省略了具体内容” —— 本章不展开,留到响应系统那几篇methods: { ... }:方法表,声明这个组件能做哪些事handler: () => {/* ... */}:methods表里的一项,键名handler、值是一个箭头函数。它正是上面模板里@click="handler"指向的那个函数 —— 模板写的是名字,运行时找到的却是这份代码
关键点:<template> 和 <script> 各管一摊,但通过 handler 这个名字连起来了。模板负责“长什么样”,script 负责“点了之后干什么”。
其中 <template> 标签里的内容就是模板。编译器会把模板内容编译成渲染函数,并添加到 <script> 标签块的组件对象上。所以最终在浏览器里运行的代码就是:
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}data()/methods:一个字没动,它们是从上面原封不动搬过来的- 多出来的
render() { ... }:这就是编译器“塞”进去的那一块。注意它被追加在对象最后(所以上一行}后面要加一个逗号,) return h('div', { onClick: handler }, 'click me'):和上面那段编译产物完全一样
结论: 浏览器里跑的既不是模板,也不是 <script> 里的源码,而是“<script> 源码 + 编译器塞进来的 render 方法”。你可以理解成:
看这张图时留意两条不同颜色的线:
- 蓝色实线:只有
<template>里的内容真的进了编译器,被翻译成了render() - 灰色虚线:
<script>块一个字节都没被编译。它原封不动地被搬到了右边,只是作为载体接住了那个新生成的render()。所以虚线下方才写着“data/methods一个字没动”
这就是为什么 Vue 允许你在同一个组件里既写模板又写逻辑 —— 两块各管一摊,靠 render() 这一个接口接上了班。
整体流程
所以,无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的。然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM —— 这就是上面那张图下半部分(“运行时”那一行)画的内容。
编译器是一个比较大的话题,后面我们会着重讲解,这里大家只需要清楚编译器的作用和角色即可。
把上面这张图和 3.4 的例子对着看,3.4 就通了:
| 你写的(输入) | 中间发生了什么 | 浏览器拿到的(输出) |
|---|---|---|
<div @click="handler">click me</div> | 编译器翻译 | h('div', { onClick: handler }, 'click me') |
| (无) | 渲染函数执行 | 虚拟 DOM 对象 |
| (无) | 渲染器施工 | 真实 DOM |
注意运行时那几个箭头方向是单向、从左到右的:模板永远看不到真实 DOM,渲染器也永远不关心模板长什么样。
这也解释了为什么“用模板”和“手写渲染函数”能共存 —— 因为它们在流水线的同一个位置汇流:不管内容从哪来,② 这一步拿到的永远是渲染函数。
3.5 Vue.js 是各个模块组成的有机整体
模块之间的关系
前面我们讲了:
- 组件的实现依赖于渲染器
- 模板的编译依赖于编译器
- 编译后生成的代码根据渲染器和虚拟 DOM 的设计决定
所以,Vue.js 的各个模块之间互相关联、互相制约,共同构成一个有机的整体。
学习 Vue.js 原理时,要把各个模块结合到一起看,才能明白到底是怎么回事。
编译器与渲染器的协作:在 Vue.js 里,编译器(“译”的一方)和渲染器(“建”的一方)是两个模块,却像两个部门在同一个项目里配合。
生活里的比喻:装修公司和开发商。
- 编译器 = 设计师:拿着“户型图”(模板)画出施工图(渲染函数)。设计师在图上可以顺手标注“这里将来会换灯”(3.5 后面要讲的
patchFlag),而这些标注对施工队是有用的情报 - 渲染器 = 施工队:只认施工图,照着盖房子。它不关心这张图是谁画的,也不关心图纸原来是手画还是扫描来的
- 虚拟 DOM 对象 = 交接单:两个部门之间唯一的沟通载体。交接单上多写一句,渲染队就能少跑一趟
这套比喻正好解释了下面那个“看起来有点怪”的设计:为什么编译器的产物里要夹带渲染器才用得上的信息。因为它们是同一个项目组,图纸是设计部门画的,图纸上当然可以写施工提示。
下面我们以编译器和渲染器这两个关键模块为例,看看它们是如何配合工作的。
编译器和渲染器的“合作”
假设我们有如下模板:
<div id="foo" :class="cls"></div>这一行里有两种写法混在一起,是理解后面内容的钥匙:
id="foo":静态属性。=后面直接是一个写死的字符串'foo',永远不会变:class="cls":动态绑定。开头的冒号:是v-bind的简写 —— 本章前面提过一次。冒号的含义是“等号右边不是死值,是一个变量”。所以这个 class 取决于cls这个变量,会变
根据上文的介绍,我们知道编译器会把这段代码编译成渲染函数:
render() {
// 为了效果更加直观,这里没有使用 h 函数,而是直接采用了虚拟 DOM 对象
// 下面的代码等价于:
// return h('div', { id: 'foo', class: cls })
return {
tag: 'div',
props: {
id: 'foo',
class: cls
}
}
}render() { ... }:还是那个渲染函数- 前三行注释:作者特意说明“这里故意不用
h函数,直接写虚拟 DOM 对象,是为了让你看得更清楚”。下面那个被注释掉的return h('div', { id: 'foo', class: cls })就是它的等价写法 ——h版本和对象版本说的是同一句话 tag: 'div':标签是<div>props: { id: 'foo', class: cls }:两个属性。⚠️ 仔细看这里的差别:id的值是带引号的'foo'(写死的字符串),class的值没有引号(是变量cls)。一个引号之差,就是“静态”和“动态”的分界线 —— 编译器和渲染器以后要区分对待的就是它俩}:对象结束
可以看到,在这段代码中,cls 是一个变量,它可能会发生变化。
我们知道渲染器的作用之一就是“寻找并且只更新变化的内容”。所以当 cls 的值发生变化时,渲染器要自己寻找变更点。对于渲染器来说,这个“寻找”的过程需要花费一些力气。
打个生活化的比方:交上去的表格里,id 那一格写着“张三”(写死的),class 那一格写着“看老王心情”(会变的)。审核的人拿到表要复核,如果每一格都得自己判断“这个会不会变”,那是最笨的办法 —— 慢,而且容易看漏。
那么,从编译器的视角看,它能否提前知道哪些内容会发生变化?
如果编译器能在编译阶段就把这些信息提取出来,直接交给渲染器 —— 这样渲染器不就不用“花大力气去找”了吗?
好想法,并且能够实现!
Vue.js 的模板是有“特点”的。拿上面的模板来说:
- 我们一眼就能看出
id="foo"是永远不会变化的 - 而
:class="cls"是v-bind绑定,可能发生变化
这就要提到“编译器”这个名字真正的含义了。编译器的工作不只是“翻译” —— 它还会分析。分析源码的语法结构(id 是写死的字面量、: 后面是变量表达式),是编译器的老本行。所以“一眼就能看出哪儿会变”这件事,恰好是编译器最擅长的。
所以编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码时完全可以附带这些信息:
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
patchFlag: 1 // 假设数字 1 代表 class 是动态的
}
}props里的内容:和上一段一字不差 —— 编译结果本身没变patchFlag: 1:新增的一个字段,patch在这里就是“打补丁、改一改”的意思,Flags是“标志”(flag)。所以 patchFlag = 补丁标志 = “这块内容应该怎么改”的提示- 值为什么是数字
1而不是true? 因为变化点不止一种:可能是 class 变了、可能是children(子节点文字)变了、可能是style变了…… 每种用不同的数字代表(这种编码方式后面几章叫“补丁类型”)。本章只用1来表示“只有 class 会变”这一种情况,够用就行 - ⚠️ 注意这个字段是“假设”的(注释里写了“假设”两个字)。原书在这里是故意简化的,目的是让你看清“渲染器能从虚拟 DOM 上读到额外情报”这件事本身
在生成的虚拟 DOM 对象中多出了一个 patchFlag 属性(补丁标志)。我们假设数字 1 代表“class 是动态的”,这样渲染器看到这个标志时就知道:
“哦,原来只有 class 属性会发生改变。”
对于渲染器来说,就相当于省去了寻找变更点的工作量,性能自然就提升了。
它们交流的媒介:虚拟 DOM 对象
通过这个例子我们了解到,编译器和渲染器之间是存在信息交流的,它们互相配合使得性能进一步提升。而它们之间交流的媒介就是虚拟 DOM 对象。
在后面的学习中,我们会看到一个虚拟 DOM 对象中会包含多种数据字段,每个字段都代表一定的含义。这些字段就是模块之间“对话”的语言。
Vue.js 各个模块不是孤立的 —— 它们像齿轮一样相互啮合、相互配合,最终构成一个有机的整体:
- 单看任何一个模块都“不起眼”
- 但合在一起后,编译器和渲染器互相协作,使性能进一步提升
第 1 章说过,Vue.js 选的是“运行时 + 编译时”的折中方案 —— 保留运行时那份灵活(模板随便写、随便改),又用编译时拿到的信息(patchFlag)把性能捞回来一大截。这正是“权衡”在第 3 章的具体体现。
本章术语速查(先混个脸熟)
读到这里,几个词可能会在后面章节反复出现,先记个大概:
| 术语 | 一句话白话 | 生活比喻 |
|---|---|---|
| 命令式(imperative) | 你一步步指挥电脑做 | 自己炒菜,火候顺序全得你管 |
| 声明式(declarative) | 你只说要什么结果 | 点外卖,只报菜名 |
| 虚拟 DOM(vdom) | 用 JS 对象描述真实 DOM | 还没动工时的施工图纸 |
| vnode | 虚拟 DOM 上的“一个节点” | 图纸上的“这一面墙” |
| 渲染器(renderer) | 把虚拟 DOM 变成真实 DOM | 照着图纸施工的施工队 |
| 挂载点(container) | 渲染结果挂靠的真实 DOM 元素 | 施工队往哪个房间搬东西 |
| 递归(recursion) | 函数在自己体内调用自己 | 一级一级往下数的楼梯 |
| 组件(component) | 一组 DOM 元素的封装 | 型号固定的宜家柜子模块 |
| 组件化 | 把大界面拆成可复用的小块再拼 | 大楼的预制板件编号管理 |
| 编译时 / 运行时 | 开发时就换算好 / 浏览器现场换算 | 出厂前组装好 / 到工地再组装 |
patchFlag | 补丁标志,告诉渲染器“哪里会变” | 表格里提前标注好“这格待填” |
3.6 总结
本章我们从全局视角了解了 Vue.js 3 的设计思路。把所有内容串起来:
1. 声明式地描述 UI
Vue.js 是声明式框架。声明式的好处是直接描述结果,用户不需要关注过程。Vue.js 采用模板的方式描述 UI,但也支持使用虚拟 DOM 来描述。
- 虚拟 DOM 比模板更灵活
- 模板比虚拟 DOM 更直观
要真正享受“声明式”的好处,还得配上响应式:因为你只写了结果、没写过程,那“结果变了要刷新”这件事就得由框架自动完成。本章只把响应式的位置标出来(第二篇《响应系统》第 4~6 章会讲实现),这里你要记住的就是那条链路:
2. 渲染器
渲染器的作用是把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。
渲染器的精髓在于后续的更新 —— 它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。这部分就是第 9、10、11 三章的内容。
3. 组件的本质
组件其实就是一组虚拟 DOM 元素的封装。它可以是一个返回虚拟 DOM 的函数,也可以是一个对象(对象里必须有一个 render 函数用来产出组件要渲染的虚拟 DOM)。
渲染器渲染组件时,会先执行组件的渲染函数得到 subtree,然后递归调用渲染器把 subtree 渲染出来。
正因为组件可以拆成一块块复用,才谈得上组件化;也正因为 Vue.js 中的有状态组件是用对象结构来表达的,上一节改出来的那个写法才对得上号。
4. 模板的工作原理
Vue.js 的模板会被编译器编译为渲染函数。后面我们会详细讲编译器。
5. 整体观
编译器和渲染器都是 Vue.js 的核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。学习 Vue.js 原理时,要把各个模块结合到一起去看,才能看到全貌。
