资讯专栏INFORMATION COLUMN

Change Detection And Batch Update

陈江龙 / 3521人阅读

摘要:如果我们不使用提供的事件系统定时器和,如在事件中进行数据更新时,我们需要手动调用。

前言

在传统的WEB开发中,当与用户或服务器发生交互时,需要我们手动获取数据并更新DOM,这个过程是繁琐的、易错的。
特别是当页面功能过于复杂时,我们既要关注数据的变化,又要维护DOM的更新,这样写出来的代码是很难维护的。
新一代的框架或库,例如Angular、React、Vue等等让我们的关注点只在数据上,当数据更新时,这些框架/库会帮我们更新DOM。
那么这里就有两个很重要的问题了:当数据变化时,这些框架/库是如何感知到的?当我们连续更新数据时,这些框架/库如何避免连续更新DOM,而是进行批量更新?
带着这两个问题,我将简要分析一下React、Angular1、Angular2及Vue的实现机制。

React Virtual DOM

React在更新UI的时候会根据新老state生成两份虚拟DOM,所谓的虚拟DOM其实就是JavaScript对象,然后在根据特定的diff算法比较这两个对象,找出不同的部分,最后根据改变的那部分进行对应DOM的更新。
那么React是如何知道数据变化了呢?我们通过手动调用setState告知React我们需要更新的数据。

setState

例如我们这里有一个很简单的组件:

</>复制代码

  1. class App extends React.Component {
  2. constructor() {
  3. super();
  4. this.handleClick = this.handleClick.bind(this);
  5. this.state = {
  6. val: 0,
  7. };
  8. }
  9. handleClick() {
  10. this.setState({val: 1});
  11. }
  12. render() {
  13. return (
  14. {this.state.val}
  15. )
  16. }
  17. }

当我点击按钮的时候调用this.setState({val: 1});,React就会将this.state.val更新成1,并且自动帮我们更新UI。
如果点击按钮的时候我们连续调用setState会怎么样?React是连续更新两次,还是只更新一次呢?为了更好的观察出React的更新机制,我们将点击按钮的逻辑换成下面的代码

</>复制代码

  1. this.setState({val: 1});
  2. console.log(this.state.val);
  3. this.setState({val: 2});
  4. console.log(this.state.val);

打开控制台,点击按钮你会发现打印了0 0,同时页面数据也更新成了2。所以我们就得出结论:React的更新并不是同步的,而是批量更新的。
我们别急着下结论,我们知道应用程序状态的改变主要是下面三种情况引起的:

Events - 如点击按钮

Timers - 如setTimeout

XHR - 从服务器获取数据

我们才测试了事件这一种情景,我们试着看看其余两种情景下state的变化,将点击按钮的逻辑换成如下代码

</>复制代码

  1. setTimeout(() => {
  2. this.setState({val: 1});
  3. console.log(this.state.val);
  4. this.setState({val: 2});
  5. console.log(this.state.val);
  6. });

打开控制台,点击按钮你会发现打印了1 2,相信这个时候很多人就懵了,为啥和第一种情况的输出不一致,不是说好的批量更新的么,怎么变成连续更新了。
我们再试试第三种情景XHR,将点击按钮的逻辑换成下面的代码

</>复制代码

  1. fetch("/")
  2. .then(() => {
  3. this.setState({val: 1});
  4. console.log(this.state.val);
  5. this.setState({val: 2});
  6. console.log(this.state.val);
  7. });

打开控制台,点击按钮你会发现打印的还是1 2,这究竟是什么情况?如果仔细观察的话,你会发现上面的输出符合一个规律:在React调用的方法中连续setState走的是批量更新,此外走的是连续更新
为了验证这个的猜想,我们试着在React的生命周期方法中连续调用setState

</>复制代码

  1. componentDidMount() {
  2. this.setState({val: 1});
  3. console.log(this.state.val);
  4. this.setState({val: 2});
  5. console.log(this.state.val);
  6. }

打开控制台你会发现打印了0 0 ,更加验证了我们的猜想,因为生命周期方法也是React调用的。到此我们可以得出这样一个结论:

</>复制代码

  1. 在React调用的方法中连续setState走的是批量更新,此外走的是连续更新

说到这里,有些人可能会有这样一个疑惑

