React 为什么将 Effects list 重构为 SubtreeFlags 的原因
1 views
1.前言
在 React 的 completeWork 流程中大体包含两个流程步骤:
- 创建或者标记元素更新。
- flags冒泡
2.什么是副作用
简易的React工作原理可以概括为:
- 触发
更新 - render阶段:计算
更新会造成的副作用 - commit阶段:执行
副作用
副作用包含很多类型,比如:
Placement指DOM节点的插入与移动Passive指useEffect回调执行ChildDeletion指移除子DOM节点- 等等
更新造成 DOM 变化主要就是Placement、ChildDeletion在起作用。
那么render阶段如何保存副作用,commit阶段又是如何使用副作用的呢?
3.Flags 冒泡
当更新流程经过 Reconciler 后,会得到一棵 WipFIberTree,其中部分 fiberNode 被标记 flags。Renderer 需要对 ”被标记的 fiberNode 对应的DOM 元素“ 执行 ”flags 对应的 DOM 操作“。那么如何高效的找到这些散落在 WipFiberTree 各处的”被标记的 fiberNode “呢?这就是 flags 冒泡作用。
v16 Effects List
在 React v.16 中使用一种被称为 Effects list 的链表结构存储”被标记副作用的 fiberNode “。在 completeWork 中, 如果 fiberNode 存在副作用,就会被插入Effects list 中,commit 阶段的三个子阶段只需要遍历 Effects list 并对 fiberNode 执行 “flags 对应的操作”。
比如下图,B、C、E存在副作用,连接形成Effects List:

commit阶段不需要从A向下遍历整棵树,只需要遍历Effects List就能找到所有有副作用的节点并执行对应操作。
v18 SubtreeFlags
在React v18中则是在 completeWork 的 ”归“阶段,从叶子元素开始,由下而上开始递归,fiberNode.subtreeFlags 记录了该 fiberNode 的 ”所有子孙 fiberNode 上被标记的 flags“。
每个 fiberNode 经由此操作,可以将子孙 fiberNode 中 ”标记的 flags “ 像上冒泡一层,直到RootFiber。
比如B、C、E包含的副作用如下图:

冒泡流程如下:
- B的
副作用为Passive,冒泡到A,A.SubtreeFlags包含Passive - E的
副作用为Placement,冒泡到D,D.SubtreeFlags包含Placement - D冒泡到C,
C.SubtreeFlags包含Placement - C的
副作用为Update,C.SubtreeFlags包含Placement,C冒泡到A - 最终
A.SubtreeFlags包含Passive、Placement、Update
这就代表A的子树中包含这三种副作用。
在commit阶段,再根据SubtreeFlags一层层查找有副作用的节点并执行对应操作。
可见,
SubtreeFlags需要遍历树,而Effects List只需要遍历链表,效率更高。那么React为什么要重构呢?
4.Suspense
答案是:SubtreeFlags 遍历子树的操作虽然比 Effects List 需要遍历更多节点,但是React18中一种新特性恰恰需要 「遍历子树」。
这个特性就是Suspense。
Suspense是v16就提供的功能,但v18之后,当开启并发功能,Suspense与之前版本的行为是有区别的。
考虑如下组件:
<Suspense fallback={<h3>loading...</h3>}>
<LazyCpn />
<Sibling /></Suspense>其中LazyCpn是使用React.lazy包裹的异步加载组件。
Sibling代码如下:
function Sibling() { useEffect(() => { console.log('Sibling effect'); }, []);
return <h1>Sibling</h1>;}由于Suspense会等待子孙组件中的异步请求完毕后再渲染,所以当代码运行时页面首先会渲染fallback:
<h3>loading...</h3>但是Sibling并不是异步的!这里就体现了新旧版本React的差异。
5.新旧版React的差异
在开启并发之前,React保证一次render阶段对应一次commit阶段。
所以在上例中,虽然由于LazyCpn在请求导致Suspense渲染fallback,但是并不会阻止Sibling渲染,也不会阻止Sibling中useEffect的执行。
控制台还是会打印 「Sibling effect」。
同时,为了在视觉上显得Sibling没有渲染,Sibling渲染的DOM节点会被设置display: none:

但这其实挺hack的。毕竟根据Suspense的理念,如果子孙组件有异步加载的内容,那应该只渲染fallback(而不是同时渲染display: none的内容)
所以在新版中,针对Suspense内 「不显示的子树」 做了单独的处理,既不会渲染display: none的内容,也不会执行useEffect回调:

要实现这部分处理的基础,就是改变commit阶段遍历的方式,也就回到开篇提到的Effects List重构为subtreeFlags。
总结
React 从 effectList 替换为 subtreeFlags 的主要原因是为了优化性能和提高灵活性,特别是在实现 Suspense 特性方面。
参考资料
React Effects List大重构,是为了他?