CHANGELOG Documentation

Official TypeScript developer resources and documentation for zh-lx/CHANGELOG. Verified technical context for AI.

0 stars TypeScript TypeScript 1 file · ~44,392 tokens
RAW Doc

1. Project Overview & Quickstart (zh-lx/CHANGELOG)

Technical Documentation: zh-lx/CHANGELOG

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

CHANGELOG

Agent Skills & Rules

  • Discovered 21 agent rule(s) and prompt configurations.

--- METRICS ---

2. Official Technical Reference & Guides (zh-lx/docs)

File: README.md

个人博客

个人前端及全栈学习过程中的一些文章输出

博客网站预览地址:https://zlxiang.com


File: package.json

{
"name": "docs",
"version": "1.0.0",
"description": "zh-lx学习记录",
"main": "index.js",
"repository": "[email protected]:zh-lx/docs.git",
"author": "zh-lx [email protected]",
"license": "MIT",
"devDependencies": {
"@types/node": "^15.0.2",
"@vuepress/bundler-webpack": "2.0.0-beta.59",
"@vuepress/plugin-register-components": "^1.9.7",
"postcss-loader": "^5.2.0",
"sass-loader": "^13.2.0",
"vuepress": "2.0.0-beta.59"
},
"scripts": {
"dev": "vuepress dev docs",
"build:local": "vuepress build docs",
"build": "git pull && git submodule update --init --recursive && yarn install && vuepress build docs",
"eject": "./node_modules/@vuepress/cli/bin/vuepress.js eject",
"pull": "git pull && git submodule update --init --recursive"
},
"dependencies": {
"@vuepress/plugin-search": "2.0.0-beta.59",
"@vuepress/plugin-theme-data": "2.0.0-beta.59"
}
}


File: tsconfig.json

{
"compilerOptions": {
"lib": ["dom", "esnext", "ES2015"],
"module": "es2015",
"types": ["node"],
"moduleResolution": "node",
"preserveConstEnums": true,
"removeComments": true,
"sourceMap": true,
"target": "es5",
"strict": false,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/": ["vuepress-theme-writing/src/client/"]
}
}
}


File: docs/node/koa源码解读及实现一个简易版koa.md


desc: '本文将细节分析 koa 的源码,并基于其原理实现一个简易版的 koa。' cover: 'https://github.com/zh-lx/blog/assets/73059627/a15b7251-115f-43ef-99f2-247b0b2f5c0d' tag: ['node', 'koa'] time: '2021-05-27'

Koa 源码解读及实现一个简易版 Koa

Koa 框架介绍

koa 是由 Express 原班人马打造的,致力于成为一个更小、更富有表现力、更健壮的 Web 框架。使用 koa 编写 web 应用,通过组合不同的 generator,可以免除重复繁琐的回调函数嵌套,并极大地提升错误处理的效率。koa 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用变得得心应手。

特点

  • 轻量、无捆绑
  • 中间件架构
  • 通过不同的 generator 以及 await/async 替代了回调
  • 增强的错误处理
  • 简单易用的 api

简单使用

Koa 对 node 服务进行了封装,并提供了简单易用的 API。假如我们想在请求 3000 端口时返回 hello, node! 的数据,使用原生 node 实现代码如下:

js
const http = require('http');

const server = http.createServer((req, res) => {
  res.end('hello, node!');
});

server.listen(3000, () => {
  console.log('server is running on 3000...');
});

使用 Koa 实现如下:

js
const Koa = require('koa');
const app = new Koa();

app.use((ctx, next) => {
  ctx.body = 'hello, node!';
});

app.listen(3000, () => {
  console.log('server is running on 3000...');
});

通过对比可以发现,koa 实现方式通过 new Koa() 创建了一个 koa 实例,实例上有 use 方法,use 的回调函数中接收 ctx 和 next 两个参数。就这简单的几点,基本就组成了 koa 的全部内容。

中间件和洋葱圈模型

中间件是 Koa 的核心,koa 通过 use() 去调用一系列的中间件,并通过 next() 将上下文交给下一个中间件去进行处理。当没有下一个 next() 可执行之后,再倒序执行每个 use() 回调函数中 next 之后的逻辑。

这就是 koa 的洋葱圈模型:

如下一段代码,在请求 localhost:3000 端口后 node 控制台打印顺序为: 1、3、5、6、4、2:

js
const Koa = require('koa');
const app = new Koa();

app.use((ctx, next) => {
  console.log(1);
  next();
  console.log(2);
});

app.use((ctx, next) => {
  console.log(3);
  next();
  console.log(4);
});

app.use((ctx, next) => {
  console.log(5);
  ctx.body = 'hello, node!';
  console.log(6);
});

app.listen(3000, () => {
  console.log('server is running on 3000...');
});

Koa 源码结构

Koa 源码

Koa 的核心文件一共有四个:application.js、context.js、request.js、response.js。所有的代码加起来不到 2000 行,十分的轻便,而且大量代码集中在 request.js 和 response.js 对于请求头和响应头的处理,核心代码只有几百行。

application

application.js 是 koa 的入口文件,里面导出了 koa 的构造函数,构造函数中包含了 koa 的主要功能实现。

listen

application 构造函数首先通过 node 中 http 模块,实现了 listen 功能:

js
listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

use

use 方法将接收到的中间件函数,全部添加到了 this.middleware 中,以便后面按顺序调用各个中间件。同时为了兼容 koa1 中的 use 使用,对于 generator 类型的中间件函数,会通过 koa-convert 库将其进行转换,以兼容 koa2 中的 koa 的递归调用。

js
use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    if (isGeneratorFunction(fn)) { // 兼容 koa1 的 use 用法
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    this.middleware.push(fn);
    return this;
}

callback

上面 listen 函数在服务启动时,createServer 函数会返回 callback 函数的执行结果。

在服务启动时,callback 函数做了中间件的合并,监听框架层的错误请求等功能。

然后返回了 handleRequest 的方法,它接收 req 和 res 两个参数,每次服务端收到请求时,会根据 node http 原生的 req 和 res,创建一个新的 koa 的上下文 ctx。

js
callback() {
    const fn = compose(this.middleware); // 合并中间件

    if (!this.listenerCount('error')) this.on('error', this.onerror); // 捕获框架层的错误

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res); // 创建上下文
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

createContext

再来看 createContext 函数,一大串的赋值骚操作,我们细细解读一下:

  1. 先通过 Object.create(),创建了新的从 context.js、request.js、response.js 引入的对象,防止引入的原始对象被污染。

  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

  3. 通过一系列的赋值操作,将原始的 http 请求的 res 和 req,以及 Koa 实例 app 等等分别挂载到了 context、request 和 response 对象中,以便于在 context.js、request.js 和response.js 中针对原始的请求、相应参数等做一些系列的处理访问,便于用户使用

js
createContext(req, res) {
    // Object.create()创建
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
  }

最终这段代码执行后的关系图如下:

handleRequest

callback 中执行完 createContext 后,会将创建好的 ctx 以及合并中间件后生成的顺序执行函数传给 handleRequest 并执行该函数。

handleRequest 中会通过 onFinished 这个方法监听 res,当 res 完成、关闭或者出错时,便会执行 onerror 回调。
之后返回中间件执行的结果,当中间件全部执行完之后,执行 respond 进行数据返回操作。

js
handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

context

cookies

context.js 中通过 get 和 set 方法做了 cookie 的设置和读取操作。

delegate

context.js 中有大量的 delegate 操作,是通过 delegate,可以让 ctx 能够直接访问其上面 response 和 request 中的属性和方法,即可以通过 ctx.xxx 获取到 ctx.request.xxx 或 ctx.response.xxx 。

delegate 是通过 delegates 这个库实现的,通过 proto.__defineGetter__ 和 proto.__defineSetter__ 去代理对象下面节点的属性和方法等。(proto.__defineGetter__ 和 proto.__defineSetter__ 现已被 mdn 废弃,改用 Object.defineProperty())

js
delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');
// ...

delegate(proto, 'request').method('acceptsLanguages').getter('ip');
// ...

context.js 中导出了一个 context 对象,主要用来在中间件以及其它各部件之间传递信息的,同时 context 对象上挂载了 request 和 response 两大对象。

另外其还做了 cookie 的处理以及使用 delegates 库对 request 和 response 对象上面的事件和方法进行了委托,便于用户使用。

request

request.js 导出了 request 对象,通过 get() 和 set() 方法对请求头的参数如 header、url、href、method、path、query……做了处理,挂载到了 request 对象上,方便用户获取和设置。

response

同 request.js ,通过 get() 和 set()对响应参数做了处理。

koa-compose

在 application.js 中,通过 compose 将中间件进行了合并,这也是 koa 的一个核心实现。

先来看 koa-compose 的源码,实现非常简单,只有几十行:

js
function compose(middleware) {
  // middleware 中间件函数数组, 数组中是一个个的中间件函数
  if (!Array.isArray(middleware))
    throw new TypeError('Middleware stack must be an array!');
  for (const fn of middleware) {
    if (typeof fn !== 'function')
      throw new TypeError('Middleware must be composed of functions!');
  }
  return function (context, next) {
    // last called middleware #
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if (i <= index)
        return Promise.reject(new Error('next() called multiple times'));
      index = i;
      let fn = middleware[i];
      if (i === middleware.length) fn = next;
      if (!fn) return Promise.resolve();
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

compose 接收一个中间件函数的数组,返回了一个闭包函数,闭包中维护了一个 index 去记录当前调用的中间件。

里面创建了一个 dispatch 函数,dispatch(i) 会通过 Promise.resolve() 返回 middleware 中的第 i 项函数执行结果,即第 i + 1 个 app.use() 传入的函数。 app.use() 回调的第二个参数是 next,所以当 app.use() 中的代码执行到 next() 时,便会执行 dispatch.bind(null, i + 1)),即执行下一个 app.use() 的回调。

依次类推,便将一个个 app.use() 的回调给串联了起来,直至没有下一个 next,边会按顺序返回执行每个 app.use() 的 next() 后面的逻辑。最终通过 Promise.resolve() 返回第一个 app.use() 的执行结果。

实现一个简单的 Koa

下面我们尝试实现一个简易版的 koa:

封装 node 的 http 模块

按照本文开篇的最简单示例去实现,新建 application.js,内部创建一个 MyKoa 类,基于 node 的 http 模块,实现 listen 函数:

js
// application.js
const http = require('http');

class MyKoa {
  listen(...args) {
    const server = http.createServer((req, res) => {
      res.end('mykoa');
    });
    server.listen(...args);
  }
}

module.exports = MyKoa;

实现 use 方法和简易 createContext

然后要实现 app.use() 方法,我们看到 app.use() 中内部有 ctx.body,所以我们还需要实现一个简单的 ctx 对象。

  1. 创建一个 context.js,内部导出 ctx 对象,分别通过 get 和 set,实现可以获取和设置 ctx.body 的值:
js
// context.js
module.exports = {
  get body() {
    return this._body;
  },

  set body(value) {
    this._body = value;
  },
};
  1. 在 application.js 的 MyKoa 类中添加 use 和 createContext 方法,同时 res.end 返回 ctx.body:
js
const http = require('http');
const _context = require('./context');

class MyKoa {
  listen(...args) {
    const server = http.createServer((req, res) => {
      const ctx = this.createContext(req, res);
      this.callback();
      res.end(ctx.body);
    });
    server.listen(...args);
  }

  use(callback) {
    this.callback = callback;
  }

  createContext(req, res) {
    const ctx = Object.assign(_context);
    return ctx;
  }
}

module.exports = MyKoa;

完善 createContext

我们要通过 ctx 去访问请求头以及设置响应头等相关信息,例如 ctx.query,ctx.message 等等,就要创建 response.js 和 request.js 对请求头和响应头做处理,将 request 和 response 对象挂载到 ctx 对象上,同时实现一个 delegate 函数让 ctx 能够访问 request 和 response 上面的属性和方法。

  1. 实现简单的 request 和 response,request 中通过 get 方法,能够解析 req.url 中的参数,将其转换为一个对象返回。response 中,通过 get 和 set message,能够获取和设置 res.statusMessage 的值:
js
// request.js
module.exports = {
  get query() {
    const arr = this.req.url.split('?');
    if (arr[1]) {
      const obj = {};
      arr[1].split('&').forEach((str) => {
        const param = str.split('=');
        obj[param[0]] = param[1];
      });
      return obj;
    }
    return {};
  },
};
js
// response.js
module.exports = {
  get message() {
    return this.res.statusMessage || '';
  },

  set message(msg) {
    this.res.statusMessage = msg;
  },
};
  1. 新建一个 utils.js,导出 delegate 方法,delegate 内部通过 Object.defineProperty ,让传入的对象 obj 能够在属性 property 改变时实时监听,例如 delegate(ctx, 'request') 当 request 对象值改变时,ctx 对 request 代理也能获取最新的值。

    然后实现简单的 getter 和 setter,通过一个 listen 函数,当使用 getter 或者 setter 时,将对应的键添加到 setters 和 getters 中,让 obj 访问对应键时代理到 proterty 对应的键值:

js
// utils.js
module.exports.delegate = function Delegate(obj, property) {
  let setters = [];
  let getters = [];
  let listens = [];

  function listen(key) {
    Object.defineProperty(obj, key, {
      get() {
        return getters.includes(key) ? obj[property][key] : obj[key]; // 如果通过 getter 代理了,则返回对应 obj[property][key] 的值,否则返回 obj[key] 的值
      },
      set(val) {
        if (setters.includes(key)) {
          obj[property][key] = val; 如果通过 setter 代理了,则设置对应 obj[property][key] 的值,否则设置 obj[key] 的值
        } else {
          obj[key] = val;
        }
      },
    });
  }

  this.getter = function (key) {
    getters.push(key);
    if (!listens.includes(key)) { // 防止重复调用listen
      listen(key);
      listens.push(key);
    }
    return this;
  };

  this.setter = function (key) {
    setters.push(key);
    if (!listens.includes(key)) { // 防止重复调用listenf
      listen(key);
      listens.push(key);
    }
    return this;
  };
  return this;
};
  1. 在 context 使用 delegate 对 request 和 response 进行代理:
js
// context.js
const { delegate } = require('./utils');
const context = (module.exports = {
  get body() {
    return this._body;
  },

  set body(value) {
    this._body = value;
  },
});
delegate(context, 'request').getter('query');
delegate(context, 'response').getter('message').setter('message');
  1. 完善 createContext 函数:
js
// application.js
const http = require('http');
const _context = require('./context');
const _request = require('./request');
const _response = require('./response');

class MyKoa {
  // ...
  createContext(req, res) {
    const ctx = Object.assign(_context);
    const request = Object.assign(_request);
    const response = Object.assign(_response);
    ctx.request = request;
    ctx.response = response;
    ctx.req = request.req = req;
    ctx.res = response.res = res;
    return ctx;
  }
}

module.exports = MyKoa;

实现中间件和洋葱模型

到现在为止,只剩下实现 app.use() 中间件的功能了。

  1. 按照前面 koa-compose 分析的思路,在 utils.js 中,实现 compose:
js
// utils.js
module.exports.compose = (middleware) => {
  return (ctx, next) => {
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if (i <= index) return Promise.reject(new Error('error'));
      index = i;
      const cb = middleware[i] || next;
      if (!cb) return Promise.resolve();
      try {
        return Promise.resolve(
          cb(ctx, function next() {
            return dispatch(i + 1);
          })
        );
      } catch (error) {
        return Promise.reject(error);
      }
    }
  };
};
  1. 在 app.js 中,初始化 this.middleware 的数组,use() 函数中将 callback 添加进数组:
js
// ...
class MyKoa {
  constructor() {
    this.middleware = [];
  }
  // ...

  use(callback) {
    this.middleware.push(callback);
  }
  // ...
}

module.exports = MyKoa;
  1. listen 方法 createServer 中,遇到请求时将中间件合并,中间件执行完毕后返回 res 结果:
js
// ...
const { compose } = require('./utils');

class MyKoa {
  // ...
  listen(...args) {
    const server = http.createServer((req, res) => {
      const ctx = this.createContext(req, res);
      //
      const fn = compose(this.middleware);
      fn(ctx)
        .then(() => {
          // 全部中间件执行完毕后,返回相应信息
          res.end(ctx.body);
        })
        .catch((err) => {
          throw err;
        });
    });
    server.listen(...args);
  }
  // ...
}
module.exports = MyKoa;

测试

到这里就大功告成了,引入我们的 Mykoa 在如下服务中测试一下:

js
const Koa = require('../my-koa/application');
const app = new Koa();

app.use((ctx, next) => {
  ctx.message = 'ok';
  console.log(1);
  next();
  console.log(2);
});

app.use((ctx, next) => {
  console.log(3);
  next();
  console.log(4);
});

app.use((ctx, next) => {
  console.log(5);
  next();
  console.log(6);
});

app.use((ctx, next) => {
  console.log(ctx.message);
  console.log(ctx.query);
  ctx.body = 'hello, myKoa';
});

app.listen(3000, () => {
  console.log('server is running on 3000...');
});

访问 http://localhost:3000/api?name=zlx 接口,返回数据为hello, myKoa 。

node 服务器控制台打印内容如下:

js
1;
3;
5;
ok;
{
  name: 'zlx';
}
6;
4;
2;

说明我们实现的没有任何问题!

源码

最后附上源码实现地址:
https://github.com/zh-lx/study-code/tree/main/node/koa


File: docs/react/其他/判断组件是否卸载.md


desc: '在组件卸载后,调用了 setState,造成了内存泄露,这里我自己写了一个 hook,去判断组件是否处于已卸载的状态。' tag: ['react', 'taro'] time: '2021-04-05'

React 使用 hook 判断组件是否卸载

前两天在做 Taro 小程序开发时,发现每次进入都会出现如下的 warning:
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.

原因说的很明显,是在组件卸载后,还调用了 setState,造成了内存泄漏。

页面代码

这是我的 index.js 文件, 进入 index 页面时会请求 api 去获取列表:

javascript
import React, { useEffect, useState } from 'react';
import { View } from '@tarojs/components';
import { getList } from '@/api/list';
import List from './list';
import './index.less';

export default function Index() {
  const [list, setList] = useState([]); // 列表
  // 获取列表
  const getList = async () => {
    let res: any = await getList();
    if (res.err_no === 0) {
      setList((res.data && res.data.list) || []);
    }
  };
  // 请求初始列表
  useEffect(() => {
    getList();
  }, []);
  return (
    <View className="index">
      <List list={list} />
    </View>
  );
}

Taro 的入口文件 app.js 文件中,会发送请求用户信息的 api 判断用户是否登录,若未登录,则会重定向至登录页面,代码如下:

javascript
import { Component } from 'react';
import { getUserInfo } from './api/parent';
import Taro from '@tarojs/taro';
import './app.less';

class App extends Component {
  componentDidMount() {
    getUserInfo().then((res) => {
      if (res.err_no !== 0) {
        Taro.redirectTo({
          url: '/pages/login/index',
        });
      }
    });
  }
  render() {
    return this.props.children;
  }
}

export default App;

错误定位

上面就会引起一个问题,在小程序一开始进入到 index 页面,会发送app.ts中getUserInfo的请求,同时也会发送index.js中的getList的请求。当getUserInfo请求率先返回结果时,如果用户未登录,则会重定向至 login 页面,此时 index 页面组件已经卸载掉,而index.js页面中的getList请求完成后还要调用setList去改变 state,便会造成内存泄漏。
知道了问题就好解决了,我们在进行setList操作时,只需要判断一下当前组件是否已卸载,若已卸载便终止 setState 操作。

通过 hook 判断组件是否处于 unmouted 状态

这里我自己写了一个 hook,去判断组件是否处于已卸载的状态,然后在setList之前判断一下组件状态:

javascript
// hook.js
import React, { useEffect, useRef, useCallback } from 'react';

export const useMounted = () => {
  const mountedRef = useRef(false);
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
    };
  }, []);
  return () => mountedRef.current;
};

修改后的index.js文件:

javascript
import React, { useEffect, useState } from 'react';
import { View } from '@tarojs/components';
import { getList } from '@/api/list';
import { useMounted } from '@/utils/hook';
import List from './list';
import './index.less';

export default function Index() {
  const [list, setList] = useState([]); // 列表
  const isMounted = useMounted();
  // 获取列表
  const getList = async () => {
    let res: any = await getList();
    // 若isMounted为false,则说明组件已卸载,终止后续操作
    if (!isMounted()) {
      return;
    }
    if (res.err_no === 0) {
      setList((res.data && res.data.list) || []);
    }
  };
  // 请求初始列表
  useEffect(() => {
    getList();
  }, []);
  return (
    <View className="index">
      <List list={list} />
    </View>
  );
}

成功解决问题!


File: docs/react/源码/React17源码解析(1) —— 源码目录及 react 架构.md


desc: '要学习 react 源码,首先要对 react 的源码和整个架构有个大体的了解,这样学起来会事半功倍,本章将介绍一下 react 的源码和架构。' cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4' tag: ['react'] time: '2021-09-22'

欢迎大家一起交流学习 react 源码,本系列导航请见:React17 源码解析(开篇) —— 搭建 react 源码调试环境


要学习 react 源码,首先要对 react 的源码和整个架构有个大体的了解,这样学起来会事半功倍。本章将介绍一下 react 的源码和架构。

源码目录

react 的源码目录如下,主要有三个文件夹:

  • fixtures:一些测试 demo,方便 react 编码时的测试
  • packages:react 的主要源码内容
  • script:和 react 打包、编译、本地开发相关的命令
    我们要探究的源码内容,都存放在 packages 文件夹下:
text
📦react
 ┣ 📂fixtures
 ┣ 📂packages
 ┃ ┣ 📂create-subscription
 ┃ ┣ 📂dom-event-testing-library
 ┃ ┣ 📂eslint-plugin-react-hooks
 ┃ ┣ 📂jest-mock-scheduler
 ┃ ┣ 📂jest-react
 ┃ ┣ 📂react
 ┃ ┣ 📂react-art
 ┃ ┣ 📂react-cache
 ┃ ┣ 📂react-client
 ┃ ┣ 📂react-debug-tools
 ┃ ┣ 📂react-devtools
 ┃ ┣ 📂react-devtools-core
 ┃ ┣ 📂react-devtools-extensions
 ┃ ┣ 📂react-devtools-inline
 ┃ ┣ 📂react-devtools-scheduling-profiler
 ┃ ┣ 📂react-devtools-shares
 ┃ ┣ 📂react-devtools-shell
 ┃ ┣ 📂react-dom
 ┃ ┣ 📂react-fetch
 ┃ ┣ 📂react-interactions
 ┃ ┣ 📂react-is
 ┃ ┣ 📂react-native-renderer
 ┃ ┣ 📂react-noop-renderer
 ┃ ┣ 📂react-reconciler
 ┃ ┣ 📂react-refresh
 ┃ ┣ 📂react-server
 ┃ ┣ 📂react-test-renderer
 ┃ ┣ 📂react-transport-dom-relay
 ┃ ┣ 📂react-transport-dom-webpack
 ┃ ┣ 📂scheduler
 ┃ ┣ 📂shared
 ┃ ┗ 📂use-subscription
 ┗ 📂scripts

根据 packages 下面各个部分的功能,我将其划分为了几个模块:

核心 api

react 的核心 api 都位于 packages/react 文件夹下,包括 createElement、memo、context 以及 hooks 等,凡是通过 react 包引入的 api,都位于此文件夹下。

调度和协调

调度和协调是 react16 fiber 出现后的核心功能,和他们相关的包如下:

  • scheduler:对任务进行调度,根据优先级排序
  • react-conciler:diff 算法相关,对 fiber 进行副作用标记

渲染

和渲染相关的内容包括以下几个目录:

  • react-art:canvas、svg 等内容的渲染
  • react-dom:浏览器环境下的渲染,也是我们本系列中主要涉及讲解的渲染的包
  • react-native-renderer:用于原生环境渲染相关
  • react-noop-renderer:用于调试环境的渲染

辅助包

  • shared:定义了 react 的公共方法和变量
  • react-is:react 中的类型判断

其他

其他的包和本次 react 源码探究的关联不是很多,所以不过多介绍了。

react 架构

了解了 react 源码目录之后,我们再来对 react 架构有个大体的认识,了解 react 是如何在变量更改时,页面发生更新渲染的。

react 为了保证页面能够流畅渲染,react16 之后的更新过程分为 render 和 commit 两个阶段。render 阶段包括 Scheduler(调度器) 和 Reconciler(协调器),commit 阶段包括 Renderer(渲染器):

触发更新

触发更新的方式主要有以下几种:ReactDOM.render(包括首次渲染)、setState、forUpdate、hooks 中的 useState 以及 ref 的改变等引起的。

scheduler

当首次渲染或者组件状态发生更新等情况时,此时页面就要发生渲染了。scheduler 过程会对诸多的任务进行优先级排序,让浏览器的每一帧优先执行高优先级的任务(例如动画、用户点击输入事件等),从而防止 react 的更新任务太大影响到用户交互,保证了页面的流畅性。

reconciler

reconciler 过程中,会开始根据优先级执行更新任务。这一过程主要是根据最新状态构建新的 fiber 树,与之前的 fiber 树进行 diff 对比,对 fiber 节点标记不同的副作用,对应渲染过程中真实 dom 的增删改。(这里不了解 fiber 和 diff 的话没关系,后面会讲到,主要是对 react 的流程有个大致概念)

