走進Vue-cli原始碼,自己動手搭建前端腳手架工具

ruichengping發表於2018-04-02

前言

前段時間看了一些vue-cli的原始碼,收穫頗深。本想找個時間更新一篇文章,但是最近事情比較多,沒有時間去整理這些東西。趁這兩天閒了下來,便整理了一下,然後跟大家分享一下。如果小夥伴們讀完之後,跟我一樣收穫很多的話,還望各位小夥伴們多多點贊收藏支援一下哦。

Vue-cli介紹

Vue-cli是一款非常優秀的用於迅速構建基於Vue的Web應用工具。他不同於creat-react-app這樣的工具,開發者只需要關注專案邏輯的程式碼,而不需要關心webpack打包、啟動Node服務等等諸如此類的這些問題。Vue-cli是一款基於模板化的開發工具,等於就是把別人的專案結構給照搬過來,所有的配置都是暴露出來的,你可以根據實際情況去做一些配置的修改,更加靈活自由一點。當然這對前端工程師提出更高的要求,考慮的東西也變多了。不過Vue-cli即將釋出3.0的版本,整個Vue-cli發生了翻天覆地的變化,它採用跟creat-react-app這類工具的模式,開發者只需要關注專案邏輯的程式碼即可。不過目前3.0還沒有出來,所以這次原始碼分析我採用的v2.9.3的原始碼,也就是2.0的程式碼。後面小夥們在閱讀的時候要注意以下。

Vue-cli專案結構

image

整個專案的目錄結構如上圖所示,下面我大概介紹每個資料夾的東西大致都是幹嘛的。

  • bin (這裡放的vue的一些命令檔案,比如vue init這樣的命令都是從由這裡控制的。)

  • docs (一些注意事項啥的,不重要的目錄,可以直接忽略。)

  • lib (這裡存放著一些vue-cli需要的一些自定義方法。)

  • node_modules (這裡應該就不用我多說了,相信大家都知道了,不知道的話可以去面壁去了!●-● )

  • test (單元測試 開發vue-cli工具時會用到,我們讀原始碼的時候可以直接忽略掉。)

  • 一些雜七雜八的東西 (比如eslint配置、.gitignore、LICENSE等等諸如此類這些東西,不影響我們閱讀原始碼,可以直接忽略掉。)

  • package.json/README.md (這個不知道也可以去面壁了!●-●)

綜合來說,我們閱讀原始碼所要關注的只有bin和lib下面即可,其他的都可忽略。下面開始閱讀之旅吧

Vue-cli原始碼閱讀之旅

在開始讀原始碼之前,首先我要介紹一個工具(commander),這是用來處理命令列的工具。具體的使用方法可檢視github的README.md https://github.com/tj/commander.js 。小夥伴們再閱讀後面的內容之前,建議先去了解一下commander,方便後續的理解。這裡我們對commander就不做詳細介紹了。這裡vue-cli採用了commander的git風格的寫法。vue檔案處理vue命令,vue-init處理vue init命令以此類推。接著我們一個一個命令看過去。

vue

引入的包:

  • commander (用於處理命令列。)

作用: vue這個檔案程式碼很少,我就直接貼出來了。

#!/usr/bin/env node

require('commander')
  .version(require('../package').version)
  .usage('<command> [options]')
  .command('init', 'generate a new project from a template')
  .command('list', 'list available official templates')
  .command('build', 'prototype a new project')
  .parse(process.argv)
複製程式碼

這個檔案主要是在使用者輸入“vue”時,終端上顯示引數的使用說明。具體的寫法可參考 https://github.com/tj/commander.js 上面的說明。

vue build

引入的包:

  • chalk (用於高亮終端列印出來的資訊。)

作用: vue build命令在vue-cli之中已經刪除了,原始碼上做了一定的說明。程式碼不多,我就直接貼出來。


const chalk = require('chalk')

console.log(chalk.yellow(
  '\n' +
  '  We are slimming down vue-cli to optimize the initial installation by ' +
  'removing the `vue build` command.\n' +
  '  Check out Poi (https://github.com/egoist/poi) which offers the same functionality!' +
  '\n'
))
複製程式碼

vue list

#!/usr/bin/env node
const logger = require('../lib/logger')
const request = require('request')
const chalk = require('chalk')

/**
 * Padding.
 */

console.log()
process.on('exit', () => {
  console.log()
})

/**
 * List repos.
 */

