0%

【React进阶-2】从零实现一个React(上)

这篇文章给大家介绍一下我们如何自己去实现一个类似于React这样的框架,当然,我们并不会去实现React里面所有的内容,只是去将React基础性的功能实现一遍,让大家对React的认识更加的深入。因为篇幅有限,所以我将这篇文章拆分成了上、下两部分,此文是上篇。

写在前面

本文参考”Rodrigo Pombo”大佬的《Build your own React》一文。文中最终会实现一个类似于react的框架,但里面并没有使用任何react的东西,全是我们自己写的实现逻辑,感兴趣的话就一起开始学习吧。

这篇文章我们主要介绍以下几件事情:

  • createElement函数
  • render函数
  • Concurrent Mode
  • Fibers
  • Render和Commit阶段
  • 调和过程
  • 函数组件
  • Hooks

下面我们开始介绍。

代码获取

本文涉及到的代码全部上传至码云,需要的同学请在下面地址中获取:

1
https://github.com/xuqwCloud/zerocreate_react

回顾一些基础概念

在所有工作开始之前,我们先来复习一下react中的一些基础概念,尝试搞清楚React、JSX、DOM等这些东西是何如进行工作的。

我们先使用如下脚手架命令创建一个react基础项目,如下:

1
npx create-react-app zerocreate_react

项目创建完成后,我们在src目录下的index.js文件内可看到如下的代码:

img

上述红色框的代码里面,我们引入了一个用JSX语法编写的App组件,并通过ReactDOM的render()方法将其渲染到了id为”root”的div里面,其中App组件的完整代码在src目录下的App.js文件内,id为”root”的div元素在public目录下的index.html文件内,这两部分的内容代码在新建react基础项目时脚手架默认是已经做好的,同时也不是我们今天的主题,所以不做过多解释。

我们接下来要做的就是改写红色框内的代码,不用它默认创建好的App组件,我们通过JSX简单定义一个组件,并将它渲染到页面上。这部分内容其实很简单,我们直接上代码:

1
2
3
const element = <h2 title='xbcb.top'>X北辰北</h2>;
const container = document.getElementById('root');
ReactDOM.render(element, container);

以上就是简化后的代码,我们将App组件简化成了一个element变量,然后给它赋值一个DOM元素,这种写法就是JSX语法,可以直接在JS代码里面写HTML标签。然后将render()函数的第二个参数提取了出来,赋值给一个变量,最终形成了如上的代码。同时在上述代码里去除了默认自带的<React.StrictMode>标签,其实这个标签的功能跟JS中的严格模式类似,在这里直接去除是为了不影响今天所介绍的主题。改写后的代码完整版和运行效果如下:

img

img

到目前为止,我们简化完了通过脚手架创建的react项目代码。接下来我们就看看改写后的这三行代码具体是怎么工作的,换句话说,我们将这三行代码转换成没有React参与的纯JS代码。

先来看第一行的替换。我们直接将第一行代码复制粘贴到babel里面,看它转换后的代码具体长什么样:

img

如上图所示,我们原来的JSX语法的代码最终会通过类似于babel这种转义工具来进行转换,最终会转成右侧的JS代码,但是右侧的JS代码里面是通过调用React的createElement()方法来实现一个JSX组件的最终定义的。这个方法接收三个参数:要创建的HTML DOM元素的标签名称、要创建的标签的所有属性及属性值(全部包含在一个对象里)、要创建的标签的子元素。我们上述的DOM元素里面没有子元素,所以createElement()方法的第三个参数就是”X北辰北”,我们可以看一下如果有子元素的话,createElement()方法会是什么样子,如下:

img

所以我们第一行的JSX代码可以先改成纯JS的代码,如下:

1
2
3
4
5
6
7
8
9
const element = React.createElement(
'h2',
{
title: 'xbcb.top'
},
'X北辰北'
);
const container = document.getElementById('root');
ReactDOM.render(element, container);

