create-nuxt-app 脚手架源码笔记

1,500 阅读7分钟

前言

为了取得更好的首屏体验,现在团队的H5项目,采用了Nuxt框架来进行SSR。但仅仅停留在使用层面是不够的,Nuxt的中间件机制,插件机制和环境变量注入是怎么实现的?只有更深的理解和掌握Nuxt内部的工作机制,才能更好的定位和理解一些问题或者是才能为某些特殊业务场景在现有框架的基础上进行二次开发。了解一个开源库最直接简单的方式就是啃它的源码。所以先从nuxt项目的脚手架开始!

命令行介绍

create-nuxt-app的使用命令行如下:

npx create-nuxt-app <my-project>

Or starting with npm v6.1 you can do:
npm init nuxt-app <my-project>

Or with yarn:
yarn create nuxt-app <my-project>

npx的用法大家都熟悉,npx会在当前目录中查找node_module下的.bin文件中的命令软连接,如果没有会从npm下载相应的包,并执行。npm init nuxt-app 的方式查看了npm init 官方文档,明白其用法:

The init command is transformed to a corresponding npx operation as follows:

  • npm init foo -> npx create-foo
  • npm init @usr/foo -> npx @usr/create-foo
  • npm init @usr -> npx @usr/create

脚手架整体结构

从Github把create-nuxt-app 的代码拉下来,可以看到库是通过lerna进行子包的管理。子包主要包括两个:

  • cna-template 主要脚手架生成项目包含的模板库,包含nuxt 和其他可配置的库 比如:常用库(axios等), lint工具(ESLint等), Testing库(Jest等)和 UI库等。
  • create-nuxt-app 主要是脚手架的主要逻辑,主要功能如下图:

image

create-nuxt-app的入口脚本如下:

// https://github.com/nuxt/create-nuxt-app  v3.1.0
// lib/cli.js

// 初始化cac对象
const cli = cac('create-nuxt-app')

// 给cac注册命令和选项值
cli
  .command('[out-dir]', 'Generate in a custom directory or current directory')
  .option('-e, --edge', 'To install `nuxt-edge` instead of `nuxt`')
  .option('-i, --info', 'Print out debugging information relating to the local environment')
  .option('--answers <json>', 'Skip all the prompts and use the provided answers')
  .option('--verbose', 'Show debug logs')
  .action((outDir = '.', cliOptions) => {
    if (cliOptions.info) {
      return showEnvInfo()
    }
    console.log(chalk`{cyan create-nuxt-app v${version}}`)
    console.log(chalk`✨  Generating Nuxt.js project in {cyan ${outDir}}`)

    const { verbose, answers } = cliOptions
    const logLevel = verbose ? 4 : 2
    // See https://saojs.org/api.html#standalone-cli
    
    //获取命令行参数传入sao库,进行项目的配置和项目文件的生成
    sao({ generator, outDir, logLevel, answers, cliOptions })
      .run()
      .catch((err) => {
        console.trace(err)
        process.exit(1)
      })
  })

核心库解析

从create-nuxt-app入口脚本可以看出,有两个库非常重要,了解CAC和SAO两个库的功能及其实现方式,才能对脚手架有更深的理解,下面分别对两个库的源码进行解析。先通过结构图的方式让大家有个整体认识,再通过提问的方式来聚焦一些问题,这样感觉更有代入感!

cac

cac 整体结构图

cac是Command And Conquer的缩写,是一个创建CLI应用的开源库。库的类引用结构如下图:

image

cac注册command和不注册command区别,内部实现差别?

大多数情况下,功能单一脚手架,通过不同option的注册,就可以区分指令,例如:create-nuxt-app。但如果脚手架功能复杂度比较高,需要支持开发环境的开发和产品的发布打包,例如:nuxt dev ,nuxt build 等,通过区分不同的command来支持不同脚本实现,不同的命令再通过添加option来实现更多的功能。

cac初始化注册option和command后注册的option有什么区别?

const cli = require('cac')()
 
cli
  .option('-l', 'list something')
  .command('rm <dir>', 'Remove a dir')
  .option('-r, --recursive', 'Remove recursively')
  .action((dir, options) => {
    console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
  })
 

cac在初始化后支持链式调用,初始化对象(cli),下面统一称初始化对象为cli,直接注册option为全局公共option,command后注册option为单个command拥有的option。

// https://github.com/cacjs/cac  version 6.0.0
// cac-master/src/CAC.ts

  // class CAC 初始化方法
  constructor(name = '') {
    super()
    this.name = name
    this.commands = []
    // 全局command对象
    this.globalCommand = new GlobalCommand(this)
    this.globalCommand.usage('<command> [options]')
  }

