├── .gitignore
├── .gitmodules
├── README.md
├── docs
├── .vuepress
│ └── public
│ │ ├── favicon.ico
│ │ └── images
│ │ ├── avatar.jpg
│ │ ├── home-bg.jpg
│ │ └── logo.png
├── node
│ └── koa源码解读及实现一个简易版koa.md
├── react
│ ├── 其他
│ │ └── 判断组件是否卸载.md
│ └── 源码
│ │ ├── React17源码解析(1) —— 源码目录及 react 架构.md
│ │ ├── React17源码解析(2) —— jsx 转换及 React.createElement.md
│ │ ├── React17源码解析(3) —— 深入理解 fiber.md
│ │ ├── React17源码解析(4) —— 详解 render 阶段(scheduler 和 reconciler).md
│ │ ├── React17源码解析(5) —— 全面理解diff算法.md
│ │ ├── React17源码解析(6) —— commit 阶段.md
│ │ ├── React17源码解析(7) —— 一文搞懂 hooks 原理.md
│ │ ├── React17源码解析(开篇)——搭建调试环境.md
│ │ └── 🚀 万字好文 —— 手把手教你实现史上功能最丰富的简易版 react.md
├── team
│ └── 代码质量
│ │ ├── CodeReview.md
│ │ ├── Js代码可读性.md
│ │ └── 前端规范化实践.md
├── vue2
│ └── 通信方式.md
├── 代码仓库
│ └── git
│ │ ├── Git操作指令大全.md
│ │ └── 实用总结.md
├── 工程化
│ └── webpack
│ │ ├── webpack体积优化及打包速度提升总结.md
│ │ └── 一文搞懂webpack的devtool及sourceMap.md
└── 开发场景
│ └── 多人协作编辑.md
├── home-footer
└── index.vue
├── package.json
├── tsconfig.json
├── vuepress.config.ts
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .temp
3 | **/.cache
4 | yarn-error.log
5 | docs/.vuepress/dist
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "vuepress-theme-writing"]
2 | path = vuepress-theme-writing
3 | url = git@github.com:zh-lx/vuepress-theme-writing.git
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 个人博客
2 |
3 | 个人前端及全栈学习过程中的一些文章输出
4 |
5 | 博客网站预览地址:https://zlxiang.com
6 |
--------------------------------------------------------------------------------
/docs/.vuepress/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh-lx/blog/8231c79988d5ab3e42fb3f82d06f7c2631119530/docs/.vuepress/public/favicon.ico
--------------------------------------------------------------------------------
/docs/.vuepress/public/images/avatar.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh-lx/blog/8231c79988d5ab3e42fb3f82d06f7c2631119530/docs/.vuepress/public/images/avatar.jpg
--------------------------------------------------------------------------------
/docs/.vuepress/public/images/home-bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh-lx/blog/8231c79988d5ab3e42fb3f82d06f7c2631119530/docs/.vuepress/public/images/home-bg.jpg
--------------------------------------------------------------------------------
/docs/.vuepress/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zh-lx/blog/8231c79988d5ab3e42fb3f82d06f7c2631119530/docs/.vuepress/public/images/logo.png
--------------------------------------------------------------------------------
/docs/node/koa源码解读及实现一个简易版koa.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本文将细节分析 koa 的源码,并基于其原理实现一个简易版的 koa。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/a15b7251-115f-43ef-99f2-247b0b2f5c0d'
4 | tag: ['node', 'koa']
5 | time: '2021-05-27'
6 | ---
7 |
8 | # Koa 源码解读及实现一个简易版 Koa
9 |
10 | ## Koa 框架介绍
11 |
12 | koa 是由 Express 原班人马打造的,致力于成为一个更小、更富有表现力、更健壮的 Web 框架。使用 koa 编写 web 应用,通过组合不同的 generator,可以免除重复繁琐的回调函数嵌套,并极大地提升错误处理的效率。koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用变得得心应手。
13 |
14 | ### 特点
15 |
16 | - 轻量、无捆绑
17 | - 中间件架构
18 | - 通过不同的 generator 以及 await/async 替代了回调
19 | - 增强的错误处理
20 | - 简单易用的 api
21 |
22 | ### 简单使用
23 |
24 | Koa 对 node 服务进行了封装,并提供了简单易用的 API。假如我们想在请求 3000 端口时返回 `hello, node!` 的数据,使用原生 node 实现代码如下:
25 |
26 | ```js
27 | const http = require('http');
28 |
29 | const server = http.createServer((req, res) => {
30 | res.end('hello, node!');
31 | });
32 |
33 | server.listen(3000, () => {
34 | console.log('server is running on 3000...');
35 | });
36 | ```
37 |
38 | 使用 Koa 实现如下:
39 |
40 | ```js
41 | const Koa = require('koa');
42 | const app = new Koa();
43 |
44 | app.use((ctx, next) => {
45 | ctx.body = 'hello, node!';
46 | });
47 |
48 | app.listen(3000, () => {
49 | console.log('server is running on 3000...');
50 | });
51 | ```
52 |
53 | 通过对比可以发现,koa 实现方式通过 `new Koa()` 创建了一个 koa 实例,实例上有 `use` 方法,`use` 的回调函数中接收 `ctx` 和 `next` 两个参数。就这简单的几点,基本就组成了 koa 的全部内容。
54 |
55 | ### 中间件和洋葱圈模型
56 |
57 | 中间件是 Koa 的核心,koa 通过 `use()` 去调用一系列的中间件,并通过 `next()` 将上下文交给下一个中间件去进行处理。当没有下一个 `next()` 可执行之后,再倒序执行每个 `use()` 回调函数中 `next` 之后的逻辑。
58 |
59 | 这就是 koa 的洋葱圈模型:
60 |
61 | 
62 |
63 | 如下一段代码,在请求 `localhost:3000` 端口后 node 控制台打印顺序为: `1、3、5、6、4、2`:
64 |
65 | ```js
66 | const Koa = require('koa');
67 | const app = new Koa();
68 |
69 | app.use((ctx, next) => {
70 | console.log(1);
71 | next();
72 | console.log(2);
73 | });
74 |
75 | app.use((ctx, next) => {
76 | console.log(3);
77 | next();
78 | console.log(4);
79 | });
80 |
81 | app.use((ctx, next) => {
82 | console.log(5);
83 | ctx.body = 'hello, node!';
84 | console.log(6);
85 | });
86 |
87 | app.listen(3000, () => {
88 | console.log('server is running on 3000...');
89 | });
90 | ```
91 |
92 | ## Koa 源码结构
93 |
94 | [Koa 源码](https://github.com/koajs/koa)
95 |
96 | 
97 | Koa 的核心文件一共有四个:`application.js`、`context.js`、`request.js`、`response.js`。所有的代码加起来不到 2000 行,十分的轻便,而且大量代码集中在 `request.js` 和 `response.js` 对于请求头和响应头的处理,核心代码只有几百行。
98 |
99 | ### application
100 |
101 | `application.js` 是 koa 的入口文件,里面导出了 koa 的构造函数,构造函数中包含了 koa 的主要功能实现。
102 |
103 | #### listen
104 |
105 | application 构造函数首先通过 node 中 http 模块,实现了 `listen` 功能:
106 |
107 | ```js
108 | listen(...args) {
109 | debug('listen');
110 | const server = http.createServer(this.callback());
111 | return server.listen(...args);
112 | }
113 | ```
114 |
115 | #### use
116 |
117 | use 方法将接收到的中间件函数,全部添加到了 `this.middleware` 中,以便后面按顺序调用各个中间件。同时为了兼容 koa1 中的 use 使用,对于 generator 类型的中间件函数,会通过 `koa-convert` 库将其进行转换,以兼容 koa2 中的 koa 的递归调用。
118 |
119 | ```js
120 | use(fn) {
121 | if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
122 | if (isGeneratorFunction(fn)) { // 兼容 koa1 的 use 用法
123 | deprecate('Support for generators will be removed in v3. ' +
124 | 'See the documentation for examples of how to convert old middleware ' +
125 | 'https://github.com/koajs/koa/blob/master/docs/migration.md');
126 | fn = convert(fn);
127 | }
128 | debug('use %s', fn._name || fn.name || '-');
129 | this.middleware.push(fn);
130 | return this;
131 | }
132 | ```
133 |
134 | #### callback
135 |
136 | 上面 listen 函数在服务启动时,`createServer` 函数会返回 callback 函数的执行结果。
137 |
138 | 在服务启动时,callback 函数做了中间件的合并,监听框架层的错误请求等功能。
139 |
140 | 然后返回了 `handleRequest` 的方法,它接收 req 和 res 两个参数,每次服务端收到请求时,会根据 node http 原生的 req 和 res,创建一个新的 koa 的上下文 ctx。
141 |
142 | ```js
143 | callback() {
144 | const fn = compose(this.middleware); // 合并中间件
145 |
146 | if (!this.listenerCount('error')) this.on('error', this.onerror); // 捕获框架层的错误
147 |
148 | const handleRequest = (req, res) => {
149 | const ctx = this.createContext(req, res); // 创建上下文
150 | return this.handleRequest(ctx, fn);
151 | };
152 |
153 | return handleRequest;
154 | }
155 | ```
156 |
157 | #### createContext
158 |
159 | 再来看 `createContext` 函数,一大串的赋值骚操作,我们细细解读一下:
160 |
161 | 1. 先通过 `Object.create()`,创建了新的从 `context.js`、`request.js`、`response.js` 引入的对象,防止引入的原始对象被污染。
162 |
163 | 2. 通过 `context.request = Object.create(this.request)` 和 `context.response = Object.create(this.response)` 将 request 和 response 对象挂载到了 context 对象上。这部分对应了 `context.js` 中 delegate 的委托部分(有关 delegate 可以见后面 koa 核心库部分的解读),能让 ctx 直接通过 `ctx.xxx` 去访问到 `ctx.request.xxx` 和 `ctx.response.xxx`
164 |
165 | 3. 通过一系列的赋值操作,将原始的 http 请求的 res 和 req,以及 Koa 实例 app 等等分别挂载到了 context、request 和 response 对象中,以便于在 `context.js`、`request.js` 和`response.js` 中针对原始的请求、相应参数等做一些系列的处理访问,便于用户使用
166 |
167 | ```js
168 | createContext(req, res) {
169 | // Object.create()创建
170 | const context = Object.create(this.context);
171 | const request = context.request = Object.create(this.request);
172 | const response = context.response = Object.create(this.response);
173 | context.app = request.app = response.app = this;
174 | context.req = request.req = response.req = req;
175 | context.res = request.res = response.res = res;
176 | request.ctx = response.ctx = context;
177 | request.response = response;
178 | response.request = request;
179 | context.originalUrl = request.originalUrl = req.url;
180 | context.state = {};
181 | return context;
182 | }
183 | ```
184 |
185 | 最终这段代码执行后的关系图如下:
186 | 
187 |
188 | #### handleRequest
189 |
190 | `callback` 中执行完 `createContext` 后,会将创建好的 ctx 以及合并中间件后生成的顺序执行函数传给 `handleRequest` 并执行该函数。
191 |
192 | handleRequest 中会通过 `onFinished` 这个方法监听 res,当 res 完成、关闭或者出错时,便会执行 onerror 回调。
193 | 之后返回中间件执行的结果,当中间件全部执行完之后,执行 respond 进行数据返回操作。
194 |
195 | ```js
196 | handleRequest(ctx, fnMiddleware) {
197 | const res = ctx.res;
198 | res.statusCode = 404;
199 | const onerror = err => ctx.onerror(err);
200 | const handleResponse = () => respond(ctx);
201 | onFinished(res, onerror);
202 | return fnMiddleware(ctx).then(handleResponse).catch(onerror);
203 | }
204 | ```
205 |
206 | ### context
207 |
208 | #### cookies
209 |
210 | `context.js` 中通过 get 和 set 方法做了 cookie 的设置和读取操作。
211 |
212 | #### delegate
213 |
214 | `context.js` 中有大量的 delegate 操作,是通过 delegate,可以让 ctx 能够直接访问其上面 response 和 request 中的属性和方法,即可以通过 `ctx.xxx` 获取到 `ctx.request.xxx` 或 `ctx.response.xxx` 。
215 |
216 | delegate 是通过 `delegates` 这个库实现的,通过 `proto.__defineGetter__` 和 `proto.__defineSetter__` 去代理对象下面节点的属性和方法等。(`proto.__defineGetter__` 和 `proto.__defineSetter__` 现已被 mdn 废弃,改用 `Object.defineProperty()`)
217 |
218 | ```js
219 | delegate(proto, 'response')
220 | .method('attachment')
221 | .method('redirect')
222 | .access('lastModified')
223 | .access('etag')
224 | .getter('headerSent')
225 | .getter('writable');
226 | // ...
227 |
228 | delegate(proto, 'request').method('acceptsLanguages').getter('ip');
229 | // ...
230 | ```
231 |
232 | `context.js` 中导出了一个 `context` 对象,主要用来在中间件以及其它各部件之间传递信息的,同时 `context` 对象上挂载了 `request` 和 `response` 两大对象。
233 |
234 | 另外其还做了 cookie 的处理以及使用 [delegates](https://www.npmjs.com/package/delegates) 库对 request 和 response 对象上面的事件和方法进行了委托,便于用户使用。
235 |
236 | ### request
237 |
238 | `request.js` 导出了 request 对象,通过 `get()` 和 `set()` 方法对请求头的参数如 header、url、href、method、path、query……做了处理,挂载到了 request 对象上,方便用户获取和设置。
239 |
240 | ### response
241 |
242 | 同 `request.js` ,通过 `get()` 和 `set()`对响应参数做了处理。
243 |
244 | ## koa-compose
245 |
246 | 在 `application.js` 中,通过 `compose` 将中间件进行了合并,这也是 koa 的一个核心实现。
247 |
248 | 先来看 `koa-compose` 的源码,实现非常简单,只有几十行:
249 |
250 | ```js
251 | function compose(middleware) {
252 | // middleware 中间件函数数组, 数组中是一个个的中间件函数
253 | if (!Array.isArray(middleware))
254 | throw new TypeError('Middleware stack must be an array!');
255 | for (const fn of middleware) {
256 | if (typeof fn !== 'function')
257 | throw new TypeError('Middleware must be composed of functions!');
258 | }
259 | return function (context, next) {
260 | // last called middleware #
261 | let index = -1;
262 | return dispatch(0);
263 | function dispatch(i) {
264 | if (i <= index)
265 | return Promise.reject(new Error('next() called multiple times'));
266 | index = i;
267 | let fn = middleware[i];
268 | if (i === middleware.length) fn = next;
269 | if (!fn) return Promise.resolve();
270 | try {
271 | return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
272 | } catch (err) {
273 | return Promise.reject(err);
274 | }
275 | }
276 | };
277 | }
278 | ```
279 |
280 | compose 接收一个中间件函数的数组,返回了一个闭包函数,闭包中维护了一个 index 去记录当前调用的中间件。
281 |
282 | 里面创建了一个 dispatch 函数,`dispatch(i)` 会通过 `Promise.resolve()` 返回 middleware 中的第 i 项函数执行结果,即第 i + 1 个 `app.use()` 传入的函数。 `app.use()` 回调的第二个参数是 `next`,所以当 `app.use()` 中的代码执行到 `next()` 时,便会执行 `dispatch.bind(null, i + 1))`,即执行下一个 `app.use()` 的回调。
283 |
284 | 依次类推,便将一个个 `app.use()` 的回调给串联了起来,直至没有下一个 next,边会按顺序返回执行每个 `app.use()` 的 `next()` 后面的逻辑。最终通过 `Promise.resolve()` 返回第一个 `app.use()` 的执行结果。
285 |
286 | ## 实现一个简单的 Koa
287 |
288 | 下面我们尝试实现一个简易版的 koa:
289 |
290 | ### 封装 node 的 http 模块
291 |
292 | 按照本文开篇的最简单示例去实现,新建 `application.js`,内部创建一个 MyKoa 类,基于 node 的 http 模块,实现 listen 函数:
293 |
294 | ```js
295 | // application.js
296 | const http = require('http');
297 |
298 | class MyKoa {
299 | listen(...args) {
300 | const server = http.createServer((req, res) => {
301 | res.end('mykoa');
302 | });
303 | server.listen(...args);
304 | }
305 | }
306 |
307 | module.exports = MyKoa;
308 | ```
309 |
310 | ### 实现 use 方法和简易 createContext
311 |
312 | 然后要实现 `app.use()` 方法,我们看到 `app.use()` 中内部有 ctx.body,所以我们还需要实现一个简单的 ctx 对象。
313 |
314 | 1. 创建一个 `context.js`,内部导出 `ctx` 对象,分别通过 get 和 set,实现可以获取和设置 `ctx.body` 的值:
315 |
316 | ```js
317 | // context.js
318 | module.exports = {
319 | get body() {
320 | return this._body;
321 | },
322 |
323 | set body(value) {
324 | this._body = value;
325 | },
326 | };
327 | ```
328 |
329 | 2. 在 `application.js` 的 MyKoa 类中添加 use 和 createContext 方法,同时 `res.end` 返回 `ctx.body`:
330 |
331 | ```js
332 | const http = require('http');
333 | const _context = require('./context');
334 |
335 | class MyKoa {
336 | listen(...args) {
337 | const server = http.createServer((req, res) => {
338 | const ctx = this.createContext(req, res);
339 | this.callback();
340 | res.end(ctx.body);
341 | });
342 | server.listen(...args);
343 | }
344 |
345 | use(callback) {
346 | this.callback = callback;
347 | }
348 |
349 | createContext(req, res) {
350 | const ctx = Object.assign(_context);
351 | return ctx;
352 | }
353 | }
354 |
355 | module.exports = MyKoa;
356 | ```
357 |
358 | ### 完善 createContext
359 |
360 | 我们要通过 ctx 去访问请求头以及设置响应头等相关信息,例如 `ctx.query`,`ctx.message` 等等,就要创建 `response.js` 和 `request.js` 对请求头和响应头做处理,将 request 和 response 对象挂载到 ctx 对象上,同时实现一个 delegate 函数让 ctx 能够访问 request 和 response 上面的属性和方法。
361 |
362 | 1. 实现简单的 request 和 response,request 中通过 get 方法,能够解析 `req.url` 中的参数,将其转换为一个对象返回。response 中,通过 get 和 set `message`,能够获取和设置 `res.statusMessage` 的值:
363 |
364 | ```js
365 | // request.js
366 | module.exports = {
367 | get query() {
368 | const arr = this.req.url.split('?');
369 | if (arr[1]) {
370 | const obj = {};
371 | arr[1].split('&').forEach((str) => {
372 | const param = str.split('=');
373 | obj[param[0]] = param[1];
374 | });
375 | return obj;
376 | }
377 | return {};
378 | },
379 | };
380 | ```
381 |
382 | ```js
383 | // response.js
384 | module.exports = {
385 | get message() {
386 | return this.res.statusMessage || '';
387 | },
388 |
389 | set message(msg) {
390 | this.res.statusMessage = msg;
391 | },
392 | };
393 | ```
394 |
395 | 2. 新建一个 `utils.js`,导出 delegate 方法,delegate 内部通过 `Object.defineProperty` ,让传入的对象 obj 能够在属性 property 改变时实时监听,例如 `delegate(ctx, 'request')` 当 request 对象值改变时,ctx 对 request 代理也能获取最新的值。
396 |
397 | 然后实现简单的 getter 和 setter,通过一个 listen 函数,当使用 getter 或者 setter 时,将对应的键添加到 setters 和 getters 中,让 obj 访问对应键时代理到 proterty 对应的键值:
398 |
399 | ```js
400 | // utils.js
401 | module.exports.delegate = function Delegate(obj, property) {
402 | let setters = [];
403 | let getters = [];
404 | let listens = [];
405 |
406 | function listen(key) {
407 | Object.defineProperty(obj, key, {
408 | get() {
409 | return getters.includes(key) ? obj[property][key] : obj[key]; // 如果通过 getter 代理了,则返回对应 obj[property][key] 的值,否则返回 obj[key] 的值
410 | },
411 | set(val) {
412 | if (setters.includes(key)) {
413 | obj[property][key] = val; 如果通过 setter 代理了,则设置对应 obj[property][key] 的值,否则设置 obj[key] 的值
414 | } else {
415 | obj[key] = val;
416 | }
417 | },
418 | });
419 | }
420 |
421 | this.getter = function (key) {
422 | getters.push(key);
423 | if (!listens.includes(key)) { // 防止重复调用listen
424 | listen(key);
425 | listens.push(key);
426 | }
427 | return this;
428 | };
429 |
430 | this.setter = function (key) {
431 | setters.push(key);
432 | if (!listens.includes(key)) { // 防止重复调用listenf
433 | listen(key);
434 | listens.push(key);
435 | }
436 | return this;
437 | };
438 | return this;
439 | };
440 | ```
441 |
442 | 3. 在 context 使用 delegate 对 request 和 response 进行代理:
443 |
444 | ```js
445 | // context.js
446 | const { delegate } = require('./utils');
447 | const context = (module.exports = {
448 | get body() {
449 | return this._body;
450 | },
451 |
452 | set body(value) {
453 | this._body = value;
454 | },
455 | });
456 | delegate(context, 'request').getter('query');
457 | delegate(context, 'response').getter('message').setter('message');
458 | ```
459 |
460 | 4. 完善 createContext 函数:
461 |
462 | ```js
463 | // application.js
464 | const http = require('http');
465 | const _context = require('./context');
466 | const _request = require('./request');
467 | const _response = require('./response');
468 |
469 | class MyKoa {
470 | // ...
471 | createContext(req, res) {
472 | const ctx = Object.assign(_context);
473 | const request = Object.assign(_request);
474 | const response = Object.assign(_response);
475 | ctx.request = request;
476 | ctx.response = response;
477 | ctx.req = request.req = req;
478 | ctx.res = response.res = res;
479 | return ctx;
480 | }
481 | }
482 |
483 | module.exports = MyKoa;
484 | ```
485 |
486 | ### 实现中间件和洋葱模型
487 |
488 | 到现在为止,只剩下实现 app.use() 中间件的功能了。
489 |
490 | 1. 按照前面 koa-compose 分析的思路,`在 utils.js` 中,实现 compose:
491 |
492 | ```js
493 | // utils.js
494 | module.exports.compose = (middleware) => {
495 | return (ctx, next) => {
496 | let index = -1;
497 | return dispatch(0);
498 | function dispatch(i) {
499 | if (i <= index) return Promise.reject(new Error('error'));
500 | index = i;
501 | const cb = middleware[i] || next;
502 | if (!cb) return Promise.resolve();
503 | try {
504 | return Promise.resolve(
505 | cb(ctx, function next() {
506 | return dispatch(i + 1);
507 | })
508 | );
509 | } catch (error) {
510 | return Promise.reject(error);
511 | }
512 | }
513 | };
514 | };
515 | ```
516 |
517 | 2. 在 app.js 中,初始化 `this.middleware` 的数组,`use()` 函数中将 callback 添加进数组:
518 |
519 | ```js
520 | // ...
521 | class MyKoa {
522 | constructor() {
523 | this.middleware = [];
524 | }
525 | // ...
526 |
527 | use(callback) {
528 | this.middleware.push(callback);
529 | }
530 | // ...
531 | }
532 |
533 | module.exports = MyKoa;
534 | ```
535 |
536 | 3. listen 方法 createServer 中,遇到请求时将中间件合并,中间件执行完毕后返回 res 结果:
537 |
538 | ```js
539 | // ...
540 | const { compose } = require('./utils');
541 |
542 | class MyKoa {
543 | // ...
544 | listen(...args) {
545 | const server = http.createServer((req, res) => {
546 | const ctx = this.createContext(req, res);
547 | //
548 | const fn = compose(this.middleware);
549 | fn(ctx)
550 | .then(() => {
551 | // 全部中间件执行完毕后,返回相应信息
552 | res.end(ctx.body);
553 | })
554 | .catch((err) => {
555 | throw err;
556 | });
557 | });
558 | server.listen(...args);
559 | }
560 | // ...
561 | }
562 | module.exports = MyKoa;
563 | ```
564 |
565 | ### 测试
566 |
567 | 到这里就大功告成了,引入我们的 Mykoa 在如下服务中测试一下:
568 |
569 | ```js
570 | const Koa = require('../my-koa/application');
571 | const app = new Koa();
572 |
573 | app.use((ctx, next) => {
574 | ctx.message = 'ok';
575 | console.log(1);
576 | next();
577 | console.log(2);
578 | });
579 |
580 | app.use((ctx, next) => {
581 | console.log(3);
582 | next();
583 | console.log(4);
584 | });
585 |
586 | app.use((ctx, next) => {
587 | console.log(5);
588 | next();
589 | console.log(6);
590 | });
591 |
592 | app.use((ctx, next) => {
593 | console.log(ctx.message);
594 | console.log(ctx.query);
595 | ctx.body = 'hello, myKoa';
596 | });
597 |
598 | app.listen(3000, () => {
599 | console.log('server is running on 3000...');
600 | });
601 | ```
602 |
603 | 访问 `http://localhost:3000/api?name=zlx` 接口,返回数据为`hello, myKoa` 。
604 |
605 | node 服务器控制台打印内容如下:
606 |
607 | ```js
608 | 1;
609 | 3;
610 | 5;
611 | ok;
612 | {
613 | name: 'zlx';
614 | }
615 | 6;
616 | 4;
617 | 2;
618 | ```
619 |
620 | 说明我们实现的没有任何问题!
621 |
622 | ## 源码
623 |
624 | 最后附上源码实现地址:
625 | https://github.com/zh-lx/study-code/tree/main/node/koa
626 |
--------------------------------------------------------------------------------
/docs/react/其他/判断组件是否卸载.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '在组件卸载后,调用了 setState,造成了内存泄露,这里我自己写了一个 hook,去判断组件是否处于已卸载的状态。'
3 | tag: ['react', 'taro']
4 | time: '2021-04-05'
5 | ---
6 |
7 | # React 使用 hook 判断组件是否卸载
8 |
9 | > 前两天在做 Taro 小程序开发时,发现每次进入都会出现如下的 warning:
10 | > Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in %s.%s a useEffect cleanup function.
11 | > 
12 | > 原因说的很明显,是在组件卸载后,还调用了 setState,造成了内存泄漏。
13 |
14 | ## 页面代码
15 |
16 | 这是我的 index.js 文件, 进入 index 页面时会请求 api 去获取列表:
17 |
18 | ```javascript
19 | import React, { useEffect, useState } from 'react';
20 | import { View } from '@tarojs/components';
21 | import { getList } from '@/api/list';
22 | import List from './list';
23 | import './index.less';
24 |
25 | export default function Index() {
26 | const [list, setList] = useState([]); // 列表
27 | // 获取列表
28 | const getList = async () => {
29 | let res: any = await getList();
30 | if (res.err_no === 0) {
31 | setList((res.data && res.data.list) || []);
32 | }
33 | };
34 | // 请求初始列表
35 | useEffect(() => {
36 | getList();
37 | }, []);
38 | return (
39 |
40 |
41 |
42 | );
43 | }
44 | ```
45 |
46 | Taro 的入口文件 app.js 文件中,会发送请求用户信息的 api 判断用户是否登录,若未登录,则会重定向至登录页面,代码如下:
47 |
48 | ```javascript
49 | import { Component } from 'react';
50 | import { getUserInfo } from './api/parent';
51 | import Taro from '@tarojs/taro';
52 | import './app.less';
53 |
54 | class App extends Component {
55 | componentDidMount() {
56 | getUserInfo().then((res) => {
57 | if (res.err_no !== 0) {
58 | Taro.redirectTo({
59 | url: '/pages/login/index',
60 | });
61 | }
62 | });
63 | }
64 | render() {
65 | return this.props.children;
66 | }
67 | }
68 |
69 | export default App;
70 | ```
71 |
72 | ## 错误定位
73 |
74 | 上面就会引起一个问题,在小程序一开始进入到 index 页面,会发送`app.ts`中`getUserInfo`的请求,同时也会发送`index.js`中的`getList`的请求。当`getUserInfo`请求率先返回结果时,如果用户未登录,则会重定向至 login 页面,此时 index 页面组件已经卸载掉,而`index.js`页面中的`getList`请求完成后还要调用`setList`去改变 state,便会造成内存泄漏。
75 | 知道了问题就好解决了,我们在进行`setList`操作时,只需要判断一下当前组件是否已卸载,若已卸载便终止 setState 操作。
76 |
77 | ## 通过 hook 判断组件是否处于 unmouted 状态
78 |
79 | 这里我自己写了一个 hook,去判断组件是否处于已卸载的状态,然后在`setList`之前判断一下组件状态:
80 |
81 | ```javascript
82 | // hook.js
83 | import React, { useEffect, useRef, useCallback } from 'react';
84 |
85 | export const useMounted = () => {
86 | const mountedRef = useRef(false);
87 | useEffect(() => {
88 | mountedRef.current = true;
89 | return () => {
90 | mountedRef.current = false;
91 | };
92 | }, []);
93 | return () => mountedRef.current;
94 | };
95 | ```
96 |
97 | 修改后的`index.js`文件:
98 |
99 | ```javascript
100 | import React, { useEffect, useState } from 'react';
101 | import { View } from '@tarojs/components';
102 | import { getList } from '@/api/list';
103 | import { useMounted } from '@/utils/hook';
104 | import List from './list';
105 | import './index.less';
106 |
107 | export default function Index() {
108 | const [list, setList] = useState([]); // 列表
109 | const isMounted = useMounted();
110 | // 获取列表
111 | const getList = async () => {
112 | let res: any = await getList();
113 | // 若isMounted为false,则说明组件已卸载,终止后续操作
114 | if (!isMounted()) {
115 | return;
116 | }
117 | if (res.err_no === 0) {
118 | setList((res.data && res.data.list) || []);
119 | }
120 | };
121 | // 请求初始列表
122 | useEffect(() => {
123 | getList();
124 | }, []);
125 | return (
126 |
127 |
128 |
129 | );
130 | }
131 | ```
132 |
133 | 成功解决问题!
134 |
--------------------------------------------------------------------------------
/docs/react/源码/React17源码解析(1) —— 源码目录及 react 架构.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '要学习 react 源码,首先要对 react 的源码和整个架构有个大体的了解,这样学起来会事半功倍,本章将介绍一下 react 的源码和架构。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4'
4 | tag: ['react']
5 | time: '2021-09-22'
6 | ---
7 |
8 | 欢迎大家一起交流学习 react 源码,本系列导航请见:[React17 源码解析(开篇) —— 搭建 react 源码调试环境](https://juejin.cn/post/7014775797596553230/)
9 |
10 |
11 |
12 | 要学习 react 源码,首先要对 react 的源码和整个架构有个大体的了解,这样学起来会事半功倍。本章将介绍一下 react 的源码和架构。
13 |
14 | ## 源码目录
15 |
16 | react 的源码目录如下,主要有三个文件夹:
17 |
18 | - fixtures:一些测试 demo,方便 react 编码时的测试
19 | - packages:react 的主要源码内容
20 | - script:和 react 打包、编译、本地开发相关的命令
21 | 我们要探究的源码内容,都存放在 packages 文件夹下:
22 |
23 | ```
24 | 📦react
25 | ┣ 📂fixtures
26 | ┣ 📂packages
27 | ┃ ┣ 📂create-subscription
28 | ┃ ┣ 📂dom-event-testing-library
29 | ┃ ┣ 📂eslint-plugin-react-hooks
30 | ┃ ┣ 📂jest-mock-scheduler
31 | ┃ ┣ 📂jest-react
32 | ┃ ┣ 📂react
33 | ┃ ┣ 📂react-art
34 | ┃ ┣ 📂react-cache
35 | ┃ ┣ 📂react-client
36 | ┃ ┣ 📂react-debug-tools
37 | ┃ ┣ 📂react-devtools
38 | ┃ ┣ 📂react-devtools-core
39 | ┃ ┣ 📂react-devtools-extensions
40 | ┃ ┣ 📂react-devtools-inline
41 | ┃ ┣ 📂react-devtools-scheduling-profiler
42 | ┃ ┣ 📂react-devtools-shares
43 | ┃ ┣ 📂react-devtools-shell
44 | ┃ ┣ 📂react-dom
45 | ┃ ┣ 📂react-fetch
46 | ┃ ┣ 📂react-interactions
47 | ┃ ┣ 📂react-is
48 | ┃ ┣ 📂react-native-renderer
49 | ┃ ┣ 📂react-noop-renderer
50 | ┃ ┣ 📂react-reconciler
51 | ┃ ┣ 📂react-refresh
52 | ┃ ┣ 📂react-server
53 | ┃ ┣ 📂react-test-renderer
54 | ┃ ┣ 📂react-transport-dom-relay
55 | ┃ ┣ 📂react-transport-dom-webpack
56 | ┃ ┣ 📂scheduler
57 | ┃ ┣ 📂shared
58 | ┃ ┗ 📂use-subscription
59 | ┗ 📂scripts
60 | ```
61 |
62 | 根据 packages 下面各个部分的功能,我将其划分为了几个模块:
63 |
64 | ### 核心 api
65 |
66 | react 的核心 api 都位于 `packages/react` 文件夹下,包括 `createElement`、`memo`、`context` 以及 hooks 等,凡是通过 react 包引入的 api,都位于此文件夹下。
67 |
68 | ### 调度和协调
69 |
70 | 调度和协调是 react16 fiber 出现后的核心功能,和他们相关的包如下:
71 |
72 | - scheduler:对任务进行调度,根据优先级排序
73 | - react-conciler:diff 算法相关,对 fiber 进行副作用标记
74 |
75 | ### 渲染
76 |
77 | 和渲染相关的内容包括以下几个目录:
78 |
79 | - react-art:canvas、svg 等内容的渲染
80 | - react-dom:浏览器环境下的渲染,也是我们本系列中主要涉及讲解的渲染的包
81 | - react-native-renderer:用于原生环境渲染相关
82 | - react-noop-renderer:用于调试环境的渲染
83 |
84 | ### 辅助包
85 |
86 | - shared:定义了 react 的公共方法和变量
87 | - react-is:react 中的类型判断
88 |
89 | ### 其他
90 |
91 | 其他的包和本次 react 源码探究的关联不是很多,所以不过多介绍了。
92 |
93 | ## react 架构
94 |
95 | 了解了 react 源码目录之后,我们再来对 react 架构有个大体的认识,了解 react 是如何在变量更改时,页面发生更新渲染的。
96 |
97 | react 为了保证页面能够流畅渲染,react16 之后的更新过程分为 render 和 commit 两个阶段。render 阶段包括 Scheduler(调度器) 和 Reconciler(协调器),commit 阶段包括 Renderer(渲染器):
98 |
99 |
100 |
101 | ### 触发更新
102 |
103 | 触发更新的方式主要有以下几种:`ReactDOM.render`(包括首次渲染)、`setState`、`forUpdate`、hooks 中的 `useState` 以及 ref 的改变等引起的。
104 |
105 | ### scheduler
106 |
107 | 当首次渲染或者组件状态发生更新等情况时,此时页面就要发生渲染了。scheduler 过程会对诸多的任务进行优先级排序,让浏览器的每一帧优先执行高优先级的任务(例如动画、用户点击输入事件等),从而防止 react 的更新任务太大影响到用户交互,保证了页面的流畅性。
108 |
109 | ### reconciler
110 |
111 | reconciler 过程中,会开始根据优先级执行更新任务。这一过程主要是根据最新状态构建新的 fiber 树,与之前的 fiber 树进行 diff 对比,对 fiber 节点标记不同的副作用,对应渲染过程中真实 dom 的增删改。(这里不了解 fiber 和 diff 的话没关系,后面会讲到,主要是对 react 的流程有个大致概念)
112 |
113 | ### commit
114 |
115 | 在 render 阶段中,最终会生成一个 effectList 数组,记录了页面真实 dom 的新增、删除和替换等以及一些事件响应,commit 会根据 effectList 对真实的页面进行更新,从而实现页面的改变。
116 |
117 | 以上大体就是 react 更新过程中的架构,具体的细节讲解会在后面的章节中详细讲述,欢迎关注本专栏。
118 |
--------------------------------------------------------------------------------
/docs/react/源码/React17源码解析(2) —— jsx 转换及 React.createElement.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '从这一章开始,我们正式开始 react 源码的学习,本章包括react17之前和之后 jsx 编译的不同、React.createElement 源码、React.Component源码。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4'
4 | tag: ['react']
5 | time: '2021-09-30'
6 | ---
7 |
8 | 欢迎大家一起交流学习 react 源码,本系列目录导航请见:[React17 源码解析(开篇) —— 搭建 react 源码调试环境](https://juejin.cn/post/7014775797596553230/)
9 |
10 |
11 |
12 | 从这一章开始,我们正式开始 react 源码的学习,本章包括内容如下:
13 |
14 | > - react17 之前和之后 jsx 编译的不同
15 | > - React.createElement 源码
16 | > - React.Component 源码
17 |
18 | ## jsx 的转换
19 |
20 | 我们从 react 应用的入口开始对源码进行分析,创建一个简单的 hello, world 应用:
21 |
22 | ```js
23 | import React, { Component } from 'react';
24 | import ReactDOM from 'react-dom';
25 | export default class App extends Component {
26 | render() {
27 | return hello, world
;
28 | }
29 | }
30 |
31 | ReactDOM.render(, document.getElementById('root'));
32 | ```
33 |
34 | 我们注意到,我们在 App 组件中直接写了 `return hello, world
` 的 jsx 语句,那么 jsx 语法是如何被浏览器识别执行的呢?
35 |
36 | 另外我在第一次学习 react 的时候,就有一个疑惑: `import React, { Component } from 'react'` 这段代码中,`React` 似乎在代码中没有任何地方被用到,为什么要引入呢?
37 |
38 | ### 16.x 版本及之前
39 |
40 | 我们在 react16.8 版本的代码中,尝试将 `React` 的引用去掉:
41 |
42 | ```js
43 | // import React, { Component } from 'react';
44 | import { Component } from 'react'; // 去掉 React 的引用
45 | import ReactDOM from 'react-dom';
46 |
47 | export default class App extends Component {
48 | render() {
49 | return hello, world
;
50 | }
51 | }
52 |
53 | ReactDOM.render(, document.getElementById('root'));
54 | ```
55 |
56 | 运行应用程序,发现会提示 `'React' must be in scope when using JSX` 的 error:
57 |
58 |
59 |
60 | 这是因为上述的类组件 render 中返回了 `hello, world
` 的 jsx 语法,在 React16 版本及之前,应用程序通过 [@babel/preset-react](https://babeljs.io/docs/en/babel-preset-react/) 将 jsx 语法转换为 `React.createElement` 的 js 代码,因此需要显式将 React 引入,才能正常调用 createElement。我们可以在 [Babel REPL](https://babeljs.io/repl/#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=3.6&spec=false&loose=false&code_lz=MYewdgzgLgBApgGzgWzmWBeGAeAFgRgD4AJRBEAGhgHcQAnBAEwEJsB6AwgbgChRJY_KAEMAlmDh0YWRiGABXVOgB0AczhQAokiVQAQgE8AkowAUPGDADkdECChWeASl4AlOMOBQAIgHkAssp0aIySpogoaFBUQmISdC48QA&debug=false&forceAllTransforms=false&shippedProposals=false&circleciRepo=&evaluate=false&fileSize=false&timeTravel=false&sourceType=module&lineWrap=true&presets=react&prettier=false&targets=&version=7.15.6&externalPlugins=&assumptions=%7B%7D) 中看到 jsx 被 @babel/preset-react 编译后的结果:
61 |
62 |
63 |
64 | ### 17.x 版本及之后
65 |
66 | React17 版本之后,官方与 babel 进行了合作,直接通过将 `react/jsx-runtime` 对 jsx 语法进行了新的转换而不依赖 `React.createElement`,转换的结果便是可直接供 `ReactDOM.render` 使用的 ReactElement 对象。因此如果在 React17 版本后只是用 jsx 语法不使用其他的 react 提供的 api,可以不引入 `React`,应用程序依然能够正常运行。
67 | React17 中 jsx 语法的编译结果如下:
68 |
69 |
70 | 更多有关于 React jsx 转换的内容可以去看官网了解:[介绍全新的 JSX 转换](https://zh-hans.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html),在这里就不再过多展开了。
71 |
72 | ## React.createElement 源码
73 |
74 | 虽然现在 react17 之后我们可以不再依赖 `React.createElement` 这个 api 了,但是实际场景中以及很多开源包中可能会有很多通过 `React.createElement` 手动创建元素的场景,所以还是推荐学习一下[React.createElement 源码](https://github.com/facebook/react/blob/17.0.2/packages/react/src/ReactElement.js)。
75 |
76 | `React.createElement` 其接收三个或以上参数:
77 |
78 | - type:要创建的 React 元素类型,可以是标签名称字符串,如 `'div'` 或者 `'span'` 等;也可以是 React 组件 类型(class 组件或者函数组件);或者是 React fragment 类型。
79 | - config:写在标签上的属性的集合,js 对象格式,若标签上未添加任何属性则为 null。
80 | - children:从第三个参数开始后的参数为当前创建的 React 元素的子节点,每个参数的类型,若是当前元素节点的 textContent 则为字符串类型;否则为新的 React.createElement 创建的元素。
81 |
82 | 函数中会对参数进行一系列的解析,源码如下,对源码相关的理解都用注释进行了标记:
83 |
84 | ```js
85 | export function createElement(type, config, children) {
86 | let propName;
87 |
88 | // 记录标签上的属性集合
89 | const props = {};
90 |
91 | let key = null;
92 | let ref = null;
93 | let self = null;
94 | let source = null;
95 |
96 | // config 不为 null 时,说明标签上有属性,将属性添加到 props 中
97 | // 其中,key 和 ref 为 react 提供的特殊属性,不加入到 props 中,而是用 key 和 ref 单独记录
98 | if (config != null) {
99 | if (hasValidRef(config)) {
100 | // 有合法的 ref 时,则给 ref 赋值
101 | ref = config.ref;
102 |
103 | if (__DEV__) {
104 | warnIfStringRefCannotBeAutoConverted(config);
105 | }
106 | }
107 | if (hasValidKey(config)) {
108 | // 有合法的 key 时,则给 key 赋值
109 | key = '' + config.key;
110 | }
111 |
112 | // self 和 source 是开发环境下对代码在编译器中位置等信息进行记录,用于开发环境下调试
113 | self = config.__self === undefined ? null : config.__self;
114 | source = config.__source === undefined ? null : config.__source;
115 | // 将 config 中除 key、ref、__self、__source 之外的属性添加到 props 中
116 | for (propName in config) {
117 | if (
118 | hasOwnProperty.call(config, propName) &&
119 | !RESERVED_PROPS.hasOwnProperty(propName)
120 | ) {
121 | props[propName] = config[propName];
122 | }
123 | }
124 | }
125 |
126 | // 将子节点添加到 props 的 children 属性上
127 | const childrenLength = arguments.length - 2;
128 | if (childrenLength === 1) {
129 | // 共 3 个参数时表示只有一个子节点,直接将子节点赋值给 props 的 children 属性
130 | props.children = children;
131 | } else if (childrenLength > 1) {
132 | // 3 个以上参数时表示有多个子节点,将子节点 push 到一个数组中然后将数组赋值给 props 的 children
133 | const childArray = Array(childrenLength);
134 | for (let i = 0; i < childrenLength; i++) {
135 | childArray[i] = arguments[i + 2];
136 | }
137 | // 开发环境下冻结 childArray,防止被随意修改
138 | if (__DEV__) {
139 | if (Object.freeze) {
140 | Object.freeze(childArray);
141 | }
142 | }
143 | props.children = childArray;
144 | }
145 |
146 | // 如果有 defaultProps,对其遍历并且将用户在标签上未对其手动设置属性添加进 props 中
147 | // 此处针对 class 组件类型
148 | if (type && type.defaultProps) {
149 | const defaultProps = type.defaultProps;
150 | for (propName in defaultProps) {
151 | if (props[propName] === undefined) {
152 | props[propName] = defaultProps[propName];
153 | }
154 | }
155 | }
156 |
157 | // key 和 ref 不挂载到 props 上
158 | // 开发环境下若想通过 props.key 或者 props.ref 获取则 warning
159 | if (__DEV__) {
160 | if (key || ref) {
161 | const displayName =
162 | typeof type === 'function'
163 | ? type.displayName || type.name || 'Unknown'
164 | : type;
165 | if (key) {
166 | defineKeyPropWarningGetter(props, displayName);
167 | }
168 | if (ref) {
169 | defineRefPropWarningGetter(props, displayName);
170 | }
171 | }
172 | }
173 |
174 | // 调用 ReactElement 并返回
175 | return ReactElement(
176 | type,
177 | key,
178 | ref,
179 | self,
180 | source,
181 | ReactCurrentOwner.current,
182 | props
183 | );
184 | }
185 | ```
186 |
187 | 由代码可知,`React.createElement` 做的事情主要有:
188 |
189 | - 解析 config 参数中是否有合法的 key、ref、**source 和 **self 属性,若存在分别赋值给 key、ref、source 和 self;将剩余的属性解析挂载到 props 上
190 | - 除 type 和 config 外后面的参数,挂载到 `props.children` 上
191 | - 针对类组件,如果 type.defaultProps 存在,遍历 type.defaultProps 的属性,如果 props 不存在该属性,则添加到 props 上
192 | - 将 type、key、ref、self、props 等信息,调用 `ReactElement` 函数创建虚拟 dom,`ReactElement` 主要是在开发环境下通过 `Object.defineProperty` 将 \_store、\_self、\_source 设置为不可枚举,提高 element 比较时的性能:
193 |
194 | ```js
195 | const ReactElement = function (type, key, ref, self, source, owner, props) {
196 | const element = {
197 | // 用于表示是否为 ReactElement
198 | $$typeof: REACT_ELEMENT_TYPE,
199 |
200 | // 用于创建真实 dom 的相关信息
201 | type: type,
202 | key: key,
203 | ref: ref,
204 | props: props,
205 |
206 | _owner: owner,
207 | };
208 |
209 | if (__DEV__) {
210 | element._store = {};
211 |
212 | // 开发环境下将 _store、_self、_source 设置为不可枚举,提高 element 的比较性能
213 | Object.defineProperty(element._store, 'validated', {
214 | configurable: false,
215 | enumerable: false,
216 | writable: true,
217 | value: false,
218 | });
219 |
220 | Object.defineProperty(element, '_self', {
221 | configurable: false,
222 | enumerable: false,
223 | writable: false,
224 | value: self,
225 | });
226 |
227 | Object.defineProperty(element, '_source', {
228 | configurable: false,
229 | enumerable: false,
230 | writable: false,
231 | value: source,
232 | });
233 | // 冻结 element 和 props,防止被手动修改
234 | if (Object.freeze) {
235 | Object.freeze(element.props);
236 | Object.freeze(element);
237 | }
238 | }
239 |
240 | return element;
241 | };
242 | ```
243 |
244 | 所以通过流程图总结一下 createElement 所做的事情如下:
245 |
246 |
247 | ## React.Component 源码
248 |
249 | 我们回到上述 hello,world 应用程序代码中,创建类组件时,我们继承了从 react 库中引入的 `Component`,我们再看一下[React.Component 源码](https://github.com/facebook/react/blob/17.0.2/packages/react/src/ReactBaseClasses.js):
250 |
251 | ```js
252 | function Component(props, context, updater) {
253 | // 接收 props,context,updater 进行初始化,挂载到 this 上
254 | this.props = props;
255 | this.context = context;
256 | this.refs = emptyObject;
257 | // updater 上挂载了 isMounted、enqueueForceUpdate、enqueueSetState 等触发器方法
258 | this.updater = updater || ReactNoopUpdateQueue;
259 | }
260 |
261 | // 原型链上挂载 isReactComponent,在 ReactDOM.render 时用于和函数组件做区分
262 | Component.prototype.isReactComponent = {};
263 |
264 | // 给类组件添加 `this.setState` 方法
265 | Component.prototype.setState = function (partialState, callback) {
266 | // 验证参数是否合法
267 | invariant(
268 | typeof partialState === 'object' ||
269 | typeof partialState === 'function' ||
270 | partialState == null
271 | );
272 | // 添加至 enqueueSetState 队列
273 | this.updater.enqueueSetState(this, partialState, callback, 'setState');
274 | };
275 |
276 | // 给类组件添加 `this.forceUpdate` 方法
277 | Component.prototype.forceUpdate = function (callback) {
278 | // 添加至 enqueueForceUpdate 队列
279 | this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
280 | };
281 | ```
282 |
283 | 从源码上可以得知,`React.Component` 主要做了以下几件事情:
284 |
285 | - 将 props, context, updater 挂载到 this 上
286 | - 在 Component 原型链上添加 isReactComponent 对象,用于标记类组件
287 | - 在 Component 原型链上添加 `setState` 方法
288 | - 在 Component 原型链上添加 `forceUpdate` 方法
289 | 这样我们就理解了 react 类组件的 `super()` 作用,以及 `this.setState` 和 `this.forceUpdate` 的由来
290 |
291 | ## 总结
292 |
293 | 本章讲述了 jsx 在 react17 之前和之后的不同的转换,实际上 react17 之后 babel 的对 jsx 的转换就是比之前多了一步 `React.createElement` 的动作:
294 |
295 |
296 | 另外讲述了 `React.createElement` 和 `React.Component` 的内部实现是怎样的。通过 babel 及 `React.createElement`,将 jsx 转换为了浏览器能够识别的原生 js 语法,为 react 后续对状态改变、事件响应以及页面更新等奠定了基础。后面的章节中,将探究 react 是如何一步步将状态等信息渲染为真实页面的。
297 |
--------------------------------------------------------------------------------
/docs/react/源码/React17源码解析(3) —— 深入理解 fiber.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本章将介绍以下内容: 为什么需要 fiber、fiber 节点结构中的属性、fiber 树是如何构建与更新的。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4'
4 | tag: ['react']
5 | time: '2021-10-02'
6 | ---
7 |
8 | react16 版本之后引入了 fiber,整个架构层面的 调度、协调、diff 算法以及渲染等都与 fiber 密切相关。所以为了更好地讲解后面的内容,需要对 fiber 有个比较清晰的认知。本章将介绍以下内容:
9 |
10 | > - 为什么需要 fiber
11 | > - fiber 节点结构中的属性
12 | > - fiber 树是如何构建与更新的
13 |
14 | ## 为什么需要 fiber
15 |
16 | Lin Clark 在 [React Conf 2017](http://conf2017.reactjs.org/speakers/lin) 的演讲中,他通过漫画的形式,很好地讲述了 fiber 为何出现,下面我根据她的演讲,结合我自己的理解来谈一谈 fiber 出现的原因。
17 |
18 | ### fiber 之前
19 |
20 | 在 react15 及之前 fiber 未出现时,react 的一系列执行过程例如生命周期执行、虚拟 dom 的比较、dom 树的更新等都是同步的,一旦开始执行就不会中断,直到所有的工作流程全部结束为止。
21 |
22 | 要知道,react 所有的状态更新,都是从根组件开始的,当应用组件树比较庞大时,一旦状态开始变更,组件树层层递归开始更新,js 主线程就不得不停止其他工作。例如组件树一共有 1000 个组件需要更新,每个组件更新所需要的时间为 1s,那么在这 1s 内浏览器都无法做其他的事情,用户的点击输入等交互事件、页面动画等都不会得到响应,体验就会非常的差。
23 |
24 | 这种情况下,函数堆栈的调用就像下图一样,层级很深,很长时间不会返回:
25 |
26 |
27 | ### fiber 之后
28 |
29 | 为了解决这一问题,react 引入了 fiber 这种数据结构,将更新渲染耗时长的大任务,分为许多的小片。每个小片的任务执行完成后,都先去执行其他高优先级的任务(例如用户点击输入事件、动画等),这样 js 的主线程就不会被 react 独占,虽然任务执行的总时间不变,但是页面能够及时响应高优先级任务,显得不会卡顿了。
30 |
31 | fiber 分片模式下,浏览器主线程能够定期被释放,保证了渲染的帧率,函数的堆栈调用如下(波谷表示执行分片任务,波峰表示执行其他高优先级任务):
32 |
33 | react 通过 fiber,为我们提供了一种跟踪、调度、暂停和中止工作的便捷方式,保证了页面的性能和流畅度。
34 |
35 | ## fiber 节点结构
36 |
37 | fiber 是一种数据结构,每个 fiber 节点的内部,都保存了 dom 相关信息、fiber 树相关的引用、要更新时的副作用等,我们可以看一下[源码](https://github.com/facebook/react/blob/17.0.2/packages/react-reconciler/src/ReactInternalTypes.js)中的 fiber 结构:
38 |
39 | ```ts
40 | // packages/react-reconciler/src/ReactInternalTypes.js
41 |
42 | export type Fiber = {|
43 | // 作为静态数据结构,存储节点 dom 相关信息
44 | tag: WorkTag, // 组件的类型,取决于 react 的元素类型
45 | key: null | string,
46 | elementType: any, // 元素类型
47 | type: any, // 定义与此fiber关联的功能或类。对于组件,它指向构造函数;对于DOM元素,它指定HTML tag
48 | stateNode: any, // 真实 dom 节点
49 |
50 | // fiber 链表树相关
51 | return: Fiber | null, // 父 fiber
52 | child: Fiber | null, // 第一个子 fiber
53 | sibling: Fiber | null, // 下一个兄弟 fiber
54 | index: number, // 在父 fiber 下面的子 fiber 中的下标
55 |
56 | ref:
57 | | null
58 | | (((handle: mixed) => void) & {_stringRef: ?string, ...})
59 | | RefObject,
60 |
61 | // 工作单元,用于计算 state 和 props 渲染
62 | pendingProps: any, // 本次渲染需要使用的 props
63 | memoizedProps: any, // 上次渲染使用的 props
64 | updateQueue: mixed, // 用于状态更新、回调函数、DOM更新的队列
65 | memoizedState: any, // 上次渲染后的 state 状态
66 | dependencies: Dependencies | null, // contexts、events 等依赖
67 |
68 | mode: TypeOfMode,
69 |
70 | // 副作用相关
71 | flags: Flags, // 记录更新时当前 fiber 的副作用(删除、更新、替换等)状态
72 | subtreeFlags: Flags, // 当前子树的副作用状态
73 | deletions: Array | null, // 要删除的子 fiber
74 | nextEffect: Fiber | null, // 下一个有副作用的 fiber
75 | firstEffect: Fiber | null, // 指向第一个有副作用的 fiber
76 | lastEffect: Fiber | null, // 指向最后一个有副作用的 fiber
77 |
78 | // 优先级相关
79 | lanes: Lanes,
80 | childLanes: Lanes,
81 |
82 | alternate: Fiber | null, // 指向 workInProgress fiber 树中对应的节点
83 |
84 | actualDuration?: number,
85 | actualStartTime?: number,
86 | selfBaseDuration?: number,
87 | treeBaseDuration?: number,
88 | _debugID?: number,
89 | _debugSource?: Source | null,
90 | _debugOwner?: Fiber | null,
91 | _debugIsCurrentlyTiming?: boolean,
92 | _debugNeedsRemount?: boolean,
93 | _debugHookTypes?: Array | null,
94 | |};
95 | ```
96 |
97 | ### dom 相关属性
98 |
99 | fiber 中和 dom 节点相关的信息主要关注 `tag`、`key`、`type` 和 `stateNode`。
100 |
101 | #### tag
102 |
103 | fiber 中 `tag` 属性的 ts 类型为 workType,用于标记不同的 react 组件类型,我们可以看一下[源码](https://github.com/facebook/react/blob/17.0.2/packages/react-reconciler/src/ReactWorkTags.js)中 workType 的枚举值:
104 |
105 | ```ts
106 | // packages/react-reconciler/src/ReactWorkTags.js
107 |
108 | export const FunctionComponent = 0;
109 | export const ClassComponent = 1;
110 | export const IndeterminateComponent = 2; // Before we know whether it is function or class
111 | export const HostRoot = 3; // Root of a host tree. Could be nested inside another node.
112 | export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
113 | export const HostComponent = 5;
114 | export const HostText = 6;
115 | export const Fragment = 7;
116 | export const Mode = 8;
117 | export const ContextConsumer = 9;
118 | export const ContextProvider = 10;
119 | export const ForwardRef = 11;
120 | export const Profiler = 12;
121 | export const SuspenseComponent = 13;
122 | export const MemoComponent = 14;
123 | export const SimpleMemoComponent = 15;
124 | export const LazyComponent = 16;
125 | export const IncompleteClassComponent = 17;
126 | export const DehydratedFragment = 18;
127 | export const SuspenseListComponent = 19;
128 | export const FundamentalComponent = 20;
129 | export const ScopeComponent = 21;
130 | export const Block = 22;
131 | export const OffscreenComponent = 23;
132 | export const LegacyHiddenComponent = 24;
133 | ```
134 |
135 | 在 react 协调时,beginWork 和 completeWork 等流程时,都会根据 `tag` 类型的不同,去执行不同的函数处理 fiber 节点。
136 |
137 | #### key 和 type
138 |
139 | `key` 和 `type` 两项用于 react diff 过程中确定 fiber 是否可以复用。
140 |
141 | `key` 为用户定义的唯一值。`type` 定义与此 fiber 关联的功能或类。对于组件,它指向函数或者类本身;对于 DOM 元素,它指定 HTML tag。
142 |
143 | #### stateNode
144 |
145 | `stateNode` 用于记录当前 fiber 所对应的真实 dom 节点或者当前虚拟组件的实例,这么做的原因第一是为了实现 `Ref` ,第二是为了实现真实 dom 的跟踪。
146 |
147 | ### 链表树相关属性
148 |
149 | 我们看一下和 fiber 链表树构建相关的 `return`、`child` 和 `sibling` 几个字段:
150 |
151 | - return:指向父 fiber,若没有父 fiber 则为 null
152 | - child: 指向第一个子 fiber,若没有任何子 fiber 则为 null
153 | - sibling:指向下一个兄弟 fiber,若没有下一个兄弟 fiber 则为 null
154 | 通过这几个字段,各个 fiber 节点构成了 fiber 链表树结构:
155 |
156 |
157 | ### 副作用相关属性
158 |
159 | 首先理解一下 react 中的副作用,举一个生活中比较通俗的例子:我们感冒了本来吃点药就没事了,但是吃了药发现身体过敏了,而这个“过敏”就是副作用。react 中,我们修改了 state、props、ref 等数据,除了数据改变之外,还会引起 dom 的变化,这种 render 阶段不能完成的工作,我们称之为副作用。
160 |
161 | #### flags
162 |
163 | react 中通过 flags 记录每个节点 diff 后需要变更的状态,例如 dom 的添加、替换、删除等等。我们可以看一下[源码](https://github.com/facebook/react/blob/17.0.2/packages/react-reconciler/src/ReactFiberFlags.js)中 Flags 枚举类型:
164 |
165 | 例如 `Deletion` 代表更新时要对 dom 进行删除,`Placement` 代表要进行添加或者替换等等。
166 |
167 | ```ts
168 | // packages/react-reconciler/src/ReactFiberFlags.js
169 |
170 | export type Flags = number;
171 |
172 | export const NoFlags = /* */ 0b000000000000000000;
173 | export const PerformedWork = /* */ 0b000000000000000001;
174 | export const Placement = /* */ 0b000000000000000010;
175 | export const Update = /* */ 0b000000000000000100;
176 | export const PlacementAndUpdate = /* */ 0b000000000000000110;
177 | export const Deletion = /* */ 0b000000000000001000;
178 | export const ContentReset = /* */ 0b000000000000010000;
179 | export const Callback = /* */ 0b000000000000100000;
180 | export const DidCapture = /* */ 0b000000000001000000;
181 | export const Ref = /* */ 0b000000000010000000;
182 | export const Snapshot = /* */ 0b000000000100000000;
183 | export const Passive = /* */ 0b000000001000000000;
184 | export const PassiveUnmountPendingDev = /* */ 0b000010000000000000;
185 | export const Hydrating = /* */ 0b000000010000000000;
186 | export const HydratingAndUpdate = /* */ 0b000000010000000100;
187 | export const LifecycleEffectMask = /* */ 0b000000001110100100;
188 | export const HostEffectMask = /* */ 0b000000011111111111;
189 | export const Incomplete = /* */ 0b000000100000000000;
190 | export const ShouldCapture = /* */ 0b000001000000000000;
191 | export const ForceUpdateForLegacySuspense = /* */ 0b000100000000000000;
192 | export const PassiveStatic = /* */ 0b001000000000000000;
193 | export const BeforeMutationMask = /* */ 0b000000001100001010;
194 | export const MutationMask = /* */ 0b000000010010011110;
195 | export const LayoutMask = /* */ 0b000000000010100100;
196 | export const PassiveMask = /* */ 0b000000001000001000;
197 | export const StaticMask = /* */ 0b001000000000000000;
198 | export const MountLayoutDev = /* */ 0b010000000000000000;
199 | export const MountPassiveDev = /* */ 0b100000000000000000;
200 | ```
201 |
202 | #### Effect List
203 |
204 | 在 render 阶段时,react 会采用深度优先遍历,对 fiber 树进行遍历,把每一个有副作用的 fiber 筛选出来,最后构建生成一个只带副作用的 Effect list 链表。和该链表相关的字段有 `firstEffect`、`nextEffect` 和 `lastEffect`:
205 |
206 |
207 |
208 | `firstEffect` 指向第一个有副作用的 fiber 节点,`lastEffect` 指向最后一个有副作用的节点,中间的节点全部通过 `nextEffect` 链接,最终形成 Effect 链表。
209 |
210 | 在 commit 阶段,React 拿到 Effect list 链表中的数据后,根据每一个 fiber 节点的 flags 类型,对相应的 DOM 进行更改。
211 |
212 | ### 其他
213 |
214 | 其他需要重点关注一下的属性还有 `lane` 和 `alternate`。
215 |
216 | #### lane
217 |
218 | `lane` 代表 react 要执行的 fiber 任务的优先级,通过这个字段,render 阶段 react 确定应该优先将哪些任务提交到 commit 阶段去执行。
219 |
220 | 我们看一下[源码](https://github.com/facebook/react/blob/17.0.2/packages/react-reconciler/src/ReactFiberLane.js)中 `lane` 的枚举值:
221 |
222 | ```ts
223 | // packages/react-reconciler/src/ReactFiberLane.js
224 |
225 | InputDiscreteHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
226 | const InputDiscreteLanes: Lanes = /* */ 0b0000000000000000000000000011000;
227 | const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000100000;
228 | const InputContinuousLanes: Lanes = /* */ 0b0000000000000000000000011000000;
229 | export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000100000000;
230 | export const DefaultLanes: Lanes = /* */ 0b0000000000000000000111000000000;
231 | const TransitionHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
232 | const TransitionLanes: Lanes = /* */ 0b0000000001111111110000000000000;
233 | const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
234 | export const SomeRetryLane: Lanes = /* */ 0b0000010000000000000000000000000;
235 | export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000;
236 | const NonIdleLanes = /* */ 0b0000111111111111111111111111111;
237 | export const IdleHydrationLane: Lane = /* */ 0b0001000000000000000000000000000;
238 | const IdleLanes: Lanes = /* */ 0b0110000000000000000000000000000;
239 | export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000;
240 | ```
241 |
242 | 同 Flags 的枚举值一样,Lanes 也是用 31 位的二进制数表示,表示了 31 条赛道,位数越小的赛道,代表的优先级越高。
243 |
244 | 例如 `InputDiscreteHydrationLane`、`InputDiscreteLanes`、`InputContinuousHydrationLane` 等用户交互引起的更新的优先级较高,`DefaultLanes` 这种请求数据引起更新的优先级中等,而 `OffscreenLane`、`IdleLanes` 这种优先级较低。
245 |
246 | 优先级越低的任务,在 render 阶段越容易被打断,commit 执行的时机越靠后。
247 |
248 | #### alternate
249 |
250 | 当 react 的状态发生更新时,当前页面所对应的 fiber 树称为 current Fiber,同时 react 会根据新的状态构建一颗新的 fiber 树,称为 workInProgress Fiber。current Fiber 中每个 fiber 节点通过 `alternate` 字段,指向 workInProgress Fiber 中对应的 fiber 节点。同样 workInProgress Fiber 中的 fiber
251 | 节点的 `alternate` 字段也会指向 current Fiber 中对应的 fiber 节点。
252 |
253 | ## fiber 树的构建与更新
254 |
255 | 下面我们结合源码,来看一下实际工作过程中 fiber 树的构建与更新过程。
256 |
257 | ### mount 过程
258 |
259 | react 首次 mount 开始执行时,以 `ReactDOM.render` 为入口函数,会经过如下一系列的函数调用:`ReactDOM.render` ——> `legacyRenderSubtreeIntoContainer` ——> `legacyCreateRootFromDOMContainer` ——> `createLegacyRoot` ——> `ReactDOMBlockingRoot` ——> `ReactDOMRoot` ——> `createRootImpl` ——> `createContainer` ——> `createFiberRoot` ——> `createHostRootFiber` ——> `createFiber`
260 |
261 | 在 `createFiber` 函数中,调用 `FiberNode` 构造函数,创建了 rootFiber,它是 react 应用的根 fiber:
262 |
263 | ```ts
264 | // packages/react-reconciler/src/ReactFiber.old.js
265 |
266 | const createFiber = function (
267 | tag: WorkTag,
268 | pendingProps: mixed,
269 | key: null | string,
270 | mode: TypeOfMode
271 | ): Fiber {
272 | return new FiberNode(tag, pendingProps, key, mode);
273 | };
274 | ```
275 |
276 | 在 `createFiberRoot` 函数中,调用 `FiberRootNode` 构造函数,创建了 fiberRoot,它指向真实根 dom 节点。
277 |
278 | ```ts
279 | // packages/react-reconciler/src/ReactFiberRoot.old.js
280 |
281 | export function createFiberRoot(
282 | containerInfo: any,
283 | tag: RootTag,
284 | hydrate: boolean,
285 | hydrationCallbacks: null | SuspenseHydrationCallbacks,
286 | ): FiberRoot {
287 | const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
288 | if (enableSuspenseCallback) {
289 | root.hydrationCallbacks = hydrationCallbacks;
290 | }
291 |
292 | const uninitializedFiber = createHostRootFiber(tag);
293 | root.current = uninitializedFiber;
294 | uninitializedFiber.stateNode = root;
295 |
296 | initializeUpdateQueue(uninitializedFiber);
297 |
298 | return root;
299 | }
300 | ```
301 |
302 | 另外 `createFiberRoot` 函数中,还让 rootFiber 的 `stateNode` 字段指向了 fiberRoot,fiberRoot 的 `current` 字段指向了 rootFiber。从而一颗最原始的 fiber 树根节点就创建完成了:
303 |
304 |
305 |
306 | 上面的 rootFiber 和 fiberRoot 创建完成后,react 就会根据 jsx 的内容去创建详细的 dom 树了,例如有如下的 jsx:
307 |
308 | ```html
309 |
321 | ```
322 |
323 | react 对于 fiber 结构的创建和更新,都是采用深度优先遍历,从 rootFiber(此处对应 id 为 root 的节点)开始,首先创建 child a1,然后发现 a1 有子节点 b1,继续对 b1 进行遍历,b1 有子节点 c1,再去创建 c1 的子节点 d1、d2、d3,直至发现 d1、d2、d3 都没有子节点来了,再回去创建 c2.
324 |
325 | 上面的过程,每个节点开始创建时,执行 `beginWork` 流程,直至该节点的所有子孙节点都创建(更新)完成后,执行 `completeWork` 流程,过程的图示如下:
326 |
327 |
328 | ### update 过程
329 |
330 | update 时,react 会根据新的 jsx 内容创建新的 workInProgress fiber,还是通过深度优先遍历,对发生改变的 fiber 打上不同的 `flags` 副作用标签,并通过 `firstEffect`、`nextEffect` 等字段形成 Effect List 链表。
331 |
332 | 例如上面的 jsx 结构,发生了如下的更新:
333 |
334 | ```diff
335 |
336 |
337 |
338 |
339 |
340 | -
341 | -
342 |
343 | -
344 | +
new content
345 |
346 |
347 |
348 | ```
349 |
350 | react 会根据新的 jsx 解析后的内容,调用 `createWorkInProgress` 函数创建 workInProgress fiber,对其标记副作用:
351 |
352 | ```js
353 | // packages/react-reconciler/src/ReactFiber.old.js
354 |
355 | export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
356 | let workInProgress = current.alternate;
357 | if (workInProgress === null) {
358 | // 区分 mount 还是 update
359 | workInProgress = createFiber(
360 | current.tag,
361 | pendingProps,
362 | current.key,
363 | current.mode
364 | );
365 | workInProgress.elementType = current.elementType;
366 | workInProgress.type = current.type;
367 | workInProgress.stateNode = current.stateNode;
368 |
369 | if (__DEV__) {
370 | workInProgress._debugID = current._debugID;
371 | workInProgress._debugSource = current._debugSource;
372 | workInProgress._debugOwner = current._debugOwner;
373 | workInProgress._debugHookTypes = current._debugHookTypes;
374 | }
375 |
376 | workInProgress.alternate = current;
377 | current.alternate = workInProgress;
378 | } else {
379 | workInProgress.pendingProps = pendingProps;
380 | workInProgress.type = current.type;
381 |
382 | workInProgress.subtreeFlags = NoFlags;
383 | workInProgress.deletions = null;
384 |
385 | if (enableProfilerTimer) {
386 | workInProgress.actualDuration = 0;
387 | workInProgress.actualStartTime = -1;
388 | }
389 | }
390 |
391 | // 重置所有的副作用
392 | workInProgress.flags = current.flags & StaticMask;
393 | workInProgress.childLanes = current.childLanes;
394 | workInProgress.lanes = current.lanes;
395 |
396 | workInProgress.child = current.child;
397 | workInProgress.memoizedProps = current.memoizedProps;
398 | workInProgress.memoizedState = current.memoizedState;
399 | workInProgress.updateQueue = current.updateQueue;
400 |
401 | // 克隆依赖
402 | const currentDependencies = current.dependencies;
403 | workInProgress.dependencies =
404 | currentDependencies === null
405 | ? null
406 | : {
407 | lanes: currentDependencies.lanes,
408 | firstContext: currentDependencies.firstContext,
409 | };
410 |
411 | workInProgress.sibling = current.sibling;
412 | workInProgress.index = current.index;
413 | workInProgress.ref = current.ref;
414 |
415 | if (enableProfilerTimer) {
416 | workInProgress.selfBaseDuration = current.selfBaseDuration;
417 | workInProgress.treeBaseDuration = current.treeBaseDuration;
418 | }
419 |
420 | if (__DEV__) {
421 | workInProgress._debugNeedsRemount = current._debugNeedsRemount;
422 | switch (workInProgress.tag) {
423 | case IndeterminateComponent:
424 | case FunctionComponent:
425 | case SimpleMemoComponent:
426 | workInProgress.type = resolveFunctionForHotReloading(current.type);
427 | break;
428 | case ClassComponent:
429 | workInProgress.type = resolveClassForHotReloading(current.type);
430 | break;
431 | case ForwardRef:
432 | workInProgress.type = resolveForwardRefForHotReloading(current.type);
433 | break;
434 | default:
435 | break;
436 | }
437 | }
438 |
439 | return workInProgress;
440 | }
441 | ```
442 |
443 | 最终生成的 workInProgress fiber 图示如下:
444 |
445 |
446 | 然后如上面所说,current fiber 和 workInProgress fiber 中对应的 alternate 会相互指向,然后 workInProgress fiber 完全创建完成后,fiberRoot 的 `current` 字段的指向会从 current fiber 中的 rootFiber 改为 workInProgress fiber 中的 rootFiber:
447 |
448 |
449 |
450 | ## 总结
451 |
452 | 本章讲解了 fiber 出现的主要原因、fiber 节点中主要的属性以及 fiber 树是如何构建与更新的。
453 |
454 | 理解了 fiber 之后,我们后面的章节就会对[React17 源码解析(1) —— 源码目录及 react 架构](https://juejin.cn/post/7015853155367780383)中的 react 更新过程展开更加详细的讲解,例如 render 过程是如何对任务优先级划分的、如何中断和恢复任务的、diff 过程是如何执行的,commit 阶段是如何渲染页面的等等。欢迎关注本专栏以便及时查看后面更新的章节。
455 |
--------------------------------------------------------------------------------
/docs/react/源码/React17源码解析(4) —— 详解 render 阶段(scheduler 和 reconciler).md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本章将学习 react render 阶段源码,包括:更新任务的触发和创建、reconciler 过程同步和异步遍历及执行任务、scheduler 是如何实现帧空闲时间调度任务以及中断任务的。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4'
4 | tag: ['react']
5 | time: '2021-10-09'
6 | ---
7 |
8 | 本章将讲解 react 的核心阶段之一 —— render 阶段,我们将探究以下部分内容的源码:
9 |
10 | > - 更新任务的触发
11 | > - 更新任务的创建
12 | > - reconciler 过程同步和异步遍历及执行任务
13 | > - scheduler 是如何实现帧空闲时间调度任务以及中断任务的
14 |
15 | ## 触发更新
16 |
17 | 触发更新的方式主要有以下几种:`ReactDOM.render`、`setState`、`forUpdate` 以及 hooks 中的 `useState` 等,关于 hooks 的我们后面再详细讲解,这里先关注前三种情况。
18 |
19 | ### ReactDOM.render
20 |
21 | `ReactDOM.render` 作为 react 应用程序的入口函数,在页面首次渲染时便会触发,页面 dom 的首次创建,也属于触发 react 更新的一种情况。其整体流程如下:
22 |
23 |
24 |
25 | 首先调用 `legacyRenderSubtreeIntoContainer` 函数,校验根节点 root 是否存在,若不存在,调用 `legacyCreateRootFromDOMContainer` 创建根节点 root、rootFiber 和 fiberRoot 并绑定它们之间的引用关系,然后调用 `updateContainer` 去非批量执行后面的更新流程;若存在,直接调用 `updateContainer` 去批量执行后面的更新流程:
26 |
27 | ```ts
28 | // packages/react-dom/src/client/ReactDOMLegacy.js
29 |
30 | function legacyRenderSubtreeIntoContainer(
31 | parentComponent: ?React$Component,
32 | children: ReactNodeList,
33 | container: Container,
34 | forceHydrate: boolean,
35 | callback: ?Function,
36 | ) {
37 | // ...
38 | let root: RootType = (container._reactRootContainer: any);
39 | let fiberRoot;
40 | if (!root) {
41 | // 首次渲染时根节点不存在
42 | // 通过 legacyCreateRootFromDOMContainer 创建根节点、fiberRoot 和 rootFiber
43 | root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
44 | container,
45 | forceHydrate,
46 | );
47 | fiberRoot = root._internalRoot;
48 | if (typeof callback === 'function') {
49 | const originalCallback = callback;
50 | callback = function() {
51 | const instance = getPublicRootInstance(fiberRoot);
52 | originalCallback.call(instance);
53 | };
54 | }
55 | // 非批量执行更新流程
56 | unbatchedUpdates(() => {
57 | updateContainer(children, fiberRoot, parentComponent, callback);
58 | });
59 | } else {
60 | fiberRoot = root._internalRoot;
61 | if (typeof callback === 'function') {
62 | const originalCallback = callback;
63 | callback = function() {
64 | const instance = getPublicRootInstance(fiberRoot);
65 | originalCallback.call(instance);
66 | };
67 | }
68 | // 批量执行更新流程
69 | updateContainer(children, fiberRoot, parentComponent, callback);
70 | }
71 | return getPublicRootInstance(fiberRoot);
72 | }
73 | ```
74 |
75 | `updateContainer` 函数中,主要做了以下几件事情:
76 |
77 | - requestEventTime:获取更新触发的时间
78 | - requestUpdateLane:获取当前任务优先级
79 | - createUpdate:创建更新
80 | - enqueueUpdate:将任务推进更新队列
81 | - scheduleUpdateOnFiber:调度更新
82 | 关于这几个函数稍后会详细讲到
83 |
84 | ```ts
85 | // packages/react-dom/src/client/ReactDOMLegacy.js
86 |
87 | export function updateContainer(
88 | element: ReactNodeList,
89 | container: OpaqueRoot,
90 | parentComponent: ?React$Component,
91 | callback: ?Function
92 | ): Lane {
93 | // ...
94 | const current = container.current;
95 | const eventTime = requestEventTime(); // 获取更新触发的时间
96 | // ...
97 | const lane = requestUpdateLane(current); // 获取任务优先级
98 |
99 | if (enableSchedulingProfiler) {
100 | markRenderScheduled(lane);
101 | }
102 |
103 | const context = getContextForSubtree(parentComponent);
104 | if (container.context === null) {
105 | container.context = context;
106 | } else {
107 | container.pendingContext = context;
108 | }
109 |
110 | // ...
111 |
112 | const update = createUpdate(eventTime, lane); // 创建更新任务
113 | update.payload = { element };
114 |
115 | callback = callback === undefined ? null : callback;
116 | if (callback !== null) {
117 | // ...
118 | update.callback = callback;
119 | }
120 |
121 | enqueueUpdate(current, update); // 将任务推入更新队列
122 | scheduleUpdateOnFiber(current, lane, eventTime); // schedule 进行调度
123 |
124 | return lane;
125 | }
126 | ```
127 |
128 | ### setState
129 |
130 | setState 时类组件中我们最常用的修改状态的方法,状态修改会触发更新流程,其执行过程如下:
131 |
132 |
133 |
134 | class 组件在原型链上定义了 `setState` 方法,其调用了触发器 `updater` 上的 `enqueueSetState` 方法:
135 |
136 | ```ts
137 | // packages/react/src/ReactBaseClasses.js
138 |
139 | Component.prototype.setState = function (partialState, callback) {
140 | invariant(
141 | typeof partialState === 'object' ||
142 | typeof partialState === 'function' ||
143 | partialState == null,
144 | 'setState(...): takes an object of state variables to update or a ' +
145 | 'function which returns an object of state variables.'
146 | );
147 | this.updater.enqueueSetState(this, partialState, callback, 'setState');
148 | };
149 | ```
150 |
151 | 然后我们再来看以下 updater 上定义的 `enqueueSetState` 方法,一看到这我们就了然了,和 `updateContainer` 方法中做的事情几乎一模一样,都是触发后续的更新调度。
152 |
153 | ```ts
154 | // packages/react-reconciler/src/ReactFiberClassComponent.old.js
155 |
156 | const classComponentUpdater = {
157 | isMounted,
158 | enqueueSetState(inst, payload, callback) {
159 | const fiber = getInstance(inst);
160 | const eventTime = requestEventTime(); // 获取更新触发的时间
161 | const lane = requestUpdateLane(fiber); // 获取任务优先级
162 |
163 | const update = createUpdate(eventTime, lane); // 创建更新任务
164 | update.payload = payload;
165 | if (callback !== undefined && callback !== null) {
166 | if (__DEV__) {
167 | warnOnInvalidCallback(callback, 'setState');
168 | }
169 | update.callback = callback;
170 | }
171 |
172 | enqueueUpdate(fiber, update); // 将任务推入更新队列
173 | scheduleUpdateOnFiber(fiber, lane, eventTime); // schedule 进行调度
174 | // ...
175 |
176 | if (enableSchedulingProfiler) {
177 | markStateUpdateScheduled(fiber, lane);
178 | }
179 | },
180 | // ...
181 | };
182 | ```
183 |
184 | ### forceUpdate
185 |
186 | `forceUpdate` 的流程与 `setState` 几乎一模一样:
187 |
188 |
189 |
190 | 同样其调用了触发器 updater 上的 `enqueueForceUpdate` 方法,`enqueueForceUpdate` 方法也同样是触发了一系列的更新流程:
191 |
192 | ```ts
193 | reconciler / src / ReactFiberClassComponent.old.js;
194 |
195 | const classComponentUpdater = {
196 | isMounted,
197 | // ...
198 | enqueueForceUpdate(inst, callback) {
199 | const fiber = getInstance(inst);
200 | const eventTime = requestEventTime(); // 获取更新触发的时间
201 | const lane = requestUpdateLane(fiber); // 获取任务优先级
202 |
203 | const update = createUpdate(eventTime, lane); // 创建更新
204 | update.tag = ForceUpdate;
205 |
206 | if (callback !== undefined && callback !== null) {
207 | if (__DEV__) {
208 | warnOnInvalidCallback(callback, 'forceUpdate');
209 | }
210 | update.callback = callback;
211 | }
212 |
213 | enqueueUpdate(fiber, update); // 将任务推进更新队列
214 | scheduleUpdateOnFiber(fiber, lane, eventTime); // 触发更新调度
215 | // ...
216 |
217 | if (enableSchedulingProfiler) {
218 | markForceUpdateScheduled(fiber, lane);
219 | }
220 | },
221 | };
222 | ```
223 |
224 | ## 创建更新任务
225 |
226 | 可以发现,上述的三种触发更新的动作,最后殊途同归,都会走上述流程图中从 `requestEventTime` 到 `scheduleUpdateOnFiber` 这一流程,去创建更新任务,先我们详细看下更新任务是如何创建的。
227 |
228 | ### 获取更新触发时间
229 |
230 | 前面的文章中我们讲到过,react 执行更新过程中,会将更新任务拆解,每一帧优先执行高优先级的任务,从而保证用户体验的流畅。那么即使对于同样优先级的任务,在任务多的情况下该优先执行哪一些呢?
231 |
232 | react 通过 `requestEventTime` 方法去创建一个 currentEventTime,用于标识更新任务触发的时间,对于相同时间的任务,会批量去执行。同样优先级的任务,currentEventTime 值越小,就会越早执行。
233 |
234 | 我们看一下 `requestEventTime` 方法的实现:
235 |
236 | ```ts
237 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
238 |
239 | export function requestEventTime() {
240 | if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
241 | // 在 react 执行过程中,直接返回当前时间
242 | return now();
243 | }
244 | // 如果不在 react 执行过程中
245 | if (currentEventTime !== NoTimestamp) {
246 | // 正在执行浏览器事件,返回上次的 currentEventTime
247 | return currentEventTime;
248 | }
249 | // react 中断后首次更新,计算新的 currentEventTime
250 | currentEventTime = now();
251 | return currentEventTime;
252 | }
253 | ```
254 |
255 | 在这个方法中,`(executionContext & (RenderContext | CommitContext)` 做了二进制运算,`RenderContext` 代表着 react 正在计算更新,`CommitContext` 代表着 react 正在提交更新。所以这句话是判断当前 react 是否处在计算或者提交更新的阶段,如果是则直接返回 `now()`。
256 |
257 | ```ts
258 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
259 |
260 | export const NoContext = /* */ 0b0000000;
261 | const BatchedContext = /* */ 0b0000001;
262 | const EventContext = /* */ 0b0000010;
263 | const DiscreteEventContext = /* */ 0b0000100;
264 | const LegacyUnbatchedContext = /* */ 0b0001000;
265 | const RenderContext = /* */ 0b0010000;
266 | const CommitContext = /* */ 0b0100000;
267 | export const RetryAfterError = /* */ 0b1000000;
268 |
269 | let executionContext: ExecutionContext = NoContext;
270 | ```
271 |
272 | 再来看一下 `now` 的代码,这里的意思时,当前后的更新任务时间差小于 10ms 时,直接采用上次的 `Scheduler_now`,这样可以抹平 10ms 内更新任务的时间差, 有利于批量更新:
273 |
274 | ```ts
275 | // packages/react-reconciler/src/SchedulerWithReactIntegration.old.js
276 |
277 | export const now =
278 | initialTimeMs < 10000 ? Scheduler_now : () => Scheduler_now() - initialTimeMs;
279 | ```
280 |
281 | 综上所述,`requestEvent` 做的事情如下:
282 |
283 | 1. 在 react 的 render 和 commit 阶段我们直接获取更新任务的触发时间,并抹平相差 10ms 以内的更新任务以便于批量执行。
284 | 2. 当 currentEventTime 不等于 NoTimestamp 时,则判断其正在执行浏览器事件,react 想要同样优先级的更新任务保持相同的时间,所以直接返回上次的 currentEventTime
285 | 3. 如果是 react 上次中断之后的首次更新,那么给 currentEventTime 赋一个新的值
286 |
287 | ### 划分更新任务优先级
288 |
289 | 说完了相同优先级任务的触发时间,那么任务的优先级又是如何划分的呢?这里就要提到 `requestUpdateLane`,我们来看一下其源码:
290 |
291 | ```ts
292 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
293 |
294 | export function requestUpdateLane(fiber: Fiber): Lane {
295 | // ...
296 |
297 | // 根据记录下的事件的优先级,获取任务调度的优先级
298 | const schedulerPriority = getCurrentPriorityLevel();
299 |
300 | // ...
301 | let lane;
302 | if (
303 | (executionContext & DiscreteEventContext) !== NoContext &&
304 | schedulerPriority === UserBlockingSchedulerPriority
305 | ) {
306 | // 如果是用户阻塞级别的事件,则通过InputDiscreteLanePriority 计算更新的优先级 lane
307 | lane = findUpdateLane(InputDiscreteLanePriority, currentEventWipLanes);
308 | } else {
309 | // 否则依据事件的优先级计算 schedulerLanePriority
310 | const schedulerLanePriority = schedulerPriorityToLanePriority(
311 | schedulerPriority,
312 | );
313 |
314 | if (decoupleUpdatePriorityFromScheduler) {
315 | const currentUpdateLanePriority = getCurrentUpdateLanePriority();
316 |
317 | // 根据计算得到的 schedulerLanePriority,计算更新的优先级 lane
318 | lane = findUpdateLane(schedulerLanePriority, currentEventWipLanes);
319 | }
320 |
321 | return lane;
322 | }
323 | ```
324 |
325 | 它首先找出会通过 `getCurrentPriorityLevel` 方法,根据 Scheduler 中记录的事件优先级,获取任务调度的优先级 schedulerPriority。然后通过 `findUpdateLane` 方法计算得出 lane,作为更新过程中的优先级。
326 |
327 | `findUpdateLane` 这个方法中,按照事件的类型,匹配不同级别的 lane,事件类型的优先级划分如下,值越高,代表优先级越高:
328 |
329 | ```ts
330 | // packages/react-reconciler/src/ReactFiberLane.js
331 |
332 | export const SyncLanePriority: LanePriority = 15;
333 | export const SyncBatchedLanePriority: LanePriority = 14;
334 | const InputDiscreteHydrationLanePriority: LanePriority = 13;
335 | export const InputDiscreteLanePriority: LanePriority = 12;
336 | const InputContinuousHydrationLanePriority: LanePriority = 11;
337 | export const InputContinuousLanePriority: LanePriority = 10;
338 | const DefaultHydrationLanePriority: LanePriority = 9;
339 | export const DefaultLanePriority: LanePriority = 8;
340 | const TransitionHydrationPriority: LanePriority = 7;
341 | export const TransitionPriority: LanePriority = 6;
342 | const RetryLanePriority: LanePriority = 5;
343 | const SelectiveHydrationLanePriority: LanePriority = 4;
344 | const IdleHydrationLanePriority: LanePriority = 3;
345 | const IdleLanePriority: LanePriority = 2;
346 | const OffscreenLanePriority: LanePriority = 1;
347 | export const NoLanePriority: LanePriority = 0;
348 | ```
349 |
350 | ### 创建更新对象
351 |
352 | eventTime 和 lane 都创建好了之后,就该创建更新了,`createUpdate` 就是基于上面两个方法所创建的 eventTime 和 lane,去创建一个更新对象:
353 |
354 | ```ts
355 | // packages/react-reconciler/src/ReactUpdateQueue.old.js
356 |
357 | export function createUpdate(eventTime: number, lane: Lane): Update<*> {
358 | const update: Update<*> = {
359 | eventTime, // 更新要出发的事件
360 | lane, // 优先级
361 |
362 | tag: UpdateState, // 指定更新的类型,0更新 1替换 2强制更新 3捕获性的更新
363 | payload: null, // 要更新的内容,例如 setState 接收的第一个参数
364 | callback: null, // 更新完成后的回调
365 |
366 | next: null, // 指向下一个更新
367 | };
368 | return update;
369 | }
370 | ```
371 |
372 | ### 关联 fiber 的更新队列
373 |
374 | 创建好了 update 对象之后,紧接着调用 `enqueueUpdate` 方法把 update 对象放到 关联的 fiber 的 updateQueue 队列之中:
375 |
376 | ```ts
377 | // packages/react-reconciler/src/ReactUpdateQueue.old.js
378 |
379 | export function enqueueUpdate(fiber: Fiber, update: Update) {
380 | // 获取当前 fiber 的更新队列
381 | const updateQueue = fiber.updateQueue;
382 | if (updateQueue === null) {
383 | // 若 updateQueue 为空,表示 fiber 还未渲染,直接退出
384 | return;
385 | }
386 |
387 | const sharedQueue: SharedQueue = (updateQueue: any).shared;
388 | const pending = sharedQueue.pending;
389 | if (pending === null) {
390 | // pending 为 null 时表示首次更新,创建循环列表
391 | update.next = update;
392 | } else {
393 | // 将 update 插入到循环列表中
394 | update.next = pending.next;
395 | pending.next = update;
396 | }
397 | sharedQueue.pending = update;
398 |
399 | // ...
400 | }
401 | ```
402 |
403 | ## reconciler 过程
404 |
405 | 上面的更新任务创建好了并且关联到了 fiber 上,下面就该到了 react render 阶段的核心之一 —— reconciler 阶段。
406 |
407 | ### 根据任务类型执行不同更新
408 |
409 | reconciler 阶段会协调任务去执行,以 `scheduleUpdateOnFiber` 为入口函数,首先会调用 `checkForNestedUpdates` 方法,检查嵌套的更新数量,若嵌套数量大于 50 层时,被认为是循环更新(无限更新)。此时会抛出异常,避免了例如在类组件 render 函数中调用了 setState 这种死循环的情况。
410 |
411 | 然后通过 `markUpdateLaneFromFiberToRoot` 方法,向上递归更新 fiber 的 lane,lane 的更新很简单,就是将当前任务 lane 与之前的 lane 进行二进制或运算叠加。
412 |
413 | 我们看一下其[源码](https://github.com/facebook/react/blob/17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.new.js):
414 |
415 | ```ts
416 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
417 |
418 | export function scheduleUpdateOnFiber(
419 | fiber: Fiber,
420 | lane: Lane,
421 | eventTime: number,
422 | ) {
423 | // 检查是否有循环更新
424 | // 避免例如在类组件 render 函数中调用了 setState 这种死循环的情况
425 | checkForNestedUpdates();
426 |
427 | // ...
428 | // 自底向上更新 child.fiberLanes
429 | const root = markUpdateLaneFromFiberToRoot(fiber, lane);
430 |
431 | // ...
432 | // 标记 root 有更新,将 update 的 lane 插入到root.pendingLanes 中
433 | markRootUpdated(root, lane, eventTime);
434 |
435 | if (lane === SyncLane) { // 同步任务,采用同步渲染
436 | if (
437 | (executionContext & LegacyUnbatchedContext) !== NoContext &&
438 | (executionContext & (RenderContext | CommitContext)) === NoContext
439 | ) {
440 | // 如果本次是同步更新,并且当前还未开始渲染
441 | // 表示当前的 js 主线程空闲,并且没有 react 任务在执行
442 |
443 | // ...
444 | // 调用 performSyncWorkOnRoot 执行同步更新任务
445 | performSyncWorkOnRoot(root);
446 | } else {
447 | // 如果本次时同步更新,但是有 react 任务正在执行
448 |
449 | // 调用 ensureRootIsScheduled 去复用当前正在执行的任务,让其将本次的更新一并执行
450 | ensureRootIsScheduled(root, eventTime);
451 | schedulePendingInteractions(root, lane);
452 |
453 | // ...
454 | } else {
455 | // 如果本次更新是异步任务
456 |
457 | // ...
458 | // 调用 ensureRootIsScheduled 执行可中断更新
459 | ensureRootIsScheduled(root, eventTime);
460 | schedulePendingInteractions(root, lane);
461 | }
462 |
463 | mostRecentlyUpdatedRoot = root;
464 | }
465 | ```
466 |
467 | 然后会根据任务类型以及当前线程所处的 react 执行阶段,去判断进行何种类型的更新:
468 |
469 | #### 执行同步更新
470 |
471 | 当任务的类型为同步任务,并且当前的 js 主线程空闲(没有正在执行的 react 任务时),会通过 `performSyncWorkOnRoot(root)` 方法开始执行同步任务。
472 |
473 | `performSyncWorkOnRoot` 里面主要做了两件事:
474 |
475 | - renderRootSync 从根节点开始进行同步渲染任务
476 | - commitRoot 执行 commit 流程
477 |
478 | ```ts
479 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
480 |
481 | function performSyncWorkOnRoot(root) {
482 | // ...
483 | exitStatus = renderRootSync(root, lanes);
484 | // ...
485 | commitRoot(root);
486 | // ...
487 | }
488 | ```
489 |
490 | 当任务类型为同步类型,但是 js 主线程非空闲时。会执行 `ensureRootIsScheduled` 方法:
491 |
492 | ```ts
493 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
494 |
495 | function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
496 | // ...
497 | // 如果有正在执行的任务,
498 | if (existingCallbackNode !== null) {
499 | const existingCallbackPriority = root.callbackPriority;
500 | if (existingCallbackPriority === newCallbackPriority) {
501 | // 任务优先级没改变,说明可以复用之前的任务一起执行
502 | return;
503 | }
504 | // 任务优先级改变了,说明不能复用。
505 | // 取消正在执行的任务,重新去调度
506 | cancelCallback(existingCallbackNode);
507 | }
508 |
509 | // 进行一个新的调度
510 | let newCallbackNode;
511 | if (newCallbackPriority === SyncLanePriority) {
512 | // 如果是同步任务优先级,执行 performSyncWorkOnRoot
513 | newCallbackNode = scheduleSyncCallback(
514 | performSyncWorkOnRoot.bind(null, root)
515 | );
516 | } else if (newCallbackPriority === SyncBatchedLanePriority) {
517 | // 如果是批量同步任务优先级,执行 performSyncWorkOnRoot
518 | newCallbackNode = scheduleCallback(
519 | ImmediateSchedulerPriority,
520 | performSyncWorkOnRoot.bind(null, root)
521 | );
522 | } else {
523 | // ...
524 | // 如果不是批量同步任务优先级,执行 performConcurrentWorkOnRoot
525 | newCallbackNode = scheduleCallback(
526 | schedulerPriorityLevel,
527 | performConcurrentWorkOnRoot.bind(null, root)
528 | );
529 | }
530 | // ...
531 | }
532 | ```
533 |
534 | `ensureRootIsScheduled` 方法中,会先看加入了新的任务后根节点任务优先级是否有变更,如果无变更,说明新的任务会被当前的 schedule 一同执行;如果有变更,则创建新的 schedule,然后也是调用`performSyncWorkOnRoot(root)` 方法开始执行同步任务。
535 |
536 | #### 执行可中断更新
537 |
538 | 当任务的类型不是同步类型时,react 也会执行 `ensureRootIsScheduled` 方法,因为是异步任务,最终会执行 `performConcurrentWorkOnRoot` 方法,去进行可中断的更新,下面会详细讲到。
539 |
540 | ### workLoop
541 |
542 | #### 同步
543 |
544 | 以同步更新为例,`performSyncWorkOnRoot` 会经过以下流程,`performSyncWorkOnRoot` ——> `renderRootSync` ——> `workLoopSync`。
545 |
546 | `workLoopSync` 中,只要 workInProgress(workInProgress fiber 树中新创建的 fiber 节点) 不为 null,就会一直循环,执行 `performUnitOfWork` 函数。
547 |
548 | ```ts
549 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
550 |
551 | function workLoopSync() {
552 | while (workInProgress !== null) {
553 | performUnitOfWork(workInProgress);
554 | }
555 | }
556 | ```
557 |
558 | #### 可中断
559 |
560 | 可中断模式下,`performConcurrentWorkOnRoot` 会执行以下过程:`performConcurrentWorkOnRoot` ——> `renderRootConcurrent` ——> `workLoopConcurrent`。
561 |
562 | 相比于 `workLoopSync`, `workLoopConcurrent` 在每一次对 workInProgress 执行 `performUnitOfWork` 前,会先判断以下 `shouldYield()` 的值。若为 false 则继续执行,若为 true 则中断执行。
563 |
564 | ```ts
565 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
566 |
567 | function workLoopConcurrent() {
568 | while (workInProgress !== null && !shouldYield()) {
569 | performUnitOfWork(workInProgress);
570 | }
571 | }
572 | ```
573 |
574 | ### performUnitOfWork
575 |
576 | 最终无论是同步执行任务,还是可中断地执行任务,都会进入 `performUnitOfWork` 函数中。
577 |
578 | `performUnitOfWork` 中会以 fiber 作为单元,进行协调过程。每次 `beginWork` 执行后都会更新 workIngProgress,从而响应了上面 workLoop 的循环。
579 |
580 | 直至 fiber 树便利完成后,workInProgress 此时置为 null,执行 `completeUnitOfWork` 函数。
581 |
582 | ```ts
583 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
584 |
585 | function performUnitOfWork(unitOfWork: Fiber): void {
586 | // ...
587 | const current = unitOfWork.alternate;
588 | // ...
589 |
590 | let next;
591 | if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
592 | // ...
593 | next = beginWork(current, unitOfWork, subtreeRenderLanes);
594 | } else {
595 | next = beginWork(current, unitOfWork, subtreeRenderLanes);
596 | }
597 |
598 | // ...
599 | if (next === null) {
600 | completeUnitOfWork(unitOfWork);
601 | } else {
602 | workInProgress = next;
603 | }
604 |
605 | ReactCurrentOwner.current = null;
606 | }
607 | ```
608 |
609 | #### beginWork
610 |
611 | `beginWork` 是根据当前执行环境,封装调用了 `originalBeginWork` 函数:
612 |
613 | ```ts
614 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
615 |
616 | let beginWork;
617 | if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
618 | beginWork = (current, unitOfWork, lanes) => {
619 | // ...
620 | try {
621 | return originalBeginWork(current, unitOfWork, lanes);
622 | } catch (originalError) {
623 | // ...
624 | }
625 | };
626 | } else {
627 | beginWork = originalBeginWork;
628 | }
629 | ```
630 |
631 | `originalBeginWork` 中,会根据 workInProgress 的 tag 属性,执行不同类型的 react 元素的更新函数。但是他们都大同小异,不论是 tag 是何种类型,更新函数最终都会去调用 `reconcileChildren` 函数。
632 |
633 | ```ts
634 | // packages/react-reconciler/src/ReactFiberBeginWork.old.js
635 |
636 | function beginWork(
637 | current: Fiber | null,
638 | workInProgress: Fiber,
639 | renderLanes: Lanes
640 | ): Fiber | null {
641 | const updateLanes = workInProgress.lanes;
642 |
643 | workInProgress.lanes = NoLanes;
644 |
645 | // 针对 workInProgress 的tag,执行相应的更新
646 | switch (workInProgress.tag) {
647 | // ...
648 | case HostRoot:
649 | return updateHostRoot(current, workInProgress, renderLanes);
650 | case HostComponent:
651 | return updateHostComponent(current, workInProgress, renderLanes);
652 | // ...
653 | }
654 | // ...
655 | }
656 | ```
657 |
658 | 以 `updateHostRoot` 为例,根据根 fiber 是否存在,去执行 mountChildFibers 或者 reconcileChildren:
659 |
660 | ```ts
661 | // packages/react-reconciler/src/ReactFiberBeginWork.old.js
662 |
663 | function updateHostRoot(current, workInProgress, renderLanes) {
664 | // ...
665 | const root: FiberRoot = workInProgress.stateNode;
666 | if (root.hydrate && enterHydrationState(workInProgress)) {
667 | // 若根 fiber 不存在,说明是首次渲染,调用 mountChildFibers
668 | // ...
669 | const child = mountChildFibers(
670 | workInProgress,
671 | null,
672 | nextChildren,
673 | renderLanes
674 | );
675 | workInProgress.child = child;
676 | } else {
677 | // 若根 fiber 存在,调用 reconcileChildren
678 | reconcileChildren(current, workInProgress, nextChildren, renderLanes);
679 | resetHydrationState();
680 | }
681 | return workInProgress.child;
682 | }
683 | ```
684 |
685 | `reconcileChildren` 做的事情就是 react 的另一核心之一 —— diff 过程,在下一篇文章中会详细讲。
686 |
687 | #### completeUnitOfWork
688 |
689 | 当 workInProgress 为 null 时,也就是当前任务的 fiber 树遍历完之后,就进入到了 `completeUnitOfWork` 函数。
690 |
691 | 经过了 `beginWork` 操作,workInProgress 节点已经被打上了 flags 副作用标签。`completeUnitOfWork` 方法中主要是逐层收集 effects
692 | 链,最终收集到 root 上,供接下来的 commit 阶段使用。
693 |
694 | `completeUnitOfWork` 结束后,render 阶段便结束了,后面就到了 commit 阶段。
695 |
696 | ```ts
697 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
698 | function completeUnitOfWork(unitOfWork: Fiber): void {
699 | let completedWork = unitOfWork;
700 | do {
701 | // ...
702 | // 对节点进行completeWork,生成DOM,更新props,绑定事件
703 | next = completeWork(current, completedWork, subtreeRenderLanes);
704 |
705 | if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
706 | // 将当前节点的 effectList 并入到父节点的 effectList
707 | if (returnFiber.firstEffect === null) {
708 | returnFiber.firstEffect = completedWork.firstEffect;
709 | }
710 | if (completedWork.lastEffect !== null) {
711 | if (returnFiber.lastEffect !== null) {
712 | returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
713 | }
714 | returnFiber.lastEffect = completedWork.lastEffect;
715 | }
716 |
717 | // 将自身添加到 effectList 链,添加时跳过 NoWork 和 PerformedWork的 flags,因为真正的 commit 时用不到
718 | const flags = completedWork.flags;
719 |
720 | if (flags > PerformedWork) {
721 | if (returnFiber.lastEffect !== null) {
722 | returnFiber.lastEffect.nextEffect = completedWork;
723 | } else {
724 | returnFiber.firstEffect = completedWork;
725 | }
726 | returnFiber.lastEffect = completedWork;
727 | }
728 | }
729 | } while (completedWork !== null);
730 |
731 | // ...
732 | }
733 | ```
734 |
735 | ## scheduler
736 |
737 | ### 实现帧空闲调度任务
738 |
739 | 刚刚上面说到了在执行可中断的更新时,浏览器会在每一帧空闲时刻去执行 react 更新任务,那么空闲时刻去执行是如何实现的呢?我们很容易联想到一个 api —— requestIdleCallback。但由于 requestIdleCallback 的兼容性问题以及 react 对应部分高优先级任务可能牺牲部分帧的需要,react 通过自己实现了类似的功能代替了 requestIdleCallback。
740 |
741 | 我们上面讲到执行可中断更新时,`performConcurrentWorkOnRoot` 函数时通过 `scheduleCallback` 包裹起来的:
742 |
743 | ```ts
744 | scheduleCallback(
745 | schedulerPriorityLevel,
746 | performConcurrentWorkOnRoot.bind(null, root)
747 | );
748 | ```
749 |
750 | `scheduleCallback` 函数是引用了 `packages/scheduler/src/Scheduler.js` 路径下的 `unstable_scheduleCallback` 函数,我们来看一下这个函数,它会去按计划插入调度任务:
751 |
752 | ```ts
753 | // packages/scheduler/src/Scheduler.js
754 |
755 | function unstable_scheduleCallback(priorityLevel, callback, options) {
756 | // ...
757 |
758 | if (startTime > currentTime) {
759 | // 当前任务已超时,插入超时队列
760 | // ...
761 | } else {
762 | // 任务未超时,插入调度任务队列
763 | newTask.sortIndex = expirationTime;
764 | push(taskQueue, newTask);
765 | // 符合更新调度执行的标志
766 | if (!isHostCallbackScheduled && !isPerformingWork) {
767 | isHostCallbackScheduled = true;
768 | // requestHostCallback 调度任务
769 | requestHostCallback(flushWork);
770 | }
771 | }
772 |
773 | return newTask;
774 | }
775 | ```
776 |
777 | 将任务插入了调度队列之后,会通过 `requestHostCallback` 函数去调度任务。
778 |
779 | react 通过 `new MessageChannel()` 创建了消息通道,当发现 js 线程空闲时,通过 postMessage 通知 scheduler 开始调度。然后 react 接收到调度开始的通知时,就通过 `performWorkUntilDeadline` 函数去更新当前帧的结束时间,以及执行任务。从而实现了帧空闲时间的任务调度。
780 |
781 | ```ts
782 | // packages/scheduler/src/forks/SchedulerHostConfig.default.js
783 |
784 | // 获取当前设备每帧的时长
785 | forceFrameRate = function (fps) {
786 | // ...
787 | if (fps > 0) {
788 | yieldInterval = Math.floor(1000 / fps);
789 | } else {
790 | yieldInterval = 5;
791 | }
792 | };
793 |
794 | // 帧结束前执行任务
795 | const performWorkUntilDeadline = () => {
796 | if (scheduledHostCallback !== null) {
797 | const currentTime = getCurrentTime();
798 | // 更新当前帧的结束时间
799 | deadline = currentTime + yieldInterval;
800 | const hasTimeRemaining = true;
801 | try {
802 | const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
803 | // 如果还有调度任务就执行
804 | if (!hasMoreWork) {
805 | isMessageLoopRunning = false;
806 | scheduledHostCallback = null;
807 | } else {
808 | // 没有调度任务就通过 postMessage 通知结束
809 | port.postMessage(null);
810 | }
811 | } catch (error) {
812 | // ..
813 | throw error;
814 | }
815 | } else {
816 | isMessageLoopRunning = false;
817 | }
818 | needsPaint = false;
819 | };
820 |
821 | // 通过 MessageChannel 创建消息通道,实现任务调度通知
822 | const channel = new MessageChannel();
823 | const port = channel.port2;
824 | channel.port1.onmessage = performWorkUntilDeadline;
825 |
826 | // 通过 postMessage,通知 scheduler 已经开始了帧调度
827 | requestHostCallback = function (callback) {
828 | scheduledHostCallback = callback;
829 | if (!isMessageLoopRunning) {
830 | isMessageLoopRunning = true;
831 | port.postMessage(null);
832 | }
833 | };
834 | ```
835 |
836 | ### 任务中断
837 |
838 | 前面说到可中断模式下的 workLoop,每次遍历执行 performUnitOfWork 前会先判断 `shouYield` 的值
839 |
840 | ```ts
841 | // packages/react-reconciler/src/ReactFiberWorkLoop.old.js
842 |
843 | function workLoopConcurrent() {
844 | while (workInProgress !== null && !shouldYield()) {
845 | performUnitOfWork(workInProgress);
846 | }
847 | }
848 | ```
849 |
850 | 我们看一下 `shouYield` 的值是如何获取的:
851 |
852 | ```ts
853 | // packages\scheduler\src\SchedulerPostTask.js
854 | export function unstable_shouldYield() {
855 | return getCurrentTime() >= deadline;
856 | }
857 | ```
858 |
859 | `getCurrentTime` 获取的是当前的时间戳,deadline 上面讲到了是浏览器每一帧结束的时间戳。也就是说 concurrent 模式下,react 会将这些非同步任务放到浏览器每一帧空闲时间段去执行,若每一帧结束未执行完,则中断当前任务,待到浏览器下一帧的空闲再继续执行。
860 |
861 | ## 总结
862 |
863 | 总结一下 react render 阶段的设计思想:
864 |
865 | 1. 当发生渲染或者更新操作时,react 去创建一系列的任务,任务带有优先级,然后构建 workInProgress fiber 树链表。
866 | 2. 遍历任务链表去执行任务。每一帧帧先执行浏览器的渲染等任务,如果当前帧还有空闲时间,则执行任务,直到当前帧的时间用完。如果当前帧已经没有空闲时间,就等到下一帧的空闲时间再去执行。如果当前帧没有空闲时间但是当前任务链表有任务到期了或者有立即执行任务,那么必须执行的时候就以丢失几帧的代价,执行这些任务。执行完的任务都会被从链表中删除。
867 |
868 | 执行过程中的流程图如下:
869 |
870 |
--------------------------------------------------------------------------------
/docs/react/源码/React17源码解析(5) —— 全面理解diff算法.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: 'react 源码解析,本章节将结合源码解析 diff 算法,包括如下内容:react diff 算法的介绍、diff 策略、diff 源码解析。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4'
4 | tag: ['react']
5 | time: '2021-10-15'
6 | ---
7 |
8 | 欢迎大家一起交流学习 react 源码,本系列导航请见:[React17 源码解析(开篇) —— 搭建 react 源码调试环境](https://juejin.cn/post/7014775797596553230/)
9 |
10 |
11 |
12 | > react 源码解析(5),本章节将结合源码解析 diff 算法,包括如下内容:
13 | >
14 | > - react diff 算法的介绍
15 | > - diff 策略
16 | > - diff 源码解析
17 |
18 | 上一章中 react 的 render 阶段,其中 `begin` 时会调用 `reconcileChildren` 函数, `reconcileChildren` 中做的事情就是 react 知名的 diff 过程,本章会对 diff 算法进行讲解。
19 |
20 | ## diff 算法介绍
21 |
22 | react 的每次更新,都会将新的 ReactElement 内容与旧的 fiber 树作对比,比较出它们的差异后,构建新的 fiber 树,将差异点放入更新队列之中,从而对真实 dom 进行 render。简单来说就是如何通过最小代价将旧的 fiber 树转换为新的 fiber 树。
23 |
24 | [经典的 diff 算法](https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf) 中,将一棵树转为另一棵树的最低时间复杂度为 O(n^3),其中 n 为树种节点的个数。假如采用这种 diff 算法,一个应用有 1000 个节点的情况下,需要比较 十亿 次才能将 dom 树更新完成,显然这个性能是无法让人接受的。
25 |
26 | 因此,想要将 diff 应用于 virtual dom 中,必须实现一种高效的 diff 算法。React 便通过制定了一套大胆的策略,实现了 O(n) 的时间复杂度更新 virtual dom。
27 |
28 | ## diff 策略
29 |
30 | react 将 diff 算法优化到 O(n) 的时间复杂度,基于了以下三个前提策略:
31 |
32 | - 只对同级元素进行比较。Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计,如果出现跨层级的 dom 节点更新,则不进行复用。
33 | - 两个不同类型的组件会产生两棵不同的树形结构。
34 | - 对同一层级的子节点,开发者可以通过 `key` 来确定哪些子元素可以在不同渲染中保持稳定。
35 |
36 | 上面的三种 diff 策略,分别对应着 tree diff、component diff 和 element diff。
37 |
38 | ## tree diff
39 |
40 | 根据策略一,react 会对 fiber 树进行分层比较,只比较同级元素。这里的同级指的是同一个父节点下的子节点(往上的祖先节点也都是同一个),而不是树的深度相同。
41 |
42 |
43 |
44 | 如上图所示,react 的 tree diff 是采用深度优先遍历,所以要比较的元素向上的祖先元素都会一致,即图中会对相同颜色的方框内圈出的元素进行比较,例如左边树的 A 节点下的子节点 C、D 会与右边树 A 节点下的 C、D、E 进行比较。
45 |
46 | 当元素出现跨层级的移动时,例如下图:
47 |
48 | A 子树从 root 节点下到了 B 节点下,在 react diff 过程中并不会直接将 A 子树移动到 B 子树下,而是进行如下操作:
49 |
50 | 1. 在 root 节点下删除 A 节点
51 | 2. 在 B 节点下创建 A 子节点
52 | 3. 在新创建的 A 子节点下创建 C、D 节点
53 |
54 | ### component diff
55 |
56 | 对于组件之间的比较,只要它们的类型不同,就判断为它们是两棵不同的树形结构,直接会将它们给替换掉。
57 |
58 | 例如下面的两棵树,左边树 B 节点和右边树 K 节点除了类型不同(比如 B 为 div 类型,K 为 p 类型),内容完全一致,但 react 依然后直接替换掉整个节点。实际经过的变换是:
59 |
60 | 1. 在 root 节点下创建 K 节点
61 | 2. 在 K 节点下创建 E、F 节点
62 | 3. 在 F 节点下创建 G、H 节点
63 | 4. 在 root 节点下删除 B 子节点
64 |
65 |
66 |
67 | 虽然如果在本例中改变类型复用子元素性能会更高一点,但是在时机应用开发中类型不一致子内容完全一致的情况极少,对这种情况过多判断反而会增加时机复杂度,降低平均性能。
68 |
69 | ### element diff
70 |
71 | react 对于同层级的元素进行比较时,会通过 key 对元素进行比较以识别哪些元素可以稳定的渲染。同级元素的比较存在插入、删除和移动三种操作。
72 |
73 | 如下图左边的树想要转变为右边的树:
74 |
75 |
76 | 实际经过的变换如下:
77 |
78 | 1. 将 root 节点下 A 子节点移动至 B 子节点之后
79 | 2. 在 root 节点下新增 E 子节点
80 | 3. 将 root 节点下 C 子节点删除
81 |
82 |
83 |
84 | ## 结合源码看 diff
85 |
86 | ### 整体流程
87 |
88 | diff 算法从 `reconcileChildren` 函数开始,根据当前 fiber 是否存在,决定是直接渲染新的 ReactElement 内容还是与当前 fiber 去进行 Diff,看一下其[源码](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberBeginWork.new.js):
89 |
90 | ```ts
91 | export function reconcileChildren(
92 | current: Fiber | null, // 当前 fiber 节点
93 | workInProgress: Fiber, // 父 fiber
94 | nextChildren: any, // 新生成的 ReactElement 内容
95 | renderLanes: Lanes // 渲染的优先级
96 | ) {
97 | if (current === null) {
98 | // 如果当前 fiber 节点为空,则直接将新的 ReactElement 内容生成新的 fiber
99 | workInProgress.child = mountChildFibers(
100 | workInProgress,
101 | null,
102 | nextChildren,
103 | renderLanes
104 | );
105 | } else {
106 | // 当前 fiber 节点不为空,则与新生成的 ReactElement 内容进行 diff
107 | workInProgress.child = reconcileChildFibers(
108 | workInProgress,
109 | current.child,
110 | nextChildren,
111 | renderLanes
112 | );
113 | }
114 | }
115 | ```
116 |
117 | 因为我们主要是要学习 diff 算法,所以我们暂时先不关心 `mountChildFibers` 函数,主要关注 `reconcileChildFibers` ,我们来看一下它的[源码](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.old.js):
118 |
119 | ```ts
120 | function reconcileChildFibers(
121 | returnFiber: Fiber, // 父 Fiber
122 | currentFirstChild: Fiber | null, // 父 fiber 下要对比的第一个子 fiber
123 | newChild: any, // 更新后的 React.Element 内容
124 | lanes: Lanes // 更新的优先级
125 | ): Fiber | null {
126 | // 对新创建的 ReactElement 最外层是 fragment 类型单独处理,比较其 children
127 | const isUnkeyedTopLevelFragment =
128 | typeof newChild === 'object' &&
129 | newChild !== null &&
130 | newChild.type === REACT_FRAGMENT_TYPE &&
131 | newChild.key === null;
132 | if (isUnkeyedTopLevelFragment) {
133 | newChild = newChild.props.children;
134 | }
135 |
136 | // 对更新后的 React.Element 是单节点的处理
137 | if (typeof newChild === 'object' && newChild !== null) {
138 | switch (newChild.$$typeof) {
139 | // 常规 react 元素
140 | case REACT_ELEMENT_TYPE:
141 | return placeSingleChild(
142 | reconcileSingleElement(
143 | returnFiber,
144 | currentFirstChild,
145 | newChild,
146 | lanes
147 | )
148 | );
149 | // react.portal 类型
150 | case REACT_PORTAL_TYPE:
151 | return placeSingleChild(
152 | reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes)
153 | );
154 | // react.lazy 类型
155 | case REACT_LAZY_TYPE:
156 | if (enableLazyElements) {
157 | const payload = newChild._payload;
158 | const init = newChild._init;
159 | return reconcileChildFibers(
160 | returnFiber,
161 | currentFirstChild,
162 | init(payload),
163 | lanes
164 | );
165 | }
166 | }
167 |
168 | // 更新后的 React.Element 是多节点的处理
169 | if (isArray(newChild)) {
170 | return reconcileChildrenArray(
171 | returnFiber,
172 | currentFirstChild,
173 | newChild,
174 | lanes
175 | );
176 | }
177 |
178 | // 迭代器函数的单独处理
179 | if (getIteratorFn(newChild)) {
180 | return reconcileChildrenIterator(
181 | returnFiber,
182 | currentFirstChild,
183 | newChild,
184 | lanes
185 | );
186 | }
187 |
188 | throwOnInvalidObjectType(returnFiber, newChild);
189 | }
190 |
191 | // 纯文本节点的类型处理
192 | if (typeof newChild === 'string' || typeof newChild === 'number') {
193 | return placeSingleChild(
194 | reconcileSingleTextNode(
195 | returnFiber,
196 | currentFirstChild,
197 | '' + newChild,
198 | lanes
199 | )
200 | );
201 | }
202 |
203 | if (__DEV__) {
204 | if (typeof newChild === 'function') {
205 | warnOnFunctionType(returnFiber);
206 | }
207 | }
208 |
209 | // 不符合以上情况都视为 empty,直接从父节点删除所有旧的子 Fiber
210 | return deleteRemainingChildren(returnFiber, currentFirstChild);
211 | }
212 | ```
213 |
214 | 入口函数中,接收 `returnFiber`、`currentFirstChild`、`newChild`、`lanes` 四个参数,其中,根据 `newChid` 的类型,我们主要关注几个比较常见的类型的 diff,单 React 元素的 diff、纯文本类型的 diff 和 数组类型的 diff。
215 |
216 | 所以根据 ReactElement 类型走的不同流程如下:
217 |
218 |
219 | ### 新内容为 REACT_ELEMENT_TYPE
220 |
221 | 当新创建的节点 type 为 object 时,我们看一下其为 `REACT_ELEMENT_TYPE` 类型的 diff,即 `placeSingleChild(reconcileSingleElement(...))` 函数。
222 |
223 | 先看一下 `reconcileSingleElement` 函数的[源码](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.old.js):
224 |
225 | ```ts
226 | function reconcileSingleElement(
227 | returnFiber: Fiber, // 父 fiber
228 | currentFirstChild: Fiber | null, // 父 fiber 下第一个开始对比的旧的子 fiber
229 | element: ReactElement, // 当前的 ReactElement内容
230 | lanes: Lanes // 更新的优先级
231 | ): Fiber {
232 | const key = element.key;
233 | let child = currentFirstChild;
234 | // 处理旧的 fiber 由多个节点变成新的 fiber 一个节点的情况
235 | // 循环遍历父 fiber 下的旧的子 fiber,直至遍历完或者找到 key 和 type 都与新节点相同的情况
236 | while (child !== null) {
237 | if (child.key === key) {
238 | const elementType = element.type;
239 | if (elementType === REACT_FRAGMENT_TYPE) {
240 | if (child.tag === Fragment) {
241 | // 如果新的 ReactElement 和旧 Fiber 都是 fragment 类型且 key 相等
242 | // 对旧 fiber 后面的所有兄弟节点添加 Deletion 副作用标记,用于 dom 更新时删除
243 | deleteRemainingChildren(returnFiber, child.sibling);
244 |
245 | // 通过 useFiber, 基于旧的 fiber 和新的 props.children,克隆生成一个新的 fiber,新 fiber 的 index 为 0,sibling 为 null
246 | // 这便是所谓的 fiber 复用
247 | const existing = useFiber(child, element.props.children);
248 | existing.return = returnFiber;
249 | if (__DEV__) {
250 | existing._debugSource = element._source;
251 | existing._debugOwner = element._owner;
252 | }
253 | return existing;
254 | }
255 | } else {
256 | if (
257 | // 如果新的 ReactElement 和旧 Fiber 的 key 和 type 都相等
258 | child.elementType === elementType ||
259 | (__DEV__
260 | ? isCompatibleFamilyForHotReloading(child, element)
261 | : false) ||
262 | (enableLazyElements &&
263 | typeof elementType === 'object' &&
264 | elementType !== null &&
265 | elementType.$$typeof === REACT_LAZY_TYPE &&
266 | resolveLazy(elementType) === child.type)
267 | ) {
268 | // 对旧 fiber 后面的所有兄弟节点添加 Deletion 副作用标记,用于 dom 更新时删除
269 | deleteRemainingChildren(returnFiber, child.sibling);
270 | // 通过 useFiber 复用新节点并返回
271 | const existing = useFiber(child, element.props);
272 | existing.ref = coerceRef(returnFiber, child, element);
273 | existing.return = returnFiber;
274 | if (__DEV__) {
275 | existing._debugSource = element._source;
276 | existing._debugOwner = element._owner;
277 | }
278 | return existing;
279 | }
280 | }
281 | // 若 key 相同但是 type 不同说明不匹配,移除旧 fiber 及其后面的兄弟 fiber
282 | deleteRemainingChildren(returnFiber, child);
283 | break;
284 | } else {
285 | // 若 key 不同,对当前的旧 fiber 添加 Deletion 副作用标记,继续对其兄弟节点遍历
286 | deleteChild(returnFiber, child);
287 | }
288 | child = child.sibling;
289 | }
290 |
291 | // 都遍历完之后说明没有匹配到 key 和 type 都相同的 fiber
292 | if (element.type === REACT_FRAGMENT_TYPE) {
293 | // 如果新节点是 fragment 类型,createFiberFromFragment 创建新的 fragment 类型 fiber并返回
294 | const created = createFiberFromFragment(
295 | element.props.children,
296 | returnFiber.mode,
297 | lanes,
298 | element.key
299 | );
300 | created.return = returnFiber;
301 | return created;
302 | } else {
303 | // createFiberFromElement 创建 fiber 并返回
304 | const created = createFiberFromElement(element, returnFiber.mode, lanes);
305 | created.ref = coerceRef(returnFiber, currentFirstChild, element);
306 | created.return = returnFiber;
307 | return created;
308 | }
309 | }
310 | ```
311 |
312 | 根据源码我们可以得知,`reconcileSingleElement` 函数中,会遍历父 fiber 下所有的旧的子 fiber,寻找与新生成的 ReactElement 内容的 key 和 type 都相同的子 fiber。每次遍历对比的过程中:
313 |
314 | - 若当前旧的子 fiber 与新内容 key 或 type 不一致,对当前旧的子 fiber 添加 `Deletion` 副作用标记(用于 dom 更新时删除),继续对比下一个旧子 fiber
315 | - 若当前旧的子 fiber 与新内容 key 或 type 一致,则判断为可复用,通过 `deleteRemainingChildren` 对该子 fiber 后面所有的兄弟 fiber 添加 `Deletion` 副作用标记,然后通过 `useFiber` 基于该子 fiber 和新内容的 props 生成新的 fiber 进行复用,结束遍历。
316 |
317 | 若都遍历完没找到与新内容 key 或 type 子 fiber,此时父 fiber 下的所有旧的子 fiber 都已经添加了 `Deletion` 副作用标记,通过 `createFiberFromElement` 基于新内容创建新的 fiber 并将其 return 指向父 fiber。
318 |
319 | 再来看 `placeSingleChild` 的[源码](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.old.js):
320 |
321 | ```ts
322 | function placeSingleChild(newFiber: Fiber): Fiber {
323 | if (shouldTrackSideEffects && newFiber.alternate === null) {
324 | newFiber.flags |= Placement;
325 | }
326 | return newFiber;
327 | }
328 | ```
329 |
330 | `placeSingleChild` 中做的事情更为简单,就是将 `reconcileSingleElement` 中生成的新 fiber 打上 `Placement` 的标记,表示 dom 更新渲染时要进行插入。
331 |
332 | 所以对于 REACT_ELEMENT_TYPE 类型的 diff 总结如下:
333 |
334 |
335 | ### 新内容为纯文本类型
336 |
337 | 当新创建节点的 typeof 为 string 或者 number 时,表示是纯文本节点,使用 `placeSingleChild(reconcileSingleTextNode(...))` 函数进行 diff。
338 |
339 | `placeSingleChild` 前面说过了,我们主要看 `reconcileSingleTextNode` 的[源码](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.old.js):
340 |
341 | ```ts
342 | function reconcileSingleTextNode(
343 | returnFiber: Fiber,
344 | currentFirstChild: Fiber | null,
345 | textContent: string,
346 | lanes: Lanes
347 | ): Fiber {
348 | if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
349 | // deleteRemainingChildren 对旧 fiber 后面的所有兄弟节点添加 Deletion 副作用标记,用于 dom 更新时删除
350 | // useFiber 传入 textContext 复用当前 fiber
351 | deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
352 | const existing = useFiber(currentFirstChild, textContent);
353 | existing.return = returnFiber;
354 | return existing;
355 | }
356 | // 若未匹配到,createFiberFromText 创建新的 fiber
357 | deleteRemainingChildren(returnFiber, currentFirstChild);
358 | const created = createFiberFromText(textContent, returnFiber.mode, lanes);
359 | created.return = returnFiber;
360 | return created;
361 | }
362 | ```
363 |
364 | 新内容为纯文本时 diff 比较简单,只需要判断当前父 fiber 的第一个旧子 fiber 类型:
365 |
366 | - 当前 fiber 也为文本类型的节点时,`deleteRemainingChildren` 对第一个旧子 fiber 的所有兄弟 fiber 添加 `Deletion` 副作用标记,然后通过 `useFiber` 基于当前 fiber 和 textContent 创建新的 fiber 复用,将其 return 指向父 fiber
367 | - 否则通过 `deleteRemainingChildren` 对所有旧的子 fiber 添加 `Deletion` 副作用标记,然后 `createFiberFromText` 创建新的文本类型 fiber 节点,将其 return 指向父 fiber
368 |
369 | 所以对文本类型 diff 的流程如下:
370 |
371 |
372 | ### 新内容为数组类型
373 |
374 | 上面所说的两种情况,都是一个或多个子 fiebr 变成单个 fiber。新内容为数组类型时,意味着要将一个或多个子 fiber 替换为多个 fiber,内容相对复杂,我们看一下 `reconcileChildrenArray` 的[源码](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactChildFiber.old.js):
375 |
376 | ```ts
377 | function reconcileChildrenArray(
378 | returnFiber: Fiber,
379 | currentFirstChild: Fiber | null,
380 | newChildren: Array<*>,
381 | lanes: Lanes
382 | ): Fiber | null {
383 | // 开发环境下会校验 key 是否存在且合法,否则会报 warning
384 | if (__DEV__) {
385 | let knownKeys = null;
386 | for (let i = 0; i < newChildren.length; i++) {
387 | const child = newChildren[i];
388 | knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
389 | }
390 | }
391 |
392 | let resultingFirstChild: Fiber | null = null; // 最终要返回的第一个子 fiber
393 | let previousNewFiber: Fiber | null = null;
394 |
395 | let oldFiber = currentFirstChild;
396 | let lastPlacedIndex = 0;
397 | let newIdx = 0;
398 | let nextOldFiber = null;
399 | // 因为在实际的应用开发中,react 发现更新的情况远大于新增和删除的情况,所以这里优先处理更新
400 | // 根据 oldFiber 的 index 和 newChildren 的下标,找到要对比更新的 oldFiber
401 | for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
402 | if (oldFiber.index > newIdx) {
403 | nextOldFiber = oldFiber;
404 | oldFiber = null;
405 | } else {
406 | nextOldFiber = oldFiber.sibling;
407 | }
408 | // 通过 updateSlot 来 diff oldFiber 和新的 child,生成新的 Fiber
409 | // updateSlot 与上面两种类型的 diff 类似,如果 oldFiber 可复用,则根据 oldFiber 和 child 的 props 生成新的 fiber;否则返回 null
410 | const newFiber = updateSlot(
411 | returnFiber,
412 | oldFiber,
413 | newChildren[newIdx],
414 | lanes
415 | );
416 | // newFiber 为 null 说明不可复用,退出第一轮的循环
417 | if (newFiber === null) {
418 | if (oldFiber === null) {
419 | oldFiber = nextOldFiber;
420 | }
421 | break;
422 | }
423 | if (shouldTrackSideEffects) {
424 | if (oldFiber && newFiber.alternate === null) {
425 | deleteChild(returnFiber, oldFiber);
426 | }
427 | }
428 | // 记录复用的 oldFiber 的 index,同时给新 fiber 打上 Placement 副作用标签
429 | lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
430 |
431 | if (previousNewFiber === null) {
432 | // 如果上一个 newFiber 为 null,说明这是第一个生成的 newFiber,设置为 resultingFirstChild
433 | resultingFirstChild = newFiber;
434 | } else {
435 | // 否则构建链式关系
436 | previousNewFiber.sibling = newFiber;
437 | }
438 | previousNewFiber = newFiber;
439 | oldFiber = nextOldFiber;
440 | }
441 |
442 | if (newIdx === newChildren.length) {
443 | // newChildren遍历完了,说明剩下的 oldFiber 都是待删除的 Fiber
444 | // 对剩下 oldFiber 标记 Deletion
445 | deleteRemainingChildren(returnFiber, oldFiber);
446 | return resultingFirstChild;
447 | }
448 |
449 | if (oldFiber === null) {
450 | // olderFiber 遍历完了
451 | // newChildren 剩下的节点都是需要新增的节点
452 | for (; newIdx < newChildren.length; newIdx++) {
453 | // 遍历剩下的 child,通过 createChild 创建新的 fiber
454 | const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
455 | if (newFiber === null) {
456 | continue;
457 | }
458 | // 处理dom移动,// 记录 index,同时给新 fiber 打上 Placement 副作用标签
459 | lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
460 | // 将新创建 fiber 加入到 fiber 链表树中
461 | if (previousNewFiber === null) {
462 | resultingFirstChild = newFiber;
463 | } else {
464 | previousNewFiber.sibling = newFiber;
465 | }
466 | previousNewFiber = newFiber;
467 | }
468 | return resultingFirstChild;
469 | }
470 |
471 | // oldFiber 和 newChildren 都未遍历完
472 | // mapRemainingChildren 生成一个以 oldFiber 的 key 为 key, oldFiber 为 value 的 map
473 | const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
474 |
475 | // 对剩下的 newChildren 进行遍历
476 | for (; newIdx < newChildren.length; newIdx++) {
477 | // 找到 mapRemainingChildren 中 key 相等的 fiber, 创建新 fiber 复用
478 | const newFiber = updateFromMap(
479 | existingChildren,
480 | returnFiber,
481 | newIdx,
482 | newChildren[newIdx],
483 | lanes
484 | );
485 | if (newFiber !== null) {
486 | if (shouldTrackSideEffects) {
487 | if (newFiber.alternate !== null) {
488 | // 删除当前找到的 fiber
489 | existingChildren.delete(
490 | newFiber.key === null ? newIdx : newFiber.key
491 | );
492 | }
493 | }
494 | // 处理dom移动,记录 index,同时给新 fiber 打上 Placement 副作用标签
495 | lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
496 | // 将新创建 fiber 加入到 fiber 链表树中
497 | if (previousNewFiber === null) {
498 | resultingFirstChild = newFiber;
499 | } else {
500 | previousNewFiber.sibling = newFiber;
501 | }
502 | previousNewFiber = newFiber;
503 | }
504 | }
505 |
506 | if (shouldTrackSideEffects) {
507 | // 剩余的旧 fiber 的打上 Deletion 副作用标签
508 | existingChildren.forEach((child) => deleteChild(returnFiber, child));
509 | }
510 |
511 | return resultingFirstChild;
512 | }
513 | ```
514 |
515 | 从上述代码我们可以得知,对于新增内容为数组时,react 会对旧 fiber 和 newChildren 进行遍历。
516 |
517 | 1. 首先先对 newChildren 进行第一轮遍历,将当前的 oldFiber 与 当前 newIdx 下标的 newChild 通过 `updateSlot` 进行 diff,diff 的流程和上面单节点的 diff 类似,然后返回 diff 后的结果:
518 | - 如果 diff 后 oldFiber 和 newIdx 的 key 和 type 一致,说明可复用。根据 oldFiber 和 newChild 的 props 生成新的 fiber,通过 `placeChild` 给新生成的 fiber 打上 `Placement` 副作用标记,同时新 fiber 与之前遍历生成的新 fiber 构建链表树关系。然后继续执行遍历,对下一个 oldFiber 和下一个 newIdx 下标的 newFiber 继续 diff
519 | - 如果 diff 后 oldFiber 和 newIdx 的 key 或 type 不一致,那么说明不可复用,返回的结果为 null,第一轮遍历结束
520 | 2. 第一轮遍历结束后,可能会执行以下几种情况:
521 | - 若 newChildren 遍历完了,那剩下的 oldFiber 都是待删除的,通过 `deleteRemainingChildren` 对剩下的 oldFiber 打上 `Deletion` 副作用标记
522 | - 若 oldFiber 遍历完了,那剩下的 newChildren 都是需要新增的,遍历剩下的 newChildren,通过 `createChild` 创建新的 fiber,`placeChild` 给新生成的 fiber 打上 `Placement` 副作用标记并添加到 fiber 链表树中。
523 | - 若 oldFiber 和 newChildren 都未遍历完,通过 `mapRemainingChildren` 创建一个以剩下的 oldFiber 的 key 为 key,oldFiber 为 value 的 map。然后对剩下的 newChildren 进行遍历,通过 `updateFromMap` 在 map 中寻找具有相同 key 创建新的 fiber(若找到则基于 oldFiber 和 newChild 的 props 创建,否则直接基于 newChild 创建),则从 map 中删除当前的 key,然后`placeChild` 给新生成的 fiber 打上 `Placement` 副作用标记并添加到 fiber 链表树中。遍历完之后则 existingChildren 还剩下 oldFiber 的话,则都是待删除的 fiber,`deleteChild` 对其打上 `Deletion` 副作用标记。
524 |
525 | 所以整体的流程如下:
526 |
527 |
528 | ## diff 后的渲染
529 |
530 | diff 流程结束后,会形成新的 fiber 链表树,链表树上的 fiber 通过 flags 字段做了副作用标记,主要有以下几种:
531 |
532 | 1. Deletion:会在渲染阶段对对应的 dom 做删除操作
533 | 2. Update:在 fiber.updateQueue 上保存了要更新的属性,在渲染阶段会对 dom 做更新操作
534 | 3. Placement:Placement 可能是插入也可能是移动,实际上两种都是插入动作。react 在更新时会优先去寻找要插入的 fiber 的 sibling,如果找到了执行 dom 的 `insertBefore` 方法,如果没有找到就执行 dom 的 `appendChild` 方法,从而实现了新节点插入位置的准确性
535 |
536 | 在 `completeUnitWork` 阶段结束后,react 会根据 fiber 链表树的 flags,构建一个 effectList 链表,里面记录了哪些 fiber 需要进行插入、删除、更新操作,在后面的 commit 阶段进行真实 dom 节点的更新,下一章将详细讲述 commit 阶段。
537 |
--------------------------------------------------------------------------------
/docs/react/源码/React17源码解析(开篇)——搭建调试环境.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本系列的源码解析的 react 版本是 v17.0.2,将从 react 应用的入口开始讲解,涉及到一个较为完整的 react 依赖的绝大部分的主要功能和核心。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4'
4 | tag: ['react']
5 | time: '2021-09-08'
6 | ---
7 |
8 | 作为前端最优秀的开源框架之一,react 值得每一个前端开发者学习。今年花了很长的一段时间,反复理解品读了 react 的源码,感觉收获颇丰,趁着国庆假期打算开始写一系列 react v17 源码解析的文章和大家一起讨论学习。
9 |
10 | 通过源码的学习,你可以提升项目中排查 bug 的能力、更好的理解 react 的工作过程和工作模式、提升数据结构和算法设计的能力,以及最重要的:提升面试竞争力。
11 |
12 | ## 系列目录
13 |
14 | 本系列的源码解析的 react 版本是 [v17.0.2](https://github.com/facebook/react/tree/17.0.2),将从 react 应用的入口开始讲解,涉及到一个较为完整的 react 依赖的绝大部分的主要功能和核心,大体的内容纲要如下:
15 |
16 | - [react 源码目录和架构](https://juejin.cn/post/7015853155367780383)
17 | - [jsx 和 React.createElement](https://juejin.cn/post/7015855371847729166/)
18 | - [深入理解 fiber](https://juejin.cn/post/7016512949330116645)
19 | - [详解 render 阶段(scheduler 和 reconciler)](https://juejin.cn/post/7019254208830373902/)
20 | - [全面理解 diff 算法](https://juejin.cn/post/7020595059095666724)
21 | - [commit 阶段](https://juejin.cn/post/7022816775188250660)
22 | - [一文搞懂 hooks 原理](https://juejin.cn/post/7023568411963686920)
23 | - [实现 mini react](https://juejin.cn/post/7030673003583111176)
24 |
25 | 由于上面的章节并未完结,后续实际写的过程中可能略有调整,本篇章会随时更新,感兴趣的看官老爷建议收藏一下本章作为目录导航。
26 |
27 | ## 搭建 react 源码调试环境
28 |
29 | 本系列章节除了源码的解析之外,也希望大家能够自己学会如何阅读源码,既然想要阅读源码,那么必然少不了对源码的调试,下面会讲解如何搭建一个 react 源码的调试环境。
30 |
31 | ### 创建项目
32 |
33 | 首先通过官方脚手架 create-react-app 创建一个 react 项目,在终端执行以下命令:
34 |
35 | ```
36 | npx create-react-app debug-react
37 | ```
38 |
39 | ### 暴露 webpack 配置
40 |
41 | 我们后续想要对通过直接引入 react 源码替代 node_modules 中的 react 包,需要修改 webpack,在 debug-react 目录下执行以下命令暴露 webpack 配置:
42 |
43 | ```
44 | cd ./debug-react
45 | yarn eject
46 | ```
47 |
48 | 在 `Are you sure you want to eject? This action is permanent.` 这里选择 y 就可以,命令执行完毕后在新增的 config 文件夹下可以看到 webpack 的配置文件:
49 |
50 |
51 |

52 |
53 |
54 | ### 引入 react 源码及修改 webpack
55 |
56 | 由于 node_modules 中的 react 包是打包好之后的文件,许多代码掺杂在一个文件中,不便于我们对源码进行调试。因此在 debug-react 的 src 目录下引入 react 的源码:
57 |
58 | ```
59 | git clone https://github.com/facebook/react.git -b 17.0.2
60 | ```
61 |
62 | 并在刚刚引入的 `src/react` 目录下执行一下命令安装依赖:
63 |
64 | ```
65 | yarn install
66 | ```
67 |
68 | 然后我们修改 webpack 的配置,使得在代码中引入的 react 等 npm 包的指向由 node_modules 改为刚刚引入的源码。在 `config/webpack.config.js` 下新增如下几个包的引用:
69 |
70 | ```diff
71 | // ...
72 | module.exports = {
73 | // ...
74 | resolve: {
75 | alias: {
76 | // Support React Native Web
77 | // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
78 | 'react-native': 'react-native-web',
79 | // Allows for better profiling with ReactDevTools
80 | ...(isEnvProductionProfile && {
81 | 'react-dom$': 'react-dom/profiling',
82 | 'scheduler/tracing': 'scheduler/tracing-profiling',
83 | }),
84 | ...(modules.webpackAliases || {}),
85 | + 'react': path.resolve(__dirname, '../src/react/packages/react'),
86 | + 'react-dom': path.resolve(__dirname, '../src/react/packages/react-dom'),
87 | + 'shared': path.resolve(__dirname, '../src/react/packages/shared'),
88 | + 'react-reconciler': path.resolve(__dirname, '../src/react/packages/react-reconciler'),
89 | },
90 | }
91 | }
92 | ```
93 |
94 | ### 修改环境变量
95 |
96 | 我们将 `__DEV__` 等环境变量默认启用,便于开发调试,修改 `config/env.js`:
97 |
98 | ```diff
99 | // ...
100 | function getClientEnvironment(publicUrl) {
101 | // ...
102 | const stringified = {
103 | + __DEV__: true,
104 | + __PROFILE__: true,
105 | + __UMD__: true,
106 | + __EXPERIMENTAL__: true,
107 | 'process.env': Object.keys(raw).reduce((env, key) => {
108 | env[key] = JSON.stringify(raw[key]);
109 | return env;
110 | }, {}),
111 | };
112 |
113 | return { raw, stringified };
114 | }
115 | ```
116 |
117 | 在 debug-react 的根目录下创建 `.eslintrc.json` 文件,内容如下:
118 |
119 | ```json
120 | {
121 | "extends": "react-app",
122 | "globals": {
123 | "__DEV__": true,
124 | "__PROFILE__": true,
125 | "__UMD__": true,
126 | "__EXPERIMENTAL__": true
127 | }
128 | }
129 | ```
130 |
131 | ### 解决一系列报错
132 |
133 | 上面的环境配置好之后,通过 `yarn start` 启动会出现一系列的报错问题,因为 react 中某些遍历是在打包时根据环境注入生成的,我们现在要直接调试源码,不进行 react 的打包,所以要解决这些报错。下面直接讲问题解决了。
134 |
135 | #### 添加 ReactFiberHostConfig 引用
136 |
137 | 如下报错
138 |
139 | ```
140 | Attempted import error: 'afterActiveInstanceBlur' is not exported from './ReactFiberHostConfig'.
141 | ```
142 |
143 | 解决方式:
144 |
145 | 直接修改 `src/react/packages/react-reconciler/src/ReactFiberHostConfig.js` 的内容如下:
146 |
147 | ```diff
148 | - import invariant from 'shared/invariant';
149 | - invariant(false, 'This module must be shimmed by a specific renderer.');
150 |
151 | + export * from './forks/ReactFiberHostConfig.dom'
152 | ```
153 |
154 | 另外修改 `src/react/packages/shared/ReactSharedInternals.js`,直接从引入 ReactSharedInternals 并导出:
155 |
156 | ```diff
157 | - import * as React from 'react';
158 | - const ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
159 |
160 | + import ReactSharedInternals from '../react/src/ReactSharedInternals';
161 | ```
162 |
163 | #### 修改 react 引用方式
164 |
165 | 如下报错
166 |
167 | ```
168 | Attempted import error: 'react' does not contain a default export (imported actFiberHosts 'React').
169 | ```
170 |
171 | 解决方式:
172 |
173 | 修改 `src/index.js` 中 react 和 react-dom 的引入方式:
174 |
175 | ```diff
176 | - import React from 'react';
177 | - import ReactDOM from 'react-dom';
178 | + import * as React from 'react';
179 | + import * as ReactDOM from 'react-dom';
180 | ```
181 |
182 | #### 修改 inveriant
183 |
184 | 如下报错
185 |
186 | ```
187 | Error: Internal React error: invariant() is meant to be replaced at compile time. There is no runtime version.
188 | ```
189 |
190 | 解决方式:
191 |
192 | 修改 `src/react/packages/shared/invariant.js` 的内容:
193 |
194 | ```diff
195 | export default function invariant(condition, format, a, b, c, d, e, f) {
196 | + if (condition) {
197 | + return;
198 | + }
199 | throw new Error(
200 | 'Internal React error: invariant() is meant to be replaced at compile ' +
201 | 'time. There is no runtime version.',
202 | );
203 | }
204 | ```
205 |
206 | #### 解决 eslint 报错
207 |
208 | 还剩下一堆有关 eslint 的报错,诸如:
209 |
210 | ```
211 | Failed to load config "fbjs" to extend from.
212 | ```
213 |
214 | 解决方式:
215 |
216 | eslint 报错的内容实在太多了,我这里直接简单粗暴的将 webpack 中 eslint 插件给关掉,修改 `src/config/webpack.config.js` 文件:
217 |
218 | ```diff
219 | module.exports = {
220 | // ...
221 | plugins: [
222 | // ...
223 | - !disableESLintPlugin &&
224 | - new ESLintPlugin({
225 | - // Plugin options
226 | - extensions: ['js', 'mjs', 'jsx', 'ts', 'tsx'],
227 | - formatter: require.resolve('react-dev-utils/eslintFormatter'),
228 | - eslintPath: require.resolve('eslint'),
229 | - failOnError: !(isEnvDevelopment && emitErrorsAsWarnings),
230 | - context: paths.appSrc,
231 | - cache: true,
232 | - cacheLocation: path.resolve(
233 | - paths.appNodeModules,
234 | - '.cache/.eslintcache'
235 | - ),
236 | - // ESLint class options
237 | - cwd: paths.appPath,
238 | - resolvePluginsRelativeTo: __dirname,
239 | - baseConfig: {
240 | - extends: [require.resolve('eslint-config-react-app/base')],
241 | - rules: {
242 | - ...(!hasJsxRuntime && {
243 | - 'react/react-in-jsx-scope': 'error',
244 | - }),
245 | - },
246 | - },
247 | - }),
248 | ]
249 | }
250 | ```
251 |
252 | ## 总结
253 |
254 | 至此,我们的调试环境就搭建完成了,可以在 react 源码中通过 `debugger` 打断点或者 `console.log()` 输出日志进行愉快地调试了!
255 |
256 | 最后贴一下搭建的调试环境的 github 地址:[debug-react](https://github.com/zh-lx/debug-react),不想自己搭建调试环境的话可以直接 clone 我搭好的环境使用。
257 |
--------------------------------------------------------------------------------
/docs/team/代码质量/CodeReview.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '众所周知,Code Review 是开发过程中一个非常重要的环节,但是很多公司或者团队是没有这一环节的,今天笔者结合自己所在团队,浅谈 Code Review 的价值及如何实施。'
3 | tag: ['代码规范', 'team']
4 | time: '2021-05-08'
5 | ---
6 |
7 | # 团队如何实施 Code Review
8 |
9 | 众所周知,Code Review 是开发过程中一个非常重要的环节,但是很多公司或者团队是没有这一环节的,今天笔者结合自己所在团队,浅谈 Code Review 的价值及如何实施。
10 |
11 | ## 1. Code Review 的价值
12 |
13 | 许多团队没有 Code Review 环节,或者因为追求项目快速上线,认为 CR 浪费时间;或者团队成员缺少 CR 观念,认为 CR 的价值并不大。所以想要推动 CR 在团队中的实施,最最重要的一点便是增强团队成员对 CR 环节的认同感。
14 |
15 |
16 | Code Review 环节,它更加依赖于团队成员的主观能动性,只有团队成员对其认可,他们才会积极地参入这一环节,CR 的价值才能最大化的体现。如果团队成员不认可 CR,即使强制设置了 CR 流程,也是形同虚设,反而可能阻碍正常开发流程的效率。那么如何让团队成员认可 CR 环节呢,自然是让他们意识到 CR 的价值,然后就会……真香!
17 |
18 | 
19 |
20 | ### 1.1 提升团队代码质量
21 |
22 | 随着团队规模的扩大和项目的迭代升级,团队之间的信息透明度会越来越低,项目的可维护性也会越来越差,可能引发如下一系列问题:
23 |
24 | 1. 已有的 utils 方法,重复造轮子
25 | 2. 代码过于复杂,缺少必要注释,后人难以维护
26 | 3. 目录结构五花八门,杂乱不堪
27 |
28 | ……
29 |
30 | 合理的 CR 环节,可以有效地把控每次提交的代码质量,不至于让项目的可维护性随着版本迭代和时间推移变得太差,这也是 CR 的首要目的。
31 | CR 环节并不会降低开发效率,就一次代码提交来说,也许部分人认为 CR 可能花费了时间,但是有效的 CR 给后人扩展和维护时所节省的时间是远超于此的。
32 |
33 | ### 1.2 团队技术交流
34 |
35 | Reviewer 和 Reviewee,在参与 CR 的过程中,都是可以收获到许多知识,进行技术交流的。
36 |
37 | 1. 有利于帮助新人快速成长,团队有新人加入时(如实习生和校招生),往往需要以为导师带领一段时间,通过 CR 环节,可以使导师最直接的了解到新人开发过程中所遇到的问题,作出相应的指导。
38 | 2. 通过 CR 环节,团队成员可以了解他人的业务,而不局限于自己的所负责的业务范围。项目发现问题时,可以迅速定位到相关业务的负责人进行修改。同时若有的团队成员离职后,也可以减少业务一人负责所带来的后期维护困难。
39 | 3. 学习他人的优秀代码。通过 CR 环节,可以迅速接触到团队成员在项目中解决某些问题的优秀代码,或者使用的一些你所未接触过的一些 api 等。
40 |
41 | ### 1.3 保证项目的统一规范
42 |
43 | 既然要进行 CR,首先要对项目的规范制定要求,包括编码风格规范、目录结构规范、业务规范等等。一方面,统一的项目规范才能保证项目的代码质量,提高项目的质量和可维护性;另一方面,在大家熟悉了统一的规范后,能够提升 CR 的效率,节省时间。
44 |
45 | ## 2. Code Review 的实践
46 |
47 | 关于 Code Review 的实践,要考虑的包括 CR 所花费的时间、CR 的形式、何时进行 CR 等等。
48 |
49 | ### 2.1 预留 CR 的时间
50 |
51 | 首先不得不承认,CR 环节是要耗费一定时间的,所以在项目排期中,不仅要考虑开发、联调、提测、改 bug 等时间,还要预留出 CR 的时间。包括担任 Reviewer 和 Reviewee 角色的时间都要考虑。
52 |
53 | 另外如果遇到的需求比较复杂,为了避免因为 CR 过程导致代码需要大量修改,最好提前和团队成员沟通好需求的设计和结果思路。
54 |
55 | ### 2.2 CR 的形式
56 |
57 | 我所见过的 CR 大多有两种形式。一种是设立一个特定时间,例如每周或者每半月等等,团队成员一起对之前的 Merge Request 进行 CR;另一种是对每次的 Merge Request 都进行 CR。
58 |
59 |
60 | 我个人更偏向于后者。第一种定期 CR,Merge Request 的数量太多,不太可能对所有的 MR 进行 CR,如果 CR 之后再对之前的诸多 MR 进行修改成本太大;而且一次性太多的 CR 会打击团队成员的积极性。第二种 MR 相对就轻松的多,可以考虑轮班每天设置 2-3 人对当天的 MR 进行 CR 即可。
61 |
62 | ### 2.3 CR 的时机
63 |
64 | CR 的环节应该设立在提测环节之前。因为 CR 后如果优化代码虽然理论上只是代码优化,但很可能会对业务逻辑产生影响,如果在提测时候,那么可能会影响到已经测试过的功能点。
65 |
66 | 当然也要分情况,如果遇到比较紧急的需求或者 bug 修复,那么也可以先提测,后续再做相应的 CR。
67 |
68 | ## 3. 对团队成员要求
69 |
70 | 前面已经提到,要增强团队成员对 CR 环节的认同感。作为 CR 环节的参与者,还应该根据自己的团队特点,对团队成员做出相应要求,可以参考我们团队。
71 |
72 | ### 3.1 Reviewer
73 |
74 | 1. 指明 review 的级别。reviewer 再给相应的代码添加评论时,建议指明评论的级别,可以在评论前用[]作出标识,例如:
75 | - [request]xxxxxxx 此条评论的代码必须修改才能予以通过
76 | - [advise]xxxxxxxx 此条评论的代码建议修改,但不修改也可以通过
77 | - [question]xxxxxx 此条评论的代码有疑问,需 reviewee 进一步解释
78 | 2. 讲明该评论的原因。在对代码做出评论时,应当解释清楚原因,如果自己有现成的更好地解决思路,应该把相应的解决思路也评论上,节省 reviewee 的修改时间。
79 | 3. 平等友善的评论。评论者在 review 的过程中,目的是提升项目代码质量,而不是抨击别人,质疑别人的能力,应该保持平等友善的语气。
80 | 4. 享受 Code Review。只有积极的参与 CR,把 CR 作为一种享受,才能将 CR 的价值最大化的体现。
81 |
82 | ### 3.2 Reviewee
83 |
84 | 1. 注重注释。对于复杂代码写明相应注释,在进行 commit 时也应简明的写清楚背景,帮助 reviewer 理解,提高 review 的效率。
85 | 2. 保持乐观的心态接受别人的 review。团队成员的 review 不是对你的批判,而是帮助你的提升,所以要尊重别人的 review,如果 review 你感觉不正确,可以在下面提出疑问,进一步解释。
86 | 3. 完成相应 review 的修改应当在下面及时进行回复,保持信息同步。
87 |
--------------------------------------------------------------------------------
/docs/team/代码质量/Js代码可读性.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '代码可读性的魅力也是这样,高可读性的代码,让别人抑郁理解,能够大量减少后期的维护时间。今天总结了 10 条常用的提高代码可读性的小方法,望大家不吝赐教。'
3 | tag: ['代码规范', 'js']
4 | time: '2021-05-18'
5 | ---
6 |
7 | # 10 种方法提高 js 代码可读性!
8 |
9 | 每个人都喜欢可读性高的代码,因为高可读性的代码总是能让人眼前一亮!
10 | 就好比你向周围的人说:快看,老师!周围的人可能不屑一顾:老师有什么好看的?但如果你说:快看,苍老师!那可能很多人会被你这句话所吸引。一字之差,结果截然不同。
11 | 代码可读性的魅力也是这样,高可读性的代码,让别人抑郁理解,能够大量减少后期的维护时间。今天总结了 10 条常用的提高代码可读性的小方法,望大家不吝赐教。
12 |
13 | ### 1.语义化命名
14 |
15 | 在声明变量时,尽量让自己的变量名称具有清晰的语义化,使他人一眼便能够看出这个变量的含义,在这种情况下,可以减少注释的使用。
16 |
17 | 示例:
18 |
19 | ```javascript
20 | // bad 别人看到会疑惑:这个list是什么的集合?
21 | const list = ['Teacher.Cang', 'Teacher.Bo', 'Teacher.XiaoZe'];
22 |
23 | // good 别人看到秒懂:原来是老师们的集合!
24 | const teacherList = ['Teacher.Cang', 'Teacher.Bo', 'Teacher.XiaoZe'];
25 | ```
26 |
27 | ### 2.各种类型命名
28 |
29 | 对于不同类型的变量值,我们可以通过一定的方式,让别人一看看上去就知道他的值类型。
30 | 一般来说,对于 boolean 类型或者 Array 类型的值,是最好区分的。例如:boolean 类型的值可以用 isXXX、hasXXX、canXXX 等命名;Array 类型的值可以用 xxxList、xxxArray 等方式命名。
31 |
32 | ```javascript
33 | // bad
34 | let belongToTeacher = true;
35 | let teachers = ['Teacher.Cang', 'Teacher.Bo', 'Teacher.XiaoZe'];
36 |
37 | // good
38 | let isTeacher = true;
39 | let teacherList = ['Teacher.Cang', 'Teacher.Bo', 'Teacher.XiaoZe'];
40 | ```
41 |
42 | ### 3.为常量声明
43 |
44 | 我们在阅读代码时,如果你突然在代码中看到一个字符串常量或者数字常量,你可能要花一定的时间去理解它的含义。如果使用`const`或者`enum`等声明一下这些常量,可读性将会有效得到提升。
45 |
46 | 示例:
47 |
48 | ```javascript
49 | // bad 别人看到会很疑惑:这个36D的含义是什么
50 | if (size === '36D') {
51 | console.log('It is my favorite');
52 | }
53 |
54 | // good 别人看到秒懂:36D是最喜欢的大小
55 | const FAVORITE_SIZE = '36D';
56 | if (size === FAVORITE_SIZE) {
57 | console.log('It is my favorite');
58 | }
59 | ```
60 |
61 | ### 4.避免上下文依赖
62 |
63 | 在遍历时,很多人会通过 value、item 甚至 v 等命名代表遍历的变量,但是当上下文过长时,这样的命名可读性就会变得很差。我们要尽量做到使读者即使不了解事情的来龙去脉的情况下,也能迅速理解这个变量代表的含义,而不是迫使读者去记住逻辑的上下文。
64 |
65 | ```javascript
66 | const teacherList = ['Teacher.Cang', 'Teacher.Bo', 'Teacher.XiaoZe'];
67 |
68 | // bad 别人看到循环的末尾处的item时需要在去上面看上下文理解item的含义
69 | teacherList.forEach((item) => {
70 | // do something
71 | // do something
72 | // do …………
73 | doSomethingWith(item);
74 | });
75 |
76 | // good 别人看到最后一眼就能明白变量的意思是老师
77 | teacherList.forEach((teacher) => {
78 | // do something
79 | // do something
80 | // do …………
81 | doSomethingWith(teacher);
82 | });
83 | ```
84 |
85 | ### 5.避免冗余命名
86 |
87 | 某些情况的变量命名,例如给对象的属性命名,直接命名该属性的含义即可,因为本身这个属性在对象中,无需再添加多余的前缀。
88 |
89 | ```javascript
90 | // bad
91 | const teacher = {
92 | teacherName: 'Teacher.Cang',
93 | teacherAge: 37,
94 | teacherSex: 'female',
95 | };
96 | console.log(person.personName);
97 |
98 | // good
99 | const teacher = {
100 | name: 'Teacher.Cang',
101 | age: 37,
102 | sex: 'female',
103 | };
104 | console.log(teacher.name);
105 | ```
106 |
107 | ### 6.使用参数默认值
108 |
109 | 相比短路,使用 ES6 的参数默认值能让人更轻易地理解未传参数时参数的赋默认值。
110 |
111 | ```javascript
112 | // bad 需要多看一步才能理解是赋默认值
113 | function getTeacherInfo(teacherName) {
114 | teacherName = teacherName || 'Teacher.Cang';
115 | // do...
116 | }
117 |
118 | // good 一看就能看出是赋默认值
119 | function getTeacherInfo(teacherName = 'Teacher.Cang') {
120 | // do...
121 | }
122 | ```
123 |
124 | ### 7.回调函数命名
125 |
126 | 很多人命名回调函数,尤其是为页面或者 DOM 元素等设置事件监听的回调函数时,习惯用事件的触发条件进行命名,这样做其实可读性是比较差的,别人看到只知道你出发了这个函数,但却需要花时间去理解这个函数做了什么。
我们在命名回调函数式,应当以函数所要执行的逻辑命名,让别人清晰地理解这个回调函数所要执行的逻辑。
127 |
128 | ```javascript
129 | // bad 需要花时间去看代码理解这个回调函数是做什么的
130 | ;
131 | function handleClick() {
132 | // do...
133 | }
134 |
135 | // good 一眼就能理解这个回调函数是提交表单
136 | ;
137 | function handleSubmitForm() {
138 | // do...
139 | }
140 | ```
141 |
142 | ### 8.减少函数的参数个数
143 |
144 | 一个函数如果参数的数量太多,使用的时候就难以记住每个参数的含义了,并且函数多个参数有顺序限制,我们在调用时需要去记住每个次序的参数的含义。通常情况下我们一个函数的参数个数在 1-2 个为佳,尽量不要超过三个。
145 | 当函数的参数比较多时,我们可以将同一类的参数使用对象进行合并,然后将合并后的对象作为参数传入,这样在调用该函数时能够很清楚地理解每个参数的含义。
146 |
147 | ```javascript
148 | // bad 调用时传的参数难以理解含义,需要记住顺序
149 | function createTeacher(name, sex, age, height, weight) {
150 | // do...
151 | }
152 | createTeacher('Teacher.Cang', 'female', 37, 155, 45);
153 |
154 | // good 调用时虽然写法略复杂了点,但各个参数含义一目了然,无需刻意记住顺序
155 | function createTeacher({ name, sex, age, height, weight }) {
156 | // do...
157 | }
158 | createTeacher({
159 | name: 'Teacher.Cang',
160 | sex: 'female',
161 | age: 37,
162 | height: 155,
163 | weight: 45,
164 | });
165 | ```
166 |
167 | ### 9.函数拆分
168 |
169 | 一个函数如果代码太长,那么可读性也是比较差的,我们应该尽量保持一个函数只处理一个功能,当逻辑复杂时将函数适当拆分。
170 |
171 | ```javascript
172 | // bad
173 | function initData() {
174 | let resTeacherList = axios.get('/teacher/list');
175 | teacherList = resTeacherList.data;
176 | const params = {
177 | pageSize: 20,
178 | pageNum: 1,
179 | };
180 | let resMovieList = axios.get('/movie/list', params);
181 | movieList = resMovieList.data;
182 | }
183 |
184 | // good
185 | function getTeacherList() {
186 | let resTeacherList = axios.get('/teacher/list');
187 | teacherList = resTeacherList.data;
188 | }
189 | function getMovieList() {
190 | const params = {
191 | pageSize: 20,
192 | pageNum: 1,
193 | };
194 | let resMovieList = axios.get('/movie/list', params);
195 | movieList = resMovieList.data;
196 | }
197 | function initData() {
198 | getTeacherList();
199 | getMovieList();
200 | }
201 | ```
202 |
203 | ### 10.注重写注释
204 |
205 | 不写注释应该是很多开发者的一个恶习,看别人不写注释的代码也是很多开发者最讨厌的事情。
206 | 所以,无论是为了自己还是别人,都请注重编写注释。
207 |
208 | ```javascript
209 | // bad 不写注释要花大量时间理解这个函数的作用
210 | function formatNumber(num) {
211 | if (num < 1000) {
212 | return num;
213 | } else {
214 | return `${(num / 1000).toFixed(1)}k`;
215 | }
216 | }
217 |
218 | // good 有了注释函数的作用和用法一目了然
219 | /**
220 | * @param num
221 | * @return num | x.xk
222 | * @example formatNumber(1000);
223 | * @description
224 | * 小于1k不转换
225 | * 大于1k转换为x.xk
226 | */
227 | function formatNumber(num) {
228 | if (num < 1000) {
229 | return num;
230 | } else {
231 | return `${(num / 1000).toFixed(1)}k`;
232 | }
233 | }
234 | ```
235 |
236 | 提高代码可读性的代码风格其实还有很多,以上笔者主要从变量命名、函数和注释三个方面,总结了 10 条比较常用的提高代码可读性的方法,希望对大家有所帮助。如有补充,欢迎评论。
237 |
--------------------------------------------------------------------------------
/docs/team/代码质量/前端规范化实践.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '多人协作项目及开源项目,制定团队协作规范十分重要,本篇将从项目 eslint 代码规范、单元测试、持续集成、commit 规范等方面的实践做一些总结。'
3 | tag: ['team']
4 | time: '2022-03-08'
5 | ---
6 |
7 | 多人协作项目及开源项目,制定团队协作规范十分重要,本篇将从项目 eslint 代码规范、单元测试、持续集成、commit 规范等方面的实践做一些总结。
8 |
9 | ## eslint 篇
10 |
11 | eslint 对多人协作项目配置规范化特别重要。
12 |
13 | ### 安装 eslint
14 |
15 | 执行如下指令,安装 eslint 并初始化:
16 |
17 | ```perl
18 | # 安装 eslint
19 | npm install eslint -D
20 |
21 | # 初始化
22 | npx eslint --init
23 | ```
24 |
25 | 如果要配合 typescript 使用,提前安装 typescript(如不使用 typescript 可忽略此步):
26 |
27 | ```js
28 | npm install typescript -D
29 | ```
30 |
31 | 
32 |
33 | ### 忽略部分文件的 eslint 检测
34 |
35 | 创建 `.eslintignore` 文件,可配置某些文件忽略 eslint 的检测,例如:
36 |
37 | ```
38 | src/test.js
39 | ```
40 |
41 | ### 集成 prettier
42 |
43 | 安装如下几个包:
44 |
45 | ```
46 | npm install prettier eslint-plugin-prettier eslint-config-prettier -D
47 | ```
48 |
49 | 在 `.eslintrc.js` 中添加 `plugin:prettier/recommended` 并且添加 prettier 的 rules:
50 |
51 | ```js
52 | module.exports = {
53 | // ...
54 | extends: ['plugin:prettier/recommended'], // 要放在 estends 数组的最后一项
55 | rules: {
56 | 'prettier/prettier': 'error',
57 | // ...
58 | },
59 | // ...
60 | };
61 | ```
62 |
63 | 然后在根目录新建 `.prettierrc` 里面配置自己的 prettier 规则,例如:
64 |
65 | ```json
66 | {
67 | "singleQuote": true,
68 | "semi": true,
69 | "endOfLine": "auto",
70 | "tabWidth": 2,
71 | "printWidth": 80
72 | }
73 | ```
74 |
75 | ### 集成 husky 和 githook
76 |
77 | 安装 lint-staged
78 |
79 | ```
80 | npm install lint-staged -D
81 | ```
82 |
83 | 指定 lint-staged 只对暂存区的文件进行检查,在 `package.json` 中新增如下内容:
84 |
85 | ```json
86 | {
87 | // ...
88 | "lint-staged": {
89 | "**/*.{jsx,txs,ts,js,vue}": ["eslint --fix", "git add"]
90 | }
91 | }
92 | ```
93 |
94 | 集成 husky,新版本(v7) 的 husky 通过如下方式集成:
95 |
96 | ```perl
97 | # 安装 husky
98 | npm install husky -D
99 |
100 | # husky 初始化,创建 .husky 目录并指定该目录为 git hooks 所在的目录
101 | npx husky install
102 |
103 | # 指定 husky 在 commit 之前运行 lint-staged 来检查代码
104 | npx husky add .husky/pre-commit "npx lint-staged"
105 | ```
106 |
107 | 由于 husky 是装在本地的,在 `package.json` 中新增如下指令,项目安装依赖时同时预装 husky:
108 |
109 | ```json
110 | {
111 | // ...
112 | "scripts": {
113 | // ...
114 | "prepare": "npx husky install"
115 | }
116 | // ...
117 | }
118 | ```
119 |
120 | ### 配合 vscode
121 |
122 | vscode 安装 eslint 插件,让我们在编写代码时就能够进行错误提示:
123 |
124 | 
125 |
126 | ## 测试篇
127 |
128 | ### 单元测试
129 |
130 | #### 安装测试库
131 |
132 | 执行如下命令,安装测试库 `mocha` + 断言库 `chai`:
133 |
134 | ```perl
135 | npm install mocha chai -D
136 | ```
137 |
138 | #### 编写测试用例
139 |
140 | 要测试如下 `src/index.js` 中的内容:
141 |
142 | ```js
143 | function add(a, b) {
144 | return a + b;
145 | }
146 |
147 | function sub(a, b) {
148 | return a - b;
149 | }
150 |
151 | module.exports.add = add;
152 | module.exports.sub = sub;
153 | ```
154 |
155 | 新建 `test/index.test.js` 测试文件,编写如下测试用例:
156 |
157 | ```js
158 | const { add, sub } = require('../src/index');
159 | const expect = require('chai').expect;
160 |
161 | describe('测试', function () {
162 | it('加法', function () {
163 | const result = add(2, 3);
164 | expect(result).to.be.equal(5);
165 | });
166 |
167 | it('减法', function () {
168 | const result = sub(2, 3);
169 | expect(result).to.be.equal(-1);
170 | });
171 | });
172 | ```
173 |
174 | #### 执行测试命令
175 |
176 | 在 `package.json` 文件中新增如下 `test` 命令:
177 |
178 | ```json
179 | {
180 | // ...
181 | "scripts": {
182 | // ...
183 | "test": "node_modules/mocha/bin/_mocha"
184 | }
185 | // ...
186 | }
187 | ```
188 |
189 | 执行 `npm run test`,即可看到测试执行结果:
190 |
191 | 
192 |
193 | #### 增加测试覆盖率
194 |
195 | 执行如下命令,安装 [istanbul](https://github.com/gotwarlost/istanbul):
196 |
197 | ```
198 | npm install istanbul -D
199 | ```
200 |
201 | 在 `package.json` 中增加如下指令:
202 |
203 | ```json
204 | {
205 | // ...
206 | "scripts": {
207 | // ...
208 | "test": "node_modules/mocha/bin/_mocha",
209 | "test:cover": "istanbul cover node_modules/mocha/bin/_mocha"
210 | }
211 | // ...
212 | }
213 | ```
214 |
215 | 执行 `npm run test:cover`,即可看到测试覆盖率:
216 |
217 | 
218 | 覆盖率说明:
219 |
220 | - 语句覆盖率(Statements):是否每个语句都执行了
221 | - 分支覆盖率(Branchs):是否每个 if 代码块都执行了
222 | - 函数覆盖率(Functions):是否每个函数都调用了
223 | - 行覆盖率(Lines):是否每一行都执行了
224 |
225 | ## 持续集成(CI)
226 |
227 | 通过持续集成,我们可以进行一些自动化构建的任务以及快速发现错误。
228 |
229 | 常见的 github CI 有 [Travis CI](https://app.travis-ci.com/)、[Circle CI](https://circleci.com/)、[Jenkins](https://www.jenkins.io/) 等,这里我们以 Travis CI 为例。
230 |
231 | ### 登录 Travis 账号
232 |
233 | github 登录 [Travis CI](https://app.travis-ci.com/),选择下图选项,确保对应 git 项目开启了 Travis CI:
234 |
235 | 
236 |
237 | ### 创建 .travis.yml
238 |
239 | 在项目根目录添加 `.travis.yml` 文件,添加对应的构建内容,例如:
240 |
241 | ```yml
242 | language: node_js
243 | sudo: false
244 |
245 | cache:
246 | apt: true
247 | directories:
248 | - node_modules # 对 node_modules 文件夹开启缓存以便更快安装依赖
249 |
250 | node_js: stable # 设置相应版本
251 |
252 | install:
253 | - npm install -D # 安装依赖
254 |
255 | script:
256 | - npm run test:cover
257 | ```
258 |
259 | 项目提交代码后,可以在 [Travis CI](https://app.travis-ci.com/) 看到项目 CI 的情况:
260 |
261 | 
262 |
263 | ## 规范 commit & 自动生成 changelog
264 |
265 | 良好的 commit 能够帮助我们更好维护代码以及提高 code review 的效率。
266 |
267 | ### commit 准则
268 |
269 | 大多数团队都会通过在 commit 最前面加上一个 tag 的方式来快速区分 commit 类型:
270 |
271 | - feat: 新功能特性
272 | - fix: 修复问题
273 | - refactor: 代码重构,没有新增功能或者修复问题
274 | - docs: 仅修改了文档
275 | - style: 代码格式修改,如增加空格,修改单双引号等
276 | - test: 测试用例修改
277 | - chore: 改变构建流程、增加依赖库或者工具等
278 | - revert: 回滚上一个版本
279 | - ci:ci 流程修改
280 | - perf: 体验、性能优化
281 |
282 | ### 使用 git-cz 规范 commit
283 |
284 | 执行以下命令,安装 `commitizen` 和 `cz-conventional-changelog`:
285 |
286 | ```perl
287 | npm install commitizen cz-conventional-changelog -D
288 | ```
289 |
290 | 修改 `package.json` 文件,新增以下内容:
291 |
292 | ```json
293 | {
294 | // ...
295 | "scripts": {
296 | // ...
297 | "commit": "git-cz"
298 | },
299 | "config": {
300 | "commitizen": {
301 | "path": "./node_modules/cz-conventional-changelog"
302 | }
303 | }
304 | // ...
305 | }
306 | ```
307 |
308 | 执行 `npm run commit` 命令,即可自动进行规范化提交:
309 |
310 | 
311 |
312 | ### 自动生成 changelog
313 |
314 | 执行如下命令安装 `conventional-changelog-cli`:
315 |
316 | ```
317 | npm install conventional-changelog-cli -D
318 | ```
319 |
320 | 在 `package.json` 中新增如下内容:
321 |
322 | ```json
323 | {
324 | // ...
325 | "scripts": {
326 | // ...
327 | "genlog": "conventional-changelog -p angular -i CHANGELOG.md -s"
328 | }
329 | // ...
330 | }
331 | ```
332 |
333 | 执行 `npm run genlog` 命令,会自动在 `CHANGELOG.md` 文件中增加 commit 的信息:
334 |
335 | 
336 |
--------------------------------------------------------------------------------
/docs/vue2/通信方式.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: 'vue 组件通信的方式,这是在面试中一个非常高频的问题,今天对 vue 组件通信方式进行一下总结。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/4be45e22-e429-4b6f-9881-3ab517f01fd5'
4 | tag: ['vue', 'vue2']
5 | time: '2021-04-24'
6 | ---
7 |
8 | # Vue 组件通信方式汇总
9 |
10 | vue 组件通信的方式,这是在面试中一个非常高频的问题,我刚开始找实习便经常遇到这个问题,当时只知道回到 props 和$emit,后来随着学习的深入,才发现 vue 组件的通信方式竟然有这么多!
11 | 今天对 vue 组件通信方式进行一下总结,如写的有疏漏之处还请大家不吝赐教。
12 |
13 | ## 1. props/\$emit
14 |
15 | ### 简介
16 |
17 | props 和\$emit 相信大家十分的熟悉了,这是我们最常用的 vue 通信方式。
18 | props:props 可以是数组或对象,用于接收来自父组件通过 v-bind 传递的数据。当 props 为数组时,直接接收父组件传递的属性;当 props 为对象时,可以通过 type、default、required、validator 等配置来设置属性的类型、默认值、是否必传和校验规则。
19 | \$emit:在父子组件通信时,我们通常会使用\$emit 来触发父组件 v-on 在子组件上绑定相应事件的监听。
20 |
21 | ### 代码实例
22 |
23 | 下面通过代码来实现一下 props 和\$emit 的父子组件通信,在这个实例中,我们都实现了以下的通信:
24 |
25 | - 父向子传值:父组件通过`:messageFromParent="message"`将父组件 message 值传递给子组件,当父组件的 input 标签输入时,子组件 p 标签中的内容就会相应改变。
26 | - 子向父传值:父组件通过`@on-receive="receive"`在子组件上绑定了 receive 事件的监听,子组件 input 标签输入时,会触发 receive 回调函数, 通过`this.$emit('on-receive', this.message)`将子组件 message 的值赋值给父组件 messageFromChild ,改变父组件 p 标签的内容。
27 | 请看代码:
28 |
29 | ```html
30 | // 子组件代码
31 |
32 |
33 |
this is child component
34 |
35 |
收到来自父组件的消息:{{ messageFromParent }}
36 |
37 |
38 |
54 | ```
55 |
56 | ```html
57 | // 父组件代码
58 |
59 |
60 |
this is parent component
61 |
62 |
收到来自子组件的消息:{{ messageFromChild }}
63 |
64 |
65 |
66 |
87 | ```
88 |
89 | ### 效果预览
90 |
91 | 
92 |
93 | ## 2. v-slot
94 |
95 | ### 简介
96 |
97 | v-slot 是 Vue2.6 版本中新增的用于统一实现插槽和具名插槽的 api,用于替代`slot(2.6.0废弃)`、`slot-scope(2.6.0废弃)`、`scope(2.5.0废弃)`等 api。
98 | v-slot 在 template 标签中用于提供具名插槽或需要接收 prop 的插槽,如果不指定 v-slot ,则取默认值 default 。
99 |
100 | ### 代码实例
101 |
102 | 下面请看 v-slot 的代码实例,在这个实例中我们实现了:
103 |
104 | - 父向子传值:父组件通过`{{ message }}`将父组件的 message 值传递给子组件,子组件通过``接收到相应内容,实现了父向子传值。
105 |
106 | ```html
107 | // 子组件代码
108 |
109 |
110 |
this is child component
111 |
112 | 收到来自父组件的消息:
113 |
114 |
115 |
116 |
117 | ```
118 |
119 | ```html
120 |
121 |
122 |
this is parent component
123 |
124 |
125 |
126 | {{ message }}
127 |
128 |
129 |
130 |
131 |
132 |
146 | ```
147 |
148 | ### 效果预览
149 |
150 | 
151 |
152 | ## 3. \$refs/\$parent/\$children/\$root
153 |
154 | ### 简介
155 |
156 | 我们也同样可以通过 `$refs/$parent/$children/$root` 等方式获取 Vue 组件实例,得到实例上绑定的属性及方法等,来实现组件之间的通信。
157 | \$refs:我们通常会将 \$refs 绑定在 DOM 元素上,来获取 DOM 元素的 attributes。在实现组件通信上,我们也可以将 \$refs 绑定在子组件上,从而获取子组件实例。
158 | \$parent:我们可以在 Vue 中直接通过`this.$parent`来获取当前组件的父组件实例(如果有的话)。
159 | \$children:同理,我们也可以在 Vue 中直接通过`this.$children`来获取当前组件的子组件实例的数组。但是需要注意的是,`this.$children`数组中的元素下标并不一定对用父组件引用的子组件的顺序,例如有异步加载的子组件,可能影响其在 children 数组中的顺序。所以使用时需要根据一定的条件例如子组件的 name 去找到相应的子组件。
160 | \$root:获取当前组件树的根 Vue 实例。如果当前实例没有父实例,此实例将会是其自己。通过 $root ,我们可以实现组件之间的跨级通信。
161 |
162 | ### 代码实例
163 |
164 | 下面来看一个 \$parent 和 \$children 使用的实例(由于这几个 api 的使用方式大同小异,所以关于 \$refs 和 \$root 的使用就不在这里展开了,在这个实例中实现了:
165 |
166 | - 父向子传值:子组件通过`$parent.message`获取到父组件中 message 的值。
167 | - 子向父传值:父组件通过`$children`获取子组件实例的数组,在通过对数组进行遍历,通过实例的 name 获取到对应 Child1 子组件实例将其赋值给 child1,然后通过`child1.message`获取到 Child1 子组件的 message。
168 | 代码如下:
169 |
170 | ```html
171 | // 子组件
172 |
173 |
174 |
this is child component
175 |
176 |
收到来自父组件的消息:{{ $parent.message }}
177 |
178 |
179 |
180 |
190 | ```
191 |
192 | ```html
193 | // 父组件
194 |
195 |
196 |
this is parent component
197 |
198 |
收到来自子组件的消息:{{ child1.message }}
199 |
200 |
201 |
202 |
203 |
223 | ```
224 |
225 | ### 效果预览
226 |
227 | 
228 |
229 | ## 4. \$attrs/\$listener
230 |
231 | ### 简介
232 |
233 | \$attrs 和 \$listeners 都是 Vue2.4 中新增加的属性,主要是用来供使用者用来开发高级组件的。
234 | \$attrs:用来接收父作用域中不作为 prop 被识别的 attribute 属性,并且可以通过`v-bind="$attrs"`传入内部组件——在创建高级别的组件时非常有用。
235 | 试想一下,当你创建了一个组件,你要接收 param1 、param2、param3 …… 等数十个参数,如果通过 props,那你需要通过`props: ['param1', 'param2', 'param3', ……]`等声明一大堆。如果这些 props 还有一些需要往更深层次的子组件传递,那将会更加麻烦。
236 | 而使用 \$attrs ,你不需要任何声明,直接通过`$attrs.param1`、`$attrs.param2`……就可以使用,而且向深层子组件传递上面也给了示例,十分方便。
237 | \$listeners:包含了父作用域中的 v-on 事件监听器。它可以通过 `v-on="$listeners"` 传入内部组件——在创建更高层次的组件时非常有用,这里在传递时的使用方法和 \$attrs 十分类似。
238 |
239 | ### 代码实例
240 |
241 | 在这个实例中,共有三个组件:A、B、C,其关系为:[ A [ B [C] ] ],A 为 B 的父组件,B 为 C 的父组件。即:1 级组件 A,2 级组件 B,3 级组件 C。我们实现了:
242 |
243 | - 父向子传值:1 级组件 A 通过`:messageFromA="message"`将 message 属性传递给 2 级组件 B,2 级组件 B 通过`$attrs.messageFromA`获取到 1 级组件 A 的 message 。
244 | - 跨级向下传值:1 级组件 A 通过`:messageFromA="message"`将 message 属性传递给 2 级组件 B,2 级组件 B 再通过` v-bind="$attrs"`将其传递给 3 级组件 C,3 级组件 C 通过`$attrs.messageFromA`获取到 1 级组件 A 的 message 。
245 | - 子向父传值:1 级组件 A 通过`@keyup="receive"`在子孙组件上绑定 keyup 事件的监听,2 级组件 B 在通过`v-on="$listeners"`来将 keyup 事件绑定在其 input 标签上。当 2 级组件 B input 输入框输入时,便会触发 1 级组件 A 的 receive 回调,将 2 级组件 B 的 input 输入框中的值赋值给 1 级组件 A 的 messageFromComp ,从而实现子向父传值。
246 | - 跨级向上传值:1 级组件 A 通过`@keyup="receive"`在子孙组件上绑定 keyup 事件的监听,2 级组件 B 在通过` `将其继续传递给 C。3 级组件 C 在通过`v-on="$listeners"`来将 keyup 事件绑定在其 input 标签上。当 3 级组件 C input 输入框输入时,便会触发 1 级组件 A 的 receive 回调,将 3 级组件 C 的 input 输入框中的值赋值给 1 级组件 A 的 messageFromComp ,从而实现跨级向上传值。
247 | 代码如下:
248 |
249 | ```html
250 | // 3级组件C
251 |
252 |
253 |
this is C component
254 |
255 |
256 |
收到来自A组件的消息:{{ $attrs.messageFromA }}
257 |
258 |
259 |
269 | ```
270 |
271 | ```html
272 | // 2级组件B
273 |
274 |
275 |
this is B component
276 |
277 |
278 |
收到来自A组件的消息:{{ $attrs.messageFromA }}
279 |
280 |
281 |
282 |
283 |
297 | ```
298 |
299 | ```html
300 | // A组件
301 |
302 |
303 |
this is A component
304 |
305 |
收到来自{{ comp }}的消息:{{ messageFromComp }}
306 |
307 |
308 |
309 |
310 |
333 | ```
334 |
335 | ### 效果预览
336 |
337 | 
338 |
339 | ## 5. provide/inject
340 |
341 | ### 简介
342 |
343 | provide/inject 这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在其上下游关系成立的时间里始终生效。如果你是熟悉 React 的同学,你一定会立刻想到 Context 这个 api,二者是十分相似的。
344 | provide:是一个对象,或者是一个返回对象的函数。该对象包含可注入其子孙的 property ,即要传递给子孙的属性和属性值。
345 | injcet:一个字符串数组,或者是一个对象。当其为字符串数组时,使用方式和 props 十分相似,只不过接收的属性由 data 变成了 provide 中的属性。当其为对象时,也和 props 类似,可以通过配置 default 和 from 等属性来设置默认值,在子组件中使用新的命名属性等。
346 |
347 | ### 代码实例
348 |
349 | 这个实例中有三个组件,1 级组件 A,2 级组件 B,3 级组件 C:[ A [ B [C] ] ],A 是 B 的父组件,B 是 C 的父组件。实例中实现了:
350 |
351 | - 父向子传值:1 级组件 A 通过 provide 将 message 注入给子孙组件,2 级组件 B 通过`inject: ['messageFromA']`来接收 1 级组件 A 中的 message,并通过`messageFromA.content`获取 1 级组件 A 中 message 的 content 属性值。
352 | - 跨级向下传值:1 级组件 A 通过 provide 将 message 注入给子孙组件,3 级组件 C 通过`inject: ['messageFromA']`来接收 1 级组件 A 中的 message,并通过`messageFromA.content`获取 1 级组件 A 中 message 的 content 属性值,实现跨级向下传值。
353 | 代码如下:
354 |
355 | ```html
356 | // 1级组件A
357 |
358 |
359 |
this is A component
360 |
361 |
362 |
363 |
364 |
385 | ```
386 |
387 | ```html
388 | // 2级组件B
389 |
390 |
391 |
this is B component
392 |
收到来自A组件的消息:{{ messageFromA && messageFromA.content }}
393 |
394 |
395 |
396 |
406 | ```
407 |
408 | ```html
409 | // 3级组件C
410 |
411 |
412 |
this is C component
413 |
收到来自A组件的消息:{{ messageFromA && messageFromA.content }}
414 |
415 |
416 |
422 | ```
423 |
424 | 注意点:
425 |
426 | 1. 可能有同学想问我上面 1 级组件 A 中的 message 为什么要用 object 类型而不是 string 类型,因为在 vue provide 和 inject 绑定并不是可响应的。如果 message 是 string 类型,在 1 级组件 A 中通过 input 输入框改变 message 值后无法再赋值给 messageFromA,如果是 object 类型,当对象属性值改变后,messageFromA 里面的属性值还是可以随之改变的,子孙组件 inject 接收到的对象属性值也可以相应变化。
427 | 2. 子孙 provide 和祖先同样的属性,会在后代中覆盖祖先的 provide 值。例如 2 级组件 B 中也通过 provide 向 3 级组件 C 中注入一个 messageFromA 的值,则 3 级组件 C 中的 messageFromA 会优先接收 2 级组件 B 注入的值而不是 1 级组件 A。
428 |
429 | ### 效果预览
430 |
431 | 
432 |
433 | ## 6. eventBus
434 |
435 | ### 简介
436 |
437 | eventBus 又称事件总线,通过注册一个新的 Vue 实例,通过调用这个实例的\$emit 和\$on 等来监听和触发这个实例的事件,通过传入参数从而实现组件的全局通信。它是一个不具备 DOM 的组件,有的仅仅只是它实例方法而已,因此非常的轻便。
438 | 我们可以通过在全局 Vue 实例上注册:
439 |
440 | ```javascript
441 | // main.js
442 | Vue.prototype.$Bus = new Vue();
443 | ```
444 |
445 | 但是当项目过大时,我们最好将事件总线抽象为单个文件,将其导入到需要使用的每个组件文件中。这样,它不会污染全局命名空间:
446 |
447 | ```javascript
448 | // bus.js,使用时通过import引入
449 | import Vue from 'vue';
450 | export const Bus = new Vue();
451 | ```
452 |
453 | ### 原理分析
454 |
455 | eventBus 的原理其实比较简单,就是使用订阅-发布模式,实现\$emit 和\$on 两个方法即可:
456 |
457 | ```javascript
458 | // eventBus原理
459 | export default class Bus {
460 | constructor() {
461 | this.callbacks = {};
462 | }
463 | $on(event, fn) {
464 | this.callbacks[event] = this.callbacks[event] || [];
465 | this.callbacks[event].push(fn);
466 | }
467 | $emit(event, args) {
468 | this.callbacks[event].forEach((fn) => {
469 | fn(args);
470 | });
471 | }
472 | }
473 |
474 | // 在main.js中引入以下
475 | // Vue.prototype.$bus = new Bus()
476 | ```
477 |
478 | ### 代码实例
479 |
480 | 在这个实例中,共包含了 4 个组件:[ A [ B [ C、D ] ] ],1 级组件 A,2 级组件 B,3 级组件 C 和 3 级组件 D。我们通过使用 eventBus 实现了:
481 |
482 | - 全局通信:即包括了父子组件相互通信、兄弟组件相互通信、跨级组件相互通信。4 个组件的操作逻辑相同,都是在 input 输入框时,通过`this.$bus.$emit('sendMessage', obj)`触发 sendMessage 事件回调,将 sender 和 message 封装成对象作为参数传入;同时通过`this.$bus.$on('sendMessage', obj)`监听其他组件的 sendMessage 事件,实例当前组件示例 sender 和 message 的值。这样任一组件 input 输入框值改变时,其他组件都能接收到相应的信息,实现全局通信。
483 | 代码如下:
484 |
485 | ```main.js
486 | // main.js
487 | Vue.prototype.$bus = new Vue()
488 | ```
489 |
490 | ```html
491 | // 1级组件A
492 |
493 |
494 |
this is CompA
495 |
496 |
497 | 收到{{ sender }}的消息:{{ messageFromBus }}
498 |
499 |
500 |
501 |
502 |
535 | ```
536 |
537 | ```html
538 | // 2级组件B
539 |
540 |
541 |
this is CompB
542 |
543 |
544 | 收到{{ sender }}的消息:{{ messageFromBus }}
545 |
546 |
547 |
548 |
549 |
550 |
585 | ```
586 |
587 | ```html
588 | // 3级组件C
589 |
590 |
591 |
this is CompC
592 |
593 |
594 | 收到{{ sender }}的消息:{{ messageFromBus }}
595 |
596 |
597 |
598 |
627 | ```
628 |
629 | ```html
630 | // 3级组件D
631 |
632 |
633 |
this is CompD
634 |
635 |
636 | 收到{{ sender }}的消息:{{ messageFromBus }}
637 |
638 |
639 |
640 |
669 | ```
670 |
671 | ### 效果预览
672 |
673 | 
674 |
675 | ## 7. Vuex
676 |
677 | 当项目庞大以后,在多人维护同一个项目时,如果使用事件总线进行全局通信,容易让全局的变量的变化难以预测。于是有了 Vuex 的诞生。
678 | Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。
679 | 有关 Vuex 的内容,可以参考[Vuex 官方文档](https://vuex.vuejs.org/zh/),我就不在这里班门弄斧了,直接看代码。
680 |
681 | ### 代码实例
682 |
683 | Vuex 的实例和事件总线 leisi,同样是包含了 4 个组件:[ A [ B [ C、D ] ] ],1 级组件 A,2 级组件 B,3 级组件 C 和 3 级组件 D。我们在这个实例中实现了:
684 |
685 | - 全局通信:代码的内容和 eventBus 也类似,不过要比 eventBus 使用方便很多。每个组件通过 watch 监听 input 输入框的变化,把 input 的值通过 vuex 的 commit 触发 mutations,从而改变 stroe 的值。然后每个组件都通过 computed 动态获取 store 中的数据,从而实现全局通信。
686 |
687 | ```javascript
688 | // store.js
689 | import Vue from 'vue';
690 | import Vuex from 'vuex';
691 | Vue.use(Vuex);
692 | export default new Vuex.Store({
693 | state: {
694 | message: {
695 | sender: '',
696 | content: '',
697 | },
698 | },
699 | mutations: {
700 | sendMessage(state, obj) {
701 | state.message = {
702 | sender: obj.sender,
703 | content: obj.content,
704 | };
705 | },
706 | },
707 | });
708 | ```
709 |
710 | ```html
711 | // 组件A
712 |
713 |
714 |
this is CompA
715 |
716 |
717 | 收到{{ sender }}的消息:{{ messageFromStore }}
718 |
719 |
720 |
721 |
722 |
752 | ```
753 |
754 | 同样和 eventBus 中一样,B,C,D 组件中的代码除了引入子组件的不同,script 部分都是一样的,就不再往上写了。
755 |
756 | ### 效果预览
757 |
758 | 
759 |
760 | ## 总结
761 |
762 | 上面总共提到了 7 中 Vue 的组件通信方式,他们能够进行的通信种类如下图所示:
763 | 
764 |
765 | - props/\$emit:可以实现父子组件的双向通信,在日常的父子组件通信中一般会作为我们的最常用选择。
766 | - v-slot:可以实现父子组件单向通信(父向子传值),在实现可复用组件,向组件中传入 DOM 节点、html 等内容以及某些组件库的表格值二次处理等情况时,可以优先考虑 v-slot。
767 | - \$refs/\$parent/\$children/\$root:可以实现父子组件双向通信,其中\$root 可以实现根组件实例向子孙组件跨级单向传值。在父组件没有传递值或通过 v-on 绑定监听时,父子间想要获取彼此的属性或方法可以考虑使用这些 api。
768 | - \$attrs/\$listeners:能够实现跨级双向通信,能够让你简单的获取传入的属性和绑定的监听,并且方便地向下级子组件传递,在构建高级组件时十分好用。
769 | - provide/inject:可以实现跨级单向通信,轻量地向子孙组件注入依赖,这是你在实现高级组件、创建组件库时的不二之选。
770 | - eventBus:可以实现全局通信,在项目规模不大的情况下,可以利用 eventBus 实现全局的事件监听。但是 eventBus 要慎用,避免全局污染和内存泄漏等情况。
771 | - Vuex:可以实现全局通信,是 vue 项目全局状态管理的最佳实践。在项目比较庞大,想要集中式管理全局组件状态时,那么安装 Vuex 准没错!
772 |
773 | > 最后,鲁迅说过:“一碗酸辣汤,耳闻口讲的,总不如亲自呷一口的明白。”
(鲁迅:这句话我真说过!)
看了这么多,不如自己亲手去敲一敲更能理解,看完可以去手动敲一敲加深理解。
774 |
--------------------------------------------------------------------------------
/docs/代码仓库/git/Git操作指令大全.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本文总结了日常工作中常用的 git 指令,涵盖了绝大部分的使用场景,让你能够轻松应对各种 git 协作流程。'
3 | cover: 'https://github.com/zh-lx/blog/assets/73059627/789ee5e2-6e23-4ab5-9e1c-37b2ebaef68d'
4 | tag: ['git']
5 | time: '2021-12-31'
6 | ---
7 |
8 | # Git 操作指令大全
9 |
10 | 本文总结了日常工作中常用的 git 指令,涵盖了绝大部分的使用场景,让你能够轻松应对各种 git 协作流程。
11 |
12 | ## 理解 git 工作区域
13 |
14 | 根据 git 的几个文件存储区域,git 的工作区域可以划分为 4 个:
15 |
16 | - 工作区:你在本地编辑器里改动的代码,所见即所得,里面的内容都是最新的
17 | - 暂存区:通过 `git add` 指令,会将你工作区改动的代码提交到暂存区里
18 | - 本地仓库:通过 `git commit` 指令,会将暂存区变动的代码提交到本地仓库中,本地仓库位于你的电脑上
19 | - 远程仓库:远端用来托管代码的仓库,通过 `git push` 指令,会将本地仓库的代码推送到远程仓库中
20 |
21 | 
22 |
23 | ## 初始配置
24 |
25 | ### 配置用户信息
26 |
27 | 首次使用 git 时,设置提交代码时的信息:
28 |
29 | ```perl
30 | # 配置用户名
31 | git config --global user.name "yourname"
32 |
33 | # 配置用户邮箱
34 | git config --global user.email "youremail@xxx.com"
35 |
36 | # 查看当前的配置信息
37 | git config --global --list
38 |
39 | # 通过 alias 配置简写
40 | ## 例如使用 git co 代替 git checkout
41 | git config --global alias.co checkout
42 | ```
43 |
44 | ### ssh key
45 |
46 | 向远端仓库提交代码时,需要在远端仓库添加本地生成的 ssh key。
47 |
48 | 1. 生成本地 ssh key,若已有直接到第 2 步:
49 |
50 | ```
51 | ssh-keygen -t rsa -C "youremail@xxx.com"
52 | ```
53 |
54 | 2. 查看本地 ssh key:
55 |
56 | ```
57 | cat ~/.ssh/id_rsa.pub
58 | ```
59 |
60 | 3. 将 ssh key 粘贴到远端仓库:
61 |
62 | 
63 |
64 | ## 高频命令
65 |
66 | 以下是最常用的操作,需要任何一个开发者学会的 git 命令:
67 |
68 | ### git clone: 克隆仓库
69 |
70 | ```perl
71 | # 克隆远端仓库到本地
72 | git clone
73 |
74 | # 克隆远端仓库到本地,并同时切换到指定分支 branch1
75 | git clone -b branch1
76 |
77 | # 克隆远端仓库到本地并指定本地仓库的文件夹名称为 my-project
78 | git clone my-project
79 | ```
80 |
81 | ### git add: 提交到暂存区
82 |
83 | 工作区提交到暂存区,用到的指令为 `git add`:
84 |
85 | ```perl
86 | # 将所有修改的文件都提交到暂存区
87 | git add .
88 |
89 | # 将修改的文件中的指定的文件 a.js 和 b.js 提交到暂存区
90 | git add ./a.js ./b.js
91 |
92 | # 将 js 文件夹下修改的内容提交到暂存区
93 | git add ./js
94 | ```
95 |
96 | ### git commit: 提交到本地仓库
97 |
98 | 将工作区内容提交到本地仓库所用到的指令为 `git commit`:
99 |
100 | ```perl
101 | # 将工作区内容提交到本地仓库,并添加提交信息 your commit message
102 | git commit -m "your commit message"
103 |
104 | # 将工作区内容提交到本地仓库,并对上一次 commit 记录进行覆盖
105 | ## 例如先执行 git commit -m "commit1" 提交了文件a,commit_sha为hash1;再执行 git commit -m "commit2" --amend 提交文件b,commit_sha为hash2。最终显示的是a,b文件的 commit 信息都是 "commit2",commit_sha都是hash2
106 | git commit -m "new message" --amend
107 |
108 | # 将工作区内容提交到本地仓库,并跳过 commit 信息填写
109 | ## 例如先执行 git commit -m "commit1" 提交了文件a,commit_sha为hash1;再执行 git commit --amend --no-edit 提交文件b,commit_sha为hash2。最终显示的是a,b文件的 commit 信息都是 "commit1",commit_sha都是hash1
110 | git commit --amend --no-edit
111 |
112 | # 跳过校验直接提交,很多项目配置 git hooks 验证代码是否符合 eslint、husky 等规则,校验不通过无法提交
113 | ## 通过 --no-verify 可以跳过校验(为了保证代码质量不建议此操作QwQ)
114 | git commit --no-verify -m "commit message"
115 |
116 | # 一次性从工作区提交到本地仓库,相当于 git add . + git commit -m
117 | git commit -am
118 | ```
119 |
120 | ### git push: 提交到远程仓库
121 |
122 | `git push` 会将本地仓库的内容推送到远程仓库
123 |
124 | ```perl
125 | # 将当前本地分支 branch1 内容推送到远程分支 origin/branch1
126 | git push
127 |
128 | # 若当前本地分支 branch1,没有对应的远程分支 origin/branch1,需要为推送当前分支并建立与远程上游的跟踪
129 | git push --set-upstream origin branch1
130 |
131 | # 强制提交
132 | ## 例如用在代码回滚后内容
133 | git push -f
134 | ```
135 |
136 | ### git pull: 拉取远程仓库并合并
137 |
138 | `git pull` 会拉取远程仓库并合并到本地仓库,相当于执行 `git fetch` + `git merge`
139 |
140 | ```perl
141 | # 若拉取并合并的远程分支和当前本地分支名称一致
142 | ## 例如当前本地分支为 branch1,要拉取并合并 origin/branch1,则直接执行:
143 | git pull
144 |
145 | # 若拉取并合并的远程分支和当前本地分支名称不一致
146 | git pull <远程主机名> <分支名>
147 | ## 例如当前本地分支为 branch2,要拉取并合并 origin/branch1,则执行:
148 | git pull git@github.com:zh-lx/git-practice.git branch1
149 |
150 | # 使用 rebase 模式进行合并
151 | git pull --rebase
152 | ```
153 |
154 | ### git checkout: 切换分支
155 |
156 | `git checkout` 用于切换分支及撤销工作区内容的修改
157 |
158 | ```perl
159 | # 切换到已有的本地分支 branch1
160 | git checkout branch1
161 |
162 | # 切换到远程分支 branch1
163 | git checkout origin/branch1
164 |
165 | # 基于当前本地分支创建一个新分支 branch2,并切换至 branch2
166 | git checkout -b branch2
167 |
168 | # 基于远程分支 branch1 创建一个新分支 branch2,并切换至 branch2
169 | git checkout origin/branch1 -b branch2
170 | ## 当前创建的 branch2 关联的上游分支是 origin/branch1,所以 push 时需要如下命令关联到远程 branch2
171 | git push --set-upstream origin branch2
172 |
173 | # 撤销工作区 file 内容的修改。危险操作,谨慎使用
174 | git checkout --
175 |
176 | # 撤销工作区所有内容的修改。危险操作,谨慎使用
177 | git checkout .
178 | ```
179 |
180 | ### git restore: 取消缓存
181 |
182 | `git restore` 用于将改动从暂存区退回工作区
183 |
184 | ```perl
185 | # 将 a.js 文件取消缓存(取消 add 操作,不改变文件内容)
186 | git reset --staged a.js
187 |
188 | # 将所有文件取消缓存
189 | git reset --staged .
190 | ```
191 |
192 | ### git reset: 回滚代码
193 |
194 | `git reset` 用于撤销各种 commit 操作,回滚代码
195 |
196 | ```perl
197 |
198 | # 将某个版本的 commit 从本地仓库退回到工作区(取消 commit 和 add 操作,不改变文件内容)
199 | ## 默认不加 -- 参数时时 mixed
200 | git reset --mixed
201 |
202 | # 将某个版本的 commit 从本地仓库退回到缓存区(取消 commit 操作,不取消 add,不改变文件内容)
203 | git reset --soft
204 |
205 | # 取消某次 commit 的记录(取消 commit 和 add,且改变文件内容)
206 | git reset --hard
207 |
208 | ## 以上三种操作退回了 commit,都是退回本地仓库的 commit,没有改变远程仓库的 commit。通常再次修改后配合如下命令覆盖远程仓库的 commit:
209 | git push -f
210 | ```
211 |
212 | ## 常用命令
213 |
214 | 实际的 git 操作场景很多,下面的命令也经常在场景中使用。
215 |
216 | ### git revert: 取消某次 commit 内容
217 |
218 | #### 说明
219 |
220 | `git revert` 相比于 `git reset`,会取消某次 commit 更改的内容,但是不会取消掉 commit 记录,而是进行一次新的 commit 去覆盖要取消的那次 commit:
221 |
222 | ```perl
223 | # 取消某次 commit 内容,但是保留 commit 记录
224 | git revert
225 | ```
226 |
227 | #### 场景
228 |
229 | 某一版需求中,pm 共提了活动功能、分享功能和视频功能三个功能模块,然后开发老哥做完提测了。上线之前,版本的代码都已经合并到主分支了,pm 突然说这一版活动功能不上线了,下一版再上,分享功能和视频功能正常上线,开发老哥心里直呼 mmp。
230 | 研发老哥的 commit 记录如下:
231 |
232 | 
233 |
234 | 现在想要做的就是取消掉第一次活动功能的 commit,但是视频功能和分享功能的 commit 还需要保留,所以肯定不能使用 `git reset` 了, 这时候 `git revert` 就派上用场了。
235 |
236 | 执行 `git revert 9ec52dc`,再重新 `git push`,活动功能的 commit 内容就会被覆盖掉了:
237 | 
238 |
239 | ### git rebase: 简洁 commit 记录
240 |
241 | `git rebase` 命令主要是针对 commit 的,目的是令 commit 记录变得更加简洁清晰。
242 |
243 | #### 多次 commit 合并为一次
244 |
245 | 可以通过 `git rebase -i` 合并多次 commit 为一次。注意:此操作会修改 commit-sha,因此只能在自己的分支上操作,不能在公共分支操作,不然会引起他人的合并冲突
246 |
247 | ##### 说明
248 |
249 | ```perl
250 | # 进行 git rebase 可交互命令变基,end-commit-sha 可选,不填则默认为 HEAD
251 | ## start 和 end commit-sha 左开右闭原则
252 | git rebase -i
253 |
254 | # 若是中间毫无冲突,变基则一步到位,否则需要逐步调整
255 |
256 | # 上次变基为完成,继续上一次的变基操作
257 | git rebase --continue
258 |
259 | # 变基有冲突时丢弃有冲突的 commit
260 | git rebase --skip
261 |
262 | # 若是变基中途操作错误但是未彻底完成,可以回滚到变基之前的状态
263 | git rebase --abort
264 | ```
265 |
266 | 执行后,会出现可交互命令,界面如下:
267 | 
268 |
269 | - pick: 是保留该 commit(采用)
270 | - edit: 一般你提交的东西多了,可以用这个把东东拿回工作区拆分更细的 commit
271 | - reword: 这个可以重新修改你的 commit msg
272 | - squash: 内容保留,把提交信息往上一个 commit 合并进去
273 | - fixup: 保留变动内容,但是抛弃 commit msg
274 |
275 | ##### 场景
276 |
277 | 开发老哥在自己的分支开发一个活动功能,共如下 4 次 commit 记录:
278 | 
279 | 要往主分支合并之前,开发老哥决定让 commit 记录简洁一些,于是执行 `git rebase -i edb259`:
280 | 
281 | 上面四行就是我们要进行合并的 commit,我们现在是进入了一个 vim 界面,按 `i` 进行编辑,将活动功能 2, 3, 4 行改为 s(squash) 如下:
282 | 
283 | 按下 `esc` 退出编辑,再输入 `wq` 退出当前的 vim,会进入到此页面编辑 commit 信息:
284 | 
285 | 我们可以修改 `feat: 活动奖励1` 为 `feat: 活动奖励`,然后 `esc` 退出编辑,再输入 `wq!` 退出当前的 vim。再次 `git push -f` 推送分支,可以看到 commit 已经合并为一次:
286 | 
287 |
288 | #### 使用 rebase 代替 merge
289 |
290 | ##### 说明
291 |
292 | 前面说到过 `git pull` = `git fetch` + `git merge`,通过加 --rebase 参数可以启用 rebase 模式, 实际上 `git pull --rebase` = `git fetch` + `git rebase`。
293 |
294 | `git rebase` 代替 `git merge` 是现在许多公司和团队要求使用的一种合并方式。相比于 `git merge`,`git rebase` 可以让分支合并后只显示 master 一条线,并且按照 commit 和时间去排序,使得 git 记录简洁和清晰了许多。
295 |
296 | ```perl
297 | # 将本地某分支合并至当前分支
298 | git rebase <分支名>
299 |
300 | # 将远程某分支合并至当前分支
301 | git rebase <远程主机名> <分支名>
302 | ```
303 |
304 | ##### 场景
305 |
306 | 假设我们有一个 main 分支,基于 main 分支拉出了一个 feat-1.2 分支。然后在 main 分支先进行了 `feat: 1`、`feat: 2` 两次 commit,再在 feat-1.2 分支进行了 `feat: 3`、`feat: 4` ,最后将 feat-1.2 分支合并至 master 分支。
307 |
308 | 如果采用 `git merge` 合并,通过 `git log --graph --decorate --all` 查看分支变更图如下:
309 | 
310 | 如果采用 `git rebase` 合并,通过 `git log --graph --decorate --all` 查看分支变更图如下:
311 | 
312 |
313 | ### git cherry-pick: 合并指定 commit
314 |
315 | #### 说明
316 |
317 | `git cherry-pick` 可以选择某次 commit 的内容合并到当前分支
318 |
319 | ```perl
320 | # 将 commit-sha1 的变动合并至当前分支
321 | git cherry-pick commit-sha1
322 |
323 | # 将多次 commit 变动合并至当前分支
324 | git cherry-pick commit-sha1 commit-sha2
325 |
326 | # 将 commit-sha1 到 commit-sha5 中间所有变动合并至当前分支,中间使用..
327 | git cherry-pick commit-sha1..commit-sha5
328 |
329 | # pick 时解决冲突后继续 pick
330 | git cherry-pick --continue:
331 | # 多次 pick 时跳过本次 commit 的 pick 进入下一个 commit 的 pick
332 | git cherry-pick --skip
333 | # 完全放弃 pick,恢复 pick 之前的状态
334 | git cherry-pick --abort
335 | # 未冲突的 commit 自动变更,冲突的不要,退出这次 pick
336 | git cherry-pick --quit
337 | ```
338 |
339 | #### 场景
340 |
341 | 某开发老哥 A 和开发老哥 B 共同开发一次版本需求,开发老哥 A 拉了一个 `feat-acitivty` 分支开发活动功能,开发老哥 B 拉了一个 `feat-share` 分支开发分享功能,需要分开提交测试。
342 |
343 | 开发老哥 B 开发了一半发现需要用到一个弹窗组件,此时开发老哥 A 也需要弹窗组件并且已经开发完了,开发老哥 A 的 commit 如下:
344 | 
345 |
346 | 此时 `git cherry-pick` 就起作用了,开发老哥 B 执行如下命令:`git cherry-pick 486885f`,轻松将弹窗组件的代码也合并进了自己的分支。
347 |
348 | ### git stash:缓存代码
349 |
350 | #### 说明
351 |
352 | `git stash` 用于将当前的代码缓存起来,而不必提交,便于下次取出。
353 |
354 | ```perl
355 | # 把本地的改动缓存起来
356 | git stash
357 |
358 | # 缓存代码时添加备注,便于查找。强烈推荐
359 | git stash save "message"
360 |
361 | # 查看缓存记录
362 | ## eg: stash@{0}: On feat-1.1: 活动功能
363 | git stash list
364 |
365 | # 取出上一次缓存的代码,并删除这次缓存
366 | git stash pop
367 | # 取出 index 为2缓存代码,并删除这次缓存,index 为对应 git stash list 所列出来的
368 | git stash pop stash@{2}
369 |
370 | # 取出上一次缓存的代码,但不删除这次缓存
371 | stash apply
372 | # 取出 index 为2缓存代码,但不删除缓存
373 | git stash apply stash@{2}
374 |
375 | # 清除某次的缓存
376 | git stash drop stash@{n}
377 |
378 | # 清除所有缓存
379 | git stash clear
380 | ```
381 |
382 | #### 场景
383 |
384 | 某日开发老哥正愉快地开发着活动功能的代码,突然 pm 大喊:线上出 bug 了!!于是开发老哥不得不停下手头的工作去修改线上 bug,但是开发老哥又不想将现在的活动代码提交,于是开发老哥执行了 stash 命令:`git stash message "活动功能暂存"`,之后转去修复线上 bug 了。
385 |
386 | ## 其他常用命令
387 |
388 | 以下命令在开发中也经常用到,强烈推荐大家学习:
389 |
390 | ### git init: 初始化仓库
391 |
392 | `git init` 会在本地生成一个 .git 文件夹,创建一个新的本地仓库:
393 |
394 | ```
395 | git init
396 | ```
397 |
398 | ### git remote: 关联远程仓库
399 |
400 | `git remote` 用于将本地仓库与远程仓库关联
401 |
402 | ```perl
403 | # 关联本地 git init 到远程仓库
404 | git remote add origin
405 |
406 | # 新增其他上游仓库
407 | git remote add
408 |
409 | # 移除与远程仓库的管理
410 | git remote remove
411 |
412 | # 修改推送源
413 | git remote set-url origin
414 | ```
415 |
416 | ### git status: 查看工作区状态
417 |
418 | `git status` 用于查看工作区的文件,有哪些已经添加到了暂存区,哪些没有被添加:
419 |
420 | ```perl
421 | # 查看当前工作区暂存区变动
422 | git status
423 |
424 | # 以概要形式查看工作区暂存区变动
425 | git status -s
426 |
427 | # 查询工作区中是否有 stash 缓存
428 | git status --show-stash
429 | ```
430 |
431 | ### git log: 查看 commit 日志
432 |
433 | `git log` 用于查看 commit 的日志
434 |
435 | ```perl
436 | # 显示 commit 日志
437 | git log
438 |
439 | # 以简要模式显示 commit 日志
440 | git log --oneline
441 |
442 | # 显示最近 n 次的 commit 日志
443 | git log -n
444 |
445 | # 显示 commit 及分支的图形化变更
446 | git log --graph --decorate
447 | ```
448 |
449 | ### git branch: 管理分支
450 |
451 | `git branch` 常用于删除、重命名分支等
452 |
453 | ```perl
454 | # 删除分支
455 | git branch -D <分支名>
456 |
457 | # 重命名分支
458 | git branch -M <老分支名> <新分支名>
459 |
460 | # 将本地分支与远程分支关联
461 | git branch --set-upstream-to=origin/xxx
462 |
463 | # 取消本地分支与远程分支的关联
464 | git branch --unset-upstream-to=origin/xxx
465 | ```
466 |
467 | ### git rm:重新建立索引
468 |
469 | `git rm` 用于修改 .gitignore 文件的缓存,重新建立索引
470 |
471 | ```perl
472 | # 删除某个文件索引(不会更改本地文件,只会是 .gitignore 范围重新生效)
473 | git rm --cache -- <文件名>
474 |
475 | # 清除所有文件的索引
476 | ## 例如你首次提交了很多文件,后来你建立了一个 .gitignore 文件,有些文件不想推送到远端仓库,但此时有的文件已经被推送了
477 | ## 使用此命令可以是 .gitignore 重新作用一遍,从远程仓库中取消这些文件,但不会更改你本地文件
478 | git rm -r --cached .
479 | ```
480 |
481 | ### git diff: 对比差异
482 |
483 | `git diff` 用于对比工作区、缓存区、本地仓库以及分支之间的代码差异:
484 |
485 | ```perl
486 | # 当工作区有变动,暂存区无变动,对比工作区和本地仓库间的代码差异
487 | # 当工作区有变动和暂存区都有变动,对比工作区和暂存区的代码差异
488 | git diff
489 |
490 | # 显示暂存区和本地仓库间的代码差异
491 | git diff --cached
492 | # or
493 | git diff --staged
494 |
495 | # 显示两个分支之间代码差异
496 | git diff <分支名1> <分支名2>
497 | ```
498 |
499 | ### git fetch: 获取更新
500 |
501 | `git fetch` 用于本地仓库获取远程仓库的更新,不会 merge 到工作区
502 |
503 | ```perl
504 | # 获取远程仓库特定分支的更新
505 | git fetch <远程主机名> <分支名>
506 |
507 | # 获取远程仓库所有分支的更新
508 | git fetch --all
509 | ```
510 |
511 | ### git merge: 合并代码
512 |
513 | `git merge` 前面在 `git rebase` 内容中也提到过,用于合并代码,用法和 `git rebase` 类似:
514 |
515 | ```perl
516 | # 将本地某分支合并至当前分支
517 | git merge <分支名>
518 |
519 | # 将远程某分支合并至当前分支
520 | git merge <远程主机名> <分支名>
521 | ```
522 |
523 | ## 总结
524 |
525 | 以上基本就涵盖了工作中绝大部分场景的 git 命令,掌握了基本可以应对各种 git 操作了。鲁迅说过:一碗酸辣汤,耳濡目染的,不如亲自呷一口的明白。看了这么多建议大家自己建一个测试仓库,亲自敲一下熟悉一下上面命令的用法。
526 |
527 | 最后总结一个大图:
528 |
529 | 
530 |
--------------------------------------------------------------------------------
/docs/代码仓库/git/实用总结.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '解决 git clone 时速度过慢的问题'
3 | tag: ['git']
4 | time: '2021-05-03'
5 | ---
6 |
7 | # git 实用总结
8 |
9 | ## git clone 速度太慢
10 |
11 | 将 `github.com` 源换成 `github.com.cnpmjs.org`,例如:
12 |
13 | ```js
14 | // before:
15 | git clone https://github.com/zh-lx/pinyin-pro.git
16 | // now:
17 | git clone https://github.com.cnpmjs.org/zh-lx/pinyin-pro.git
18 | ```
19 |
--------------------------------------------------------------------------------
/docs/工程化/webpack/webpack体积优化及打包速度提升总结.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '最近在做项目的 webpack5 项目的打包优化,总结了 8 个速度优化和 6 个体积优化的实用方案,在你的项目中也尝试一下吧,直接起飞。'
3 | cover: 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5fde5629fe0a4eec9ee1dee8ac7cf622~tplv-k3u1fbpfcp-no-mark:480:480:0:0.awebp?'
4 | tag: ['webpack']
5 | time: '2022-01-12'
6 | ---
7 |
8 | 最近在做项目的 webpack5 项目的打包优化,总结了 8 个速度优化和 6 个体积优化的实用方案,在你的项目中也尝试一下吧,直接起飞。
9 |
10 | ## 打包速度优化
11 |
12 | ### 速度分析
13 |
14 | 要进行打包速度的优化,首先我们需要搞明白哪一些流程的在打包执行过程中耗时较长。
15 |
16 | 这里我们可以借助 `speed-measure-webpack-plugin` 插件,它分析 webpack 的总打包耗时以及每个 plugin 和 loader 的打包耗时,从而让我们对打包时间较长的部分进行针对性优化。
17 |
18 | 通过以下命令安装插件:
19 |
20 | ```perl
21 | yarn add speed-measure-webpack-plugin -D
22 | ```
23 |
24 | 在 `webpack.config.js` 中添加如下配置
25 |
26 | ```js
27 | // ...
28 | const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
29 | const smp = new SpeedMeasurePlugin();
30 |
31 | module.exports = smp.wrap({
32 | // ...
33 | plugins: [
34 | // ...
35 | ],
36 | module: {
37 | // ...
38 | },
39 | });
40 | ```
41 |
42 | 执行 webpack 打包命令后,如下图可以看到各个 loader 和 plugin 的打包耗时:
43 |
44 | 
45 |
46 | ### cdn 分包
47 |
48 | 对于项目中我们用的一些比较大和比较多的包,例如 react 和 react-dom,我们可以通过 cdn 的形式引入它们,然后将 `react`、`react-dom` 从打包列表中排除,这样可以减少打包所需的时间。
49 |
50 | 排除部分库的打包需要借助 `html-webpack-externals-plugin` 插件,执行如下命令安装:
51 |
52 | ```
53 | yarn add html-webpack-externals-plugin -D
54 | ```
55 |
56 | 以 react 和 react-dom 为例,在 webpack 中添加如下配置:
57 |
58 | ```js
59 | const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');
60 |
61 | module.exports = {
62 | // ...
63 | plugins: [
64 | new HtmlWebpackExternalsPlugin({
65 | externals: [
66 | {
67 | module: 'react',
68 | entry: 'https://unpkg.com/react@17.0.2/umd/react.production.min.js',
69 | global: 'React',
70 | },
71 | {
72 | module: 'react-dom',
73 | entry:
74 | 'https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js',
75 | global: 'ReactDOM',
76 | },
77 | ],
78 | }),
79 | ],
80 | };
81 | ```
82 |
83 | 效果对比如下:
84 |
85 | 优化前打包时间约为 2s:
86 | 
87 | 优化后打包时间不到 1s:
88 | 
89 |
90 | ### 多进程构建
91 |
92 | 对于耗时较长的模块,同时开启多个 nodejs 进程进行构建,可以有效地提升打包的速度。可以采取的一些方式有:
93 |
94 | - thread-loader
95 | - HappyPack(作者已经不维护)
96 | - parallel-webpack
97 | 下面以官方提供的 thread-loader 为例,执行以下命令安装 `thread-loader`:
98 |
99 | ```perl
100 | yarn add thread-loader -D
101 | ```
102 |
103 | 在 `webpack.config.js` 中添加如下配置:
104 |
105 | ```js
106 | module.exports = {
107 | module: {
108 | rules: [
109 | {
110 | test: /.js$/,
111 | include: path.resolve('src'),
112 | use: [
113 | 'thread-loader',
114 | // 耗时的 loader (例如 babel-loader)
115 | ],
116 | },
117 | ],
118 | },
119 | };
120 | ```
121 |
122 | 使用时,需将此 loader 放置在其他 loader 之前,放置在此 loader 之后的 loader 会在一个独立的 worker 池中运行。
123 |
124 | 每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。所以请仅在耗时的操作中使用此 loader!(一般只在大型项目中的 ts、js 文件使用)
125 |
126 | ### 并行压缩
127 |
128 | 一些插件内置了 `parallel` 参数(如 `terser-webpack-plugin`, `css-minimizer-webpack-plugin`, `html-minimizer-webpack-plugin`),开启后可以进行并行压缩。
129 |
130 | webpack5 版本内置了 `terser-webpack-plugin` 的配置,如果是 v4 或者更低版本,执行以下命令安装 `terser-webpack-plugin` :
131 |
132 | ```perl
133 | yarn add terser-webpack-plugin -D
134 | ```
135 |
136 | 在 `webpack.config.js` 进行如下配置:
137 |
138 | ```js
139 | const TerserPlugin = require('terser-webpack-plugin');
140 |
141 | module.exports = {
142 | optimization: {
143 | minimize: true,
144 | minimizer: [new TerserPlugin()],
145 | },
146 | };
147 | ```
148 |
149 | ### 预编译资源模块
150 |
151 | 通过预编译资源模块,可以代替 cdn 分包的方式,解决每个模块都得引用一个 script 的缺陷。
152 |
153 | 还是以 react 和 react-dom 为例,新建一个 `webpack.dll.js` 文件,用于预编译资源的打包,例如要对 react 和 react-dom 进行预编译,配置如下:
154 |
155 | ```js
156 | const path = require('path');
157 | const webpack = require('webpack');
158 |
159 | module.exports = {
160 | mode: 'production',
161 | entry: {
162 | library: ['react', 'react-dom'],
163 | },
164 | output: {
165 | filename: 'react-library.dll.js',
166 | path: path.resolve(__dirname, './dll'),
167 | library: '[name]_[hash]', // 对应的包映射名
168 | },
169 | plugins: [
170 | new webpack.DllPlugin({
171 | context: __dirname,
172 | name: '[name]_[hash]', // 引用的包映射名
173 | path: path.join(__dirname, './dll/react-library.json'),
174 | }),
175 | ],
176 | };
177 | ```
178 |
179 | 在 `package.json` 中新增一条如下命令:
180 |
181 | ```json
182 | {
183 | // ...
184 | "scripts": {
185 | // ...
186 | "build:dll": "webpack --config ./webpack.dll.js"
187 | }
188 | // ...
189 | }
190 | ```
191 |
192 | 执行 `npm run build:dll` 后,会在 `/build/library` 目录下生成如下内容,`library.js` 中打包了 react 和 react-dom 的内容,`library.json` 中添加了对它的引用:
193 |
194 | 
195 |
196 | 然后在 `webpack.config.js` 中新增如下内容:
197 |
198 | ```js
199 | const webpack = require('webpack');
200 | const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
201 |
202 | module.exports = {
203 | plugins: [
204 | new webpack.DllReferencePlugin({
205 | context: __dirname,
206 | manifest: require('./dll/react-library.json'),
207 | }),
208 | // 打包后的 .dll.js 文件需要引入到 html中,可以通过 add-asset-html-webpack-plugin 插件自动引入
209 | new AddAssetHtmlPlugin({
210 | filepath: require.resolve('./dll/react-library.dll.js'),
211 | publicPath: '',
212 | }),
213 | ],
214 | };
215 | ```
216 |
217 | 效果对比如下:
218 |
219 | 使用 dll 预编译资源之前,打包效果如下,总打包耗时 1964ms,且需要打包 react:
220 | 
221 | 使用 dll 预编译资源之后,打包效果如下,总打包耗时 1148ms,不需要打包 react:
222 | 
223 |
224 | ### 使用缓存
225 |
226 | 通过使用缓存,能够有效提升打包速度。缓存主要有以下几种方案:
227 |
228 | - 使用 webpack5 内置的 cache 模块
229 | - cache-loader(webpack5 内置了 cache 模块后可弃用 cache-loader)
230 |
231 | #### 内置的 cache 模块
232 |
233 | webpack5 内置了 cache 模块,缓存生成的 webpack 模块和 chunk,来改善构建速度。它在开发环境下会默认设置为 `type: 'memory'` 而在生产环境中被禁用。`cache: { type: 'memory' }` 与 `cache: true` 作用一样,可以通过设置 `cache: { type: 'filesystem' }` 来开放更多配置项。
234 |
235 | 例如在 `webpack.config.js` 中作如下配置:
236 |
237 | ```js
238 | module.exports = {
239 | cache: {
240 | type: 'filesystem',
241 | },
242 | };
243 | ```
244 |
245 | 会在 node_modules 目录下生成一个 .cache 目录缓存文件内容,且二次打包速度显著提升:
246 |
247 | 
248 |
249 | #### cache-loader
250 |
251 | 在一些性能开销较大的 loader 之前添加 `cache-loader`,能将结果缓存到磁盘里。保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用。
252 |
253 | 执行如下命令安装 `cache-loader`:
254 |
255 | ```perl
256 | npm install cache-loader -D
257 | ```
258 |
259 | 在 `webpack.config.js` 对应的开销大的 loader 前加上 `cache-loader`:
260 |
261 | ```js
262 | module.exports = {
263 | module: {
264 | rules: [
265 | {
266 | test: /\.js$/,
267 | use: ['cache-loader', 'babel-loader'],
268 | },
269 | ],
270 | },
271 | };
272 | ```
273 |
274 | 同样会在 node_modules 目录下生成一个 .cache 目录缓存文件内容,且二次打包速度显著提升:
275 |
276 | 
277 |
278 | ### 缩小构建范围
279 |
280 | 通过合理配置 `rules` 中的文件查找范围,可以减少打包的范围,从而提升打包速度。
281 |
282 | 在 `webpack.config.js` 中新增如下配置:
283 |
284 | ```js
285 | module.exports = {
286 | // ...
287 | module: {
288 | rules: [
289 | {
290 | test: /\.js$/,
291 | use: ['babel-loader'],
292 | exclude: /node_modules/,
293 | },
294 | ],
295 | },
296 | };
297 | ```
298 |
299 | 效果对比如下:
300 |
301 | 配置前,编译总耗时 1867ms:
302 | 
303 | 配置后,编译总耗时 1227ms:
304 | 
305 |
306 | ### 加快文件查找速度
307 |
308 | 通过合理配置 webpack 的 resolve 模块,可以加快文件的查找速度,例如可以对如下的选项进行配置:
309 |
310 | - resolve.modules 减少模块搜索层级,指定当前 node_modules,慎用。
311 | - resovle.mainFields 指定包的入口文件。
312 | - resolve.extension 对于没有指定后缀的引用,指定解析的文件后缀查找顺序
313 | - 合理使用 alias,指定第三方依赖的查找路径
314 | 对 `webpack.config.js` 作如下配置:
315 |
316 | ```js
317 | module.exports = {
318 | resolve: {
319 | alias: {
320 | react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
321 | },
322 | modules: [path.resolve(__dirname, './node_modules')],
323 | extensions: ['.js', '.jsx', '.json'],
324 | mainFields: ['main'],
325 | },
326 | };
327 | ```
328 |
329 | ## 打包体积优化
330 |
331 | ### 体积分析
332 |
333 | 同速度优化一样,我们要对体积进行优化,也需要了解打包时各个模块的体积大小。这里借助 `webpack-bundle-analyzer` 插件,它可以分析打包的总体积、各个组件的体积以及引入的第三方依赖的体积。
334 |
335 | 执行如下命令安装 `webpack-bundle-analyzer`:
336 |
337 | ```perl
338 | yarn add webpack-bundle-analyzer -D
339 | ```
340 |
341 | 在 `webpack.config.js` 中添加如下配置:
342 |
343 | ```js
344 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
345 |
346 | module.exports = {
347 | // ...
348 | plugins: [new BundleAnalyzerPlugin()],
349 | };
350 | ```
351 |
352 | 然后执行 webpack 打包命令,会在 `localhost:8888` 页面看到打包后的体积分析:
353 |
354 | 
355 |
356 | ### 提取公共模块
357 |
358 | 假如我们现在有一个 MPA(多页面应用) 的 react 项目,每个页面的入口文件及其依赖的组件中都会引入一份 `react` 和 `react-dom` ,那最终打包后的每个页面中同样也会有一份以上两个包的代码。我们可以将这两个包单独抽离出来,最终在每个打包后的页面入口文件中引入,从而减少打包后的总体积。
359 |
360 | 在 `webpack.config.js` 中添加如下配置:
361 |
362 | ```js
363 | module.exports = {
364 | optimization: {
365 | splitChunks: {
366 | minSize: 20000,
367 | cacheGroups: {
368 | react: {
369 | test: /(react|react-dom)/,
370 | name: 'vendors',
371 | chunks: 'all',
372 | },
373 | },
374 | },
375 | },
376 | };
377 | ```
378 |
379 | 效果对比:
380 |
381 | 优化前总体积 473 kb:
382 | 
383 | 优化后总体积 296 kb:
384 | 
385 |
386 | ### 压缩代码
387 |
388 | #### html 压缩
389 |
390 | 安装 `html-webpack-plugin` 插件,生产环境下默认会开启 html 压缩:
391 |
392 | ```
393 | npm install html-webpack-plugin
394 | ```
395 |
396 | `webpack.config.js` 做如下配置:
397 |
398 | ```js
399 | module.exports = {
400 | // ...
401 | plugins: [
402 | new HtmlWebpackPlugin({
403 | template: path.join(__dirname, '../', 'public/index.html'),
404 | }),
405 | ],
406 | };
407 | ```
408 |
409 | #### css 压缩
410 |
411 | `css-minimizer-webpack-plugin` 插件可以压缩 css 文件代码,但由于压缩的是 css 代码,所以还需要依赖 `mini-css-extract-plugin` 将 css 代码单独抽离:
412 |
413 | ```js
414 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
415 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
416 |
417 | module.exports = {
418 | plugins: [
419 | new MiniCssExtractPlugin({
420 | filename: '[name].css',
421 | chunkFilename: '[id].css',
422 | }),
423 | ],
424 | module: {
425 | rules: [
426 | {
427 | test: /\.css$/,
428 | use: [MiniCssExtractPlugin.loader, 'css-loader'],
429 | },
430 | ],
431 | },
432 | optimization: {
433 | minimizer: [
434 | // webpack5 可以使用 '...' 访问 minimizer 数组的默认值
435 | '...',
436 | new CssMinimizerPlugin(),
437 | ],
438 | },
439 | };
440 | ```
441 |
442 | #### js 压缩
443 |
444 | 生产环境下会默认开启 js 的压缩,无需单独配置。
445 |
446 | ### 图片压缩
447 |
448 | 使用 `image-minimizer-webpack-plugin` 配合 `imagemin` 可以在打包时实现图片的压缩。
449 |
450 | 执行如下命令安装 `image-minimizer-webpack-plugin` 配合 `imagemin` :
451 |
452 | ```
453 | npm install image-minimizer-webpack-plugin imagemin imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo --save-dev
454 | ```
455 |
456 | 在 `webpack.config.js` 中新增如下配置:
457 |
458 | ```js
459 | const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
460 | const { extendDefaultPlugins } = require('svgo');
461 |
462 | module.exports = {
463 | module: {
464 | rules: [
465 | {
466 | test: /\.(jpe?g|png|gif|svg)$/i,
467 | type: 'asset',
468 | },
469 | ],
470 | },
471 | optimization: {
472 | minimizer: [
473 | '...',
474 | new ImageMinimizerPlugin({
475 | minimizer: {
476 | implementation: ImageMinimizerPlugin.imageminMinify,
477 | options: {
478 | // Lossless optimization with custom option
479 | // Feel free to experiment with options for better result for you
480 | plugins: [
481 | ['gifsicle', { interlaced: true }],
482 | ['jpegtran', { progressive: true }],
483 | ['optipng', { optimizationLevel: 5 }],
484 | // Svgo configuration here https://github.com/svg/svgo#configuration
485 | [
486 | 'svgo',
487 | {
488 | plugins: extendDefaultPlugins([
489 | {
490 | name: 'removeViewBox',
491 | active: false,
492 | },
493 | {
494 | name: 'addAttributesToSVGElement',
495 | params: {
496 | attributes: [{ xmlns: 'http://www.w3.org/2000/svg' }],
497 | },
498 | },
499 | ]),
500 | },
501 | ],
502 | ],
503 | },
504 | },
505 | }),
506 | ],
507 | },
508 | };
509 | ```
510 |
511 | 效果对比如下:
512 |
513 | 压缩前图片打包后 1.1m:
514 | 
515 | 压缩后 451kb:
516 | 
517 |
518 | ### 移除无用的 css
519 |
520 | 通过 `purgecss-webpack-plugin`,可以识别没有用到的 class,将其从 css 文件中 treeShaking 掉,需要配合 `mini-css-extract-plugin` 一起使用。
521 |
522 | 执行如下命令安装 `purgecss-webpack-plugin`:
523 |
524 | ```perl
525 | npm install purgecss-webpack-plugin -D
526 | ```
527 |
528 | 在 `webpack.config.js` 文件中做如下配置:
529 |
530 | ```js
531 | const path = require('path');
532 | const glob = require('glob');
533 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
534 | const PurgecssPlugin = require('purgecss-webpack-plugin');
535 |
536 | const PATHS = {
537 | src: path.join(__dirname, 'src'),
538 | };
539 |
540 | module.exports = {
541 | module: {
542 | rules: [
543 | {
544 | test: /.css$/,
545 | use: [MiniCssExtractPlugin.loader, 'css-loader'],
546 | },
547 | ],
548 | },
549 | plugins: [
550 | new MiniCssExtractPlugin({
551 | filename: '[name].css',
552 | }),
553 | new PurgecssPlugin({
554 | paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
555 | }),
556 | ],
557 | };
558 | ```
559 |
560 | 在 css 文件中添加一段未用到的 css 代码:
561 |
562 | ```css
563 | div {
564 | font-size: 44px;
565 | display: flex;
566 | }
567 | // 此段为用到:
568 | .unuse-css {
569 | font-size: 20px;
570 | }
571 | ```
572 |
573 | 使用 `purgecss-webpack-plugin` 之前,打包结果如下:
574 | 
575 | 使用 `purgecss-webpack-plugin` 之后,打包结果如下,无用代码已经移除:
576 | 
577 |
578 | ### polyfill service
579 |
580 | 我们在项目使用了 es6+ 语法时,往往需要引入 polyfill 去兼容不同浏览器。目前我们常采用的方案一般是 `babel-polyfill` 或者 `babel-plugin-transform-runtime`,然而在部分不同的浏览器上,它们一般都会与冗余,从而导致项目一些不必要的体积增大。
581 |
582 | 以下是几种常见 polyfill 方案的对比:
583 | | 方案 | 优点 | 缺点 |
584 | | ------------------------------ | ---------------------------------------- | -------------------------------------------------------- |
585 | | babel-polyfill | 功能全面 | 体积太大超过 200kb,难以抽离 |
586 | | babel-plugin-transform-runtime | 只 polyfill 用到的类或者方法,体积相对较小 | 不能 polyfill 原型上的方法,不适合复杂业务 |
587 | | 团队维护自己的 polyfill | 定制化高,体积小 | 维护成本太高 |
588 | | polyfill service | 只返回需要的 polyfill,体积最小 | 部分奇葩浏览器的 UA 不识别,走优雅降级方案返回全部 polyfill |
589 |
590 | 这里我们可以采用 polyfill service 方案,它能够识别 User Agent,下发不同的 polyfill,做到按需加载需要的 polyfill,从而优化我们项目的体积。
591 |
592 | 去 https://polyfill.io/ 查看最新的 polyfill service 的 url,例如目前是:
593 |
594 | ```
595 | https://polyfill.io/v3/polyfill.min.js
596 | ```
597 |
598 | 直接在项目的 html 中通过 script 引入即可:
599 |
600 | ```html
601 |
602 | ```
603 |
--------------------------------------------------------------------------------
/docs/工程化/webpack/一文搞懂webpack的devtool及sourceMap.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本文将介绍 SourceMap 的作用、简单工作原理以及 webpack 中不同 devtool 所构建的 SourceMap 有何区别,以及如何选择最合适的 devtool'
3 | cover: 'https://img2.baidu.com/it/u=4161682823,2906342423&fm=253&fmt=auto&app=138&f=PNG?w=500&h=333'
4 | tag: ['webpack']
5 | time: '2021-05-07'
6 | ---
7 |
8 | # 一文搞懂 SourceMap 以及 webpack devtool
9 |
10 | 本文将介绍 SourceMap 的作用、简单工作原理以及 webpack 中不同 devtool 所构建的 SourceMap 有何区别,以及如何选择最合适的 devtool,希望对大家有所帮助。
11 |
12 | ## 理解 SourceMap
13 |
14 | ### SourceMap 作用
15 |
16 | 随着各种打包工具的星期,为了提高前端项目的性能和不同浏览器上的兼容性,我们线上环境的代码一般都要经过如下等处理:
17 |
18 | - 压缩混淆,减小体积
19 | - 多个文件合并,减少 HTTP 请求数
20 | - 将 es6+代码转换成浏览器能够识别的 es5 代码
21 |
22 | 经过如上的步骤之后,我们代码的性能和兼容性提高了,然后由于转换后的代码和源代码的不同,会导致我们的开发调试变得很困难,SourceMap 的诞生就是为了解决如上问题的。
23 |
24 | 简而言之,SourceMap 就是一个储存着代码位置信息的文件,转换后的代码的每一个位置,所对应的转换前的位置。有了它,点击浏览器的控制台报错信息时,可以直接显示出错源代码位置而不是转换后的代码。
25 |
26 | 用一个实例来加深理解,如下是一个简单的 `index.js` 文件,我们故意将最后一行 `console.log('hello world')` 错写成 `console.logo('hello world')`:
27 |
28 | ```js
29 | const a = 1;
30 | const b = 2;
31 | console.log(a + b);
32 | console.logo('hello world');
33 | ```
34 |
35 | #### 无 SourceMap
36 |
37 | 我们将 `webpack.config.js` 的 devtool 选项配置为 `'none'`,打包上述的 `index.js` 文件:
38 |
39 | ```js
40 | // ...
41 | module.exports = {
42 | // ...
43 | mode: 'production',
44 | devtool: 'none',
45 | // ...
46 | };
47 | ```
48 |
49 | 点击控制台出错代码如下,可以看到代码是压缩混淆之后的,我们难以追溯到出错的源代码:
50 |
51 | 
52 |
53 | #### 有 SourceMap
54 |
55 | 将 `webpack.config.js` 的 devtool 选项配置由 `'none'` 改成 `source-map` 后,再次打包上面的 `index.js` 文件:
56 |
57 | ```js
58 | // ...
59 | module.exports = {
60 | // ...
61 | mode: 'production',
62 | devtool: 'source-map',
63 | // ...
64 | };
65 | ```
66 |
67 | 点击控制台的报错,可以看到显示的是源代码,我们能够很清晰的定位到错误的行号,并且光标直接停留在错误代码所在的列:
68 |
69 | 
70 |
71 | 通过如上对比,我们可以轻松的理解 SourceMap 带来的好处。那么 SourceMap 在浏览器上到底是如何工作的呢?
72 |
73 | ### SourceMap 工作原理
74 |
75 | 我们使用 webpack 打包并选择 devtool 为 `source-map` 后,每个打包后的 js 模块会有一个对应的.map 文件:
76 |
77 | 
78 |
79 | 打包出来的 `main.js.map`文件中,就是一个标准的 SourceMap 内容格式:
80 |
81 | ```json
82 | {
83 | "version": 3,
84 | "sources": [
85 | "webpack:///webpack/bootstrap",
86 | "webpack:///./src/index.js",
87 | "webpack:///./src/add.js"
88 | ],
89 | "names": [
90 | // ...
91 | "p",
92 | "s",
93 | "console",
94 | "log",
95 | "a",
96 | "b",
97 | "add"
98 | ],
99 | "mappings": "aACE,IAAIA,EAAmB,GAGvB,SAASC,EAAoBC,GAG5B,GAAGF,EAAiBE,GACnB,OAAOF,EAAiBE,GAAUC,QAGnC,IAAIC,EAASJ,EAAiBE,GAAY,CACzCG,EAAGH,EACHI,GAAG,EACHH,QAAS,IAUV,OANAI,EAAQL,GAAUM,KAAKJ,EAAOD,QAASC,EAAQA,EAAOD,QAASF,GAG/DG,EAAOE,GAAI,EAGJF,EAAOD,QAKfF,EAAoBQ,EAAIF,EAGxBN,EAAoBS,EAAIV,EAGxBC,EAAoBU,EAAI,SAASR,EAASS,EAAMC,GAC3CZ,EAAoBa,EAAEX,EAASS,IAClCG,OAAOC,eAAeb,EAASS,EAAM,CAAEK,YAAY,EAAMC,IAAKL,KAKhEZ,EAAoBkB,EAAI,SAAShB,GACX,oBAAXiB,QAA0BA,OAAOC,aAC1CN,OAAOC,eAAeb,EAASiB,OAAOC,YAAa,CAAEC,MAAO,WAE7DP,OAAOC,eAAeb,EAAS,aAAc,CAAEmB,OAAO,KAQvDrB,EAAoBsB,EAAI,SAASD,EAAOE,GAEvC,GADU,EAAPA,IAAUF,EAAQrB,EAAoBqB,IAC/B,EAAPE,EAAU,OAAOF,EACpB,GAAW,EAAPE,GAA8B,iBAAVF,GAAsBA,GAASA,EAAMG,WAAY,OAAOH,EAChF,IAAII,EAAKX,OAAOY,OAAO,MAGvB,GAFA1B,EAAoBkB,EAAEO,GACtBX,OAAOC,eAAeU,EAAI,UAAW,CAAET,YAAY,EAAMK,MAAOA,IACtD,EAAPE,GAA4B,iBAATF,EAAmB,IAAI,IAAIM,KAAON,EAAOrB,EAAoBU,EAAEe,EAAIE,EAAK,SAASA,GAAO,OAAON,EAAMM,IAAQC,KAAK,KAAMD,IAC9I,OAAOF,GAIRzB,EAAoB6B,EAAI,SAAS1B,GAChC,IAAIS,EAAST,GAAUA,EAAOqB,WAC7B,WAAwB,OAAOrB,EAAgB,SAC/C,WAA8B,OAAOA,GAEtC,OADAH,EAAoBU,EAAEE,EAAQ,IAAKA,GAC5BA,GAIRZ,EAAoBa,EAAI,SAASiB,EAAQC,GAAY,OAAOjB,OAAOkB,UAAUC,eAAe1B,KAAKuB,EAAQC,IAGzG/B,EAAoBkC,EAAI,GAIjBlC,EAAoBA,EAAoBmC,EAAI,G,sCC/ErDC,QAAQC,ICHW,SAACC,EAAGC,GACrB,OAAOD,EAAIC,EDEDC,CAFF,EACA,IAEVJ,QAAQC,IAAI",
100 | "file": "main.js",
101 | "sourcesContent": [
102 | " \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = 0);\n",
103 | "import { add } from './add';\nconst a = 1;\nconst b = 2;\nconsole.log(add(a, b));\nconsole.log('hello world');\n",
104 | "export const add = (a, b) => {\n return a + b;\n};\n"
105 | ],
106 | "sourceRoot": ""
107 | }
108 | ```
109 |
110 | 它包含以下内容:
111 |
112 | - version: SourceMap 的版本,如今最新版本为 3
113 | - sources: 源文件列表
114 | - names: 源文件中的变量名
115 | - mappings: 压缩混淆后的代码定位源代码的位置信息
116 | - file: 该 Source Map 对应文件的名称
117 | - sourcesContent: 源代码字符串列表,用于调试时展示源文件,列表每一项对应于 sources
118 | - sourceRoot: 源文件根目录,这个值会加在每个源文件之前
119 |
120 | `main.js` 文件的内容如下,里面含有 `//# sourceMappingURL=main.js.map` 这段内容:
121 |
122 | ```js
123 | !(function (e) {
124 | var t = {};
125 | function n(r) {
126 | if (t[r]) return t[r].exports;
127 | var o = (t[r] = { i: r, l: !1, exports: {} });
128 | return e[r].call(o.exports, o, o.exports, n), (o.l = !0), o.exports;
129 | }
130 | (n.m = e),
131 | (n.c = t),
132 | (n.d = function (e, t, r) {
133 | n.o(e, t) || Object.defineProperty(e, t, { enumerable: !0, get: r });
134 | }),
135 | (n.r = function (e) {
136 | 'undefined' != typeof Symbol &&
137 | Symbol.toStringTag &&
138 | Object.defineProperty(e, Symbol.toStringTag, { value: 'Module' }),
139 | Object.defineProperty(e, '__esModule', { value: !0 });
140 | }),
141 | (n.t = function (e, t) {
142 | if ((1 & t && (e = n(e)), 8 & t)) return e;
143 | if (4 & t && 'object' == typeof e && e && e.__esModule) return e;
144 | var r = Object.create(null);
145 | if (
146 | (n.r(r),
147 | Object.defineProperty(r, 'default', { enumerable: !0, value: e }),
148 | 2 & t && 'string' != typeof e)
149 | )
150 | for (var o in e)
151 | n.d(
152 | r,
153 | o,
154 | function (t) {
155 | return e[t];
156 | }.bind(null, o)
157 | );
158 | return r;
159 | }),
160 | (n.n = function (e) {
161 | var t =
162 | e && e.__esModule
163 | ? function () {
164 | return e.default;
165 | }
166 | : function () {
167 | return e;
168 | };
169 | return n.d(t, 'a', t), t;
170 | }),
171 | (n.o = function (e, t) {
172 | return Object.prototype.hasOwnProperty.call(e, t);
173 | }),
174 | (n.p = ''),
175 | n((n.s = 0));
176 | })([
177 | function (e, t) {
178 | console.log(3), console.log('hello world');
179 | },
180 | ]);
181 | //# sourceMappingURL=main.js.map
182 | ```
183 |
184 | 浏览器在加载 `main.js` 时,通过 sourceMappingURL 加载对应的.map 文件,更加.map 文件的 SourceMap 内容中的 sources 字段,在浏览器的 Sources 中生成对应目录结构,之后再将 sourcesContent 中的内容对应填入上述生成的文件中,这样我们在调试时就可以将压缩混淆后的代码定位到对应的源代码位置。
185 |
186 | 如果选择 devtool 为`inline-source-map`,那么 sourceMappingURL 后面的内容则是以 base64 的形式内嵌的。
187 |
188 | ## webpack devtool 选项
189 |
190 | 更加 webpack 官网的文档显示,devtool 的可配置选项一共将近 30 个:
191 | | devtool | performance | production | quality |
192 | |------------------------------------------|--------------------------------|------------|----------------|
193 | | (none) | build: fastest rebuild: fastest | yes | bundle |
194 | | eval | build: fast rebuild: fastest | no | generated |
195 | | eval-cheap-source-map | build: ok rebuild: fast | no | transformed |
196 | | eval-cheap-module-source-map | build: slow rebuild: fast | no | original lines |
197 | | eval-source-map | build: slowest rebuild: ok | no | original |
198 | | cheap-source-map | build: ok rebuild: slow | no | transformed |
199 | | cheap-module-source-map | build: slow rebuild: slow | no | original lines |
200 | | source-map | build: slowest rebuild: slowest | yes | original |
201 | | inline-cheap-source-map | build: ok rebuild: slow | no | transformed |
202 | | inline-cheap-module-source-map | build: slow rebuild: slow | no | original lines |
203 | | inline-source-map | build: slowest rebuild: slowest | no | original |
204 | | eval-nosources-cheap-source-map | build: ok rebuild: fast | no | transformed |
205 | | eval-nosources-cheap-module-source-map | build: slow rebuild: fast | no | original lines |
206 | | eval-nosources-source-map | build: slowest rebuild: ok | no | original |
207 | | inline-nosources-cheap-source-map | build: ok rebuild: slow | no | transformed |
208 | | inline-nosources-cheap-module-source-map | build: slow rebuild: slow | no | original lines |
209 | | inline-nosources-source-map | build: slowest rebuild: slowest | no | original |
210 | | nosources-cheap-source-map | build: ok rebuild: slow | no | transformed |
211 | | nosources-cheap-module-source-map | build: slow rebuild: slow | no | original lines |
212 | | nosources-source-map | build: slowest rebuild: slowest | yes | original |
213 | | hidden-nosources-cheap-source-map | build: ok rebuild: slow | no | transformed |
214 | | hidden-nosources-cheap-module-source-map | build: slow rebuild: slow | no | original lines |
215 | | hidden-nosources-source-map | build: slowest rebuild: slowest | yes | original |
216 | | hidden-cheap-source-map | build: ok rebuild: slow | no | transformed |
217 | | hidden-cheap-module-source-map | build: slow rebuild: slow | no | original lines |
218 | | hidden-source-map | build: slowest rebuild: slowest | yes | original |
219 |
220 | 表格中我们配置不同的 devtool 选项,主要是为了达到不同的 quality 和 performance 目的
221 |
222 | ### quality 的理解
223 |
224 | quality 描述了打包后我们在调试时能看到的源码内容:
225 |
226 | - bundled: 模块未分离
227 | - generated: 模块分离,未经 loader 处理的代码
228 | - transformed: 模块分离,经 loader 处理过的代码
229 | - original: 自己写的代码,定位精确到行、列
230 | - original lines: 自己写的代码,定位只精确到行
231 |
232 | ### devtool 格式
233 |
234 | devtool 的名称格式可以总结为,我们只需要记住以下每个选项的特点,就可以轻易理解所有的 devtool 选项了:
235 |
236 | ```
237 | [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
238 | ```
239 |
240 | #### inline-
241 |
242 | 将 SourceMap 内联到原始文件中,而不是创建一个单独的文件。
243 |
244 | devtool 为 `source-map` 的情况下打包后,有一个.map 文件存储代码的映射关系:
245 |
246 | 
247 |
248 | devtool 为 `inline-source-map` 的情况下,映射关系会一同写到编译后的代码中:
249 |
250 | 
251 |
252 | #### hidden-
253 |
254 | hidden 仍然会生成.map 文件,但是打包后的代码中没有 sourceMappingURL,也就是说请求代码时浏览器不会加载.map 文件,控制台中看不到源代码。这种一般用于错误收集等场景,出错时前端把出错的行列传给服务端,服务端根据行列以及.map 文件解析出出错的源码位置。
255 |
256 | devtool 为 `source-map` 时,显示出错源代码位置:
257 |
258 | 
259 |
260 | devtool 为 `hidden-source-map` 时,只显示打包后的代码出错位置:
261 |
262 | 
263 |
264 | #### eval-
265 |
266 | eval- 会通过 `eval` 包裹每个模块打包后代码以及对应生成的 SourceMap,因为 `eval` 中为字符串形式,所以当源码变动的时候进行字符串处理会提升 rebuild 的速度。
267 |
268 | 但同样因为是 `eval` 包裹 js 代码,很容易被 XSS 攻击,存在很大的安全隐患。
269 |
270 | 另外,在现代浏览器中有两种编译模式:fast path 和 slow path。fast path 是编译那些稳定和可预测(stable and predictable)的代码。而明显的,eval 不可预测,所以将会使用 slow path。在旧的浏览器中,使用 eval 的性能会大幅下降。
271 |
272 | 综上,eval 我们一般只用于开发环境,不会用于打包线上环境的代码。
273 |
274 | #### nosources-
275 |
276 | 使用这个关键字生成的 SourceMap 中不包含 sourcesContent 内容,因此调试时只能看到文件信息和行信息,无法看到源码。
277 |
278 | 
279 |
280 | #### cheap-[module-]
281 |
282 | 使用 cheap 时,SourceMap 的代码定位只会定位到源码所在的行,不会定位至具体的列,所以构建速度有所提升。另外如果只用 cheap ,显示的是 loader 编译之后的源代码,加上 module 后会显示编译之前的源代码。
283 |
284 | 例如有如下代码:
285 |
286 | ```js
287 | import { add } from './add';
288 | const a = 1;
289 | const b = 2;
290 | console.log(add(a, b));
291 | console.logo(111);
292 | console.log('hello world');
293 | ```
294 |
295 | 使用 `source-map` 打包的结果,点击控制台的报错信息,可以看到直接定位到 loader 编译前的源代码,并且光标会定位到出错代码所在的列:
296 |
297 | 
298 |
299 | 使用 `cheap-source-map` 打包,点击控制台的报错信息,是定位到了错误代码所在的行,但是光标并没有定位到错误代码所在的列。另外显示的源代码是经过了 loader 编译之后的代码,而不是原始的源代码:
300 |
301 | 
302 |
303 | 使用 `cheap-module-source-map` 打包,点击控制台的报错信息,可以看到显示的是 loader 编译前的源代码:
304 |
305 | 
306 |
307 | ## 如何选择 devtool
308 |
309 | 根据不同环境,我们需要选择不同的 devtool。
310 |
311 | 开发环境下,由于我们需要频繁的修改代码,更多的考虑的开发效率和调试效率,所以更多关注 performance 中 rebuild 的性能。
312 |
313 | 生产环境下,我们不必过多关注打包性能,主要考虑 quality 代码的保护性、出错的定位速度已经安全性等。
314 |
315 | ### production
316 |
317 | 线上环境官方推荐的 devtool 有 4 种:
318 |
319 | - none
320 | - source-map
321 | - hidden-source-map
322 | - nosources-source-map
323 | 线上环境没有绝对的最优选择一说,根据自己业务需要去选择即可,很多项目也是选择除上述 4 种之外的 `cheap-module-source-map` 选项。
324 |
325 | ### development
326 |
327 | 开发环境选择就比较容易了,只需要考虑打包速度快、调试方便,官方推荐以下 4 种:
328 |
329 | - eval
330 | - eval-source-map
331 | - eval-cheap-source-map
332 | - eval-cheap-module-source-map
333 | 大多数情况下我们选择 `eval-cheap-module-source-map` 即可。
334 |
--------------------------------------------------------------------------------
/docs/开发场景/多人协作编辑.md:
--------------------------------------------------------------------------------
1 | ---
2 | desc: '本文总结了多种解决多人编辑场景下的内容覆盖的方案:编辑锁、版本合并、协同编辑,针对不同场景,我们可以选择不同的方案。'
3 | tag: ['开发场景']
4 | time: '2022-04-21'
5 | ---
6 |
7 | # 如何解决多人协作编辑场景内容覆盖问题
8 |
9 | ## 背景
10 |
11 | 最近做项目时,用户 A 反馈在使用后台编辑了某内容后,过了一段时间重新进入页面发现内容还是很久前的版本,自己的保存没有生效。经过查找日志发现了原因,是期间有多位用户编辑过该内容,用户 B 点击保存时由于自己页面的内容还是很早之前的,覆盖了用户 A 编辑后的版本。
12 |
13 | 现在前端和服务端已经商讨出了该问题的解决方案,但个人对于这个课题比较感兴趣,所以对此进行了一个较为深入的调研。
14 |
15 | ## 方案 A:编辑锁 —— 禁止多人编辑
16 |
17 | 解决上述问题第一个思路是,那么既然多人同时编辑可能导致内容覆盖,我就不让多人同时编辑,当有人在编辑某个文档时,系统会将这个文档锁定,避免其他人同时编辑,这种方式一般被称为编辑锁。
18 |
19 | 编辑锁的思路来源于关系型数据库的悲观锁。悲观锁,正如其名,具有强烈的独占和排他特性。它假定当前事务操纵数据资源时,肯定还有其他事务同时访问该数据资源,为了避免当前事务的操作受到干扰,先锁定资源,其他事务无法访问该资源直到锁结束。
20 |
21 | ### 编辑锁实现思路
22 |
23 | 当用户 1 进入某个内容的编辑时,向服务端发送一个请求,服务端将该内容的锁定人标记为用户 1。其他用户访问该内容时,显示用户 1 正在编辑当前内容而无法访问,直至用户 1 编辑完毕将该内容解锁。
24 |
25 | 为了提高效率以及防止某一用户长时间锁定某个资源无法释放,通常编辑锁可做以下优化手段:
26 |
27 | - 当用户 1 锁定某个资源时,其他用户没有该内容的编辑权限,但是有访问权限。
28 | - 当前锁定某个资源的用户,需每隔一段时间向服务端发送请求,将资源的锁续期。若超出间隔时间没有发送,则自动将该资源解锁。
29 | - 用户 2 访问该资源时,假如当前资源被用户 1 锁定,用户 2 可向用户 1 申请锁权限转移,若用户 1 同意,则服务端将该资源的锁定人由用户 1 改为用户 2。
30 |
31 | 
32 |
33 | ### 编辑锁的优缺点
34 |
35 | 编辑锁同时只能有一人编辑的特点,可以有效避免用户因为版本覆盖而导致的无效工作,同时因为其实现相对来说比较简单,所以是多人协作编辑场景中应用最广泛的一种方案(考虑到实现成本,我们的项目中最终也采取了这种折中的方案)。
36 |
37 | 但是编辑锁无法做到多人同时编辑,就使用体验来讲还是不够友好。
38 |
39 | ## 方案 B:允许多人编辑,防止覆盖
40 |
41 | ### 版本变更提示
42 |
43 | 上面写到的背景中,引起我们项目问题的原因,主要是用户在不知情的情况下发生了内容的覆盖。那么对症下药,我们可以增加一个版本变更提示的功能:当用户编辑内容时,如果在编辑的过程中有其他用户提交了新的版本,则页面给出提示发生了版本变更,是否要覆盖。
44 |
45 | #### 版本变更提示实现思路
46 |
47 | 版本变更提示的实现上非常简单,用户获取内容时,服务端同时返回该内容的当前版本号。当用户保存时,将修改后内容和拿到的版本号一并提交,服务端校验版本号与数据库中的版本号是否一致:
48 |
49 | - 若一致,表示在该用户编辑的过程中没有版本变更,直接保存成功
50 | - 若不一致,表示在该用户编辑的过程中发生了新的版本变更,保存失败并通知用户,用户可选择是否要进行覆盖
51 |
52 | 
53 |
54 | #### 版本变更提示的不足
55 |
56 | 版本变更提示这种方案只是避免了用户未知情况的版本覆盖问题,但是最终还是只能选择进行版本覆盖或者舍弃当前版本,不能解决多人协作时的冲突问题。
57 |
58 | ### 优化:版本合并
59 |
60 | 版本变更提示的方案中,最终我们只能选择保留当前编辑的版本或者数据库中的最新版本。小孩子才做选择,作为成年人,我们当然是全都想要。
61 |
62 | 
63 |
64 | 那么我们可以使用版本合并,将每个版本的变更都保留下来。说起版本合并,那作为研发的我们很容易联想到 git 的版本合并,git 的 merge 其实也是一个 diff-patch 的过程。diff 和 patch 是一对工具,diff 可以比较两个内容之间的差异并记录下来,根据差异生成一个 patch,然后将 patch 应用于其他内容从而更新内容。
65 |
66 | 在我们的协作编辑场景中,当用户将文件内容由版本 A 改为版本 B 时,保存时将版本 B 和版本 A 的 patchBA 发送给服务端,服务端获取数据库中该文件最新版本 X,然后将通过 patchBA 应用于版本 X,从而实现更新。
67 |
68 | 上面 diff-patch 过程中最大的问题,是应用 patchBA 时可能发生冲突,如果有冲突版本 X 无法直接更新。此时,服务端需要将 patchXA 发送给用户,用户手动解决 patchBA 和 patchXA 之间的冲突并进行合并,冲突解决后,发送一个 patchBX 给服务端,从而最终实现更新。
69 |
70 | 
71 |
72 | #### 基于行的 diff
73 |
74 | 常见的文本 diff 一般分为基于行的 diff 和基于字符的 diff,例如我们常用的 git 就是基于行的 diff。
75 |
76 | 例如现在有如下一组原始数据 A,然后用户 1 基于版本 A 修改了小钱的手机号。数据如下:
77 |
78 | ```js
79 | const versionA = `
80 | 小张 18866277777=
81 | 小吴 12233333111
82 | 小钱 19277788888
83 | 小叶 12111111222
84 | `;
85 | // 修改后:
86 | const versionB = `
87 | 小张 18866277777
88 | 小吴 12233333111
89 | 小钱 18888888888
90 | 小叶 12111111222
91 | `;
92 | ```
93 |
94 | 在 javascript 中,我们可以借助 [diff](https://github.com/kpdecker/jsdiff) 这个 npm 库进行基于行的内容 diff 和 patch 操作。`createPatch` 方法第一个参数为文件名,第二个参数为修改前的内容,第三个参数为修改后的内容,最后返回两个版本的 patch 结果:
95 |
96 | ```js
97 | import { createPatch, applyPatch } from 'diff';
98 |
99 | const patchBA = createPatch('data', versionA, versionB);
100 | console.log(patchBA); // 输出结果如下
101 | ```
102 |
103 | 上面的 patch 结果输出如下,前面带 `-` 的表示要删除的行,带 `+` 的表示要新增的行:
104 |
105 | ```diff
106 | Index: data
107 | ===================================================================
108 | --- data
109 | +++ data
110 | @@ -2,6 +2,6 @@
111 | 小张 18866277777
112 | 小吴 12233333111
113 | -小钱 19277788888
114 | +小钱 18888888888
115 | 小叶 12111111222
116 | ```
117 |
118 | 那么假如此时又有一个用户 2,基于版本 A 将内容修改成了如下的版本 C,在中间添加了一行小黑的数据。我们不能直接将版本 C 保存为最终结果,而需要拿到远程最新版本 B,将 patchCA 应用于版本 B,以获得最终的结果:
119 |
120 | ```js
121 | const versionC = `
122 | 小张 18866277777
123 | 小黑 19222221111
124 | 小吴 12233333111
125 | 小钱 19277788888
126 | 小叶 12111111222
127 | `;
128 |
129 | const patchCA = createPatch('data', versionA, versionC);
130 | let result = applyPatch(versionB, patchCA);
131 | console.log(result);
132 | ```
133 |
134 | 最终 result 的结果输出如下,可以看到小钱所在行数据的修改以及增加的小黑那一行都出现在了最终的数据结果中:
135 |
136 | ```text
137 | 小张 18866277777
138 | 小黑 19222221111
139 | 小吴 12233333111
140 | 小钱 18888888888
141 | 小叶 12111111222
142 | ```
143 |
144 | 但是基于行的算法很容易产生冲突。例如有如下一行信息需要进行填写,其中有`姓名`、`年龄`、`手机号`和`所在省份`进行填写。用户 A 填写了其中的`姓名`和`年龄`,用户 B 填写了其中的`手机号`和`所在省份`:
145 |
146 | ```js
147 | const data = '姓名:; 年龄:; 手机号:; 所在省份:;';
148 |
149 | const dataA = '姓名:小周; 年龄:23; 手机号:; 所在省份:;';
150 | const dataB = '姓名:; 年龄:; 手机号:18866668888; 所在省份:北京市;';
151 | ```
152 |
153 | 如果现在将 dataA 和 dataB 按照基于行的 diff 合并,因为一个只有一行数据,那么一定会发生冲突。但其实它们可以合并为`'姓名:小周; 年龄:23; 手机号:18866668888; 所在省份:北京市;'` 这样一条完整的数据,为了解决这个问题,我们可以使用更细粒度的基于字符的 diff。
154 |
155 | #### 基于字符的 diff
156 |
157 | 实际上上面提到的 [diff](https://github.com/kpdecker/jsdiff) 库也有基于字符粒度的 diff,但是缺少了基于字符粒度的 patch 封装,我们使用 [diff-match-patch](https://github.com/JackuB/diff-match-patch) 这个库进行基于字符粒度的 diff。
158 |
159 | ```js
160 | import DiffMatchPatch from 'diff-match-patch';
161 | const dmp = new DiffMatchPatch();
162 |
163 | const patchA = dmp.patch_make(data, dataA);
164 | const result = dmp.patch_apply(patchA, dataB);
165 | console.log(result);
166 | ```
167 |
168 | result 结果是一个数组,数组第一项是合并后的结果,第二项是一个 patch 是否成功的数组:
169 |
170 | ```js
171 | ['姓名:小周; 年龄:23; 手机号:18866668888; 所在省份:北京市;', [true]];
172 | ```
173 |
174 | 所以从结果看,基于字符的 diff-patch 效果要远好于基于行的 diff-patch。
175 |
176 | #### 不可避免的冲突
177 |
178 | 个人进行了一些其他的文本基于字符粒度 diff-patch 的样例测试,在部分情况下还是会有冲突,但是 `diff-match-patch` 面对冲突时会自动选择一部分版本进行保留,这可能导致我们部分内容的丢失:
179 |
180 | ```js
181 | import DiffMatchPatch from 'diff-match-patch';
182 | const dmp = new DiffMatchPatch();
183 |
184 | const data = '篮球,足球';
185 | const dataA = '棒球,足球,羽毛球';
186 | const dataB = '台球,足球,乒乓球';
187 |
188 | const patchA = dmp.patch_make(data, dataA);
189 | const res = dmp.patch_apply(patchA, dataB);
190 | console.log(res);
191 | ```
192 |
193 | 如上述代码,版本 A 中将`篮球`改成了`棒球`,版本 B 中将`篮球`改成了`台球`,理论上 patch 是会发生冲突的。但最终 res 的输出如下,`diff-match-patch` 直接舍弃了 `台球` 保留了 `棒球`:
194 |
195 | ```js
196 | ['棒球,足球,羽毛球,乒乓球', [true]];
197 | ```
198 |
199 | ### 进一步优化:协同编辑
200 |
201 | 上面的版本合并我们知道,最大的问题就是版本冲突,结合我们使用 git 的经验,其实我们可以发现,我们编辑时的版本,与数据库最新版本之间的版本差异数量越大,越容易产生冲突。那么我们自然可以联想到,只要让我们当前正在编辑的版本与数据库最新版本尽量保持一致,就不容易产生冲突了。这就来到了我们本篇的重点——协同编辑。
202 |
203 | 在追求高用户体验的场景,例如各种在线文档,几乎都会采用协同编辑的方案,保证多人在线实时编辑。要实现协同编辑,主要需要实现几个关键技术点:
204 |
205 | - 用于增量传输的 Diff 算法:在协同编辑领域,常用的两种技术为 OT(Operational Transformation) 和 CRDT (Conflict-free Replicated Data Type)
206 | - 文档的实时更新:可以采用 WebSocket 或者是轮询的方法,在追求性能和体验的情况下,通常我们会选择 WebSocket
207 | - 更新内容的富文本编辑器:此项是可选的,通常多人在线编辑的场景需要支持丰富的内容编辑,因此需要一个富文本编辑器,普通的文本编辑场景不需要。
208 |
209 | #### OT
210 |
211 | OT 是多用于协同编辑领域的一种技术,正如其英文全称 Operational Transformation 一样,分为两个步骤:首先是将用户的编辑行为转换成可枚举的操作(Operational);如果是有多人操作同时进行,则对这些操作进行转换(Transformation)。
212 |
213 | 在协同编辑领域,OT 技术应用广泛,一些优秀的在线文档如飞书云文档、Google Doc 等都是使用了 OT,可以说是久经考验,因此值得我们学习。
214 |
215 | 首先从 Operational 说起,以最简单的字符串类型数据为例,我们对于字符串的修改,可以分为以下三类操作:
216 |
217 | - retain(n):保留 n 个字符不做更改
218 | - insert(str):插入字符串 str
219 | - delete(str):删除字符串 str
220 |
221 | ```text
222 | 我要吃烤地瓜
223 | ↓↓↓
224 | 今天吃了三个烤地瓜
225 | ```
226 |
227 | 例如上述的文本变化,在 OT 算法的 Operation 中,实际上是经过了以下的操作:
228 |
229 | ```js
230 | delete '我要';
231 | insert('今天');
232 | retain(1);
233 | insert('了三个');
234 | retain(3);
235 | ```
236 |
237 | 当有多个用户在同时进行 operation 时,传到服务端的 operation 同步到各个用户端可能产生冲突。
238 | 
239 | 例如上面的用户 A 和用户 B 同时操作了同一段文本内容 `abcd`:
240 |
241 | - 用户 A 的前端显示肯定是先应用了自己的 optA,文本内容变为 `abcde`,然后再应用来自服务端的 optB,文本内容变为 `abcdfe`
242 | - 用户 B 先应用了自己的 optB,文本内容变为 `abcdf`,然后再应用来自服务端的 optB,文本内容变为 `abcdef`
243 | 最终用户 A 和用户 B 展示的修改结果就发生了冲突。解决上面冲突的方法,我们就需要一个转换算法 transformation 对 operation 进行转换。根据转换视角的不同,常用的转换算法有两种:EasySync 和 undo 。
244 |
245 | ##### 基于 EasySync 的双边转换
246 |
247 | EasySync 是一种双边操作转换,即上面的案例中,用户 A 和用户 B 会站在各自的视角,都先各自执行自己的操作,再分别执行服务端转换后的对方的操作,最终得到一致的结果。即:
248 |
249 | 
250 | 其中的 `transform()` 的结果如下:
251 |
252 | ```js
253 | transform(optA, optB) = () => {
254 | retain(4);
255 | insert('f');
256 | retain(1);
257 | }
258 |
259 | transform(optB, optA) = () => {
260 | retain(5);
261 | insert('e');
262 | }
263 | ```
264 |
265 | EasySync 算法中,假如把初始状态 `abcd` 记为状态 `O`,那么需满足如下公式恒成立:
266 |
267 | ```text
268 | O -> optA -> transform(optA, optB) === O -> optB -> transform(optB, optA)
269 | ```
270 |
271 | ##### 基于 undo 的单边转换
272 |
273 | undo 是一种单边转换算法,即无论站在用户 A 还是用户 B 的视角,他们最终应用的操作都是一样的,从而保证结果的一致。
274 |
275 | 例如上述案例,假如 optA 先传到服务端,那么用户 A 先应用了 optA,等 optB 也传到服务端之后,再应用 `transform(optA, optB)` 操作。而用户 B 在本地应用了 optB 之后,传到了服务端发现过程中有 optA 先修改了内容,则先执行 undo 操作,将状态改回 `O`,然后再执行 optA 和 `transform(optA, optB)`,最终保持与 A 的内容一致。流程图如下:
276 |
277 | 
278 |
279 | 其中,`transform(optA, optB)` 相当于如下操作:
280 |
281 | ```js
282 | retain(4);
283 | insert('f');
284 | retain(1);
285 | ```
286 |
287 | ##### 基于 OT 的开源库
288 |
289 | 在基于 OT 的在线编辑实现中,我们可以发现难点主要有以下几点:
290 |
291 | - 根据前端的内容变更来创建对应的 operation
292 | - 服务端对相应的 operation 进行转换
293 | - 通过 WebSocket,实现内容的实时响应
294 | 其中尤其前两点实现起来不是特别的容易,好在 github 有一些开源库可以帮助我们快捷的进行实现(第一点和第二点各有不少的开源库,但是难点在于前端和服务端所采用的 operation 需要保持一致)。
295 |
296 | - [etherpad](https://github.com/ether/etherpad-lite): 基于 EasySync OT 实现的在线编辑器,将上面的三个技术点全部进行了封装。如果是个人开发者,可以使用其提供的中心化 WebSocket;如果是公司使用,可以根据[指引](https://github.com/ether/etherpad-lite#installation)将自己在服务单部署 WebSocket 服务,保证数据的安全。
297 | 在线体验地址:https://video.etherpad.com/
298 | - [ot.js](https://github.com/Operational-Transformation/ot.js):字符串格式的 ot 实现,上述的 OT 用例中就是以该库的 operation 进行讲解。封装了根据字符串的变更创建对应的 operation 以及 operation 的 transform 操作,缺点是需要自己手动实现 WebSocket 及相关的前后端通信逻辑,且只支持字符串格式。
299 | 教学 demo:https://github.com/Operational-Transformation/ot-demo
300 | - [shareDB](https://github.com/share/sharedb):支持多种字符串、富文本、json 等多种 operation 及其 transform,由于支持的转换格式丰富,所以如果是想要针对自己的产品实现 OT 协同,应用比较广泛。
301 |
教学 demo:https://github.com/share/sharedb/tree/master/examples
302 |
303 | #### CRDT
304 |
305 | CRDT (Conflict-free Replicated Data Type)即“无冲突复制数据类型”,它主要被应用在分布式系统中,保证分布式应用的数据一致性。文档协同编辑可以理解为分布式应用的一种,它的本质是数据结构,通过数据结构的设计保证并发操作数据的最终一致性。
306 |
307 | CRDT 的提出时间比 OT 要晚很多,所以在多人协作编辑场景下的成熟产品相对较少,但是也有了一些应用,例如:atom 编辑器的 teletype、PingCode Wiki、以及 figma 的协作编辑也是借鉴了 CRDT 的思想。
308 |
309 | ##### CRDT 核心思想
310 |
311 | 前面提到 CRDT 主要应用于分布式系统,那么它的数据操作,都需要符合可交换性和幂等性,已解决以下可能遇到的问题:
312 |
313 | - 网络问题导致发送接收顺序不一致(可交换性)
314 | - 以及多次发送(幂等性)
315 | 那么放到我们的编辑场景中,首先要保证操作的可交换性,那么我们只需要知道所有操作的顺序,最后对操作进行排序就可以了。我们可以依据每个操作的 timeStamp,对操作进行排序。每个用户都有一个 UID,多个不同用户如果出现 timeStamp 相同的情况下,我们可以按照 UID 进行升序,保证并发操作的顺序。
316 |
317 | 同时,UID 和 timeStamp 组合在一起,就保证了每一个操作都具备唯一的 ID,可以实现幂等性,解决统一操作多次发送的问题。
318 |
319 | ##### CRDT 树状结构
320 |
321 | 
322 |
323 | 还是以上面的这个场景为例,在 CRDT 中,会为每一个字符都创建一个操作标识,假如初始状态 `abcd` 中的 `d` 的标识为 `UserO@T4`,那么用户 A 和用户 B 的操作都是基于操作标识的:
324 |
325 | ```
326 | UserA@T5: insert('e') at UserO@T4
327 | UserB@T6: insert('f') at UserO@T4
328 | ```
329 |
330 | 那么这两个操作只需要保证好他们的顺序,不需要转换就能够保证最终修改结果的一致,实际的操作数据结构如下:
331 |
332 | 
333 |
334 | 从这个操作结构我们可以看得出,为了保证 CRDT 的实现,数据库中需要存储每一个字符的标志符,同时当有新的操作产生时,需要遍历操作的树状结构,找到与当前操作所关联的那个节点。
335 |
336 | ##### 基于 CRDT 的开源库
337 |
338 | - [Yjs](https://github.com/yjs/yjs):社区最知名的 CRDT 框架,从 V8 的角度去优化 Yjs 结构对象的创建,整体思路就是让 Yjs 创建对象的过程能够被浏览器优化,无论是内存占用还是对象创建速度。
339 | 其他的基于 Yjs 的框架还有 [SyncedStore](https://github.com/yousefed/SyncedStore) 等。
340 |
341 | #### OT 与 CRDT 对比
342 |
343 | OT 算法由于发展时间长,已经相对成熟,但是社区很多人对 CRDT 在多人协作场景的应用表示看好,并且认为未来 CRDT 会比 OT 更加有前景。就目前来说,二者对比如下:
344 |
345 | | 框架 | 优势 | 劣势 |
346 | | ---- | ----------------------------------------------------------------- | ------------------------------------------------- |
347 | | OT | 1. 高性能
2. 能够保存用户操作意图
3. 不影响文档体积 | 1. 需要中心化服务器
2. 算法设计复杂
|
348 | | CRDT | 1. 去中心化
2. 算法设计相对简单
3. 稳定性高 | 1. 比较消耗内存和性能
2. 损失用户操作意图 |
349 |
350 | ## 总结
351 |
352 | 本文总结了多种解决多人编辑场景下的内容覆盖的方案,针对不同场景,我们可以选择不同的方案。
353 |
354 | - 如果只是想解决内容覆盖问题,没有多人协作的要求,那么推荐使用编辑锁
355 | - 如果有多人协作需求,但对内容的实时性要求低,那么可以考虑采用版本合并的方案
356 | - 如果想要实现多人实时协作,那么只能考虑采用 OT 或者 CRDT 实现协同编辑。
357 |
358 | ## 参考
359 |
360 | - [多人协同编辑技术的演进](https://zhuanlan.zhihu.com/p/425265438?utm_medium=social&utm_oi=685143097629872128)
361 | - [实时协同编辑的实现](https://fex.baidu.com/blog/2014/04/realtime-collaboration/)
362 | - [什么是 CRDT](https://www.zhihu.com/question/507425610/answer/2299709925)
363 | - [OT 算法在协同编辑中的应用](https://segmentfault.com/a/1190000040203619?utm_source=sf-hot-article)
364 |
--------------------------------------------------------------------------------
/home-footer/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "1.0.0",
4 | "description": "zh-lx学习记录",
5 | "main": "index.js",
6 | "repository": "git@github.com:zh-lx/docs.git",
7 | "author": "zh-lx <18366276315@163.com>",
8 | "license": "MIT",
9 | "devDependencies": {
10 | "@types/node": "^15.0.2",
11 | "@vuepress/bundler-webpack": "2.0.0-beta.59",
12 | "@vuepress/plugin-register-components": "^1.9.7",
13 | "postcss-loader": "^5.2.0",
14 | "sass-loader": "^13.2.0",
15 | "vuepress": "2.0.0-beta.59"
16 | },
17 | "scripts": {
18 | "dev": "vuepress dev docs",
19 | "build:local": "vuepress build docs",
20 | "build": "git pull && git submodule update --init --recursive && yarn install && vuepress build docs",
21 | "eject": "./node_modules/@vuepress/cli/bin/vuepress.js eject",
22 | "pull": "git pull && git submodule update --init --recursive"
23 | },
24 | "dependencies": {
25 | "@vuepress/plugin-search": "2.0.0-beta.59",
26 | "@vuepress/plugin-theme-data": "2.0.0-beta.59"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "esnext", "ES2015"],
4 | "module": "es2015",
5 | "types": ["node"],
6 | "moduleResolution": "node",
7 | "preserveConstEnums": true,
8 | "removeComments": true,
9 | "sourceMap": true,
10 | "target": "es5",
11 | "strict": false,
12 | "allowSyntheticDefaultImports": true,
13 | "baseUrl": ".",
14 | "paths": {
15 | "@/*": ["vuepress-theme-writing/src/client/*"]
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/vuepress.config.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import { webpackBundler } from '@vuepress/bundler-webpack';
3 | import { defineUserConfig } from '@vuepress/cli';
4 | import WriteTheme from './vuepress-theme-writing/src/node/index';
5 |
6 | export default defineUserConfig({
7 | title: '前端技术分享',
8 | description:
9 | '欢迎来到周立翔的小窝,这里记录我个人学习过程中的感悟和小结,与诸君共勉',
10 | theme: WriteTheme({
11 | logo: '/images/logo.png',
12 | repo: 'zh-lx/blog',
13 | sidebarDepth: 6,
14 | }),
15 | alias: {
16 | HomeFooter: path.resolve(__dirname, './home-footer/index.vue'),
17 | },
18 | bundler: webpackBundler({}),
19 | define: {
20 | $Site: {
21 | title: '周立翔的小窝',
22 | description:
23 | '欢迎来到周立翔的小窝,这里记录我个人学习过程中的感悟和小结,与诸君共勉',
24 | // type: 'docs',
25 | },
26 | $Author: {
27 | name: '周立翔',
28 | avatar: '/images/avatar.jpg',
29 | introduction: 'a geek developer',
30 | },
31 | $Contact: {
32 | juejin: 'https://juejin.cn/user/650530414137534',
33 | github: 'https://github.com/zh-lx',
34 | qq: '1134558955',
35 | wechat: 'zhoulx1688888',
36 | // email: '1134558955@qq.com',
37 | // csdn: '',
38 | // zhihu: 'https://www.zhihu.com/people/zhou-li-xiang-66-91',
39 | },
40 | },
41 | });
42 |
--------------------------------------------------------------------------------