【源码分析】分析一个vue项目启动的执行原理

2,259 阅读4分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

项目入口

每个使用vue-cli初始化的项目都有一个src/main.js,这个文件里引用了vue并且将其实例化。那我们运行项目,vue是怎么找到main.js这个文件并进行执行的呢,让我们一起扒开vue-cli的源码看一看其内部是如何实现的。

import '@babel/polyfill'
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App)
}).$mount('#app')

启动命令

首先看一下vue-cli为我们提供的npm脚本

"scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
}

每当我们执行npm run serve时,控制台都会输出一系列编译信息,随后又都消失变成下图

image.png

可执行命令解析

npm run serve对应的实际是vue-cli-service serve的执行,我们可以在node_modules/.bin目录中看到vue-cli-service这个可执行文件,点开它看内容好像是一个普通的js脚本

#!/usr/bin/env node

const semver = require('semver')
const { error } = require('@vue/cli-shared-utils')
const requiredVersion = require('../package.json').engines.node

if (!semver.satisfies(process.version, requiredVersion)) {
  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
    '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)
})

但这个文件内引用的其他文件是从何而来呢?其实这个可执行文件只是另一个实际文件的软链接,我们通过如下命令可以查看其真实的文件的在哪里

ll node_modules/.bin/vue-cli-service

输出如下

node_modules/.bin/vue-cli-service -> ../@vue/cli-service/bin/vue-cli-service.js

可见,当我们执行npm run serve时,其实就是使用当前环境下的nodejs执行node_modules目录下的@vue/cli-service/bin/vue-cli-service.js文件。那么问题就转变为nodejs程序执行的问题了,我们可以沿着这个突破口去看vue-cli内部究竟是怎样的原理。

cli-service整体分析

vue-cli-service.js这个文件中,首先引入了lib目录下的Service并对其进行实例化,生成一个服务示例service

然后又通过process.argv读取nodejs执行时传入的变量,即我们在执行vue-cli-service时后面跟的serve

接着,使用执行vue-cli-service时传入的命令,即serve执行了service实例上的run方法。

至此,我们可以将vue-cli-service的执行简单概括为以下步骤

  1. 服务实例化——new Service()
  2. 解析执行命令——serve
  3. 运行服务——service.run('serve')

深入cli-service实现

接着,我们再来看一下Service.js这个文件内部又做了哪些事情

服务实例化

可以看到,Service.js对外暴露了一个类,前面执行的new Service()就是在执行这个类的构造方法,而构造方法本身的内容也不多,就是初始化一些服务的基本信息,这些基本信息会挂在初始化后的实例service上。

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 = []
    this.commands = {}
    // Folder containing the target package.json for plugins
    this.pkgContext = context
    // package.json containing the plugins
    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.
    this.modes = this.plugins.reduce((modes, { apply: { defaultModes }}) => {
      return Object.assign(modes, defaultModes)
    }, {})
  }

但构造方法里有一个地方值得注意,就是this.resolvePlugins(plugins, useBuiltIn)这个方法的执行,我们可以根据这个方法的名字了解到,这里是在解析插件。那么插件是个什么概念呢,我们看一下方法的具体实现。

resolvePlugins (inlinePlugins, useBuiltIn) {
    const idToPlugin = id => ({
      id: id.replace(/^.\//, 'built-in:'),
      apply: require(id)
    })

    let plugins

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

    if (inlinePlugins) {
      plugins = useBuiltIn !== false
        ? builtInPlugins.concat(inlinePlugins)
        : inlinePlugins
    } else {
      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 = () => {}
            try {
              apply = require(id)
            } catch (e) {
              warn(`Optional dependency ${id} is not installed.`)
            }

            return { id, apply }
          } else {
            return idToPlugin(id)
          }
        })
      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)
      })))
    }

    return plugins
  }

这里将插件分为了内置插件行内插件用户插件三种,其中我们普通的执行vue-cli-service serve未声明任何行内插件,所以这里的逻辑会走入else

