Skip to main content

7 posts tagged with "React"

View All Tags

· 24 min read

最近在深入探究 GLSL,每次想写一段 shader 看下效果,都要新开一个 HTML、写 <script> 标签、配 <canvas>、引入threejs包、起本地服务、改完刷新看效果 —— 即便有 AI 加持,这一套流程的心智负担还是太重了。我其实只是想验证一些变换公式,查看顶点变换或颜色变化等效果,而不是搭一个项目。

试了一圈现成的在线 GLSL playground,要么功能停在能跑就行,要么稍微改点代码就出 bug,再要么就是必须登录或者收费的复杂产品。于是决定自己撸一个 playground —— ShaderPad

目标:

  • 「打开浏览器 30 秒内跑起一个 shader」:零登录、零配置、零下载,面向 Web 着色器学习和调试的极简 playground
  • 支持分享和快速复现3d场景
  • 提供可快速插入 mdx 文本里的 npm 包

在线体验 ,对应的 github 仓库

整体效果​

shaderpad首页

整体页面结构跟大部分在线编辑器类似,左边是代码编辑区,提供了顶点着色器和片元着色器的编辑功能,会高亮 glsl 语法。右边是基于 threejs 的3d场景,实时预览当前编辑的代码的效果,并且提供了悬浮的控制台面板,方便查看一些报错或log

为了方便观察,3d场景内置了辅助网格和辅助坐标轴,并且引入了 OrbitControls,用户可以通过鼠标旋转场景,查看不同的角度

技术栈​

维度选型理由
前端框架Astro 4.15 + React Island群岛架构 + 首屏友好
编辑器Monaco Editor 0.50VSCode 同款,TS 智能提示、GLSL 语法高亮
3d渲染Three.js 0.170(WebGLRenderer + RawShaderMaterial)
状态管理nanostores 0.11极小(<1KB),React 集成通过 @nanostores/react
包管理pnpm 8.11 workspace硬链接节省空间,monorepo 友好
部署轻量服务器 + Nginx Proxy Manager + GitHub Actions

为什么选 Astro?​

做 ShaderPad 之前我其实没怎么用过 Astro。这次之所以选它,是被它的「默认零 JS」设计打动了。它并非基于 React 构建,而是允许你通过 integrations 把 React / Vue / Svelte 等当作「岛屿」嵌入到静态 HTML 中。

对比 Gatsby、Docusaurus 或 Next.js 这些默认把整个 React 运行时推到浏览器的重型方案,Astro 显得非常克制。在「孤岛架构」下,它把整个页面当成一片静态海洋,里面散落着几个交互小岛。对 ShaderPad 来说,文档内容就是静态海面,只有 Playground 这个组件才是真正的交互孤岛。

底层原理上的优势:

  1. 极致的编译时剥离:Astro 的渲染模式是 SSG。在 build 时它会把 .astro 组件全跑一遍,暴力剥离掉所有不需要在客户端执行的 JS 逻辑,只输出纯 HTML。
  2. 精准的按需水合:因为 Playground 强依赖 WebGL(完全没法在 Node 端执行),我给它加了 client:only="react" 指令。Astro 遇到它时,只会在 HTML 里留个带有 astro-island 标签的占位 DOM,并注入极轻量的调度器(几 KB)。等页面加载完,调度器才会去拉取 React 运行时和组件代码,在客户端完成「水合」。

落到实际产物上:构建出来的文档站 index 页面只有 6.84 kB(Gzip 后 2.73 kB),大头全在按需加载的 Playground 孤岛里(约 500+ kB)。这种「主静态、副交互」的颗粒度控制,完美契合了重客户端工具的需求,彻底甩掉了全站 React 渲染的性能包袱。这也是为什么 Astro 在「文档站 + 工具型网站」场景下越来越受欢迎的原因——它把"该省的省到极致"这件事做得很彻底。

架构设计​

Monorepo 结构​

monorepo 算是现在多仓管理的标配。它不光能在一个仓库里管多个 package,更像是在逼我面对一个问题:"如果这个项目要嵌进别人的网页里,边界该画在哪?"。所以从一开始我就带着 SDK 视角在搭:核心引擎、UI 组件、样式层各管一摊,公共部分一律上提到 packages/,主站只负责壳子和体验。

  • @shaderpad/runtime 抽离出 LanguageAdapter 接口(GLSL 轻量语法预检),未来扩展到 Node / Tauri 桌面端可直接复用。
  • @lucascv/shaderpad-playground 把 Playground 抽成独立 npm 包,独立发版、可嵌入到任何 React 文档站。
  • apps/web 主站保持轻量,作为包的消费者,部署产物也更加干净。
shaderPad/
├── apps/
│ └── web/ # 主站(Astro)
│ ├── src/
│ │ ├── pages/ # 路由(index / play / learn/*)
│ │ ├── components/ # React 组件
│ │ ├── lib/
│ │ │ ├── runtime/ # 浏览器侧渲染引擎
│ │ │ └── share/ # URL/localStorage 持久化
│ │ └── shaders/examples.ts # 内置示例库
│ └── astro.config.mjs
├── packages/
│ ├── shader-runtime/ # 跨端共享核心(未来扩展桌面端)
│ │ └── src/languages/ # GLSL / TSL / WGSL adapter
│ └── shader-playground/ # 可独立发版的 npm 包
│ ├── src/
│ │ ├── runtime/three-engine.ts
│ │ ├── ui/ # ShaderPlayground / CodeEditor / PreviewCanvas
│ │ └── styles/playground.css
│ └── tsup.config.ts
└── .github/workflows/
├── deploy-web.yml # 主站部署
└── release.yml # npm 自动发版(OIDC)

数据流​

[Monaco Editor]  --change-->  Playground state (codeRef)
|
|--auto save (1s debounce)--> localStorage
|
'--compileAndRun()--> [ShaderEngine]
|
+---------+---------+
| |
(vertex/fragment) (uniforms)
| |
v v
Three.js RawShaderMaterial <-- OrbitControls / Grid / Axes

核心渲染模块​

ShaderEngine(运行时核心)​

要让代码在网页上跑起来,必须有一套稳定、高效的渲染器。我封装了 ShaderEngine 这个核心类来处理 Three.js 的脏活累活。

它不仅是对 WebGLRenderer 的简单包装,更重要的是它接管了渲染的生命周期与错误捕获,对外只暴露最极简的 API:

class ShaderEngine {
init() // 创建 WebGLRenderer + 透视相机 + 辅助坐标系
applyShader(source, mode) // 将用户的源码注入 RawShaderMaterial
forceCompile() // 绕过 Three.js 顶层,直接调用 WebGL API 预编译并捕获行号
setGeometry(type) // 无缝切换几何体(复用 Material,不闪烁)
start() / stop() / dispose() // 挂载 RAF 动画循环,并确保销毁时不漏内存
}

内置模块​

提供了以下几种 threejs 常见的几何体:

  • PlaneGeometry 平面
  • BoxGeometry 立方体
  • SphereGeometry 球体

默认是 PlaneGeometry,用户可以自行切换。针对不同几何体提供了几种不同的常见 shader 示例,比如时间渐变、鼠标跟随、噪声效果等,选中后就可以查看效果,用户可以根据需要选择。

另外比较关键的是,我还内置了一些开发中常见的 uniform 变量,如下:

uniform float u_time;       // 自启动以来的秒数,每帧递增
uniform vec2 u_resolution; // 画布宽高(像素)
uniform vec2 u_mouse; // 鼠标位置,归一化到 [0,1](Y 已翻转)
uniform float u_random; // applyShader 时的随机数 [0,1)

这样就可以在 shader 中直接使用这些变量来做一些动态效果。

当然目前没法完全自定义 uniform,暂时是逐步加入一些常见变量,有需求的可以评论或者追加 github issue

为什么用 RawShaderMaterial?​

在实现 ShaderEngine 时,我面临一个取舍:用 ShaderMaterial 还是 RawShaderMaterial?

ShaderMaterial 很方便,它会自动帮你注入一堆 Three.js 内置的 uniforms 和 attributes(比如 cameraPosition、modelViewMatrix 等)。但在「教学和调试」场景下,这反而成了致命缺点——用户会很困惑:「我明明没声明这个变量,为什么它能跑?」

为了做到「所见即所得」,我最终选择了 RawShaderMaterial。它是一张白纸,不注入任何隐藏代码,用户写的 source 就是最终跑在 GPU 里的 GLSL。 这也意味着报错行号能做到 1:1 绝对对应,不会出现「明明只有 10 行代码,控制台却报第 150 行错误」的灵异事件。代价是用户必须在代码开头显式声明所需的内置矩阵:

attribute vec3 position;
attribute vec2 uv;
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

但这换来的是运行机制的完全透明,对于一个学习工具来说,这个权衡是非常值得的。

编译错误的精确定位​

