[译] $digest 在 Angular 中重生

前端之家收集整理的这篇文章主要介绍了[译] $digest 在 Angular 中重生前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。
原文链接Angular.js’ $digest is reborn in the newer version of Angular

我使用 Angular.js 框架好些年了,尽管它饱受批评,但我依然觉得它是个不可思议的框架。我是从这本书 Building your own Angular.js 开始学习的,并且读了框架的大量源码,所以我觉得自己对 Angular.js 内部机制比较了解,并且对创建这个框架的架构思想也比较熟悉。最近我在试图掌握新版 Angular 框架内部架构思想,并与旧版 Angular.js 内部架构思想进行比较。我发现并不是像网上说的那样,恰恰相反,Angular 大量借鉴了 Angular.js 的设计思想。

其中之一就是名声糟糕的 digest loop

这个设计的主要问题就是成本太高。改变程序中的任何事物,需要执行成百上千个函数查询哪个数据发生变化。而这是 Angular 的基础部分,但是它会把查询限定在部分 UI 上,从而提高性能

如果能更好理解 Angular 是如何实现 digest 的,就可能把你的程序设计的更高效,比如,使用 $scope.$digest() 而不是 $scope.$apply,或者使用不可变对象。但事实是,为了设计出更高效的程序,从而去理解框架内部实现,这可能对很多人来说不是简单的事情。

所以大量有关 Angular 的文章教程里都宣称框架里不会再有 $digest cycle 了。这取决于对 digest 概念如何理解,但我认为这很有误导性,因为它仍然存在。的确,在 Angular 里没有 scopes 和 watchers,也不再需要调用 $scope.$digest(),但是检测数据变化的机制依然是遍历整个组件树,隐式调用 watchers ,然后更新 DOM。所以实际上是完全重写了,但被优化增强了,关于新的查询机制可以查看我写的 Everything you need to know about change detection inAngular

digest 的必要性

开始前让我们先回忆下 Angular.js 中为何存在 digest。所有框架都是在解决数据模型(JavaScript Objects)和 UI(Browser DOM)的同步问题,最大难题是如何知道什么时候数据模型发生改变,而查询数据模型何时发生改变的过程就是变更检测(change detection)。这个问题的不同实现方案也是现在众多前端框架的最大区别点。我计划写篇文章,有关不同框架变更检测实现的比较,如果你感兴趣并希望收到通知,可以关注我。

有两种方式来检测变化:需要使用者通知框架;通过比较来自动检测变化。

假设我们有如下一个对象:

let person = {name: 'Angular'};

然后我们去更新 name 属性值,但是框架是怎么知道这个值何时被更新呢?一种方式是需要使用者告诉框架(注:如 React 方式):

constructor() {
    let person = {name: 'Angular'};
    this.state = person;
}
...
// explicitly notifying React about the changes
// and specifying what is about to change
this.setState({name: 'Changed'});

或者强迫用户去封装该属性,从而框架能添加 setters(注:如 Vue 方式):

let app = new Vue({
    data: {
        name: 'Hello Vue!'
    }
});
// the setter is triggered so Vue knows what changed
app.name = 'Changed';

另一种方式是保存 name 属性的上一个值,并与当前值进行比较:

if (prevIoUsValue !== person.name) // change detected,update DOM

但是什么时候结束比较呢?我们应该在每一次异步代码运行时都去检查,由于这部分运行的代码是作为异步事件去处理,即所谓的 Virtual Machine(VM) turn/tick(注:Virtual Machine 的理解可参考 VM),所以可以紧接着在 VM turn 的后面,执行数据变化检查代码。这也是为何 Angular.js 使用 digest,所以我们可以定义 digest 为(注:为清晰理解,不翻译):

change detection mechanism that walks the tree of components,checks each component for changesandupdates DOM when a component property ischanged。

如果我们这么去定义 digest的话,那我可以说数据变化检查机制的主要部分在 Angular 里没有变化,变化的是 digest 的实现。

Angular.js

