【若川视野 x 源码共读】第9期 create-vue

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

这是源码共读的第9期,链接:juejin.cn

1. 学习目标

  1. 学会全新的官方脚手架工具 create-vue 的使用和原理
  2. 学会使用 VSCode 直接打开 github 项目
  3. 学会使用测试用例调试源码
  4. 学以致用,为公司初始化项目写脚手架工具

2. 源码地址

github.com/vuejs/creat…

线上vsCode阅读

vsCode打开github项目
vscode.dev/github/ 替换掉 github.com/ 即可

3. 关于create-vue的使用

执行命令npm init vue@next,根据提示选择使用的技术栈,给出后续操作提示并生成项目。如图所示:

image.png

这里的npm init vue@next实际是执行的npx create-vue@next

涉及的知识点:

  • npm init XX 等同于 npx creat-XX npm 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到本地

1
bash复制代码git clone git@github.com:vuejs/create-vue.git

如果想克隆到自己的项目并保留原代码库create-vue的提交记录,参考如下操作:

  1. 新建仓库
  2. 克隆仓库到本地
1
2
bash复制代码git clone git@github.com:baosisi07/create-vue-analysi.git
cd create-vue-analysi
  1. 使用Git Subtree将源代码clone到当前目录的create-vue
1
2
3
4
5
6
7
swift复制代码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解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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变量获取当前正在运行的脚本名称。如:

1
2
3
4
js复制代码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中配置即可,例如:

1
2
3
4
5
6
7
8
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脚本变得更容易。
安装:

1
css复制代码npm i -g zx

使用:

  1. 首先,为了在最顶层使用await,我们将脚本文件后缀名改为.mjs
  2. 在zx脚本文件开头添加
1
javascript复制代码#!/usr/bin/env zx
  1. 运行脚本
1
bash复制代码zx ./script.mjs

zx常用函数 zx文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码// 所有函数都是直接使用,无需引入的

// $使用
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完成了以下功能:

  1. 创建默认文件vue-project,可以自定义输入文件名
  2. 提供使用频率比较高的库供用户选择并生成相应的模版
  3. 完成项目创建,并提供运行提示

由package.json中可以看到,执行create-vue实际是执行了outfile.cjs,而outfile.cjs是根目录下的index.ts所生成。

我们先看下index.ts的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码#!/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

主要作用就是解析命令行参数。看示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码// 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可能包含如下属性:

1
2
3
4
5
6
7
8
9
10
11
json复制代码{
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

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
js复制代码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

定义标准输入/输出的颜色,颜色示例:

image.png

还有一个使用到的库 gradient-string用于定义渐变字符串
这里的banner就是这个库生成的结果,如图:

image.png
使用示例:

1
2
3
4
5
js复制代码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)

通过参数选择包管理器和执行的脚本命令

1
2
js复制代码getCommand('npm', 'test') => npm run test
getCommand('yarn', 'install') => yarn
renderEslint(rootdir, options)
  • 根据是否需要typeScriptprettierCypress等生成相应的dependencies及scripts到package.json中
  • 生成相应的.eslintrc.cjs

6. 源码解析

6.1 获取初始化时命令行的参数作为询问依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
js复制代码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配置。

image.png

除了传入projectName如果传入包含在isFeatureFlagsUsed中的任一参数,并且值为true时,则直接跳过所有询问项,直接生成。可以看到minimist处理的别名和原名的值都是存在的且是同步的。

image.png

6.2 交互式询问配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
js复制代码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. 根据询问结果定义各个配置项变量,供后续使用
1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码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. 创建新项目目录,已存在则清空
1
2
3
4
5
js复制代码if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
3. 新建package.json并添加name和version
1
2
js复制代码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需要对支持的包进行单独的渲染配置
  • 生成示例代码文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
js复制代码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是基础模版。

image.png

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的方法添加样式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
js复制代码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 实际先后执行了 buildsnapshottest

build就是我们上面分析的outfile.cjs的内容

7.1 snapshot的作用

  1. 找到['typescript', 'jsx', 'router', 'pinia', 'vitest', 'cypress']这几个包组合的所有可能性 (还有default
  2. ['typescript', 'jsx', 'router', 'pinia'](测试无关)为前缀,with-tests结尾,(包括with-tests)生成所有可能的组合
  3. 依次遍历以上所有的组合,以'-'拼接生成相应的目录名,在相应目录下生成项目文件(删除前一次的目录及文件)
  4. 把所有的生成项目的组合放到playground/目录下

7.2 test做了啥

主要是对playground/下的项目文件进行测试

需要注意的是: 这里的测试命令(如: pnpm test:unit)在运行build的时候就会从template中拿过来了

具体的行为如下:

  1. 如果目录名含有vitest,执行pnpm test:unit
  2. 如果目录名含有cypress,执行pnpm build然后执行pnpm test:e2e:ci(页面测试 url方式打开)
  3. 如果目录名不含vitest,则使用cypress的组件测试
  4. 项目名以with-tests结尾,依次执行
    • pnpm test:unit
    • pnpm build
    • pnpm test:e2e:ci
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
js复制代码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确实很快很好用,以前只知道怎么用,这下知道怎么写了,哈哈哈哈哈 过程比较漫长,这篇源码读了很久,也写了很久,但是最终的目的达到了(也不能为了写文章而写文章,是吧)。学到了很多优秀的工具,比如zxstart-server-and-test,编码的思想,比如模版渲染,还有一些编码技巧,比如snapshot生成组合那里(<<运算符的使用)等等。

下面就差实践了,后面为公司写一个脚手架工具😊。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%