vite如何实现react组件级热更新,看看@vitejs/plugin-react怎么做

2,885 阅读31分钟

本文一共分为五个部分带大家了解@vitejs/plugin-react原理,涉及babel插件编写,Vite插件的编写,@vitejs/plugin-react实现,react-refresh库源码解析,React devtools接口在react-refresh中的注入,Vite HMR Api 的使用,并且分析React devtools的接口是如何完成更新的,深入React内部了解整个React更新过程,最后我会手把手带大家写一个@vitejs/plugin-react插件,真正实现React组件级别热更新,本文内容较长,请耐心阅读。

- 第一部分:体验基本的Vite HMR Api

- 第二部分:@vitejs/plugin-react更新规则

- 第三部分:插件源码分析

- 第四部分:自己实现@vitejs/plugin-react(包括babel插件编写部分)

- 第五部分:React devtools Api内部分析

今天在学习Vite HMR原理的时候发现Vite内部只是实现了HMRApi,但是我们知道在真实的项目当中我们是不会真正去使用import.meta.hot.accept之类的Api的,抱着探究的心态我阅读了整个插件的源码实现,相当的perfect让我们一起来看看这个插件做了什么吧!

通过执行npm init vite@latest选择建立React项目 可以快速搭建一个自带组件级热更新的React项目。

执行npm installnpm run dev就可以进行自带组件级热更新的react+ts项目了。

1.Vite自带HMR Api体验

  • 首先执行npm i vite react react-dom
  • 创建App.jsx和index.html文件
//App.jsx
import React from "react";
import ReactDOM from "react-dom/client";

//引用Vite HMR api
import.meta.hot.accept(function (module) {
  module[0].render();
});

function App() {
  return <div>测试Vite HMR Api</div>;
}

const root = ReactDOM.createRoot(document.getElementById("root"));

export default function render() {
  root.render(<App></App>);
}

render();

//index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite HMR Api</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="App.jsx"></script>
  </body>
</html>

此时我们修改App.jsx文件并且保存就能够看到页面发生改变(并没有刷新网页)

当然我们还可以通过监听其他模块,来得到最新的改变的模块这不是本文研究的重点,我们不再深入,感兴趣的童鞋可以看看关于Vite HMR Api的介绍。

2.React组件级热更新规则探究

  • 我们可以发现目前实现的Api是不能够在保存文件之后,映射效果到对应的网页上面的,那么这是如何实现的呢? 让我们引入@vitejs/plugin-react插件
//vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
});

引入之后就不需要在自己写import.meta.hot.accept代码,他可以自动完成React组件热更新。新建main.jsx文件

//main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App></App>);

//App.jsx
import React from "react";

function App() {
  return <div>测试Vite HMR Api</div>;
}

export default App;

让我们来看看效果吧!

即使没有写热更新代码也能够完成React热更新。 好啦!我们来看看插件更新规则。

这里是使用@vitejs/plugin-react更新规则,我们后面来说为什么是这样的。

  • 对于@vitejs/plugin-react实现的热更新来说,要想保存状态的更新(也就是说在执行保存操作之后,状态依旧存在,可以参照下面的效果)只能是函数组件,并且hooks顺序不可改变,不可添加,不可删除。以上操作都会导致热更新之后状态丢失。
  • 对于类组件来说,状态不可以保存,只能对类组件进行重新挂载。
  • 如果函数组件中包含了类组件,那么更新的基础点是重函数组件开始的,也就是说函数组件会重新调用并且获得新的Virtual DOM元素,然后向下比较在进行更新。
  • 如果类组件向上寻找,找不到函数组件,也就是说根组件就是类组件,那么就只能刷新网页了。
  • 综上所诉,更新的基准点是函数组件,且保存状态的更新要满足第一条的条件,类组件不可保存状态更新。

(1).对于可以保持状态的函数组件热更新:

新建文件Button.jsx添加如下代码

import React, { useState } from "react";
import ButtonClass from "./ButtonClass";

export default function Button() {
  //这里是第一个hooks
  const [number, setNumber] = useState(0);

  //即将添加的hook 添加后状态将会不可保存 现在以注释的形式存在
  //当我们取消注释之后保存文件就会导致添加了hooks那么就不可能
  //进行带有状态的热更新
  //   const [text, setText] = useState("hello");

  function checkClick() {
    setNumber(number + 1);
  }
  return (
    <div>
      <button onClick={checkClick}>点我+1</button>
      <div>我是number:{number}</div>
      <div>hello Button</div>
    </div>
  );
}

我们可以先点击"点我+1"产生状态,然后修改Virtual DOM的结构在保存文件,发现更新后的网页样式状态没有发生变化,然后我们再添加Hook,这个时候出现了Hook的添加,在进行保存那么这个时候之前产生的状态失效了。

(2).当根组件是类组件的时候:

引入ButtonClass.jsx文件,添加如下代码,并且将其作为根元素渲染:

//ButtonClass.jsx
import React, { useState } from "react";
//这里是类组件(不可保存状态更新)
export default class ButtonClass extends React.Component {
  constructor(props) {
    super(props);
    this.state = { number: 0 };
  }
  checkClick = () => {
    this.setState({ number: this.state.number + 1 });
  };
  render() {
    return (
      <div>
        <button onClick={this.checkClick}>点我+1</button>
        <div>我是number:{this.state.number}</div>
        <div>hello ButtonClass</div>
      </div>
    );
  }
}

//main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import ButtonClass from "./ButtonClass";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<ButtonClass></ButtonClass>);

我们可以发现浏览器上方的图标发生了旋转,这表示浏览器进行了刷新。

(3).当把类组件放到函数组件之下:

修改Button.jsx代码如下

import React, { useState } from "react";
import ButtonClass from "./ButtonClass";

export default function Button() {
  const [number, setNumber] = useState(0);

  function checkClick() {
    setNumber(number + 1);
  }
  return (
    <div>
      <ButtonClass></ButtonClass>
    </div>
  );
}
那么我们可以发现,当类组件放在函数组件之下的时候不会刷新页面,而是找到函数组件,然后以函数组件为基准点进行刷新,当然类组件暂时是不能够支持状态保存热更新的。

3.@vitejs/plugin-react源码探究

(1).分析"$RefreshReg$"和"$RefreshSig$"函数

首先我们来看看我们写的Button.jsx与编译后的结果吧

//Button.jsx
import React, { useState } from "react";
import ButtonClass from "./ButtonClass";

export default function Button() {
const [number, setNumber] = useState(0);
const [text, setText] = useState("hello");
function checkClick() {
  setNumber(number + 1);
}
return (
  <div>
    <ButtonClass></ButtonClass>
  </div>
);
}

//编译后的Button.jsx
//这里是vite内部为了实现import.meta.hot的Api自己注入的客户端内容
import {createHotContext as __vite__createHotContext} from "/@vite/client";
//同上
import.meta.hot = __vite__createHotContext("/Button.jsx");
//这里是引入的React-refresh库 但是利用Vite插件做了一层封装
import RefreshRuntime from "/@react-refresh";
let prevRefreshReg;
let prevRefreshSig;
if (import.meta.hot) {
  //__vite_plugin_react_preamble_installed__这个变量是在index.html中注入的
  if (!window.__vite_plugin_react_preamble_installed__) {
      throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong.  See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201");
  }
  prevRefreshReg = window.$RefreshReg$;//目前这两个函数都还没有赋值
  prevRefreshSig = window.$RefreshSig$;
  //注册可能需要热更新的组件,第二个参数是文件的根路径+id id就是组件名称
  window.$RefreshReg$ = (type,id)=>{
      RefreshRuntime.register(type, "E:/\u5927\u4E8C\u5B66\u4E60\u6587\u4EF6/23.vite-article/Button.jsx " + id);
  }
  ;
  //这是React-refresh内部暴露的函数
  window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
}
_s = $RefreshSig$();//初始化
import __vite__cjsImport2_react from "/node_modules/.vite/deps/react.js?v=c1c5dc5c";//引入react.js
const React = __vite__cjsImport2_react.__esModule ? __vite__cjsImport2_react.default : __vite__cjsImport2_react;
const useState = __vite__cjsImport2_react["useState"];//引入React Api
import ButtonClass from "/ButtonClass.jsx?t=1663508443851";
import {jsxDEV as _jsxDEV} from "/@id/__x00__react/jsx-dev-runtime";//注入React运行时
//对于符合某些规则的函数内部调用_s函数也就是$RefreshSig$()的返回值(也是一个函数)
export default function Button() {
  _s();
  const [number,setNumber] = useState(0);
  const [text,setText] = useState("hello");
  function checkClick() {
      setNumber(number + 1);
  }
  return _jsxDEV("div", {
      children: 
      _jsxDEV(ButtonClass, {}, void 0, false, {
          fileName: _jsxFileName,
          lineNumber: 18,
          columnNumber: 7
      }, this)
  }, void 0, false, {
      fileName: _jsxFileName,
      lineNumber: 17,
      columnNumber: 5
  }, this);
}
//第二次调用传入函数组件以及一串hook的唯一值
_s(Button, "I6w2SOjEQeUBBKsF9Ly0PU7UOdA=");
_c = Button;
var _c;
$RefreshReg$(_c, "Button");//注册可能需要热更新的组件
if (import.meta.hot) {
  window.$RefreshReg$ = prevRefreshReg;
  window.$RefreshSig$ = prevRefreshSig;
  //接受自身模块更新
  import.meta.hot.accept();
  //30ms之后进行热更新(前面对要进行热更新模块进行了收集)
  if (!window.__vite_plugin_react_timeout) {
      window.__vite_plugin_react_timeout = setTimeout(()=>{
          window.__vite_plugin_react_timeout = 0;
          RefreshRuntime.performReactRefresh();
      }
      , 30);
  }
}

