在浏览器中打包 TypeScript 系列1:ES 模块和导入映射Import map

533 阅读6分钟

原文地址

这是“在浏览器中打包 TypeScript 系列”的第 1 部分。

第 2 部分:在浏览器中打包 TypeScript

JS打包简史

让我们绕个小弯,看看在使用 ES 模块之前是如何使用 JS 的。 (年份为近似值)

1. 黑暗时代(2010年之前)

只要有 JavaScript,就可以通过脚本标签引入 JavaScript。您只需导入一堆会污染全局名称空间的脚本,并希望所有脚本都已加载。

2、黎明曙光(2010-2018)

随着 2009 年 Node.js 的首次发布,事情开始变得更加光明,JS 社区也变得更加活跃。大约在同一时间,我们看到了几个标志性项目,例如 RequireJs 和 browserify。

RequireJS 是一个在浏览器中异步导入模块的库。 Browserify 是类似 Node.js (CommonJS) 模块的 JS 打包器。 Browserify 带来了很多实用工具,比如 uglify、reactify、sassify、babelify、likify-this-post 等。

下边是过去定义模块的稍微简化的方法:

if(typeof define === "function" && define.amd) {
  // RequireJS module
  define("jquery", [], function() {
    return jQuery;
  });
} else if (typeof module === "object" && typeof module.exports === "object") {
  // CommonJS module (Node.js / Browserify)
  module.exports = jQuery;
} else {
  // Export to global namespace
  window.jQuery = jQuery;
}

是的,这是很多步骤,每个库都必须提供一个支持不同导出机制的包。

3.王者时代(2018-2022)

Webpack 于 2014 年发布,但花了一些时间才获得关注。但一旦它成功了,它就成为了 JS 项目的唯一构建工具。 Webpack 引入了加载器的概念。

loader 是一个 Webpack 插件,支持导入非 JS 文件。例如,如果你想导入 CSS,你需要一个 css-loader。

Webpack 允许您自定义很多东西,最终它成为问题和投诉的主要来源。人们可以弄清楚如何使用它正确配置构建管道。然后,随着每个主要版本的 API 发生变化,因此每个加载的内容都需要由其作者进行更新,这反过来又修改了加载器的 API,因此陷入了无底的兔子洞……

React 团队尝试通过 create-react-app 项目“改善”这种情况。很多人似乎都喜欢这个项目。我不是他们中的一员。 create-react-app 将 Webpack 隐藏在干净的界面后面。他们的 webpack 配置文件几乎有 800 行长!这对我来说太复杂了,我总是更喜欢手动编写 webpack 配置文件来仅配置我需要的东西。

Webpack 解决了很多问题,例如打包、使用插件、导入 CSS 等。但随着时间的推移,它的缓慢性和错误配置加载器的挫败感已经变得太多了。幸运的是,每年都有新项目不断出现,让生活变得更加简单。

4. 未来已来(2022年-)

顺便说一句,现在是 2023 年!这本质上意味着未来已经开始。

事实证明,Node.js 速度慢,不像本机代码那么快。所以出现了 ESBuild(用 Go 编写)和 swc(用 Rust 编写)可以让我们不需要等待 JS 构建完成几秒钟。这可以在几毫秒内完成。

Vite 和 Turbo 等项目使用混合方法。他们为工作选择合适的工具。

需要说明的是我没有写过关于Rollup的内容

ES 模块

我们非常习惯使用 JS 打包工具、转译器等来构建我们的 UI 项目。但如今我们真的需要这些工具带来的额外复杂性吗?我们可以只导入我们编写的代码吗?

答案是:“这取决于”。

It Depends book

首先让我们弄清楚如何导入代码。现在所有浏览器都支持 ES 模块。 JavaScript 中管理模块的标准机制。

如果您想了解详细信息,我强烈推荐这个精彩的解释。出于本博文的目的,我将仅讨论使用 ES 模块。

让我们深入研究代码!让我们从定义一个模块开始:

// name.js
export const name = 'James Bond';

这很简单。我们有一个名为 name.js 的文件,它导出一个名为 name 的变量。这个文件本身并不是很有用。实际的好处来自于我们可以导入这个文件。只要导入的 URL 相同,浏览器就会缓存该模块并重用它。

// main.js
import { name } from './name.js';
console.log(`Hi from JS ${name}!`);
<!-- index.html -->
<html>
  <head>
    <title>Sample page</title>
    <script type="module">
      import { name } from './name.js';
      console.log(`Hi from HTML ${name}!`);
    </script>
    <script type="module" src="./main.js"></script>
  </head>
