Vue.js原始碼解析-從scripts指令碼看vue構建

elmluo發表於2021-05-18

1. scripts 指令碼構建

  • vue 專案的 package.json 檔案中可以看到相關的 npm 執行命令列。
  • 其中 dev 開頭的表示開發環境下相關的執行構建指令碼,build 開頭的表示生產環境下的構建指令碼。
  • 只需要根據這些執行的 npm run ... 命令,找到對應的入口檔案即可。
  • 這裡開發環境用 npm run dev ,生產環境用 npm run build。
"scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev --sourcemap",
    "dev:cjs": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-cjs-dev",
    "dev:esm": "rollup -w -c scripts/config.js --environment TARGET:web-runtime-esm",
    "dev:test": "karma start test/unit/karma.dev.config.js",
    "dev:ssr": "rollup -w -c scripts/config.js --environment TARGET:web-server-renderer",
    "dev:compiler": "rollup -w -c scripts/config.js --environment TARGET:web-compiler ",
    "dev:weex": "rollup -w -c scripts/config.js --environment TARGET:weex-framework",
    "dev:weex:factory": "rollup -w -c scripts/config.js --environment TARGET:weex-factory",
    "dev:weex:compiler": "rollup -w -c scripts/config.js --environment TARGET:weex-compiler ",
    "build": "node scripts/build.js",
    "build:ssr": "npm run build -- web-runtime-cjs,web-server-renderer",
    "build:weex": "npm run build -- weex",
    "test": "npm run lint && flow check && npm run test:types && npm run test:cover && npm run test:e2e -- --env phantomjs && npm run test:ssr && npm run test:weex",
    "test:unit": "karma start test/unit/karma.unit.config.js",
    "test:cover": "karma start test/unit/karma.cover.config.js",
    "test:e2e": "npm run build -- web-full-prod,web-server-basic-renderer && node test/e2e/runner.js",
    "test:weex": "npm run build:weex && jasmine JASMINE_CONFIG_PATH=test/weex/jasmine.js",
    "test:ssr": "npm run build:ssr && jasmine JASMINE_CONFIG_PATH=test/ssr/jasmine.js",
    "test:sauce": "npm run sauce -- 0 && npm run sauce -- 1 && npm run sauce -- 2",
    "test:types": "tsc -p ./types/test/tsconfig.json",
    "lint": "eslint src scripts test",
    "flow": "flow check",
    "sauce": "karma start test/unit/karma.sauce.config.js",
    "bench:ssr": "npm run build:ssr && node benchmarks/ssr/renderToString.js && node benchmarks/ssr/renderToStream.js",
    "release": "bash scripts/release.sh",
    "release:weex": "bash scripts/release-weex.sh",
    "release:note": "node scripts/gen-release-note.js",
    "commit": "git-cz"
  },

web 端構建流程簡圖如下

1.1 dev 開發環境構建過程

1.1.1 配置檔案程式碼

執行 npm run dev 的時候,執行的配置檔案為 scripts/config.js,引數為 TARGET:web-full-dev。

"scripts": {
    ...
    "dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev --sourcemap",
    ...
},

開啟 scripts/config.js 配置檔案,查詢 web-full-dev,根據原始碼註釋,說明了 web-full-dev 是對 執行 + 編譯 的開發環境的構建。

entry 和 dist 中,有 resolve 函式,目的就是將 alias.js 的目錄別名和 resolve 函式中傳入的引數路徑進行一個拼接,獲取檔案在系統中的完整路徑,程式碼註釋說明如下。

// 匯入目錄別名
const aliases = require('./alias')

// 自定義 resolve 函式,結果返回對應檔案的完整路徑。
const resolve = p => {
    
  // 如果 執行 npm run dev,base 在這裡得到的就是 "web" 
  const base = p.split('/')[0]
  
  if (aliases[base]) {
    return path.resolve(aliases[base], p.slice(base.length + 1))
  } else {
    return path.resolve(__dirname, '../', p)
  }
}

1.1.2 如何進行程式碼除錯?

vscode 對 node.js 除錯支援的已經比較好。執行 npm run dev 的時候,對應的 config.js 指令碼會被解釋執行。可以通過點選 vscode 左側編輯器的 NPM SCRIPTS 選項進行 debug 除錯。

如在 entry 入口處打上斷點,執行除錯,這裡的除錯工具和 chrome 除錯工具類似。

斷點 step into 進入 resolve 方法,繼續進行後續步驟除錯。

1.2 build 生產環境構建過程

1.2.1 scripts/build.js 配置檔案解析

根據 package.json 找到 build 入口檔案 scripts/build.js,執行 npm run build, build.js 檔案被解釋執行,build.js 的程式碼邏輯也比較簡單,下面一起來看一下。

"scripts": {
    ...
    "build": "node scripts/build.js",
    ...
},
  • 如果 dist 目錄不存在,就建立 dist 目錄。
  • 獲取 config.js 配置檔案內容 builds。
  • 再通過 node 命令列引數,對配置內容容進行篩選,結果重新賦給了 builds。
  • 執行 build(builds) ,通過傳入的配置項,對專案進行構建。
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const rollup = require('rollup')
const terser = require('terser')

