动手实现简单版的 React(一)

发布于 2022-07-05 10:27:19 字数 23610 浏览 1023 评论 0

这一年陆陆续续、跌跌撞撞看过一些实现 react 的文章,但是还没有自己亲自动手过,也就谈不上深入理解过,希望能够通过代码和文字帮助鞭策自己。

jsx 语法

在 React 中一个 Node 节点会被描述为一个如下的 js 对象:

{
   type: 'div',
   props: {
      className: 'content'
   },
   children: []
}

这个对象在 React 中会被 React.createElement 方法返回,而 jsx 语法经过 babel 编译后对应的 node 节点就会编译为 React.createElement 返回,如下的 jsx 经过 babel 编译后如下:

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");

从编译过后的代码大致可以得到以下信息:

  • 子节点是通过剩余参数传递给 createElement 函数的
  • 子节点包括了文本节点
  • 当节点的 attribute 为空时,对应的 props 参数为 null

为了加深对于这些的理解,我使用了 typescript 来编写,vdom 的 interface 可以大致描述如下,props 的 value 为函数的时候就是处理相应的事件:

interface VdomInterface {
	type: string
	props: Record<string, string | Function>
	children: VdomInterface[]
}

其中因为子节点其实还可以是文本节点,因此需要兼容一下,

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

接下来就需要考虑如何将这些对象渲染到真实的 DOM 中,在 React 中,我们是通过 react-dom 中的 render 方法渲染上去的:

ReactDOM.render(<App/>, document.querySelector('#app'))

react 是在版本 0.14 划分为 reactreact-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属性

对于每个 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')
)

打开浏览器可以看到已经生效。

组件 Component

首先先修改测试内容,将 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'))

打开浏览器,可以看到内容已经被正常渲染出来了。

处理 Functional Component

我们将测试内容修改为函数式组件:

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

目前 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) {
  }
}

这里使用了两个泛型来标记 propsstate 的类型,并通过 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操作的实践提出了两点假设:

  • 不同类型的元素产生不同的树,
  • 开发人员可以通过辅助来表示子元素在两次渲染中保持了稳定(也就是key属性)

可以在 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 方法就是对新旧树进行对比。

  • 新树没有的节点,则删除旧树节点
  • 新树有旧树没有的节点,则创建对应节点
  • 新树和旧树是相同节点,则继续 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)
      }
    }
	}
}

编写测试,这次的测试我们需要覆盖当前的场景

  • 新旧树类型相同,只是更改属性
  • 新旧树类型不同,tear down 旧树后创建新树
  • 只是更新 textNode 内容
  • 新树有节点,旧树没有节点(增加)
  • 旧树有节点,新树没有节点(删除)
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 difftree diffelement diff,但是对于最重要的优化手段 key 目前没有排上用场,也就是目前还没有完成 list diff

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

烂人

暂无简介

文章
评论
26 人气
更多

推荐作者

紫罗兰の梦幻

文章 0 评论 0

-2134

文章 0 评论 0

liuxuanli

文章 0 评论 0

意中人

文章 0 评论 0

○愚か者の日

文章 0 评论 0

xxhui

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文