源码学习 vue-loader源码

134 阅读5分钟

按执行流程一步步看vue-loader 源码

通常配置webpack 时,我们会配置一个 loader 和 一个 plugin

// webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')
// ...
{
	test: /\.vue$/,
	loader: 'vue-loader'
},
// ...
plugins: [
	new VueLoaderPlugin(),
]

当我们运行 webpack 时, 首先会进入 vue-loader/lib/plugin

在apply方法内先挂载了一个钩子,


// vue-loader/lib/plugin.js 
class VueLoaderPlugin {
  apply (compiler) {
    compiler.hooks.compilation.tap(id, compilation => {
        let normalModuleLoader
        if (Object.isFrozen(compilation.hooks)) {
          // webpack 5
          normalModuleLoader = require('webpack/lib/NormalModule').getCompilationHooks(compilation).loader
        } else {
          normalModuleLoader = compilation.hooks.normalModuleLoader
        }
        normalModuleLoader.tap(id, loaderContext => {
          loaderContext[NS] = true
        })
      })
	  // ...
  }
}

然后读取webpack配置内的所有rule 配置, 并使用 foo.vue 文件名作为测试,查找出能匹配 vue 文件的Rule所在索引 , 并取出相应 rule

// vue-loader/lib/plugin.js
const rawRules = compiler.options.module.rules
const { rules } = new RuleSet(rawRules)

// find the rule that applies to vue files
let vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue`))
if (vueRuleIndex < 0) {
  vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue.html`))
}
const vueRule = rules[vueRuleIndex]

找到 找到 vue-loader 在 rule.use 内的索引, 然后取出相应的loader 配置, 并写入 ident 属性,

// vue-loader/lib/plugin.js
const vueUse = vueRule.use
// get vue-loader options
const vueLoaderUseIndex = vueUse.findIndex(u => {
  return /^vue-loader|(\/|\\|@)vue-loader/.test(u.loader)
})


// 取出 vue-loader 配置, 参考如下
/*
	{
		loader:'vue-loader'	
		options:undefined
	}
*/
const vueLoaderUse = vueUse[vueLoaderUseIndex]
vueLoaderUse.ident = 'vue-loader-options'
vueLoaderUse.options = vueLoaderUse.options || {}

克隆出所有的 rule , 在所有规则之前加入一个 vue的 pitcher loader,这个loader 的 resourceQuery 匹配 query 上有 vue的文件,

最后合并这些重写rules


// vue-loader/lib/plugin.js
const clonedRules = rules
  .filter(r => r !== vueRule)
  .map(cloneRule)

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

// replace original rules
compiler.options.module.rules = [
  pitcher,
  ...clonedRules,
  ...rules
]

最后会进入一开始挂上的钩子, 针对 compilation.hooks.normalModuleLoader 再挂上一个钩子

// vue-loader/lib/plugin.js
compiler.hooks.compilation.tap(id, compilation => {
        let normalModuleLoader
        if (Object.isFrozen(compilation.hooks)) {
          // webpack 5
          normalModuleLoader = require('webpack/lib/NormalModule').getCompilationHooks(compilation).loader
        } else {
          normalModuleLoader = compilation.hooks.normalModuleLoader
        }
        normalModuleLoader.tap(id, loaderContext => {
          loaderContext[NS] = true
        })
      })

最后触发 compilation.hooks.normalModuleLoader 钩子, 并对 loaderContext 的 'vue-loader' 属性为 true

// vue-loader/lib/plugin.js
const NS = 'vue-loader'

// .....
normalModuleLoader.tap(id, loaderContext => {
  loaderContext[NS] = true
})

继续执行,进入了vue-loader 内部, 将this存储在 loaderContext , 并提取出内部属性, 并将vue单文件组件内容分别解析成 template 、 script 、 style 内容