我们来解释一下编译后的文件做了什么吧!(下面的_s函数都代表$RefreshSig$函数的返回值)

  • 我们可以发现对于内部的Button调用了_s函数($RefreshSig$)全称是RefreshSignatrue(热更新签名),实际上这个函数是第二次调用用于收集与当前函数有关的所有fullkey(后面分析),在这个函数外部也会调用一次_s(),这一次会传入参数,例如本例中的_s(Button, "I6w2SOjEQeUBBKsF9Ly0PU7UOdA=")其中第二个参数这一串值就是通过hooks生成的,如果你改变了当前的hook顺序,新增,减少都会导致这个值发生改变你可能会问我怎么知道你的hooks顺序是否发生改变了呢?实际上内部使用了babel分析来判断你是否做了这样的操作。
  • 但是不是所有的函数都会调用_s这个函数,实际上只有当这个函数名称是大写且返回值开头是_jsxDev的时候,也就是这是一个React函数组件的时候(因为不是React函数组件根本就不需要分析hooks)或者这是一个自定义hooks例如usePosition的时候才需要注入。
  • 可能你还有一个问题,如果我在内部使用了customHooks怎么办呢? 所以如果你写出了useXXX的自定义钩子就需要对这个钩子再次注入_s函数,并且通过babel分析生成关于这个钩子的一串唯一值。
  • 对于函数($RefreshReg$)全称是RefreshRegister(热更新注册)这个函数就是用于注册热更新函数的。

下面我们来看看他们的源码吧 来自

node_modules/react-refresh/cjs/react-refresh-runtime.development.js

下面是$RefreshReg$源码:

//$RefreshReg$=>register
function register(type, id) {
  {
    if (type === null) {
      return;
    }
    if (typeof type !== 'function' && typeof type !== 'object') {
      return;
    } 
    //已经注册过了就不注册了
    if (allFamiliesByType.has(type)) {
      return;
    } 
    var family = allFamiliesByID.get(id);/获取family
    //如果是第一次注册(例如第一次刷新页面)将id,family做成映射表
    if (family === undefined) {
      //上次的type,这里使用对象是因为如果直接用family = type 在其他地方修改family不会引起
      //存储family地方的改变,但是family为对象的话就是引用地址类型,其他地方发生改变,family.cuurent
      //也会发生改变,便于保存、修改上次的type
      family = {
        current: type
      };
      allFamiliesByID.set(id, family);
    } else {
      //如果保存了文件就会进入这里将第一次保存的family和type包装成数组放入pendingUpdates中
      //我们可以发现family装的就是上次的type函数,所以pendingUpdates中保存的是一个个数组,数组
      //中装的是上次可能进行的热更新函数组件以及本次的可能的热更新组件,注意他们不可能是同一
      //个函数保存后会重新请求文件,获取的是一个全新的函数,便于在热更新的时候使用
      pendingUpdates.push([family, type]);
    }
    //将现在的更新函数和之前的更新函数做映射
    allFamiliesByType.set(type, family);  
    //对memo和forward的特殊处理(非主要逻辑)
    if (typeof type === 'object' && type !== null) {
      switch (getProperty(type, '$$typeof')) {
        case REACT_FORWARD_REF_TYPE:
          register(type.render, id + '$render');
          break;

        case REACT_MEMO_TYPE:
          register(type.type, id + '$type');
          break;
      }
    }
  }
}
  • 这里出现了几个新的变量"allFamiliesByID","pendingUpdates","allFamiliesByType"
  • allFamiliesByID = new Map() id=>family的映射 其中id代表文件根路径+可能进行热更新的组件函数名,例如:根路径为:"E:/xxx/src/App.jsx",我在这个文件内部写了App组件 那么id就是"App"形成的id就是"E:/xxx/src/App.jsx App" 这样的id可以表示所有文件中的不同函数组件,所以也只需要设置一次。用于通过id获取family(上次缓存的type)
  • allFamiliesByType = new PossiblyWeakMap() 这是一个weakMap或者map结构,存储后可通过本次的type获取之前的type。
  • pendingUpdates = new Array() 存放本次更新所有可能的[family,type],用于在调用真正热更新的时候使用,如果length为0表示不需要更新。
  • 总结一下:这个函数就是为了利用id,type等构建缓存,并且收集pendingUpdates便于后续使用,这几个都是这个包中的全局变量,并且只有非第一次执行(保存文件)这个函数的时候才会添加到pendingUpdates中第一次只做了缓存到allFamiliesByType,allFamiliesByID中。

下面是$RefreshSig$源码:

//$RefreshSig$=>createSignatureFunctionForTransform
function createSignatureFunctionForTransform() {
  {
    var savedType;//保存的type
    var hasCustomHooks;//是否有自定义hooks
    var didCollectHooks = false;//是否已经收集过自定义hooks
    return function (type, key, forceReset, getCustomHooks) {
      if (typeof key === 'string') {
        if (!savedType) {
          savedType = type;
          hasCustomHooks = typeof getCustomHooks === 'function';
        } 
        if (type != null && (typeof type === 'function' || typeof type === 'object')) {
          //设置函数签名
          setSignature(type, key, forceReset, getCustomHooks);
        }
        return type;
      } 
      //当函数组件内部调用_s的时候回调用此时没有参数传入_s()
      else {
        if (!didCollectHooks && hasCustomHooks) {
          didCollectHooks = true;
          collectCustomHooksForSignature(savedType);
        }
      }
    };
  }
}

//setSignature
function setSignature(type, key) {
  var forceReset = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
  var getCustomHooks = arguments.length > 3 ? arguments[3] : undefined;
    //一个type对应一个签名
    if (!allSignaturesByType.has(type)) {
      allSignaturesByType.set(type, {
        forceReset: forceReset,
        ownKey: key,//自己的key 也就是自己本身hooks顺序生成的key
        fullKey: null,//自己调用了自定义hooks 自定义hooks也有自己的key 对他们做加法运算生成的key
        //如果检测到了自定义hooks 会将使用了的自定义hooks包装成函数在编译阶段放进来
        //后面举例说明
        //function(){return [useScrollPosition,...其他自定义hooks]
        getCustomHooks: getCustomHooks || function () {
          return [];
        }
      });
      //特殊组件类型处理
    if (typeof type === 'object' && type !== null) {
      switch (getProperty(type, '$$typeof')) {
        case REACT_FORWARD_REF_TYPE:
          setSignature(type.render, key, forceReset, getCustomHooks);
          break;

        case REACT_MEMO_TYPE:
          setSignature(type.type, key, forceReset, getCustomHooks);
          break;
      }
    }
  }
}
  • 那么这个函数的功能就是缓存放入allSignaturesByType中,并且一个type对应一个签名,type可以是自定义hooks函数,函数组件。
  • 签名的类型是
 {
      forceReset: forceReset,
      ownKey: key,
      fullKey: null,
      getCustomHooks: getCustomHooks || function () {
        return [];
      }
  }

那我们知道了对于单个函数组件(内部没有使用自定义hooks)如何存放签名,那么写了自定义hooks的函数呢? 我们来看这一段代码:

function useScrollPosition() {
  const [position, setPosition] = useState(0);
  const handle = () => {
    setPosition(window.scrollY);
  };
  useEffect(() => {
    document.addEventListener("scroll", handle);
  });
  return position;
}
export default function Button() {
  const [number, setNumber] = useState(0);
  const position = useScrollPosition();
  const [text, setText] = useState("hello");

  function checkClick() {
    setNumber(number + 1);
  }
  return (
    <div>
      <ButtonClass></ButtonClass>
    </div>
  );
}

//编译后

//省略部分逻辑...
_s = $RefreshSig$()//初始化,创建RefreshSig函数
_s2 = $RefreshSig$();//初始化,创建RefreshSig函数

