上一篇文章我们主要实现了 JSX 在 WebGL
上的渲染与更新,对 虚拟DOM 和 Diff 有了更深的了解,但相比于我们使用的 React
,还缺乏了之中很重要的一环 --- 组件模式。
想必大家能认同,React组件(Component
)具有 强大的功能,高拓展性和高解耦性,在其基础上构建的各种 UI
组件框架完全改变了传统的 Web 开发模式,成为了Web 中的大型复杂应用提供了一种很好的构建模式和保障,也让我们的开发效率也有了质的变化。
在这篇文章中,我们会在上一篇的实现基础上,加入 React
组件模式,并在实现的过程中适时的去讲解一些原理和思维,也有利于大家 由浅入深的理解 和 编程思维上的提升。
由于下篇是完全以上篇作为基础的。所以如果你还没看过上篇,请优先猛戳:
代码复用性,向来是编程领域一个核心的理念。我们最常使用 函数、类 方式进行代码的封装,但这里有个痛点:
Web 中 UI 与 逻辑 的分离的特性,导致较难优雅地整合封装。
通常我们需要引 JS
、CSS
,再在 HTML
中按规定书写结构,最后再初始化库。整个过程十分割裂,且不优雅。
而 React Component
帮我们解决了这个痛点,即保持了 动态UI 与 逻辑 的分离,又有了全新的整合方式。从而让 UI 组件变得非常的 高效易用,只需要在结构中以标签的形式引入即可。
我们就继续在上篇文章的基础上,加入 Component
的特性。基于 JSX
的变量传递,我们只需要实现一个 Component
类,并针对性地去完成组件的 渲染和更新 即可。
TIPs:由于组件的加入,这时我们的 虚拟DOM(VNode) 就包含了两种类型: 组件节点(compVNode) 和 元素节点(elVNode)。后续都会以此区分,便于讲解。
// 组件基类 class Component { // 通过保持三份不同的时期的快照 // 有利于对组件的状态及属性的管理和追踪 // 属性 public __prevProps public props = {} public __nextProps // 状态 public __prevState public state = {} public __nextState // 储存当前组件渲染出的 VNode public __vnode // 储存 组件节点 public __component constructor(props) { // 初始化参数 this.props = props } public render(): any { return null } public __createVNode() { // 该方法用于封装重新执行 render 的逻辑 // state 与 props 状态变更的逻辑 this.__prevProps = this.props this.__prevState = this.state this.props = this.__nextProps this.state = this.__nextState this.__nextState = this.__nextProps = undefined // 重新执行 render 生成 VNode this.__vnode = this.render() return this.__vnode } }
有了这个类后,我们就可以使用 React
的方式继承出 自定义组件 进行渲染:
class App extends Component { constructor(props) { super(props) this.state = { content: 'ReactWebGL Component, Hello World', } } public render() { return ( <Container name="parent"> {this.state.content} </Container> ) } } render(<App />, game.stage)
由于我们之前提过,JSX
是以 变量传递 的形式编译的。因此将 <App />
转换成 VNode
后,其 vnode.type
的值便是 App
这个类,并不是类似 div
那样的 字符串标签名。所以接下来我们需要实现一个 组件初始化 的函数(Component
)。这个函数的主要功能是:
实例化组件并获取组件 render
方法中返回的 元素节点。
// 渲染组件 function renderComponent(compVNode) { const { type: Comp, props } = compVNode let instance = compVNode.instance // 当 instance 已经存在时,则不需要重新创建实例 if (!instance) { // 传入 props,初始化组件实例 // 支持 类组件 或者 函数组件 if (Comp.prototype && Comp.prototype.render) { instance = new Comp(props) } else { instance = new Component(props) instance.constructor = Comp instance.render = () => instance.constructor(props) } // 初次渲染时 // 将来的属性与状态其实便是与当前值一致 instance.__nextProps = props instance.__nextState = instance.state } // 调用 render 获取 VNode const vnode = instance.__createVNode() // 组件、元素、实例之间保持相互引用,有利于双向连接整棵虚拟树 instance.__component = compVNode compVNode.instance = instance compVNode.vnode = vnode vnode.component = compVNode return vnode }
接下来我们只需要在 createElm
的函数加入: 当传入的为 组件节点 时调用函数初始化生成 元素节点,后续只需要继续原有的逻辑继续创建,便能正确渲染组件。
function createElm(vnode) { // 当为组件时,初始化组件 // 重新赋值成 元素节点 if (typeof vnode.type === 'function') { vnode = renderComponent(vnode) } // 维持原有逻辑 ... return vnode.elm }
保存执行,页面中已经能正确的渲染出 <App>
组件了。延续之前的逻辑,在完成初次渲染后,接下来就是组件的更新。这就是我们最常使用的 this.setState
了。
在上一篇文章中,我们实现了 虚拟DOM 更新函数 diff
。参数为 新旧虚拟节点(oldVNode
、newVNode
)。所以组件更新的原理也一样:
获取组件实例先后渲染的新旧 VNode
再触发 diff
函数。
在刚才 Component
的渲染中,我们已经把 render
生成的 VNode
保存在 this.__vnode
上,这便是初始化时生成的 旧虚拟节点(oldVNode
)。所以我们要做的就是:
setState
中 更新状态,调用 render
生成 新虚拟节点(newVNode
),触发 diff
更新。
因此我们在类上新增两个方法: setState
和 __update
:
class Component { // 其余方法 ... // 更新函数 public __update = () => { // 临时存储 旧虚拟节点 (oldVNode) const oldVNode = this.__vnode this.__nextProps = this.props if (!this.__nextState) this.__nextState = this.state // 重新生成 新虚拟节点(newVNode) this.__vnode = this.__createVNode() // 调用 diff,更新 VNode diff(oldVNode, this.__vnode) } // 更新状态 public setState(partialState, callback?) { // 合并状态, 暂存于 即将更新态 中 if (typeof partialState === 'function') { partialState = partialState(this.state) } this.__nextState = { ...this.state, ...partialState, } // 调用更新,并执行回调 this.__update() callback && callback() } }
到这里我们可以看出: setState
封装了 diff
方法。但由于 diff
的复杂度,性能的优化会是一个我们需要着重考虑的点。每执行一次 setState
,就需要重新生成 newVNode
进行 diff
。因此,当组件非常复杂时或者连续更新时,可能会导致 主进程的阻塞,造成页面的卡死。
这里我们需要有两个优化:
setState
异步化,避免阻塞主进程;setState
合并,多次连续调用会被最终合并成一次;
为了这个优化,我们首先需要一个更新队列功能:
我们先来实现个 异步执行队列:
// 更新异步化,采用属于微任务的 Promise,兼容性使用 setTimeout // 这里使用微任务,可以保证宏任务的优先执行 // 保证例如 UI渲染 等更为重要的任务,避免页面卡顿; const defer = typeof Promise === 'function' ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout // 更新队列 const updateQueue: any[] = [] // 队列更新 API export function enqueueRender(updater) { // 将所有 updater 同步推入更新队列中 // 为实例添加一个属性 __dirty,标识是否处于待更新状态 // 初始 和 更新完毕,该值会被置为 false // 推入队列时,标记为 true if ( !updater.__dirty && (updater.__dirty = true) && updateQueue.push(updater) === 1 ) { // 异步化冲洗队列 // 最终只执行一次冲洗 defer(flushRenderQueue) } } // 合并一次循环中多次 updater function flushRenderQueue() { if (updateQueue.length) { // 排序更新队列 updateQueue.sort() // 循环队列出栈 let curUpdater = updateQueue.pop() while (curUpdater) { // 当组件处于 待更新态 时,触发组件更新 // 如果该组件已经被更新完毕,则该状态为 false // 则后续的更新均不再执行 if (curUpdater.__dirty) { // 调用组件自身的更新函数 curUpdater.__update() // 执行 callback flushCallback(curUpdater) } curUpdater = updateQueue.pop() } } } // 执行缓存在 updater.__setStateCallbacks 上的回调 function flushCallback(updater) { const callbacks = updater.__setStateCallbacks let cbk if (callbacks && callbacks.length) { while (cbk = callbacks.shift()) cbk.call(updater) } }
完成这个方法后,我们来修改下上面的 setState
函数:
class Component { // 其余方法 ... public setState(partialState = {}, callback?) { // 合并状态, 暂存于 即将更新态 中 // 处理参数为函数的形式 if (typeof partialState === 'function') { partialState = partialState(this.state, this.props) } this.__nextState = { ...this.state, ...partialState, } // 缓存回调 callback && this.__setStateCallbacks.push(callback) // 把组件自身先推入更新队列 enqueueUpdate(this) } }
此时,由于 更新队列 为异步的,因此当多次连续调用 setState
时,组件的状态会被 同步合并,待全部完成后,才会进入更新队列的冲洗并最终只执行一次组件更新。
React 中组件还有个更新的方法: forceUpdate(callback)
,该方法的功能其实是与 this.setState({}, callback)
相同的,唯一有个需要注意的点就是: 触发的更新不需要经过 shouldComponentUpdate
的判断,实现只需要加个标识位即可。
回到性能优化这个点,从这里的简单实现我们可以看出: 虽然异步化了更新流程,但本质上仍然没有解决 复杂的组件 diff
带来长时间执行阻塞主进程。我记得以前文章有说过: 最有效的性能优化方式就是 异步、任务分割 和 缓存策略。
通过把同步的代码执行变成异步,把串行变成并行,可以有效提高 执行的时间利用率 和 保证代码优先级。从这里可以延伸出两种优化方向:
diff
本身就较为复杂,还要需要处理好主进程与线程之间的交互,会导致复杂度极高,但也并非不可行,后续也许是个优化方向。将原本会阻塞主进程的 大块逻辑执行进行拆解,分割成一个个小任务。从而可以在逻辑中找到合适的时机点 分段执行,即 不会阻塞主进程,又可以让代码快速高效的执行,最大化利用物理资源。
Facebook 的大神们选择了这条优化方向,这就是 React 16 新引入的 Fiber
理念的最主要目的。上面我们实现的 diff
中,有着一个很大的障碍:
一棵完整 虚拟DOM树 更新,必须一次性更新完成,中间无法被暂停,也无法被分割。
而 Fiber
最主要的功能就是 指针映射,保存上一个更新的组件与下一步需要更新的组件,从而完成 可暂停可重启。计算进程的运行时间,利用浏览器的 requestIdleCallback
与 requestAnimationFrame
接口,当有优先级更高的任务时,优先执行,暂停下一个组件的更新。待空闲时再重启更新。
Fiber
算是一种编程思想,在其它语言中也有许多应用(Ruby Fiber
)。核心思想是:
任务拆分和协同,主动把执行权交给主线程,使主线程有时间空挡处理其他高优先级任务。
但实现复杂度较高,为了本文便于理解,暂时并没有引入。等以后有机会我们再来一起深挖 Fiber
的实现,也许能成为更多使用场景的性能优化手段。
在上篇文章中,我们优先实现了 diffVNode
方法用于更新 元素节点,但组件节点的更新与元素节点的更新是不同的。当出现组件嵌套的情况时,我们就需要一个新的方法(diffComponent
)用于组件节点的更新。
与 元素节点 不同,组件节点之间的更新重要的是重渲染,类似于我们上面的 setState
。
复用已创建好的组件实例,根据新的 状态(state
)与 属性(props
) 重新执行 render
生成 元素节点,再递归比对,
也就是说,我们需要在 diffVNode
外围再做一层判断处理:
function diff(oldVNode, newVNode) { if (isSameVNode(oldVNode, newVNode)) { if (typeof oldVNode.type === 'function') { // 组件节点 diffComponent(oldVNode, newVNode) } else { // 元素节点, // 直接执行比对 diffVNode(oldVNode, newVNode) } } else { // 新节点替换旧节点 ... } } // 组件比对 function diffComponent(oldCompVNode, newCompVNode) { const { instance, vnode: oldVNode, elm } = oldCompVNode const { props: nextProps } = newCompVNode if (instance && oldVNode) { instance.__dirty = false // 更新状态和属性 instance.__nextProps = nextProps if (!instance.__nextState) instance.__nextState = instance.state // 复用旧组件实例和元素 newCompVNode.instance = instance newCompVNode.elm = elm // 使用新属性、新状态,旧组件实例 // 重新生成 新虚拟DOM const newVNode = initComponent(newCompVNode) // 递归触发 diff diff(oldVNode, newVNode) } }
组件有一个相当重要的特征,便是具有 生命周期。不同的函数钩子对应了组件 从初始化到销毁 的各个关键时间点。主要是为了让业务方有能力 插入组件的渲染工作流 中,编写业务逻辑。我们先来简单梳理下最新 React 组件的生命周期:
constructor
state
;static getDerivedStateFromProps(nextProps, prevState)
state
和 props
,而 props
由父级传入,组件本身并无法直接修改,因此唯一的常见需求就是: 根据父级传入的 props
动态修改 state
。该生命周期就是为此而生;大家可能会有疑问: 该方法为什么为静态方法? 而不是常规的实例方法呢?
render()
state
和 props
,生成 虚拟DOM;componentDidMount()
static getDerivedStateFromProps(nextProps, prevState)
shouldComponentUpdate(nextProps, nextState)
diff
优化策略中有提到: 为了减少 无谓的更新消耗,赋予组件一个可以 主动中断更新流 的 API
。根据参数中的 更新属性 和 更新状态,业务方自行判断是否需要继续往下执行 diff
,从而能有效地提升 更新性能;大家记得 React 中有种组件叫 纯组件(PureComponent
) 吧,其实这个类继承于普通的 Component
上封装的,可以减少多余的 render
,提升性能。
shouldComponentUpdate
函数设定更新条件: 仅当 props
和 state
发生改变时,才会触发更新。这里使用了 Object
浅层比对,也就是仅做第一层比对,即 1. key 是否完全匹配;2. value 是否全等; 所以如果需要超过一层的数据变动,纯组件即无法正确更新了;render()
getSnapshotBeforeUpdate(prevProps, prevState)
componentWillUpdate
,触发时机点: 在数据状态已更新,最新 VNode
已生成,但 真实元素还未被更新;componentDidUpdate(prevProps, prevState, snapshot)
setState
时必须加条件,避免无限循环;componentWillUnmount()
我们也按这样的目标来在我们的 Component
上实现这些生命周期,那如何更好的组织生命周期呢?这里我考虑到的是:
组件作为元素的容器,生命周期的本质其实是 其渲染出的元素节点的生命周期。
也就是说,关键点还是在于 元素在视图中的工作流,何时被挂载 - 更新 - 卸载。所以为了更好的维护性和拓展性,更理想的方式应该是 为元素节点统一添加生命周期,而不是单独为组件,这样可大大降低复杂度,增加可拓展性。
那我们第一步先根据上面需要的生命周期来理一下需要哪些时机:
create
);insert
);willupdate
);update
);willremove
);原理就很简单了,只需要在 VNode
工作流中的对应时期调用相应的生命周期函数即可。那我们现在 VNode
上新增一个属性 hooks
,用于 储存 对应的生命周期函数:
interface VNode { ... hooks: { create?: (vnode) => void insert?: (vnode) => void willupdate?: (oldVNode, newVNode) => void update?: (oldVNode, newVNode) => void willremove?: (vnode) => void } }
新增一个触发的方法(fireVNodeHook
):
function fireVNodeHook(vnode, name, ...data) { // 根据 生命周期名称 // 执行储存在 VNode 上的对应函数即可 const { hooks: _hooks } = vnode if (_hooks) { hook = _hooks[name] hook && hook(...data) } }
有了这层基础方法后,我们只需要分别在之前所写的 渲染与更新 流程中的各个函数适时地触发就行了。
create
该时机是在 元素被创建后,但还未被挂载之前。由于我们之前将逻辑统一收归为 createElm
,因此只需要在该函数末尾统一加入触发即可。
function createElm(vnode) { // 创建元素逻辑 ... // 触发 虚拟DOM 上储存的 钩子函数 fireVNodeHook(vnode, 'create', vnode) return vnode.elm }
insert
元素被挂载到视图 上的时机。从元素的角度来看,就是被 append
到父级中的时机点。这个时机点比较分散,但也比较好加入,找到我们使用 Api
中的 append
方法加入,总共有三个地方:
render
函数中加入对 根节点 的触发;createElm
函数中加入对 所有子级 的触发;diffChildren
列表比对中 新增列表项 的触发;willupdate
与 update
更新之前 与 更新之后,对应的便是我们的 diff
函数。由于最终均需走到 diffVNode
中,因此只需要在 diffVNode
开头和末尾触发即可。
willremove
元素被卸载时,其实与 insert
类似,只需要关注 Api
中 removeChild
的调用时机即可。在 diff
列表比对期间,当新列表中不存在时,我们需要删除旧列表中的元素,也就是之前写的业务函数 removeVNodes
。
由于 元素节点 才是贯穿整棵 虚拟DOM 渲染与更新的关键,因此我们上面先实现的是对 元素节点 的生命周期触发。但是我们最终需要是 组件节点 的生命周期。由于 组件节点 与 元素节点 为一一对应的 上下层级关系,因此这里我们还需要做一层转接:
把组件节点的生命周期赋值给其生成的元素节点。
首先我们先来为组件定义上生命周期,并定义一个中转对象 __hooks
,实现 组件节点周期与元素节点周期的转换:
class Component { public __hooks = { // 元素节点 插入时机,触发 didMount insert: () => this.componentDidMount(), // 元素节点 更新之前,触发 getSnapshotBeforeUpdate willupdate: (vnode) => { this.__snapshot = this.getSnapshotBeforeUpdate(this.__prevProps, this.__prevState) }, // 元素节点 更新之后, 触发 didUpdate update: (oldVNode, vnode) => { this.componentDidUpdate(this.__prevProps, this.__prevState, this.__snapshot) this.__snapshot = undefined }, // 元素节点 卸载之前, 触发 willUnmount willremove: (vnode) => this.componentWillUnmount(), } // 默认生命周期函数 // getDerivedStateFromProps(nextProps, state) public getSnapshotBeforeUpdate(prevProps, prevState) { return undefined } public shouldComponentUpdate(nextProps, nextState) { return true } public componentDidMount() { } public componentDidUpdate(prevProps, prevState, snapshot) { } public componentWillUnmount() { } }
然后我们只需要在 __createVNode
方法中将 this.__hooks
赋值给生成出的 VNode
即可:
class Component { ... public __createVNode() { // ... this.__vnode = this.render() // 赋值给对应的 元素节点, // 实现该 元素节点 与 组件 之间生命周期的绑定 this.__vnode.hooks = this.__hooks return this.__vnode } }
最后,大家可能发现我们还有两个钩子没有实现: getDerivedStateFromProps
和 shouldComponentUpdate
,这是因为这两个生命周期会影响到更新结果,因此需要 深入到更新流程中,无法单纯的通过 元素节点 的生命周期来实现。
但其实也很简单,就是在 更新之前,需要根据这两个函数的返回结果,适当调整下更新逻辑即可:
// Componet 中的 __update 方法 class Component { // ... public __update = () => { // 临时存储 旧虚拟节点 (oldVNode) const oldVNode = this.__vnode this.__nextProps = this.props if (!this.__nextState) this.__nextState = this.state // 执行 getDerivedStateFromProps // 更新 state 状态 const cls = this.constructor if (cls.getDerivedStateFromProps) { const state = cls.getDerivedStateFromProps(this.__nextProps, this.state) if (state) { this.__nextState = Object.assign(this.__nextState, state) } } // 在 diff 之前调用 shouldComponentUpdate 进行判断 // true: 生成新 VNode,继续 diff // false: 清空状态 if (this.shouldComponentUpdate(this.props, this.__nextState)) { // 重新生成 新虚拟节点(newVNode) this.__vnode = this.__createVNode() // 调用 diff,更新 VNode diff(oldVNode, this.__vnode) } else { // 清空状态更新 this.__nextProps = this.__nextState = undefined } // 刚才 异步更新队列 中标识的组件 待更新状态 // 在更新完后置为 false this.__dirty = false } }
组件更新还有另外一个地方,即 diffComponent
,也需要加入上述类似的执行和判断。完成这部分代码后,我们来简单测试个 DEMO:
<App>
、<BBB txt={this.state.txt} />
正确渲染;<App> setState
,<BBB>
文字元素正确更新;
<p style="text-align: center;font-weight: bold;">图1. 生命周期演示DEMO</p>
在这系列文章中,我们实现了 React 最核心的部分: JSX、组件、渲染、更新。我们基于 动手实践 的方式,循序渐进地探讨了一些原理与策略,得出一些最佳实践。相信走完这遍旅程后,大家能对 React 有了更深层次的了解,能够给到各位小伙伴启发与帮助。其实我也一样,也是在这个旅程中跟大家一起共同学习,共同成长。
还有许多模块,如 Context
、Refs
、Fragment
和 一些全局API,如 cloneElement
等,还有代码中一些更严谨的判断及边界情况的处理,并没有在文章中体现,主要是由于这些部分更多的是纯逻辑的扩展,同时也是为了便于理解。如果童鞋们有兴趣,可以到 github 中查看完整版代码:
另外我也想稍微唠嗑下关于 React-WebGL 这个想法。
近阶段我接触了一些 Web 游戏的开发,有了一些从前端开发者出发的思考与理解。在游戏开发领域,传统的游戏开发者有着一套与前端领域完全不同的思维编程模式。随着 Web 的发展,使他们需要拓展到 Js 的环境中。所以出现了一系列的游戏引擎库,本质上是从其它平台的库移植过来的。当我从一个前端开发者的角度在进行开发时,其实并不是说入门难,学习成本高,而是给我的感觉是: 类似用纯原生 js 在写页面,觉得效率低下。所以这也是 React-WebGL 的出发点,期望能将现在 Web 中更优秀的理念运用到游戏开发,甚至找到一种更高效的开发模式,提升效率,完善生态。
当然,这仅仅只是一个起点, 游戏开发 与 界面开发 确实有着许多异同点,如何找到一种更现代化更高效的 Web 游戏开发模式,这还需要很长的一段旅程。我也一直在思考,一直在摸索,相信能有一些好玩的东西。没有尝试,没有努力,就千万别在起点就放弃了。有什么问题,有什么想法,直接找我一起探讨哈。🙃~~
Tips:看博主写得这么辛苦下,跪求点赞、关注、Star!更多文章猛戳 ->
邮箱: 159042708@qq.com 微信/QQ: 159042708
[祝福#感恩#武汉加油##RIP KOBE#]()