One Last You Jen Bird
  1. 1 One Last You Jen Bird
  2. 2 Warcry mpi
  3. 3 Hypocrite Nush
  4. 4 Life Will Change Lyn
  5. 5 かかってこいよ NakamuraEmi
  6. 6 Flower Of Life 发热巫女
  7. 7 Quiet Storm Lyn
  8. 8 Last Surprise Lyn
  9. 9 Time Bomb Veela
  10. 10 Libertus Chen-U
  11. 11 The Night We Stood Lyn
2019-07-20 23:05:01

vue-cli2源码解析

node本身虽然是一门后端语言,但和前端的联系还是相当紧密的,基本上渗透到了日常使用中,玩法也是多种多样。工程化,中台服务,命令行工具等等。这篇文章就从vue-cli入手,学习一下node在命令行工具开发中的应用以及相关生态。虽然vue-cli3已经发布很久了,但考虑到其插件服务的架构是重点,相对来说vue-cli2的node命令行开发更纯粹一些,也比较全面的涵盖日常cli开发的工具和思路,所以就从版本2开始吧。

核心依赖工具概览

在进入具体代码之前,先看看package.json里的依赖的一些核心包,这些包都是命令行工具开发常见甚至必备包,有必要先做了解。

commander

node命令行接口的完整解决方案。通过解析用户命令行输入内容,完成命令行内容展示和node相关逻辑的桥接。

chalk

修改命令行字符串样式,让输入更加美观

inquirer

给命令行提供更丰富的交互手段,比如列表选择,Y/N选择

download-git-repo

编程式的形式从远程仓库下载代码,这里主要用来下载初始化需要的模板

ora

命令行loading效果,和chalk类似都是为了美观

execa

强化版的子进程管理,在node内置的child_process基础上更好的支持多平台,以及提供Promise接口等强化功能

handlebars

一个JS语义模板库,将模板编译成html

consolidate

模板引擎的整合库,把一些流行的模板引擎包括上面的handlebars适配成和express兼容的接口

metalsmith

可以批量处理模板的静态网页生成器,vue-cli中最终产出的落地就靠这个

源码分析

因为vue-cli核心是通过vue-init这条命令启动的,所以直接看bin/vue-init文件内容。首先文件开头是#!/usr/bin/env node,说明该脚本的解释程序为node,并提供路径让系统动态查找到node执行该脚本文件,之后引入了一系列三方库文件就不赘述,再到了commander代码部分。

program
  .usage('<template-name> [project-name]')
  .option('-c, --clone', 'use git clone')
  .option('--offline', 'use cached template')

/**
 * Help.
 */

program.on('--help', () => {
  console.log('  Examples:')
  console.log()
  console.log(chalk.gray('    # create a new project with an official template'))
  console.log('    $ vue init webpack my-project')
  console.log()
  console.log(chalk.gray('    # create a new project straight from a github template'))
  console.log('    $ vue init username/repo my-project')
  console.log()
})

规定了入参,以及help相关输出,并将用户输入解析为对象。接下来就是关键逻辑。

const rawName = program.args[1]
const inPlace = !rawName || rawName === '.'

if (inPlace || exists(to)) {
  inquirer.prompt([{
    type: 'confirm',
    message: inPlace
      ? 'Generate project in current directory?'
      : 'Target directory exists. Continue?',
    name: 'ok'
  }]).then(answers => {
    if (answers.ok) {
      run()
    }
  }).catch(logger.fatal)
} else {
  run()
}

这里根据用户输入name判断是否与当前目录重名或者目录是否已存在,并进一步询问,根据用户机交互决定是否继续向下执行run函数。

/**
 * Check, download and generate the project.
 */

