继续跟上川哥举办的阅读源码活动~ 掘金原文:https://juejin.cn/post/7018344866811740173#heading-4

1. 阅读前准备

这是一个vite+vue3的脚手架,目前还属于比较初版的状态,README也不是很全面,大致浏览源码后,主要有以下几点可以好好学习下:

  • npm init
  • 交互式初始化
  • 根据选择配置生成不同模板
  • 模板生成原理

项目地址: https://github.com/vuejs/create-vue

这是一个我之前完全没有接触过的项目,文章中间会拓展很多create-Vue之外的内容,最终希望自己可以通过阅读源码,产出cli工具,方便在工作中项目开发

2. 使用它

使用起来非常简单,只需要 npm init 即可

  1. npm init vue@next

接着输入项目名、确认是否需要额外的配置项,即可生成一个完整的项目
image.png
最终只需要cd vue-projectnpm installnpm run dev即可非常急速的打开这个项目

3. 阅读源码

首先克隆项目、安装依赖

  1. git clone https://github.com/vuejs/create-vue.git
  2. npm install

进入package.json看看可执行的脚本
image.png
看到几个熟悉的脚本:

  • build:将脚手架主文件index.js打包输出成outfile.cjs
  • test:执行测试用例

先从执行test调试起!

3.1 snapshot

执行npm run test时,会先执行npm run pretest (第三期的知识点:npm钩子函数)
看看snapshot中都做了什么事情~

进入snapshot发现这里做了两件事:

  • 调用node的子进程方法中同步进程函数,根据index.js文件中的规则,在playground文件夹下创建不同的模板,方便调用test.js进行测试
  • 根据五种拓展属性,进行二进制的排列组合,最终会生成31种+default一共32种组合

这里主要看一下生成模板的方法:

  1. // 获取基本路径
  2. const __dirname = path
  3. .dirname(new URL(import.meta.url).pathname)
  4. .substring(process.platform === 'win32' ? 1 : 0)
  5. // 这里的bin是执行打包后的index.js文件生成项目模板,方便调试就改成了index.js
  6. // const bin = path.resolve(__dirname, './outfile.cjs')
  7. const bin = path.resolve(__dirname, './index.js')
  8. // 目标文件夹
  9. const playgroundDir = path.resolve(__dirname, './playground/')
  10. function createProjectWithFeatureFlags(flags) {
  11. // flags会以数组的形式传入,如果出现多个参数时,用-分割
  12. const projectName = flags.join('-')
  13. console.log(`Creating project ${projectName}`)
  14. // 调用子进程
  15. // 这部分会生成一个类似于 node ./index.js --typescript --force的命令
  16. // 主要就是调用index.js中的init方法生成模板
  17. const { status } = spawnSync(
  18. 'node',
  19. [bin, projectName, ...flags.map((flag) => `--${flag}`), '--force'],
  20. {
  21. cwd: playgroundDir,
  22. stdio: ['pipe', 'pipe', 'inherit']
  23. }
  24. )
  25. if (status !== 0) {
  26. process.exit(status)
  27. }
  28. }

这里主要的灵魂就是调起子进程,执行文件生成项目模板

接着看看具体是怎么生成模板的

3.2 index.js

这部分主要做了以下几件事:

  • 支持feature Flags直接生成模板
  • 调用prompts进行交互式配置
  • 根据配置调用render()渲染模板
  • 根据配置生成不同README

3.2.1 交互式

