1. vue-cli-service
解决什么问题?
根据官方文档的描述,vue-cli-service
是 vue-cli
的运行时依赖。它可以:
- 基于webpack构建,内置了合理的默认配置;
- 可以通过配置文件进行配置webpack;
- 可以通过插件扩展
vue-cli-service
的命令
2. 带着问题看源码
vue-cli-service
主要流程是怎样的?vue-cli-service
帮我们预设了合理的webpack配置,也支持我们在vue.config.js
里修改webpack配置,是如何做到webpack配置的缝合的?vue-cli-service serve
和vue-cli-service build
做了什么?- 如何注册一个新的命令?
3. vue-cli-service
主要流程是怎样的?
3.0 目录结构
├─lib
| ├─options.js
| ├─PluginAPI.js
| ├─Service.js
| ├─commands
| | ├─help.js
| | ├─inspect.js
| | ├─serve.js
| | ├─build
| | | ├─demo-lib-js.html
| | | ├─demo-lib.html
| | | ├─demo-wc.html
| | | ├─entry-lib-no-default.js
| | | ├─entry-lib.js
| | | ├─entry-wc.js
| | | ├─formatStats.js
| | | ├─index.js
| | | ├─resolveAppConfig.js
| | | ├─resolveLibConfig.js
| | | ├─resolveWcConfig.js
| | | ├─resolveWcEntry.js
| | | └setPublicPath.js
├─bin
| └vue-cli-service.js
注意:这里只展示了部分目录和文件,为了直观显示本文需要讲述的文件以及所在的目录。
3.1 packages\@vue\cli-service\bin\vue-cli-service.js
#!/usr/bin/env node
const { semver, error } = require('@vue/cli-shared-utils')
const requiredVersion = require('../package.json').engines.node
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)
}
const Service = require('../lib/Service')
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd())
const rawArgv = process.argv.slice(2)
const args = require('minimist')(rawArgv, {
boolean: [
// build
// FIXME: --no-module, --no-unsafe-inline, no-clean, etc.
'modern',
'report',
'report-json',
'inline-vue',
'watch',
// serve
'open',
'copy',
'https',
// inspect
'verbose'
]
})
const command = args._[0]
service.run(command, args, rawArgv).catch(err => {
error(err)
process.exit(1)
})
从入口可以看出,vue-cli-service
的核心是使用了 Service
类,实例化并调用run
方法。下面我们看看 Service
类。
3.2 packages\@vue\cli-service\lib\Service.js
// ...
module.exports = class Service {
constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
process.VUE_CLI_SERVICE = this
this.initialized = false
// 项目的工作路径
this.context = context
this.inlineOptions = inlineOptions
// webpack配置相关
this.webpackChainFns = []
// webpack配置相关
this.webpackRawConfigFns = []
this.devServerConfigFns = []
this.commands = {}
// Folder containing the target package.json for plugins
this.pkgContext = context
// package.json containing the plugins
// 解析package.json
this.pkg = this.resolvePkg(pkg)
// If there are inline plugins, they will be used instead of those
// found in package.json.
// When useBuiltIn === false, built-in plugins are disabled. This is mostly
// for testing.
// 解析插件
this.plugins = this.resolvePlugins(plugins, useBuiltIn)
// pluginsToSkip will be populated during run()
// 需要跳过的插件
this.pluginsToSkip = new Set()
// resolve the default mode to use for each command
// this is provided by plugins as module.exports.defaultModes
// so we can get the information without actually applying the plugin.
// 模式,基于每个插件设置的defaultModes来决定
this.modes = this.plugins.reduce((modes, { apply: { defaultModes } }) => {
return Object.assign(modes, defaultModes)
}, {})
}
// ...
构造函数,初始化变量,下面看看 this.resolvePlugins
初始化插件是如何实现的。
resolvePlugins (inlinePlugins, useBuiltIn) {
const idToPlugin = (id, absolutePath) => ({
id: id.replace(/^.\//, 'built-in:'),
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))
// 行内插件
if (inlinePlugins) {
plugins = useBuiltIn !== false
? builtInPlugins.concat(inlinePlugins)
: inlinePlugins
} else {
// 在package.json符合@vue/cli-plugin-xxx规范的插件
const projectPlugins = Object.keys(this.pkg.devDependencies || {})
.concat(Object.keys(this.pkg.dependencies || {}))
.filter(isPlugin)
.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)
}
// 本地插件
// Local plugins
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
}
// @vue\cli-shared-utils\lib\pluginResolution.js
const pluginRE = /^(@vue\/|vue-|@[\w-]+(\.)?[\w-]+\/vue-)cli-plugin-/
exports.isPlugin = id => pluginRE.test(id)
可以看到,插件根据引入的位置可以分为四种:内置插件、行内插件、本地插件、在package.json符合 pluginRE
正则的插件。然后用 idToPlugin
封装成 {id: 插件id, apply: 插件方法}
。下面先看看 run
方法,再看看内置插件 commands/serve
是如何实现的。
async run (name, args = {}, rawArgv = []) {
// resolve mode
// prioritize inline --mode
// fallback to resolved default modes from plugins or development if --watch is defined
const mode = args.mode || (name === 'build' && args.watch ? 'development' : this.modes[name])
// --skip-plugins arg may have plugins that should be skipped during init()
// 根据入参来决定跳过的插件
this.setPluginsToSkip(args)
// load env variables, load user config, apply plugins
// 初始化:加载环境变量、加载用户配置,执行插件
await this.init(mode)
args._ = args._ || []
// 根据name来筛选命令
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)
}
run
方法,主要做了以下事情:通过参数设置跳过的插件,通过 this.init
来初始化(加载环境变量、加载用户配置,执行插件),执行命令方法。下面看看 this.init
的实现
init (mode = process.env.VUE_CLI_MODE) {
if (this.initialized) {
return
}
this.initialized = true
this.mode = mode
// load mode .env
if (mode) {
this.loadEnv(mode)
}
// load base .env
this.loadEnv()
// load user config
const userOptions = this.loadUserOptions()
const loadedCallback = (loadedUserOptions) => {
this.projectOptions = defaultsDeep(loadedUserOptions, defaults())
debug('vue:project-config')(this.projectOptions)
// apply plugins.
this.plugins.forEach(({ id, apply }) => {
if (this.pluginsToSkip.has(id)) return
apply(new PluginAPI(id, this), this.projectOptions)
})
// apply webpack configs from project config file
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)
}
}
可以看到 this.init
加载环境变量、加载用户配置,执行插件。其中在执行插件的时候,api传入了 PluginAPI
实例,它是插件的一些api,下面先贴一下代码,后面在讲插件的时候会用到。
3.3 packages\@vue\cli-service\lib\PluginAPI.js
const path = require('path')
const hash = require('hash-sum')
const { semver, matchesPluginId } = require('@vue/cli-shared-utils')
// Note: if a plugin-registered command needs to run in a specific default mode,
// the plugin needs to expose it via `module.exports.defaultModes` in the form
// of { [commandName]: mode }. This is because the command mode needs to be
// known and applied before loading user options / applying plugins.
class PluginAPI {
/**
* @param {string} id - Id of the plugin.
* @param {Service} service - A vue-cli-service instance.
*/
constructor (id, service) {
this.id = id
this.service = service
}
get version () {
return require('../package.json').version
}
assertVersion (range) {
if (typeof range === 'number') {
if (!Number.isInteger(range)) {
throw new Error('Expected string or integer value.')
}
range = `^${range}.0.0-0`
}
if (typeof range !== 'string') {
throw new Error('Expected string or integer value.')
}
if (semver.satisfies(this.version, range, { includePrerelease: true })) return
throw new Error(
`Require @vue/cli-service "${range}", but was loaded with "${this.version}".`
)
}
/**
* Current working directory.
*/
getCwd () {
return this.service.context
}
/**
* Resolve path for a project.
*
* @param {string} _path - Relative path from project root
* @return {string} The resolved absolute path.
*/
resolve (_path) {
return path.resolve(this.service.context, _path)
}
/**
* Check if the project has a given plugin.
*
* @param {string} id - Plugin id, can omit the (@vue/|vue-|@scope/vue)-cli-plugin- prefix
* @return {boolean}
*/
hasPlugin (id) {
return this.service.plugins.some(p => matchesPluginId(id, p.id))
}
/**
* Register a command that will become available as `vue-cli-service [name]`.
*
* @param {string} name
* @param {object} [opts]
* {
* description: string,
* usage: string,
* options: { [string]: string }
* }
* @param {function} fn
* (args: { [string]: string }, rawArgs: string[]) => ?Promise
*/
registerCommand (name, opts, fn) {
if (typeof opts === 'function') {
fn = opts
opts = null
}
this.service.commands[name] = { fn, opts: opts || {} }
}
/**
* Register a function that will receive a chainable webpack config
* the function is lazy and won't be called until `resolveWebpackConfig` is
* called
*
* @param {function} fn
*/
chainWebpack (fn) {
this.service.webpackChainFns.push(fn)
}
/**
* Register
* - a webpack configuration object that will be merged into the config
* OR
* - a function that will receive the raw webpack config.
* the function can either mutate the config directly or return an object
* that will be merged into the config.
*
* @param {object | function} fn
*/
configureWebpack (fn) {
this.service.webpackRawConfigFns.push(fn)
}
/**
* Register a dev serve config function. It will receive the express `app`
* instance of the dev server.
*
* @param {function} fn
*/
configureDevServer (fn) {
this.service.devServerConfigFns.push(fn)
}
/**
* Resolve the final raw webpack config, that will be passed to webpack.
*
* @param {ChainableWebpackConfig} [chainableConfig]
* @return {object} Raw webpack config.
*/
resolveWebpackConfig (chainableConfig) {
return this.service.resolveWebpackConfig(chainableConfig)
}
/**
* Resolve an intermediate chainable webpack config instance, which can be
* further tweaked before generating the final raw webpack config.
* You can call this multiple times to generate different branches of the
* base webpack config.
* See https://github.com/mozilla-neutrino/webpack-chain
*
* @return {ChainableWebpackConfig}
*/
resolveChainableWebpackConfig () {
return this.service.resolveChainableWebpackConfig()
}
/**
* Generate a cache identifier from a number of variables
*/
genCacheConfig (id, partialIdentifier, configFiles = []) {
const fs = require('fs')
const cacheDirectory = this.resolve(`node_modules/.cache/${id}`)
// replace \r\n to \n generate consistent hash
const fmtFunc = conf => {
if (typeof conf === 'function') {
return conf.toString().replace(/\r\n?/g, '\n')
}
return conf
}
const variables = {
partialIdentifier,
'cli-service': require('../package.json').version,
env: process.env.NODE_ENV,
test: !!process.env.VUE_CLI_TEST,
config: [
fmtFunc(this.service.projectOptions.chainWebpack),
fmtFunc(this.service.projectOptions.configureWebpack)
]
}
try {
variables['cache-loader'] = require('cache-loader/package.json').version
} catch (e) {
// cache-loader is only intended to be used for webpack 4
}
if (!Array.isArray(configFiles)) {
configFiles = [configFiles]
}
configFiles = configFiles.concat([
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml'
])
const readConfig = file => {
const absolutePath = this.resolve(file)
if (!fs.existsSync(absolutePath)) {
return
}
if (absolutePath.endsWith('.js')) {
// should evaluate config scripts to reflect environment variable changes
try {
return JSON.stringify(require(absolutePath))
} catch (e) {
return fs.readFileSync(absolutePath, 'utf-8')
}
} else {
return fs.readFileSync(absolutePath, 'utf-8')
}
}
variables.configFiles = configFiles.map(file => {
const content = readConfig(file)
return content && content.replace(/\r\n?/g, '\n')
})
const cacheIdentifier = hash(variables)
return { cacheDirectory, cacheIdentifier }
}
}
module.exports = PluginAPI
4. vue-cli-service serve
和 vue-cli-service build
做了什么?
4.1 内置插件serve做了什么?
const {
info,
error,
hasProjectYarn,
hasProjectPnpm,
IpcMessenger
} = require('@vue/cli-shared-utils')
const defaults = {
host: '0.0.0.0',
port: 8080,
https: false
}
/** @type {import('@vue/cli-service').ServicePlugin} */
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) {
info('Starting development server...')
// ...
// configs that only matters for dev server
api.chainWebpack(webpackConfig => {
// ...
})
// resolve webpack config
const webpackConfig = api.resolveWebpackConfig()
// check for common config errors
validateWebpackConfig(webpackConfig, api, options)
// ...
// create compiler
const compiler = webpack(webpackConfig)
// handle compiler error
compiler.hooks.failed.tap('vue-cli-service serve', msg => {
error(msg)
process.exit(1)
})
// create server
const server = new WebpackDevServer(Object.assign({
// ...
}), compiler)
// ...
return new Promise((resolve, reject) => {
// ...
resolve({
server,
url: localUrlForBrowser
})
// ...
server.start().catch(err => reject(err))
})
})
}
// ...
module.exports.defaultModes = {
serve: 'development'
}
调用 PluginAPI
的 registerCommand
方法注册命令,传了三个参数:命令名称、命令的用法配置、命令函数。注册命令其实就是在 service实例
的 commands
变量添加一个字段,以 {fn: 命令方法, otps: 命令使用方法选项}
的方式存起来
// packages\@vue\cli-service\lib\PluginAPI.js
chainWebpack (fn) {
this.service.webpackChainFns.push(fn)
}
resolveWebpackConfig (chainableConfig) {
return this.service.resolveWebpackConfig(chainableConfig)
}
// packages\@vue\cli-service\lib\Service.js
resolveChainableWebpackConfig () {
const chainableConfig = new Config()
// apply chains
this.webpackChainFns.forEach(fn => fn(chainableConfig))
return chainableConfig
}
resolveWebpackConfig (chainableConfig = this.resolveChainableWebpackConfig()) {
if (!this.initialized) {
throw new Error('Service must call init() before calling resolveWebpackConfig().')
}
// get raw config
let config = chainableConfig.toConfig()
const original = config
// apply raw config fns
this.webpackRawConfigFns.forEach(fn => {
if (typeof fn === 'function') {
// function with optional return value
const res = fn(config)
if (res) config = merge(config, res)
} else if (fn) {
// merge literal values
config = merge(config, fn)
}
})
// #2206 If config is merged by merge-webpack, it discards the __ruleNames
// information injected by webpack-chain. Restore the info so that
// vue inspect works properly.
if (config !== original) {
cloneRuleNames(
config.module && config.module.rules,
original.module && original.module.rules
)
}
// check if the user has manually mutated output.publicPath
const target = process.env.VUE_CLI_BUILD_TARGET
if (
!process.env.VUE_CLI_TEST &&
(target && target !== 'app') &&
config.output.publicPath !== this.projectOptions.publicPath
) {
throw new Error(
`Do not modify webpack output.publicPath directly. ` +
`Use the "publicPath" option in vue.config.js instead.`
)
}
if (
!process.env.VUE_CLI_ENTRY_FILES &&
typeof config.entry !== 'function'
) {
let entryFiles
if (typeof config.entry === 'string') {
entryFiles = [config.entry]
} else if (Array.isArray(config.entry)) {
entryFiles = config.entry
} else {
entryFiles = Object.values(config.entry || []).reduce((allEntries, curr) => {
return allEntries.concat(curr)
}, [])
}
entryFiles = entryFiles.map(file => path.resolve(this.context, file))
process.env.VUE_CLI_ENTRY_FILES = JSON.stringify(entryFiles)
}
return config
}
serve的命令方法,其实就做了两件事:
- 通过
api.chainWebpack
注入内置的webpack命令,之后通过api.resolveWebpackConfig
来解析webpack配置,并通过validateWebpackConfig
方法来验证webpack配置格式是否正确,并执行webpack
- 创建
WebpackDevServer
4.2 如何实现webpack配置的缝合?
// packages\@vue\cli-service\lib\PluginAPI.js
registerCommand (name, opts, fn) {
if (typeof opts === 'function') {
fn = opts
opts = null
}
this.service.commands[name] = { fn, opts: opts || {} }
}
// packages\@vue\cli-service\lib\Service.js
resolveChainableWebpackConfig () {
const chainableConfig = new Config()
// apply chains
this.webpackChainFns.forEach(fn => fn(chainableConfig))
return chainableConfig
}
resolveWebpackConfig (chainableConfig = this.resolveChainableWebpackConfig()) {
if (!this.initialized) {
throw new Error('Service must call init() before calling resolveWebpackConfig().')
}
// get raw config
let config = chainableConfig.toConfig()
const original = config
// apply raw config fns
this.webpackRawConfigFns.forEach(fn => {
if (typeof fn === 'function') {
// function with optional return value
const res = fn(config)
if (res) config = merge(config, res)
} else if (fn) {
// merge literal values
config = merge(config, fn)
}
})
// ...
return config
}
可以看出,service
就是通过 webpackChainFns
和 webpackRawConfigFns
记录webpack配置,前者通过 webpack-chain
来合并配置,而后者通过 webpack-merge
来合并配置。
此时我们回头看 Service
的 init
,加载用户配置:
// packages\@vue\cli-service\lib\Service.js
init (mode = process.env.VUE_CLI_MODE) {
// ...
// load user config
// 加载用户配置,就是vue.config.js
const userOptions = this.loadUserOptions()
const loadedCallback = (loadedUserOptions) => {
this.projectOptions = defaultsDeep(loadedUserOptions, defaults())
// apply plugins.
// 注册插件,放到this.service.commands下
this.plugins.forEach(({ id, apply }) => {
if (this.pluginsToSkip.has(id)) return
apply(new PluginAPI(id, this), this.projectOptions)
})
// apply webpack configs from project config file
// 把用户的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)
}
}
也就是说,初始化的时候,先加载本地配置,加载完了,先插件的注册,用户的webpack命令把它们分别存到 webpackChainFns
和 webpackRawConfigFns
,然后执行命令的时候,再把内置的命令push进去。
// packages\@vue\cli-service\lib\Service.js
// apply plugins.
this.plugins.forEach(({ id, apply }) => {
if (this.pluginsToSkip.has(id)) return
apply(new PluginAPI(id, this), this.projectOptions)
})
// apply webpack configs from project config file
if (this.projectOptions.chainWebpack) {
this.webpackChainFns.push(this.projectOptions.chainWebpack)
}
if (this.projectOptions.configureWebpack) {
this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
}
4.3 serve内置webpack配置了什么?
// configs that only matters for dev server
api.chainWebpack(webpackConfig => {
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
if (!webpackConfig.get('devtool')) {
webpackConfig
.devtool('eval-cheap-module-source-map')
}
// https://github.com/webpack/webpack/issues/6642
// https://github.com/vuejs/vue-cli/issues/3539
webpackConfig
.output
.globalObject(`(typeof self !== 'undefined' ? self : this)`)
if (!process.env.VUE_CLI_TEST && options.devServer.progress !== false) {
// the default progress plugin won't show progress due to infrastructreLogging.level
webpackConfig
.plugin('progress')
.use(require('progress-webpack-plugin'))
}
}
})
// resolve webpack config
const webpackConfig = api.resolveWebpackConfig()
// check for common config errors
validateWebpackConfig(webpackConfig, api, options)
// load user devServer options with higher priority than devServer
// in webpack config
const projectDevServerOptions = Object.assign(
webpackConfig.devServer || {},
options.devServer
)
// expose advanced stats
if (args.dashboard) {
const DashboardPlugin = require('../webpack/DashboardPlugin')
webpackConfig.plugins.push(new DashboardPlugin({
type: 'serve'
}))
}
// entry arg
const entry = args._[0]
if (entry) {
webpackConfig.entry = {
app: api.resolve(entry)
}
}
serve命令webpack配置了:
- 默认配置sourcemap:
eval-cheap-module-source-map
- 配置output的globalObject为self/this,一般用途是作为library输出,尤其是
umd
标准,这个全局对象在node.js/浏览器上需要指定为this
,类似web的目标则需要指定为self
。所以可以通过这样去赋值:(typeof self !== 'undefined' ? self : this)
- 使用插件
progress-webpack-plugin
显示进度 - 如果参数带了
dashboard
,则加载插件DashboardPlugin
- 配置entry
剩下的就是配置 webpackDevServer
,就不展开了。同理,我们可以看看 build
命令内置了哪些webpack配置
4.4 build内置webpack配置了什么?
// packages\@vue\cli-service\lib\commands\build\index.js
const defaults = {
clean: true,
target: 'app',
module: true,
formats: 'commonjs,umd,umd-min'
}
const buildModes = {
lib: 'library',
wc: 'web component',
'wc-async': 'web component (async)'
}
const modifyConfig = (config, fn) => {
if (Array.isArray(config)) {
config.forEach(c => fn(c))
} else {
fn(config)
}
}
module.exports = (api, options) => {
api.registerCommand('build', {
description: 'build for production',
usage: 'vue-cli-service build [options] [entry|pattern]',
options: {
'--mode': `specify env mode (default: production)`,
'--dest': `specify output directory (default: ${options.outputDir})`,
'--no-module': `build app without generating