request({
  url: 'https://api.github.com/users/vuejs-templates/repos',
  headers: {
    'User-Agent': 'vue-cli'
  }
}, (err, res, body) => {
  if (err) logger.fatal(err)
  const requestBody = JSON.parse(body)
  if (Array.isArray(requestBody)) {
    console.log('  Available official templates:')
    console.log()
    requestBody.forEach(repo => {
      console.log(
        '  ' + chalk.yellow('★') +
        '  ' + chalk.blue(repo.name) +
        ' - ' + repo.description)
    })
  } else {
    console.error(requestBody.message)
  }
})
複製程式碼

引入的包:

  • request (傳送http請求的工具。)
  • chalk (用於高亮console.log列印出來的資訊。)
  • logger (自定義工具-用於日誌列印。)

作用: 當輸入"vue list"時(我們測試時,可以直接在當前原始碼檔案目錄下的終端上輸入“bin/vue-list”),vue-cli會請求介面,獲取官方模板的資訊,然後做了一定處理,在終端上顯示出來模板名稱和對應的說明。

效果如下:

  Available official templates:

  ★  browserify - A full-featured Browserify + vueify setup with hot-reload, linting & unit testing.
  ★  browserify-simple - A simple Browserify + vueify setup for quick prototyping.
  ★  pwa - PWA template for vue-cli based on the webpack template
  ★  simple - The simplest possible Vue setup in a single HTML file
  ★  webpack - A full-featured Webpack + vue-loader setup with hot reload, linting, testing & css extraction.
  ★  webpack-simple - A simple Webpack + vue-loader setup for quick prototyping.
複製程式碼

vue init

vue init”是用來構建專案的命令,也是vue-cli的核心檔案,上面的三個都是非常簡單的命令,算是我們閱讀原始碼的開胃菜,真正的大餐在這裡。

工作流程

在講程式碼之前,首先我們要講一下整個vue-cli初始專案的流程,然後我們沿著流程一步一步走下去。

image

整個vue init大致流程如我上圖所示,應該還是比較好理解的。這裡我大致闡述一下大致的流程。

  1. vue-cli會先判斷你的模板在遠端github倉庫上還是在你的本地某個檔案裡面,若是本地資料夾則會立即跳到第3步,反之則走第2步。

  2. 第2步會判斷是否為官方模板,官方模板則會從官方github倉庫中下載模板到本地的預設倉庫下,即根目錄下.vue-templates資料夾下。

  3. 第3步則讀取模板目錄下meta.js或者meta.json檔案,根據裡面的內容會詢問開發者,根據開發者的回答,確定一些修改。

  4. 根據模板內容以及開發者的回答,渲染出專案結構並生成到指定目錄。

原始碼內容

這裡vue-init檔案的程式碼比較多,我這裡就拆分幾塊來看。首先我先把整個檔案的結構列出來,方便後續的閱讀。

  /**
   * 引入一大堆包
   */
    const program = require('commander')
    ...
  
   
   /**
    * 配置commander的使用方法
    */     
    
    program
      .usage('<template-name> [project-name]')
      .option('-c, --clone', 'use git clone')
      .option('--offline', 'use cached template')
      
  /**
    * 定義commander的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()
    })
    
    
    function help () {
      program.parse(process.argv)
      if (program.args.length < 1) return program.help() //如果沒有輸入引數,終端顯示幫助
    }
    help()
    
    /**
     * 定義一大堆變數
     */
     
     let template = program.args[0]
     ...
     
     /**
      * 判斷是否輸入專案名  是 - 直接執行run函式  否- 詢問開發者是否在當前目錄下生成專案,開發者回答“是” 也執行run函式 否則不執行run函式
      */
     
     /**
     * 定義主函式 run
     */
     function run (){
         ...
     }
     
     /**
      * 定義下載模板並生產專案的函式 downloadAndGenerate
      */
      function downloadAndGenerate(){
          ...
      }
複製程式碼

整個檔案大致的東西入上面所示,後面我們將一塊一塊內容來看。

引入的一堆包