而在else里面,其实解析了项目package.json中的所有包依赖,并收集项目依赖中有哪些包是用户插件,而解析规则则是通过使用/^(@vue\/|vue-|@[\w-]+(\.)?[\w-]+\/vue-)cli-plugin-/这个正则匹配包名。

服务执行

接着,在服务初始化完成后,我们来到服务的执行,即service.run(),我们可以看到执行方法本身内容也不多

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
    this.init(mode)

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

但这里面有两个关键的操作,即this.init(mode)fn(args, rawArgv)

服务执行初始化

其中,init方法会带着解析出的development作为唯一的参数进行执行

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()
    this.projectOptions = defaultsDeep(userOptions, 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)
    }
}

也正是在这一步,为整个vue项目的执行设置了众多默认配置。我们知道,通常对于vue的一些定制化配置都是通过查看vue-cli的文档,然后在vue.config.js中进行配置的,包括webpack、css预处理器、发布目录等。

那么vue本身所提供的默认配置是什么样的呢,我们查看defaults()这个方法,可以看到这是vue-cli内置的完整默认配置项

exports.defaults = () => ({
  // project deployment base
  publicPath: '/',
  // for compatibility concern. TODO: remove in v4.
  baseUrl: '/',

  // where to output built files
  outputDir: 'dist',

  // where to put static assets (js/css/img/font/...)
  assetsDir: '',

  // filename for index.html (relative to outputDir)
  indexPath: 'index.html',

  // whether filename will contain hash part
  filenameHashing: true,

  // boolean, use full build?
  runtimeCompiler: false,

  // deps to transpile
  transpileDependencies: [
    /* string or regex */
  ],

  // sourceMap for production build?
  productionSourceMap: !process.env.VUE_CLI_TEST,

  // use thread-loader for babel & TS in production build
  // enabled by default if the machine has more than 1 cores
  parallel: hasMultipleCores(),

  // multi-page config
  pages: undefined,

  // <script type="module" crossorigin="use-credentials">
  // #1656, #1867, #2025
  crossorigin: undefined,

  // subresource integrity
  integrity: false,

  css: {
    // extract: true,
    // modules: false,
    // localIdentName: '[name]_[local]_[hash:base64:5]',
    // sourceMap: false,
    // loaderOptions: {}
  },

  // whether to use eslint-loader
  lintOnSave: true,

  devServer: {
    /*
    open: process.platform === 'darwin',
    host: '0.0.0.0',
    port: 8080,
    https: false,
    hotOnly: false,
    proxy: null, // string | Object
    before: app => {}
  */
  }
})

接着,在init方法里,我们会对在上一步初始化好的所有插件逐一进行应用。

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

这里是一个重点,就是搞清楚所谓的"插件"本质究竟是什么。我们可以返回刚才解析插件的的部分看

const idToPlugin = id => ({
  id: id.replace(/^.\//, 'built-in:'),
  apply: require(id)
})

let plugins

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

...

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 = () => {}
        try {
          apply = require(id)
        } catch (e) {
          warn(`Optional dependency ${id} is not installed.`)
        }

        return { id, apply }
      } else {
        return idToPlugin(id)
      }
    })

可以发现,对于内置插件,其实就是lib目录下的一些js文件,而对于用户插件,其实就是用户开发的npm包。并且在插件的解析环节,vue-cli-service为每个插件都声明了一个apply方法,这个方法的本质就是将js文件或npm包进行引入,即require()

那我们找到执行命令serve所对应的“插件”文件——serve.js,可以发现它对外暴露出一个大方法。形如

module.exports = (api, options) => {
    api.registerCommand('serve', {}, async function serve (args) {})
}

其实主要是方法的“大”干扰了我们对它的理解,如果我们把它里面的内容都去掉,其实插件本身暴露的方法内部就只是执行了api.registerCommand这一件事,而registerCommand方法有3个参数,分别是一个和当前文件名同名的字符串,作为命令的标识,一个用于描述该命令的对象和一个方法用于处理这个命令对应的真正任务。

那么了解了插件本质以后,我们再回到外面看看执行插件方法时所传入的参数是怎样的

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

