模块 4.2:Vite, npm 与前端构建

模块 4.2:Vite, npm 与前端构建

Table of Contents

上一节 4.1,我们给项目做了模块化改造,迈出了"现代前端"的第一步。这一节开始接触工具——npmVite,并认识 Node.js其中 npm 的性价比尤其高:这一节学会,以后无论做前端开发,还是使用各种开源工具,都会用到它。

这一节不再提供新代码——你手上 4.1 模块化改完的那份,就是本节的起点。要做的,全是在它身上敲几条命令、改动 package.json.gitignore,没有需要你编写的业务代码。


模块化,真的什么都好吗?

上一节我们反复强调了模块化的重要性,以及它如何让代码之间的引用关系变得清晰。但若从开发者的体验出发,模块化改造之后,真的处处都好吗?

其实未必。上一节的内容偏"规矩"(import / export 这套机制),学起来可能略显枯燥;即使没有完全消化,也不要紧——那一节真正需要记住的只有两点:

  1. 模块化势在必行:前端发展到今天,迟早要走这一步;
  2. 模块化之后,要浏览网页就必须走服务器——不能再双击 index.html 直接打开了。

而其中第 2 点——“看一眼效果都要走一趟服务器”——已经相当影响体验。而且不止这一处:到目前为止,前端这一块至少还剩三件让人难受的事


先盘一盘:4.1 之后,还剩哪些难受

4.1 我们把项目模块化了,也介绍了它的价值——它为第三方库的大规模调用铺平了道路。但跟着做下来,你应该已经亲身体会到这几件别扭事:

难受一:想看一眼效果,太麻烦了。 模块化之后,代码不能再双击 index.html 打开(ES 模块必须走服务器)。所以在 4.1 里,每改一行、想看效果,都得 push、上服务器 pull、再刷新——一整轮折腾下来,黄花菜都凉了

难受二:打开一个页面,浏览器要拉一大堆文件。 按 F12 看一眼 Network——十几个请求排成一长队。光 css 就有 8 个文件,每个都单独走一趟网络,再加上几个 js 和 anime.js……浏览器要一个个去取,慢。

难受三:改了 css,刷新却没变。 改完某个 css、保存、刷新,页面却没有动静。你以为是自己改错了,排查半天——其实是浏览器把旧文件缓存住了,根本没去取新的。这个坑,在模块 3.5 部署时就隐约碰到过。

这三件事,靠手动去管是治不利索的。好在——有一类工具,专门解决它们。


有一类工具,专门收拾这些:构建工具

先往深一层看:刚才那三件难受,背后其实是一组矛盾——工程治理、开发体验、用户体验,这三方在互相拉扯。

  • 工程治理:希望项目规范、安全、好维护、好扩展(模块化、拆文件,都是为它);
  • 开发者:希望写得简单、顺手、舒服;
  • 浏览器与最终用户:希望加载更快、资源更少、性能更高。

这三方常常彼此冲突:为了规范,牺牲了开发便利;为了开发便利,又影响运行效率……很难同时满意。那么,有没有办法让三方都尽量满意?

有——让一类工具夹在中间做"翻译":工程的规矩照守;开发者按自己舒服的方式写;最后由它把开发者写的源码,转换成浏览器想要的样子。这个转换过程就叫前端构建,这类工具就叫 构建工具(build tool)。

它具体替你做三件事,恰好一一对上那三件难受:

  • 在本地起一个服务器——改完一保存,浏览器自动更新(这叫热更新),不必再走 push、pull、刷新那一整轮;→ 治难受一
  • 把一堆碎文件合并成一个——8 个 css 合成 1 个,请求数从十几个降到几个;→ 治难受二
  • 给文件名加一段"指纹"(hash)——内容一变,名字就变,浏览器立刻知道需要重新取,缓存便失效了。→ 治难受三

这个构建工具,我们选 Vite

构建工具不止一个。早些年最有名的叫 webpack;而最近几年,前端的首选已经换成了 Vite

Vite 是个法语词,意为"快",读音是 /viːt/(不要按英文习惯读成 “vait”)。它的作者是中国人——尤雨溪。我们选它,不只是因为作者是中国人,更因为它确实是目前最优秀、最流行的那一个。