const download = require('download-git-repo')  //用於下載遠端倉庫至本地 支援GitHub、GitLab、Bitbucket
const program = require('commander') //命令列處理工具
const exists = require('fs').existsSync  //node自帶的fs模組下的existsSync方法,用於檢測路徑是否存在。(會阻塞)
const path = require('path') //node自帶的path模組,用於拼接路徑
const ora = require('ora') //用於命令列上的載入效果
const home = require('user-home')  //用於獲取使用者的根目錄
const tildify = require('tildify') //將絕對路徑轉換成帶波浪符的路徑
const chalk = require('chalk')// 用於高亮終端列印出的資訊
const inquirer = require('inquirer') //用於命令列與開發者互動
const rm = require('rimraf').sync // 相當於UNIX的“rm -rf”命令
const logger = require('../lib/logger') //自定義工具-用於日誌列印
const generate = require('../lib/generate')  //自定義工具-用於基於模板構建專案
const checkVersion = require('../lib/check-version') //自定義工具-用於檢測vue-cli版本的工具
const warnings = require('../lib/warnings') //自定義工具-用於模板的警告
const localPath = require('../lib/local-path') //自定義工具-用於路徑的處理

const isLocalPath = localPath.isLocalPath  //判斷是否是本地路徑
const getTemplatePath = localPath.getTemplatePath  //獲取本地模板的絕對路徑
複製程式碼

定義的一堆變數

let template = program.args[0]  //模板名稱
const hasSlash = template.indexOf('/') > -1   //是否有斜槓,後面將會用來判定是否為官方模板   
const rawName = program.args[1]  //專案構建目錄名
const inPlace = !rawName || rawName === '.'  // 沒寫或者“.”,表示當前目錄下構建專案
const name = inPlace ? path.relative('../', process.cwd()) : rawName  //如果在當前目錄下構建專案,當前目錄名為專案構建目錄名,否則是當前目錄下的子目錄【rawName】為專案構建目錄名
const to = path.resolve(rawName || '.') //專案構建目錄的絕對路徑
const clone = program.clone || false  //是否採用clone模式,提供給“download-git-repo”的引數

const tmp = path.join(home, '.vue-templates', template.replace(/[\/:]/g, '-'))  //遠端模板下載到本地的路徑
複製程式碼

主邏輯

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()
}
複製程式碼

對著上面程式碼,vue-cli會判斷inPlace和exists(to),true則詢問開發者,當開發者回答“yes”的時候執行run函式,否則直接執行run函式。這裡詢問開發者的問題有如下兩個:

  • Generate project in current directory? //是否在當前目錄下構建專案

  • Target directory exists. Continue? //構建目錄已存在,是否繼續

這兩個問題依靠變數inPlace來選擇,下面我看一下變數inPlace是怎麼得來的。

const rawName = program.args[1]  //rawName為命令列的第二個引數(專案構建目錄的相對目錄)
const inPlace = !rawName || rawName === '.'  //rawName存在或者為“.”的時候,視為在當前目錄下構建
複製程式碼

通過上面的描述可知,變數inPlace用於判斷是否在當前目錄下構建,因此變數inPlace為true時,則會提示Generate project in current directory? ,反之當變數inPlace為false時,此時exists(to)一定為true,便提示Target directory exists. Continue?

Run函式

邏輯:

image

原始碼:

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
        // 從這句話以及download-git-repo的用法,我們得知了vue的官方的模板庫的地址:https://github.com/vuejs-templates
        const officialTemplate = 'vuejs-templates/' + template
        if (template.indexOf('#') !== -1) {  //模板名是否帶"#"
          downloadAndGenerate(officialTemplate) //下載模板
        } else {
          if (template.indexOf('-2.0') !== -1) { //是都帶"-2.0"
             //發出警告
            warnings.v2SuffixTemplatesDeprecated(template, inPlace ? '' : name)
            return
          }

          // warnings.v2BranchIsNowDefault(template, inPlace ? '' : name)
          downloadAndGenerate(officialTemplate)//下載模板
        }
      } else {
        downloadAndGenerate(template)//下載模板
      }
    })
  }
}
複製程式碼

downloadAndGenerate函式

function downloadAndGenerate (template) {
  const spinner = ora('downloading template')  
  spinner.start()//顯示載入狀態
  // Remove if local template exists
  if (exists(tmp)) rm(tmp)  //當前模板庫是否存在該模板,存在就刪除
   //下載模板  template-模板名    tmp- 模板路徑   clone-是否採用git clone模板   err-錯誤簡訊
    
  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)
    })
  })
}
複製程式碼

lib

generate.js (★)

lib檔案下最重要的js檔案,他是我們構建專案中最重要的一環,根據模板渲染成我們需要的專案。這塊內容是需要我們重點關注的。