// 1. 如果 dist 目錄不存在,就建立
if (!fs.existsSync('dist')) {
  fs.mkdirSync('dist')
}

// 2. 獲取 config.js 中的配置物件
let builds = require('./config').getAllBuilds()

// 3. 通過 node 命令列 arg 引數,過濾出對應 arg 的配置物件
// filter builds via command line arg
if (process.argv[2]) {
  const filters = process.argv[2].split(',')
  builds = builds.filter(b => {
    return filters.some(f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1)
  })
} else {
  // filter out weex builds by default
  builds = builds.filter(b => {
    return b.output.file.indexOf('weex') === -1
  })
}

// 4. 根據篩選後的配置物件,進行 build 構建。
build(builds)

function build (builds) {
  let built = 0
  const total = builds.length
  const next = () => {
    buildEntry(builds[built]).then(() => {
      built++
      if (built < total) {
        next()
      }
    }).catch(logError)
  }

  next()
}

這樣就實現了通過不同的命令列引數傳參打包不同生產版本的 vue。

1.2.1 build.js配置檔案斷點除錯實踐

step1: 在 build.js 主要位置打上斷點。

step2: require('./config') 引入配置檔案,解釋執行,獲取所有配置內容。

step3: 獲取命令列引數,如果沒有傳,預設將所有版本都打包(web 端會去掉 weex 相關內容),下面的截圖中可以看到執行的配置項已經去掉了 weex 相關。

step4: 可以看到 npm run build 出來的 dist 內容。

2. 瀏覽器 runtime 版本和 runtime-compiler 版本

vue構建程式碼中,可以通過不同的配置項,來生成是否需要 compiler 的 vue 。兩者的區別主要在於,如果不使用 vue-loader,前者只能通過寫 render 函式,實現模板渲染。後者 template 和 render 函式可以。

2.1 runtime 版本

2.1.1 runtime 版本入口

構建的 runtime 版本,裡面不帶編譯器,所以 runtime 打包之後的程式碼體積更小。
通過 config.js 配置物件的舉例,原始碼對不同的構建也進行了註釋說明。

scripts: {
  ...
  // runtime-only build (Browser) 構建開發環境的web端runtime版本
  'web-runtime-dev': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.js'),
    format: 'umd',
    env: 'development',
    banner
  },
  // runtime-only production build (Browser) 構建生產環境的web端runtime版本
  'web-runtime-prod': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.min.js'),
    format: 'umd',
    env: 'production',
    banner
  },
  ...
}

2.1.2 分析 entry-runtime.js

這個檔案內容就兩行,主要返回 runtime 版的 Vue 建構函式。兩行程式碼單獨成檔案,應該是為了讓專案目錄,在功能結構上更加清晰。

/* @flow */

import Vue from './runtime/index'

export default Vue

2.1.3 分析 runtime/index.js

  • 在 src/platforms/web/runtime/index.js 中,主要對 Vue 建構函式進行了一些處理。
  • 安裝 vue 內部定義的指令、元件,安裝一些平臺相關的特殊工具方法,定義 $mount 掛載方法等
/* @flow */

import Vue from 'core/index'
import config from 'core/config'
import { extend, noop } from 'shared/util'
import { mountComponent } from 'core/instance/lifecycle'
import { devtools, inBrowser } from 'core/util/index'

import {
  query,
  mustUseProp,
  isReservedTag,
  isReservedAttr,
  getTagNamespace,
  isUnknownElement
} from 'web/util/index'

import { patch } from './patch'
import platformDirectives from './directives/index'
import platformComponents from './components/index'

// install platform specific utils
Vue.config.mustUseProp = mustUseProp
Vue.config.isReservedTag = isReservedTag
Vue.config.isReservedAttr = isReservedAttr
Vue.config.getTagNamespace = getTagNamespace
Vue.config.isUnknownElement = isUnknownElement

// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)

// install platform patch function
// 虛擬dom轉換為真實dom
Vue.prototype.__patch__ = inBrowser ? patch : noop

// public mount method
// 在這裡實現公共 $mount 方法 (entry-runtime-with-compiler.js 帶編譯器版本中也用到這個方法)
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  // 初始化執行將渲染的結果替換到el上。
  return mountComponent(this, el, hydrating)
}

// devtools global hook
/* istanbul ignore next */
if (inBrowser) {
  setTimeout(() => {
    if (config.devtools) {
      if (devtools) {
        devtools.emit('init', Vue)
      } else if (
        process.env.NODE_ENV !== 'production' &&
        process.env.NODE_ENV !== 'test'
      ) {
        console[console.info ? 'info' : 'log'](
          'Download the Vue Devtools extension for a better development experience:\n' +
          'https://github.com/vuejs/vue-devtools'
        )
      }
    }
    if (process.env.NODE_ENV !== 'production' &&
      process.env.NODE_ENV !== 'test' &&
      config.productionTip !== false &&
      typeof console !== 'undefined'
    ) {
      console[console.info ? 'info' : 'log'](
        `You are running Vue in development mode.\n` +
        `Make sure to turn on production mode when deploying for production.\n` +
        `See more tips at https://vuejs.org/guide/deployment.html`
      )
    }
  }, 0)
}

