webpack系列之四loader詳解3

滴滴WebApp架構組發表於2019-02-21

系列作者:肖磊

GitHub: github.com/CommanderXL

前2篇文章:webpack loader詳解1webpack loader詳解2主要通過原始碼分析了 loader 的配置,匹配和載入,執行等內容,這篇文章會通過具體的例項來學習下如何去實現一個 loader。

這裡我們來看下 vue-loader(v15) 內部的相關內容,這裡會講解下有關 vue-loader 的大致處理流程,不會深入特別細節的地方。

git clone git@github.com:vuejs/vue-loader.git
複製程式碼

我們使用 vue-loader 官方倉庫當中的 example 目錄的內容作為整篇文章的示例。

首先我們都知道 vue-loader 配合 webpack 給我們開發 Vue 應用提供了非常大的便利性,允許我們在 SFC(single file component) 中去寫我們的 template/script/style,同時 v15 版本的 vue-loader 還允許開發在 SFC 當中寫 custom block。最終一個 Vue SFC 通過 vue-loader 的處理,會將 template/script/style/custom block 拆解為獨立的 block,每個 block 還可以再交給對應的 loader 去做進一步的處理,例如你的 template 是使用 pug 來書寫的,那麼首先使用 vue-loader 獲取一個 SFC 內部 pug 模板的內容,然後再交給 pug 相關的 loader 處理,可以說 vue-loader 對於 Vue SFC 來說是一個入口處理器。

在實際運用過程中,我們先來看下有關 Vue 的 webpack 配置:

const VueloaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  ...
  module: {
    rules: [
      ...
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  }

  plugins: [
    new VueloaderPlugin()
  ]
  ...
}
複製程式碼

一個就是 module.rules 有關的配置,如果處理的 module 路徑是以.vue形式結尾的,那麼會交給 vue-loader 來處理,同時在 v15 版本必須要使用 vue-loader 內部提供的一個 plugin,它的職責是將你定義過的其它規則複製並應用到 .vue 檔案裡相應語言的塊。例如,如果你有一條匹配 /\.js$/ 的規則,那麼它會應用到 .vue 檔案裡的 <script> 塊,說到這裡我們就一起先來看看這個 plugin 裡面到底做了哪些工作。

VueLoaderPlugin

我們都清楚 webpack plugin 的裝載過程是在整個 webpack 編譯週期中初始階段,我們先來看下 VueLoaderPlugin 內部原始碼的實現:

// vue-loader/lib/plugin.js

class VueLoaderPlugin {
  apply() {
    ...
    // use webpack's RuleSet utility to normalize user rules
    const rawRules = compiler.options.module.rules
    const { rules } = new RuleSet(rawRules)

    // find the rule that applies to vue files
    // 判斷是否有給`.vue`或`.vue.html`進行 module.rule 的配置
    let vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue`))
    if (vueRuleIndex < 0) {
      vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue.html`))
    }
    const vueRule = rules[vueRuleIndex]

    ...

    // 判斷對於`.vue`或`.vue.html`配置的 module.rule 是否有 vue-loader
    // get the normlized "use" for vue files
    const vueUse = vueRule.use
    // get vue-loader options
    const vueLoaderUseIndex = vueUse.findIndex(u => {
      return /^vue-loader|(\/|\\|@)vue-loader/.test(u.loader)
    })
    ...

    // 建立 pitcher loader 的配置
    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      },
      options: {
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
        cacheIdentifier: vueLoaderUse.options.cacheIdentifier
      }
    }

    // 擴充開發者的 module.rule 配置,加入 vue-loader 內部提供的 pitcher loader
    // replace original rules
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }
}
複製程式碼

這個 plugin 主要完成了以下三部分的工作:

  1. 判斷是否有給.vue.vue.html進行 module.rule 的配置;
  2. 判斷對於.vue.vue.html配置的 module.rule 是否有 vue-loader;
  3. 擴充開發者的 module.rule 配置,加入 vue-loader 內部提供的 pitcher loader

