In-depth Vite: Talking about the high-level features of ESM

When it comes to the development history of front-end modularization, ESM must not be missed. In addition, there are well-known CommonJS, AMD, CMD and ES6. At present, ESM has been gradually supported by major browser manufacturers and Node.js, and is becoming a mainstream front-end modular solution.

And Vite itself realizes the no-bundle in the development stage with the help of the browser's native ESM parsing capability (type="module"), that is, it can build Web applications without packaging. However, our understanding of native ESM only stays on the feature of type="module", which is somewhat narrow. On the one hand, browsers and Node.js each provide different ESM usage features, such as import maps, imports of package.json and exports attribute, etc. On the other hand, the front-end community began to gradually transition to ESM, and some packages even left only ESM products. The concept of Pure ESM swept the front-end circle, and at the same time, ESM-based CDN infrastructure also sprung up. Generally emerging, such as esm.sh, skypack, jspm and so on.

Therefore, you can see that ESM is not limited to the concept of a module specification, it represents the trend of the front-end community ecology and the future of various front-end infrastructure, whether it is a browser, Node.js or third-party package ecology on npm The development of all of them confirms this point.

Next, let's take a look at some advanced features based on ESM in browsers and Node.js, and then analyze the Pure ESM mode, what pain points exist in this mode, and how to solve these pain points.

1. High-level features

1.1 import map

In the browser, we can use the script tag containing the type="module" attribute to load the ES module, and the module path mainly includes three types:

  • Absolute path, such as https://cdn.skypack.dev/react
  • Relative path, such as ./module-a
  • Bare import is to directly write a third-party package name, such as react, lodash

The browser supports the first two module paths natively, and for bare import, it can be executed directly in Node.js, because the path resolution algorithm of Node.js will find the module path of the third-party package from the node_modules of the project, but put in It cannot be executed directly in the browser. And this way of writing is very common in the daily development process. Apart from manually replacing bare import with an absolute path, is there any other solution?

The answer is yes. The built-in import map of modern browsers is to solve the above problems. We can use this feature with a simple example:

<!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>Document</title>
</head>


<body>
  <div id="root"></div>
  <script type="importmap">
  {
    "imports": {
      "react": "https://cdn.skypack.dev/react"
    }
  }
  </script>


  <script type="module">
    import React from 'react';
    console.log(React)
  </script>
</body>


</html>

Execute this HTML in the browser. If it is executed normally, you can see that the browser has obtained the content of React from the network, as shown in the following figure:

image.png

In a browser that supports import maps, when encountering a script tag of type="importmap", the browser will record the path mapping table of the third-party package, and when encountering a bare import, it will pull the remote one according to this table Depends on the code. As in the above example, we use skypack, a third-party ESM CDN service, and we can get the ESM format product of React through https://cdn.skypack.dev/react.

Although the import map feature is simple and convenient, browser compatibility is a big problem. The compatibility data on CanIUse is as follows:

image.png

It can only be compatible with about 68% of the browsers on the market. On the other hand, the compatibility of type="module" (compatible with more than 95% of browsers), the compatibility of import map is not very optimistic. But fortunately, the community already has a corresponding Polyfill solution——es-module-shims, which fully implements all major ESM features including import maps, including:

  • dynamic import. That is, dynamic import, which is not supported by some old versions of Firefox and Edge.
  • import.meta and import.meta.url. Meta information of the current module, similar to __dirname and __filename in Node.js.
  • module preload. In the past, we would add rel="preload" to the link tag to preload resources, that is, start loading resources before the browser parses HTML. Now there is a corresponding modulepreload for ESM to support this behavior.
  • JSON Modules and CSS Modules, that is, import json or css in the following ways.
<script type="module">
// 获取 json 对象
import json from 'https://site.com/data.json' assert { type: 'json' };
// 获取 CSS Modules 对象
import sheet from 'https://site.com/sheet.css' assert { type: 'css' };
</script>