Three.js 编译失败的报错信息默认是「WebGL: ERROR: 0:5: 'foo' : undeclared identifier」这种字符串,没法结构化处理。Playground 在 ShaderEngine 里直接绕过 Three.js 的封装,调底层 gl.getShaderInfoLog + gl.getShaderSource 自己解析,把行号 / 列号 / 错误消息拆成结构体再浮条展示:

{ line: 5, column: 12, message: "'foo' : undeclared identifier" }

这样写 GLSL 时,看到的都是真实可定位的错误,而不是"WebGL: ERROR: 0:5"这种天书。

模块沉淀:从单一工具到通用的 npm 包​

做完主站后我意识到——「实时编辑 + 实时预览」这套交互本身非常有价值,它不应该只局限在 ShaderPad 自己的网站里。如果能在任何 MDX 文档或技术博客里直接嵌入一个能跑的 Shader,阅读体验会呈指数级上升。

比如现在你可以直接修改下面的代码,实时查看效果(试试把 cos 改成 sin,或者调一下 vec3(0, 2, 4) 的颜色偏移):

Neon Plasma (可编辑)
Loading...

于是我把 Playground 抽成了一个独立的 npm 包:@lucascv/shaderpad-playground,5 行代码就能嵌进任何 React 文档站。

pnpm add @lucascv/shaderpad-playground react three monaco-editor @monaco-editor/react
import { ShaderPlayground } from "@lucascv/shaderpad-playground";
import "@lucascv/shaderpad-playground/styles";

<ShaderPlayground
code="void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }"
storageKey="my-article/hello"
/>;

Live Demo:shaderpad.lucaslib.net/embed-test | npm:@lucascv/shaderpad-playground

封装过程踩到几个值得记一笔的点:

为什么必须是 MDX(前提中的前提)​

这事能成立,关键在 MDX 这个东西本身。普通 Markdown 只能写文字 + 代码块,碰到 <ShaderPlayground /> 这种 JSX 直接懵——<div> 都识别不了,更别说塞交互组件。

MDX(Markdown + JSX)就是为这个问题生的:它扩展了 Markdown 语法,允许在文档里直接写 React 组件。原理不复杂——编译期用 @mdx-js/mdx 这类工具把 .mdx 文件解析成 React 树:Markdown 部分走 remark 管线出 React 元素,JSX 部分原样透传,最后合成一棵完整的组件树渲染。

而且目前主流文档框架几乎都原生支持 MDX:Docusaurus(你正在看的这个博客就是)、Astro、Nextra、VitePress 都是开箱即用,不用额外搭脚手架。如果你的博客 / 文档站已经在用这些技术栈,零迁移成本就能接入——这点很关键,意味着 Playground 的受众不是只有 React 重度用户,而是几乎所有写技术文档的人。

这套机制让「散文 + 代码块 + 实时 demo」在同一个文件里无缝混排。其他路线都做不到这种程度:

  • iframe 嵌外部 playground:样式割裂、跨域通信麻烦、宿主主题融不进去
  • 截图 + 跳 CodePen / ShaderToy:读者被迫跳出当前阅读流
  • 录视频:完全丧失可编辑性,等于把核心价值阉了

MDX 路线能让交互组件和正文真正长在一起

持久化代码​

用户编辑过的代码要保留下来(下次打开还是他改过的版本),但如果作者改了文章的示例代码,旧草稿就会"幽灵生效"——看起来加载了,但内容是上一版的。

包里的做法是把源文件内容用 djb2 算一个短 hash,写进 localStorage key 里:

// v2 key 格式:embed-test/pair-box:a3f9b1c2
function buildKey(storageKey, source) {
return `${storageKey}:${shortHash(source)}`;
}

源文件一变 hash 就变,自动生成新 key,旧草稿自然绕过。这套机制上线后,文档示例库再迭代也没出过"代码不匹配"的玄学问题。

单 / 双着色器配置​

最简的用法是只传一个 code 字段,跑单 stage。但有些场景(顶点动画、varying 传递)必须 vertex + fragment 联动才能跑起来,所以包内同时提供了 pair 配置:

<ShaderPlayground
pair={{
vertex: `void main() { gl_Position = vec4(position, 1.0); }`,
fragment: `void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }`,
}}
storageKey="docs/glsl/coords"
/>

传 pair 时画布会自动切到 Vertex / Fragment 双 tab,编辑器用单一实例、两边各自一份代码,互不打架。

组件包打包​

为什么选 tsup?ESM/CJS 格式怎么控?

在抽离 @lucascv/shaderpad-playground 这个独立的 npm 包时,我需要一个打包工具。之所以选 tsup,是因为它底层基于 esbuild,打包速度极快,而且开箱即用支持 .d.ts 类型生成。对于这种不用配复杂 Webpack loader 的纯 TS/React UI 组件库来说,体验简直是降维打击。

但我在这里踩了一个经典的 ESM/CJS 双格式导出的坑。

一开始打包后,Astro 主站引入组件时报了页面水合(Hydration)失败的 SyntaxError。排查后发现,是因为包的 package.json 声明了 "type": "module",导致宿主在解析依赖时,拿到了格式不匹配的产物。

为了同时完美支持现代框架(需要 ESM 以支持 Tree-shaking)和类似 Docusaurus 2.x 这种可能依赖旧版 Webpack 设定的工具(需要 CJS),必须在 tsup.config.ts 里手动接管输出扩展名:

export default defineConfig({
format: ["esm", "cjs"],
outExtension({ format }) {
// 强制把 ESM 产物后缀设为 .js(因为 type: module),CJS 产物后缀设为 .cjs
return { js: format === "cjs" ? ".cjs" : ".js" };
},
});

同时,package.json 里的 exports 字段必须严丝合缝地对齐:

"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js", // ESM 消费者走这里
"require": "./dist/index.cjs" // CJS 消费者走这里
}
}

只有当这两边绝对对齐,各种宿主框架在根据自身环境 import 或 require 时,才能精准命中正确的模块,彻底消灭 Hydration 报错。

几个工程取舍​

  • CSS 变量全用 spg- 前缀:颜色 / 边框 / 强调色全部走 CSS 变量,主题跟随 documentElement[data-theme],和宿主站主题自然融合,不会突兀地"白底黑字"。
  • 响应式断点 720px:宽屏左右分屏(编辑器 + 画布),窄屏自动堆叠成上下结构,手机也能直接看效果。
  • React 17 / 18 / 19 全兼容:react / react-dom / three / monaco-editor 全是 peerDependencies,不打包进 dist,包体核心 ~66KB(gzip),按需由消费方装。本网站基于 Docusaurus 2.4 + React 17 这个老古董环境,也都支持集成。

URL 分享功能​

作为一个在线调试工具,如果不做后端数据库,怎么分享代码?

这里的方案是:把整个 Shader 源码通过 LZString 压缩 + Base64 编码后,直接塞进 URL 的 hash 路由参数里。可以在 ShaderPad 右上角点击 share,然后新打开标签页粘贴体验

为什么是这套组合​

https://shaderpad.lucaslib.net/?a=1&b=2#/playground?code=xxx
└── query ──┘ └────── hash ──────┘

整段 URL 只有 hash 留在客户端,query 和 path 都会被发到服务器——意味着要后端配合、还要防日志和 CDN 污染。改 hash 不触发 HTTP 请求,对「无后端 + 静态部署」是天然选择。

  • LZString 压缩。 GLSL 天然高冗余,关键字和模板片段反复出现。LZString 是为「短字符串 + URL」场景设计的,输出本身就是 string,压缩比通常 3~5x。
  • Base64 兜底「URL 安全」。 压缩后的字节流是二进制,里面可能混着控制字符。Base64 把任意字节映射到 64 个 URL-safe 字符,~33% 的体积代价被上一层的压缩比覆盖。

浏览器对 URL 长度有隐性上限(实测 Chrome 大概在 8KB~32KB 之间),代码长了会被截断。所以分享出去的链接天然适合"短小精悍的示例"——这其实和调试场景挺契合的,单文件 shader 本来就不该太长。

这样任何人拿到链接,打开就能直接还原当前的编辑状态,完全不需要后端的介入,真正做到了「无状态」的极简分享。

部署​

刚好最近换了台新服务器,ShaderPad 就作为第一个部署的应用上线了。关于新服务器配置环境,还专门写了一篇文章 《linux个人云服务器开荒指南》

顺便吐槽一句某某云:旧那台 1 核 2G 的云服务器续费依旧贵得离谱,反而新买一台 2 核 4G 首年还有大折扣,算下来差不多。旧机器上也没跑几个应用,迁移成本不高,索性换台配置高一点的。

部署的核心组件是 Nginx Proxy Manager(下文简称 NPM,注意和 Node 的 npm 不是一回事)。

Nginx Proxy Manager​