commit

在 render 阶段中,最终会生成一个 effectList 数组,记录了页面真实 dom 的新增、删除和替换等以及一些事件响应,commit 会根据 effectList 对真实的页面进行更新,从而实现页面的改变。

以上大体就是 react 更新过程中的架构,具体的细节讲解会在后面的章节中详细讲述,欢迎关注本专栏。


File: docs/react/源码/React17源码解析(2) —— jsx 转换及 React.createElement.md


desc: '从这一章开始,我们正式开始 react 源码的学习,本章包括react17之前和之后 jsx 编译的不同、React.createElement 源码、React.Component源码。' cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4' tag: ['react'] time: '2021-09-30'

欢迎大家一起交流学习 react 源码,本系列目录导航请见:React17 源码解析(开篇) —— 搭建 react 源码调试环境


从这一章开始,我们正式开始 react 源码的学习,本章包括内容如下:

  • react17 之前和之后 jsx 编译的不同
  • React.createElement 源码
  • React.Component 源码

jsx 的转换

我们从 react 应用的入口开始对源码进行分析,创建一个简单的 hello, world 应用:

js
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
export default class App extends Component {
  render() {
    return <div>hello, world</div>;
  }
}

ReactDOM.render(<App />, document.getElementById('root'));

我们注意到,我们在 App 组件中直接写了 return hello, world 的 jsx 语句,那么 jsx 语法是如何被浏览器识别执行的呢?

另外我在第一次学习 react 的时候,就有一个疑惑: import React, { Component } from 'react' 这段代码中,React 似乎在代码中没有任何地方被用到,为什么要引入呢?

16.x 版本及之前

我们在 react16.8 版本的代码中,尝试将 React 的引用去掉:

js
// import React, { Component } from 'react';
import { Component } from 'react'; // 去掉 React 的引用
import ReactDOM from 'react-dom';

export default class App extends Component {
  render() {
    return <div>hello, world</div>;
  }
}

ReactDOM.render(<App />, document.getElementById('root'));

运行应用程序,发现会提示 'React' must be in scope when using JSX 的 error:

这是因为上述的类组件 render 中返回了 hello, world 的 jsx 语法,在 React16 版本及之前,应用程序通过 @babel/preset-react 将 jsx 语法转换为 React.createElement 的 js 代码,因此需要显式将 React 引入,才能正常调用 createElement。我们可以在 Babel REPL 中看到 jsx 被 @babel/preset-react 编译后的结果:

17.x 版本及之后

React17 版本之后,官方与 babel 进行了合作,直接通过将 react/jsx-runtime 对 jsx 语法进行了新的转换而不依赖 React.createElement,转换的结果便是可直接供 ReactDOM.render 使用的 ReactElement 对象。因此如果在 React17 版本后只是用 jsx 语法不使用其他的 react 提供的 api,可以不引入 React,应用程序依然能够正常运行。
React17 中 jsx 语法的编译结果如下:

更多有关于 React jsx 转换的内容可以去看官网了解:介绍全新的 JSX 转换,在这里就不再过多展开了。

React.createElement 源码

虽然现在 react17 之后我们可以不再依赖 React.createElement 这个 api 了,但是实际场景中以及很多开源包中可能会有很多通过 React.createElement 手动创建元素的场景,所以还是推荐学习一下React.createElement 源码。

React.createElement 其接收三个或以上参数:

  • type:要创建的 React 元素类型,可以是标签名称字符串,如 'div' 或者 'span' 等;也可以是 React 组件 类型(class 组件或者函数组件);或者是 React fragment 类型。
  • config:写在标签上的属性的集合,js 对象格式,若标签上未添加任何属性则为 null。
  • children:从第三个参数开始后的参数为当前创建的 React 元素的子节点,每个参数的类型,若是当前元素节点的 textContent 则为字符串类型;否则为新的 React.createElement 创建的元素。

函数中会对参数进行一系列的解析,源码如下,对源码相关的理解都用注释进行了标记:

js
export function createElement(type, config, children) {
  let propName;

  // 记录标签上的属性集合
  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // config 不为 null 时,说明标签上有属性,将属性添加到 props 中
  // 其中,key 和 ref 为 react 提供的特殊属性,不加入到 props 中,而是用 key 和 ref 单独记录
  if (config != null) {
    if (hasValidRef(config)) {
      // 有合法的 ref 时,则给 ref 赋值
      ref = config.ref;

      if (__DEV__) {
        warnIfStringRefCannotBeAutoConverted(config);
      }
    }
    if (hasValidKey(config)) {
      // 有合法的 key 时,则给 key 赋值
      key = '' + config.key;
    }

    // self 和 source 是开发环境下对代码在编译器中位置等信息进行记录,用于开发环境下调试
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    // 将 config 中除 key、ref、__self、__source 之外的属性添加到 props 中
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // 将子节点添加到 props 的 children 属性上
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    // 共 3 个参数时表示只有一个子节点,直接将子节点赋值给 props 的 children 属性
    props.children = children;
  } else if (childrenLength > 1) {
    // 3 个以上参数时表示有多个子节点,将子节点 push 到一个数组中然后将数组赋值给 props 的 children
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    // 开发环境下冻结 childArray,防止被随意修改
    if (__DEV__) {
      if (Object.freeze) {
        Object.freeze(childArray);
      }
    }
    props.children = childArray;
  }

  // 如果有 defaultProps,对其遍历并且将用户在标签上未对其手动设置属性添加进 props 中
  // 此处针对 class 组件类型
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  // key 和 ref 不挂载到 props 上
  // 开发环境下若想通过 props.key 或者 props.ref 获取则 warning
  if (__DEV__) {
    if (key || ref) {
      const displayName =
        typeof type === 'function'
          ? type.displayName || type.name || 'Unknown'
          : type;
      if (key) {
        defineKeyPropWarningGetter(props, displayName);
      }
      if (ref) {
        defineRefPropWarningGetter(props, displayName);
      }
    }
  }

  // 调用 ReactElement 并返回
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props
  );
}

由代码可知,React.createElement 做的事情主要有:

  • 解析 config 参数中是否有合法的 key、ref、**source 和 **self 属性,若存在分别赋值给 key、ref、source 和 self;将剩余的属性解析挂载到 props 上

  • 除 type 和 config 外后面的参数,挂载到 props.children 上

  • 针对类组件,如果 type.defaultProps 存在,遍历 type.defaultProps 的属性,如果 props 不存在该属性,则添加到 props 上

  • 将 type、key、ref、self、props 等信息,调用 ReactElement 函数创建虚拟 dom,ReactElement 主要是在开发环境下通过 Object.defineProperty 将 _store、_self、_source 设置为不可枚举,提高 element 比较时的性能:

    js
    const ReactElement = function (type, key, ref, self, source, owner, props) {
      const element = {
        // 用于表示是否为 ReactElement
        $$typeof: REACT_ELEMENT_TYPE,
    
        // 用于创建真实 dom 的相关信息
        type: type,
        key: key,
        ref: ref,
        props: props,
    
        _owner: owner,
      };
    
      if (__DEV__) {
        element._store = {};
    
        // 开发环境下将 _store、_self、_source 设置为不可枚举,提高 element 的比较性能
        Object.defineProperty(element._store, 'validated', {
          configurable: false,
          enumerable: false,
          writable: true,
          value: false,
        });
    
        Object.defineProperty(element, '_self', {
          configurable: false,
          enumerable: false,
          writable: false,
          value: self,
        });
    
        Object.defineProperty(element, '_source', {
          configurable: false,
          enumerable: false,
          writable: false,
          value: source,
        });
        // 冻结 element 和 props,防止被手动修改
        if (Object.freeze) {
          Object.freeze(element.props);
          Object.freeze(element);
        }
      }
    
      return element;
    };

所以通过流程图总结一下 createElement 所做的事情如下:

React.Component 源码

我们回到上述 hello,world 应用程序代码中,创建类组件时,我们继承了从 react 库中引入的 Component,我们再看一下React.Component 源码:

js
function Component(props, context, updater) {
  // 接收 props,context,updater 进行初始化,挂载到 this 上
  this.props = props;
  this.context = context;
  this.refs = emptyObject;
  // updater 上挂载了 isMounted、enqueueForceUpdate、enqueueSetState 等触发器方法
  this.updater = updater || ReactNoopUpdateQueue;
}

// 原型链上挂载 isReactComponent,在 ReactDOM.render 时用于和函数组件做区分
Component.prototype.isReactComponent = {};

// 给类组件添加 `this.setState` 方法
Component.prototype.setState = function (partialState, callback) {
  // 验证参数是否合法
  invariant(
    typeof partialState === 'object' ||
      typeof partialState === 'function' ||
      partialState == null
  );
  // 添加至 enqueueSetState 队列
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

// 给类组件添加 `this.forceUpdate` 方法
Component.prototype.forceUpdate = function (callback) {
  // 添加至 enqueueForceUpdate 队列
  this.updater.enqueueForceUpdate(this, callback, 'forceUpdate');
};

从源码上可以得知,React.Component 主要做了以下几件事情:

  • 将 props, context, updater 挂载到 this 上
  • 在 Component 原型链上添加 isReactComponent 对象,用于标记类组件
  • 在 Component 原型链上添加 setState 方法
  • 在 Component 原型链上添加 forceUpdate 方法
    这样我们就理解了 react 类组件的 super() 作用,以及 this.setState 和 this.forceUpdate 的由来

总结

本章讲述了 jsx 在 react17 之前和之后的不同的转换,实际上 react17 之后 babel 的对 jsx 的转换就是比之前多了一步 React.createElement 的动作:

另外讲述了 React.createElement 和 React.Component 的内部实现是怎样的。通过 babel 及 React.createElement,将 jsx 转换为了浏览器能够识别的原生 js 语法,为 react 后续对状态改变、事件响应以及页面更新等奠定了基础。后面的章节中,将探究 react 是如何一步步将状态等信息渲染为真实页面的。


File: docs/react/源码/React17源码解析(3) —— 深入理解 fiber.md

[Binary file]


File: docs/react/源码/React17源码解析(4) —— 详解 render 阶段(scheduler 和 reconciler).md

[Binary file]


File: docs/react/源码/React17源码解析(5) —— 全面理解diff算法.md


desc: 'react 源码解析,本章节将结合源码解析 diff 算法,包括如下内容:react diff 算法的介绍、diff 策略、diff 源码解析。' cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4' tag: ['react'] time: '2021-10-15'

欢迎大家一起交流学习 react 源码,本系列导航请见:React17 源码解析(开篇) —— 搭建 react 源码调试环境


react 源码解析(5),本章节将结合源码解析 diff 算法,包括如下内容:

  • react diff 算法的介绍
  • diff 策略
  • diff 源码解析

上一章中 react 的 render 阶段,其中 begin 时会调用 reconcileChildren  函数, reconcileChildren 中做的事情就是 react 知名的 diff 过程,本章会对 diff 算法进行讲解。

diff 算法介绍

react 的每次更新,都会将新的 ReactElement 内容与旧的 fiber 树作对比,比较出它们的差异后,构建新的 fiber 树,将差异点放入更新队列之中,从而对真实 dom 进行 render。简单来说就是如何通过最小代价将旧的 fiber 树转换为新的 fiber 树。

经典的 diff 算法 中,将一棵树转为另一棵树的最低时间复杂度为 O(n^3),其中 n 为树种节点的个数。假如采用这种 diff 算法,一个应用有 1000 个节点的情况下,需要比较 十亿 次才能将 dom 树更新完成,显然这个性能是无法让人接受的。

因此,想要将 diff 应用于 virtual dom 中,必须实现一种高效的 diff 算法。React 便通过制定了一套大胆的策略,实现了 O(n) 的时间复杂度更新 virtual dom。

diff 策略

react 将 diff 算法优化到 O(n) 的时间复杂度,基于了以下三个前提策略:

  • 只对同级元素进行比较。Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计,如果出现跨层级的 dom 节点更新,则不进行复用。
  • 两个不同类型的组件会产生两棵不同的树形结构。
  • 对同一层级的子节点,开发者可以通过 key 来确定哪些子元素可以在不同渲染中保持稳定。

上面的三种 diff 策略,分别对应着 tree diff、component diff 和 element diff。

tree diff

根据策略一,react 会对 fiber 树进行分层比较,只比较同级元素。这里的同级指的是同一个父节点下的子节点(往上的祖先节点也都是同一个),而不是树的深度相同。

如上图所示,react 的 tree diff 是采用深度优先遍历,所以要比较的元素向上的祖先元素都会一致,即图中会对相同颜色的方框内圈出的元素进行比较,例如左边树的 A 节点下的子节点 C、D 会与右边树 A 节点下的 C、D、E 进行比较。

当元素出现跨层级的移动时,例如下图:

A 子树从 root 节点下到了 B 节点下,在 react diff 过程中并不会直接将 A 子树移动到 B 子树下,而是进行如下操作:

  1. 在 root 节点下删除 A 节点
  2. 在 B 节点下创建 A 子节点
  3. 在新创建的 A 子节点下创建 C、D 节点

component diff

对于组件之间的比较,只要它们的类型不同,就判断为它们是两棵不同的树形结构,直接会将它们给替换掉。

例如下面的两棵树,左边树 B 节点和右边树 K 节点除了类型不同(比如 B 为 div 类型,K 为 p 类型),内容完全一致,但 react 依然后直接替换掉整个节点。实际经过的变换是:

  1. 在 root 节点下创建 K 节点
  2. 在 K 节点下创建 E、F 节点
  3. 在 F 节点下创建 G、H 节点
  4. 在 root 节点下删除 B 子节点

虽然如果在本例中改变类型复用子元素性能会更高一点,但是在时机应用开发中类型不一致子内容完全一致的情况极少,对这种情况过多判断反而会增加时机复杂度,降低平均性能。

element diff

react 对于同层级的元素进行比较时,会通过 key 对元素进行比较以识别哪些元素可以稳定的渲染。同级元素的比较存在插入、删除和移动三种操作。

如下图左边的树想要转变为右边的树:

实际经过的变换如下:

  1. 将 root 节点下 A 子节点移动至 B 子节点之后
  2. 在 root 节点下新增 E 子节点
  3. 将 root 节点下 C 子节点删除

结合源码看 diff

整体流程

diff 算法从 reconcileChildren 函数开始,根据当前 fiber 是否存在,决定是直接渲染新的 ReactElement 内容还是与当前 fiber 去进行 Diff,看一下其源码:

ts
export function reconcileChildren(
  current: Fiber | null, // 当前 fiber 节点
  workInProgress: Fiber, // 父 fiber
  nextChildren: any, // 新生成的 ReactElement 内容
  renderLanes: Lanes // 渲染的优先级
) {
  if (current === null) {
    // 如果当前 fiber 节点为空,则直接将新的 ReactElement 内容生成新的 fiber
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes
    );
  } else {
    // 当前 fiber 节点不为空,则与新生成的 ReactElement 内容进行 diff
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes
    );
  }
}

因为我们主要是要学习 diff 算法,所以我们暂时先不关心 mountChildFibers 函数,主要关注 reconcileChildFibers ,我们来看一下它的源码:

ts
function reconcileChildFibers(
  returnFiber: Fiber, // 父 Fiber
  currentFirstChild: Fiber | null, // 父 fiber 下要对比的第一个子 fiber
  newChild: any, // 更新后的 React.Element 内容
  lanes: Lanes // 更新的优先级
): Fiber | null {
  // 对新创建的 ReactElement 最外层是 fragment 类型单独处理,比较其 children
  const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }

  // 对更新后的 React.Element 是单节点的处理
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      // 常规 react 元素
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes
          )
        );
      // react.portal 类型
      case REACT_PORTAL_TYPE:
        return placeSingleChild(
          reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes)
        );
      // react.lazy 类型
      case REACT_LAZY_TYPE:
        if (enableLazyElements) {
          const payload = newChild._payload;
          const init = newChild._init;
          return reconcileChildFibers(
            returnFiber,
            currentFirstChild,
            init(payload),
            lanes
          );
        }
    }

    // 更新后的 React.Element 是多节点的处理
    if (isArray(newChild)) {
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes
      );
    }

    // 迭代器函数的单独处理
    if (getIteratorFn(newChild)) {
      return reconcileChildrenIterator(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes
      );
    }

    throwOnInvalidObjectType(returnFiber, newChild);
  }

  // 纯文本节点的类型处理
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        lanes
      )
    );
  }

  if (__DEV__) {
    if (typeof newChild === 'function') {
      warnOnFunctionType(returnFiber);
    }
  }

  // 不符合以上情况都视为 empty,直接从父节点删除所有旧的子 Fiber
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

入口函数中,接收 returnFiber、currentFirstChild、newChild、lanes 四个参数,其中,根据 newChid 的类型,我们主要关注几个比较常见的类型的 diff,单 React 元素的 diff、纯文本类型的 diff 和 数组类型的 diff。

所以根据 ReactElement 类型走的不同流程如下:

新内容为 REACT_ELEMENT_TYPE

当新创建的节点 type 为 object 时,我们看一下其为 REACT_ELEMENT_TYPE 类型的 diff,即 placeSingleChild(reconcileSingleElement(...)) 函数。

先看一下 reconcileSingleElement 函数的源码:

ts
function reconcileSingleElement(
  returnFiber: Fiber, // 父 fiber
  currentFirstChild: Fiber | null, // 父 fiber 下第一个开始对比的旧的子  fiber
  element: ReactElement, // 当前的 ReactElement内容
  lanes: Lanes // 更新的优先级
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  // 处理旧的 fiber 由多个节点变成新的 fiber 一个节点的情况
  // 循环遍历父 fiber 下的旧的子 fiber,直至遍历完或者找到 key 和 type 都与新节点相同的情况
  while (child !== null) {
    if (child.key === key) {
      const elementType = element.type;
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) {
          // 如果新的 ReactElement 和旧 Fiber 都是 fragment 类型且 key 相等
          // 对旧 fiber 后面的所有兄弟节点添加 Deletion 副作用标记,用于 dom 更新时删除
          deleteRemainingChildren(returnFiber, child.sibling);

          // 通过 useFiber, 基于旧的 fiber 和新的 props.children,克隆生成一个新的 fiber,新 fiber 的 index 为 0,sibling 为 null
          // 这便是所谓的 fiber 复用
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          if (__DEV__) {
            existing._debugSource = element._source;
            existing._debugOwner = element._owner;
          }
          return existing;
        }
      } else {
        if (
          // 如果新的 ReactElement 和旧 Fiber 的 key 和 type 都相等
          child.elementType === elementType ||
          (__DEV__
            ? isCompatibleFamilyForHotReloading(child, element)
            : false) ||
          (enableLazyElements &&
            typeof elementType === 'object' &&
            elementType !== null &&
            elementType.$$typeof === REACT_LAZY_TYPE &&
            resolveLazy(elementType) === child.type)
        ) {
          // 对旧 fiber 后面的所有兄弟节点添加 Deletion 副作用标记,用于 dom 更新时删除
          deleteRemainingChildren(returnFiber, child.sibling);
          // 通过 useFiber 复用新节点并返回
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          if (__DEV__) {
            existing._debugSource = element._source;
            existing._debugOwner = element._owner;
          }
          return existing;
        }
      }
      // 若 key 相同但是 type 不同说明不匹配,移除旧 fiber 及其后面的兄弟 fiber
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 若 key 不同,对当前的旧 fiber 添加 Deletion 副作用标记,继续对其兄弟节点遍历
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 都遍历完之后说明没有匹配到 key 和 type 都相同的 fiber
  if (element.type === REACT_FRAGMENT_TYPE) {
    // 如果新节点是 fragment 类型,createFiberFromFragment 创建新的 fragment 类型 fiber并返回
    const created = createFiberFromFragment(
      element.props.children,
      returnFiber.mode,
      lanes,
      element.key
    );
    created.return = returnFiber;
    return created;
  } else {
    // createFiberFromElement 创建 fiber 并返回
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}

根据源码我们可以得知,reconcileSingleElement 函数中,会遍历父 fiber 下所有的旧的子 fiber,寻找与新生成的 ReactElement 内容的 key 和 type 都相同的子 fiber。每次遍历对比的过程中:

  • 若当前旧的子 fiber 与新内容 key 或 type 不一致,对当前旧的子 fiber 添加 Deletion 副作用标记(用于 dom 更新时删除),继续对比下一个旧子 fiber
  • 若当前旧的子 fiber 与新内容 key 或 type 一致,则判断为可复用,通过 deleteRemainingChildren 对该子 fiber 后面所有的兄弟 fiber 添加 Deletion 副作用标记,然后通过 useFiber 基于该子 fiber 和新内容的 props 生成新的 fiber 进行复用,结束遍历。

若都遍历完没找到与新内容 key 或 type 子 fiber,此时父 fiber 下的所有旧的子 fiber 都已经添加了 Deletion 副作用标记,通过 createFiberFromElement 基于新内容创建新的 fiber 并将其 return 指向父 fiber。

再来看 placeSingleChild 的源码:

ts
function placeSingleChild(newFiber: Fiber): Fiber {
  if (shouldTrackSideEffects && newFiber.alternate === null) {
    newFiber.flags |= Placement;
  }
  return newFiber;
}

placeSingleChild 中做的事情更为简单,就是将 reconcileSingleElement 中生成的新 fiber 打上 Placement 的标记,表示 dom 更新渲染时要进行插入。

所以对于 REACT_ELEMENT_TYPE 类型的 diff 总结如下:

新内容为纯文本类型

当新创建节点的 typeof 为 string 或者 number 时,表示是纯文本节点,使用 placeSingleChild(reconcileSingleTextNode(...)) 函数进行 diff。

placeSingleChild 前面说过了,我们主要看 reconcileSingleTextNode 的源码:

ts
function reconcileSingleTextNode(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  textContent: string,
  lanes: Lanes
): Fiber {
  if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
    // deleteRemainingChildren 对旧 fiber 后面的所有兄弟节点添加 Deletion 副作用标记,用于 dom 更新时删除
    // useFiber 传入 textContext 复用当前 fiber
    deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
    const existing = useFiber(currentFirstChild, textContent);
    existing.return = returnFiber;
    return existing;
  }
  // 若未匹配到,createFiberFromText 创建新的 fiber
  deleteRemainingChildren(returnFiber, currentFirstChild);
  const created = createFiberFromText(textContent, returnFiber.mode, lanes);
  created.return = returnFiber;
  return created;
}

新内容为纯文本时 diff 比较简单,只需要判断当前父 fiber 的第一个旧子 fiber 类型:

  • 当前 fiber 也为文本类型的节点时,deleteRemainingChildren 对第一个旧子 fiber 的所有兄弟 fiber 添加 Deletion 副作用标记,然后通过 useFiber 基于当前 fiber 和 textContent 创建新的 fiber 复用,将其 return 指向父 fiber
  • 否则通过 deleteRemainingChildren 对所有旧的子 fiber 添加 Deletion 副作用标记,然后 createFiberFromText 创建新的文本类型 fiber 节点,将其 return 指向父 fiber

所以对文本类型 diff 的流程如下:

新内容为数组类型

上面所说的两种情况,都是一个或多个子 fiebr 变成单个 fiber。新内容为数组类型时,意味着要将一个或多个子 fiber 替换为多个 fiber,内容相对复杂,我们看一下 reconcileChildrenArray 的源码:

