读源码的目的
提到阅读源码,很多人会很畏惧,也有很多人会很向往。
当我们能够熟练使用别人提供给我们的工具时,要想更进一步,难免要去研究工具背后的事情。
这也是每一个资深技术人都应该做的事情。
可是,我们不能忽视阅读源码的难度,因为编写这些工具的人通常都是业内顶级的工程师,他们的技术水平非常高。
所幸,你遇到了这篇文章。
我会带你由浅入深的阅读 React18 的源码。
首先 React 的源码非常庞大,我们不要详尽地去看它的所有东西。我们主要专注于 React 的设计,看看他都有哪些最佳实践和模式,并且把这些东西融入到自己的代码库中。
Monorepo
React 的代码是 monorepo 模式,它将多个不同的项目放到了一个存储库中。所以根目录中没有大家熟悉的 src 目录,取而代之的是 packages 目录,packages 是前端 monorepo 约定俗成的文件夹命名。
React 存储库中包含了 30 多个包。大家熟悉的有 react、react-dom、react-server 和 react-devtools。
monorepo 的优势是可以将多个独立的部分组成一个大型项目,并让本地的配置更加容易,而且这些独立部分之间的代码可重用性很好。
但是 monorepo 并不是一个完美的解决方案,在进行子包的拆分时,我们需要投入更多的设计和思考。
通常来说,我们在本地环境和生产环境是不一样的,所以增加了部署的复杂性。同时也增加爱了整个代码库的复杂性。
但是一旦你把 monorepo 的工作流程弄清楚之后,上面这些问题就没有那么明显了。
从哪儿开始?
其实大多数人在阅读陌生的代码时,都会感觉到非常困惑。如果没有人给你梳理流程,介绍模块,这种困惑感会更强。
所以我们要从某一个位置作为开始。
阅读任何一个库的源码,我们都可以从它的第一个 API 开始。
那么 React 的第一个 API 是什么呢?
一定是下面这段代码:
import ReactDOM from 'react-dom' const root = ReactDOM.createRoot(container) root.render(element)
这段代码是 React 18 中将组件渲染到 DOM 上面。
在 SPA 项目中通常只会运行一次,我们就从这里开始。
你可能发现了,react-dom 并不是核心包,它是和浏览器绑定一起使用的。react 的核心包通过某种方式,可以让它在不同的环境中使用。
createRoot
createRoot 函数包装了一个内部函数,并做了一些简单的验证逻辑。
它没有直接提供实现,而是分离了验证逻辑并且把真正的实现逻辑放到了一个单独的文件中。
function createRoot( container: Element | Document | DocumentFragment, options?: CreateRootOptions ): RootType { if (__DEV__) { if (!Internals.usingClientEntryPoint && !__UMD__) { console.error( 'You are importing createRoot from "react-dom" which is not supported. ' + 'You should instead import it from "react-dom/client".' ) } } return createRootImpl(container, options) }
包装的内部函数名后面有 Impl 后缀,表示它是一个负责具体实现的私有函数。React 中有大量这种命名的习惯。
但我不认为这个命名是一个好的习惯,如果采用 createRootInstance 或者 cerateRootEntity 这种更具体的名称可能会更好理解。
进入这个单独的文件。
export function createRoot( container: Element | Document | DocumentFragment, options?: CreateRootOptions ): RootType { if (!isValidContainer(container)) { throw new Error( 'createRoot(...): Target container is not a DOM element.' ) } // ... const root = createContainer( container, ConcurrentRoot, null, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onRecoverableError, transitionCallbacks ) // ... return new ReactDOMRoot(root) }
我们发现这个函数实际上和外层的函数是同名的,只是外层使用了别名导出来避免命名冲突。我一般不会将函数名重名,除非我要实现多态。
createRoot 函数做的第一件事就是做出验证,如果不符合预期就退出。这种做法我用了很多年,是避免多层嵌套和复杂 if 语句的常规操作。
这个函数大概有 80 行,我移除了细节部分,现在我们可以专注于核心的部分。
它做的事情就是将容器和 React 的协调器建立连接。它使用了工厂函数 createContainer,同时也使用 new 来创建 ReactDOMRoot。这似乎有些奇怪。
再来看 ReactDOMRoot 这个函数。
function ReactDOMRoot(internalRoot: FiberRoot) { this._internalRoot = internalRoot }
它非常简单,只有一行代码而已。它会将 internalRoot 挂载到 this 上面。
再回到 createContainer 函数。
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = function ( children: ReactNodeList ): void { const root = this._internalRoot if (root === null) { throw new Error('Cannot update an unmounted root.') } if (__DEV__) { // ... } updateContainer(children, root, null, null) }
它使用了多重赋值,将一个方法挂载到了 ReactDOMHydrationRoot 和 ReactDOMRoot 的 prototype 的 render 方法中。
使用了 prototype 的好处是,我们可以通过 instanceof 来检查某个对象是否为某个类型。但是我不怎么会使用 prototype,我更喜欢工厂函数和闭包。
依赖原型的另一个好处是性能。使用闭包会造成一定的开销,但是原型并不会,所有被添加到原型上的方法只会被创建一次,但是闭包不会这样。
连接到协调器
创建完 ReactDOMRoot,接下来就是 updateContainer 方法,它是协调器的一部分。
export const updateContainer = enableNewReconciler ? updateContainer_new : updateContainer_old
使用条件导出的方式是很少见的。updateContainer 的逻辑是通过一个叫做 enableNewReconciler 的标志位来区分到底用哪一套逻辑。这种方式通常用于渐进式部署或者 AB 测试。
不同的方法也被放到了不同的文件夹中,分别是 .new.js 和 .old.js,它通过后缀名的不同来区分。React 中有大量这种命名方式的文件。
我们来看其中一个。
export function updateContainer( element: ReactNodeList, container: OpaqueRoot, parentComponent: ?React$Component<any, any>, callback: ?Function ): Lane { // ... const eventTime = requestEventTime() const lane = requestUpdateLane(current) if (enableSchedulingProfiler) { markRenderScheduled(lane) } // ... const update = createUpdate(eventTime, lane) // Caution: React DevTools currently depends on this property // being called "element". update.payload = { element } // ... const root = enqueueUpdate(current, update, lane) // ... return lane }
它的作用就是将下一个组件树通过 enqueueUpdate 更新到队列中。
值得注意的是,update 下面的那两行注释。这对一些从代码上看很不明显的操作进行解释,是一种非常好的例子。
接下来我们应该去看协调器的代码了,但是在这之前,我建议先去看看组件的内部结构。
什么是组件?
ReactDOMRoot.prototype.render 函数需要一个组件作为参数。
组件是一个对象,但是 React 不会让使用者用对象的形式表示 UI,那样的话,体验上来说实在是太糟糕了。所以我们通常会使用 JSX 来编写 React 代码。
然后在项目真正运行之前,通过转译器将 JSX 转换为创建对象的函数调用。
每个 JSX 元素最终都会被转换为 React.createElement 方法,当然我们也可以直接使用这个函数来创建 UI,只是在语法上非常抽象。
export function createElement(type, config, children) { let propName // Reserved names are extracted const props = {} let key = null let ref = null let self = null let source = null if (config != null) { // ... for (propName in config) { if ( hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName) ) { props[propName] = config[propName] } } } const childrenLength = arguments.length - 2 if (childrenLength === 1) { props.children = children } else if (childrenLength > 1) { // ... } // ... return ReactElement( type, key, ref, self, source, ReactCurrentOwner.current, props ) }
其实在 React 17 版本之后,JSX 不会再自动转为 React.createElement 了。因为在转换这个函数之前,都必须导入 React 才行。
如果你对 JSX 的工作原理不够了解的话,可能不能直观的感受这个变化是什么。
这个更新可以允许构建工具使用没有附加到 React 对象不同的功能。
export function jsx(type, config, maybeKey) { let propName // Reserved names are extracted const props = {} let key = null let ref = null // ... for (propName in config) { if ( hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName) ) { props[propName] = config[propName] } } // ... return ReactElement( type, key, ref, undefined, undefined, ReactCurrentOwner.current, props ) }
上面的 jsx 函数和 createElement 函数实现很像。
最终它们都委托一个 ReactElement 工厂函数来创建真正的组件对象。
这里其实就出现了一个重要的问题,什么时候应该提取一个通用函数?就像这个 ReactElement 函数一样。
通常来说,抽取通用函数,可以在视觉上消除重复的代码。
但是我们在一开始并不知道应该抽取哪些代码作为通用函数,往往都是在不断复制之后才知道应该复制哪些代码。
重复的代码看起来很烦人,但是管理它们并不难。相反,错误的抽象就可能会制造复杂性。现在回想起来,这个问题曾经在我工作生涯的早期多次犯过,只是当时没有意识不到这个问题。
我们再来看 ReactElement 这个函数。
const ReactElement = function ( type, key, ref, self, source, owner, props ) { const element = { $$typeof: REACT_ELEMENT_TYPE, type: type, key: key, ref: ref, props: props, _owner: owner, } if (__DEV__) { element._store = {} Object.defineProperty(element._store, 'validated', { configurable: false, enumerable: false, writable: true, value: false, }) Object.defineProperty(element, '_self', { configurable: false, enumerable: false, writable: false, value: self, }) Object.defineProperty(element, '_source', { configurable: false, enumerable: false, writable: false, value: source, }) // ... } return element }
这个方法没有做什么特殊的事情,它只是为组件对象的属性分配了正确的值。
不过它有一个需要注意的地方,就是在开发环境中,使用了 defineProperty 方法定义了 N 个属性。这个方法可以让我们更加精细地控制每个属性的行为。
设置 writable 为 false,意味着以后不可以给它重新赋值或删除它。
设置 enumerable 为 false,意味着它不可以在 for...in 语法和 Object.keys 方法中被遍历到。
设置 configurable 为 false,意味着设置了这些选项之后不可以再修改。
这个方法在开发应用中很少会用到,因为对应用来说,不需要对对象的属性进行这种控制。但是在开发一个库时,可能会经常用到,因为它可以控制库对外开放的对象的内部结构。防止它们出现在错误的地方。
$$typeof 被设置为 REACT_ELEMENT_TYPE,这是一个 Symbol 类型的变量。因为 ReactElement 是一个工厂函数,所以无法使用 instanceof 来检测它,所以用 Symbol 是一个很好的方法。
渲染器和协调器的交互
现在我们知道了什么是组件,也知道了它们是怎么被创建的。现在我们更进一步,来看看元素是怎么样被渲染到屏幕上的。
我们需要阅读协调器的文档,它的源码在 react-reconciler 子包中。
渲染器有一个 diffing 算法,它可以找出组件的变化并且通知渲染器重新生成组件。渲染器定义了某些方法来处理组件的渲染,但是它不负责调用它们,因为它不知道什么时候调用它们。
这些方法由协调器调用。
react-reconciler 中的 README.md 详细介绍了它是如何与渲染器进行交互的。
本质上,每种渲染器都需要遵循目标环境的约定,它必须提供给协调器所要依赖的很多方法和属性。这意味着只要你的渲染器拥有这些方法和属性,就可以自己创建渲染器将 UI 呈现在你想要的任何位置,而不仅仅是浏览器或者原生应用,比如说嵌入式系统中。
const Reconciler = require('react-reconciler') const HostConfig = { createInstance(type, props) { // e.g. DOM renderer returns a DOM node }, // ... supportsMutation: true, // it works by mutating nodes appendChild(parent, child) { // e.g. DOM renderer would call .appendChild() here }, // ... } const MyRenderer = Reconciler(HostConfig) const RendererPublicAPI = { render(element, container, callback) { // Call MyRenderer.updateContainer() to schedule changes on the roots. // See ReactDOM, React Native, or React ART for practical examples. }, }
module.exports = RendererPublicAPIÏ
我们需要实现的接口是 HostConfig,它这样命名是因为渲染器正在将 React 连接到主机环境,当我看到 Config 这个词时,我能想象到一些环境变量相关的东西。
毕竟命名是计算机科学中最难的两大问题之一。
渲染器方法
渲染器具有很高的复杂度,我不会去讲解每个功能。相反我会专注于它最重要的功能:如何解决将内容渲染到屏幕上的难题。
需要注意,react-reconciler API 不保证稳定性。它会比渲染器或者 core 更加频繁地调整。
createInstance 是将每个组件可视化的方法。
export function createInstance( type: string, props: Props, rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: Object ): Instance { let parentNamespace: string if (__DEV__) { // ... } else { parentNamespace = hostContext } const domElement: Instance = createElement( type, props, rootContainerInstance, parentNamespace ) //... return domElement }
现在看到的是处理 DOM 元素的渲染器,在理论上,渲染器可以在屏幕上绘制任何内容。
这个函数是在做一个条件赋值,它使用工程函数创建了一个 DOM 元素。
再来看对纯文本节点进行操作的 createTextInstance 函数。
export function createTextInstance( text: string, rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: Object ): TextInstance { if (__DEV__) { const hostContextDev = hostContext validateDOMNesting(null, text, hostContextDev.ancestorInfo) } const textNode: TextInstance = createTextNode( text, rootContainerInstance ) precacheFiberNode(internalInstanceHandle, textNode) return textNode }
它和我们之前看到的很多模式都类似,在开发模式下进行必要的验证,然后将具体的创建任务委托给另外一个工厂函数。实际上 React 源码中存在了大量的这种模式。
上面提到的这两个函数会创建所有 DOM 元素,然后把这些元素添加到真正的 DOM 中去。
appendChild 是具体的实现。
export function appendChild( parentInstance: Instance, child: Instance | TextInstance ): void { parentInstance.appendChild(child) }
它直接接收一个 DOM 元素的实例,并调用它的 appendChild 方法来添加渲染的组件。
因为我们很难确定插入组件的确切位置,所以这需要在父元素的帮助下完成。
总结
到这里,我们对 React 的渲染过程有了一个初步的理解,相信你已经搞懂了渲染器的工作原理和它们的实现方法,你应该也知道它们是如何连接到协调器上面,以及在 React 内部是如何表示组件的。
现在你应该明白了:阅读源码并不难。
接下来你可以自由发挥,大胆地去探索 React 源码的更多内容吧!