likes
comments
collection
share

自己动手写 React 源码——【2】实现 JSX

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

自己动手写 React 源码——【2】实现 JSX

深入理解 React 源码,带你从零实现 React v18 的核心功能,构建自己的 React 库。

电子书地址:2xiao.github.io/leetcode-js…

源代码地址:github.com/2xiao/my-re…

送我一个免费的 ⭐️ 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 包中的 createElementjsx 方法,并实现 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 包的入口,导出一个对象,包含版本号 versionReact.createElement 方法。其中,React.createElement 方法就是刚才实现的 jsx 方法。

// packages/react/index.ts
import { jsx } from './src/jsx';
export default {
	version: '1.0.0',
	createElement: jsx
};

至此,我们已经实现了 jsx 方法和 React.createElement 方法,并支持了 devprod 两个环境,接下来实现打包流程。

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.jsreact/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 来调试以下打包结果,流程如下图所示:

自己动手写 React 源码——【2】实现 JSX

首先我们在 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.htmlmain.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 查看,地址:github.com/2xiao/my-re…


《自己动手写 React 源码》遵循 React 源码的核心思想,通俗易懂的解析 React 源码,带你从零实现 React v18 的核心功能。

学完本书,你将有这些收获:

  • 面试加分:框架底层原理是面试必问环节,熟悉 React 源码会为你的面试加分,也会为你拿下 offer 增加不少筹码;

  • 提升开发效率:熟悉 React 源码之后,会对 React 的运行流程有新的认识,让你在日常的开发中,对性能优化、使用技巧和 bug 解决更加得心应手;

  • 巩固基础知识:学习本书也顺便巩固了数据结构和算法,如 reconciler 中使用了 fiber、update、链表等数据结构,diff 算法要考虑怎样降低对比复杂度;

本书的特色:

  • 教程详细,代码开源,带你构建自己的 React 库;

  • 功能全面,可跑通官方测试用例;

  • 按 Git Tag 划分迭代步骤,记录每个功能的实现过程;

电子书地址:2xiao.github.io/leetcode-js…

源代码地址:github.com/2xiao/my-re…

送我一个免费的 ⭐️ Star,这是对我最大的鼓励和支持。