本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
这是源码共读的第37期,链接:vite 3.0 都发布了,这次来手撕 create-vite 源码。
一、学习目标
学习 vite
源码项目管理方式(monorepo
),熟悉脚手架 create-vite
的实现,旨在了解成熟项目的项目管理(monorepo
)且掌握脚手架搭建并能开发自定义脚手架,从而节省项目搭建时间提高开发效率。
二、准备工作
2.1 源码下载(github),了解目录结构,熟悉其项目管理方式
```
├── docs/ // vite 手册
└── packages/
├── create-vite/ // 脚手架
├── plugin-legacy/ // 插件: 为打包后的文件提供传统浏览器兼容性支持
├── plugin-react/ // 插件:提供完整的 React 支持
├── plugin-vue/ // 插件:提供 Vue 3 单文件组件支持
├── plugin-vue-jsx/ // 插件:提供 Vue 3 JSX 支持
├── vite/ // vite 前端构建工具
├── playground/ // 组件操场
├── scripts
├── vite.config.ts // Vite 配置文件
└── package.json
```
在该源码中,整体使用了monorepo
的方式管理项目,单独维护的模块有脚手架( packages/create-vite
)、官方提供的插件(packages/plugin-legacy
、packages/plugin-legacy
、packages/plugin-react
、packages/plugin-vue
、packages/plugin-vue-jsx
)和 packages/vite
,其手册docs
放在整体项目里维护。
monorepo
是项目管理的一种方式,其理念:在一个项目仓库(repo)中管理多个模块/包(package)。
其优点:统一了工作流,搭建一个脚手架就能管理(构建、测试、发布)多个package,统一测试,统一发版(当然,根据具体项目具体分析,确定如何统一维护);
其缺点:repo 体积会比较大,拥有自己 package.json 的 package,会安装自己的 node_modules,但大概率这些包会与其他 package 重复,这使原很大的 node_modules 变得更大;针对该缺点,目前常见的解决方案是
monorepo + yarn workspaces
2.2 脚手架包:packages/create-vite
查看`packages.json` 文件,`bin` 字段设置了命令 `create-vite` 的入口文件 `index.js`,重置 `index.js` 文件内容
```
#!/usr/bin/env node
// import './dist/index.mjs'
console.log("哈喽, 露水晰")
```
2.3 了解自定义命令的执行过程
可以查看笔者往期文章,简述:
- 新建一个文件夹,创建一个
node 项目
(终端输入命令:npm init
,生成package.json
文件) - 在
package.json
文件,添加bin
字段{ 'create-vite-lsx': 'index.js'}
- 新建
index.js
(#!/usr/bin/env node
作用:说明该文件可当作脚本执行)#!/usr/bin/env node console.log("哈喽, 露水晰")
- 打成全局包,须要打成全局包才可以使用命令(
npm install .-g
或npm link
) - 在终端输入
create-vite-lsx
,得到如下结果说明该命令执行成功
三、create-vite 命令具体实现
src/index.js
// 1. 导入使用的模块
...
// 2. 主方法
async function init() {
...
}
// 3. 执行 init
init().catch((e) => {
console.error(e)
})
3.1 模块
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import spawn from 'cross-spawn'
import minimist from 'minimist'
import prompts from 'prompts'
import {
blue,
cyan,
green,
lightGreen,
lightRed,
magenta,
red,
reset,
yellow
} from 'kolorist'
- fs 文件操作(创建文件、删除文件、读某目录下的文件列表...)
- path 路径操作
- cross-spawn 运行批处理脚本
- minimist 解析命令行参数
- prompts 输入控件,用户交互
- kolorist 用于将颜色放入标准输入/标准输出的库,即给 console.log 输出加颜色
3.2 init 方法
- 解析命令行参数,得到 targetDir(项目被创建目录名称),template(模板类型)
- 与用户交互
- 若
targetDir
不存在,则等待用户输入,且赋值给targetDir
; targetDir
项目是否存在,若存在则给用户二次确认,若输入y
则将原存在的targetDir
目录下所有文件强制删除(删除是在用户交互结束后执行);否则,中断程序;- 判断项目名称 packageName(默认值为 targetDir 路径的最后一级名称,与 targetDir 不同,该名称是写入到 package.json 的 name)是否有效,若无效则重新输入;
- 选择模板(提供了多种模板供用户选择),如果
template
空则给用户选择framework
- 若
- 通过与用户的交互,拿到项目路径
targetDir
、包名称packageName
和使用的模板template
- 创建项目
- 若选择了自定义创建项目,则根据指引一步一步执行;
- 若根据模板创建,则一个文件一个文件的从源目录拷贝到目标目录(分两步:非 pcakge.json 文件和package.json 文件的创建),并修改文件中一些自定义的值(例:项目名称)
3.3 在 init 过程中使用到的一些工具类
文件拷贝、判断目录是否为空及删除目录下的所有文件(除了 .git 文件)
/**
* 功能: 复制文件/目录到指定目录
*
* fs.copyFileSync(src, dest, mode) 将文件从源路径同步复制到目标路径
*
* @param {String} src 要复制的源文件名
* @param {String} dest 复制操作将创建的目标文件名
*/
function copy(src, dest) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
/**
* 功能: 复制目录下的所有文件从源(srcDir)到目标目录(destDir)
*
* @param {string} srcDir
* @param {string} destDir
*/
function copyDir(srcDir, destDir) {
// 创建目标目录
fs.mkdirSync(destDir, { recursive: true })
// 获取源目录下的文件列表, 将其复制到目标目录
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}
/**
* 功能: 判断该文件路径是否为空
*
* @param {string} path
*/
function isEmpty(path) {
const files = fs.readdirSync(path)
return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
/**
* 功能: 强制删除目录下的文件(除了.git文件)
*
* fs.操作的文件/目录, 是以当前node执行环境目录为基础的
* fs.existsSync(dir) 判断指定文件是否存在
* fs.readdirSync(dir) 获取指定目录下的所有文件, 返回一个文件列表
* fs.rmSync(file[, options]) 删除指定文件
*
* @param {string} dir
*/
function emptyDir(dir) {
// 如果不存在
if (!fs.existsSync(dir)) {
return
}
// 如果存在, 则删除该目录下除了.git文件的其他文件
for (const file of fs.readdirSync(dir)) {
if (file === '.git') {
continue
}
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
四、自己动手试试搭建一个脚手架
基本思路:暂时只有一个模板 template-html
(静态网站),无需用户选择模板类型。输入搭建项目的命令create-project
,根据指定的项目名称创建项目所在目录,再根据指定模板将模板下的所有文件拷贝到前面的项目目录下,并修改相应的名字配置。
// index.js
const fs = require('node:fs');
const path = require('node:path')
const minimist = require('minimist')
const prompts = require('prompts')
const { reset, red } = require('kolorist')
// 解析命令行参数
const argv = minimist(process.argv.slice(2), { string: ['_']})
const cwd = process.cwd()
const renameFiles = {
_gitignore: '.gitignore'
}
const defaultTargetDir = 'my-project'
const defaultTemplate = 'html'
async function init() {
let targetDir = formatTargetDir(argv._[0])
let template = argv.template || defaultTemplate
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
// 命令行交互
let result = {}
try {
result = await prompts(
[
{
type: targetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state)=> {
targetDir = formatTargetDir(state.value) || defaultTargetDir
}
},
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm', // 二次确认: ? Target directory "packages" is not empty. Remove existing files and continue? » (y/N)
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: `Target directory "${targetDir}:`) +
` is not empty. Remove existing files and continue?`
},
{
type: (_, { overwrite } = {}) => {
if (overwrite === false) {
throw new Error(red('✖') + ' Operation cancelled')
}
return null
},
name: 'overwriteChecker'
}
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
return
}
const { overwrite, packageName } = result
const root = path.join(cwd, targetDir)
if (overwrite) {
// 如果有二次确认过, 即输入的项目名称已存在,且已二次确认使用该目录则删除该目录下的所有文件(除了.git)
emptyDir(root)
} else if (!fs.existsSync(root)) {
// 如果该目录不存在, 则创建目录
fs.mkdirSync(root, { recursive: true })
}
const templateDir = path.resolve(cwd,`template-${template}`)
const write = (file, content) => {
const targetPath = renameFiles[file]
? path.join(root, renameFiles[file])
: path.join(root, file)
if (content) {
fs.writeFileSync(targetPath, content)
} else {
copy(path.join(templateDir, file), targetPath)
}
}
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
)
pkg.name = packageName || getProjectName()
write('package.json', JSON.stringify(pkg, null, 2))
}
/**
* 去掉字符串首尾空格且将末尾的"/"去掉
* @param {string | undefined} targetDir
* @returns
*/
function formatTargetDir(targetDir) {
return targetDir?.trim().replace(/\/+$/g, '')
}
/**
* 判断目录是否为空
* @param {String} path
* @returns
*/
function isEmpty(path) {
const files = fs.readdirSync(path)
return files.length == 0 || (files.length === 1 && files[0] === '.git')
}
/**
* 强制删除目录下的文件(除了.git文件)
* fs.操作的文件/目录, 是以当前node执行环境目录为基础的
* fs.existsSync(dir) 判断指定文件是否存在
* fs.readdirSync(dir) 获取指定目录下的所有文件, 返回一个文件列表
* fs.rmSync(file[, options]) 删除指定文件
*
* @param {string} dir
*/
function emptyDir(dir) {
// 如果不存在
if (!fs.existsSync(dir)) {
return
}
// 如果存在, 则删除该目录下除了.git文件的其他文件
for (const file of fs.readdirSync(dir)) {
if (file === '.git') {
continue
}
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
/**
* 拷贝文件
* @param {*} src
* @param {*} dest
*/
function copy(src, dest) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
/**
* 拷贝目录
* @param {string} srcDir
* @param {string} destDir
*/
function copyDir(srcDir, destDir) {
// 创建目标目录
fs.mkdirSync(destDir, { recursive: true })
// 获取源目录下的文件列表, 将其复制到目标目录
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}
init().catch(err=> {
console.log("err", err)
})
终端输入命令 create-project
, 输出为下
五、总结
首先,在学习 create-vite
源码过程中,收获了很多。例如:
(1)vite
项目管理模式(monorepo
),尤其是 monrepo
项目管理理念:一个仓库(repo)管理多个模块/包(package),对比 create-react-app
和element-plus
等项目,都使用了此理念;
(2)自定义脚手架搭建过程及使用的交互包 prompts
、五彩斑斓的打印包 kolorist
和 node 文件操作。