webpack & vite
webpack 基本概念
- chunk 模块
- JS 模块有 esm, cjs, umd, ...
- CSS 模块有 @import
- webpack 模块有 esm, cjs, amd, assets, wasm
- entry 入口文件: (模块 chunk), 默认值是
./src/index.js
作为构建依赖图的起点 - output: 指定创建的 bundle 的输出目录, 输出文件名, 默认输出目录是
dist
, 主要输出文件的默认值是./dist/index.js
- loader: webpack 原生支持加载 JavaScript 和 JSON 文件, loader 使得 webpack 可以加载其他类型的文件, 并转换为有效的模块;
webpack.config
中, loader 有两个属性:- test 属性, 匹配转换的文件 (使用正则表达式匹配转换的文件时, 不要加引号)
- use 属性, 指定转换时, 使用哪个 loader
- plugin 插件: 用于转换某些类型的模块, 而插件可以执行其他任务, 扩展 webpack 能力, 例如注入环境变量, 资源管理, 打包优化
- mode 模式: 可以是 development, production 或 none, 设置 mode 参数以开启 webpack 在对应模式下的优化, 默认是 production
// webpack.config.js
module.exports = {
output: {
filename: "my-first-webpack.bundle.js",
},
module: {
// webpack 打包器遇到 require()/import 导入 .txt 文件时, 先使用 raw-loader 转换
rules: [{ test: /\.txt$/, use: "raw-loader" }],
},
};
// webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack"); // 用于访问内置插件
module.exports = {
mode: "production",
module: {
rules: [{ test: /\.txt$/, use: "raw-loader" }],
},
// 生成一个 html 文件, 并自动注入生成的所有 bundle
plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })],
};
移除 package.json
中的 main: index.js
入口, 添加 private: true
防止意外发布代码
// package.json
{
"main": "index.js",
"private": true
}
webpack 打包简单原理
// index.js
const lodash = require("lodash");
import Vue from "vue";
// webpack 打包后
(function (modules) {
function webpack_require() {
/** implements... */
}
modules[entry](webpack_require);
})({
"index.js": (webpack_require) => {
const lodash = webpack_require("lodash");
const Vue = webpack_require("vue");
},
})``;
webpack 支持多种模块化 (esm, cjs, umd), 兼容性更好, vite 使用 esm 实现按需加载和 hmr 模块热更新
vite
冷启动 webpack 开发服务器时, webpack 需要先打包所有文件; vite 将 app 中的模块分为依赖和源码两类
- 依赖: 依赖的代码通常是不改变的纯 JavaScript, 也可能有多种模块化格式 (esm, cjs, umd), vite 使用 esbuild 预构建依赖
- 源码通常有非纯 JS 文件: .css, .scss, .ts, .tsx, .vue 等, 通常会有修改, 不是所有的源码都需要加载, 例如路由组件
vite 以原生 esm 方式提供源码 <script type="module" src="/src/main.js"></script>
, 可以理解为浏览器接管了部分打包工作; 浏览器请求源码时, vite 拦截, 转换 (使用 esbuild 转换为 JS, 转换导入路径等), 并按需提供转换后的源码, 按需提供: 只有当前页面使用时, 才提供转换后的源码
pnpm create vite@latest
做了什么
# 全局安装 create-vite@latest 脚手架, 等价于 pnpm install create-vite@latest
pnpm create vite@latest # 创建 vite 项目
pnpm create vite@latest --template vue # 创建 vue 模板项目
pnpm install vite -D # 手动创建 vite 空项目, vite 开箱即用 (out of box)
- 默认 esm 导入模块时, 要么使用绝对路径, 要么使用相对路径
- esm 不会搜索 node_modules: 因为是客户端, 需要避免大量的网络请求 (对应下面的 vite 依赖预构建第 2 点)
- cjs 会搜索 node_modules, 因为是服务端, 直接从磁盘读取, 不需要发送网络请求
import { defineComponent } from "vue";
// 对于源码, vite 会转换导入路径
import { defineComponent } from "/node_modules/.vite/deps/vue.js?v=bb0c94a1";
vite 项目结构
.
├── env.d.ts
├── index.html # vite 项目的入口文件
├── package.json
├── pnpm-lock.yaml
├── public
│ └── favicon.ico
├── src
│ ├── App.tsx/App.vue
│ └── main.ts/main.tsx
└── vite.config.ts
{
"scripts": {
"dev": "vite", // 启动开发服务器, 别名 vite dev, vite serve
"build": "vite build", // 生产模式打包
"preview": "vite preview" // 预览生产模式打包
}
}
- 开发模式使用 esbuild 打包源码, 转换导入路径: 先查找当前目录下的 node_modules, 再向上逐层查找 node_modules 直到根目录, 最后查找全局 node_modules
- 生产模式使用 rollup 打包源码和依赖, rollup 支持输入 esm, cjs, umd 等, 输出 esm, cjs, umd 等
依赖预构建 (Pre-bundling dependencies)
开发模式下, 首次启动 vite 时, vite 自动且透明的使用 esbuild 打包依赖, 即依赖预构建
依赖预构建的目的
- 兼容 cjs 和 umd 模块: 依赖可能是 esm 模块, 也可能是 cjs 或 umd 模块, 生产模式下, vite 的 devServer 开发服务器将所有代码 (源码 + 依赖) 视为 esm 模块, vite 必须先将 cjs 或 umd 模块转换为 esm 模块
- 性能: 某个依赖有很多个内部模块, 都使用 export 导出, 并且内部模块有相互引用, 可能导致浏览器同时发送很多个 http 请求, vite 可以将该依赖的多个内部模块预构建为 1 个 esm 模块, 这样只需要发送 1 个 HTTP 请求!
- 方便转换导入路径, 统一从
node_modules/.vite/deps
目录下导入, 例import { defineComponent } from "/node_modules/.vite/deps/?;
- 依赖预构建只适用于开发模式, 并使用 esbuild 将依赖转换为 esm 模块, 生产模式使用 rollup
自动依赖收集
如果没有 node_modules/.vite
依赖预构建的缓存, vite 会扫描源码, 自动寻找引入的依赖项 (bare import), 并将这些依赖项作为预构建的入口点
monorepo 和链接依赖
monorepo 项目中, 某个子包可能是另一个子包的依赖, vite 自动检测没有从 node_modules 中解析的依赖 (例如 @my-app/core
), 将 @my-app/core
视为源码, 并分析该 @my-app/core
的依赖列表
预构建缓存
文件系统缓存: vite 会将预构建的依赖缓存到 node_modules/.vite
, 根据以下决定是否需要重新运行依赖预构建
- package.json 中的 dependencies 列表
- 包管理器的锁文件: package-lock.json, pnpm-lock.yaml
- vite.config.js 中的某些配置项
- 使用 --force 命令行选项, 或手动删除
node_modules/.vite
缓存目录
浏览器缓存
预构建的依赖使用浏览器的强制缓存 max-age=31536000, immutable
, 强制缓存不会再请求 devServer 开发服务器, 减少网络请求次数
export default defineConfig({
optimizeDeps: {
exclude: [], // 指定不进行预构建的依赖项列表
include: [], // 指定进行预构建的依赖项列表
},
});
vite 配置
基于策略模式的 vite.config.js
import { defineConfig } from "vite";
import { viteBaseConfig } from "vite.base.config";
import { viteDevConfig } from "vite.dev.config";
import { viteProdConfig } from "vite.prod.config";
// 策略模式
const cmdToConfig = {
build: () => {
// 其他代码
return Object.assign({}, viteBaseConfig, viteProdConfig);
},
serve: () => {
// 其他代码
return { ...viteBaseConfig, ...viteProdConfig };
},
};
// pnpm build 时, command === 'build'
// pnpm dev 时, command=== 'serve'
export default defineConfig(({ command /** "build" | "serve" */ }) => {
// 避免过多的 if-else
return cmdToConfig[command]();
});
NOTE
没有在 package.json 中设置 "type": "module" 或使用 .mjs 扩展名, 为什么 vite.config.js 仍可以使用 esm 模块语法
vite 会先检查 vite.config.js
的模块语法, 如果是 esm, 则会先转换为 cjs
环境变量
- 开发模式 .env.development
- 生产模式 .env.production
- ...
vite 在 import.meta.env
对象上暴露环境变量, 内置环境变量有
- import.meta.env.MODE
@type {string}
应用运行的模式 - import.meta.env.BASE_URL
@type {string}
部署应用的基本 url - import.meta.env.PROD
@type {boolean}
是不是生产模式 - import.meta.env.DEV
@type {boolean}
是不是开发模式 - import.meta.env.SSR
@type {boolean}
是不是服务器端渲染
vite 基于 dotenv 从 envDir 环境目录中的以下文件加载其他的环境变量
.env
所有情况下都会被加载.env.local
所有情况下都会被加载, 但会被 git 忽略.env.[mode]
只在对应模式下会被加载, 优先级更高, 例如 .env.development, .env.production.env.[mode].local
只在对应模式下会被加载, 优先级更高, 但会被 git 忽略
//! pnpm dev 时, command=serve, 默认 mode=development, 会加载 .env, .env.development
//! pnpm build 时, command=build, 默认 mode=production, 会加载 .env, .env.production
export default defineConfig(({ command, mode }) => {
console.log("command:", command); // command: serve
console.log("mode:", mode); // mode: development
const envDir = process.cwd();
const env = loadEnv(mode, envDir, "CHAHAN");
// env: { CHAHAN_PROJECT_ID: '161043261' }
console.log("env:", env);
});
也可以在启动 vite 时指定 mode vite --mode development
- 只有
VITE_
或指定前缀 (envPrefix 配置项) 的环境变量才会暴露给 vue/react 的前端环境, 普通环境变量只会暴露给 vite/node 环境 - 前端环境变量挂载在
import.meta.env
对象上, vite/node 环境变量挂载在process.env
对象上 VITE_
或指定前缀 (envPrefix 配置项) 的环境变量不应该包含任何敏感信息
process.env.NODE_ENV
和 vite 模式
- NODE_ENV 和 mode 是两个不同的概念
- NODE_ENV 决定
import.meta.env.PROD
和import.meta.env.DEV
的 true/false - mode 决定
import.meta.env.MODE
的值
# process.env.NODE_ENV=production, mode=production
vite build
# process.env.NODE_ENV=production, mode=development
vite build --mode development
# process.env.NODE_ENV=development, mode=production
NODE_ENV=development vite build
# process.env.NODE_ENV=development, mode=development
NODE_ENV=development vite build --mode development
# import.meta.env.PROD=true, import.meta.env.DEV=false
NODE_ENV=production
# import.meta.env.PROD=false, import.meta.env.DEV=true
NODE_ENV=development
# import.meta.env.PROD=false, import.meta.env.DEV=true
NODE_ENV=other
# import.meta.env.MODE=production
--mode production
# import.meta.env.MODE=development
--mode development
# import.meta.env.MODE=staging
--mode staging
devServer 开发服务器
mkdir dev-server && cd dev-server && pnpm init
pnpm i koa -D
# tree -L 2
.
└── src
├── App.vue
├── index.html
├── index.js
└── main.js
devServer 简单原理
import Koa from "koa";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
// console.log("__filename", __filename);
const __dirname = path.dirname(__filename);
console.log("__dirname", __dirname);
const app = new Koa();
// 127.0.0.1 主机 IP 地址
// localhost 主机域名
app.use(async (ctx) => {
if (ctx.request.url === "/") {
const htmlContent = await fs.readFile(path.join(__dirname, "./index.html"));
ctx.response.body = htmlContent;
ctx.response.set("Content-Type", "text/html");
}
if (ctx.request.url.endsWith(".js")) {
console.log(ctx.request.url);
const jsContent = await fs.readFile(path.join(__dirname, ctx.request.url));
ctx.response.body = jsContent;
ctx.response.set("Content-Type", "text/javascript");
}
if (ctx.request.url.endsWith(".vue")) {
console.log(ctx.request.url);
const vueContent = await fs.readFile(path.join(__dirname, ctx.request.url));
// vite 先将 vue 代码编译为 JS 代码
ctx.response.body = vueContent;
// vite 开发服务器会设置 http 响应头 Content-Type=text/javascript
// 告诉浏览器, 即使是 .vue 文件, 也请使用 JS 的方式解析
ctx.response.set("Content-Type", "text/javascript");
}
});
app.listen(5173, () => {
console.log("http://localhost:3000");
});
<!-- 浏览器请求 localhost:5173, devServer 响应 index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
I'm index.html
<!-- 浏览器解析 index.html, index.html 中引用 ./main.js
浏览器再请求 main.js -->
<script type="module" src="./main.js"></script>
</body>
</html>
// 浏览器解析 main.js, main.js 中导入 ./App.vue
// 浏览器再请求 App.vue
import "./App.vue";
console.log("I'm main.js");
// 这是编译前的 .vue 文件
// <script lang="js">
// console.log("I'm App.vue, my content is compiled pure JS");
// </script>
// 这是编译后的 .vue 文件
// 即使后缀名还是 .vue, 内容已经是纯 JS 了!
// vite 开发服务器会设置 http 响应头 Content-Type=text/javascript
// 告诉浏览器, 即使是 .vue 文件, 也请使用 JS 的方式解析
console.log("I'm App.vue, my content is compiled pure JS");
CSS 支持
- vite 使用 AST 解析 App.vue, 发现 App.vue 中导入了 CSS 文件 (例如
import './app.css
) - vite 使用 node:fs 模块读取 ./app.css 文件内容
- vite 创建一个 style 标签, 将 ./app.css 文件内容复制到 style 标签中
- vite 将创建的 style 标签插入到 index.html 的 head 标签中 (如果是 .module.css, 则插入前会修改选择器名, 以避免样式冲突)
- vite 将 ./app.css 文件内容替换为纯 JS, 即使后缀名还是 .css, 内容已经是纯 JS 了! vite 开发服务器会设置 http 响应头 Content-Type=text/javascript, 告诉浏览器, 即使是 .css 文件, 也请使用 JS 的方式解析; 目的是方便模块热更新 (hmr, hot module replacement) 和 CSS 模块化 (.module.css)
CSS 模块化
- 对于 *.css, 插入到 head 标签前, 不修改选择器名, 如果一个 .vue/.jsx 文件导入了多个 .css 文件, 且多个 .css 文件中存在同名的选择器, 则会发生样式冲突!
- 对于 *.module.css, 插入到 head 标签前, 会修改选择器名: 添加前缀 _, 添加后缀随机 hash 值 (也可以自定义 CSS 模块化规则, 参考本站 React 基础: CSS 模块化), 避免了样式冲突
/** scrollbar.scss */
.container::-webkit-scrollbar {
display: none;
}
/** transition.module.scss */
.fade-enter-active,
.fade-leave-active {
transition: opacity 1s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/** 如果一个 .vue/.jsx 文件导入了多个 .css 文件, 且多个 .css 文件中存在同名的选择器, 则会发生样式冲突! */
.container::-webkit-scrollbar {
display: none;
}
/** 修改类名: 添加前缀 _, 添加后缀随机 hash 值, 避免了样式冲突 */
._fade-enter-active_12xur_1,
._fade-leave-active_12xur_2 {
transition: opacity 1s ease;
}
._fade-enter-from_12xur_6,
._fade-leave-to_12xur_7 {
opacity: 0;
}
vite 集成了 postcss
- postcss: CSS 世界中的 babel, CSS 语法降级; 浏览器私有前缀补全 (例如 -webkit-)
- 例子:
global.scss
---sass 预处理器-->global.pre.css
---postcss 后处理器-->global.post.css
pnpm install postcss postcss-cli postcss-preset-env -D
npx postcss ./src/app.css -o ./dist/app.post.css
# 或
pnpm exec postcss ./src/app.css -o ./dist/app.post.css
关于 npx, pnpm exec
- npx 先在当前目录下的 node_modules/.bin 中查找目标命令, 类似
npm run
, 但不需要在 package.json 的 scripts 中定义 - 如果当前目录下未找到, 则在全局目录下查找; 如果全局目录下也未找到, 则从 npm 仓库下载包到临时目录
node 操作文件时, 如果发现传递的是相对路径, 则会使用 process.cwd()
拼接路径
使用 require('./module.js')
加载模块时, Node.js 先读取文件内容为字符串, 将字符串包裹到 IIFE 中, 验证: mkdir cjs && cd cjs && pnpm init && echo "console.log(arguments)" > ./main.js && node ./main.js
(function (exports, require, module, __filename, __dirname) {
const a = 1;
module.exports = a;
});
静态资源处理
- 支持 JS/TS/Vue SFC 的 import, CSS 的 url
- 导入 json 时, vite 实际导入一个 JS 对象 (webpack 实际导入 JSON 字符串)
- 导入 json 时可以解构 (tree shaking 优化)
- 导入静态资源时, 实际导入静态资源的 url
- 静态资源体积小于 assetsInlineLimit 配置项的值, 则会被内联为 base64 字符串
- 导入脚本作为 webWorker
// 导入静态资源
import imgUrl /** @type {string} */ from "./img.png";
const img = document.createElement("img");
img.src = imgUrl;
document.body.append(img);
// 导入脚本作为 webWorker
const webWorker = new Worker(new URL("./web_worker.ts", import.meta.url), {
type: "module",
});
性能优化
页面性能指标
- 首次内容绘制 FCP, First Contentful Paint: 从页面开始加载到浏览器首次渲染出内容的时间 (用户首次看到内容的时间, 内容: 首个文本或首张图片)
- 最大 DOM 元素的绘制时间 LCP, Largest Contentful Paint
- 速度指数 SI, Speed Index: 页面的各个可视区域的平均绘制时间, 页面等待后端发送的数据时, 会影响到 Speed Index
- 首次可交互时间 TTI, Time to Interactive: 从页面开始加载到用户与页面可以交互的时间, 此时页面渲染已完成, 交互元素绑定的事件已注册
- 总阻塞时间 TBT, Total Blocking Time: 从页面开始加载到首次可交互时间 (TTI) 期间, 主线程被阻塞, 无法与用户交互的总时间
- 累积布局偏移 CLS, Cumulative Layout Shift: 比较两次渲染的布局偏移情况
浏览器缓存
- 请求强缓存的资源, 不会请求服务器, 浏览器直接返回
- 服务器可以使用响应头中的 Cache-Control 或 Expires 字段设置强缓存, Cache-Control 的优先级高于 Expires, 表示资源在客户端的缓存有效期
- 请求协商缓存的资源, 仍会请求服务器, 资源未更新时服务器返回 304 Not Modified, 响应体为空; 资源已更新时服务器返回 200 OK, 响应体中携带更新的资源
- 服务器可以使用响应头中的 ETag 或 Last-Modified 字段设置协商缓存, 客户端请求时自动携带 If-None-Match (对应 ETag) 或 If-Modified-Since (对应 Last-Modified) 请求头进行验证, ETag 的优先级高于 Last-Modified
资源释放
例如某个组件中创建了计时器 (setTimeout), 如果组件卸载时不清除计时器 (clearTimeout), 下一次组件挂载时, 等于创建了两个计时器, 导致内存泄漏
requestAnimationFrame, requestIdleCallback
- requestAnimationFrame: 下一帧执行传递的 callback
- requestIdleCallback: 当前帧的空闲时间执行传递的 callback
dist 体积优化
- dist 体积可视化
pnpm install rollup-plugin-visualizer -D
- 分包, tree-shaking, gzip 压缩, 动态导入, CDN 加速
分包: 将不经常更新的文件单独打包
// 例: 将 node_modules 中的依赖单独打包
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
})
gzip 压缩
设置响应头 Content-Encoding: gzip
动态导入
冷启动 webpack 开发服务器时, webpack 需要先打包所有文件
vite 按需加载, 动态导入 (通常在 vue-router, react-router 中使用动态导入)
// react, 未使用动态导入
import Home from "./home";
import Login from "./login";
const routes = [
{
path: "/home",
Component: Home,
},
{
path: "/login",
Component: Login,
},
];
// react, 使用动态导入
const routes = [
{
path: "/home",
Component: lazy(() => import("./home")), // 要求 ./home.jsx 有默认导出
// Component: lazy(() =>
// import("./home").then(({ Home }) => ({ default: Home })),
// ),
},
{
path: "/login",
Component: lazy(() =>
import("./login").then(({ Login }) => ({ default: Login })),
),
},
];
跨域
浏览器的同源策略: 服务器响应了数据, 但浏览器禁止 JS 访问不同协议, 或不同域名 (主机 IP 地址), 或不同端口的服务器响应的数据
开发模式处理跨域
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
// 原理: vite (Node.js 后端) 没有跨域限制
// 例如 fetch('/api/448719894') 时
// 浏览器实际请求 path=http://127.0.0.1:5173/api/448719894, 实际请求 vite 开发服务器
// vite 开发服务器发现 path 匹配 /api 代理规则, 应用代理规则, 转发请求
"/api": {
target: "https://space.bilibili.com/",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
生产模式处理跨域
- nginx 代理
- 后端设置响应头
Access-Control-Allow-Origin: "*"
允许跨域的域名Access-Control-Allow-Headers: "*"
允许的 http 请求头Access-Control-Allow-Credentials: true
允许携带凭证Access-Control-Allow-Methods: "*"
允许的 http 请求方法