未来函数在线检测_Angular Ivy 执行变更检测

9fec9ffc0a92264fbceb6e99a493ef6d.png
原文链接​medium.com 原作者:Alexey Zuev ​medium.com

译者:

知乎用户​www.zhihu.com

更新

试试 Ivy jit 模式

求助

如果你是 Angular In depth 的粉丝,请在 Twitter 上支持我们

免责声明:本文只是我个人对 new Angular renderer 的学习笔记。

16414f6e4b78fa5e7b4d56bd8afb5e3c.png

虽然 Ivy 渲染引擎尚未完全公布,但是许多开发者希望了解 Ivy 的运作方式以及将会给开发者带来哪些变化。

在本文中,我们将可视化的方式展示 Ivy 的变更检测机制 ,除了展示那些激动人心的功能之外,还将从零开始指导你构建基于 Ivy 的 demo 应用。

正文

首先,介绍一下作为 demo 的应用

7697356beb05c5ef5b18406be247a5fd.png
@Component({
  selector: 'my-app',
  template: `
   <h2>Parent</h2>
   <child [prop1]="x"></child>
  `
})
export class AppComponent {
  x = 1;
}
@Component({
  selector: 'child',
  template: `
   <h2>Child {{ prop1 }}</h2>
   <sub-child [item]="3"></sub-child>
   <sub-child *ngFor="let item of items" [item]="item"></sub-child>
  `
})
export class ChildComponent {
  @Input() prop1: number;

  items = [1, 2];
}
@Component({
  selector: 'sub-child',
  template: `
   <h2 (click)="clicked.emit()">Sub-Child {{ item }}</h2>
   <input (input)="text = $event.target.value">
   <p>{{ text }}</p>
  `
})
export class SubChildComponent {
  @Input() item: number;
  @Output() clicked = new EventEmitter();
  text: string;
}

我创建了一个在线demo,希望可以帮助你更好地理解本文内容。

a172a5252ee1c9e218937be69d6755ec.gif

demo 使用的是 Angular 6.0.1 的 aot 编译器。任何一个生命周期区块都可以点击,点击后将重定向到对应的定义页面。

为了触发行变更检测的运行流程,你只需要在 Sub-Child 组件的下方的输入框内键入文字即可。

视图

View 在 Angular 中是主要的底层抽象。

在上述例子中,view 的结构图如下所示:

Root view
   |
   |___ AppComponent view
          |
          |__ ChildComponent view
                 |
                 |_ Embedded view
                 |       |
                 |       |_ SubChildComponent view
                 |
                 |_ Embedded view
                 |       |
                 |       |_ SubChildComponent view   
                 |
                 |_ SubChildComponent view

View 是用来描述模板的,所以其包含了用于反射模板结构的数据。

ChildComponent 的 View 中包含了以下模板:

<h2>Child {{ prop1 }}</h2>
<sub-child [item]="3"></sub-child>
<sub-child *ngFor="let item of items" [item]="item"></sub-child>

当前的 view engine 使用 视图定义(view definition) factory 创建节点,并将节点存储在视图定义的 nodes 数组中。

7726af7fd6b35c4b3c8ac3867a0e7035.png

Ivy 使用 ngComponentDef.template 函数创建 LNodes,并将其存储data 数组中:

ec2bdccc8b5960c85481e1f4d7580ecb.png

除了 nodes 之外,Ivy view 于 data 数组中包含绑定信息(如上图中的 data[4], data[5], data[6])。视图中的所有绑定均以其在模板中出现的顺序存储,存储的位置起始于 bindingStateIndex 参数的值

现在,如何才能获取 ChildComponent 的 view 实例呢? ComponentInstance.__ngHostLNode__ 包含了组件宿主节点的引用(或者使用注入 ChangeDetectorRef 的方式获取组件宿主节点)

通过这样的方式,Angular 首先创建 root view 并将宿主元素定位在 data 数组中下标为0的位置

RootView
   data: [LNode]
             native: root component selector

之后遍历所有组件并为每一个 view 填充 data

变更检测

补充一下基础知识:ChangeDetectorRef 是一个抽象类,包含两个抽象方法 detectChangesmarkForCheck

660b67d9f02ca6c976dce4c49f0f0b58.png

当我们在组件的构造器中注入 ChangeDetectorRef 时,实际上获取的是从 ChangeDetectorRef 类拓展所得到的 ViewRef 实例。