初始化的时候会,会调用command对象,生成全局command对象,赋值给globalCommand。cli调用的option方法,将option赋值给globalCommand对象,并返cli对象

  option(rawName: string, description: string, config?: OptionConfig) {
    this.globalCommand.option(rawName, description, config)
    return this
  }

cli调用command方法后,会返回command对象,后面链式调用的都是command类的方法,例如option方法等

  command(rawName: string, description?: string, config?: CommandConfig) {
    const command = new Command(rawName, description || '', config, this)
    command.globalCommand = this.globalCommand
    this.commands.push(command)
    return command
  }

GlobalCommand和Command是继承关系

// cac-master/src/Command.ts

  class GlobalCommand extends Command {
    constructor(cli: CAC) {
      super('@@global@@', '', {}, cli)
    }
  }
cac怎样注册option?

option方法单独抽取成一个类,进行操作,格式处理和验证。

// cac-master/src/Option.ts

export default class Option {
  /** Option name */
  name: string
  /** Option name and aliases */
  names: string[]
  isBoolean?: boolean
  // `required` will be a boolean for options with brackets
  required?: boolean
  config: OptionConfig
  negated: boolean

  constructor(
    public rawName: string,
    public description: string,
    config?: OptionConfig
  ) {
    this.config = Object.assign({}, config)

    // You may use cli.option('--env.* [value]', 'desc') to denote a dot-nested option
    rawName = rawName.replace(/\.\*/g, '')

    this.negated = false
    // removeBrackets方法:将'-h,--help  [dir]' 正则替换成 '-h,-help'
    this.names = removeBrackets(rawName)
      .split(',')
      .map((v: string) => {
        // 将 -h 或者 --help 转化成 h 或者 help
        let name = v.trim().replace(/^-{1,2}/, '')
        // 校验如果是no-开头的名字,设置negated参数为true,并替换'no-'字符串
        if (name.startsWith('no-')) {
          this.negated = true
          name = name.replace(/^no-/, '')
        }
        // 将其他格式的option字符串,统一转换成驼峰格式 例如--clear-screen 转化成 clearScreen
        return camelcaseOptionName(name)
      })
      // 根据名字长度对数组进行排序 [help, h] to [h, help]
      .sort((a, b) => (a.length > b.length ? 1 : -1)) // Sort names

    // Use the longest name (last one) as actual option name
    this.name = this.names[this.names.length - 1]

    if (this.negated) {
      this.config.default = true
    }

    // 如果名字包含 '<' 说明option必须输入相应的值
    if (rawName.includes('<')) {
      this.required = true
    // 如果名字包含 '[' 说明option非必须输入相应的值
    } else if (rawName.includes('[')) {
      this.required = false
    } else {
      // No arg needed, it's boolean flag
      this.isBoolean = true
    }

  }
}

cac如何获取和处理终端输入参数?
node examples/command-options.js rm hello -h

