vue-cli 源碼閱讀

vue-cli 源碼閱讀

前一陣寫了一個平時本身用的 React 模板項目 boone-react,想着之後每次寫新項目的時候就能夠直接拉取現成的模板就不用本身配置了。但完成後,就發現每次都須要 git clone 下來仍是比較麻煩的,並且若是以後有不一樣的模板需求,不方便擴展,因而本身寫了一個 CLI 工具 boone-cli,這樣在本地安裝執行相關命令就能夠完成基本需求了。但感受仍是有不少不足,想着去看看以前用過屢次的 vue-cli 是怎麼實現的。vue

Tip:閱讀的是 vue-cli 的 v2.9.6 版本。由於 CRA 和 vue-cli@3.0 都是整合了一個插件系統來完成的項目新建工做,重點就不在命令行工具了而是插件系統如何實現了,增長閱讀負擔, 本身菜還找藉口

從項目結構開始

把測試文件、構建配置文件、文檔文件去除後,項目的源碼結構以下。其中:node

  • package.json 很少說,注意裏面的 bin 字段,這裏定義了安裝後執行的命令以及命令所對應的執行腳本
  • bin 目錄下是命令文件,在敲入 vue init 等命令後會執行對應的文件
  • lib 目錄下是一些自定義函數,會在各個執行腳本中用到

分析完結構就知道了,主要須要看的就是 binlib 兩個目錄下的文件了。react

├── bin
│   ├── vue
│   ├── vue-build
│   ├── vue-create
│   ├── vue-init
│   └── vue-list
├── lib
│   ├── ask.js
│   ├── check-version.js
│   ├── eval.js
│   ├── filter.js
│   ├── generate.js
│   ├── git-user.js
│   ├── local-path.js
│   ├── logger.js
│   ├── options.js
│   └── warnings.js
├── package.json

bin 目錄

vue 腳本

這裏使用了 commander,主要用來處理命令行工具的開發。
vue 這個腳本文件主要功能就是給用戶提示,提示 vue-cli 工具的用法和全部命令。PASS!webpack

// vue 腳本
const program = require('commander')

program
  .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')
  .command('create', '(for v3 warning only)')

program.parse(process.argv)

執行後,終端顯示
clipboard.pnggit

vue build 腳本

這裏使用了 chalk,可讓終端打印的文本更加漂亮。github

// vue-build 腳本
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'
))

這個腳本在當前版本中已經被移除了,所以目前只有一些提示信息。PASS!web

vue create 腳本

和上面那個相似,也是打印一些提示信息。vue create 是在 3.0 版本引入的功能。vue-cli

// vue-create 腳本
const chalk = require('chalk')

console.log()
console.log(
  `  ` +
  chalk.yellow(`vue create`) +
  ' is a Vue CLI 3 only command and you are using Vue CLI ' +
  require('../package.json').version + '.'
)
console.log(`  You may want to run the following to upgrade to Vue CLI 3:`)
console.log()
console.log(chalk.cyan(`  npm uninstall -g vue-cli`))
console.log(chalk.cyan(`  npm install -g @vue/cli`))
console.log()

執行後,終端顯示shell

clipboard.png

vue list 腳本

該腳本用於向用戶展現目前的官方模板的信息npm

// vue list 腳本
const logger = require('../lib/logger')    // 自定義的 log 對象,能夠調用不一樣方法輸出不一樣顏色的 log 信息
const request = require('request') // 用於發 HTTP 請求
const chalk = require('chalk')

// 在開始輸出和結束輸出的時候加一空行 padding
console.log()
process.on('exit', () => {
  console.log()
})

// 請求官方模板信息
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)
  }
})

執行後終端顯示
clipboard.png

✨ vue init 腳本

該腳本是最關鍵的腳本,用於幫助用戶初始化 vue 項目。

