在 Vue 里渲染一块内容,会有以下步骤及流程:
第一步,解析语法,生成AST
第二步,根据AST结果,完成data数据初始化
第三步,根据AST结果和DATA数据绑定情况,生成虚拟DOM
第四步,将虚拟DOM 生成真正的DOM插入到页面中,进行页面渲染。
那怎么理解这个流程呢?
一、解析语法生成AST
AST 语法树,实际就是抽象语法树(Abstract Syntax Tree),是指通过构建语法树的形式将源代码中的语句映射到树中的每一个节点上。
DOM 结构树,也是AST中的一种,把HTML DOM语法解析并生成最终页面。
我们详细看看这个过程:
1、捕获语法
在生成AST的过程中,会涉及到编译器的原理, 会经过以下过程:
(1)、语法分析
语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语。如 :程序、语句、表达式等。语法分析程序判断源程序在结构上是否正确, 如 v-if` / v-for 这样的指令 ,也有``这样的自定义 DOM 标签,还有`click`/`props 这样的简化绑定语法。需要将它们一一解析出来,并相应地进行后续处理。
(2)、语义分析
语义分析是审查源程序有无语义错误,为代码生成阶段收集类型信息,一般类型检查也会在这个过程中进行。如我们绑定了某个不存在的变量或者事件,又或者是使用了某个未定义的自定义组件等,都会在这个阶段进行报错提示。
(3) 、生成 AST
在Vue 里,语法分析、语义分析基本上是通过正则的方式来处理,生成 AST其实就是将解析出来的元素、指令、属性、父子节点关系等内容进行处理,得到一个 AST 对象,以下是简化后的源码:
/** * HTML编译成AST对象 */ export function parse( template: string, options: CompilerOptions ): ASTElement | void { // 返回AST对象 // 篇幅原因,一些前置定义省略 // 此处开始解析HTML模板 parseHTML(template, { expectHTML: options.expectHTML, isUnaryTag: options.isUnaryTag, shouldDecodeNewlines: options.shouldDecodeNewlines, start(tag, attrs, unary) { // 一些前置检查和设置、兼容处理此处省略 // 此处定义了初始化的元素AST对象 const element: ASTElement = { type: 1, tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), parent: currentParent, children: [] }; // 检查元素标签是否合法(不是保留命名) if (isForbiddenTag(element) && !isServerRendering()) { element.forbidden = true; process.env.NODE_ENV !== "production" && warn( "Templates should only be responsible for mapping the state to the " + "UI. Avoid placing tags with side-effects in your templates, such as " + `<${tag}>` + ", as they will not be parsed." ); } // 执行一些前置的元素预处理 for (let i = 0; i < preTransforms.length; i++) { preTransforms[i](element, options); } // 是否原生元素 if (inVPre) { // 处理元素元素的一些属性 processRawAttrs(element); } else { // 处理指令,此处包括v-for/v-if/v-once/key等等 processFor(element); processIf(element); processOnce(element); processKey(element); // 删除结构属性 // 确定这是否是一个简单的元素 element.plain = !element.key && !attrs.length; // 处理ref/slot/component等属性 processRef(element); processSlot(element); processComponent(element); for (let i = 0; i < transforms.length; i++) { transforms[i](element, options); } processAttrs(element); } // 后面还有一些父子节点等处理,此处省略 } // 其他省略 }); return root; }
2、DOM 元素捕获
假如我们需要捕获一个<div>
元素,再生成一个<div>
元素。
有一段模板,我们可以对它进行捕获:
<div> <a>111</a> <p>222<span>333</span> </p> </div>
捕获后我们可以得到这样一个对象:
divObj = { dom: { type: "dom", ele: "div", nodeIndex: 0, children: [ { type: "dom", ele: "a", nodeIndex: 1, children: [{ type: "text", value: "111" }] }, { type: "dom", ele: "p", nodeIndex: 2, children: [ { type: "text", value: "222" }, { type: "dom", ele: "span", nodeIndex: 3, children: [{ type: "text", value: "333" }] } ] } ] } };
这个对象保存了我们需要的一些信息:
- HTML元素里需要绑定哪些变量,因为变量更新的时候需要更新该节点内容。
- 以怎样的方式来拼接,是否有逻辑指令,如
v-if
、v-for
等 - 哪些节点绑定了什么监听事件,是否匹配一些常用的事件能力支持
Vue 会根据 AST 对象生成一段可执行的代码,我们看看这部分的实现:
// 生成一个元素 function genElement(el: ASTElement): string { // 根据该元素是否有相关的指令、属性语法对象,来进行对应的代码生成 if (el.staticRoot && !el.staticProcessed) { return genStatic(el); } else if (el.once && !el.onceProcessed) { return genOnce(el); } else if (el.for && !el.forProcessed) { return genFor(el); } else if (el.if && !el.ifProcessed) { return genIf(el); } else if (el.tag === "template" && !el.slotTarget) { return genChildren(el) || "void 0"; } else if (el.tag === "slot") { return genSlot(el); } else { // component或者element的代码生成 let code; if (el.component) { code = genComponent(el.component, el); } else { const data = el.plain ? undefined : genData(el); const children = el.inlineTemplate ? null : genChildren(el, true); code = `_c('${el.tag}'${ data ? `,${data}` : "" // data }${ children ? `,${children}` : "" // children })`; } // 模块转换 for (let i = 0; i < transforms.length; i++) { code = transforms[i](el, code); } // 返回最后拼装好的可执行的代码 return code; } }
3、模板引擎赋能
通过以上介绍,或许大家会说,原本就是一个<div>
,经过 AST 生成一个对象,最终还是生成一个<div>
,这不是多余的步骤吗?
其实 ,在这个过程中我们可以实现一些功能:
- 排除无效 DOM 元素,并在构建过程可进行报错
- 使用自定义组件的时候,可匹配出来
- 可方便地实现数据绑定、事件绑定等功能
- 为虚拟 DOM Diff 过程打下铺垫
- HTML 转义预防 XSS 漏洞
通用的模板引擎能处理很多低效又重复的工作,例如浏览器兼容、全局事件的统一管理和维护、模板更新的虚拟 DOM 机制、树状组织管理组件。这样我们知道了模板引擎都做了什么事情后,就可以区分 Vue 框架提供的能力和我们需要自行处理的逻辑,可以更专注于业务开发。
二、虚拟DOM
虚拟 DOM 大概可分成三个过程:
第一步,用 JS 对象模拟 DOM 树,得到一棵虚拟 DOM 树。
第二步,当页面数据变更时,生成新的虚拟 DOM 树,比较新旧两棵虚拟 DOM 树的差异。
第三步,把差异应用到真正的 DOM 树上。
1、用 JS 对象模拟 DOM 树
为什么要用到虚拟 DOM ? 因为一个真正的 DOM 元素非常庞大,拥有很多的属性值,而实际上我们并不是全部都会用到,通常包括节点内容、元素位置、样式、节点的添加删除等方法。所以,我们通过用 JS 对象表示 DOM 元素的方式,可以大大降低了比较差异的计算量。
我们来看一下 VNode 源码,只有以下20来个属性:
tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node // strictly internal raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node? asyncFactory: Function | void; // async component factory function asyncMeta: Object | void; isAsyncPlaceholder: boolean; ssrContext: Object | void; fnContext: Component | void; // real context vm for functional nodes fnOptions: ?ComponentOptions; // for SSR caching devtoolsMeta: ?Object; // used to store functional render context fordevtools fnScopeId: ?string; // functional scope id support
2 、比较新旧两棵虚拟 DOM 树的差异
虚拟 DOM 中,差异对比是很关键的一步,当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异。这样的差异需要记录:
- 需要替换掉原来的节点
- 移动、删除、新增子节点
- 修改了节点的属性
- 对于文本节点的文本内容改变
下图,我们对比两棵 DOM 树,得到的差异有:
- p 元素插入了一个 span 元素子节点
- 原先的文本节点挪到了 span 元素子节点下面
3、应用差异到真正的 DOM 树
通过前面的示例,我们知道差异记录要应用到真正的 DOM 树上,需要进行一些操作,例如节点的替换、移动、删除,文本内容的改变等。
在 Vue 中是怎么进行 DOM Diff 呢? 简单看这段代码感受下, 虽然代码里很多函数没贴出来,但其实看函数名也可以大概理解都是什么作用,例如updateChildren
、addVnodes
、removeVnodes
、setTextContent
等。
// 对比差异后更新 const oldCh = oldVnode.children; const ch = vnode.children; if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode); } if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); } else if (isDef(ch)) { if (process.env.NODE_ENV !== "production") { checkDuplicateKeys(ch); } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, ""); addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1); } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ""); } } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text); } if (isDef(data)) { if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode); }
三、数据绑定
在 Vue 中,最基础的模板语法是数据绑定。
例如:
<div>{{ message }}</div>
这里使用插值表达式{{}}
绑定了一个message
的变量,开发者在 Vue 实例data
中绑定该变量:
new Vue({ data: { message: "test" } });
最终页面展示内容为<div>test</div>
。那这是怎么做到的呢?
1、 数据绑定的实现
这种使用双大括号来绑定变量的方式,我们称之为数据绑定。
数据绑定的过程其实不复杂:
(1) 、解析语法生成 AST
(2) 、根据 AST 结果生成 DOM
(3) 、将数据绑定更新至模板
这个过程是 Vue 中模板引擎在做的事情,我们来看看上面在 Vue 里的代码片段<div></div>
,我们可以通过 DOM 元素捕获,解析后获得这样一个 AST 对象:
divObj = { dom: { type: "dom", ele: "div", nodeIndex: 0, children: [{ type: "text", value: "" }] }, binding: [{ type: "dom", nodeIndex: 0, valueName: "message" }] };
我们在生成 DOM 的时候,添加对message
的监听,数据更新时会找到对应的nodeIndex
更新值:
// 假设这是一个生成 DOM 的过程,包括 innerHTML 和事件监听 function generateDOM(astObject) { const { dom, binding = [] } = astObject; // 生成DOM,这里假设当前节点是baseDom baseDom.innerHTML = getDOMString(dom); // 对于数据绑定的,来进行监听更新 baseDom.addEventListener("data:change", (name, value) => { // 寻找匹配的数据绑定 const obj = binding.find(x => x.valueName == name); // 若找到值绑定的对应节点,则更新其值。 if (obj) { baseDom.find(`[data-node-index="${obj.nodeIndex}"]`).innerHTML = value; } }); } // 获取DOM字符串,这里简单拼成字符串 function getDOMString(domObj) { // 无效对象返回'' if (!domObj) return ""; const { type, children = [], nodeIndex, ele, value } = domObj; if (type == "dom") { // 若有子对象,递归返回生成的字符串拼接 const childString = ""; children.forEach(x => { childString += getDOMString(x); }); // dom对象,拼接生成对象字符串 return `<${ele} data-node-index="${nodeIndex}">${childString}</${ele}>`; } else if (type == "text") { // 若为textNode,返回text的值 return value; } }
这样,我们就能在message
变量更新的时候,通过该变量关联的引用,来自动更新对应展示的内容。而要知道message
变量什么时候进行了改变,我们需要对数据进行监听。
2、数据更新监听
加粗样式
我们能看到,上面的简单代码描述过程中,使用的数据监听方法是用了addEventListener("data:change", Function)
的方式。
在 Vue 中,数据更新的时候就执行了模板更新、watch、computed 等一些工作,主要是依赖了Getter/Setter
。而 Vue3.0 将使用Proxy
的方式来进行:
Object.defineProperty(obj, key, { enumerable: true, configurable: true, // getter get: function reactiveGetter() { const value = getter ? getter.call(obj) : val; if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value; }, // setter最终更新后会通知 set: function reactiveSetter(newVal) { const value = getter ? getter.call(obj) : val; if (newVal === value || (newVal !== newVal && value !== value)) { return; } if (process.env.NODE_ENV !== "production" && customSetter) { customSetter(); } if (getter && !setter) return; if (setter) { setter.call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep.notify(); } });
Vue 中大多数能力都依赖于模板引擎,包括组件化管理、事件管理、Vue 实例、生命周期等,相信只要理解了 AST、虚拟 DOM、数据绑定相关的机制后,再去翻阅 Vue 源码 ,了解更多的能力就不是问题了。