Vue 2.0->React 从响应式到更新原理|小册免费学

1,415 阅读9分钟

写在前面

本文是阅读React Hooks 与 Immutable 数据流实战的心得文章,站在一个vue使用者的视角学习解读React项目。本人学习React的项目生猛了点,上来直接看React项目,有不懂的再查,一方面vue与react还是有很多理念上的相似之处的,另一方面看到React Hooks被社区认为是React的更好实践,正好看一下React Hooks在实际项目的实践。这篇文章主要从Vue的响应式与更新原理、diff算法出发。在这些方面与React做个比较,以促理解。

Vue 2.0 响应式原理与更新

之前一次蚂蚁金服的面试提醒我不能只关注写应用,底层的知识还是要了解,而且最好深入些,对于Vue 2的响应式原理,之前知道的就是采用了Object.defineProperty()实现的,一些具体细节并不明了。正好借此机会回顾一下Vue的原理。

响应式基础:Object.defineProperty

Vue 2实现响应性的基本方法就是调用JSObject.defineProperty方法

Object.defineProperty(obj, prop, {
    enumerable: true,
    configurable: true,
    get:yourGetter,
    set:yourSetter,
    // blah blah blah
})

当时刚接触JS的时候想这玩意干啥用?画蛇添足,我直接一个obj[prop] = val不解决了?后来发现自己还是草率了。

这里的重点是getset,说得简单点就是get在调用obj[prop]obj.prop时起效,set在调用obj[prop] = val时起效,一般来说obj.prop返回Object的指定属性,obj[prop] = val修改某个属性,但指定了getset之后,你可以让Object的赋值与访问返回任意你想要的结果,比如你要是这样写:

Object.defineProperty(myObj, prop, {
    get:function myGetter(){
        return 'wow'
    },
    // blah blah blah
})

那么obj[prop]的返回值就一直是wow,当然Vue没我这么无聊,它使用了自定义getset的方法来实现响应性,我第一次看到Vue的响应性原理时,脑海中浮现出的词就是“劫持”,重写getset“劫持”了赋值和读取操作,当然,论劫持,Proxy更内行,这个暂按下不表。