Angular.js 使用 watcherlistener 的概念,watcher 就是一个返回被监测值的函数,大多数时候这个被监测值就是数据模型的属性。但也不总是数据模型属性,如我们可以在作用域里追踪组件状态,计算属性值,第三方组件等等。如果当前返回值与先前值不同,Angular.js 就会调用 listener,而 listener 通常用来更新 UI。

$watch 函数的参数列表如下:

$watch(watcher,listener);

所以,如果我们有一个带有name 属性person 对象,并在模板里这样使用 <span>{{name}}</span>,那就可以像这样去追踪这个属性变化从而更新 DOM:

$watch(() => {
    return person.name
},(value) => {
    span.textContent = value
});

这与插值和 ng-bind 类的指令本质上做的一样,Angular.js 使用指令来映射 DOM 的数据模型。但是 Angular 不再这么去做,它使用属性映射来连接数据模型和 DOM。上面的示例在 Angular 会这么实现:

<span [textContent]="person.name"></span>

由于存在很多组件,并组成了组件树,每一个组件都有着不同的数据模型,所以就存在分层的 watchers,与分层的组件树很相似。尽管使用作用域把 watchers 组合在一起,但它们并不相关。

现在,在 digest 期间,Angular.js 会遍历 watchers 树并更新 DOM。如果你使用 $timeout$http 或根据需要使用 $scope.$apply$scope.$digest 等方式,就会在每一次异步事件中触发 digest cycle

watchers 是严格按照顺序触发:首先是父组件,然后是子组件。这很有意义,但却有着不受欢迎的缺点。一个被触发的 watcher listener 有很多副作用,比如包括更新父组件的属性。如果父监听器已经被触发了,然后子监听器又去更新父组件属性,那这个变化不会被检测到。这就是为何 digest loop 要运行多次来获取稳定的程序状态,即确保没有数据再发生变化。运行次数最大限定为 10 次,这个设计现在被认为是有缺陷的,并且 Angular 不容许这样做。

Angular

Angular 并没有类似 Angular.js 中 watcher 概念,但是追踪模型属性函数依然存在。这些函数是由框架编译器生成的,并且是私有不可访问的。另外,它们也和 DOM 紧密耦合在一起,这些函数就存储在生成视图结构 ViewDefinitionupdateRenderer 中。

它们也很特别:只追踪模型变化,而不是像 Angular.js 追踪一切数据变化。每一个组件都有一个 watcher 来追踪在模板中使用的组件属性,并对每一个被监听的属性调用 checkAndUpdateTextInline 函数。这个函数会比较属性的上一个值与当前值,如果有变化就更新 DOM。

比如,AppComponent 组件的模板:

<h1>Hello {{model.name}}</h1>

Angular Compiler 会生成如下类似代码

function View_AppComponent_0(l) {
    // jit_viewDef2 is `viewDef` constructor
    return jit_viewDef2(0,// array of nodes generated from the template
        // first node for `h1` element
        // second node is textNode for `Hello {{model.name}}`
        [
            jit_elementDef3(...),jit_textDef4(...)
        ],...
        // updateRenderer function similar to a watcher
        function (ck,v) {
            var co = v.component;
            // gets current value for the component `name` property
            var currVal_0 = co.model.name;
            // calls CheckAndUpdateNode function passing
            // currentView and node index (1) which uses
            // interpolated `currVal_0` value
            ck(v,1,currVal_0);
        });
}
注:使用 Angular-CLI ng new 一个新项目,执行 ng serve 运行程序后,就可在 Chrome Dev Tools 的 Source Tab 的 ng:// 域下查看到编译组件后生成**.ngfactory.js 文件,即上面类似代码

所以,即使 watcher 实现方式不同,但 digest loop 仍然存在,仅仅是换了名字为 change detection cycle (注: 为清晰理解,不翻译):

In development mode,tick()also performs a secondchange detection cycleto ensure that no further changes are detected.

上文说到在 digest 期间,Angular.js 会遍历 watchers 树并更新 DOM,这与 Angular 中机制非常类似。在变更检测循环期间(注:与本文中 digest cycle 相同概念),Angular 也会遍历组件树并调用渲染函数更新 DOM。这个过程是 checking and updating view process 过程的一部分,我也写了一篇长文 Everything you need to know about change detection in Angular