我們看到有關 pitcher loader 的 rule 匹配條件是通過resourceQuery方法來進行判斷的,即判斷 module path 上的 query 引數是否存在 vue,例如:

// 這種型別的 module path 就會匹配上
'./source.vue?vue&type=template&id=27e4e96e&scoped=true&lang=pug&'
複製程式碼

如果存在的話,那麼就需要將這個 loader 加入到構建這個 module 的 loaders 陣列當中。以上就是 VueLoaderPlugin 所做的工作,其中涉及到擴充後的 module rule 裡面加入的 pitcher loader 具體做的工作後文會分析。

Step 1

接下來我們看下 vue-loader 的內部實現。首先來看下入口檔案的相關內容:

// vue-loader/lib/index.js
...
const { parse } = require('@vue/component-compiler-utils')

function loadTemplateCompiler () {
  try {
    return require('vue-template-compiler')
  } catch (e) {
    throw new Error(
      `[vue-loader] vue-template-compiler must be installed as a peer dependency, ` +
      `or a compatible compiler implementation must be passed via options.`
    )
  }
}

module.exports = function(source) {
  const loaderContext = this // 獲取 loaderContext 物件

  // 從 loaderContext 獲取相關引數
  const {
    target, // webpack 構建目標,預設為 web
    request, // module request 路徑(由 path 和 query 組成)
    minimize, // 構建模式
    sourceMap, // 是否開啟 sourceMap
    rootContext, // 專案的根路徑
    resourcePath, // module 的 path 路徑
    resourceQuery // module 的 query 引數
  } = loaderContext

  // 接下來就是一系列對於引數和路徑的處理
  const rawQuery = resourceQuery.slice(1)
  const inheritQuery = `&${rawQuery}`
  const incomingQuery = qs.parse(rawQuery)
  const options = loaderUtils.getOptions(loaderContext) || {}

  ...
  

  // 開始解析 sfc,根據不同的 block 來拆解對應的內容
  const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

  // 如果 query 引數上帶了 block 的 type 型別,那麼會直接返回對應 block 的內容
  // 例如: foo.vue?vue&type=template,那麼會直接返回 template 的文字內容
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

  ...

  // template
  let templateImport = `var render, staticRenderFns`
  let templateRequest
  if (descriptor.template) {
    const src = descriptor.template.src || resourcePath
    const idQuery = `&id=${id}`
    const scopedQuery = hasScoped ? `&scoped=true` : ``
    const attrsQuery = attrsToQuery(descriptor.template.attrs)
    const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}`
    const request = templateRequest = stringifyRequest(src + query)
    templateImport = `import { render, staticRenderFns } from ${request}`
  }

  // script
  let scriptImport = `var script = {}`
  if (descriptor.script) {
    const src = descriptor.script.src || resourcePath
    const attrsQuery = attrsToQuery(descriptor.script.attrs, 'js')
    const query = `?vue&type=script${attrsQuery}${inheritQuery}`
    const request = stringifyRequest(src + query)
    scriptImport = (
      `import script from ${request}\n` +
      `export * from ${request}` // support named exports
    )
  }

  // styles
  let stylesCode = ``
  if (descriptor.styles.length) {
    stylesCode = genStylesCode(
      loaderContext,
      descriptor.styles,
      id,
      resourcePath,
      stringifyRequest,
      needsHotReload,
      isServer || isShadow // needs explicit injection?
    )
  }

  let code = `
${templateImport}
${scriptImport}
${stylesCode}

/* 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`

  if (descriptor.customBlocks && descriptor.customBlocks.length) {
    code += genCustomBlocksCode(
      descriptor.customBlocks,
      resourcePath,
      resourceQuery,
      stringifyRequest
    )
  }

  ...

  // Expose filename. This is used by the devtools and Vue runtime warnings.
  code += `\ncomponent.options.__file = ${
    isProduction
      // For security reasons, only expose the file's basename in production.
      ? JSON.stringify(filename)
      // Expose the file's full path in development, so that it can be opened
      // from the devtools.
      : JSON.stringify(rawShortFilePath.replace(/\\/g, '/'))
  }`

  code += `\nexport default component.exports`
  return code
}
複製程式碼

以上就是 vue-loader 的入口檔案(index.js)主要做的工作:對於 request 上不帶 type 型別的 Vue SFC 進行 parse,獲取每個 block 的相關內容,將不同型別的 block 元件的 Vue SFC 轉化成 js module 字串,具體的內容如下:

import { render, staticRenderFns } from "./source.vue?vue&type=template&id=27e4e96e&scoped=true&lang=pug&"
import script from "./source.vue?vue&type=script&lang=js&"
export * from "./source.vue?vue&type=script&lang=js&"
import style0 from "./source.vue?vue&type=style&index=0&id=27e4e96e&scoped=true&lang=css&"

/* normalize component */
import normalizer from "!../lib/runtime/componentNormalizer.js"
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  null,
  "27e4e96e",
  null
)

