Angular的DOM更新机制

2,461 阅读7分钟

本文是阅读The mechanics of DOM updates in Angular后的实践与总结。感兴趣的同学可以直接去学习一下原文。

本文旨在介绍用Angular编写的组件在框架内的定义方式和存储结构,以及在Angular中最常用的数据绑定与DOM更新的实现机制。

Angular中绑定数据的常见写法

在Angular中,要实现DOM的刷新,我们通常会使用数据绑定。而最常见的数据绑定的形式如下

<!-- app.component.html -->
<span>Hello, {{name}}! Welcome to {{city}}!</span>
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  name = "Moonlight";
  city = "Guangzhou";
}

AppComponent中的数据name或者city发生变化时,就会自动更新页面DOM的显示内容。后文将详细解释,这种DOM更新是如何实现的。

视图结构及相关概念

在开始详细解释流程之前,这里需要引入Angular中视图(View)的相关概念。在编写组件时,我们利用模板(Template)来定义页面展示结构。但是这些模板中有很多是Angular自己定义的语法,并不能被标准的HTML所解释。因此,Angular框架采用了一种叫做视图(View)的数据结构来存储模板所定义的DOM结构关系。

一个Angular组件对应会生成一个视图定义。而该组件中的各种DOM子节点相关数据也会在视图中通过各种节点(Node)来存储。关于这些内容,可以通过Maxim的这篇文章了解更多。

在上一小节给出的例子中,我们的视图结构非常简单,画出来大概是这种样子:

| AppComponent Template |  <-------->  | AppComponent ViewDefinition |
-------------------------              -------------------------------
|      Span Node        |  <-------->  |     Span Element NodeDef    |
|      Text Node        |  <-------->  |          Text NodeDef       |

其中左侧是我们通过HTML定义的模板,而右边就是在Angular中进行存储的数据结构。

接下来,我们看一下Angular中对于这些数据结构具体是如何定义的。

ViewDefinition

视图的接口定义中有两项关键的属性列在下面

export interface ViewDefinition extends Definition<ViewDefinitionFactory> {
  updateRenderer: ViewUpdateFn;
  nodes: NodeDef[];
}

其中updateRenderer是用于更新渲染的函数,在后文中会遇到。而nodes则是该视图中所有的节点列表。

NodeDef

再来看一下,视图中的节点接口是如何定义的。这里也摘选几项重要的属性如下:

export interface NodeDef {
  flags: NodeFlags;
  bindings: BindingDef[];
}

其中flags用于标记该节点是一种什么类型的节点,该属性的取值从NodeFlags这个枚举常量中选取。例如我们上面的例子中就用到了NodeFlags.TypeElementNodeFlags.TypeText这两种:

export const enum NodeFlags {
  TypeElement = 1 << 0,
  TypeText = 1 << 1,
}

而另一个属性bindings则记录了在这一个节点中需要进行数据绑定的内容。这一块是用于实现DOM更新的关键,后面将会详细介绍。

数据绑定的实现

上文中分析了我们在编写组件时定义的模板与内在的视图数据结构之间的联系。那么这一小节我们来看一下数据绑定具体是如何实现的。

首先,我们可以用Angular的编译器ngc对将本文最开始示例中的组件进行编译并看一下输出结果。配置一下编译脚本:

// package.json
{
    "scripts": {
        "compile": "ngc"
    }
}

然后运行npm run compile。应该可以在dist/out-tsc目录下面看到输出结果。我们重点关注dist/out-tsc/src/app/app.component.ngfactory.js这个文件。它是我们定义的AppComponent组件经ngc编译之后所得到的组件工厂文件。在该文件中,我们重点关注这个函数

export function View_AppComponent_0(_l) { 
  return i1.ɵvid(0, 
    [
      (_l()(), i1.ɵeld(0, 0, null, null, 1, "span", [], null, null, null, null, null)), 
      (_l()(), i1.ɵted(1, null, ["Hello, ", "! Welcome to ", "!"]))
    ], null, function (_ck, _v) { 
      var _co = _v.component; 
      var currVal_0 = _co.name;
      var currVal_1 = _co.city; 
      _ck(_v, 1, 0, currVal_0, currVal_1); 
    }
  ); 
}

该函数是编译器生成的视图工厂,用来生成上文中我们提到的视图。在这段代码中,i1.ɵvid, i1.ɵeldi1.ɵted分别对应的是viewDef, elementDef, textDef这三个函数(这里vid, eldted分别就是上面三个函数名的缩写,这种缩写对应关系可以在codegen_private_exports.ts中找到)。

