簡介
最近在做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
建立專案時新增的。
輔助資料:
- 附錄/執行npm run時發生了什麼
- 附錄/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做以下幾件事:
- 載入mode .env和base .env中的內容到process.env中
- 載入vue.config.js,並且生成本專案的配置清單
- 應用外掛
- 掛載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專案啟動的過程簡單地說如下:
- 執行npm run dev
- 執行dev對應的vue-cli-service serve --mode dev
- vue-cli-service中建立Service類,讀取從命令列、package.json、.env、vue.config.js這些配置檔案中拿到的配置,應用外掛獲得特性
- 執行serve.js對應的命令,其主要內容就是建立webpack編譯器和啟動webpackDevServer。