现在,检查一下在 Ivy 中真正运行变更检测机制的内部方法。有些内部方法是公有 API(markViewDirtydetectChanges),但是并不是全部方法都是共有API。

0854a74eab7e76658f35b8bdf5770a97.png

detectChanges 方法

以同步的方式在一个组件(及其子组件)上执行变更检测

本方法以同步的方式在一个组件上触发变更检测。只有当你有足够的理由时,才应当直接调用 detectChanges 方法触发变更检测;一般更推荐的方法是使用 markDirty 函数:等待未来的某个时间点通过调度器调用该函数。因为单个用户操作通常可能导致多个组件失效,与此同时,同时调用每个组件的变更检测非常低效。等到所有组件都被标记为 dirty 后,对所有组件执行单次变更检测更加高效。
export function detectChanges<T>(component: T): void {
  const hostNode = _getComponentHostLElementNode(component);
  ngDevMode && assertNotNull(hostNode.data, 'Component host node should be attached to an LView');
  const componentIndex = hostNode.tNode !.flags >> TNodeFlags.DirectiveStartingIndexShift;
  const def = hostNode.view.tView.directives ![componentIndex] as ComponentDef<T>;
  detectChangesInternal(hostNode.data as LView, hostNode, def, component);
}

tick

用于在整个应用层面触发变更检测

detectChanges 方法相类似,只是 tick 方法在根组件上被调用。除此之外, tick 还会执行生命周期钩子并基于组件的 ChangeDetectionStrategyditry 状态有条件地检查组件。
export function tick<T>(component: T): void {
  const rootView = getRootView(component);
  const rootComponent = (rootView.context as RootContext).component;
  const hostNode = _getComponentHostLElementNode(rootComponent);

  ngDevMode && assertNotNull(hostNode.data, 'Component host node should be attached to an LView');
  renderComponentOrTemplate(hostNode, rootView, rootComponent);
}

scheduleTick

用于调度整个应用的变更检测计划。不像是 tickscheduleTick 会将多个调用合并在一个变更检测轮旬中执行。当 view 需要重新渲染时,通常会通过调用 markDirty 方法间接调用scheduleTick 方法。

export function scheduleTick<T>(rootContext: RootContext) {
  if (rootContext.clean == _CLEAN_PROMISE) {
    let res: null|((val: null) => void);
    rootContext.clean = new Promise<null>((r) => res = r);
    rootContext.scheduler(() => {
      tick(rootContext.component);
      res !(null);
      rootContext.clean = _CLEAN_PROMISE;
    });
  }
}

markViewDirty(markForCheck)

将当前视图及其所有祖先视图标记为 ditry

在 Angular 5中,该方法只会向上遍历并确保对所有的父级 views 进行检查。注意!在 Ivy 中,markForCheck 并不会触发任何变更检测循环。

export function markViewDirty(view: LView): void {
  let currentView: LView|null = view;

  while (currentView.parent != null) {
    currentView.flags |= LViewFlags.Dirty;
    currentView = currentView.parent;
  }
  currentView.flags |= LViewFlags.Dirty;

  ngDevMode && assertNotNull(currentView !.context, 'rootContext');
  scheduleTick(currentView !.context as RootContext);
}

markDirty

将组件标记为 dirty(需要变更检测)

某个组件标记为 dirty 意味着调度器在未来的某个时间点将对该组件进行变更检测。对已被标记为 dirty 的组件再次标记不会产生额外的效果,组件还是保持原有的 dirty 状态。每一个组件树只会被调度一个变更检测。(使用不同的 renderComponent 所构造的组件拥有不同的处置器)。
export function markDirty<T>(component: T) {
  ngDevMode && assertNotNull(component, 'component');
  const lElementNode = _getComponentHostLElementNode(component);
  markViewDirty(lElementNode.view);
}

checkNoChanges

没啥新的内容


当我调试新的变更检测机制时,我发现我忘记安装 zone.js 了。但是很奇怪的是,就算没有 zone.js 应用也能正常运行,没有 zone 的依赖,没有 cdRef.detectChangestick,但是为什么?

基于当前设计,对于采用了 OnPush 的组件, Angular 只有在这些状况下才会触发变更检测。

