Snowpack:一个高性能的前端构建工具

2,081 阅读6分钟

这篇文章,我们介绍下 Snowpack - 特别是刚发布的 Snowpack 3 版本。Snowpack 已经在社区中得到了很多关注,与 webpack 不同,它提供了一个不一样的实现方式!让我们一起来学习下吧~

构建工具的发展史

在开始 snowpack 之前,我们需要明白为什么会有 webpack 这样的打包工具出现。在 ES2015 模块出现前,JavaScript 缺乏模块系统,所以在浏览器中,最接近模块化的方式是将代码注入全局环境并分割成文件,这就是我们文件间共享的方式。

window.APP = {}

window.APP.Authentication = {...}
window.APP.ApiLoader = {...}

当 Node.js 降临并普及时,它有一个 CommonJS 形式的模块系统:

const Authentication = require('./Authentication.js')
const APILoader = require('./APILoader.js')

这种模式流行起来后,人们希望在浏览器中使用它。这正是工具萌现的时候。它们可以将一个使用 CommonJS 模块的应用打包成一个大的 JavaScript 文件,删除所有的 require ,然后在浏览器中执行。Browserify 是我印象中第一个这样的工具,当时感觉太神奇了!接着,webpack 出现了...

随后,当 ES Modules 出现的时候,人们更热衷于它,但有两个问题:

  1. 虽然已有标准规范,但是浏览器不支持 ESM;
  2. 即使浏览器支持了 ESM,你可能还希望上生产环境前进行打包,因为如果将每个模块都定义为单独的文件,则浏览器需要花费时间加载它们。

Webpack(或者其他构建工具)支持 ESM,但是它们始终将你的代码打包成一个文件,无论是开发环境还是生产环境。那么一个典型的工作流是:

  1. 项目中编辑一个文件;
  2. webpack 找到更改的文件,并重新打包应用;
  3. 你可以刷新浏览器查看你的修改。通常,这些都是由 webpack plugin hot module 来完成。

这第 2 步,在应用规模增长时会有问题。webpack 发现文件更改后,确认哪些部分需要重新打包到主包中,这需要花费时间。对于大型应用来说,这可能会导致编译速度极慢。那么 Snowpack 就是来解决这个问题的😆~

Snowpack 的实现方式

对我来说,Snowpack 的关键卖点就是文档中这句话:

Snowpack serves your application unbundled during development. Each file needs to be built only once and then is cached forever. When a file changes, Snowpack rebuilds that single file.

Snowpack 充分利用了所有主流浏览器支持 ESM 的特性,并且在开发环境不会去捆绑打包,而是将每个模块作为一个单独文件提供,让浏览器通过 ESM 来导入应用程序。有关浏览器及非打包 ESM 的更多细节,请参考:Using ES Modules in the Browser today

需要注意的是,你必须使用 ESM 来使用 snowpack,你不能在你的应用程序中使用 CommonJS。

这就会出现一个问题:如果你从 npm 上安装了一个 CommonJS 的依赖包怎么办?尽管我希望有一天大部分的 npm 包都能作为 ESM 发布,但离这个目标还有很长的时间。实际上,即使你只用 ESM 开发你的应用,在某些时候你也可能需要用到 CommonJS 编写的依赖。

幸运的是,snowpack 也可以解决这个问题!当你引用了一个 node_modules 文件夹中的依赖(比如 react)时,它会将该依赖打包到 snowpack 自己的迷你包中并支持 ESM,然后使用 ESM 的方式导入这个迷你包。

看到这里你就会明白 snowpack 为什么这么吸引我。让我们来在开始学习使用它吧!

Snowpack 入手实践

开始

首先,创建一个空的项目文件夹,进入项目,运行 npm init -y 来初始化 package.json 文件。

安装 snowpack 依赖到 dependency:

npm install --save-dev snowpack

然后,在 package.json 的 scripts 中加入两个命令:

"scripts": {
  "start": "snowpack dev",
  "build": "snowpack build"
},

这里创建了两个 npm run 命令:

  • npm run start: 执行 snowpack 的开发模式
  • npm run build: 执行 snowpack 的生产构建,后面会详细讲~

当运行我们的应用程序时,snowpack 会在本地启动一个小型开发服务器。它将查找index.html 文件,所以让我们创建一个 index.html 文件,同时创建 app.js,它现在只会将 hello world 日志打印到控制台:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Snowpack testing</title>
</head>
<body>

  <script src="./app.js"></script>
