160行实现山寨版React
![](https://img.haomeiwen.com/i8757538/e724a228034c48ca.png)
引言
React是一个伟大的库,由于其简单、性能、声明式编程方式,深受许多开发者的喜爱。
但我个人对react有个特殊情节,那就是它的工作原理。
我发现react背后的理念十分简单却让我着迷。
我相信理解它的核心原理将会帮助你编写更高效和安全的代码。
本教程中,我会教你写一个完整功能的react版本,包括组件API,自定义虚拟DOM实现。我将会分四个部分,分主题介绍:
-
元素: 我们会学到如何处理JSX代码块为轻量级的虚拟DOM
-
渲染: 将虚拟DOM转换成真实DOM
-
修补: 将会演示为什么key属性如此重要,如何保证虚拟DOM高效更新真实DOM
-
组件: 最后一节介绍react组件系统和创建、生命周期、渲染步骤。
每个部分都会以在线codepen示例结束,这样你可以很快知道写到哪儿了。
我们现在开始。
元素
元素是真实DOM的轻量级对象表示。
元素维持像节点类型、属性、子代等重要信息,以便后续渲染。
树形组合元素结构称作虚拟DOM,下面示例:
{
"type": "ul",
"props": {
"className": "some-list"
},
"children": [
{
"type": "li",
"props": {
"className": "some-list__item"
},
"children": [
"One"
]
},
{
"type": "li",
"props": {
"className": "some-list__item"
},
"children": [
"Two"
]
}
]
}
为了避免每次写臃肿的js对象, 大部分react开发者使用JSX语法, JSX看起来像js和HTML标签的混合
/** @jsx createElement */
const list = <ul className="some-list">
<li className="some-list__item">One</li>
<li className="some-list__item">Two</li>
</ul>;
为了保证执行,JSX需要编译成正常的函数调用,注意编译指示注释中定义必须使用的函数:
const list = createElement('ul', {className: 'some-list'},
createElement('li', {className: 'some-list__item'}, 'One'),
createElement('li', {className: 'some-list__item'}, 'Two'),
);
最后,编译函数调用应该会生成虚拟DOM结构。
我们的实现很简短,但看起来很原始,但完美达到目的。
const createElement = (type, props, ...children) => {
props = props != null ? props : {};
return {type, props, children};
};
第一部分的CodePen可运行代码示例,包含上面说的方法,实现了生成虚拟DOM树。
渲染
渲染是把虚拟DOM转换成真实DOM。
一般来说,最直接的逻辑就是遍历虚拟DOM树,然后创建每个节点对应的真实DOM元素:
const render = (vdom, parent=null) => {
if (parent) parent.textContent = '';
const mount = parent ? (el => parent.appendChild(el)) : (el => el);
if (typeof vdom == 'string' || typeof vdom == 'number') {
return mount(document.createTextNode(vdom));
} else if (typeof vdom == 'boolean' || vdom === null) {
return mount(document.createTextNode(''));
} else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
return mount(Component.render(vdom));
} else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
const dom = document.createElement(vdom.type);
for (const child of [].concat(...vdom.children)) // flatten
dom.appendChild(render(child));
for (const prop in vdom.props)
setAttribute(dom, prop, vdom.props[prop]);
return mount(dom);
} else {
throw new Error(`Invalid VDOM: ${vdom}.`);
}
};
const setAttribute = (dom, key, value) => {
if (typeof value == 'function' && key.startsWith('on')) {
const eventType = key.slice(2).toLowerCase();
dom.__gooactHandlers = dom.__gooactHandlers || {};
dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
dom.__gooactHandlers[eventType] = value;
dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
} else if (key == 'checked' || key == 'value' || key == 'id') {
dom[key] = value;
} else if (key == 'key') {
dom.__gooactKey = value;
} else if (typeof value != 'object' && typeof value != 'function') {
dom.setAttribute(key, value);
}
};
上面的代码看起来有点丑,这里我们分解成不那么复杂的小部分代码:
-
自定义属性设置器: 虚拟DOM接受的属性一般不是有效DOM属性。像事件处理器,键标识和值都需要单独处理。
-
原始虚拟DOM渲染:原始类型,像字符串、数字、布尔型和空值,会被转换成空文本节点。
-
复杂虚拟DOM渲染: 带字符串标签的节点会递归渲染子代,最后转成DOM元素。
-
组件虚拟DOM渲染: 带函数标签的节点会分开处理,暂时不关注,我们后续会处理这部分。
更新
更新是调配当前DOM成最新构建的虚拟DOM树。
想象你有些深嵌套和频繁更新的虚拟DOM,当有变化时,即使是最小的部分,也需要显示。单纯实现需要每次全量渲染:
-
删除现有的DOM节点
-
重新渲染一切
从效率角度考虑,这很烂,创建并正确渲染DOM是很低效操作。
我们可以写个更新逻辑,保证更少的DOM修改进行优化。
-
创建全新虚拟DOM
-
同当前虚拟DOM进行递归比较
-
定位任何添加、删除或变化的节点
-
更新变化
计算复杂度的问题也出现了。
比较两棵树有 O(n3)复杂度, 例如,你要更新上千个元素,需要十亿次比较,数量相当庞大。
相反,我们将会采用探索式的 O(n)逻辑,先假设两点:
-
两个不同类型的元素将会产生不同的树
-
通过渲染时指定key属性,开发者可以找子代元素的位置
实际上,这些假设适用于大部分的真实用例。
现在我们看看这一部分的代码:
const render = (vdom, parent=null) => {
if (parent) parent.textContent = '';
const mount = parent ? (el => parent.appendChild(el)) : (el => el);
if (typeof vdom == 'string' || typeof vdom == 'number') {
return mount(document.createTextNode(vdom));
} else if (typeof vdom == 'boolean' || vdom === null) {
return mount(document.createTextNode(''));
} else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
return mount(Component.render(vdom));
} else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
const dom = document.createElement(vdom.type);
for (const child of [].concat(...vdom.children)) // flatten
dom.appendChild(render(child));
for (const prop in vdom.props)
setAttribute(dom, prop, vdom.props[prop]);
return mount(dom);
} else {
throw new Error(`Invalid VDOM: ${vdom}.`);
}
};
const setAttribute = (dom, key, value) => {
if (typeof value == 'function' && key.startsWith('on')) {
const eventType = key.slice(2).toLowerCase();
dom.__gooactHandlers = dom.__gooactHandlers || {};
dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
dom.__gooactHandlers[eventType] = value;
dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
} else if (key == 'checked' || key == 'value' || key == 'id') {
dom[key] = value;
} else if (key == 'key') {
dom.__gooactKey = value;
} else if (typeof value != 'object' && typeof value != 'function') {
dom.setAttribute(key, value);
}
};
我们看看看可能的组合:
-
原始虚拟DOM + 文本节点情况下,比较DOM文本内容,根据不同,执行全量更新
-
原始虚拟DOM + 元素节点: 全量渲染
-
复杂虚拟DOM + 文本节点: 全量渲染
-
复杂虚拟DOM + 不同类型的元素DOM: 全量渲染
-
复杂虚拟DOM + 同类型的元素DOM: 这是最有趣的组合,子代调度逻辑会在这里执行。
-
组件虚拟DOM + 任意类型DOM: 就像上面部分,需要单独处理。
文本和复杂节点一般无法兼容,需要全量渲染,幸运的是它们一般也不会变化。
子代递归调度会这样实现:
-
当前活动元素被暂存,调度过程会有时中断焦点
-
DOM子代根据相应的键被迁移至暂存池中,索引会当做默认键
-
虚拟DOM子代和真实DOM节点通过键对应,然后递归更新,如果没有对应,会重新渲染
-
没有虚拟DOM对应的DOM节点会被移除
-
新属性会应用到最终父元素
-
焦点会返回到上一个活动元素中
组件
组件在概念上和js函数很类似,接收任意props输入,返回描述界面的元素集合。
组件可以是无状态函数或有自身状态和方法、生命周期钩子的派生类。
class Component {
constructor(props) {
this.props = props || {};
this.state = null;
}
static render(vdom, parent=null) {
const props = Object.assign({}, vdom.props, {children: vdom.children});
if (Component.isPrototypeOf(vdom.type)) {
const instance = new (vdom.type)(props);
instance.componentWillMount();
instance.base = render(instance.render(), parent);
instance.base.__gooactInstance = instance;
instance.base.__gooactKey = vdom.props.key;
instance.componentDidMount();
return instance.base;
} else {
return render(vdom.type(props), parent);
}
}
static patch(dom, vdom, parent=dom.parentNode) {
const props = Object.assign({}, vdom.props, {children: vdom.children});
if (dom.__gooactInstance && dom.__gooactInstance.constructor == vdom.type) {
dom.__gooactInstance.componentWillReceiveProps(props);
dom.__gooactInstance.props = props;
return patch(dom, dom.__gooactInstance.render());
} else if (Component.isPrototypeOf(vdom.type)) {
const ndom = Component.render(vdom);
return parent ? (parent.replaceChild(ndom, dom) && ndom) : (ndom);
} else if (!Component.isPrototypeOf(vdom.type)) {
return patch(dom, vdom.type(props));
}
}
setState(nextState) {
if (this.base && this.shouldComponentUpdate(this.props, nextState)) {
const prevState = this.state;
this.componentWillUpdate(this.props, nextState);
this.state = nextState;
patch(this.base, this.render());
this.componentDidUpdate(this.props, prevState);
} else {
this.state = nextState;
}
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps != this.props || nextState != this.state;
}
componentWillReceiveProps(nextProps) {
return undefined;
}
componentWillUpdate(nextProps, nextState) {
return undefined;
}
componentDidUpdate(prevProps, prevState) {
return undefined;
}
componentWillMount() {
return undefined;
}
componentDidMount() {
return undefined;
}
componentWillUnmount() {
return undefined;
}
}
静态方法会在内部被调用。
-
渲染: 执行初始化渲染,无状态组件会当成一般函数调用,结果会立刻显示。类组件实例化后会添加到DOM才会渲染。
-
更新: 执行后面更新。有时DOM节点已经有对应的组件实例绑定,向组件实例传递新属性,然后更新差异,或者执行全量渲染。
实例方法意味着用户会在派生类内部重写或调用。
-
构造函数: 处理属性和定义初始状态
-
状态调整: 处理新状态,触发对应的生命周期钩子方法,初始化更新循环
-
生命周期钩子: 在组件周期内,挂载、更新和删除之前,会被触发的一系列方法
注意没有渲染方法,意味着会在子类中定义。
以下是所有代码和todo应用示例代码:
/* Scroll down to reach playground: */
/** @jsx createElement */
const createElement = (type, props, ...children) => {
props = props != null ? props : {};
return {type, props, children};
};
const setAttribute = (dom, key, value) => {
if (typeof value == 'function' && key.startsWith('on')) {
const eventType = key.slice(2).toLowerCase();
dom.__gooactHandlers = dom.__gooactHandlers || {};
dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
dom.__gooactHandlers[eventType] = value;
dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
} else if (key == 'checked' || key == 'value' || key == 'id') {
dom[key] = value;
} else if (key == 'key') {
dom.__gooactKey = value;
} else if (typeof value != 'object' && typeof value != 'function') {
dom.setAttribute(key, value);
}
};
const render = (vdom, parent=null) => {
if (parent) parent.textContent = '';
const mount = parent ? (el => parent.appendChild(el)) : (el => el);
if (typeof vdom == 'string' || typeof vdom == 'number') {
return mount(document.createTextNode(vdom));
} else if (typeof vdom == 'boolean' || vdom === null) {
return mount(document.createTextNode(''));
} else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
return mount(Component.render(vdom));
} else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
const dom = document.createElement(vdom.type);
for (const child of [/* flatten */].concat(...vdom.children))
dom.appendChild(render(child));
for (const attr of dom.attributes) console.log(attr);
for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);
return mount(dom);
} else {
throw new Error(`Invalid VDOM: ${vdom}.`);
}
};
const patch = (dom, vdom, parent=dom.parentNode) => {
const replace = parent ? el => (parent.replaceChild(el, dom) && el) : (el => el);
if (typeof vdom == 'object' && typeof vdom.type == 'function') {
return Component.patch(dom, vdom, parent);
} else if (typeof vdom != 'object' && dom instanceof Text) {
return dom.textContent != vdom ? replace(render(vdom)) : dom;
} else if (typeof vdom == 'object' && dom instanceof Text) {
return replace(render(vdom));
} else if (typeof vdom == 'object' && dom.nodeName != vdom.type.toUpperCase()) {
return replace(render(vdom));
} else if (typeof vdom == 'object' && dom.nodeName == vdom.type.toUpperCase()) {
const pool = {};
const active = document.activeElement;
for (const index in Array.from(dom.childNodes)) {
const child = dom.childNodes[index];
const key = child.__gooactKey || index;
pool[key] = child;
}
const vchildren = [/* flatten */].concat(...vdom.children);
for (const index in vchildren) {
const child = vchildren[index];
const key = child.props && child.props.key || index;
dom.appendChild(pool[key] ? patch(pool[key], child) : render(child));
delete pool[key];
}
for (const key in pool) {
if (pool[key].__gooactInstance)
pool[key].__gooactInstance.componentWillUnmount();
pool[key].remove();
}
for (const attr of dom.attributes) dom.removeAttribute(attr.name);
for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);
active.focus();
return dom;
}
};
class Component {
constructor(props) {
this.props = props || {};
this.state = null;
}
static render(vdom, parent=null) {
const props = Object.assign({}, vdom.props, {children: vdom.children});
if (Component.isPrototypeOf(vdom.type)) {
const instance = new (vdom.type)(props);
instance.componentWillMount();
instance.base = render(instance.render(), parent);
instance.base.__gooactInstance = instance;
instance.base.__gooactKey = vdom.props.key;
instance.componentDidMount();
return instance.base;
} else {
return render(vdom.type(props), parent);
}
}
static patch(dom, vdom, parent=dom.parentNode) {
const props = Object.assign({}, vdom.props, {children: vdom.children});
if (dom.__gooactInstance.constructor == vdom.type) {
dom.__gooactInstance.componentWillReceiveProps(props);
dom.__gooactInstance.props = props;
return patch(dom, dom.__gooactInstance.render());
} else if (Component.isPrototypeOf(vdom.type)) {
const ndom = Component.render(vdom);
return parent ? (parent.replaceChild(ndom, dom) && ndom) : (ndom);
} else if (!Component.isPrototypeOf(vdom.type)) {
return patch(dom, vdom.type(props));
}
}
setState(nextState) {
if (this.base && this.shouldComponentUpdate(this.props, nextState)) {
const prevState = this.state;
this.componentWillUpdate(this.props, nextState);
this.state = nextState;
patch(this.base, this.render());
this.componentDidUpdate(this.props, prevState);
} else {
this.state = nextState;
}
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps != this.props || nextState != this.state;
}
componentWillReceiveProps(nextProps) {
return undefined;
}
componentWillUpdate(nextProps, nextState) {
return undefined;
}
componentDidUpdate(prevProps, prevState) {
return undefined;
}
componentWillMount() {
return undefined;
}
componentDidMount() {
return undefined;
}
componentWillUnmount() {
return undefined;
}
}
/* Playground: */
class TodoItem extends Component {
render() {
return <li className="todo__item">
<span>{this.props.text} - </span>
<a href="#" onClick={this.props.onClick}>X</a>
</li>;
}
}
class Todo extends Component {
constructor(props) {
super(props);
this.state = {
input: '',
items: [],
};
this.handleAdd('Goal #1');
this.handleAdd('Goal #2');
this.handleAdd('Goal #3');
}
handleInput(e) {
this.setState({
input: e.target.value,
items: this.state.items,
});
}
handleAdd(text) {
const newItems = [].concat(this.state.items);
newItems.push({
id: Math.random(),
text,
});
this.setState({
input: '',
items: newItems,
});
}
handleRemove(index) {
const newItems = [].concat(this.state.items);
newItems.splice(index, 1);
this.setState({
input: this.state.input,
items: newItems,
});
}
render() {
return <div className="todo">
<ul className="todo__items">
{this.state.items.map((item, index) => <TodoItem
key={item.id}
text={item.text}
onClick={e => this.handleRemove(index)}
/>)}
</ul>
<input type="text" onInput={e => this.handleInput(e)} value={this.state.input}/>
<button onClick={e => this.handleAdd(this.state.input)}>Add</button>
</div>;
}
}
render(<Todo/>, document.getElementById('root'));
总结
至此,我们实现了完整功能的react山寨版,Gooact用来致敬我的好朋友,我们看看最终的结果:
-
Gooact能够根据虚拟DOM引用创建和高效更新复杂DOM树
-
Gooact支持函数和类组件,实现内部状态管理和复杂生命周期钩子
-
Gooact代码由Babel转译生成
-
Gooact没压缩前只有160行代码
本文的主要目的是展示React的核心原理,无需深入辅助API来了解内部结构,所以Gooact不包括这些:
-
Gooact不支持Fragment、Portals、contenxt和引用,还有最新版引入的特性也没有
-
Gooact没有采用复杂的React Fiber,我会在后面的文章中介绍
-
Gooact不会跟踪重复键,以防有时产生bug
-
Gooact对于有些方法缺乏额外回调支持
译者注
-
译文有删减,因译者水平有限,如有错误,欢迎指正交流