从零开发一个脚手架(进阶)

本文作者:岳永

介绍

脚手架入门版我们了解到了脚手架的作用好处以及开发一个脚手架的基本套路,那么在脚手架进阶版我会带着大家一起来探索下vue官方版脚手架vue-cli实现的核心主要逻辑,以便我们在脚手架高级版中能够融会贯通的实现我们自己想要的功能。ok, 接下来首先看下脚手架进阶版的目录树。

进阶版项目目录树

coo-cli2
├─README.md
├─index.js // 入口文件
├─package.json
├─lib
|  ├─utils
|  |   ├─executeCommand.js
|  |   ├─normalizeFilePaths.js // 斜杠转义
|  |   ├─waitFnLoading.js
|  |   ├─writeFileTree.js // 写入文件
|  |   ├─codemods // AST注入
|  |   |    ├─injectImports.js
|  |   |    └injectOptions.js
|  ├─promptModules // 各个模块的向导提示语
|  |       ├─babel.js
|  |       ├─linter.js
|  |       ├─router.js
|  |       └vuex.js
|  ├─linkConfig
|  |     └vue-repo.js
|  ├─generator // 当前生成项目的功能模版
|  |     ├─webpack
|  |     ├─vuex
|  |     ├─vue
|  |     ├─router
|  |     ├─linter
|  |     ├─babel
|  ├─core
|  |  ├─Creator.js // 创建交互提示类函数
|  |  ├─Generator.js // 项目生成构类函数
|  |  ├─PromptModuleAPI.js // 交互提示注入类函数
|  |  ├─create.js
|  |  ├─help.js
|  |  ├─actions
|  |  |    ├─createProject.js
|  |  |    ├─index.js
|  |  |    └initTemplate.js // 初始模版脚本
复制代码

coo init my-project命令实现的主要功能(进阶版)

  • 多个功能模块的交互提示
  • 按需注入依赖
  • 模板文件渲染
  • AST解析生成
  • 下载依赖
  • 启动项目

下面我们就按照这个功能实现顺序逐个进行学习。
建议结合项目源码一起配合阅读本文,效果更好。cli-learn,当前项目分支 v2

WX20210423-123924@2x.png

注册 init 命令

const program = require('commander')
const { createProjectAction, initTemplateAction } = require('./actions')

program
  .command('init <project-template> [others...]')
  .description('copy a mini cli just like vue-cli')
  .action(initTemplateAction)
复制代码

首先我们需要在/lib/core/create.js中新增注册init命令,命令行解析如何实现请移步脚手架入门版,这里不再赘述。在上面的代码,我们可以看到init命令实现的主要行为逻辑是在initTemplateAction函数中,所以我们接下来学习的重点主要是看这个函数做了哪些事情。

功能模块交互

脚手架入门版我们也实现了一个简单的用户交互,就是为用户提供包管理工具(yarn or npm)的选择向导,我们也了解了实现这个交互功能主要是使用inquirer库。那么在脚手架进阶版的学习当中,我们依然使用这个库为用户提供功能模块的交互向导。这次相比与脚手架入门版实现的简单交互不同,脚手架进阶版提供了vuexrouterlinterbabel多个功能模块供用户选择,所以我们把每个模块的交互提示语统一归类在了/lib/promptModules文件夹中,如下:

|  ├─promptModules // 各个模块的向导提示语
|  |       ├─babel.js
|  |       ├─linter.js
|  |       ├─router.js
|  |       └vuex.js
复制代码

这个部分我们重点学习如何实现将/lib/promptModules下的多个交互提示语弹出供用户选择。
位置:/lib/core/actions/initTemplate.js

const path = require('path')
const inquirer = require('inquirer')
// 创建交互提示构造类
const Creator = require('../Creator')
// 交互提示注入构造类
const PromptModuleAPI = require('../PromptModuleAPI')
const waitFnLoading = require('../../utils/waitFnLoading')