function run () {
  // check if template is local
  if (isLocalPath(template)) {
    const templatePath = getTemplatePath(template)
    if (exists(templatePath)) {
      generate(name, templatePath, to, err => {
        if (err) logger.fatal(err)
        console.log()
        logger.success('Generated "%s".', name)
      })
    } else {
      logger.fatal('Local template "%s" not found.', template)
    }
  } else {
    checkVersion(() => {
      if (!hasSlash) {
        // use official templates
        const officialTemplate = 'vuejs-templates/' + template
        if (template.indexOf('#') !== -1) {
          downloadAndGenerate(officialTemplate)
        } else {
          if (template.indexOf('-2.0') !== -1) {
            warnings.v2SuffixTemplatesDeprecated(template, inPlace ? '' : name)
            return
          }

          // warnings.v2BranchIsNowDefault(template, inPlace ? '' : name)
          downloadAndGenerate(officialTemplate)
        }
      } else {
        downloadAndGenerate(template)
      }
    })
  }
}

run函数根据模板路径决定是下载官方项目模板还是下载自定义模板,如果下载官方模板会走downloadAndGenerate函数,下载自定义模板则跳过download步骤,直接走generate函数

function downloadAndGenerate (template) {
  const spinner = ora('downloading template')
  spinner.start()
  // Remove if local template exists
  if (exists(tmp)) rm(tmp)
  download(template, tmp, { clone }, err => {
    spinner.stop()
    if (err) logger.fatal('Failed to download repo ' + template + ': ' + err.message.trim())
    generate(name, tmp, to, err => {
      if (err) logger.fatal(err)
      console.log()
      logger.success('Generated "%s".', name)
    })
  })

donwload函数封装了download-git-repogithub拉取对应模板的逻辑,在下载时会使用ora做样式的loading优化,之后就是最核心的generate函数填充模板的部分了。先看整体代码。

module.exports = function generate (name, src, dest, done) {
  const opts = getOptions(name, src)
  const metalsmith = Metalsmith(path.join(src, 'template'))
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd(),
    noEscape: true
  })
  opts.helpers && Object.keys(opts.helpers).map(key => {
    Handlebars.registerHelper(key, opts.helpers[key])
  })

  const helpers = { chalk, logger }

  if (opts.metalsmith && typeof opts.metalsmith.before === 'function') {
    opts.metalsmith.before(metalsmith, opts, helpers)
  }

  metalsmith.use(askQuestions(opts.prompts))
    .use(filterFiles(opts.filters))
    .use(renderTemplateFiles(opts.skipInterpolation))

  if (typeof opts.metalsmith === 'function') {
    opts.metalsmith(metalsmith, opts, helpers)
  } else if (opts.metalsmith && typeof opts.metalsmith.after === 'function') {
    opts.metalsmith.after(metalsmith, opts, helpers)
  }

  metalsmith.clean(false)
    .source('.') // start from template root instead of `./src` which is Metalsmith's default for `source`
    .destination(dest)
    .build((err, files) => {
      done(err)
      if (typeof opts.complete === 'function') {
        const helpers = { chalk, logger, files }
        opts.complete(data, helpers)
      } else {
        logMessage(opts.completeMessage, data)
      }
    })

  return data
}

首先通过getOptions获取模板填充相关配置,进入到该函数看细节。

module.exports = function options (name, dir) {
  const opts = getMetadata(dir)

  setDefault(opts, 'name', name)
  setValidateName(opts)

  const author = getGitUser()
  if (author) {
    setDefault(opts, 'author', author)
  }

  return opts
}

其中getMetadatasetDefault的作用就是获取该模板的一些元配置信息,比如prompts,helpers,并将其填充到inquirer中,以在填充时和用户交互,然后再使用setValidateName这三方包工具验证app的名称是否符合npm命名规范,最后就是填充作者信息了。

下一步调用Metalsmith准备模板输出的落地,首先通过metadata方法定义一些在模板中可以使用的一些全局变量,再通过Handlebars.registerHelper注册一些模板渲染时,可以使用的helper函数,再通过 beforeafter将这些元数据合并到模板渲染前后会调用的钩子函数中。

配置准备好后,接下来的就是渲染重点了。因为metalsmith是插件化的用法,通过use启用需要的插件,这里一共用到了三个插件:askQuestionsfilterFilesrenderTemplateFiles

