Open laochake opened 3 years ago
从React的Doc上可以看到React的理念是:
React is, in our opinion, the premier way to build big, fast Web apps with JavaScript.
但是我们有时候一个很长很深的DOM列表(在没有做列表优化的前提下),setState创建更新后,React会进行对比创建前和创建后的节点(Reconcilation阶段),对比的过程是不可中断的, 由于网页的主线程不仅包含了js执行,样式计算, 还包含了渲染需要的重排重绘,也就是当Reconcilation(js执行任务)执行很久的时候,当前的任务在主线程占用时间过多,就会影响浏览器正常的重排/重绘,也会影响正常的用户交互(输入,点击,选择等等)。
setState
Reconcilation
js
样式计算
重排重绘
重排
重绘
举个比较极端的例子,我们有个很深的列表(1500层),而且变化频繁:
function App() { const [randomArray, setRandomArray] = useState( Array.from({ length: 1500 }, () => Math.random()) ); useEffect(() => { changeRandom() }, []); const changeRandom = () => { setRandomArray(randomSort(randomArray)); cancelAnimationFrame(raf); raf = requestAnimationFrame(changeRandom); }; const finalList = randomArray.reduce((acc, cur) => { acc = ( <div key={cur} style={{ color: randomColor() }}> {cur} {acc} </div> ); return acc; }, <div></div>); return ( <div> <section>{finalList}</section> </div> ); }
从performance面板看,也是changeRandom所触发的整个js执行任务占用了161ms
performance
changeRandom
js执行任务
161ms
从事件循环看,changeRandom函数执行setState进入reconcilation阶段,但是由于列表层次太深,整个过程又是不可中断的,所以耗时多阻碍了其他任务包括键盘输出,样式计算, 重排, 重绘等:
reconcilation
键盘输出
从浏览器的一帧来看,当上述的reconcilationtask阻塞了太久,导致正常刷新率情况下的每帧16.6ms下没有更新视图,造成掉帧的问题
所以基于React的理念,为了解决上述的问题,实现reconcilation过程可中断,当然包括其他比如Concurrent Mode的那些试验性的feature, 以及优先级调度相关的东西, React决定使用Fiber重写底层实现
React
Concurrent Mode
Fiber
还是上述的代码,我们随便找一个渲染出来的DOM元素
右健审查 | 在对应的DOM标签中右键store as global variable | 切换控制台到console | 输入temp1.__reactInternalInstance$mszvvg3x40p(后面会有智能提示)
即可看到当前节点对应的Fiber信息
列出主要的几个Fiber数据结构:
export const FunctionComponent = 0; // FC对应的Fiber节点 export const ClassComponent = 1; export const IndeterminateComponent = 2; // Before we know whether it is function or class export const HostRoot = 3; // 根Fiber export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer. export const HostComponent = 5; // DOM文档的节点对应Fiber 如div,section... export const HostText = 6; // 文本节点 export const Fragment = 7; export const Mode = 8; export const ContextConsumer = 9; export const ContextProvider = 10; export const ForwardRef = 11; export const Profiler = 12; export const SuspenseComponent = 13; export const MemoComponent = 14; export const SimpleMemoComponent = 15; export const LazyComponent = 16; export const IncompleteClassComponent = 17; export const DehydratedSuspenseComponent = 18; export const EventComponent = 19; export const EventTarget = 20; export const SuspenseListComponent = 21;
current.alternate = workInProgress workInProgress.alternate = current
demo中构建的Fiber树是这样的:
interruptible
beginWork
completeWork
document.createElement
commit
append
Scheduler: 调度模块,调度render/reconcilation阶段的任务,将任务分为5ms一个,可中断
render/reconcilation
启动Fiber时间分片功能需要开启Concurrent Mode模式,也就是说我们平时开发中默认用的ReactDOM.render,虽然用了Fiber,但其实没有用到时间分片。
ReactDOM.render
开启Concurrent Mode只需要两个步骤:
npm install react@experimental react-dom@experimental
ReactDOM.createRoot
FiberRoot
render
ReactDOM.createRoot(rootNode).render(<App />)
开启完毕。
当然除了我们平常用的ReactDOM.render的Legacy Mode以及Concurrent Mode, React还出了一个Blocking Mode,其实就是拥有部分Concurrent Mode功能的一个中间版本,创建方式是:ReactDOM.createBlockingRoot(rootNode).render(<App />) 三种模式的对比: 可以看到在ConcurrentMode下,开始有了SuspenseList,可以控制Suspense组件的一个顺序,也支持了优先级渲染,中断预渲染等,还有一些新的hook, 比如用useTransition搭配Suspense可以用来做加载的优化,useDefferredValue来做一些state值的缓存,对于某些优先级不是很高但是又很耗时间的更新,可以不用立即更新,而是获取deffer延迟的state等等,但是这些还是还是在试用包里面,可能随时会改,所以就不细说,感兴趣的可以看下: Suspense for Data Fetching
Legacy Mode
Blocking Mode
ReactDOM.createBlockingRoot(rootNode).render(<App />)
hook
useTransition
Suspense
useDefferredValue
state
deffer
我们回到最开始的demo, 我们对比下开启Concurrent Mode(也就是开启分片)前后的performance面板对比: 开启分片前: 可以看到主线程上的每次更新都是由changeRandom发起然后再进行reconcilation阶段和commit阶段,整个方法包含在一个Task里面:
Task
开启分片后: 开启分片后,可以看到render/reconcilation阶段分成了很多个任务,有很多个Task都是5ms的任务
5ms
这时候我们加一个输入框,来测试是否用户体验提升很多,是否reconcilation分片真的这么强。
于是加个输入框,并且不要让输入框影响随机的div深列表,所以我们把List单独抽出来,并且用React.memo包起来,这样输入框的setState并不会引发List的重渲染:
div
List
React.memo
外层:
function App() { const [filterText, setFilterText] = useState(''); return ( <div> <input value={filterText} onChange={(e) => setFilterText(e.target.value)} /> <button>按钮</button> <section> <List /> </section> </div> ); }
随机List:
const List = React.memo(function List (props) { const [randomArray, setRandomArray] = useState( Array.from({ length: 1500 }, () => Math.random()) ); useEffect(() => { changeRandom() }, []); const changeRandom = () => { setRandomArray(randomSort(randomArray)); cancelAnimationFrame(raf); raf = requestAnimationFrame(changeRandom); }; const finalList = randomArray.reduce((acc, cur) => { acc = ( <span key={Math.random()} style={{ color: randomColor() }}> {cur} {acc} </span> ); return acc; }, <span></span>); return <div>{finalList}</div> })
在Legacy模式下,可以看到输入框的输入有点卡顿,主要是整个render/reconcilation任务占用了太多时间导致 然后我们开启Concurrent Mode,发现还是被阻塞了,还是会有一点卡,看performance发现主要是被commit和layout/paint两个流程卡住了,
Legacy
layout/paint
当然当输入也没有刚好卡在render/reconcilation的分片当中,是不会被render/reconcilation本身阻塞的,所以可以总结: render/reconcilation的分片以及达到了效果,在分片的间隔时间已经可以去插入执行其他优先级更高的用户相应了。 但是,为了更好的演示分片带来的效果,我决定排除commit流程和Layout/Paint重排重绘带来的影响。
Layout/Paint
抛开重排重绘带来的影响: 直接给List组件外层套一个style={{ display: 'none' }}, 这样render/reconcilation阶段完成之后就不会进行重排重绘了,但是生成的Fiber还是会生成,只不过最后commit到DOM上也不会渲染
style={{ display: 'none' }}
<section style={{ display: 'none' }}> <List /> </section>
为了效果更明显,我直接拷贝多了两个List, 并且每个List增加到3000条,
const List = React.memo(function List (props) { const [randomArray, setRandomArray] = useState( Array.from({ length: 3000 }, () => Math.random()) ); useEffect(() => { changeRandom() }, []); const changeRandom = () => { setRandomArray(randomSort(randomArray)); cancelAnimationFrame(raf); raf = requestAnimationFrame(changeRandom); }; const finalList = randomArray.reduce((acc, cur) => { acc = ( <span key={Math.random()} style={{ color: randomColor() }}> {cur} {acc} </span> ); return acc; }, <span></span>); return <div>{finalList}{finalList1}{finalList2}</div>
})
这样, 在`Legacy`模式下, 我们看到performance里面,已经没有`Layout/Paint`这样的任务来阻碍我们的输入了,只剩下`commit`,现在加大List数量的情况下,`render/reconcilation`大概阻塞了500多ms, 很卡。 ![](http://m.gggalen.club/blog-be/public/images/1610290514491.png) 这时候再看`Concurrent Mode`,很流畅,commitRoot直接没有了(也算是Concurrent Mode一个优化吧,对于`display:none`来说,本身commit就没有必要) ![](http://m.gggalen.club/blog-be/public/images/1610291490967.png) #### 总结及Concurrent Mode的其他Features 当然我们举了很夸张的例子(深节点,移除重排重绘)来单独看`Concurrent Mode`模式下对于`render/reconcilation`带来的优化效果。当然分片只是Fiber的一小部分功能,`Fiber`架构解锁了很多`Concurrent Mode`的新功能: `<SuspenseList>`, `useTransition`, `useDeferredValue`等等,当然这些暂时是试用性的。 在我们的demo中,可以使用`useDeferredValue`来做state的延迟,比如我们轮训获取到了实时的长列表,但是又不想阻塞输入框等用户的操作,我们可以将旧的`state`用`useDeferredValue`暂时存起来,然后将旧版的state传给带memo的组件,这时候我们通过降低了列表的时效性来换取了用户交互体验的提升,而且我们原state永远是最新的,所以跟增大轮询时间又不太一样。 总之,`Concurrent Mode`解锁了很多新的功能,当然有些是试用性的,但是可以期待当`Concurrent Mode`正式使用的时候,新特性给性能和用户体验带来的提升。
Demo 地址有么
你好,请问有完整的demo源码么?最近也在研究 react 分片这块,模拟 demo 有点问题,感觉博主写的挺好的,想参考一下。感谢!
谈谈React Fiber与分片
React的理念和Fiber的出现
从React的Doc上可以看到React的理念是:
但是我们有时候一个很长很深的DOM列表(在没有做列表优化的前提下),
setState
创建更新后,React会进行对比创建前和创建后的节点(Reconcilation
阶段),对比的过程是不可中断的, 由于网页的主线程不仅包含了js
执行,样式计算
, 还包含了渲染需要的重排重绘
,也就是当Reconcilation
(js执行任务)执行很久的时候,当前的任务在主线程占用时间过多,就会影响浏览器正常的重排
/重绘
,也会影响正常的用户交互(输入,点击,选择等等)。举个比较极端的例子,我们有个很深的列表(1500层),而且变化频繁:
从
performance
面板看,也是changeRandom
所触发的整个js执行任务
占用了161ms
从事件循环看,
changeRandom
函数执行setState
进入reconcilation
阶段,但是由于列表层次太深,整个过程又是不可中断的,所以耗时多阻碍了其他任务包括键盘输出
,样式计算
,重排
,重绘
等:从浏览器的一帧来看,当上述的
reconcilation
task阻塞了太久,导致正常刷新率情况下的每帧16.6ms下没有更新视图,造成掉帧的问题所以基于
React
的理念,为了解决上述的问题,实现reconcilation
过程可中断,当然包括其他比如Concurrent Mode
的那些试验性的feature, 以及优先级调度相关的东西, React决定使用Fiber
重写底层实现Fiber的数据结构和Fiber树的构建
还是上述的代码,我们随便找一个渲染出来的DOM元素
即可看到当前节点对应的Fiber信息
列出主要的几个Fiber数据结构:
demo中构建的Fiber树是这样的:
React渲染的两个流程
interruptible
)beginWork
主要的作用是创建初始化和更新的当前Fiber节点的子Fiber节点,并且返回当前Fiber的第一个子节点去开始下一次performUnitOfWorkcompleteWork
主要是创建Fiber.stateNode的过程,即根据beginWork
生成的新Fiber调用document.createElement
去创建DOM节点存储在Fiber.stateNode中,再在commit
流程的时候去append
到真实的DOM中Scheduler: 调度模块,调度
render/reconcilation
阶段的任务,将任务分为5ms一个,可中断开启Concurrent Mode以及分片
启动Fiber时间分片功能需要开启Concurrent Mode模式,也就是说我们平时开发中默认用的
ReactDOM.render
,虽然用了Fiber,但其实没有用到时间分片。开启Concurrent Mode只需要两个步骤:
ReactDOM.createRoot
创建一个FiberRoot
再render
替代ReactDOM.render
开启完毕。
当然除了我们平常用的
ReactDOM.render
的Legacy Mode
以及Concurrent Mode
, React还出了一个Blocking Mode
,其实就是拥有部分Concurrent Mode
功能的一个中间版本,创建方式是:ReactDOM.createBlockingRoot(rootNode).render(<App />)
三种模式的对比: 可以看到在ConcurrentMode下,开始有了SuspenseList,可以控制Suspense组件的一个顺序,也支持了优先级渲染,中断预渲染等,还有一些新的hook
, 比如用useTransition
搭配Suspense
可以用来做加载的优化,useDefferredValue
来做一些state
值的缓存,对于某些优先级不是很高但是又很耗时间的更新,可以不用立即更新,而是获取deffer
延迟的state等等,但是这些还是还是在试用包里面,可能随时会改,所以就不细说,感兴趣的可以看下: Suspense for Data Fetching开启分片后性能和用户体验对比(concurrent mode vs legacy mode)
我们回到最开始的demo, 我们对比下开启
Concurrent Mode
(也就是开启分片)前后的performance
面板对比: 开启分片前: 可以看到主线程上的每次更新都是由changeRandom
发起然后再进行reconcilation
阶段和commit
阶段,整个方法包含在一个Task
里面:开启分片后: 开启分片后,可以看到
render/reconcilation
阶段分成了很多个任务,有很多个Task
都是5ms
的任务这时候我们加一个输入框,来测试是否用户体验提升很多,是否
reconcilation
分片真的这么强。于是加个输入框,并且不要让输入框影响随机的
div
深列表,所以我们把List
单独抽出来,并且用React.memo
包起来,这样输入框的setState
并不会引发List
的重渲染:外层:
随机List:
在
Legacy
模式下,可以看到输入框的输入有点卡顿,主要是整个render/reconcilation
任务占用了太多时间导致 然后我们开启Concurrent Mode
,发现还是被阻塞了,还是会有一点卡,看performance
发现主要是被commit
和layout/paint
两个流程卡住了,当然当输入也没有刚好卡在
render/reconcilation
的分片当中,是不会被render/reconcilation
本身阻塞的,所以可以总结:render/reconcilation
的分片以及达到了效果,在分片的间隔时间已经可以去插入执行其他优先级更高的用户相应了。 但是,为了更好的演示分片带来的效果,我决定排除commit
流程和Layout/Paint
重排重绘带来的影响。抛开Layout/Paint流程和commit流程来看分片带来的performance优化
抛开重排重绘带来的影响: 直接给List组件外层套一个
style={{ display: 'none' }}
, 这样render/reconcilation
阶段完成之后就不会进行重排重绘了,但是生成的Fiber
还是会生成,只不过最后commit到DOM上也不会渲染为了效果更明显,我直接拷贝多了两个List, 并且每个List增加到3000条,
})