It is worth mentioning that es-module-shims is implemented based on wasm, and its performance is not bad. Compared with the browser's native behavior, there is no obvious performance degradation. You can go to this address to view the specific benchmark results.

It can be seen that although import map is not natively supported by a wide range of browsers, we can still use import map in browsers that support type="module" through Polyfill.

1.2 Nodejs package import and export strategy

In Node.js (>=12.20 version), there are generally the following ways to use the native ES Module:

  • The file ends with .mjs;
  • Declare type: "module" in package.json.

Well, when Node.js handles ES Module import and export, if it is at the npm package level, the details may be more complicated than you think.

Let's first look at how to export a package. You have two options: main and exports attributes. These two properties come from package.json, and according to Node's official resolve algorithm (see details), exports has a higher priority than main, which means that if you set these two properties at the same time, exports will take effect first . Moreover, the use of main is relatively simple, just set the entry file path of the package.

"main": "./dist/index.js"

What needs to be sorted out is the exports attribute, which includes a variety of export forms: default export, subpath export, and conditional export. These export forms are shown in the following code.

// package.json
{
  "name": "package-a",
  "type": "module",
  "exports": {
    // 默认导出,使用方式: import a from 'package-a'
    ".": "./dist/index.js",
    // 子路径导出,使用方式: import d from 'package-a/dist'
    "./dist": "./dist/index.js",
    "./dist/*": "./dist/*", // 这里可以使用 `*` 导出目录下所有的文件
    // 条件导出,区分 ESM 和 CommonJS 引入的情况
    "./main": {
      "import": "./main.js",
      "require": "./main.cjs"
    },
  }
}

Among them, conditional export can include the following common attributes:

  • node: Applicable in the Node.js environment, it can be defined as nested conditional export, such as
{
  "exports": {
    {
      ".": {
       "node": {
         "import": "./main.js",
         "require": "./main.cjs"
        }     
      }
    }
  },
}
  • import: used for import mode import, such as import("package-a");
  • require: used for importing in require mode, such as require("package-a");
  • default, a bottom-up solution, if none of the previous conditions are met, use the path exported by default.

Of course, the conditional export also includes attributes such as types, browser, development, and production. After introducing "export", let's take a look at "import", which is the imports field in package.json, which is generally declared like this:

{
  "imports": {
    // key 一般以 # 开头
    // 也可以直接赋值为一个字符串: "#dep": "lodash-es"
    "#dep": {
      "node": "lodash-es",
      "default": "./dep-polyfill.js"
    },
  },
  "dependencies": {
    "lodash-es": "^4.17.21"
  }
}

Then, you can use the import statement in your own package to import:

// index.js
import { cloneDeep } from "#dep";


const obj = { a: 1 };


// { a: 1 }
console.log(cloneDeep(obj));

Node.js will locate #dep to the third-party package lodash-es during execution. Of course, you can also locate it to an internal file. This is equivalent to implementing the path alias function, but unlike the alias function in the build tool, the aliases declared in "imports" must match in full, otherwise Node.js will directly throw an error.

2. Pure ESM

What is Pure ESM? Pure ESM was originally proposed in a post on Github, which has two meanings, one is to allow npm packages to provide products in ESM format, and the other is to leave only ESM products and abandon CommonJS and other formats.

When this concept was proposed, there were many different voices in the community, some were in favor and some were dissatisfied. But no matter what, many npm packages in the community have shown the trend of ESM First. It is foreseeable that more and more packages will provide ESM versions to embrace the trend of community ESM unification. At the same time, some npm packages are It is more radical and directly adopts the Pure ESM mode, such as the famous chalk and imagemin. In the latest version, only ESM products are provided, and CommonJS products are no longer provided.

However, for large frameworks without upper-level encapsulation requirements, such as Nuxt and Umi, there will be no problem in directly using Pure ESM; but if it is a low-level basic library, it is best to provide both ESM and CommonJS. product in a variety of formats.

Next, let's take a look at how to use ESM, we can directly import CommonJS modules, such as:

// react 仅有 CommonJS 产物
import React from 'react';
console.log(React)

