100行代码实现Virtual DOM和render

在编写代码之前,我们需要先了解需要先来了解一下Virtual DOM是怎么样构建并渲染到浏览器的,常见的构建Virtual DOM的方法有两种,一种是jsx(react),另一种是template(vue)。

基本原理

  • React的jsx一般是通过babel的jsx插件编译成createElement[源码],Vue的template也可以通过vue-loader编译成对应的createElement[源码]

  • 然后,在组件被创建并初始化state之后,执行render,将组件的props和state根据jsx(react)或template(vue),生成一个虚拟DOM树

  • 最后所有的VDOM生成完毕之后,将所有VDOM都mount上真实HTML DOM。

Component -> render -> VDOM -> mount -> HTML

VDOM

VDOM的节点是一个简化版的DOM对象,只存储了我们关心的属性,大大提高了DOM操作时的性能。通常一个虚拟节点(VNode)包含:标签名、子节点数组、属性、事件、key和对应的真实DOM。

1
2
3
4
5
6
7
8
export class VNode {
type: string;
children: VNode[] = [];
props: { propName: string; propValue: any }[] = [];
events: { propName: string; propValue: () => void }[] = [];
key: any;
el: Node;
}

这时候再定义一个创建虚拟DOM对象的方法,用于jsx调用,该方法接收三个参数:标签名,标签属性,子标签。返回一个虚拟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
function createElement(type: string, props, ...children) {
props = props || {};
const vNode = new VNode();
vNode.type = type;
vNode.events = Object.keys(props)
.filter(value => value.startsWith('on'))
.map(value => {
return {
propName: value,
propValue: props[value]
}
});

vNode.props = Object.keys(props)
.filter(value => !value.startsWith('on') && value !== 'key')
.map(value => {
return {
propName: value,
propValue: props[value]
}
});
vNode.key = props['key'];
vNode.children = children;
return vNode;
}

render

虚拟DOM挂载到HTML DOM,根据根节点的ID,使用document.createElement将虚拟DOM转成HTML DOM,并挂载到真实节点mountEl下

1
2
3
4
5
6
function mount(el, vNode: VNode) {
const node = mountVNode(vNode);
if (el != null) {
el.appendChild(node);
}
}

根据VNode树生成真实HTML 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
function mountVNode(vNode: VNode): HTMLElement | Text {
if (vNode == null) {
return null;
}
if (vNode instanceof VNode) {
let el: HTMLElement;
el = document.createElement(vNode.type);
vNode.props.forEach(value => {
el.setAttribute(value.propName, value.propValue)
});
vNode.events.forEach(value => {
el.addEventListener(value.propName.replace(/^on/, ''), value.propValue);
});
vNode.children.forEach(value => {
const subEl = mountVNode(value);
if (subEl != null) {
el.appendChild(subEl);
}
});
vNode.el = el;
return el;
} else {
return document.createTextNode(String(vNode));
}
}

定义一个所有组件的抽象父组件,实现组件共有的基础功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface ComponentProps {
el: HTMLElement;
[key: string]: any;
}

export abstract class Component {

readonly el: HTMLElement;
vNode: VNode;
abstract render(): VNode;

constructor(props: ComponentProps) {
if (props) {
Object.assign(this, props);
}
}

protected mount() {
this.vNode = this.render();
const node = mountVNode(this.vNode);
this.el && node && this.el.appendChild(node);
}
}

最后再用定义一个挂载方法,创建组件并挂载到真实DOM,并且按顺序执行生命周期即可

1
2
3
4
5
6
7
export function renderDOM(componentType: { new (props: ComponentProps) }, props, selector?: string) {
const component = new componentType({...props, el: document.querySelector(selector)});
component.beforeMount && component.beforeMount();
component.mount();
component.mounted && component.mounted();
return component;
}

测试运行

Virtual DOM到render的过程基本就完成了,接下来我们定义一个组件,测试调用一下,看看结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div id="el"></div>

<script >
// 定义组件
class TestComponent extends Component {
buttonText = 'buttonText';
clickCount = 1;

constructor(props: ComponentProps) {
super(props);
}
render() {
return (<div>
<span >hello world</span>
<button onclick={() => {console.log('Hello World!', ++this.clickCount)}}>{this.buttonText}</button>
</div>);
}
}
// 渲染到DOM
renderDOM(TestComponent, '#el')
</script>

这仅仅是实现了从Virtual DOM渲染到真实DOM,并没有包含diff算法部分,所以当数据变化之后,并不会重新刷新真实DOM。想要刷新真实DOM,就需要在数据发生变化的时候,根据数据重新render一份vnode树,然后和之前生成的vnode树进行diff并更新真实DOM,具体如何实现diff,后续文章会进行具体讲解。