熟悉React的朋友都知道,React支持jsx语法,我们可以直接将HTML代码写到JS中间,然后渲染到页面上,我们写的HTML如果有更新的话,React还有虚拟DOM的对比,只更新变化的部分,而不重新渲染整个页面,大大提高渲染效率。到了16.x,React更是使用了一个被称为Fiber
的架构,提升了用户体验,同时还引入了hooks
等特性。那隐藏在React背后的原理是怎样的呢,Fiber
和hooks
又是怎么实现的呢?本文会从jsx
入手,手写一个简易版的React,从而深入理解React的原理。
本文主要实现了这些功能:
简易版Fiber架构
简易版DIFF算法
简易版函数组件
简易版Hook:
useState
娱乐版
Class
组件
本文代码地址:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/fiber-and-hooks
本文程序跑起来效果如下:
JSX和creatElement
以前我们写React要支持JSX还需要一个库叫JSXTransformer.js
,后来JSX的转换工作都集成到了babel里面了,babel还提供了在线预览的功能,可以看到转换后的效果,比如下面这段简单的代码:
const App =
(
<div>
<h1 id="title">Title</h1>
<a href="xxx">Jump</a>
<section>
<p>
Article
</p>
</section>
</div>
);
经过babel转换后就变成了这样:
上面的截图可以看出我们写的HTML被转换成了React.createElement
,我们将上面代码稍微格式化来看下:
var App = React.createElement(
'div',
null,
React.createElement(
'h1',
{
id: 'title',
},
'Title',
),
React.createElement(
'a',
{
href: 'xxx',
},
'Jump',
),
React.createElement(
'section',
null,
React.createElement('p', null, 'Article'),
),
);
从转换后的代码我们可以看出React.createElement
支持多个参数:
- type,也就是节点类型
- config, 这是节点上的属性,比如
id
和href
- children, 从第三个参数开始就全部是children也就是子元素了,子元素可以有多个,类型可以是简单的文本,也可以还是
React.createElement
,如果是React.createElement
,其实就是子节点了,子节点下面还可以有子节点。这样就用React.createElement
的嵌套关系实现了HTML节点的树形结构。
让我们来完整看下这个简单的React页面代码:
渲染在页面上是这样:
这里面用到了React的地方其实就两个,一个是JSX,也就是React.createElement
,另一个就是ReactDOM.render
,所以我们手写的第一个目标就有了,就是createElement
和render
这两个方法。
手写createElement
对于<h1 id="title">Title</h1>
这样一个简单的节点,原生DOM也会附加一大堆属性和方法在上面,所以我们在createElement
的时候最好能将它转换为一种比较简单的数据结构,只包含我们需要的元素,比如这样:
{
type: 'h1',
props: {
id: 'title',
children: 'Title'
}
}
有了这个数据结构后,我们对于DOM的操作其实可以转化为对这个数据结构的操作,新老DOM的对比其实也可以转化为这个数据结构的对比,这样我们就不需要每次操作都去渲染页面,而是等到需要渲染的时候才将这个数据结构渲染到页面上。这其实就是虚拟DOM!而我们createElement
就是负责来构建这个虚拟DOM的方法,下面我们来实现下:
function createElement(type, props, ...children) {
// 核心逻辑不复杂,将参数都塞到一个对象上返回就行
// children也要放到props里面去,这样我们在组件里面就能通过this.props.children拿到子元素
return {
type,
props: {
...props,
children
}
}
}
上述代码是React的createElement
简化版,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/60016c448bb7d19fc989acd05dda5aca2e124381/packages/react/src/ReactElement.js#L348
手写render
上述代码我们用createElement
将JSX代码转换成了虚拟DOM,那真正将它渲染到页面的函数是render
,所以我们还需要实现下这个方法,通过我们一般的用法ReactDOM.render( <App />,document.getElementById('root'));
可以知道他接收两个参数:
- 根组件,其实是一个JSX组件,也就是一个
createElement
返回的虚拟DOM- 父节点,也就是我们要将这个虚拟DOM渲染的位置
有了这两个参数,我们来实现下render
方法:
function render(vDom, container) {
let dom;
// 检查当前节点是文本还是对象
if(typeof vDom !== 'object') {
dom = document.createTextNode(vDom)
} else {
dom = document.createElement(vDom.type);
}
// 将vDom上除了children外的属性都挂载到真正的DOM上去
if(vDom.props) {
Object.keys(vDom.props)
.filter(key => key != 'children')
.forEach(item => {
dom[item] = vDom.props[item];
})
}
// 如果还有子元素,递归调用
if(vDom.props && vDom.props.children && vDom.props.children.length) {
vDom.props.children.forEach(child => render(child, dom));
}
container.appendChild(dom);
}
上述代码是简化版的render
方法,对源码感兴趣的朋友可以看这里:https://github.com/facebook/react/blob/3e94bce765d355d74f6a60feb4addb6d196e3482/packages/react-dom/src/client/ReactDOMLegacy.js#L287
现在我们可以用自己写的createElement
和render
来替换原生的方法了:
可以得到一样的渲染结果:
为什么需要Fiber
上面我们简单的实现了虚拟DOM渲染到页面上的代码,这部分工作被React官方称为renderer,renderer是第三方可以自己实现的一个模块,还有个核心模块叫做reconsiler,reconsiler的一大功能就是大家熟知的diff,他会计算出应该更新哪些页面节点,然后将需要更新的节点虚拟DOM传递给renderer,renderer负责将这些节点渲染到页面上。但是这个流程有个问题,虽然React的diff算法是经过优化的,但是他却是同步的,renderer负责操作DOM的appendChild
等API也是同步的,也就是说如果有大量节点需要更新,JS线程的运行时间可能会比较长,在这段时间浏览器是不会响应其他事件的,因为JS线程和GUI线程是互斥的,JS运行时页面就不会响应,这个时间太长了,用户就可能看到卡顿,特别是动画的卡顿会很明显。在React的官方演讲中有个例子,可以很明显的看到这种同步计算造成的卡顿:
而Fiber就是用来解决这个问题的,Fiber可以将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其他事件,等他空了再回来继续计算,这样整个计算流程就显得平滑很多。下面是使用Fiber后的效果:
怎么来拆分
上面我们自己实现的render
方法直接递归遍历了整个vDom树,如果我们在中途某一步停下来,下次再调用时其实并不知道上次在哪里停下来的,不知道从哪里开始,即使你将上次的结束节点记下来了,你也不知道下一个该执行哪个,所以vDom的树形结构并不满足中途暂停,下次继续的需求,需要改造数据结构。另一个需要解决的问题是,拆分下来的小任务什么时候执行?我们的目的是让用户有更流畅的体验,所以我们最好不要阻塞高优先级的任务,比如用户输入,动画之类,等他们执行完了我们再计算。那我怎么知道现在有没有高优先级任务,浏览器是不是空闲呢?总结下来,Fiber要想达到目的,需要解决两个问题:
- 新的任务调度,有高优先级任务的时候将浏览器让出来,等浏览器空了再继续执行
- 新的数据结构,可以随时中断,下次进来可以接着执行
requestIdleCallback
requestIdleCallback
是一个实验中的新API,这个API调用方式如下:
// 开启调用
var handle = window.requestIdleCallback(callback[, options])
// 结束调用
Window.cancelIdleCallback(handle)
requestIdleCallback
接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个IdleDeadline
,可以拿到当前还空余多久,options
可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了。使用这个API可以解决任务调度的问题,让浏览器在空闲时才计算diff并渲染。更多关于requestIdleCallback的使用可以查看MDN的文档。但是这个API还在实验中,兼容性不好,所以React官方自己实现了一套。本文会继续使用requestIdleCallback
来进行任务调度,我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback
里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行,代码架子就是这样:
function workLoop(deadline) {
</