原文链接

前言

实现一个简易版本的 React

JSX 渲染 DOM

实现简易版 React 就是 JSX 渲染成真实的 DOM。关于 JSX 的解析我们就交给 @babel/plugin-transform-react-jsx,本文我们把重点放到如何实现 createElement 函数

环境搭建

首先搭建我们的环境,安装 webpackbabel等并进行配置。要解析 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 树要做的就是根据 tagNamedocument.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 元素的接口一致,我们可以用一个类来实现组件,内部添加我们需要的用到的 appendChildsetAttribute 方法。

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 调用作为参数,所以依然会先计算出参数,此例中会依次计算出 testcllozhelloworld。然后会进入 createElement(MyComponet, attributes, ...children),此时 children 已经是 DOM 元素,createElement 做的就是 new 一个 MyComponent 对象,然后把属性写入实例的 props 属性,children 全部 push 到实例的 children 属性。我们可以在 createElement 函数中打印参数 typechildren 以及 最后返回的 el来验证我们的结果,结果见下图。

jsx-parser1

到这里,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 中的子元素 appenddiv 上就结束了。

jsx-parser1

整个流程执行过程中可能让人有点混乱的就是代码的执行顺序,因为我们的 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 属性下,这样我们的嵌套模版的渲染调用也不用再特别处理了,ElementWrapperappendChild 的时候就是访问的 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.createElementdocument.createTextNode 换成对应类的方法即可。

修改完所有方法后我们将和模版无关的 ElementWrapperTextWrappercreateElementrender 都放到一个单独的 toy-react.js 中。


重构完代码后,就要着手修改渲染部分的代码了。先明确一个基本的需求就是精确插入。要实现精确插入最好的 DOM API 就是 Range。下面我们就用 Range 来重写我们原来代码中 appendChild 的部分。关于 RangeAPI 请参考 MDN

Component 类的修改是最简单的,因为 Component 最终渲染成的也是真实的 DOM 元素,我们在 Component 类中只要调用实例的 render 方法即可(即执行模版对应的 createElement 函数)。

//删除访问属性root,添加一个新的属性
[RENDER_TO_DOM](range) {
    this.render()[RENDER_TO_DOM](range);
}

这里的 [RENDER_TO_DOM] 是用 Symbol 创建的一个变量,由于现在静态方法还没有很完善的支持,我们可以使用 Symbol 来模拟静态方法,虽然还是被继承,但是减少被访问的几率。

下面是改造 ElementWrapperTextWrapper,主要就是添加 [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 的最后一个节点的后面(注意setStartsetEnd 偏移量表示的是位置而不是第几个节点,有 n 个节点就有 n+1 个位置),因为 ElementappendChild 就是插入到最后的;TextWrapper 类不需要有插入后代的逻辑。然后就是插入的逻辑,和 Component 类一样,用 [RENDER_TO_DOM] 作为方法名,逻辑就是用 deleteContents 方法先删除 Range 对应的内容,再插入,实现渲染的效果。从这里我们就可以看出,我们的 Component 类的功能就是通过调用实例的 render() 方法执行 createElement,封装 attributeschildren,最终的渲染都是交给 ElementWrapperTextWrapper

接下来就是修改最外层的 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 DOMdiff 算法。

具体怎么理解呢?我们可以回到我们的例子中去。我们的 JSX 有三种结构:ComponentElementTextNode,也就对应到了我们的三个类:ComponentElementWrapperTextWrapperComponent 类是将属性,后代都当做实例的一个属性存储,而不是像后两者是用 DOM API 创建真正的节点。而我们完全可以将 ElementTextNodeComponent 一样处理,将属性和后代用对象的形式表示。最终我们生成的就不是一个 DOM 数,而是一颗 Object 的树,他的结构和 DOM 类似,这就是我们的 Virtual DOM

有了这个 Virtual DOM 我们就可以优化我们的渲染了。当我们对组件进行了更新,我们不再是重新渲染整个组件,而是生成新的 VDOM,然后和旧的 VDOM 进行对比,然后只渲染必要的地方,这也就是 Virtual DOM diff 算法,也就是 ReactVue 优化性能最重要的部分。