There is no problem with Node.js executing the above native ESM code, but conversely, if you want to require an ES module in CommonJS, it will not work.
 
image.png

The fundamental reason is that require is loaded synchronously, and ES modules themselves have the characteristics of asynchronous loading, so the two are naturally mutually exclusive, that is, we cannot require an ES module.

So is it impossible to import ES modules in CommonJS? Not necessarily, we can import them through dynamic import:

image.png

I don’t know if you have noticed, but in order to introduce an ES module, we must change the original synchronous execution environment to asynchronous, which brings the following problems:

  • If the execution environment does not support asynchrony, CommonJS will not be able to import ES modules;
  • Importing ES modules is not supported in jest, and testing will be more difficult;
  • In tsc, the syntax of await import() will be compulsorily compiled into the syntax of require (details), which can only be bypassed by eval('await import()').

All in all, it is difficult to import ES modules in CommonJS. Therefore, if a basic low-level library uses Pure ESM, it is best for the product to be in ESM format when you rely on this library. That is to say, Pure ESM is contagious. If there are Pure ESM products in the underlying library, it is best for the upper layer to use Pure ESM, otherwise there will be various restrictions mentioned above.

But from another perspective, for large frameworks (such as Nuxt), there is basically no need for secondary packaging. If the framework itself can use Pure ESM, it can also drive more packages in the community (such as framework plug-ins) to Pure ESM, at the same time, has no restrictions on the upstream caller, but it is a good thing to promote the community ESM specification.

However, most packages on npm still belong to the category of basic libraries. For most packages, we adopt the solution of exporting ESM/CommonJS two products. Will there be restrictions on the syntax of the project?

We know that global variables and methods such as __dirname, __filename, and require.resolve in CommonJS cannot be used in ESM. Similarly, we cannot use ESM-specific import.meta objects in CommonJS. If we want to provide two How to deal with the syntax related to these module specifications?

In traditional compilation and construction tools, it is difficult for us to escape this problem, but the new generation of basic library packager tsup gives us a solution.

3. A new generation of basic library packager

tsup is a base library packager based on Esbuild, focusing on no configuration (no config) packaging. With it, we can easily produce ESM and CommonJS dual-format products, and can freely use some global variables or APIs that are strongly related to the module format. For example, the source code of a certain library is as follows:

export interface Options {
  data: string;
}


export function init(options: Options) {
  console.log(options);
  console.log(import.meta.url);
}

Since the import.meta object is used in the code, which is a variable that only exists under ESM, the CommonJS version packaged by tsup is transformed into the following:

var getImportMetaUrl = () =>
  typeof document === "undefined"
    ? new URL("file:" + __filename).href
    : (document.currentScript && document.currentScript.src) ||
      new URL("main.js", document.baseURI).href;
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();


// src/index.ts
function init(options) {
  console.log(options);
  console.log(importMetaUrl);
}

It can be seen that the API in ESM is converted into the corresponding format of CommonJS, and vice versa. Finally, we can use the aforementioned conditional export to export the products of ESM and CommonJS respectively, as shown below.

{
  "scripts": {
    "watch": "npm run build -- --watch src",
    "build": "tsup ./src/index.ts --format cjs,esm --dts --clean"
  },
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      // 导出类型
      "types": "./dist/index.d.ts"
    }
  }
}

While solving the problem of dual-format products, tsup itself uses Esbuild for packaging. It has very powerful performance and can also generate type files. At the same time, it also makes up for the shortcomings of Esbuild without a type system. It is still highly recommended for everyone to use.

Of course, back to Pure ESM itself, I think this is a foreseeable trend in the future, but for the basic library, it is not suitable to switch to Pure ESM now, and now as a transitional period, it is better to send ESM/CommonJS dual-format packages Reliable, and tools like tsup can reduce the cost of basic library construction. When all libraries have ESM products, it will be easy for us to implement Pure ESM.

Guess you like

Origin blog.csdn.net/xiangzhihong8/article/details/131465615