Change Detection And Batch Update

媒介

在传统的WEB开辟中,当与用户或效劳器发作交互时,须要我们手动猎取数据并更新DOM,这个历程是烦琐的、易错的。
特别是当页面功用过于庞杂时,我们既要关注数据的变化,又要保护DOM的更新,如许写出来的代码是很难保护的。
新一代的框架或库,比方Angular、React、Vue等等让我们的关注点只在数据上,当数据更新时,这些框架/库会帮我们更新DOM。
那末这里就有两个很重要的题目了:当数据变化时,这些框架/库是怎样感知到的?当我们一连更新数据时,这些框架/库怎样防止一连更新DOM,而是举行批量更新?
带着这两个题目,我将扼要剖析一下React、Angular1、Angular2及Vue的完成机制。

React

Virtual DOM

《Change Detection And Batch Update》

React在更新UI的时刻会依据新老state天生两份假造DOM,所谓的假造DOM实在就是JavaScript对象,然后在依据特定的diff算法比较这两个对象,找出差别的部份,末了依据转变的那部份举行对应DOM的更新。
那末React是怎样晓得数据变化了呢?我们经由过程手动挪用setState示知React我们须要更新的数据。

setState

比方我们这里有一个很简单的组件:

class App extends React.Component {
  constructor() {
    super();
    this.handleClick = this.handleClick.bind(this);
    this.state = {
      val: 0,
    };
  }

  handleClick() {
    this.setState({val: 1});
  }

  render() {
    return (
      <div>
        <span>{this.state.val}</span>
        <button onClick={this.handleClick}>change val</button>
      </div>
    )
  }
}

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

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

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

翻开控制台,点击按钮你会发明打印了0 0,同时页面数据也更新成了2。所以我们就得出结论:React的更新并非同步的,而是批量更新的。
我们别急着下结论,我们晓得应用程序状况的转变主如果下面三种状况引发的:

  • Events – 如点击按钮

  • Timers – 如setTimeout

  • XHR – 从效劳器猎取数据

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

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

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

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