我们简单看一下上面三个函数的定义代码可知

  • elementDef返回HTML元素相关的节点定义
  • textDef函数返回文本相关的节点定义
  • 上述两个函数返回的节点作为参数传递给viewDef函数,用于生成视图的定义

为了了解绑定是如何实现的,我们着重看一下textDef函数中的内容。因为绑定发生在这个节点中

// text.ts
export function textDef(
    checkIndex: number, ngContentIndex: number | null, staticText: string[]): NodeDef {
  const bindings: BindingDef[] = [];
  for (let i = 1; i < staticText.length; i++) {
    bindings[i - 1] = {
      flags: BindingFlags.TypeProperty,
      name: null,
      ns: null,
      nonMinifiedName: null,
      securityContext: null,
      suffix: staticText[i],
    };
  }
  
  return {
      ...,
      text: {prefix: staticText[0]},
      bindings,
      ...
  }

从上述代码可以看出,在该函数中,将文本节点以数据绑定的位置为界分段记录在节点定义中。以我们前面给出的例子来说明,调用此函数的方式为

textDef(1, null, ["Hello, ", "! Welcome to ", "!"]);

因此会生成如下的节点

{
    ...,
    text: { prefix: "Hello, " },
    bindings: [
        {suffix: "! Welcome to "},
        {suffix: "!"},
    ],
    ...
}

到这里就算是将DOM中的文本结构以一种抽象的数据结构记录下来了。但是,利用这样的结构又怎么实现DOM的更新呢?下一小节我们来解释这个过程。

DOM更新的实现

首先,我们回顾一下上面的viewDef函数定义

export function viewDef(
    flags: ViewFlags, nodes: NodeDef[], updateDirectives?: null | ViewUpdateFn,
    updateRenderer?: null | ViewUpdateFn): ViewDefinition {}

该函数的最后一个参数是一个名为updateRenderer的函数。根据这篇文章的介绍,在Angular对组件进行变更检测时会执行组件的updateRenderer函数。那么,结合我们前文中例子中的编译结果,来看一下这个函数中都做了什么事情

function (_ck, _v) { 
  var _co = _v.component; 
  var currVal_0 = _co.name;
  var currVal_1 = _co.city; 
  _ck(_v, 1, 0, currVal_0, currVal_1); 
}

该函数中第一个参数是check函数,在不同的环境下指向不同的实现方式。例如,在生产环境中,该函数的具体实现是prodCheckAndUpdateNode。而第二个参数是我们在利用createView函数实例化ViewDefinition时生成的ViewData数据。

这段函数的内容比较直白,即获取到当前组件中的name和city属性的值,然后用prodCheckAndUpdateNode函数来进行变更检测和更新。那么我们来进一步看一下prodCheckAndUpdateNode函数中具体做了什么事情。

通过在源码中的逐级索引(建议将源码clone下来后,在本地利用IDE来跳转阅读,会更加方便),我们整理出核心的检测思路如下:

  • checkAndUpdateTextInline:在这个函数中会对建立的每组绑定进行检查(前文的例子中我们建立了两组bindings)。如果有任何一组绑定发生了变化,都需要对DOM进行更新。
  • checkBinding:在检查的核心逻辑中我们可以看出,如果是第一次检查,则一定会触发后续的更新逻辑。否则,会通过对比存储的旧数据和新数据是否相同来判断是否触发更新逻辑。
  • _addInterpolationPart:这个函数将每一组绑定中的属性值以及对应的suffix拼接起来然后返回。通过多次调用这个函数,然后加上开头的prefix就可以形成最终的文本内容,即
value = prefix +
        v0 + bindings[0].suffix +
        v1 + bindings[1].suffix;
        
  • 最后再利用渲染器将该结果更新到DOM节点中,至于renderer.setValue的具体实现,则会根据渲染器的不同而有所区别,就不在此文中进行深入讨论了。

总结

本文梳理了Angular实现数据绑定和DOM更新的基本流程和核心逻辑。当然,本文并没有覆盖所有数据绑定的情形,例如通过HTML元素的属性进行绑定或者反过来对HTML元素的事件进行绑定等。不过,我们可以同样按照本文中的思路,先写一个简单的示例,然后查看ngc编译后的结果,再去阅读源码,就能很清楚地理解框架中的实现原理了。