翻译自这里:https://engineering.hexacta.com/didact-instances-reconciliation-and-virtual-dom-9316d650f1d0

Didact:Instances, reconciliation and virtual DOM

截止目前我们已经可以使用JSX来创建并渲染页面DOM。在这一节我们将会把重点放在如何更新DOM上。

在介绍setState之前,更新DOM只能通过更改入参并再次调用render方法来实现。如果我们想实现一个时钟,代码大概下面这个样子:

1
2
3
4
5
6
7
8
9
10
const rootDom = document.getElementById("root");

function tick() {
const time = new Date().toLocaleTimeString();
const clockElement = <h1>{time}</h1>;
render(clockElement, rootDom);
}

tick();
setInterval(tick, 1000);

事实上,上面的代码运行后并不能达到预期的效果,多次调用当前版本的render方法只会不断往页面上添加新的元素,而不是我们预期的更新已经存在的元素。下面我们想办法实现更新操作。在render方法末尾,我们可以去检查父类元素是否含有子元素,如果有,我们就用新生成的元素去替换旧的元素。

1
2
3
4
5
6
7
8
9
10
function render(element, parentDom){
// ...
// Create dom from element
// ...
if(!parentDom.lastChild){
parentDom.appendChild(dom);
} else {
parentDom.replaceChild(dom, parentDom.lastChild);
}
}

针对开头那个时钟的例子,上面render的实现是没问题的。但对于更复杂的情况,比如有多个子元素时上面代码就不能满足要求了。正确的做法是我们需要比较前后两次调用render方法时所生成的元素树,对比差异后只更新有变化的部分。

Virtual DOM and Reconciliation

React把一致性校验的过程称作“diffing”,我们要做的和React一样。首先需要把当前的元素树保存起来以便和后面新的元素树比较,也就是说,我们需要把当前页面内容所对应的虚拟DOM保存下来。

这颗虚拟DOM树的节点有必要讨论一下。一种选择是使用Didact Elements,它们已经含有props.children属性,我们可以根据这个属性构建出虚拟DOM树。现在有两个问题摆在面前:首先,为了方便比较,我们需要保存每个虚拟DOM指向的真实DOM的引用(校验过程中我们有需要会去更新实际DOM的属性),并且元素还要是不可变的;第二,目前元素还不支持含有内部状态(state)的组件。

Instances

我们需要引入一个新的概念—–instances—–来解决上面的问题。一个实例表示一个已经渲染到DOM的元素,它是含有elementdomchildInstances属性的一个JS对象。childInstances是由子元素对应实例组成的数组。

注意,这里说的实例和Dan Abramov在React Components, Elements, and Instances中提到的实例并不是一回事。Dan说的是公共实例,是调用继承自React.Component的组件的构造函数后返回的东西。我们将在后面的章节添加公共实例。

每个DOM节点都会有对应的实例。一致性校验的目的之一就是尽量避免去创建或者移除实例。创建和移除实例意味着我们要修改DOM树,所以我们越多的重用实例就会越少的去修改DOM树。

Refactoring

接下来我们来重构render方法,增加一致性校验算法,同时增加一个instantiate方法来为元素创建实例。

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
let rootInstance = null; // 用来保存上一次调用render产生的实例

function render(element, container){
const prevInstance = rootInstance;
const nextInstance = reconcile(container, prevInstance, element);
rootInstance = nextInstace;
}

// 目前只是针对根元素的校验,没有处理到子元素
function reconcile(parentDom, instance, element){
if(instance === null){
const newInstance = instantiate(element);
parentDom.appendChild(newInstance.dom);
return newInstance;
} else {
const newInstance = instantiate(element);
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
}
}

// 生成元素对应实例的方法
function instantiate(element){
const { type, props} = element;

const isTextElement = type === 'TEXT_ELEMENT';
const dom = isTextElement ? document.createTextNode('')
: document.createElement(type);

// 添加事件
const isListener = name => name.startsWith("on");
Object.keys(props).filter(isListener).forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, props[name]);
});

// 设置属性
const isAttribute = name => !isListener(name) && name != "children";
Object.keys(props).filter(isAttribute).forEach(name => {
dom[name] = props[name];
});

const childElements = props.children || [];
const childInstances = childElements.map(instantiate);
const childDoms = childInstances.map(childInstance => childInstace.dom);
childDoms.forEach(childDom => dom.appendChild(childDOm));

const instance = {dom, element, childInstances};
return instance;
}

上面的render方法和之前的差不多,不同之处是保存了上次调用render方法产生的实例。我们还把一致性校验的功能从创建实例的代码中分离了出来。

为了重用dom节点,我们需要一个能更新dom属性的方法,这样就不用每次都创建新的dom节点了。我们来改造一下现有代码中设置属性的那部分的代码。

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 instantiate(element) {
const { type, props } = element;

// 创建DOM元素
const isTextElement = type === 'TEXT_ELEMENT';
const dom = isTextElement
? document.createTextNode("")
: document.createElement(type);

updateDomProperties(dom, [], props); // 实例化一个新的元素

// 实例化并添加子元素
const childElements = props.children || [];
const childInstances = childElements.map(instantiate);
const childDoms = childInstances.map(childInstance => childInstance.dom);
childDoms.forEach(childDom => dom.appendChild(childDom));

const instance = { dom, element, childInstances };
return instance;
}

