前文记录了 JavaScript 模块化和 npm。模块化让我们可以拆分自己的代码,npm 让我们可以管理第三方依赖。

但 npm 安装的 package 并不能直接交给浏览器使用,这就引出了构建工具的需求。本文以 Vite 为例,简单了解前端构建工具解决的问题。

1. 构建工具

构建工具主要解决的是:源码和浏览器最终运行的文件之间存在差距。

浏览器原生的模块导入要求路径是它能理解的 URL。例如相对路径可以:

1
import { createTodoItem } from "./todo.js";

完整 URL 也可以:

1
import confetti from "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/+esm";

但是 npm 安装的 package 通常会放在 node_modules/ 中。如果直接在浏览器里写:

1
import lodash from "lodash";

这里的 "lodash" 既不是相对路径,也不是完整 URL。浏览器默认并不知道应该去 node_modules/ 中寻找 lodash,也不知道这个 package 的入口文件在哪里,所以这种写法不能直接交给浏览器运行。

除了模块路径解析,开发源码和最终网页之间还可能有其它差异。浏览器最终需要的是 HTML + CSS + JavaScript + 静态资源,但是开发时写的可能是:

  • 多个 JS/TS 模块;
  • npm package;
  • CSS 预处理或 CSS module;
  • 图片、字体等资源引用;
  • 开发服务器和热更新需求。

构建工具的任务,就是在开发阶段把这些东西组织起来,并在发布阶段生成适合部署的静态文件。更直观地说,它负责把“开发时方便写的源码”转换成“浏览器能加载的资源”。

页面越接近浏览器原生能力,就越不需要构建工具;项目越接近工程化应用,就越需要构建工具。

如果页面很简单,其实完全不需要构建工具。例如:

1
2
3
index.html
style.css
main.js

只使用浏览器原生支持的 HTML、CSS、JavaScript,直接在 HTML 中引用 CSS 和 JS:

1
2
<link rel="stylesheet" href="./style.css" />
<script src="./main.js"></script>

这种情况下浏览器可以直接加载这些文件,不需要 npm,也不需要 Vite。即使使用浏览器原生的 ES Module,只要模块路径都是明确的相对路径,例如 ./main.js,也不一定需要构建工具。

但是当项目开始使用 npm package,或者需要更完整的开发体验时,构建工具就变得很自然。它需要解决的问题包括:

  • import "lodash" 怎样映射到实际文件?
  • 多个模块之间的依赖图怎样组织?
  • TypeScript 等源码怎样转换?
  • CSS 和图片等资源怎样被打包或复制?
  • 开发时怎样启动本地服务器并快速刷新?
  • 发布时怎样生成 dist/?

在下面这些情况下,通常会需要构建工具:

  • 使用 npm package,并希望直接通过 import xxx from "xxx" 引入;
  • 使用 TypeScript、Sass、PostCSS 等需要转换的源码;
  • 项目文件较多,希望有模块依赖分析、代码分割、压缩等生产构建能力;
  • 希望开发时有本地服务器、HMR、错误提示等工具支持;
  • 使用 React、Vue 等框架,尤其是需要 JSX、单文件组件等语法。

最后一条会在下一篇 React 中继续展开。这里先只关注构建工具本身。

2. Vite

Vite 是一个前端构建工具。同类工具还有 Webpack、Parcel、Rollup、esbuild 等,但是现在从零开始写一个普通前端小项目时,Vite 是比较常见的选择。

Vite 的常用命令通常写在 package.json 中:

1
2
3
4
5
6
7
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

开发时执行:

1
npm run dev

它会启动 Vite 开发服务器。浏览器访问的是这个本地服务,Vite 负责按需处理源码、解析模块,并在修改代码后快速更新页面。

发布前执行:

1
npm run build

它会执行生产构建,通常生成:

1
2
3
4
5
dist/
├── index.html
└── assets/
├── index-xxxx.js
└── index-xxxx.css

dist/ 才是最终用于部署的构建产物。它不是可执行程序,而是一组给浏览器加载的静态资源。

构建后还可以执行:

1
npm run preview

这个命令用于在本地预览刚刚生成的 dist/,检查生产构建结果是否正常。它不是正式的生产服务器。

3. 创建项目

如果只想创建原生 JavaScript 项目,可以选择 vanilla 模板:

1
2
3
4
5
npm create vite@latest todo-vite
# 选择 vanilla
cd todo-vite
npm install
npm run dev

项目创建完成后,vanilla 模板的目录结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
todo-vite/
├── node_modules/
├── public/
│ ├── favicon.svg
│ └── icons.svg
├── src/
│ ├── assets/
│ ├── counter.js
│ ├── javascript.svg
│ ├── main.js
│ └── style.css
├── index.html
├── package.json
└── package-lock.json

其中:

  • package.json 记录项目依赖和 dev、build 等命令;
  • node_modules/ 保存 npm 安装的 package;
  • public/ 保存不需要经过 Vite 处理、可以直接复制到构建结果中的静态资源;
  • src/ 保存主要源码,默认模板中的图片和计数器代码只是演示内容;
  • index.html 是浏览器访问页面时的入口。

与普通静态网页不同,Vite 把 index.html 也看作项目源码的一部分。默认的入口 index.html 非常简单,通过模块脚本引入 src/main.js:

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>todo-vite</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

浏览器仍然先加载 HTML,再根据这个标签加载 main.js。main.js 又可以继续导入 CSS、其它 JS 模块或 npm package、静态资源:

1
2
3
4
5
import './style.css'
import javascriptLogo from './assets/javascript.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import { setupCounter } from './counter.js'

执行 npm run dev 时,Vite 根据这些 import 处理模块及资源;执行 npm run build 时,Vite 再沿着相同的依赖关系生成 dist/。

4. TodoList 示例

默认页面只是 Vite 提供的演示。现在把它改成 TodoList:删除无用资源,保留 index.html 中的 #app,重写 style.css,再把业务拆成 main.js 和 todo.js。

改造后的源码结构如下:

1
2
3
4
src/
├── main.js
├── style.css
└── todo.js

加入 Vite 以后,仍然可以写原生 DOM 操作。Vite 只负责开发服务器、模块处理和构建流程,不负责 UI 本身如何组织。

例如 src/todo.js:

1
2
3
4
5
6
7
8
9
10
11
export function createTodoItem(text) {
const item = document.createElement("li");
item.textContent = text;

const button = document.createElement("button");
button.textContent = "删除";
button.addEventListener("click", () => item.remove());

item.appendChild(button);
return item;
}

src/main.js 可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import "./style.css";
import { createTodoItem } from "./todo.js";

const app = document.querySelector("#app");

app.innerHTML = `
<h1>TodoList</h1>
<form id="form">
<input id="input" />
<button type="submit">添加</button>
</form>
<ul id="list"></ul>
`;

const form = document.querySelector("#form");
const input = document.querySelector("#input");
const list = document.querySelector("#list");

form.addEventListener("submit", (event) => {
event.preventDefault();

const text = input.value.trim();
if (!text) {
return;
}

list.appendChild(createTodoItem(text));
input.value = "";
});

最后把默认的 src/style.css 替换为 TodoList 的样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
* {
box-sizing: border-box;
}

body {
margin: 0;
min-width: 320px;
min-height: 100vh;
font-family: sans-serif;
background: #f5f5f5;
}

#app {
width: min(90%, 480px);
margin: 80px auto;
padding: 24px;
background: #fff;
border: 1px solid #ddd;
}

h1 {
margin-top: 0;
}

form {
display: flex;
gap: 8px;
}

input {
flex: 1;
min-width: 0;
padding: 8px;
}

button {
padding: 8px 12px;
cursor: pointer;
}

ul {
margin: 20px 0 0;
padding: 0;
list-style: none;
}

li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #eee;
}

这个例子里,Vite 解决的是:

  • 启动本地开发服务器;
  • 处理 import "./style.css" 这类资源引用;
  • 处理 JS 模块之间的依赖关系;
  • 构建时生成 dist/。