function useScrollPosition() {
    _s();
    const [position,setPosition] = useState(0);
    const handle = ()=>{
        setPosition(window.scrollY);
    }
    ;
    useEffect(()=>{
        document.addEventListener("scroll", handle);
    }
    );
    return position;
}
//这里使用了自定义hooks生成了唯一ownKey
_s(useScrollPosition, "gVejKcPeA2/cG0XaTceq7U6lhYU=");
export default function Button() {
    _s2();
    const [number,setNumber] = useState(0);
    const position = useScrollPosition();
    console.log(position);
    const [text,setText] = useState("hello");
    function checkClick() {
        setNumber(number + 1);
    }
    return /* @__PURE__ */
    _jsxDEV("div", {
        children: /* @__PURE__ */
        _jsxDEV(ButtonClass, {}, void 0, false, {
            fileName: _jsxFileName,
            lineNumber: 32,
            columnNumber: 7
        }, this)
    }, void 0, false, {
        fileName: _jsxFileName,
        lineNumber: 31,
        columnNumber: 5
    }, this);
}
//Button是一个函数组件内部使用了自定义hooks将使用的自定义hooks编译成函数并包装成数组返回
_s2(Button, "JsE+byd3LPGYijE+XuQgiSjmqs4=", false, function() {
    return [useScrollPosition];
});
//省略部分逻辑...

  • 请大家仔细阅读这部分代码,我们可以发现如果组件中使用了自定义hooks那么_s的调用会被编译成下面这样,并且你使用了几个自定义钩子那么第四个参数的函数的返回值就会有几个自定义hooks,而自己的key就是自己hooks生成的key。useScrollPosition内部没有使用自定义hooks所以不需要生成后面两个参数,例如下面_s的调用
_s(useScrollPosition, "gVejKcPeA2/cG0XaTceq7U6lhYU=");
_s2(Button, "JsE+byd3LPGYijE+XuQgiSjmqs4=", false, function() { return [useScrollPosition]; });
  • 好的,让我们回到之前的createSignatureFunctionForTransform函数的else部分也就是:
//createSignatureFunctionForTransform
if (!didCollectHooks && hasCustomHooks) {
      didCollectHooks = true;
      collectCustomHooksForSignature(savedType);
}

//collectCustomHooksForSignature 收集与当前函数相关的所有type的fullkey
function collectCustomHooksForSignature(type) {
  {
    var signature = allSignaturesByType.get(type);

    if (signature !== undefined) {
      computeFullKey(signature);
    }
  }
}

//computeFullKey 计算fullkey的方式
function computeFullKey(signature) {
  //当再次调用computeFullKey函数的时候会返回现在计算出的值
  if (signature.fullKey !== null) {
    return signature.fullKey;
  }
  var fullKey = signature.ownKey;
  var hooks;
  try {
    //获取当前函数组件或自定义hooks依赖的hooks
    hooks = signature.getCustomHooks();
  } catch (err) {
    //发生错误强制重新挂载
    signature.forceReset = true;
    signature.fullKey = fullKey;
    return fullKey;
  }
  for (var i = 0; i < hooks.length; i++) {
    var hook = hooks[i];
    if (typeof hook !== 'function') {
      signature.forceReset = true;
      signature.fullKey = fullKey;
      return fullKey;
    }
    //重之前缓存的allSignaturesByType中获取这个依赖hook的签名
    var nestedHookSignature = allSignaturesByType.get(hook);
    if (nestedHookSignature === undefined) {
      continue;
    }
    //再次计算依赖hook的fullkey
    var nestedHookKey = computeFullKey(nestedHookSignature);
    if (nestedHookSignature.forceReset) {
      signature.forceReset = true;
    }
    //计算出来的fullkey与现在这个hook的ownkey相加
    fullKey += '\n---\n' + nestedHookKey;
  }
  signature.fullKey = fullKey;//最后给签名赋值
  return fullKey;
}

如果说你注册了自定义hooks那么在函数内部调用_s()的时候(注意有参数的一定先执行,然后是无参数的执行)就会进入这里的逻辑(并且这里的逻辑只需要执行一次,计算fullkey过程只需要执行一次,如果下一次文件的改变发来新文件才会再次执行),我们可以发现函数内部执行_s()的时候就会调用collectCustomHooksForSignature函数,他会去计算出所有相关hook的fullkey并缓存,计算方式其实就是递归计算所有依赖hook的ownkey相加。例如a依赖b,b依赖c 他们的ownkey分别是"1","2","3"那么对于c来说fullkey就是"3",对于b来说fullkey就是"23",a的fullkey就是"123"。这样就能处理自定义hooks一层套一层的情况,只要你改变了任何一个依赖hooks的顺序改变都会导致fullkey发生改变。

好的,到这里我们就解释清楚了如何判断hooks顺序发生改变,当发生改变fullkey必然发生变化我们只需要对比fullkey就可以了。引出两个问题:

  • 为什么一定要保证hooks顺序,hooks顺序打乱就不能进行保持状态的热更新?
  • 为什么类组件暂时不能实现保持状态的热更新?

(2).performReactRefresh函数解析

我们带着问题继续分析,通过热更新签名热更新注册函数之后,来到了

if (!window.__vite_plugin_react_timeout) {
   //30ms之后执行performReactRefresh
   window.__vite_plugin_react_timeout = setTimeout(()=>{
   window.__vite_plugin_react_timeout = 0;
   RefreshRuntime.performReactRefresh();
  }, 30);
}

为了保证热更新pendingUpdates能够完整收集,延迟30ms在执行这个函数。让我们来看看如何执行热更新的吧!这段函数比较长请大家耐心品味

//performReactRefresh
function performReactRefresh() {
  //如果没有收集到不进行热更新
  if (pendingUpdates.length === 0) {
    return null;
  }
  //判断当前是否正在进行热更新
  if (isPerformingRefresh) {
    return null;
  }
  isPerformingRefresh = true;//互斥设置
  try {
    var staleFamilies = new Set();//需要重新挂载的type
    var updatedFamilies = new Set();//需要重新渲染的type
    var updates = pendingUpdates;
    pendingUpdates = [];//格式化
    updates.forEach(function (_ref) {
      var family = _ref[0],
          nextType = _ref[1];//拿到现在的type
      var prevType = family.current;//拿到之前的type
      //之前的type和现在的type都指向family在修改family为最新的type
      //这里是为了在react内部可以通过上次的type拿到现在的type用于更新
      updatedFamiliesByType.set(prevType, family);
      updatedFamiliesByType.set(nextType, family);
      family.current = nextType; 
      //判断是那种类型的更新放入不同的set中
      if (canPreserveStateBetween(prevType, nextType)) {
        updatedFamilies.add(family);
      } else {
        staleFamilies.add(family);
      }
    });
    //将两个set包装成一个update
    var update = {
      updatedFamilies: updatedFamilies,
      staleFamilies: staleFamilies 
    };
    //通过React devtools自带的api接口放入处理函数
    helpersByRendererID.forEach(function (helpers) {
      helpers.setRefreshHandler(resolveFamily);
    });
    var didError = false;
    var firstError = null; 
    var failedRootsSnapshot = cloneSet(failedRoots);//失效的Root节点
    var mountedRootsSnapshot = cloneSet(mountedRoots);//挂载的Root节点
    var helpersByRootSnapshot = cloneMap(helpersByRoot);//一个root对应一个helpers
    //不关注这部分逻辑,感兴趣的读者可以自己了解
    failedRootsSnapshot.forEach();
    //对于挂载的节点调用api,例如root节点
    mountedRootsSnapshot.forEach(function (root) {
      var helpers = helpersByRootSnapshot.get(root);
      if (helpers === undefined) {
        throw new Error('Could not find helpers for a root. This is a bug in React Refresh.');
      }
      if (!mountedRoots.has(root)) {// No longer mounted.
      }
      try {
        //传入包装的update和根结点root让react自己完成热更新
        helpers.scheduleRefresh(root, update);
      } catch (err) {
        //错误处理
        if (!didError) {
          didError = true;
          firstError = err;
        } 

      }
    });
    if (didError) {
      throw firstError;
    }
    return update;
  } finally {
    isPerformingRefresh = false;//完成后结束互斥影响
  }
}

//canPreserveStateBetween 判断放入那种类型的更新集合
function canPreserveStateBetween(prevType, nextType) {
  //不能是class组件
  if (isReactClass(prevType) || isReactClass(nextType)) {
    return false;
  }
  //签名不能相同(hooks顺序没有改变,新增,减少)
  if (haveEqualSignatures(prevType, nextType)) {
    return true;
  }
  return false;
}

好了,到了这里编译结果的所有新增函数我们都讲解完毕,但是我们可以发现在最后的热更新函数中出现了helpers,这是什么呢? 这个就是React devtools的api接口,react-refresh就是利用的这个接口实现的热更新,我们来看看helpers来自于哪里,以及具体有那些api。

(3).如何注入React Devtools Api

让我们看看index.html的编译结果吧!

<!DOCTYPE html>
<html lang="en">
  <head>
    <script type="module">
     import RefreshRuntime from "/@react-refresh"
     //注入react devtools api
     RefreshRuntime.injectIntoGlobalHook(window)
     window.$RefreshReg$ = () => {}
     window.$RefreshSig$ = () => (type) => type
     //只有注入了devTools api才会设置为true否者热更新无法进行收集update没有意义
     window.__vite_plugin_react_preamble_installed__ = true
    </script>

    <script type="module" src="/@vite/client"></script>

    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite HMR Api</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="main.jsx"></script>
  </body>