接下来要做的,就是把 Vite 请进我们的项目。但在此之前,得先铺一点底——因为 Vite 自己,也是需要"被运行"的。


想跑 Vite,先得有"运行 JS 的环境":Node.js

Vite 这个工具,本身是用 JavaScript 写的。

而此前我们接触的 JavaScript 都在浏览器里运行。Vite 不是网页,它是一个命令行工具,需要在浏览器之外、在你的电脑上运行。那么,由谁来运行它?

打一个你可能熟悉的比方:要运行一个 .py 文件,得先装 Python;要打开一个 .xlsx 文件,得先装 Excel。同样的道理——要在浏览器之外运行 JavaScript,就得先装 Node.js

Node.js 就是"让 JavaScript 跳出浏览器、在你电脑上直接运行"的运行环境。 装了它,Vite 这类用 js 写成的命令行工具才跑得起来。

此外,Node 还附赠了一个非常重要的东西——一个包管理工具,叫 npm

这个 npm,你可以理解成一个"JavaScript 的应用商店"——与模块 2、3 里在 Ubuntu 上用过的 apt 是一个意思。apt 帮你装 nginx、装 git;npm 帮你装各种现成的 js 工具和库。待会儿要的 Vite,就用 npm 来装。(之所以说"先",是因为它其实还不止是个商店——这一点等用到时再说。)

先把 Node 装上

按你的系统选择对应方式:

  • macOS:去 nodejs.org 下载 LTS 版安装包,双击安装(或 brew install node)。
  • Windows:去 nodejs.org 下载 LTS 版 .msi,一路下一步。
  • Linux:用 NodeSource 走 apt(与模块 3.5 装 nginx 同一思路):
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt install -y nodejs
    

装完后新开一个终端验证:

node -v       # 看到 v20.x.x 之类
npm -v        # 看到 10.x.x 之类

两条都能输出版本号,就说明安装成功。npm 随 Node 一起安装,不必单独安装;而且这一次装好,以后所有项目都可以使用


npm 和 apt 有个关键区别:它"只管这一个项目"

虽然 npm 和 apt 都是"应用商店",但有一处重要的不同:

  • apt 给整台电脑装东西——装一个 nginx,全系统都能用。
  • npm 默认给"当前这一个项目"装东西——这个项目用 anime.js 4.4,那个项目用 anime.js 3.0,两边各装各的、互不干扰。

正因为是"按项目来管",使用前得先明确告诉它:这里有一个项目。这个动作叫初始化。进入上一节做过模块化改造的那份项目的根目录(与 index.html 同级),运行:

npm init -y

运行完,目录里会多出一个文件 package.json,大致如下(你的内容会与此略有出入,下面会解释原因):

{
  "name": "zero-to-tech",
  "version": "1.0.0",
  "description": "> 这是 零到全栈 · 模块 4.1 …… 的配套代码—— > 网站模块化改造之前的起点版本。",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs"
}

这个 package.json,就是项目的**“npm 档案”**——npm 以后给它装了什么、它能运行哪些命令,都记在这里。

这个文件的后缀名是 .json,是本课程中第一次见到这个后缀的文件,顺便介绍一下这种格式。JSON 是一种用纯文本承载数据的通用格式——它就是一堆 "键": 值,用大括号括起来。人一眼能读懂,机器也便于解析,全世界的程序都用它来存数据、传数据。你不需要学怎么写它,认得出这个"键: 值"结构就够了。

npm init 有黑魔法吗?没有。 它做的唯一一件事,就是生成这样一份 package.json。你完全可以手动新建一个相同的文件(你在 4.1 手动新建过 main.js,是一样的操作)——只要内容是合法 JSON,npm 就认(最小的合法写法就是一对大括号 {})。加 -y 只是图省事,顺手填上几个默认值而已。

字段看着不少,但不必被它唬住——对我们这个项目而言,真正要管的只有两个,其余的全都用不上。

