Build-Your-Own-React
项目学习地址:pomb.us/build-your-… 跟着作者一步一步地实现了一个react mini版,这篇文章是对前5个步骤的总结(倒不如说是翻译😂),前5个步骤是整个项目最简单的部分,到了第6步就开始变难了,想要完全整明白其中的思想还得花点时间专研一下。因此这次只总结了前5个步骤,到第5步已经能模拟出react的整个渲染页面的过程了,第6步开始讲的是虚拟dom的更新,函数组件的实现和钩子的原理。
1.react的原理复习
1.jsx的基本原理
const element = <h1 title="foo">Hello</h1>
上面的代码经过babel转换后:
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
creatElement创建出以下对象
const element = {
type:'h1',
props:{
title:'foo',
children:'Hello'
}
}
- type:可以是HTMLelement元素,也可以是一个函数
- props:存放写在元素中的属性,行内属性和子元素
- children:元素这里是一个字符串,但是它也可以是一个数组,存放要创建元素的子元素
2.ReactDOM.render的原理
const element = {
type:'h1',
props:{
title:'foo',
children:'Hello'
}
}
const container = document.getElementById('root')
//通过元素的type创建dom节点
const node = document.createElement(element.type)
//然后我们将所有元素props分配给该节点。这里只有title属性
node['title'] = element.props.title
// 创建文本节点
//作者:Using textNode instead of setting innerText will allow us to treat all elements in the same way later.
const text = document.createTextNode('')
text['nodeValue'] = element.props.children
//将创建好的文本节点放入元素
node.appendChild(text)
//将创建好的元素节点放入root
container.appendChild(node)
2.步骤一:实现creatElement
1.createElement:
function createElement(type,props,...children){
return {
type,
props:{
...props,
children
}
}
}
// 使用例子:
createElement('div',null,a)
{
type:'div',
props:{children:[a]}
}
这里使用拓展运算符来平铺props参数,且让传入的children参数成为一个数组
2.对于children的处理:
children可以是一个对象类型也可以是文本,对于这些文本,使用一个createTextElement函数来将他们包装成一个对象,并为这个对象的type设置一个标识:'TEXT_ELEMENT'
function createElement(type,props,...children){
return {
type,
props:{
...props,
children:children.map(child=>typeof child==='object'?child:createElement(child))
}
}
}
function createTextElement(text){
return {
type:'TEXT_ELEMENT',
props:{
nodeValue:text,
children:[]
}
}
}
作者注:
React doesn’t wrap primitive values or create empty arrays when there aren’t children, but we do it because it will simplify our code, and for our library we prefer simple code than performant code.
当没有子代时,React 不会包装基本值或创建空数组,但我们这样做是因为它将简化代码,对于我们的库,我们更喜欢简单的代码而不是性能代码。
作者将这个库命名为Didact:
const Didact = {
createElement
}
const element = Didact.createElement(
'div',
{id:'foo'},
Didact.createElement('a',null,'bar'),
Didact.createElement('b')
)
3.使用babel进行编译:
安装babel和babel编译jsx的插件,添加上如下注释即可编译element,也可以到babel官网翻译
/** @jsx Didact.createElement */
const element = (
<div id='foo'>
<a>bar</a>
<b/>
</div>
)
我的编译结果:
/** @jsx Didact.createElement */
const element = Didact.createElement("div", {
id: "foo"
}, Didact.createElement("a", null, "bar"), Didact.createElement("b", null));
3.步骤二:实现render
1.先只考虑添加节点的功能,使用创建好的element对象生成dom元素,并放入container容器中
function render(element,container){
// 创建dom节点
const dom = document.createElement(element.type);
container.appendChild(dom)
}
2.递归地对元素中的子元素做同样的事情:
function render(element,container){
// 创建dom节点
...
// 递归地为element的子元素做同样的事情
element.props.children.forEach(child=>{
render(child,dom)
})
...
}
3.要记得处理文本元素,如果element.type==='TEXT_ELEMNT',就要创建一个文本节点而不是一个常规的dom节点
function render(element,container){
// 创建dom节点
const dom = element.type==='TEXT_ELEMENT'
?document.createTextNode('')
:document.createElement(element.type)
// 递归地为element的子元素做同样的事情
element.props.children.forEach(child=>{
render(child,dom)
})
...
}
4.为创建好的元素元素添加上属性
function render(element,container){
// 创建dom节点,需要判断是否为文本节点
...
// 为元素添加上属性,
const isProperty = key => key!=='children';
Object.keys(element.props) //获取element.props的键值
.filter(isProperty) //使用isProperty来过滤掉children属性
.forEach(name=>{ //遍历过滤后的属性,为创建好的dom添加上这些属性
dom[name] = element.props[name]
})
// 递归地为element的子元素做同样的事情
...
container.appendChild(dom)
}
到这一步,这个库就有了将jsx语法渲染成dom的能力:
完整代码:
const container = document.getElementById('root');
const Didact = {
createElement,
render
}
/** @jsx Didact.createElement */
const element = (
<div id='foo'>
<a href="#">连接</a>
<div className="div2">
<ul>
<li>1.a</li>
<li>2.b</li>
<li>3.c</li>
</ul>
</div>
</div>
)
Didact.render(element,container)
function createElement(type,props,...children){
return {
type,
props:{
...props,
children:children.map(child=>typeof child==='object'?child:createTextElement(child))
}
}
}
// render函数
function render(element,container){
// 创建dom节点,需要判断是否为文本节点
const dom = element.type==='TEXT_ELEMENT'
?document.createTextNode('')
:document.createElement(element.type)
// 为元素添加上属性,
const isProperty = key => key!=='children';
Object.keys(element.props) //获取element.props的键值
.filter(isProperty) //使用isProperty来过滤掉children属性
.forEach(name=>{ //遍历过滤后的属性,为创建好的dom添加上这些属性
dom[name] = element.props[name]
})
// 递归地为element的子元素做同样的事情
element.props.children.forEach(child=>{
render(child,dom)
})
container.appendChild(dom)
}
//创建元素
function createTextElement(text){
return {
type:'TEXT_ELEMENT',
props:{
nodeValue:text,
children:[]
}
}
}
4.步骤三:实现并发模式
前三步的代码存在的问题:
当我们开始渲染时,浏览器会阻塞执行,直到render函数执行完成。而如果需要渲染的节点过于庞大,渲染这么一颗庞大的节点树需要很长的时间,这样就会阻塞浏览器的其他任务,如果浏览器需要处理其他任务,需要等到render结束,这样很显然不是很好。
1.解决方法:
解决办法:将把工作分成小单元,在完成每个单元后,如果浏览器还有其他需要完成的事情,我们就把控制权交还给浏览器
// ***************workLoop函数***************
let nextUnitOfWork = null;
function workLoop(deadline){
let shouldYield = false;
while(nextUnitOfWork&&!shouldYield){
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(nextUnitOfWork){
}
requestIdleCallback的作用:
MDN解释:
window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。
这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。
函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
你可以在空闲回调函数中调用requestIdleCallback(),以便在下一次通过事件循环之前调度另一个回调。
作者:可以把它看作是一个setTimeout函数,只不过运行时机由浏览器把控而不是我们。
requestIdleCallback向给与它的回调函数中传入了一个deadline参数,我们可以通过这个deadline参数来知道距离下次浏览器的控制还有多少时间。同时,在 workLoop中运行了requestIdleCallback(workLoop)意味着只要浏览器空闲,那么就会不断地循环执行workLoop,所以这个函数可以监听到 nextUnitOfWork的改变并做出相应的动作。
开始workloop循环
要开始workloop循环,就需要传入第一个工作单元,实现performUnitOfWrok,这个函数不仅要执行工作,在工作完成后还需要返回下一个工作单元
5.步骤四:构建Fiber树
1.在render函数中,我们创建Fiber树的根节点并且将它设置为nextUnitOfWrok。剩下的工作将在performUnitOfWork函数中进行,它会做如下三件事:
- 添加元素到dom节点
- 为元素的子节点创建fiber
- 选择下一个工作单元
2.FIber树结构:
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
上文的代码结构转换为Fiber树如下:
为了更容易地找到下一个工作单元,每一个fiber需要连接到它的第一个子元素和它的下一个兄弟元素。
下一个工作单元的选择规则:
- 当一个fiber完成时,如果检测到它有子节点,则这个节点将会作为下一个工作单元。
- 如果fiber完成时它没有子节点,则将它的下一个兄弟节点作为下一个工作单元。
- 如果fiber完成时没有子节点也没有兄弟节点,则往上找它的'叔叔'节点,即父节点的兄弟节点
- 如果叔叔节点都没有,就继续沿着父节点网上找,直到找到某位'前辈'的兄弟,或者到达根节点
- 如果真的到达根节点了,说明render函数已经执行完成
3.render函数重构
创建creatDom函数,将创建dom的逻辑从render中抽离出来。
在render函数中,将nextUnitOfWork设置为fiber树的根。
// ***************创建Dom函数***************
// 抽离出render中创建dom的逻辑,使用fiber节点来创建dom
function createDom(fiber){
// 创建dom节点,需要判断是否为文本节点
const dom = fiber.type==='TEXT_ELEMENT'
?document.createTextNode('')
:document.createElement(fiber.type)
// 为元素添加上属性,
const isProperty = key => key!=='children';
Object.keys(fiber.props) //获取element.props的键值
.filter(isProperty) //使用isProperty来过滤掉children属性
.forEach(name=>{ //遍历过滤后的属性,为创建好的dom添加上这些属性
dom[name] = fiber.props[name]
})
return dom
}
// ***************render函数***************
function render(element,container){
nextUnitOfWork = {
dom:container,
props:{
children:[element]
}
}
}
let nextUnitOfWork = null;
结合步骤4的workloop,当nextUnitOfWork不为空时,workloop监听到nextUnitOfWork不为空,performUnitOfWork函数就开始工作了。
4.完成performUnitOfWork函数
注:这里作者将performUnitOfWork函数中的形参改成了fiber,之前是nextUnitOfWork
function performUnitOfWork(fiber){
// 如果fiber没有dom则为其创建dom
if(!fiber.dom) fiber.dom = createDom(fiber);
// 如果fiber存在parent ,则要将这个fiber的dom放入parent中
if(fiber.parent) fiber.parent.dom.appendChild(fiber.dom)
// 开始处理子节点,为每一个子节点创建新的fiber
const elements = fiber.props.children
let index = 0;
let prevSibling = null;
while(index<elements.length){
const element = elements[index]
const newFiber = {
type:element.type,
props:element.props,
parent:fiber,
dom:null
}
// 如果index===0,表明它是fiber的第一个子节点
if(index === 0){
fiber.child = newFiber
}else{
prevSibling.sibling = newFiber
}
// 记录下本次循环的newFiber,它将在下一次循环中连接它的兄弟节点
prevSibling = newFiber
index++
}
// 最后,按照fiber树的寻找规则寻找下一个需要工作的工作单元
// 如果有子节点就返回子节点
if(fiber.child) return fiber.child
// 没有子节点的情况
let nexFiber = fiber
while(nexFiber){
// 如果有兄弟节点就返回兄弟节点
if(nexFiber.sibling) return nexFiber.sibling
// 没有兄弟节点就需要沿着父元素找父元素的兄弟节点
nexFiber = nexFiber.parent
}
}
至此Fiber树就构建完成了!
6.步骤五,渲染和提交阶段
1.在这里遇到了一个问题:
在前面我们实现过workLoop函数:
function workLoop(deadline){
let shouldYield = false;
while(nextUnitOfWork&&!shouldYield){
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
每次获取下一个工作单元时,都需要等待浏览器的下一次空闲才能进行,如果浏览器持续繁忙,则结果是界面迟迟不出现,这显然是不合理的。
解决办法:
- 将
performUnitOfWork函数中的if(fiber.parent) fiber.parent.dom.appendChild(fiber.dom)这个判断条件移除。这样做的目的是不让performUnitOfWork函数在执行时就操作dom - 添加一个参数来记录fiber树的根
- 在workLoop中添加判断,当整个fiber树构建完成后,提交整个fiber树(我们已经记录下了根节点,所以提交并不是什么难事)
- commitRoot函数提交后,再根据整个fiber树统一创建dom
重构后的代码:
// ***************commit函数***************
function commitRoot(){
//TODO
}
// ***************render函数***************
function render(element,container){
//记录根节点
wipRoot = {
dom:container,
props:{
children:[element]
}
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null;
//新增wipRoot记录fiber根节点
let wipRoot = null
function workLoop(deadline){
let shouldYield = false;
while(nextUnitOfWork&&!shouldYield){
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
//新增判断,当满足以下条件,说明fiber树构建完成
if(!nextUnitOfWork&&wipRoot){
//提交根节点
commitRoot()
}
requestIdleCallback(workLoop)
}
2.commitRoot函数实现,到这一步只需要递归地将fiber树中的元素创建到dom中即可,后面还会对这个函数进行重构
function commitRoot(){
commitWork(wipRoot.child)
wipRoot = null
}
function commitWork(fiber){
if(!fiber) return
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}