const chalk = require('chalk')
const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const async = require('async')
const render = require('consolidate').handlebars.render
const path = require('path')
const multimatch = require('multimatch')
const getOptions = require('./options')
const ask = require('./ask')
const filter = require('./filter')
const logger = require('./logger')

// register handlebars helper  註冊handlebars的helper
Handlebars.registerHelper('if_eq', function (a, b, opts) {
  return a === b
    ? opts.fn(this)
    : opts.inverse(this)
})

Handlebars.registerHelper('unless_eq', function (a, b, opts) {
  return a === b
    ? opts.inverse(this)
    : opts.fn(this)
})

/**
 * Generate a template given a `src` and `dest`.
 *
 * @param {String} name
 * @param {String} src
 * @param {String} dest
 * @param {Function} done
 */

module.exports = function generate (name, src, dest, done) {
  const opts = getOptions(name, src)  //獲取配置
  const metalsmith = Metalsmith(path.join(src, 'template'))  //初始化Metalsmith物件
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd(),
    noEscape: true
  })//新增一些變數至metalsmith中,並獲取metalsmith中全部變數
  
  //註冊配置物件中的helper
  opts.helpers && Object.keys(opts.helpers).map(key => {
    Handlebars.registerHelper(key, opts.helpers[key])
  })

  const helpers = { chalk, logger }

 //配置物件是否有before函式,是則執行
  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)) //渲染模板檔案


  //配置物件是否有after函式,是則執行
  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') {
      //配置物件有complete函式則執行
        const helpers = { chalk, logger, files }
        opts.complete(data, helpers)
      } else {
      //配置物件有completeMessage,執行logMessage函式
        logMessage(opts.completeMessage, data)
      }
    })

  return data
}

/**
 * Create a middleware for asking questions.
 *
 * @param {Object} prompts
 * @return {Function}
 */

function askQuestions (prompts) {
  return (files, metalsmith, done) => {
    ask(prompts, metalsmith.metadata(), done)
  }
}

/**
 * Create a middleware for filtering files.
 *
 * @param {Object} filters
 * @return {Function}
 */

function filterFiles (filters) {
  return (files, metalsmith, done) => {
    filter(files, filters, metalsmith.metadata(), done)
  }
}

/**
 * Template in place plugin.
 *
 * @param {Object} files
 * @param {Metalsmith} metalsmith
 * @param {Function} done
 */

function renderTemplateFiles (skipInterpolation) {
  skipInterpolation = typeof skipInterpolation === 'string'
    ? [skipInterpolation]
    : skipInterpolation    //保證skipInterpolation是一個陣列
  return (files, metalsmith, done) => {
    const keys = Object.keys(files) //獲取files的所有key
    const metalsmithMetadata = metalsmith.metadata() //獲取metalsmith的所有變數
    async.each(keys, (file, next) => { //非同步處理所有files
      // skipping files with skipInterpolation option  
      // 跳過符合skipInterpolation的要求的file
      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
      //跳過不符合handlebars語法的file
      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)
  }
}

/**
 * Display template complete message.
 *
 * @param {String} message
 * @param {Object} data
 */

function logMessage (message, data) {
  if (!message) return  //沒有message直接退出函式
  render(message, data, (err, res) => {
    if (err) {
      console.error('\n   Error when rendering template complete message: ' + err.message.trim())  //渲染錯誤列印錯誤資訊
    } else {
      console.log('\n' + res.split(/\r?\n/g).map(line => '   ' + line).join('\n'))
      //渲染成功列印最終渲染的結果
    }
  })
}

複製程式碼

引入的包:

  • chalk (用於高亮終端列印出來的資訊。)
  • metalsmith (靜態網站生成器。)
  • handlebars (知名的模板引擎。)
  • async (非常強大的非同步處理工具。)
  • consolidate (支援各種模板引擎的渲染。)
  • path (node自帶path模組,用於路徑的處理。)
  • multimatch ( 可以支援多個條件的匹配。)
  • options (自定義工具-用於獲取模板配置。)
  • ask (自定義工具-用於詢問開發者。)
  • filter (自定義工具-用於檔案過濾。)
  • logger (自定義工具-用於日誌列印。)

主邏輯:

獲取模板配置 -->初始化Metalsmith -->新增一些變數至Metalsmith -->handlebars模板註冊helper -->配置物件中是否有before函式,有則執行 -->詢問問題 -->過濾檔案 -->渲染模板檔案 -->配置物件中是否有after函式,有則執行 -->最後構建專案內容 -->構建完成,成功若配置物件中有complete函式則執行,否則列印配置物件中的completeMessage資訊,如果有錯誤,執行回撥函式done(err)

其他函式:

  • askQuestions: 詢問問題
  • filterFiles: 過濾檔案
  • renderTemplateFiles: 渲染模板檔案
  • logMessage: 用於構建成功時,列印資訊

Metalsmith外掛格式:

function <function name> {
  return (files,metalsmith,done)=>{
    //邏輯程式碼
    ...
  }
}

複製程式碼

options.js

const path = require('path')
const metadata = require('read-metadata')
const exists = require('fs').existsSync
const getGitUser = require('./git-user')
const validateName = require('validate-npm-package-name')

/**
 * Read prompts metadata.
 *
 * @param {String} dir
 * @return {Object}
 */

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
}

