啟動vue專案時發生了什麼

hdxg發表於2024-05-01

簡介

最近在做vue專案時,遇到一些vue cli方面的報錯,於是便想深入研究一下vue cli。這裡先簡單寫一篇,如果有更細緻的探究,再另作打算。

執行npm run dev

前提是你已經安裝了node,並且附帶了npm。

執行npm run dev時,npm會自動搜尋當前目錄下的package.json,找到scripts配置項中的dev指令碼。

"scripts": {
  "dev": "vue-cli-service serve --mode dev",
  "build": "vue-cli-service build --mode prod",
  "rsync": "rsync -av -e ssh ./dist/template-webapp-client-vue3 root@aliyun:/srv/www",
  "lint": "vue-cli-service lint"
},

可以看到npm run dev實際上在執行vue-cli-service serve --mode dev

vue-cli-service命令被放在node_modules/.bin下,這是使用vue create建立專案時新增的。

輔助資料:

  1. 附錄/執行npm run時發生了什麼
  2. 附錄/node_modules/.bin下的檔案是怎麼來的

vue-cli-service做了什麼

先檢視node_modules/.bin/vue-cli-service.cmd這份檔案:

@ECHO off
GOTO start

:find_dp0
SET dp0=%~dp0
EXIT /b

:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
  SET "_prog=%dp0%\node.exe"
) ELSE (
  SET "_prog=node"
  SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  "%dp0%\..\@vue\cli-service\bin\vue-cli-service.js" %*

這個批處理檔案的主要目的是確定Node.js的路徑,並使用該路徑來執行一個特定的Vue CLI服務指令碼。而這個被執行的指令碼就是node_modules\@vue\cli-service\bin\vue-cli-service.js

好的,我們繼續檢視node_modules\@vue\cli-service\bin\vue-cli-service.js

#!/usr/bin/env node  
// 指定該指令碼使用 Node.js 來執行,並且使用 `/usr/bin/env` 來查詢 node 的實際安裝路徑  
  
const { semver, error } = require('@vue/cli-shared-utils') // `semver` 是一個用於處理語義化版本號的庫 ,`error` 可能是一個用於輸出錯誤並可能以某種方式退出的函式  
const requiredVersion = require('../package.json').engines.node // 獲取專案所需的 Node.js 版本  
  
// 使用 semver 的 satisfies 函式檢查當前 Node.js 的版本號是否滿足專案所需版本  
// 如果不滿足,則使用 `error` 函式輸出錯誤訊息,並提示使用者升級 Node.js 版本  
if (!semver.satisfies(process.version, requiredVersion, { includePrerelease: true })) {  
  error(  
    `You are using Node ${process.version}, but vue-cli-service ` +  
    `requires Node ${requiredVersion}.\nPlease upgrade your Node version.`  
  )
  process.exit(1) // 退出程式,並返回狀態碼 1,通常表示出現了錯誤  
}  

// 使用 `VUE_CLI_CONTEXT` 環境變數(如果存在)或當前工作目錄作為上下文建立一個Service物件
const Service = require('../lib/Service')
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd()) 

// 解析引數
const rawArgv = process.argv.slice(2) // 獲取命令列引數(不包括 `node` 和指令碼名),儲存在 `rawArgv` 中  
const args = require('minimist')(rawArgv, {  // 使用 'minimist' 庫解析命令列引數  
  boolean: [ // 定義哪些命令列引數是布林型別的(即它們的存在與否表示 true 或 false)  
    'modern',  
    'report',  
    'report-json',  
    'inline-vue',  
    'watch',  
    'open',  
    'copy',  
    'https',  
    'verbose'  
  ]  
})

const command = args._[0] // 獲取命令列中的第一個引數(通常是命令名),儲存在 `command` 中  

// 執行命令
service.run(command, args, rawArgv).catch(err => {
  error(err)
  process.exit(1)
})

這份檔案的意思就是建立一個Service物件,並執行run()方法。執行的時候使用命令列傳入的引數。

Service類

全域性變數和函式

// # 三方庫
const path = require('path')
const debug = require('debug')
const { merge } = require('webpack-merge')
const Config = require('webpack-chain')
const dotenv = require('dotenv')
const dotenvExpand = require('dotenv-expand')
const defaultsDeep = require('lodash.defaultsdeep')

// # vue-cli自帶庫
const { warn, error, isPlugin, resolvePluginId, loadModule, resolvePkg, resolveModule, sortPlugins } = require('@vue/cli-shared-utils')


