40行代码实现简易版React render

2,277 阅读5分钟

这篇文章带大家实现一个简单的render函数,在此之前,你需要对jsx语法和DOM元素的工作原理有基本了解

const element = <h1 title="foo">Hello</h1>;
const container = document.getElementById("root");
ReactDOM.render(element, container);

我们将实现这个React渲染函数,只有三行代码:

  • 第一行通过jsx定义了一个dom
  • 第二行从html中获取一个根节点
  • 第三行将自己定义的dom注入到根节点中

注意知道的是:在这里我们只实现函数本身,不会关心jsx是如何编译成render函数的,因为那是编译器(比如Babel)的工作,jsx在编译的时候,通过某些构建工具(比如Babel)转换为js,转换过程很简单:用createElement函数代替我们定义的内容,同时将标签名、props、子元素作为参数传递给createElement函数

让我们删除所有React的代码,用普通的JavaScript代替它:

在上面的代码中,第一行是用jsx定义的元素,实际上它并不是有效的JavaScript,所以为了使用有效的js,首先我们需要用createElement替换掉jsx:

const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
);

然后再把上面代码中的createElement函数转换成普通的element对象,可以写成下面这样:

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
};

可以看到,createElement函数的作用是根据其参数创建一个对象,对象有两个属性:type和props

  • type是一个字符串,表示标签的标签名,它的作用是指定我们想要创建的DOM节点的类型,用来传递给document的tagName
  • props是另一个对象,它包含来自jsx的所有键和值,它还有一个特殊的属性:children,用来表示标签的子元素,这个例子中的子元素是个字符串,但通常children是一个包含很多元素的数组,这也就是为什么dom节点是一个树

我们需要替换的另一部分React代码是对ReactDOM.render的调用:

  • 首先,我们使用type创建一个节点,在本例中是h1
  • 然后将所有的props属性赋值给该节点,在本例中只有title
  • 然后创建子节点,在本例中只有一个字符串作为子节点,因此我们创建一个文本节点
  • 最后,我们将子节点append到父节点,并将父节点append到根节点
const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
};
const container = document.getElementById("root"); // 获取根节点
const node = document.createElement(element.type); // 使用type创建一个节点
node["title"] = element.props.title; // 将title属性赋值给该节点
const text = document.createTextNode(""); // 创建子节点
text["nodeValue"] = element.props.children; // 用nodeValue设置子节点的内容
node.appendChild(text); // 将子节点append到父节点
container.appendChild(node); // 将父节点append到根节点

现在,我们有了和开始几行代码一样的功能,但是没有使用React

接下来,让我们开始编写自己的createElement函数:

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b"),
);

正如我们在前面的步骤中所看到的:element是一个具有type和props的对象。我们的函数唯一需要做的就是创建这个对象:

function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children,
        },
    };
}

我们对props使用扩展操作符,对children参数使用rest语法,这样可以保证子元素props将始终是一个数组,例如:

  • createElement("div")返回:
{
  "type": "div",
  "props": { "children": [] },
}
  • createElement("div", null, a)返回:
{
  "type": "div",
  "props": { "children": [a] },
}
  • createElement("div", null, a, b)返回:
{
  "type": "div",
  "props": { "children": [a, b] },
}

需要注意的是:children还可以包含string或者number基本类型的值。因此,我们需要先写一个函数区分基本类型的值和对象类型的值,然后把所有不是对象类型的值封装一下,并为它们创建一个特殊的类型:TEXT_ELEMENT

function createTextElement(text) {
    return {
        type: "TEXT_ELEMENT",
        props: {
            nodeValue: text,
            children: [],
        },
    };
}

function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child => (
                typeof child === "object" ? child : createTextElement(child)
            )),
        },
    }
}

需要知道的是,在react源码中,当没有子元素时,React不会封装原始值、也不会创建空数组,而我们这样做的原因,仅仅是因为我太懒,这样写起来简单,并且对于我们简易版的render函数,我更喜欢简单的代码、而不是性能代码。所以不用去追求细节(躺平就完了)

然后仍然回到React的createElement函数:

const element = React.createElement(
    "div",
    { id: "foo" },
    React.createElement("a", null, "bar"),
    React.createElement("b"),
);

为了替换它,我们给自己的函数起个新的名字:myReactRender

const myReactRender = {
    createElement,
};

const element = myReactRender.createElement(
    "div",
    { id: "foo" },
    myReactRender.createElement("a", null, "bar"),
    myReactRender.createElement("b"),
);

目前已经实现了createElement函数,接下来需要实现render函数:

function render(element, container) {
    // TODO create dom nodes
}
const myReactRender = {
    createElement,
    render,
};
const element = myReactRender.createElement(
    "div",
    { id: "foo" },
    myReactRender.createElement("a", null, "bar"),
    myReactRender.createElement("b"),
);
const container = document.getElementById("root");
myReactRender.render(element, container);

接下来需要处理render函数:

  • 首先使用type创建DOM节点
  • 还需要处理文本节点,如果type是TEXT_ELEMENT,我们将创建一个文本节点
  • 然后递归地对每个children执行相同的操作
  • 最后将新节点append到父节点中
function render(element, container) {
    const dom = element.type == "TEXT_ELEMENT"
        ? document.createTextNode("")
        : document.createElement(element.type);
    element.props.children.forEach(child => render(child, dom));
    container.appendChild(dom);
}

最后一件事,就是将props的每一个值分配给创建的dom:

function render(element, container) {
    const dom = element.type == "TEXT_ELEMENT"
        ? document.createTextNode("")
        : document.createElement(element.type);
    Object.keys(element.props)
        .filter(key => key !== "children")
        .forEach(name => { dom[name] = element.props[name] });
    element.props.children.forEach(child => render(child, dom));
    container.appendChild(dom);
}

最后一步,如果我们仍然想在这里使用jsx语法,我们如何告诉Babel使用myReactRender的渲染函数而不是React的? 我们只需要添加这样的注释就好了,当Babel编译jsx时,它将使用我们定义的函数:

/** @jsx myReactRender.createElement */
const element = (
    <div id="foo">
        <a>11111</a>
    </div>
);

就是这样,现在我们有了一个可以将JSX渲染到DOM的库,完整代码如下:

function createTextElement(text) {
    return {
        type: "TEXT_ELEMENT",
        props: {
            nodeValue: text,
            children: [],
        },
    };
}
function createElement(type, props, ...children) {
    return {
        type,
        props: {
            ...props,
            children: children.map(child => (
                typeof child === "object" ? child : createTextElement(child)
            )),
        },
    }
}
function render(element, container) {
    const dom = element.type == "TEXT_ELEMENT"
        ? document.createTextNode("")
        : document.createElement(element.type);
    Object.keys(element.props)
        .filter(key => key !== "children")
        .forEach(name => { dom[name] = element.props[name] });
    element.props.children.forEach(child => render(child, dom));
    container.appendChild(dom);
}
const myReactRender = { createElement, render };
const container = document.getElementById("root");
/** @jsx myReactRender.createElement */
const element = (
    <div id="foo">
        <a>abc</a>
    </div>
);
myReactRender.render(element, container);

截止目前,我们用40行代码实现了react的render函数。