可以看到,第一个参数是对于另一个类PluginAPI进行实例化所得到的插件实例对象,而第二个参数则是前面所得到的项目配置对象(由用户在vue.config.js中进行的自定义配置和默认配置合并所得)

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
}

就只是为当前实例初始化了一个id属性作为插件的唯一标识,和一个service属性指向当前的服务实例,但每个由类的构造方法初始化的实例对象都拥有类中所声明的方法。

比如在serve.js中调用的registerCommand就是在PluginAPI类中所定义的

registerCommand (name, opts, fn) {
    if (typeof opts === 'function') {
      fn = opts
      opts = null
    }
    this.service.commands[name] = { fn, opts: opts || {}}
}

截止目前,我们理一下插件的整体逻辑。

首先,每个插件都是普通的js文件或npm包,其会对外暴露一个方法,这个方法可以被外部执行,而执行时传入的两个参数apioptions所对应的分别则是PluginAPIthis.projectOptions,即“插件实例对象”和“项目全局配置”

那么我们接着回到服务实例对init方法的执行上

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

这里对于每个插件进行apply方法的执行,则是会运行每个插件对外暴露的方法。以serve插件为例,它内部就是执行了api.registerCommand这一件事情,所以按照其实现逻辑会在当前的服务实例上注册插件内部声明的serve方法

registerCommand (name, opts, fn) {
    if (typeof opts === 'function') {
      fn = opts
      opts = null
    }
    this.service.commands[name] = { fn, opts: opts || {}}
}

以此类推,每个插件都会在这一步得以应用,即执行插件所暴露出的方法。而一般插件的设计不会直接在插件对外暴露的方法中执行具体逻辑,而是使用插件API所实现的一系列方法,将插件所要做的事在此注册到vue-cli服务实例上,并在后续合适的时间点再进行执行。

服务命令运行

那么终于来到命令执行的环节,我们可以看到命令最终执行的run方法,其实就是从服务实例中解析出前面注册过的具体实现,然后进行执行。

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
  this.init(mode)

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

对于serve命令的执行其实做了很多事情,我们这里大致列举一下

info('Starting development server...')
...
const webpackConfig = api.resolveWebpackConfig()
...
const entry = args._[0]
if (entry) {
  webpackConfig.entry = {
    app: api.resolve(entry)
  }
}
...
const port = await portfinder.getPortPromise()
...
const compiler = webpack(webpackConfig)
...

比如,在控制台打印一闪而过的Starting development server...、解析webpack配置、计算可用端口(利用portfinder这个工具)、创建webpack编译实例等等,最后会返回一个大的promise对象,所以我们可以看到service的run方法后面进行的错误捕获,当监听到node服务错误后终止该进程。

service.run(command, args, rawArgv).catch(err => {
  error(err)
  process.exit(1)
})

项目入口定义

那么分析完整个vue-cli执行vue程序的启动过程原理,回到我们一开始抛出的疑问——“vue-cli是怎么找到main.js作为项目入口的呢?”

其实是在由于,在vue-cli的内置插件中,有一个插件完成了此项工作,我们可以回看一下内置插件的解析

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

如果你调试进每个插件的应用过程,会发现base.js中其实声明了vue-cli内部使用webpack的基本配置,作用类似于直接使用webpack时的webpack.base.conf.js

点进base.js你就会看到熟悉的用法

webpackConfig
  .mode('development')
  .context(api.service.context)
  .entry('app')
    .add('./src/main.js')
    .end()
  .output
    .path(api.resolve(options.outputDir))
    .filename(isLegacyBundle ? '[name]-legacy.js' : '[name].js')
    .publicPath(options.publicPath)

这也正是我们在vue.config.js中对webpack进行自定义配置时所使用的方式。

总结

至此,关于vue-cli是如何从npm run servevue-cli-service serve,最终将一个通过vue-cli创建的项目运行起来的过程终于被揭开神秘的面纱,希望大家能够从这个简单的源码分析中掌握到一些有用的信息。