按执行流程一步步看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/[email protected][email protected][email protected]/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/[email protected][email protected][email protected]/node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../../node_modules/.pnpm/[email protected][email protected][email protected]/node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=template&id=3cf90f21&"
然后重要的就是根据不同的 query.type 做不同的处理, 并针对 style 和 template 注入了 stylePostLoader
和 templateLoader
。
如果没命中 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 给挨个处理
第一次写源码系列文章,写的不是很好,摸索中