</html>

我们的 ES 模块可以有一段代码,该代码将在模块第一次导入时执行。浏览器控制台将打印两条信息:

Hi from JS James Bond!
Hi from HTML James Bond!

在上面的示例中,我们导入了 name.js 模块两次:从 main.js 和 index.html 导入。浏览器构建一个模块导入 URL(例如 http://localhost/name.js )并缓存它。由于这种缓存机制, name.js 被下载并执行一次。 URL 中的查询参数将使模块变得唯一。

导入依赖项

如果您的项目很小并且没有外部依赖项,那么直接使用 ES 模块是一个很好的起点。

让我们看看如果添加单个依赖项会发生什么。 Lodash 提供了我经常使用的方便的 debounce 实现。将此导入片段粘贴到浏览器控制台中:

const { default: debounce } = await import('https://unpkg.com/lodash-es@4.17.21/debounce.js');

单个函数导入可获取 14 个文件!这就是问题开始出现的地方。幸运的是,unpkg.com 不是我们唯一的选择。

await import('https://esm.sh/lodash-es@4.17.21/debounce.js');

Esm.sh 将模块打包到单个文件中。此导入将请求数量减少到 2。如果我们指定直接导入 URL,我们可以将请求计数减少到 1。

Import map

现在我们已经了解了基础知识,让我们来谈谈外部依赖项。现有的打包程序要么导入相对文件,要么从 node_modules 目录导入依赖项。如果未找到文件或依赖项,则构建过程中会失败。

然而,在浏览器中,我们没有构建阶段。网络已经通过 URL 解决了这个问题。让我们回到主模块并假设我们从 http://localhost 下载它。

// main.js
import { name } from './name.js';
// OR import { name } from 'http://localhost/name.js';
console.log(`Hi from JS ${name}!`);

浏览器构建一个模块 URL http://localhost/main.js 并相对 http://localhost/name.js 导入 name.js 。

从 ES 模块导入时,必须使用相对路径或完整 URL。

那么第三方依赖怎么样?

遗憾的是,这里没有灵丹妙药。您可以供应商依赖项并自行托管它们,也可以从 CDN 提供商(如 esm.sh、jspm、unpkg 或 skypack)导入它们。

我们可以通过相对路径或完整的 URL 导入。这是否意味着每次我们想要导入 lodash 或其他库时,我们都需要使用 esm.sh/lodash-es@4… 怪物?不!这正是Import map 要解决的问题。

<script type="importmap">
{
  "imports": {
    "lodash-es": "https://esm.sh/v124/lodash-es@4.17.21"
  }
}
</script>

如果您在任何导入之前包含上述importmap,那么您可以自由使用 lodash-es 。

Import maps不仅允许您为导入添加别名,还允许您覆盖依赖项的导入。

请注意,Import maps仅供应用程序使用。Import maps不能嵌套。如果您正在开发一个库,那么您将需要使用不同的机制来管理依赖项。 (查看 Deno 的推荐)

动态导入

模块可以静态和动态导入。静态导入必须位于文件的开头。它们始终被解析,并且导入路径中不能有任何变量。另一方面,动态导入允许我们在导入路径中选择任何策略。静态导入是一个语句,而动态导入是一个返回 Promise 的函数。

// main.js
const { name } = await import('./name.js');
console.log(`Hi from JS ${name}!`);

从字符串导入

事实证明,我们甚至可以从字符串导入模块。 (请随意将下面的代码片段粘贴到您的浏览器控制台中)

// Define our module
const code = `export const name = 'James Bond';`;
// Create a URL object
const blob = new Blob([code], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
// Import
const module = import(url);
URL.revokeObjectURL(url); // Garbage collect

// Use imported module
const { name } = await module;
console.log(`Hi from JS ${name}!`);

TypeScript

在我们开始使用 TypeScript 之前,ES 模块在浏览器中工作得很好。遗憾的是,浏览器目前无法执行 TypeScript。幸运的是,这个问题并不像听起来那么复杂。当浏览器导入模块时,它会向服务器发送一个简单的 GET HTTP 请求。然而,服务器可以即时转译 TypeScript 代码,并以浏览器可以解析的 JavaScript 文件进行响应。

// Transpile our TS file
const body = await Deno.readTextFile(filePath);
const res = await esbuild.transform(
  body,
  {
    loader: "ts",
  },
);
// Now we can respond with a JS file

我将在下一篇博客文章中详细介绍 ESBuild。