《骑鹅历险记》的主角是一个喜欢欺负小动物的小男孩,他因为戏弄小精灵而变成小人,然后意外的骑在一只志向远大的鹅身上,和路过的大雁们周游各地八个月,最后变成了一个讲礼貌的好孩子。小时候想变成小人跟着鹅到处旅行,长大以后成为了制造bug的程序员,随着它们去调用栈、去库和框架的源码里历险。另一种圆梦。
复现
观察下面这段vue代码:
<template>
<div>
<button @click="load">load</button>
<list>
<div class='item' v-for="item in list"></div>
</list>
</div>
</template>
<style lang="scss" scoped>
.item {
//...
}
</style>
复制代码
这个组件实现的功能是:渲染一个列表,点击load按钮,list
字段从[]
切换为[{id: 1}]
。
代码中的List组件非常简单:
<div class="list">
<slot></slot>
<div class="placeholder">placeholder</div>
</div>
复制代码
基于以上的代码,出现的问题是:在点击load
按钮、数据加载进来之后,.item
的样式却并不会生效。
溯源
在了解这个问题出现的原因之前,需要了解一下scopeId
的相关原理[1]:
当 <style>
标签有 scoped
属性时,它的 CSS 只作用于当前组件中的元素。
<template>
<div class="example">hi</div>
</template>
<style scoped>
.example {
color: red;
}
</style>
复制代码
转换结果:
<template>
<div class="example" data-v-f3f3eg9>hi</div>
</template>
<style>
.example[data-v-f3f3eg9] {
color: red;
}
</style>
复制代码
这里的data-v-f3f3eg9
就是scopeId
。其中,给样式增加scopeId
的动作发生在vue-loader
中,给html增加scopeId
的动作在vue
中执行:在解析模板、生成vnode的时候,会给当前组件里对应的元素添加对应的属性值:data-v-scopeId
。
对于给定的具体案例,在点击load按钮前,生成的实际html代码为:
<div>
<button data-v-123>load</button>
<div data-v-456 class="list">
<div class="placeholder">placeholder</div>
</div>
</div>
复制代码
在点击load按钮后,生成的真实html代码为:
<div>
<button data-v-123>load</button>
<div data-v-456 class="list">
<div class="item">1</div>
<div class="placeholder">placeholder</div>
</div>
</div>
复制代码
可以看到,div.item这个元素的scopeId消失了,从而导致携带了scopeId的css代码无法对它生效。
前文中提到了,给html增加scopeId
的动作在vue
中执行:在解析模板、生成vnode的时候,会给当前组件里对应的元素添加对应的属性值。
vnode是真实DOM节点的代理,为了降低DOM操作带来的昂贵性能开销,会先用性能更好的JavaScript计算出真实DOM的最终改动,再将改动应用到真实的DOM上,减少修改更新真实DOM新的次数。这个计算的过程叫做dom diff。
当点击load按钮,vue的双向绑定特性触发dom diff算法,更新div.list
节点进行相应的增删添加操作。
在这个具体的例子中:
旧的子节点列表只有一个vnode
,也就是div.placeholder
对应的vnode
:[vnode1]
新的子节点列表有两个vnode
,也就是div.placeholder
和div.item
:[vnode1,vnode2]
从[vnode1]
变化为[vnode2,vnode1]
,在算法的执行流程中, 将会命中红线所示的流程,进入更新子节点的子流程:
在更新子节点的流程中,vue会判断vnode1和vnode2相同、可以复用,从而将vnode1中保存的真实dom给vnode2使用。即,把div.placeholder
更新为div.item
,但是setScopeId
只会在创建新的element(真实DOM)的时候执行。所以,div.item
的scopeId
在这里丢失了。
解决问题的方法就很简单了:只要不让两个vnode被判定相同从而进行element的复用,就可以生成新的element,从而创建出对应的scopeId。
判断vnode是否相同的代码sameVnode
方法代码如下:
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)
)
)
)
}
复制代码
从这段代码来看,解决问题的方法似乎很简单,设置key值、让sameVnode的返回值为false即可。
在使用v-for
的时候,不设置key值是一个写进了源码警告的操作,以上的代码似乎有生搬硬造之嫌。但是还有一种情况, 在设置了key值之后,scopeId也会消失:
观察以下代码:
<template>
<div>
<button @click="load">load</button>
<list>
<van-cell class='item' v-for="item in item.list" :key="item.id">
{{ item.id }}
</div>
</list>
</div>
</template>
复制代码
van-cell
是有赞开源的组件库vant
中的一个函数式组件。
如果创建的是类组件,vue会直接创建vnode,把data中的属性(包括key值)放到vnode中;如果创建的是函数式组件,vue会使用组件自己提供的渲染函数。
而van-cell
这个函数式组件提供的渲染函数并不接收key属性,也就是van-cell
创建的vnode的key为undefined。这时候,sameVnode
的返回值还是true,依然会进行element的复用。
发散
react也有自己的dom diff算法,在react中编写同样功能的代码,是否会出现同样的问题呢?观察以下代码:
const List = ({ list }: { list: number[] }) => {
return (
<div>
{list.map((item) => {
return <Cell item={item}></Cell>;
})}
<div>placeholder</div>
</div>
);
};
const Cell = ({ item }: { item: number }) => {
return <div className={s.item}>{item}</div>;
};
复制代码
react判断是否可复用的标准是:key是否相同,type是否相同。在这个例子里,似乎也会因为div.item
和div.placeholder
没有key且type相同而错误将div.placeholer
用于div.item
的复用。
但是,react从jsx生成fiber node的时候,map操作会隐式生成一个fragment
元素,因此,在点击load按钮之前和点击load按钮之后,div.list
对应的fiber node的children均为[fragment, div.placeholder]
,区别在于点击load之后,fragment
的children从[]
变为了[div.item]
。
因此,即使没有设置key,react依然能准确的生成新的div.item
对应的fiber node。
总结
vue把模版编译成vnode,然后作用于真实DOM。react把jsx代码编译成fiber node,然后作用于真实DOM。看上去似乎没多大区别,就像披萨不过是大饼上撒了肉和蔬菜。vnode和fiber在结构、设计上的不同,vue和react在整体流程上设计和架构的不同,在实际应用中的表现也会截然不同。
(注:只从实际开发中遇到的一个问题做一个发散式的探索,不对两个框架做具体评价,两者各有优势,应当根据具体情况具体选择)
参考文献