const PluginAPI = require('./PluginAPI')
const { defaults } = require('./options')
const loadFileConfig = require('./util/loadFileConfig')
const resolveUserConfig = require('./util/resolveUserConfig')

// Seems we can't use `instanceof Promise` here (would fail the tests)
const isPromise = p => p && typeof p.then === 'function'
module.exports = class Service {
  // ...
}

/** @type {import('../types/index').defineConfig} */
module.exports.defineConfig = (config) => config

constructor

constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
  process.VUE_CLI_SERVICE = this
  this.initialized = false
  this.context = context // process.pwd(),也就是我們的專案根目錄
  this.inlineOptions = inlineOptions
  this.webpackChainFns = []
  this.webpackRawConfigFns = []
  this.devServerConfigFns = []
  this.commands = {}
  this.pkgContext = context
  this.pkg = this.resolvePkg(pkg)
  this.plugins = this.resolvePlugins(plugins, useBuiltIn)
  this.pluginsToSkip = new Set() // 在執行run()時填充
  // 採集每個command類外掛所對應的預設mode
  // command的預設mode可以在其檔案中看到(modules.exports.defaultModes)
  this.modes = this.plugins.reduce((modes, { apply: { defaultModes } }) => {
    return Object.assign(modes, defaultModes)
  }, {})
}

resolvePkg

// # 解析並讀取packageJson配置
// 使用context指定路徑(即專案根目錄)下的package.json
// 如果package.json中有pkg.vuePlugins.resolveFrom這個配置,則使用該配置指定的package.json
resolvePkg (inlinePkg, context = this.context) {
  // 如果有inlinePkg,則使用inlinePkg
  // 然而從原始碼中來看,並沒有傳入inlinePkg,所以這個分支不會執行
  if (inlinePkg) {
    return inlinePkg
  }
  const pkg = resolvePkg(context) // 讀取指定上下文下的package.json
  if (pkg.vuePlugins && pkg.vuePlugins.resolveFrom) {
    this.pkgContext = path.resolve(context, pkg.vuePlugins.resolveFrom)
    return this.resolvePkg(null, this.pkgContext)
  }
  return pkg
}

const pkg = resolvePkg(context)這一行中的resolvePkg()原始碼如下:

const fs = require('fs')
const path = require('path')
const readPkg = require('read-pkg')

exports.resolvePkg = function (context) {
  if (fs.existsSync(path.join(context, 'package.json'))) {
    return readPkg.sync({ cwd: context })
  }
  return {}
}

read-pkg是用於讀取package.json的。這是因為在ES模組中,無法使用import匯入json檔案,所以需要read-pkg這個包來解決這個問題。

resolvePlugins

解析、載入plugins後返回。

plugin可以來自內建外掛、命令列中指定的外掛、package.json中指定的外掛。

如果使用了命令列中指定的外掛,則會忽略package.json中指定的外掛。

內建(built-in)外掛在@vue/cli-service/lib目錄下,包括:

