开篇词 这一次,真正吃透 React 知识链路与底层逻辑
在接下来的一段时间里,我们将一起深入 React 这个框架领域,完成从“小工”到“专家”的蜕变。
作为一名React 重度用户,与其说我对 React 源码、底层原理及周边生态有着较为深入的探究,不如说我对它们有着浓厚的兴趣。早期,我专注于性能优化和前端工程化,曾将线上大型应用性能提升率做到 40%,并基于 React 打造过团队新基建。此外,我还担任过多年一线前端面试官,积累了丰富的面试经验。
前端生涯至今,我从未停止过挑战自己的能力边界,始终乐于拥抱新的技术和工具,这不止让我保持了很好的职场竞争力,还使我深知新手从入门到精通过程中的痛点和难点。
作为一线开发者,我不认同技术圈时下盛行的“造名词”风气,痛恨故弄玄虚的“语言壁垒”——其实技术本身在多数情况下都是一些简单且有趣的东西,人们越是试图神化它,越容易脱离技术本质。这也是我在这个专栏中秉持和践行的一个原则。
学好 React,到底有多爽?
在过去的几年,“变化”始终是前端框架世界里的一号关键词:前有 jQuery 刚刚式微时各路神仙各显神通,后有 React/Vue/Angular 三分天下,如今又渐渐演变成了 React/Vue 两分天下。
而反观框架本身,你会发现 Vue、React 乃至 Angular 之间不仅写法越来越像,甚至在设计层面也日渐趋同——它们似乎像是约好了一样,在齐刷刷地朝着 WebComponents 标准前进。因此在展望未来的前端框架时,我们有充分的理由相信,属于前端框架的一号关键词终有一日会从“变化”发展为“稳定”或“标准化”。
在这样的趋势下,站在任何主观视角去拉踩任何前端框架的行为都是不合适的。学习者在意的不应是“哪个框架最牛”这样娱乐性的问题,而应该是学习的效用。
那么学习 React,将会带来什么样的效用呢?
利好个人职业生涯:大厂更喜欢 React
若单说岗位数量,我不敢妄言,但在一二线的互联网大厂中,React 的绝对优势凸显无疑。(比如,阿里就统一使用 React 作为底层技术栈,并且在内部紧密共建 React 生态。)国外的一份“2019 年度 JavaScript 趋势报告”中,React 也被评估为综合指数最高的前端框架:
在招聘上,大厂普遍青睐 React 人才,各种高薪职位中不乏“精通 React”“掌握 React”的字眼。作为前端,我们必须认识到这样一个现状:大厂(包括国内、国外)更喜欢 React,当我们立下一个有朝一日进大厂的志愿时,就意味着必须先下定决心搞定 React。
(信息来源:拉勾网)
强化项目实战能力:吃透 React,疑难杂症不在话下
面试时,React 相关的问题往往具备较高的区分度,能够在 React 方面脱颖而出的候选人并不多。很多时候,候选人似乎也确实不理解面试官“为什么要问得这么难”。比如常见的吐槽就有“我不读源码,不研究调用栈,用 React 写业务照样一把梭”这样的说辞。
确实,通过阅读 React 文档以及市面上一些“快速上手”“XX 实战”类型的学习材料,也能胜任一定的业务开发工作,但当业务复杂度攀升,“奇形怪状”的问题就会如雨后春笋般接连冒头。当你对 React 的运行机制不甚了解时,遇到这样的“疑难杂症”,就很容易懵掉。
面试环节的 React 深度考察,正是为了筛选出这些能够真正吃透 React、解决复杂问题的“高级玩家”:对 React 的理解深度,将决定着你所能解决的实战问题复杂度的上限。
普通开发者的“逆袭”机会:一个好的框架,就是最好的老师
这两年,许多中小型公司的前端工程师都面临着这样一个困境:业务含金量不高,老板又不重视,技术专项难以提取,架构机会更是没有……好像永远都没办法破局,难道我这辈子就这样了吗?
当然不是!当环境无法给我们提供优质的成长途径时,不妨自己尝试创造途径,比如:
-
深挖一个优质的前端框架,吃透其底层原理;
-
跟框架作者(React 团队)学架构思想、学编码规范、学设计模式。
React 正是一个优秀前端框架的典型 ,它在架构上融合了数据驱动视图、组件化、函数式编程、面向对象、Fiber 等经典设计“哲学”,在底层技术选型上涉及了 JSX、虚拟 DOM 等经典解决方案,在周边生态上至少涵盖了状态管理和前端路由两大领域的最佳实践。此外,它还自建了状态管理机制与事件系统,创造性地在前端框架中引入了 Hooks 思想...... React 十年如一日的稳定输出背后,有太多值得我们去吸收和借鉴的东西。
这个专栏我将带你掌握目前行业里相对前沿且具有代表性的一套东西,也是真正能够在你的职业生涯里沉淀下来、发挥长期效用的“底层技能”。
React 为什么这么难学?
在实际的招聘过程中,我和同事都曾经不止一次地发过这样的感慨:当下要想从社区招到一个符合预期的 React 开发,真的太太太太太太难了。
不知道你有没有观察到一个比较有趣的现象:Vue 知识体系/原理的相关内容百花齐放,但 React 知识体系/原理的相关内容却屈指可数。
市面上以 React 为主题的进阶性内容,大部分是在教会一系列 API 的基础上,描述如何去实战一个具体的项目,即专精于“使用”;而为数不多的源码分析性内容,虽然试图去拆解“原理”,但却往往伴随着细化到逐行代码的知识粒度,对读者的时间、耐力和既有水平(提炼知识、抽象知识的能力)都提出了很高的要求。
这些现象的背后,和 React 令人望而却步的庞大知识体系、精密复杂的底层原理以及长长的知识链路是分不开的。平心而论,学透 React 很难,而我想帮你解决的,也正是这个“难”。
课程设计:串联知识链路,讲透底层逻辑
我分享技术内容有两年多了,一直将“接地气、说人话”作为写作的第一要务,这个专栏更是将“把复杂的问题简单化、把琐碎的问题系统化”作为课程设计的核心原则。它并非平铺直叙的学习笔记,而是一次我与你之间的对话。
我希望做一个能够将学习体验与知识深度中和到最佳状态,切实为你带来学习效用的专栏。为此,专栏在设计层面做了以下几件事情。
设计原则:贴着大厂面试逻辑做大纲,贴着源码讲原理
大厂的 React 面试不是走过场,更不是“造火箭”式的炫技,它是最有“效用导向”的一个学习依据。如果能够将大厂面试的逻辑利用充分,我们将实现面试和应用的双重突破。
贴着源码讲原理,绝不是带着你死磕源码,源码 !== 原理,源码是代码,而原理是逻辑,代码是繁杂冗长的,原理却可以是简洁清晰的。在一些场景下,源码确实能够成为一个不错的教具,但阅读源码不是抵达原理的唯一途径。因此,必要时我会提取对你理解问题有帮助的源码;也会在一些场景下选取其他的教具,确保你能够用正确且高效的姿势抵达知识的重点。
专栏所涉及的原理,可以帮你解决实际工作中的大多数疑难杂症,也可以 Match 上大厂对资深前端工程师的技术深度的要求。
对于体系性较强的知识:创建足够充分的上下文
之前曾经读到过木心关于红楼梦的书评,印象极深:“红楼梦中的诗词像是水中摇曳的水草,美极。若是捞出来看,就干巴巴了。”
同样的道理也适用于 React 的知识链路:一些知识之所以难学,不是因为它有多复杂,而是因为理解它是需要上下文的。你若把它放到正确的上下文里,可能想通这件事也就是一瞬间的工夫;但如果你的学习上下文是断裂的,那么知识点本身自然会变得“干巴巴”,难以下咽。
对于复杂度较高的知识:用现象向原理提问
考虑来学习这门专栏的同学的学习阶段参差不齐,我在讲解复杂原理时,会尽量遵循“先提现象/问题,再挖原理”这个顺序,将困难知识的学习坡度降至最低。专栏中有一些内容的前置知识,我写得比较细,一般也会提前标明这是“先导知识”,如果你是高端玩家,直接跳过即可。
整个专栏的结构规划思路如下。
-
模块一:基础夯实。这部分内容涉及 React 的基本原理和源码,对大多数人普遍薄弱的、说不清楚的基础知识做深入浅出的讲解,帮你突破一些重点和难点。
-
模块二:核心原理。这部分内容源于日常开发中的疑难杂症、大厂面试的压轴难题,呈现出框架的底层逻辑和源码设计,我将用最少的篇幅来提取尽量多的信息。如果你想要从事一些高级岗位,或者精通 React,那么这块的内容你肯定避不开,而面试官能够通过这些内容,对候选人的能力做一个评价,甚至是定级。
-
模块三:周边生态。很多人用过 Redux、听说过 React-Router,但为什么要用它?其背后的工作原理、设计思想又是怎样的?专栏要讲的就是这部分比较有区分度的内容,面向使用过 React 全家桶,或者接触过还不具备熟练使用能力的前端工程师,解决你出了 Bug 却不知如何调试的问题。
-
模块四:生产实践。对于一个优秀的前端应用来说,性能和设计模式是永恒的主题,性能决定用户体验,设计模式决定研发效率。这部分将结合我团队的实践经验以及当下行业里推崇的最佳实践,为你输出实战观点。对于这些最佳实践,你不仅要知道怎么做,还要理解“为什么这么做”。学完个模块可以强化你的实际应用能力,提升自主研发创新实践的线索和灵感。
讲师寄语
我认为,学习的本质是重复,对于重要的知识,我会翻来覆去地说,想方设法让你记住它。所以,如果你在学习过程中发现某一块知识似曾相识或者早已埋下伏笔,多半意味着你发现了一个重难点,请牢牢抓住它。
为了将晦涩的知识转化为你手里实实在在的生产力,专栏的框架和内容也历经了多次的迭代和重构。每一次的果断推翻早期设想,每一次的重构表达逻辑,都是希望帮你更好地消化吸收这份知识。希望你也能够不吝耐心和智慧,顺利走完整个 React 学习曲线中最难的一段路。
到这里,我的故事就结束了,而你和我的故事才刚刚开始。欢迎在留言区分享你的前端经历,或者写写学习 React 过程中遇到的问题、想要学习的内容,让我们一起写出“我们”的故事,开启 React 奇幻之旅。
01 JSX 代码是如何“摇身一变”成为 DOM 的?
时下虽然接入 JSX 语法的框架越来越多,但与之缘分最深的毫无疑问仍然是 React。2013 年,当 React 带着 JSX 横空出世时,社区曾对 JSX 有过不少的争议,但如今,越来越多的人面对 JSX 都要说上一句“真香”!本课时我们就来一起认识下这个“真香”的 JSX,聊一聊“JSX 代码是如何‘摇身一变’成为 DOM 的”。
关于 JSX 的 3 个“大问题”
在日常的 React 开发工作中,我们已经习惯了使用 JSX 来描述 React 的组件内容。关于 JSX 语法本身,相信每位 React 开发者都不陌生。这里我用一个简单的 React 组件,来帮你迅速地唤醒自己脑海中与 JSX 相关的记忆。下面这个组件中的 render 方法返回值,就是用 JSX 代码来填充的:
import React from "react";
import ReactDOM from "react-dom";
class App extends React.Component {
render() {
return (
<div className=“App”>
<h1 className=“title”>I am the title</h1>
<p className=“content”>I am the content</p>
</div>
);
}
}
const rootElement = document.getElementById(“root”);
ReactDOM.render(<App />, rootElement);
由于本专栏的整体目标是帮助你在 React 这个领域完成从“小工”到“行家”的进阶,此处我无意再去带你反复咀嚼 JSX 的基础语法,而是希望能够引导你去探寻 JSX 背后的故事。针对这“背后的故事”,我总结了 3 个最具代表性和区分度的问题。
在开始正式讲解之前,我希望你能在自己心中尝试回答这 3 个问题:
-
JSX 的本质是什么,它和 JS 之间到底是什么关系?
-
为什么要用 JSX?不用会有什么后果?
-
JSX 背后的功能模块是什么,这个功能模块都做了哪些事情?
面对以上问题,如果你无法形成清晰且系统的思路,那么很可能是你把 JSX 想得过于简单了。大多数人只是简单地把它理解为模板语法的一种,但事实上,JSX 作为 React 框架的一大特色,它与 React 本身的运作机制之间存在着千丝万缕的联系。
上述 3 个问题的答案,就恰恰隐藏在这层“联系”中,在面试场景下,候选人对这层“联系”吃得透不透,是我们评价其在 React 方面是否“资深”的一个重要依据。
接下来,我就将带你由表及里地起底 JSX 相关的底层原理,帮助你吃透这层“联系”,建立起强大的理论自信。你可以将“能够用自己的话回答上面 3 个问题”来作为本课时的学习目标,待课时结束后,记得回来检验自己的学习成果^_^。
JSX 的本质:JavaScript 的语法扩展
JSX 到底是什么,我们先来看看 React 官网给出的一段定义:
JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。
“语法扩展”这一点在理解上几乎不会产生歧义,不过“它充分具备 JavaScript 的能力”这句,却总让人摸不着头脑,JSX 和 JS 怎么看也不像是一路人啊?这就引出了“JSX 语法是如何在 JavaScript 中生效的”这个问题。
JSX 语法是如何在 JavaScript 中生效的:认识 Babel
Facebook 公司给 JSX 的定位是 JavaScript 的“扩展”,而非 JavaScript 的“某个版本”,这就直接决定了浏览器并不会像天然支持 JavaScript 一样地支持 JSX。那么,JSX 的语法是如何在 JavaScript 中生效的呢?React 官网其实早已给过我们线索:
JSX 会被编译为 React.createElement(), React.createElement() 将返回一个叫作“React Element”的 JS 对象。
这里提到,JSX 在被编译后,会变成一个针对 React.createElement 的调用,此时你大可不必急于关注 React.createElement 这个 API 到底做了什么(下文会单独讲解)。咱们先来说说这个“编译”是怎么回事:“编译”这个动作,是由 Babel 来完成的。
什么是 Babel 呢?
Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。
—— Babel 官网
比如说,ES2015+ 版本推出了一种名为“模板字符串”的新语法,这种语法在一些低版本的浏览器里并不兼容。下面是一段模板字符串的示例代码:
var name = "Guy Fieri";
var place = "Flavortown";
`Hello ${name}, ready for ${place}?`;
Babel 就可以帮我们把这段代码转换为大部分低版本浏览器也能够识别的 ES5 代码:
var name = "Guy Fieri";
var place = "Flavortown";
"Hello ".concat(name, ", ready for ").concat(place, "?");
类似的,Babel 也具备将 JSX 语法转换为 JavaScript 代码的能力。
那么 Babel 具体会将 JSX 处理成什么样子呢?我们不如直接打开 Babel 的 playground 来看一看。这里我仍然键入文章开头示例代码中的JSX 部分:
可以看到,所有的 JSX 标签都被转化成了 React.createElement 调用,这也就意味着,我们写的 JSX 其实写的就是 React.createElement,虽然它看起来有点像 HTML,但也只是“看起来像”而已。JSX 的本质是React.createElement这个 JavaScript 调用的语法糖,这也就完美地呼应上了 React 官方给出的“JSX 充分具备 JavaScript 的能力”这句话。
React 选用 JSX 语法的动机
换个角度想想,既然 JSX 等价于一次 React.createElement 调用,那么 React 官方为什么不直接引导我们用 React.createElement 来创建元素呢?
原因非常简单,我们来看一个相对复杂一些的组件的 JSX 代码和 React.createElement 调用之间的对比。它们各自的形态如下图所示,图中左侧是 JSX 代码,右侧是 React.createElement 调用:
你会发现,在实际功能效果一致的前提下,JSX 代码层次分明、嵌套关系清晰;而 React.createElement 代码则给人一种非常混乱的“杂糅感”,这样的代码不仅读起来不友好,写起来也费劲。
JSX 语法糖允许前端开发者使用我们最为熟悉的类 HTML 标签语法来创建虚拟 DOM,在降低学习成本的同时,也提升了研发效率与研发体验。
读到这里,相信你已经充分理解了“JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。 ”这一定义背后的深意。那么我们文中反复提及的 React.createElement 又是何方神圣呢?下面我们就深入相关源码来一窥究竟。
JSX 是如何映射为 DOM 的:起底 createElement 源码
在分析开始之前,你可以先尝试阅读我追加进源码中的逐行代码解析,大致理解 createElement 中每一行代码的作用:
/**
101. React的创建元素方法
*/
export function createElement(type, config, children) {
// propName 变量用于储存后面需要用到的元素属性
let propName;
// props 变量用于储存元素属性的键值对集合
const props = {};
// key、ref、self、source 均为 React 元素的属性,此处不必深究
let key = null;
let ref = null;
let self = null;
let source = null;
// config 对象中存储的是元素的属性
if (config != null) {
// 进来之后做的第一件事,是依次对 ref、key、self 和 source 属性赋值
if (hasValidRef(config)) {
ref = config.ref;
}
// 此处将 key 值字符串化
if (hasValidKey(config)) {
key = ‘’ + config.key;
}
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;
// 接着就是要把 config 里面的属性都一个一个挪到 props 这个之前声明好的对象里面
for (propName in config) {
if (
// 筛选出可以提进 props 对象里的属性
hasOwnProperty.call(config, propName) &&
!RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}
// childrenLength 指的是当前元素的子元素的个数,减去的 2 是 type 和 config 两个参数占用的长度
const childrenLength = arguments.length - 2;
// 如果抛去type和config,就只剩下一个参数,一般意味着文本节点出现了
if (childrenLength === 1) {
// 直接把这个参数的值赋给props.children
props.children = children;
// 处理嵌套多个子元素的情况
} else if (childrenLength > 1) {
// 声明一个子元素数组
const childArray = Array(childrenLength);
// 把子元素推进数组里
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
// 最后把这个数组赋值给props.children
props.children = childArray;
}
// 处理 defaultProps
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 最后返回一个调用ReactElement执行方法,并传入刚才处理过的参数
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
上面是对源码细节的初步展示,接下来我会带你逐步提取源码中的关键知识点和核心思想。
入参解读:创造一个元素需要知道哪些信息
我们先来看看方法的入参:
export function createElement(type, config, children)
createElement 有 3 个入参,这 3 个入参囊括了 React 创建一个元素所需要知道的全部信息。
-
type:用于标识节点的类型。它可以是类似“h1”“div”这样的标准 HTML 标签字符串,也可以是 React 组件类型或 React fragment 类型。
-
config:以对象形式传入,组件所有的属性都会以键值对的形式存储在 config 对象中。
-
children:以对象形式传入,它记录的是组件标签之间嵌套的内容,也就是所谓的“子节点”“子元素”。
如果文字描述使你觉得抽象,下面这个调用示例可以帮你增进对概念的理解:
React.createElement("ul", {
// 传入属性键值对
className: "list"
// 从第三个入参开始往后,传入的参数都是 children
}, React.createElement("li", {
key: "1"
}, "1"), React.createElement("li", {
key: "2"
}, "2"));
这个调用对应的 DOM 结构如下:
<ul className="list">
<li key="1">1</li>
<li key="2">2</li>
</ul>
对入参的形式和内容有了大致的把握之后,下面我们继续来讲解 createElement 的函数逻辑。
createElement 函数体拆解
前面你已经阅读过 createElement 源码细化到每一行的解读,这里我想和你探讨的是 createElement在逻辑层面的任务流转。针对这个过程,我为你总结了下面这张流程图:
这个流程图,或许会打破不少同学对 createElement 的幻想。在实际的面试场景下,许多候选人由于缺乏对源码的了解,谈及 createElement 时总会倾向于去夸大它的“工作量”。但其实,相信你也已经发现了,createElement 中并没有十分复杂的涉及算法或真实 DOM 的逻辑,它的每一个步骤几乎都是在格式化数据。
说得更直白点,createElement 就像是开发者和 ReactElement 调用之间的一个“转换器”、一个数据处理层。它可以从开发者处接受相对简单的参数,然后将这些参数按照 ReactElement 的预期做一层格式化,最终通过调用 ReactElement 来实现元素的创建。整个过程如下图所示:
现在看来,createElement 原来只是个“参数中介”。此时我们的注意力自然而然地就聚焦在了 ReactElement 上,接下来我们就乘胜追击,一起去挖一挖 ReactElement 的源码吧!
出参解读:初识虚拟 DOM
上面已经分析过,createElement 执行到最后会 return 一个针对 ReactElement 的调用。这里关于 ReactElement,我依然先给出源码 + 注释形式的解析:
const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// REACT_ELEMENT_TYPE是一个常量,用来标识该对象是一个ReactElement
$$typeof: REACT_ELEMENT_TYPE,
<span class="hljs-comment">// 内置属性赋值</span>
type: type,
key: key,
ref: ref,
props: props,
<span class="hljs-comment">// 记录创造该元素的组件</span>
_owner: owner,
};
//
if (DEV) {
// 这里是一些针对 DEV 环境下的处理,对于大家理解主要逻辑意义不大,此处我直接省略掉,以免混淆视听
}
return element;
};
ReactElement 的代码出乎意料的简短,从逻辑上我们可以看出,ReactElement 其实只做了一件事情,那就是“创建”,说得更精确一点,是“组装”:ReactElement 把传入的参数按照一定的规范,“组装”进了 element 对象里,并把它返回给了 React.createElement,最终 React.createElement 又把它交回到了开发者手中。整个过程如下图所示:
如果你想要验证这一点,可以尝试输出我们示例中 App 组件的 JSX 部分:
const AppJSX = (<div className="App">
<h1 className="title">I am the title</h1>
<p className="content">I am the content</p>
</div>)
console.log(AppJSX)
你会发现它确实是一个标准的 ReactElement 对象实例,如下图(生产环境下的输出结果)所示:
这个 ReactElement 对象实例,本质上是以 JavaScript 对象形式存在的对 DOM 的描述,也就是老生常谈的“虚拟 DOM”(准确地说,是虚拟 DOM 中的一个节点。关于虚拟 DOM, 我们将在专栏的“模块二:核心原理”中花大量的篇幅来研究它,此处你只需要能够结合源码,形成初步认知即可)。
既然是“虚拟 DOM”,那就意味着和渲染到页面上的真实 DOM 之间还有一些距离,这个“距离”,就是由大家喜闻乐见的ReactDOM.render方法来填补的。
在每一个 React 项目的入口文件中,都少不了对 React.render 函数的调用。下面我简单介绍下 ReactDOM.render 方法的入参规则:
ReactDOM.render(
// 需要渲染的元素(ReactElement)
element,
// 元素挂载的目标容器(一个真实DOM)
container,
// 回调函数,可选参数,可以用来处理渲染结束后的逻辑
[callback]
)
ReactDOM.render 方法可以接收 3 个参数,其中第二个参数就是一个真实的 DOM 节点,这个真实的 DOM 节点充当“容器”的角色,React 元素最终会被渲染到这个“容器”里面去。比如,示例中的 App 组件,它对应的 render 调用是这样的:
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
注意,这个真实 DOM 一定是确实存在的。比如,在 App 组件对应的 index.html 中,已经提前预置 了 id 为 root 的根节点:
<body>
<div id="root"></div>
</body>