</body>
</html>
console.log('hello world')

现在我们执行 npm run start

你可以在你的终端中看到这些信息:

snowpack

  http://localhost:8080 • http://172.18.162.123:8080
  Server started in 80ms.

▼ Console

[snowpack] Hint: run "snowpack init" to create a project config file. Using defaults...
[snowpack] Nothing to install.

它告诉我们 snowpack 在 http://localhost:8080 中运行。下一行提示我们需要创建一个 snowpack 的配置文件(后面会做),我想说一下最后一行:

[snowpack] Nothing to install.

这是 snowpack 告诉我们它检查了所有需要处理的 npm 模块,但它没有找到。稍后,我们将添加一个 npm 包,看看 snowpack 是如何处理它的。

生成配置文件

可以使用 npx snowpack init 命令生成配置文件。目前我们不需要改变 snowpack 的默认配置,直到我们后面进入生产阶段。你也可以查阅 snowpack 配置文档,让 snowpack 按照你想要的方式运行。

ES Modules 编码

让我们创建另一个 JavaScript 文件,看看 snowpack 如何处理多个文件。我创建了api.js,它导出一个函数,该函数接受用户名,并从 GitHub 获取一些公共存储库:

export function fetchRepositories(user) {
  return fetch(`https://api.github.com/users/${user}/repos`)
    .then(response=> response.json());
}

然后,在 app.js 中,我们可以导入并使用这个函数。你可以用你自己的用户名替换我的GitHub 用户名!

import {fetchRepositories} from './api.js';
fetchRepositories('imyangyong').then(data => console.log(data));

保存后,在浏览器的控制台中,你会看到一个错误:

Uncaught SyntaxError: Cannot use import statement outside a module

这是因为我们在 html 文件中 <script> 标签:

<script src="./app.js"></script>

因为 ESM 在 ESM 模式和非 ESM 模式下的行为略有不同,所以浏览器不可能在所有脚本中都支持 ESM。这样做肯定会破坏一些现有的网站,而 JavaScript 的主要目标之一就是任何新特性都要向后兼容。否则,每一个新的 JS 特性都可能破坏数千个现有的网站!

为了使用 ESM 模式,我们只需要在 script 标签加一个 module 的类型就可以:

<script type="module" src="./app.js"></script>

当你保存该文件时,你的浏览器应该会自动刷新(snowpack 热更新,开箱即用),在控制台中你将看到 GitHub 存储库列表。

安装 npm 依赖

让我们看看 snowpack 是如何从 npm 安装包的。我将用 Preact 将我们的存储库列表呈现到屏幕上。首先,让我们安装它:

npm install --save preact

然后,编辑 app.js,让它在屏幕上渲染出 Hello world:

import {fetchRepositories} from './api.js';
import {h, render} from 'preact';

fetchRepositories('jackfranklin').then(data => {
  render(h('p', null, 'Hello world'), document.body);
});

注意,我使用 h helper 来创建 html,而不是使用 JSX。我这样做是为了提高速度,让示例运行起来。在本文稍后的部分,我们将切换到 JSX,看看 snowpack 是如何处理它的,所以请抓紧时间。

现在运行 npm run start,snowpack 将打印出:

[snowpack] ! building dependencies...
[snowpack] ✔ dependencies ready! [0.33s]

你可以看到,snowpack 找到了 Preact,然后把它打包成一个 ESM 的包供我们使用。如果你查看 Network 会看了对 app.js, api.jspreact.js 的请求。这是 snowpack 从 Preact 依赖中为我们创建的文件。snowpack 这种方式的好处是,创建了 Preact 文件并缓存起来,Preact 包更新才会改变它。Preact 本身是个依赖,通常情况下,基本不会改变。这就是 snowpack 开发的敏捷性之一。

支持 jsx/tsx

Snowpack 对许多语法和文件类型有很好的支持(开箱即用)。它自身确实支持 jsx / tsx,但是有一个条件:所有 jsx / tsx 必须在 .jsx/.tsx 后缀名文件中定义。否则的话,你必须使用 babel plugin 来构建(详细信息,请查看文档),但我一直喜欢使用 .jsx / .tsx。让我们创建一个新的 jsx 文件,其中包含我们的 Preact 组件 repo-list.jsx:

import {h} from 'preact';

export function RepoList(props) {
  return <ul>{props.repos.map(repo => {
    return <li><p>{repo.name}</p></li>
  })}</ul>
}