// vue-loader/lib/index.js 

  const loaderContext = this

  const stringifyRequest = r => loaderUtils.stringifyRequest(loaderContext, r)

  const {
    target,
    request,
    minimize,
    sourceMap,
    rootContext,
    resourcePath,
    resourceQuery
  } = loaderContext

  const rawQuery = resourceQuery.slice(1) // 提取 问号后面的query 
  const inheritQuery = `&${rawQuery}`
  const incomingQuery = qs.parse(rawQuery)
  const options = loaderUtils.getOptions(loaderContext) || {}

  const isServer = target === 'node'
  const isShadow = !!options.shadowMode
  const isProduction = options.productionMode || minimize || process.env.NODE_ENV === 'production'
  const filename = path.basename(resourcePath)
  const context = rootContext || process.cwd()
  const sourceRoot = path.dirname(path.relative(context, resourcePath))

  // 将 vue 但文件解析成
  const descriptor = parse({ 
    source,
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

解析出组件内容后, 会判断 query 参数是否有type 属性, 因为有type 属性的话就表示是第二次进入这个loader, 这个我们后面再说

// vue-loader/lib/index.js 
if (incomingQuery.type) {
	return selectBlock(
	  descriptor,
	  loaderContext,
	  incomingQuery,
	  !!options.appendExtension
	)
}

取出入口文件的目录相对路径, 和目录相对路径 + query 参数, 并根据这个路径生成一个 hash 字符串

然后就是做一些特性的判断,如 是否用了 scoped, 是否使用了 functional 组件等等

// vue-loader/lib/index.js 

  // module id for scoped CSS & hot-reload
  const rawShortFilePath = path
    .relative(context, resourcePath)
    .replace(/^(\.\.[\/\\])+/, '')

  const shortFilePath = rawShortFilePath.replace(/\\/g, '/') + resourceQuery

  const id = hash(
    isProduction
      ? (shortFilePath + '\n' + source)
      : shortFilePath
  )


   const hasScoped = descriptor.styles.some(s => s.scoped)
  const hasFunctional = descriptor.template && descriptor.template.attrs.functional

接下来就是生成template模版了, 本来的 vue组件内容, 经过一下的处理后变成引入一个新的 import 语句


// vue-loader/lib/index.js 

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

生成的 import 语句参考如下:

import { render, staticRenderFns } from "./index.vue?vue&type=template&id=3cf90f21&"

同样的,处理完template 完后处理 script


// vue-loader/lib/index.js 

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

生成的 import 语句

import script from "./index.vue?vue&type=script&lang=js&"
export * from "./index.vue?vue&type=script&lang=js&"

最后处理 style

// vue-loader/lib/index.js 

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

生成的 import 语句如下:

import style0 from "./index.vue?vue&type=style&index=0&lang=less&"

三个模块都处理完后, 最后要做的就是将他们合并起来生成最终 code , 并返回

// vue-loader/lib/index.js 

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


  code += `\nexport default component.exports`

  return code

生成的code 字符串参考如下

import { render, staticRenderFns } from "./index.vue?vue&type=template&id=3cf90f21&"
import script from "./index.vue?vue&type=script&lang=js&"
export * from "./index.vue?vue&type=script&lang=js&"
import style0 from "./index.vue?vue&type=style&index=0&lang=less&"


/* normalize component */
import normalizer from "!../../../node_modules/.pnpm/vue-loader@15.7.0_css-loader@2.1.1+webpack@4.41.2/node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  null,
  null,
  null
  
)

export default component.exports

就这样, 一个我们平时书写的 .vue文件经过 vue-loader 的第一次处理后 生成了如上的 code 代码, 通过生成了3个新的 import 语句,再次引入自身 .vue文件但是携带不同的 type 参数,交给webpack 。 webpack 接收到这个 code 后,发现 这个.vue文件原来还有 import. 引用了其他三个文件,它会继续查找这个三个文件, 也就是再经过 loader,然后loader 就可以通过 type 进行判断,返回相应的内容。

好,我们继续往下走. 因为webpack 发现还有新的 import 文件, 这时候就触发了之前在 plugin中添加的 pitcher loader 了, 还记得吗,他的规则是这样的


// vue-loader/lib/plugin.js
const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
	  // 匹配规则
      resourceQuery: query => {
        const parsed = qs.parse(query.slice(1)) // 匹配 ?vue 文件
        return parsed.vue != null
      },
      options: {
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
        cacheIdentifier: vueLoaderUse.options.cacheIdentifier
      }
    }

于是我们就进入了 pitcher loader 内部,

内部首先取出loader 的参数, cacheDirectory, cacheIdentifier 都是 plugin 给它传的。 解析出query 参数, 并判断 type 参数是否存在, 验证了.vue 文件时,会将 eslint-loader 给过滤掉, 避免重复触发

并且紧接着会将 pitcher loader. 自身给过滤掉。 再判断是否使用了 null-loader , 使用了的话就直接退出了

// vue-loader/lib/loaders/pitcher.js
module.exports.pitch = function (remainingRequest) {
 const options = loaderUtils.getOptions(this)
  const { cacheDirectory, cacheIdentifier } = options
  const query = qs.parse(this.resourceQuery.slice(1))

  let loaders = this.loaders

  // 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)) { // 避免重复linter
      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)
    }
  }

  // remove self
  loaders = loaders.filter(isPitcher)

  // do not inject if user uses null-loader to void the type (#1239)
  if (loaders.some(isNullLoader)) {
    return
  }
// ...
}

接下来定义了一个 genRequest 的函数, 这个函数的作用呢就是接收一个 loaders 数组,然后根据数组内的loader 它会生成 内联的loader 路径,