/* custom blocks */
import block0 from "./source.vue?vue&type=custom&index=0&blockType=foo"
if (typeof block0 === 'function') block0(component)

// 省略了有關 hotReload 的程式碼

component.options.__file = "example/source.vue"
export default component.exports
複製程式碼

從生成的 js module 字串來看:將由 source.vue 提供 render函式/staticRenderFns,js script,style樣式,並交由 normalizer 進行統一的格式化,最終匯出 component.exports。

Step 2

這樣 vue-loader 處理的第一個階段結束了,vue-loader 在這一階段將 Vue SFC 轉化為 js module 後,接下來進入到第二階段,將新生成的 js module 加入到 webpack 的編譯環節,即對這個 js module 進行 AST 的解析以及相關依賴的收集過程,這裡我用每個 request 去標記每個被收集的 module(這裡只說明和 Vue SFC 相關的模組內容):

[
 './source.vue?vue&type=template&id=27e4e96e&scoped=true&lang=pug&',
 './source.vue?vue&type=script&lang=js&',
 './source.vue?vue&type=style&index=0&id=27e4e96e&scoped=true&lang=css&',
 './source.vue?vue&type=custom&index=0&blockType=foo'
]
複製程式碼

我們看到通過 vue-loader 處理到得到的 module path 上的 query 引數都帶有 vue 欄位。這裡便涉及到了我們在文章開篇提到的 VueLoaderPlugin 加入的 pitcher loader。如果遇到了 query 引數上帶有 vue 欄位的 module path,那麼就會把 pitcher loader 加入到處理這個 module 的 loaders 陣列當中。因此這個 module 最終也會經過 pitcher loader 的處理。此外在 loader 的配置順序上,pitcher loader 為第一個,因此在處理 Vue SFC 模組的時候,最先也是交由 pitcher loader 來處理。

事實上對一個 Vue SFC 處理的第二階段就是剛才提到的,Vue SFC 會經由 pitcher loader 來做進一步的處理。那麼我們就來看下 vue-loader 內部提供的 pitcher loader 主要是做了哪些工作呢:

  1. 剔除 eslint loader;
  2. 剔除 pitcher loader 自身;
  3. 根據不同 type query 引數進行攔截處理,返回對應的內容,跳過後面的 loader 執行的階段,進入到 module parse 階段
// vue-loader/lib/loaders/pitcher.js

module.export = code => code

