vue-cli3.0源码阅读笔记

790 阅读6分钟

参考Vue-cli@3.0 插件系统简析

@vue/cli

  • 起始文件bin/vue.js,定义了vue create vue ui等命令,会调用相应的模块来处理命令

@vue/cli-service

定义了serve build inspect help 四个命令功能

vue-cli-service.js

bin/vue-cli-service.js 起始文件,创建了一个service单例,并传入命令名和参数去执行。

Service.js

核心的类 Service,作为@vue/cli运行时的服务,看执行命令时首先完成实例化的构造函数:

constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
    process.VUE_CLI_SERVICE = this
    this.initialized = false
    this.context = context
    this.inlineOptions = inlineOptions
    this.webpackChainFns = []
    this.webpackRawConfigFns = []
    this.devServerConfigFns = []
    // 缓存动态注册 CLI 命令
    this.commands = {}
    // package.json所有的目录
    this.pkgContext = context
    // package.json中的内容
    this.pkg = this.resolvePkg(pkg)
    // 命令行中指定了plugins时,使用内置插件和指定的插件(useBuiltIn=false 时禁用内置插件,一般测试时设置)
    // 未指定plugins时,使用内置插件和package.json中添加的插件
    // { id: string, apply: PluginModule }[]
    this.plugins = this.resolvePlugins(plugins, useBuiltIn)
    // 会在run()过程中被填充
    this.pluginsToSkip = new Set()
    // 从插件的module.exports.defaultModes字段获取默认Mode
    // {[command]: mode} mode: test,development,product
    this.modes = this.plugins.reduce((modes, { apply: { defaultModes }}) => {
      return Object.assign(modes, defaultModes)
    }, {})
  }

resolvePlugins方法中,解析并加载插件,然后存储到plugins中。主要包括两部分

  • 内置插件:
// 这一类插件在内部动态注册新的 CLI 命令,可通过 npm script 的形式去启动对应的 CLI 命令服务。
'./commands/serve',
'./commands/build',
'./commands/inspect',
'./commands/help',
//这一类插件主要是处理 webpack 本地编译构建时的各种相关的配置。
// config plugins are order sensitive
'./config/base',
'./config/css',
'./config/dev',
'./config/prod',
'./config/app',
  • package.json中dependenciesdevDependencies两个字段中添加的插件

接着调用实例上的run方法来启动命令对应的服务

/**
 * @param name 命令名
 * @param args 解析过后的参数对象
 * @param rawArgv 命令行参数
*/
async run (name, args = {}, rawArgv = []) {
    // 解析mode,优先级(命令参数、命令插件默认值、watch时回退到development)
    const mode = args.mode || (name === 'build' && args.watch ? 'development' : this.modes[name])

    // 如果有--skip-plugins 参数,设置init()时需要跳过的插件
    this.setPluginsToSkip(args)

    // 加载环境变量,加载用户配置(vue.config.js),执行插件(动态注册CLI命令服务、添加chianWebpack配置等)
    this.init(mode)

    ... // args中获取命令名,并找到对应的插件
    const { fn } = command
    return fn(args, rawArgv) // 开始执行对应的命令服务
}

执行命令服务之前,有一个init()的初始化操作。从这里可以看出来,插件模块会暴露出一个函数,接收2个参数:

  • api PluginAPI的实例
  • projectOptions 配置选项
init (mode = process.env.VUE_CLI_MODE) {
    ... // 加载环境变量配置等
    
    // 加载vue.config.json中的配置
    const userOptions = this.loadUserOptions()
    this.projectOptions = defaultsDeep(userOptions, defaults())

    // 执行plugin
    this.plugins.forEach(({ id, apply }) => {
        if (this.pluginsToSkip.has(id)) return
        // 传入一个api实例,在插件内部完成解析添加配置、注册命令功能。
        // 最终这些配置和命令都会通过pluginAPI存储在service中。
        apply(new PluginAPI(id, this), this.projectOptions)
    })

    // 执行插件中设置
    if (this.projectOptions.chainWebpack) {
        this.webpackChainFns.push(this.projectOptions.chainWebpack)
    }
    if (this.projectOptions.configureWebpack) {
        this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
    }
}