NPM 是一个基于 Nginx 的可视化反向代理管理工具,包装成 Docker 镜像后一行命令就能起,很香。

  • 提供 Web 管理界面,不用手写 nginx.conf、不用 nginx -s reload
  • SSL 证书申请 + 部署一条龙(Let's Encrypt 自动化),告别以前去某某云控制台手动申请再 vim nginx.conf 的繁琐

端口规划(默认会占三个,记得在某某云防火墙里放行规则):

端口用途暴露建议
80HTTP公开
443HTTPS公开
81管理界面只对可信 IP 开放

自动化部署​

走 GitHub Actions + rsync:

  • push 到 main → 触发 .github/workflows/deploy-web.yml
  • 跑 pnpm install + pnpm --filter web build,产物在 apps/web/dist/
  • rsync-deployments action 把 dist/ 推到服务器的 /var/www/shaderpad/dist/(与 NPM 静态资源目录保持一致)

踩过的坑​

Nginx 与 CI 环境的琐碎坑点​

  • Nginx 500 错误:启动 NPM 容器的时候没正确挂载宿主机的静态资源目录(如 /home),导致 Nginx 找不到文件,修改 docker-compose.yml 补上 volumes 映射即可。
  • pnpm@10 lockfile 冲突:GitHub Actions CI 环境默认拉了最新的 pnpm v10,解析项目 v8 的 pnpm-lock.yaml 时报 ERR_PNPM_LOCKFILE_BREAKING_CHANGE 错误。解决方案是在 CI 步骤里明确指定安装 pnpm@8.11.0 版本。

总结与思考​

以前我一直想做个在线工具,但总觉得市面上轮子已经够多了,加上开发和部署成本,迟迟没有动手。这次在 AI 的加持下(核心代码大量借助了 Minimax-M3 等大模型),极大地压缩了「从想法到上线」的周期。

ShaderPad 不仅让我调试和学习 glsl 代码更方便,也让我跑通了从「单体应用开发」到「通用组件抽离」,再到「自动化发版部署」的完整工程化闭环。

第一版先保持极简,后续如果大家觉得好用,会考虑扩展对 TSL 和 WGSL 的支持。欢迎来玩!

在线体验地址:ShaderPad ,对应 github 仓库

· One min read

最近在一个 react 单页面项目(webpack4)里遇到了一个热更新的报错: Uncaught ReferenceError: process is not defined。

原因是 react-error-overlay 这个插件版本(@6.0.11)太高,这个版本是支持了 webpack5,和 react-scripts@4 有不兼容的依赖项导致报错

解决办法是安装 6.0.9 版本

yarn add react-error-overlay@6.0.9

调整 package.json

{
"resolutions": {
"react-error-overlay": "6.0.9"
},
"overrides": {
"react-error-overlay": "6.0.9"
}
}

resolutions for yarn,overrides for npm >= 8.3

如果是用的 npm,并且 6<=版本<8.3,可以用以下方法

npm install npm-force-resolutions --save-dev

调整 package.json

{
"resolutions": {
"react-error-overlay": "6.0.9"
},
"scripts": {
"preinstall": "npm-force-resolutions"
}
}

后续执行 npm install 就会强制 react-error-overlay 为 6.0.9

by the way,升级 webpack5 也不是不行 ~ 只不过坑也蛮多的。上述办法就比较轻松了

· 4 min read

写在前面​

  • 技术赋能业务,业务促进技术
  • 不要提前设计,不要为了用技术而用技术

拥有自己的应用模板​

  • 沉淀自己的应用模板很重要,这是属于你的最佳实践
    • 快速新建项目
    • 代码规范配置
    • 预设高质量依赖
    • 良好的构建配置和插件集合
  • 尝试给自己写个脚手架,和模板配合

ts + eslint​

  • 最大限度确保代码的质量,不过也不能滥用 any
  • 封装公共 type,比如 TPlainObject、ISOTimeStamp、颜色枚举等
  • eslint 只做代码检查,不做代码格式化

prettier​

  • 代码格式化一致性,再也不怕帮同事改代码了
  • commitlint 代码提交前强制格式化
  • .vscode 增加 formatOnSave

key​

请给遍历组件加上 key,提高组件的复用率,避免错位(index 做 key)导致的更新异常现象

正确用 Hook​

  • 统一使用 Hook 编写组件
  • 只能在最顶层使用,不要在循环、条件语句或函数中使用
  • 变量和对应的函数(func/effect)放一起,而不是给所有变量和所函数划分界限
  • 适当使用 useState,部分情况可以用 useRef 替代

最小原则​

  • 最小渲染次数。这个是架构层面出发的必须注重的优化,react 应用的性能优化基本都会从这里开始。通过 useMemo 或者 shouldUpdateComponent/memo 来避免组件的重复渲染
  • 擅用缓存。可以借助 useCallback 或 useRef 缓存变量和函数,避免重复创建
  • 最少依赖项。使用新的依赖之前,先思考必要性。比如 Redux,如果只是小项目,通信也不复杂,那直接用 Context 有何不可

组件化​

遵守单一职责原则,确保组件简洁、耦合度小、功能明确

能否做好组件化是鉴别前端工程师能力的一个重要方式

卸载​

别忘了卸载定时器、移除事件,不要浪费内存

错误边界​

给组件都统一添加错误边界(Error Boundary)来防止白屏。让 PM、测试看到白屏和局部错误是俩回事

单元测试​

公共组件和工具函数/自定义 Hook,有时间的话写点单元测试,受益匪浅哦。覆盖率尽可能大于 70%,单元测试工具推荐 Jest

应用优化​

  • 加快首屏
    • 组件/路由/资源懒加载
    • 本地/离线缓存
  • 减小包体积
    • 代码分割
    • 按需加载
    • tree shaking

总结​

有讲的不对、需要改进的地方,欢迎评论,一块进步 ~

· 14 min read

在 React 中,数据获取、设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。在 React 16.8 新增了 Hook,这些副作用都可以通过 useEffect 或者 useLayoutEffect 来调用。前阵子抽空学了下 useState 的源码和做了总结,今天也稍微总结了下 use(Layout)Effect。

写在前面​

不熟悉 Fiber 架构的话可以参考 https://blog.zhouweibin.top/detail/60aa954b6da89c0011cca404

对 useState 原理感兴趣的可以参考我之前写的博客 https://blog.zhouweibin.top/detail/60a16c423ba50e0011ffbc3b

涉及到上面知识的本文可能不会赘述,下面主要重点谈谈 use(Layout)Effect 这两个 Hooks 在 React 挂载和更新过程中做的事情

effectList​

这是一个副作用链表,会连接具有副作用 effect 的 fiber 节点,副作用的数据格式如下

{
create: Function, // 传入use(Layout)Effect函数的第一个参数,即回调函数
destroy: Function, // 回调函数return的函数,在组件销毁的时候执行
deps: any[], // 依赖项
next: Effect, // 指向下一个effect
tag: string, // effect的类型,区分是useEffect还是useLayoutEffect
}

副作用在函数组件中体现为 use(Layout)Effect 的回调和销毁函数(即回调的返回值)(统称为副作用)

收集​

从单个 fiber 节点看,fiber.memorizedState 挂载着 Hooks 链表,Hooks 链表会按顺序连接该节点上的 Hook,其中 use(Layout)Effect 上的 memorizedState 会存放副作用,待执行的副作用会串联成一个循环链表存储在updateQueue上,如下图所示:

从整个 Fiber Tree 来看,render 阶段会自下而上创建 effectList,主要是通过completeUnitOfWork这个方法创建,只有依赖项有变化才会将当前的 fiber 节点追加到 effectList。如下图所示:

最终的 effectList 如下所示:

首次挂载组件(mount),执行 useEffect 时,会调用mountEffectImpl,简化后的源码如下:

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook(); // 创建hook对象
const nextDeps = deps === undefined ? null : deps; // 依赖

// NOTE 给 fiber 节点打上副作用的 effectTag
currentlyRenderingFiber.flags = fiberFlags;

// 创建 effect 对象,挂载到 hook 的 memoizedState 上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps
);
}

更新时(update),会调用 updateEffectImpl

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;

if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
// 获取上一次 effect 的 destroy 函数
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// NOTE 如果前后依赖相同
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}

// NOTE flags的作用
currentlyRenderingFiber.flags |= fiberFlags;
// 如果前后依赖有变,在 effect 的 tag 中加入 HookHasEffect
// 并将新的 effect 挂载到 hook.memoizedState 上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps
);
}

组件 mount 和 update 时收集 effect 的区别在于,前者无须调用前一次的销毁函数,后者在创建 effect 前会判断依赖项,主要依赖项变化才会创建 effect 并挂载到 hook 上

不管组件处于什么状态,都会调用到pushEffect这个函数它的作用其实就是创建 effect 对象,将 effect 对象追加到 fiber 的 updateQueue 上

function pushEffect(tag, create, destroy, deps) {
const effect: Effect = {
tag,
create,
destroy,
deps,
next,
};

// NOTE 从 workInProgress 上取到 updateQueue(和 effectList 的区别)
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
// 如果 updateQueue 为空,把 effect 放到链表中,和它自己形成闭环
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// updateQueue 不为空,将 effect 追加到链表上
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}