</html>

//injectIntoGlobalHook
function injectIntoGlobalHook(globalObject) {
  {
    //__REACT_DEVTOOLS_GLOBAL_HOOK__就是react devtools api
    //但是现在还没有注入,因为我们在index.html开始就设置了变量
    //后面import React的时候才会执行react.js的内容完成
    //__REACT_DEVTOOLS_GLOBAL_HOOK__的注入
    var hook = globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__;
    if (hook === undefined) {
      var nextID = 0;
      //初始化__REACT_DEVTOOLS_GLOBAL_HOOK__
      globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook = {
        renderers: new Map(),
        supportsFiber: true,
        inject: function (injected) {
          return nextID++;
        },
        onScheduleFiberRoot: function (id, root, children) {},
        onCommitFiberRoot: function (id, root, maybePriorityLevel, didError) {},
        onCommitFiberUnmount: function () {}
      };
    }

    if (hook.isDisabled) {
      console['warn']('Something has shimmed the React DevTools global hook (__REACT_DEVTOOLS_GLOBAL_HOOK__). ' + 'Fast Refresh is not compatible with this shim and will be disabled.');
      return;
    }
    var oldInject = hook.inject;//缓存老的inject函数
    //重写inject
    hook.inject = function (injected) {
      var id = oldInject.apply(this, arguments);
      if (typeof injected.scheduleRefresh === 'function' && typeof injected.setRefreshHandler === 'function') {
        helpersByRendererID.set(id, injected);
      }
      return id;
    };
    hook.renderers.forEach(function (injected, id) {
      if (typeof injected.scheduleRefresh === 'function' && typeof injected.setRefreshHandler === 'function') {
        //注入react devtools api
        helpersByRendererID.set(id, injected);
      }
    });
    var oldOnCommitFiberRoot = hook.onCommitFiberRoot;
    var oldOnScheduleFiberRoot = hook.onScheduleFiberRoot || function () {};
    hook.onScheduleFiberRoot = function (id, root, children) {
      if (!isPerformingRefresh) {
        failedRoots.delete(root);
        if (rootElements !== null) {
          rootElements.set(root, children);
        }
      }
      return oldOnScheduleFiberRoot.apply(this, arguments);
    };
    hook.onCommitFiberRoot = function (id, root, maybePriorityLevel, didError) {
    //省略逻辑
    } 
    };
  }
}

我们可以看到重写了inject函数,后面只要调用__REACT_DEVTOOLS_GLOBAL_HOOK__.inject(injected)就能完成注册, REACT_DEVTOOLS_GLOBAL_HOOK.renders.set()也可以。我们进入react看看如何完成的注入吧! 下面代码来自 node_modules/react-dom/cjs/react-dom.development.js

//injectInternals 4778->4830行
function injectInternals(internals) {
  if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') {
    return false;
  }
  var hook = __REACT_DEVTOOLS_GLOBAL_HOOK__;
  if (hook.isDisabled) {
    return true;
  }
    return true;
  }
  try {
    if (enableSchedulingProfiler) {
      internals = assign({}, internals, {
        getLaneLabelMap: getLaneLabelMap,
        injectProfilingHooks: injectProfilingHooks
      });
    }
    //注入react devtools api
    rendererID = hook.inject(internals); 
    injectedHook = hook;
  } catch (err) {
    {
      error('React instrumentation encountered an error: %s.', err);
    }
  }
  if (hook.checkDCE) {
    return true;
  } else {
    return false;
  }
}

//injectIntoDevTools 29238->29269行
function injectIntoDevTools(devToolsConfig) {
  var findFiberByHostInstance = devToolsConfig.findFiberByHostInstance;
  var ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
  //这里调用上面的injectInternals函数完成注入
  return injectInternals({
    //省略部分方法...
    scheduleRefresh:  scheduleRefresh ,
    scheduleRoot:  scheduleRoot ,
    setRefreshHandler:  setRefreshHandler ,
    getCurrentFiber:  getCurrentFiberForDevTools ,
    reconcilerVersion: ReactVersion
  });
}

//29825->29830 显然这是全局调用的函数,在执行react的时候就会执行
var foundDevTools = injectIntoDevTools({
  findFiberByHostInstance: getClosestInstanceFromNode,
  bundleType:  1 ,
  version: ReactVersion,
  rendererPackageName: 'react-dom'
});
  • 总结一下:react-refresh就是通过react devtools api实现的热更新,但是需要收集到对应需要更新的依赖,同时你还有分辨出那些是需要重新挂载的那些是需要重新渲染的。而调用这些收集、更新函数的,当然是在编译阶段注入的。
  • 好了,到这里就完成了整个react-refresh的分析以及react devtools的注入,如果你还想知道scheduleRefresh是如何通过root和update完成热更新的,我们在第五部分讲解,当然你需要有一定的React知识例如:React的workLoop机制,beginWork、complete、commit阶段的执行、lane模型的优先级判断机制等。

(4).@vitejs/plugin-react源码分析

好的,现在我们知道真正的热更新已经交给refresh去完成了,那么我们怎么通过App.jsx变成编译后的App.jsx呢?这就是 @vitejs/plugin-react 需要完成的事情了。我们看看这个插件导出的函数viteReact吧!这个函数太长了 我们省略一些逻辑。

function viteReact(opts = {}) {
  let projectRoot = process.cwd();
  let skipFastRefresh = opts.fastRefresh === false;
  let skipReactImport = false;
  const importReactRE = /(^|\n)import\s+(\*\s+as\s+)?React(,|\s+)/;
  //vite插件 实现App.jsx变为编译后的App.jsx
  const viteBabel = {
    name: "vite:react-babel",
    enforce: "pre",
    config() {
      if (opts.jsxRuntime === "classic") {
        return {
          esbuild: {
            logOverride: {
              "this-is-undefined-in-esm": "silent"
            }
          }
        };
      }
    },
    configResolved(config) {
      projectRoot = config.root;
      //build状态下不进行热更新
      skipFastRefresh || (skipFastRefresh = isProduction || config.command === "build");
      const jsxInject = config.esbuild && config.esbuild.jsxInject;
      //如果设置了自动注入jsxInject警告并且跳过import React from "react"的注入
      if (jsxInject && importReactRE.test(jsxInject)) {
        skipReactImport = true;
        config.logger.warn(
          "[@vitejs/plugin-react] This plugin imports React for you automatically, so you can stop using `esbuild.jsxInject` for that purpose."
        );
      }
      //如果使用了react-refresh插件报错 会产生冲突
      config.plugins.forEach((plugin) => {
        const hasConflict = plugin.name === "react-refresh" || plugin !== viteReactJsx && plugin.name === "vite:react-jsx";
        if (hasConflict)
          return config.logger.warn(
            `[@vitejs/plugin-react] You should stop using "${plugin.name}" since this plugin conflicts with it.`
          );
      });
    },
    //code是要转化的代码,id是当前模块绝对路径
    async transform(code, id, options) {
      const ssr = options?.ssr === true;
      const [filepath, querystring = ""] = id.split("?");
      const [extension = ""] = querystring.match(fileExtensionRE) || filepath.match(fileExtensionRE) || [];
      if (/\.(mjs|[tj]sx?)$/.test(extension)) {
        const isJSX = extension.endsWith("x");//是否是jsx或者tsx文件
        const isNodeModules = id.includes("/node_modules/");//是否是module
        const isProjectFile = !isNodeModules && (id[0] === "\0" || id.startsWith(projectRoot + "/"));
        let babelOptions = staticBabelOptions;
        if (typeof opts.babel === "function") {
          //进行babel配置
        }
        const plugins = isProjectFile ? [...babelOptions.plugins] : [];
        let useFastRefresh = false;
        //判断是否注入热更新 比如build,ssr,node_modules都不进行注入
        if (!skipFastRefresh && !ssr && !isNodeModules) {
          const isReactModule = isJSX || importReactRE.test(code);
          if (isReactModule && filter(id)) {
            useFastRefresh = true;
            //这个插件用于注入_s _c函数的执行以及ownKey的生成
            plugins.push([
              await loadPlugin("react-refresh/babel"),
              { skipEnvCheck: true }
            ]);
          }
        }
        //将React.createElement转化为_jsxDev并且注入React运行时插件
        plugins.push([
                await loadPlugin(
                  "@babel/plugin-transform-react-jsx" + (isProduction ? "" : "-development")
              ),
              {
                runtime: "automatic",
                importSource: opts.jsxImportSource,
                pure: opts.jsxPure !== false
              }
           ]);
              
        }
            
        //你可能没有写import React from "react"这里判断一下是否需要自动注入
        if (!skipReactImport && !importReactRE.test(code)) {
              prependReactImport = true;
        }
         
        if (prependReactImport) {
            code = prependReactImportCode + code;
        }
        //利用babel插件执行代码转换
        const result = await transformAsync({
          ...babelOptions,
          root: projectRoot,
          filename: id,
          plugins,
        });
        
        if (result) {
          let code2 = result.code;
          code2 = addRefreshWrapper(code2, id, accept);
          return {
            code: code2,
            map: result.map
          };
        }
      }
    }
  };
  //编译后的代码会插入import xx from "/@react-refresh"
  //我们需要读取react-refresh源码返回给vite处理为什么不直接注入"react-refresh"?
  //因为我们要对导出的代码进行一层包装。
  const viteReactRefresh = {
    name: "vite:react-refresh",
    enforce: "pre",
    //runtimePublicPath="/@react-refresh"
    resolveId(id) {
      if (id === runtimePublicPath) {
        return id;
      }
    },
    load(id) {
      if (id === runtimePublicPath) {
        return runtimeCode;
      }
    },
    //装换html在html<head>中注入window.injectIntoGlobalHook
    transformIndexHtml() {
      if (!skipFastRefresh)
        return [
          {
            tag: "script",
            attrs: { type: "module" },
            children: preambleCode.replace(`__BASE__`, devBase)
          }
        ];
    }
  };
  //省略这个插件的介绍
  const viteReactJsx = {
  }
  //返回插件
  return [viteBabel, viteReactRefresh];
}