/**
 * Gets the metadata from either a meta.json or meta.js file.
 *
 * @param  {String} dir
 * @return {Object}
 */

function getMetadata (dir) {
  const json = path.join(dir, 'meta.json')
  const js = path.join(dir, 'meta.js')
  let opts = {}

  if (exists(json)) {
    opts = metadata.sync(json)
  } else if (exists(js)) {
    const req = require(path.resolve(js))
    if (req !== Object(req)) {
      throw new Error('meta.js needs to expose an object')
    }
    opts = req
  }

  return opts
}

/**
 * Set the default value for a prompt question
 *
 * @param {Object} opts
 * @param {String} key
 * @param {String} val
 */

function setDefault (opts, key, val) {
  if (opts.schema) {
    opts.prompts = opts.schema
    delete opts.schema
  }
  const prompts = opts.prompts || (opts.prompts = {})
  if (!prompts[key] || typeof prompts[key] !== 'object') {
    prompts[key] = {
      'type': 'string',
      'default': val
    }
  } else {
    prompts[key]['default'] = val
  }
}

function setValidateName (opts) {
  const name = opts.prompts.name
  const customValidate = name.validate
  name.validate = name => {
    const its = validateName(name)
    if (!its.validForNewPackages) {
      const errors = (its.errors || []).concat(its.warnings || [])
      return 'Sorry, ' + errors.join(' and ') + '.'
    }
    if (typeof customValidate === 'function') return customValidate(name)
    return true
  }
}

複製程式碼

引入的包:

  • path (node自帶path模組,用於路徑的處理。)
  • read-metadata (用於讀取json或者yaml後設資料檔案並返回一個物件。)
  • fs.existsSync (node自帶fs模組的existsSync方法,用於檢測路徑是否存在。)
  • git-user (獲取本地的git配置。)
  • validate-npm-package-name (用於npm包的名字是否是合法的。)

作用:

  • 主方法: 第一步:先獲取模板的配置檔案資訊;第二步:設定name欄位並檢測name是否合法;第三步:只是author欄位。
  • getMetadata: 獲取meta.js或則meta.json中的配置資訊
  • setDefault: 用於向配置物件中新增一下預設欄位
  • setValidateName: 用於檢測配置物件中name欄位是否合法

git-user.js

const exec = require('child_process').execSync

module.exports = () => {
  let name
  let email

  try {
    name = exec('git config --get user.name')
    email = exec('git config --get user.email')
  } catch (e) {}

  name = name && JSON.stringify(name.toString().trim()).slice(1, -1)
  email = email && (' <' + email.toString().trim() + '>')
  return (name || '') + (email || '')
}
複製程式碼

引入的包:

  • child_process.execSync (node自帶模組child_process中的execSync方法用於新開一個shell並執行相應的command,並返回相應的輸出。)

作用: 用於獲取本地的git配置的使用者名稱和郵件,並返回格式 姓名<郵箱> 的字串。

eval.js

const chalk = require('chalk')

/**
 * Evaluate an expression in meta.json in the context of
 * prompt answers data.
 */

module.exports = function evaluate (exp, data) {
  /* eslint-disable no-new-func */
  const fn = new Function('data', 'with (data) { return ' + exp + '}')
  try {
    return fn(data)
  } catch (e) {
    console.error(chalk.red('Error when evaluating filter condition: ' + exp))
  }
}
複製程式碼