改完后保存运行,发现前端页面是正常的,跟之前的结果没有任何区别,所以我们每次修改完之后最好还是返回页面看看和之前的结果是否有出入,从而验证下修改的方法对不对。

将第一行JSX代码改为纯JS的代码之后,还没有完,我们虽然到目前为止将JSX的转换搞清楚了,但转换后的代码里有React的代码片段,用到了React的createElement()方法,所以接下来还要将这个方法转换。

关于createElement()方法的详细介绍我们在接下来的一节内容会详细介绍,在这里我们只需要知道这个方法通过我们介绍的那样,传入三个参数后,它最终会返回一个对象,这个对象里面包含很多个属性,在这里我们只关心type和props这两个属性。type属性就是我们要创建的标签的名称,它是一个字符串,除了字符串之外它还可以是一个函数,函数的情况我们后续介绍,在此处仅仅介绍字符串的情况;props属性是一个对象,它里面包含了JSX组件里面的所有属性,也就是传到createElement()方法中的第二个参数中的所有属性和相应属性值,props中的对象除了这些属性之外,它还有一个特殊的属性children,children属性一般是一个数组,用来存放元素中的子元素,也就是传到createElement()方法中的第三个参数中的一些信息,在我们的代码里面,children属性的值就是一个字符串”X北辰北”。所以上述的React.createElement()代码片段可以改为如下:

1
2
3
4
5
6
7
8
9
const element = {
type: 'h2',
props: {
title: 'xbcb.top',
children: 'X北辰北'
}
};
const container = document.getElementById('root');
ReactDOM.render(element, container);

目前为止,我们第一行的代码转换完成了,将最先的JSX代码转换成了带有react的JS代码,然后将带有react的代码转换成了纯JS代码。但是现在如果你直接保存后去页面查看,会发现报错,因为我们这里到目前为止相当于直接拿到了React.createElement()方法返回的简化结果,并没有像源码里一样去将它完整的返回格式写出来,所以接下来我们要将它按照我们的方式去渲染,并不用react里面的东西。

渲染我们第一行的代码,就需要将第三行代码来转换了。因为第三行代码中有用到ReactDOM.render()方法,我们直接将我们定义的element对象传到这方法里面执行渲染的话肯定会报错。所以我们同样的将其转换为纯JS代码来将我们简化后的element对象渲染。

render()方法其实很好改写,我们先根据element对象的type属性创建一个DOM元素,然后将props属性里面所有东西添加到这个DOM元素上,代码如下:

1
2
3
4
5
6
7
8
9
10
11
const element = {
type: 'h2',
props: {
title: 'xbcb.top',
children: 'X北辰北'
}
};
const container = document.getElementById('root');

const node = document.createElement(element.type);
node['title'] = element.props.title;

对于children属性,我们要特殊处理,在我们代码里children是一个字符串,所以我们要另建一个DOM元素,用来表示children,然后将其添加到我们刚才创建的这个node节点上,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const element = {
type: 'h2',
props: {
title: 'xbcb.top',
children: 'X北辰北'
}
};
const container = document.getElementById('root');

const node = document.createElement(element.type);
node['title'] = element.props.title;

const text = document.createTextNode('');
text['nodeValue'] = element.props.children;
node.appendChild(text);

如上述代码所示,我们为children属性创建了一个文本节点并将其追加到了node节点上,在这里,我们也可以直接使用node.innerHTML='X北辰北'这行代码实现同样的效果,并不需要创建额外的文本节点。这样做的主要目的就是考虑到后期我们写的代码中都保持这样的操作逻辑而创建了这个文本节点而已。到目前为止element对象中的所有属性都渲染完成,我们就只剩最后一步,将node节点挂载到container节点上,也就是id为root的节点下,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const element = {
type: 'h2',
props: {
title: 'xbcb.top',
children: 'X北辰北'
}
};
const container = document.getElementById('root');

const node = document.createElement(element.type);
node['title'] = element.props.title;

