blog icon indicating copy to clipboard operation
blog copied to clipboard

你可能还没试过的 react modern build 构建优化!

Open SunshowerC opened this issue 6 years ago • 1 comments

  • 前言
  • 基本实现
    • 编译
      • 编译 ES5
      • 编译 ES6
    • 嵌入代码到 HTML
    • 编译流程
    • 编译结果
  • 浏览器的存在的坑
  • react-scripts-modern
  • 参考文章

前言

用过 vue-cli 3.0 的人可能会知道,vue-cli 提供了一个 modern 模式,可以在一个工程同时构建打包 ES5 和 ES6 两份代码:

Vue CLI 会产生两个应用的版本:一个现代版的包,面向支持 ES modules 的现代浏览器,另一个旧版的包,面向不支持的旧浏览器。

最酷的是这里没有特殊的部署要求。其生成的 HTML 文件会自动使用 Phillip Walton 精彩的博文(译文)中讨论到的技术:

  • 现代版的包会通过 <script type="module"> 在被支持的浏览器中加载;它们还会使用 <link rel="modulepreload"> 进行预加载。

  • 旧版的包会通过 <script nomodule> 加载,并会被支持 ES modules 的浏览器忽略。

  • 一个针对 Safari 10 中 <script nomodule> 的修复会被自动注入。

对于一个 Hello World 应用来说,现代版的包已经小了 16%。在生产环境下,现代版的包通常都会表现出显著的解析速度和运算速度,从而改善应用的加载性能。

简单来说,通过这种技术能够

  • 让现代浏览器加载 ES6 的未编译过的代码,直接使用最新的语法特性以及新的 API,无需任何 polyfill
  • 让老版本的浏览器加载 使用 语法转换过的,以及Polyfill 过的 ES5 的代码。

这么棒的技术已经在 vue-cli 上集成了,可惜在 create-react-app 上有过一阵讨论,却依旧没什么进展。

所以我个人根据 create-react-app 2.x 的 react-scripts 做了些自己的改造,在 react 上实现了这个现代模式的构建。具体可以访问 github 仓库 : react-scripts-modern

接下来我们来看看基本实现思路。

基本实现

编译

上面说过,我们需要用 webpack 构建编译出 ES5 和 ES6 两份代码。

编译 ES5

首先是编译出 ES5 的代码,大家应该很熟悉 babel 7 那一套了,这里不再多说,这里直接给出 babel 配置代码。还对 babel 7

// .babelrc.js
module.exports = {
    "plugins": [
        [
            "@babel/plugin-transform-runtime",
            {
                "corejs": false, // 默认值,可以不写
                "helpers": true, // 默认,可以不写
                "regenerator": false, // 通过 preset-env 已经使用了全局的 regeneratorRuntime, 不再需要 transform-runtime 提供的 不污染全局的 regeneratorRuntime
                "useESModules": true, // 使用 es modules helpers, 减少 commonJS 语法代码
            }
        ]
    ],
    presets: [
        [
            "@babel/preset-env",
            {
                "modules": false, // 模块使用 es modules ,不使用 commonJS 规范 
                "useBuiltIns": 'usage', // 默认 false, 可选 entry , usage
            }
        ]
    ]
}

babel 7不是很了解的同学,可以看下官方文档或者我之前写的文章: Show me the code,babel 7 最佳实践!

编译 ES6

要编译 ES6 是不是说就可以直接不用 babel 了呢?

然鹅并不是,因为还有一些 ES7/ES8 特性是 浏览器尚未正式支持但我们确实需要的,例如:异步加载,JSX 语法等等,我们一般需要找到对应的 babel plugin 来实现,所以还是需要 babel

module.exports = {
    // ... 其他可能需要的 plugin 
    presets: [
        [
            "@babel/preset-env",
            {
                "modules": false, // 模块使用 es modules ,不使用 commonJS 规范 
                "targets": {
                  "esmodules": true, // 忽略 browserslist 配置,不转换 ES6 语法也不 polyfill ES6 的 API 
                },
            }
        ]
    ]
}

嵌入代码到 HTML

正常的构建过程,我们一般使用 HtmlWebpackPlugin 来将 webpack 构建出来的 JS/CSS 资源嵌入到 我们的 HTML 模板中。

那么,若要将我们的 HTML 的 script 标签加上 modulenomodule 属性,我们就需要额外写一个 webpack 插件,在 HtmlWebpackPlugin 的钩子中,做一些我们的处理。

  // 在 htmlWebpackPlugin 拿到资源的钩子函数中,
  // 给 script 标签加上 type=module 或者 nomodule 属性
  this.htmlWebpackPlugin
    .getHooks(compilation)
    .alterAssetTags
    .tapAsync(
      id,
      (data, cb) => {
        data.assetTags.scripts.forEach(tag => {
          // 遍历下资源,把 script 中的 ES2015+ 和 legacy 的处理开
          if (tag.tagName === 'script') {
            // 给 legacy 的资源加上 nomodule 属性,反之加上 type="module" 的属性
            if (/-legacy\./.test(tag.attributes.src)) {
              delete tag.attributes.type
              tag.attributes.nomodule = true
            } else {
              tag.attributes.type = 'module'
            }
          }
        })
      }
    )