引入的包:

  • chalk (用於高亮終端列印出來的資訊。)

作用: 在data的作用域執行exp表示式並返回其執行得到的值

ask.js

const async = require('async')
const inquirer = require('inquirer')
const evaluate = require('./eval')

// Support types from prompt-for which was used before
const promptMapping = {
  string: 'input',
  boolean: 'confirm'
}

/**
 * Ask questions, return results.
 *
 * @param {Object} prompts
 * @param {Object} data
 * @param {Function} done
 */
 
/**
 * prompts meta.js或者meta.json中的prompts欄位
 * data metalsmith.metadata()
 * done 交於下一個metalsmith外掛處理
 */
module.exports = function ask (prompts, data, done) {
 //遍歷處理prompts下的每一個欄位
  async.eachSeries(Object.keys(prompts), (key, next) => {
    prompt(data, key, prompts[key], next)
  }, done)
}

/**
 * Inquirer prompt wrapper.
 *
 * @param {Object} data
 * @param {String} key
 * @param {Object} prompt
 * @param {Function} 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)
    }
  }
  //設定問題,具體使用方法可去https://github.com/SBoudrias/Inquirer.js上面檢視
  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)
}

複製程式碼

引入的包:

  • async (非同步處理工具。)
  • inquirer (命令列與使用者之間的互動。)
  • eval (返回某作用下表示式的值。)

作用: 將meta.js或者meta.json中的prompts欄位解析成對應的問題詢問。

filter.js

const match = require('minimatch')
const evaluate = require('./eval')
/**
 * files 模板內的所有檔案
 * filters meta.js或者meta.json的filters欄位
 * data metalsmith.metadata()
 * done  交於下一個metalsmith外掛處理
 */
module.exports = (files, filters, data, done) => {
  if (!filters) {
    //meta.js或者meta.json沒有filters欄位直接跳過交於下一個metalsmith外掛處理
    return done()
  }
  //獲取所有檔案的名字
  const fileNames = Object.keys(files)
  //遍歷meta.js或者meta.json沒有filters下的所有欄位
  Object.keys(filters).forEach(glob => {
    //遍歷所有檔名
    fileNames.forEach(file => {
      //如果有檔名跟filters下的某一個欄位匹配上
      if (match(file, glob, { dot: true })) {        
        const condition = filters[glob]
        if (!evaluate(condition, data)) {
          //如果metalsmith.metadata()下condition表示式不成立,刪除該檔案
          delete files[file]
        }
      }
    })
  })
  done()
}
複製程式碼

引入的包:

  • minimatch (字元匹配工具。)
  • eval (返回某作用下表示式的值。)

作用: 根據metalsmith.metadata()刪除一些不需要的模板檔案,而metalsmith.metadata()主要在ask.js中改變的,也就是說ask.js中獲取到使用者的需求。

logger.js

const chalk = require('chalk')
const format = require('util').format

/**
 * Prefix.
 */

const prefix = '   vue-cli'
const sep = chalk.gray('·')

/**
 * Log a `message` to the console.
 *
 * @param {String} message
 */

exports.log = function (...args) {
  const msg = format.apply(format, args)
  console.log(chalk.white(prefix), sep, msg)
}

/**
 * Log an error `message` to the console and exit.
 *
 * @param {String} message
 */

exports.fatal = function (...args) {
  if (args[0] instanceof Error) args[0] = args[0].message.trim()
  const msg = format.apply(format, args)
  console.error(chalk.red(prefix), sep, msg)
  process.exit(1)
}

/**
 * Log a success `message` to the console and exit.
 *
 * @param {String} message
 */

exports.success = function (...args) {
  const msg = format.apply(format, args)
  console.log(chalk.white(prefix), sep, msg)
}
複製程式碼

引入的包:

  • chalk (用於高亮終端列印出來的資訊。)
  • format (node自帶的util模組中的format方法。)

作用: logger.js主要提供三個方法log(常規日誌)、fatal(錯誤日誌)、success(成功日誌)。每個方法都挺簡單的,我就不錯過多的解釋了。

local-path.js

const path = require('path')

module.exports = {
  isLocalPath (templatePath) {
    return /^[./]|(^[a-zA-Z]:)/.test(templatePath)
  },

  getTemplatePath (templatePath) {
    return path.isAbsolute(templatePath)
      ? templatePath
      : path.normalize(path.join(process.cwd(), templatePath))
  }
}
複製程式碼