所以,实际上@vitejs/plugin-react主要通过viteBabel插件进行编译热更新代码,而viteReactRefresh则是为了引入react-refresh到客户端中。

  • 为什么一定要单独写一个插件来进行react-refresh注入,不能直接import x from "react-refresh"吗?
  • 顶部和底部的代码如何进入文件的? 我们来看这样全局定义的一些字符串
//读取react-refresh源码 并且加了一层防抖
//想象一下如果你一直保存一个文件 那么每次都去发送文件是想到消耗
//性能的,我们禁止一直保存文件。至少间隔16ms让文件发出去一次
const runtimeCode = `
const exports = {}
${fs__default.readFileSync(runtimeFilePath, "utf-8")}
function debounce(fn, delay) {
  let handle
  return () => {
    clearTimeout(handle)
    handle = setTimeout(fn, delay)
  }
}
exports.performReactRefresh = debounce(exports.performReactRefresh, 16)
export default exports
`;
//这里是注入index.html中的script代码
const preambleCode = `
import RefreshRuntime from "__BASE__${runtimePublicPath.slice(1)}"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
`;

//这里是注入含有函数组件或则自定义hooks的文件的头部代码
const header = `
import RefreshRuntime from "${runtimePublicPath}";

let prevRefreshReg;
let prevRefreshSig;

if (import.meta.hot) {
  if (!window.__vite_plugin_react_preamble_installed__) {
    throw new Error(
      "@vitejs/plugin-react can't detect preamble. Something is wrong. " +
      "See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201"
    );
  }

  prevRefreshReg = window.$RefreshReg$;
  prevRefreshSig = window.$RefreshSig$;
  window.$RefreshReg$ = (type, id) => {
    RefreshRuntime.register(type, __SOURCE__ + " " + id)
  };
  window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
}`.replace(/[\n]+/gm, "");

//这里是开启vite自带的HMR 以及调用performReactRefresh函数的部分
const footer = `
if (import.meta.hot) {
  window.$RefreshReg$ = prevRefreshReg;
  window.$RefreshSig$ = prevRefreshSig;

  __ACCEPT__
  if (!window.__vite_plugin_react_timeout) {
    window.__vite_plugin_react_timeout = setTimeout(() => {
      window.__vite_plugin_react_timeout = 0;
      RefreshRuntime.performReactRefresh();
    }, 30);
  }
}`;

//这是经过transform钩子最终返回的函数,code为babel编译注入了_c _s和ownKey等代码
//header代码动态注入id footer代码注入"import.meta.hot.accept();"保证一定开启了
//接受自身模块更新
function addRefreshWrapper(code, id, accept) {
  return header.replace("__SOURCE__", JSON.stringify(id)) + code + 
  footer.replace("__ACCEPT__", accept ? "import.meta.hot.accept();" : "");
}
  • 好的,恭喜你,完成了整个@vitejs/plugin-react的源码阅读。
  • 最后我们在来总结一下: 为了实现React组件级别的更新我们通过babel编译引入了refreshReg函数来判断hooks顺序是否发生改变,引入refreshSig来注册可能需要更新的热更新函数组件。然后调用了react devtools api来实现热更新,我们再来解答一下刚刚提出的问题。
  • 为什么一定要保证hooks顺序才实现保持状态的热更新:hooks的状态存储与函数组件没有必然联系,事实上函数状态发生改变时,函数组件就会重新执行,hooks也会按照顺序挂载,在fiber内部通过链表形式存储,如果hooks顺序发生了改变导致读取链表中顺序的hooks出现错误,就可能会导致状态出现紊乱。这也是为什么不能在函数组件中使用if else等条件语句去判断当前是否执行hooks。所以当hooks顺序发生改变,增加,减少的时候都不能进行保持状态的热更新,我们来理清一下整个热更新流程,首先当文件发生改变,我们会接受到一个已经改变的文件模块,这个函数必然是与之前不同的,进行收集之后触发更新,调用了react devtools api触发更新,然,那么React内部进行新旧函数对比,他俩肯定不是同一个函数,所以新函数会被生成新的Virtual DOM然后继续比较,但是hooks一直存在,不会因为是否是新的Virtual DOM而发生改变,所以hooks的状态依旧能正确的注入到这个新的函数中,这也重测方面印证了Hooks的可行性,就像这样,天然就支持保持状态的热更新。
  • 为什么类组件不能进行保持状态的热更新:首先对于React来说你只是在渲染一个全新的组件,每次发来的class都是新的模块,这个class和之前的class并非同一个class,那么React目前提供的api只能进行卸载然后重新挂载这个组件。这个时候状态必然丢失,也就没办法保持状态进行热更新了,就算React提供了替换实例而不卸载DOM和执行生命周期函数,但是事件绑定依旧无法转移我们就只能在执行componentDidMount,这显然不合理。
  • 思考:对于class的保持状态热更新该如何实现?分离state与实例?代理class组件?

4.手把手带你写一个@vitejs/plugin-react

通过刚刚的源码分析是否还是有点懵逼呢?我来带大家写一个简单版的React组件级更新插件巩固一下吧! 我们先来分析一下需要完成那些事情

  • 首先既然是简单版我们就不需要判断hooks顺序是否发生改变了对于类组件和函数组件我们都采用无状态更新方式这样我们就只需要编译注入refreshRig函数
  • 实现viteBabel插件实现判断是否是函数组件并且注入refreshRig函数
  • 实现viteReactRefresh插件注入react-refresh库,转换html注入react devtools代码

如果你想了解Vite源码,但是又苦于阅读难度太大,可以看看我写的一个简单版本的Vite(约5000行),对于开发模式实现了alias别名配置,自动监测预构建,开箱即用的js ts jsx tsx,静态资源加载,css less sass支持,插件机制,HMR热更新,虚拟模块,以及实现了build打包。内部有部分注释帮助你更低成本的了解Vite。

点我进入mini-vite源码的github仓库

点我进入mini-vite-template链接

git clone github.com/ysy945/mini…

并执行npm i;npm run start就可以开始项目啦!

这个插件当然要先适配简版的Vite,当然同样也会适配官方版本的Vite

好了 进入正题 打开新的文件夹 执行npm init -y; 安装两个核心包并且创建index.js

首先我们引入一下必须的库

import { transformAsync } from "@babel/core";
import template from "@babel/template";
import path from "path";
import fs from "fs";
//用于判断当前节点类型
import {
  isCallExpression,
  isReturnStatement,
  isMemberExpression,
  isFunctionExpression,
  isArrowFunctionExpression,
} from "@babel/types";
//这里为了适配自己的vite(前面带有virtual特殊字段的表示虚拟模块)
const runtimePublicPath = "virtual:@react-refresh";
const ast = template.default.ast;//引入通过字符串生成ast节点的工具
//插入index.html的代码
const preambleCode = `
import RefreshRuntime from "/${runtimePublicPath}"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
`;

(1).编写babel插件

简单说一下babel插件的形式,你可以返回一个对象,其中至少包括visitor属性,当然也可以添加name标识插件名称,visitor里面可以拦截到各种节点,例如想拦截"VariableDeclaration"节点,将这个节点名称作为函数名,接受的参数是path,这里包含了许多的方法例如:

  • path.node 返回当前ast节点
  • path.parent 返回当前ast节点的父节点
  • path.isXXX 判断当前节点是否是某个节点例如:path.isVariableDeclaration
  • path.insertBefore 在当前节点的前面插入一个节点
  • path.insertAfter 在当前节点下面插入一个节点
  • path.getSibling 获取兄弟节点
  • path.replaceWith 替换当前节点
  • path.replaceWithMultiple 替换当前节点为多个节点
  • path.replaceWithSourceString 用字符串替代当前节点
  • path.skip 跳过当前节点的所有子节点的遍历
  • path.stop 跳过剩下所有节点的遍历

你可以通过astexplorer.net/查询js代码对应的ast结构。当然还有很多其他的方法,就不在一一列举了,我们来看看如何书写babel插件吧!