在执行完 fiber 节点时,如果有更新 effect,会将该 fiber 节点追加到父节点的 effectList

function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;

let next = completeWork(current, completedWork, subtreeRenderLanes);

// effect list构建
if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
// 层层拷贝
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
// 说明当前节点是兄弟节点,子节点有effect,已经给returnFiber.lastEffect赋值过了
if (returnFiber.lastEffect !== null) {
// 连接兄弟节点的effect
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}

const flags = completedWork.flags;

// NOTE: 该 fiber 节点有 effect
if (flags > PerformedWork) {
// 当前节点有effect连接上effect list
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
// returnFiber没有firstEffect的情况是第一次遇见有effect的节点
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}

// 兄弟元素遍历再到返返回父级
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
}

执行​

render 阶段 useEffect 和 useLayoutEffect 没什么区别,在 commit 阶段会有比较明显的区别。先看下 useEffect,主要有三个地方会执行

useEffect​

  1. 刚进入 commit 阶段
function commitRootImpl(root, renderPriorityLevel) {
// 进入commit阶段,先执行一次之前未执行的 useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ...
do {
try {
// beforeMutation阶段的处理函数:commitBeforeMutationEffects内部,
// 异步调度 useEffect
commitBeforeMutationEffects();
} catch (error) {
...
}
} while (nextEffect !== null);
// ...
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;

if (rootDoesHavePassiveEffects) {
// NOTE 记录有副作用的 effect
rootWithPendingPassiveEffects = root;
}
}
  1. beforeMutation,该阶段会异步调度 useEffect
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
// ...
if ((flags & Passive) !== NoFlags) {
// 如果fiber节点上的flags存在Passive调度useEffect
if (!rootDoesHavePassiveEffects) {
// 锁住调度状态,防止多次调度
rootDoesHavePassiveEffects = true;
// NOTE NormalSchedulerPriority 一般优先级
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
  1. layout 阶段填充 effect 执行数组,真正执行 useEffect 的时候,实际上是先执行上一次 effect 的销毁函数,再执行本次 effect 的创建函数
function commitLifeCycles(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// ...
// layout阶段填充effect执行数组
schedulePassiveEffects(finishedWork);
return;
}
}

在调用 schedulePassiveEffects 填充 effect 执行数组时,有一个重要的地方就是只在包含 HasEffect 的 effectTag 的时候,才将 effect 放到数组内,这一点保证了依赖项有变化再去处理 effect。也就是:如果前后依赖未变,则 effect 的 tag 就赋值为传入的 hookFlags,否则,在 tag 中加入 HookHasEffect 标志位。正是因为这样,在处理 effect 链表时才可以只处理依赖变化的 effect,use(Layout)Effect 才可以根据它的依赖变化情况来决定是否执行回调。

function schedulePassiveEffects(finishedWork: Fiber) {
// 获取到函数组件的 updateQueue
const updateQueue = finishedWork.updateQueue;
// 获取 effect 链表
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
// 循环 effect 链表
do {
const { next, tag } = effect;
if (
(tag & HookPassive) !== NoHookEffect &&
(tag & HookHasEffect) !== NoHookEffect
) {
// 当 effect 的 tag 含有 HookPassive 和 HookHasEffect 时
// 分别插入销毁函数数组和创建函数数组
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}

上边已经填充好 effect 执行数组了,接下来就是执行这个数组。执行过程是先循环待销毁的 effect 数组,再循环待创建的 effect 数组,这一过程发生在flushPassiveEffectsImpl函数中

function flushPassiveEffectsImpl() {
// 先校验,如果root上没有 Passive effectTag 的节点,则直接return
if (rootWithPendingPassiveEffects === null) {
return false;
}
// ...
// 执行effect的销毁
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = ((unmountEffects[i]: any): HookEffect);
const fiber = ((unmountEffects[i + 1]: any): Fiber);
const destroy = effect.destroy;
effect.destroy = undefined;

if (typeof destroy === "function") {
try {
destroy();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
}

// 再执行effect的创建
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = ((mountEffects[i]: any): HookEffect);
const fiber = ((mountEffects[i + 1]: any): Fiber);
try {
const create = effect.create;
effect.destroy = create();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
// ...
return true;
}

useLayoutEffect​

useLayoutEffect 在执行的时候,也是先执行上一次的销毁函数再执行创建函数。和 useEffect 不同的是这两者都是同步执行的,前者在 mutation 阶段执行,后者在 layout 阶段执行。

与 useEffect 不同的是,它不用数组去存储销毁函数和创建函数,而是直接操作当前 fiber 上的 updateQueue

  1. 卸载上一次的 effect,发生在 mutation 阶段
// 调用卸载layout effect的函数,传入layout有关的effectTag和说明effect有变化的effectTag:HookLayout | HookHasEffect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

// 循环updateQueue上的effect链表
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
  1. 执行本次的 effect 创建,发生在 layout 阶段
// 调用创建layout effect的函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// 创建
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}

一些问题​

  • useEffect 和 useLayoutEffect 的使用和区别

useEffect在实现上利用 scheduler(关于 scheduler 后续会写篇文章介绍) 的异步调度函数:scheduleCallback,这函数会将执行 useEffect 的动作作为一个任务去调度,这个任务会异步调用,发生在 DOM 渲染之后

而useLayoutEffect发生在 DOM 渲染之前,执行时机和componentDidMount、componentDidUpdate是一致的,也就是说如果在useEffect中触发状态更新或者 DOM 渲染,会额外追加一次 DOM 渲染

一般情况下建议使用 useEffect,因为它 不会阻塞渲染,useLayoutEffect 会阻塞渲染,只有在涉及到修改 DOM、动画等场景下考虑使用 useLayoutEffect

  • 不同 useEffect 回调触发了多次 setState,会触发多次 render 吗?

不会。setState 最后其实都会调用 scheduleUpdateOnFiber

// scheduleUpdateOnFiber
// ...
if (executionContext === NoContext) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
flushSyncCallbackQueue();
}
// ...

只有当 executionContext 为 NoContext 才会触发同步更新,其余情况都是批量更新。在 commit 的 beforeMutation 阶段,会赋值 executionContext 为 CommitContext,代表进入 commit 阶段

// beforeMutation
// 将当前上下文标记为CommitContext,作为commit阶段的标志
// ...
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// ...

· 13 min read

得闲翻下 react17 的源码和相关文章,学习一下 react 的 useState 的实现原理。整个过程蛮有趣的,首先得理解 fiber 架构,其次在 debugger 源码的时候得学习和理解一些必要的上下文,整体来说受益匪浅

前置知识​

闭包​

在 hooks 的应用比如 dispatch 函数,也就是 useState 返回的第二个参数

闭包是指有权访问另一个函数作用域中变量或方法的函数,创建闭包的方式就是在一个函数内创建闭包函数,通过闭包函数访问这个函数的局部变量, 利用闭包可以突破作用链域的特性,将函数内部的变量和方法传递到外部。

单链表​

A-->B-->C。fiber 架构通过使用这种数据结构,实现了 render 过程的可中断性。比如更新了 B 节点,突然有高优先级插入,这个时候只需要记住当前更新到的节点是 B 即可,然后去执行高优任务,之后再从 B 节点继续更新

fiber & hook​

fiber 对象和 hook 对象的关系如下图: fiber & hook

fiber 架构​

React 16 引入了 fiber 架构,会基于 ReactElement 生成唯一的 fiber 对象。在更新阶段,会基于旧的 fiber 链表创建一个新的 fiber 链表(如下图),可以复用旧的对象,并且获取旧 hooks 的状态

fiber双链表

两个单链表​

hooks 链表​

/*
Hooks are stored as a linked list on the fiber's memoizedState field.
hooks 以链表的形式存储在fiber节点的memoizedState属性上
The current hook list is the list that belongs to the current fiber.
当前的hook链表就是当前正在遍历的fiber节点上的
The work-in-progress hook list is a new list that will be added to the work-in-progress fiber.
work-in-progress hook 就是即将被添加到正在遍历fiber节点的hooks新链表
*/
let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

无论是初次挂载还是更新,每调用一次 hooks 函数,都会产生一个 hook 对象与之对应,hook 对象结构如下

{
baseQueue: null, // 当前 update
baseState: 'hook1', // 初始值,即 useState 入参
memoizedState: null, // 当前状态(更新时表示上一次的状态)
queue: null, // 待执行的更新队列(queue.pending)
next: { // 下一个 hook
baseQueue: null,
baseState: null,
memoizedState: 'hook2',
next: null
queue: null
}
}

产生的 hook 对象依次排列,形成链表存储到函数组件 fiber.memoizedState 上。在这个过程中,有一个十分重要的指针:workInProgressHook,它通过记录当前生成(更新)的 hook 对象,可以间接反映在组件中当前调用到哪个 hook 函数了。每调用一次 hook 函数,就将这个指针的指向移到该 hook 函数产生的 hook 对象上

比如先调用 hookA

fiber.memoizedState: hookA
^
workInProgressHook

调用 hookB

fiber.memoizedState: hookA -> hookB
^
workInProgressHook

hooks 的更新队列​

这其实是个单链表环,存放某个 hook 的更新队列,如下图,是一个 useState 的更新队列:

任务待执行链表.png

其实还有一个副作用链表,跟 useEffect 有关,最终会挂到 root 节点上

源码执行流程​

renderWithHooks​

对于函数组件(FunctionComponent)类型,在 beginwork 的时候会调用 renderWithHooks 注册,根据 mount 还是 update 调用一个函数生成 hooks 链表挂在 Fiber 上

// renderWithHooks
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
...
// 当前fiber
currentlyRenderingFiber$1 = workInProgress;
// hooks队列
workInProgress.memoizedState = null;
// 副作用队列
workInProgress.updateQueue = null;
{
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountInDEV;
}
}
...
return children;
}

mount​

usestate源码执行顺序mount.png

update​

usestate源码执行顺序.png

mount 阶段​

/**
* ReactCurrentDispatcher 是一个内部储存状态的状态机.
* 主要作用还是用于切换不同执行时机的 dispatcher 对象
* */
const ReactCurrentDispatcher = { current: null };
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current;
return dispatcher;
}
function useState(initialState) {
var dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
// resolveDispatcher
...
useState: function (initialState) {
...
try {
return mountState(initialState);
} finally {
ReactCurrentDispatcher$1.current = prevDispatcher;
}
},
...
// mountWorkInProgressHook
function mountWorkInProgressHook() {
var hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};

if (workInProgressHook === null) {
currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
} else {
workInProgressHook = workInProgressHook.next = hook;
}

return workInProgressHook;
}

