深入剖析 React 代码库 [EP3:逆向工程最著名的 React 代码片段]
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
TL;DR:这篇文章写得又长又无聊。您可以快速浏览,直接去看下一篇。下一篇会总结所有要点。之后的文章都会更加简短精炼。
在上一集中,我们完成了 React 代码仓库的搭建。
在今天的节目中,我们将深入研究实际的源代码,并对可能是最知名的 React 代码片段进行逆向工程。
记起
我们在前几集中学到了什么
React monorepo 包含大量与 React 相关的包,包括 React 核心、渲染器、协调器、实用工具包、开发工具和测试工具。
其中一些(例如 `react- reactcore`、 `react-reconciler`react-dom和react-reconciler`react-test`)对于深入理解 React 源代码作为浏览器环境下构建 UI 的库更为重要。
其他一些包则与更高级的内容相关,例如测试、工具或 React Native,只有在我们探索React 及其工具集时才有用。
了解了这些之后,我们就可以直接开始编写代码了。
找到正确的方法
探索 React 代码库的合适方法很难找到,主要是因为它目前非常庞大且复杂。
我已经尝试过几次在没有任何大致了解或计划的情况下贸然深入研究。
这次,我们将尝试另一种方法。
今日计划
我们将尝试用我能想到的最合乎逻辑的方法来探索代码库。我们不会采用“从头开始package.json,找到入口index.js文件,然后从那里入手”的方法,因为这种方法很容易迷失方向。
相反,我们将从最简单的 React 代码入手,这种代码我们大多数人都见过几十遍,然后借助真正的 React 源代码对其进行逆向工程。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
ReactDOM.render(<App />, document.getElementById('root'));
这种方法化繁为简,学习曲线平缓,让你能够从最实用、最有趣的内容入手。这与我们编写可用于生产环境的代码的方式类似,先勾勒出解决方案的框架,然后根据需要逐步细化。简而言之,我们是从基础出发,逐步构建通往最终目标的路径,而不是反其道而行之。
附注:这是一种实验性的方法,因此我不知道它在大规模应用中是否真的有效。
所以,如果您喜欢并且它对您有效,请留言告诉我,以便我继续使用它。
或者,如果您觉得它不好用,也请留言告诉我哪里出了问题,我会根据您的反馈尝试设计一个更好的方法。
提前感谢🙏🏻
本集素材
我为本系列教程在 GitHub 上创建了一个代码仓库。我们将在那里进行探索、实验和尝试。
这是一个单体仓库(没错,就像 React 代码仓库那样),所以从现在开始,每个章节都会对应一个目录。
请将代码仓库克隆到您的本地计算机。
$ git clone https://github.com/fromaline/deep-dive-into-react-codebase.git
或者在您喜欢的在线代码编辑器中打开它,例如Gitpod或CodeSandbox。
我们的设置
在代码仓库中,你会找到当前剧集的目录,其中ep3包含最简单的 React 配置。它只是一个html页面,其中`<head> react` 和 `<body>`react-dom是通过 `.` 添加的unpkg。
<!-- index.html -->
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@17.0.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.0/umd/react-dom.development.js"></script>
<script src="./index.js"></script>
</body>
以及一个具有众所周知的设置的js文件,您几乎可以在任何 React Web 应用程序源代码中找到它。
// index.js
const App = <div>Hello world!</div>;
ReactDOM.render(<App />, document.getElementById('root'));
这种简单的设置简化了我们的调查体验。它消除了现代前端工具(例如webpack和babel)为了方便最终用户而引入的复杂性。但我们并不想仅仅成为最终用户,我们渴望获得深入的理解,因此我们并不需要这些工具。
启动并运行
index.html现在我们需要在浏览器中启动它。
我使用的是http-server,但您可以使用您喜欢的任何工具,例如live-serverVSCode 或 Python 的http.server。
$ http-server episodes/ep3
首先我们看到的是这样的错误。
Uncaught SyntaxError: Unexpected token '<' index.js:1
出现此错误的原因是我们在没有使用合适的工具(例如Babel)编译 JSX 的情况下使用了它。因此,我们需要自己“编译”JSX。
Babel 的内部工作原理非常简单。它会将 JSX 代码替换为对 React.createElement 或其他函数的调用(如果已使用特殊注解语法明确指定)。
// @jsx React.createElement
const App = <div>Hello world!</div>;
经过转译阶段后,代码看起来就像普通的 JavaScript 代码。你可以在Babel REPL中再次确认。
const App = React.createElement('div', null, 'Hello world!');

