新建文件
- 创建 index.html 文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mini-react</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
- 在同一目录下创建 main.js 文件(下文的 JS 代码除特别说明外皆写在此文件中)
因为 CORS 策略不支持 file 协议,所以 index.html 需要使用 Web 服务器打开,推荐使用 VSCode 中的 Live Serve 扩展打开
目标
模拟 React 构造出 CreateRoot().render(),使用它创建出图中的 <div id="container">Hello World</div>。
在开始之前,让我们看看 React 是的 API 是怎样使用的吧。
开始吧
直接操作 DOM
我们想要使用 JS 创建出<div id="container">Hello World</div>这个结构,需要怎么做呢?当然是使用 JS 直接操作 DOM 了。
const div = document.createElement('div')
div.id = 'container'
document.querySelector('#root').appendChild(div)
const text = document.createTextNode('')
text.nodeValue = 'Hello World'
div.appendChild(text)
使用 VDOM
众所周知 React 是使用 VDOM 而不是直接操作 DOM 的,VDOM 其实就是使用 JS 对象表示出 DOM 的结构,看看我们上面的代码, 显然可得 VDOM 需要有 type(DOM 的标签类型)、props(DOM 的属性)、children(当前 DOM 的子 DOM)。于是可以将上面代码中的 DOM 转换成 VDOM 表示。
const text = {
type: 'TEXT_ELEMENT',
props: {
nodeValue: 'Hello World',
children: [],
},
}
const div = {
type: 'div',
props: {
id: 'container',
children: [textEl],
},
}
等等,为什么 children 属性在 props 里面?为了实现将 JSX 作为子组件传递。
文本节点根本不可能有子节点,为什么文本节点也要有 children 属性?为了统一文本节点和元素节点的结构,方便后续处理。
有了 VDOM 之后我们就可以使用 VDOM 来创建真实的 DOM 了。
const divDOM = document.createElement(div.type)
divDOM.id=div.props.id
document.querySelector('#root').appendChild(divDOM)
const textDOM = document.createTextNode('')
textDOM.nodeValue=text.props.nodeValue
divDOM.appendChild(textDOM)
动态创建
然而这两个 VDOM 是我们写死的,无法做到动态创建,想要实现动态创建,我们可以编写函数,函数接收参数以动态的创建 VDOM,创建元素节点的时候我们需要 type,props,children 作为参数,而创建文本节点的时候我们只需要 nodeValue,由此写出下面两个函数:
// 下面的步骤都需要这两个函数
function createTextNode(nodeValue) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue,
children: [],
},
}
}
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children,
},
}
}
函数名可以随便命名吗?
createTextNode是可以随便命名的,因为它最后只会被内部使用,createElement也可以随便命名,但不推荐,后续如果使用 JSX 的话,需要写一点配置。
有了这两个函数之后我们就可以通过不同的参数动态创建出 VDOM 了,现在让我们创建出 VDOM 并将它们渲染为真实 DOM 吧。
const text = createTextNode('Hello World')
const div = createElement('div', { id: 'container' }, text)
const divDOM = document.createElement(div.type)
divDOM.id=div.props.id
document.querySelector('#root').appendChild(divDOM)
const textDOM = document.createTextNode('')
textDOM.nodeValue=text.props.nodeValue
divDOM.appendChild(textDOM)
render 函数
经过几次的渲染 DOM,我们可以总结出其需要的步骤:创建需要的 DOM,给 DOM 的属性赋值,追加到父 DOM 中。如果每渲染一个 VDOM,我们就需要写出执行这些步骤的代码,那还写个锤子代码。所以我们可以将这个过程封装为函数,通过不同的参数来渲染不同的 VDOM,调用该函数来执行渲染过程。执行这个渲染过程,我们需要 VDOM,以及该 VDOM 的父 DOM。
// 下面的步骤都需要这个函数
function render(vdom, container) {
let dom // 真实 DOM
// 根据不同的类型创建对应的真实 DOM
if (vdom.type === 'TEXT_ELEMENT') {
dom = document.createTextNode('')
} else {
dom = document.createElement(vdom.type)
}
// 处理 props
Object.keys(vdom.props).forEach((key) => {
// children 需要单独处理
if (key !== 'children') {
dom[key] = vdom.props[key]
}
})
// 使用递归处理 props.children
vdom.props.children.forEach((child) => {
render(child, dom)
})
// 将 DOM 追加到父 DOM 中
container.appendChild(dom)
}
OK了,现在我们来用用它吧
// 创建出 VDOM
const text = createTextNode('Hello World')
const div = createElement('div', { id: 'app' }, textNode)
// 渲染在 #root 里
render(div, document.querySelector('#root'))
在这里,我们可以对 createElement 进行一下优化,使它可以直接将非对象的 child 创建出一个文本节点。
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
// 通过 map 处理,如果 child 是对象(VDOM 对象)的话,不做处理,
// 如果不是对象的话,则将其更换为文本节点
children: children.map((child) => {
if (typeof child === 'object') {
return child
} else {
return createTextNode(child)
}
}),
},
}
}
优化完成之后,我们就可以这样用了,这个优化是为了让我们的 createElement 对齐 React 中的 createElement。
// 创建出 VDOM
const div = createElement('div', { id: 'app' }, 'Hello World')
// 渲染在 #root 里
render(div, document.querySelector('#root'))
构造 createRoot().render() 结构
我们已经将 render 函数完成了,只剩最后一步,即可模拟出 createRoot().render()了。让我们再来看一眼它原来是怎么用的。
可以看见 createRoot 是 ReactDOM 中的方法,render 是 createRoot 返回的对象中的方法,构造出 ReactDOM。
const ReactDOM = {
createRoot(container) {
return {
render(vdom) {
render(vdom, container)
},
}
},
}
于是我们可以模仿 React 的用法使用我们的 mini-react 了。
const dom = createElement('div', { id: 'app' }, 'Hello ', 'World')
ReactDOM.createRoot(document.querySelector('#root')).render(dom)
拆分文件,模仿的更像一点
在当前目录下 创建 core/React.js 和 core/ReactDOM.js 两个文件。
- 将
createTextNode、createElement、render函数移入 React.js 中,添加 React 并默认导出。
const React = {
createElement,
render,
}
export default React
- 将
ReactDOM移入 ReactDOM.js 中,导入 React 并添加默认导出 ReactDOM。
import React from './React.js' // 添加到文件开头
export default ReactDOM // 添加到文件结尾
在当目录下创建 App.js,内容如下:
import React from './core/React.js'
const App = React.createElement('div', { id: 'app' }, 'Hello World')
export default App
将 main.js 内容替换为如下代码以使用我们自己实现的 ReactDOM 来创建根节点并渲染 APP
import ReactDOM from './core/ReactDOM.js'
import App from './App.js'
ReactDOM.createRoot(document.querySelector('#root')).render(App)
为什么是 render(APP),而不是 render(<App />)?因为我们目前实现的 render 并不支持函数组件,我们只能将 VDOM 传递给 createRoot().render 函数。
更进一步,使用 JSX
由于浏览器无法直接使用 JSX,所以我们需要借助构建工具将 jsx 转换为 js 文件,在这里我们使用 pnpm 作为包管理器,使用 vite 作为构建工具。
- 将 App.js 和 main.js 更名为 jsx 文件。注意不要忘了修改 index.html 中导入 main.jsx 和 main.jsx 中导入 App.jsx。
- 在当前目录下初始化项目
pnpm init
- 安装 vite
pnpm i vite
- 运行
pnpm vite dev
将 App.jsx 的 React.createElement 替换为 JSX 语法。
import React from './core/React.js'
const App = <div id='container'>Hello World</div>
export default App
为什么这可以生效?因为构建工具帮我们将 JSX 转换为使用 React.createElement 创建的 VDOM 了。
下图可以看到编译后的 App.jsx 是什么样。
/* @PURE */ 是什么东西?简单来说就是一个给构建工具使用的标记,其含义是将一个函数标记为纯函数,如果未使用它,构建工具可以安全的移除它。
至此我们已经完成只支持渲染的 mini-react。