function babelReactRefreshPlugin(jsxFunNames) {
  return {
    name: "babel-react-refresh-plugin",
    pre() {},
    visitor: {
      VariableDeclaration(path) {
        const { node } = path; //获取当前拦截的ast
        const declaration = node.declarations[0]; //获取声明语句
        const name = declaration.id.name; //获取变量名
        let _isJsxFunction = false;
        //如果这个变量声明值是一个函数 看看返回值是不是React.createElement如果是需要记录
        if (isFunctionExpression(declaration.init)) {
          _isJsxFunction = isJsxFunction(declaration);
        }
        //也有可能是箭头函数
        else if (isArrowFunctionExpression(declaration.init)) {
          //可能是()=>{return React.createElement}
          if (isJsxFunction(declaration)) {
            _isJsxFunction = true;
          }
          //可能是()=>React.createElement()类型
          else {
            if (
              isCallExpression(declaration.init.body) &&
              isMemberExpression(declaration.init.body.callee)
            ) {
              const callee = declaration.init.body.callee;
              //当前函数的返回值是否是以React.createElement开始的
              if (
                isMemberExpression(callee) &&
                callee.object.name === "React" &&
                callee.property.name === "createElement"
              ) {
                _isJsxFunction = true;
              }
            }
          }
        }

        //到这里就可以判断当前这个VariableDeclaration是否是一个需要被热处理的函数了
        if (_isJsxFunction) {
          //判断名称是否开头是大写
          if (!(name[0] >= "A" && name[0] <= "Z")) {
            return;
          }
          //将名称放入数组中 便于后面vite插件使用
          jsxFunNames.push(name);
          //在这个函数声明下面添加_c${n} = 函数名
          path.insertAfter(
            ast(
              `_c${jsxFunNames.length} = ${jsxFunNames[jsxFunNames.length - 1]}`
            )
          );
          path.skip(); //跳过后续子节点的遍历
        }
      },
      //这里的逻辑一样的
      FunctionDeclaration(path) {
        const { node } = path;
        const name = node.id.name;
        if (isJsxFunction({ init: node })) {
          if (!(name[0] >= "A" && name[0] <= "Z")) {
            return;
          }
          jsxFunNames.push(name);
          //在当前节点的后面插入_c1 = App
          path.insertAfter(
            ast(
              `_c${jsxFunNames.length} = ${jsxFunNames[jsxFunNames.length - 1]}`
            )
          ); //_c1 = App
          path.skip();
        }
      },
    },
  };
}

function isJsxFunction(declaration) {
  const body = declaration.init.body.body;
  if (!body) return false;
  const returnStatement = body[body.length - 1]; //最后一个可能是return语句
  //判断一下是返回语句且返回值是表达式调用
  if (
    isReturnStatement(returnStatement) &&
    isCallExpression(returnStatement.argument)
  ) {
    const callee = returnStatement.argument.callee;
    //如果是React.createElement 表示当前是一个函数组件
    if (
      isMemberExpression(callee) &&
      callee.object.name === "React" &&
      callee.property.name === "createElement"
    ) {
      return true;
    }
  }
  return false;
}
  • 我们首先需要拦截通过声明表达式的函数例如:var App = function(){return React.createElement()} 拿到函数名App判断是否是大写开始并且返回值是否包含React.createElement 如果都满足那么放入"jsxFunNames"中,这个变量是外面传递进来的,最终将判断为函数组件的函数名放入这个数组。并且我们需要在当前节点的下一个节点插入_c1 = App 这里我们调用了ast函数直接将字符串转为ast语法树在进行插入 同样的,如果有两个函数组件那么对应的就要在函数组件下面插入_c"length" = 函数名
  • 当然还要处理var App = ()=>React.createElement()类型
  • var App = ()=>{return React.createElement()}
  • function App(){return React.createElement()}
  • 完成处理之后我们的传入的数组会保存下来所有函数组件的函数名然后进入下一阶段的代码编写。

(2).编写viteReactRefresh插件

//refresh插件
  const viteReactRefresh = {
    name: "vite:react-refresh",
    enforce: "pre",
    resolveId(id) {
      //如果请求的路径是"/@react-refresh"
      if (id === runtimePublicPath) {
        return "/" + runtimePublicPath;
      }
    },
    //接受当前的vite的config
    configResolved(config) {
      resolvedConfig = config;
    },
    //如果当前即将载入的内容路径是"/@react-refresh"
    async load(id) {
      if (id === "/" + runtimePublicPath) {
        let runtimeCode = ``;
        const root = resolvedConfig.root; //获取根目录
        //获取react-refresh的源代码路径
        const reactRefreshPath = path.resolve(
          root,
          "node_modules/react-refresh/cjs/react-refresh-runtime.development.js"
        );
        //尝试读取react-refresh文件
        try {
          const refreshCode = await fs.promises.readFile(
            reactRefreshPath,
            "utf-8"
          );
          
          //由于代码中含有process.env.NODE_ENV 浏览器是无法执行这段代码的
          //我们简单处理这个情况直接替换就可以了
          //同时我们还需要插入防抖的代码包装好 返回给接下来的vite钩子处理
          runtimeCode = `
          const exports = {}
          ${refreshCode.replace(
            "process.env.NODE_ENV",
            JSON.stringify("development")
          )}
            function debounce(fn, delay) {
            let handle
            return () => {
              clearTimeout(handle)
              handle = setTimeout(fn, delay)
            }
          }
          exports.performReactRefresh = debounce(exports.performReactRefresh, 16)
          export default exports
          `;
        } 
        //错误处理
        catch (e) {
          console.log(`pluginError: [vite:react-refresh]错误 ${e}`);
        }
        //返回我们读取到的code
        return { code: runtimeCode };
      }
    },
    //当然别忘了注入react devtools api的代码哦
    //因为我自己的vite没有实现返回数组的形式,所以为了适配只能简单处理了
    //如果引入parser处理反而更麻烦了
    transformIndexHtml(html) {
      if(resolvedConfig.command === "build")return html//生产模式不执行这个钩子
      //我们匹配<head>标签获取他的位置
      const start = /<head>/g.exec(html).index;
      //截取前面以及后面的代码
      const first = html.slice(0, start + 6);
      const last = html.slice(start + 6, html.length);
      //插入我们已经包装好的代码
      return first + `<script type="module">${preambleCode}</script>` + last;
    },
  };
  • 这个插件主要是完成了import xxx from "/@react-refresh"代码的载入,并且对源码做了防抖处理,避免了用户连续保存文件造成的性能浪费,同时利用transformIndexHtml钩子 转换了index.html代码注入了一些必要的变量。

(3).利用babel插件与babel/core完成BabelTransform(vite)插件的编写

const babelTransform = {
    name: "vite:transform-refresh",
    //利用babel插件转换代码 如果有需要热更新的jsx函数需要注入热更新代码
    async transform(code, id) {
      //如果是build模式则不执行这个transform钩子(热更新不需要在生产模式中)
      if (resolvedConfig.command === "build") return code; 
      const ext = [".jsx", ".tsx"];
      if (!ext.includes(path.extname(id))) return code; //如果不是jsx tsx不处理
      const jsxFunNames = []; //用于收集需要热更新的函数名
      //利用babel/core自带的api注入我们刚才写的babel插件执行代码的转换
      const { code: transformCode } = await transformAsync(code, {
        plugins: [babelReactRefreshPlugin(jsxFunNames)],
      });
      //如果没有需要热更新的地方 不插入热更新代码
      if (jsxFunNames.length === 0) {
        return code;
      }

      //拼接文件的头部
      const header = `
      import RefreshRuntime from "virtual:@react-refresh"
      if (!window.__vite_plugin_react_preamble_installed__) {
      throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong. See           https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201");
      }
      let prevRefreshReg;
      if (import.meta.hot) {
      prevRefreshReg = window.$RefreshReg$;
      window.$RefreshReg$ = (type, id) => {
      RefreshRuntime.register(type, "${id}" + id);
};
      window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
}
`;
      //"_c1,_c2;" 拼接变量
      const Variables = jsxFunNames
        .map((a, index) => {
          const str =
            index === jsxFunNames.length - 1
              ? `_c${index + 1};`
              : `_c${index + 1},`;
          return str;
        })
        .join("");

      //$RefreshReg$(_c1,"App");
      const callExpression = jsxFunNames
        .map((a, index) => {
          return `$RefreshReg$(_c${index + 1}, "${a}");`;
        })
        .join("\r\n");

      //拼接底部模块
      const footer = `
       var ${Variables}
       ${callExpression}
       if (import.meta.hot) {
         window.$RefreshReg$ = prevRefreshReg;
         import.meta.hot.accept&&import.meta.hot.accept()
         if (!window.__vite_plugin_react_timeout) {
         window.__vite_plugin_react_timeout = setTimeout(() => {
          window.__vite_plugin_react_timeout = 0;
          RefreshRuntime.performReactRefresh();
    }, 30);
  }
}`;

      return {
        code: header + transformCode + footer,
      };
    },
  };
  • 这个插件的逻辑就非常简单了 利用刚刚写的babel插件完成code的转换 并且拼接header与footer的代码就完成了整个文件的转换。
  • 注意类组件是不需要进行分析的,只需要处理函数组件。类组件依赖函数组件进行热更新,之前已经讲过了。

