共计 6979 个字符,预计需要花费 18 分钟才能阅读完成。
从学习虚构 dom 工作原理登程,实现一个极简版的 toy-react,其中蕴含了 react 的某几个要害个性,并非真正的 react
起步
说到 react,第一个想到的就是 JSX,所以为了便于开发和调试,咱们的工程要具备以下几点:
- 反对 ES6
- 反对 JSX 语法解析
- 主动打包输入
{
"@babel/core": "^7.2.2",
"@babel/plugin-transform-react-jsx": "^7.10.4",
"@babel/preset-env": "^7.3.1",
"babel-loader": "^8.0.5",
"css-loader": "^4.2.2",
"html-loader": "^1.2.1",
"html-webpack-plugin": "^4.3.0",
"style-loader": "^1.2.1",
"webpack": "^4.29.0",
"webpack-cli": "^3.2.1",
"webpack-dev-server": "^3.1.14"
}
工程搭建
装置完了以上依赖,下一步是 webpack 的配置
-
babel-loader 相干配置负责兼容 ES6 及 JSX 解析
- @babel/plugin-transform-react-jsx 插件中指定 pragma 配置为 createElement,前面会本人定制 createElement 办法,不改的话默认输入 React.createElement
- css-loader 相干配置解决 css 文件资源
- HtmlWebpackPlugin 负责输入 html 文件
- 开启 watch 模式防止每次更改代码后手动编译
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development',
entry: './index.js',
watch: true,
optimization: {minimize: false,},
output: {path: path.join(__dirname, 'dist'),
publicPath: './',
filename: 'bundle.js',
chunkFilename: '[name].js',
},
module: {
rules: [
{
test: /\.css$/,
use: [{ loader: 'style-loader'},
{loader: 'css-loader',},
],
},
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {presets: ['@babel/preset-env'],
plugins: [
[
'@babel/plugin-transform-react-jsx',
{pragma: 'createElement'},
],
],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
title: 'toy-react',
template: 'index.html',
}),
],
devServer: {contentBase: path.join(__dirname, '/dist/'),
inline: true,
host: 'localhost',
port: 8080,
},
}
目录构造
依据以下目录构造创立对应文件
toy-react
├── README.md
├── index.html
├── index.js
├── package.json
├── style.css
├── toy-react-util.js
├── toy-react.js
└── webpack.config.js
执行 webpack 命令,试试成果
npx webpack
打包输入后果如下,bundle.js 为最终打包文件,其中蕴含了 js 和 css
执行 index.html 能够间接拜访打包后的网页成果
dist
├── bundle.js
└── index.html
toy-react
实现的性能和 diff 比照逻辑比较简单,上面会依据代码片段列出对应的性能实现
以 react 官网提供的《Tic Tac Toe》小游戏为演示 demo,应用 toy-react 运行,成果如下:
createElement
通过 webpack 打包输入的 JSX 内容,能够看到 createElement 办法,一共蕴含了 3 个参数:
-
type
- 依据 type 类型解决并返回不同类型的 element,目前只波及一般标签及文字内容,后续会对 ElementWrapper 和 TextWrapper 进行封装
-
attributes
- 包含事件、class、自定义属性等
-
children
- 子元素
/**
* 创立 Element
* @param {*} type element 类型: ElementWrapper or Component
* @param {*} attributes element 属性: attributes or props
* @param {...any} children 子元素
*/
export const createElement = (type, attributes, ...children) => {
let element
typeof type === 'string'
? (element = new ElementWrapper(type))
: (element = new type())
// setProps
for (let p in attributes) {element.setProps(p, attributes[p])
}
const insertChildren = children => {for (let child of children) {
// text content
if (typeof child === 'string') {child = new TextWrapper(child)
}
// element
if (typeof child === 'object' && child instanceof Array) {insertChildren(child)
} else {element.appendChild(child)
}
}
}
insertChildren(children)
return element
}
Component Class
次要实现了以下几点:
-
get vdom
- 初始化 vdom
-
props
- 设置 props
-
appendChild
- 插入子组件
-
RENDER_TO_DOM
- 保留 range 和 vdom 进行 RENDER_TO_DOM
-
update
- 比照更新 vdom 后 RENDER_TO_DOM
-
setState
- 初始化和更新 state
/**
* Component 类
*/
export class Component {constructor() {this.props = Object.create(null)
this.children = []
this._root = null
this._range = null
}
get vdom() {return this.render().vdom
}
setProps(name, value) {this.props[name] = value
}
appendChild(component) {this.children.push(component)
}
[RENDER_TO_DOM](range) {
this._range = range
this._vdom = this.vdom
this._vdom[RENDER_TO_DOM](range)
}
update() {
const vdom = this.vdom
diff(this._vdom, vdom)
this._vdom = vdom
}
setState(newState) {if (this.state === null || typeof this.state !== 'object')
return (this.state = newState)
merge(this.state, newState)
this.update()}
}
update、diff 简易版实现
依据实在 dom 树结构,创立虚构 vdom,数据变更时会进行新旧 vdom 比照,并且将变动过的节点范畴 range 记录,递归比照实现后,依据变更的节点内容及 range 替换该范畴的 dom 内容,也就是RENDER_TO_DOM
- 首先要比照新旧 vdom 是否雷同,这里采纳比较简单粗犷的形式,间接比照节点类型、节点属性,文字类型的节点判断 content,如果这几点产生扭转,间接 RENDER_TO_DOM
/**
* 判断 node 是否雷同
* @param {*} oldNode
* @param {*} newNode
*/
export const isSameNode = (oldNode, newNode) => {
// 判断 dom 类型
if (oldNode.type !== newNode.type) return false
// 判断 dom 属性
for (let name in newNode.props) {if (newNode.props[name] !== oldNode.props[name]) return false
}
if (Object.keys(oldNode.props).length > Object.keys(newNode.props).length)
return false
// 文字内容
if (newNode.type == '#text') {if (newNode.content !== oldNode.content) return false
}
return true
}
- 如果上述几点没产生扭转,递归比照新旧 vdom 的 children,直到比照实现后 RENDER_TO_DOM,保留以后 vdom,实现 Component 的 update 操作;这里须要留神的是追加节点的边缘地位须要记录
/**
* _vdom 比照
* @param {*} oldNode 旧的 vdom
* @param {*} newNode 新的 vdom
*/
export const diff = (oldNode, newNode) => {if (!isSameNode(oldNode, newNode))
return newNode[RENDER_TO_DOM](oldNode._range)
newNode._range = oldNode._range
const newChildren = newNode.vchildren
const oldChildren = oldNode.vchildren
if (!newChildren || !newChildren.length) return
// 结点边缘地位
let tailRange = oldChildren[oldChildren.length - 1]._range
for (let i = 0; i < newChildren.length; i++) {const newChild = newChildren[i]
const oldChild = oldChildren[i]
if (i < oldChildren.length) {diff(oldChild, newChild)
} else {
const range = getRange(
tailRange.endContainer,
tailRange.endOffset,
tailRange.endContainer,
tailRange.endOffset
)
// 追加 newChild 后批改 tailRange
newChild[RENDER_TO_DOM](range)
tailRange = range
}
}
}
/**
* 替换 node
* @param {*} range
* @param {*} node
*/
export const replaceContent = (range, node) => {range.insertNode(node)
range.setStartAfter(node)
range.deleteContents()
range.setStartBefore(node)
range.setEndAfter(node)
}
setState
设置 state 实现形式是比照新旧状态,如果为初始化就间接用传入的状态,如果存在新旧状态就进行比照 merge,状态 merge 后调用 update 来对 dom 构造进行更新渲染
/**
* 新旧状态比照替换
* @param {*} oldState
* @param {*} newState
*/
export const merge = (oldState, newState) => {for (let p in newState) {if (oldState[p] === null || typeof oldState[p] !== 'object') {oldState[p] = newState[p]
} else {merge(oldState[p], newState[p])
}
}
}
ElementWraper Class
继承自 Component,实现以下几点:
-
get vdom
- 设置 vchildren,确保不为 null,用于 RENDER_TO_DOM,
-
setAttribute
- 处理事件、class、自定义属性等
-
RENDER_TO_DOM
- 递归解决 vchildren 中的 vdom,依据取到的 range 进行 RENDER_TO_DOM
/**
* Element 类
*/
class ElementWrapper extends Component {constructor(type) {super(type)
this.type = type
}
get vdom() {
this.vchildren = this.children.map(child => {if (child) return child.vdom
})
return this
}
setAttribute(root) {for (let name in this.props) {const value = this.props[name]
// 过滤 on 事件命名所有字符
if (name.match(/^on([\s\S]+)$/)) {
// 按小写字母解决
root.addEventListener(RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase()),
value
)
} else {if (name === 'className') {root.setAttribute('class', value)
} else {root.setAttribute(name, value)
}
}
}
}
[RENDER_TO_DOM](range) {
this._range = range
const root = document.createElement(this.type)
this.setAttribute(root)
if (!this.vchildren) this.vchildren = this.children.map(child => child.vdom)
for (let child of this.vchildren) {if (child) {child[RENDER_TO_DOM](getRange(root, root.childNodes.length, root, root.childNodes.length)
)
}
}
replaceContent(range, root)
}
}
TextWrapper Class
继承自 Component,实现以下几点:
-
vdom
- 获取 vdom
-
RENDER_TO_DOM
- 依据 range 从新替换 dom
/**
* Text 类
*/
class TextWrapper extends Component {constructor(content) {super(content)
this.type = '#text'
this.content = content
}
get vdom() {return this}
[RENDER_TO_DOM](range) {
this._range = range
const root = document.createTextNode(this.content)
replaceContent(range, root)
}
}
render
Component 通过 RENDER_TO_DOM 渲染到 parentElement 中,range 间接应用 parentElement 的即可
/**
* 渲染函数
* @param {*} component
* @param {*} parentElement
*/
export const render = (component, parentElement) => {component[RENDER_TO_DOM](
getRange(
parentElement,
0,
parentElement,
parentElement.childNodes.length,
true
)
)
}
// 将定义好的 Game 组件传入 render, 渲染到 id 为 root 的父元素中
render(<Game />, document.getElementById('root'))
总结
实现以上流程,曾经初步理解了 vdom 的工作原理,能够作为学习 react、vue 等框架源码的参考,后续很多细节须要去学习、补充,例如:
- vdom 的比照精度不够细
- 渲染形式毛糙
- 生命周期不够欠缺
- hooks
…
????????????
参考资料
- Web API:Range
- 残缺工程