const parsedObj = mri(process.argv.slice(2), { alias: { h: [ 'help' ], r: [ 'recursive' ] })

console.log(parsedObj)
// { _: [ 'rm', 'hello' ], h: true, help: true }

执行脚本命令node是通过process.argv 对象来获取参数值的,argv值是一个数组:

  • argv[0]: node的bin路径 /usr/local/bin/node
  • argv[1]:执行脚本文件绝对路径
  • argv.slice(2): 执行脚本输入的参数

cac处理输入参数引用的开源库: mri,具体的使用规则可以查看mri官网使用,跟其他开源库相比较例如:minimistyargs-parser,一些对参数的处理更加合理。mri提供了执行比较数据,mir执行速度是minimist的5.x倍,是yargs-parser的40.x倍。但mri npm每周几十万下载量和minimist,yargs-parser的每周千万下载量还是有一定差距的。

cac怎么打印帮助信息和版本信息?

sao

SAO是一个构建脚手架工具,和yeoman比较,generator配置比较简单,上手更加容易。

npm i -g sao
 
# An official generator for creating a Node.js project 
# Generate from git repo 
# 用官方的generator来生成项目
sao saojs/sao-nm my-module
# Or from npm package (npm.im/sao-nm) 
# 生成自己的generator
sao generator sao-sample

关于sao的用法及更多细节,可以前往官方文档 saojs.org

sao 整体结构图

image

sao怎么区分generator是来自本地,npm和git repo?
// https://github.com/saojs/sao 
// sao-master/lib/index.js

class SAO {
  /**
   * Create an instance of SAO
   * @param {Object} opts
   */
  constructor(opts) {
    this.opts = Object.assign({}, opts)
    this.opts.outDir = path.resolve(this.opts.outDir)
    this.opts.npmClient = installPackages.setNpmClient(this.opts.npmClient)
    ...
    // 对传入的generator进行转化和分析
    this.parsedGenerator = parseGenerator(this.opts.generator)
    // Sub generator can only be used in an existing
    if (this.parsedGenerator.subGenerator) {
      logger.debug(
        `Setting out directory to process.cwd() since it's a sub generator`
      )
      this.opts.outDir = process.cwd()
    }
  }
  

初始化SAO对象的时候,会对传入的generator参数进行转化,来区分不同的generator类型,下面是转化generator的细节。

// sao-master/lib/parseGenerator.js

module.exports = generator => {
  // 判断generator是否为本地路径
  if (isLocalPath(generator)) {
    let subGenerator
    // 判断路径最后是否包含':'
    if (isLocalPath.removePrefix(generator).includes(':')) {
      subGenerator = generator.slice(generator.lastIndexOf(':') + 1)
      generator = generator.slice(0, generator.lastIndexOf(':'))
    }
    // 返回generator的绝对路径
    const absolutePath = path.resolve(generator)

    return {
      type: 'local',
      path: absolutePath,
      hash: sum(absolutePath), // 通过'hash-sum'库来编译路径
      subGenerator
    }
  }

  const SPECIAL_PREFIX_RE = /^(npm|github|bitbucket|gitlab|alias):/
  // generator 是否正则匹配,例如 在新生成一个generator的时候 命令行:sao generator ../test,就会走到下面逻辑
  if (!SPECIAL_PREFIX_RE.test(generator) && !generator.includes('/')) {
    generator = `npm:sao-${generator}`
  }
  
  let type = null
  if (SPECIAL_PREFIX_RE.test(generator)) {
    // 根据匹配的值设置type值, 例如 generator: npm:sao-generator, type = 'npm'
    type = SPECIAL_PREFIX_RE.exec(generator)[1]
    generator = generator.replace(SPECIAL_PREFIX_RE, '')
  }
  
  if (type === 'npm') {
    const hasSubGenerator = generator.indexOf(':') !== -1
    const slug = generator.slice(
      0,
      hasSubGenerator ? generator.indexOf(':') : generator.length
    )
    
    // 通过parse-package-name库来校验generator是否合法
    const parsed = require('parse-package-name')(slug)
    const hash = sum(`npm:${slug}`)

    return {
      type: 'npm',
      name: parsed.name,
      version: parsed.version,
      slug,
      subGenerator:
        hasSubGenerator && generator.slice(generator.indexOf(':') + 1),
      hash,
      path: path.join(paths.packagePath, hash, 'node_modules', parsed.name)
    }
  }
  

通过判断对generator 路径值的判断,来区分generator在本地,还是需要通过npm或者Git 仓库来下载。

// sao-master/lib/index.js

  /**
   * Run the generator.
   */
  async run(generator, parent) {
    generator = generator || this.parsedGenerator

    // 根据不同的generator类型来加载资源
    if (generator.type === 'repo') {
      await ensureRepo(generator, this.opts)
    } else if (generator.type === 'npm') {
      await ensurePackage(generator, this.opts)
    } else if (generator.type === 'local') {
      await ensureLocal(generator)
    }

    // 加载当前generator的saofile.js或者saofile.json配置文件,来对脚手架进行配置。
    const loaded = await loadConfig(generator.path)
    const config = loaded.path
      ? loaded.data
      : require(path.join(__dirname, 'saofile.fallback.js'))

    ...

    await this.runGenerator(generator, config)
  }
  
    // 确定本地generator存在
    async function ensureLocal(generator) {
      const exists = await fs.pathExists(generator.path)
    
      if (!exists) {
        throw new SAOError(
          `Directory ${chalk.underline(generator.path)} does not exist`
        )
      }
    }

    // 安装npm包
    async function ensurePackage(generator, { update, registry }) {
      const installPath = path.join(paths.packagePath, generator.hash)
      
      // 判断本地是否存在需要下载的包
      if (update || !(await fs.pathExists(generator.path))) {
       // 判断本地是否存在安装路径,如果没有则创建
        await fs.ensureDir(installPath)
        // 创建packjson.js 文件
        await fs.writeFile(
          path.join(installPath, 'package.json'),
          JSON.stringify({
            private: true
          }),
          'utf8'
        )
        logger.debug('Installing generator at', installPath)
        // 执行安装脚本,安装相应的包
        await installPackages({
          cwd: installPath,
          registry,
          packages: [`${generator.name}@${generator.version || 'latest'}`]
        })
      }
    }
    
    

确定generator类型后,对不同类型的generator进行处理,npm和git类型下载安装到本地。

sao库的generator为什么比较方便配置呢,原因就是通过saofile.js文件的配置就可以完成脚手架的搭建。
// saofile.js

{
    // 返回模板文件需要注入的数据,例如根据环境变量需要注入的不同参数等
    templateData () {
        return {
            a,
            b
        }
    },
    // 脚手架输入问题的配置项参数
    prompts: [
          {
            name: 'name',
            message: 'Project name:',
            default: '{outFolder}'
          },
          {
            name: 'language',
            message: 'Programming language:',
            choices: [
              { name: 'JavaScript', value: 'js' },
              { name: 'TypeScript', value: 'ts' }
            ],
            type: 'list',
            default: 'js'
          },
          ...
    ],
    // 确定参数后文件操作配置项
    actions () {
        return [
            {
              type: 'move',
              patterns: {
                gitignore: '.gitignore',
                '_package.json': 'package.json',
                '_.prettierrc': '.prettierrc',
                '_.eslintrc.js': '.eslintrc.js',
                '_jsconfig.json': 'jsconfig.json',
                '_stylelint.config.js': 'stylelint.config.js',
                'semantic.yml': '.github/semantic.yml'
            }
        ]
    },
    // 脚手架执行完成后的回调函数
    completed () {
        ...
    }
    
}
saofile.js配置后,prompts参数内部是怎么使用的呢?
// sao-master/lib/runPrompts.js

  // 将inquirer 赋值给prompt 
  const prompt =
    mock || yes
      ? require('./utils/mockPrompt')(mock && mock.answers)
      : inquirer.prompt.bind(inquirer)
  
  // 用户交互输入选择完成后,将结果返回      
  const answers = await prompt(
    prompts.map(p => {
      if (typeof p.default === 'string') {
        p.default = renderTemplate(p.default, context)
      }
      if (!mock && p.store && storedAnswers[p.name] !== undefined) {
        p.default = storedAnswers[p.name]
      }
      return p
    })
  )

简单介绍一下强大的 inquirer库的用法。

const inquirer = require('inquirer');

const promptList = [
// 配置交互参数
];

inquirer.prompt(promptList).then(answers => {
    console.log(answers); // 返回的结果
})
    
配置参数:
    type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor;
    name: 存储当前问题回答的变量;
    message:问题的描述;
    default:默认值;
    choices:列表选项,在某些type下可用,并且包含一个分隔符(separator);
    validate:对用户的回答进行校验;
    filter:对用户的回答进行过滤处理,返回处理后的值;
    transformer:对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容;
    when:根据前面问题的回答,判断当前问题是否需要被回答;
    pageSize:修改某些type类型下的渲染行数;
    prefix:修改message默认前缀;
    suffix:修改message默认后缀。
 
saofile.js配置actions后,脚手架内部是怎么处理的呢?
// sao-master/lib/runActions.js

module.exports = async (config, context) => {
  const actions =
    typeof config.actions === 'function'
      ? await config.actions.call(context, context)
      : config.actions
  
  // 遍历 saofile.js 中的actions
  for (const action of actions) {
    logger.debug('Running action:', action)
    // 如果类型为add 
    if (action.type === 'add' && action.files) {
      
      // 调用majo库
      const stream = majo()
      stream.source(['!**/node_modules/**'].concat(action.files), {
        baseDir: path.resolve(
          context.generator.path,
          action.templateDir || config.templateDir || 'template'
        )
      })
  ...

majo 是一个灵活操作文件的开源库,它的API比较简单,核心代码大约150行,简单介绍一下majo的用法。

const { majo } = require('majo')
 
const stream = majo()
 
// Given that you have js/app.js js/index.js
stream
  // 匹配需要操作文件的文件集合
  .source('js/**')
  // 添加操作文件的中间件
  .use(ignoreSomeFiles)
  // 设置目标文件路径,并触发操作动作
  .dest('dist')
  // 操作完成后回调函数
  .then(() => {
    // Now you got filtered files
  })
  
// 中间件函数 
function ignoreSomeFiles(stream) {
  for (const filename in stream.files) {
    const content = stream.fileContents(filename)
    // Remove it if content has specific string
    if (/some-string/.test(content)) {
      delete stream.files[filename]
    }
  }
}

了解完用法,通过源码看看这些功能是怎么实现的吧!

  • source() 方法
// https://github.com/egoist/majo 
// majo-master/src/index.ts

  /**
   * Find files from specific directory
   * @param source Glob patterns
   * @param opts
   * @param opts.baseDir The base directory to find files
   * @param opts.dotFiles Including dot files
   */
  source(patterns: string | string[], options: SourceOptions = {}) {
    const { baseDir = '.', dotFiles = true, onWrite } = options
    // 解析基本路径
    this.baseDir = path.resolve(baseDir)
    this.sourcePatterns = Array.isArray(patterns) ? patterns : [patterns]
    // 是否处理带点文件 例如 .gitignore 
    this.dotFiles = dotFiles
    this.onWrite = onWrite
    return this
  }
  • use() 方法
 /**
   * Use a middleware
   */
  use(middleware: Middleware) {
    // 将中间件方法放到一个去全局数组中维护
    this.middlewares.push(middleware)
    return this
  }
  • filter() 方法
  /**
   * Filter files
   * @param fn Filter handler
   */
  filter(fn: FilterHandler) {
    // 添加一个中间件
    return this.use(context => {
      // 遍历中间件接受的文件对象
      for (const relativePath in context.files) {
        if (!fn(relativePath, context.files[relativePath])) {
          // 如果filter 回调返回false,则删除文件    
          delete context.files[relativePath]
        }
      }
    })
  }
  • process() 方法
  /**
   * Process middlewares against files
   */
  async process() {
    if (!this.sourcePatterns || !this.baseDir) {
      throw new Error(`[majo] You need to call .source first`)
    }
    
    // 通过fast-glob (https://www.npmjs.com/package/fast-glob)来匹配文件集合
    const allEntries = await glob(this.sourcePatterns, {
      cwd: this.baseDir,
      dot: this.dotFiles,
      stats: true
    })

    
    await Promise.all(
      allEntries.map(entry => {
        const absolutePath = path.resolve(this.baseDir as string, entry.path)
        return readFile(absolutePath).then(contents => {
          const file = {
            contents,
            stats: entry.stats as fs.Stats,
            path: absolutePath
          }
          // Use relative path as key
          this.files[entry.path] = file
        })
      })
    )
    
    // 执行中间件方法
    await new Wares().use(this.middlewares).run(this)

    return this
  }
  • dest() 方法
  /**
   * Run middlewares and write processed files to disk
   * @param dest Target directory
   * @param opts
   * @param opts.baseDir Base directory to resolve target directory
   * @param opts.clean Clean directory before writing
   */
  async dest(dest: string, options: DestOptions = {}) {
    const { baseDir = '.', clean = false } = options
    const destPath = path.resolve(baseDir, dest)
    
    // 执行中间件处理方法
    await this.process()

    if (clean) {
      await remove(destPath)
    }

    await Promise.all(
      // 获取文件名    
      Object.keys(this.files).map(filename => {
        // 获取文件内容
        const { contents } = this.files[filename]
        // 拼接目标文件夹中文件路径
        const target = path.join(destPath, filename)
        if (this.onWrite) {
          this.onWrite(filename, target)
        }
        // 在目标文件夹中生成文件
        return ensureDir(path.dirname(target)).then(() =>
          writeFile(target, contents)
        )
      })
    )

    return this
  }
saofile.js中的templateData是怎么注入到文件中的?
// sao-master/lib/runActions.js

stream.use(({ files }) => {
  let fileList = Object.keys(stream.files)

  ...
  
  fileList.forEach(relativePath => {
    const contents = files[relativePath].contents.toString()
    
    // 将jstransformer-ejs作为参数传递给jstransformer
    const transformer = require('jstransformer')(
      require(`jstransformer-${config.transformer || 'ejs'}`)
    )

    const templateData = action.templateData || config.templateData
    stream.writeContents(
      relativePath,
      
      // 将templateData注入到文件中
      transformer.render(
        contents,
        config.transformerOptions,
        Object.assign(
          {},
          context.answers,
          typeof templateData === 'function'
            ? templateData.call(context, context)
            : templateData,
          {
            context
          }
        )
      ).body
    )
  })
})

从代码中可以看出,采用了jstransformer库,传入了jstransformer-ejs 作为转化模板类型,最后将模板参数传入生成文件。

总结

代码的梳理过程是枯燥乏味的,有些地方一开始不能理解是痛苦的,但坚持一下,再坚持一下,就会为作者的精妙设计拍案而起。整理成笔记不仅可以加深自己的认识还希望能对其他人有一定的帮助,共同进步!

阿然 北京 2020/06/30 晚