likes
comments
collection
share

手写源码:从零实现迷你 React 01

作者站长头像
站长
· 阅读数 22

xxx 曾经说过:要想真正了解一个框架,就去实现一个。

最近为了了解 React 的底层原理,实现了一个迷你 React,其中包括渲染机制,函数组件和类组件,DOM 的 diff 算法,类组件的生命周期,hooks 等,后续还将完善实现 React18 的 Fiber 架构,并发模式,时间切片和调度系统。

在这个系列文章的第一篇,会先完成一个小目标,将代码中的 jsx 渲染到浏览器中,正常显示。要完成这一步,需要以下几个部分:

  • 环境搭建
  • jsx 的本质
  • React.createElement 实现
  • render 实现

PS:以下的实现代码均是以 React 原理和流程为基础,不会完全按照 React 代码去进行实现!

01 环境搭建

首先,使用 create-react-app 这个脚手架生成一个 React 项目

npx create-react-app my-react

接着删除所有无用的代码,只保留最基本的 html 文件和 js 文件

.
├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│   └── index.html
└── src
    ├── index.js

02 jsx 的本质

我们在使用 React 开发中,会写出下面的代码:

const element = (
  <div className="test">
    xxx
    <span>
      aaa
      <span>bbb</span>
    </span>
  </div>
);

这个看起来像是 html 的代码,其实是 jsx 代码,是 JavaScript 的语法扩展,本质上就是 js 代码。 当然,直接拿这段代码到浏览器里是无法执行的!

如果想要这段代码成功执行,还需要借助 babel 进行编译。

在 React17 之前,babel 会将上面的 jsx 代码转换下面的 js 代码:

const element = /*#__PURE__*/ React.createElement(
  "div",
  {
    className: "test",
  },
  "xxx",
  /*#__PURE__*/ React.createElement(
    "span",
    null,
    "aaa",
    /*#__PURE__*/ React.createElement("span", null, "bbb")
  )
);

可以看出,在 React 框架里有这样一个方法:React.createElement,这个方法的作用就是生成一个虚拟 DOM 的。通过调用这个方法,可以将我们写的 jsx 代码生成对应的虚拟 DOM 对象,也就是 vnode,格式如下:

vnode = {
    type: 'div',
    props,
    children,
    children,
    ......
}

而在 React17 以后,有了一个变化就是我们不需要在每个组件里面引入 React 了!在这之前,我们的组件代码里,必须要写上 import React from "./react" 这句,不然运行的时候会报错,就是因为在底层调用了 React.createElement 这个方法。

React17 以后,生成虚拟 DOM 的函数变了,变成了 jsx-runtime 这种方式:

import { jsx as _jsx } from "react/jsx-runtime";
import { jsxs as _jsxs } from "react/jsx-runtime";
const element = /*#__PURE__*/_jsxs("div", {
  className: "test",
  children: ["xxx", /*#__PURE__*/_jsxs("span", {
    children: ["aaa", /*#__PURE__*/_jsx("span", {
      children: "bbb"
    })]
  })]
});

后面的代码里,我的迷你 React 项目里也会实现基于这种方式生成 vnode。

那么,由于通过 create-react-app 脚手架生成的项目默认是基于 React18 的,所以我们还需要更改代码里的配置才能开始变成自己的迷你 React 项目。

第一步,在项目的 package.json 文件里更改脚本命令,这个配置关闭了 React17 之后使用 jsx-runtime 生成虚拟 DOM 的方式:

 "scripts": {
    "start": "react-scripts start",
  }
  
  改为:
  
   "scripts": {
    "start": "DISABLE_NEW_JSX_TRANSFORM=true react-scripts start",
  }

第二步,打开根目录下的 index.js 文件,将里面的代码修改为 React 之前的引入方式:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

改为: 
import React from "react";
import ReactDOM from "react-dom";
ReactDOM.render(<div>111</div>, document.getElementById("root"));

这些基础配置做完后,在我们的项目运行成功后,应该可以正常显示渲染结果,接下来,我们就可以正式开始编写迷你 React 的代码了!

03 渲染原理解释

在 React 中,想要把一个 jsx 代码显示在页面上需要什么样的过程?

手写源码:从零实现迷你 React 01

说白了就是将jsx -> 虚拟 DOM -> 真实 DOM -> 挂载

那么下面我们就一步步开始编写这个过程的代码。

04 React.createElement 实现

首先是将 jsx 代码转换成虚拟 DOM,这个过程将通过 createElement 这个函数来实现。

在 src 中新建 utils.js 文件,新建一个常量

export const REACT_ELEMENT = Symbol("react.element");