ts
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes
): Fiber | null {
  // 开发环境下会校验 key 是否存在且合法,否则会报 warning
  if (__DEV__) {
    let knownKeys = null;
    for (let i = 0; i < newChildren.length; i++) {
      const child = newChildren[i];
      knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
    }
  }

  let resultingFirstChild: Fiber | null = null; // 最终要返回的第一个子 fiber
  let previousNewFiber: Fiber | null = null;

  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;
  // 因为在实际的应用开发中,react 发现更新的情况远大于新增和删除的情况,所以这里优先处理更新
  // 根据 oldFiber 的 index 和 newChildren 的下标,找到要对比更新的 oldFiber
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // 通过 updateSlot 来 diff oldFiber 和新的 child,生成新的 Fiber
    // updateSlot 与上面两种类型的 diff 类似,如果 oldFiber 可复用,则根据 oldFiber 和 child 的 props 生成新的 fiber;否则返回 null
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes
    );
    // newFiber 为 null 说明不可复用,退出第一轮的循环
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        deleteChild(returnFiber, oldFiber);
      }
    }
    // 记录复用的 oldFiber 的 index,同时给新 fiber 打上 Placement 副作用标签
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

    if (previousNewFiber === null) {
      // 如果上一个 newFiber 为 null,说明这是第一个生成的 newFiber,设置为 resultingFirstChild
      resultingFirstChild = newFiber;
    } else {
      // 否则构建链式关系
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  if (newIdx === newChildren.length) {
    // newChildren遍历完了,说明剩下的 oldFiber 都是待删除的 Fiber
    // 对剩下 oldFiber 标记 Deletion
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  if (oldFiber === null) {
    // olderFiber 遍历完了
    // newChildren 剩下的节点都是需要新增的节点
    for (; newIdx < newChildren.length; newIdx++) {
      // 遍历剩下的 child,通过 createChild 创建新的 fiber
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      // 处理dom移动,// 记录 index,同时给新 fiber 打上 Placement 副作用标签
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 将新创建 fiber 加入到 fiber 链表树中
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }

  // oldFiber 和 newChildren 都未遍历完
  // mapRemainingChildren 生成一个以 oldFiber 的 key 为 key, oldFiber 为 value 的 map
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  // 对剩下的 newChildren 进行遍历
  for (; newIdx < newChildren.length; newIdx++) {
    // 找到 mapRemainingChildren 中 key 相等的 fiber, 创建新 fiber 复用
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // 删除当前找到的 fiber
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key
          );
        }
      }
      // 处理dom移动,记录 index,同时给新 fiber 打上 Placement 副作用标签
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 将新创建 fiber 加入到 fiber 链表树中
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  if (shouldTrackSideEffects) {
    // 剩余的旧 fiber 的打上 Deletion 副作用标签
    existingChildren.forEach((child) => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}

从上述代码我们可以得知,对于新增内容为数组时,react 会对旧 fiber 和 newChildren 进行遍历。

  1. 首先先对 newChildren 进行第一轮遍历,将当前的 oldFiber 与 当前 newIdx 下标的 newChild 通过 updateSlot 进行 diff,diff 的流程和上面单节点的 diff 类似,然后返回 diff 后的结果:
    • 如果 diff 后 oldFiber 和 newIdx 的 key 和 type 一致,说明可复用。根据 oldFiber 和 newChild 的 props 生成新的 fiber,通过 placeChild 给新生成的 fiber 打上 Placement 副作用标记,同时新 fiber 与之前遍历生成的新 fiber 构建链表树关系。然后继续执行遍历,对下一个 oldFiber 和下一个 newIdx 下标的 newFiber 继续 diff
    • 如果 diff 后 oldFiber 和 newIdx 的 key 或 type 不一致,那么说明不可复用,返回的结果为 null,第一轮遍历结束
  2. 第一轮遍历结束后,可能会执行以下几种情况:
    • 若 newChildren 遍历完了,那剩下的 oldFiber 都是待删除的,通过 deleteRemainingChildren 对剩下的 oldFiber 打上 Deletion 副作用标记
    • 若 oldFiber 遍历完了,那剩下的 newChildren 都是需要新增的,遍历剩下的 newChildren,通过 createChild 创建新的 fiber,placeChild 给新生成的 fiber 打上 Placement 副作用标记并添加到 fiber 链表树中。
    • 若 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 副作用标记。

所以整体的流程如下:

diff 后的渲染

diff 流程结束后,会形成新的 fiber 链表树,链表树上的 fiber 通过 flags 字段做了副作用标记,主要有以下几种:

  1. Deletion:会在渲染阶段对对应的 dom 做删除操作
  2. Update:在 fiber.updateQueue 上保存了要更新的属性,在渲染阶段会对 dom 做更新操作
  3. Placement:Placement 可能是插入也可能是移动,实际上两种都是插入动作。react 在更新时会优先去寻找要插入的 fiber 的 sibling,如果找到了执行 dom 的 insertBefore 方法,如果没有找到就执行 dom 的 appendChild 方法,从而实现了新节点插入位置的准确性

在 completeUnitWork 阶段结束后,react 会根据 fiber 链表树的 flags,构建一个 effectList 链表,里面记录了哪些 fiber 需要进行插入、删除、更新操作,在后面的 commit 阶段进行真实 dom 节点的更新,下一章将详细讲述 commit 阶段。


File: docs/react/源码/React17源码解析(6) —— commit 阶段.md

[Binary file]


File: docs/react/源码/React17源码解析(7) —— 一文搞懂 hooks 原理.md


desc: '本章将讲解 react hooks ,理解 hooks 的设计思想和工作过程,以及探究常用 hooks 的源码。' cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4' tag: ['react'] time: '2021-10-21'

欢迎大家一起交流学习 react 源码,本系列导航请见:React17 源码解析(开篇) —— 搭建 react 源码调试环境


本文将讲解 hooks 的执行过程以及常用的 hooks 的源码。

hooks 相关数据结构

要理解 hooks 的执行过程,首先想要大家对 hooks 相关的数据结构有所了解,便于后面大家顺畅地阅读代码。

Hook

每一个 hooks 方法都会生成一个类型为 Hook 的对象,用来存储一些信息,前面提到过函数组件 fiber 中的 memoizedState 会存储 hooks 链表,每个链表结点的结构就是 Hook。

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

export type Hook = {|
  memoizedState: any, // 上次渲染时所用的 state
  baseState: any, // 已处理的 update 计算出的 state
  baseQueue: Update<any, any> | null, // 未处理的 update 队列(一般是上一轮渲染未完成的 update)
  queue: UpdateQueue<any, any> | null, // 当前出发的 update 队列
  next: Hook | null, // 指向下一个 hook,形成链表结构
|};

举个例子,我们通过函数组件使用了两个 useState hooks:

ts
const [name, setName] = useState('小科比');
const [age, setAge] = useState(23);

则实际的 Hook 结构如下:

ts
{
  memoizedState: '小科比',
  baseState: '小科比',
  baseQueue: null,
  queue: null,
  next: {
    memoizedState: 23,
    baseState: 23,
    baseQueue: null,
    queue: null,
  },
};

不同的 hooks 方法,memoizedState 存储的内容不同,常用的 hooks memoizedState 存储的内容如下:

  • useState: state
  • useEffect: effect 对象
  • useMemo/useCallback: [callback, deps]
  • useRef: { current: xxx }

Update & UpdateQueue

Update 和 UpdateQueue 是存储 useState 的 state 及 useReducer 的 reducer 相关内容的数据结构。

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

type Update<S, A> = {|
  lane: Lane, // 优先级
  // reducer 对应要执行的 action
  action: A,
  // 触发 dispatch 时的 reducer
  eagerReducer: ((S, A) => S) | null,
  // 触发 dispatch 是的 state
  eagerState: S | null,
  // 下一个要执行的 Update
  next: Update<S, A>,
  // react 的优先级权重
  priority?: ReactPriorityLevel,
|};

type UpdateQueue<S, A> = {|
  // 当前要触发的 update
  pending: Update<S, A> | null,
  // 存放 dispatchAction.bind() 的值
  dispatch: (A => mixed) | null,
  // 上一次 render 的 reducer
  lastRenderedReducer: ((S, A) => S) | null,
  // 上一次 render 的 state
  lastRenderedState: S | null,
|};

每次调用 setState 或者 useReducer 的 dispatch 时,都会生成一个 Update 类型的对象,并将其添加到 UpdateQueue 队列中。

例如,在如下的函数组件中:

ts
const [name, setName] = useState('小科比');
setName('大科比');

当我们点击 input 按钮时,执行了 setName(),此时对应的 hook 结构如下:

ts
{
  memoizedState: '小科比',
  baseState: '小科比',
  baseQueue: null,
  queue: {
    pending: {
      lane: 1,
      action: '大科比',
      eagerState: '大科比',
      // ...
    },
    lastRenderedState: '小科比',
    // ...
  },
  next: null,
};

最后 react 会遍历 UpdateQueue 中的每个 Update 去进行更新。

Effect

Effect 结构是和 useEffect 等 hooks 相关的,我们看一下它的结构:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

export type Effect = {|
  tag: HookFlags, // 标记是否有 effect 需要执行
  create: () => (() => void) | void, // 回调函数
  destroy: (() => void) | void, // 销毁时触发的回调
  deps: Array<mixed> | null, // 依赖的数组
  next: Effect, // 下一个要执行的 Effect
|};

当我们的函数组件中使用了如下的 useEffect 时:

ts
useEffect(() => {
  console.log('hello');
  return () => {
    console.log('bye');
  };
}, []);

对应的 Hook 如下:

ts
{
  memoizedState: {
    create: () => { console.log('hello') },
    destroy: () => { console.log('bye') },
    deps: [],
    // ...
  },
  baseState: null,
  baseQueue: null,
  queue: null,
  next: null,
}

执行过程

下面我们探索一下 hooks 在 react 中具体的执行流程。

引入 hooks

我们以一个简单的 hooks 写法的 react 应用程序为例去寻找 hooks 源码:

ts
import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>{count}</p>
      <input
        type="button"
        value="增加"
        onClick={() => {
          setCount(count + 1);
        }}
      />
    </div>
  );
}

根据引入的 useState api,我们找到 react hooks 的入口文件:

ts
// packages/react/src/ReactHooks.js

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  // ...
  return dispatcher;
}

export function useState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

// ...

根据上面的源码我们可以知道,所有的 hooks api 都是挂载在 resolveDispatcher 中返回的 dispatcher 对象上面的,也就是挂载在 ReactCurrentDispatcher.current 上面,那么我们再继续去看一下 ReactCurrentDispatcher 是什么:

ts
// packages/react/src/ReactCurrentDispatcher.js

import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

export default ReactCurrentDispatcher;

到这里我们的线索就断了,ReactCurrentDispatcher 上只有一个 current 用于挂在 hooks,但是 hooks 的详细源码以及 ReactCurrentDispatcher 的具体内容我们并没有找到在哪里,所以我们只能另寻出路,从 react 的执行过程去入手。

函数组件更新过程

我们的 hooks 都是在函数组件中使用的,所以让我们去看一下 render 过程关于函数组件的更新。render 过程中的调度是从 beginWork 开始的,来到 beginWork 的源码后我们可以发现,针对函数组件的渲染和更新,使用了 updateFunctionComponent 函数:

ts
// packages/react-reconciler/src/ReactFiberBeginWork.old.js

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  // ...
  switch (workInProgress.tag) {
    // ...
    case FunctionComponent: {
      // ...
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes
      );
    }
    // ...
  }
  // ...
}

那我们在继续看一下 updateFunctionComponent 函数的源码,里面调用了 renderWithHooks 函数,这便是函数组件更新和渲染过程执行的入口:

ts
// packages/react-reconciler/src/ReactFiberBeginWork.old.js

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes
) {
  // ...

  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes
  );

  // ...
}

renderWithHooks

费劲千辛万苦,我们终于来到了函数组件更新过程的执行入口 —— renderWithHooks 函数的源码:

ts
// packages/react-reconciler/src/ReactFiberBeginWork.old.js

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  // currentlyRenderingFiber 指向当前所执行的fiber
  currentlyRenderingFiber = workInProgress;

  // 置空 workInProgress fiber 中的 memoizedState 和 updateQueue
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;

  // ...
  // 根据是否首次渲染,分别将 HooksDispatcherOnMount 和 HooksDispatcherOnUpdate 赋值给 ReactCurrentDispatcher.current
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

  // 执行函数组件的构造函数
  let children = Component(props, secondArg);

  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    // didScheduleRenderPhaseUpdateDuringThisPass 为 true 说明发生了 re-render,会再次执行 render
    let numberOfReRenders: number = 0;
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      // ...
      ReactCurrentDispatcher.current = __DEV__
        ? HooksDispatcherOnRerenderInDEV
        : HooksDispatcherOnRerender;

      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }

  // ...
  // 函数执行结束后,关闭 hooks 入口
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;

  // ...当前 fiber 的任务执行结束,重置全局变量

  renderLanes = NoLanes;
  currentlyRenderingFiber = (null: any);

  currentHook = null;
  workInProgressHook = null;

  didScheduleRenderPhaseUpdate = false;

  // ...

  return children;
}

renderWithHooks 函数中首先会将 workInProgress fiber 树的 memoizedState(前面深入理解 fiber 一文中提到过,memoizedState 记录了当前页面的 state,在函数组件中,它以链表的形式记录了 hooks 信息) 和 updateQueue 置为 null,在接下来的函数组件执行过程中,会把新的 hooks 信息挂载到这两个属性上,然后在 commit 阶段,会将根据 current fiber 树构建当前的 workInProgress fiber 树,并保存 hooks 信息,用于替换真实的 DOM 元素节点。

然后会通过 current 上是否有 memoizedState,判断组件是否首次渲染,从而分别将 HooksDispatcherOnMount 和 HooksDispatcherOnUpdate 赋值给 ReactCurrentDispatcher.current。

接下来执行 Component() 来调用函数组件的构造函数,组件的 hooks 会被依次执行,并将 hooks 的信息保存到 workInProgress fiber 上(待会儿会细讲执行过程),然后将返回的 jsx 信息保存到 children 上。

最后会重置一些变量,并返回函数组件执行后的 jsx。

不同阶段更新 Hook

现在我们终于找到了 ReactCurrentDispatcher.current 的定义,首次渲染时,会将 HooksDispatcherOnMount 赋值给 ReactCurrentDispatcher.current,更新时,会将 HooksDispatcherOnUpdate 赋值给 ReactCurrentDispatcher.current, dispatcher 上面挂在了各种 hooks:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useMutableSource: mountMutableSource,
  useOpaqueIdentifier: mountOpaqueIdentifier,

  unstable_isNewReconciler: enableNewReconciler,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useMutableSource: updateMutableSource,
  useOpaqueIdentifier: updateOpaqueIdentifier,

  unstable_isNewReconciler: enableNewReconciler,
};

首次渲染时,HooksDispatcherOnMount 上挂载的 hook 都是 mountXXX,而更新时 HooksDispatcherOnMount 上挂在的 hook 都是 updateXXX。所有 mount 阶段的 hook 中,都会执行 mountWorkInProgressHook 这个函数,而所有 update 阶段的 hook 中,都会执行 updateWorkInProgressHook 这个函数。下面我们来看下这两个函数分别做了什么。

mountWorkInProgressHook

每个 hooks 方法中,都需要有一个 Hook 结构来存储相关信息。mountWorkInProgressHook 中,会初始化创建一个 fiber,然后将其挂载到 workInProgress fiber 的 memoizedState 所指向的 hooks 链表上,以便于下次 update 的时候取出该 Hook:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // 若当前 workInProgressHook 为 null,将此 hook 作为 memoizedState 的头结点
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 若不为 null,将 hook 挂载到链表最后
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

updateWorkInProgressHook

updateWorkInProgressHook 的作用主要是取出 current fiber 中的 hooks 链表中对应的 hook 节点,挂载到 workInProgress fiber 上的 hooks 链表:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: null | Hook;

  // 迭代 current fiber 链表
  if (currentHook === null) {
    // 若 current 为 null,从 currentlyRenderingFiber.alternate 取 current
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 否则从 current fiber 中取下一个 hook
    nextCurrentHook = currentHook.next;
  }

  // 迭代 workInProgress fiber 链表
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    // workInProgressHook 说明是首次创建
    nextWorkInProgressHook 为 null = currentlyRenderingFiber.memoizedState;
  } else {
    // 取下一个 workInProgress Hook
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 只有 re-render 的情况下,nextWorkInProgressHook 不为 null,因为在之前的 render 过程中已经创建过 workInProgress hook了
    // 此时复用
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // 正常情况下,currentlyRenderingFiber.memoizedState 为 null,需要到从 current fiber 中克隆一个新的创建

    invariant(
      nextCurrentHook !== null,
      'Rendered more hooks than during the previous render.',
    );
    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // 若 workInProgressHook 为 null,作为首节点赋值给 memoizedState
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // 将 workInProgressHook 添加到链表尾
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }

  return workInProgressHook;
}

我们详细理解一下上述代码,前面我们提到过 renderWithHooks 函数中会执行如下代码: workInProgress.memoizedState = null,所以在执行上述函数时,正常来说 currentlyRenderingFiber.memoizedState 为 null,需要从 current fiber 对应的节点中取 clone 对应的 hook,再挂载到 workInProgress fiber 的 memoizedState 链表上;re-render 的情况下,由于已经创建过了 hooks,会复用已有的 workInProgress fiber 的 memoizedState。

这里正好提到,为什么 hook 不能用在条件语句中,因为如果前后两次渲染的条件判断不一致时,会导致 current fiber 和 workInProgress fiber 的 hooks 链表结点无法对齐。

总结

所以我们总结一下 renderWithHooks 这个函数,它所做的事情如下:

hooks 源码

前面 hooks 的执行入口我们都找到了,现在我们看一下常用的一些 hooks 源码。

useState & useReducer

这里会把 useState 和 useReducer 放在一起来说,因为 useState 相当于一个简化版的 useReducer。

用法

useState 的简单用法如下:

ts
const [count, setCount] = useState(0);
// 改变 count 的值
setCount(count++);

useReducer 简单用法如下:

ts
const [count, dispatch] = useReducer(function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return count + 1;
    default:
      return count;
  }
}, 0);
// 改变 count 的值
dispatch({ type: 'increment' });

mountState & mountReducer

我们先从 useState 开始讲起,mount 阶段,useState 对应的源码是 mountState。这里面后创建初始的 hook 和更新队列 queue,然后创建 dispatch,最终返回 [hook.memoizedState, dispatch],对应的是我们代码中的 [count, setCount],供我们使用:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // 创建一个 hook,链接到 hooks 链表中
  const hook = mountWorkInProgressHook();
  // 校验初始的 state 是否是个函数,是的话执行该函数
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  // 前面提到过,对于 useState,memoizedState 保存的是 state
  hook.memoizedState = hook.baseState = initialState;
  // 创建更新队列 updateQueue
  const queue = (hook.queue = {
    pending: null, // 待执行的 hook
    dispatch: null, // 更新函数
    lastRenderedReducer: basicStateReducer, // 上次渲染的 reducer
    lastRenderedState: (initialState: any), // 上次渲染的 state
  });
  // 创建 dispatch(负责更新的函数)
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  // 返回 useState 的 state 及 dispatch,供我们使用
  return [hook.memoizedState, dispatch];
}

再来看下 mount 阶段的 useReducer 的源码,也就是 mountReducer,可以看到和 mountState 所做的事情基本时一样的,mountState 可以看做是有一个初始 state 的 mountReducer:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = mountWorkInProgressHook();
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = ((initialArg: any): S);
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

dispatchAction

上面的代码中,其他内容我们前面基本都有讲过,你们应该了解它们的作用,我们着重来看一下 dispatch,它是通过执行 dispatchAction 创建的。

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  // ...
  // 获取更新触发时间及优先级
  const eventTime = requestEventTime();
  const lane = requestUpdateLane(fiber);

  // 初始化 update
  const update: Update<S, A> = {
    lane,
    action,
    eagerReducer: null,
    eagerState: null,
    next: (null: any),
  };

  // 将 update 链接到更新队列中
  const pending = queue.pending;
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;

  const alternate = fiber.alternate;
  if (
    fiber === currentlyRenderingFiber ||
    (alternate !== null && alternate === currentlyRenderingFiber)
  ) {
    // currentlyRenderingFiber 存在,说明是在 render 过程发生的更新
    didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
  } else {
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // fiber.lanes === NoLanes 说明是首次更新
      // 如果值不同,则保存在eagerState,下次 render 时可以直接使用,而无需再计算。
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        if (__DEV__) {
          prevDispatcher = ReactCurrentDispatcher.current;
          ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          const currentState: S = (queue.lastRenderedState: any);
          // 我们可以根据当前 state 和 action 来计算新的 state 值
          const eagerState = lastRenderedReducer(currentState, action);
          update.eagerReducer = lastRenderedReducer;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // 如果与当前值相同,则跳过更新
            return;
          }
          // 如果值不同,新一轮更新时渲染 eagerState
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactCurrentDispatcher.current = prevDispatcher;
          }
        }
      }
    }
    // ...
    // 开启调度,触发新的一轮更新,也就是走 beginWork, completeWork 那一套流程
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  }
  // ...

  if (enableSchedulingProfiler) {
    markStateUpdateScheduled(fiber, lane);
  }
}

首先,会创建一个初始的 update 对象,用来记录相关的 hook 信息,并将它添加到 queue 中,这里的 queue 的添加你可以发现它形成了一个循环链表,这样 pending 作为链表的一个尾结点,而 pending.next 就能够获取链表的头结点。这样做的目的是,在 setCount 时,我们需要将 update 添加到链表的尾部;而在下面的 updateReducer 中,我们需要获取链表的头结点来遍历链表,通过循环链表能够轻松实现我们的需求。

之后,会根据当前所处的阶段是否在 render 阶段发生:

  • 如果是 render 阶段发生,那么会触发 re-render 过程,将 didScheduleRenderPhaseUpdateDuringThisPass 置为 true。前面 renderWithHooks 的代码中我们说了,didScheduleRenderPhaseUpdateDuringThisPass 为 true 时会代表 re-render,会重新执行 render 过程,直至其为 false。
  • 如果不是在 render 阶段发生,那么会通过当前的 state 和 action 来判断下次渲染的 state 的值,并与当前 state 的值进行比较,如果两个值一致,则不需要更新,跳过更新过程;如果两个值不一致,调用 scheduleUpdateOnFiber 开始调度,触发新一轮更新。

updateReducer

update 时,useState 和 useReducer 就更没什么区别了,updateState 就是直接返回了 updateReducer 函数,所以我们直接看 updateReducer 的源码就可以。

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

updateReducer 中,用 pending 来指向本次要触发的 update,然后将本次 hook 要执行的 update 和 current fiber 中之前未完成的 update 全部链接到 baseQueue,也就是代表全局的 update。

在 render 阶段,会遍历 update 来计算 state 的值,若某个 update 的优先级低于当前 render 执行的任务的优先级,则跳过此次 update 及未遍历完的 update 的执行,先执行其他的 update。然后再下一次 render 时从跳过的 update 开始继续执行。

update 阶段 dispatch 会生成一个新的 update 链接到 hooks 中,并根据之前的 state 和本次 action 去计算新的 state。

updateReducer 的源码如下:

ts
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  // 取出 hook 节点
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  invariant(
    queue !== null,
    'Should have a queue. This is likely a bug in React. Please file an issue.',
  );

  queue.lastRenderedReducer = reducer;

  const current: Hook = (currentHook: any);

  // 上次未完成的 update
  let baseQueue = current.baseQueue;

  // 获取本次待执行的 update
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    if (baseQueue !== null) {
      // 如果 baseQueue 和 pendingQueue 都存在,将 pendingQueue 链接到 baseQueue 尾部
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    // ...
    // react 的异步模型,可能发生一个更高优先级任务打断当前任务的执行
    // 所以要将 baseQueue 也赋值给 current fiber
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = current.baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    let update = first;
    // 遍历 hooks 链表,计算 state
    do {
      const updateLane = update.lane;
      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        // 如果当前的 update 优先级低于 render 优先级,下次 render 时再执行本次的 update
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          eagerReducer: update.eagerReducer,
          eagerState: update.eagerState,
          next: (null: any),
        };
        // 把这个 update 添加到 newBaseQueue 中下次 render 执行
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        // 更新 queue 的优先级
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        // 标记本次 update 跳过了
        markSkippedUpdateLanes(updateLane);
      } else {
        if (newBaseQueueLast !== null) {
          // newBaseQueueLast 不为 null,说明此前有跳过的 update
          // update 之间可能存在依赖,将后续 update 都连接到 newBaseQueue 中留到下次 render 执行
          const clone: Update<S, A> = {
            lane: NoLane,
            action: update.action,
            eagerReducer: update.eagerReducer,
            eagerState: update.eagerState,
            next: (null: any),
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        // 执行本次的 update,计算新的 state
        if (update.eagerReducer === reducer) {
          // update.eagerReducer 和 reducer 相等,说明 reducer 已经计算过,直接取结算过的 state
          newState = ((update.eagerState: any): S);
        } else {
          // 根据 state 和 action 计算新的 state
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      update = update.next;
    } while (update !== null && update !== first);

    if (newBaseQueueLast === null) {
      // newBaseQueueLast 为 null,说明所有 update 处理完了,更新 baseState
      newBaseState = newState;
    } else {
      // 未处理完留到下次执行
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    // 如果新的 state 和之前的 state 不相等,标记需要更新
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    // 将新的 state 和 baseQueue 保存到 hook 中
    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

总结

总结一下 useState 和 useReducer 的执行过程如下图:

useEffect

同样,我们也分为 mount 和 update 两种情况来看 useEffect。

用法

useEffect 的使用大家应该都了解,在这里就不赘述了,我们本次的用例如下:

ts
useEffect(() => {
  console.log('update');
  return () => {
    console.log('unmount');
  };
}, [count]);

mountEffect

mount 阶段 useEffect 实际上是调用了 mountEffect 方法,进一步通过传递参数调用了 mountEffectImpl 这个函数:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountEffect(
  create: () => (() => void) | void, // 执行的回调函数
  deps: Array<mixed> | void | null // 依赖项
): void {
  // ...
  return mountEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps
  );
}

和 mountState 中所做的事情类似,mountEffectImpl 中首先通过 mountWorkInProgressHook 创建了 hook 链接到 hooks 链表中,前面提到过 useEffect 的 hook 是一个 Effect 类型的对象。然后通过 pushEffect 方法创建一个 effect 添加到 hook 的 memoizedState 属性:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 创建 hook 并链接到 hooks 链表中
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  // 创建一个 effect 对象并添加到 hook 的 memoizedState 中
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps
  );
}

pushEffect

pushEffect 函数中主要做了两件事,创建 effect 对象,然后将其添加到 fiber 的 updateQueue 链表上:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function pushEffect(tag, create, destroy, deps) {
  // 创建 effect
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    // componentUpdateQueue 为 null,将 effect 添加到 componentUpdateQueue 头结点
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // 链接到当前 fiber 节点的 updateQueue 的 lastEffect 中
    const lastEffect = componentUpdateQueue.lastEffect;
    // 构成循环链表结构
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

updateEffect

update 阶段,useEffect 实际上是调用了 updateEffect 函数,同样是进一步调用了 updateEffectImpl 函数:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  // ...
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps
  );
}