// vue init 腳本
const download = require('download-git-repo') // 下載 github 上的倉庫至用戶本地
const program = require('commander')    // 命令行工具
const exists = require('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 // 相似於刪除命令
const logger = require('../lib/logger') // 自定義的 log 對象,能夠調用不一樣方法輸出不一樣顏色的 log 信息
const generate = require('../lib/generate') // 用於生成最終文件
const checkVersion = require('../lib/check-version') // 檢查 node 版本和 vue-cli 版本
const warnings = require('../lib/warnings') // 用於提示用戶模板名的變化,主要用於 vue@1.0 和 vue@2.0 過渡時期
const localPath = require('../lib/local-path') // 根據模板路徑判斷是不是本地路徑

const isLocalPath = localPath.isLocalPath
const getTemplatePath = localPath.getTemplatePath
// 配置使用說明,直接敲入 vue init 後會展現該命令的使用信息
program
  .usage('<template-name> [project-name]')
  .option('-c, --clone', 'use git clone')
  .option('--offline', 'use cached template')

// 配置幫助信息內容
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]    // vue init 後面跟着的第一個參數,表示 template 名字
// 判斷上面的 template 參數中是否有斜槓 /,若是有斜槓說明不是官方模板(看一下 help 內容就知道了)
const hasSlash = template.indexOf('/') > -1
const rawName = program.args[1]    // vue init 後面跟着的第二個參數,表示構建的項目名
const inPlace = !rawName || rawName === '.'    // 若是第二個參數爲空或者爲 '.',則表示在當前目錄下新建項目
// 若是是在當前目錄下構建,則構建目錄名爲當前目錄名,不然以 rawName 做爲構建目錄名,能夠理解爲項目名
const name = inPlace ? path.relative('../', process.cwd()) : rawName
const to = path.resolve(rawName || '.') // 構建目錄的絕對路徑
// 用於傳給 download-git-repo 的參數,若是該參數爲 true 則使用 git clone 來下載項目而不是 HTTP 下載,默認 false
const clone = program.clone || false
// 模板下載到用戶本地的地方,而且把 template 中的斜槓 / 和冒號 : 替換爲短線 -
const tmp = path.join(home, '.vue-templates', template.replace(/[\/:]/g, '-'))
// 檢測用戶環境是否聯網,若是沒有聯網則使用本地以前存儲的模板,把 template 賦值爲本地存儲的模板的路徑
if (program.offline) {
  console.log(`> Use cached template at ${chalk.yellow(tildify(tmp))}`)
  template = tmp
}

// 開始和結束輸出一個空行做爲 padding
console.log()
process.on('exit', () => {
  console.log()
})

// 若是是沒傳構建項目名,或傳入 '.',則讓用戶肯定是否在當前目錄下構建項目,肯定後繼續執行 run
// 若是傳入了項目名,但項目名已經存在,則讓用戶肯定是否繼續在已有項目目錄下新建項目,肯定後繼續執行 run
// 若是沒有以上兩種特殊狀況,繼續執行 run
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()
}
// 主函數,用於下載模板,生成項目目錄等
function run () {
  // 判斷模板是不是本地模板
  if (isLocalPath(template)) {
    // 獲取本地模板的絕對路徑
    const templatePath = getTemplatePath(template)
    // 本地模板路徑存在的話,經過 generate 函數生成項目文件;不存在的話打印錯誤信息並退出
    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 {
    // 若是模板不是本地模板,則先檢查使用者的 node 版本和 vue-cli 版本,符合後執行回調函數
    checkVersion(() => {
      if (!hasSlash) {
        // 若是用的是官方模板,拼接出官方模板的路徑,官方模板路徑 https://github.com/vuejs-templates/模板名
        const officialTemplate = 'vuejs-templates/' + template
        if (template.indexOf('#') !== -1) {
          // 帶 # 號說明是安裝 vue@1.0 版本的模板
          // 能夠在 https://github.com/vuejs-templates/webpack/tree/1.0 中看到如何初始化 1.0 項目
          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)
      }
    })
  }
}

// 下載模板文件而且生成項目文件
function downloadAndGenerate (template) {
  // 啓動 loading 指示器
  const spinner = ora('downloading template')
  spinner.start()
  // 若是本地的模板已經存在,把存在的模板刪掉,這樣能夠確保本地緩存的文件是儘可能新的模板文件
  if (exists(tmp)) rm(tmp)
  // 利用 download-git-repo 下載模板文件到本地存儲模板的目錄,下載成功後執行回調
  download(template, tmp, { clone }, err => {
    // 中止 loading 指示器
    spinner.stop()
    if (err) logger.fatal('Failed to download repo ' + template + ': ' + err.message.trim())
    // 生成項目文件,name -> 項目名稱, tmp -> 模板文件路徑,to -> 項目生成路徑
    generate(name, tmp, to, err => {
      if (err) logger.fatal(err)
      console.log()
      logger.success('Generated "%s".', name)
    })
  })
}

lib 目錄

ask.js

該文件負責處理 prompt,實現與用戶的問答交互

const async = require('async') // 封裝了不少異步處理的 API,用於處理異步流程
const inquirer = require('inquirer') // 命令行問答交互
const evaluate = require('./eval')

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

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) {
  // 當 prompt 條件不符的時候跳過詢問
  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)
    }
  }
  // 根據 meta.json 裏 prompt 的格式進行處理,拼裝問題,將答案賦予在 data(metalsmith.metadata) 屬性上
  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)
}