(4).整个插件的源码

import { transformAsync } from "@babel/core";
import template from "@babel/template";
import path from "path";
import fs from "fs";
import {
  isCallExpression,
  isReturnStatement,
  isMemberExpression,
  isFunctionExpression,
  isArrowFunctionExpression,
} from "@babel/types";
const runtimePublicPath = "virtual:@react-refresh";
const ast = template.default.ast;
const preambleCode = `
import RefreshRuntime from "/${runtimePublicPath}"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
`;

function babelReactRefreshPlugin(jsxFunNames) {
  return {
    //省略这里的代码...
  };
}
function isJsxFunction(declaration) {
  //省略这里的代码...
}
//这里是真正的vite-react-refresh导出的函数
export default function viteReact() {
  let resolvedConfig;
  //refresh插件
  const viteReactRefresh = {
    //省略这里的代码...
  };
  const babelTransform = {
    //省略这里的代码
  };
  return [viteReactRefresh, babelTransform];
}

插件已经完成啦,我们放入自己的Vite中看看运行效果吧!

我们在放入官方的Vite中看看效果吧!

这个插件我已经发布到npm上啦! 通过npm i vite-plugin-refresh-react安装。由于这个插件发布得比较急没有打包cjs规范的文件所以需要在package.json中添加 "type":"module" 字段否则将会无法读取到。当然ts文件的类型补充也还没有做,如果你是通过vite.config.ts引入的这个插件请添加 @ts-ignore 字段。这个插件除了无法保持状态更新其他和官方发布的插件是一样的。

5.React devtools Api内部分析

好啦!终于到了本文的最后一个部分,手都敲抽了。相信坚持到这里的小伙伴一定收获了不少知识了吧。如果你对React的内部实现热更新同样感兴趣可以继续尝试阅读。

(1).ReactDevTools.scheduleRefresh分析

我们紧接着之前的分析看看这个函数干了什么吧!我先来回顾一下当初调用这个函数传递了一些什么参数呢?

//react-refresh -> performReactRefresh中的部分代码
mountedRootsSnapshot.forEach(function (root) {
      //省略部分代码...
      try {
        //我们可以发现一个是root 另外一个是update 里面是包装的一个对象
        //{staleFamilies,updatedFamilies}
        //hooks顺序发生改变或者类组件装入到staleFamilies 否则装入updatedFamilies中
        helpers.scheduleRefresh(root, update);
      } catch (err) {
        if (!didError) {
          didError = true;
          firstError = err;
        }
      }
    });

其中root则是fiberRootNode,我们先来看看他的结构吧!

callbackNode: null
callbackPriority: 0//当点回调的优先级
containerInfo: div#root//容器DOM
context: {}
//当前页面渲染的fiber树
current: FiberNode {tag3keynullelementTypenulltypenullstateNodeFiberRootNode, …}
effectDuration: 0
entangledLanes: 0
entanglements: (31) [0,....,0]
eventTimes: (31) [-1000, -1,....,0]
expirationTimes: (31) [-1,...,0]//对应车道的到期时间
expiredLanes: 0//到期车道
...

不得不说这个相比于React17又添加了一些属性,让我们简单分析一下吧!

  • 首先我们需要了解到React有两棵树,一颗是current树,另外一颗是workInProgress树,其中workInProgress树是当前正在构建的fiber树,而对于一个root节点只有一个fiberRootNode,他的current属性指向当前页面展示的fiber树我们主要关注current属性就行了。
  • 那么current指向的才是我们需要的fiber根节点,我们再来看看他的结构吧!