export default Vue

引用關係如下截圖。

2.2 runtime-compiler 版本

2.2.1 runtime-compiler 版本入口

這個在前面的 npm run dev 執行分析中也提到過了,在閱讀除錯 vue 原始碼的時候,如果想要了解 compiler的實現邏輯,就需要用到待 runtime 加 compiler 版本的 vue。

scripts: {
  ... 
   // Runtime+compiler development build (Browser)
  'web-full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
  },
  // Runtime+compiler production build  (Browser)
  'web-full-prod': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.min.js'),
    format: 'umd',
    env: 'production',
    alias: { he: './entity-decoder' },
    banner
  },
  ...
}

2.2.2 分析 web\entry-runtime-with-compiler.js

  • 主要邏輯在 src\platforms\web\entry-runtime-with-compiler.js 檔案中
  • runtime + compiler 版本的 vue, 其實是在 runtime 版本的基礎上,加 compiler 相關的功能邏輯。
  • 它首先儲存了 runtime 版本 Vue.prototype 上的 $mount 方法。
  • 再重寫 Vue.prototype 上的 $mount 方法。
  • 如果使用者傳入了 template 模板,就通過編譯器,轉換成 render 函式。
  • 最後通過先前儲存的 runtime 版本的 $mount 方法進行掛載。
/* @flow */

import config from 'core/config'
import { warn, cached } from 'core/util/index'
import { mark, measure } from 'core/util/perf'

import Vue from './runtime/index'
import { query } from './util/index'
import { compileToFunctions } from './compiler/index'
import { shouldDecodeNewlines, shouldDecodeNewlinesForHref } from './util/compat'

const idToTemplate = cached(id => {
  const el = query(id)
  return el && el.innerHTML
})

// 1. 儲存 runtime 版本 Vue.prototype 上的 $mount 方法
const mount = Vue.prototype.$mount

// 2. 重寫 Vue.prototype 上的 $mount(加上 compiler 相關功能邏輯) 
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  // 處理 options 配置
  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }

    // 3. 存在 template 選項內容,就進行編譯。
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      // 編譯獲取 render 函式
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  
  // 4. 編譯結束,呼叫 runtime 版本的 $mount 方法進行掛載
  return mount.call(this, el, hydrating)
}

/**
 * Get outerHTML of elements, taking care
 * of SVG elements in IE as well.
 */
function getOuterHTML (el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML
  } else {
    const container = document.createElement('div')
    container.appendChild(el.cloneNode(true))
    return container.innerHTML
  }
}

Vue.compile = compileToFunctions

export default Vue

3. 專案開發中的 vue 版本

平時在專案開發當中,自己在編寫主入口檔案的時候,我們都會引入 node_modules 中的 vue。直接import 進來的 vue.js 到底帶不帶 compiler?下面讓我們就來認證一下。

3.1 import 引用了哪個版本vue?

  • 主要看 node_modules 中依賴包的package.json 檔案。
  • 可以看到 main 和 module 配置內容,都是不帶 compiler 的版本vue檔案。
  • 符合 CommonJS 規範的話,使用 main 作為引入主檔案,符合 ES 規範的話,使用 module 作為主檔案。

3.2 對 dist 檔案進行認證

  • 在上面的內容中,我們知道 es module 專案中 import Vue from 'vue',引入的是dist/vue.runtime.common.js
  • 而 dist/ue.runtime.common.js 中,如果是開發環境用 dist/vue.runtime.common.dev.js,如果是生產環境用 dist/vue.runtime.common.prod.js

在 dist/vue.runtime.common.dev.js 檔案中搜尋 mountComponent 方法,可以看到對應的warn

function mountComponent (
  vm,
  el,
  hydrating
) {
  vm.$el = el;
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode;
    {
      /* istanbul ignore if */
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
            
        // 如果 options 中使用 template 模板,會觸發 warn。
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        );
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        );
      }
    }
  }
  callHook(vm, 'beforeMount');
  ...
  ...
  return vm
}

所以在用 vue 進行專案開發的時候,使用的是不帶 compiler 的版本,為了節省專案打包之後的體積。
而在.vue 檔案中能寫的 template,實際是通過 vue-loader 外掛進行了前期編譯處理。

總結

通過對npm run dev和npm run build命令列開始分析,知道了vue構建過程,通過不同功能的配置項和命令列引數,最終編譯生成的dist目錄下的不同版本檔案,同時vscode編輯器自帶方便的除錯功能,可以從入口指令碼開始,方便的除錯功能程式碼。平時專案當中用到的vue是不帶compiler版本的vue。而經常書寫的.vue檔案,其中的template能被解析,其實是通過vue-loader進行了編譯處理。

相關文章