“
前端框架日新月异,而其中的数据绑定已经作为一个框架最基础的功能。我们常常使用的单向绑定、双向绑定、事件绑定、样式绑定等,里面具体怎么实现,而当我们数据变动的时候又会触发怎样的底部流程呢?
”
模板数据绑定
数据绑定的过程其实不复杂:
1. 解析语法生成AST。
2. 根据AST结果生成DOM。
3. 将数据绑定更新至模板。
解析语法生成AST
抽象语法树(Abstract Syntax Tree)也称为AST语法树,指的是源代码语法所对应的树状结构。也就是说,对于一种具体编程语言下的源代码,通过构建语法树的形式将源代码中的语句映射到树中的每一个节点上。
其实我们的DOM结构树,也是AST的一种,把HTML DOM语法解析并生成最终的页面。而模板引擎中常用的,则是将模板语法解析生成HTML DOM。
1
捕获特定语法
生成AST的过程涉及到编译器的原理,一般经过以下过程:
语法分析
语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等。 语法分析程序判断源程序在结构上是否正确,源程序的结构由上下文无关文法描述。语法分析程序可以用YACC等工具自动生成。
语义分析
语义分析是编译过程的一个逻辑阶段,语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查,进行类型审查。语义分析是审查源程序有无语义错误,为代码生成阶段收集类型信息。 一般类型检查也会在这个过程中进行。
生成AST
AST的结构则根据使用者需要定义,下面的一些对象都是本人根据需要假设定义的。
2
DOM元素捕捉
最简单的,我们来捕获一个<div>
元素,然后生成一个<div>
元素。
例如我们可以将以下这样的DOM进行捕获:
<div> <a>123</a> <p>456<span>789</span></p></div>
捕获后我们或许可以得到这样的一个对象:
thisDiv = { dom: { type: 'dom', ele: 'div', nodeIndex: 0, children: [ {type: 'dom', ele: 'a', nodeIndex: 1, children: [ {type: 'text', value: '123'} ]}, {type: 'dom', ele: 'p', nodeIndex: 2, children: [ {type: 'dom', ele: 'span', nodeIndex: 3, children: [{type: 'text', value: '456'}]}, {type: 'text', value: '789'} ]}, ] }}
原本就是一个<div>
,经过AST生成一个对象,最终还是生成一个<div>
,这是多余的步骤吗?不是的,在这个过程中我们可以实现一些功能:
1. 排除无效DOM元素,并在构建过程可进行报错。
2. 使用自定义组件的时候,可匹配出来。
3. 可方便地实现数据绑定、事件绑定等功能。
4. 为虚拟DOM Diff
过程打下铺垫。
3
数据绑定捕捉
这里我们拿来做例子的是,在Angular和Vue里面都有,是双大括号的数据绑定的语法。
在前面DOM元素捕获的基础上,我们来添加数据绑定:
<div>{{ data }}</div>
这么一个简单的数据,我们可以获得这样一个对象:
thisDiv = { dom: { type: 'dom', ele: 'div', nodeIndex: 0, children: [ {type: 'text', value: '123'} ] }, binding: [ {type: 'dom', nodeIndex: 0, valueName: 'data'} ]}
这样,我们在生成一个DOM的时候,同时添加对data
的监听,数据更新时我们会找到对应的nodeIndex
,更新值:
// 假设这是一个生成DOM的过程,包括数据绑定和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; }}
我们来对上面的代码进行说明。
1. 根据节点信息生成对应的HTML string,即getDOMString()方法。
这里我们只是简单完成了一种实现方式,根据节点生成DOM也有其他方式,例如使用.createElement()、.appendChild()、textContent等等。
我们称通过生成HTML string的方式为字符串模板,同时我们将通过createElement()/appendChild()的方式生成DOM称为节点模板。
2. 通过监听数据变更,同时根据绑定的数值获取对应节点,并进行局部更新。
在使用字符串模版的时候,我们将nodeIndex绑定在元素属性上,主要是用于数据更新时追寻节点进行内容更新。 在使用节点模版的时候,我们可在创建节点的时候,将该节点保存下来,直接用于数据更新。
当然,即使在字符串模版,我们也可以遍历一遍binding
来获取所有绑定数据的节点并保存,这样就不用每次数据更新事件触发的时候重新进行获取,毕竟DOM节点的匹配也是会有一定的消耗的。
3. 无论是数据还是事件、属性、样式等的绑定,都可以通过相似的方法进行。
虽然这里我们只介绍了数据的绑定,但其实事件的绑定、属性和样式的绑定都可以用相似的方式进行,当然事件监听和事件的触发都是我们自己定义的,对于传递的内容都可以用自己想要的方式来传。
AST生成模板
1
生成模板的方法
我们在捕获得到一个AST树结构后,会将其生成对应的DOM。一般来说我们有这些方式:
1.字符串模版:使用拼接的方式生成DOM字符串,直接通过innderHTML()
插入页面。
2.节点模板:使用createElementappendChild()textContent
等方法,动态地插入DOM节点,根节点使用innderHTML()
插入页面。
3.使用createElement()/appendChild()/textContent
方法动态地插入DOM节点,但是根节点使用innderHTML()
插入页面。
这几个有什么区别呢?
刚开始的时候,我们每次更新页面数据和状态,通常通过innerHTML
方法来用新的HTML String
替换旧的,这种方法写起来很简单,无非是将各种节点使用字符串的方式拼接起来而已。但是如果我们更新的节点范围比较大,这时候我们需要替换掉很大一片的HTML String
。
对于浏览器,这样的一次HTML String
替换并不只是更新一些字符串那么简单。
2
浏览器的渲染机制
浏览器的一次页面渲染其实开销并不小,首先浏览器会解析三种文件:
· 解析 HTML / SVG / XHTML ,会生成一个DOM结构树
· 解析 CSS ,会生成一个 CSS规则树
· 解析 JS,可通过DOM API 和 CSS API 来操作DOM结构树和 CSS规则树
DOM结构树 与 CSS规则树结合,最终生成一个Render 树(即最终呈现的页面,例如其中会移除DOM结构树中匹配到 CSS 里面display:none;
的DOM节点)。其中,CSS
匹配DOM结构的过程是很复杂的,曾经在机器配置不高的日子也会出现过性能问题。
一般来说浏览器绘制页面的过程是:1.计算CSS规则树=> 2.生成Render数 => 3.计算各个节点的大小/position/z-index=> 4.绘制。其中计算的环节也是消耗较大的地方。
我们使用DOM API
和CSS API
的时候,通常会触发浏览器的两种操作:Repaint
和Reflow
。
Repaint:页面部分重画,通常不涉及尺寸的改变,常见于颜色的变化。这时候一般只触发绘制过程的第4个步骤。
Reflow:意味着节点需要重新计算和绘制,常见于尺寸的改变。
这时候会触发3和4两个步骤。
所以我们在写页面的时候会注意一些问题,例如不要一条一条地修改DOM的样式(会触发多次的计算或绘制),在写动画的时候多使用fixed
/absolute
等(Reflow
的范围小),等等。
回到话题,如果我们直接每次更新页面数据和状态,都使用innerHTML
的方式,无疑会增加浏览器的负担,所以需要跟踪节点进行局部跟新。当然,innerHTML
也有它的优势,那就是我们可以使用一个innerHTML
替代很多很多的createElement()/appendChild()/textContent
方法,这在我们较少使用数据绑定和更新的情况下高效得多。
模板数据更新
我们讲了模版生成AST,以及通过AST生成DOM、并进行数据绑定的过程,接下来说明下模版数据更新的过程。
1
数据更新监听
前面将数据绑定的时候,也讲了使用事件监听的方式监听数据更新。这里接着介绍一些其他的方式。
脏检测:在Angular中,并不直接监听数据的变动,而是监听常见的事件如用户交互(点击、输入等)、定时器、生命周期等。在每次事件触发完毕后,计算数据的新值和旧值是否有差异,若有差异则更新页面,并触发下一次的脏检测,直到没有差异或是次数达到设定阈值。
脏检测是Angular的一大特色。由于事件触发的时候,并不能知道哪些数据会有变化,所以会进行大面积数据的新旧值Diff,这也毫无疑问会导致一些性能问题。在Angular2版本之后,由于使用了zone.js
对异步任务进行跟踪,把这个计算放进worker,完了更新回主线程,是个类似多线程的设计,也提升了性能。
同时,在Angular2中应用的组织类似DOM,也是树结构的,脏检查会从根组件开始,自上而下对树上的所有子组件进行检查。相比Angular1中的带有环的结构,这样的单向数据流效率更高,而且容易预测。
Getter/Setter:在Vue中,主要是使用Proxy
的方式,在相关的数据写入时进行模版更新。
手动Function:在React中,通过手动调用set()
的方式写入数据来更新模版。
使用Proxy
或者是set()
的时候,我们可以通过event emit
或是callback
回调的方法,来触发数据的计算以及模版的更新。
2
数据监听Diff
说到数据更新的Diff,更多的则是Diff 更新模板
这样一个过程。
在这个过程中,最突出的也就是虚拟DOM,它解决了常见的局部数据更新的问题,例如数组中值位置的调换、部分更新。一般来说计算过程如下:
1. 用JS对象模拟DOM树。
不知道大家仔细研究过DOM节点对象没,一个真正的DOM元素非常庞大,拥有很多的属性值。而其中很多的属性对于计算过程来说是不需要的,所以我们的第一步就是简化DOM对象。
我们用一个JavaScript
对象结构表示DOM树的结构,然后用这个树构建一个真正的DOM树。
2. 比较两棵虚拟DOM树的差异。
当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异。通常来说这样的差异需要记录:
- · 需要替换掉原来的节点
- · 移动、删除、新增子节点
- · 修改了节点的属性
- · 对于文本节点的文本内容改变
经过差异对比之后,我们能获得一组差异记录,接下里我们需要使用它。
3. 把差异应用到真正的DOM树上。
对差异记录要应用到真正的DOM树上,例如节点的替换、移动、删除,文本内容的改变等。
结束语
当然上面的介绍以个人理解为主,部分源码验证为辅。 还是那句话,多思考多总结,不管结论是否正确,结果是否所期望,过程中的收获也会让人成长。
原文作者:腾讯高级工程师 王贝珊
-前端好课-
【Web前端从小白到大师】全新升级
更新比例高达50%,你值得拥有
若需了解更多,请扫码添加小助手咨询~
也可直接查找微信号:TencentNext
▲ NEXT学院 官方课程助教 ▲
点击阅读原文,开始课程试学