一起写个vite吧!(1) --环境搭建+依赖预构建
vite简介
作为近两年非常热门的明星项目,
vite
有着非常优秀的构建性能,现在为主流的框架都提供了支持:Vue
、React
、Svelte
、Lit
、Preact
,如果你使用vite搭建过项目,你会发现它没有webpack
那样的打包过程,几乎是秒启动,之前因为vite
是刚出来不够成熟所以生态方面不够完善,但是vite
的社区实在是太活跃了,现在vite在市场上也占有一席之地了,对于一个这么优秀的工具,很多人只是知道它快而已,那么它为什么快呢,让我们”实现一个vite就知道了“,废话不多说,咱们直接开干。
搭建环境
这里推荐大家包管理工具使用
pnpm
,性能很好,首先我们在命令行输入pnpm init -y
初始化项目,然后安装项目的一些依赖:
// 运行时依赖
pnpm i cac chokidar connect debug es-module-lexer esbuild fs-extra magic-string picocolors resolve rollup sirv ws -S
// 开发环境依赖
pnpm i @types/connect @types/debug @types/fs-extra @types/resolve @types/ws tsup
对于上面依赖包的作用先不用管,等后面用到会说这些包的作用,不过不会说的很项详细,如果想要深入了解可查看对应的文档。
我们使用tsup
打包我们的ts代码,首先我们在package.json中加入这些命令:
"scripts": {
"start": "tsup --watch",
"build": "tsup --minify"
},
在项目根目录建立两个配置文件,分别是tsconfig.json
和tsup.config.ts
,内容如下:
// tsconfig.json
{
"compilerOptions": {
// 支持 commonjs 模块的 default import,如 import path from 'path'
// 否则只能通过 import * as path from 'path' 进行导入
"esModuleInterop": true,
"target": "ES2020",
"moduleResolution": "node",
"module": "ES2020",
"strict": true
}
}
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
// 后续会增加 entry
entry: {
index: "src/node/cli.ts",
},
// 产物格式,包含 esm 和 cjs 格式
format: ["esm", "cjs"],
// 目标语法
target: "es2020",
// 生成 sourcemap
sourcemap: true,
// 没有拆包的需求,关闭拆包能力
splitting: false,
});
接着新建我们新建
src/node/cli.ts
文件,我们进行 cli 的初始化,可能有的同学没有接触过cli,其实cli就是一个命令行交互工具,像vue-cli
就是一个脚手架,大致原理就是拿出process.argv
进行解析,然后执行对应的回调函数,这里我们使用cac
搭建脚手架,功能十分简单,就是我们在命令行输入mini-vite
后去执行action里的函数。
src/node/cli.ts
import cac from "cac";
const cli = cac();
// [] 中的内容为可选参数,也就是说仅输入 `vite` 命令下会执行下面的逻辑
cli
.command("[root]", "Run the development server")
.alias("serve")
.alias("dev")
.action(async () => {
console.log('test');
});
cli.help();
cli.parse();
现在可以执行
pnpm start
来编译这个mini-vite
项目,tsup 会生成产物目录dist
,然后新建bin/mini-vite
文件来引用产物:
#!/usr/bin/env node
require("../dist/index.js");
同时,你需要在 package.json
中注册mini-vite
命令,配置如下:
{
"bin": {
"mini-vite": "bin/mini-vite"
}
}
ok,现在我们的脚手架基本就搭建好了,我们使用一个项目来试试,这里为大家提供一个demo项目,点击可以去项目的仓库,将这个项目拉进当前的根目录,将
playground
项目放在mini-vite
目录中,然后执行pnpm i
,由于项目的dependencies
中已经声明了mini-vite
:
{
"devDependencies": {
"mini-vite": '../'
}
}
那么mini-vite
命令会自动安装到测试项目的node_modules/.bin
目录中:
接着我们在playground
项目中执行pnpm dev
命令(内部执行mini-vite
),可以看到如下的 log 信息:
test
ok大致的流程已经跑通了,我们把console.log
语句换成服务启动的逻辑:
import cac from "cac";
import { startDevServer } from "./server";
const cli = cac();
cli
.command("[root]", "Run the development server")
.alias("serve")
.alias("dev")
.action(async () => {
await startDevServer();
});
然后我们新建src/node/server/index.ts
,内容如下:
// connect 是一个具有中间件机制的轻量级 Node.js 框架。
// 既可以单独作为服务器,也可以接入到任何具有中间件机制的框架中,如 Koa、Express
import connect from "connect";
// picocolors 是一个用来在命令行显示不同颜色文本的工具
import { blue, green } from "picocolors";
export async function startDevServer() {
const app = connect();
const root = process.cwd();
const startTime = Date.now();
app.listen(3000, async () => {
console.log(
green("🚀 No-Bundle 服务已经成功启动!"),
`耗时: ${Date.now() - startTime}ms`
);
console.log(`> 本地访问路径: ${blue("http://localhost:3000")}`);
});
}
再次执行pnpm dev
,你可以发现终端出现如下的启动日志:
依赖预构建
首先我们先什么是依赖预构建,平时我们项目的代码一般分为业务代码和第三方依赖的代码,
vite
是一个提倡no-bundle
的构建工具,能做到开发按需编译,但是这是对于我们的业务代码来说,对于第三方依赖vite
还是选择bundle(打包)的,并且使用的是非常快的打包器esbuild
来打包,上面我们提到的tsup
底层也是使用esbuild
来打包的。那么为什么我们需要依赖预构建呢,因为现在有很多的第三方库是没有es版本的产物的,但是vite
又是基于esm
的,所以需要将commonjs
格式转换成esm
格式的,而且还有一个依赖瀑布流的问题,比如lodash-es
本身有esm
产物,但是在vite
里一个import就会发一次网络请求,在依赖层级深
、涉及模块数量多
的情况下会触发很多请求导致页面刚开始加载卡顿,所以需要将库里面分散的文件合并到一起,减少请求量,ok,现在我们开始依赖预构建的开发拉。
首先我们新建src/node/optimizer/index.ts
来存放依赖预构建的逻辑:
export async function optimize(root: string) {
// 1. 确定入口
// 2. 从入口处扫描依赖
// 3. 预构建依赖
}
然后在服务入口中引入预构建的逻辑:
src/node/server/index.ts
import connect from 'connect'
// picocolors 是一个用来在命令行显示不同颜色文本的工具
import { blue, green } from 'picocolors'
import { optimize } from '../optimizer'
export async function startDevServer() {
const app = connect()
const root = process.cwd()
const startTime = Date.now()
app.listen(3000, async () => {
await optimize(root)
console.log(
green('🚀 No-Bundle 服务已经成功启动!'),
`耗时: ${Date.now() - startTime}ms`
)
console.log(`> 本地访问路径: ${blue('http://localhost:3000')}`)
})
}
connect
是一个简单的框架,可以将各种“中间件”粘合在一起来处理请求,类似于koa
,但是比koa
更轻量,因为vite
只需要它插件的能力,所以使用了connect
起的后端服务。
接下来我们需要完成三部分功能:
- 确定预构建入口
- 从入口开始扫描出用到的依赖
- 对依赖进行预构建
首先是入口,这里我们设入口是main.tsx
src/node/optimizer/index.ts
import path from 'path'
import { build } from 'esbuild'
import { green } from 'picocolors'
import { scanPlugin } from './scanPlugin'
export async function optimize(root: string) {
// 1. 确定入口
const entry = path.resolve(root, 'src/main.tsx')
// 2. 从入口处扫描依赖
const deps = new Set<string>()
await build({
entryPoints: [entry],
bundle: true,
write: false,
plugins: [scanPlugin(deps)],
})
console.log(
`${green('需要预构建的依赖')}:\n${[...deps]
.map(green)
.map((item) => ` ${item}`)
.join('\n')}`
)
// 3. 预构建依赖
}
依赖扫描需要我们借助 Esbuild 插件来完成,最后会记录到 deps 这个集合中。接下来我们来着眼于 Esbuild 依赖扫描插件的开发,你需要在
optimzier
目录中新建scanPlguin.ts
文件编写扫描依赖的插件,有些同学可能会说了:“你是个扑街啊,我现在都不知道esbuild是个啥就要些什么鬼插件了,你是在玩我是吧!”不急不急,其实esbuild
就是个打包工具跟其他的打包工具的差别不大,用法差不多,对于esbuild
不了解的童鞋,可以看下这篇文章:Esbuild使用和插件开发。ok那咱们继续,文件内容如下:
src/node/optimizer/scanPlugin.ts
import { Plugin } from 'esbuild'
import { BARE_IMPORT_RE, EXTERNAL_TYPES } from '../constants'
export function scanPlugin(deps: Set<string>): Plugin {
return {
name: 'esbuild:scan-deps',
setup(build) {
// 忽略的文件类型
build.onResolve(
{
filter: new RegExp(`\\.(${EXTERNAL_TYPES.join('|')})$`),
},
(resolveInfo) => {
return {
path: resolveInfo.path,
// 打上 external 标记
external: true,
}
}
)
// 记录依赖
build.onResolve(
{
filter: BARE_IMPORT_RE,
},
(resolveInfo) => {
const { path: id } = resolveInfo
// 推入 deps 集合中
deps.add(id)
return {
path: id,
external: true,
}
}
)
},
}
}
大家可以注意到配置的write
为false
,表示产物不用写入磁盘,这就大大节省了磁盘 I/O 的时间了,也是依赖扫描
为什么往往比依赖打包
快很多的原因之一。
文件中用到了一些常量,在src/node/constants.ts
中定义,内容如下:
src/node/constants.ts
export const EXTERNAL_TYPES = [
"css",
"less",
"sass",
"scss",
"styl",
"stylus",
"pcss",
"postcss",
"vue",
"svelte",
"marko",
"astro",
"png",
"jpe?g",
"gif",
"svg",
"ico",
"webp",
"avif",
];
export const BARE_IMPORT_RE = /^[\w@][^:]/;
对于正则不熟的同学可以去这个网站测试:测试网站,我也不熟[苦笑]
插件的逻辑比较简单,即把一些无关的资源进行 external(external后就不会进入下个钩子了),不让 esbuild 处理,防止 Esbuild 报错,同时将
bare import
(如import React from 'react')的路径视作第三方包,推入 deps 集合中。
ok那么我们在playground
项目根路径中执行pnpm dev
,可以如下的输出:
ok现在我们进入依赖预构建的最后一步拉:对每个依赖进行打包,这个也是esbuild的插件开发,首先先在src/node/optimizer/index.ts里增加依赖打包:
src/node/optimizer/index.ts
import path from 'path'
import { build } from 'esbuild'
import { green } from 'picocolors'
import { scanPlugin } from './scanPlugin'
import { preBundlePlugin } from './preBundlePlugin'
import { PRE_BUNDLE_DIR } from '../constants'
export async function optimize(root: string) {
// 1. 确定入口
const entry = path.resolve(root, 'src/main.tsx')
// 2. 从入口处扫描依赖
const deps = new Set<string>()
await build({
entryPoints: [entry],
bundle: true,
write: false,
plugins: [scanPlugin(deps)],
})
console.log(
`${green('需要预构建的依赖')}:\n${[...deps]
.map(green)
.map((item) => ` ${item}`)
.join('\n')}`
)
// 3. 预构建依赖
await build({
entryPoints: [...deps],
write: true,
bundle: true,
format: 'esm',
splitting: true,
outdir: path.resolve(root, PRE_BUNDLE_DIR),
plugins: [preBundlePlugin(deps)],
})
}
我们引入了一个新的常量PRE_BUNDLE_DIR
,定义如下:
src/node/constants.ts
import path from 'path'
export const EXTERNAL_TYPES = [
'css',
'less',
'sass',
'scss',
'styl',
'stylus',
'pcss',
'postcss',
'vue',
'svelte',
'marko',
'astro',
'png',
'jpe?g',
'gif',
'svg',
'ico',
'webp',
'avif',
]
export const BARE_IMPORT_RE = /^[\w@][^:]/
// 预构建产物默认存放在 node_modules 中的 .m-vite 目录中
export const PRE_BUNDLE_DIR = path.join('node_modules', '.m-vite')
ok,接下来我们继续开发esbuild插件
src/node/optimizer/preBundlePlugin.ts
import { Loader, Plugin } from 'esbuild'
import { BARE_IMPORT_RE } from '../constants'
// 用来分析 es 模块 import/export 语句的库
import { init, parse } from 'es-module-lexer'
import path from 'path'
// 一个实现了 node 路径解析算法的库
import resolve from 'resolve'
// 一个更加好用的文件操作库
import fs from 'fs-extra'
// 用来开发打印 debug 日志的库
import createDebug from 'debug'
import { normalizePath } from '../utils'
const debug = createDebug('dev')
export function preBundlePlugin(deps: Set<string>): Plugin {
return {
name: 'esbuild:pre-bundle',
setup(build) {
build.onResolve(
{
filter: BARE_IMPORT_RE,
},
(resolveInfo) => {
const { path: id, importer } = resolveInfo
const isEntry = !importer
// 命中需要预编译的依赖
if (deps.has(id)) {
// 若为入口,则标记 dep 的 namespace
return isEntry
? {
path: id,
namespace: 'dep',
}
: {
// 因为走到 onResolve 了,所以这里的 path 就是绝对路径了
path: resolve.sync(id, { basedir: process.cwd() }),
}
}
}
)
// 拿到标记后的依赖,构造代理模块,交给 esbuild 打包
build.onLoad(
{
filter: /.*/,
namespace: 'dep',
},
async (loadInfo) => {
await init
const id = loadInfo.path
const root = process.cwd()
const entryPath = resolve.sync(id, { basedir: root })
const code = await fs.readFile(entryPath, 'utf-8')
const [imports, exports] = await parse(code)
let relativePath = normalizePath(path.relative(root, entryPath))
if (
!relativePath.startsWith('./') &&
!relativePath.startsWith('../') &&
relativePath !== '.'
) {
relativePath = `./${relativePath}`
}
let proxyModule = []
// cjs
if (!imports.length && !exports.length) {
// 构造代理模块
// 通过 require 拿到模块的导出对象
const res = require(entryPath)
// 用 Object.keys 拿到所有的具名导出
const specifiers = Object.keys(res)
// 构造 export 语句交给 Esbuild 打包
proxyModule.push(
`export { ${specifiers.join(',')} } from "${relativePath}"`,
`export default require("${relativePath}")`
)
} else {
// esm 格式比较好处理,export * 或者 export default 即可
if (exports.includes('default' as any)) {
proxyModule.push(
`import d from "${relativePath}";export default d`
)
}
proxyModule.push(`export * from "${relativePath}"`)
}
debug('代理模块内容: %o', proxyModule.join('\n'))
const loader = path.extname(entryPath).slice(1)
return {
loader: loader as Loader,
contents: proxyModule.join('\n'),
resolveDir: root,
}
}
)
},
}
}
然后我们需要在新建/src/utils.ts文件补充如下工具函数
import os from 'os'
import path from 'path'
export function slash(p: string): string {
return p.replace(/\\/g, '/')
}
export const isWindows = os.platform() === 'win32'
export function normalizePath(id: string): string {
return path.posix.normalize(isWindows ? slash(id) : id)
}
对于 CommonJS 格式的依赖,单纯用 export default require('入口路径')
是有局限性的,比如对于 React 而言,用这样的方式生成的产物最后只有 default 导出:
那么用户在使用这个依赖的时候,必须这么使用:
// ✅ 正确
import React from 'react';
const { useState } = React;
// ❌ 报错
import { useState } from 'react';
上述的插件代码中已经这个问题,我们不妨把目光集中在下面这段代码中:
if (!imports.length && !exports.length) {
// 构造代理模块
// 通过 require 拿到模块的导出对象
const res = require(entryPath);
// 用 Object.keys 拿到所有的具名导出
const specifiers = Object.keys(res);
// 构造 export 语句交给 Esbuild 打包
proxyModule.push(
`export { ${specifiers.join(",")} } from "${entryPath}"`,
`export default require("${entryPath}")`
);
}
如此一来,Esbuild 预构建的产物中便会包含 CommonJS 模块中所有的导出信息:
// 预构建产物导出代码
export {
react_default as default,
useState,
useEffect,
// 省略其它导出
}
ok我们现在思考一个问题,我们为什么contents可以直接用读取文件的方式传给esbuild吗,就像这样:
build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
return {
loader: 'js',
contents: fs.readFileSync(entryFile, 'utf8');
}
}
这样会出现什么问题呢?比如我们用
dep:react
这个代理模块来作为入口内容在 Esbuild 中进行加载,与此同时,其他库的预打包也有可能会引入 React,比如@emotion/react
这个库里面会有require('react')
的行为。那么在Esbuild
打包之后,react.js
与@emotion_react.js
的代码中会引用同一份 Chunk 的内容,这份 Chunk 也就对应 React 入口文件(node_modules/react/index.js
)。如果代理模块是通过文件系统直接读取的话,由于此时代理模块和真实模块没有任何的引用关系,esbuild
会打包两个相同的chunk。 这也就能解释为什么Vite
中要在代理模块中对真实模块的内容进行重导出了,主要是为了避免Esbuild
产生重复的打包内容。
ok,mini-vite的是实现第一部分就到这里了,如果有什么问题可以跟我一起讨论哇!
其他章节
mini-vite仓库地址(里面有对应的提交记录,如果有兴趣可以拉下来跑一跑哇)
转载自:https://juejin.cn/post/7154007357305913357