PluginAPI

PluginAPI是插件和service的连接器。

/**
* @param {string} id - Id of the plugin.
* @param {Service} service - A vue-cli-service instance.
*/
constructor (id, service) {
    this.id = id
    this.service = service
}

/**
 * 向service中注册一个命令服务,可被以这样的命令访问 `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 || {}}
}

/**
 * 注册一个链式配置webpack的函数,会在 `resolveWebpackConfig` 时被调用
 * @param {function} fn
 */
chainWebpack (fn) {
    this.service.webpackChainFns.push(fn)
}

/**
 * 注册一个webpack配置对象,或者一个修改webpack配置对象的函数(可直接修改config参数,或返回一个对象)
 * 这个webpack配置对象会被合并到最终配置
 * @param {object | function} fn
 */
configureWebpack (fn) {
    this.service.webpackRawConfigFns.push(fn)
}

/**
 * 注册一个配置dev server的函数(参数为 dev server 的 experss `app` 实例化对象)
 * @param {function} fn
 */
configureDevServer (fn) {
    this.service.devServerConfigFns.push(fn)
}

/**
 * 解析最终的webpack config,这个配置会被传递给webpack
 * @param {ChainableWebpackConfig} [chainableConfig]
 * @return {object} Raw webpack config.
 */
resolveWebpackConfig (chainableConfig) {
  return this.service.resolveWebpackConfig(chainableConfig)
}

/**
 * 解析出一个还可以进行修改的 webpack 链式配置对象(还未变成raw webpack config)
 * 你可以多次调用这个方法生成不同版本的配置
 * See https://github.com/mozilla-neutrino/webpack-chain
 *
 * @return {ChainableWebpackConfig}
 */
resolveChainableWebpackConfig () {
    return this.service.resolveChainableWebpackConfig()
}

接下来看下几个 @vue/cli-service 命令插件的源码

serve 命令