</>复制代码

  1. handleClick() {
  2. setTimeout(() => {
  3. this.setState({val: 1});
  4. console.log(this.state.val);
  5. this.setState({val: 2});
  6. console.log(this.state.val);
  7. });
  8. }

setTimeout也是在handleClick当中调用的,为啥不是批量更新呢?
setTimeout确实是在handleClick当中调用的,但是两个setState可不是在handleClick当中调用的,它们是在传递给setTimeout的参数——匿名函数中执行的,走的是事件轮询,不要弄混了。

综上,说setState是异步的需要加一个前提条件,在React调用的方法中执行,这时我们需要通过回调获取到最新的state

</>复制代码

  1. this.setState({val: 1}, () => {
  2. console.log(this.state.val);
  3. });

相信这个道理大家不难理解,因为事件和生命周期方法都是React调用的,它想怎么玩就怎么玩。那么React内部是如何实现批量更新的呢?

事务

React当中事务最主要的功能就是拿到一个函数的执行上下文,提供钩子函数。啥意思?看个例子

</>复制代码

  1. import Transaction from "react/lib/Transaction";
  2. const transaction = Object.assign({}, Transaction.Mixin, {
  3. getTransactionWrappers() {
  4. return [{
  5. initialize() {
  6. console.log("initialize");
  7. },
  8. close() {
  9. console.log("close");
  10. }
  11. }];
  12. }
  13. });
  14. transaction.reinitializeTransaction();
  15. const fn = () => {
  16. console.log("fn");
  17. };
  18. transaction.perform(fn);

执行这段代码,打开控制台会发现打印如下

</>复制代码

  1. initialize
  2. fn
  3. close

事务最主要的功能就是可以Wrapper一个函数,通过perform调用,在执行这个函数之前会先调用initialize方法,等这个函数执行结束了在调用close方法。事务的核心代码很短,只有五个方法,有兴趣的可以去看下。
结合上面setState连续调用的情况,我们可以大致猜出React的更新机制,例如执行handleClick的时候

</>复制代码

  1. let updating = false;
  2. setState = function() {
  3. if(updating){
  4. // 缓存数据
  5. }else {
  6. // 更新
  7. }
  8. }
  9. const transaction = Object.assign({}, Transaction.Mixin, {
  10. getTransactionWrappers() {
  11. return [{
  12. initialize() {
  13. updating = true;
  14. },
  15. close() {
  16. updating = false;
  17. // 更新
  18. }
  19. }];
  20. }
  21. });
  22. transaction.reinitializeTransaction();
  23. transaction.perform(instance.handleClick);

我们再来深入一下setState的实现,看看是不是这么回事,下面是setState会调用到的方法

</>复制代码

  1. function enqueueUpdate(component) {
  2. ensureInjected();
  3. if (!batchingStrategy.isBatchingUpdates) {
  4. batchingStrategy.batchedUpdates(enqueueUpdate, component);
  5. return;
  6. }
  7. dirtyComponents.push(component);
  8. if (component._updateBatchNumber == null) {
  9. component._updateBatchNumber = updateBatchNumber + 1;
  10. }
  11. }

看变量名称我们也都能猜到大致功能,通过batchingStrategy.isBatchingUpdates来决定是否进行batchedUpdates(批量更新),还是dirtyComponents.push(缓存数据),结合事务,React的批量更新策略应该是这样的

</>复制代码

  1. const transaction = Object.assign({}, Transaction.Mixin, {
  2. getTransactionWrappers() {
  3. return [{
  4. initialize() {
  5. batchingStrategy.isBatchingUpdates = true;
  6. },
  7. close() {
  8. batchingStrategy.isBatchingUpdates = false;
  9. }
  10. }];
  11. }
  12. });
  13. transaction.reinitializeTransaction();
  14. transaction.perform(instance.handleClick);
  15. transaction.perform(instance.componentDidMount);
小结

React通过setState感知到数据的变化,通过事务进行批量更新,通过Virtual DOM比较进行高效的DOM更新。

Angular1 Dirty Checking

Angular1通过脏值检测去更新UI,所谓的脏值检测其实指Angular1从$rootScope开始遍历所有scope的$$watchers数组,通过比较新老值来决定是否更新DOM。看个例子

</>复制代码

  1. {{val}}

</>复制代码

  1. angular.module("myApp", [])
  2. .controller("MyCtrl", function($scope) {
  3. $scope.val = 0;
  4. });