const text = document.createTextNode('');
text['nodeValue'] = element.props.children;
node.appendChild(text);

container.appendChild(node);

最后保存代码,回到页面查看效果,最终的index.js文件和效果如下:

img

img

到此为止,我们介绍完了第一部分的内容:实现了将文章开始时红色框内的react代码转换为纯JS的步骤,里面并没有使用任何关于react的东西,全部是我们自己的JS代码。但在中间并没有给大家详细介绍createElement()方法的细节,这部分的内容,我们接下来介绍。

createElement()方法

这部分内容我们来仿照React.createElement()方法自己实现一个createElement()方法,用于根据JSX语法,创建出一个可以渲染的element对象。

首先我们将之前改写的代码恢复原状或者新建一个项目,为了讲解清楚,我们新建一个包含较多层级的element元素,这个元素用JSX语法来写,代码如下:

1
2
3
4
5
6
7
8
const element = (
<div id='xbcb'>
<a>X北辰北</a>
<br />
</div>
);
const container = document.getElementById('root');
ReactDOM.render(element, container);

上一部分我们知道,JSX语法被babel转义后其实就是React.createElement()方法的嵌套调用,如下图所示:

img

那我们将上述过程可以模拟出来,最终代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const element = React.createElement(
'div',
{
id: 'xbcb'
},
React.createElement(
'a',
null,
'X北辰北'
),
React.createElement(
'br',
null,
null
)
);
const container = document.getElementById('root');
ReactDOM.render(element, container);

上述代码中的React.createElement()方法每次返回的都是一个带有type和props属性的对象,props属性值又是一个对象,这个对象中有一个特殊的属性children,这个children属性一般是一个数组,所以React.createElement()方法每次根据不同的参数返回的示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
createElement("div") returns:
{
"type": "div",
"props": { "children": [] }
}

createElement("div", null, a) returns:
{
"type": "div",
"props": { "children": [a] }
}

createElement("div", null, a, b) returns:
{
"type": "div",
"props": { "children": [a, b] }
}

所以我们可以自己定义一个createElement()方法,去模拟返回结构一样的一个对象,代码如下:

1
2
3
4
5
6
7
8
9
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children,
}
}
}

上面代码中,在props和children位置我们用了扩展运算符,方便我们遍历取出所有值而已。

但是上述的children属性有时候也不是一个数组,比如我们第一部分的代码中那样,它仅仅是一个字符串,所以我们对children属性做一个遍历,针对不同的情况定义不同的处理方式,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map( value => {
typeof value == 'object' ? value : createTextElement(value)
})
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}

在上面的代码里,我们用了一个特殊的类型TEXT_ELEMENT,如果children属性不是一个数组的话,我们就返回一个TEXT_ELEMENT类型的对象。但是在react源码里面并不是这么简单的来做处理的,大家一定要注意,我们在这里仅仅是为了演示方便才这么做的。

到目前为止我们的createElement()方法其实已经实现了,但是在代码里面依然在调用React.createElement(),所以我们定义一个类似于命名空间的东西,直接调用我们自己的createElement()方法,如下:

1
2
3
const XbcbLib = {
createElement
};

最后我们调用自己的XbcbLib.createElement()方法,并将转换后的element对象输出一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map( value => {
//typeof value == "object" ? value : createTextElement(value)
if(typeof value == 'object') {
return value;
}else {
return createTextElement(value)
}
})
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}

const XbcbLib = {
createElement
};

const element = XbcbLib.createElement(
'div',
{
id: 'xbcb'
},
XbcbLib.createElement(
'a',
null,
'X北辰北'
),
XbcbLib.createElement(
'br'
)
);
console.log(element);

img

以上我们就实现了一个简易版的createElement()方法,如果我们有一个类似于babel的转义平台的话,我们的JSX语法转义的时候会调用我们自己的XbcbLib.createElement()方法,最终返回给我们一个上述结果所示的包含有type和props属性的对象。但是这个对象目前直接拿到ReactDOM.render()方法里去渲染的话会报错,接下来我们自己实现一个render方法。