这些规律同样也应用于 Ivy:

  • Input 的内容发生了变化
  • 组件/子组件的绑定事件触发
  • 手动调用 markForCheck(Ivy 中是 markViewDirty)

subChildComponent 中存在一个 output 绑定,其通过 (input) 事件触发。应用第二个规则,将会调用 markForCheck 方法。我们已经知道 markForCheck 方法将会触发变更检测,这样就可以解释为什么就算没有 zone.js 应用也可以正常运行了。

那个很有名的bug会怎么样 - ExpressionChangedAfterItHasBeenCheckedError

这个问题还是存在的。

变更检测的错误

因为 Angular 开发组花了大量精力确保 Ivy 以正确的顺序处理所有生命周期钩子,这也就是意味着操作的顺序应当是相似的。

Max NgWizard K 的博客更好的解释了这个问题

虽然操作很相似,但是操作的顺序似乎发生了一些改变。比如,Angular 首先检测子组件,然后检测嵌入的视图。

让我们返回到 demo 中的 ChildComponent

<h2>Child {{ prop1 }}</h2>
<sub-child [item]="3"></sub-child>
<sub-child *ngFor="let item of items" [item]="item"></sub-child>

我的想法在是写一个常规的组件 sub-child 后,再写其他嵌入在视图中的组件。

现在以动态的方式看看这种实现:

4688a8c2e079604819d8bf79958b2093.gif

上图可见,Angular 首先检查嵌入的视图,再检查常规的组件。在这个方面,和过去的视图引擎没有什么区别。

在 demo 中还有一个可选的 run angular complier 按钮,可以检测其他场景。

One-time string initialization

假设有一个组件以一个字符串值的方式接受颜色参数。现在我们希望将该输入以字符串常量的形式传入:

<comp color="#efefef"></comp>

这就是所谓的 One-time string initialization(一次性字符串初始化),针对这种状况 Angular 文档的陈述是这样的:

Angular 设置了它,并遗忘了它

其实这意味着,Angular 将不再会对这个绑定做任何额外的检查。但是在 Angular 5 中,在 updateDirectives 函数被调用的阶段内,每一次变更检测循环都会对其进行检查。