这个是一个很简单的数据渲染的例子,我们在控制台打印下scope,看下$$watchers的内容

因为只有val一个表达式所以$$watchers长度只有1

eq 是否进行数据的深度比较

exp 检测出错时log所用

fn 更新DOM

get 获取当前数据

last 老的数据

那么Angular1是如何感知到数据变化的呢?

$apply

Angular1通过调用$scope.$apply()进行脏值检测的,核心代码如下

遍历所有scope的$$watchers,通过get获取到最新值同last比较,值变化了则通过调用fn更新DOM。有人可能会疑惑了,我们在编码的时候并没有调用$apply,那么UI是怎么更新的呢?
实际上是Angular1帮我们调用了,我们看下ng事件的源码实现

</>复制代码

  1. forEach(
  2. "click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste".split(" "),
  3. function(eventName) {
  4. var directiveName = directiveNormalize("ng-" + eventName);
  5. ngEventDirectives[directiveName] = ["$parse", "$rootScope", function($parse, $rootScope) {
  6. return {
  7. restrict: "A",
  8. compile: function($element, attr) {
  9. var fn = $parse(attr[directiveName], /* interceptorFn */ null, /* expensiveChecks */ true);
  10. return function ngEventHandler(scope, element) {
  11. element.on(eventName, function(event) {
  12. var callback = function() {
  13. fn(scope, {$event:event});
  14. };
  15. if (forceAsyncEvents[eventName] && $rootScope.$$phase) {
  16. scope.$evalAsync(callback);
  17. } else {
  18. scope.$apply(callback);
  19. }
  20. });
  21. };
  22. }
  23. };
  24. }];
  25. }
  26. );

很明显调用了$scope.$apply,我们再看下$timeout的源码

</>复制代码

  1. function timeout(fn, delay, invokeApply) {
  2. // ...
  3. timeoutId = $browser.defer(function() {
  4. try {
  5. deferred.resolve(fn.apply(null, args));
  6. } catch (e) {
  7. deferred.reject(e);
  8. $exceptionHandler(e);
  9. }
  10. finally {
  11. delete deferreds[promise.$$timeoutId];
  12. }
  13. if (!skipApply) $rootScope.$apply();
  14. }, delay);
  15. // ...
  16. }

最后也调用了$rootScope.$apply,$http服务实际上也做了同样的处理,说到这,三种引起应用程序状态变化的情景,Angular1都做了封装,所以我们写代码的时候不需要手动去调用$apply了。
新手常碰到的一个问题就是为啥下面的代码不起作用

</>复制代码

  1. $("#btn").on("click", function() {
  2. $scope.val = 1;
  3. });

因为我们没有用Angular1提供的事件系统,所以Angular1没法自动帮我们调用$apply,这里我们只能手动调用$apply进行脏值检测了

</>复制代码

  1. $("#btn").on("click", function() {
  2. $scope.val = 1;
  3. $scope.$apply();
  4. });
小结

在Angular1中我们是直接操作数据的,这个过程Angular1是感知不到的,只能在某个点调用$apply进行脏值检测,所以默认就是批量更新。如果我们不使用Angular1提供的事件系统、定时器和$http,如在jQuery事件中进行数据更新时,我们需要手动调用$apply。

Angular2

当数据变化时,Angular2从根节点往下遍历进行更新,默认Angular2深度遍历数据,进行新老数据的比较来决定是否更新UI,这点和Angular1的脏值检测有点像,但是Angular2的更新没有副作用,是单向数据流。
同时大家也不用担心性能问题

</>复制代码

  1. It can perform hundreds of thousands of checks within a couple of milliseconds. This is mainly due to the fact that Angular generates VM friendly code — by Pascal Precht

Angular2也提供了不同的检测策略,例如

</>复制代码

  1. @Component({
  2. selector: "child",
  3. template: `
  4. {{data.name}}
  5. `,
  6. changeDetection: ChangeDetectionStrategy.OnPush
  7. })

设置了变化检测策略为OnPush的组件不走深度遍历,而是直接比较对象的引用来决定是否更新UI。

Zone.js

Angular2同Angular1一样都是直接操作数据的,框架都无法直接感知数据的变化,只能在特定的时机去做批量更新。
Angular1是通过封装自动调用$apply,但是存在手动调用的场景,为了解决这个问题,Angular2没有采用1的实现机制,转而使用了Zone.js。