check-version.js

這個文件主要用來檢查:

  1. 使用者的 node 是否知足 vue-cli 要求的 node 版本,若是不知足則給出提示要求使用者更新 node 版本,而且不會繼續執行腳本
  2. 經過發出 HTTP 請求獲取 vue-cli 的最新版本號,與用戶當前安裝的版本進行比對,若是當前安裝版本較低,給用戶提示有新版本 vue-cli 能夠更新。但即使不更新腳本仍能夠繼續執行下去
const request = require('request') // 發 HTTP 請求
const semver = require('semver') // 用於 npm 包的版本比較
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()
  })
}

eval.js

const chalk = require('chalk')

// 執行腳本獲取 meta.json 裏的 prompts 中的各個字段的 when 屬性的實際值
module.exports = function evaluate (exp, data) {
  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))
  }
}

filter.js

const match = require('minimatch')    // 用於文件名匹配使用
const evaluate = require('./eval')

module.exports = (files, filters, data, done) => {
  // 若是沒有 filters 部分,直接跳過這個中間件
  if (!filters) {
    return done()
  }
  // 獲取全部的文件名
  const fileNames = Object.keys(files)
  Object.keys(filters).forEach(glob => {
    // 遍歷全部的文件名,若是文件名與 filter 中的 key 值匹配到,那麼判斷 key 值對應的 value 值是否爲 true(根據用戶交互的答案
    // 判斷)若是不爲 true 那麼刪除掉文件
    fileNames.forEach(file => {
      if (match(file, glob, { dot: true })) {
        const condition = filters[glob]
        if (!evaluate(condition, data)) {
          delete files[file]
        }
      }
    })
  })
  done()
}

✨ generate.js

重要文件,根據用戶與命令行工具的交互生成的項目配置,再結合模板文件,生成最終的項目文件。

讀這個文件的時候必定要結合一個模板庫來閱讀,好比 vue webpack 模板

引入相關包

// 引入的一堆包
const chalk = require('chalk')
const Metalsmith = require('metalsmith') // 一個插件化的靜態網站生成器
const Handlebars = require('handlebars') // 模板引擎
const async = require('async') // 封裝了不少異步處理的 API
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') // 自定義的 log 對象,能夠調用不一樣方法輸出不一樣顏色的 log 信息

註冊 Handlebarshelper

// 註冊 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)
})

關於 helper 能夠查看 Handlebars 官方文檔 配合 vue 的模板文件就能夠理解了。這裏以 vue 模板 webpack 中的 eslint 配置文件 簡單舉例說明:

這裏由 #if_eq/if_eq 包裹的區域是一個 block#if_eq 做爲 helper 後面跟着兩個參數,對應註冊 helper 時的回調函數中的 ab 參數,第三個參數中包含一些渲染模板所需的方法和數據等信息。其中 opts.fn 表明用數據渲染出塊中內容,opts.inverse 則不渲染內容而且把模板標記去掉。

