Skip to content

vue-loader实现

使用

webpack loader 配置地址

vue-loader 文档地址

github vue-loader

安装vue-loader以及vue-template-compiler:

javascript
npm install -D vue-loader vue-template-compiler
npm install -D vue-loader vue-template-compiler

webpack配置:

javascript
// webpack.config.js 文件
const { VueLoaderPlugin } = require('vue-loader')
const config = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ],
}
// webpack.config.js 文件
const { VueLoaderPlugin } = require('vue-loader')
const config = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ],
}

VueLoaderPlugin应用

找到vue-loader/lib/plugin-webpack5.js文件:

javascript
class VueLoaderPlugin {
  apply (compiler) {
    // ...
    // 1. 定义 picther loader
    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
        if (!query) { return false }
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      },
      options: {
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
        cacheIdentifier: vueLoaderUse.options.cacheIdentifier
      }
    }
    // 2. 替换 rules
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }
}
class VueLoaderPlugin {
  apply (compiler) {
    // ...
    // 1. 定义 picther loader
    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
        if (!query) { return false }
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      },
      options: {
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
        cacheIdentifier: vueLoaderUse.options.cacheIdentifier
      }
    }
    // 2. 替换 rules
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }
}

该插件的主要作用是添加了一个pitcherloader。该loader通过resourceQuery判断query是否存在,也就是文件请求路径是否带有查询参数。如果有查询参数,还需要解析query判断查询参数中是否有vue。因此,pitcher loader是在文件请求路径查询参数中包含vue时才会匹配。

解析 loaders

make阶段的factorizeModule过程中,会进行文件的解析(resolve)。对于以.vue结尾的文件,根据rules规则,会匹配到vue-loader,从而解析出vue-loader的实际引用路径,类似如下:

javascript
'/Users/xxx/xxx/node_modules/vue-loader/lib/index.js'
'/Users/xxx/xxx/node_modules/vue-loader/lib/index.js'

loader-runner

make阶段的buildModule过程中,webpack使用loader-runner库执行loaders。对于以.vue结尾的文件,此时只会执行vue-loader

vue-loader

找到node_modules/vue-loader/lib/index.js文件:

javascript
module.exports = function (source) {
  // 1. 解析查询参数
  const loaderContext = this
  const { resourceQuery = ''} = loaderContext
  const rawQuery = resourceQuery.slice(1)
  const incomingQuery = qs.parse(rawQuery)

  // 2. 解析源码
  const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

  // 3. 如果参数中有 type ,使用对应的 loader 执行
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

  // 4. 其他,根据不同的 type,分别生成不同的代码
}
module.exports = function (source) {
  // 1. 解析查询参数
  const loaderContext = this
  const { resourceQuery = ''} = loaderContext
  const rawQuery = resourceQuery.slice(1)
  const incomingQuery = qs.parse(rawQuery)

  // 2. 解析源码
  const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

  // 3. 如果参数中有 type ,使用对应的 loader 执行
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

  // 4. 其他,根据不同的 type,分别生成不同的代码
}

vue-loader会通过component-compiler-utils包对.vue文件代码解析,解析出来的格式为:

javascript
{
  script: {
   type: 'script'
    // ...
  },
 styles: [],
 template: {},
 customBlocks: [],
 errors: []
}
{
  script: {
   type: 'script'
    // ...
  },
 styles: [],
 template: {},
 customBlocks: [],
 errors: []
}

在首次加载时,没有查询参数,所以会进入到第四步,根据不同的type将解析出来的代码按照模块的形式引入,如:

javascript
// type 为 template
'import { render, staticRenderFns } from "./component.vue?vue&type=template&id=a8ea6a18&scoped=true&"'

// type 为 script
'import script from "./component.vue?vue&type=script&lang=js&"\n
export * from "./component.vue?vue&type=script&lang=js&"'

// type 为 style
'import style0 from "./component.vue?vue&type=style&index=0&id=a8ea6a18&lang=css&scoped=true&"\n'
// type 为 template
'import { render, staticRenderFns } from "./component.vue?vue&type=template&id=a8ea6a18&scoped=true&"'

// type 为 script
'import script from "./component.vue?vue&type=script&lang=js&"\n
export * from "./component.vue?vue&type=script&lang=js&"'

// type 为 style
'import style0 from "./component.vue?vue&type=style&index=0&id=a8ea6a18&lang=css&scoped=true&"\n'

pitch

Picthing Loader 文档

vue-loader代码转换后,下次加载这些模块的时候,由于请求路径参数带有vue,因此在VueLoaderPlugin插件中添加的pitcher loader也会匹配。所以对于?vue&type=xxx结尾的文件,会匹配[pitcher loader, vue-loader]这两个loader。首先执行的是pitcher loaderpitch方法:

javascript
if (query.type === `template`) {
  const request = genRequest([
    ...cacheLoader,
    ...postLoaders,
    templateLoaderPath + `??vue-loader-options`,
    ...preLoaders
  ])
  return `export * from ${request}`
}
if (query.type === `template`) {
  const request = genRequest([
    ...cacheLoader,
    ...postLoaders,
    templateLoaderPath + `??vue-loader-options`,
    ...preLoaders
  ])
  return `export * from ${request}`
}