现在真正要管的,就这两个:

  • name:项目叫什么。默认取你的文件夹名——所以你看到的多半是 zero-to-tech。它只是个名字,对我们的项目并不关键。
  • version:项目自己的版本号,默认 1.0.0,可改可不改,也不关键。

剩下那一堆——descriptionmain,有的版本还会有 typekeywordsauthorlicense——这里不打算逐个细讲,因为对我们这个网页项目,它们全都用不上:要么是"将来把项目发布成 npm 包"时才用到,要么只是默认占位。

既然用不上,那就直接删掉package.json 就是个普通文本文件,手动删几行完全没问题(npm 也不要求这些字段必须存在)。把 nameversion 之外的都删掉,只留这两行

{
  "name": "zero-to-tech",
  "version": "1.0.0"
}

一句话收尾:删完,这份档案清清爽爽,但目前基本还是空的——没记什么有用的东西。不必着急,等下真正用 npm 装了东西,npm 会自动往这个干净的文件里写入有用的内容,那时这本账才算开始有用。


装 Vite

有了 npm、有了 package.json 这本档案,安装 Vite 只需一行命令。在项目根目录(与 index.htmlpackage.json 同级)运行:

npm install -D vite

注意这个 -D(即 --save-dev):意思是"把 Vite 装成开发依赖"。为什么强调它?因为 npm 装的东西分两类——

一类是在项目里帮你"干活"的工具,例如 Vite。它好比盖房子用的脚手架:帮你把活干完,但本身不会成为房子的一部分——上线的网站里并没有 Vite。这种"脚手架"性质的,安装时加 -D

另一类是会真正成为网站一部分的东西,例如等会儿要装的第三方动画库。它是盖房子的,会跟着房子一起上线。这种安装时不加 -D

两类的处理方式不同,等会儿装 anime 时会再说明。

命令运行完,发生了两件事——

第一,目录里多出一个 node_modules 文件夹。 Vite(连同它依赖的一大堆包)都被下载、堆放在这里。这个文件夹通常很大、文件极多——一般不必去翻它,知道"用 npm 装的东西都在这儿"即可。

你可能会疑惑:只装了一个 Vite,为什么这么多文件?因为 Vite 自己也依赖别的包,那些包又依赖别的包……一层层牵连进来一大串,全都会被装进 node_modules。你可以在 npmgraph.js.org 输入 vite 看一看它的依赖网,就能理解 node_modules 为何这么大了。

第二,package.json 有了变化,同时还多出一个 package-lock.json

你的 package.json 现在多了一段 devDependencies

{
  ...
  "devDependencies": {
    "vite": "^7.0.0"
  }
}

这一段是 npm 自动加上的——含义是"这个项目用了 vite,版本 ^7.0.0"(^ 表示"7 这个大版本里的最新,但不跳到 8",相当于帮你锁住版本范围)。这就是那本账开始记账了。

不过 devDependencies 只记录我们直接安装的这一个 vite;它所牵连的那些"连环依赖"不记在这本账上——它们各自记在自己的 package.json 里。

与此同时出现的 package-lock.json,把这次实际安装的每个包的精确版本逐一锁死,确保你换一台电脑、或同事拉下来 npm install 时,装出来的东西完全一致。你不必读懂它,知道它在"锁死版本"、并且需要提交到 git 就够了。

Vite 装哪去了?怎么运行它?

我们说 Vite 装进了 node_modules。但若去翻,你会发现那里有两个与 vite 相关的东西,二者别混淆:

  • node_modules/vite/ —— 一个文件夹,是 vite 的代码本体,即"vite 这个库住在哪儿"。
  • node_modules/.bin/vite —— 一条可执行命令,即"把 vite 运行起来的那个开关"。

为什么有这两个?因为有些 npm 包(如 vite)不只是"供你 import 的代码",它还对外提供一条命令行命令。这类包会在自己的 package.json 里声明一个 bin 字段;npm 见到后,安装时就在 node_modules/.bin/ 下放一个指向命令入口的快捷方式(专业说法叫"符号链接"),并标记为可执行。所以:

想看 vite 的代码node_modules/vite/想运行 vite 这条命令node_modules/.bin/vite


跑起来:开发服务器 + 打包

