【若川视野 x 源码共读】第9期 | create-vue 超详细解读

1,581 阅读6分钟

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

这是源码共读的第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到本地

git clone git@github.com:vuejs/create-vue.git

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

  1. 新建仓库
  2. 克隆仓库到本地
git clone git@github.com:baosisi07/create-vue-analysi.git
cd create-vue-analysi
  1. 使用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钩子之外执行,比如

  • prepareprepublishprepublishOnlyprepackpostpack

  • 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

使用:

  1. 首先,为了在最顶层使用await,我们将脚本文件后缀名改为.mjs
  2. 在zx脚本文件开头添加
#!/usr/bin/env zx
  1. 运行脚本
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完成了以下功能:

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

由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

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

image.png

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

image.png 使用示例:

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)
  • 根据是否需要typeScriptprettierCypress等生成相应的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配置。

image.png

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

image.png

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是基础模版。

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的方法添加样式
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
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生成组合那里(<<运算符的使用)等等。

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