这一年陆陆续续、跌跌撞撞看过一些实现 react 的文章,可是尚未本身亲自动手过,也就谈不上深刻理解过,但愿可以经过代码和文字帮助鞭策本身。html
这个系列至少会实现一个 React 16 以前的简单API,对于 React Fiber 和 React Hooks 尽可能会有代码实现,没有也会写相应的文章,但愿这是一个不会倒的 flag。前端
在 React 中一个 Node 节点会被描述为一个以下的 js 对象:node
{
type: 'div',
props: {
className: 'content'
},
children: []
}
复制代码
这个对象在 React 中会被 React.createElement
方法返回,而 jsx 语法通过 babel 编译后对应的 node 节点就会编译为 React.createElement
返回,以下的 jsx 通过 babel 编译后以下:react
const name = 'huruji'
const content = <ul className="list"> <li>{name}</li> huruji </ul>
复制代码
const name = 'huruji';
const content = React.createElement("ul", {
className: "list"
}, React.createElement("li", null, name), "huruji");
复制代码
从编译事后的代码大体能够获得如下信息:webpack
子节点是经过剩余参数传递给 createElement
函数的git
子节点包括了文本节点github
当节点的 attribute 为空时,对应的 props 参数为 nullweb
为了加深对于这些的理解,我使用了 typescript 来编写,vdom 的 interface 能够大体描述以下,props 的 value 为函数的时候就是处理相应的事件:算法
interface VdomInterface {
type: string
props: Record<string, string | Function>
children: VdomInterface[]
}
复制代码
其中由于子节点其实还能够是文本节点,所以须要兼容一下,typescript
export interface VdomInterface {
type: string
props: Record<string, string | Function>
children: VdomType[]
}
type VdomType = VdomInterface | string
复制代码
实际上,React 的声明文件对于每一个不一样的 HTML 标签的 props
都作了不一样的不一样的适配,对应的标签只能编写该标签下全部的 attributes,因此常常会看到如下这种写法:
type InputProps = React.InputHTMLAttributes<{}> & BasicProps;
export default class Input extends React.Component<InputProps, any> {
// your comonent's code
}
复制代码
这里一切从简,createElement
函数的内容就会是下面这个样子:
interface VdomInterface {
type: string
props: Record<string, string | Function>
children: VdomInterface[]
}
export default function createElement( type: string, props: Record<string, string | Function>, ...children: VdomType[] ): VdomType {
if (props === null) props = {}
console.log(type)
debugger
return {
type,
props,
children
}
}
复制代码
编写咱们的测试,为了避免须要再编写繁琐的 webpack
配置,我使用了 saso 做为此次打包的工具,建立目录目录结构:
--lib2
--src
--index.html
--index.tsx
--App.tsx
--saso.config.js
复制代码
由于 saso 推崇以 .html
文件为打包入口,因此在 .html
中须要指定 .index.ts
做为 script 属性 src 的值:
<script src="./index.tsx" async defer></script>
复制代码
在 saso 配置文件 saso.config.js
配置一下 jsx 编译后的指定函数,内容以下:
module.exports = {
jsx: {
pragma: 'createElement'
},
}
复制代码
App.tsx 内容以下:
import { createElement } from '../lib2/index'
const name = 'huruji'
const content = (
<ul className="list">
<li>{name}</li>
huruji
</ul>
)
export default content
复制代码
index.ts 内容以下:
import App from './App'
console.log(App)
复制代码
在根目录中运行 saso dev
,能够在控制台中看到打包编译完成,在浏览器中访问 http://localhost:10000
并打开控制台,能够看到组件 App 编译事后被转化为了一个 js 对象:
接下来就须要考虑如何将这些对象渲染到真实的 DOM 中,在 React 中,咱们是经过 react-dom
中的 render
方法渲染上去的:
ReactDOM.render(<App/>, document.querySelector('#app'))
复制代码
react 是在版本 0.14 划分为 react
和 react-dom
,react 之因此将 渲染到真实 DOM 单独分为一个包,一方面是由于 react 的思想本质上与浏览器或者DOM是没有关系的,所以分为两个包更为合适,另一个方面,这也有利于将 react 应用在其余平台上,如移动端应用(react native)。
这里为了简单,就不划分了, 先写下最简单的渲染函数,以下:
export default function render(vdom:VdomType, parent: HTMLElement) {
if(typeof vdom === 'string') {
const node = document.createTextNode(vdom)
parent.appendChild(node)
} else if(typeof vdom === 'object') {
const node = document.createElement(vdom.type)
vdom.children.forEach((child:VdomType) => render(child, node))
parent.appendChild(node)
}
}
复制代码
vdom 是字符串时对应于文本节点,其实这从 VdomType 类型中就能够看出来有 string 和 object 的状况(这也正是我喜欢 ts 的缘由)。
在 index.tsx
中编写相应的测试内容:
import { render, createElement } from '../lib2'
render(
<div>
<p>name</p>
huruji
</div>,
document.querySelector('#app')
)
复制代码
这个时候能够看到,对应的内容已经渲染到 dom 中了
对于每一个 dom 来讲,除了普通的属性外,jsx 使用 className 来替代为 class,on 开头的属性做为事件处理,style是一个对象,key 属性做为标识符来辅助 dom diff,所以这些须要单独处理,key属性咱们存储为 __key
, 以下:
export default function setAttribute(node: HTMLElement & { __key?: any }, key: string, value: string | {} | Function) {
if (key === 'className') {
node['className'] = value as string
} else if (key.startsWith('on') && typeof value === 'function') {
node.addEventListener(key.slice(2).toLowerCase(), value as () => {})
} else if (key === 'style') {
if (typeof value === 'object') {
for (const [key, val] of Object.entries(value)) {
node.style[key] = val
}
}
} else if (key === 'key') {
node.__key = value
} else {
node.setAttribute(key, value as string)
}
}
复制代码
修改对应的测试,以下:
import { render, createElement } from '../lib2'
render(
<div className="list" style={{ color: 'red' }} onClick={() => console.log('click')}>
<p key="123" style={{ color: 'black' }}>
name
</p>
huruji
</div>,
document.querySelector('#app')
)
复制代码
打开浏览器能够看到已经生效:
首先先修改测试内容,将 dom 移到 App.tsx
中,index.tsx
内容修改成:
import { render, createElement } from '../lib2'
import App from './App'
render(<App />, document.querySelector('#app'))
复制代码
打开浏览器能够看到这个时候报错了:
其实这个错误很明显,就是这个时候 Content 组件编译后传给 createElement
函数的第一个参数是一个 vdom 对象,可是咱们并无对 type 是对象的时候作处理,所以须要修改一下 createElement
:
export default function createElement( type: string | VdomType, props: Record<string, string | Function>, ...children: VdomType[] ): VdomType {
if (props === null) props = {}
if (typeof type === 'object' && type.type) {
return type
}
return {
type: type as string,
props,
children
}
}
复制代码
这个时候就正常了。
先新建一个 Component
对象:
export default class Component {
public props
constructor(props) {
this.props = props || {}
}
}
复制代码
对于 class Component 的写法,转化事后的传递给 createElement
的第一个参数就是一个以 React.Component
为原型的函数:
class Content extends React.Component {
render(){
return <div>content</div>
}
}
const content = <div><Content name="huruji"/></div> 复制代码
class Content extends React.Component {
render() {
return React.createElement("div", null, "content");
}
}
const content = React.createElement("div", null, React.createElement(Content, {
name: "huruji"
}));
复制代码
也就是说这个时候 type 是一个函数,目前在 createElement
中和 render
中并无作处理。因此确定会报错。
在编写 class 组件的时候,咱们必需要包含 render 方法,而且若是编写过 ts 的话,就知道这个 render 方法是 public 的,所以确定须要实例化以后再调用 render
方法,咱们放在 render
方法处理。Component 的 interface 能够表示为:
export interface ComponentType {
props?: Record<string, any>
render():VdomType
}
复制代码
render
方法中单独处理一下 type 为 function 的状况:
const props = Object.assign({}, vdom.props, {
children: vdom.children
})
const instance = new (vdom.type)(props)
const childVdom = instance.render()
render(childVdom, parent)
}
复制代码
这里作的事情就是实例化后调用 render
方法。
这个时候,整个 render 方法的内容以下:
export default function render(vdom:VdomType, parent: HTMLElement) {
if(typeof vdom === 'string') {
const node = document.createTextNode(vdom)
parent.appendChild(node)
} else if(typeof vdom === 'object' && typeof vdom.type === 'string') {
const node = document.createElement(vdom.type)
vdom.children.forEach((child:VdomType) => render(child, node))
for(const prop in vdom.props) {
setAttribute(node, prop, vdom.props[prop])
}
parent.appendChild(node)
} else if (typeof vdom === 'object' && typeof vdom.type === 'function') {
const props = Object.assign({}, vdom.props, {
children: vdom.children
})
const instance = new (vdom.type)(props)
const childVdom = instance.render()
render(childVdom, parent)
}
}
复制代码
修改咱们的测试内容:
import { render, createElement, Component } from '../lib2'
class App extends Component {
constructor(props) {
super(props)
}
render() {
const { name } = this.props
debugger
return <div style={{ color: 'red', fontSize: '100px' }}>{name}</div>
}
}
render(<App name={'app'} />, document.querySelector('#app'))
复制代码
打开浏览器,能够看到内容已经被正常渲染出来了:
咱们将测试内容修改成函数式组件:
function App({ name }) {
return <div style={{ color: 'red', fontSize: '100px' }}>{name}</div>
}
复制代码
这个时候能够看到报错:
这个错误是显而易见的,render 里将 Functional Component 也当成了 Class Component 来处理,可是 Functional Component 里并无 render 属性,所以咱们仍然须要修改,Class Component 的原型是咱们定义的 Component ,咱们能够经过这个来区分。
先增长一下 interface ,这能帮助咱们更好地理解:
export interface ClassComponentType {
props?: Record<string, any>
render():VdomType
}
export type FunctionComponent = (props:any) => VdomType
export interface VdomInterface {
type: FunctionComponent | string | {
new(props:any): ClassComponentType
}
props: Record<string, string | Function>
children: VdomType[]
}
复制代码
将 type 为 function 的逻辑修改成:
const props = Object.assign({}, vdom.props, {
children: vdom.children
})
let childVdom = null
if(Component.isPrototypeOf(vdom.type)) {
const vnode = vdom.type as {new(props:any): ClassComponentType}
const instance = new (vnode)(props)
childVdom = instance.render()
} else {
const vnode = vdom.type as FunctionComponent
childVdom = vnode(props)
}
render(childVdom, parent)
复制代码
这个时候整个 render
的内容以下:
import { VdomType, FunctionComponent } from './createElement'
import setAttribute from './setAttribute'
import Component, { ComponentType, ClassComponentType } from './component'
export default function render(vdom:VdomType, parent: HTMLElement) {
if(typeof vdom === 'string') {
const node = document.createTextNode(vdom)
parent.appendChild(node)
} else if(typeof vdom === 'object' && typeof vdom.type === 'string') {
const node = document.createElement(vdom.type)
vdom.children.forEach((child:VdomType) => render(child, node))
for(const prop in vdom.props) {
setAttribute(node, prop, vdom.props[prop])
}
parent.appendChild(node)
} else if (typeof vdom === 'object' && typeof vdom.type === 'function') {
const props = Object.assign({}, vdom.props, {
children: vdom.children
})
let childVdom = null
if(Component.isPrototypeOf(vdom.type)) {
const vnode = vdom.type as {new(props:any): ClassComponentType}
const instance = new (vnode)(props)
childVdom = instance.render()
} else {
const vnode = vdom.type as FunctionComponent
childVdom = vnode(props)
}
render(childVdom, parent)
}
}
复制代码
这个时候从新打开一下浏览器,能够发现可以正常渲染了:
目前 render 方法里渲染的节点包括:普通的文本节点、普通的标签节点、functional component、class component,可是我的感受好像有点乱,在 render 方法中并无反映咱们的意图。
仔细回想一下 createElement
函数,除了文本节点外,其余类型的节点都会通过这个函数处理,咱们其实能够在这里动动手脚,标记下节点的类型。
export default function createElement( type: VdomType | Vdom, props: Record<string, string | Function>, ...children: Vdom[] ): Vdom {
let nodeType:nodeType = 'node'
if (props === null) props = {}
if (typeof type === 'object' && type.type) {
return type
}
if (typeof type === 'function') {
if (Component.isPrototypeOf(type)) {
nodeType = 'classComponent'
} else {
nodeType = 'functionalComponent'
}
}
return {
type: type as VdomType,
props,
children,
nodeType
}
}
复制代码
这个时候重写下 render
方法会更加清晰:
import { Vdom } from './types/vdom'
import setAttribute from './setAttribute'
import { ClassComponent, FunctionComponent } from './types/component';
export default function render(vdom:Vdom, parent: HTMLElement) {
if(typeof vdom === 'string') {
const node = document.createTextNode(vdom)
parent.appendChild(node)
return
}
switch(vdom.nodeType) {
case 'node':
const node = document.createElement(vdom.type as string)
vdom.children.forEach((child:Vdom) => render(child, node))
for(const prop in vdom.props) {
setAttribute(node, prop, vdom.props[prop])
}
parent.appendChild(node)
break;
case 'classComponent':
const classProps = Object.assign({}, vdom.props, {
children: vdom.children
})
const classVnode = vdom.type as {new(props:any): ClassComponent}
const instance = new (classVnode)(classProps)
const classChildVdom = instance.render()
render(classChildVdom, parent)
break
case 'functionalComponent':
const props = Object.assign({}, vdom.props, {
children: vdom.children
})
const vnode = vdom.type as FunctionComponent
const childVdom = vnode(props)
render(childVdom, parent)
break
default:
}
}
复制代码
接下来就是须要完成更新了,首先咱们知道 setState
是异步的,那么怎么实现异步?前端最多见的就是使用定时器,这固然能够,不过参考 Preact 的源码,能够发现使用的是经过 Promise.resolve
微任务将 setState
的操做放在当次事件循环的最后,这样就能够作到异步了。
Promise.resolve().then(update)
复制代码
先完善下 Component
的类型,方便后续动手:
export default class Component<P,S> {
static defaultProps
public props:P
public _pendingStates
public base
public state: Readonly<S>
constructor(props) {
this.props = props || Component.defaultProps || {}
}
setState(nextState) {
}
}
复制代码
这里使用了两个泛型来标记 props
和 state
的类型,并经过 Readonly
标记了 state
为只读。为了方便,咱们能够在 setState
里将传进来的参数使用 _pendingState
保存一下,将相应的更新函数单独抽出来:
setState(nextState) {
this._pendingStates = nextState
enqueueRender(this)
}
复制代码
更新函数以下:
function defer(fn) {
return Promise.resolve().then(fn)
}
function flush(component) {
component.prevState = Object.assign({}, component.state)
Object.assign(component.state, component._pendingStates)
}
export default function queueRender(component) {
defer(flush(component))
}
复制代码
更新完 state
最重要的仍是要从新渲染视图,既然要从新渲染视图,就须要对新旧 DOM 树进行对比,而后找到更新方式(删除节点、增长节点、移动节点、替换节点)应用到视图中。
咱们一直被告诉传统的 tree diff 算法的时间复杂度为 O(n^3) ,但彷佛不多文章说起为啥是 O(n^3) ,知乎上有一个回答能够参考下 react的diff 从O(n^3)到 O(n) ,请问 O(n^3) 和O(n) 是怎么算出来,大体的就是 tree diff 算法是一个递归算法,在递归过程拆分红可能的子树对比,而后还须要计算最小的转换方式,致使了最终的时间复杂度为 O(n^3) ,上张 tree diff 算法演变过程冷静冷静:
我终于知道为啥这方面的文章少的缘由了,有兴趣的同窗能够看看 tree diff 的论文:A Survey on Tree Edit Distance and Related Problems(27页)
这个算法在前端来讲太大了,1000 个节点就须要1亿次操做,这会让应用卡成翔的,React 基于DOM操做的实践提出了两点假设:
能够在 React 的文档 Advanced guides - Reconciliation 中找到 React 本身的说明,假设原文以下:
Two elements of different types will produce different trees.
The developer can hint at which child elements may be stable across different renders with a key prop.
DOM 操做的事实就是:
局部小改动多,大片的改动少(性能考虑,用显示隐藏来规避)
跨层级的移动少,同层节点移动多(好比表格排序)
分别对应着上面的两点假设,很是合理。
那么 diff 策略就是只对比同层级的节点,若是节点一致则继续对比子节点,若是节点不一致,则先 tear down 老节点,而后再建立新节点,这也就意味着即便是跨层级的移动也是先删除相应的节点,再建立节点。
以下,这个时候执行的操做是: create A -> create B -> create C -> delete A
记住这个规则。
回到代码,要想可以对比首先就应该可以获取到对应的真实DOM,对于 component 组件同时须要能够获取到对应的 constructor 来对比是不是相同的组件,为了获取到这些,咱们能够在渲染的时候经过属性保存下:
const base = render(classChildVdom, parent)
instance.base = base
base._component = instance
复制代码
得到新树的方法很简单,经过从新调用组件的 render
方法就得到了新树,更新下 queueRender
方法里面的代码:
import diff from './diff'
function defer(fn) {
return Promise.resolve().then(fn)
}
function flush(component) {
component.prevState = Object.assign({}, component.state)
Object.assign(component.state, component._pendingStates)
diff(component.base, component.render())
}
export default function queueRender(component) {
defer(() => flush(component))
}
复制代码
diff
方法就是对新旧树进行对比。
props
则进行对比,进行删改,这个相对来讲比较简单判断是否同类型 node 的代码以下:
function isSameNodeType(dom: Dom, vdom:Vdom) {
if(typeof vdom === 'string' || typeof vdom === 'number') {
return dom.nodeType === 3
}
if(typeof vdom.type === 'string') {
return dom.nodeName.toLowerCase() === vdom.type.toLowerCase()
}
return dom && dom._component && dom._component.constructor === vdom.type
}
复制代码
对于 属性的对比 首先遍历旧结点,处理修改和删除的操做,以后遍历新节点属性,完成增长操做
function diffAttribute(dom, oldProps, newProps) {
Object.keys(oldProps).forEach(key => {
if(newProps[key] && newProps[key] !== oldProps[key]) {
dom.removeAttribute(key)
setAttribute(dom, key, newProps[key])
}
})
Object.keys(newProps).forEach(key => {
if(!oldProps[key]) {
setAttribute(dom, key, newProps[key])
}
})
}
复制代码
对于 Component diff,先处理 Component 相同的状况,Component 相同则继续 diff dom 和 调用 comonent render
获得树
对于 node diff,不一样类型 render 后直接替换,相同类型则递归diff 子节点。
export default function diff(dom: Dom, vdom, parent: Dom = dom.parentNode) {
if(!dom) {
render(vdom, parent)
} else if (!vdom) {
dom.parentNode.removeChild(dom)
} else if ((typeof vdom === 'string' || typeof vdom === 'number') && dom.nodeType === 3) {
if(vdom !== dom.textContent) dom.textContent = vdom + ''
} else if (vdom.nodeType === 'classComponent' || vdom.nodeType === 'functionalComponent') {
const _component = dom._component
if (_component.constructor === vdom.type) {
_component.props = vdom.props
diff(dom, _component.render())
} else {
const newDom = render(vdom, dom.parentNode)
dom.parentNode.replaceChild(newDom, dom)
}
} else if (vdom.nodeType === 'node') {
if(!isSameNodeType(dom, vdom)) {
const newDom = render(vdom, parent)
dom.parentNode.replaceChild(newDom, dom)
} else {
const max = Math.max(dom.childNodes.length, vdom.children.length)
diffAttribute(dom, dom._component.props, vdom.props)
for(let i = 0; i < max; i++) {
diff(dom.childNodes[i] || null, vdom.children[i] || null, dom)
}
}
}
}
复制代码
编写测试,此次的测试咱们须要覆盖当前的场景
class App extends Component<any, any> {
public state = { name: 'app', list: [], nodeType: 'div', className: 'name' }
constructor(props) {
super(props)
}
update() {
debugger
this.setState({
name: this.state.name + '1'
})
}
add() {
const { list } = this.state
debugger
for (let i = 0; i < 1000; i++) {
list.push((Math.random() + '').slice(2, 8))
}
this.setState({
list: [].concat(list)
})
}
sort() {
const { list } = this.state
list.sort((a, b) => a - b)
this.setState({
list: [].concat(list)
})
}
delete() {
const { list } = this.state
list.pop()
this.setState({
list: [].concat(list)
})
}
changeType() {
const { nodeType } = this.state
this.setState({
nodeType: nodeType === 'div' ? 'p' : 'div'
})
}
changeProps() {
const { className } = this.state
this.setState({
className: className + 'a'
})
}
render() {
const { name, list, nodeType, className } = this.state
return (
<div className="container">
<div className="optcontainer">
<div className="opt" onClick={this.update.bind(this)}>
update text
</div>
<div className="opt" onClick={this.add.bind(this)}>
add
</div>
<div className="opt" onClick={this.delete.bind(this)}>
delete
</div>
<div className="opt" onClick={this.sort.bind(this)}>
sort
</div>
</div>
<div className="optcontainer">
<div className="opt" onClick={this.changeType.bind(this)}>
changeNodeType
</div>
<div className="opt" onClick={this.changeProps.bind(this)}>
changeNodeProps
</div>
</div>
{nodeType === 'div' ? (
<div className={className}>{name + 'div'}</div>
) : (
<p className={className}>{name + 'p'}</p>
)}
<ul>{list.map(l => <li>{l}</li>)}</ul>
</div>
)
}
}
render(<App />, document.querySelector('#app'))
复制代码
打开浏览器,能够看到以下界面:
component diff
、
tree diff
、
element diff
,可是对于最重要的优化手段
key
目前没有排上用场,也就是目前尚未完成
list diff
。
最后照旧是一个广告贴,最近新开了一个分享技术的公众号,欢迎你们关注👇(目前关注人数可怜🤕)