//正在构建的workInProgress树 current --------------- workInProgress
//                                     alternate                
//两棵树通过alternate链接
alternate: FiberNode {tag3keynullelementTypenull, …}
child: FiberNode {tag8keynull, …}//子fiber
childLanes: 1 //所有的后代组件中某一个组件需要被更新且优先级是最高优先级
deletions: null
dependencies: null
elementType: null
flags: 1024//用于做标签这个代表2**10 在commit阶段使用
//当前fiber处于同层组件的位置 <div><input/><input/><div>
//第一个input的index为0 第二个为1 用于在DOM diff中使用
index: 0
key: null //唯一ID
lanes: 0 //当前fiber所占的优先级0表示不需要更新
memoizedProps: null//上次的props
memoizedState: {element: {…}, ...}//上次的状态
mode: 3 //更新模式
pendingProps: null //最新的props
ref: null //ref属性{current:xxx}
return: null //父fiber 作为根结点没有父fiber
sibling: null//下一个兄弟fiber
//根节点指向fiberRootNode 如果是html类型的fiber指向真实DOM 类组件和函数组件为null
stateNode: FiberRootNode {tag1containerInfo: div#root, ...}
tag: 3
type: null
//更新队列 与setState有关
updateQueue: {baseState: {…}, firstBaseUpdatenulllastBaseUpdatenull, ...}

好了,了解了基本的React内部元素结构我们继续分析scheduleRefresh

//scheduleRefresh
var scheduleRefresh = function (root, update) {
  {
    //还记得这个函数吗?看看resolveFamily是什么吧
    if (resolveFamily === null) {
      return;
    }
    var staleFamilies = update.staleFamilies,
        updatedFamilies = update.updatedFamilies;
    //执行副作用useEffect等
    flushPassiveEffects();
    //设置优先级和上下文后执行内部函数
    flushSync(function () {
      //分辨不同情况的更新调用不同函数
      scheduleFibersWithFamiliesRecursively(root.current, updatedFamilies, staleFamilies);
    });
  }
};

//resolveFamily 通过当前的type获取最新的type
function resolveFamily(type) {
    return updatedFamiliesByType.get(type);
}

//这是唯一调用设置updatedFamiliesByType的地方
var prevType = family.current;
updatedFamiliesByType.set(prevType, family);
updatedFamiliesByType.set(nextType, family);
//这里赋值表示updatedFamiliesByType中的所有type都是最新文件中的type
family.current = nextType;

我们可以发现这个函数只是一个过渡,我们进入到scheduleFibersWithFamiliesRecursively中看看

function scheduleFibersWithFamiliesRecursively(fiber, updatedFamilies, staleFamilies) {
  {
    var alternate = fiber.alternate,//获取正在构建的fiber
        child = fiber.child,//获取子fiber
        sibling = fiber.sibling,//获取兄弟fiber
        //表示不同的React元素HostComponent LazyComponent等 3代表根root
        tag = fiber.tag,
        //获取当前fiber的类型 组件就是函数 html元素则是标签名
        type = fiber.type;
        var candidateType = null;
    
    //第一次进来的tag=3是hostComponent不会匹配继续向下匹配 
    switch (tag) {
      case FunctionComponent://0
      case SimpleMemoComponent://15
      case ClassComponent://1
        candidateType = type;
        break;
      //特殊处理
      case ForwardRef://11
        candidateType = type.render;
        break;
    }
    //没有resolveFamily无法后续处理
    if (resolveFamily === null) {
      throw new Error('Expected resolveFamily to be set during hot reload.');
    }
   
    var needsRender = false;//是走渲染还是重新挂载
    var needsRemount = false;
    
    //一直深度优先遍历到时类组件或则函数组件
    if (candidateType !== null) {
      //获取最新的type函数
      var family = resolveFamily(candidateType);
      //比如我记录的是App组件 但是遍历是遍历所有的节点所以有可能出现类组件
      //或者其他组件,所以必须通过resolveFamily过滤,找到那个真正需要重新挂载的函数
      if (family !== undefined) {
        //如果是改变了hooks顺序的函数组件或则类组件标记为需要重新挂载
        if (staleFamilies.has(family)) {
          needsRemount = true;
        } 
        //这里则是有可能能保存状态的热更新的type
        else if (updatedFamilies.has(family)) {
          //如果是类组件依旧无法保存状态更新 重新挂载吧
          if (tag === ClassComponent) {
            needsRemount = true;
          } 
          //函数组件? 恭喜你 你可以保存状态更新去渲染吧
          else {
            needsRender = true;
          }
        }
      }
    }
    
    //如果需要重新挂载给当前fiber打上标记
    if (needsRemount) {
      fiber._debugNeedsRemount = true;
    }
    
    //如果需要挂载或者需要重新渲染给当前fiber打上优先级标志
    if (needsRemount || needsRender) {
      //这里比较关键我们马上分析返回根结点
      var _root = enqueueConcurrentRenderForLane(fiber, SyncLane);

      //如果返回的是根节点进入fiber调度 执行beginWork compelteWork commit阶段
      if (_root !== null) {
        scheduleUpdateOnFiber(_root, fiber, SyncLane, NoTimestamp);
      }
    }
    //如果当前还有子元素且不是需要挂载的元素我们继续向下标记
    if (child !== null && !needsRemount) {
      scheduleFibersWithFamiliesRecursively(child, updatedFamilies, staleFamilies);
    }
    //同上
    if (sibling !== null) {
      scheduleFibersWithFamiliesRecursively(sibling, updatedFamilies, staleFamilies);
    }
  }
}

//enqueueConcurrentRenderForLane

function enqueueConcurrentRenderForLane(fiber, lane) {
  return markUpdateLaneFromFiberToRoot(fiber, lane);
}

//这个函数就不展开讲了简单的说就是给fiber的lane赋值然后我们知道
//fiber上有一个childLanes属性,那么当前fiber的lane发生了改变,
//他的所有父代fiber 爷代fiber 向上的所有fiber的childLanes都需
//要发生更新一直到根fiber停止,这个属性是相当有用了,后续的更新,
//会判断当前lanes和childLanes是否为0在决定是否继续向下更新,
function markUpdateLaneFromFiberToRoot(sourceFiber, lane) {
  sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
  var alternate = sourceFiber.alternate;

  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
  var node = sourceFiber;
  var parent = sourceFiber.return;
  while (parent !== null) {
    parent.childLanes = mergeLanes(parent.childLanes, lane);
    alternate = parent.alternate;
    if (alternate !== null) {
      alternate.childLanes = mergeLanes(alternate.childLanes, lane);
    } else {
      {
        if ((parent.flags & (Placement | Hydrating)) !== NoFlags) {
          warnAboutUpdateOnNotYetMountedFiberInDEV(sourceFiber);
        }
      }
    }
    node = parent;
    parent = parent.return;
  }

  if (node.tag === HostRoot) {
    var root = node.stateNode;
    return root;
  } else {
    return null;
  }
}
  • 通过上述代码我们知道了,这个函数会首先根据获取的fiberRootNode进行深度优先遍历,遍历所有的fiber,并且先过滤出函数和类组件,然后在通过resolveFamily函数找到真正需要热更新的函数。
  • 然后对这个节点的lane属性打上标记,当然还需要更新他的所有上代节点的childLanes
  • 最后执行scheduleUpdateOnFiber发起react调度
  • 我们举例跟踪一下:假设我写了一个App函数组件,内部嵌套了一个Text类组件,这是渲染的开始,当我们注释掉Text类组件内部的部分代码,保存App.jsx好的,分析一下浏览器会请求那个文件呢?你可能会回答当然是Text.jsx文件啊!,但是实际上并不是,因为对于类组件我们没有做任何的处理,所以并不会出现import.meta.hot.accept()这样的代码,但是他的父组件是函数组件我们的插件对这个函数组件是做了处理的,所以会包含import.meta.hot.accept()代码,也就是说App.jsx接受自身的更新,但是Text.jsx不接受自身更新,所以虽然你保存的是Text.jsx文件,但是实际上浏览器发送请求的是App.jsx,那么拿到最新的App.jsx,你之前保存了Text.jsx文件所以内部会有一个lastHMRTimeStamp属性会发生改变,正是这个属性发生改变,当浏览器请求App.jsx文件的时候会进行Transform钩子的转换,这个时候回分析请求路径,就会给import Text from "/xxx/Text.jsx?t=1664489561"添加上query参数,这个时候再交给浏览器分析,发现了新的路径请求,那么连锁反应就来了,他会在去请求Text.jsx文件,然后接着向下执行,这个时候就来到了收集热更新pendingUpdates,收集完成后显然只有App这个函数组件是需要热更新的,紧接就开始遍历整个fiber节点找到需要更新的这个函数节点,找到之后,发现这个函数组件的fiber的lane属性并不是0,那么这个函数组件就需要被重新执行获得最新的Virtual DOM然后接着向下比较,我们知道我们仅仅修改了Text组件内部的一小块的内容,对于函数组件来说进行DOM diff比较的时候只会去修改Text组件,但是Text组件我们重新请求了吧,所以这个Text组件和之前的Text组件不是同一个组件那么就需要重新挂载,所以Text的状态没了,但是继续向下比较,发现我们删除了一块内容,那么我们把他更新,好了到此为止,整个更新过程就结束了。类组件状态没了,但是效果传递到了我们的页面上面。你可能会问,那如果我函数组件套类组件在套类组件,然后更新最下面那个类组件的部分内容呢?如果你听懂了,就会知道它同样会发生链式反应先请求最上方的函数组件,然后一个一个向下请求。最终走向热更新。如果一直找不到函数组件呢,那么就会刷新页面。也及时根组件就是类组件的情况。
  • 那么rerender和remount又有什么区别呢?
  • 首先要明确rerender是可以保持状态更新的函数组件,remount是hooks顺序发生改变的函数组件或类组件,当然我们并没有写过类组件的注入逻辑,所以不可能是类组件,也许有其他用途吧,毕竟是官方的api考虑的比较多。
//我们可以发现remount和rerender的区别就在于remount会给fiber加上一个单独的属性
//然后他们都会进行调度
if (needsRemount) {
    fiber._debugNeedsRemount = true;
}
if (needsRemount || needsRender) {
    var _root = enqueueConcurrentRenderForLane(fiber, SyncLane);
    if (_root !== null) {
    scheduleUpdateOnFiber(_root, fiber, SyncLane, NoTimestamp);
 }

接下来我们继续追踪 fiber._debugNeedsRemount看看哪里对这个属性做了if语句的判断,好的,我在beginWork中找到了唯一使用这个值的地方,我们来看看beginWork的代码吧!

//beginWork
function beginWork(current, workInProgress, renderLanes) {
  {
    //如果有这个属性这里就不在走正常的beginWork而是插入了一个remountFiber函数
    if (workInProgress._debugNeedsRemount && current !== null) {
      return remountFiber(current, workInProgress, createFiberFromTypeAndProps(workInProgress.type, workInProgress.key, workInProgress.pendingProps, workInProgress._debugOwner || null, workInProgress.mode, workInProgress.lanes));
    }
  }
  //省略部分代码...
  
  //remountFiber 继续追踪
  function remountFiber(current, oldWorkInProgress, newWorkInProgress) {
  {
    //获取父fiber
    var returnFiber = oldWorkInProgress.return;

    if (returnFiber === null) {
      throw new Error('Cannot swap the root fiber.');
    } 
    //初始化
    current.alternate = null;
    oldWorkInProgress.alternate = null; 
    newWorkInProgress.index = oldWorkInProgress.index;
    newWorkInProgress.sibling = oldWorkInProgress.sibling;
    newWorkInProgress.return = oldWorkInProgress.return;
    newWorkInProgress.ref = oldWorkInProgress.ref;
    //如果父fiber的第一个孩子就是老的fiber那么替换就行了
    if (oldWorkInProgress === returnFiber.child) {
      returnFiber.child = newWorkInProgress;
    }
    //这里是如果父fiber的第一个child不是oldWorkInProgress,那么我们需要
    //一个一个去寻找到这个oldWorkInProgress在父fiber中的位置,我们知道
    //child是链表的形式去存放的,事实上整个fiber数都是链表形式存储的。
    else {
      //获取第一个孩子
      var prevSibling = returnFiber.child;
    
      if (prevSibling === null) {
        throw new Error('Expected parent to have a child.');
      }
      
      //如果第一个孩子的下一个孩子也就是第二个孩子也不是那就继续去
      //寻找直到我们找到它
      while (prevSibling.sibling !== oldWorkInProgress) {
        prevSibling = prevSibling.sibling;
        if (prevSibling === null) {
          throw new Error('Expected to find the previous sibling.');
        }
      }
      //然后做出替换
      prevSibling.sibling = newWorkInProgress;
    }
    //修改父fiber中需要删除的子fiber这个deletions会真的去删除DOM
    var deletions = returnFiber.deletions;
    if (deletions === null) {
      returnFiber.deletions = [current];
      returnFiber.flags |= ChildDeletion;
    } else {
      deletions.push(current);
    }
    //删除完成后,给新fiber打上插入的标记便于插入父节点中
    newWorkInProgress.flags |= Placement;
    return newWorkInProgress;
  }
}
  • 好的,很明显这个函数,他先获取父fiber,然后创建了一个新的fiber把一些不需要改变的属性放到了newFiber中, 然后删除这个老的fiber(打上标记在commit阶段去删除),插入这个新的fiber。到这里remount逻辑就清除了,直接干掉真实DOM重新渲染。
  • 那么rerender呢? 他就是走了正常的比较程序。还想继续深入可以看看React的更新机制就可以啦,rerender已经是正常更新的逻辑了。

全文总结: ok!那么整个热更新都已经跑通了!我们先介绍了简单的Vite HMR Api 的使用,然后告诉了大家@vitejs/plugin-react的更新机制,紧接着我们分析了整个插件的源码。但是源码还是太空洞了,我又带大家写了一个简单版本的热更新插件并且,最后分析了React内部如何调度的更新。相信读者一定收获颇丰吧!

最后:这是我在掘金的第一篇文章,希望读者大大们都点个赞吧!谢谢大家。