module.pitch = function () {
  ...
  const query = qs.parse(this.resourceQuery.slice(1))
  let loaders = this.loaders

  // 剔除 eslint loader
  // if this is a language block request, eslint-loader may get matched
  // multiple times
  if (query.type) {
    // if this is an inline block, since the whole file itself is being linted,
    // remove eslint-loader to avoid duplicate linting.
    if (/\.vue$/.test(this.resourcePath)) {
      loaders = loaders.filter(l => !isESLintLoader(l))
    } else {
      // This is a src import. Just make sure there's not more than 1 instance
      // of eslint present.
      loaders = dedupeESLintLoader(loaders)
    }
  }

  // 剔除 pitcher loader 自身
  // remove self
  loaders = loaders.filter(isPitcher)

  if (query.type === 'style') {
    const cssLoaderIndex = loaders.findIndex(isCSSLoader)
    if (cssLoaderIndex > -1) {
      const afterLoaders = loaders.slice(0, cssLoaderIndex + 1)
      const beforeLoaders = loaders.slice(cssLoaderIndex + 1)
      const request = genRequest([
        ...afterLoaders,
        stylePostLoaderPath,
        ...beforeLoaders
      ])
      return `import mod from ${request}; export default mod; export * from ${request}`
    }
  }

  if (query.type === 'template') {
    const path = require('path')
    const cacheLoader = cacheDirectory && cacheIdentifier
      ? [`cache-loader?${JSON.stringify({
        // For some reason, webpack fails to generate consistent hash if we
        // use absolute paths here, even though the path is only used in a
        // comment. For now we have to ensure cacheDirectory is a relative path.
        cacheDirectory: path.isAbsolute(cacheDirectory)
          ? path.relative(process.cwd(), cacheDirectory)
          : cacheDirectory,
        cacheIdentifier: hash(cacheIdentifier) + '-vue-loader-template'
      })}`]
      : []
    const request = genRequest([
      ...cacheLoader,
      templateLoaderPath + `??vue-loader-options`,
      ...loaders
    ])
    // the template compiler uses esm exports
    return `export * from ${request}`
  }

  // if a custom block has no other matching loader other than vue-loader itself,
  // we should ignore it
  if (query.type === `custom` &&
      loaders.length === 1 &&
      loaders[0].path === selfPath) {
    return ``
  }

  // When the user defines a rule that has only resourceQuery but no test,
  // both that rule and the cloned rule will match, resulting in duplicated
  // loaders. Therefore it is necessary to perform a dedupe here.
  const request = genRequest(loaders)
  return `import mod from ${request}; export default mod; export * from ${request}`
}
複製程式碼

對於 style block 的處理,首先判斷是否有 css-loader,如果有的話就重新生成一個新的 request,這個 request 包含了 vue-loader 內部提供的 stylePostLoader,並返回一個 js module,根據 pitch 函式的規則,pitcher loader 後面的 loader 都會被跳過,這個時候開始編譯這個返回的 js module。相關的內容為:

import mod from "-!../node_modules/vue-style-loader/index.js!../node_modules/css-loader/index.js!../lib/loaders/stylePostLoader.js!../lib/index.js??vue-loader-options!./source.vue?vue&type=style&index=0&id=27e4e96e&scoped=true&lang=css&"
export default mod
export * from "-!../node_modules/vue-style-loader/index.js!../node_modules/css-loader/index.js!../lib/loaders/stylePostLoader.js!../lib/index.js??vue-loader-options!./source.vue?vue&type=style&index=0&id=27e4e96e&scoped=true&lang=css&"  
複製程式碼

對於 template block 的處理流程類似,生成一個新的 request,這個 request 包含了 vue-loader 內部提供的 templateLoader,並返回一個 js module,並跳過後面的 loader,然後開始編譯返回的 js module。相關的內容為:

export * from "-!../lib/loaders/templateLoader.js??vue-loader-options!../node_modules/pug-plain-loader/index.js!../lib/index.js??vue-loader-options!./source.vue?vue&type=template&id=27e4e96e&scoped=true&lang=pug&"
複製程式碼

這樣對於一個 Vue SFC 處理的第二階段也就結束了,通過 pitcher loader 去攔截不同型別的 block,並返回新的 js module,跳過後面的 loader 的執行,同時在內部會剔除掉 pitcher loader,這樣在進入到下一個處理階段的時候,pitcher loader 不在使用的 loader 範圍之內,因此下一階段 Vue SFC 便不會經由 pitcher loader 來處理。