dev:随改随看

Vite 这条命令在 node_modules/.bin/vite,直接用这个路径运行它:

./node_modules/.bin/vite

Vite 当场在本地起了一个服务器,并给出一个地址 http://localhost:5173

顺带认识一个词:localhost。它看着像域名,其实代表的就是你自己这台电脑——所以这个地址的意思是"用 http 访问本机的 5173 端口",并未走出本机。

打开它——网站跑起来了(点导航在两个页面之间切换,也都正常)。更重要的是:改一行代码、一保存,浏览器自动就刷新了,不必手动、更不必部署。这就是热更新

难受一,解决了。 4.1 里看一眼效果要 push、pull、刷新一整轮;现在改完转眼就能看到。开发反馈从"以分钟计"变成"以秒计"。

build:打包成上线版本

“合并文件、加 hash"这两件,则是在上线打包时完成的。先用 Control + C(macOS)停掉开发服务器。

Windows 的 cmd 用 Ctrl + C;Windows 的 PowerShell 用 Ctrl + Shift + C。

./node_modules/.bin/vite build

运行完,多出一个 dist 文件夹:

dist/
├── index.html
└── assets/
    ├── main-Beya6efK.css     ← 你那 8 个 css,合并成了这 1 个
    └── main-_go62SDo.js      ← 你那几个 js 模块,合并成了这 1 个
  • 难受二,解决了。 8 个 css 合并成 1 个、几个 js 合并成 1 个,请求数从十几个降到几个。(你可能注意到:anime.js 此刻还是单独从 CDN 拉取的一个请求——这条尾巴,留到本节最后再收。)
  • 难受三,解决了。 注意文件名上挂的那串"乱码” main-Beya6efK.css——它就是 hash,由文件内容计算而来。内容一改,名字就变,浏览器一看名字不同,立刻知道"需要重新取",不会再拿旧缓存敷衍你。

你可能还会发现:dist 里怎么只有 index.html、没有 text-lab.html?这是因为 Vite 打包时默认只认根目录那一个 index.html 作为入口。要让两个页面都打包进来,需要给 Vite 加一个"我有两个入口"的配置——但这一步本节不展开。原因是:下一节我们会把项目改成 React,届时两个页面会合成一个 index.html(页面之间靠组件切换),这个"多入口"的麻烦根本就不存在了。所以这个问题先留着,交给 React 去收。

preview:上线前本地验一眼

dist 是打包好的产物,但不要双击 dist/index.html——它里头仍是 <script type="module">,依然不能用 file:// 双击打开(与 4.1 同理,模块必须走服务器)。要在本地查看打包结果是否正确,用:

./node_modules/.bin/vite preview

它会起一个服务器,专门伺服 dist。打开后按 F12 看 Network——干净利落,只有几个请求。

注意:preview 看的是 dist 里的真实产物。而上面说过,dist只有 index.html、没有 text-lab.html——所以在 preview 中点导航,是切不到"文字实验室"那一页的(这正好印证了上面那个"多入口"的坑)。

(补充一句:打开 dist/index.html 会看到 Vite 给 script 加了 crossorigin 标记,那是正经走 http 时的 CORS 处理,此处不必深究。)


一条要记住的规矩:源代码 ≠ 运行的代码

到这一步,你手上其实有了两份东西:

你维护的,是源代码——js/css/ 那些,文件多、好读、用名字 import。 真正上线运行的,是 dist——文件少、经过压缩、改了名,是浏览器认得的形态。 这是两份不同的东西,别混为一谈。

由此可得几个直接的结论:

  • 改网站永远改源代码,然后重新 build。不要手动改 dist 里的文件——下一次 build 就会把它覆盖掉。
  • dist 才是真正要送上线的那一份。 dev 用于"写代码时"(运行源码 + 热更新,图改得快);build 产出的 dist 才是"给生产服务器运行的版本"。也就是说——下一个模块(4.6 部署)真正推上线的,正是这种 distpreview 只是上线前在本地先验一眼。

./node_modules/.bin/vite 太长了:npm run 来帮忙

每次都敲 ./node_modules/.bin/vite 这一长串,实在啰嗦。

