原文地址
这是“在浏览器中打包 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 项目。但如今我们真的需要这些工具带来的额外复杂性吗?我们可以只导入我们编写的代码吗?
答案是:“这取决于”。
首先让我们弄清楚如何导入代码。现在所有浏览器都支持 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 或其他库时,我们都需要使用 https://esm.sh/lodash-es@4.17.21 怪物?不!这正是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。