首先在script中添加一行"dev":"node index.js"运行调试,开始我们的debug
最先进入的会是一段交互式配置,这部分代码将做省略,保留一些调用了工具方法的配置

  1. let result = {}
  2. try {
  3. result = await prompts(
  4. [
  5. {
  6. name: 'shouldOverwrite',
  7. // 这里判断了一次改名称是否可写入
  8. type: () => (canSafelyOverwrite(targetDir) || forceOverwrite ? null : 'confirm'),
  9. message: () => {
  10. const dirForPrompt =
  11. targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`
  12. return `${dirForPrompt} is not empty. Remove existing files and continue?`
  13. }
  14. },
  15. {
  16. name: 'overwriteChecker',
  17. type: (prev, values = {}) => {
  18. if (values.shouldOverwrite === false) {
  19. throw new Error(red('✖') + ' Operation cancelled')
  20. }
  21. return null
  22. }
  23. },
  24. {
  25. name: 'packageName',
  26. // 这里判断了一次当前包名称是否合法
  27. type: () => (isValidPackageName(targetDir) ? null : 'text'),
  28. message: 'Package name:',
  29. // 这里调用了转换包名工具
  30. initial: () => toValidPackageName(targetDir),
  31. validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'
  32. },
  33. ],
  34. )
  35. } catch (cancelled) {
  36. console.log(cancelled.message)
  37. process.exit(1)
  38. }

这三个工具函数也比较简单,通过正则或node方法,进行简单的判断

  1. // 使用正则判断当前包名是否合法,没有采用validate-npm-package-name(第七期检测包名是否合法的工具),自己实现了检测功能
  2. function isValidPackageName(projectName) {
  3. return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName)
  4. }
  5. // 对传入的包名称进行转写
  6. function toValidPackageName(projectName) {
  7. return projectName
  8. .trim()
  9. .toLowerCase()
  10. .replace(/\s+/g, '-')
  11. .replace(/^[._]/, '')
  12. .replace(/[^a-z0-9-~]+/g, '-')
  13. }
  14. // 使用node的内置方法,判断本地是否存在该名称的文件夹,以及该文件夹下是否有文件
  15. function canSafelyOverwrite(dir) {
  16. return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0
  17. }

在执行完所有的交互配置后,会在文件内部result属性中记录最终的配置结果:
image.png

3.2.2生成模板

生成完所有配置后,会根据配置进行模板生成,具体生成顺序如下:

  • 生成base基础模板
  • 根据不同配置需求,继续添加合并模板
  • 如果需要typeScript,将所有.js文件改成.ts,并将jsconfig.json改成tsconfig.json
  • 如果不需要测试,就把测试相关文件删除
  • 生成README文档

第一步!生成模板

  1. import renderTemplate from './utils/renderTemplate.js'
  2. // 设置模板文件地址
  3. const templateRoot = path.resolve(__dirname, 'template')
  4. // 生成模板主函数
  5. const render = function render(templateName) {
  6. // 具体要生成的模板目录下的子目录
  7. const templateDir = path.resolve(templateRoot, templateName)
  8. // 传入子目录和项目的地址
  9. renderTemplate(templateDir, root)
  10. }
  11. // Render base template
  12. render('base')

接着看看具体生成模板的流程,同样也分成三部分:

  • 递归copy所有的模板文件进项目中
  • 如果文件中存在package.json,进行对象合并
  • 将以_开头的文件替换成.开头

第一部分:处理文件夹

  1. const stats = fs.statSync(src)
  2. // 如果传入的src是文件夹,就递归调用renderTemplate处理文件夹下的每一个文件
  3. if (stats.isDirectory()) {
  4. // if it's a directory, render its subdirectories and files recursively
  5. fs.mkdirSync(dest, { recursive: true })
  6. for (const file of fs.readdirSync(src)) {
  7. renderTemplate(path.resolve(src, file), path.resolve(dest, file))
  8. }
  9. return
  10. }

第二部分:处理package.json文件:

  1. import deepMerge from './deepMerge.js'
  2. import sortDependencies from './sortDependencies.js'
  3. const filename = path.basename(src)
  4. // 如果文件名称为package.json且该文件已经存在
  5. if (filename === 'package.json' && fs.existsSync(dest)) {
  6. // 存放需要操作的文件
  7. const existing = JSON.parse(fs.readFileSync(dest))
  8. // 存放需要拷贝的文件
  9. const newPackage = JSON.parse(fs.readFileSync(src))
  10. // 重点!:调用了两个函数,deepMerge用于处理两个json的合并,sortDependencies用于排序
  11. const pkg = sortDependencies(deepMerge(existing, newPackage))
  12. fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n')
  13. return
  14. }

处理文件过程中使用到两个函数:deepMergesortDependencies,分别用于处理合并和排序
继续深入看deepMerge,这部分主要是对 对象和数组的处理:

  • 处理对象时,通过递归赋值进行处理
  • 处理数组时,通过解构合并进行处理 ```typescript const isObject = (val) => val && typeof val === ‘object’ const mergeArrayWithDedupe = (a, b) => Array.from(new Set([…a, …b]))

/**

  • Recursively merge the content of the new object to the existing one
  • @param {Object} target the existing object
  • @param {Object} obj the new object */ function deepMerge(target, obj) { // 循环传入的template中的模板 for (const key of Object.keys(obj)) { const oldVal = target[key] const newVal = obj[key]

    1. //如果参数的内容是数组

    if (Array.isArray(oldVal) && Array.isArray(newVal)) { // 使用解构进行合并 target[key] = mergeArrayWithDedupe(oldVal, newVal) } else if (isObject(oldVal) && isObject(newVal)) { // 如果参数内容是对象,就继续递归处理,直到是内容 target[key] = deepMerge(oldVal, newVal) } else { // 直接为内容时,直接赋值 target[key] = newVal } }

    return target }

    1. 合并后继续做排序的处理,这时会进入到`sortDependencies`函数中
    2. ```typescript
    3. export default function sortDependencies(packageJson) {
    4. const sorted = {}
    5. // 依照数组中元素的顺序,进行遍历排序
    6. const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
    7. for (const depType of depTypes) {
    8. if (packageJson[depType]) {
    9. sorted[depType] = {}
    10. Object.keys(packageJson[depType])
    11. .sort()
    12. .forEach((name) => {
    13. sorted[depType][name] = packageJson[depType][name]
    14. })
    15. }
    16. }
    17. return {
    18. ...packageJson,
    19. ...sorted
    20. }
    21. }

    第三部分,修改文件名
    项目中以.开头的文件都是一些配置文件,而如果直接在模板中存放这些文件时,可能会造成编译器识别的一些影响,所以在模板中,都以_开头存放文件,在生成模板文件时再进行改名

这里比较简单,就是一个node.path的调用

  1. if (filename.startsWith('_')) {
  2. // rename `_file` to `.file`
  3. dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))
  4. }

至此,一个模板已经完全生成!

让我们进入简单的第二步:根据自定义配置继续生成模板

这部分比较简单,从之前的配置信息中,生成调用不同路径下的文件,继续生成模板

  1. // Add configs.
  2. if (needsJsx) {
  3. render('config/jsx')
  4. }
  5. if (needsRouter) {
  6. render('config/router')
  7. }
  8. if (needsVuex) {
  9. render('config/vuex')
  10. }
  11. if (needsTests) {
  12. render('config/cypress')
  13. }
  14. if (needsTypeScript) {
  15. render('config/typescript')
  16. }
  17. // Render code template.
  18. // prettier-ignore
  19. const codeTemplate =
  20. (needsTypeScript ? 'typescript-' : '') +
  21. (needsRouter ? 'router' : 'default')
  22. render(`code/${codeTemplate}`)
  23. // Render entry file (main.js/ts).
  24. if (needsVuex && needsRouter) {
  25. render('entry/vuex-and-router')
  26. } else if (needsVuex) {
  27. render('entry/vuex')
  28. } else if (needsRouter) {
  29. render('entry/router')
  30. } else {
  31. render('entry/default')
  32. }

继续进入第三步:使用typeScript
这部分比较简单,递归遍历所有后缀为.js的文件,替换成.ts,以及将jsconfig.json转换成tsconfig.json(简单粗暴),在这个过程中调用了preOrderDirectoryTraverse方法,我们重点细看这部分方法

  1. if (needsTypeScript) {
  2. // rename all `.js` files to `.ts`
  3. // rename jsconfig.json to tsconfig.json
  4. preOrderDirectoryTraverse(
  5. root,
  6. () => {},
  7. (filepath) => {
  8. // 如果后缀为.js
  9. if (filepath.endsWith('.js')) {
  10. // 进行替换
  11. fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts'))
  12. // 同时如果为jsconfig.json
  13. } else if (path.basename(filepath) === 'jsconfig.json') {
  14. // 也进行替换
  15. fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))
  16. }
  17. }
  18. )
  19. // Rename entry in `index.html`
  20. const indexHtmlPath = path.resolve(root, 'index.html')
  21. const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
  22. fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
  23. }

继续进入preOrderDirectoryTraverse

  1. // 这里主要是做文件夹和文件的递归
  2. export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  3. // 首先读取该路径下所有文件
  4. for (const filename of fs.readdirSync(dir)) {
  5. const fullpath = path.resolve(dir, filename)
  6. // 如果是一个文件夹
  7. if (fs.lstatSync(fullpath).isDirectory()) {
  8. // 执行文件夹的回调
  9. dirCallback(fullpath)
  10. // in case the dirCallback removes the directory entirely
  11. // 如果文件夹没有被删除,还存在
  12. if (fs.existsSync(fullpath)) {
  13. // 继续递归里面的文件
  14. preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
  15. }
  16. continue
  17. }
  18. // 执行文件的回调
  19. fileCallback(fullpath)
  20. }
  21. }

至此,ts的转化也已经完成~

进入第四步:删除测试相关文件
调用fs.rmdirSync删除文件夹时,只能删除空文件夹,所以需要通过遍历文件夹以及文件夹内所有的文件,先删除文件,再删除文件夹
这里同样调用了preOrderDirectoryTraverse方法,
并且在文件夹回调函数中调用了emptyDir方法

  1. if (!needsTests) {
  2. preOrderDirectoryTraverse(
  3. root,
  4. (dirpath) => {
  5. const dirname = path.basename(dirpath)
  6. // 如果文件夹为测试文件夹
  7. if (dirname === 'cypress' || dirname === '__tests__') {
  8. // 调用方法进行删除
  9. emptyDir(dirpath)
  10. fs.rmdirSync(dirpath)
  11. }
  12. },
  13. () => {}
  14. )
  15. }
  16. function emptyDir(dir) {
  17. // 继续文件夹递归
  18. postOrderDirectoryTraverse(
  19. dir,
  20. (dir) => fs.rmdirSync(dir),
  21. (file) => fs.unlinkSync(file)
  22. )
  23. }
  24. export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  25. for (const filename of fs.readdirSync(dir)) {
  26. const fullpath = path.resolve(dir, filename)
  27. // 如果是文件夹,就继续递归
  28. if (fs.lstatSync(fullpath).isDirectory()) {
  29. postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
  30. dirCallback(fullpath)
  31. continue
  32. }
  33. // 如果是文件,调用文件回调函数
  34. fileCallback(fullpath)
  35. }
  36. }

最后第五步:生成README

  1. fs.writeFileSync(
  2. path.resolve(root, 'README.md'),
  3. generateReadme({
  4. projectName: result.projectName || defaultProjectName,
  5. packageManager,
  6. needsTypeScript,
  7. needsTests
  8. })
  9. )

4. 总结

这次的cli源码阅读能感知到自己的知识短板,在执行流程能大概读懂外,对于很多封装方法中涉及到的算法、node指令,阅读起来还是比较吃力,需要反复使用debug和查阅node文档进行理解。

结合第7期,可以发现在制作脚手架、开源库时,有很多工具库可以直接使用,加快开发速度,例如prompts交互式生成配置,就是一个很不错的库

后续还会反复阅读这个库的源码,学习和借鉴制作cli的流程,开发一个在工作团队内部可以方便快速拉取模板代码库的cli,并总结一篇制作cli工具的文章