还记得 package.json 里那个 scripts 吗?它正是为此而生——给常用命令登记一个简短别名。把它改成这样:

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview"
}

以后就不必敲那一长串了,直接:

npm run dev       # = ./node_modules/.bin/vite
npm run build     # = ./node_modules/.bin/vite build
npm run preview   # = ./node_modules/.bin/vite preview

npm run xxx 的意思是"运行 scripts 里那个叫 xxx 的命令"。运行时,它会先去 node_modules/.bin 里查找有没有对应的命令——因为那里正好有 vite,所以这里写 vite 就行,不必写全路径。

这里要解开前面那个扣子。 前面我们说 npm 是个"应用商店",那它怎么还能运行我项目里的命令?这就是前面那个"先"字的伏笔:npm 不止"装、卸"这一手。apt 确实只管装卸软件,但 npm 还兼了一份"项目管家"的职责——因为它认 package.json 这本项目档案(前面就说过,它"记着这个项目能运行哪些命令")。 所以 npm run dev 运行的,并不是 npm 自己的某个功能,而是你写在 scripts 里的那条命令vite)——npm 只是翻开 package.json、照着上面写的替你执行一遍。一句话:npm run 运行的是"你的"命令,npm 不过是个照本宣科的执行者。

dev / build / preview 这三个名字是固定的吗?不是,是我们自己起的。 scripts 本质上就是一张"命令快捷方式表"——键(名字)可以随便起,值是任意一条命令(哪怕与 js 毫无关系也行,npm 只是把它交给终端去执行)。例如你完全可以加一条 "hello": "echo 你好",然后运行 npm run hello

至此,这一节的三条命令你都见过了,整理如下:

命令作用何时使用
npm run dev起本地服务器,随改随看、热更新开发时,写代码全程
npm run build打包成 dist/上线前,生成给浏览器运行的版本
npm run preview本地起服务器预览 dist上线前自测,确认产物是否正确

.gitignore:有些东西,不该提交

到这里,目录里多了两个又大又"沉"的文件夹:node_modulesdist。它们要不要提交到 Git?——不要。 它们都是**“随时能重新生成"的产物**:

  • node_modules:照着 package.json,运行一次 npm install 就能重装出来
  • dist:照着源代码,运行一次 npm run build 就能重新打包出来