在 src 文件夹里新建 react.js 文件,在这个文件中编写 createElement 函数。

import { REACT_ELEMENT } from "./utils";

function createElement(type, properties = {}, children) {
  // 获取到 properties 中的 ref 和 key
  const ref = properties.ref || null;
  const key = properties.key || null;

  // 将 properties 中的 ref 和 key 删除∏
  ["ref", "key", "__self", "__source"].forEach((key) => {
    delete properties[key];
  });

  const props = { ...properties };

  // 如果参数多于 3 个,说明有子元素存在,在 props 上增加 children
  if (arguments.length > 3) {
    props.children = Array.prototype.slice.call(arguments, 2);
  } else {
    props.children = children;
  }

  const vnode = {
    $$typeof: REACT_ELEMENT,
    type,
    ref,
    key,
    props,
  };
  return vnode;
}

const React = {
  createElement,
};

export default React;

05 render 实现

经过使用 createElement 这个函数后,我们已经得到了我们编写的 jsx 代码的虚拟 DOM 对象,下一步,就是将这个虚拟 DOM 对象传递到 render 函数中,进行转换成真实 DOM 并且挂载。

在 src 文件夹中新建 react-dom.js 文件,在这个文件里写几个函数。

首先,我们会创建一个创建 DOM 的函数:

function mount(VNode, containerDOM) {
  let newDOM = createDOM(VNode);
  newDOM && containerDOM.appendChild(newDOM);
}

function mountArray(children, parent) {
  for (let i = 0; i < children.length; i++) {
    if (typeof children[i] === "string") {
      parent.appendChild(document.createTextNode(children[i]));
    } else {
      mount(children[i], parent);
    }
  }
}

// 创建真实 DOM
function createDOM(VNode) {
  const { type, props } = VNode;
  let dom;
  // 创建真实 dom
  if (type && VNode.$$typeof === REACT_ELEMENT) {
    dom = document.createElement(type);
  }
  // 创建子元素
  if (props) {
    // 判断子元素是什么类型,包括对象,数组,字符串
    if (typeof props.children === "object" && props.children.type) {
      mount(props.children, dom);
    } else if (Array.isArray(props.children)) {
      mountArray(props.children, dom);
    } else if (typeof props.children === "string") {
      dom.appendChild(document.createTextNode(props.children));
    }
  }

  return dom;
}

下一步,在 render 函数中调用,并且将函数 export 出去:

// 通过虚拟 dom 创建真实 dom,并且挂载到根节点上
function render(VNode, containerDOM) {
  mount(VNode, containerDOM);
}

...上面的创建 DOM 逻辑

const ReactDOM = {
  render,
};

export default ReactDOM;

接下来,我们还需要处理事件和样式,由于事件比较复杂,这里先放到后续文章中实现:

function setPropsFromDOM(dom, VNodeProps = {}) {
  if (!dom) return;
  for (let key in VNodeProps) {
    if (key == "children") continue;
    if (/^on[A-Z].*/.test(key)) {
      // 事件
      // TODO 处理事件
    } else if (key == "style") {
      // 样式 例如 {color: 'red'}
      // 通过遍历讲样式添加到 dom
      Object.keys(VNodeProps[key]).forEach((styleName) => {
        dom.style[styleName] = VNodeProps[key][styleName];
      });
    } else {
      dom[key] = VNodeProps[key];
    }
  }
}

这里主要是通过遍历的方式将样式添加到 DOM 上,在生成真实 DOM 后调用:

function createDOM(VNode) {
  const { type, props } = VNode;
  let dom;
  // 创建真实 dom
  if (type && VNode.$$typeof === REACT_ELEMENT) {
    dom = document.createElement(type);
  }
  // 创建子元素
  if (props) {
    // 判断子元素是什么类型,包括对象,数组,字符串
    if (typeof props.children === "object" && props.children.type) {
      mount(props.children, dom);
    } else if (Array.isArray(props.children)) {
      mountArray(props.children, dom);
    } else if (typeof props.children === "string") {
      dom.appendChild(document.createTextNode(props.children));
    }
  }

  // 处理 props
  setPropsFromDOM(dom, props);

  return dom;
}

经过了这一步,我们在 index.js 文件就可以尝试使用了。

import React from "./react";
import ReactDOM from "./react-dom";

ReactDOM.render(
  <div className="box" style={{ color: "red" }}>
    Hello React
  </div>,
  document.getElementById("root")
);

运行后可以看到,我们编写的代码,可以正常的显示在网页上了,并且样式也都在!

大功告成!

在下一篇文章中,将继续完善这个迷你 React 功能,增加渲染函数组件,类组件,以及 setState 功能!