// lib/commands/serve.js
module.exports = (api, options) => {
    api.registerCommand... // 具体看下面
}


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`,
            '--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...')

        // although this is primarily a dev server, it is possible that we
        // are running it in a mode with a production env, e.g. in E2E tests.
        const isInContainer = checkInContainer()
    

        // 解析得到 webpack config
        const webpackConfig = api.resolveWebpackConfig()

        // 检查常规配置错误(publicPath、outputDir)
        validateWebpackConfig(webpackConfig, api, options)

        ... // 加载 devServer 配置
        ... // 添加 webpack Dashboard插件
        ... // 解析 entry 参数
        ... // 解析 devServer 相关参数、proxy 设置等
        ... // 注入 dev hot-reload 中间件

        // 创建一个编译器
        const compiler = webpack(webpackConfig)

        // 创建一个dev server
        const server = new WebpackDevServer(compiler, Object.assign({
            logLevel: 'silent',
            clientLogLevel: 'silent',
            historyApiFallback: {
                disableDotRule: true,
                rewrites: genHistoryApiFallbackRewrites(options.publicPath, options.pages)
            },
            contentBase: api.resolve('public'),
            watchContentBase: !isProduction,
            hot: !isProduction,
            compress: isProduction,
            publicPath: options.publicPath,
            overlay: isProduction // TODO disable this
                ? false
                : { warnings: false, errors: true }
        }, projectDevServerOptions, {
            https: useHttps,
            proxy: proxySettings,
            before (app, server) {
                // 开启编辑器支持,通过 vue-devtools 和 @vue/cli-overlay
                app.use('/__open-in-editor', launchEditorMiddleware(() => console.log(
                  `To specify an editor, specify the EDITOR env variable or ` +
                  `add "editor" field to your Vue project config.\n`
                )))
                // 允许其他插件注册中间件。例如:PWA
                api.service.devServerConfigFns.forEach(fn => fn(app, server))
                // 执行项目中的中间件
                projectDevServerOptions.before && projectDevServerOptions.before(app, server)
            },
            // 不打开浏览器
            open: false
        }))

        ... // 错误退出处理
        
        return new Promise((resolve, reject) => {
            // 记录指令,第一次编译完成打开浏览器
            let isFirstCompile = true
            compiler.hooks.done.tap('vue-cli-service serve', stats => {
                ... // 第一次编译完成拷贝访问url
        
                ... // 日志打印
        
            if (isFirstCompile) {
                isFirstCompile = false
        
                ...
        
                // Send final app URL
                if (args.dashboard) {
                    const ipc = new IpcMessenger()
                    ipc.send({
                        vueServe: {
                            url: localUrlForBrowser
                        }
                    })
                }
        
                // 标记Promise完成,其他命令就可以这样调用 pi.service.run('serve').then(...)
                resolve({
                  server,
                  url: localUrlForBrowser
                })
            } else if (process.env.VUE_CLI_TEST) {
                // signal for test to check HMR
                console.log('App updated')
            }
          })
        
        server.listen(port, host, err => {
            if (err) {
                reject(err)
            }
        })
    })
})

build 命令

术语解释

  • legacy 兼容版本,浏览器不支持ES2015+,会增加polyfills
  • modern 现代版本,浏览器支持ES2015+语法
// lib/commands/build/index.js
module.exports = (api, options) => {
    api.registerCommand... // 具体看下面
}

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})`,
      '--modern': `build app targeting modern browsers with auto fallback`,
      '--no-unsafe-inline': `build app without introducing inline scripts`,
      '--target': `app | lib | wc | wc-async (default: ${defaults.target})`,
      '--inline-vue': 'include the Vue module in the final bundle of library or web component target',
      '--formats': `list of output formats for library builds (default: ${defaults.formats})`,
      '--name': `name for lib or web-component mode (default: "name" in package.json or entry filename)`,
      '--filename': `file name for output, only usable for 'lib' target (default: value of --name)`,
      '--no-clean': `do not remove the dist directory before building the project`,
      '--report': `generate report.html to help analyze bundle content`,
      '--report-json': 'generate report.json to help analyze bundle content',
      '--skip-plugins': `comma-separated list of plugin names to skip for this run`,
      '--watch': `watch for changes`
    }
}, async (args, rawArgs) => {
    for (const key in defaults) {
        if (args[key] == null) {
            args[key] = defaults[key]
        }
    }
    args.entry = args.entry || args._[0]
    if (args.target !== 'app') {
        args.entry = args.entry || 'src/App.vue'
    }

    process.env.VUE_CLI_BUILD_TARGET = args.target
    if (args.modern && args.target === 'app') {
        process.env.VUE_CLI_MODERN_MODE = true
        if (!process.env.VUE_CLI_MODERN_BUILD) {
            // 主进程进行兼容版本构建
            await build(Object.assign({}, args, {
                modernBuild: false,
                keepAlive: true
            }), api, options)
            // 发起一个子进程进行现代版本构建
            const { execa } = require('@vue/cli-shared-utils')
            const cliBin = require('path').resolve(__dirname, '../../../bin/vue-cli-service.js')
            await execa(cliBin, ['build', ...rawArgs], {
                stdio: 'inherit',
                env: {
                    VUE_CLI_MODERN_BUILD: true
                }
            })
        } else {
          // 主进程进行现代版本构建
          await build(Object.assign({}, args, {
              modernBuild: true,
              clean: false
          }), api, options)
        }
        delete process.env.VUE_CLI_MODERN_MODE
    } else {
        if (args.modern) {
            const { warn } = require('@vue/cli-shared-utils')
            warn(
                `Modern mode only works with default target (app). ` +
                `For libraries or web components, use the browserslist ` +
                `config to specify target browsers.`
            )
        }
        await build(args, api, options)
    }
    delete process.env.VUE_CLI_BUILD_TARGET
})

下面是具体的构建方法

