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

发布于 2022-07-05 10:27:19 字数 23610 浏览 1012 评论 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技术交流群

发布评论

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

关于作者

烂人

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

qq_FjTq5B

文章 0 评论 0

18273202778

文章 0 评论 0

WordPress小学生

文章 0 评论 0

〃温暖了心ぐ

文章 0 评论 0

迷乱花海

文章 0 评论 0

niuniu

文章 0 评论 0

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