所以我们接着往下看 updateEffectImpl 函数做了什么,它从 updateWorkInProgressHook 取出对应的 hook,然后看上一轮 render 中是否有 hook 存在,若存在且上一轮 render 和本轮的依赖项没发生变化,说明副作用不需要执行,创建一个 effect 对象添加到 updateQueue 链表后直接返回;若两次的依赖项发生了变化,向 fiber 添加 flags 副作用标签,待 commit 时更新,然后再创建一个 effect 对象添加到 updateQueue 链表:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 从 hooks 链表中取出对应 hook
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // 若上一轮 render 对应 hook 存在
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 若上一轮和本次的依赖项未发生变化,说明没有副作用
        // 创建一个 effect 对象添加到 updateQueue 链表中,然后返回
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  // 执行到这里说明上一轮和本轮依赖项发生变化
  // 向 fiber 添加 flags 副作用标签,待 commit 时更新
  currentlyRenderingFiber.flags |= fiberFlags;

  // 创建一个 effect 对象添加到 updateQueue 链表中
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps
  );
}

总结

总结一下 useEffect 的大体流程如下:

useRef

useRef 的代码十分的简单了,我们直接将 mount 阶段和 update 阶段的放到一起来看:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountRef<T>(initialValue: T): {|current: T|} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  // ...
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {|current: T|} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

mount 阶段,调用 mountRef 函数,通过 mountWorkInProgressHook 创建一个 hook 并添加到 hooks 链表上,hook.memoizedState 上存储的是 {current: initialValue} 这个 ref 对象。

update 阶段,调用 updateRef 函数,通过 updateWorkInProgressHook 方法直接取出 hook.memoizedState。

可以看到 hook.memoizedState 指向的是一个对象的引用,这就解释了我们可以直接通过 ref.current 去改变和获取最新的值,不必进行任何依赖注入。

useCallback & useMemo

useCallback 和 useMemo 也是一样,源码结构上十分相似,所以也放在一起来讲。

用法

基础用法如下:

ts
// 第一个参数是 “创建” 函数,第二个参数是依赖项数组
// “创建” 函数会根据依赖项数组返回一个值,并且仅会在某个依赖项改变时才重新计算
const value = useMemo(() => add(a, b), [a, b]);

// 第一个参数是回调函数,第二个参数是依赖项数组
// 依赖项改变时回调函数会进行更新
const callback = useCallback(() => {
  add(a, b);
}, [a, b]);

mount 阶段

mount 时,分别调用了 mountCallback 和 mountMemo 函数,两者都通过 mountWorkInProgressHook 方法创建 hook 添加到了 hooks 链表中。不同的是,mountCallback 的 memoizedState 是 [callback, nextDeps],并且返回的是其第一个参数;mountMemo 的 memoizedState 是 [nextValue, nextDeps],返回的也是 nextValue 也就是其第一个参数的执行结果。

