深入理解 React 源码,带你从零实现 React v18 的核心功能,构建自己的 React 库。
电子书地址:https://2xiao.github.io/leetcode-js/react
源代码地址:https://github.com/2xiao/my-react
送我一个免费的 ⭐️ Star,这是对我最大的鼓励和支持。
1. 源码目录结构
我们先了解一下React 源码的项目结构,React 使用的是 Mono-repo 的结构管理各个包,源码中主要包括如下部分:
- fixtures:测试用例
- packages:主要部分,包含 scheduler,reconciler 等
- scripts:react 构建相关
其中,主要的包在 packages 目录下,主要包含以下模块:
- react:核心 Api 所在,如 React.createElement、React.Component
- react-reconclier:协调器,react 的核心逻辑所在,在 render 阶段用来构建 fiber 节点,宿主环境无关
- scheduler:调度器相关
- react-server: ssr 相关
- react-fetch: 请求相关
- react-interactions: 和事件如点击事件相关
各种宿主环境的包:
- react-dom:浏览器环境
- react-native-renderer:原生环境
- react-art:canvas & svg 渲染
- react-noop-renderer:调试或 fiber 用
辅助包:
- shared:公用辅助方法,宿主环境无关
- react-is : 判断类型
- react-client: 流相关
- react-fetch: 数据请求相关
- react-refresh: 热加载相关
我们先来实现 react 包中的 createElement
和 jsx
方法,并实现 react 包的打包流程。
2. 实现 JSX 方法
在 React 中使用 JSX 语法描述用户界面,JSX 语法就是一种语法糖,是 一种 JavaScript 语法扩展,它允许开发者在 JavaScript 代码中直接编写类似 HTML 的代码,并在运行时将其转换为 React 元素。
JSX 转换就是将 JSX 源代码变成浏览器可以理解的 JavaScript 代码的过程,以下面的代码为例:
// JSX 源代码
import React from 'react';
function App() {
return <h1>Hello World</h1>;
}
// 转换结果
// React 17之前,JSX 转换结果
import React from 'react';
function App() {
return React.createElement('div', null, 'Hello world!');
}
// React 17之后,JSX 转换结果
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
return _jsx('div', { children: 'Hello world!' });
}
JSX 转换的过程大致分为两步:
- 编译时:由 Babel 编译实现,Babel 会将 JSX 语法转换为标准的 JavaScript API;
- 运行时:由 React 实现,
jsx
方法 和React.createElement
方法;
因此,我们只需要实现运行时的部分即可,即 jsx
方法和 React.createElement
方法,包括 dev 和 prod 两个环境。
我们先在 packages
文件夹下新建 react
文件夹,进入到这个文件夹下,执行 pnpm init
:
cd packages/react
pnpm init
初始化的 package.json
文件如下所示:
// packages/react/package.json
{
"name": "react",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
其中 main
字段代表了 react 包的入口文件,main
对应的是 CommonJS 规范,由于我们的项目是使用 rollup 打包的,rollup 是原生支持 esModule 的,esModule 规范中对应 main
的字段为 module
,所以我们将入口改为:"module": "index.ts"
;然后,删除 scripts
字段,在 description
字段中增加包描述,dependencies
字段指明了包的依赖,此时的 package.json
文件如下所示:
// packages/react/package.json
{
"name": "react",
"version": "1.0.0",
"description": "react common functions",
"module": "index.ts",
"dependencies": {
"shared": "workspace:*"
},
"keywords": [],
"author": "",
"license": "ISC"
}
在 react 包下新建一个 src 目录,在 src 目录下新建一个 jsx.ts 文件。
执行 jsx
方法和 React.createElement
方法的返回结果是一种被称为 ReactElement
的数据结构,所以我们首先要定义一下 ReactElement
的构造函数:
// packages/react/src/jsx.ts
import { REACT_ELEMENT_TYPE } from 'shared/ReactSymbols';
import {
Type,
Ref,
Key,
Props,
ReactElementType,
ElementType
} from 'shared/ReactTypes';
const ReactElement = function (
type: Type,
key: Key,
ref: Ref,
props: Props
): ReactElementType {
const element = {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
__mark: 'erxiao'
};
return element;
};
其中 $$typeof
是一个内部使用的字段,通过这个字段来指明当前这个数据结构是一个ReactElement
,_mark
字段是为了与官方 react 包做区分的一个自定义字段。
我们将所有的类型定义和公共方法都放在一个公用的 shared
包中。在 packages
文件夹下新建 shared
文件夹,进入到这个文件夹下,执行 pnpm init
:
cd packages/shared
pnpm init
shared
包不需要入口文件,因为它里面的所有方法都会直接在其他包里面被引用,代码如下:
// packages/shared/package.json
{
"name": "shared",
"version": "1.0.0",
"description": "shared hepler functions and symbols",
"keywords": [],
"author": "",
"license": "ISC"
}
// packages/shared/ReactSymbols.ts
const supportSymbol = typeof Symbol === 'function' && Symbol.for;
// 表示普通的 React 元素,即通过 JSX 创建的组件或 DOM 元素
export const REACT_ELEMENT_TYPE = supportSymbol
? Symbol.for('react.element')
: 0xeac7;
// packages/shared/ReactTypes.ts
export type Type = any;
export type Key = any;
export type Props = any;
export type Ref = any;
export type ElementType = any;
export interface ReactElementType {
$$typeof: symbol | number;
key: Key;
props: Props;
ref: Ref;
type: ElementType;
__mark: string;
}
接着我们来实现 jsx
方法.
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
return _jsx('div', { children: 'Hello world!' });
}
从以上示例可以看出, jsx
方法接收两个参数,第一个参数 type
为组件的 type,第二个参数是其他配置,可能有第三个参数为组件的 children
,返回一个 ReactElement
数据结构。
// packages/react/src/jsx.ts
// ...之前的代码
export const jsx = (type: ElementType, config: any, ...children: any) => {
let key: Key = null;
let ref: Ref = null;
const props: Props = {};
for (const prop in config) {
const val = config[prop];
if (prop === 'key') {
if (val !== undefined) {
key = '' + val;
}
continue;
}
if (prop === 'ref') {
if (val !== undefined) {
ref = val;
}
continue;
}
if ({}.hasOwnProperty.call(config, prop)) {
props[prop] = val;
}
}
const childrenLength = children.length;
if (childrenLength) {
if (childrenLength === 1) {
props.children = children[0];
} else {
props.children = children;
}
}
return ReactElement(type, key, ref, props);
};
这就是完整的 jsx
方法的实现。
为了区分生产环境和开发环境,这里再定义一个 jsxDEV
方法,唯一的区别是,开发环境不处理 children
参数,方便多做一些额外的检查:
// packages/react/src/jsx.ts
// ...之前的代码
export const jsxDEV = (type: ElementType, config: any) => {
let key: Key = null;
let ref: Ref = null;
const props: Props = {};
for (const prop in config) {
const val = config[prop];
if (prop === 'key') {
if (val !== undefined) {
key = '' + val;
}
continue;
}
if (prop === 'ref') {
if (val !== undefined) {
ref = val;
}
continue;
}
if ({}.hasOwnProperty.call(config, prop)) {
props[prop] = val;
}
}
return ReactElement(type, key, ref, props);
};
新增 index.ts
文件,这个文件是 react 包的入口,导出一个对象,包含版本号 version
和 React.createElement
方法。其中,React.createElement
方法就是刚才实现的 jsx
方法。
// packages/react/index.ts
import { jsx } from './src/jsx';
export default {
version: '1.0.0',
createElement: jsx
};
至此,我们已经实现了 jsx
方法和 React.createElement
方法,并支持了 dev
和 prod
两个环境,接下来实现打包流程。
3. 实现打包流程
根据上面的示例,我们实现了 jsx
方法、jsxDEV
方法和 React.createElement
方法,需要将打包到对应的文件中:
- react/jsx-runtime.js
- react/jsx-dev-runtime.js
- react/index.js
我们的打包脚本都在 scripts/rollup
目录下,新增一个 react.config.js
文件,里面是 react 包的打包配置,再新增一个 utils.js
文件,里面是一些公用的方法。
需要先安装几个包:
pnpm i -D -w rollup-plugin-generate-package-json
pnpm i -D -w rollup-plugin-typescript2
pnpm i -D -w @rollup/plugin-commonjs
pnpm i -D -w rimraf
rollup-plugin-generate-package-json
:用于生成package.json
文件。rollup-plugin-typescript2
:用于编译 Typescript。@rollup/plugin-commonjs
:用于将 CommonJS 模块转换为 ES 模块,以便在 Rollup 中进行打包。CommonJS 是一种用于在浏览器之外执行 JavaScript 代码的模块规范,而 Rollup 默认只支持 ES 模块。rimraf
:用于删除之前的打包产物
react.config.js
导出一个数组,数组中的第一个对象即为 react/index.js
的配置,定义一下输入文件和输出文件,然后配置插件和 package.json
;数组中的第二个对象为 react/jsx-runtime.js
和 react/jsx-dev-runtime.js
的配置。
// scripts/rollup/react.config.js
import { getPackageJSON, resolvePkgPath, getBaseRollupPlugins } from './utils';
import generatePackageJson from 'rollup-plugin-generate-package-json';
const { name, module } = getPackageJSON('react');
const pkgPath = resolvePkgPath(name);
const pkgDistPath = resolvePkgPath(name, true);
export default [
// react
{
input: `${pkgPath}/${module}`,
output: {
file: `${pkgDistPath}/index.js`,
name: 'React',
format: 'umd'
},
plugins: [
...getBaseRollupPlugins(),
// 生成 package.json 文件
generatePackageJson({
inputFolder: pkgPath,
outputFolder: pkgDistPath,
baseContents: ({ name, description, version }) => ({
name,
description,
version,
main: 'index.js'
})
})
]
},
// jsx-runtime
{
input: `${pkgPath}/src/jsx.ts`,
output: [
// jsx-runtime
{
file: `${pkgDistPath}/jsx-runtime.js`,
name: 'jsx-runtime',
format: 'umd'
},
// jsx-dev-runtime
{
file: `${pkgDistPath}/jsx-dev-runtime.js`,
name: 'jsx-dev-runtime',
format: 'umd'
}
],
plugins: getBaseRollupPlugins()
}
];
// scripts/rollup/utils.js
import path from 'path';
import fs from 'fs';
import ts from 'rollup-plugin-typescript2';
import cjs from '@rollup/plugin-commonjs';
const pkgPath = path.resolve(__dirname, '../../packages');
const distPath = path.resolve(__dirname, '../../dist/node_modules');
export function resolvePkgPath(pkgName, isDist) {
if (isDist) {
return `${distPath}/${pkgName}`;
}
return `${pkgPath}/${pkgName}`;
}
export function getPackageJSON(pkgName) {
const path = `${resolvePkgPath(pkgName)}/package.json`;
const str = fs.readFileSync(path, { encoding: 'utf-8' });
return JSON.parse(str);
}
export function getBaseRollupPlugins({ typescript = {} } = {}) {
return [cjs(), ts(typescript)];
}
现在我们到根目录下的 package.json
文件中新增一个 scripts
命令:
// package.json
// ...
"scripts": {
"lint": "eslint --ext .ts,.jsx,.tsx --fix --quiet ./packages",
"build-dev": "rimraf dist && rollup --config scripts/rollup/react.config.js --bundleConfigAsCjs"
},
// ...
运行 npm run build-dev
,可以看到,根目录下的 dist/node_modules/react
文件夹中出现了 react 包的打包产物:
- index.js
- jsx-dev-runtime.js
- jsx-runtime.js
- package.json
4. 调试打包结果
1. npm link
react 包打包完之后,我们可以使用 npm link
来调试以下打包结果,流程如下图所示:
首先我们在 my-react 项目中,生成了一个 react 包的打包产物,即 dist/node_modules/react
文件夹中的内容。
然后进入到 dist/node_modules/react
目录下,通过 pnpm link --global
命令,就将全局 node_modules
下的 react
指向了我们刚刚生成的 react
包。
接着,用 create-react-app
创建一个新的 Demo 项目,在这个 Demo 项目中,再执行 pnpm link react --global
命令,就能将 Demo 项目中依赖的 react
从这个项目的node_modules/react
变成全局 node_modules
下的 react
。
这样我们就能通过 Demo 项目直接调用我们刚刚生成的 react
包了。
这种方式的优点是:可以模拟实际项目引用 React 的情况;缺点是:不支持热更新,每次更新 my-react 项目之后都需要重新打包,并在 Demo 项目中重新执行 npm run dev
,比较繁琐。
2. vite
如果想支持热更新调试,可以使用 Vite。
在根目录运行 pnpm create vite demos --template react
,语言选择 Typescript
,然后新建文件夹 demos/test-1
,将 index.html
和 main.tsx
挪进去,删除其余的文件。
在 package.json
中增加指令 npm run demo
: "vite serve demos/test-1 --config scripts/vite/vite.config.js --force"
,并安装以下依赖:
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"vite": "^5.1.4"
新建 scripts/vite/vite.config.js
文件:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import replace from '@rollup/plugin-replace';
import { resolvePkgPath } from '../rollup/utils';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), replace({ __DEV__: true, preventAssignment: true })],
resolve: {
alias: [
{
find: 'react',
replacement: resolvePkgPath('react')
},
{
find: 'react-dom',
replacement: resolvePkgPath('react-dom')
},
{
find: 'hostConfig',
replacement: path.resolve(
resolvePkgPath('react-dom'),
'./src/hostConfig.ts'
)
}
]
}
});
最后,新增 packages/react/jsx-dev-runtime.ts
文件:
export { jsxDEV } from './src/jsx';
这样只需执行 npm run demo
即可实时调试代码,实现热更新。
至此,我们就完成了 JSX 方法的开发、打包、调试。
相关代码可在 git tag v1.2
查看,地址:https://github.com/2xiao/my-react/tree/v1.2
《自己动手写 React 源码》遵循 React 源码的核心思想,通俗易懂的解析 React 源码,带你从零实现 React v18 的核心功能。
学完本书,你将有这些收获:
- 面试加分:框架底层原理是面试必问环节,熟悉 React 源码会为你的面试加分,也会为你拿下 offer 增加不少筹码;
- 提升开发效率:熟悉 React 源码之后,会对 React 的运行流程有新的认识,让你在日常的开发中,对性能优化、使用技巧和 bug 解决更加得心应手;
- 巩固基础知识:学习本书也顺便巩固了数据结构和算法,如 reconciler 中使用了 fiber、update、链表等数据结构,diff 算法要考虑怎样降低对比复杂度;
本书的特色:
- 教程详细,代码开源,带你构建自己的 React 库;
- 功能全面,可跑通官方测试用例;
- 按 Git Tag 划分迭代步骤,记录每个功能的实现过程;
电子书地址:https://2xiao.github.io/leetcode-js/react
源代码地址:https://github.com/2xiao/my-react
送我一个免费的 ⭐️ Star,这是对我最大的鼓励和支持。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。