Zone.js最主要的功能就是可以获取到异步方法执行的上下文。什么是执行上下文?例如

</>复制代码

  1. function foo() {
  2. bar();
  3. }
  4. foo();
  5. baz();

同步的方法我们可以明确的知道bar什么时候执行和结束,可以在bar结束的时候调用baz。但是对于异步方法,例如

</>复制代码

  1. function foo() {
  2. bar();
  3. }
  4. setTimeout(foo);
  5. baz();

我们无法知道foo是什么时候开始执行和结束,因为它是异步的。如果调用改成这样

</>复制代码

  1. function foo() {
  2. bar();
  3. }
  4. setTimeout(function() {
  5. foo();
  6. baz();
  7. });

通过添加一层wrapper函数,不就可以保证在foo执行完调用baz了么。Zone.js主要重写了浏览器所有的异步实现,如setTimeout、XMLHttpRequest、addEventListener等等,然后提供钩子函数,

</>复制代码

  1. new Zone().fork({
  2. beforeTask: function() {
  3. console.log("beforeTask");
  4. },
  5. afterTask: function() {
  6. console.log("afterTask");
  7. }
  8. }).run(function mainFn() {
  9. console.log("main exec");
  10. setTimeout(function timeoutFn() {
  11. console.log("timeout exec");
  12. }, 2000);
  13. });

打开控制台,你会发现打印如下

</>复制代码

  1. beforeTask
  2. main exec
  3. afterTask
  4. beforeTask
  5. timeout exec
  6. afterTask

Zone.js捕获到了mainFn和timeoutFn执行的上下文,这样我们就可以在每个task执行结束后执行更新UI的操作了。Angular2更新机制大体如下