现在我们看到了自己的Hello world榜样,或许终于可以继续前进了!
逆向工程
目标
今天和下一集的目标是掌握react-domReact 组件树如何挂载到真实的 DOM 中。理解这个过程非常重要,因为它是 React 应用中初始化的第一步。
假设
我们先来提出一个假设。
根据我对真实 DOM 工作原理的理解,我假设它遍历的是由包react-dom构成的 React 组件树(虚拟 DOM) 。react
const App = {
type: 'div',
props: {},
children: ['Hello world!'],
};
然后react-dom基于虚拟 DOM 创建真实的 DOM 结构。
const el = document.createElement(App.type);
// ...
if (App.children.length === 0) {
const child = App.children[0];
// ...
if (typeof child === 'string') {
child.textContent = child;
}
}
然后react-dom将结果挂载到提供的容器中。
container.appendChild(el);
检验假设
现在我们将检验这个假设,看看我们是否正确。
它的功能是什么React.createElement?它是如何运作的?
首先,我们来看看它的React.createElement实际工作原理以及返回值。我们已经知道它与react软件包有关,因此让我们检查一下packages/react目录。
// packages/react/index.js
// ...
export {
// ...
createElement,
// ...
} from './src/React';
找到了,现在找出它的出口地。
// packages/react/src/React.js
const createElement = __DEV__ ? createElementWithValidation : createElementProd;
如您所见,createElement该值会根据__DEV__全局变量而有所不同,而全局变量又定义了代码是否在所谓的开发模式下编译。
根据这两个函数的名称和__DEV__变量的含义,我推测,其中一个函数会在开发模式createElementWithValidation下进行额外的验证,以提供有意义的错误信息和警告。而另一个函数可能性能更高,并且通常更适合生产环境使用。createElementProd
createElementWithValidation
首先,我们通过在 React 应用中引入错误来验证之前的假设。我们提供一个 null 值,而不是实际的有效类型。
// index.js
const App = React.createElement(null, null, 'Hello world!');
太好了,现在我们看到了一个典型的 React 警告,并且可以轻松地追踪到它的初始化位置。
最初调用该函数的地方就是我们的createElementWithValidation函数,所以点击它react.development.js:2240来查看实际代码。
从这段代码片段可以明显看出,我们的第一个假设接近正确。它会createElementWithValidation检查提供的数据类型是否type有效,如果无效,则会根据提供的数据类型具体存在的问题抛出不同的警告。
附注:你可能会问,为什么代码中会有这样奇怪的语句?
{
error('React.createElement: type is invalid...')
}
简单来说,这是一个没有if条件语句的块语句。if由于这是开发版本,所有警告和错误都必须显示出来,因此 webpack 移除了该语句。
这个话题有点超出本文范围,更多信息请查看我的 Twitter 帖子。
现在让我们消除错误,并观察该函数内部还发生了什么。
function createElementWithValidation(type, props, children) {
var validType = isValidElementType(type);
// We warn in this case but don't throw. We expect the element creation to
// succeed and there will likely be errors in render.
if (!validType) {
// warnings, but no returns!
}
这里第一个有趣的地方是错误处理的实现方式,变量后面甚至还有相关的注释validType。React
开发者不会在类型无效时抛出异常,而是会继续执行,但会预期渲染过程中会出现一些错误。
我们知道 React 中的渲染是由渲染器(renderers)处理的,在本例中也是如此react-dom。
因此,我们可以推断,React 组件内部会进行一些验证,并发出相应的警告react-dom。
附注:这是一个有趣的假设,因为它意味着react软件包的输出并非始终有效,渲染器需要自行验证从中获取的内容。
我们将在后续文章中验证这一假设。
我们继续讲解这个函数。在完成初始检查后,它会调用一个更通用的createElement函数。
var element = createElement.apply(this, arguments);
所以,这一事实可能表明,实际上只有一个createElement函数负责创建元素。而 ` createElementWithValidationand` 和 ` createElementProdor` 只是封装函数,添加了一些额外的功能。
我们将在完成当前的观察后验证这个假设。
这里我们看到了对空值的检查以及类型强制转换和有用的注释。
// The result can be nullish if a mock or a custom function is used.
// TODO: Drop this when these are no longer allowed as the type argument.
if (element == null) {
return element;
}
这段代码片段表明,element如果使用“模拟函数或自定义函数”,则该值可能为 null 甚至 undefined。
现在很难确定如何在此处使用自定义函数,因为createElement它是硬编码的,但我们稍后肯定会找到答案。
附注:目前我还不完全理解这TODO部分代码的含义。我最初的猜测是,如果元素的值不允许为 null 或 undefined,那么就可以移除这个检查。
如果您对它的含义有更好的理解,请在评论区留言!非常感谢。
接下来是对子键进行验证。
// Skip key warning if the type isn't valid since our key validation logic
// doesn't expect a non-string/function type and can throw confusing errors.
// We don't want exception behavior to differ between dev and prod.
// (Rendering will throw with a helpful message and as soon as the type is
// fixed, the key warnings will appear.)
if (validType) {
for (var i = 2; i < arguments.length; i++) {
validateChildKeys(arguments[i], type);
}
}
从实际代码片段可以看出,只有当最初提供的元素类型有效时,才会进行键验证。从注释的前两句话可以更清楚地看出这种行为背后的原因。它validateChildKey不接受非字符串/函数类型,因此可能会抛出与生产版本不同的、令人困惑的错误。
附注:让我有点难以置信的是,关键验证逻辑要求元素的类型必须有效,因为乍一看它们似乎大多无关。
从评论的第三句话中我们再次看到,应该由渲染器而不是react软件包来处理正确的错误。
最后,函数以一些其他的验证和返回语句结束。
if (type === exports.Fragment) {
validateFragmentProps(element);
} else {
validatePropTypes(element);
}
return element;
这里我们看到一个简单的返回语句,以及在此之前的两个独立的验证:
- 片段属性验证
- 通用元素属性验证
因此我们可以得出结论,prop 类型验证发生在这里,而如果元素是片段,则 props 验证的处理方式有所不同。
现在让我们来看看它createElementProd的作用以及它与createElementWithValidation.
createElementProd
让我们回到正题packages/react/src/React.js,追溯createElementProd导出源。
// packages/react/src/React.js
const createElement = __DEV__ ? createElementWithValidation : createElementProd;
我们可以使用现代 IDE 的标准功能来查找createElementProd函数的实现位置,或者直接检查文件开头的导入语句。我将使用后一种方法。
// packages/react/src/React.js
import {
createElement as createElementProd,
// ...
} from './ReactElement';
实际上,createElementProd它只是这些函数的导入别名createElement。
因此,我们最初关于createElementWithValidation`and` 的假设createElementProd几乎正确,但并不完全正确。
实际上,情况甚至更简单:
- 我们只有一个
createElement函数,它在生产环境中使用。 createElementWithValidation该功能增加了额外的验证,以提供有意义的警告,并且它在开发环境中使用。
createElement
有了关于整个创建元素机制的新知识,我们只需要弄清楚createElement返回值,就能理解元素在生产环境和开发环境中是如何创建的。
为此,让我们跳转到createElement函数内部的调用createElementWithValidation。
在 return 语句之后立即设置一个调试断点。
最后,我们看到了通话结果React.createElement。现在,让我们修正假设中不准确的部分,使其反映我们新的认知。
调整假设
根据我对真实 DOM 工作原理的理解,我认为它会遍历由包
react-dom构成的 React 组件树(虚拟 DOM) 。reactconst App = { type: 'div', props: {}, children: ['Hello world!'], };
实际上,React 组件树看起来更像这样。
const App = {
"$$typeof": Symbol(react.element),
"type": "div",
"key": null,
"ref": null,
"props": {
"children": "Hello world!"
},
"_owner": null,
"_store": {},
"_self": null,
"_source": null
}
原版哪里出错了?
children它不是单独的属性,而是内部的一个属性。props- 如果只有一个子元素,则直接传递,无需包装数组。至少当该子元素是文本类型时是如此。
- React 组件还有一些其他属性(我们尚未弄清楚它们的作用),更具体地说:
$$typeofkeyref_owner_store_selfsource
但总的来说,我们假设的第一部分相当准确!我们只是对其进行了扩展并修正了一些小问题。
包起来
这是一段漫长的旅程,我们今天学到了很多!
在下一集中,我们将继续验证我们的假设。更准确地说,我们将尝试弄清楚react-dom虚拟 DOM 的具体作用以及渲染的实际工作原理。
期待在下一集中与您相见!
我们今天学到了什么
在我看来,我们今天学到的最重要的一点与 React 的内部运作机制无关,而是理解代码底层工作原理的方法。
所以,我希望你们能自己实践一下!
我期待看到类似的内容。
- 请在Twitter上关注我:
- 请关注我在dev.to上的账号,阅读这个每周更新的系列文章。下一期将于1月30日(下周日!)发布。