所以下面的代碼能夠理解爲:當 lintConfig 的變量值等於 standard 的時候,渲染出中間關於 standard 的配置,不然不去渲染,這就實現了根據用戶的交互結果進行動態配置。

{{#if_eq lintConfig "standard"}}
  extends: [
    // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
    // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
    'plugin:vue/essential', 
    // https://github.com/standard/standard/blob/master/docs/RULES-en.md
    'standard'
  ],
{{/if_eq}}

✨ 根據傳入的項目名,模板路徑,生成路徑,生成項目文件

module.exports = function generate (name, src, dest, done) {
  // 獲取模板元信息
  const opts = getOptions(name, src)
  // 根據模板路徑初始化一個 metalsmith 實例
  const metalsmith = Metalsmith(path.join(src, 'template'))
  // 聲明 data 變量(由 metalsmith 中的全局 metadata 和擴展的對象,即第二個參數組成)
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd(),
    noEscape: true
  })
  // 元信息中是否有 helper 字段,有則註冊
  opts.helpers && Object.keys(opts.helpers).map(key => {
    Handlebars.registerHelper(key, opts.helpers[key])
  })

  const helpers = { chalk, logger }

  // 元信息中的 metalsmith 字段中是否有 before 函數,有則執行
  if (opts.metalsmith && typeof opts.metalsmith.before === 'function') {
    opts.metalsmith.before(metalsmith, opts, helpers)
  }

  // 1. 問問題並獲取答案 2. 過濾掉不須要的文件 3. 跳過不須要渲染的文件
  metalsmith.use(askQuestions(opts.prompts))
    .use(filterFiles(opts.filters))
    .use(renderTemplateFiles(opts.skipInterpolation))

  // 判斷元信息中的 metalsmith 字段值是不是函數,是則執行
  // 不然判斷 metalsmith 字段值中的 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('.') // metalsmith 默認源目錄爲 './src',修改成 '.'
    .destination(dest) // 設置文件輸出路徑
    .build((err, files) => {
      done(err)
      // 若是 meta.json 裏有 complete 而且爲 function,則執行該函數,沒有的話打印 meta.json 裏的 completeMessage
      if (typeof opts.complete === 'function') {
        const helpers = { chalk, logger, files }
        opts.complete(data, helpers)
      } else {
        logMessage(opts.completeMessage, data)
      }
    })

  return data
}

這一步基本就是在走流程,主要靠 metalsmith 這個包來完成。這個包的工做原理大概三步走:

- 讀取源目錄下的全部文件
- 引入一些插件來完成對文件的操做和處理
- 輸出結果文件至指定的目標目錄

其中第二步的插件是相似中間件的機制,metalsmith 的中間件接收三個參數:要處理的文件,metalsmith 實例和一個回調,經過調用回調來觸發下一個插件(中間件)

// metalsmith 中間件,用於向用戶詢問問題
function askQuestions (prompts) {
  return (files, metalsmith, done) => {
    ask(prompts, metalsmith.metadata(), done)
  }
}
// metalsmith 中間件,根據上一個中間件用戶交互的結果來篩選刪除掉不用的文件
function filterFiles (filters) {
  return (files, metalsmith, done) => {
    filter(files, filters, metalsmith.metadata(), done)
  }
}
// metalsmith 中間件,渲染模板,若是 meta.json 裏有 skipInterpolation 字段則跳過與其匹配的文件,不須要對其進行 handlebars 模板渲染
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) => {
      // 若是有 skipInterpolation,而且與文件有匹配則跳過渲染過程
      if (skipInterpolation && multimatch([file], skipInterpolation, { dot: true }).length) {
        return next()
      }
      // 獲取到文件的內容
      const str = files[file].contents.toString()
      // 若是文件內容中沒有 handlebars 的標記 {{{ }}} 則跳過渲染
      if (!/{{([^{}]+)}}/g.test(str)) {
        return next()
      }
      // 渲染 handlebars 部分,利用 metalsmithMetadata,其中已經包括了以前用戶交互的答案
      render(str, metalsmithMetadata, (err, res) => {
        if (err) {
          err.message = `[${file}] ${err.message}`
          return next(err)
        }
        // 將文件內容替換爲渲染出來的結果
        files[file].contents = new Buffer(res)
        next()
      })
    }, done)
  }
}
// 用於打印結束信息
function logMessage (message, data) {
  if (!message) return
  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'))
    }
  })
}