render()方法

上面实现简易版的createElement()方法之后,我们这部分介绍一下如何实现类似于React的render方法。在开始之前,我们和上面部分一样,先定义一个自己的render()方法,然后将这个方法同样地放到我们自己的命名空间下,在代码调用的地方让其调用我们自己的render(),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map( value => {
//typeof value == "object" ? value : createTextElement(value)
if(typeof value == 'object') {
return value;
}else {
return createTextElement(value)
}
})
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}

function render(element, container) { //定义自己的render方法

}

const XbcbLib = {
createElement,
render //将render方法放在自己的命名空间下
};

const element = XbcbLib.createElement(
'div',
{
id: 'xbcb'
},
XbcbLib.createElement(
'a',
null,
'X北辰北'
),
XbcbLib.createElement(
'br'
)
);
//console.log(element);
const container = document.getElementById('root');
XbcbLib.render(element, container); //调用自己的render方法

上述代码中,我们其实已经完全去除了react中的代码片段,到目前为止,我们index.js文件里的代码就全部都是纯JS的代码了,但目前我们定义的element对象并不会渲染到前端页面上,所以接下来我们就介绍一下render()的具体实现。

首先render()方法内的思路其实很简单,就是根据element对象的type属性去创建节点,然后将其挂载到container节点下就行了,代码类似于下面这样:

1
2
3
4
function render(element, container) {
const dom = document.createElement(element.type);
container.appendChild(dom);
}

但是上述的代码有点太过简单了,我们的element对象中有children属性,它里面的值我们还要循环遍历去渲染,所以在此处我们还要有一个递归来操作,如下:

1
2
3
4
5
6
7
8
9
function render(element, container) {
const dom = document.createElement(element.type);

element.props.children.forEach(child => { //递归调用render方法,渲染children内的子元素
render(child, dom);
});

container.appendChild(dom);
}

但是上述的代码中对文本节点并没有做太多的处理,接下来我们将dom节点创建过程进行一下处理,如果它是TEXT_ELEMENT类型,我们就创建一个文本节点,代码如下:

1
2
3
4
5
6
7
8
9
function render(element, container) {
const dom = element.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(element.type); //优化dom节点创建过程

element.props.children.forEach(child => {
render(child, dom);
});

container.appendChild(dom);
}

接下来我们就为创建的dom节点增加节点属性,通过遍历element对象的props属性来做,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function render(element, container) {
const dom = element.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(element.type);

const isProperty = key => key != 'children'; //遍历props属性来为dom节点增加节点属性
Object.keys(element.props).filter(isProperty).forEach(name => {
dom[name] = element.props[name];
});

element.props.children.forEach(child => {
render(child, dom);
});

container.appendChild(dom);
}

到目前为止,我们的render()方法已经实现了,现在保存代码去前端页面查看的话,发现页面也是可以正常渲染的,最终的代码和效果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map( value => {
//typeof value == "object" ? value : createTextElement(value)
if(typeof value == 'object') {
return value;
}else {
return createTextElement(value)
}
})
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}

function render(element, container) {
const dom = element.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(element.type);

const isProperty = key => key != 'children';
Object.keys(element.props).filter(isProperty).forEach(name => {
dom[name] = element.props[name];
});

element.props.children.forEach(child => {
render(child, dom);
});

container.appendChild(dom);
}

const XbcbLib = {
createElement,
render
};

const element = XbcbLib.createElement(
'div',
{
id: 'xbcb'
},
XbcbLib.createElement(
'a',
null,
'X北辰北'
),
XbcbLib.createElement(
'br'
)
);
//console.log(element);
const container = document.getElementById('root');
XbcbLib.render(element, container);

img

