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

xxx 曾经说过:要想真正了解一个框架,就去实现一个。
最近为了了解 React 的底层原理,实现了一个迷你 React,其中包括渲染机制,函数组件和类组件,DOM 的 diff 算法,类组件的生命周期,hooks 等,后续还将完善实现 React18 的 Fiber 架构,并发模式,时间切片和调度系统。
在这个系列文章的第一篇,会先完成一个小目标,将代码中的 jsx 渲染到浏览器中,正常显示。要完成这一步,需要以下几个部分:

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

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

01 环境搭建

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

lua 复制代码
npx create-react-app my-react

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

java 复制代码
.
├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│   └── index.html
└── src
    ├── index.js

02 jsx 的本质

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

javascript 复制代码
const element = (
  <div className="test">
    xxx
    <span>
      aaa
      <span>bbb</span>
    </span>
  </div>
);

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

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

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

javascript 复制代码
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,格式如下:

javascript 复制代码
vnode = {
    type: 'div',
    props,
    children,
    children,
    ......
}

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

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

javascript 复制代码
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 的方式:

json 复制代码
 "scripts": {
    "start": "react-scripts start",
  }
  
  改为:
  
   "scripts": {
    "start": "DISABLE_NEW_JSX_TRANSFORM=true react-scripts start",
  }

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

javascript 复制代码
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 代码显示在页面上需要什么样的过程?

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

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

04 React.createElement 实现

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

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

javascript 复制代码
export const REACT_ELEMENT = Symbol("react.element");

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

javascript 复制代码
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 的函数:

javascript 复制代码
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 出去:

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

...上面的创建 DOM 逻辑

const ReactDOM = {
  render,
};

export default ReactDOM;

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

javascript 复制代码
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 后调用:

javascript 复制代码
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 文件就可以尝试使用了。

javascript 复制代码
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 功能!

相关推荐
Ticnix27 分钟前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人30 分钟前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl34 分钟前
OpenClaw 深度技术解析
前端
崔庆才丨静觅37 分钟前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空1 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust
Mr Xu_1 小时前
Vue 3 中计算属性的最佳实践:提升可读性、可维护性与性能
前端·javascript
jerrywus1 小时前
我写了个 Claude Code Skill,再也不用手动切图传 COS 了
前端·agent·claude
玖月晴空1 小时前
探索关于Spec 和Skills 的一些实战运用-Kiro篇
前端·aigc·代码规范