写在前面
本文是阅读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实现响应性的基本方法就是调用JS的Object.defineProperty方法
Object.defineProperty(obj, prop, {
enumerable: true,
configurable: true,
get:yourGetter,
set:yourSetter,
// blah blah blah
})
当时刚接触JS的时候想这玩意干啥用?画蛇添足,我直接一个obj[prop] = val不解决了?后来发现自己还是草率了。
这里的重点是get与set,说得简单点就是get在调用obj[prop]或obj.prop时起效,set在调用obj[prop] = val时起效,一般来说obj.prop返回Object的指定属性,obj[prop] = val修改某个属性,但指定了get与set之后,你可以让Object的赋值与访问返回任意你想要的结果,比如你要是这样写:
Object.defineProperty(myObj, prop, {
get:function myGetter(){
return 'wow'
},
// blah blah blah
})
那么obj[prop]的返回值就一直是wow,当然Vue没我这么无聊,它使用了自定义get与set的方法来实现响应性,我第一次看到Vue的响应性原理时,脑海中浮现出的词就是“劫持”,重写get与set“劫持”了赋值和读取操作,当然,论劫持,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)
}
}
}
以上为Watcher类update()方法,虽然挺长的,但思考之后还是把全部贴出来,英文注释写的挺好,总结一下就是先判断你是否是计算属性,然后数你的订阅者数量,通知你的订阅者,若非计算属性,再判断是否规定了要同步更新(即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啦!
歇会,来看看这张图,这还是张React的经典图呢,这张图进一步展示了刚才描述的VNode比较方法与策略,也就是那句有名的“只比较同级元素”,其实我光看这句话还有点迷糊,经过了上面的分析,补充一个比方:只比较同级元素是不是同一个人,至于他带的表他背的包一不一样,之后再比。
按套路是不是该聊聊updateChildren了?就不,一是因为我对这个并不好奇,不干扰我对于整个更新过程的把握;二是分析这个的太多了,也很详尽;三是,我累了,关于updateChildren的详解,指路
React 渲染与更新
落笔的时候我突然发现,从来没有文章讲React的响应式原理啊!整理一下思路,发现React不搞数据双向绑定那套,那好,直接来聊聊更新原理和diff算法吧。
来张小册中的思维导图,可以看到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被称为渲染拦截器,允许在props与state变化时加入一些自己的逻辑,当return false时,就可以拦截render
上张经典图,React先检查SCU(shouldComponentUpdate)的返回结果,如果是false,直接复用节点,不再进行Reconciliation 过程。如果返回为true,再走流程比较节点是否相同。
总结
刚和shouldComponentUpdate混熟,就要说拜拜了,全面拥抱函数式组件,下篇学学Hooks
本文正在参与「掘金小册免费学啦!」活动, 点击查看活动详情 。