第 61 期 - 从零构建 React 的过程与原理
摘要
本文讲述了从零构建 React 的过程,包括视图层次结构表示、核心钩子实现、重新渲染策略、依赖树构建、DOM 更新优化等内容,还探讨了各钩子的实现逻辑以及一些特殊情况的处理,最后提及未来项目发展方向。
一、构建视图层次结构
在构建 React 的过程中,首先要处理视图层次结构。传统上,React 使用 JSX 编写,但其实际库并不直接处理这种类似 HTML 的语法,而是将所有的 JSX 语法转换成函数调用,例如<div id="parent"><span>hello</span></div>
会被转换成React.createElement("div", { id:"parent" }, React.createElement("span", null, "hello"))
。
为了模拟createElement
的输入类型,定义了ReactComponentExternalMetadata
类型,同时为便于处理组件元数据,定义了内部表示类型如TagComponent
、FunctionalComponent
和ReactComponentInternalMetadata
。接着可以将createElement
的输入转换为内部表示。
有了视图层次结构的表示后,就可以将其应用于 DOM,通过applyComponentsToDom
函数实现。不过最初这个函数只遍历标签元素,因为标签元素的树是急切求值的。当涉及到函数式组件组合时,情况变得复杂,因为函数式组件的“children”属性不再是可轻松遍历的有效树状结构,需要构建一个新的树结构ReactViewTreeNode
来明确表示节点及其子节点。
二、核心钩子的实现
(一)useState
- 解决组件实例关联问题
- 在 React 中,
useState
用于将状态绑定到组件。第一个问题是useState
如何知道是从哪个组件实例中被调用的。解决方法是在遍历组件内部元数据生成视图树时,全局跟踪调用的组件,useState
定义内部读取这个全局变量来确定调用组件。
- 在 React 中,
- 解决钩子调用顺序与状态关联问题
- 对于一个组件中有多个
useState
的情况,需要在渲染之间记住与哪个状态相关联。React 通过钩子调用顺序来确定渲染之间钩子调用的相等性,不能有条件地调用钩子。可以通过在钩子定义内部每次调用时递增一个全局可访问的计数器,并在调用时读取计数器的值来跟踪钩子调用的当前顺序。在组件视图节点中维护一个“钩子状态”的数组,根据计数器索引数组来获取和更新状态。
- 对于一个组件中有多个
(二)重新渲染组件
- triggerReRender 函数的实现
- 实现
triggerReRender
函数包含三个步骤。首先从setState
闭包中捕获的currentlyRenderingComponent
开始重新生成视图树,这一步可以直接使用generateViewTree
函数。 - 然后打补丁到现有视图树,即遍历现有视图树找到当前渲染组件的父节点,用新生成的节点替换旧节点并转移状态。
- 最后使用打了补丁的视图树更新 DOM,虽然这里的实现是一种极端低效的策略,即从根开始拆除整个 DOM 然后重新构建。
- 实现
(三)更多钩子
- useRef
useRef
是一个比较容易实现的钩子,它将一个不变的引用绑定到组件上,允许改变引用处的内容而不触发重渲染。实现过程与useState
类似,读取当前渲染的组件,索引到其钩子中,在组件未渲染时初始化。
- useEffect
useEffect
有效果回调、效果依赖和效果清理三个核心组成部分。每次依赖改变或组件首次挂载时调用效果回调,如果回调返回一个函数,在下一个效果之前将被调用作为清理函数。- 实现过程是先读取当前渲染的组件,索引到其钩子中并在首次渲染时初始化。如果依赖项相比于上一次渲染发生变化,更新钩子状态的效果回调和依赖项。在渲染完成之后,遍历组件持有的所有效果,检查依赖项是否变化,根据情况调用清理函数、效果回调并更新新的清理函数。
- useMemo
useMemo
接受一个输出值的函数和一个依赖项数组,如果依赖项在渲染之间没有变化,不会再次调用函数,而是重用上次输出。实现过程与useEffect
相似,先进行初步的设置逻辑获取钩子状态或初始化,然后检查依赖项是否变化,变化则调用函数,否则返回之前的值。
- useCallback
useCallback
实际上就是返回函数的useMemo
,实现起来比较简单,即export const useCallback = <T,>(fn: () => T, deps: Array<unknown>): (() => T) => { return useMemo(() => fn, deps); }
。
- useContext
useContext
与其他钩子不同,它不负责创建状态,仅负责读取在更高层次树中共享的状态。共享状态的功能来源于上下文提供者组件。- 先实现
createContext
函数创建特殊提供者组件,通过在内部元数据上添加属性来分配要分发给组件后代的数据。在useContext
实现中,向上读取视图树寻找匹配的提供者节点,如果找到则读取数据,找不到则回退到读取默认值。
三、渲染相关的其他问题
(一)组件重新渲染的正确判断
- 依赖树的构建
- 组件树中的组件在实现之间应该重新渲染相同次数,但之前的实现存在问题。需要构建一个依赖树来正确判断组件何时需要重新渲染。构建依赖树的关键是知道给定组件是在哪个组件中调用的,通过在渲染组件之前创建依赖树节点并全局存储,对于每个
createElement
调用,推入新的渲染节点作为子节点。
- 组件树中的组件在实现之间应该重新渲染相同次数,但之前的实现存在问题。需要构建一个依赖树来正确判断组件何时需要重新渲染。构建依赖树的关键是知道给定组件是在哪个组件中调用的,通过在渲染组件之前创建依赖树节点并全局存储,对于每个
- 依赖树在渲染中的应用
- 在生成视图树时,只有当组件依赖于触发重新渲染的组件(或者如果它之前从未被渲染过)时,才重新渲染一个组件,否则可以跳过渲染并使用之前的视图树输出缓存结果。
(二)渲染之间对视图节点进行协调
- 状态在渲染间的传递
- 之前在渲染之间传递状态的方式存在错误,只考虑了触发重新渲染的组件,其他组件会重新初始化状态。需要确定树的两个节点之间的相等性,不只是根节点,可以通过节点的索引路径来定义相等性,当节点在视图树之间的索引路径相同时,可以将状态从前一个树转移过来。
(三)条件元素的处理
- 条件渲染的两种情况
- 对于条件渲染,一种是有条件地返回不同元素,另一种是有条件地渲染一个元素。第一种情况在现有实现中已自动处理,第二种情况需要更新
createElement
函数以允许null | false | undefined
作为子元素。 - 简单地过滤掉这些值会导致问题,更好的方法是将它们表示为树中的空槽,这样可以保持树在渲染之间的稳定性,正确地传递状态。
- 对于条件渲染,一种是有条件地返回不同元素,另一种是有条件地渲染一个元素。第一种情况在现有实现中已自动处理,第二种情况需要更新
(四)高效的 DOM 更新
- 基于节点路径的 DOM 更新策略
- 目前的实现每次重新渲染都会拆除整个 DOM,而 React 只会更新需要的 DOM 节点。可以通过比较新旧视图树,根据节点的索引路径,对于匹配的标签节点传递新视图节点的 props 给 HTML 标签,对于新节点创建新的 HTML 元素,对于不存在的旧节点删除对应的 HTML 元素。同时由于条件元素(空插槽)的存在,实际代码会更复杂一些。
四、项目的未来发展方向
- 服务器端渲染
- 要实现服务器端渲染,需要从视图树构建一个字符串而不是 DOM,还需要找到一种方法将服务器生成的 HTML 映射到客户端生成的视图/依赖树上,也就是所谓的 hydration。
- 不同的渲染目标
- 没有理由只从视图树生成一个 DOM,任何具有层次结构的 UI 都可以使用这里实现的 React 内部机制。
- 用 swift 重新实现
- 由于工作原因对 UIKit 的使用,作者想将这个项目移植到 swift 上,创建 UIViews 而不是 DOM 元素。
扩展阅读
Made by 捣鼓键盘的小麦 / © 2025 Front Talk 版权所有