fetch('/')
  .then(() => {
    this.setState({val: 1});
    console.log(this.state.val);

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

翻开控制台,点击按钮你会发明打印的照样1 2,这究竟是什么状况?假如仔细视察的话,你会发明上面的输出相符一个规律:在React挪用的要领中一连setState走的是批量更新,另外走的是一连更新
为了考证这个的猜测,我们试着在React的生命周期要领中一连挪用setState

componentDidMount() {
  this.setState({val: 1});
  console.log(this.state.val);

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

翻开控制台你会发明打印了0 0 ,越发考证了我们的猜测,因为生命周期要领也是React挪用的。到此我们可以得出如许一个结论:

在React挪用的要领中一连setState走的是批量更新,另外走的是一连更新

说到这里,有些人可以会有如许一个迷惑

handleClick() {
  setTimeout(() => {
    this.setState({val: 1});
    console.log(this.state.val);

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

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

综上,说setState是异步的须要加一个前提条件,在React挪用的要领中实行,这时候我们须要经由过程回调猎取到最新的state

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

置信这个原理人人不难理解,因为事宜和生命周期要领都是React挪用的,它想怎样玩就怎样玩。那末React内部是怎样完成批量更新的呢?

事件

React当中事件最重要的功用就是拿到一个函数的实行上下文,供应钩子函数。啥意思?看个例子

import Transaction from 'react/lib/Transaction';

const transaction = Object.assign({}, Transaction.Mixin, {
  getTransactionWrappers() {
    return [{
      initialize() {
        console.log('initialize');
      },
      close() {
        console.log('close');
      }
    }];
  }
});
transaction.reinitializeTransaction();
const fn = () => {
  console.log('fn');
};
transaction.perform(fn);

实行这段代码,翻开控制台会发明打印以下

initialize
fn
close

事件最重要的功用就是可以Wrapper一个函数,经由过程perform挪用,在实行这个函数之前会先挪用initialize要领,等这个函数实行完毕了在挪用close要领。事件的中心代码很短,只要五个要领,有兴致的可以去看下。
连系上面setState一连挪用的状况,我们可以大抵猜出React的更新机制,比方实行handleClick的时刻

let updating = false;

setState = function() {
  if(updating){
    // 缓存数据
  }else {
    // 更新
  }
}

const transaction = Object.assign({}, Transaction.Mixin, {
  getTransactionWrappers() {
    return [{
      initialize() {
        updating = true;
      },
      close() {
        updating = false;
        // 更新
      }
    }];
  }
});
transaction.reinitializeTransaction();

transaction.perform(instance.handleClick);

我们再来深切一下setState的完成,看看是不是是这么回事,下面是setState会挪用到的要领

function enqueueUpdate(component) {
  ensureInjected();

  if (!batchingStrategy.isBatchingUpdates) {
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }

  dirtyComponents.push(component);
  if (component._updateBatchNumber == null) {
    component._updateBatchNumber = updateBatchNumber + 1;
  }
}

看变量称号我们也都能猜到大抵功用,经由过程batchingStrategy.isBatchingUpdates来决议是不是举行batchedUpdates(批量更新),照样dirtyComponents.push(缓存数据),连系事件,React的批量更新战略应该是如许的

const transaction = Object.assign({}, Transaction.Mixin, {
  getTransactionWrappers() {
    return [{
      initialize() {
        batchingStrategy.isBatchingUpdates = true;
      },
      close() {
        batchingStrategy.isBatchingUpdates = false;
      }
    }];
  }
});
transaction.reinitializeTransaction();

transaction.perform(instance.handleClick);
transaction.perform(instance.componentDidMount);

小结

React经由过程setState感知到数据的变化,经由过程事件举行批量更新,经由过程Virtual DOM比较举行高效的DOM更新。

Angular1

Dirty Checking

《Change Detection And Batch Update》

Angular1经由过程脏值检测去更新UI,所谓的脏值检测实在指Angular1从$rootScope最先遍历一切scope的$$watchers数组,经由过程比较新老值来决议是不是更新DOM。看个例子

<div ng-controller="MyCtrl">{{val}}</div>
angular.module('myApp', [])
  .controller('MyCtrl', function($scope) {
    $scope.val = 0;
  });

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

《Change Detection And Batch Update》

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

  • eq 是不是举行数据的深度比较

  • exp 检测失足时log所用

  • fn 更新DOM

  • get 猎取当前数据

  • last 老的数据

那末Angular1是怎样感知到数据变化的呢?

$apply

Angular1经由过程挪用$scope.$apply()举行脏值检测的,中心代码以下

《Change Detection And Batch Update》

遍历一切scope的$$watchers,经由过程get猎取到最新值同last比较,值变化了则经由过程挪用fn更新DOM。有人可以会迷惑了,我们在编码的时刻并没有挪用$apply,那末UI是怎样更新的呢?
实际上是Angular1帮我们挪用了,我们看下ng事宜的源码完成

forEach(
  'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '),
  function(eventName) {
    var directiveName = directiveNormalize('ng-' + eventName);
    ngEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse, $rootScope) {
      return {
        restrict: 'A',
        compile: function($element, attr) {
          var fn = $parse(attr[directiveName], /* interceptorFn */ null, /* expensiveChecks */ true);
          return function ngEventHandler(scope, element) {
            element.on(eventName, function(event) {
              var callback = function() {
                fn(scope, {$event:event});
              };
              if (forceAsyncEvents[eventName] && $rootScope.$$phase) {
                scope.$evalAsync(callback);
              } else {
                scope.$apply(callback);
              }
            });
          };
        }
      };
    }];
  }
);

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

function timeout(fn, delay, invokeApply) {
  // ...
  
  timeoutId = $browser.defer(function() {
    try {
      deferred.resolve(fn.apply(null, args));
    } catch (e) {
      deferred.reject(e);
      $exceptionHandler(e);
    }
    finally {
      delete deferreds[promise.$$timeoutId];
    }

    if (!skipApply) $rootScope.$apply();
  }, delay);

  // ...
}

末了也挪用了$rootScope.$apply,$http效劳实际上也做了一样的处置惩罚,说到这,三种引发应用程序状况变化的情形,Angular1都做了封装,所以我们写代码的时刻不须要手动去挪用$apply了。
新手常遇到的一个题目就是为啥下面的代码不起作用

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

因为我们没有用Angular1供应的事宜体系,所以Angular1没法自动帮我们挪用$apply,这里我们只能手动挪用$apply举行脏值检测了

$('#btn').on('click', function() {
  $scope.val = 1;
  $scope.$apply();
});

小结

在Angular1中我们是直接操纵数据的,这个历程Angular1是感知不到的,只能在某个点挪用$apply举行脏值检测,所以默许就是批量更新。假如我们不运用Angular1供应的事宜体系、定时器和$http,如在jQuery事宜中举行数据更新时,我们须要手动挪用$apply。

Angular2

《Change Detection And Batch Update》

当数据变化时,Angular2从根节点往下遍历举行更新,默许Angular2深度遍历数据,举行新老数据的比较来决议是不是更新UI,这点和Angular1的脏值检测有点像,然则Angular2的更新没有副作用,是单向数据流。
同时人人也不必忧郁机能题目

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也供应了差别的检测战略,比方

@Component({
  selector: 'child',
  template: `
    <div>{{data.name}}</div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})

《Change Detection And Batch Update》

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

Zone.js

Angular2同Angular1一样都是直接操纵数据的,框架都没法直接感知数据的变化,只能在特定的机遇去做批量更新。
Angular1是经由过程封装自动挪用$apply,然则存在手动挪用的场景,为了处理这个题目,Angular2没有采纳1的完成机制,转而运用了Zone.js。

Zone.js最重要的功用就是可以猎取到异步要领实行的上下文。什么是实行上下文?比方

function foo() {
  bar();
}

foo();
baz();

同步的要领我们可以明白的晓得bar什么时刻实行和完毕,可以在bar完毕的时刻挪用baz。然则关于异步要领,比方

function foo() {
  bar();
}

setTimeout(foo);
baz();

我们没法晓得foo是什么时刻最先实行和完毕,因为它是异步的。假如挪用改成如许

function foo() {
  bar();
}

setTimeout(function() {
  foo();
  baz();
});

经由过程增加一层wrapper函数,不就可以保证在foo实行完挪用baz了么。Zone.js重要重写了浏览器一切的异步完成,如setTimeout、XMLHttpRequest、addEventListener等等,然后供应钩子函数,

new Zone().fork({
  beforeTask: function() {
    console.log('beforeTask');
  },
  afterTask: function() {
    console.log('afterTask');
  }
}).run(function mainFn() {
  console.log('main exec');
  setTimeout(function timeoutFn() {
    console.log('timeout exec');
  }, 2000);
});

翻开控制台,你会发明打印以下

beforeTask
main exec
afterTask

beforeTask
timeout exec
afterTask

Zone.js捕捉到了mainFn和timeoutFn实行的上下文,如许我们就可以在每一个task实行完毕后实行更新UI的操纵了。Angular2更新机制大致以下

class ApplicationRef {
  changeDetectorRefs:ChangeDetectorRef[] = [];

  constructor(private zone: NgZone) {
    this.zone.onTurnDone
      .subscribe(() => this.zone.run(() => this.tick());
  }

  tick() {
    this.changeDetectorRefs
      .forEach((ref) => ref.detectChanges());
  }
}

ngZone是对Zone.js的效劳封装,Angular2会在每一个task实行完毕后触发更新。

小结

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

Vue

《Change Detection And Batch Update》

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

setter

Vue经由过程Object.defineProperty将data转化为getter/setter,如许我们直接修正数据时,Vue就可以感知到数据的变化了,这个时刻就可以举行UI更新了。
假如我们一连更新数据,Vue会立马更新DOM照样和React一样先缓存下来守候状况稳固举行批量更新呢?我们照样从应用程序状况转变的三种情形来看

var vm = new Vue({
  el: '#app',
  data: {
    val: 0
  },
  methods: {
    onClick: function() {
      vm.val = 1;
      console.log(vm.$el.textContent);

      vm.val = 2;
      console.log(vm.$el.textContent);
    }
  }
});

翻开控制台,点击按钮会发明打印0 0,申明Vue并非立马更新的,走的是批量更新。因为事宜体系用的Vue供应的,是可控的,我们再看下定时器下实行的状况

var vm = new Vue({
  el: '#app',
  data: {
    val: 0
  }
});

setTimeout(function() {
  vm.val = 1;
  console.log(vm.$el.textContent);

  vm.val = 2;
  console.log(vm.$el.textContent);
});

翻开控制台,点击按钮会发明照旧打印了0 0,有人可以就迷惑了Vue是不是是跟Angular2一样也修正了异步要领的原生完成呢?
Vue并没有这么干,不必于React、Angular1/2捕捉异步要领上下文去更新,Vue采纳了差别的更新战略。

异步更新行列

每当视察到数据变化时,Vue就最先一个行列,将同一事宜轮回内一切的数据变化缓存起来。假如一个watcher被屡次触发,只会推入一次到行列中。

比及下一次事宜轮回,Vue将清空行列,只举行必要的DOM更新。在内部异步行列优先运用MutationObserver,假如不支撑则运用setTimeout(fn, 0) — vuejs.org

这是官方文档上的申明,笼统成代码就是如许的

var waiting = false;
var queue = [];

function setter(val) {
  if(!waiting) {
    waiting = true;
    
    setTimeout(function() {
      queue.forEach(function(item) {
        // 更新DOM
      });
      
      waiting = false;
      queue = [];
    }, 0);
  } else {
    queue.push(val);
  }
}

setter(1);
setter(2);

Vue是经由过程JavaScript单线程的特征,应用事宜行列举行批量更新的。

config.async

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

Vue.config.async = false;

var vm = new Vue({
  el: '#app',
  data: {
    val: 0
  }
});

setTimeout(function() {
  vm.val = 1;
  console.log(vm.$el.textContent);

  vm.val = 2;
  console.log(vm.$el.textContent);
});

翻开控制台你会发明打印了1 2,然则最好别这么干

假如封闭了异步形式,Vue 在检测到数据变化时同步更新 DOM。在有些状况下这有助于调试,然则也可以致使机能下落,而且影响 watcher 回调的挪用递次。async: false不引荐用在临盆环境中 — vuejs.org

总结

自此我们剖析了React、Angular1/2和Vue的变化检测以及批量更新的战略。
React和Angular1/2都是经由过程猎取实行上下文来举行批量更新,然则React和Angular1支撑的并不完全,都有各自的题目。
Angular2可以适配恣意状况,然则是经由过程篡改了原生要领完成的。Vue则经由过程ES5特征和JavaScript单线程的特征举行批量更新,无需特别处置惩罚,可以满足任何状况。

    原文作者:小俞
    原文地址: https://segmentfault.com/a/1190000006986141
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