具体是怎么实现的呢,长话短说,defineReactive方法递归地去“响应化”数据

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
){
  const dep = new Dep()
  // ....
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // ...
      if (Dep.target) {
        dep.depend()
        // ...
      }
    },
    set: function reactiveSetter (newVal) {
      // ...
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

只留下了个人认为最关键的代码:Dep对象实现了发布订阅模式,将互相依赖的数据属性关联起来,dep.depend()实现了依赖收集,dep.notify()实现了派发更新,而上述代码中的observe()则实现了对嵌套结构的递归依赖收集。

更新逻辑

以前知道Vue的组件更新依赖于虚拟DOM(并不是数据一改变就直接去动DOM),但并不是很关心具体的算法实现,正好借此一探究竟,顺便与React的逻辑做个比较。

书接上文,在将对象响应化的过程中有一句dep.notify(),该方法派发更新,通俗说就是通知所有用到这个属性的地方都得更新,而总体而言更新的逻辑很简单,虚拟DOM节点(Vue中即VNode)没改变的留下,改变了的用新节点替换旧节点,是不是听上去很简单?不知内情的人可能看两眼就过了,但刷算法题和写过业务的经验告诉我:这几句话可没说完呢!实际业务中的更新可是乱七八糟的,父组件更新一下,子组件更新一下,你也不知道具体谁会先更新呀,那么什么时候比较虚拟DOM呢?虽然说得简单,“虚拟DOM节点没改变的留下,改变了的用新节点替换旧节点”,但是具体到某一个VNode,怎么样知道改没改变呢?比较的时候怎么样设计出一个能够判断是否VNode改变,又不会太慢的算法呢?

下面从回答这几个问题出发来更深一步地探究Vue组件更新逻辑与Diff算法。

class Watcher {
  // ...
  update () {
    /* istanbul ignore else */
    if (this.computed) {
      // A computed property watcher has two modes: lazy and activated.
      // It initializes as lazy by default, and only becomes activated when
      // it is depended on by at least one subscriber, which is typically
      // another computed property or a component's render function.
      if (this.dep.subs.length === 0) {
        // In lazy mode, we don't want to perform computations until necessary,
        // so we simply mark the watcher as dirty. The actual computation is
        // performed just-in-time in this.evaluate() when the computed property
        // is accessed.
        this.dirty = true
      } else {
        // In activated mode, we want to proactively perform the computation
        // but only notify our subscribers when the value has indeed changed.
        this.getAndInvoke(() => {
          this.dep.notify()
        })
      }
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}  

以上为Watcherupdate()方法,虽然挺长的,但思考之后还是把全部贴出来,英文注释写的挺好,总结一下就是先判断你是否是计算属性,然后数你的订阅者数量,通知你的订阅者,若非计算属性,再判断是否规定了要同步更新(即this.sync,不看源码我还真不知道这个,工作中都没用过),最后如果这些都不满足,执行queueWatcher(this),把更新需求放到一个队列里。

队列的执行时机跟本文探讨的问题联系不大,指路,关键词:nextTick。回归正题,在nextTick执行队列时放生了什么呢?在scheduler.js中有这样一行代码

queue.sort((a, b) => a.id - b.id)

也就是scheduler根据更新任务的id来对更新队列进行排序,优先执行组件树中的较高位置(父组件的创建过程是先于子的,其Watcher的id会更小)。

在没读源码实现之前自己想过一些更新的规划算法,现在看到这个由父到子的更新顺序,感觉还是一种既简单又相对合理的算法,一方面Vue技术揭秘中给出了这样的原因

1.组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。

2.用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。

3.如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。

另一方面,这种实现与Vue现有的VNode设计与diff算法也是耦合的,但这里还是要留个疑问,有没有更好的更新规划算法呢,这样的算法需要搭配怎样的虚拟DOM数据结构呢?

第一个问题结束,知道了Vue怎么安排更新,接下来的关键就是Diff算法了。Diff算法在Vue中通过有名的patch方法来实现,关于patch我好奇的点不多,前面说过,它的主要逻辑是:“虚拟DOM节点(Vue中即VNode)没改变的留下,改变了的用新节点替换旧节点”,其中第一个好奇的点是如何比较两个VNode是否相同,毕竟从JS的角度,你就算写两个长得一样的Object,它也不认为是相等的,你要是递归比较,这个时间复杂度就上天了,完全不可控。

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

直接贴VNode比较方法源码,可以看到该方法比较了key,isComment,tag等等性质,不去干递归比较那事。关于这些VNode的属性网上透彻的描述较少,总结一下就是有名有姓的VNode(你自己写的组件啥的),用key来比较,别的类型的组件比较他们的大致结构。所以总结下来比较VNode是看新VNode与旧VNode是不是同样类型的组件,至于下面挂的子组件乱七八糟一堆就不管了,儿孙自有儿孙福,接下来交给updateChildren啦!

image.png

歇会,来看看这张图,这还是张React的经典图呢,这张图进一步展示了刚才描述的VNode比较方法与策略,也就是那句有名的“只比较同级元素”,其实我光看这句话还有点迷糊,经过了上面的分析,补充一个比方:只比较同级元素是不是同一个人,至于他带的表他背的包一不一样,之后再比。

按套路是不是该聊聊updateChildren了?就不,一是因为我对这个并不好奇,不干扰我对于整个更新过程的把握;二是分析这个的太多了,也很详尽;三是,我累了,关于updateChildren的详解,指路

React 渲染与更新

落笔的时候我突然发现,从来没有文章讲React的响应式原理啊!整理一下思路,发现React不搞数据双向绑定那套,那好,直接来聊聊更新原理和diff算法吧。

image.png 来张小册中的思维导图,可以看到React的diff算法思路和我之前总结的Vue diff算法思路很相似(或者说是Vue与React很相似),同级比较,key比较的思想,具体实施时也

说完diff算法,再来看看React的更新逻辑。这方面跟Vue的差异是比较大的,也接触到了些新的概念。Vue中实现的发布订阅模式,每一个变化的属性都有自己的Watcher去管理,之前的开发中从未花心思去管理优化Vue的更新。React中的多数生命周期函数和Vue很像,不难触类旁通,但shouldComponentUpdate是个陌生的概念,事实上React在调用setState时,就会触发render,但这样的做法跟Vue相比,显得不够细致,Vue是根据依赖去更新的,难怪会有人说“Vue的更新粒度比React更细”,显然在Recat这其中有很多优化的空间。再来看看shouldComponentUpdate,y一个典型例子如下:

shouldComponentUpdate(nextProps, nextState) {
    if (this.props.a !== nextProps.a) {
        return true;
    }
    if (this.state.b !== nextState.b) {
        return true;
    }
    return false;
}

shouldComponentUpdate被称为渲染拦截器,允许在propsstate变化时加入一些自己的逻辑,当return false时,就可以拦截render

image.png

上张经典图,React先检查SCU(shouldComponentUpdate)的返回结果,如果是false,直接复用节点,不再进行Reconciliation 过程。如果返回为true,再走流程比较节点是否相同。

总结

刚和shouldComponentUpdate混熟,就要说拜拜了,全面拥抱函数式组件,下篇学学Hooks

本文正在参与「掘金小册免费学啦!」活动, 点击查看活动详情