该函数同样是根据不同的type对引用路径做不同的处理。比如template在加载时,由于还需要解析template内的代码,所以加入了templateLoader

javascript
const { compileTemplate } = require('@vue/component-compiler-utils')
const compiled = compileTemplate(finalOptions)
const { code } = compiled
return code + `\nexport { render, staticRenderFns }`
const { compileTemplate } = require('@vue/component-compiler-utils')
const compiled = compileTemplate(finalOptions)
const { code } = compiled
return code + `\nexport { render, staticRenderFns }`

loader会将templateVue代码进行编译。(这里template中的代码则是由vue-loader中的selectBlock方法提供)

vue热更新

hot reload 源码

vue-hot-reload-api 源码

vue-loader/lib/index.js文件中后半段,处理完script/template/style等代码之后,会添加热更新相关代码:

javascript
// 整理属性,定义 component 变量
`/* normalize component */
import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
script,
render,
staticRenderFns,
${hasFunctional ? `true` : `false`},
${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
${hasScoped ? JSON.stringify(id) : `null`},
${isServer ? JSON.stringify(hash(request)) : `null`}
${isShadow ? `,true` : ``}
)
`.trim() + `\n`

// 添加 module.hot 代码
if (needsHotReload) {
  code += `\n` + genHotReloadCode(id, hasFunctional, templateRequest)
}
// 整理属性,定义 component 变量
`/* normalize component */
import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
script,
render,
staticRenderFns,
${hasFunctional ? `true` : `false`},
${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
${hasScoped ? JSON.stringify(id) : `null`},
${isServer ? JSON.stringify(hash(request)) : `null`}
${isShadow ? `,true` : ``}
)
`.trim() + `\n`

// 添加 module.hot 代码
if (needsHotReload) {
  code += `\n` + genHotReloadCode(id, hasFunctional, templateRequest)
}

首先定义了component变量,随后通过genHotReloadCode方法添加热更新代码。

genHotReloadCode

lib/codegen/hotReload.js文件中找到genHotReloadCode方法:

javascript
const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api'))

exports.genHotReloadCode = (id, functional, templateRequest) => {
  return `
/* hot reload */
if (module.hot) {
  var api = require(${hotReloadAPIPath})
  api.install(require('vue'))
  if (api.compatible) {
    module.hot.accept()
    if (!api.isRecorded('${id}')) {
      api.createRecord('${id}', component.options)
    } else {
      api.${functional ? 'rerender' : 'reload'}('${id}', component.options)
    }
    ${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''}
  }
}
  `.trim()
}
const hotReloadAPIPath = JSON.stringify(require.resolve('vue-hot-reload-api'))

exports.genHotReloadCode = (id, functional, templateRequest) => {
  return `
/* hot reload */
if (module.hot) {
  var api = require(${hotReloadAPIPath})
  api.install(require('vue'))
  if (api.compatible) {
    module.hot.accept()
    if (!api.isRecorded('${id}')) {
      api.createRecord('${id}', component.options)
    } else {
      api.${functional ? 'rerender' : 'reload'}('${id}', component.options)
    }
    ${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''}
  }
}
  `.trim()
}

api.install

如果module.hot存在,首先会加载vue-hot-reload-api模块,然后调用其install方法:

javascript
exports.install = function (vue, browserify) {
  if (installed) { return }
  installed = true
  Vue = vue.__esModule ? vue.default : vue
  version = Vue.version.split('.').map(Number)
  exports.compatible = version[0] >= 2
}
exports.install = function (vue, browserify) {
  if (installed) { return }
  installed = true
  Vue = vue.__esModule ? vue.default : vue
  version = Vue.version.split('.').map(Number)
  exports.compatible = version[0] >= 2
}

主要是处理一些版本兼容的问题,如vue-hot-reload-api只适合Vue版本大于2的情况。

api.createRecord

其次如果id没有被记录的话,会执行api.createRecord('${id}', component.options)方法:

javascript
exports.createRecord = function (id, options) {
  if(map[id]) { return }

  // 1. Ctor 为当前组件的构造函数
  var Ctor = null
  if (typeof options === 'function') {
    Ctor = options
    options = Ctor.options
  }
  // 2. 注入生命周期
  makeOptionsHot(id, options)
  // 3. 记录当前id 对应的构造函数等信息
  map[id] = {
    Ctor: Ctor,
    options: options,
    instances: []
  }
}
exports.createRecord = function (id, options) {
  if(map[id]) { return }

  // 1. Ctor 为当前组件的构造函数
  var Ctor = null
  if (typeof options === 'function') {
    Ctor = options
    options = Ctor.options
  }
  // 2. 注入生命周期
  makeOptionsHot(id, options)
  // 3. 记录当前id 对应的构造函数等信息
  map[id] = {
    Ctor: Ctor,
    options: options,
    instances: []
  }
}

其中makeOptionsHot方法在不为函数组件时,会执行下列代码:

javascript
// 注入 beforeCreate 生命周期回调
injectHook(options, initHookName, function() {
  var record = map[id]
  if (!record.Ctor) {
    record.Ctor = this.constructor
  }
  record.instances.push(this)
})

// 注入 beforeDestroy 生命周期回调
injectHook(options, 'beforeDestroy', function() {
  var instances = map[id].instances
  instances.splice(instances.indexOf(this), 1)
})
// 注入 beforeCreate 生命周期回调
injectHook(options, initHookName, function() {
  var record = map[id]
  if (!record.Ctor) {
    record.Ctor = this.constructor
  }
  record.instances.push(this)
})

// 注入 beforeDestroy 生命周期回调
injectHook(options, 'beforeDestroy', function() {
  var instances = map[id].instances
  instances.splice(instances.indexOf(this), 1)
})

当注入了beforeCreate后,组件被实例化时,就会将组件实例添加到instances中。

api.reload

当前自身模块发生变化时,如果该component已经被record了,那么会执行api.reload方法:

javascript
// 继承新的 options
var newCtor = record.Ctor.super.extend(options)
newCtor.options._Ctor = record.options._Ctor
record.Ctor.options = newCtor.options
record.Ctor.cid = newCtor.cid
record.Ctor.prototype = newCtor.prototype

record.instances.slice().forEach(function (instance) {
  if (instance.$vnode && instance.$vnode.context) {
    instance.$vnode.context.$forceUpdate()
  }
}
// 继承新的 options
var newCtor = record.Ctor.super.extend(options)
newCtor.options._Ctor = record.options._Ctor
record.Ctor.options = newCtor.options
record.Ctor.cid = newCtor.cid
record.Ctor.prototype = newCtor.prototype

record.instances.slice().forEach(function (instance) {
  if (instance.$vnode && instance.$vnode.context) {
    instance.$vnode.context.$forceUpdate()
  }
}

该函数会替换原来记录的组件构造函数,并遍历相关实例进行更新。

api.rerender

最后执行genTemplateHotReloadCode方法:

javascript
const genTemplateHotReloadCode = (id, request) => {
  return `
    module.hot.accept(${request}, function () {
      api.rerender('${id}', {
        render: render,
        staticRenderFns: staticRenderFns
      })
    })
  `.trim()
}
const genTemplateHotReloadCode = (id, request) => {
  return `
    module.hot.accept(${request}, function () {
      api.rerender('${id}', {
        render: render,
        staticRenderFns: staticRenderFns
      })
    })
  `.trim()
}

该方法通过module.hot.accept监听组件中template的变化。如果template有变化,那么执行api.rerender方法:

javascript
exports.rerender = tryWrap(function (id, options) {
  var record = map[id]
  if (record.Ctor) {
    record.Ctor.options.render = options.render
    record.Ctor.options.staticRenderFns = options.staticRenderFns
    record.instances.slice().forEach(function (instance) {
      instance.$options.render = options.render
      instance.$options.staticRenderFns = options.staticRenderFns
      
      var restore = patchScopedSlots(instance)
      instance.$forceUpdate()
      instance.$nextTick(restore)
    })
  }
}
exports.rerender = tryWrap(function (id, options) {
  var record = map[id]
  if (record.Ctor) {
    record.Ctor.options.render = options.render
    record.Ctor.options.staticRenderFns = options.staticRenderFns
    record.instances.slice().forEach(function (instance) {
      instance.$options.render = options.render
      instance.$options.staticRenderFns = options.staticRenderFns
      
      var restore = patchScopedSlots(instance)
      instance.$forceUpdate()
      instance.$nextTick(restore)
    })
  }
}

此时会遍历收集到的instances,通过$forceUpdate方法强制更新组件。

总结

vue-loader的实现主要包括以下几个步骤:

  1. 通过VueLoaderPlugin替换webpackmodule.rules配置,添加一个pitcher loader,该loader会匹配路径参数中带有vue的文件。
  2. 通过loader-runner.vue文件解析。通过component-compiler-utils包将.vue文件解析为对象形式,然后根据不同的typescript/template等等),生成不同的查询参数,添加到.vue文件后。
  3. 当下一次build时,遇到前面处理后带参数的.vue文件,会匹配到VueLoaderPlugin插件中添加的pitcher loader。首先会执行pitcher loaderpitch方法,该方法根据参数中不同的type为文件添加不同的loader进行处理。如template会添加templateLoadercss添加css-loader等。相当于根据路径的type为路径包装了一层。添加完成后,会移除pitcher loader
  4. 在下一次进入到vue-loader时,由于存在type,此时进入到selectBlock环节。根据不同的type对相应代码做不同的处理即可。

vue-loader热更新实现原理:

  1. 通过vue-loader解析代码的末尾,将解析出来的属性整理成component对象,并添加热更新代码。
  2. 热更新代码首先调用createRecord会为当前component注入beforeCreate等生命周期。当组件实例化时,会记录下组件实例。
  3. 如果当前模块代码改变了,且record存在,此时调用reload方法更新组件构造函数,并重新渲染。
  4. 如果vue组件中template代码改变了,此时调用rerender方法进行重新渲染。