Step 3

接下來進入到第三個階段,編譯返回的新的 js module,完成 AST 的解析和依賴收集工作,並開始處理不同型別的 block 的編譯轉換工作。就拿 Vue SFC 當中的 style / template block 來舉例,

style block 會經過以下的流程處理:

source.vue?vue&type=style -> vue-loader(抽離 style block) -> stylePostLoader(處理作用域 scoped css) -> css-loader(處理相關資源引入路徑) -> vue-style-loader(動態建立 style 標籤插入 css)

vue-loader-style-block

template block 會經過以下的流程處理:

source.vue?vue&type=template -> vue-loader(抽離 template block ) -> pug-plain-loader(將 pug 模組轉化為 html 字串) -> templateLoader(編譯 html 模板字串,生成 render/staticRenderFns 函式並暴露出去)

vue-loader-template-block

我們看到經過 vue-loader 處理時,會根據不同 module path 的型別(query 引數上的 type 欄位)來抽離 SFC 當中不同型別的 block。這也是 vue-loader 內部定義的相關規則:

// vue-loader/lib/index.js

const qs = require('querystring')
const selectBlock = require('./select')
...

module.exports = function (source) {
  ...
  const rawQuery = resourceQuery.slice(1)
  const inheritQuery = `&${rawQuery}`
  const incomingQuery = qs.parse(rawQuery)

  ...
  const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

  // if the query has a type field, this is a language block request
  // e.g. foo.vue?type=template&id=xxxxx
  // and we will return early
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }
  ...
}
複製程式碼

當 module path 上的 query 引數帶有 type 欄位,那麼會直接呼叫 selectBlock 方法去獲取 type 對應型別的 block 內容,跳過 vue-loader 後面的處理流程(這也是與 vue-loader 第一次處理這個 module時流程不一樣的地方),並進入到下一個 loader 的處理流程中,selectBlock 方法內部主要就是根據不同的 type 型別(template/script/style/custom),來獲取 descriptor 上對應型別的 content 內容並傳入到下一個 loader 處理:

module.exports = function selectBlock (
  descriptor,
  loaderContext,
  query,
  appendExtension
) {
  // template
  if (query.type === `template`) {
    if (appendExtension) {
      loaderContext.resourcePath += '.' + (descriptor.template.lang || 'html')
    }
    loaderContext.callback(
      null,
      descriptor.template.content,
      descriptor.template.map
    )
    return
  }

  // script
  if (query.type === `script`) {
    if (appendExtension) {
      loaderContext.resourcePath += '.' + (descriptor.script.lang || 'js')
    }
    loaderContext.callback(
      null,
      descriptor.script.content,
      descriptor.script.map
    )
    return
  }

  // styles
  if (query.type === `style` && query.index != null) {
    const style = descriptor.styles[query.index]
    if (appendExtension) {
      loaderContext.resourcePath += '.' + (style.lang || 'css')
    }
    loaderContext.callback(
      null,
      style.content,
      style.map
    )
    return
  }

  // custom
  if (query.type === 'custom' && query.index != null) {
    const block = descriptor.customBlocks[query.index]
    loaderContext.callback(
      null,
      block.content,
      block.map
    )
    return
  }
}
複製程式碼

總結

通過 vue-loader 的原始碼我們看到一個 Vue SFC 在整個編譯構建環節是怎麼樣一步一步處理的,這也是得益於 webpack 給開發這提供了這樣一種 loader 的機制,使得開發者通過這樣一種方式去對專案原始碼做對應的轉換工作以滿足相關的開發需求。結合之前的2篇(webpack loader詳解1webpack loader詳解2)有關 webpack loader 原始碼的分析,大家應該對 loader 有了更加深入的理解,也希望大家活學活用,利用 loader 機制去完成更多貼合實際需求的開發工作。

相關文章