标签: esm

  • 使用 Vite 建立灵活的外部仓库

    使用 Vite 建立灵活的外部仓库

    0. Vite 与 ESM

    与 Webpack 不同,Vite 以 ESM 为其唯一的模块管理规范。首先,在开发环境,它会把每个文件编译成独立的 ESM 模块,实现非常快速的热加载。其次,编译打包时,它默认的目标环境是 ES2019,支持 ESM,所以模块打包后,也会使用 ESM 加载。

    这给我们带来一个好处:如果用 Vite 开发项目,并且对其进行分包,构建之后放到线上(比如发布到 NPM);接下来我们就可以在其它项目中,使用 ESM 方式加载这个项目的代码。

    举个简单的例子。lodash 有一个同步发布的 lodash-es 包,功能完全一致,只是使用 ESM 构建,我们可以直接在代码中 import forEach from 'https://unpkg.com/lodash-es@4.17.21/forEach.js' 引用。一方面可以节省我们自己的带宽;另一方面如果用户在其它应用里使用过同一个库,就可以提高速度。

    1. Vite 分包

    Vite 把构建过程委托给 Rollup,所以构建时分包需要传参给 rollupOptions

    在某个项目中,我需要整合一批 codepen 上的绘图效果,这些效果都放在 /src/effects/ 目录下,所以我就要检查这个目录,并且生成对应的分包配置。

    export default defineConfig(async () => {
      // 从 v10.10 开始,node.js 的 `fs.readdir()` 函数支持 `withFileTypes` 参数,使用这个参数可以直接返回 `fs.Dirent` 对象,类似使用 `fs.stat()` 得到的 `fs.Stats` 对象。方便我们判断对象类型
      const files = await readdir(effectsDirectory, {withFileTypes: true});
      // 把目录下的内容分为两类,一个是基础类库,一个是不同特效
      const [effects, baseFiles] = files.reduce(([effects, base], file) => {
        const {name} = file;
        if (file.isDirectory()) {
          effects.push(name);
        } else if (file.isFile()) {
          base.push(name);
        }
        return [effects, base];
      }, [[], []]);
    
      return {
        build: {
          rollupOptions: {
            manualChunks(id) {
              // effects/some-effect 下的文件按目录分别打包
              const effect = effects.find(effect => id.includes(`/${effect}/`));
              if (effect) {
                return effect;
              }
              // 效果基类打包成一个文件,因为效果只需要基类,所以从主体剥离
              const baseFile = baseFiles.find(base => id.endsWith(base) && !/p5/i.test(id));
              if (baseFile) {
                return 'BaseEffect';
              }
              // p5 是个很大的效果库,官方不提供 esm 包,只能单独打一个
              if (/p5/i.test(id)) {
                return 'p5';
              }
              // 其它依赖正常打包,只在本项目中使用,不会被引用
              return id.includes('node_modules') ? 'vendor' : 'chuck';
            },
          },
          // 这个第3节会解释
          target: 'es2020',
        },
      },
    }
    

    2. 去掉文件名中的 hash

    Vite 很贴心的帮我们给生成的文件都加上了 hash。在独立项目中,给文件名加 hash 可以有效避免缓存问题;但是作为外部仓库的话,无法确定的 hash 会增加业务项目的开发难度,所以我希望构建时输出到特定版本号的目录里,然后去掉文件名中的 hash。

    这个操作同样需要修改 rollupOptions。rollup 有三个不同的选项分别处理不同的命名,这里我们可以忽略入口文件(entryFileNames),只改剩下两个。

    export default defineConfig(() => {
      return {
        build: {
          rollupOptions: {
            output: {
              // 资源文件,包括 css
              assetFileNames: 'assets/[name].[ext]',
              // 分包文件
              chunkFileNames: '[name].js',
            },
          },
        },
      };
    });

    3. 动态加载 CSS

    使用 Vite 开发时,我们同样可以在代码里 import 样式等非 JS 素材。构建时,Vite 会把它们处理后放在合适的地方。

    可惜的是,Vite 并不会帮我们自动加载分包后的素材。需要我们手动处理。这时就要利用 import.meta.url,它会返回当前模块的 URL,配合前面的的文件名策略,我们就可以完成动态加载,而不需要业务项目的开发者手动处理。

    但是 Vite 默认的版本基线是 ES2019,并不支持 import.meta.url,所以我们需要把 build.target 设置成 ES2020 或以上。

    let isCssLoaded = false;
    
    // 只有未加载且处于生产环境才加载 css。
    if (!isCssLoaded && __IS_PROD__) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      // 这一步非常重要,因为 vite/rollup 有 bug,会把 `import.meta.url` 翻译成 `self.location`,导致出错
      const baseUrl = import.meta.url;
      link.href = new URL('./assets/particle-orb.css', baseUrl).toString();
      document.head.appendChild(link);
      link.onload = () => {
        isCssLoaded = true;
      }
    }

    4. 总结

    新的技术选型总能给我们带来新的可能,ESM 之后,我们在项目之间复用代码也有了新的选择,赶紧用起来吧。

    如果你在使用 Vite 或者 ESM 时遇到什么问题,欢迎提问。如果有什么经验,也欢迎分享。

  • node.js 里 ESM 与 CommonJS 的区别

    node.js 里 ESM 与 CommonJS 的区别

    可能大部分同学并不会直接用 node.js 开发 Web 后端程序,但是作为现代化前端,我们日常的各种开发都严重依赖开发脚手架,也即 node.js 环境下的各种工具链。目前已经有一些仓库逐步迁移到 ESM,所以了解一下 node.js 里 ESM 和 CommonJS 的区别也很有必要。

    我还是老习惯,后文列举出的差异并非文档记录,而是我在实操中遇到的大坑小坑,希望记下来能节省将来的时间和大家的时间。

    0. 设计原则

    我们可以把 CommonJS 理解成按需加载,需要什么就加载什么,加载进来就执行。所以可以动态加载、条件加载、循环加载。

    ESM 倾向于静态加载,方便解析依赖,优化运行效率。所以起初不能条件加载或者循环加载。不过后面考虑到实际需求,还是开放了 import() 做动态加载。

    这个设计原则可以导出后面的诸多不同。

    1. package.json

    package.json 里添加 type: 'module' 可以开启本项目的 ESM。不写或者 type: 'commonjs' 则继续使用 CommonJS。

    如果没有此配置,虽然我们代码中写的 好像是 ESM,但其实都会被 Babel 或其它什么工具转译成 CommonJS,最终运行的并不是 ESM。这点一定要注意。

    2. __dirname

    ESM 不再支持用 __dirname__filename 获取正在执行的 JS 文件在系统中的路径。作为替代方案,可以使用 import.meta.url 获取当前文件的 URL,不过返回结果是 file:// 协议,如果要继续使用 __dirname 可以这样:

    import {dirname} from 'path';
    import { fileURLToPath } from 'url';
    
    function getDirname(url) {
      const __filename = fileURLToPath(url);
      return dirname(__filename);
    }

    3. 解构

    使用 CommonJS 时,我们可以直接对导出的对象进行解构,比如:

    // lib.js
    module.exports = {
      foo: 'bar',
    };
    
    // index.js
    const {foo} = require('./lib');

    这样的用法在 ESM 中不可行。ESM 解构只能针对使用 export foo = 'bar' 这样主动暴露出的属性。对于一般对象的解构,我们只能写成:

    // lib.js
    export default {
      foo: 'bar',
    }
    
    // index.js
    import lib from './lib';
    
    const {foo} = lib;

    4. .mjs 文件与 .cjs 文件

    我们知道,node.js 加载模块时可以省去文件扩展名,比如 require('./foo'),不需要写最后的 .js.json,node.js 会自动去目录里查找对应的文件。

    node.js 会根据 type 的不同使用不同默认策略加载 js,我们也可以使用特定扩展名要求 node.js 在加载时使用特定模块类型。比如,我们使用 ESM 时,node.js 会把加载进来的 JS 都当 ESM 处理,如果这些 JS 还在使用 CommonJS 加载其它 JS,就会报错(前面说了,ESM 里不支持 require)。此时,我们可以把目标文件的扩展名写为 .cjs,node.js 就会当它是 CommonJS 来处理了。

    此功能在使用第三方库的时候很有用。比如 Postcss,它会加载项目里的配置文件,但它只支持 CommonJS,这时,如果执行时因没有 require 报错,就可以把配置文件的扩展名改成 .cjs

    5. 顶层 await

    开启 ESM 之后,可以使用顶层 await,省去一个异步函数。

    (这是促使我使用 ESM 的主要原因)

    6. importrequire

    ESM 中,我们可以使用 import 导入 CommonJS 模块和 ESM 模块;但是 CommonJS 的 require 只能用来导入 CommonJS 模块。如果要在 CommonJS 中导入 ESM 模块,需要使用 import() 然后异步处理。

    自然,ESM 里不能用 require

    7. 其它区别

    这些区别我在实际开发中没有遇到,大家自己阅读吧:Modules: ECMAScript modules | Node.js v17.8.0 Documentation (nodejs.org)

    (更多…)
  • Webpack 不支持 `import.meta`,利用 ESM 在浏览器里使用 yargs

    Webpack 不支持 `import.meta`,利用 ESM 在浏览器里使用 yargs

    前些天遇到一个需求:解析 curl 请求,并转换成 ajax 请求由浏览器发出去。

    我觉得这个需求听起来不算稀罕,理论上应该有现成的库。于是在 npm 找了一下,发现 curlconverter 似乎可以满足需求。但是使用的时候报错:Module parse failed: Unexpected token

    这个错误很奇怪,看起来像是 loader 没配好。打开报错的文件位置,怎么看语法都没问题。尝试修改 webpack 配置,也未果。因为项目是 vue-cli 创建的,在如何查看最终配置上也浪费很多时间。

    最后继续诉诸 Google,关键词换来换去,终于在搜索 mjs Module parse failed: Unexpected token 时找到这个 issue:https://github.com/arnog/mathlive/issues/525,继而找到 https://github.com/webpack/webpack/issues/6719,终于确定,这是 webpack 的问题。

    因为 webpack 不支持 import.meta,所以会把 import.meta 当成语法错报告。我觉得这个行为很扯,因为 loader 配错也会报这个,所以对于第一次接触到这个问题的开发者(比如我)而言,会浪费大量时间在那些初级错误的搜索结果里。

    接下来解决问题。

    curlconverter 虽然不能直接使用,但仔细阅读它的代码,其中 https://github.com/NickCarneiro/curlconverter/blob/master/util.js 解析 curl 命令的功能实现应该问题不大,我只要想办法把 yargs 加载进来即可。而 yargs 支持浏览器 ESM 加载,所以我在页面里添加了 <script type="module" src="./yargs.js">,使用如下代码:

    // 虽然 yargs 已经到 16.2,但是 16.0.4 之后的版本都有问题
    import Yargs from 'https://unpkg.com/yargs@16.0.3/browser.mjs';
    
    // 加载完成 yargs 之后,把它挂载到 window 上
    window.Yargs = Yargs;

    将 yargs 挂到 window 上,成为外部库。然后在 vue.config.js 里配置 externals:

    module.exports = {
      chainWebpack: config => {
        config.externals.yargs = 'commonjs Yargs';
      }
    }

    接下来,将前面说的 utils.js 复制到本地并修改其中 parseCurlCommand 的实现,最终完成了需求。


    总结一下:

    1. 使用 yargs 解析命令行请求比较方便,远比自己写方便
    2. yargs 无法配合 webpack,据说可以配合 rollup 或者 snowpack,在我的 vue-cli 项目中,需要使用一些特殊的手段加载
    3. curlconverter 也很好用,可惜不能直接用
  • 解决 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
  • 解决“[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。

    升级后问题解决。

  • 在 Node.js 12 中使用 ESM

    在 Node.js 12 中使用 ESM

    Node.js 12 之后开始支持 ECMAScript Modules(简称ESM),不过并不是默认开启或者自动切换。坦率地说我也卡了一阵子才搞清楚怎么直接使用。简单记一下吧。

    (更多…)