由上述的代码中可以看到,我们新建的元素最终会被渲染到页面上,里面的任何过程中都没有使用react的东西,全部都是我们自己定义的方法,但是上述代码中有一个地方存在着不足,那就是我们的element变量按理来说应该是一个JSX编写的组件代码,但在我们的代码里它却是一个循环调用XbcbLib.createElement()方法的对象,我们每次编写组件代码的时候不可能这样去定义我们的组件,要不然太麻烦了。

我们之前这么做的目的是为了解释createElement()方法的实现,从而将element对象的定义改成了这种形式,但现在我们想改回去初始状态,也就是我们element变量它就是一个通过JSX语法编写的组件,这个组件通过我们自定义的createElement()和render()方法最终渲染到页面。那我们改回去试一下:

1
2
3
4
5
6
const element = (
<div id='xbcb'>
<a>X北辰北</a>
<br />
</div>
);

我们向上述一样改回去之后,里面就丢失了XbcbLib.createElement()方法的调用,这个element组件会默认使用React.createElement()来转换,所以得到的结果拿到我们自定义的render()方法里去渲染的话会报错,那怎么样将JSX语法的转换工作直接用我们自定义的XbcbLib.createElement()方法去转换呢?其实很简单,我们只需要在element组件定义的顶部添加如下注释即可:

1
/** @jsx XbcbLib.createElement */

添加完这行注释之后,我们的JSX语法的组件转换为JS代码时,它就不会通过默认的React.createElement()方法了,而是会用我们自定义的XbcbLib.createElement()方法,所以我们通过JSX编写的组件element,最终也会渲染到页面,最终完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map( value => {
//typeof value == "object" ? value : createTextElement(value)
if(typeof value == 'object') {
return value;
}else {
return createTextElement(value)
}
})
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}

function render(element, container) {
const dom = element.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(element.type);

const isProperty = key => key != 'children';
Object.keys(element.props).filter(isProperty).forEach(name => {
dom[name] = element.props[name];
});

element.props.children.forEach(child => {
render(child, dom);
});

container.appendChild(dom);
}

const XbcbLib = {
createElement,
render
};

/** @jsx XbcbLib.createElement */
const element = (
<div id='xbcb'>
<a>X北辰北</a>
<br />
</div>
);

const container = document.getElementById('root');
XbcbLib.render(element, container);

Concurrent Mode

我们上述的render()方法里面其实有一个问题:如果我们的element对象异常庞大的时候,render函数其实内部循环遍历渲染完成所有元素是会占用大量时间的,但是当如果有需要用户输入这些需求的时候,只有等到element对象全部渲染完才可以响应,所以这对用户体验来说是非常糟糕的,同时对于开发者而言也是不可接受的,所以我们接下来就介绍一种方式——Concurrent Mode。用这种方式去优化上述的代码实现过程。

我们用Concurrent Mode实现的思路就是将整个工作拆解成好几个单元,在完成每一个单元任务后如果有需要做的额外工作的话,先中断浏览器渲染任务,优先处理这些额外工作,等这些工作处理完之后再继续执行剩余的单元渲染任务。用代码实现的话类似于下面这样来做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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) {
// TODO
}

像上述代码一样,我们用requestIdleCallback()创建了一个循环,它的作用类似于setTimeout(),它是一个浏览器内置的API,它在浏览器主线程空闲时会被调用,从而执行里面的回调方法。除此之外,requestIdleCallback()也给我们提供了一个deadline参数,我们可以用它来检查浏览器控制某一个单元任务时所需要花费的时间。

由于Concurrent Mode在目前react版本里仅仅是一个测试阶段的东西,所以在此处我们在项目开发时不建议使用,而且实际开发中大家用到的也不是特别多。关于Concurrent Mode实现的细节,我们下一节继续介绍。

Fibers

我们上一节介绍了将一个渲染任务拆分为单个任务的实现思路,也就是所说的Concurrent Mode,接下来的内容我们来介绍一下要想实现这种思路的话需要用到的一种新的数据结构——Fiber Tree,并继续优化完成Concurrent Mode中的代码。