// mountState
function mountState(initialState) {
// 创建 hook 对象
var hook = mountWorkInProgressHook();

if (typeof initialState === "function") {
initialState = initialState();
}

hook.memoizedState = hook.baseState = initialState;

var queue = (hook.queue = {
pending: null, // update 队列
dispatch: null, // 触发函数,会触发更新,useState返回值的第二个参数
lastRenderedReducer: basicStateReducer, // 最后一次使用的 reducer,初始化时使用最基础的 reducer
lastRenderedState: initialState, // 上一次的值,挂载阶段该值为初始值
});
var dispatch = (queue.dispatch = dispatchAction.bind(
null,
currentlyRenderingFiber$1,
queue
));
// dispatch是一个闭包函数
return [hook.memoizedState, dispatch];
}

useState 默认使用的更新器是 basicStateReducer

function basicStateReducer(state, action) {
return typeof action === "function" ? action(state) : action;
}

update 阶段​

先执行 useState 返回的 dispatch 函数

// mountState
...
var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
return [hook.memoizedState, dispatch];
function dispatchAction(fiber, queue, action) {
...
// 更新对象,是一个链表节点
var update = {
lane: lane, // 优先级
action: action, // 新值,也就是 setState 传入的参数
next: null, // 指向下一个更新对象
eagerReducer: null, // 上一次使用的 reducer
eagerState: null, // 旧值
};

var pending = queue.pending;
if (pending === null) {
// 在队列上添加当前hook的首个更新对象,并保持循环
update.next = update;
} else {
// 非首个更新对象,会追加到更新队列之后
update.next = pending.next;
pending.next = update;
}
queue.pending = update;

// fiber.alternate 指向旧 fiber 链表
var alternate = fiber.alternate;

if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
} else {
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
var lastRenderedReducer = queue.lastRenderedReducer;

if (lastRenderedReducer !== null) {
var prevDispatcher;

{
// NOTE 暂不清楚这一步的作用
prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}

try {
var currentState = queue.lastRenderedState;
// 计算新的 state
var eagerState = lastRenderedReducer(currentState, action);

update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;

if (objectIs(eagerState, currentState)) {
// 前后值没变,没必要更新
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
{
ReactCurrentDispatcher$1.current = prevDispatcher;
}
}
}
}
...
// 发起一次调度更新
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
}

更新 WorkInProgressHook

function updateWorkInProgressHook() {
var nextCurrentHook;

if (currentHook === null) {
var current = currentlyRenderingFiber$1.alternate;

if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}

var nextWorkInProgressHook;

if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber$1.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}

if (nextWorkInProgressHook !== null) {
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
...
currentHook = nextCurrentHook;
var newHook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null
};

if (workInProgressHook === null) {
currentlyRenderingFiber$1.memoizedState = workInProgressHook = newHook;
} else {
workInProgressHook = workInProgressHook.next = newHook;
}
}

return workInProgressHook;
}

真正得到最终状态,其实是在下一次获取状态的时候。

更新阶段开始时,同样会执行 renderWithHooks,此时会将 ReactCurrentDispatcher.current 指向 HooksDispatcherOnUpdate 对象

const HooksDispatcherOnUpdate: Dispatcher = {
...
useState: updateState,
}
function updateState(initialState) {
return updateReducer(basicStateReducer, initialState);
}
...
// updateReducer
function updateReducer(reducer, initialArg, init) {
var hook = updateWorkInProgressHook();
var queue = hook.queue;
...
do {
...
if (update.eagerReducer === reducer) {
newState = update.eagerState;
} else {
// 遍历更新队列,得到最新值
var action = update.action;
newState = reducer(newState, action);
}
...
update = update.next;
} while (update !== null && update !== first);
...
var dispatch = queue.dispatch;
return [hook.memoizedState, dispatch];
}

简单总结一下 update 的过程:

  1. 调用 dispatcher 函数
  2. 收集 update,将 update 对象按序插入更新队列 queue.pending
  3. 调度一次 React 的更新
  4. 重新执行组件函数时,useState 会被重新执行,在 resolve dispatcher 的阶段取到负责更新的 dispatcher
  5. 按序执行更新队列,拿到最新的 state
  6. 渲染真实 DOM,更新结束

其他问题​

为什么在 React 16 前,函数式组件不能拥有状态管理?

因为 16 以前只有类组件在更新时存在实例,而 16 以后 Fiber 架构的出现,让每一个节点都拥有对应的实例,也就拥有了保存状态的能力

为什么只能在函数组件中使用 hooks?

只有函数组件才走 renderWithHooks 的逻辑

为什么 hooks 不能在循环、判断语句中调用,而只能在函数最外层使用?

因为在更新时,这个队列需要是一致的,才能保证 hooks 的结果正确。

useState 的 setState 是同步操作还是异步操作?

默认是异步。从上面的源码分析可以看出,执行 setState 时会将更新对象(update)存入更新队列,等到 commit 阶段才会一次性执行该更新队列。在没有重新执行 app 函数时,拿到的始终是旧状态,所以就造成了异步的现象

连续执行同一个 setState,会造成多次渲染吗?比如下面这段代码:

onClick = () => {
setState(0);
setState(1);
setState(2);
};

不会。执行事件函数时,会判断当前是否处于批量更新(具体逻辑可以参见 scheduleUpdateOnFiber 源码),如果处于批量更新状态,说明还不能重新渲染,需要等待该状态结束(比如 react 中的 onClick 事件)。在批量更新阶段,每个 hooks 会通过 queue 将更新(update)存入更新队列(单链表)中,这个队列会保证更新顺序。等到批量更新状态结束了,才会重新执行组件函数,之后执行到对应 hook 时,会一次性执行挂载在 hook 上的更新队列,从而更新状态。

执行两次 setState(0) 会执行两次函数组件吗?

不会。在更新时会通过 ObjectIs 判断更新前后的值是否变化,如果没变化是不会调度更新的

· 12 min read

在 react 中可以通过 setState 来更新状态,重新渲染该组件及其子组件。react 15 在一次事务更新中,会合并多次状态更新(批量更新),等到事务结束再统一更新状态;而 react 16 中已经没有了事务的概念,在 Fiber 架构下,setState 是如何实现的呢?

写在前面​

先看道面试题

// ...
constructor() {
super();
this.state = {
val: 0
};
}

componentDidMount() {
this.setState({val: this.state.val + 1});
console.log(this.state.val);
this.setState({val: this.state.val + 1});
console.log(this.state.val);
setTimeout(() => {
this.setState({val: this.state.val + 1});
console.log(this.state.val);
this.setState({val: this.state.val + 1});
console.log(this.state.val);
}, 0);
}
// ...
};

上面代码的执行结果是

0
0
2
3

直观点看,在 setTimeout 外面的状态更新是异步的,而 setTimeout 里的状态更新是同步的。说明 setState 更新状态并不总是异步的。当它处于 react 的生命周期钩子函数、合成事件,表现为异步;其他情况则表现为同步,比如 setTimeout 和原生事件等

本文会结合源码分析,总结下 react 15 和 16 两个版本关于 setState 的实现

react 15​

react 15 是通过事务机制和锁来控制批量更新时机,实现“异步”更新

关键词:

  • Transaction(事务)
  • dirtyComponents
  • isBatchingUpdates(锁)

Transaction​