// 命令類外掛
@vue/cli-service/lib/commands/serve
@vue/cli-service/lib/commands/build
@vue/cli-service/lib/commands/inspect
@vue/cli-service/lib/commands/help
// 配置類外掛,用於向webpack配置檔案新增配置項
@vue/cli-service/lib/config/base
@vue/cli-service/lib/config/assets
@vue/cli-service/lib/config/css
@vue/cli-service/lib/config/prod
@vue/cli-service/lib/config/app
// package.json中的外掛
@vue/cli-plugin-babel
@vue/cli-plugin-eslint
@vue/cli-plugin-router
@vue/cli-plugin-typescript
@vue/cli-plugin-vuex
resolvePlugins (inlinePlugins, useBuiltIn) {
  const idToPlugin = (id, absolutePath) => ({
    id: id.replace(/^.\//, 'built-in:'), // 如果是'./commands/serve'這樣的內建外掛,則轉為'built-in:commands/serve'
    apply: require(absolutePath || id) // 匯入外掛,可以是絕對路徑的三方外掛,也可以是相對路徑的內建外掛
  })

  let plugins

  const builtInPlugins = [
    './commands/serve',
    './commands/build',
    './commands/inspect',
    './commands/help',
    // config plugins are order sensitive
    './config/base',
    './config/assets',
    './config/css',
    './config/prod',
    './config/app'
  ].map((id) => idToPlugin(id))

  // 如果有inlinePlugins,則使用inlinePlugins或[...ininePlugins, ...builtInPlugins]
  if (inlinePlugins) {
    plugins = useBuiltIn !== false
      ? builtInPlugins.concat(inlinePlugins)
    : inlinePlugins
  }
  // 否則使用package.json中的dependencies和devDependencies中的vue plugin
  // (這裡會根據包名自動判斷是否是vue plugin)
  else {
    const projectPlugins = Object.keys(this.pkg.devDependencies || {})
    .concat(Object.keys(this.pkg.dependencies || {}))
    .filter(isPlugin) // 包名符合@vue/cli-plugin-xxx或vue-cli-plugin-xxx
    .map(id => {
      if (
        this.pkg.optionalDependencies &&
        id in this.pkg.optionalDependencies
      ) {
        let apply = loadModule(id, this.pkgContext)
        if (!apply) {
          warn(`Optional dependency ${id} is not installed.`)
          apply = () => {}
        }

        return { id, apply }
      } else {
        return idToPlugin(id, resolveModule(id, this.pkgContext))
      }
    })

    plugins = builtInPlugins.concat(projectPlugins)
  }

  // 載入本地外掛
  // 本地外掛應該指的是放在專案根目錄下由使用者自定義的外掛
  if (this.pkg.vuePlugins && this.pkg.vuePlugins.service) {
    const files = this.pkg.vuePlugins.service
    if (!Array.isArray(files)) {
      throw new Error(`Invalid type for option 'vuePlugins.service', expected 'array' but got ${typeof files}.`)
    }
    plugins = plugins.concat(files.map(file => ({
      id: `local:${file}`,
      apply: loadModule(`./${file}`, this.pkgContext)
    })))
  }
  debug('vue:plugins')(plugins)

  // 對外掛進行排序
  // 有一些外掛需要在指定的其他外掛執行之後/前執行
  const orderedPlugins = sortPlugins(plugins)
  debug('vue:plugins-ordered')(orderedPlugins)

  return orderedPlugins
}

run

async run (name, args = {}, rawArgv = []) {
  // 計算mode,優先使用--mode引數
  // 次之,在build命令且有--watch時使用development
  // 最次,使用命令的預設mode
  const mode = args.mode || (name === 'build' && args.watch ? 'development' : this.modes[name])

  // 跳過--skip-plugins指定的外掛
  this.setPluginsToSkip(args, rawArgv)

  // 載入環境變數、載入使用者配置、應用外掛
  await this.init(mode)

  // 執行name對應的命令
  // 這些命令是在外掛中註冊到this.commands中的
  args._ = args._ || []
  let command = this.commands[name]
  if (!command && name) {
    error(`command "${name}" does not exist.`)
    process.exit(1)
  }
  if (!command || args.help || args.h) {
    command = this.commands.help
  } else {
    args._.shift() // remove command itself
    rawArgv.shift()
  }
  const { fn } = command
  return fn(args, rawArgv)
}

init

init做以下幾件事:

  1. 載入mode .env和base .env中的內容到process.env中
  2. 載入vue.config.js,並且生成本專案的配置清單
  3. 應用外掛
  4. 掛載webpack配置
init (mode = process.env.VUE_CLI_MODE) {
  if (this.initialized) {
    return
  }
  this.initialized = true
  this.mode = mode

  // 載入mode .env和base .env
  if (mode) {
    this.loadEnv(mode)
  }
  this.loadEnv()

  // 載入vue.config.js(可能是非同步的)
  const userOptions = this.loadUserOptions()

  // 載入完畢後的回撥
  const loadedCallback = (loadedUserOptions) => {
    // 將vue.config.js和預設選項結合,形成本專案的配置
    this.projectOptions = defaultsDeep(loadedUserOptions, defaults())

    debug('vue:project-config')(this.projectOptions)

    // 應用外掛
    this.plugins.forEach(({ id, apply }) => {
      if (this.pluginsToSkip.has(id)) return
      apply(new PluginAPI(id, this), this.projectOptions)
    })

    // 應用來自vue.config.js中的webpack配置
    if (this.projectOptions.chainWebpack) {
      this.webpackChainFns.push(this.projectOptions.chainWebpack)
    }
    if (this.projectOptions.configureWebpack) {
      this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
    }
  }

  if (isPromise(userOptions)) {
    return userOptions.then(loadedCallback)
  } else {
    return loadedCallback(userOptions)
  }
}

loadEnv

從專案根目錄下載入.env或.env.${mode}和.env.${mode}.local中的環境變數到process.env中。

loadEnv (mode) {
  const logger = debug('vue:env')
  const basePath = path.resolve(this.context, `.env${mode ? `.${mode}` : ``}`)
  const localPath = `${basePath}.local`

  const load = envPath => {
    try {
      const env = dotenv.config({ path: envPath, debug: process.env.DEBUG })
      dotenvExpand(env)
      logger(envPath, env)
    } catch (err) {
      // only ignore error if file is not found
      if (err.toString().indexOf('ENOENT') < 0) {
        error(err)
      }
    }
  }

  load(localPath)
  load(basePath)

  // 設定NODE_ENV和BABEL_ENV
  if (mode) {
    // 在test模式時強制設定NODE_ENV和BABEL_ENV為defaultEnv
    const shouldForceDefaultEnv = (
      process.env.VUE_CLI_TEST &&
      !process.env.VUE_CLI_TEST_TESTING_ENV
    )
    const defaultNodeEnv = (mode === 'production' || mode === 'test')
    ? mode
    : 'development'
    if (shouldForceDefaultEnv || process.env.NODE_ENV == null) {
      process.env.NODE_ENV = defaultNodeEnv
    }
    if (shouldForceDefaultEnv || process.env.BABEL_ENV == null) {
      process.env.BABEL_ENV = defaultNodeEnv
    }
  }
}

loadUserOptions

載入vue.config.js。

loadUserOptions () {
  const { fileConfig, fileConfigPath } = loadFileConfig(this.context)

  if (isPromise(fileConfig)) {
    return fileConfig
      .then(mod => mod.default)
      .then(loadedConfig => resolveUserConfig({
      inlineOptions: this.inlineOptions,
      pkgConfig: this.pkg.vue,
      fileConfig: loadedConfig,
      fileConfigPath
    }))
  }

  return resolveUserConfig({
    inlineOptions: this.inlineOptions,
    pkgConfig: this.pkg.vue,
    fileConfig,
    fileConfigPath
  })
}

* PluginAPI

這是對Vue CLI外掛暴露的API物件類。將PluginAPI物件傳給外掛,透過這個物件,外掛可以向service物件進行以下關鍵操作:

  • 獲取cwd
  • 註冊command
  • 新增webpack配置
  • 新增dev server配置

總結

這個類做了以下事情:

  • 讀取package.json檔案,主要需要知道其中關於Vue CLI外掛的資訊
  • 讀取並安裝VueCLI內建外掛、package.json中的三方Vue CLI外掛
  • 載入.env檔案中的環境變數到process.env中
  • 載入vue.config.js,並將其中的配置合併到webpack配置中

然後Vue CLI外掛賦予Service額外的特性,比如:

  • 新增command
  • 新增webpack配置
  • 新增dev server配置

serve.js

從Service類的resolvePlugins()函式中我們可以知道,vue-cli-service serve實際上在執行@vue/cli-service/lib/commands/serve.js這個指令碼。那我們繼續來看這個檔案吧。

這份檔案是一個命令型外掛,service會載入並安裝它:

module.exports = (api, options) => {
  // ... 
  api.registerCommand('serve', {
    description: 'start development server',
    usage: 'vue-cli-service serve [options] [entry]',
    options: {
      '--open': `open browser on server start`,
      '--copy': `copy url to clipboard on server start`,
      '--stdin': `close when stdin ends`,
      '--mode': `specify env mode (default: development)`,
      '--host': `specify host (default: ${defaults.host})`,
      '--port': `specify port (default: ${defaults.port})`,
      '--https': `use https (default: ${defaults.https})`,
      '--public': `specify the public network URL for the HMR client`,
      '--skip-plugins': `comma-separated list of plugin names to skip for this run`
    }
  }, async function serve (args) {
    // ... 
  })
}

外掛的主體部分比較瑣碎,但主要就是圍繞著兩件事情:建立webpack物件用來編譯,然後使用webpackDevServer執行編譯完的結果。

// ...
const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
// ...
const compiler = webpack(webpackConfig)
// ...
const server = new WebpackDevServer(
	// 一些伺服器配置
  compiler
)
// ...
server.start().catch(err => reject(err))

總結

綜上,vue cli專案啟動的過程簡單地說如下:

  1. 執行npm run dev
  2. 執行dev對應的vue-cli-service serve --mode dev
  3. vue-cli-service中建立Service類,讀取從命令列、package.json、.env、vue.config.js這些配置檔案中拿到的配置,應用外掛獲得特性
  4. 執行serve.js對應的命令,其主要內容就是建立webpack編譯器和啟動webpackDevServer。

相關文章