介绍Fiber之前,我们先通过一些篇幅来介绍下react中为何会引入fiber这个概念。

在react 15的时代,如果我们页面元素很多并且频繁刷新页面的时候,会出现掉帧的情况,也就是我们所说的页面卡顿。深究原因,是因为大量的同步计算任务阻塞了UI渲染,因为我们调用setState的时候,react会遍历应用内的所有节点并计算差异,然后再更新UI,整个过程是一气呵成的,不能被打断,所以页面元素如果很多的话,整个计算过程会一直占用JS主线程,如果时间超过16ms的话就很容易出现掉帧的情况。而且JS本身就是单线程,在浏览器的主线程中JS计算、页面布局和页面绘制都是互斥的关系,所以JS运算持续占用主线程,UI就得不到及时的渲染和更新。

解决这个问题的思路就是将JS运算切割为多个步骤,也就是上一节说的拆分为多个单元任务,将其分批完成。换句话说,在完成一部分单元任务之后,将控制权交给浏览器,让浏览器有时间进行页面渲染或者其他优先级较高的操作,等浏览器忙完后再继续执行之前剩余的单元任务。在这里,我们的fiber正式登场:维护每一个单元任务的数据结构就是Fiber。

所以旧版本中的react通过递归的方式就行渲染元素,就是像我们上述实现的代码那样;但是Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,具体的实现形式就是上一节所用到的浏览器的requestIdleCallback()这个API。

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

我们通过一段示例代码来看一下Fiber这个数据结构是什么样子的:

img

如上图所示,左边代码是我们通过JSX语法定义的一个element对象,将其通过render函数渲染到我们的页面上,右边就是这个element对象最后形成的一个fiber树,我们接下来详细介绍下它里面的一些知识。

首先在我们的render函数里面,它会创建第一个fiber数据结构root,它也是我们fiber树的”根节点”,我们暂时这样称呼它,并且将这个fiber设置为下一个任务单元的引用(就是下一个任务单元开始执行的地方),也就是上一节中的nextUnitOfWork,剩余其他的工作将会在performUnitOfWork()中去进行,然后会依次为我们的每一个元素都创建一个fiber,最终形成上图的fiber树。我们创建的每一个fiber都会做以下三件事情:

  • 将元素添加到DOM
  • 为每一个元素的子元素创建一个fiber
  • 选择下一个任务单元的引用

我们创建fiber这个数据结构的目的之一就是为了能快速便捷的寻找出下一个任务单元,所以就像上图中所示那样,每一个fiber都会有一个链接用来指向它的父元素、子元素、兄弟元素。如果一个fiber有子元素,那么在当前fiber上完成所要做的任务之后,下一个任务就是子元素的fiber相关的任务,比如上图中的div这个fiber有子元素h1,所以在div上完成所需的工作后,下一个工作任务将在h1上去完成;如果一个fiber没有子元素的话,我们将它的兄弟元素作为下一个fiber,比如上图中的p这个fiber,它并没有子元素,所以在它上面完成所需的工作任务之后,下一个工作任务将在它的兄弟元素a这个fiber上进行;如果一个fiber元素既没有子元素也没有兄弟元素,那我们就会去找它的”叔叔”,也就是它原来兄弟元素的父元素,比如上图中的a和h2两个元素,它俩就会分别去找p的父元素和h1的父元素;如果父元素没有兄弟节点,我们会循环遍历父元素依次往上找,直到找到root这个fiber元素截止,如果找到了root这个元素,那就意味着我们已经完成了render的所有工作。

我们简单了解了fiber相关的知识后,接下来通过代码来优化一下我们之前写过的render函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createDom(fiber) {
const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(fiber.type);

const isProperty = key => key != 'children';
Object.keys(fiber.props).filter(isProperty).forEach(name => {
dom[name] = fiber.props[name];
});

return dom;
}

function render(element, container) {

}