就像 Angular.js 一样,在 Angular 中变更检测也同样是由异步事件触发(注:如异步请求数据返回事件;用户点击按钮事件;setTimeout/setInterval)。但是由于 Angular 使用 zone 包来给所有异步事件打补丁,所以对于大部分异步事件来说,不需要手动触发变更检测。Angular 框架会订阅 onMicrotaskEmpty 事件,并在一个异步事件完成时会通知 Angular 框架,而这个 onMicrotaskEmpty 事件是在当前 VM Turn 的 microtasks 队列里不存在任务时被触发。然而,变更检测也可以手动方式触发,如使用 view.detectChangesApplicationRef.tick (注:view.detectChanges 会触发当前组件及子组件的变更检测,ApplicationRef.tick 会触发整个组件树即所有组件的变更检测)。

Angular 强调所谓的单向数据流,从顶部流向底部。在父组件完成变更检测后,低层级里的组件,即子组件,不容许改变父组件的属性。但如果一个组件在 DoCheck 生命周期钩子里改变父组件属性,却是可以的,因为这个钩子函数是在更新父组件属性变化之前调用的(注:即第 6 步 DoCheck, 在 第 9 步 updates DOM interpolationsfor thecurrent viewif properties oncurrent viewcomponent instance changed 之前调用)。但是,如果改变父组件属性是在其他阶段,比如 AfterViewChecked 钩子函数阶段,在父组件已经完成变更检测后,再去调用这个钩子函数,在开发者模式下框架会抛出错误

Expression has changed after it was checked

关于这个错误,你可以读这篇文章 Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError error 。(注:这篇文章已翻译)

在生产环境下 Angular 不会抛出错误,但是也不会检查数据变化直到下一次变更检测循环。(注:因为开发者模式下 Angular 会执行两次变更检测循环,第二次检查会发现父组件属性被改变就会抛出错误,而生产环境下只执行一次。)

使用生命周期钩子来追踪数据变化

在 Angular.js 里,每一个组件定义了一堆 watchers 来追踪如下数据变化:

在 Angular 里却是这么实现这些功能的:可以使用 OnChanges 生命周期钩子函数来监听父组件属性;可以使用 DoCheck 生命周期钩子来监听当前组件属性,因为这个钩子函数会在 Angular 处理当前组件属性变化前去调用,所以可以在这个函数里做任何需要的事情,来获取即将在 UI 中显示的改变值;也可以使用 OnInit 钩子函数来监听第三方组件并手动运行变更检测循环。

比如,我们有一个显示当前时间的组件,时间是由 Time 服务提供,在 Angular.js 中是这么实现的:

function link(scope,element) {
    scope.$watch(() => {
        return Time.getCurrentTime();
    },(value) => {
        $scope.time = value;
    })
}

而在 Angular 中是这么实现的:

class TimeComponent {
    ngDoCheck()
    {
        this.time = Time.getCurrentTime();
    }
}

另一个例子是如果我们有一个没集成在 Angular 系统内的第三方 slider 组件,但我们需要显示当前 slide,那就仅仅需要把这个组件封装进 Angular 组件内,监听 slider's changed 事件,并手动触发变更检测循环来同步 UI。Angular.js 里这么写:

function link(scope,element) {
    slider.on('changed',(slide) => {
        scope.slide = slide;
        
        // detect changes on the current component
        $scope.$digest();
        
        // or run change detection for the all app
        $rootScope.$digest();
    })
}

Angular 里也同样原理(注:也同样需要手动触发变更检测循环,this.appRef.tick() 会检测所有组件,而 this.cd.detectChanges() 会检测当前组件及子组件):

class SliderComponent {
    ngOnInit() {
        slider.on('changed',(slide) => {
            this.slide = slide

            // detect changes on the current component
            // this.cd is an injected ChangeDetector instance
            this.cd.detectChanges();

            // or run change detection for the all app
            // this.appRef is an ApplicationRef instance
            this.appRef.tick();
        })
    }
}

猜你在找的Angularjs相关文章