文章目录
  1. 1. 模板数据绑定
    1. 1.1. 解析语法生成AST
      1. 1.1.1. 捕获特定语法
      2. 1.1.2. DOM元素捕获
      3. 1.1.3. 数据绑定捕获
    2. 1.2. AST生成模版
      1. 1.2.1. 生成模版的方法
      2. 1.2.2. 浏览器的渲染机制
  2. 2. 模版数据更新
    1. 2.0.1. 数据更新监听
    2. 2.0.2. 数据更新Diff
  • 3. 结束语
  • 前端框架日新月异,而其中的数据绑定已经作为一个框架最基础的功能。我们常常使用的单向绑定、双向绑定、事件绑定、样式绑定等,里面具体怎么实现,而当我们数据变动的时候又会触发怎样的底部流程呢?

    模板数据绑定


    数据绑定的过程其实不复杂:

    1. 解析语法生成AST。
    2. 根据AST结果生成DOM。
    3. 将数据绑定更新至模板。

    解析语法生成AST

    抽象语法树(Abstract Syntax Tree)也称为AST语法树,指的是源代码语法所对应的树状结构。也就是说,对于一种具体编程语言下的源代码,通过构建语法树的形式将源代码中的语句映射到树中的每一个节点上。

    其实我们的DOM结构树,也是AST的一种,把HTML DOM语法解析并生成最终的页面。而模板引擎中常用的,则是将模板语法解析生成HTML DOM。

    捕获特定语法

    生成AST的过程涉及到编译器的原理,一般经过以下过程:

    1. 语法分析。

    语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等。
    语法分析程序判断源程序在结构上是否正确,源程序的结构由上下文无关文法描述。语法分析程序可以用YACC等工具自动生成。

    1. 语义分析

    语义分析是编译过程的一个逻辑阶段,语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查,进行类型审查。语义分析是审查源程序有无语义错误,为代码生成阶段收集类型信息。
    一般类型检查也会在这个过程中进行。

    1. 生成AST。

    AST的结构则根据使用者需要定义,下面的一些对象都是本人根据需要假设定义的。

    DOM元素捕获

    最简单的,我们来捕获一个<div>元素,然后生成一个<div>元素。

    例如我们可以将以下这样的DOM进行捕获:

    1
    2
    3
    4
    <div>
    <a>123</a>
    <p>456<span>789</span></p>
    </div>

    捕获后我们或许可以得到这样的一个对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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过程打下铺垫。

    数据绑定捕获

    这里我们拿来做例子的是,在Angular和Vue里面都有,是双大括号的数据绑定的语法。

    在前面DOM元素捕获的基础上,我们来添加数据绑定:

    1
    <div>{{ data }}</div>

    这么一个简单的数据,我们可以获得这样一个对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    thisDiv = {
    dom: {
    type: 'dom', ele: 'div', nodeIndex: 0, children: [
    {type: 'text', value: '123'}
    ]
    },
    binding: [
    {type: 'dom', nodeIndex: 0, valueName: 'data'}
    ]
    }

    这样,我们在生成一个DOM的时候,同时添加对data的监听,数据更新时我们会找到对应的nodeIndex,更新值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // 假设这是一个生成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生成模版

    生成模版的方法

    我们在捕获得到一个AST树结构后,会将其生成对应的DOM。一般来说我们有这些方式:

    1. 字符串模版:使用拼接的方式生成DOM字符串,直接通过innderHTML()插入页面。
    2. 节点模版:使用createElement()/appendChild()/textContent等方法,动态地插入DOM节点,根节点使用appendChild()插入页面。
    3. 混合模版:使用createElement()/appendChild()/textContent等方法动态地插入DOM节点,但是根节点使用innderHTML()插入页面。

    这几个有什么区别呢?

    刚开始的时候,我们每次更新页面数据和状态,通常通过innerHTML方法来用新的HTML String替换旧的,这种方法写起来很简单,无非是将各种节点使用字符串的方式拼接起来而已。但是如果我们更新的节点范围比较大,这时候我们需要替换掉很大一片的HTML String

    对于浏览器,这样的一次HTML String替换并不只是更新一些字符串那么简单。

    浏览器的渲染机制

    浏览器的一次页面渲染其实开销并不小,首先浏览器会解析三种文件:

    • 解析HTML/SVG/XHTML,会生成一个DOM结构树
    • 解析CSS,会生成一个CSS规则树
    • 解析JS,可通过DOM APICSS API来操作DOM结构树CSS规则树

    CSS规则树DOM结构树结合,最终生成一个Render树(即最终呈现的页面,例如其中会移除DOM结构树中匹配到CSS里面display:none;的DOM节点)。其中,CSS匹配DOM结构的过程是很复杂的,曾经在机器配置不高的日子也会出现过性能问题。

    一般来说浏览器绘制页面的过程是:1.计算CSS规则树 => 2.生成Render树 => 3.计算各个节点的大小/position/z-index => 4.绘制。其中计算的环节也是消耗较大的地方。

    我们使用DOM APICSS API的时候,通常会触发浏览器的两种操作:RepaintReflow

    Repaint:页面部分重画,通常不涉及尺寸的改变,常见于颜色的变化。这时候一般只触发绘制过程的第4个步骤。

    Reflow:意味着节点需要重新计算和绘制,常见于尺寸的改变。这时候会触发3和4两个步骤。

    所以我们在写页面的时候会注意一些问题,例如不要一条一条地修改DOM的样式(会触发多次的计算或绘制),在写动画的时候多使用fixed/absolute等(Reflow的范围小),等等。

    回到话题,如果我们直接每次更新页面数据和状态,都使用innerHTML的方式,无疑会增加浏览器的负担,所以需要跟踪节点进行局部跟新。当然,innerHTML也有它的优势,那就是我们可以使用一个innerHTML替代很多很多的createElement()/appendChild()/textContent方法,这在我们较少使用数据绑定和更新的情况下高效得多。

    模版数据更新


    我们讲了模版生成AST,以及通过AST生成DOM、并进行数据绑定的过程,接下来说明下模版数据更新的过程。

    数据更新监听

    前面将数据绑定的时候,也讲了使用事件监听的方式监听数据更新。这里接着介绍一些其他的方式。

    脏检测:在Angular中,并不直接监听数据的变动,而是监听常见的事件如用户交互(点击、输入等)、定时器、生命周期等。在每次事件触发完毕后,计算数据的新值和旧值是否有差异,若有差异则更新页面,并触发下一次的脏检测,直到没有差异或是次数达到设定阈值。

    脏检测是Angular的一大特色。由于事件触发的时候,并不能知道哪些数据会有变化,所以会进行大面积数据的新旧值Diff,这也毫无疑问会导致一些性能问题。在Angular2版本之后,由于使用了zone.js对异步任务进行跟踪,把这个计算放进worker,完了更新回主线程,是个类似多线程的设计,也提升了性能。

    同时,在Angular2中应用的组织类似DOM,也是树结构的,脏检查会从根组件开始,自上而下对树上的所有子组件进行检查。相比Angular1中的带有环的结构,这样的单向数据流效率更高,而且容易预测。

    Getter/Setter:在Vue中,主要是使用Proxy的方式,在相关的数据写入时进行模版更新。

    手动Function:在React中,通过手动调用set()的方式写入数据来更新模版。

    使用Proxy或者是set()的时候,我们可以通过event emit或是callback回调的方法,来触发数据的计算以及模版的更新。

    数据更新Diff

    说到数据更新的Diff,更多的则是Diff + 更新模板这样一个过程。

    在这个过程中,最突出的也就是虚拟DOM,它解决了常见的局部数据更新的问题,例如数组中值位置的调换、部分更新。一般来说计算过程如下:

    1. 用JS对象模拟DOM树。

    不知道大家仔细研究过DOM节点对象没,一个真正的DOM元素非常庞大,拥有很多的属性值。而其中很多的属性对于计算过程来说是不需要的,所以我们的第一步就是简化DOM对象。
    我们用一个JavaScript对象结构表示DOM树的结构,然后用这个树构建一个真正的DOM树。

    2. 比较两棵虚拟DOM树的差异。

    当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异。通常来说这样的差异需要记录:

    • 需要替换掉原来的节点
    • 移动、删除、新增子节点
    • 修改了节点的属性
    • 对于文本节点的文本内容改变

    经过差异对比之后,我们能获得一组差异记录,接下里我们需要使用它。

    3. 把差异应用到真正的DOM树上。

    对差异记录要应用到真正的DOM树上,例如节点的替换、移动、删除,文本内容的改变等。

    结束语


    总的来说,一个前端模板引擎大致分为模板生成AST => AST生成模板 => 数据/事件/属性绑定的监听 => 数据变更Diff => 局部更新模板这些过程。当然上面的介绍以个人理解为主,部分源码验证为辅。
    还是那句话,多思考多总结,不管结论是否正确,结果是否所期望,过程中的收获也会让人成长。

    码生艰难,写文不易,给我家猪囤点猫粮了喵~

    查看Github有更多内容噢:https://github.com/godbasin
    更欢迎来被删的前端游乐场边撸猫边学前端噢
    如果你想要关注日常生活中的我,欢迎关注“牧羊的猪”公众号噢

    作者:被删

    出处:https://godbasin.github.io

    本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

    文章目录
    1. 1. 模板数据绑定
      1. 1.1. 解析语法生成AST
        1. 1.1.1. 捕获特定语法
        2. 1.1.2. DOM元素捕获
        3. 1.1.3. 数据绑定捕获
      2. 1.2. AST生成模版
        1. 1.2.1. 生成模版的方法
        2. 1.2.2. 浏览器的渲染机制
    2. 2. 模版数据更新
      1. 2.0.1. 数据更新监听
      2. 2.0.2. 数据更新Diff
  • 3. 结束语