// 初始化vue模板
const initTemplate = async (project) => {
  const creator = new Creator()
  const promptModules = getPromptModules()
  const promptAPI = new PromptModuleAPI(creator)
  promptModules.forEach(m => m(promptAPI))

  // console.dir(creator.getFinalPrompts(), '已获取所有模块的交互提示语');

  // 为用户提供问题向导
  const answers = await inquirer.prompt(creator.getFinalPrompts())
}

// 批量导入模块
function getPromptModules () {
  return [
    'babel',
    'router',
    'vuex',
    'linter',
  ].map(file => require(`../../promptModules/${file}`))
}

module.exports = initTemplate
复制代码

上述代码,我们重点关注下Creator类,PromptModuleAPI类,和/lib/promptModules目录下的各个文件。

Creator

位置: /lib/core/Creator.js

class Creator {
  constructor() {
    this.featurePrompt = {
      name: 'features',
      message: 'Check the features needed for your project:',
      pageSize: 10,
      type: 'checkbox',
      choices: [],
    }

    this.injectedPrompts = []
  }

  // 获取所有交互提示语
  getFinalPrompts () {
    this.injectedPrompts.forEach(prompt => {
      const originalWhen = prompt.when || (() => true)
      prompt.when = answers => originalWhen(answers)
    })

    const prompts = [
      this.featurePrompt,
      ...this.injectedPrompts,
    ]

    return prompts
  }
}

module.exports = Creator
复制代码

简单说下这个类的实现,初始化featurePrompt对象,这个对象就是未来要传入iquirer.prompt方法的交互参数,type类型为checkbox,说明这里是复选项。初始化injectedPrompts空数组,在说这个数组的作用之前,需要先补充关于inquirer库的另一个功能,就是它还可以提供具有相关性的问题,通俗讲就是下一个问题是否弹出要根据上一个问题选择的答案来决定。举个?:

  {
    name: 'Router',
    value: 'router',
    description: 'Structure the app with dynamic pages',
    link: 'https://router.vuejs.org/',
  },
  {
    name: 'historyMode',
    when: answers => answers.features.includes('router'),
    type: 'confirm',
    message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
    description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
    link: 'https://router.vuejs.org/guide/essentials/history-mode.html',
  }
复制代码

第二个问题对象中有一个when属性,该属性值是一个函数answers => answers.features.includes('router')。如果函数的执行结果为true,那么第二个问题才会弹出。也就是你在上一个问题中选择了router的话,它的结果就会变为true。弹出第二个问题:Use history mode for router?...
ok, 了解了这个功能后,我们就可以用这个空数组injectedPrompts来存放一些具有相关性问题(有when属性)的提示语了。Creator类做的最后一件事就是通过getFinalPrompts方法对数组injectedPrompts遍历,实现对命中相关性提示语和普通提示语的一个合并返回。

PromptModuleAPI

位置:/lib/core/PromptModuleAPI.js

module.exports = class PromptModuleAPI {
  constructor(creator) {
    this.creator = creator
  }

  injectFeature (feature) {
    this.creator.featurePrompt.choices.push(feature)
  }

  injectPrompt (prompt) {
    this.creator.injectedPrompts.push(prompt)
  }
}
复制代码

PromptModuleAPI类的实现很简单,首先接收一个creator参数赋值,这里的参数creator就是我们刚刚上部分提到的Creator类的实例化。该类内部注册了两个方法,分别是injectFeature用来push普通提示语、injectPrompt用来push相关性提示语。

/lib/promptModules下的文件

上文也已经提到过,/lib/promptModules下的文件主要是各个功能模块的交互提示语,如下:

|  ├─promptModules // 各个模块的向导提示语
|  |       ├─babel.js
|  |       ├─linter.js // eslint
|  |       ├─router.js
|  |       └vuex.js
复制代码

这里我们就拿一个既有普通提示语也有相关性提示语的router.js举例:

const chalk = require('chalk')

module.exports = (api) => {
  api.injectFeature({
    name: 'Router',
    value: 'router',
    description: 'Structure the app with dynamic pages',
    link: 'https://router.vuejs.org/',
  })

  api.injectPrompt({
    name: 'historyMode',
    when: answers => answers.features.includes('router'),
    type: 'confirm',
    message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
    description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
    link: 'https://router.vuejs.org/guide/essentials/history-mode.html',
  })
}
复制代码