看下官方关于 Transaction 的注释:

这个注释其实就是 Transaction 的核心功能,主要是包装要执行的函数 method(生命周期钩子函数或者 react 事件处理函数等),通过 Transaction.perform 来执行 method

下面是一个基础的 Transaction 对象,是 react 事务对象的父类

var Transaction = {
// 重置 Transaction,刷新之前的数据
reinitializeTransaction: function () {
// getTransactionWrappers 其实是一个获取wrapper的抽象方法,需要具体实现
// 用于获取当前事务需要的所有 wrappers
this.transactionWrappers = this.getTransactionWrappers();
if (this.wrapperInitData) {
this.wrapperInitData.length = 0;
} else {
this.wrapperInitData = [];
}
this._isInTransaction = false;
},

_isInTransaction: false,

getTransactionWrappers: null,

// 判断当前事务是否正在执行
// 可用于防止当前事务被打断
isInTransaction: function () {
return !!this._isInTransaction;
},

perform: () => {
// this._isInTransaction = true 表明正处于事务中
// this.initializeAll
// 执行method
// this.closeAll
// this._isInTransaction = false
},
initializeAll: () => {
// 遍历所有wrappers,执行initialize方法
}
closeAll: () => {
// 遍历所有wrappers,执行所有close方法
}

Transaction 执行 perform 前,会先执行所有已注入的 wrapper 的 initialize 方法来初始化事务,执行完方法后再调用 wrapper 的 close 方法来结束事务

setState​

setState 的执行流程如下:

看下源码:

ReactComponent.prototype.setState = function(partialState, callback) {
this.updater.enqueueSetState(this, partialState);
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState');
}
};

// ...
enqueueSetState: function(publicInstance, partialState) {
var internalInstance = getInternalInstanceReadyForUpdate(
publicInstance,
'setState',
);

var queue =
internalInstance._pendingStateQueue ||
(internalInstance._pendingStateQueue = []);
queue.push(partialState);

enqueueUpdate(internalInstance);
},
// ...

function enqueueUpdate(internalInstance) {
ReactUpdates.enqueueUpdate(internalInstance);
}
// ...
function enqueueUpdate(component) {
// ...
if (!batchingStrategy.isBatchingUpdates) {
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
dirtyComponents.push(component);
}

假设现在触发了一次 react 的点击事件,并执行 setState ,此时 setState 处于 react 的更新事务流程中。开始执行事务时会设置 isBatchingUpdates 为 true,表示当前事务处于批量更新过程(锁住这个状态),导致后续加入的 setState 只会加入 dirtyComponents 中,等到事务 close 后,将 isBatchingUpdates 设置为 false,执行 batchingStrategy.batchedUpdates 合并全部状态,再更新。

react 中有一个更新策略对象 ReactDefaultBatchingStrategy,主要用于维护批量更新状态(isBatchingUpdates)和启动更新事务(batchedUpdates)

// 更新事务的 wrappers
var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];
// 修改批量更新状态
var RESET_BATCHED_UPDATES = {
initialize: emptyFunction,
close: function () {
ReactDefaultBatchingStrategy.isBatchingUpdates = false;
}
};
// 遍历所有的 dirtyComponents 更新组件
var FLUSH_BATCHED_UPDATES = {
initialize: emptyFunction,
close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
};
// ...
// 更新策略对象
var ReactDefaultBatchingStrategy = {
// 是否批量更新
isBatchingUpdates: false,

batchedUpdates: function (callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
ReactDefaultBatchingStrategy.isBatchingUpdates = true;
if (alreadyBatchingUpdates) {
return callback(a, b, c, d, e);
} else {
return transaction.perform(callback, null, a, b, c, d, e);
}
}
};
// ...
// 更新事务对象,是 Transition 对象的子类
function ReactDefaultBatchingStrategyTransaction() {
// 初始化事务
this.reinitializeTransaction();
}
// ...
// 实现了 getTransactionWrappers 这个抽象方法,即注入更新事务的 wrappers
Object.assign(ReactDefaultBatchingStrategyTransaction.prototype, Transaction, {
getTransactionWrappers: function() {
return TRANSACTION_WRAPPERS;
},
});
...
var transaction = new ReactDefaultBatchingStrategyTransaction();

从源码可以看到,setState 会调用 ReactDefaultBatchingStrategy.batchedUpdates,从而执行 transition.perform 开启更新事务

react 16​

react 16 引入了 Fiber 架构,抛弃了 react 15 的事务机制,借助锁和更新链表实现批量更新

关键词:

  • updateQueue(状态更新队列)
  • isBatchingUpdates(锁)

react 16 的组件渲染过程主要分为 render 阶段和 commit 阶段,从执行 setState 开始看下两个阶段都做了什么

主要执行流程如下:

以下源码参考自 v16.8.3

render​

这个阶段主要是收集各个 fiber 节点上的更新,将它们追加到 updateQueue 上

// react/packages/react/src/ReactBaseClasses.js
Component.prototype.setState = function (partialState, callback) {
// ...
this.updater.enqueueSetState(this, partialState, callback, "setState");
};

// react/packages/react-reconciler/src/ReactFiberClassComponent.js
const classComponentUpdater = {
// ...
enqueueSetState(inst, payload, callback) {
const fiber = getInstance(inst);
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);

// 创建一个更新队列
const update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
// ...
update.callback = callback;
}
// TODO 暂不清楚这个的具体作用
flushPassiveEffects();
// 将更新任务挂载到 fiber 上
enqueueUpdate(fiber, update);
// 调度更新
scheduleWork(fiber, expirationTime);
},
// ...
};

// react/packages/react-reconciler/src/ReactUpdateQueue.js
export function enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
// Update queues are created lazily.
const alternate = fiber.alternate;
let queue1;
let queue2;
// ...
{
appendUpdateToQueue(queue1, update);
}
// ...
}
// ...
function appendUpdateToQueue<State>(
queue: UpdateQueue<State>,
update: Update<State>
) {
// 将 update 对象挂载到更新队列 updateQueue 上,形成一个环状单向链表
if (queue.lastUpdate === null) {
queue.firstUpdate = queue.lastUpdate = update;
} else {
queue.lastUpdate.next = update;
queue.lastUpdate = update;
}
}

render 阶段,在更新单个 fiber 时(beginWork),会遍历更新队列计算得到最新的 state,该操作由 processUpdateQueue 完成。状态更新过程的调用栈有:beginWork -> updateClassComponent -> updateClassInstance -> processUpdateQueue

// react/packages/react-reconciler/src/ReactUpdateQueue.js
function updateClassInstance(
current: Fiber,
workInProgress: Fiber,
ctor: any,
newProps: any,
renderExpirationTime: ExpirationTime,
): boolean {
// ...
const oldState = workInProgress.memoizedState;
let newState = (instance.state = oldState);
let updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
// 遍历更新队列计算得到新值
processUpdateQueue(
workInProgress,
updateQueue,
newProps,
instance,
renderExpirationTime,
);
newState = workInProgress.memoizedState;
}
// ...
}
// ...
// 处理更新队列,返回新的 state
export function processUpdateQueue<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
props: any,
instance: any,
renderExpirationTime: ExpirationTime,
): void {
// ...
while (update !== null) {
// ...
{
resultState = getStateFromUpdate(
workInProgress,
queue,
update,
resultState,
props,
instance,
);
// 收集 callback,在 commit 阶段执行
const callback = update.callback;
if (callback !== null) {
workInProgress.effectTag |= Callback;
update.nextEffect = null;
if (queue.lastEffect === null) {
queue.firstEffect = queue.lastEffect = update;
} else {
queue.lastEffect.nextEffect = update;
queue.lastEffect = update;
}
}
}
// Continue to the next update.
update = update.next;
}
// ...
workInProgress.memoizedState = resultState;
// ...
}
// ...
function getStateFromUpdate (..., partialState, ...) {
// ...
if (typeof _payload2 === 'function') {
// ...
// Merge the partial state and the previous state.
return _assign({}, prevState, partialState);
}
}

commit​

该阶段又分为三个子阶段

  • before mutation
  • mutation
  • layout

在第一个阶段,classComponent 会更新当前的 state 和 props(实际计算新值的操作在render阶段的beginWork中执行,并赋值给 fiber 节点的memorizedState和memorizedProps)

其中执行 setState 的 callback 发生在第三个阶段 layout,入口代码如下:

// react/packages/react-reconciler/src/ReactFiberScheduler.js
// ...
while (nextEffect !== null) {
try {
commitAllLifeCycles(root, committedExpirationTime);
} catch (e) {
// ...
}
}
// ...

commitAllLifeCycles 这个函数内主要调用了 commitLifeCycles

// react/packages/react-reconciler/src/ReactFiberCommitWork.js
function commitLifeCycles(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedExpirationTime: ExpirationTime
): void {
switch (finishedWork.tag) {
// ...
case ClassComponent: {
const instance = finishedWork.stateNode;
// ...
const updateQueue = finishedWork.updateQueue;
if (updateQueue !== null) {
// ...
commitUpdateQueue(
finishedWork,
updateQueue,
instance,
committedExpirationTime
);
}
return;
}
// ...
}
}

