react19自定义打包umd格式并生成sourcemap

669 阅读8分钟

本文中打包的commit基于tag v19.0.0. 也就是commit hash 7aa5dda3b3 本文打包的出产物只能用于调试,不能用于生产环境

react18及其之前打包出sourcemap的umd产物见这篇文章

背景

react19官方的打包产物并没有umd格式的,官方只提供了cjs格式的打包产物jsdelivr。在react18的时候可以看到明显是由umd产物的jsdelivr。在18到19中间有一个pr删除了umd格式的打包pr,导致官方现在打包的react和react-dom只有cjs格式的。对于umd格式,用于自己调试react源码十分方便。对于18及其之前的版本,自带umd格式,怎么打包出带有sourcemap的umd产物见这篇文章。对于react19我们的目标是打包出umd格式的产物并且带有sourcemap。

另外,官方并没有提供esm格式的react,对于esm.sh等react的esm版本,都是通过esbuild去打包cjs格式的react产物二次生成的esm版本。本次也会通过源码把esm版本的react一起打包出。

自定义编译

环境准备

clone react的完整源码,并切换到tag v19.0.0.切换出新的打包分支

git clone https://github.com/facebook/react.git
git checkout -b build-my-v19.0.0
git reset --hard v19.0.0

打包分析

打包工具分析

react源码使用rollup打包。对于react19之前的版本,使用的rollup版本为^1.19.4。react19源码中的rollup版本为^3.29.5。对于低版本的rollup,插件格式为rollup-plugin-*这种格式,对于高版本的rollup,插件格式为@rollup/plugin-*,高低版本互相不兼容。

对于react18及其以前的版本,因为本身带有umd格式的产物打包出,只需要去修改react的打包逻辑即可得到对应的sourcemap。在之前的文章中也是通过修改react打包中的一些逻辑得到了sourcemap。

对于react19和react18有所不同,由于官方的打包逻辑中删除了umd格式的相关代码,我们去官方的逻辑上加反而会更麻烦,因为官方的打包逻辑涉及的代码和逻辑很多。这时更好的办法是自己去实现一个全新打包的逻辑,rollup的配置除了format和官方不一样其他全部保持一致。

另,为什么之前react18及其之前不采用全部重写的打包,而且去官方的逻辑里面修改。react17出时rollup的版本就已经很新了,这时的rollup插件已经是新格式,自己实现一个会和react17使用的老的rollup版本产生冲突,而且很多rollup-plugin-*格式的插件都已经废弃,文档难找,所以使用官方的逻辑修改。

官方产物分析

业务使用的官方的cjs产物主要是两个npm包,一个是react,一个是react-dom

对于react这个npm包来讲,react "version": "19.0.0" 是一个独立包,这个包并不依赖其他的包。在react18及其之前,这个包的依赖有一个loose-envify,在react19中这个依赖被删除。这个包还有其他的子包,如react/jsx-runtime,这个包在开启new jsx runtime的时候会使用,但是在打包umd格式的react版本时不用考虑,reactumd 版本里是React.createElement

对于react-dom这个npm来讲,这个包依赖一个react源码中打出的包scheduler。同样和react的npm包一样,这个包也有子包react-dom/client ,和react不一样的是react-dom/client在react19版本中是在业务中一定会用到的代码。对于react19而言,react-dom生成的的cjs代码体积很小,而react-dom/client很大。在react18及其以前,react-dom/client是从react-dom里面重新导出了两个函数createRoothydrateRoot

下面是react18及其之前的打包关系图,注意图中的包和文件夹都是主要的,像是shared之类的文件夹以及react/jsx-runtime之类的并没有算进去

react18源码打包.png

对于react19的打包关系图如下

react19打包关系图.png

对于react19的umd版本的产物依赖关系,只需按照和react18及其以前的umd版本依赖关系即可。

打包过程分析

打包的代码位于scripts/rollup目录下,其中最主要的是scripts/rollup/build.js。通读整个文件发现,整个react打包主要包括不同bundle的不同channel的打包,以及打包完后的封装。不同channel指的是在stable也就是稳定版本发的包,以及experimental包,也就是npm版本带有experimental的包,这些包通常是测试人员才会使用。

整个打包的核心还是一个rollup的使用,其中最主要的是rollup插件的使用,react封装了很多自己的插件完成react的打包目标。插件的是由函数getPlugins动态生成的。

接下来调试每个插件以及分析插件的作用

scripts/rollup/bundles中的bundles数组删除至只剩react react-dom以及react-dom/client,其中的bundleTypes只剩NODE_PROD,运行yarn build开始调试