</>复制代码

  1. class ApplicationRef {
  2. changeDetectorRefs:ChangeDetectorRef[] = [];
  3. constructor(private zone: NgZone) {
  4. this.zone.onTurnDone
  5. .subscribe(() => this.zone.run(() => this.tick());
  6. }
  7. tick() {
  8. this.changeDetectorRefs
  9. .forEach((ref) => ref.detectChanges());
  10. }
  11. }

ngZone是对Zone.js的服务封装,Angular2会在每个task执行结束后触发更新。

小结

由于Zone.js的存在,我们可以在任何场景下更新数据而无需手动调用检测,Angular2也是批量更新。

Vue

Vue模板中每个指令/数据绑定都有一个对应的watcher对象,当数据变化时,会触发watcher重新计算并更新相应的DOM。

setter

Vue通过Object.defineProperty将data转化为getter/setter,这样我们直接修改数据时,Vue就能够感知到数据的变化了,这个时候就可以进行UI更新了。
如果我们连续更新数据,Vue会立马更新DOM还是和React一样先缓存下来等待状态稳定进行批量更新呢?我们还是从应用程序状态改变的三种情景来看

</>复制代码

  1. var vm = new Vue({
  2. el: "#app",
  3. data: {
  4. val: 0
  5. },
  6. methods: {
  7. onClick: function() {
  8. vm.val = 1;
  9. console.log(vm.$el.textContent);
  10. vm.val = 2;
  11. console.log(vm.$el.textContent);
  12. }
  13. }
  14. });

打开控制台,点击按钮会发现打印0 0,说明Vue并不是立马更新的,走的是批量更新。由于事件系统用的Vue提供的,是可控的,我们再看下定时器下执行的情况

</>复制代码

  1. var vm = new Vue({
  2. el: "#app",
  3. data: {
  4. val: 0
  5. }
  6. });
  7. setTimeout(function() {
  8. vm.val = 1;
  9. console.log(vm.$el.textContent);
  10. vm.val = 2;
  11. console.log(vm.$el.textContent);
  12. });

打开控制台,点击按钮会发现依旧打印了0 0,有人可能就疑惑了Vue是不是跟Angular2一样也修改了异步方法的原生实现呢?
Vue并没有这么干,不用于React、Angular1/2捕获异步方法上下文去更新,Vue采用了不同的更新策略。

异步更新队列

</>复制代码

  1. 每当观察到数据变化时,Vue就开始一个队列,将同一事件循环内所有的数据变化缓存起来。如果一个watcher被多次触发,只会推入一次到队列中。

等到下一次事件循环,Vue将清空队列,只进行必要的DOM更新。在内部异步队列优先使用MutationObserver,如果不支持则使用setTimeout(fn, 0) — vuejs.org

这是官方文档上的说明,抽象成代码就是这样的

</>复制代码

  1. var waiting = false;
  2. var queue = [];
  3. function setter(val) {
  4. if(!waiting) {
  5. waiting = true;
  6. setTimeout(function() {
  7. queue.forEach(function(item) {
  8. // 更新DOM
  9. });
  10. waiting = false;
  11. queue = [];
  12. }, 0);
  13. } else {
  14. queue.push(val);
  15. }
  16. }
  17. setter(1);
  18. setter(2);

Vue是通过JavaScript单线程的特性,利用事件队列进行批量更新的。

config.async

我们可以通过将Vue.config.async设置为false,关闭异步更新机制,让它变成同步更新,看下面的例子

</>复制代码

  1. Vue.config.async = false;
  2. var vm = new Vue({
  3. el: "#app",
  4. data: {
  5. val: 0
  6. }
  7. });
  8. setTimeout(function() {
  9. vm.val = 1;
  10. console.log(vm.$el.textContent);
  11. vm.val = 2;
  12. console.log(vm.$el.textContent);
  13. });

打开控制台你会发现打印了1 2,但是最好别这么干

</>复制代码

  1. 如果关闭了异步模式,Vue 在检测到数据变化时同步更新 DOM。在有些情况下这有助于调试,但是也可能导致性能下降,并且影响 watcher 回调的调用顺序。async: false不推荐用在生产环境中 — vuejs.org

总结

自此我们分析了React、Angular1/2和Vue的变化检测以及批量更新的策略。
React和Angular1/2都是通过获取执行上下文来进行批量更新,但是React和Angular1支持的并不彻底,都有各自的问题。
Angular2可以适配任意情况,但是是通过篡改了原生方法实现的。Vue则通过ES5特性和JavaScript单线程的特性进行批量更新,无需特殊处理,可以满足任何情况。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/80450.html

相关文章

  • TensorFlow Object Detection API Custom Object Hang

    摘要: TensorFlow Object Detection API Hangs On — Training and Evaluating using Custom Object Detector *The links to all files updated and the GitHub repo address added. First of All Install TensorF...

    fevin 评论0 收藏0
  • Change Detection系列一】$digest 在Angular新版本中重生

    摘要:感谢您的阅读如果喜欢这篇文章请点赞。它对我意义重大,它能帮助其他人看到这篇文章。对于更高级的文章,你可以在或上跟随我。 I’ve worked with Angular.js for a few years and despite the widespread criticism I think this is a fantastic framework. I’ve started w...

    legendaryedu 评论0 收藏0
  • 《DeepLearning.ai 深度学习笔记》发布,黄海广博士整理

    摘要:在这堂课中,学生将可以学习到深度学习的基础,学会构建神经网络,包括和等。课程中也会有很多实操项目,帮助学生更好地应用自己学到的深度学习技术,解决真实世界问题。 深度学习入门首推课程就是吴恩达的深度学习专项课程系列的 5 门课。该专项课程最大的特色就是内容全面、通俗易懂并配备了丰富的实战项目。今天,给大家推荐一份关于该专项课程的核心笔记!这份笔记只能用两个字形容:全面! showImg(...

    wenhai.he 评论0 收藏0
  • 这5篇文章将使你成为一个Angular Change Detection专家。

    摘要:编写工作首先介绍了一个称为的内部组件表示,并解释了变更检测过程在视图上运行。本文主要由两部分组成第一部分探讨错误产生的原因,第二部分提出可能的修正。它对我意义重大,它能帮助其他人看到这篇文章。 在过去的8个月里,我大部分空闲时间都是reverse-engineering Angular。我最感兴趣的话题是变化检测。我认为它是框架中最重要的部分,因为它负责像DOM更新、输入绑定和查询列表...

    Coly 评论0 收藏0
  • [译] $digest 在 Angular 中重生

    摘要:但如果一个组件在生命周期钩子里改变父组件属性,却是可以的,因为这个钩子函数是在更新父组件属性变化之前调用的注即第步,在第步之前调用。 原文链接:Angular.js’ $digest is reborn in the newer version of Angular showImg(https://segmentfault.com/img/remote/146000001468785...

    incredible 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<