3
前言
实现一个简易版本的 React
JSX 渲染 DOM
实现简易版 React
就是 JSX
渲染成真实的 DOM
。关于 JSX
的解析我们就交给 @babel/plugin-transform-react-jsx
,本文我们把重点放到如何实现 createElement
函数
环境搭建
首先搭建我们的环境,安装 webpack
,babel
等并进行配置。要解析 JSX
还需要安装一个 babel
的插件 @babel/plugin-transform-react-jsx
,是这个插件将 JSX
转换成了 React
可以识别的函数。配置过程就跳过了,放一下我的 webpack.config.js
。
module.exports = {
entry: {
main: './main.js',
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'createElement' }]],
},
},
},
],
},
mode: 'development',
optimization: {
minimize: false,
},
};
如果你使用了 eslint
那么需要在配置文件的 parserOptions
字段中配置 ecmaFeatures: {jsx: true, }
,否则 eslint
会报错。
说完了环境我们就来看一看上面说的 @babel/plugin-transform-react-jsx
插件是如何转化我们的 JSX
代码的。我们随便写一段带自定义标签的 JSX
然后看看打包后的 JS
代码使什么样的。
<MyComponent id="a" class="b">
<div>
clloz
<div>test</div>
</div>
<div>hello</div>
<div>world</div>
</MyComponent>
npx webpack
之后打开浏览器查看打包后的文件,得到以下内容:
createElement(
createElement(
MyComponent,
{
id: 'a',
class: 'b',
},
createElement('div', null, 'clloz', createElement('div', null, 'test')),
createElement('div', null, 'hello'),
createElement('div', null, 'world'),
);
我在上面的
babel
插件中配置了{ pragma: 'createElement' }
,如果你没有那么这里的方法应该是React.createElement
。
看完这段打包后的代码我们基本上就明白了,插件将 HTML
结构的代码转化成了一个函数。函数的参数可以分为三个部分,根节点,根节点属性,以及子节点,子节点依然是这个函数调用(复合 HTML
的结构)。
我们要做的就是实现这个函数,根据函数的参数将对应的 HTML
结构用 DOM
方法生成。
creatElement 实现
JSX
被转化成一个 createElement
函数,我们最后需要生成一个真实的 DOM
结构挂载到对应的元素上。根据需求,我们可以设计 createElement
返回 DOM
结构,然后在用一个简单的函数实现挂载。结构如下:
component = <div>
<h1>my component</h1>
{this.children}
</div>
//{this.children} 是 jsx 语法,插件会把 this.children 作为 createElement 的最后一个参数。
render(
<MyComponent id="a" class="b">
<div {...this.props}> //用props传递属性,用扩展运算符展开
clloz
<div>test</div>
</div>
<div>hello</div>
<div>world</div>
</MyComponent>,
document.body,
);
function createElement(type, attributes, ...children) {
//...
}
function render(component, parentElement) {
parentElement.appendChild(parentElement);
}
现在的重点就是 createElement
的设计。我们分析一下 createElement
的参数。第一个参数是 type
,它有两种可能,一种是组件名,一种是表示 tagName
的字符串;第二个参数是属性,它是一个对象或者是 null
;从第三个参数开始都是子节点,如果是元素节点则是一个嵌套的 createElement
函数调用,如果是文本节点则是一个字符串。
我们想要生成 DOM
树要做的就是根据 tagName
用 document.createElement
创建元素,用 setAttribute
设置元素属性。
对于子元素的处理,用 for ... of ...
进行循环,先判断是否为文本节点,如果是则用 document.createTextNode
创建;接着判断是否多个子元素,如果是的话则进行递归,否则挂载创建的的 DOM
节点。根据这个逻辑我们可以完成 createElement
方法。
function createElement(type, attributes, ...children) {
let el;
if (typeof type === 'string') {
el = document.createElement(type);
} else {
el = new type();
}
for (let attr in attributes) {
el.setAttribute(attr, attributes[attr]);
}
let insertChildren = children => {
for (let child of children) {
if (typeof child === 'string') {
child = document.createTextNode(child);
}
if (typeof child === 'object' && Array.isArray(child)) {
insertChildren(child);
} else {
// console.log(el, child);
el.appendChild(child);
// console.log(el);
}
}
};
insertChildren(children);
return el;
}
普通元素的逻辑这样就算处理完了,接下来就是要处理组件模版了。首先模版本身是一个 HTML
结构,然后其内部再嵌套一个 HTML
结构。由于组件本身是一个标识符,我们不能直接对其执行 DOM API
,为了保持和 DOM
元素的接口一致,我们可以用一个类来实现组件,内部添加我们需要的用到的 appendChild
和 setAttribute
方法。
class Component {
constructor() {
this.props = Object.create(null);
this.children = [];
}
setAttribute(name, value) {
this.props[name] = value;
}
appendChild(el) {
this.children.push(el);
}
get template() {
return this.render();
}
}
class MyComponent extends Component {
render() {
return (
<div {...this.props}>
<h1>my component</h1>
{this.children}
</div>
);
}
}
首先要明确一点的是,组件本身内部的 JSX
也会被插件解析,所以他也是在 createElement
下工作的。这里我们用一个 Component
作为父类,所有组件继承自这个父类。例子中的 MyComponent
实例就一个方法,用来返回插件解析后的 JSX
的执行结果。
在 Component
类中写了一些属性和方法,这些都是应对模版不能直接使用 DOM API
。我们用一个 props
来存放属性,用一个 children
来存放子元素。最后用一个访问器属性来返回最终生成的 DOM
结构。
render(
<MyComponent id="a" class="b">
<div>
clloz
<div>test</div>
</div>
<div>hello</div>
<div>world</div>
</MyComponent>,
document.body,
);
function render(component, parentElement) {
parentElement.appendChild(component.template);
}
最终我们成功渲染出如下 DOM
结构:
<div id="a" class="b">
<h1>my component</h1>
<div>
clloz<div>test</div>
</div>
<div>hello</div>
<div>world</div>
</div>;
执行过程梳理
我们来梳理一下整个代码的执行流程。
代码的入口是 render
函数的执行。render
函数的参数是一个 createElement
方法调用,所以会先执行这个方法。在本例中这个参数是:
createElement(
MyComponent,
{
id: 'a',
class: 'b',
},
createElement('div', null, 'clloz', createElement('div', null, 'test')),
createElement('div', null, 'hello'),
createElement('div', null, 'world'),
);
这个 createElement
中又有 createElement
调用作为参数,所以依然会先计算出参数,此例中会依次计算出 test
,clloz
,hello
和 world
。然后会进入 createElement(MyComponet, attributes, ...children)
,此时 children
已经是 DOM
元素,createElement
做的就是 new
一个 MyComponent
对象,然后把属性写入实例的 props
属性,children
全部 push
到实例的 children
属性。我们可以在 createElement
函数中打印参数 type
和 children
以及 最后返回的 el
来验证我们的结果,结果见下图。
到这里,render
函数的参数全部准备好了,就是一个 MyComponent
实例,实例的 props
属性存放属性,children
属性存放子元素(已转为 DOM
元素)。
接下来就是执行 render
函数中的 parentElement.appendChild(component.template);
,这里 component.template
属性是一个访问器属性,会返回实例的 render
方法。render
方法中返回的就是组件对应的模版,这里需要注意的是模版也已经被 babel
插件转化为 JavaScript
代码了,见下面的代码。
class MyComponent extends Component {
render() {
return (
<div {...this.props}>
<h1>my component</h1>
{this.children}
</div>
);
}
}
createElement("div", null, createElement("h1", null, "my component"), this.children);
这里就很简单了,按照上面的流程,先生成 h1
,然后生成成最外层的 div
(注意这里用 {...this.props}
传递了元素属性),最后将 children
中的子元素 append
到 div
上就结束了。
整个流程执行过程中可能让人有点混乱的就是代码的执行顺序,因为我们的 createElement
方法是嵌套使用的,createElement
方法调用的函数是另一个 createElement
方法调用,并且可能嵌套多层。我们这里是只嵌套两层。其实我们完全可以忽略 createElement
内部的细节,如果是非模版的 tagName
或者文本,那么返回的就是一个元素节点或者文本节点;如果是一个 component
那么就会在渲染的时候调用模版的 render()
方法将模版渲染(模版内也都是普通的 tagName
和文本)并将模版内的子元素进行挂载。
我们在思考大的流程的时候应该把函数或者模块当做一个黑盒,关注输入输出即可。如果感觉这样比较吃力,很可能是我们的函数或模块设计的有问题,应该分开的逻辑耦合在了一起。
我们上面的代码还有个问题,就是不能处理嵌套的模版。模版的渲染需要使用访问器属性 template
,我们的跟模版在 render
函数中直接传入的 component.template
作为参数,而内部嵌套的模版需要添加调用的地方。实现其实也非常简单,我们在 createElement
函数中 el.appendChild(child)
的时候加一个判断,当前传入的 child
是否是一个 Component
,如果是则传入 child.template
。
if (child instanceof Component) {
el.appendChild(child.template);
} else {
el.appendChild(child);
}
现在我们就能成功渲染嵌套的模版了,第一部分将 JSX
生成 DOM
就完成了。这部分代码在 Github仓库 的 main_bak.js
中。
这里需要注意一点的是,如果多次重复执行
component.template
, 包括在Component
类的render
方法中console.log
,都只有最后一次才会将children
中的DOM
真正append
到父元素上。原因Node.appenChild
方法会删除已经存在于文档树中的节点。
完整代码在toy-react – Github
数据和渲染
现在我们实现了最基本的根据模版来渲染我们的 HTML
,不过这里的 JSX
是一个静态的内容,实际的项目中我们肯定是有动态的数据的。那么下一步就是和数据的结合,我们如何注入数据以及如何在数据变更后重新渲染我们的视图。
渲染
我们的第一部分的内容中,我们实现了一个 createElement
能够进行基础的渲染。可是仔细想一想,我们目前能做的就是把生成的 DOM
挂载到一个指定的父元素上面,这显然是不够的。在正常情况下,我们模版的位置不一定就是挂载到某个元素的最后,很可能是在元素的中间并且在变化的时候需要重新渲染,当前的这种直接 appendChild
到元素的做法肯定是不够精准的。
在进行下一阶段的工作之前我们先把上一部分的代码进行一个简单的重构。首先是我们的 createElement
函数中,元素节点和文本节点的创建都是直接用 DOM
方法写在里面的,这在我们功能比较单一的时候是没有什么问题的。但是为了方便功能的扩展(我们要在创建节点的时候添加复杂一点的逻辑)以及解耦,我们把这部分代码独立出来。
class ElementWrapper {
constructor(type) {
this.root = document.createElement(type);
}
setAttribute(name, value) {
this.root.setAttribute(name, value);
}
appendChild(component) {
this.root.appendChild(component.root);
}
}
class TextWrapper {
constructor(content) {
this.root = document.createTextNode(content);
}
}
我们将节点的创建和属性设定独立出来,生成的节点挂载到实例的 root
属性下。为了保持接口的一致性,我们将Component
类的访问器属性改为 root
。由于现在我们的元素节点和文本节点挂载到对应实例的 root
属性下,这样我们的嵌套模版的渲染调用也不用再特别处理了,ElementWrapper
在 appendChild
的时候就是访问的 root
属性,如果是 Component
的话就会访问访问器属性root
触发模版的 render
,这样我们的代码逻辑更清晰,耦合性更低。我们的 Component
最后效果如下:
class Component {
constructor() {
this.props = Object.create(null);
this.children = [];
}
setAttribute(name, value) {
this.props[name] = value;
}
appendChild(component) {
this.children.push(component);
}
get root() {
if (!this._root) this._root = this.render().root;
return this._root;
}
}
最后将 createElement
方法中的 document.createElement
和 document.createTextNode
换成对应类的方法即可。
修改完所有方法后我们将和模版无关的 ElementWrapper
,TextWrapper
,createElement
和 render
都放到一个单独的 toy-react.js
中。
重构完代码后,就要着手修改渲染部分的代码了。先明确一个基本的需求就是精确插入。要实现精确插入最好的 DOM API
就是 Range
。下面我们就用 Range
来重写我们原来代码中 appendChild
的部分。关于 Range
的 API
请参考 MDN
Component
类的修改是最简单的,因为 Component
最终渲染成的也是真实的 DOM
元素,我们在 Component
类中只要调用实例的 render
方法即可(即执行模版对应的 createElement
函数)。
//删除访问属性root,添加一个新的属性
[RENDER_TO_DOM](range) {
this.render()[RENDER_TO_DOM](range);
}
这里的 [RENDER_TO_DOM]
是用 Symbol
创建的一个变量,由于现在静态方法还没有很完善的支持,我们可以使用 Symbol
来模拟静态方法,虽然还是被继承,但是减少被访问的几率。
下面是改造 ElementWrapper
和 TextWrapper
,主要就是添加 [RENDER_TO_DOM]
方法以及改变 appendChild
方法。
class ElementWrapper {
constructor(type) {
this.root = document.createElement(type);
}
setAttribute(name, value) {
this.root.setAttribute(name, value);
}
appendChild(component) {
let range = document.createRange();
range.setStart(this.root, this.root.childNodes.length);
range.setEnd(this.root, this.root.childNodes.length);
component[RENDER_TO_DOM](range);
}
[RENDER_TO_DOM](range) {
range.deleteContents();
range.insertNode(this.root);
}
}
class TextWrapper {
constructor(content) {
this.root = document.createTextNode(content);
}
[RENDER_TO_DOM](range) {
range.deleteContents();
range.insertNode(this.root);
}
}
ElementWrapper
类中的 appendChild
我们用 Range
就定位在了 root
的最后一个节点的后面(注意setStart
和 setEnd
偏移量表示的是位置而不是第几个节点,有 n
个节点就有 n+1
个位置),因为 Element
的 appendChild
就是插入到最后的;TextWrapper
类不需要有插入后代的逻辑。然后就是插入的逻辑,和 Component
类一样,用 [RENDER_TO_DOM]
作为方法名,逻辑就是用 deleteContents
方法先删除 Range
对应的内容,再插入,实现渲染的效果。从这里我们就可以看出,我们的 Component
类的功能就是通过调用实例的 render()
方法执行 createElement
,封装 attributes
,children
,最终的渲染都是交给 ElementWrapper
和 TextWrapper
。
接下来就是修改最外层的 render
函数,原来我们就是通过 appendChild
直接将渲染好的 DOM
结构挂载到 parentElement
的最后,现在我们同样用 Range
来确定范围。这里其实可以根据我们的需求来精确设定 Range
的范围,不过我们暂时先把 Range
设为整个 parentElement
的全部,也就是我们会清空 parentElement
然后在插入。
export function render(component, parentElement) {
let range = document.createRange();
range.setStart(parentElement, 0);
range.setEnd(parentElement, parentElement.childNodes.length);
range.deleteContents();
component[RENDER_TO_DOM](range);
}
这样,整个代码就全部修改完成了,这部分主要工作就是修改了生成的 DOM
元素的插入逻辑。npx webpack
执行后渲染成功。这部分代码在 Github仓库 的 part2
文件夹中。
我们再将整个代码的流程梳理一下:render
函数作为入口,他的第一个参数是 createElement
的调用,给我们返回了一个模板类的实例(实例中封装了模版的属性,子节点),我们在 render
函数中调用实例的 [RENDER_TO_DOM]
(也就是 Component
类中的)方法来渲染模版。渲染过程就是调用模版的 render
方法(返回一个 createElement
函数调用,渲染模版对应的 DOM
结构),最终获得完整的 DOM
结构。
重新渲染
下面一步我们来实现模版的重新渲染,当我们的数据发生变化的时候,我们需要重新渲染我们的模版。这里我们来实现一个简单的点击按钮增加计数的功能。我们的模版如下:
class MyComponent extends Component {
constructor() {
super();
this.state = {
a: 1,
b: 2,
};
}
render() {
return (
<div {...this.props}>
<h1>{this.state.a.toString()}</h1>
<button
onclick={() => {
this.state.a++;
this.rerender();
}}
></button>
{this.children}
</div>
);
}
}
我们用 state
来保存一些状态,给 button
绑定了一个 onclick
方法改变状态,并且用 rerender
方法来重新渲染。rerender
的实现其实很简单,只要把 Range
中的内容删除,然后重新调用一下 render
即可。同时我们要在 ElementWrapper
中给带 on
的属性绑定事件。修改后的代码如下:
//Component rerender
rerender() {
this._range.deleteContents();
this[RENDER_TO_DOM](this._range);
}
//ElementWrapper setAttribute 修改
setAttribute(name, value) {
if (name.match(/^on([\s\S]+)$/)) {
this.root.addEventListener(
RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase()),
value,
);
} else {
this.root.setAttribute(name, value);
}
}
这里我们用正则表达式来匹配 on
开头的属性,[\s\S]
匹配所有字符(关于正则表达式参考另一篇文章:正则表达式入门及JavaScript中的应用,然后绑定对应的时间,在绑定事件的时候我们对首字母进行了小写处理。
接下来我们将事件绑定的接口进行一下封装,写一个 setState
函数,内部传入一个新的对象,我们将新对象和旧对象进行合并(深拷贝),然后调用 rerender
重新渲染,达到更新视图的目的。
setState(newObj) {
if (this.state === null || typeof this.state !== 'object') {
this.state = newObj;
this.rerender();
return;
}
let merge = (oldState, newState) => {
for (let p in newState) {
if (oldState[p] === null || typeof oldState[p] !== 'object') {
oldState[p] = newState[p];
} else {
merge(oldState(p), newState[p]);
}
}
};
merge(this.state, newObj);
this.rerender();
}
//模版
render() {
return (
<div {...this.props}>
<h1>{this.state.a.toString()}</h1>
<button onclick={() => {this.setState({ a: this.state.a + 1 });}}>
add
</button>
{this.children}
</div>
);
}
本段代码在 Github仓库 的 part3
文件夹中。
现在我们的 toy-react
已经有一些基本功能了,我们把React tutorial 中实现的三子棋游戏中的 react
替换为我们的 toy-react.js
也是可以执行的。到 CodePen 中复制 JS
到我们的 main.js
中(import
保留,取到其中的 React.
),然后在 main.html
中添加一个 <di id="root"></di>
和样式。打包完成后我们会发现已经能够运行了,虽然跟 react
比起来我们的渲染是非常低效的,不过一个基本的框架已经有了。
在我们的 Component
类的 rerender
方法中由于空 Range
会合并的情况存在,需要保持 Range
不为空,代码如下。
rerender() {
let oldRange = this._range;
let range = document.createRange();
range.setStart(oldRange.startContainer, oldRange.startOffset);
range.setEnd(oldRange.startContainer, oldRange.startOffset);
this[RENDER_TO_DOM](range);
oldRange.setStart(range.endContainer, range.endOffset);
oldRange.deleteContents();
}
虚拟 DOM
我们现在是只要触发 setState
就会更新 DOM
,并且是重新渲染整个组件。DOM
的更新是非常消耗性能的。而且我们可能只是改动了组件中的很小的一个部分,最后我们整个组件对应的 DOM
全部进行了重新渲染,这肯定是非常影响性能的。实际上这也正是 React
着重解决的问题之一,我们只要关心数据,DOM
的渲染交给 React
即可,它会帮我们高效地完成。这其中的中点就是 Virtual DOM
和 diff
算法。
具体怎么理解呢?我们可以回到我们的例子中去。我们的 JSX
有三种结构:Component
,Element
,TextNode
,也就对应到了我们的三个类:Component
,ElementWrapper
和 TextWrapper
。Component
类是将属性,后代都当做实例的一个属性存储,而不是像后两者是用 DOM API
创建真正的节点。而我们完全可以将 Element
和 TextNode
像 Component
一样处理,将属性和后代用对象的形式表示。最终我们生成的就不是一个 DOM
数,而是一颗 Object
的树,他的结构和 DOM
类似,这就是我们的 Virtual DOM
。
有了这个 Virtual DOM
我们就可以优化我们的渲染了。当我们对组件进行了更新,我们不再是重新渲染整个组件,而是生成新的 VDOM
,然后和旧的 VDOM
进行对比,然后只渲染必要的地方,这也就是 Virtual DOM diff
算法,也就是 React
和 Vue
优化性能最重要的部分。