先看askQuesions实现。

module.exports = function ask (prompts, data, done) {
  async.eachSeries(Object.keys(prompts), (key, next) => {
    prompt(data, key, prompts[key], next)
  }, done)
}
function prompt (data, key, prompt, done) {
  // skip prompts whose when condition is not met
  if (prompt.when && !evaluate(prompt.when, data)) {
    return done()
  }

  let promptDefault = prompt.default
  if (typeof prompt.default === 'function') {
    promptDefault = function () {
      return prompt.default.bind(this)(data)
    }
  }

  inquirer.prompt([{
    type: promptMapping[prompt.type] || prompt.type,
    name: key,
    message: prompt.message || prompt.label || key,
    default: promptDefault,
    choices: prompt.choices || [],
    validate: prompt.validate || (() => true)
  }]).then(answers => {
    if (Array.isArray(answers[key])) {
      data[key] = {}
      answers[key].forEach(multiChoiceAnswer => {
        data[key][multiChoiceAnswer] = true
      })
    } else if (typeof answers[key] === 'string') {
      data[key] = answers[key].replace(/"/g, '\\"')
    } else {
      data[key] = answers[key]
    }
    done()
  }).catch(done)
}

这里将配置中的prompts传入,通过命令行交互生成用户需要的data并将其通过metalsmith.metadata()存到全局,在渲染过程中就可以直接使用这些值了。

接下来是filterFiles插件。


module.exports = (files, filters, data, done) => {
  if (!filters) {
    return done()
  }
  const fileNames = Object.keys(files)
  Object.keys(filters).forEach(glob => {
    fileNames.forEach(file => {
      if (match(file, glob, { dot: true })) {
        const condition = filters[glob]
        if (!evaluate(condition, data)) {
          delete files[file]
        }
      }
    })
  })
  done()
}

类似的,同样是根据配置中的filter结合evaluate函数从metadata的全局数据中判断需要被过滤的模板生成文件,比如eslint这种根据用户交互决定是否生成的文件,如果需要被过滤则用delete操作符删除。

最后,模板填充相关的data也有了,需要被渲染的模板也决定了,就可以调用renderTemplateFiles生成具体的落地文件了。

function renderTemplateFiles (skipInterpolation) {
  skipInterpolation = typeof skipInterpolation === 'string'
    ? [skipInterpolation]
    : skipInterpolation
  return (files, metalsmith, done) => {
    const keys = Object.keys(files)
    const metalsmithMetadata = metalsmith.metadata()
    async.each(keys, (file, next) => {
      // skipping files with skipInterpolation option
      if (skipInterpolation && multimatch([file], skipInterpolation, { dot: true }).length) {
        return next()
      }
      const str = files[file].contents.toString()
      // do not attempt to render files that do not have mustaches
      if (!/{{([^{}]+)}}/g.test(str)) {
        return next()
      }
      render(str, metalsmithMetadata, (err, res) => {
        if (err) {
          err.message = `[${file}] ${err.message}`
          return next(err)
        }
        files[file].contents = new Buffer(res)
        next()
      })
    }, done)
  }
}

渲染手段就是利用consolidate.handlebars.render。还剩下最后一步就是将这些落地文件输出到具体目录,然后输出一些相关信息了,通过build实现。


  metalsmith.clean(false)
    .source('.') // start from template root instead of `./src` which is Metalsmith's default for `source`
    .destination(dest)
    .build((err, files) => {
      done(err)
      if (typeof opts.complete === 'function') {
        const helpers = { chalk, logger, files }
        opts.complete(data, helpers)
      } else {
        logMessage(opts.completeMessage, data)
      }
    })

至此就完成了vue-cli的核心工作。

小结

整个流程其实挺清晰的,主线就是通过download-git-repo下载模板,inquirer命令行交互获取具体数据,利用metalsmith的批量渲染模板和插件式处理能力, 调用handlebars完成渲染和输出。

-- EOF --

添加在分类「 前端开发 」下,并被添加 「工程化」「Node.js」 标签。