注意,尽管我们没有直接调用 h helper,但我们需要导入它,这样 snowpack 就不会假定我们在使用 React。

接着,在 app.js 中来渲染我们的组件:

import {h, render} from 'preact';
import {fetchRepositories} from './api.js';
import {RepoList} from './repo-list.jsx';

fetchRepositories('jackfranklin').then(data => {
  render(h(RepoList, { repos: data }, null), document.body);
});

我们的代码库列表就会显示在屏幕上。

生产构建

默认情况下,运行 snowpack 生产构建会生成像开发环境一样的 ESM 模块项目,所以这只支持现代浏览器(支持esm)。若要支持传统浏览器,就必须将模块打入 bundle 中(在 snowpack 生产构建指南中有进一步的解释)。snowpack 的宗旨是成为一个 ESM 多文件构建工具,而不是一个完全的打包器。在写这篇文章的时候,snowpack 正在通过 esbuild 提供内置的打包服务,但是文档中说这仍然是一个实验性的过程,不应该应用在大型项目。

相反,推荐使用 snowpack 提供的另一个 plugin:

注意,你不需要手动去安装其他包。这些是 snowpack plugins,你可以在你的 snowpack 配置文件中进行配置。然后,当你运行 snowpack build 时,snowpack 会调用 webpack/rollup 来打包你的应用。

webpack 构建

我们很快就会看到 snowpack 内置的 esbuild 支持,但目前使用其中一个插件是一个简单的解决方案,也是推荐的做法。让我们来设置 snowpack 的 webpack 插件,以便在生产环境中编译时压缩代码。首先,我们安装它:

npm install --save-dev @snowpack/plugin-webpack

如果你还没有 snowpack 的配置文件,执行 npx snowpack init 生成默认的配置文件 snowpack.config.js。在 plugins 中:

plugins: [
  ['@snowpack/plugin-webpack', {}]
],

尽管 webpack 插件可以开箱即用,你也可以增加一些自己的额外配置。现在,当我们运行 npm run build时,snowpack 会发现我们已经相应地添加了 webpack 插件和对应的包,从而为我们生成一个可以发布的、优化的、压缩的 bundle 包。

webpack 插件提供的一件开箱即用的好处是 "tree shaking" , 避免将不需要的代码添加到我们的最终包中。

如果我们在 api.js 中导出并定义一个从未使用过的函数,就可以看到这一点:

export function fetchRepositories(user) {
  return fetch(`https://api.github.com/users/${user}/repos`)
    .then(response=> response.json());
}

export function neverUsed() {
  console.log('NEVER CALLED')
}

我们再次执行 npm run build, 在 build/js 文件夹中的 app.[hash].js 文件中,我们搜索不到 NEVER CALLED 的存在。webpack 很聪明,知道我们从未用过这个函数,所以将其删除掉。

esbuild 构建

虽然 snowpack 对 esbuild 的支持目前在实验性阶段,但是为了了解 esbuild 得到友好支持后的样子(参阅 esbuild 文档了解更多关于 esbuild 及其路线图的细节),让我们配置一下。首先,从你的 snowpack.config.js 文件中删除所有的 webpack 插件配置,并添加一个 optimize 对象:

plugins: [
],
optimize: {
  bundle: true,
  minify: true,
  target: 'es2018',
  treeshake: true,
},

现在,当你执行 npm run build,esbuild 将接管构建任务,创建一个完全优化压缩的 build/app.js 文件。它也进行了 tree shaking,所以函数 neverUsed 最终也被删除掉。

目前,如果你需要完全健壮的、经过实战测试的打包构建,我会坚持使用 webpack 插件,但对于自己的小型项目,可能值得进一步探索 esbuild。

结论

Snowpack 为我提供了一个非常棒的开发体验,这让我非常渴望在另一个项目中再次尝试它。我知道在这篇文章中我们使用了 Preact,但是 snowpack 支持许多其他的库,包括React、Svelte 等,你可以在网站上找到更多的信息。

如果你以前没有使用过 snowpack,我强烈建议你试一试,并在接下来的几个月和几年里关注 snowpack。如果在不久的将来它会成为大多数开发者使用的工具,我也不会感到惊讶😆。

下面是 GitHub 上的一个实用的 snowpack 演示,演示了 snowpack 在开发模式下如何作为模块打包器工作,以及它如何在生产中(通过 webpack / esbuild)构建代码。

参考