git-user.js

用於獲取用戶所配置的 git 信息

const exec = require('child_process').execSync // node 自帶模塊,能夠執行 shell 命令,並返回相應輸出

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 || '')
}

local-path.js

const path = require('path')

module.exports = {
  // 根據模板路徑,判斷是不是本地模板文件
  // 正則說明:以 . 或 ./ 開頭,說明是 UNIX 系統的本地文件;以 C: 或 c: 開頭說明是 WINDOWS 系統的本地文件
  isLocalPath (templatePath) {
    return /^[./]|(^[a-zA-Z]:)/.test(templatePath)
  },
  // 判斷傳入函數的模板路徑是不是絕對路徑,若是不是的話轉成絕對路徑
  getTemplatePath (templatePath) {
    return path.isAbsolute(templatePath)
      ? templatePath
      : path.normalize(path.join(process.cwd(), templatePath))
  }
}

logger.js

用於打印格式化的 log 信息

const chalk = require('chalk')
const format = require('util').format // node 中的 format 方法,用於格式化處理

// 設置打印的前綴
const prefix = '   vue-cli'
const sep = chalk.gray('·')

// 打印普通訊息並退出
exports.log = function (...args) {
  const msg = format.apply(format, args)
  console.log(chalk.white(prefix), sep, msg)
}

// 打印錯誤信息並退出
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)
}

// 打印成功信息並退出
exports.success = function (...args) {
  const msg = format.apply(format, args)
  console.log(chalk.white(prefix), sep, msg)
}

options.js

獲取模板文件中的元數據信息並進行一些擴展

const path = require('path')
const metadata = require('read-metadata') // 讀取 JSON 或者 YAML 格式元數據文件,並將其轉爲對象
const exists = require('fs').existsSync
const getGitUser = require('./git-user') // 獲取用戶的 git 配置信息:用戶名<用戶郵箱>
const validateName = require('validate-npm-package-name') // 校驗傳入的字符串是不是合法的 npm 包名

// 讀取模板庫中的元數據文件,並進行擴展
module.exports = function options (name, dir) {
  const opts = getMetadata(dir)

  // 設置項目默認名字
  setDefault(opts, 'name', name)
  // 設置名字的合法校驗規則
  setValidateName(opts)

  // 設置項目的 author 信息
  const author = getGitUser()
  if (author) {
    setDefault(opts, 'author', author)
  }

  return opts
}

// 獲取模板庫中 meta.js 或者 meta.json 文件中的配置信息
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
}

// 爲某個交互問題設置默認值
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
  }
}

// 在 opts 中的對 name 的 validate 基礎上,添加了對項目名是否合法的校驗(是不是合法的 npm 包)
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
  }
}

warning.js

用於提示用戶模板名的變化,主要用於 vue@1.0 和 vue@2.0 過渡時期

const chalk = require('chalk')

module.exports = {
  // 表示模板名中帶有 '-2.0' 的模板已經棄用了,默認用 2.0 模板
  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()
  },
  // 告知用戶若是想安裝 vue@1.0 的模板應該如何輸入命令
  // 但目前在 vue init 的 run 函數中已經被註釋掉了,可見 vue 的版本過渡已經完成了
  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()
  }
}

結語

讀完整個代碼下來難啃的主要就是 vue initgenerate.js 兩個文件,只要理清這兩個文件中的處理流程,讀起來就比較容易了。在讀代碼的過程當中也學習到了不少東西,好比說 CLI 工具的實現、與之相關的各類包的用法與適用場景、對於路徑的處理、模板與 CLI 工具的配合、模塊化的處理等等。總之 vue-cli 這個項目對於新手而言讀起來仍是比較友好的,若是是和我同樣比較害怕讀源碼的小白,不妨試試看!

相關文章
相關標籤/搜索