既然随时可重建,提交进 Git 就是白占空间、还拖慢同步。我们用 .gitignore(模块 3.3 学过的"忽略名单”)把它们挡在门外——在根目录新建 .gitignore,写上:

node_modules
dist

这与 3.3 是同一个道理:Git 里提交的,永远是"源头"——你的代码,加上 package.json / package-lock.json 这两本账;而不提交"产物"(装出来的库、打包出来的 dist)。别人拿到源头,npm install + npm run build,就能原样复现一切。

(至于 dist 究竟怎么送上线——是推源码让服务器自己 build,还是本地 build 好只推 dist——这是 4.6 部署那一节的内容,到时再细说。)


One more thing:既然有了 npm,让它来管依赖

到这里,构建这套东西你已经全部见过了。但还记得前面留的那条尾巴吗——anime.js 至今仍是从一个 CDN 网址拉取的

回头看 cards.jsscore.js 顶上那一行:

import { animate, stagger } from "https://cdn.jsdelivr.net/npm/animejs@4/+esm";

这是 4.1 留下的写法。它能跑,但有两点并不踏实:

  • 每次打开页面,都要联网去 jsdelivr 那台别人的服务器拉取 anime——一旦没网、或那个网址哪天失效,动画就会失灵;
  • 网址里的 @4 指"最新的 4.x",哪天它小版本一更新,页面可能毫无征兆地改变行为

现在情况不同了——我们有 npm 了。 既然 npm 能把库装到本地、还能锁版本,那 anime 也没必要再拴在别人的网址上。安装它:

npm install animejs

注意这条命令——它与前面装 Vite 那条几乎一模一样,但角色截然不同。 看一眼 package.json,anime 落进的是 dependencies,而非 vite 所在的 devDependencies

{
  "dependencies": {       // ← 库:网站"运行时"真正要用的
    "animejs": "^4.4.1"
  },
  "devDependencies": {    // ← 工具:只在"开发 / 构建时"帮忙的
    "vite": "^7.0.0"
  }
}

这两段的区别,正是本节最值得带走的一个直觉——devDependencies 是"脚手架"(Vite 帮你把房子盖好,盖完即撤,上线的网站里没有 Vite);dependencies 是"房子的砖"(anime 会被打进最终产物、随之上线,网站运行时确实在用它)。我们特意把这两个安装动作隔了大半节课,就是想说明:它俩看着像,实则一个是工具、一个是库。

装进来之后还差最后一步——仅仅装进来还不算用上。回到 cards.jsscore.js,把那行长网址换成一个干净的名字:

import { animate, stagger } from "animejs";

这个 "animejs" 是个"光秃秃的名字",浏览器原本并不认——但 Vite 会在背后把它翻译成 node_modules 里的真实位置。(这一步千万别漏:只 npm install 而不改 import,代码用的仍是 CDN 那一份,装到本地的等于白装。)

改完,再 npm run build 一次,此时 dist 里那个 js 已经不一样了——anime.js 也被打了进去。于是:

  • 那条单独连 CDN 的请求,消失了
  • 你的网站,不再依赖任何别人的服务器,版本也牢牢锁在自己手里。

4.1 留下的那条 CDN 尾巴,到这里就收干净了。


这节课结束时,你至少应该理解什么

  • 4.1 之后还剩三件难受:看效果要部署一整轮、打开页面十几个请求、改了 css 被缓存坑——它们都需要一类工具来收拾。
  • 构建工具:夹在"工程治理 / 开发体验 / 用户体验"三方之间做翻译——把开发者舒服的写法,转换成浏览器要的样子;它替你"起本地服务器、合并文件、加 hash",最流行的一个叫 Vite(作者尤雨溪)。
  • Node.js:让 JavaScript 跳出浏览器、在你电脑上运行的环境(如同跑 .py 要装 Python、开 .xlsx 要装 Excel);它附赠 npm——一个"JS 应用商店",与 apt 同义,但按项目管理
  • npm init 就是生成一份 package.json(手写一个也行);其中 name / version 保留,main / type / description 等用不上、直接删掉,留一个清爽的空账本。
  • npm install 把东西下载进 node_modules,并自动记进账;你的账只列直接依赖,连环依赖不记在你头上;package-lock.json 锁定精确版本、需要提交 git。
  • 如何运行装好的工具:原始写法是 ./node_modules/.bin/vite;给 scripts 登记别名后,用 npm run dev / build / preview(这三个名字是我们自己起的,scripts 是一张自由的命令别名表)。
  • 源代码 ≠ 运行的代码:你维护源码,上线运行的是 distnode_modulesdist 都是可重建的产物,写进 .gitignore、不提交
  • dependencies vs devDependencies:库(运行时要用、会上线)vs 工具(开发时帮忙、不上线)——这也是为什么 anime 进前者、Vite 进后者。

留个尾巴:复用这件事,还是没解决

回顾这两节:4.1 用模块化治了顺序坑、全局污染;4.2 请来 Vite,把"看一眼太慢、请求太多、缓存坑"全收拾了,最后还顺手把 anime 收进本地、甩掉了 CDN。工程化这套地基,至此铺平了。

但回头看,仍有两笔账模块化和构建都没有碰,且根子在同一处——我们有两个 html 页面

  1. 那条导航栏,在 index.htmltext-lab.html 里一字不差地各写了一遍(模块化能复用 js 逻辑,却复用不了这一整块 HTML 结构);
  2. 刚才打包时也撞见了——两个 html 入口,build 还得专门配置才打得全

这两笔账,根都在"两个独立的 html"。而下一节那个新角色,恰好把它一并解决——它叫 React:它会把两个页面合成一个 index.html,页面之间靠"组件"切换。于是导航写一次便处处可用,多入口的麻烦也随之消失。

那是下一节的事了。


← 上一节:模块 4.1 现代前端第一步——模块化 | 下一节:模块 4.3 React 登场 →