commitUpdateQueue 会遍历更新队列执行 setState 的 callback

// react/packages/react-reconciler/src/ReactUpdateQueue.js
export function commitUpdateQueue<State>(
finishedWork: Fiber,
finishedQueue: UpdateQueue<State>,
instance: any,
renderExpirationTime: ExpirationTime
): void {
// If the finished render included captured updates, and there are still
// lower priority updates left over, we need to keep the captured updates
// in the queue so that they are rebased and not dropped once we process the
// queue again at the lower priority.
if (finishedQueue.firstCapturedUpdate !== null) {
// Join the captured update list to the end of the normal list.
if (finishedQueue.lastUpdate !== null) {
finishedQueue.lastUpdate.next = finishedQueue.firstCapturedUpdate;
finishedQueue.lastUpdate = finishedQueue.lastCapturedUpdate;
}
// Clear the list of captured updates.
finishedQueue.firstCapturedUpdate = finishedQueue.lastCapturedUpdate = null;
}

// Commit the effects
commitUpdateEffects(finishedQueue.firstEffect, instance);
finishedQueue.firstEffect = finishedQueue.lastEffect = null;

commitUpdateEffects(finishedQueue.firstCapturedEffect, instance);
finishedQueue.firstCapturedEffect = finishedQueue.lastCapturedEffect = null;
}

function commitUpdateEffects<State>(
effect: Update<State> | null,
instance: any
): void {
while (effect !== null) {
const callback = effect.callback;
if (callback !== null) {
effect.callback = null;
callCallback(callback, instance);
}
effect = effect.nextEffect;
}
}

批量更新​

在 react 15 是借助了事务和锁实现了批量更新,在 react 16 是怎么实现的呢?

比如我们触发了一个合成事件 onClick,会触发 dispatchEvent ,执行到 batchedUpdates 函数

function batchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
const previousIsBatchingUpdates = isBatchingUpdates; // 默认是 false
isBatchingUpdates = true;
try {
return fn(a);
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performSyncWork();
}
}
}

这里同样是用了锁(isBatchingUpdates)来开启批量更新模式,所以如果我们在合成事件(fn)里面触发 多次setState,react 会根据 isBatchingEventUpdates = true 判断当前处于批量更新模式,会把更新任务挂载到更新队列上,当合成事件执行完毕后才统一调度更新

其他​

react 17 和 react 16 其他版本,其实跟 react v16.8.3 实现 setState 的思路大同小异(后面有空再补上最新版本的)。大体上都是产生一个 update 对象去承载新的状态。多个 update 对象会连接成一个环状单向链表 - updateQueue 并挂载 fiber 上, 然后在更新该 fiber 的时候 (beginWork)会循环该 updateQueue,依次处理其中的 update,计算得到最新的 state

· 17 min read

在 react 16 之前,更新虚拟 DOM 为 stack reconciler,这是一个递归的过程,在树很深的时候,单次 diff 时间过长会造成 JS 线程持续被占用,用户交互响应迟滞,页面渲染会出现明显的卡顿。为了解决这种问题,react 团队使用 fiber 架构重构了 React,使其能够将任务分片,划分优先级,同时能够实现类似于操作系统中对线程的抢占式调度

写在前面​

浏览器每一帧都需要完成哪些工作? 浏览器一帧内的工作.png

通过上图可看到,一帧内需要完成如下六个步骤的任务:

  1. 处理用户交互
  2. 解析 js 脚本
  3. Begin frame。resize、scroll 等事件的处理
  4. rAF
  5. 布局
  6. 绘制

页面是一帧一帧绘制出来的,当 FPS(每秒绘制的帧数)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿。也就是说浏览器 1s 绘制 60 帧,每一帧分到的时间是 1000/60 ≈ 16 ms。所以我们应该力求不让一帧的工作量超过 16ms。

由此我们知道,浏览器是一帧一帧执行的,在两个执行帧之间,主线程通常会有一小段空闲时间

浏览器一帧工作的空余.png

requestIdleCallback 可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务

Fiber reconciler​

Fiber 协调核心:可中断、可恢复 和 优先级

新特性如下:

  • 增量式渲染。把渲染任务拆分成块,匀到多帧
  • 能够暂停、终止和复用渲染任务
  • 给不同类型的更新赋予优先级
  • 并行渲染(还在开发中,值得期待)

将以前的 stack reconciler 拆分成两个阶段:render 与 commit。render 阶段是可打断的,被拆分成一个个的小任务(fiber),在每一侦的渲染空闲期执行。然后是 commit 阶段,这个阶段是不拆分且不能打断的,将最终的 effectList 一口气更新到页面上。这两个阶段后续会详细介绍

  • 每个更新任务都会赋予一个优先级
  • 当更新任务抵达调度器时,高优先级的更新任务(A)会更快地被调度进入 Reconciler —— 优先级
  • 此时有新的更新任务(B),调度器会检查它优先级,若高于当前任务(A),处于当前 Reconciler 层的 A 任务会被中断,调度器将 B 任务推入 Reconciler 层 —— 可中断
  • 当 B 任务完成渲染后,新一轮调度开始,之前被中断的 A 任务将会被重新推入 Reconciler 层,继续它的渲染 —— 可恢复

fiber 对象​

React Fiber 把更新过程碎片化,执行过程如下面的图所示,每执行完一段更新过程,就把控制权交还给 React 负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。

维护每一个分片的数据结构,就是 Fiber。

有了分片之后,更新过程的调用栈如下图所示,中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机。让线程处理别的事情

fiber调度.png

一个 react 节点对应着一个 fiber 节点

type Fiber = {
// 标识不同的组件类型
// FunctionComponent: 0; ClassComponent: 1...
tag: WorkTag,

key: null | string,

// react 元素类型
// 可以为ClassComponent、FunctionComponent、Symbol、HostComponent...
elementType: any,

// The resolved function/class/ associated with this fiber.
type: any,

// 该fiber节点对应的ReactElement对象
stateNode: any,

// fiber的父级
return: Fiber | null,

// 单链表树状结构
child: Fiber | null,
sibling: Fiber | null,
index: number,

ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,

// 新 props
pendingProps: any,
// 旧 props
memoizedProps: any,

// 更新队列
updateQueue: UpdateQueue<any> | null,

// 旧 state
memoizedState: any,

// DOM diff相关
effectTag: SideEffectTag,

// 副作用链表
nextEffect: Fiber | null,
firstEffect: Fiber | null,
lastEffect: Fiber | null,

// 标识该组件应该在未来的某个时刻完成更新
// expirationTime 五种类型:
// NoWork(默认0)、Never(1)、Interactive(100 - 150)、Async(250 - 5000)、Sync(Number.MAXINTEGER)
expirationTime: ExpirationTime,

// 快速确定子fiber树有没有挂起的更改
childExpirationTime: ExpirationTime,

// fiber 的镜像节点
alternate: Fiber | null,
};

fiber 树示意图如下:

fiber树结构.png

演示:

创建fiber.gif

双链表​

在 react 中始终存在 workInprogressTree(future vdom) 与 oldTree(current vdom)两个链表,两个链表相互引用。

fiber双链表

双链表的好处有:

  • 复用内部对象(fiber),可以节省内存分配、GC 的时间开销
  • 获取旧状态
  • 当 workInprogressTree 生成报错时,这时也不会导致页面渲染崩溃,而只是更新失败,页面仍然还在

三个阶段​

Scheduler​

该阶段主要是给任务分配优先级,统筹任务调度

Scheduler 的源码解析可以参考:https://juejin.cn/post/6889314677528985614

react 16 基于 requestIdleCallback 的 polyfill 方案来实现任务调度,用法如下:

window.requestIdleCallback(callback[, options])
let handle = window.requestIdleCallback((idleDeadline) => {
const {didTimeout, timeRemaining} = idleDeadline;
console.log(`是否超时?${didTimeout}`);
console.log(`可用时间剩余${timeRemaining.call(idleDeadline)}ms`);
// do sth
const now = Date.now(), timespent = 10;
while (Date.now() < now + timespent);
console.log(`花了${timespent}ms`);
console.log(`可用时间剩余${timeRemaining.call(idleDeadline)}ms`);
}, {timeout: 1000});
// 是否超时?false
// 可用时间剩余45ms
// 花了12ms
// 可用时间剩余32ms

由于 requestIdleCallback 有兼容问题,react 团队采用的是它的 polyfill 方案,可参考

Render​

遍历 Fiber 树,得出需要更新的节点信息。可以被打断,让位于优先级更高的操作,比如用户点击等

  1. 从Fiber Root开始遍历,构建一个新的 Fiber 树。performSyncWorkOnRoot(root)-->renderRootSync
  2. 更新每个 fiber。workLoopSync-->performUnitOfWork-->beginWork