这里导出一个函数,在getPromptModules方法中通过数组的map方法包装后返回数组函数,对其进行遍历,遍历的每一项m就是这里导出的函数,它的参数为PromptModuleAPI类的实例化,即导出函数的参数apinew PromptModuleAPI(),核心代码如下:
位置:/lib/core/actions/initTemplate.js

// 创建交互提示构造类
const Creator = require('../Creator')
// 交互提示注入构造类
const PromptModuleAPI = require('../PromptModuleAPI')

// 初始化vue模板
const initTemplate = async (project) => {
  const creator = new Creator()
  const promptModules = getPromptModules()
  const promptAPI = new PromptModuleAPI(creator)
  promptModules.forEach(m => m(promptAPI))
}

// 批量导入模块
function getPromptModules () {
  return [
    'babel',
    'router',
    'vuex',
    'linter',
  ].map(file => require(`../../promptModules/${file}`))
}

module.exports = initTemplate
复制代码

针对上述代码逻辑,用流程图总结一下:

WX20210326-165917@2x.png
到这里,creator.getFinalPrompts方法就可以把/lib/promptModules下的所有模块提示语获取到,并以数组的形式返回,作为inquirer.prompt方法的入参。

// 为用户提供问题向导
const answers = await inquirer.prompt(creator.getFinalPrompts())
复制代码

效果如下:

WX20210325-195002@2x.png

所有功能都选上,answers的值为:

WX20210325-195635@2x.png

依赖注入

脚手架入门版中,我们是使用download-git-repo这个库传入remote url的形式直接从远程拉取项目到本地,没有涉及到按需依赖注入的需求,那么在脚手架进阶版中我们也做到像vue-cli那样根据用户的选择来进行相对应模板及依赖的注入。ok,接下来我们就开始学习如何实现模板依赖注入到项目中。

依赖注入本质就是把用户选择的所有功能同我们项目中预设好的模板关联,向package.json中的属性赋值。

那么首先我们需要定义一个变量pkg来表示package.json文件,并设定一些默认值。

// package.json 文件内容
const pkg = {
  name: project,
  version: '0.1.0',
  dependencies: {},
  devDependencies: {},
}
复制代码

项目中预设好的模板都放在/lib/generator

|  ├─generator // 当前生成项目的功能模版
|  |     ├─webpack // webpack 模板
|  |     ├─vuex // vuex 模板
|  |     ├─vue // vue 模板
|  |     ├─router // vue-router 模板
|  |     ├─linter // eslint 模板
|  |     ├─babel // babel 模板
复制代码
  • 依赖选项同模版关联

在上一小节中,我们可以知道用户选择了哪些依赖项,这样就可以通过遍历依赖项数组features来获取对应的模版。如下代码:
位置: /lib/core/actions/initTemplate.js

const Generator = require('../Generator')
const generator = new Generator(pkg, path.join(process.cwd(), project))

// 由于这是一个 vue 相关的脚手架,所以 vue 模板应该是默认提供的,不需要用户选择。
// 另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不需要用户选择。
// 所以在遍历之前我们需要手动把这两个默认依赖加入到依赖项数组中即可。
answers.features.unshift('vue', 'webpack')

// 根据用户选择的选项加载相应的模块,在 package.json 写入对应的依赖项
answers.features.forEach(feature => {
  require(`../../generator/${feature}`)(generator, answers)
})
复制代码

这样我们就已经做好了模板关联。

  • package.json中的属性赋值

这一步主要是通过 require(../../generator/${feature})(generator, answers) 来导入一个函数并执行,实现属性赋值。以/lib/generator下的babel模板代码为例:

module.exports = (generator) => {
  generator.extendPackage({
    babel: {
      presets: ['@babel/preset-env'],
    },
    dependencies: {
      'core-js': '^3.8.3',
    },
    devDependencies: {
      '@babel/core': '^7.12.13',
      '@babel/preset-env': '^7.12.13',
      'babel-loader': '^8.2.2',
    },
  })
}
复制代码

可以看到,这里导出一个函数,模板内调用了传进来的 new Generator(pkg, path.join(process.cwd(), project)) 实例的extendPackage()方法向pkg变量注入了babel相关的所有依赖。

// 向 pkg 变量注入模版相关的所有依赖
extendPackage (fields) {
  const pkg = this.pkg
  for (const key in fields) {
    const value = fields[key]
    const existing = pkg[key]
    if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
      pkg[key] = Object.assign(existing || {}, value)
    } else {
      pkg[key] = value
    }
  }
}
复制代码

模板渲染

这部分我们重点来学习下脚手架是如何解析渲染模板的。所谓的模板,上面也已经提到,主要是放在/lib/generator中。解析渲染模版的主要逻辑在/lib/core/Generator.js中。
模板我们就拿router为例,这里假设用户选择了vue-router,并选了history模式,如下注入代码:

module.exports = (generator, options = {}) => {
  // 向入口文件 `src/main.js` 注入代码 import router from './router'
  generator.injectImports(generator.entryFile, `import router from './router'`)

  // 向入口文件 `src/main.js` 的 new Vue() 注入选项 router
  generator.injectRootOptions(generator.entryFile, `router`)

  generator.extendPackage({
    dependencies: {
      'vue-router': '^3.5.1',
    },
  })

  generator.render('./template', {
    historyMode: options.historyMode,
    hasTypeScript: false,
    plugins: [],
  })
}
复制代码

可以看到,模板调用generator对象的injectImports方法向入口文件src/main.js注入代码import router from './router',调用injectRootOptions方法向入口文件src/main.jsnew Vue()注入选项router,由于这两个方法做的事情的本质都是一样,都是向一个对象中添加属性值,所以我们这里拿injectImports方法简单看一下。如下代码:

/**
 * Add import statements to a file.
 * 
 * injectImports(file: `src/main.js`, imports: `import store from './store'`)
 * 
 * imports: {
 *  './src/main.js': Set{0: `import store from './store'`}
 * }
 */
injectImports (file, imports) {
  const _imports = (
    this.imports[file]
    || (this.imports[file] = new Set())
  );
  (Array.isArray(imports) ? imports : [imports]).forEach(imp => {
    _imports.add(imp)
  })
}
复制代码

接下来我们看下generator.render('./template', {})方法主要做的事情:

  1. 使用globby解析读取模板渲染路径./template下的所有文件
const _files = await globby(['**/*'], { cwd: source, dot: true })
/** router模板
 * _files: [
 *  'src/App.vue',
*   'src/router/index.js',
    'src/views/About.vue',
    'src/views/Home.png'
  * ]
  */
复制代码
  1. 遍历所有读取的文件。如果是二进制文件,直接读取文件内容返回。否则先读取文件内容,再调用ejs进行渲染
for (const rawPath of _files) {
  const sourcePath = path.resolve(source, rawPath)
  //sourcePath: /Users/erwin/Desktop/脚手架/coo-cli2/lib/generator/router/template/*
  // 解析文件内容
  const content = this.renderFile(sourcePath, data, ejsOptions)
  // only set file if it's not all whitespace, or is a Buffer (binary files)
  if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
    files[rawPath] = content
  }
}

renderFile (pathName, data, ejsOptions) {
  // 如果是二进制文件,直接将读取结果返回
  if (isBinaryFileSync(pathName)) {
    return fs.readFileSync(pathName) // return buffer
  }

  // 返回文件内容
  const template = fs.readFileSync(pathName, 'utf-8')
  return ejs.render(template, data, ejsOptions)
}
复制代码

这里简单说下使用ejs渲染模板的好处,就是它可以通过变量来判断是否要渲染某段代码,例如模板router
中的index.js的这段代码:

const router = new VueRouter({
  <%_ if (historyMode) { _%>
  mode: 'history',
  <%_ } _%>
  routes
})
复制代码