完全的插件代码可直接访问 html-webpack-esmodules-plugin.js

编译流程

上面说到,我们其实是分别进行了 两次 webpack 编译打包构建:

(async ()=>{
    await buildByConfig(es6WebpackConfig)
    await buildByConfig(es5WebpackConfig)
})()

那么其实存在一个问题,打包一次,生成一份 js 代码,一个 index.html

那如果 webpack 打包了两次,构建出了两份 JS 代码, 那岂不是也会出现 两份 index.html ?

当然事实上虽然不会构建出两份 index.html,但是这个问题明显会导致第二次构建出来 index.html 覆盖掉 第一次构建出来的 index.html。**导致只有 ES5 或者 ES6 的资源被 外链进 index.html **,不符合我们的预期。

解决方案其实很简单,第一次构建是用 public/index.html 为模板,构建出来 build/index.html;那么,第二次构建,就明显不能用 public/index.html 为模板,而是用第一次构建生成的 build/index.html 为模板,进行第二次构建,那么即便生成的 build/index.html 模板覆盖掉了原有的 build/index.html,但仍然是包含 es5 和 es6 的代码。

编译结果

<!-- ES6 的代码,只会被 现代浏览器下载执行 -->
<script type="module" src="/js/main.min.js" ></script>
<!-- ES5 的代码,只会被老版浏览器下载执行 -->
<script nomodule src="/js/main-legacy.min.js" ></script>

浏览器的存在的坑

  1. safari 10.3 不支持 nomodule, 需要进行简单的 polyfill。

  2. 在 safari 浏览器或者 IOS webview 的场景下, 如果同时使用了 module/nomodule 和 常规的 script 外链。例如:

    <script src="https://www.google.com/some-script.js" ></script>
    <script type="module" src="/js/main.min.js" ></script>
    <script nomodule src="/js/main-legacy.min.js" ></script>
    

    由于<script src="https://www.google.com/some-script.js" ></script> 的存在, **safari 会同时下载 main.min.js(ES6的代码), main-legacy.min.js(ES5的代码),但只会执行其中一份代码(所以不会影响代码逻辑),但是下载了 ES5 + ES6 的代码,有一份代码却没有用到,终究是造成了负面影响。

    解决方案是将所有带有 type=module/nomodulescript 标签放到没有 type=module/nomodulescript 标签之前:

    <script type="module" src="/js/main.min.js" ></script>
    <script nomodule src="/js/main-legacy.min.js" ></script>
    <script src="https://www.google.com/some-script.js" ></script>
    
  3. 在更低的浏览器版本(例如 Chrome 43),会出现和 2 类似的问题,同样会下载两份代码,却执行其中一份,同时2 的解决方案对此无效。这种情况,很大程度上只能用动态插入 script 标签取代 type=module/nomodule 来解决。

    <!-- 将代码内联在 HTML  -->
    <head>
    <script>
        (function(){
            var insertScript = function(option, elem) {
                if(!option) return false;
                var s = document.createElement("script");
                elem = elem || document.head
                for(var name in option) {
                  s.defer = true
                  s.setAttribute(name, option[name])
                }
                elem.appendChild(s)
            }
            var  script = document.createElement("script");
            var supportEsModule = 'noModule' in script;
            var modernList = [{src: '/main.min.js'}];
            var legacyList = [{src: '/main-legacy.min.js'}];
            var scriptList = supportEsModule ? modernList : legacyList
            scriptList.forEach(function(item){
                insertScript(item)
            })
        })()
    </script>
    </head>
    

    但这种方法明显延缓 资源的下载时机(大概十几毫秒),虽然 现代浏览器通过 preload 还是能够实现提前下载资源避免这种方案的缺陷,但是 不支持nomodule 也不支持 preload 的老版浏览器依然会有这种缺陷。

    所以除非你的用户里老版浏览器占据了相当一部分份额,否则个人不建议这种做法。

react-scripts-modern

以上全部实现,我都已经封装到了 react-scripts-modern

习惯用 create-react-app 生成项目的同学 可以快速通过 create-react-app 工程名 --scripts-version react-scripts-modern (加上 --typescript 属性 生成 react + ts 工程) 命令生成支持 modern build 的工程,开箱即用

参考文章

  1. Deploying ES2015+ Code in Production Today
  2. Webpack 构建策略 module 和 nomodule

SunshowerC avatar Dec 18 '18 15:12 SunshowerC

非常感谢,参考你的代码成功实现了现代模式构建两套代码。 总结下基于create-react-app V5.0的经验,eject之后需要改的地方有:

  1. babel配置
  2. webpack.config.js 添加现代模式判断逻辑
  3. paths.js 添加buildHtml路径
  4. build.js 添加现代模式判断逻辑

ChrisLuckComes avatar Mar 09 '22 06:03 ChrisLuckComes