以下是插件列表

  1. dynamicImports

    这是一个自定义插件,这个插件是为生成代码的动态加载提供修改,并无断点命中,所以在打包umd时可以直接删除。

  2. rollup-plugin-flow-remove-types

    这也是一个自定义插件,目的是在编译时删除flowtypes,这个插件并没有生成sourcemap,所以这个插件在打包生成sourcemap时应该去掉。对于flow的处理在自己的打包逻辑中全部换成使用babel处理。在flow的官网中使用babel处理flow应该使用下面的代码

    {
      "presets": ["@babel/preset-flow"],
      "plugins": ["babel-plugin-syntax-hermes-parser"],
    }
    
  3. useForks

    这个插件也是一个自定义插件,这个插件的作用是在react打包时通过不同入口去替换不同的文件。react源码里面有大量带有fork的文件和文件夹,在不同的打包添加下同一个入口文件会被替换成不同的文件。这个插件在新的打包逻辑里面一定需要,而且逻辑需要和cjs下的对应reactreact-dom react-dom/client的打包保持一致.

    useForks的参数是动态更改的,这里直接调试不同的入口得到对应的forks值

    在entry为react时forks有以下的值,

    image.png 其他两个entry也可以按照此调试方法得到对应的forks值,这时把对应的forks值保存下来,打包umd时把对应的forks传入即可。

  4. forbidFBJSImports,自定义插件

    这是打包到FB时所用的插件,调试发现并没有命中断点,新的逻辑里面删除即可

  5. resolve

    这是rollup官方的插件,去node_modules找包的,新的逻辑同样需要,复制options就可以

  6. stripBanner

    这是rollup的三方插件,去除源码里面的开头和结尾的注释的,对于新的逻辑来说无用而且不影响js的逻辑,删除就行

  7. babel rollup的babel插件,这里babel的options无需按照原本的babel options。直接使用上面提到的flow的处理即可。

    注意这里删除了原本的babel options,会导致production模式下react内部报错的error message无法被转化为error code. 需要此功能的按照官方的options配置。由于我们的打包是调试的所以无需这个

  8. remove 'use strict'

    强行删除代码里面的remove 'use strict',这个插件在新逻辑也不需要

  9. replace

    官方的replace插件,新的逻辑里面的值为下面的值

    {
        __DEV__: 'false'
          __PROFILE__: 'false',
          'process.env.NODE_ENV': "'production'",
          __EXPERIMENTAL__: 'false',
    }
    
  10. top-level-definitions

    在生成的代码里面加东西,这里调试发现这个插件对于react react-dom react-dom/client并没有改变生成后的代码。所以新逻辑里面删除这个插件就行

    image.png

    image.png

    image.png

  11. 剩下的三个

    剩下三个插件都是代码优化以及体积检测的分别是closure prettier sizes,新逻辑里面直接删除就行。

重写打包

根据上面的分析重写一个打包的逻辑,其中核心的插件代码

const plugins = [
  useForks(forks),             // forks的值是上面复制的值
  resolve(),
  babel({
    babelrc: false,
    configFile: false,
    presets: [
      [
        "@babel/flow",
        {
          allowDeclareFields: true,
        },
      ],
    ],
    plugins: ["babel-plugin-syntax-hermes-parser"],
    babelHelpers: "bundled",
  }),
  replace({
    preventAssignment: true,
    values: {
      __DEV__: false ? "false" : "true",
      __PROFILE__: false ? "true" : "false",
      "process.env.NODE_ENV": false ? "'production'" : "'development'",
      __EXPERIMENTAL__: "false",
    },
  }),
];

整个新的打包流程就跑通了。完整的打包代码位于mybuild。运行node mybuild/build.js可以把umd格式以及esm格式的代码完整的打包出来。打包出的js的cdn链接为

cdn.jsdelivr.net/npm/xiaochu…

cdn.jsdelivr.net/npm/xiaochu…

测试

这里只写umd版本的测试,通过webpack externals react以及react-dom,使用cdn引入

image.png

image.png

发现并没有报错并且成功渲染

image.png

断点createRoot并跳进函数,成功跳进源码的函数声明

image.png

image.png

去sources里面可以看到完整的源码目录

image.png

总结

对于react源码打包其中最重要的plugin是useFork,这是动态解析react源码文件的一个插件。打包umd时需要去断点复制出对应的forks变量来初始化插件。对于打包的重写首先搞清楚打包后的产物关系,其次比较和18及其之前版本的差别,之后进行自己的逻辑的构建。

其他

  • 本文的代码位于2239559319/react
  • 打包出的代码只能用于调试不能用于生成

author: xiaochuan

于2024-12-24