// vue-loader/lib/loaders/pitcher.js
  const genRequest = loaders => {

    const seen = new Map()
    const loaderStrings = []

    loaders.forEach(loader => {
      const identifier = typeof loader === 'string'
        ? loader
        : (loader.path + loader.query)
      const request = typeof loader === 'string' ? loader : loader.request
      if (!seen.has(identifier)) {
        seen.set(identifier, true)
        // loader.request contains both the resolved loader path and its options
        // query (e.g. ??ref-0)
        loaderStrings.push(request)
      }
    })

    return loaderUtils.stringifyRequest(this, '-!' + [
      ...loaderStrings,
      this.resourcePath + this.resourceQuery
    ].join('!'))
  }

生成样式参考:

"-!../../../node_modules/.pnpm/vue-loader@15.7.0_css-loader@2.1.1+webpack@4.41.2/node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../../node_modules/.pnpm/vue-loader@15.7.0_css-loader@2.1.1+webpack@4.41.2/node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=template&id=3cf90f21&"

然后重要的就是根据不同的 query.type 做不同的处理, 并针对 style 和 template 注入了 stylePostLoadertemplateLoader

如果没命中 style , template , 自定义模块,剩下的就是script 了。上面生成的3个import 引用进入到这里后, 又生成了3个携带了内联 loader 地址的内容。因为上面我们将自身的 picther loader 已经删除了 ,所以下次就不会再进入这里了

// vue-loader/lib/loaders/pitcher.js

	const templateLoaderPath = require.resolve('./templateLoader')
const stylePostLoaderPath = require.resolve('./stylePostLoader')


  // Inject style-post-loader before css-loader for scoped CSS and trimming
  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
      ])
      // console.log(request)
      return `import mod from ${request}; export default mod; export * from ${request}`
    }
  }

  // for templates: inject the template compiler & optional cache
  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).replace(/\\/g, '/'),
        cacheIdentifier: hash(cacheIdentifier) + '-vue-loader-template'
      })}`]
      : []

    const preLoaders = loaders.filter(isPreLoader)
    const postLoaders = loaders.filter(isPostLoader)

    const request = genRequest([
      ...cacheLoader,
      ...postLoaders,
      templateLoaderPath + `??vue-loader-options`,
      ...preLoaders
    ])
    // console.log(request)
    // the template compiler uses esm exports
    return `export * from ${request}`
  }

  // if a custom block has no other matching loader other than vue-loader itself
  // or cache-loader, we should ignore it
  if (query.type === `custom` && shouldIgnoreCustomBlock(loaders)) {
    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}`

pitcher loader 的工作结束 ,我们继续往下走

此时又回到了 正常的loader 内部, 这部分经过的步骤都完全相同, 唯一不同的是这次接收到的 request 是pitcher loader 交给我们的携带了内联 loader 的request

// vue-loader/lib/index.js 

  const loaderContext = this

  const stringifyRequest = r => loaderUtils.stringifyRequest(loaderContext, r)

  const {
    target,
    request,
    minimize,
    sourceMap,
    rootContext,
    resourcePath,
    resourceQuery
  } = loaderContext

  const rawQuery = resourceQuery.slice(1) // 提取 问号后面的query 
  const inheritQuery = `&${rawQuery}`
  const incomingQuery = qs.parse(rawQuery)
  const options = loaderUtils.getOptions(loaderContext) || {}

  const isServer = target === 'node'
  const isShadow = !!options.shadowMode
  const isProduction = options.productionMode || minimize || process.env.NODE_ENV === 'production'
  const filename = path.basename(resourcePath)
  const context = rootContext || process.cwd()
  const sourceRoot = path.dirname(path.relative(context, resourcePath))

  // 将 vue 但文件解析成 script style template 数据
  const descriptor = parse({ 
    source,
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

往下走,这次我们因为携带了 query.type 就会进入到 selectBlock 这个方法, 并且将返回这个方法所返回的结果

// vue-loader/lib/index.js
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

selectBlock 这个方法也很简单, 就是针对不同的query.type 来返回已解析好的对应的 descriptor 对象上的内容, 调用 loaderContext.callback 传入内容,交给webpack

// vue-loader/lib/select.js
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-loader/lib/plugin 注入 pitcher loader ➡️ vue-loader 第一次命中.vue 文件 ➡️ 因为没有 query.type 所以生成了三个新的import 引用并携带了 query.type ➡️ 因为新的引用携带了 query.type 所以命中了 pitcher loader ➡️ pitcher -loader 执行过程中将自己从 loader 中删除, 并针对 style, template 注入了 专门的loader 进行处理 生成内联 loader 引用 ➡️ 交给 vue-loader ➡️ vue-loader接收到pitcher loader 处理后的引用, 根据不同的type 返回了不同内容, 比如 template 是render函数 ➡️ 因为 pitcher loader 构造了内联 loader , 所以返回的内容又会被这些 内联的loader 给挨个处理

第一次写源码系列文章,写的不是很好,摸索中