所以看上去 useMemo 就是比 useCallback 多了一步第一个参数的执行过程。

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  // 创建 hook 添加到链表中
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null
): T {
  // 创建 hook 添加到链表中
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

update 阶段

update 时,分别调用了 updateCallback 和 updateMemo 函数,它们都通过 updateWorkInProgressHook 取出对应的 hook,若依赖项未发生改变,则取上一轮的 callback 或者 value 返回;若依赖项发生改变,则重新赋值 hook.memoizedState 并返回新的 callback 或新计算的 value:

ts
// packages/react-reconciler/src/ReactFiberHooks.old.js

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  // 获取对应 hook
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      // 依赖项未发生改变,取上一轮的 callback 并返回
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  // 依赖项改变了,重新赋值 hook.memoizedState,返回新的 callback
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null
): T {
  // 获取对应 hook
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      // 依赖项未发生改变,取上一轮的值并返回
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  // 依赖项改变了,计算新的值,重新赋值 hook.memoizedState并返回新的值
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

结语

本章讲解了 react hooks 的源码,理解了 hooks 的设计思想和工作过程。其他 hook 平时用的比较少,就不在这里展开讲了,但通过上面几个 hook 的源码讲解,其他 hook 看源码你应该也能看得懂。


File: docs/react/源码/React17源码解析(开篇)——搭建调试环境.md

[Binary file]


File: docs/react/源码/🚀 万字好文 —— 手把手教你实现史上功能最丰富的简易版 react.md


desc: '本章基于 react17 版本实现一个 mini react,涵盖了 react 源码所有的知识点例如 fiber 架构、render 和 commit 阶段、diff 算法、hook、类组件、函数组。' cover: 'https://github.com/zh-lx/blog/assets/73059627/aa66652c-e6b2-40b4-b763-c9b1616b68a4' tag: ['react'] time: '2021-10-27'

前言

本章将基于 react17 版本实现一个 mini react,涵盖了 react 源码所有的知识点例如 fiber 架构、render 和 commit 阶段、diff 算法、类组件、函数组件、hooks 等绝大部分 react 原理的知识点。

对应的 github 仓库: mini-react

强烈建议对照我仓库的 commit 记录 来看本文,里面有本文每一步的代码提交,你可以清晰的看到每一步的代码变动情况:

本文的每节标题中带有 👉 标志的即有对应的 commit 记录,点击标题链接也可直接跳转

[一: 初始化项目 👉](https://github.com/zh-lx/mini-react/commit/2fbbb63bf6686fd301f1418dc07a6bfe255db895)

首先我们通过 react 官方的脚手架 create-react-app 初始化一个 react 项目,在终端执行如下指令:

text
create-react-app mini-react

然后执行 cd ./mini-react 进入到我们的项目,将多余的文件和代码移除,只保留 index.js 和 index.css 文件即可,初始化后的项目目录结构如下:

text
📦mini-react
 ┣ 📂public
 ┣ 📂src
 ┃ ┣ 📜index.css
 ┃ ┗ 📜index.js
 ┣ 📜.gitignore
 ┣ 📜package.json
 ┣ 📜README.md
 ┗ 📜yarn.lock

index.js 文件中,包含一个 jsx 结构,它包含了类组件、函数组件、普通 dom、条件渲染和列表渲染等多种类型的 jsx 内容(后面会用于我们渲染时要考虑的多种情况的处理),然后通过 ReactDOM.render将其渲染在页面上,代码如下:

js
import { Component } from 'react';
import ReactDOM from 'react-dom';
import './index.css';

class ClassComponent extends Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  render() {
    return (
      <div className="class-component">
        <div>this is a class Component</div>
        <div>prop value is: {this.props.value}</div>
      </div>
    );
  }
}

function FunctionComponent(props) {
  return (
    <div className="function-component">
      <div>this is a function Component</div>
      <div>prop value is: {props.value}</div>
    </div>
  );
}

const jsx = (
  <div className="deep1-box">
    <ClassComponent value={666} />
    <FunctionComponent value={100} />
    <div className="deep2-box-1">
      <a href="https://github.com/zh-lx/mini-react">mini react link</a>
      <p style={{ color: 'red' }}> this is a red p</p>
      <div className="deep3-box">
        {true && <div>condition true</div>}
        {false && <div>condition false</div>}
        <input
          type="button"
          value="say hello"
          onClick={() => {
            alert('hello');
          }}
        />
      </div>
    </div>
    <div className="deep2-box-2">
      {['item1', 'item2', 'item3'].map((item) => (
        <li key={item}>{item}</li>
      ))}
    </div>
  </div>
);

ReactDOM.render(jsx, document.getElementById('root'));

index.css 内容主要是给各种类名添加对应的样式,用以在页面的视觉效果上区分它们的层级关系,代码如下:

css
.deep1-box {
  border: 1px solid rgb(146, 89, 236);
  padding: 8px;
}
.class-component {
  border: 1px solid rgb(228, 147, 147);
  padding: 8px;
}
.function-component {
  margin-top: 8px;
  padding: 8px;
  border: 1px solid rgb(133, 233, 120);
}
.deep2-box-1 {
  margin-top: 8px;
  padding: 8px;
  border: 1px solid rgb(233, 224, 107);
}
.deep3-box {
  padding: 8px;
  border: 1px solid rgb(55, 189, 241);
}
.deep2-box-2 {
  margin-top: 8px;
  padding: 8px;
  border: 1px solid rgb(23, 143, 77);
}

如此一来,项目的初始化就完成了,页面的效果如图所示:

接下来进入到我们相关的源码实现环节。

二: 实现 ReactDOM.render

我们创建项目用的 react 版本是 17.0.2,所以我们上面的 jsx 内容在运行时已经被 babel 编译为了 React.Element 的形式,不需要像 React16.x 及之前的版本需要 React.createElement api 进行转换了(这方面的内容可以看我之前的文章 jsx 转换及 React.createElement)。

所以我们不需要额外实现 React.createElement 这个 api 了,直接从 ReactDOM.render 的实现开始。

[创建 ReactDOM.render 👉](https://github.com/zh-lx/mini-react/commit/a844706548f3708b7b4ba1647edb67beacf3425c)

首先我们在 src 目录下创建一个名为 mini-react 的文件夹,用于保存我们自己实现的 react 源码,然后在 /src/mini-react 目录下创建一个 react-dom.js 文件,在里面导出挂载有 render 函数的 ReactDOM 对象,render 函数要做的事情就是接收 element 和 container 两个参数,并将 element 渲染为真实 dom 挂载到 container 上。
src/mini-react/react-dom.js 代码如下:

js
function render(element, container) {
  const dom = renderDom(element);
  container.appendChild(dom);
}

// 将 React.Element 渲染为真实 dom
function renderDom(element) {}

const ReactDOM = {
  render,
};
export default ReactDOM;

[根据 React.element 创建 dom 👉](https://github.com/zh-lx/mini-react/commit/b5ef737086a416d8daf6546f3c7765e73a412413)

接下来我们要做的在 renderDom 函数中实现根据 React.element 创建真实 dom,React.element 的结构在之前 jsx 转换及 React.createElement 篇中讲过,这里我们也可以再在控制台打印一下 jsx 的内容:

所以我们要将 React.element 渲染为 dom,主要就是看 React.element 本身的类型以及它的 type 和 props 参数,所以接下来根据我们上面的 jsx 不同类型的元素,在将 React.element 转换为真实 dom 时要考虑如下情况:

  1. 当 element 为假 (!element === true) 且不为 0 时,表示条件渲染的假值,不进行渲染(或者通过 document.createDocumentFragment 创建空的文档节点)
  2. 当 element 自身为 string 类型时,表示为文本节点,通过调用 document.createTextNode 进行创建
  3. 当 element 自身为 number 类型时,将其转换为 string 类型,然后通过调用 document.createTextNode 创建文本节点
  4. 当 element 自身为 Array 类型是,表示为数组(例如 map 返回的元素数组),需要通过一个 fragment 挂载所有的数组元素,再将 fragment 挂载到对应的父节点下
  5. 当 element 的 type 为 string 类型时,表示为常规的 dom 元素,直接调用 document.createElement 创建 dom。
  6. 当 element 的 type 为 function 类型时,表示类组件或者函数组件,需要针对处理
  7. 如果 element 的 children 不为 null,需要递归创建子元素
  8. 还有其他的情况如 react 的一些内置组件如 React.fragment、Context、Portal 等,我们以实现 react 主功能为主,暂时不考虑这些情况了

完整的 renderDom 的内容如下:

js
// 将 React.Element 渲染为真实 dom
function renderDom(element) {
  let dom = null; // 要返回的 dom

  if (!element && element !== 0) {
    // 条件渲染为假,返回 null
    return null;
  }

  if (typeof element === 'string') {
    // 如果 element 本身为 string,返回文本节点
    dom = document.createTextNode(element);
    return dom;
  }

  if (typeof element === 'number') {
    // 如果 element 本身为 number,将其转为 string 后返回文本节点
    dom = document.createTextNode(String(element));
    return dom;
  }

  if (Array.isArray(element)) {
    // 列表渲染
    dom = document.createDocumentFragment();
    for (let item of element) {
      const child = renderDom(item);
      dom.appendChild(child);
    }
    return dom;
  }

  const {
    type,
    props: { children },
  } = element;

  if (typeof type === 'string') {
    // 常规 dom 节点的渲染
    dom = document.createElement(type);
  } else if (typeof type === 'function') {
    // React组件的渲染
    if (type.prototype.isReactComponent) {
      // 类组件
      const { props, type: Comp } = element;
      const component = new Comp(props);
      const jsx = component.render();
      dom = renderDom(jsx);
    } else {
      // 函数组件
      const { props, type: Fn } = element;
      const jsx = Fn(props);
      dom = renderDom(jsx);
    }
  } else {
    // 其他情况暂不考虑
    return null;
  }

  if (children) {
    // children 存在,对子节点递归渲染
    const childrenDom = renderDom(children);
    if (childrenDom) {
      dom.appendChild(childrenDom);
    }
  }

  return dom;
}

然后在我们的 /src/index.js 中引入我们自己写的 react-dom.js 替换 react-dom 包:

diff
import React from 'react';
- import ReactDOM from 'react-dom';
+ import ReactDOM from './mini-react/react-dom';
import './index.css';
// ...

运行之后可以看到,现在我们的页面中已经渲染出了相关的 dom 元素了:

[更新 dom 属性 👉](https://github.com/zh-lx/mini-react/commit/79a61e710053bcbe58e7bdaf2f3295654a49f904)

上一步我们的页面中已经渲染出了相关的 dom 元素,但是元素的属性例如我们给 a 标签添加的 href、p 标签的 style、以及元素的 classname 等等都没生效,而且我们的 input[button] 显示的是输入框,说明我们元素上面挂载的属性都没有生效。

所以接下来我们需要对元素的各种属性进行挂载,通过打印我们可以得知,元素的属性都是在 React.element 的 props 上:

针对元素 props 中的各种属性,我们需要考虑如下情况:

  1. 如果是 children,表示是元素的子元素,不是其属性
  2. 如果是 className,要将其转换为元素对应的 class
  3. 如果是 style,需要将对象中的键值对取出一一更新到元素的样式上
  4. 如果是以 on 开头的属性,说明是事件,需要作为事件处理
  5. 其他属性直接挂载
    我们用一个 updateAttributes 函数处理元素属性的更新,在 react-dom.js 文件中添加如下代码:
js
// 更新 dom 属性
function updateAttributes(dom, attributes) {
  Object.keys(attributes).forEach((key) => {
    if (key.startsWith('on')) {
      // 事件的处理
      const eventName = key.slice(2).toLowerCase();
      dom.addEventListener(eventName, attributes[key]);
    } else if (key === 'className') {
      // className 的处理
      const classes = attributes[key].split(' ');
      classes.forEach((classKey) => {
        dom.classList.add(classKey);
      });
    } else if (key === 'style') {
      // style处理
      const style = attributes[key];
      Object.keys(style).forEach((styleName) => {
        dom.style[styleName] = style[styleName];
      });
    } else {
      // 其他属性的处理
      dom[key] = attributes[key];
    }
  });
}

然后在 renderDom 函数中调用 updateAttributes 函数:

diff
function renderDom(element) {
  // ...
  const {
    type,
-   props: { children },
+   props: { children, ...attributes },
  } = element
  // ...
+ updateAttributes(dom, attributes);
  return dom;
}

再来看我们的页面效果,元素属性都已经挂载上去了:

三: 实现 fiber 架构

我们上面 renderDom 的代码中,有一个问题,在类组件
、函数组件、列表渲染以及 children 等情况下,我们都会去递归调用 renderDom 函数。如果我们的组件树特别的大,那么我们的 mini-react 会一直递归去渲染,导致整个渲染完成的时间过长。由于 js 是单线程,如果此时有更高级别的任务例如用户输入、动画等,这些任务就需要等待,那么用户视觉上就会感到页面卡顿。

这就来到了 react 的核心概念之一 —— fiber,我们将大的渲染任务拆分为多个小的渲染任务,每个小任务都是一个工作单元,用 fiber 结构表示这一个工作单元,fiber 与 fiber 之间构成了一颗 fiber 树。(如果你不知道什么是 fiber,可以看我前面的文章 深入理解 fiber)。然后在浏览器的每一帧优先执行高优先级任务,空闲时间去执行低优先级任务。

深入理解 fiber 中讲过,fiber 与 fiber 之间通过 child、sibling、return 几个字段相互连接构成了一颗 fiber 树。react 处理任务时,会从 root fiber 开始,采用深度优先遍历的策略处理 fiber:处理完当前 fiber 后,如果有 child,则继续处理 child;如果没有 child,则处理其 sibling;当一个 fiber 的 child 和 sibling 都处理完后,通过 return 返回上级节点继续处理。如我们此应用中的 jsx 结构,对应的 fiber 树结构如下:

上图中箭头上的数字就是 fiber 的执行顺序。

[创建 rootFiber 和 nextUnitOfWork 👉](https://github.com/zh-lx/mini-react/commit/c6441df9abef4eb09f65be2809789d2cd77d44e2)

我们在 /src/mini-react 目录下新建 fiber.js 文件,用来存储和 fiber 相关的实现代码。首先因为我们是深度优先遍历去进行迭代处理任务单元及 fiber,所以我们需要一个全局的 nextUnitOfWork 变量,作为下一个要处理的任务单元。

然后我们说过了 fiber 的迭代是从 root fiber 开始的,因此我们需要根据 ReactDOM.render 接收的 element 和 container 参数,创建一个 rootFiber,指向 root fiber。每个 fiber 上都需要挂载 stateNode 和 element 属性,stateNode 指向根据此 fiber 创建的真实 dom 节点,用于渲染,element 指向 fiber 所对应的 React.element。rootFiber 创建好之后,将 nextUnitOfWork 指向它,它会作为第一个要处理的任务单元。

/src/mini-react/fiber.js 代码如下:

js
let nextUnitOfWork = null;
let rootFiber = null;

// 创建 rootFiber 作为首个 nextUnitOfWork
export function createRoot(element, container) {
  rootFiber = {
    stateNode: container, // 记录对应的真实 dom 节点
    element: {
      // 挂载 element
      props: { children: [element] },
    },
  };
  nextUnitOfWork = rootFiber;
}

然后在 /src/mini-react/react-dom.js 文件引入 createRoot 函数并在 render 函数中调用,传入 element 和 container 去创建 root fiber:

diff
+ import { createRoot } from './fiber';

function render(element, container) {
- const dom = renderDom(element);
- container.appendChild(dom);
+ createRoot(element, container);
}

[递归改成迭代 👉](https://github.com/zh-lx/mini-react/commit/e6967c0374060eb110e83469e1b696c6ffc38855)

去掉递归逻辑

接下里我们要将递归逻辑改成迭代去执行,所以我们先将 renderDom 中所有的递归逻辑去掉,并将其导出以便后面我们再 /src/mini-react/fiber.js 文件中引用:

diff
// 将 React.Element 渲染为真实 dom
function renderDom(element) {
  //...

- if (Array.isArray(element)) {
-   // 列表渲染
-   dom = document.createDocumentFragment();
-   for (let item of element) {
-     const child = renderDom(item);
-     dom.appendChild(child);
-   }
-   return dom;
- }

  const {
    type,
    props: { children },
  } = element;

  if (typeof type === 'string') {
    // 常规 dom 节点的渲染
    dom = document.createElement(type);
- } else if (typeof type === 'function') {
-   // React组件的渲染
-   if (type.prototype.isReactComponent) {
-     // 类组件
-     const { props, type: Comp } = element;
-     const component = new Comp(props);
-     const jsx = component.render();
-     dom = renderDom(jsx);
-  } else {
-     // 函数组件
-     const { props, type: Fn } = element;
-     const jsx = Fn(props);
-     dom = renderDom(jsx);
-   }
  } else {
    // 其他情况暂不考虑
    return null
  }

- if (children) {
-   // children 存在,对子节点递归渲染
-   const childrenDom = renderDom(children);
-   if (childrenDom) {
-     dom.appendChild(childrenDom);
-   }
- }

  // ...
}

根据 fiber 创建 dom

然后我们再 /src/mini-react/fiber.js 文件中引入 renderDom 函数,并新创建一个 performUnitOfWork 函数,里面包含迭代处理 fiber 的逻辑。

首先我们要根据 fiber 去创建 dom,当 fiber 的 stateNode 属性为空时,表示还没有对其创建 dom,所以我们调用 renderDom 函数,根据 fiber 的 element 属性去创建对应的 dom,并将其挂载到父节点下。

父节点根据 fiber 的 return 属性去寻找父 fiber,值得注意的是,由于我们在 renderDom 中去除了迭代逻辑后,在 React 组件或者条件渲染为假值时返回的 dom 会为空。所以我们只有在创建好的 stateNode 不为空时才进行挂载,同样的道理,向上通过 return 寻找的父 fiber 的 stateNode 也可能为空,这种情况我们继续通过 return 向上寻找,直到找到 stateNode 不为空的 fiber 节点再进行挂在即可。

代码如下:

js
import { renderDom } from './react-dom';

// ...

// 执行当前工作单元并设置下一个要执行的工作单元
function performUnitOfWork(workInProgress) {
  if (!workInProgress.stateNode) {
    // 若当前 fiber 没有 stateNode,则根据 fiber 挂载的 element 的属性创建
    workInProgress.stateNode = renderDom(workInProgress.element);
  }
  if (workInProgress.return && workInProgress.stateNode) {
    // 如果 fiber 有父 fiber且有 dom
    // 向上寻找能挂载 dom 的节点进行 dom 挂载
    let parentFiber = workInProgress.return;
    while (!parentFiber.stateNode) {
      parentFiber = parentFiber.return;
    }
    parentFiber.stateNode.appendChild(workInProgress.stateNode);
  }
}

构造 fiber 树

现在我们只有 root fiber 一个 fiber,我们需要构造 fiber 树结构,所以要根据 React.element 去创建对应的 fiber,并通过 child、 sibling 和 return 这几个字段的形成 fiber 树。父子关系除了 React.element 有 children 属性这种情况外,React 组件以及列表渲染,也会构成父子关系。所以我们做如下考虑:

  1. 当 React.element 的 type 属性是 function 时,表示 react 组件,我们将其渲染后所得到的 jsx 作为 children 处理。
  2. 如果 React.element 的 type 属性是 Array,表示列表渲染,此时 array 这个节点时没有意义的,不需要形成 fiber,所以我们直接将 array 中的子节点打平放到与 array 同级的 children 数组中进行处理,生成对应 fiber
  3. 当前 fiber 的 element 属性的 children 不为空时,根据 children 去迭代构建 fiber 树

上面三种情况,无论 children 是一个节点还是多个节点的数组,为了代码简洁我们最终都将其处理为数组形式。然后 children 数组的第一个节点生成的 fiber 通过当前 fiber 的 child 属性连接到 fiber 树中,其他的 fiber 通过上一个子 fiber 的 sibling 属性链接。

代码如下:

js
// 执行当前工作单元并设置下一个要执行的工作单元
function performUnitOfWork(workInProgress) {
  // 根据fiber创建 dom
  // ...

  let children = workInProgress.element?.props?.children;
  let type = workInProgress.element?.type;

  if (typeof type === 'function') {
    // 当前 fiber 对应 React 组件时,对其 return 迭代
    if (type.prototype.isReactComponent) {
      // 类组件,通过生成的类实例的 render 方法返回 jsx
      const { props, type: Comp } = workInProgress.element;
      const component = new Comp(props);
      const jsx = component.render();
      children = [jsx];
    } else {
      // 函数组件,直接调用函数返回 jsx
      const { props, type: Fn } = workInProgress.element;
      const jsx = Fn(props);
      children = [jsx];
    }
  }

  if (children || children === 0) {
    // children 存在时,对 children 迭代
    let elements = Array.isArray(children) ? children : [children];
    // 打平列表渲染时二维数组的情况(暂不考虑三维及以上数组的情形)
    elements = elements.flat();

    let index = 0; // 当前遍历的子元素在父节点下的下标
    let prevSibling = null; // 记录上一个兄弟节点

    while (index < elements.length) {
      // 遍历子元素
      const element = elements[index];
      // 创建新的 fiber
      const newFiber = {
        element,
        return: workInProgress,
        stateNode: null,
      };
      if (index === 0) {
        // 如果下标为 0,则将当前 fiber 设置为父 fiber 的 child
        workInProgress.child = newFiber;
      } else {
        // 否则通过 sibling 作为兄弟 fiber 连接
        prevSibling.sibling = newFiber;
      }
      prevSibling = newFiber;
      index++;
    }
  }
}

设置下一个工作单元

如我们这一节的开头所说,fiber 树的遍历采用深度优先遍历,如果当前 fiber 有 child,则设置 child 作为下一个工作单元;若无 child 但是有 sibling,则设置 sibling 作为下一个工作单元;如果都没有则深度优先遍历通过 return 返回父 fiber。代码如下:

js
function performUnitOfWork(fiber) {
  // 根据 fiber 创建 dom
  // ...

  // 构建 fiber 树
  // ...

  // 设置下一个工作单元
  if (workInProgress.child) {
    // 如果有子 fiber,则下一个工作单元是子 fiber
    nextUnitOfWork = workInProgress.child;
  } else {
    let nextFiber = workInProgress;
    while (nextFiber) {
      if (nextFiber.sibling) {
        // 没有子 fiber 有兄弟 fiber,则下一个工作单元是兄弟 fiber
        nextUnitOfWork = nextFiber.sibling;
        return;
      } else {
        // 子 fiber 和兄弟 fiber 都没有,深度优先遍历返回上一层
        nextFiber = nextFiber.return;
      }
    }
    if (!nextFiber) {
      // 若返回最顶层,表示迭代结束,将 nextUnitOfWork 置空
      nextUnitOfWork = null;
    }
  }
}

[创建 workLoop 👉](https://github.com/zh-lx/mini-react/commit/5b5c5faa7faa354d091dff1ae7e3c4cab1128322)

现在我们迭代处理的逻辑都实现完成了,那么我们在什么时间出执行迭代逻辑呢?我们要在 /src/mini-react/fiber.js 中创建一个名为 workLoop 函数,这个函数中我们会浏览器每帧的空闲时间段迭代处理 nextUnitOfWork,若一帧处理不完,则中断当前迭代,留到下一帧继续处理。代码如下:

js
// 处理循环和中断逻辑
function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    // 循环执行工作单元任务
    performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1;
  }
  requestIdleCallback(workLoop);
}

我们使用 requestIdleCallback 函数在浏览器每帧空闲时期去调用回调函数 workLoop,requestIdleCallback会给回调函数传入一个 deadline 参数我们可以使用它来检查当前帧还有多少时间浏览器空闲时间。我们用一个 shouldYied 的变量表示是否应该中断当前循环,当 deadline.timeRemaining() < 1 时, shouldYied 为 true,会中断当前迭代,留到下一帧再继续执行。(由于 requestIdleCallback 执行较慢及兼容性问题,React 现在不再使用 requestIdleCallback 了,而是自己实现了类似的功能,不过这里我们为了方便还是直接使用就行,思想上是相同的。)

最后我们在 /src/mini-react/fiber.js 中初始化通过 requestIdleCallback 去调用 workLoop 就大功告成了:

js
requestIdleCallback(workLoop);

[四: render 和 commit 分离 👉](https://github.com/zh-lx/mini-react/commit/fce38e53a0980deaab6685f9c2073c2e5058d1a8)

上面的代码中还有一个问题,看下面这一段代码,我们在迭代的过程中,是从 root fiber 开始向子 fiber 迭代的,每处理完一个 fiber,就创建相应 dom 挂载到页面上。但是我们的迭代任务是可中断的,如果中途中断,那么在页面上用户就会看到不完整的 ui:

js
function performUnitOfWork(fiber) {
  // ...

  if (workInProgress.return && workInProgress.stateNode) {
    // 如果 fiber 有父 fiber且有 dom
    // 向上寻找能挂载 dom 的节点进行 dom 挂载
    let parentFiber = workInProgress.return;
    while (!parentFiber.stateNode) {
      parentFiber = parentFiber.return;
    }
    parentFiber.stateNode.appendChild(workInProgress.stateNode);
  }

  // ...
}

这并不是我们理想的效果,我们可以考虑将所有的 dom 都创建完成之后再挂载到页面上。

这就来到了 react 的 render 和 commit 阶段,我们在 render 阶段去只处理工作单元,创建 dom 但是不挂载 dom,等到所有的工作单元全部处理完成之后,再在 commit 阶段同步执行 dom 的挂载。

所以总结下我们要做的工作如下:

  1. performUnitOfWork 中移除 dom 挂载的操作,只处理 fiber 创建对应 dom 但是并不挂载
  2. 实现一个 commitRoot 函数,执行 dom 的挂载操作,这个阶段是同步执行的,不可被打断
  3. 在 workLoop 中,当 nextUnitOfWork 为 null 且 rootFiber 存在时,表示 render 阶段执行结束,开始调用 commitRoot 函数进入 commit 阶段。

在 /src/mini-react 文件夹下新建 commit.js 文件,里面导出 commitRoot 文件,由于这个过程是不可中断的,所以我们递归去执行 dom 的挂载。同时我们的挂载采用字底向上的挂载,先挂载子节点,最后在挂载父节点,这样可以减少页面的重排和重绘,节省性能。

js
// 从根节点开始 commit
export function commitRoot(rootFiber) {
  commitWork(rootFiber.child);
}

// 递归执行 commit,此过程不中断
function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  // 深度优先遍历,先遍历 child,后遍历 sibling
  commitWork(fiber.child);
  let parentDom = fiber.return.stateNode;
  parentDom.appendChild(fiber.stateNode);
  commitWork(fiber.sibling);
}

由于是自底向上挂载 dom,所以当 fiber 对应 React 组件时,我们可以在 renderDom 函数中返回一个 document.createDocumentFragment() 文档节点去挂载组件下面的子节点,这样就不用考虑 stateNode 为空的情况了(条件渲染为假值的情况在迭代创建 fiber 的过程中已经被过滤了,所以也不需要考虑)。这样有利于后续我们自己实现类组件和函数组件相关的 api。/src/mini-react/react-dom.js 文件改动如下:

diff
// 将 React.Element 渲染为真实 dom
export function renderDom(element) {
  // ...

  if (typeof type === 'string') {
    // 常规 dom 节点的渲染
    dom = document.createElement(type);
+ } else if (typeof type === 'function') {
+   // React 组件的渲染
+   dom = document.createDocumentFragment();
  } else {
    // 其他情况暂不考虑
    return null;
  }

  // ...
}

最后在 /src/mini-react/fiber.js 中引入 /src/mini-react/commit.js 中的 commitRoot 函数,并在 render 结束时调用该函数,commit 阶段结束则重置 rootFiber。同时去掉 performUnitOfWork 函数中的创建 dom 逻辑:

diff
+ import { commitRoot } from './commit';

function performUnitOfWork(fiber) {
  // ...

- if (workInProgress.return && workInProgress.stateNode) {
-   // 如果 fiber 有父 fiber且有 dom
-   // 向上寻找能挂载 dom 的节点进行 dom 挂载
-   let parentFiber = workInProgress.return;
-   while (!parentFiber.stateNode) {
-     parentFiber = parentFiber.return;
-   }
-   parentFiber.stateNode.appendChild(workInProgress.stateNode);
- }

  // ...
}

// 处理循环和中断逻辑
function workLoop(deadline) {
  // ...
+ if (!nextUnitOfWork && rootFiber) {
+   // 表示进入 commit 阶段
+   commitRoot(rootFiber);
+   rootFiber = null;
+ }
  requestIdleCallback(workLoop);
}

五: diff 算法 —— 实现更新和删除

前面我们只说到了首次渲染时 dom 的创建过程,那么元素的删除和更新等情况又是如何处理的呢?这就来到了 react 又一大核心 —— diff 算法。关于 diff 算法的理解,同样可以看我之前的文章 全面理解 diff 算法,在这里我们不过多展开了,直接进入到代码的实现环节。

[current 和 workInProgess 👉](https://github.com/zh-lx/mini-react/commit/50a52fbb70b5e126eca4a479ad189237889234db)

我们在 diff 算法中讲过,diff 过程中,react 中有两棵 fiber 树:current fiber 树(上一次渲染时生成的 fiber 树)和 workInProgress fiber 树(本次渲染的 fiber 树),diff 过程实际上就是这两棵 fiber 树之间的 diff。

我们代码中每次 render 阶段执行的 fiber 树,实际上就是 workInProgress fiber 树,rootFiber 就是 workInProgress fiber 树的根结点,所以我们需要再维护一棵 current fiber 树。同时为了便于理解,我们将 rootFiber 更名为 workInProgressRoot:

diff
- let rootFiber = null;
+ let workInProgressRoot = null; // 当前工作的 fiber 树,
+ let currentRoot = null; // 上一次渲染的 fiber 树

所有用到了 rootFiber 的地方全部更名为 workInProgressRoot,这里就不展开了。

然后我们之前的文章 深入理解 fiber 中也讲过,workInProgress fiber 中有一个 alternate 属性,指向对应的 current fiber。在 react 更新流程(commit 阶段)结束后,会将当前的 currentRoot 指向 workInProgressRoot,代码如下:

diff
// 创建 rootFiber 作为首个 nextUnitOfWork
export function createRoot(element, container) {
  workInProgressFiber = {
    stateNode: container, // 记录对应的真实 dom 节点
    element: {
      // 挂载 element
      props: { children: [element] },
    },
+   alternate: currentRoot
  };
  nextUnitOfWork = workInProgressFiber;
}

// ...

// 处理循环和中断逻辑
function workLoop(deadline) {
  // ...
- if (!nextUnitOfWork && rootFiber) {
+ if (!nextUnitOfWork && workInProgressRoot) {
    // 表示进入 commit 阶段
-   commitRoot(rootFiber);
+   commitRoot(workInProgressRoot);
    // commit 阶段结束,重置变量
-   rootFiber = null;
+   currentRoot = workInProgressRoot;
+   workInProgressRoot = null;
  }
  requestIdleCallback(workLoop);
}

[创建 reconciler 👉](https://github.com/zh-lx/mini-react/commit/7eebaac428789ea2048c92661636378d686b42c7)

下面开始实现 diff 过程,diff 过程是以 reconcileChildren 为入口函数的,在 fiber 树的构建过程中,对 fiber 打上不同的 flag 副作用标签。在 /src/mini-react 目录下新建 reconciler.js 文件,其中导出 reconcileChildren 函数,将 performUnitOfWork 函数中的 fiber 构造 fiber 树逻辑迁移到该函数中。代码如下:

js
export function reconcileChildren(workInProgress, elements) {
  let index = 0; // 当前遍历的子元素在父节点下的下标
  let prevSibling = null; // 记录上一个兄弟节点

  while (index < elements.length) {
    // 遍历子元素
    const element = elements[index];
    // 创建新的 fiber
    const newFiber = {
      element,
      return: workInProgress,
      stateNode: null,
    };
    if (index === 0) {
      // 如果下标为 0,则将当前fiber设置为父 fiber 的 child
      workInProgress.child = newFiber;
    } else {
      // 否则通过 sibling 作为兄弟 fiber 连接
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
    index++;
  }
}

在 performUnitOfWork 函数中移除构造 fiber 树的逻辑,并引入引入上面函数reconcileChildren 函数:

diff
+ import { reconcileChildren } from './reconciler';

// 执行当前工作单元任务并设置下一个要执行的工作单元
function performUnitOfWork(workInProgress) {
  // 根据 fiber 创建对应 dom
  // ...

  // 迭代处理函数组件、类组件、列表渲染和 children 等情况
  // ...
  if (children || children === 0) {
    // children 存在时,对 children 迭代
    let elements = Array.isArray(children) ? children : [children];
    // 打平列表渲染时二维数组的情况(暂不考虑三维及以上数组的情形)
    elements = elements.flat();
-   // 移除此段构造 fiber 树的逻辑
+   reconcileChildren(workInProgress, elements);
  }

  // 设置下一个工作单元
  // ...
}

[diff 并添加 flag 👉](https://github.com/zh-lx/mini-react/commit/538421d67d76e2e48ee5fcadbb0369a19fe963cc)

现在我们 reconcileChildren 函数中已经有了 elements 了,elements 使我们想要渲染到页面上的元素,为了使渲染性能最高,我们需要知道如何对旧的 dom 树进行操作的开销最小。所以我们需要就 elements 和旧的 fiber 进行 diff,与 elements 所对应的旧 fiber,就是 workInProgress.alternate 下的子元素了。

我们对 elements 和 oldFiber 同时遍历,根据 element 的 type 和 olderFiber 对应的 element type 去比较,并对 diff 的结果添加 flag 副作用标签:

  • 如果 type 相同,表示是相同的元素,添加 Update 的 flag,直接更新 dom 元素的属性
  • 如果 type 不同且新的 element 存在,添加 Placement 的 flag,表示需要创建新的 dom。同时还要对其添加 index 属性,记录在插入时在父节点下的下标位置
  • 如果 type 不同且 oldFiber 存在,添加 Deletion 的 flag,表示需要对旧的 element 进行删除
    (react 中使用 type 和 key 同时比较,这样做在某些情况下例如列表渲染列表项改变时更加高效,但由于实现较为麻烦我们这里只使用 type。同时 react 中除了删除、更新和添加还有其他的副作用标签,因此会使用 flags 二进制运算添加多个标签,这里我们也不考虑那么复杂的情况了。)

调整后的 reconcileChildren 代码如下:

js
import { deleteFiber } from './fiber';

export function reconcileChildren(workInProgress, elements) {
  let index = 0; // 当前遍历的子元素在父节点下的下标
  let prevSibling = null; // 记录上一个兄弟节点
  let oldFiber = workInProgress?.alternate?.child; // 对应的旧 fiber

  while (index < elements.length || oldFiber) {
    // 遍历 elements 和 oldFiber
    const element = elements[index];
    // 创建新的 fiber
    let newFiber = null;
    const isSameType =
      element?.type &&
      oldFiber?.element?.type &&
      element.type === oldFiber.element.type;

    // 添加 flag 副作用
    if (isSameType) {
      // type相同,表示更新
      newFiber = {
        element: {
          ...element,
          props: element.props,
        },
        stateNode: oldFiber.stateNode,
        return: workInProgress,
        alternate: oldFiber,
        flag: 'Update',
      };
    } else {
      // type 不同,表示添加或者删除
      if (element || element === 0) {
        // element 存在,表示添加
        newFiber = {
          element,
          stateNode: null,
          return: workInProgress,
          alternate: null,
          flag: 'Placement',
          index,
        };
      }
      if (oldFiber) {
        // oldFiber存在,删除 oldFiber
        oldFiber.flag = 'Deletion';
        deleteFiber(oldFiber);
      }
    }

    if (oldFiber) {
      // oldFiber 存在,则继续遍历其 sibling
      oldFiber = oldFiber.sibling;
    }

    if (index === 0) {
      // 如果下标为 0,则将当前fiber设置为父 fiber 的 child
      workInProgress.child = newFiber;
      prevSibling = newFiber;
    } else if (newFiber) {
      // newFiber 和 prevSibling 存在,通过 sibling 作为兄弟 fiber 连接
      prevSibling.sibling = newFiber;
      prevSibling = newFiber;
    }
    index++;
  }
}

[Placement —— 添加 dom 👉](https://github.com/zh-lx/mini-react/commit/d5d5f6e360417c8f4ff537270f84b0704742bd0f)

当 fiber 被打上 Placement 的 flag 标签时,表示添加元素,我们根据元素所对应 fiber 的 index 属性,去寻找要在父元素的哪一个子元素之前插入。如果 parentDom.childNodes[fiber.index] 存在,说明要在这个元素前插入,通过 insertBefore 插入元素;如果不存在,则说明要插入到父元素最后,直接通过 appendChild 插入。

代码如下:

js
// 递归执行 commit,此过程不中断
function commitWork(fiber) {
  // 深度优先遍历,先遍历 child,后遍历 sibling
  commitWork(fiber.child);
  let parentDom = fiber.return.stateNode;
  if (fiber.flag === 'Placement') {
    // 添加 dom
    const targetPositionDom = parentDom.childNodes[fiber.index]; // 要插入到那个 dom 之前
    if (targetPositionDom) {
      // targetPositionDom 存在,则插入
      parentDom.insertBefore(fiber.stateNode, targetPositionDom);
    } else {
      // targetPositionDom 不存在,插入到最后
      parentDom.appendChild(fiber.stateNode);
    }
  }
  commitWork(fiber.sibling);
}

[Update —— 更新 dom 👉](https://github.com/zh-lx/mini-react/commit/bc9053286848749fb9d1b0cbdd2c18f0b7daad76)

当 fiber 被打上 Update 的 flag 标签时,表示更新 dom,那么我们要对旧的 dom 中的属性及监听事件进行移除,并添加新的属性和监听事件。

这里我们直接对 updateAttributes 函数进行修改并导出,里面添加移除旧的属性的逻辑:

js
export function updateAttributes(dom, attributes, oldAttributes) {
  if (oldAttributes) {
    // 有旧属性,移除旧属性
    Object.keys(oldAttributes).forEach((key) => {
      if (key.startsWith('on')) {
        // 移除旧事件
        const eventName = key.slice(2).toLowerCase();
        dom.removeEventListener(eventName, oldAttributes[key]);
      } else if (key === 'className') {
        // className 的处理
        const classes = oldAttributes[key].split(' ');
        classes.forEach((classKey) => {
          dom.classList.remove(classKey);
        });
      } else if (key === 'style') {
        // style处理
        const style = oldAttributes[key];
        Object.keys(style).forEach((styleName) => {
          dom.style[styleName] = 'initial';
        });
      } else {
        // 其他属性的处理
        dom[key] = '';
      }
    });
  }

  Object.keys(attributes).forEach((key) => {
    // ... 之前添加新属性的逻辑
  }
}

然后再 /src/mini-react/commit.js 中引入updateAttributes 函数,更新 dom 时去调用它:

diff
+ import { updateAttributes } from './react-dom';
// ...

function commitWork(fiber) {
  // ...
  if (fiber.flag === 'Placement') {
    // ...
+  } else if (fiber.flag === 'Update') {
+    const { children, ...newAttributes } = fiber.element.props;
+    const oldAttributes = Object.assign({}, fiber.alternate.element.props);
+    delete oldAttributes.children;
+    updateAttributes(fiber.stateNode, newAttributes, oldAttributes);
  }
  commitWork(fiber.sibling)
}

[Deletion —— 删除 dom 👉](https://github.com/zh-lx/mini-react/commit/5592e5f32743cbfc7066a87f3a3a85a6754f33e7)

当 fiber 被打上 Deletion 的 flag 标签时,表示删除元素,对于删除元素我们这里要思考两个问题:

  1. 对于打上了 Deletion flag 的 fiber,说明是在之前 current fiber 树中有,但是 workInProgress fiber 树中没有的,那么我们在 workInProgress fiber 树中遍历是找不到它的。
  2. 要删除的元素,只需要从它的父节点上直接删除它就行,不需要再去遍历整个 fiber 树
    所以基于以上两点,我们需要一个全局的 deletions 数组,存储所有要删除 dom 的对应 fiber。

我们在 /src/mini-react/fiber.js 中,定义一个全局变量 deletions,同时导出获取 deletions 和向 deletions 中添加 fiber 的方法:

js
let deletions = []; // 要执行删除 dom 的 fiber

// 将某个 fiber 加入 deletions 数组
export function deleteFiber(fiber) {
  deletions.push(fiber);
}

// 获取 deletions 数组
export function getDeletions() {
  return deletions;
}

然后在 performUnitOfWork 函数中,每次对 fiber 添加 Deletion 的 flag 副作用标签时,调用 deleteFiber 函数,将该 fiber 添加到 deletions 数组中:

diff
+ import { deleteFiber } from './fiber';

export function reconcileChildren(workInProgress, elements) {
  // ...

  if (oldFiber) {
    // oldFiber存在,删除 oldFiber
    oldFiber.flag = 'Deletion';
+   deleteFiber(oldFiber);
  }

  // ...
}

然后我们在 /src/mini-react/commit.js 中,添加删除 dom 的相关逻辑,对于删除 dom,我们只要对 deletions 数组遍历一遍执行删除动作即可,删除完毕直接 return,不需要继续去执行递归操作了。调整后的 commit.js 内容如下(代码的删改请看 github 我本次 commit 的变动):

js
import { updateAttributes } from './react-dom';
import { getDeletions } from './fiber';

// 从根节点开始 commit
export function commitRoot(rootFiber) {
  const deletions = getDeletions();
  deletions.forEach(commitWork);

  commitWork(rootFiber.child);
}

// 递归执行 commit,此过程不中断
function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  let parentDom = fiber.return.stateNode;
  if (fiber.flag === 'Deletion') {
    if (typeof fiber.element?.type !== 'function') {
      parentDom.removeChild(fiber.stateNode);
    }
    return;
  }

  // 深度优先遍历,先遍历 child,后遍历 sibling
  commitWork(fiber.child);
  if (fiber.flag === 'Placement') {
    // 添加 dom
    const targetPositionDom = parentDom.childNodes[fiber.index]; // 要插入到那个 dom 之前
    if (targetPositionDom) {
      // targetPositionDom 存在,则插入
      parentDom.insertBefore(fiber.stateNode, targetPositionDom);
    } else {
      // targetPositionDom 不存在,插入到最后
      parentDom.appendChild(fiber.stateNode);
    }
  } else if (fiber.flag === 'Update') {
    const { children, ...newAttributes } = fiber.element.props;
    const oldAttributes = Object.assign({}, fiber.alternate.element.props);
    delete oldAttributes.children;
    updateAttributes(fiber.stateNode, newAttributes, oldAttributes);
  }

  commitWork(fiber.sibling);
}

最后还要记得,当本次 commitRoot 执行完毕后,在 /src/mini-react/fiber.js 中的 workLoop 函数将 deletions 数组置空:

diff
// 处理循环和中断逻辑
function workLoop(deadline) {
  let shouldYield = false;
  while (nextUnitOfWork && !shouldYield) {
    // 循环执行工作单元任务
    performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1;
  }
  if (!nextUnitOfWork && workInProgressRoot) {
    // 表示进入 commit 阶段
    commitRoot(workInProgressRoot);
    currentRoot = workInProgressRoot;
    workInProgressRoot = null;
+   deletions = [];
  }
  requestIdleCallback(workLoop);
}

检查效果

代码

如此一来,我们添加、更新和删除 dom 的内容都实现了,我们在 /src/index.js 中,设置 5s 的延迟后改变一下 jsx 的内容。5s 后我们删除 a 标签、去掉 p 标签的红色字体样式,并且给 li 标签设置字体大小(此部分代码改动只做效果预览使用,不会提交上去):

js
import { Component } from 'react';
import ReactDOM from './mini-react/react-dom';
import './index.css';

class ClassComponent extends Component {
  constructor(props) {
    super(props);
    this.state = {};
  }

  render() {
    return (
      <div className="class-component">
        <div>this is a class Component</div>
        <div>prop value is: {this.props.value}</div>
      </div>
    );
  }
}

function FunctionComponent(props) {
  return (
    <div className="function-component">
      <div>this is a function Component</div>
      <div>prop value is: {props.value}</div>
    </div>
  );
}

const jsx = (
  <div className="deep1-box">
    <ClassComponent value={666} />
    <FunctionComponent value={100} />
    <div className="deep2-box-1">
      <a href="https://github.com/zh-lx/mini-react">mini react link</a>
      <p style={{ color: 'red' }}> this is a red p</p>
      <div className="deep3-box">
        {true && <div>condition true</div>}
        {false && <div>condition false</div>}
        <input
          type="button"
          value="say hello"
          onClick={() => {
            alert('hello');
          }}
        />
      </div>
    </div>
    <div className="deep2-box-2">
      {['item1', 'item2', 'item3'].map((item) => (
        <li key={item}>{item}</li>
      ))}
    </div>
  </div>
);

ReactDOM.render(jsx, document.getElementById('root'));

setTimeout(() => {
  const jsx = (
    <div className="deep1-box">
      <ClassComponent value={666} />
      <FunctionComponent value={100} />
      <div className="deep2-box-1">
        <p> this is a red p</p>
        <div className="deep3-box">
          {true && <div>condition true</div>}
          {false && <div>condition false</div>}
          <input
            type="button"
            value="say hello"
            onClick={() => {
              alert('hello');
            }}
          />
        </div>
      </div>
      <div className="deep2-box-2">
        {['item1', 'item2', 'item3'].map((item) => (
          <li style={{ fontSize: '20px' }} key={item}>
            {item}
          </li>
        ))}
      </div>
    </div>
  );

  ReactDOM.render(jsx, document.getElementById('root'));
}, 5000);

预览

效果预览如下:

六: 实现 React.Component

现在我们 /src/index 中的 React.Component api 还是从 react 库中引用的,接下来我们要自己对其实现。

[完善类组件功能 👉](https://github.com/zh-lx/mini-react/commit/6144a2d43143c6860ccb7a48182a1430ee07b1e7)

现在我们先完善一下我们的 ClassComponent 组件,添加一个点击按钮让 count 加 1 的功能:

js
class ClassComponent extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  addCount = () => {
    this.setState({
      count: this.state.count + 1,
    });
  };

  render() {
    return (
      <div className="class-component">
        <div>this is a class Component</div>
        <div>prop value is: {this.props.value}</div>
        <div>count is: {this.state.count}</div>
        <input type="button" value="add count" onClick={this.addCount} />
      </div>
    );
  }
}

[实现 setState 👉](https://github.com/zh-lx/mini-react/commit/a0e9d46e2f3185e48f1edbd4601c291fdbb149af)

我们知道,类组件是通过类创建出一个实例,然后调用实例上的 render 方法去返回 jsx。我们执行 setState 时,需要改变 state 的值,然后触发类组件的更新去渲染新的 dom。

所以我们需要调整一下类组件的渲染逻辑,我们将 performUnitOfWork 函数中类组件迭代的逻辑抽离出来,新建一个 updateClassComponent 函数去进行扩展:

diff
function performUnitOfWork(workInProgress) {
  // 当前 fiber 对应 React 组件时,对其 return 迭代
  if (type.prototype.isReactComponent) {
    // 类组件
-   const { props, type: Comp } = workInProgress.element;
-   const component = new Comp(props);
-   const jsx = component.render();
-   children = [jsx];
+   updateClassComponent(workInProgress);
  }

  // ...
}

然后再 updateClassComponent 函数中,我们比较 fiber.alternate 是否存在。如果存在,说明类组件之前渲染过,那我们复用之前的类实例(之前的类实例中保存着最新的 state 状态,不然重新创建类实例 state 状态会重置),然后在 Component 类上创建一个 _UpdateProps 方法,更新最新的 props;如果不存在,则调用类方法创建一个新的类实例,进行渲染。

代码如下:

js
function updateClassComponent(fiber) {
  let jsx;
  if (fiber.alternate) {
    // 有旧组件,复用
    const component = fiber.alternate.component;
    fiber.component = component;
    component._UpdateProps(fiber.element.props);
    jsx = component.render();
  } else {
    // 没有则创建新组件
    const { props, type: Comp } = fiber.element;
    const component = new Comp(props);
    fiber.component = component;
    jsx = component.render();
  }

  reconcileChildren(fiber, [jsx]);
}

接下来我们在 /src/mini-react 中新建 react.js 文件,在里面自己实现一下 Component 及 setState 的相关逻辑 Component 类包含以下逻辑

  1. Component 类接受 props 参数,并挂载到 this 对象上
  2. 在原型链上添加 isReactComponent 属性,用于 react 识别是类组件还是函数组件
  3. 原型链上添加 setState 方法,其接受一个 object 或者是 function 类型的参数,如果是 function 类型,该函数接受 this.state 和 this.props 回参,返回更新后的 state 值,将其合并至 this.state 中;如果是 object 类型,直接将其合并至 this.state 中。然后调用 commitRender 函数去出发更新(接下来会说这个函数的逻辑)
  4. 原型链上添加 _UpdateProps 方法,用于更新类组件时更新 props
    综上的逻辑,/src/mini-react/react.js 的内容如下:
js
import { commitRender } from './fiber';
export class Component {
  constructor(props) {
    this.props = props;
  }
}
Component.prototype.isReactComponent = true;

Component.prototype.setState = function (param) {
  if (typeof param === 'function') {
    const result = param(this.state, this.props);
    this.state = {
      ...this.state,
      ...result,
    };
  } else {
    this.state = {
      ...this.state,
      ...param,
    };
  }

  commitRender();
};

Component.prototype._UpdateProps = function (props) {
  this.props = props;
};

然后回到上面提到的 commitRender 函数,这里面的逻辑比较简单,就是将当前的 currentRoot 作为 workInProgressRoot,并将 nextUnitOfWork 指向它,去触发 render:

js
export function commitRender() {
  workInProgressRoot = {
    stateNode: currentRoot.stateNode, // 记录对应的真实 dom 节点
    element: currentRoot.element,
    alternate: currentRoot,
  };
  nextUnitOfWork = workInProgressRoot;
}

最后在 /src/index.js 引入我们自己实现的 React.Component 即可:

diff
- import { Component } from 'react';
+ import { Component } from './mini-react/react';

效果预览

效果预览如下,非常 nice!

[七: 实现 hooks 👉](https://github.com/zh-lx/mini-react/commit/b796bca22792a598b9ac499ecdf9748d2f25aadf)

最后我们再来实现一下函数组件的 hooks 功能。

完善函数组件功能

和类组件一样,我们先完善一下函数组件的功能,引入 useState hook,然后在点击按钮时让 count 的值 +1。代码如下:

js
import { useState } from 'react';

function FunctionComponent(props) {
  const [count, setCount] = useState(0);
  const addCount = () => {
    setCount(count + 1);
  };
  return (
    <div className="function-component">
      <div>this is a function Component</div>
      <div>prop value is: {props.value}</div>
      <div>count is: {count}</div>
      <input type="button" value="add count" onClick={addCount} />
    </div>
  );
}

实现 useState

上面说到了,类组件是在调用 setState api 时,改变当前类实例中的 state 状态,然后触发更新去渲染 dom。但是类组件的 setState 中,可以通过 this 获取到类实例然后拿到 state,而函数组件无法通过 this 获取,那应该如何操作呢?

我们可以在 /src/mini-react/fiber.js 中设置一个全局变量 currentFunctionFiber,指向 render 过程中当前处理的函数组件对应的 fiber,并用它来挂载这个函数组件当前的 hooks。同时因为一个函数组件中可能有多个 hooks,所以我们还需要有一个全局的 hookIndex 变量来记录当前执行的 hooks 是当前函数组件中的第几个,同时导出 getCurrentFunctionFiber 和 getHookIndex 的函数来获取 currentFunctionFiber 和 hookIndex,方便后面 /src/mini-react/react 文件中引入使用:

js
let currentFunctionFiber = null; // 当前正在执行的函数组件对应 fiber
let hookIndex = 0; //  当前正在执行的函数组件 hook 的下标

// 获取当前的执行的函数组件对应的 fiber
export function getCurrentFunctionFiber() {
  return currentFunctionFiber;
}

// 获取当前 hook 下标
export function getHookIndex() {
  return hookIndex++;
}

然后同类组件一样,我们将 performUnitOfWork 函数组件的处理逻辑也单独抽离到一个 updateFunctionComponent 函数中:

diff
function performUnitOfWork(workInProgress) {
  // ...

  if (typeof type === 'function') {
    // 当前 fiber 对应 React 组件时,对其 return 迭代
    if (type.prototype.isReactComponent) {
      // 类组件
      updateClassComponent(workInProgress);
    } else {
      // 函数组件
-     const { props, type: Fn } = workInProgress.element;
-     const jsx = Fn(props);
-     children = [jsx];
+     updateFunctionComponent(workInProgress);
    }
  }

  // ...
}

updateFunctionComponent 函数中,会将 currentFunctionFiber 指向 workInProgress,并将其上面挂载的 hooks 数组置空,将全局的 hookIndex 重置为 0。然后调用函数组件构造函数,返回对应的 jsx 结构,代码如下:

js
// 函数组件的更新
function updateFunctionComponent(fiber) {
  currentFunctionFiber = fiber;
  currentFunctionFiber.hooks = [];
  hookIndex = 0;
  const { props, type: Fn } = fiber.element;
  const jsx = Fn(props);
  reconcileChildren(fiber, [jsx]);
}

最后就是去实现我们的 useState 这个函数了。首先其接受一个初始值,并返回一个数组,然后通过 getCurrentFunctionFiber 和 getHookIndex 函数来获取 currentFunctionFiber 和 hookIndex。

然后根据 currentFunctionFiber.alternate.hooks.[hookIndex] 判断有没有已经存在的对应的旧的 hook,如果有,则直接取过来用以便获取之前的 hook 的状态值;若没有则使用传入的初始值初始化一个 hook。

一个 hook 上有两个属性:

  • state: 表示当前 useState hook 要返回的值
  • queue: 存储了本次 render 过程要对这个 state 进行的操作数组

所以我们 useState 的返回值就很明确了,返回一个数组,数组第一个值是 hook.state,第二个值是一个函数,这个函数的功能就是将接收的参数 push 到 hook.queue 中。

综上代码如下:

js
export function useState(initial) {
  const currentFunctionFiber = getCurrentFunctionFiber();
  const hookIndex = getHookIndex();
  // 取当前执行的函数组件之前的 hook
  const oldHook = currentFunctionFiber?.alternate?.hooks?.[hookIndex];

  // oldHook存在,取之前的值,否则取现在的值
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [], // 一次函数执行过程中可能调用多次 setState,将其放进队列一并执行
  };

  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {
    hook.state = action(hook.state);
  });

  const setState = (action) => {
    if (typeof action === 'function') {
      hook.queue.push(action);
    } else {
      hook.queue.push(() => {
        return action;
      });
    }
    commitRender();
  };
  currentFunctionFiber.hooks.push(hook);
  return [hook.state, setState];
}

效果预览

最后在我们的 /src/index.js 中引入自己实现的 useState 之后,看一下效果:

总结

OK,到这里我们的 mini react 就实现完成了,基本涵盖了 react 源码所有的知识点例如 fiber 架构、render 和 commit 阶段、diff 算法、类组件、函数组件、hooks 等等。

当然源码中也有一些不足,例如对于 dom 的创建还未考虑 React.fragment、其他内置组件以及嵌套的列表渲染等等。另外我们的 diff 算法的实现是一个简易版的 diff,并未考虑 key 值和 type 共同 diff 等,感兴趣的可以去 mini react 仓库再次基础上进行扩充和完善。

通过实现本次的 mini react,希望你对 react 的原理有了一个更清晰的认知,也欢迎关注我的 react17 源码专栏,里面有我之前写的一系列 react17 源码的阅读及解析~


File: docs/team/代码质量/CodeReview.md

[Binary file]


File: docs/team/代码质量/Js代码可读性.md

[Binary file]


File: docs/team/代码质量/前端规范化实践.md

[Binary file]


File: docs/vue2/通信方式.md


desc: 'vue 组件通信的方式,这是在面试中一个非常高频的问题,今天对 vue 组件通信方式进行一下总结。' cover: 'https://github.com/zh-lx/blog/assets/73059627/4be45e22-e429-4b6f-9881-3ab517f01fd5' tag: ['vue', 'vue2'] time: '2021-04-24'

Vue 组件通信方式汇总

vue 组件通信的方式,这是在面试中一个非常高频的问题,我刚开始找实习便经常遇到这个问题,当时只知道回到 props 和$emit,后来随着学习的深入,才发现 vue 组件的通信方式竟然有这么多!
今天对 vue 组件通信方式进行一下总结,如写的有疏漏之处还请大家不吝赐教。

1. props/\$emit

简介

props 和$emit 相信大家十分的熟悉了,这是我们最常用的 vue 通信方式。
**props:**props 可以是数组或对象,用于接收来自父组件通过 v-bind 传递的数据。当 props 为数组时,直接接收父组件传递的属性;当 props 为对象时,可以通过 type、default、required、validator 等配置来设置属性的类型、默认值、是否必传和校验规则。
**$emit:**在父子组件通信时,我们通常会使用$emit 来触发父组件 v-on 在子组件上绑定相应事件的监听。

代码实例

下面通过代码来实现一下 props 和$emit 的父子组件通信,在这个实例中,我们都实现了以下的通信:

  • 父向子传值:父组件通过:messageFromParent="message"将父组件 message 值传递给子组件,当父组件的 input 标签输入时,子组件 p 标签中的内容就会相应改变。
  • 子向父传值:父组件通过@on-receive="receive"在子组件上绑定了 receive 事件的监听,子组件 input 标签输入时,会触发 receive 回调函数, 通过this.$emit('on-receive', this.message)将子组件 message 的值赋值给父组件 messageFromChild ,改变父组件 p 标签的内容。
    请看代码:
html
// 子组件代码
<template>
  <div class="child">
    <h4>this is child component</h4>
    <input type="text" v-model="message" @keyup="send" />
    <p>收到来自父组件的消息:{{ messageFromParent }}</p>
  </div>
</template>
<script>
  export default {
    name: 'Child',
    props: ['messageFromParent'], // 通过props接收父组件传过来的消息
    data() {
      return {
        message: '',
      };
    },
    methods: {
      send() {
        this.$emit('on-receive', this.message); // 通过$emit触发on-receive事件,调用父组件中receive回调,并将this.message作为参数
      },
    },
  };
</script>
html
// 父组件代码
<template>
  <div class="parent">
    <h3>this is parent component</h3>
    <input type="text" v-model="message" />
    <p>收到来自子组件的消息:{{ messageFromChild }}</p>
    <Child :messageFromParent="message" @on-receive="receive" />
  </div>
</template>
<script>
  import Child from './child';
  export default {
    name: 'Parent',
    data() {
      return {
        message: '', // 传递给子组件的消息
        messageFromChild: '',
      };
    },
    components: {
      Child,
    },
    methods: {
      receive(msg) {
        // 接受子组件的信息,并将其赋值给messageFromChild
        this.messageFromChild = msg;
      },
    },
  };
</script>

效果预览

2. v-slot

简介

v-slot 是 Vue2.6 版本中新增的用于统一实现插槽和具名插槽的 api,用于替代slot(2.6.0废弃)、slot-scope(2.6.0废弃)、scope(2.5.0废弃)等 api。
v-slot 在 template 标签中用于提供具名插槽或需要接收 prop 的插槽,如果不指定 v-slot ,则取默认值 default 。

代码实例

下面请看 v-slot 的代码实例,在这个实例中我们实现了:

  • 父向子传值:父组件通过<template v-slot:child>{{ message }}</template>将父组件的 message 值传递给子组件,子组件通过<slot name="child"></slot>接收到相应内容,实现了父向子传值。
html
// 子组件代码
<template>
  <div class="child">
    <h4>this is child component</h4>
    <p>
      收到来自父组件的消息: <slot name="child"></slot>
      <!--展示父组件通过插槽传递的{{message}}-->
    </p>
  </div>
</template>
html
<template>
  <div class="parent">
    <h3>this is parent component</h3>
    <input type="text" v-model="message" />
    <Child>
      <template v-slot:child>
        {{ message }}
        <!--插槽要展示的内容-->
      </template>
    </Child>
  </div>
</template>
<script>
  import Child from './child';
  export default {
    name: 'Parent',
    data() {
      return {
        message: '',
      };
    },
    components: {
      Child,
    },
  };
</script>

效果预览

3. \$refs/\$parent/\$children/\$root

简介

我们也同样可以通过 $refs/$parent/$children/$root 等方式获取 Vue 组件实例,得到实例上绑定的属性及方法等,来实现组件之间的通信。
**$refs:**我们通常会将 $refs 绑定在 DOM 元素上,来获取 DOM 元素的 attributes。在实现组件通信上,我们也可以将 $refs 绑定在子组件上,从而获取子组件实例。
**$parent:**我们可以在 Vue 中直接通过this.$parent来获取当前组件的父组件实例(如果有的话)。
**$children:**同理,我们也可以在 Vue 中直接通过this.$children来获取当前组件的子组件实例的数组。但是需要注意的是,this.$children数组中的元素下标并不一定对用父组件引用的子组件的顺序,例如有异步加载的子组件,可能影响其在 children 数组中的顺序。所以使用时需要根据一定的条件例如子组件的 name 去找到相应的子组件。
**$root:**获取当前组件树的根 Vue 实例。如果当前实例没有父实例,此实例将会是其自己。通过 $root ,我们可以实现组件之间的跨级通信。

代码实例

下面来看一个 $parent 和 $children 使用的实例(由于这几个 api 的使用方式大同小异,所以关于 $refs 和 $root 的使用就不在这里展开了,在这个实例中实现了:

  • 父向子传值:子组件通过$parent.message获取到父组件中 message 的值。
  • 子向父传值:父组件通过$children获取子组件实例的数组,在通过对数组进行遍历,通过实例的 name 获取到对应 Child1 子组件实例将其赋值给 child1,然后通过child1.message获取到 Child1 子组件的 message。
    代码如下:
html
// 子组件
<template>
  <div class="child">
    <h4>this is child component</h4>
    <input type="text" v-model="message" />
    <p>收到来自父组件的消息:{{ $parent.message }}</p>
    <!--展示父组件实例的message-->
  </div>
</template>
<script>
  export default {
    name: 'Child1',
    data() {
      return {
        message: '', // 父组件通过this.$children可以获取子组件实例的message
      };
    },
  };
</script>
html
// 父组件
<template>
  <div class="parent">
    <h3>this is parent component</h3>
    <input type="text" v-model="message" />
    <p>收到来自子组件的消息:{{ child1.message }}</p>
    <!--展示子组件实例的message-->
    <Child />
  </div>
</template>
<script>
  import Child from './child';
  export default {
    name: 'Parent',
    data() {
      return {
        message: '',
        child1: {},
      };
    },
    components: {
      Child,
    },
    mounted() {
      this.child1 = this.$children.find((child) => {
        return child.$options.name === 'Child1'; // 通过options.name获取对应name的child实例
      });
    },
  };
</script>

效果预览

4. \$attrs/\$listener

简介

$attrs 和 $listeners 都是 Vue2.4 中新增加的属性,主要是用来供使用者用来开发高级组件的。
**$attrs:**用来接收父作用域中不作为 prop 被识别的 attribute 属性,并且可以通过v-bind="$attrs"传入内部组件——在创建高级别的组件时非常有用。
试想一下,当你创建了一个组件,你要接收 param1 、param2、param3 …… 等数十个参数,如果通过 props,那你需要通过props: ['param1', 'param2', 'param3', ……]等声明一大堆。如果这些 props 还有一些需要往更深层次的子组件传递,那将会更加麻烦。
而使用 $attrs ,你不需要任何声明,直接通过$attrs.param1、$attrs.param2……就可以使用,而且向深层子组件传递上面也给了示例,十分方便。
**$listeners:**包含了父作用域中的 v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件——在创建更高层次的组件时非常有用,这里在传递时的使用方法和 $attrs 十分类似。

代码实例

在这个实例中,共有三个组件:A、B、C,其关系为:[ A [ B [C] ] ],A 为 B 的父组件,B 为 C 的父组件。即:1 级组件 A,2 级组件 B,3 级组件 C。我们实现了:

  • 父向子传值:1 级组件 A 通过:messageFromA="message"将 message 属性传递给 2 级组件 B,2 级组件 B 通过$attrs.messageFromA获取到 1 级组件 A 的 message 。
  • 跨级向下传值:1 级组件 A 通过:messageFromA="message"将 message 属性传递给 2 级组件 B,2 级组件 B 再通过 v-bind="$attrs"将其传递给 3 级组件 C,3 级组件 C 通过$attrs.messageFromA获取到 1 级组件 A 的 message 。
  • 子向父传值:1 级组件 A 通过@keyup="receive"在子孙组件上绑定 keyup 事件的监听,2 级组件 B 在通过v-on="$listeners"来将 keyup 事件绑定在其 input 标签上。当 2 级组件 B input 输入框输入时,便会触发 1 级组件 A 的 receive 回调,将 2 级组件 B 的 input 输入框中的值赋值给 1 级组件 A 的 messageFromComp ,从而实现子向父传值。
  • 跨级向上传值:1 级组件 A 通过@keyup="receive"在子孙组件上绑定 keyup 事件的监听,2 级组件 B 在通过 <CompC v-on="$listeners" />将其继续传递给 C。3 级组件 C 在通过v-on="$listeners"来将 keyup 事件绑定在其 input 标签上。当 3 级组件 C input 输入框输入时,便会触发 1 级组件 A 的 receive 回调,将 3 级组件 C 的 input 输入框中的值赋值给 1 级组件 A 的 messageFromComp ,从而实现跨级向上传值。
    代码如下:
html
// 3级组件C
<template>
  <div class="compc">
    <h5>this is C component</h5>
    <input name="compC" type="text" v-model="message" v-on="$listeners" />
    <!--将A组件keyup的监听回调绑在该input上-->
    <p>收到来自A组件的消息:{{ $attrs.messageFromA }}</p>
  </div>
</template>
<script>
  export default {
    name: 'Compc',
    data() {
      return {
        message: '',
      };
    },
  };
</script>
html
// 2级组件B
<template>
  <div class="compb">
    <h4>this is B component</h4>
    <input name="compB" type="text" v-model="message" v-on="$listeners" />
    <!--将A组件keyup的监听回调绑在该input上-->
    <p>收到来自A组件的消息:{{ $attrs.messageFromA }}</p>
    <CompC v-bind="$attrs" v-on="$listeners" />
    <!--将A组件keyup的监听回调继续传递给C组件,将A组件传递的attrs继续传递给C组件-->
  </div>
</template>
<script>
  import CompC from './compC';
  export default {
    name: 'CompB',
    components: {
      CompC,
    },
    data() {
      return {
        message: '',
      };
    },
  };
</script>
html
// A组件
<template>
  <div class="compa">
    <h3>this is A component</h3>
    <input type="text" v-model="message" />
    <p>收到来自{{ comp }}的消息:{{ messageFromComp }}</p>
    <CompB :messageFromA="message" @keyup="receive" />
    <!--监听子孙组件的keyup事件,将message传递给子孙组件-->
  </div>
</template>
<script>
  import CompB from './compB';
  export default {
    name: 'CompA',
    data() {
      return {
        message: '',
        messageFromComp: '',
        comp: '',
      };
    },
    components: {
      CompB,
    },
    methods: {
      receive(e) {
        // 监听子孙组件keyup事件的回调,并将keyup所在input输入框的值赋值给messageFromComp
        this.comp = e.target.name;
        this.messageFromComp = e.target.value;
      },
    },
  };
</script>

效果预览

5. provide/inject

简介

provide/inject 这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在其上下游关系成立的时间里始终生效。如果你是熟悉 React 的同学,你一定会立刻想到 Context 这个 api,二者是十分相似的。
**provide:**是一个对象,或者是一个返回对象的函数。该对象包含可注入其子孙的 property ,即要传递给子孙的属性和属性值。
**injcet:**一个字符串数组,或者是一个对象。当其为字符串数组时,使用方式和 props 十分相似,只不过接收的属性由 data 变成了 provide 中的属性。当其为对象时,也和 props 类似,可以通过配置 default 和 from 等属性来设置默认值,在子组件中使用新的命名属性等。

代码实例

这个实例中有三个组件,1 级组件 A,2 级组件 B,3 级组件 C:[ A [ B [C] ] ],A 是 B 的父组件,B 是 C 的父组件。实例中实现了:

  • 父向子传值:1 级组件 A 通过 provide 将 message 注入给子孙组件,2 级组件 B 通过inject: ['messageFromA']来接收 1 级组件 A 中的 message,并通过messageFromA.content获取 1 级组件 A 中 message 的 content 属性值。
  • 跨级向下传值:1 级组件 A 通过 provide 将 message 注入给子孙组件,3 级组件 C 通过inject: ['messageFromA']来接收 1 级组件 A 中的 message,并通过messageFromA.content获取 1 级组件 A 中 message 的 content 属性值,实现跨级向下传值。
    代码如下:
html
// 1级组件A
<template>
  <div class="compa">
    <h3>this is A component</h3>
    <input type="text" v-model="message.content" />
    <CompB />
  </div>
</template>
<script>
  import CompB from './compB';
  export default {
    name: 'CompA',
    provide() {
      return {
        messageFromA: this.message, // 将message通过provide传递给子孙组件
      };
    },
    data() {
      return {
        message: {
          content: '',
        },
      };
    },
    components: {
      CompB,
    },
  };
</script>
html
// 2级组件B
<template>
  <div class="compb">
    <h4>this is B component</h4>
    <p>收到来自A组件的消息:{{ messageFromA && messageFromA.content }}</p>
    <CompC />
  </div>
</template>
<script>
  import CompC from './compC';
  export default {
    name: 'CompB',
    inject: ['messageFromA'], // 通过inject接受A中provide传递过来的message
    components: {
      CompC,
    },
  };
</script>
html
// 3级组件C
<template>
  <div class="compc">
    <h5>this is C component</h5>
    <p>收到来自A组件的消息:{{ messageFromA && messageFromA.content }}</p>
  </div>
</template>
<script>
  export default {
    name: 'Compc',
    inject: ['messageFromA'], // 通过inject接受A中provide传递过来的message
  };
</script>

注意点:

  1. 可能有同学想问我上面 1 级组件 A 中的 message 为什么要用 object 类型而不是 string 类型,因为在 vue provide 和 inject 绑定并不是可响应的。如果 message 是 string 类型,在 1 级组件 A 中通过 input 输入框改变 message 值后无法再赋值给 messageFromA,如果是 object 类型,当对象属性值改变后,messageFromA 里面的属性值还是可以随之改变的,子孙组件 inject 接收到的对象属性值也可以相应变化。
  2. 子孙 provide 和祖先同样的属性,会在后代中覆盖祖先的 provide 值。例如 2 级组件 B 中也通过 provide 向 3 级组件 C 中注入一个 messageFromA 的值,则 3 级组件 C 中的 messageFromA 会优先接收 2 级组件 B 注入的值而不是 1 级组件 A。

效果预览

6. eventBus

简介

eventBus 又称事件总线,通过注册一个新的 Vue 实例,通过调用这个实例的$emit 和$on 等来监听和触发这个实例的事件,通过传入参数从而实现组件的全局通信。它是一个不具备 DOM 的组件,有的仅仅只是它实例方法而已,因此非常的轻便。
我们可以通过在全局 Vue 实例上注册:

javascript
// main.js
Vue.prototype.$Bus = new Vue();

但是当项目过大时,我们最好将事件总线抽象为单个文件,将其导入到需要使用的每个组件文件中。这样,它不会污染全局命名空间:

javascript
// bus.js,使用时通过import引入
import Vue from 'vue';
export const Bus = new Vue();

原理分析

eventBus 的原理其实比较简单,就是使用订阅-发布模式,实现$emit 和$on 两个方法即可:

javascript
// eventBus原理
export default class Bus {
  constructor() {
    this.callbacks = {};
  }
  $on(event, fn) {
    this.callbacks[event] = this.callbacks[event] || [];
    this.callbacks[event].push(fn);
  }
  $emit(event, args) {
    this.callbacks[event].forEach((fn) => {
      fn(args);
    });
  }
}

// 在main.js中引入以下
// Vue.prototype.$bus = new Bus()

代码实例

在这个实例中,共包含了 4 个组件:[ A [ B [ C、D ] ] ],1 级组件 A,2 级组件 B,3 级组件 C 和 3 级组件 D。我们通过使用 eventBus 实现了:

  • 全局通信:即包括了父子组件相互通信、兄弟组件相互通信、跨级组件相互通信。4 个组件的操作逻辑相同,都是在 input 输入框时,通过this.$bus.$emit('sendMessage', obj)触发 sendMessage 事件回调,将 sender 和 message 封装成对象作为参数传入;同时通过this.$bus.$on('sendMessage', obj)监听其他组件的 sendMessage 事件,实例当前组件示例 sender 和 message 的值。这样任一组件 input 输入框值改变时,其他组件都能接收到相应的信息,实现全局通信。
    代码如下:
main.js
// main.js
Vue.prototype.$bus = new Vue()
html
// 1级组件A
<template>
  <div class="containerA">
    <h2>this is CompA</h2>
    <input type="text" v-model="message" @keyup="sendMessage" />
    <p v-show="messageFromBus && sender !== $options.name">
      收到{{ sender }}的消息:{{ messageFromBus }}
    </p>
    <CompB />
  </div>
</template>
<script>
  import CompB from './compB';
  export default {
    name: 'CompA',
    components: {
      CompB,
    },
    data() {
      return {
        message: '',
        messageFromBus: '',
        sender: '',
      };
    },
    mounted() {
      this.$bus.$on('sendMessage', (obj) => {
        // 通过eventBus监听sendMessage事件
        const { sender, message } = obj;
        this.sender = sender;
        this.messageFromBus = message;
      });
    },
    methods: {
      sendMessage() {
        this.$bus.$emit('sendMessage', {
          // 通过eventBus触发sendMessage事件
          sender: this.$options.name,
          message: this.message,
        });
      },
    },
  };
</script>
html
// 2级组件B
<template>
  <div class="containerB">
    <h3>this is CompB</h3>
    <input type="text" v-model="message" @keyup="sendMessage" />
    <p v-show="messageFromBus && sender !== $options.name">
      收到{{ sender }}的消息:{{ messageFromBus }}
    </p>
    <CompC />
    <CompD />
  </div>
</template>
<script>
  import CompC from './compC';
  import CompD from './compD';
  export default {
    name: 'CompB',
    components: {
      CompC,
      CompD,
    },
    data() {
      return {
        message: '',
        messageFromBus: '',
        sender: '',
      };
    },
    mounted() {
      this.$bus.$on('sendMessage', (obj) => {
        // 通过eventBus监听sendMessage事件
        const { sender, message } = obj;
        this.sender = sender;
        this.messageFromBus = message;
      });
    },
    methods: {
      sendMessage() {
        this.$bus.$emit('sendMessage', {
          // 通过eventBus触发sendMessage事件
          sender: this.$options.name,
          message: this.message,
        });
      },
    },
  };
</script>
html
// 3级组件C
<template>
  <div class="containerC">
    <p>this is CompC</p>
    <input type="text" v-model="message" @keyup="sendMessage" />
    <p v-show="messageFromBus && sender !== $options.name">
      收到{{ sender }}的消息:{{ messageFromBus }}
    </p>
  </div>
</template>
<script>
  export default {
    name: 'CompC',
    data() {
      return {
        message: '',
        messageFromBus: '',
        sender: '',
      };
    },
    mounted() {
      this.$bus.$on('sendMessage', (obj) => {
        // 通过eventBus监听sendMessage事件
        const { sender, message } = obj;
        this.sender = sender;
        this.messageFromBus = message;
      });
    },
    methods: {
      sendMessage() {
        this.$bus.$emit('sendMessage', {
          // 通过eventBus触发sendMessage事件
          sender: this.$options.name,
          message: this.message,
        });
      },
    },
  };
</script>
html
// 3级组件D
<template>
  <div class="containerD">
    <p>this is CompD</p>
    <input type="text" v-model="message" @keyup="sendMessage" />
    <p v-show="messageFromBus && sender !== $options.name">
      收到{{ sender }}的消息:{{ messageFromBus }}
    </p>
  </div>
</template>
<script>
  export default {
    name: 'CompD',
    data() {
      return {
        message: '',
        messageFromBus: '',
        sender: '',
      };
    },
    mounted() {
      this.$bus.$on('sendMessage', (obj) => {
        // 通过eventBus监听sendMessage事件
        const { sender, message } = obj;
        this.sender = sender;
        this.messageFromBus = message;
      });
    },
    methods: {
      sendMessage() {
        this.$bus.$emit('sendMessage', {
          // 通过eventBus触发sendMessage事件
          sender: this.$options.name,
          message: this.message,
        });
      },
    },
  };
</script>

效果预览

7. Vuex

当项目庞大以后,在多人维护同一个项目时,如果使用事件总线进行全局通信,容易让全局的变量的变化难以预测。于是有了 Vuex 的诞生。
Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。
有关 Vuex 的内容,可以参考Vuex 官方文档,我就不在这里班门弄斧了,直接看代码。

代码实例

Vuex 的实例和事件总线 leisi,同样是包含了 4 个组件:[ A [ B [ C、D ] ] ],1 级组件 A,2 级组件 B,3 级组件 C 和 3 级组件 D。我们在这个实例中实现了:

  • 全局通信:代码的内容和 eventBus 也类似,不过要比 eventBus 使用方便很多。每个组件通过 watch 监听 input 输入框的变化,把 input 的值通过 vuex 的 commit 触发 mutations,从而改变 stroe 的值。然后每个组件都通过 computed 动态获取 store 中的数据,从而实现全局通信。
javascript
// store.js
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default new Vuex.Store({
  state: {
    message: {
      sender: '',
      content: '',
    },
  },
  mutations: {
    sendMessage(state, obj) {
      state.message = {
        sender: obj.sender,
        content: obj.content,
      };
    },
  },
});
html
// 组件A
<template>
  <div class="containerA">
    <h2>this is CompA</h2>
    <input type="text" v-model="message" />
    <p v-show="messageFromStore && sender !== $options.name">
      收到{{ sender }}的消息:{{ messageFromStore }}
    </p>
    <CompB />
  </div>
</template>
<script>
  import CompB from './compB';
  export default {
    name: 'CompA',
    components: {
      CompB,
    },
    data() {
      return {
        message: '',
      };
    },
    computed: {
      messageFromStore() {
        return this.$store.state.message.content;
      },
      sender() {
        return this.$store.state.message.sender;
      },
    },
    watch: {
      message(newValue) {
        this.$store.commit('sendMessage', {
          sender: this.$options.name,
          content: newValue,
        });
      },
    },
  };
</script>

同样和 eventBus 中一样,B,C,D 组件中的代码除了引入子组件的不同,script 部分都是一样的,就不再往上写了。

效果预览

总结

上面总共提到了 7 中 Vue 的组件通信方式,他们能够进行的通信种类如下图所示:

  • props/$emit:可以实现父子组件的双向通信,在日常的父子组件通信中一般会作为我们的最常用选择。
  • v-slot:可以实现父子组件单向通信(父向子传值),在实现可复用组件,向组件中传入 DOM 节点、html 等内容以及某些组件库的表格值二次处理等情况时,可以优先考虑 v-slot。
  • $refs/$parent/$children/$root:可以实现父子组件双向通信,其中$root 可以实现根组件实例向子孙组件跨级单向传值。在父组件没有传递值或通过 v-on 绑定监听时,父子间想要获取彼此的属性或方法可以考虑使用这些 api。
  • $attrs/$listeners:能够实现跨级双向通信,能够让你简单的获取传入的属性和绑定的监听,并且方便地向下级子组件传递,在构建高级组件时十分好用。
  • provide/inject:可以实现跨级单向通信,轻量地向子孙组件注入依赖,这是你在实现高级组件、创建组件库时的不二之选。
  • eventBus:可以实现全局通信,在项目规模不大的情况下,可以利用 eventBus 实现全局的事件监听。但是 eventBus 要慎用,避免全局污染和内存泄漏等情况。
  • Vuex:可以实现全局通信,是 vue 项目全局状态管理的最佳实践。在项目比较庞大,想要集中式管理全局组件状态时,那么安装 Vuex 准没错!

最后,鲁迅说过:“一碗酸辣汤,耳闻口讲的,总不如亲自呷一口的明白。”(鲁迅:这句话我真说过!)看了这么多,不如自己亲手去敲一敲更能理解,看完可以去手动敲一敲加深理解。


File: docs/代码仓库/git/Git操作指令大全.md


desc: '本文总结了日常工作中常用的 git 指令,涵盖了绝大部分的使用场景,让你能够轻松应对各种 git 协作流程。' cover: 'https://github.com/zh-lx/blog/assets/73059627/789ee5e2-6e23-4ab5-9e1c-37b2ebaef68d' tag: ['git'] time: '2021-12-31'

Git 操作指令大全

本文总结了日常工作中常用的 git 指令,涵盖了绝大部分的使用场景,让你能够轻松应对各种 git 协作流程。

理解 git 工作区域

根据 git 的几个文件存储区域,git 的工作区域可以划分为 4 个:

  • 工作区:你在本地编辑器里改动的代码,所见即所得,里面的内容都是最新的
  • 暂存区:通过 git add 指令,会将你工作区改动的代码提交到暂存区里
  • 本地仓库:通过 git commit 指令,会将暂存区变动的代码提交到本地仓库中,本地仓库位于你的电脑上
  • 远程仓库:远端用来托管代码的仓库,通过 git push 指令,会将本地仓库的代码推送到远程仓库中

初始配置

配置用户信息

首次使用 git 时,设置提交代码时的信息:

perl
# 配置用户名
git config --global user.name "yourname"

# 配置用户邮箱
git config --global user.email "[email protected]"

# 查看当前的配置信息
git config --global --list

# 通过 alias 配置简写
## 例如使用 git co 代替 git checkout
git config --global alias.co checkout

ssh key

向远端仓库提交代码时,需要在远端仓库添加本地生成的 ssh key。

  1. 生成本地 ssh key,若已有直接到第 2 步:

    text
    ssh-keygen -t rsa -C "[email protected]"
  2. 查看本地 ssh key:

    text
    cat ~/.ssh/id_rsa.pub
  3. 将 ssh key 粘贴到远端仓库:

高频命令

以下是最常用的操作,需要任何一个开发者学会的 git 命令:

git clone: 克隆仓库

perl
# 克隆远端仓库到本地
git clone <git url>

# 克隆远端仓库到本地,并同时切换到指定分支 branch1
git clone <git url> -b branch1

# 克隆远端仓库到本地并指定本地仓库的文件夹名称为 my-project
git clone <git url> my-project

git add: 提交到暂存区

工作区提交到暂存区,用到的指令为 git add:

perl
# 将所有修改的文件都提交到暂存区
git add .

# 将修改的文件中的指定的文件 a.js 和 b.js 提交到暂存区
git add ./a.js ./b.js

# 将 js 文件夹下修改的内容提交到暂存区
git add ./js

git commit: 提交到本地仓库

将工作区内容提交到本地仓库所用到的指令为 git commit:

perl
# 将工作区内容提交到本地仓库,并添加提交信息 your commit message
git commit -m "your commit message"

# 将工作区内容提交到本地仓库,并对上一次 commit 记录进行覆盖
## 例如先执行 git commit -m "commit1" 提交了文件a,commit_sha为hash1;再执行 git commit -m "commit2" --amend 提交文件b,commit_sha为hash2。最终显示的是a,b文件的 commit 信息都是 "commit2",commit_sha都是hash2
git commit -m "new message" --amend

# 将工作区内容提交到本地仓库,并跳过 commit 信息填写
## 例如先执行 git commit -m "commit1" 提交了文件a,commit_sha为hash1;再执行 git commit --amend --no-edit 提交文件b,commit_sha为hash2。最终显示的是a,b文件的 commit 信息都是 "commit1",commit_sha都是hash1
git commit --amend --no-edit

# 跳过校验直接提交,很多项目配置 git hooks 验证代码是否符合 eslint、husky 等规则,校验不通过无法提交
## 通过 --no-verify 可以跳过校验(为了保证代码质量不建议此操作QwQ)
git commit --no-verify -m "commit message"

# 一次性从工作区提交到本地仓库,相当于 git add . + git commit -m
git commit -am

git push: 提交到远程仓库

git push 会将本地仓库的内容推送到远程仓库

perl
# 将当前本地分支 branch1 内容推送到远程分支 origin/branch1
git push

# 若当前本地分支 branch1,没有对应的远程分支 origin/branch1,需要为推送当前分支并建立与远程上游的跟踪
git push --set-upstream origin branch1

# 强制提交
## 例如用在代码回滚后内容
git push -f

git pull: 拉取远程仓库并合并

git pull 会拉取远程仓库并合并到本地仓库,相当于执行 git fetch + git merge

perl
# 若拉取并合并的远程分支和当前本地分支名称一致
## 例如当前本地分支为 branch1,要拉取并合并 origin/branch1,则直接执行:
git pull

# 若拉取并合并的远程分支和当前本地分支名称不一致
git pull <远程主机名> <分支名>
## 例如当前本地分支为 branch2,要拉取并合并 origin/branch1,则执行:
git pull [email protected]:zh-lx/git-practice.git branch1

# 使用 rebase 模式进行合并
git pull --rebase

git checkout: 切换分支

git checkout 用于切换分支及撤销工作区内容的修改

perl
# 切换到已有的本地分支 branch1
git checkout branch1

# 切换到远程分支 branch1
git checkout origin/branch1

# 基于当前本地分支创建一个新分支 branch2,并切换至 branch2
git checkout -b branch2

# 基于远程分支 branch1 创建一个新分支 branch2,并切换至 branch2
git checkout origin/branch1 -b branch2
## 当前创建的 branch2 关联的上游分支是 origin/branch1,所以 push 时需要如下命令关联到远程 branch2
git push --set-upstream origin branch2

# 撤销工作区 file 内容的修改。危险操作,谨慎使用
git checkout -- <file>

# 撤销工作区所有内容的修改。危险操作,谨慎使用
git checkout .

git restore: 取消缓存

git restore 用于将改动从暂存区退回工作区

perl
# 将 a.js 文件取消缓存(取消 add 操作,不改变文件内容)
git reset --staged a.js

# 将所有文件取消缓存
git reset --staged .

git reset: 回滚代码

git reset 用于撤销各种 commit 操作,回滚代码

perl
# 将某个版本的 commit 从本地仓库退回到工作区(取消 commit 和 add 操作,不改变文件内容)
## 默认不加 -- 参数时时 mixed
git reset --mixed <commit_sha>

# 将某个版本的 commit 从本地仓库退回到缓存区(取消 commit 操作,不取消 add,不改变文件内容)
git reset --soft <commit_sha>

# 取消某次 commit 的记录(取消 commit 和 add,且改变文件内容)
git reset --hard <commit_sha>

## 以上三种操作退回了 commit,都是退回本地仓库的 commit,没有改变远程仓库的 commit。通常再次修改后配合如下命令覆盖远程仓库的 commit:
git push -f

常用命令

实际的 git 操作场景很多,下面的命令也经常在场景中使用。

git revert: 取消某次 commit 内容

说明

git revert 相比于 git reset,会取消某次 commit 更改的内容,但是不会取消掉 commit 记录,而是进行一次新的 commit 去覆盖要取消的那次 commit:

perl
# 取消某次 commit 内容,但是保留 commit 记录
git revert <commit-sha>

场景

某一版需求中,pm 共提了活动功能、分享功能和视频功能三个功能模块,然后开发老哥做完提测了。上线之前,版本的代码都已经合并到主分支了,pm 突然说这一版活动功能不上线了,下一版再上,分享功能和视频功能正常上线,开发老哥心里直呼 mmp。
研发老哥的 commit 记录如下:

现在想要做的就是取消掉第一次活动功能的 commit,但是视频功能和分享功能的 commit 还需要保留,所以肯定不能使用 git reset 了, 这时候 git revert 就派上用场了。

执行 git revert 9ec52dc,再重新 git push,活动功能的 commit 内容就会被覆盖掉了:

git rebase: 简洁 commit 记录

git rebase 命令主要是针对 commit 的,目的是令 commit 记录变得更加简洁清晰。

多次 commit 合并为一次

可以通过 git rebase -i 合并多次 commit 为一次。注意:此操作会修改 commit-sha,因此只能在自己的分支上操作,不能在公共分支操作,不然会引起他人的合并冲突

说明

perl
# 进行 git rebase 可交互命令变基,end-commit-sha 可选,不填则默认为 HEAD
## start 和 end commit-sha 左开右闭原则
git rebase -i <start commit-sha> <end commit-sha>

# 若是中间毫无冲突,变基则一步到位,否则需要逐步调整

# 上次变基为完成,继续上一次的变基操作
git rebase --continue

# 变基有冲突时丢弃有冲突的 commit
git rebase --skip

# 若是变基中途操作错误但是未彻底完成,可以回滚到变基之前的状态
git rebase --abort

执行后,会出现可交互命令,界面如下:

  • pick: 是保留该 commit(采用)
  • edit: 一般你提交的东西多了,可以用这个把东东拿回工作区拆分更细的 commit
  • reword: 这个可以重新修改你的 commit msg
  • squash: 内容保留,把提交信息往上一个 commit 合并进去
  • fixup: 保留变动内容,但是抛弃 commit msg

场景

开发老哥在自己的分支开发一个活动功能,共如下 4 次 commit 记录:

要往主分支合并之前,开发老哥决定让 commit 记录简洁一些,于是执行 git rebase -i edb259:

上面四行就是我们要进行合并的 commit,我们现在是进入了一个 vim 界面,按 i 进行编辑,将活动功能 2, 3, 4 行改为 s(squash) 如下:

按下 esc 退出编辑,再输入 wq 退出当前的 vim,会进入到此页面编辑 commit 信息:

我们可以修改 feat: 活动奖励1 为 feat: 活动奖励,然后 esc 退出编辑,再输入 wq! 退出当前的 vim。再次 git push -f 推送分支,可以看到 commit 已经合并为一次:

使用 rebase 代替 merge

说明

前面说到过 git pull = git fetch + git merge,通过加 --rebase 参数可以启用 rebase 模式, 实际上 git pull --rebase = git fetch + git rebase。

git rebase 代替 git merge 是现在许多公司和团队要求使用的一种合并方式。相比于 git merge,git rebase 可以让分支合并后只显示 master 一条线,并且按照 commit 和时间去排序,使得 git 记录简洁和清晰了许多。

perl
# 将本地某分支合并至当前分支
git rebase <分支名>

# 将远程某分支合并至当前分支
git rebase <远程主机名> <分支名>

场景

假设我们有一个 main 分支,基于 main 分支拉出了一个 feat-1.2 分支。然后在 main 分支先进行了 feat: 1、feat: 2 两次 commit,再在 feat-1.2 分支进行了 feat: 3、feat: 4 ,最后将 feat-1.2 分支合并至 master 分支。

如果采用 git merge 合并,通过 git log --graph --decorate --all 查看分支变更图如下:

如果采用 git rebase 合并,通过 git log --graph --decorate --all 查看分支变更图如下:

git cherry-pick: 合并指定 commit

说明

git cherry-pick 可以选择某次 commit 的内容合并到当前分支

perl
# 将 commit-sha1 的变动合并至当前分支
git cherry-pick commit-sha1

# 将多次 commit 变动合并至当前分支
git cherry-pick commit-sha1 commit-sha2

# 将 commit-sha1 到 commit-sha5 中间所有变动合并至当前分支,中间使用..
git cherry-pick commit-sha1..commit-sha5

# pick 时解决冲突后继续 pick
git cherry-pick --continue:
# 多次 pick 时跳过本次 commit 的 pick 进入下一个 commit 的 pick
git cherry-pick --skip
# 完全放弃 pick,恢复 pick 之前的状态
git cherry-pick --abort
# 未冲突的 commit 自动变更,冲突的不要,退出这次 pick
git cherry-pick --quit

场景

某开发老哥 A 和开发老哥 B 共同开发一次版本需求,开发老哥 A 拉了一个 feat-acitivty 分支开发活动功能,开发老哥 B 拉了一个 feat-share 分支开发分享功能,需要分开提交测试。

开发老哥 B 开发了一半发现需要用到一个弹窗组件,此时开发老哥 A 也需要弹窗组件并且已经开发完了,开发老哥 A 的 commit 如下:

此时 git cherry-pick 就起作用了,开发老哥 B 执行如下命令:git cherry-pick 486885f,轻松将弹窗组件的代码也合并进了自己的分支。

git stash:缓存代码

说明

git stash 用于将当前的代码缓存起来,而不必提交,便于下次取出。

perl
# 把本地的改动缓存起来
git stash

# 缓存代码时添加备注,便于查找。强烈推荐
git stash save "message"

# 查看缓存记录
## eg: stash@{0}: On feat-1.1: 活动功能
git stash list

# 取出上一次缓存的代码,并删除这次缓存
git stash pop
# 取出 index 为2缓存代码,并删除这次缓存,index 为对应 git stash list 所列出来的
git stash pop stash@{2}

# 取出上一次缓存的代码,但不删除这次缓存
stash apply
# 取出 index 为2缓存代码,但不删除缓存
git stash apply stash@{2}

# 清除某次的缓存
git stash drop stash@{n}

# 清除所有缓存
git stash clear

场景

某日开发老哥正愉快地开发着活动功能的代码,突然 pm 大喊:线上出 bug 了!!于是开发老哥不得不停下手头的工作去修改线上 bug,但是开发老哥又不想将现在的活动代码提交,于是开发老哥执行了 stash 命令:git stash message "活动功能暂存",之后转去修复线上 bug 了。

其他常用命令

以下命令在开发中也经常用到,强烈推荐大家学习:

git init: 初始化仓库

git init 会在本地生成一个 .git 文件夹,创建一个新的本地仓库:

text
git init

git remote: 关联远程仓库

git remote 用于将本地仓库与远程仓库关联

perl
# 关联本地 git init 到远程仓库
git remote add origin <git url>

# 新增其他上游仓库
git remote add <git url>

# 移除与远程仓库的管理
git remote remove  <git url>

# 修改推送源
git remote set-url origin <git url>

git status: 查看工作区状态

git status 用于查看工作区的文件,有哪些已经添加到了暂存区,哪些没有被添加:

perl
# 查看当前工作区暂存区变动
git status

# 以概要形式查看工作区暂存区变动
git status -s

# 查询工作区中是否有 stash 缓存
git status --show-stash

git log: 查看 commit 日志

git log 用于查看 commit 的日志

perl
# 显示 commit 日志
git log

# 以简要模式显示 commit 日志
git log --oneline

# 显示最近 n 次的 commit 日志
git log -n

# 显示 commit 及分支的图形化变更
git log --graph --decorate

git branch: 管理分支

git branch 常用于删除、重命名分支等

perl
# 删除分支
git branch -D <分支名>

# 重命名分支
git branch -M <老分支名> <新分支名>

# 将本地分支与远程分支关联
git branch --set-upstream-to=origin/xxx

# 取消本地分支与远程分支的关联
git branch --unset-upstream-to=origin/xxx

git rm:重新建立索引

git rm 用于修改 .gitignore 文件的缓存,重新建立索引

perl
# 删除某个文件索引(不会更改本地文件,只会是 .gitignore 范围重新生效)
git rm --cache -- <文件名>

# 清除所有文件的索引
## 例如你首次提交了很多文件,后来你建立了一个 .gitignore 文件,有些文件不想推送到远端仓库,但此时有的文件已经被推送了
## 使用此命令可以是 .gitignore 重新作用一遍,从远程仓库中取消这些文件,但不会更改你本地文件
git rm -r --cached .

git diff: 对比差异

git diff 用于对比工作区、缓存区、本地仓库以及分支之间的代码差异:

perl
# 当工作区有变动,暂存区无变动,对比工作区和本地仓库间的代码差异
#  当工作区有变动和暂存区都有变动,对比工作区和暂存区的代码差异
git diff

# 显示暂存区和本地仓库间的代码差异
git diff --cached
# or
git diff --staged

# 显示两个分支之间代码差异
git diff <分支名1> <分支名2>

git fetch: 获取更新

git fetch 用于本地仓库获取远程仓库的更新,不会 merge 到工作区

perl
# 获取远程仓库特定分支的更新
git fetch <远程主机名> <分支名>

# 获取远程仓库所有分支的更新
git fetch --all

git merge: 合并代码

git merge 前面在 git rebase 内容中也提到过,用于合并代码,用法和 git rebase 类似:

perl
# 将本地某分支合并至当前分支
git merge <分支名>

# 将远程某分支合并至当前分支
git merge <远程主机名> <分支名>

总结

以上基本就涵盖了工作中绝大部分场景的 git 命令,掌握了基本可以应对各种 git 操作了。鲁迅说过:一碗酸辣汤,耳濡目染的,不如亲自呷一口的明白。看了这么多建议大家自己建一个测试仓库,亲自敲一下熟悉一下上面命令的用法。

最后总结一个大图:


File: docs/代码仓库/git/实用总结.md


desc: '解决 git clone 时速度过慢的问题' tag: ['git'] time: '2021-05-03'

git 实用总结

git clone 速度太慢

将 github.com 源换成 github.com.cnpmjs.org,例如:

js
// before:
git clone https://github.com/zh-lx/pinyin-pro.git
// now:
git clone https://github.com.cnpmjs.org/zh-lx/pinyin-pro.git

File: docs/工程化/webpack/webpack体积优化及打包速度提升总结.md


desc: '最近在做项目的 webpack5 项目的打包优化,总结了 8 个速度优化和 6 个体积优化的实用方案,在你的项目中也尝试一下吧,直接起飞。' cover: 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5fde5629fe0a4eec9ee1dee8ac7cf622~tplv-k3u1fbpfcp-no-mark:480:480:0:0.awebp?' tag: ['webpack'] time: '2022-01-12'

最近在做项目的 webpack5 项目的打包优化,总结了 8 个速度优化和 6 个体积优化的实用方案,在你的项目中也尝试一下吧,直接起飞。

打包速度优化

速度分析

要进行打包速度的优化,首先我们需要搞明白哪一些流程的在打包执行过程中耗时较长。

这里我们可以借助 speed-measure-webpack-plugin 插件,它分析 webpack 的总打包耗时以及每个 plugin 和 loader 的打包耗时,从而让我们对打包时间较长的部分进行针对性优化。

通过以下命令安装插件:

perl
yarn add speed-measure-webpack-plugin -D

在 webpack.config.js 中添加如下配置

js
// ...
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();

module.exports = smp.wrap({
  // ...
  plugins: [
    // ...
  ],
  module: {
    // ...
  },
});

执行 webpack 打包命令后,如下图可以看到各个 loader 和 plugin 的打包耗时:

cdn 分包

对于项目中我们用的一些比较大和比较多的包,例如 react 和 react-dom,我们可以通过 cdn 的形式引入它们,然后将 react、react-dom 从打包列表中排除,这样可以减少打包所需的时间。

排除部分库的打包需要借助 html-webpack-externals-plugin 插件,执行如下命令安装:

text
yarn add html-webpack-externals-plugin -D

以 react 和 react-dom 为例,在 webpack 中添加如下配置:

js
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackExternalsPlugin({
      externals: [
        {
          module: 'react',
          entry: 'https://unpkg.com/[email protected]/umd/react.production.min.js',
          global: 'React',
        },
        {
          module: 'react-dom',
          entry:
            'https://unpkg.com/[email protected]/umd/react-dom.production.min.js',
          global: 'ReactDOM',
        },
      ],
    }),
  ],
};

效果对比如下:

优化前打包时间约为 2s:

优化后打包时间不到 1s:

多进程构建

对于耗时较长的模块,同时开启多个 nodejs 进程进行构建,可以有效地提升打包的速度。可以采取的一些方式有:

  • thread-loader
  • HappyPack(作者已经不维护)
  • parallel-webpack
    下面以官方提供的 thread-loader 为例,执行以下命令安装 thread-loader:
perl
yarn add thread-loader -D

在 webpack.config.js 中添加如下配置:

js
module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        include: path.resolve('src'),
        use: [
          'thread-loader',
          // 耗时的 loader (例如 babel-loader)
        ],
      },
    ],
  },
};

使用时,需将此 loader 放置在其他 loader 之前,放置在此 loader 之后的 loader 会在一个独立的 worker 池中运行。

每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。所以请仅在耗时的操作中使用此 loader!(一般只在大型项目中的 ts、js 文件使用)

并行压缩

一些插件内置了 parallel 参数(如 terser-webpack-plugin, css-minimizer-webpack-plugin, html-minimizer-webpack-plugin),开启后可以进行并行压缩。

webpack5 版本内置了 terser-webpack-plugin 的配置,如果是 v4 或者更低版本,执行以下命令安装 terser-webpack-plugin :

perl
yarn add terser-webpack-plugin -D

在 webpack.config.js 进行如下配置:

js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};

预编译资源模块

通过预编译资源模块,可以代替 cdn 分包的方式,解决每个模块都得引用一个 script 的缺陷。

还是以 react 和 react-dom 为例,新建一个 webpack.dll.js 文件,用于预编译资源的打包,例如要对 react 和 react-dom 进行预编译,配置如下:

js
const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    library: ['react', 'react-dom'],
  },
  output: {
    filename: 'react-library.dll.js',
    path: path.resolve(__dirname, './dll'),
    library: '[name]_[hash]', // 对应的包映射名
  },
  plugins: [
    new webpack.DllPlugin({
      context: __dirname,
      name: '[name]_[hash]', // 引用的包映射名
      path: path.join(__dirname, './dll/react-library.json'),
    }),
  ],
};

在 package.json 中新增一条如下命令:

json
{
  // ...
  "scripts": {
    // ...
    "build:dll": "webpack --config ./webpack.dll.js"
  }
  // ...
}

执行 npm run build:dll 后,会在 /build/library 目录下生成如下内容,library.js 中打包了 react 和 react-dom 的内容,library.json 中添加了对它的引用:

然后在 webpack.config.js 中新增如下内容:

js
const webpack = require('webpack');
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');

module.exports = {
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('./dll/react-library.json'),
    }),
    // 打包后的 .dll.js 文件需要引入到 html中,可以通过 add-asset-html-webpack-plugin 插件自动引入
    new AddAssetHtmlPlugin({
      filepath: require.resolve('./dll/react-library.dll.js'),
      publicPath: '',
    }),
  ],
};

效果对比如下:

使用 dll 预编译资源之前,打包效果如下,总打包耗时 1964ms,且需要打包 react:

使用 dll 预编译资源之后,打包效果如下,总打包耗时 1148ms,不需要打包 react:

使用缓存

通过使用缓存,能够有效提升打包速度。缓存主要有以下几种方案:

  • 使用 webpack5 内置的 cache 模块
  • cache-loader(webpack5 内置了 cache 模块后可弃用 cache-loader)

内置的 cache 模块

webpack5 内置了 cache 模块,缓存生成的 webpack 模块和 chunk,来改善构建速度。它在开发环境下会默认设置为 type: 'memory' 而在生产环境中被禁用。cache: { type: 'memory' } 与 cache: true 作用一样,可以通过设置 cache: { type: 'filesystem' } 来开放更多配置项。

例如在 webpack.config.js 中作如下配置:

js
module.exports = {
  cache: {
    type: 'filesystem',
  },
};

会在 node_modules 目录下生成一个 .cache 目录缓存文件内容,且二次打包速度显著提升:

cache-loader

在一些性能开销较大的 loader 之前添加 cache-loader,能将结果缓存到磁盘里。保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用。

执行如下命令安装 cache-loader:

perl
npm install cache-loader -D

在 webpack.config.js 对应的开销大的 loader 前加上 cache-loader:

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['cache-loader', 'babel-loader'],
      },
    ],
  },
};