function updateDomProperties(dom, prevProps, nextProps){
const isEvent = name => name.startsWith('on');
const isAttribute = name => !isEvent(name) && name != 'children';

Object.keys(prevProps).filter(isEvent).forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});

Object.keys(preProps).filter(isAttribute).forEach(name => {
dom[name] = nextProps[name];
});

// 设置属性
Object.keys(nextProps).filter(isAttribute).forEach(name => {
dom[name] = nextProps[name];
});

// 添加事件监听
Object.keys(nextProps).filter(isEvent).forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}

updateDomProperties方法会移除所有旧的属性,然后再添加新属性。如果属性没有变化的话依然会进行移除和添加操作,这一定程度上有些浪费,但我们先这样放着,后面再处理。

Reusing DOM nodes

前面说过,一致性校验算法需要尽可能多的去重用已经创建的节点。因为目前元素的type都是代表HTML中标签名的字符串,所以如果同一位置前后两次渲染的元素的类型一样则表示两者为同一类元素,对应的已经渲染到页面上的dom节点就可以被重用。下面我们在reconcile中增加判断前后两次渲染的元素类型是否相同的功能,相同的话执行更新操作,否则是新建或者替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function reconcile(parentDom, instance, element) {
if (instance == null) {
// 创建实例
const newInstance = instantiate(element);
parentDom.appendChild(newInstance.dom);
return newInstance;
} else if (instance.element.type === element.type) { // 和老的实例进行类型比较
// 更新
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.element = element;
return instance;
} else {
// 如果不相等的话直接替换
const newInstance = instantiate(element);
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
}
}

Children Reconciliation

现在校验过程还没有对子元素进行处理。针对子元素的校验是React中的一个关键部分,这一过程需要元素的一个额外属性key来完成,如果某个元素在新旧虚拟DOM上的key值相同,则表示该元素没有发生变化,直接重用即可。在当前版本的代码中我们会遍历instance.childInstanceselement.props.children,并对同一位置的实例和元素进行比较,通过这种方式完成对子元素的一致性校验。这种方法的缺点就是,如果子元素只是调换了位置,那么对应的DOM节点将没法重用。

我们把同一实例上一次的instance.childInstances和这次对应元素的element.props.children进行递归比较,并且保存每次reconcile返回的结果以便更新childInstances

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
function reconcile(parentDom, instance, element){
if(instance == null){
const newInstance = instantiate(element);
parentDom.appendChild(newInstance.dom);
return newInstance;
} else if(instance.element.type === element.type){
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.childInstances = reconcileChildren(instance, element);
instance.element = element;
return instance;
} else {
const newInstance = instantiate(element);
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
}
}

function reconcileChildren(instance, element){
const dom = instance.dom;
const childInstances = instance.childInstances;
const nextChildElements = element.props.children || [];
const newChildInstances = [];
const count = Math.max(childInstances.length, nextChildElements.length);
for(let i = 0; i< count; i++){
const childInstance = childInstances[i];
const childElement = nextChildElements[i];//上面一行和这一行都容易出现空指针,稍后处理
const newChildInstance = reconcile(dom, childInstance, childElement);
newChildInstances.push(newChildInstance);
}
return newChildInstances;
}

Removing DOM nodes

如果nextChildElements数量多于childInstances,那么对子元素进行一致性校验时就容易出现undefined与剩下的子元素进行比较的情况。不过这不是什么大问题,因为在reconcile中的if(instance == null)会处理这种情况,并且会根据多出来的元素创建新的实例。如果childInstances的数量多于nextChildElement,那么reconcile就会收到一个undefined作为其element参数,然后在尝试获取element.type时就会抛出错误。

出现这个错误是因为我们没有考虑DOM节点需要移除的情况。所以接下来我们要做两件事情,一个是在reconcile中增加增加element === null的校验,一个是在reconcileChildren中过滤掉值为nullchildInstances元素。

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
function reconcile(parentDom, instance, element){
if(instance == null){
const newInstance = instantiate(element);
parentDom.appendChild(newInstance.dom);
return Instance;
} else if(element == null){
parentDom.removeChild(instance.dom);
return null; // 注意这地方返回null了
} else if(instance.element.type === element.type){
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.childInstances = reconcileChildren(instance, element);
instance.element = element;
return instance;
} else {
const newInstance = instantiate(element);
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
}
}

function reconcileChildren(instance, element){
const dom = instance.dom;
const childInstances = instance.childInstances;
const nextChildElements = element.props.children || [];
const newChildInstances = [];
const count = Math.max(childInstances.length, nextChildElements.length);
for(let i = 0; i < count; i++){
const childInstance = childInstances[i];
const childElement = nextChildElements[i];
const newChildInstances = reconcile(dom, childInstance, childElement);
newChildInstances.push(newChildInstance);
}
return newChildInstances.filter(instance => instance != null)
}

Summary

这一节,我们为Didact增加了更新DOM的功能。我们通过重用节点,避免了频繁的创建和移除DOM节点,提高了Didact的工作效率。重用节点还有一定的好处,比如保存了DOM的位置或者焦点等一些内部状态信息。

目前我们是在根元素上调用render方法的,每次有变化时也是针对整棵元素树进行的一致性校验。下一节我们将介绍组件。有了组件我们就可以只针对有变化的那一部分子树进行一致性校验。