function workLoopSync() {
// workInProgress:当前正在处理的节点
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}

function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
// ...
// NOTE
next = beginWork(current, unitOfWork, subtreeRenderLanes);
// ...
if (next === null) {
// 如果没有子节点,则完成当前工作
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}

beginWork 主要做了以下事情:

  1. 判断 fiber 节点是否可以复用
  2. 根据不同的 Tag(标记不同的组件类型:纯组件、函数组件、类组件),生成不同的 fiber 节点赋值给workInprogress.child
function beginWork(current, workInProgress, renderLanes) {
// ...
switch (workInProgress.tag) {
// ...
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);

// NOTE
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
// 执行 render()、生命周期钩子函数等 -> reconcileChildren
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case HostRoot:
// ReactDOM.render(<App/>) -> reconcileChildren
return updateHostRoot(current, workInProgress, renderLanes);
// ...
}

以updateFunctionComponent为例,主要干了几件事:

  1. 生成新的 hooks 链表并挂载在 fiber 节点上
  2. 调用 reconcileChildren
  3. 返回 workInprogress.child
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes
) {
// ...
{
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes
);
}
// ...
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
// ...

reconcileChildren主要是处理子 fiber 节点,在这个阶段做 diff,并在变更的节点上标记副作用类型

// 处理子 fiber 节点
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
// ...
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const isUnkeyedTopLevelFragment = ...;
// Handle object types
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// 根据不同的类型,处理不同的节点对比
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
...
}

if (typeof newChild === 'string' || typeof newChild === 'number') {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
// 多节点数组
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
...
}
...
// reconcileChildFibers 会判断节点变更的类型,赋值flags
// 在commit阶段,根据flags更新dom
// placeSingleChild 会先判断是否是第一次渲染,是的话会增加Placement副作用
function placeSingleChild(newFiber: Fiber): Fiber {
if (shouldTrackSideEffects && newFiber.alternate === null) {
newFiber.flags = Placement;
}
return newFiber;
}
...
// 对于 ReactElement,会调用 reconcileSingleElement
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
expirationTime: ExpirationTime,
): Fiber {
// 新 key
const key = element.key;
// 旧的第一个子节点
let child = currentFirstChild;

while (child !== null) {
// 新旧key相同
if (child.key === key) {
// 节点类型未改变
if (
child.tag === Fragment
? element.type === REACT_FRAGMENT_TYPE
: child.elementType === element.type ||
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false)
) {
// 复用当前 child,先删除它的兄弟节点
deleteRemainingChildren(returnFiber, child.sibling);
// 复制 fiber 节点,重置 index 和 sibling
const existing = useFiber(
child,
element.type === REACT_FRAGMENT_TYPE
? element.props.children
: element.props,
expirationTime,
);
// ...
// 设置父节点
existing.return = returnFiber;
return existing;
} else {
// 节点类型不同,要删除旧节点
deleteRemainingChildren(returnFiber, child);
break;
}
} else {
// 新旧 key 不相同,直接删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}

// child 为 null,需要建立子节点
if (element.type === REACT_FRAGMENT_TYPE) {
// 创建 Fragment 类型的 fiber 节点
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
expirationTime,
element.key,
);
created.return = returnFiber;
return created;
} else {
// 创建 Element 类型的 fiber 节点
const created = createFiberFromElement(
element,
returnFiber.mode,
expirationTime,
);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}

...
// 副作用类型
import {
NoEffect,
PerformedWork,
Placement, // 挂载,didMount
Update, // 更新, didUpdate
Snapshot, // getSnapshotBeforeUpdate,更新之前设置快照
PlacementAndUpdate,
Deletion, // 卸载,willUnmount
ContentReset,
Callback,
DidCapture,
Ref,
Incomplete,
HostEffectMask,
Passive,
} from'shared/ReactSideEffectTags';
  1. fiber 节点更新收尾,串联 fiber 节点,收集 effect(如果有副作用)。completeUnitOfWork、completeWork
function completeUnitOfWork(unitOfWork: Fiber) {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
if ((completedWork.flags & Incomplete) === NoFlags) {
let next;
// 处理 fiber 的 props、创建 dom 对象、绑定事件等
next = completeWork(current, completedWork, subtreeRenderLanes);
// ...
// 收集 effect,把子节点 side Effect 加到父节点的 side Effect 上
if (
returnFiber !== null &&
(returnFiber.flags & Incomplete) === NoFlags
) {
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}

const flags = completedWork.flags;
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}
} else {
// ...
}
// 存在兄弟节点,将 workInProgress 指向兄弟节点,并return,执行兄弟节点的beginWork -> Fiber Node
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
// 没有兄弟节点,返回父节点
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
// ...
}

收集 effect 时,会创建 effectList,如下图所示,橙色节点上有 effect

commit​

执行到 commitRoot 时,意味着 render 阶段结束,进入 commit 阶段。该阶段主要是处理 FiberRoot 上收集的 effectList,只针对变化的节点做工作,并执行生命周期钩子函数

先看下几个生命周期的执行时机:

// 第1阶段 render/reconciliation
componentWillMount;
componentWillReceiveProps;
shouldComponentUpdate;
componentWillUpdate;

// 第2阶段 commit
componentDidMount;
componentDidUpdate;
componentWillUnmount;

第 1 阶段的生命周期函数可能会被多次调用,默认以低优先级执行,被高优先级任务打断后,稍后会重新执行

commitRoot 主要是执行commitRootImpl这个函数

function commitRoot(root) {
// ...
commitRootImpl.bind(null, root, renderPriorityLevel)
// ...
}
// ...
function commitRootImpl() {
do {
// NOTE 调用 flushPassiveEffects 执行完所有 effect
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ...
firstEffect = finishedWork.firstEffect;
if (firseEffect !== null) {
nextEffect = firstEffect;
// 第一阶段,before mutation
do {
commitBeforeMutationEffects();
} while(nextEffect !== null)
// ...
// 重置 nextEffect 为 firstEffect,接着进行第二阶段
nextEffect = firstEffect;
// 第二阶段 mutation
do {
commitMutationEffects(root, renderPriorityLevel);
} while(nextEffect !== null)

// 将当前的 workInProgress树 作为 current 树
root.current = finishedWork;

// ...
// 第三阶段 layout
do {
commitLayoutEffects(root, expirationTime);
} while(nextEffect)
// ...
// 确保 root 上所有的 work 都被调度完
ensureRootIsScheduled(root);
// 检测在 useLayoutEffect 中是否做了布局修改等,刷新布局,如果在 layoutEffect 中调用了 setState 也会在该函数中检测中并开启新的一轮调度
flushSyncCallbackQueue();
}
...
}

commit 阶段可以细分为以下三个阶段:

  1. before mutation:读取组件变更前的状态,对于类组件,会调用生命周期函数getSnapshotBeforeUpdate,在 DOM 变更前可以获取到组件实例相关的信息;对于函数组件,会异步调度 useEffect
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 对于使用 getSnapShowBeforeUpdate 的组件 fiber.effectTag |= SnapShot
if ((effectTag & Snapshot) !== NoEffect) {
// ...
const current = nextEffect.alternate;
// 执行 getSnapShotBeforeUpdate 生命周期
commitBeforeMutationEffectOnFiber(current, nextEffect);
// ...
}
// 对于使用 useEffect 的组件,其 Fiber.effectTag = UpdateEffect | PassiveEffect
if ((effectTag & Passive) !== NoEffect) {
// If there are passive effects, schedule a callback to flush at
// the earliest opportunity.
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
// NOTE
scheduleCallback(NormalPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
  1. mutation:根据 effectTag 执行对应的 dom 操作。对于类组件,还会调用 componentWillUnmount;对于函数组件,还会执行 useLayoutEffect 的销毁函数
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
while (nextEffct) {
// ...
const effectTag = nextEffect.effectTag;
let primaryEffectTag =
effectTag & (Placement | Update | Deletion | Hydrating);
// ...
switch (primaryEffectTag) {
// 挂载 DOM
case Placement: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
return;
}
// 更新组件及DOM
case PlacementAndUpdate: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
// 刷新 layoutEffect.destroy
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 更新组件
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 卸载
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
// ...
}
nextEffect = nextEffect.nextEffect;
}
}
  1. layout:在 DOM 操作完成后,读取组件的状态,对于类组件,会调用生命周期componentDidMount和componentDidUpdate;对于函数式组件,主要执行 commitHookEffectListMount
function commitLayoutEffects(root, committedExpirationTime) {
while (nextEffect !== null) {
// ...
const effectTag = nextEffect.effectTag;
if (effectTag & (Update | Callback)) {
recordEffect();
const current = nextEffect.alternate;
// didmount
commitLayoutEffectOnFiber(
root,
current,
nextEffect,
committedExpirationTime
);
}
// ...
}
}

function commitLayoutEffectOnFiber(
finishedRoot,
current,
finishedWork,
committedExpirationTime
) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
// ...
return;
}
case ClassComponent: {
// didMount
}
}
}