同样会在 node_modules 目录下生成一个 .cache 目录缓存文件内容,且二次打包速度显著提升:

缩小构建范围

通过合理配置 rules 中的文件查找范围,可以减少打包的范围,从而提升打包速度。

在 webpack.config.js 中新增如下配置:

js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: /node_modules/,
      },
    ],
  },
};

效果对比如下:

配置前,编译总耗时 1867ms:

配置后,编译总耗时 1227ms:

加快文件查找速度

通过合理配置 webpack 的 resolve 模块,可以加快文件的查找速度,例如可以对如下的选项进行配置:

  • resolve.modules 减少模块搜索层级,指定当前 node_modules,慎用。
  • resovle.mainFields 指定包的入口文件。
  • resolve.extension 对于没有指定后缀的引用,指定解析的文件后缀查找顺序
  • 合理使用 alias,指定第三方依赖的查找路径
    对 webpack.config.js 作如下配置:
js
module.exports = {
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
    },
    modules: [path.resolve(__dirname, './node_modules')],
    extensions: ['.js', '.jsx', '.json'],
    mainFields: ['main'],
  },
};

打包体积优化

体积分析

同速度优化一样,我们要对体积进行优化,也需要了解打包时各个模块的体积大小。这里借助 webpack-bundle-analyzer 插件,它可以分析打包的总体积、各个组件的体积以及引入的第三方依赖的体积。

