标签: babel

  • 复盘近期升级工具链的过程

    复盘近期升级工具链的过程

    公司希望我提升产品在移动端的体验,于是我就打开了 Lighthouse,然后看了眼代码,发现有几个问题:

    1. 移动端和 pc 端一起编译,共用一套编译配置
    2. 目标平台包括 IE11
    3. 还在使用 babel@6
    4. 全量引用 babel-polyfill
    5. CDN 没有完全 http2

    最后一项联系运维同学解决就好了,我开始尝试解决其它几项。

    0. 目标

    1. 移动端和 pc 端采用不同的编译配置
    2. 尽量用最新的工具链
    3. 兼容 IE11

    1. 尝试升级到 babel@7

    babel@6 停留在 4 年前,存在着各种各样的问题,包括本身的实现和兼容代码都有问题。我希望先升级到 babel@7,以便使用 useBuiltIns: 'usage',减少打包后的代码量。

    实际结果很不理想。升级后代码膨胀了很多,经过研究,发现原因是 core-js 策略比较保守,所以多引用了很多兼容性代码,远远多于 babel@6。

    比较详细的分析可见:babel@6 升级到 babel@7,兼容性代码膨胀的原因

    2. 尝试从 webpack 迁移到 esbuild

    接下来尝试离开 webpack + babel 体系,使用 esbuild。先打包一套 es6 代码,供移动端和现代化浏览器使用,然后再生成一套兼容性代码,给 IE11 使用。

    相比于 webpack + babel,esbuild 的优势是统一+快。按照官网统计,它的速度可能是 webpack 体系的 100 倍。它的问题是只支持 ES6,也不支持扩展,不像 babel 那样,加个插件就能什么标准都支持。

    这次尝试我也失败了。原因倒不是因为 IE11,而是原来的架构跟 webpack 深度耦合,比如:lazy-loading、分割模块、i18n,等。所以迁移成本非常高,发现情况不对之后只好止损。

    3. 使用 esbuild-loader 替换 babel-loader

    webpack 暂时不能放弃,那就用 esbuild-loader 吧,毕竟要快很多。于是就遭遇到上面说的规范问题。

    1. esbuild 只支持标准规范,原本可以通过 babel plugins 支持的特性现在都不支持了,需要改回去
    2. 一些写的不规范的代码,比如变量先使用后定义,在 var 能跑,但是在 letconst 阶段就跑不了,也得改回去

    经过一段时间的折腾,这个尝试还是比较成功。构建时间缩短了一半以上。

    4. 尝试用 babel-standalone 编译 IE11 代码

    上面一步只算完成一半,因为还不支持 IE11。接下来我们计划使用 babel-standalone 实时转译代码。

    babel-standalone 提供在 JS 运行时里实时编译代码的功能,比如做一个在线编辑器,或者应用里内嵌了 V8 等运行时,希望提升兼容性,就可以用这个工具。

    这个尝试失败了,因为 IE11 的 JS 运行时效率太差,我们生产级别的 JS 直接就卡死了,且优化不能。

    5. 尝试用 SWC 在编译时生成 IE11 代码

    SWC 跟 esbuild 比较类似,是另一套新生态尝试,基于 Rust 开发,也能提供极高的编译效率,且支持 ES3。

    我希望能把需要在 IE11 运行的几个工具组件单独编译一套,然后根据浏览器入口加载不同的 JS。在开发阶段,则通过 swc-loader 提供兼容性代码。

    重构工具链的过程也挺费时,现有构建工具链实在是反人类。不过最终还是完成了,真正使得这次尝试又失败的原因是 SWC 本身的 bug。几个无法 work around 的 bug 导致我只能放弃这个方案。

    详见:初试 SWC(Speedy Web Compiler)

    不过,SWC 最新版本已经修好了我提交的 bug,大家有机会试试吧。

    6. 用 babel 在编译时生成 IE11 代码

    最后回到 babel+babel-loader。为了体积考虑,我决定继续使用 babel@6。

    然后我发现,目前的代码架构实在是,哎……相信看过 Git 操作特定分支的小技巧 一文的同学都明白我在痛苦什么。

    所以又跟项目结构搏斗许久,终于基本完成了这次重构。

    7. 结果

    这次重构达成了几个目的:

    1. 移除了全量 babel-polyfill,只有 IE11 继续加载
    2. 大部分模块用 ES5 构建,但是不加载 polyfill
    3. 少数模块,不需要兼容 IE11 的使用 ES6 构建

    具体数字没机会统计,就不列了。

    (更多…)
  • 初试 SWC(Speedy Web Compiler)

    初试 SWC(Speedy Web Compiler)

    SWC 是一个用 Rust 写的编译工具,功能跟 babel 很类似。它的优势在于速度,按照官网所说,在单核上它的速度是 babel 的 20倍,四核上是 babel 的 70 倍。

    我最近尝试升级工具链,第一步是使用 esbuild-loader 替代 babel-loader,比较顺利。但是 esbuild 只支持 ES2015,为了支持 IE11,我们还需要再转译一次。这次我想试试 SWC。

    安装与配置

    安装 swc 很简单。我打算 build 的时候用 @swc/cli 转译;debug IE11 的时候把 swc-loader 套在 esbuild-loader 前面输出。所以需要安装三个包:

    nom i @swc/core @swc/cli swc-loader -D

    接下来添加配置文件 .swcrc

    {
      "sourceMaps": false,
      "module": {
        "type": "umd"
      },
      "minify": true
    }

    默认目标平台 target: 'es5',不需要再写。其它语言选项也走默认即可。

    SWC sourceMap 支持三种配置:truefalseinline,意思一看就明白。但目前 bug 比较多,即使设置 sourceMap:false,也只是不生成 sourcemap 文件,对应的链接标记仍然会生成。

    module.type 有另一个 bug:即使目标平台是 ES5,它也会使用 ESM 引入依赖,导致语法解析出错。比如代码中使用了 async function,SWC 转译时就需要 import regeneratorRuntime from 'regenerator-runtime'。所以上面我配置 module.type"umd",一方面避免错误的 ESM import,另一方面可以通过全局方式引用这些库。

    minify: true 表示我们希望 SWC 转译代码时顺便把它们压缩一下。官方文档说这个功能还不是很完善,不过我暂时没发现明显的问题。

    swc 游乐场

    SWC 官方提供预览网站:SWC Playground – SWC。我们可以把源码贴上去,查看编译结果。也可以调整选项,看看不同选项对结果的影响。

    问题

    把 SWC 放进工具链的过程还算顺利,不过接下来使用的时候却遭遇了不少问题。

    首先就是上文说的 ESM 导入的问题。默认情况下 SWC 会 import 'regenerator-runtime',这个问题必须解决。考虑到这个代码特征比较明显,起初,我打算直接字符串过滤掉。后来翻了文档,又在 playground 里尝试了一下,发现 UMD 应该可以解决问题。

    然后是语法问题。下面这段代码 SWC 编译错误:

    // 输入
    let a = 10;
    for (let b = 0; b < a; b++) {
        let c = 0, b = 10, d = 100;
        console.log(b);
    }
    
    // 输出
    var a = 10;
    for (var b = 0; b < a; b++) {
        var c = 0, b = 10, d = 100;
        console.log(b);
    }

    这段代码其实有点意思,我看了很久才找到问题:let 声明变量时,for () 相对于下面的代码块,是上级 context;而 var 时,它们处于同级 context。所以下面这段循环只会运行一次,而不是像上面一样,执行 10 次。

    (这段代码很能考察 letvar 的不同,我准备把它加入我的面试题库。)

    还有其它一些语法问题,因为上面这个问题无法绕过,意味着使用 SWC 这条路不可行,所以我也没有深入研究,就不一一列举了。

    总结

    SWC 用户量太小,未知 Bug 很多;开发团队不大,又是用 Rust 写的,也给想贡献代码的前端社区带来很大阻碍。所以目前还难以应对大型项目、工业级别的需求。

    不过它的速度的确很快,对改进项目构建速度有很大帮助。希望那些 bug 能尽快修复,我们可以早点把它应用到产品当中。

  • 偶然发现 babel@6 + babel-preset-es2015 的 bug

    偶然发现 babel@6 + babel-preset-es2015 的 bug

    这两天尝试做一套解决方案,能够只编译一套 ES2015+ 代码,在现代浏览器就正常使用,在 IE11 自动切换到 babel-standalone 实时编译。

    然后偶然发现 babel@6 的一个 bug,如下:

    class T {
      foo() {
        const {hasOn: o} = (() => {
          for (let e = 0; e < 1; e++) {
            if (o = 1, 2 >= o) return null;
          }
          var o;
        })() || {};
      }
    }
    
    t = new T();
    t.foo();

    上面这段代码其实是符合语法的。看起来,L3 使用 const 声明了变量 o,然后在 L5 又再次试图给它赋值,似乎有修改常量之嫌。但其实,因为 L5 在另外一个块域里,而且 L7 var o; 会产生变量提升,所以这个赋值操作的是 var o 声明的局部变量。

    如果你把它放在 V8 里,比如 node.js 或者 Chrome 浏览器,它就能正常执行;如果你用 babel@6 + babel-preset-es2015 转译,就会报错,说 o is read only

    产生这个错误的原因是 babel-plugin-transform-es2015-classes 解析上面这段代码时存在错误,导致 babel-plugin-check-es2015-constants 认为常量被修改。不过我也只查到这一步,暂时不知道怎么修复这个 bug,也不知道怎么在 babel@7 里检查这个问题。有兴趣的同学可以试一下。

  • babel@6 升级到 babel@7,兼容性代码膨胀的原因

    babel@6 升级到 babel@7,兼容性代码膨胀的原因

    最近尝试把厂里项目的依赖从 babel@6 升级到 babel@7,发现打包之后体积大了很多。于是打开 webpack-bundle-analyzer,果然大部分代码都是 corejs 引入的,项目本身的逻辑只占少部分。

    从报告来看,虽然目标浏览器的版本均高于 Promise 的启动版本(比如 Chrome 32),但 es.promise 仍然会被打包进来。于是以 es.promise 为突破口开始分析,找到答案:因为 JavaScript 引擎 V8 直至 v6.6 版本时,在 Promise 实现方面都存在严重 bug,所以 babel@7 保守地选择 Chrome 66/67 作为临界点。

    想来其它体积多半也是这么加上来的,就不再一个一个排查了。 ​​​

    这就很难处理。不升级吧,新特性兼容不了;升级吧,包体积变大,公司上层又未必同意。

    下一步试试 esbuild 吧,或者回头手动打包一套 polyfill。目前设想的方案是:

    1. 用 babel 之类的工具提取出所有特性
    2. 根据 caniuse 生成必须的特性列表
    3. 像上面说的 Promise,因为 bug 所以必须兼容,我们就不考虑了,可以反过来加一条 eslint 规则规避
    4. 最终生成新的 polyfill 打包进来
    (更多…)
  • 正确使用 @babel/preset-env 与 @babel/plugin-transform-runtime

    正确使用 @babel/preset-env 与 @babel/plugin-transform-runtime

    这几天又在折腾项目脚手架,看到之前的配置里用到 transform-runtime,于是就想研究下。找了半天,发现现有的文章、讨论都没有准确、合理的说明,所以写篇博客介绍一下。

    定位

    @babel/preset-env

    @babel/preset-env 是一大堆插件的集合,包含了当前浏览器环境下,所有语言特性的插件,可以根据 browserslist 的结果,选择合适的插件将新语言特性转译成旧浏览器可以支持的表达方式。

    官方推荐使用预制件(preset),可以大大节省配置时间,提高开发效率。

    @babel/plugin-transform-runtime

    babel 转译时,往往需要用到一些工具函数,比如 _extend。转译以文件为单位,意味着每个文件里都可能有重复的 babel 工具函数。现代化项目常有大量的文件,就可能产生重复和浪费。

    @babel/plugin-transform-runtime 可以帮我们把直接写入文件里工具函数变成对 core-js 的引用,可以减少转译后的文件大小。

    使用场景

    从上面的定位可以看出,@babel/plugin-transform-runtime@babel/preset-env 并不重合,两者的功能没有交集。

    对于任何需要考虑兼容性的项目,我们都应该使用 babel 进行转译,并使用 @babel/preset-env 降低配置的复杂度,大部分通用的配置如下:

    module.exports = {
      'presets': [
        [
          '@babel/preset-env',
          {
            'corejs': 3, // 使用 core-js@3 版本,core-js@2 从 ES2017 之后就没再更新了,不推荐使用
            'useBuiltIns': 'usage', // 只转译用到的新语言元素
            'bugfix': true, // v7.9 之后引入的新选项,尽量减小转译后的代码体积,v8 之后会变成默认选项
          },
        ],
      ],
    };

    小型项目可以到此为止,中大型项目、文件数量比较多的,可以引入 @babel/plugin-transform-transform。此时我们需要先安装两个依赖——注意,这俩依赖都要装:

    # 用来开发、debug 和编译
    npm i @babel/plugin-transform-runtime -D
    
    # 用来在生产环境中使用
    npm i @babel/runtime
    # 如果要使用特定的 core-js 版本,也可以安装特定的 runtime
    npm install --save @babel/runtime-corejs2
    npm install --save @babel/runtime-corejs3

    接下来,修改上面的 babel.config.js,加入插件配置:

    module.exports = {
      // 重复上面的配置
      "plugins": [
        // 其它插件
        [
          "@babel/plugin-transform-runtime",
          // 一般情况下我们使用默认配置即可
        ],
      ],
    };

    完成。

    库项目

    库类项目也推荐使用 @babel/plugin-transform-runtime,因为库项目通常会面临另一个问题。如果我们直接导入 core-js 作 polyfill 的话,像 PromiseSetMap 这样的全局对象就会被覆盖。对于一般的应用而言,问题不大;但如果是库,你无法预期其它开发者会在什么情况下使用你的库,很可能他的目标平台都支持这些新语法元素,不希望转译污染。

    此时,使用 @babel/plugin-transform-runtime 可以让 babel 在转译时使用沙箱包装 polyfill,避免污染全局。

    参考文档

  • 解决 mocha 测试时 `cannot use import statement outside a module` 错误,以及配置 travis

    解决 mocha 测试时 `cannot use import statement outside a module` 错误,以及配置 travis

    前些天同事突然发现一个库项目的测试无法运行,报错的内容大概是:cannot use import statement outside a module "should",即无法在模块外使用 import 导入内容。这个错误比较奇怪,简单 Google 之,基本上大家的解决方案都是使用 <script type="module"> 即在浏览器里用 ESM 加载 JS,这明显和我们的环境不同。

    Node.js 当然也支持 ESM,不过这应该也不是问题症结。大体上我可以判断,因为测试集(JS 文件)用到 import 语法,而且用 mocha --require @babel/register 启动测试,所以应该是 Babel 没有正确转译导致的问题。

    检查 Babel 的相关配置,发现同事为了能同时编译现代浏览器和 IE 两个版本的库,.babelrc 大概是这样的:

    {
      "env": {
        "default": {
          "presets": [],
        },
        "withie": {
          "presets": [],
        }
      }
    }

    猜测 mocha 走了 default 分支,然后没有转译,所以出错。解决方案就是添加 node 分支,以当前 node 版本为 target,这样该转译就转译,不转译就用原生,性能更好,修改好的配置大概是这样的:

    {
      "env": {
        "default": {"presets": []},
        "withie": {"presets": []},
        "node": {
          "presets": [
            [
              "@babel/preset-env",
              {
                "targets": {
                  "node": "current"
                },
                "useBuiltIns": false
              }
            ]
          ]
        }
      }
    }

    使用时,需要增加环境变量用来切换配置:BABEL_ENV=node mocha --require @babel/register。比较奇怪的是,其他脚本使用 BROWSERSLIST_ENV 切换,这里只能使用 BABEL_ENV,我暂时不知道为什么。

    修改之后的脚本就可以正常测试了。接下来我打算给它加上 Travis,这样就能自动 lint + 测试,比较方便控制质量。加 Travis 很简单,拷过来一个 .travis.yml 改吧改吧就行了,但是第一次运行失败了,而且是超时。经过研究,原来 mocha 从 v4 开始,完成测试后不会自动退出,除非手动指定,方法是增加 --exit 参数。

    所以最终的测试脚本是(其它配置略去):

    {
      "scripts": {
        "test": "BABEL_ENV=node mocha --require @babel/register --exit",
      }
    }

    最终 Travis 配置是:

    sudo: required
      dist: trusty
     
    
      language: node_js
      node_js:
        - 14
     
    
      branches:
        only:
          - master
     
    
      cache:
        directories:
          - ~/.npm # cache npm's cache
          - ~/npm # cache latest npm
     
    
      install:
        - npm ci
     
    
      script:
        - npm run lint
        - npm run test
  • 解决“Error: Rule can only have one resource source (provided resource and test + include + exclude)”

    解决“Error: Rule can only have one resource source (provided resource and test + include + exclude)”

    又有一台服务器到期,不想续了,所以把东西往另一台服务器上搬。其中有一个小服务,用来存储 CI 测试失败的截图。里面有用到 font-awesome,但只用一个图标,太浪费。所以这次就顺手换成了 bootstrap-icons,然后顺便更新依赖,结果,再编译的时候,就报错:

    ERROR  Error: Rule can only have one resource source (provided resource and test + include + exclude) in {
       "exclude": [
         null
       ],
       "use": [
         {
           "loader": "/Users/meathill/Projects/mini-store-admin/node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js",
           "options": {
             "cacheDirectory": "/Users/meathill/Projects/mini-store-admin/node_modules/.cache/babel-loader",
             "cacheIdentifier": "219fb45a"
           },
           "ident": "clonedRuleSet-38[0].rules[0].use[0]"
         },
         {
           "loader": "/Users/meathill/Projects/mini-store-admin/node_modules/babel-loader/lib/index.js",
           "options": "undefined",
           "ident": "undefined"
         }
       ]
     }

    非常诡异,按照错误栈点进去,从代码可以推断是生成的 webpack config 有问题,在 babel-loader 配置里既包含 resource 又包含 exclude。但是,这个项目是通过 @vue/cli 创建的,所以它的配置也是 @vue/cli-service 自动生成的,我并没有修改过。而且只有这个项目有问题,其它项目,同样使用 @vue/cli 创建,但是没有更新依赖,就没问题。

    Google 之,有一些关于这个错误的讨论,但几乎都是用户自己写 webpack.config.js 没写好出问题。有人建议 rm -rf node_modules & rm package-lock.json & npm i,即重装所有依赖,我试了两次,也不行。

    开始尝试、推翻、再尝试、再推翻。后来怀疑到 webpack ,在 package-lock.json 里查找之,发现安装的版本竟然是 5.1.0,而没有更新过依赖,可以正常编译的项目里都是 4.x。那基本可以确认了。

    1. 先删掉 node_modulespackage-lock.json
    2. 手动在 package.jsondevDependencies 里添加 "webpack": "^4.44.2"
    3. 重新安装全部依赖: npm i
    4. 尝试编译,npm run build,发现问题解决

    总结

    我猜问题是这样的:某些新版本的库要求 webpack@5,更新依赖时,根据依赖选择的规则,就以 webpack@5 作为主依赖安装。然而 @vue/cli 依赖 webpack@4,它自带的 webpack 配置无法兼容 webpack@5 ,于是就报错,不能继续编译。如果你也在使用 @vue/cli,那么请不要贸然升级 webpack@5。

  • 解决“[ERR_PACKAGE_PATH_NOT_EXPORTED]: No “exports” main resolved”

    解决“[ERR_PACKAGE_PATH_NOT_EXPORTED]: No “exports” main resolved”

    周末例行升级系统,今天打开项目,npm run dev,就报这个错误。检查代码,没变化,依赖也没变化。因为错误位置在 main.js,尝试给它加上 exports,无果。

    Google 之,发现一个非常新的 issue:https://github.com/babel/babel/issues/11216,3天前,来自 @babel/babel 仓库,多半是了。

    点进去一看,原来 node.js 从 13.10.1 之后,对 package.json 里的 exports 属性解读出现问题,继而导致 Babel 抛出错误。最简单的解决方法就是升级 Babel 到 7.8.4。

    升级后问题解决。

  • 基于 @vue/cli 的项目配置 browserslist

    基于 @vue/cli 的项目配置 browserslist

    前些日子虽然写了 最近折腾 @babel/preset-env 的一些小心得,但其实没有正确的理解和配置 browserslist,所以今天问题又来了。

    (更多…)
  • 最近折腾 @babel/preset-env 的一些小心得

    最近折腾 @babel/preset-env 的一些小心得

    近来厂里的项目越来越多,代码共享必不可少。我现在采取的方案是:

    1. 把公共组件拿出来,开一个新仓库
    2. 使用 webpack 进行打包编译,libraryTarget: 'umd'
    3. 将打包编译的代码一起提交到仓库
    4. 使用 npm i <owner>/<repo> -S 安装依赖,因为我厂的仓库均为私有,所以不能发布到 NPM

    这套方案简单好用,实操效果良好。接下来我希望优化打包结果,于是研究了打包配置项,下面是我的一点心得。

    [2021-04-07] 更新:

    我们目前采用 GitHub Registry 托管私有 packages 的方案,比上面直接安装仓库的方案更好,想了解的同学可以看 使用 GitHub Registry 托管私有 NPM 源

    (更多…)