unction updateDirectives(_ck,_v) {
   var currVal_0 = '#efefef';
  _ck(_v,1,0,currVal_0);

如果你希望更多相关内容,可以阅读Netanel Basal的博客Getting to Know the @Attribute Decorator in Angular

现在让我们看看在 Ivy 中,事情会变成什么样:

var _c0 = ["color", "#efefef"];
AppComponent.ngComponentDef = i0.ɵdefineComponent({ 
  type: AppComponent,
  selectors: [["my-app"]], 
  ...
  template: function `(rf, ctx) { 
    // create mode
      if (rf & 1) {
        i0.ɵE(0, "child", _c0); <========== used only in create mode
        i0.ɵe();
      }
      if (rf & 2) {
        ...
      }
  }
})

上述代码中,Angular 编译器将常量存储在负责创建和更新组建的代码之外,只有在创建模式中使用它。

Angular 不再为容器创建 text nodes

更新: https:// github.com/angular/angu lar/pull/24346

即使你不了解 Angular ViewContainer 的工作原理,当你打开浏览器开发者工具时,你肯定曾会看到下述的图片内容:

在生产模式中,我们只会看到 <!--->

而下述是 Ivy 的输出:

691e782337bd52852d39f9eea30faac9.png

虽然我不是100%确定,但是猜测当 Ivy 稳定后,上述截图的输出内容就是生成的模板结果了。

因此下述代码中的 query 将会返回 null:

@Component({
  ...,
  template: '<ng-template #foo></ng-template>'
})
class SomeComponent {
  @ViewChild('foo', {read: ElementRef}) query;
}
Angular 将不会再通过 指向容器中被注释的 DOM 节点的原生元素 阅读 ElementRef 了。

Incremental DOM(IDOM) from scratch

挺久之前,Google 发布了一个名为 incremental DOM 的库。

这个库专注于构建 DOM 树并允许动态更新 DOM 树。该库的设计理念并非旨在直接使用其创建 DOM 树,而是作为模板引擎的编译目标而存在。似乎, Ivy 和 incremental DOM 有些相似之处。

现在创建一个小 demo 帮助大家理解 IDOM 渲染的工作流程。

这个应用包含一个计数器并展示在 input 元素中输入的内容。

7cf9093cd1776cfdc6cddb1a3f124b81.png

假设页面上已经存在了 <inupt> 元素和 <button> 元素:

<input type="text" value="Alexey">
<button>Increment</button>

而我们所需要渲染的动态 HTML 看起来像是:

<h1>Hello, Alexey</h1>
<ul>
  <li>
    Counter: <span>1</span>
  </li>
</ul>

为了渲染上述动态内容,我们需要 elementOpen elementClosetext 的“instructions”(我之所以这种方式为其命名,因为 Angular 使用这些名字作为 Ivys,可以被当做是某种特殊的虚拟 CPU)

首先我们需要些一个特殊的帮助函数遍历节点树:

// The current nodes being processed
let currentNode = null;
let currentParent = null;

function enterNode() {
  currentParent = currentNode;
  currentNode = null;
}
function nextNode() {
  currentNode = currentNode ? 
    currentNode.nextSibling : 
    currentParent.firstChild;
}
function exitNode() {
  currentNode = currentParent;
  currentParent = currentParent.parentNode;
}

现在,我们写 instructions:

function renderDOM(name) {
  const node = name === '#text' ? 
    document.createTextNode('') :
    document.createElement(name);

  currentParent.insertBefore(node, currentNode);

  currentNode = node;

  return node;
}

function elementOpen(name) {
  nextNode();
  const node = renderDOM(name);
  enterNode();

  return currentParent;
}

function elementClose(node) {
  exitNode();

  return currentNode;
}

function text(value) {
  nextNode();
  const node = renderDOM('#text');

  node.data = value;

  return currentNode;
}
view rawidom.instruct

换句话说,这些函数仅仅是遍历 DOM 节点并在当前位置插入节点。通过 text instruction 设置了 data 属性确保我们可以再浏览器中看到文本值。

我们希望元素可以拥有保持某种状态的能力,因此引入了 NodeData

const NODE_DATA_KEY = '__ID_Data__';

class NodeData {
  // key
  // attrs

  constructor(name) {
    this.name = name;
    this.text = null;
  }
}

function getData(node) {
  if (!node[NODE_DATA_KEY]) {
    node[NODE_DATA_KEY] = new NodeData(node.nodeName.toLowerCase());
  }

  return node[NODE_DATA_KEY];
}

现在,我们需要改变 renderDOM 方法,如果当前位置已经存在同样的节点时,不会再将新的元素添加到 DOM 中:

const matches = function(matchNode, name/*, key */) {
  const data = getData(matchNode);
  return name === data.name // && key === data.key;
};

function renderDOM(name) {
  if (currentNode && matches(currentNode, name/*, key */)) {
    return currentNode;
  }

  ...
}

注意我的评论 /*, key */。如果我们的元素有某种 key 可以区分元素的话会更好。查看这个文档

后续就是添加逻辑负责文本节点的更新

function text(value) {
  nextNode();
  const node = renderDOM('#text');

  // update
  // checks for text updates
  const data = getData(node);
  if (data.text !== value) {
    data.text = (value);
    node.data = value;
  }
  // end update

  return currentNode;
}

对元素节点进行相同的处理。

patch 函数将会接受 DOM 元素,更新函数 和那些被更新函数所消费的数据:

function patch(node, fn, data) {
  currentNode = node;

  enterNode();
  fn(data);
  exitNode();
};

最后,让我们测试我们的方案:

function render(data) {
  elementOpen('h1');
  {
    text('Hello, ' + data.user)
  }
  elementClose('h1');
  elementOpen('ul')
  {
    elementOpen('li'); 
    {
      text('Counter: ')
      elementOpen('span'); 
      {
        text(data.counter);
      }
      elementClose('span');
    }
    elementClose('li');
  }

  elementClose('ul');
}

document.querySelector('button').addEventListener('click', () => {
   data.counter ++;
   patch(document.body, render, data);
});
document.querySelector('input').addEventListener('input', (e) => {
   data.user = e.target.value;
   patch(document.body, render, data);
});

const data = {
  user: 'Alexey',
  counter: 1
};

patch(document.body, render, data);

结果在此处.

你可以验证更新文本节点的代码,通过浏览器工具可以看到文本节点的内容变化。

9223623ee241d0651e2eba2273eb42fc.gif

所以 IDOM 的核心理念就是使用真实的 DOM 与 new tree 进行比较。

感谢阅读...

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值