ejs就可以根据用户是否选择history模式来决定是否渲染mode: 'history'。这里的historyMode是在调用render方法传入

generator.render('./template', {
  historyMode: options.historyMode,
  hasTypeScript: false,
  plugins: [],
})
复制代码

然后通过调用ejs.render(template, data, ejsOptions)方法的第二个参数data来进行模板渲染。

renderFile (pathName, data, ejsOptions) {
  // 如果是二进制文件,直接将读取结果返回
  if (isBinaryFileSync(pathName)) {
    return fs.readFileSync(pathName) // return buffer
  }

  // 返回文件内容
  const template = fs.readFileSync(pathName, 'utf-8')
  return ejs.render(template, data, ejsOptions)
}
复制代码
  1. ejs渲染好的内容作为 value,赋值给files对象的 key
// 文件渲染
const content = this.renderFile(sourcePath, data, ejsOptions)
// only set file if it's not all whitespace, or is a Buffer (binary files)
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
  files[rawPath] = content
}
复制代码

AST解析生成

await generator.generate()

// 生成模版文件
async generate () {
  // 解析文件内容
  await this.resolveFiles()

  this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n'
  // 将所有文件写入到用户要创建的目录
  // context: /Users/erwin/Desktop/脚手架/my-project
  await writeFileTree(this.context, this.files)
}
复制代码

这个小结,主要是将前面所有遍历出来的模板文件(包括默认模板文件(vuewebpack)和用户选择的模板文件(babellinterroutervuex))对入口文件/src/main.js进行属性取值后,通过vue-codemod配合jscodeshift转成AST,进行注入,最后返回一个注入好的文件内容赋值给属性/src/main.js。代码如下:

// vue-codemod 库,对代码进行解析得到 AST,再将 import 语句和根选项注入
// 处理 import 语句的导入
Object.keys(files).forEach(file => {
  let imports = this.imports[file] // 对vue模板下的src/main.js进行属性取值
  imports = imports instanceof Set ? Array.from(imports) : imports
  if (imports && imports.length > 0) {
    // 将 import 语句注入到 src/main.js入口文件中
    files[file] = runTransformation(
      { path: file, source: files[file] }, // source: src/main.js源文件
      require('../utils/codemods/injectImports'),
      { imports }, // imports: [`import router from './router'`]
    )
  }
}
复制代码

渲染好的模板文件和package.json文件通过writeFileTree方法在本地进行写入。
位置:/lib/core/utils/writeFileTree.js

const fs = require('fs-extra')
const path = require('path')

module.exports = async function writeFileTree (dir, files) {
  Object.keys(files).forEach((name) => {
    const filePath = path.join(dir, name)
    fs.ensureDirSync(path.dirname(filePath))
    fs.writeFileSync(filePath, files[name])
  })
}
复制代码

这段代码的逻辑如下:

  1. 遍历所有渲染好的文件,逐一生成。
  2. 在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。

例如现在一个文件的路径为src/main.js,第一次写入时,由于还没有src目录,所以会先生成src目录,再生成main.js文件。
3. 写入文件。

下载依赖并启动项目

// 下载依赖
await waitFnLoading(commandSpawn, 'installing packages...')('npm', ['install'], { cwd: `./${project}` })

// 启动项目
await commandSpawn('npm', ['run', 'dev'], { cwd: `./${project}` })
复制代码

这个功能在脚手架入门版已实现,具体了解请移步脚手架入门版,这里不再赘述。

项目运行效果:

vue1.gif
项目运行成功目录截图:

WX20210329-154301@2x.png

总结

脚手架进阶版相比入门版,虽然内容多了不少,但其实整体架构也不是很难,这也是vue-cli脚手架实现的核心主要部分,希望可以帮助大家探究自己工作当中最常用的vue脚手架的实现和开发思想。接下来,在脚手架高级版中,我会继续带大家借助vue-cli的实现思想开发一些工作当中实用的脚手指令,帮助大家提高开发效率~

参考资料:

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享