本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
这是源码共读的第9期,链接:juejin.cn
1. 学习目标
- 学会全新的官方脚手架工具 create-vue 的使用和原理
- 学会使用 VSCode 直接打开 github 项目
- 学会使用测试用例调试源码
- 学以致用,为公司初始化项目写脚手架工具
2. 源码地址
vsCode打开github项目 用 vscode.dev/github/ 替换掉 github.com/ 即可
3. 关于create-vue的使用
执行命令npm init vue@next,根据提示选择使用的技术栈,给出后续操作提示并生成项目。如图所示:
这里的npm init vue@next实际是执行的npx create-vue@next
涉及的知识点:
npm init XX等同于npx creat-XXnpm init- npx安装包时,会临时下载,用完就删除 (npm 5.2版本开始支持) npx
@next是在发布时增加的标签npm publish --tag next对应某个版本 默认是latest- 查看tag对应的版本
npm dist-tag ls XX creat-vue比@vue/cli快,原因在于相对依赖少,代码行数少
4. 调试准备
4.1 项目克隆
首先,将项目clone到本地
git clone git@github.com:vuejs/create-vue.git
如果想克隆到自己的项目并保留原代码库create-vue的提交记录,参考如下操作:
- 新建仓库
- 克隆仓库到本地
git clone git@github.com:baosisi07/create-vue-analysi.git
cd create-vue-analysi
- 使用Git Subtree将源代码clone到当前目录的
create-vue下
git subtree add --prefix=create-vue git@github.com:vuejs/create-vue.git main
// 初始化
// git subtree add --prefix=用来放S项目的相对路径 S项目git地址 xxx分支
// 提交更改 (自动遍历之前的提交记录,自动找到S项目的提交记录)
// git subtree push --prefix=S项目的路径 S项目git地址 xxx分支
// 在其他项目更新S项目
// git subtree pull --prefix=S项目的路径 S项目git地址 xxx分支
Git Subtree用于在多个项目间双向同步子项目,比如A项目使用子项目S,S有单独的仓库进行管理,S项目更新可以在A项目中同步到,在A项目中对S进行修改提交,也会同步到S的代码库,如果B项目也使用了S,那么,B也可以同步到S的更新。
4.2 package.json解析
{
"name": "create-vue",
"version": "3.2.2",
"description": "An easy way to start a Vue project",
// type定义了node如何解析.js文件,默认是 CommonJS 此时表示此包采用ES module语法解析.js
"type": "module",
// bin指定可执行脚本。所以我们可以使用 npx create-vue
"bin": {
"create-vue": "outfile.cjs"
},
// 包下载安装完成时包括的所有文件
"files": [
"outfile.cjs",
"template"
],
// 设置了此软件包/应用程序在哪个版本的 Node.js 上运行
"engines": {
"node": "^14.16.0 || >=16.0.0"
},
// 定义npm脚本(shell脚本)命令
"scripts": {
"prepare": "husky install",
"format": "prettier --write .",
"build": "zx ./scripts/build.mjs",
"snapshot": "zx ./scripts/snapshot.mjs",
"pretest": "run-s build snapshot",
"test": "zx ./scripts/test.mjs",
"prepublishOnly": "zx ./scripts/prepublish.mjs"
},
}
npm钩子
npm 脚本有pre和post两个钩子,完成一些准备工作和清理工作。npm钩子 除了常见的一些声明周期钩子,有些钩子会在除了pre-Event和Post-Event钩子之外执行,比如
-
prepare,prepublish,prepublishOnly,prepack,postpack -
prepare (npm 4 引入)等同于prepublish
- 在pack和publish之前执行
- install不带参数时运行 install的钩子postinstall之后执行
- prepublish之后,prepublishOnly之前执行
-
prepublish (已废弃)
- 因为在publish和install时都会运行,令人疑惑,所以废弃,后来用prepare来代替
- 不会在publish时执行,但会在ci和install时执行
-
prepublishOnly
在prepared和packed之前执行,仅在publish时执行 自定义钩子可以通过
npm_lifecycle_event变量获取当前正在运行的脚本名称。如:
const target = process.env.npm_lifecycle_event
if(tartget === 'preMyScript') {
console.log('running preMyScript')
}
husky + lint-staged
husky使得使用git hook变得容易
如果想在install之后自动开启git钩子,可以在prepare中定义,像上面package.json中的配置
lint-staged对将要提交的内容进⾏lint校验或prettier格式化,结合husky使提交内容更规范
在package.json中配置即可,例如:
{
name: 'create-vue',
"lint-staged": {
"*.{js,ts,vue,json}": [
"prettier --write"
]
}
}
run-s
这个命令来自 npm-run-all,它是一个CLI工具,可以并行或顺序运行多个npm脚本。
共提供了三个命令:
- npm-run-all 默认串行执行
- run-s npm-run-all -s (sequentially)简写 串行执行 等同于
run script1 && run script2 - run-p npm-run-all -p (parallel)简写 并行执行 等同于
run script1 & run script2
zx
bash命令虽然好,但是涉及一些复杂的操作时,并不能很好的书写脚本。zx提供了像书写js一样来写脚本,它对子进程进行合理的包装,通过传参的方式提供给我们简单的方法,使编写bash脚本变得更容易。 安装:
npm i -g zx
使用:
- 首先,为了在最顶层使用await,我们将脚本文件后缀名改为.mjs
- 在zx脚本文件开头添加
#!/usr/bin/env zx
- 运行脚本
zx ./script.mjs
zx常用函数 zx文档
// 所有函数都是直接使用,无需引入的
// $使用
let name = 'foo & bar'
await $`mkdir ${name}`
// cd()改变目录
cd('/tmp')
// fetch()是node-fetch的包装
let resp = await fetch('https://medv.io')
// question()对readline包的包装
let bear = await question('What kind of bear is best? ')
// sleep()对setTimeout的包装
await sleep(1000)
// echo()相当于console.log()
let branch = await $`git branch --show-current`
echo`Current branch is ${branch}.`
// or
echo('Current branch is', branch)
5. 源码预热
我们通过运行初始化命令可以看到,create-vue完成了以下功能:
- 创建默认文件vue-project,可以自定义输入文件名
- 提供使用频率比较高的库供用户选择并生成相应的模版
- 完成项目创建,并提供运行提示
由package.json中可以看到,执行create-vue实际是执行了outfile.cjs,而outfile.cjs是根目录下的index.ts所生成。
我们先看下index.ts的代码:
#!/usr/bin/env node
import * as fs from 'fs'
import * as path from 'path'
import minimist from 'minimist'
import prompts from 'prompts'
import { red, green, bold } from 'kolorist'
import renderTemplate from './utils/renderTemplate'
import { postOrderDirectoryTraverse, preOrderDirectoryTraverse } from './utils/directoryTraverse'
import generateReadme from './utils/generateReadme'
import getCommand from './utils/getCommand'
import renderEslint from './utils/renderEslint'
import banner from './utils/banner'
async function init() {
...
}
init().catch((e) => {
console.error(e)
})
5.1 使用的基础包:
minimist
主要作用就是解析命令行参数。看示例:
// example/parse.js
// process.argv是一个数组,数组的第一个元素是执行node进程的可执行文件的绝对路径 第二个是被执行脚本的路径 后面的则是实际的参数值
var argv = require('minimist')(process.argv.slice(2));
console.log(argv);
$ node example/parse.js -a beep -b boop
{ _: [], a: 'beep', b: 'boop' }
$ node example/parse.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
{ _: [ 'foo', 'bar', 'baz' ],
x: 3,
y: 4,
n: 5,
a: true,
b: true,
c: true,
beep: 'boop' }
prompts
prompts用于收集用户信息的交互式命令行工具。
语法
prompts(prompts, options)
prompts: Object | Array
options.onSubmit: Function
options.onCancel: Function
每项prompt可能包含如下属性:
{
type: String | Function,
name: String | Function,
message: String | Function,
initial: String | Function | Async Function
format: Function | Async Function,
onRender: Function
onState: Function
stdin: Readable
stdout: Writeable
}
其中的Function可以接受三个参数(prev, values, prompt)
- prev指上一询问项的值
- valuses指前面所有的结果集合
- prompt指上一个prompt对象
使用类型即
type类型有:
若为null等falsey类值时则会跳过当前询问项
- text
- password
- invisible
- number
- confirm
- list
- toggle
- select
- multiselect
- autocompleteMultiselect
- autocomplete
- date
使用示例:
const prompts = require('prompts');
(async () => {
const response = await prompts({
type: 'text',
name: 'meaning',
message: 'What is the meaning of life?'
});
// response => {meaning: value} 以name做为key
console.log(response.meaning);
})();
// 链式
const questions = [
{
type: 'text',
name: 'username',
message: 'What is your GitHub username?'
},
{
type: 'number',
name: 'age',
message: 'How old are you?'
},
{
type: 'text',
name: 'about',
message: 'Tell something about yourself',
initial: 'Why should I?'
}
];
(async () => {
const onCancel = prompt => {
console.log('Never stop prompting!');
return true;
}
const onSubmit = (prompt, answer) => console.log(`Thanks I got ${answer} from ${prompt.name}`);
const response = await prompts(questions, { onSubmit, onCancel }
// response => { username, age, about } 包含questions的name的对象
);
})();
kolorist
定义标准输入/输出的颜色,颜色示例:
还有一个使用到的库 gradient-string用于定义渐变字符串 这里的banner就是这个库生成的结果,如图:
使用示例:
require('gradient-string')([
{ color: '#42d392', pos: 0 },
{ color: '#42d392', pos: 0.1 },
{ color: '#647eff', pos: 1 }
])('Vue.js - The Progressive JavaScript Framework'))
5.2 使用的工具函数:
renderTemplate(src, dist)
- 将src的目录或文件递归地拷贝到dist下
- 以
_命名的文件会替换为以.命名 package.json如果已存在dist中,则对其内容进行merge处理,而不是替换
getCommand(manager, script)
通过参数选择包管理器和执行的脚本命令
getCommand('npm', 'test') => npm run test
getCommand('yarn', 'install') => yarn
renderEslint(rootdir, options)
- 根据是否需要
typeScript、prettier、Cypress等生成相应的dependencies及scripts到package.json中 - 生成相应的
.eslintrc.cjs
6. 源码解析
6.1 获取初始化时命令行的参数作为询问依赖
async function init() {
// 获取进程的当前目录
const cwd = process.cwd()
const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'],
'with-tests': ['tests'],
router: ['vue-router']
},
// all arguments are treated as booleans
boolean: true
})
console.log(argv)
// ??为空值操作符 与||类似 区别在于??仅在左边值为`null` 或 `undefined`时才返回右边的值 比||可靠
// isFeatureFlagsUsed用于标记参数,
const isFeatureFlagsUsed =
typeof (
argv.default ??
argv.ts ??
argv.jsx ??
argv.router ??
argv.pinia ??
argv.tests ??
argv.vitest ??
argv.cypress ??
argv.eslint
) === 'boolean'
console.log(isFeatureFlagsUsed)
// 取命令行的第一个参数 作为projectName 默认vue-project
let targetDir = argv._[0]
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
}
传入的第一个参数作为projectName,此时会跳过相关询问项,直接跳到后面的询问,如下图,继续询问ts配置。
除了传入projectName,如果传入包含在isFeatureFlagsUsed中的任一参数,并且值为true时,则直接跳过所有询问项,直接生成。可以看到minimist处理的别名和原名的值都是存在的且是同步的。
6.2 交互式询问配置
result = await prompts(
[
{
name: 'projectName',
type: targetDir ? null : 'text',
message: 'Project name:',
initial: defaultProjectName,
// 状态变化的回调 设置新的目录名称 供后面的询问项使用
onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},
...
{
name: 'needsPrettier',
type: (prev, values) => {
if (isFeatureFlagsUsed || !values.needsEslint) {
// 如果不支持Eslint 则自动跳过此项询问
return null
}
return 'toggle'
},
message: 'Add Prettier for code formatting?',
initial: false,
active: 'Yes',
inactive: 'No'
}
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)
前面的prompts有过了解的话,这里其实很好理解,大致会询问以下信息:
- 项目名称:
- 是否覆盖已存在的重名项目?
- 为
package.json输入一个合法的名称
- 项目语言:
JavaScript/TypeScript - 是否支持
JSX - 是否安装
Vue Router以满足单页面应用 - 是否安装状态管理工具
Pinia了解更多 - 是否安装单元测试工具
Vitest了解更多 - 是否安装端到端或单元测试工具
Cypress了解更多 - 是否支持代码质量检测
ESLint - 是否安装
Prettier对代码进行格式化
6.3 生成初始化项目所需文件
1. 根据询问结果定义各个配置项变量,供后续使用
const {
projectName,
packageName = projectName ?? defaultProjectName,
shouldOverwrite = argv.force,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsPinia = argv.pinia,
needsCypress = argv.cypress || argv.tests,
needsVitest = argv.vitest || argv.tests,
needsEslint = argv.eslint || argv['eslint-with-prettier'],
needsPrettier = argv['eslint-with-prettier']
} = result
2. 创建新项目目录,已存在则清空
if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
3. 新建package.json并添加name和version
const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
4. 根据各个配置渲染引用模版生成文件
- 渲染基础模版
- 渲染包对应的配置项config,主要更新package.json中的依赖和配置项,添加config类的文件
- ts和Eslint需要对支持的包进行单独的渲染配置
- 生成示例代码文件
const templateRoot = path.resolve(__dirname, 'template')
const render = function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root) //这里是拷贝文件及package.json合并操作
}
// 渲染基础模板
render('base')
// 根据变量值渲染对应的config 包含其独有的配置文件及package.json配置项
if (needsJsx) {
render('config/jsx')
}
...
if (needsTypeScript) {
render('config/typescript')
// 使用ts的话,会对其他的模块添加支持ts的配置
render('tsconfig/base')
if (needsCypress) {
render('tsconfig/cypress')
}
if (needsCypressCT) {
render('tsconfig/cypress-ct')
}
if (needsVitest) {
render('tsconfig/vitest')
}
}
// 使用ESlint的话,其关联的几个包会增加额外的Dependency、scripts,并生成相应的eslintrc文件
if (needsEslint) {
renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })
}
// 生成示例代码
// 基础组件 包含ts或router的示例页面
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// 配置pinia或router的入口文件
if (needsPinia && needsRouter) {
render('entry/router-and-pinia')
} else if (needsPinia) {
render('entry/pinia')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
如图template目录为可以使用的模版,根据配置项来渲染相应的package.json配置(多个配置项会做合并处理),或其他配置文件,比如vite.config.js,这里的base是基础模版。
5. 支持TS与否,对模版文件进行处理
支持ts时
- 将模版中的js文件转换为ts
- 如果存在ts文件,则移除js文件
- 不存在则重命名为ts
- 移除jsconfig.json,因为有tsconfig.json
- 替换index.html的入口js文件为ts 不支持时
- 清理ts文件
6. 生成README.md文件并给出运行提示
process.env.npm_config_user_agent动态取用户使用的包管理工具- 包管理工具使用优先级 pnpm > yarn > npm
- 动态生成
README.md的安装提示及其他配置引导 - 打印运行提示,利用
kolorist的方法添加样式
const userAgent = process.env.npm_config_user_agent ?? ''
const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'
fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName ?? defaultProjectName,
packageManager,
needsTypeScript,
needsVitest,
needsCypress,
needsCypressCT,
needsEslint
})
)
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'install')))}`)
if (needsPrettier) {
console.log(` ${bold(green(getCommand(packageManager, 'lint')))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()
7. 测试
npm run test 实际先后执行了 build、snapshot和test
build就是我们上面分析的outfile.cjs的内容
7.1 snapshot的作用
- 找到
['typescript', 'jsx', 'router', 'pinia', 'vitest', 'cypress']这几个包组合的所有可能性 (还有default) ['typescript', 'jsx', 'router', 'pinia'](测试无关)为前缀,with-tests结尾,(包括with-tests)生成所有可能的组合- 依次遍历以上所有的组合,以
'-'拼接生成相应的目录名,在相应目录下生成项目文件(删除前一次的目录及文件) - 把所有的生成项目的组合放到
playground/目录下
7.2 test做了啥
主要是对playground/下的项目文件进行测试
需要注意的是: 这里的测试命令(如: pnpm test:unit)在运行build的时候就会从template中拿过来了
具体的行为如下:
- 如果目录名含有
vitest,执行pnpm test:unit - 如果目录名含有
cypress,执行pnpm build然后执行pnpm test:e2e:ci(页面测试 url方式打开) - 如果目录名不含
vitest,则使用cypress的组件测试 - 项目名以
with-tests结尾,依次执行pnpm test:unitpnpm buildpnpm test:e2e:ci
for (const projectName of fs.readdirSync(playgroundDir)) {
if (projectName.includes('vitest')) {
cd(path.resolve(playgroundDir, projectName))
console.log(`Running unit tests in ${projectName}`)
await $`pnpm test:unit`
}
if (projectName.includes('cypress')) {
cd(path.resolve(playgroundDir, projectName))
console.log(`Building ${projectName}`)
await $`pnpm build`
console.log(`Running e2e tests in ${projectName}`)
await $`pnpm test:e2e:ci`
if (!projectName.includes('vitest')) {
try {
await `pnpm test:unit:ci`
} catch (e) {
console.error(`Component Testing in ${projectName} fails:`)
console.error(e)
}
}
}
// 等同于 `--vitest --cypress`
if (projectName.endsWith('with-tests')) {
cd(path.resolve(playgroundDir, projectName))
console.log(`Running unit tests in ${projectName}`)
await $`pnpm test:unit`
console.log(`Building ${projectName}`)
await $`pnpm build`
console.log(`Running e2e tests in ${projectName}`)
await $`pnpm test:e2e:ci`
}
}
8. 总结
create-vue确实很快很好用,以前只知道怎么用,这下知道怎么写了,哈哈哈哈哈 过程比较漫长,这篇源码读了很久,也写了很久,但是最终的目的达到了(也不能为了写文章而写文章,是吧)。学到了很多优秀的工具,比如zx、start-server-and-test,编码的思想,比如模版渲染,还有一些编码技巧,比如snapshot生成组合那里(<<运算符的使用)等等。
下面就差实践了,后面为公司写一个脚手架工具😊。