async function build (args, api, options) {
    ...

    // 解析 raw webpack config,确定是打包成lib,WebComponent还是app
    let webpackConfig
    if (args.target === 'lib') {
        webpackConfig = require('./resolveLibConfig')(api, args, options)
    } else if (
        args.target === 'wc' ||
        args.target === 'wc-async'
    ) {
        webpackConfig = require('./resolveWcConfig')(api, args, options)
    } else {
        webpackConfig = require('./resolveAppConfig')(api, args, options)
    }

    // 检查常规配置错误(publicPath、outputDir)
    validateWebpackConfig(webpackConfig, api, options, args.target)
    ... // watch参数处理
    ... // dashboard参数插件处理
    ... // 分析参数插件处理
    ... // 删除发布目录处理

    // 开始打包
    return new Promise((resolve, reject) => {
        webpack(webpackConfig, (err, stats) => {
            stopSpinner(false)
            if (err) {
                return reject(err)
            }

          if (stats.hasErrors()) {
              return reject(`Build failed with errors.`)
          }

          if (!args.silent) {
              const targetDirShort = path.relative(
                  api.service.context,
                  targetDir
              )
              log(formatStats(stats, targetDirShort, api))
              if (args.target === 'app' && !isLegacyBuild) {
                  if (!args.watch) {
                      done(`Build complete. The ${chalk.cyan(targetDirShort)} directory is ready to be deployed.`)
                      info(`Check out deployment instructions at ${chalk.cyan(`https://cli.vuejs.org/guide/deployment.html`)}\n`)
                  } else {
                      done(`Build complete. Watching for changes...`)
                  }
              }
          }

          // test-only signal
          if (process.env.VUE_CLI_TEST) {
              console.log('Build complete.')
          }

          resolve()
        })
    })
}

eslint 插件

// @view/cli-plugin-eslint/index.js

module.exports = (api, options) => {
    if (options.lintOnSave) {
        ...
        api.chainWebpack...
    }
    api.registerCommand...
}


api.chainWebpack(webpackConfig => {
    webpackConfig.resolveLoader.modules.prepend(
        path.join(__dirname, 'node_modules')
    )

    const { lintOnSave } = options
    const allWarnings = lintOnSave === true || lintOnSave === 'warning'
    const allErrors = lintOnSave === 'error'

    webpackConfig.module
        .rule('eslint')
            .pre()
            .exclude
                .add(/node_modules/)
                .add(require('path').dirname(require.resolve('@vue/cli-service')))  // eslint-disable-line
                .end()
            .test(/\.(vue|(j|t)sx?)$/)
            .use('eslint-loader')
                .loader('eslint-loader')
                .options({
                    extensions,
                    cache: true,
                    cacheIdentifier,
                    emitWarning: allWarnings,
                    // production mode 才发送错误信息
                    emitError: allErrors,
                    eslintPath: path.dirname(
                        resolveModule('eslint/package.json', cwd) ||
                        require.resolve('eslint/package.json')
                    ),
                    formatter:
                        loadModule('eslint/lib/formatters/codeframe', cwd, true) ||
                        require('eslint/lib/formatters/codeframe')
                })
})

api.registerCommand(
    'lint',
    {
        description: 'lint and fix source files',
        usage: 'vue-cli-service lint [options] [...files]',
        options: {
            '--format [formatter]': 'specify formatter (default: codeframe)',
            '--no-fix': 'do not fix errors or warnings',
            '--no-fix-warnings': 'fix errors, but do not fix warnings',
            '--max-errors [limit]': 'specify number of errors to make build failed (default: 0)',
            '--max-warnings [limit]': 'specify number of warnings to make build failed (default: Infinity)'
        },
        details: 'For more options, see https://eslint.org/docs/user-guide/command-line-interface#options'
    },
    args => {
      require('./lint')(args, api)
    }
)
// lint.js

module.exports = function lint (args = {}, api) {
    // 解析获取config

    // eslint 命令行
    const engine = new CLIEngine(config)

    // 获取需要进行lint操作的文件
    
    // 执行 lint 操作
    const report = engine.executeOnFiles(files)
    process.cwd = processCwd

    const formatter = engine.getFormatter(args.format || 'codeframe')

    if (config.fix) {
        CLIEngine.outputFixes(report)
    }

    // 错误处理
}

综上,我们可以看到,一个 @vue/cli-plugin 插件通过暴露一个函数进行插件的初始化,接收api实例和options配置作为参数,在函数中可进行options的修改,注册命令服务。

命令服务也是一个函数,这个函数接收命令行的参数作为参数。在这个函数中进行webpack配置修改,启动webpack编译打包,启动开发服务器等一系列操作。

相关库