let nextUnitOfWork = null;

如上述代码一样,我们先将原来的render函数中的部分代码删除,然后仅仅留下创建dom节点的代码,然后将这个节点返回,并将render函数改名为createDom()函数,同时在下方创建一个render函数,并指定参数,里面内部详细的代码我们接下来继续实现。

1
2
3
4
5
6
7
8
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element]
}
}
}

以上代码是关于render函数的实现代码,在render函数内部我们设置了fiber树的root节点。

接下来我们就实现workLoop的具体实现,它会在我们的浏览器空闲时被调用执行,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function workLoop(deadline) {
let shouldYield = false;
while(nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {

}

上述代码其实很简单,就是创建了一个workLoop,然后通过浏览器API的requestIdleCallback(),在浏览器空闲时调用这个API来进行fiber任务单元的控制,其中最重要的是performUnitOfWork()方法,接下来我们看看其内部的实现:

1
2
3
4
5
6
7
8
9
function performUnitOfWork(fiber) {
if(!fiber.dom) {
fiber.dom = createDom(fiber);
}

if(fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
}

上述代码中我们先是创建了一个新的dom节点,然后将其追加到了fiber的父元素节点中,接下来我们为每一个子元素创建一个fiber,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function performUnitOfWork(fiber) {
if(!fiber.dom) {
fiber.dom = createDom(fiber);
}

if(fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}

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,
}
}
}

然后我们将新创建的fiber添加到fiber树上,作为另一个fiber的子节点或者兄弟节点,这取决于它是否是第一个元素,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function performUnitOfWork(fiber) {
if(!fiber.dom) {
fiber.dom = createDom(fiber);
}

if(fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}

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,
}

if(index == 0) {
fiber.child = newFiber;
}else {
prevSibling.sibling = newFiber;
}

prevSibling = newFiber;
index++;
}
}

最后,我们去寻找下一个任务单元,我们首先从它的子节点开始寻找,其次是兄弟节点,然后是叔叔节点,就这样依次遍历,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function performUnitOfWork(fiber) {
if(!fiber.dom) {
fiber.dom = createDom(fiber);
}

if(fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}

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,
}

if(index == 0) {
fiber.child = newFiber;
}else {
prevSibling.sibling = newFiber;
}

prevSibling = newFiber;
index++;
}

if(fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while(nextFiber) {
if(nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}

以上就是我们这一节介绍的关于fiber的相关知识点的代码,我们在这一节中通过fiber将render方法就行了重写,全部代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map( value => {
//typeof value == "object" ? value : createTextElement(value)
if(typeof value == 'object') {
return value;
}else {
return createTextElement(value)
}
})
}
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}

function createDom(fiber) {
const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(fiber.type);

const isProperty = key => key != 'children';
Object.keys(fiber.props).filter(isProperty).forEach(name => {
dom[name] = fiber.props[name];
});

return dom;

// element.props.children.forEach(child => {
// render(child, dom);
// });

// container.appendChild(dom);
}

let nextUnitOfWork = null;

function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element]
}
}
}

function workLoop(deadline) {
let shouldYield = false;
while(nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

function performUnitOfWork(fiber) {
if(!fiber.dom) {
fiber.dom = createDom(fiber);
}

if(fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}

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,
}

if(index == 0) {
fiber.child = newFiber;
}else {
prevSibling.sibling = newFiber;
}

prevSibling = newFiber;
index++;
}

if(fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while(nextFiber) {
if(nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}

const XbcbLib = {
createElement,
render
};

/** @jsx XbcbLib.createElement */
const element = (
<div id='xbcb'>
<a>X北辰北</a>
<br />
</div>
);

const container = document.getElementById('root');
XbcbLib.render(element, container);

由于篇幅有限,我们将这篇文章分为两部分介绍,下文继续介绍还没有介绍完的

  • Render和Commit阶段
  • 调和过程
  • 函数组件
  • Hooks

这四方面的知识。