引入的包:

  • path (node自帶的路徑處理工具。)

作用:

  • isLocalPath: UNIX (以“.”或者"/"開頭) WINDOWS(以形如:“C:”的方式開頭)。
  • getTemplatePath: templatePath是否為絕對路徑,是則返回templatePath 否則轉換成絕對路徑並規範化。

check-version.js

const request = require('request')
const semver = require('semver')
const chalk = require('chalk')
const packageConfig = require('../package.json')

module.exports = done => {
  // Ensure minimum supported node version is used
  if (!semver.satisfies(process.version, packageConfig.engines.node)) {
    return console.log(chalk.red(
      '  You must upgrade node to >=' + packageConfig.engines.node + '.x to use vue-cli'
    ))
  }

  request({
    url: 'https://registry.npmjs.org/vue-cli',
    timeout: 1000
  }, (err, res, body) => {
    if (!err && res.statusCode === 200) {
      const latestVersion = JSON.parse(body)['dist-tags'].latest
      const localVersion = packageConfig.version
      if (semver.lt(localVersion, latestVersion)) {
        console.log(chalk.yellow('  A newer version of vue-cli is available.'))
        console.log()
        console.log('  latest:    ' + chalk.green(latestVersion))
        console.log('  installed: ' + chalk.red(localVersion))
        console.log()
      }
    }
    done()
  })
}

複製程式碼

引入的包:

  • request (http請求工具。)
  • semver (版本號處理工具。)
  • chalk (用於高亮終端列印出來的資訊。)

作用:

  • 第一步:檢查本地的node版本號,是否達到package.json檔案中對node版本的要求,若低於nodepackage.json檔案中要求的版本,則直接要求開發者更新自己的node版本。反之,可開始第二步。
  • 第二步: 通過請求https://registry.npmjs.org/vue-cli來獲取vue-cli的最新版本號,跟package.json中的version欄位進行比較,若本地的版本號小於最新的版本號,則提示有最新版本可以更新。這裡需要注意的是,這裡檢查版本號並不影響後續的流程,即便本地的vue-cli版本不是最新的,也不影響構建,僅僅提示一下。

warnings.js

const chalk = require('chalk')

module.exports = {
  v2SuffixTemplatesDeprecated (template, name) {
    const initCommand = 'vue init ' + template.replace('-2.0', '') + ' ' + name

    console.log(chalk.red('  This template is deprecated, as the original template now uses Vue 2.0 by default.'))
    console.log()
    console.log(chalk.yellow('  Please use this command instead: ') + chalk.green(initCommand))
    console.log()
  },
  v2BranchIsNowDefault (template, name) {
    const vue1InitCommand = 'vue init ' + template + '#1.0' + ' ' + name

    console.log(chalk.green('  This will install Vue 2.x version of the template.'))
    console.log()
    console.log(chalk.yellow('  For Vue 1.x use: ') + chalk.green(vue1InitCommand))
    console.log()
  }
}
複製程式碼

引入的包:

  • chalk (用於高亮終端列印出來的資訊。)

作用:

  • v2SuffixTemplatesDeprecated:提示帶“-2.0”的模板已經棄用了,官方模板預設用2.0了。不需要用“-2.0”來區分vue1.0和vue2.0了。
  • v2BranchIsNowDefault: 這個方法在vue-init檔案中已經被註釋掉,不再使用了。在vue1.0向vue2.0過渡的時候用到過,現在都是預設2.0了,自然也就不用了。

總結

由於程式碼比較多,很多程式碼我就沒有一一細講了,一些比較簡單或者不是很重要的js檔案,我就單單說明了它的作用了。但是重點的js檔案,我還是加了很多註解在上面。其中我個人認為比較重點的檔案就是vue-initgenerate.jsoptions.jsask.jsfilter.js,這五個檔案構成了vue-cli構建專案的主流程,因此需要我們花更多的時間在上面。另外,我們在讀原始碼的過程中,一定要理清楚整個構建流程是什麼樣子的,心裡得有一個譜。我自己在讀完整個vue-cli之後,我自己根據vue-cli的流程也動手搞了一個腳手架工具,僅供大家參考學習一下。地址如下:

https://github.com/ruichengping/asuna-cli

最後祝願大家可以在前端的道路上越走越好!如果喜歡我的文章,請記得關注我哦!後續會推出更多的優質的文章哦,敬請期待!

相關文章