执行如下命令安装 webpack-bundle-analyzer:

perl
yarn add webpack-bundle-analyzer -D

在 webpack.config.js 中添加如下配置:

js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  // ...
  plugins: [new BundleAnalyzerPlugin()],
};

然后执行 webpack 打包命令,会在 localhost:8888 页面看到打包后的体积分析:

提取公共模块

假如我们现在有一个 MPA(多页面应用) 的 react 项目,每个页面的入口文件及其依赖的组件中都会引入一份 react 和 react-dom ,那最终打包后的每个页面中同样也会有一份以上两个包的代码。我们可以将这两个包单独抽离出来,最终在每个打包后的页面入口文件中引入,从而减少打包后的总体积。

在 webpack.config.js 中添加如下配置:

js
module.exports = {
  optimization: {
    splitChunks: {
      minSize: 20000,
      cacheGroups: {
        react: {
          test: /(react|react-dom)/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
};

效果对比:

优化前总体积 473 kb:

优化后总体积 296 kb:

压缩代码

html 压缩

安装 html-webpack-plugin 插件,生产环境下默认会开启 html 压缩:

text
npm install html-webpack-plugin

webpack.config.js 做如下配置:

js
module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, '../', 'public/index.html'),
    }),
  ],
};

css 压缩

css-minimizer-webpack-plugin 插件可以压缩 css 文件代码,但由于压缩的是 css 代码,所以还需要依赖 mini-css-extract-plugin 将 css 代码单独抽离:

js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
      chunkFilename: '[id].css',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
  optimization: {
    minimizer: [
      // webpack5 可以使用 '...' 访问 minimizer 数组的默认值
      '...',
      new CssMinimizerPlugin(),
    ],
  },
};

js 压缩

生产环境下会默认开启 js 的压缩,无需单独配置。

图片压缩

使用 image-minimizer-webpack-plugin 配合 imagemin 可以在打包时实现图片的压缩。

执行如下命令安装 image-minimizer-webpack-plugin 配合 imagemin :

text
npm install image-minimizer-webpack-plugin imagemin imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo --save-dev

在 webpack.config.js 中新增如下配置:

js
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
const { extendDefaultPlugins } = require('svgo');

module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpe?g|png|gif|svg)$/i,
        type: 'asset',
      },
    ],
  },
  optimization: {
    minimizer: [
      '...',
      new ImageMinimizerPlugin({
        minimizer: {
          implementation: ImageMinimizerPlugin.imageminMinify,
          options: {
            // Lossless optimization with custom option
            // Feel free to experiment with options for better result for you
            plugins: [
              ['gifsicle', { interlaced: true }],
              ['jpegtran', { progressive: true }],
              ['optipng', { optimizationLevel: 5 }],
              // Svgo configuration here https://github.com/svg/svgo#configuration
              [
                'svgo',
                {
                  plugins: extendDefaultPlugins([
                    {
                      name: 'removeViewBox',
                      active: false,
                    },
                    {
                      name: 'addAttributesToSVGElement',
                      params: {
                        attributes: [{ xmlns: 'http://www.w3.org/2000/svg' }],
                      },
                    },
                  ]),
                },
              ],
            ],
          },
        },
      }),
    ],
  },
};

效果对比如下:

压缩前图片打包后 1.1m:

压缩后 451kb:

移除无用的 css

通过 purgecss-webpack-plugin,可以识别没有用到的 class,将其从 css 文件中 treeShaking 掉,需要配合 mini-css-extract-plugin 一起使用。

执行如下命令安装 purgecss-webpack-plugin:

perl
npm install purgecss-webpack-plugin -D

在 webpack.config.js 文件中做如下配置:

js
const path = require('path');
const glob = require('glob');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const PurgecssPlugin = require('purgecss-webpack-plugin');

const PATHS = {
  src: path.join(__dirname, 'src'),
};

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
    new PurgecssPlugin({
      paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
    }),
  ],
};

在 css 文件中添加一段未用到的 css 代码:

css
div {
  font-size: 44px;
  display: flex;
}
// 此段为用到:
.unuse-css {
  font-size: 20px;
}

使用 purgecss-webpack-plugin 之前,打包结果如下:

使用 purgecss-webpack-plugin 之后,打包结果如下,无用代码已经移除:

polyfill service

我们在项目使用了 es6+ 语法时,往往需要引入 polyfill 去兼容不同浏览器。目前我们常采用的方案一般是 babel-polyfill 或者 babel-plugin-transform-runtime,然而在部分不同的浏览器上,它们一般都会与冗余,从而导致项目一些不必要的体积增大。

以下是几种常见 polyfill 方案的对比:

方案 优点 缺点
babel-polyfill 功能全面 体积太大超过 200kb,难以抽离
babel-plugin-transform-runtime 只 polyfill 用到的类或者方法,体积相对较小 不能 polyfill 原型上的方法,不适合复杂业务
团队维护自己的 polyfill 定制化高,体积小 维护成本太高
polyfill service 只返回需要的 polyfill,体积最小 部分奇葩浏览器的 UA 不识别,走优雅降级方案返回全部 polyfill

这里我们可以采用 polyfill service 方案,它能够识别 User Agent,下发不同的 polyfill,做到按需加载需要的 polyfill,从而优化我们项目的体积。

去 https://polyfill.io/ 查看最新的 polyfill service 的 url,例如目前是:

text
https://polyfill.io/v3/polyfill.min.js

直接在项目的 html 中通过 script 引入即可:

html
<script src="https://polyfill.io/v3/polyfill.min.js"></script>

File: docs/工程化/webpack/一文搞懂webpack的devtool及sourceMap.md


desc: '本文将介绍 SourceMap 的作用、简单工作原理以及 webpack 中不同 devtool 所构建的 SourceMap 有何区别,以及如何选择最合适的 devtool' cover: 'https://img2.baidu.com/it/u=4161682823,2906342423&fm=253&fmt=auto&app=138&f=PNG?w=500&h=333' tag: ['webpack'] time: '2021-05-07'

一文搞懂 SourceMap 以及 webpack devtool

本文将介绍 SourceMap 的作用、简单工作原理以及 webpack 中不同 devtool 所构建的 SourceMap 有何区别,以及如何选择最合适的 devtool,希望对大家有所帮助。

理解 SourceMap

SourceMap 作用

随着各种打包工具的星期,为了提高前端项目的性能和不同浏览器上的兼容性,我们线上环境的代码一般都要经过如下等处理:

  • 压缩混淆,减小体积
  • 多个文件合并,减少 HTTP 请求数
  • 将 es6+代码转换成浏览器能够识别的 es5 代码

经过如上的步骤之后,我们代码的性能和兼容性提高了,然后由于转换后的代码和源代码的不同,会导致我们的开发调试变得很困难,SourceMap 的诞生就是为了解决如上问题的。

简而言之,SourceMap 就是一个储存着代码位置信息的文件,转换后的代码的每一个位置,所对应的转换前的位置。有了它,点击浏览器的控制台报错信息时,可以直接显示出错源代码位置而不是转换后的代码。

用一个实例来加深理解,如下是一个简单的 index.js 文件,我们故意将最后一行 console.log('hello world') 错写成 console.logo('hello world'):

js
const a = 1;
const b = 2;
console.log(a + b);
console.logo('hello world');

无 SourceMap

我们将 webpack.config.js 的 devtool 选项配置为 'none',打包上述的 index.js 文件:

js
// ...
module.exports = {
  // ...
  mode: 'production',
  devtool: 'none',
  // ...
};

点击控制台出错代码如下,可以看到代码是压缩混淆之后的,我们难以追溯到出错的源代码:

有 SourceMap

将 webpack.config.js 的 devtool 选项配置由 'none' 改成 source-map 后,再次打包上面的 index.js 文件:

js
// ...
module.exports = {
  // ...
  mode: 'production',
  devtool: 'source-map',
  // ...
};

点击控制台的报错,可以看到显示的是源代码,我们能够很清晰的定位到错误的行号,并且光标直接停留在错误代码所在的列:

通过如上对比,我们可以轻松的理解 SourceMap 带来的好处。那么 SourceMap 在浏览器上到底是如何工作的呢?

SourceMap 工作原理

我们使用 webpack 打包并选择 devtool 为 source-map 后,每个打包后的 js 模块会有一个对应的.map 文件:

打包出来的 main.js.map文件中,就是一个标准的 SourceMap 内容格式:

json
{
  "version": 3,
  "sources": [
    "webpack:///webpack/bootstrap",
    "webpack:///./src/index.js",
    "webpack:///./src/add.js"
  ],
  "names": [
    // ...
    "p",
    "s",
    "console",
    "log",
    "a",
    "b",
    "add"
  ],
  "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",
  "file": "main.js",
  "sourcesContent": [
    " \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",
    "import { add } from './add';\nconst a = 1;\nconst b = 2;\nconsole.log(add(a, b));\nconsole.log('hello world');\n",
    "export const add = (a, b) => {\n  return a + b;\n};\n"
  ],
  "sourceRoot": ""
}

它包含以下内容:

  • version: SourceMap 的版本,如今最新版本为 3
  • sources: 源文件列表
  • names: 源文件中的变量名
  • mappings: 压缩混淆后的代码定位源代码的位置信息
  • file: 该 Source Map 对应文件的名称
  • sourcesContent: 源代码字符串列表,用于调试时展示源文件,列表每一项对应于 sources
  • sourceRoot: 源文件根目录,这个值会加在每个源文件之前

main.js 文件的内容如下,里面含有 //# sourceMappingURL=main.js.map 这段内容:

js
!(function (e) {
  var t = {};
  function n(r) {
    if (t[r]) return t[r].exports;
    var o = (t[r] = { i: r, l: !1, exports: {} });
    return e[r].call(o.exports, o, o.exports, n), (o.l = !0), o.exports;
  }
  (n.m = e),
    (n.c = t),
    (n.d = function (e, t, r) {
      n.o(e, t) || Object.defineProperty(e, t, { enumerable: !0, get: r });
    }),
    (n.r = function (e) {
      'undefined' != typeof Symbol &&
        Symbol.toStringTag &&
        Object.defineProperty(e, Symbol.toStringTag, { value: 'Module' }),
        Object.defineProperty(e, '__esModule', { value: !0 });
    }),
    (n.t = function (e, t) {
      if ((1 & t && (e = n(e)), 8 & t)) return e;
      if (4 & t && 'object' == typeof e && e && e.__esModule) return e;
      var r = Object.create(null);
      if (
        (n.r(r),
        Object.defineProperty(r, 'default', { enumerable: !0, value: e }),
        2 & t && 'string' != typeof e)
      )
        for (var o in e)
          n.d(
            r,
            o,
            function (t) {
              return e[t];
            }.bind(null, o)
          );
      return r;
    }),
    (n.n = function (e) {
      var t =
        e && e.__esModule
          ? function () {
              return e.default;
            }
          : function () {
              return e;
            };
      return n.d(t, 'a', t), t;
    }),
    (n.o = function (e, t) {
      return Object.prototype.hasOwnProperty.call(e, t);
    }),
    (n.p = ''),
    n((n.s = 0));
})([
  function (e, t) {
    console.log(3), console.log('hello world');
  },
]);
//# sourceMappingURL=main.js.map

浏览器在加载 main.js 时,通过 sourceMappingURL 加载对应的.map 文件,更加.map 文件的 SourceMap 内容中的 sources 字段,在浏览器的 Sources 中生成对应目录结构,之后再将 sourcesContent 中的内容对应填入上述生成的文件中,这样我们在调试时就可以将压缩混淆后的代码定位到对应的源代码位置。

如果选择 devtool 为inline-source-map,那么 sourceMappingURL 后面的内容则是以 base64 的形式内嵌的。

webpack devtool 选项

更加 webpack 官网的文档显示,devtool 的可配置选项一共将近 30 个:

devtool performance production quality
(none) build: fastest rebuild: fastest yes bundle
eval build: fast rebuild: fastest no generated
eval-cheap-source-map build: ok rebuild: fast no transformed
eval-cheap-module-source-map build: slow rebuild: fast no original lines
eval-source-map build: slowest rebuild: ok no original
cheap-source-map build: ok rebuild: slow no transformed
cheap-module-source-map build: slow rebuild: slow no original lines
source-map build: slowest rebuild: slowest yes original
inline-cheap-source-map build: ok rebuild: slow no transformed
inline-cheap-module-source-map build: slow rebuild: slow no original lines
inline-source-map build: slowest rebuild: slowest no original
eval-nosources-cheap-source-map build: ok rebuild: fast no transformed
eval-nosources-cheap-module-source-map build: slow rebuild: fast no original lines
eval-nosources-source-map build: slowest rebuild: ok no original
inline-nosources-cheap-source-map build: ok rebuild: slow no transformed
inline-nosources-cheap-module-source-map build: slow rebuild: slow no original lines
inline-nosources-source-map build: slowest rebuild: slowest no original
nosources-cheap-source-map build: ok rebuild: slow no transformed
nosources-cheap-module-source-map build: slow rebuild: slow no original lines
nosources-source-map build: slowest rebuild: slowest yes original
hidden-nosources-cheap-source-map build: ok rebuild: slow no transformed
hidden-nosources-cheap-module-source-map build: slow rebuild: slow no original lines
hidden-nosources-source-map build: slowest rebuild: slowest yes original
hidden-cheap-source-map build: ok rebuild: slow no transformed
hidden-cheap-module-source-map build: slow rebuild: slow no original lines
hidden-source-map build: slowest rebuild: slowest yes original

表格中我们配置不同的 devtool 选项,主要是为了达到不同的 quality 和 performance 目的

quality 的理解

quality 描述了打包后我们在调试时能看到的源码内容:

  • bundled: 模块未分离
  • generated: 模块分离,未经 loader 处理的代码
  • transformed: 模块分离,经 loader 处理过的代码
  • original: 自己写的代码,定位精确到行、列
  • original lines: 自己写的代码,定位只精确到行

devtool 格式

devtool 的名称格式可以总结为,我们只需要记住以下每个选项的特点,就可以轻易理解所有的 devtool 选项了:

text
[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

inline-

将 SourceMap 内联到原始文件中,而不是创建一个单独的文件。

devtool 为 source-map 的情况下打包后,有一个.map 文件存储代码的映射关系:

devtool 为 inline-source-map 的情况下,映射关系会一同写到编译后的代码中:

hidden-

hidden 仍然会生成.map 文件,但是打包后的代码中没有 sourceMappingURL,也就是说请求代码时浏览器不会加载.map 文件,控制台中看不到源代码。这种一般用于错误收集等场景,出错时前端把出错的行列传给服务端,服务端根据行列以及.map 文件解析出出错的源码位置。

devtool 为 source-map 时,显示出错源代码位置:

devtool 为 hidden-source-map 时,只显示打包后的代码出错位置:

eval-

eval- 会通过 eval 包裹每个模块打包后代码以及对应生成的 SourceMap,因为 eval 中为字符串形式,所以当源码变动的时候进行字符串处理会提升 rebuild 的速度。

但同样因为是 eval 包裹 js 代码,很容易被 XSS 攻击,存在很大的安全隐患。

另外,在现代浏览器中有两种编译模式:fast path 和 slow path。fast path 是编译那些稳定和可预测(stable and predictable)的代码。而明显的,eval 不可预测,所以将会使用 slow path。在旧的浏览器中,使用 eval 的性能会大幅下降。

综上,eval 我们一般只用于开发环境,不会用于打包线上环境的代码。

nosources-

使用这个关键字生成的 SourceMap 中不包含 sourcesContent 内容,因此调试时只能看到文件信息和行信息,无法看到源码。

cheap-[module-]

使用 cheap 时,SourceMap 的代码定位只会定位到源码所在的行,不会定位至具体的列,所以构建速度有所提升。另外如果只用 cheap ,显示的是 loader 编译之后的源代码,加上 module 后会显示编译之前的源代码。

例如有如下代码:

js
import { add } from './add';
const a = 1;
const b = 2;
console.log(add(a, b));
console.logo(111);
console.log('hello world');

使用 source-map 打包的结果,点击控制台的报错信息,可以看到直接定位到 loader 编译前的源代码,并且光标会定位到出错代码所在的列:

使用 cheap-source-map 打包,点击控制台的报错信息,是定位到了错误代码所在的行,但是光标并没有定位到错误代码所在的列。另外显示的源代码是经过了 loader 编译之后的代码,而不是原始的源代码:

使用 cheap-module-source-map 打包,点击控制台的报错信息,可以看到显示的是 loader 编译前的源代码:

如何选择 devtool

根据不同环境,我们需要选择不同的 devtool。

开发环境下,由于我们需要频繁的修改代码,更多的考虑的开发效率和调试效率,所以更多关注 performance 中 rebuild 的性能。

生产环境下,我们不必过多关注打包性能,主要考虑 quality 代码的保护性、出错的定位速度已经安全性等。

production

线上环境官方推荐的 devtool 有 4 种:

  • none
  • source-map
  • hidden-source-map
  • nosources-source-map
    线上环境没有绝对的最优选择一说,根据自己业务需要去选择即可,很多项目也是选择除上述 4 种之外的 cheap-module-source-map 选项。

development

开发环境选择就比较容易了,只需要考虑打包速度快、调试方便,官方推荐以下 4 种:

  • eval
  • eval-source-map
  • eval-cheap-source-map
  • eval-cheap-module-source-map
    大多数情况下我们选择 eval-cheap-module-source-map 即可。

File: docs/开发场景/多人协作编辑.md

[Binary file]