一、开始
上一篇文章分析了Webpack打包的原理,其中的loader用来转换非JS文件,这次来分析一个具体且常用的loader——vue-loader。
本文会从源码入手,详细分析vue-loader的工作原理,以及说明Scoped CSS、CSS Modules的实现原理。
下面是一张流程图:

二、前置知识
1. Pitching Loader
loader用于对模块的源代码进行转换,因为webpack只能识别JS文件,loader的作用一般是将其他文件转为JS文件。
loader的调用顺序是从右到左,从下到上。但在实际执行loader之前,会从左到右,从上到下调用loader的pitch方法。
下面是官网的一个例子。
webpack配置如下:
module.exports = {
// ...
module: {
rules: [
{
use: ['a-loader', 'b-loader', 'c-loader'],
}
]
}
}将发生的步骤为:
|- a-loader `pitch`
|- b-loader `pitch`
|- c-loader `pitch`
|- requested module is picked up as a dependency
|- c-loader normal execution
|- b-loader normal execution
|- a-loader normal executionpitching loader的一个作用是可以共享数据。pitch的第三个参数data,会暴露给loader的this.data,比如下面的例子:
module.exports = function (content) {
return someSyncOperation(content, this.data.value);
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
data.value = 42;
};另一个作用是当一个loader的pitch方法返回非undefined时,会中断后面loader的执行,比如若上面例子中的b-loader如下:
module.exports = function (content) {
return someSyncOperation(content)
}
module.exports.pitch = function () {
return 'export {}'
}则loader的执行顺序会缩减为:
|- a-loader `pitch`
|- b-loader `pitch` returns a module
|- a-loader normal execution2. 内联loader
loader有两种使用方式:配置方式和内联方式。
- 配置方式是我们常用的方式,就是在
webpack.config.js中使用loader。 - 内联方式是在
import/require语句中显示的指定loader。
内联方式使用loader的示例如下:
import Styles from 'style-loader!css-loader?modules!./styles.css';!将多个loader分割,行内loader调用顺序还是从右到左,所以上述语句意思为对./style.css文件依次使用css-loader和style-loader处理,并且css-loader传递了参数modules。
与上面语句等价的webpack配置如下:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: {
modules: true
}
},
{ loader: 'sass-loader' }
]
}
]
}
};可以为内联import语句增加前缀,来覆盖配置中的loader、preLoader、postLoader。
- 使用
!前缀,将禁用所有的normal loader(普通loader)import Styles from '!style-loader!css-loader?modules!./styles.css'; - 使用
!!前缀,将禁用所有已配置的loader(preLoader、loader、postLoader)import Styles from '!!style-loader!css-loader?modules!./styles.css'; - 使用
-!前缀,将禁用所有已配置的preLoader和loader,但不禁用postLoaderimport Styles from '-!style-loader!css-loader?modules!./styles.css';
3. resourceQuery
在配置loader时,大部分时候通过配置test字段,来匹配文件:
{
test: /\.vue$/,
loader: 'vue-loader'
}
// 当引入vue后缀文件时,将文件内容传输给vue-loader进行处理
import Foo from './source.vue'resourceQuery可以根据文件的引用路径参数来匹配文件,当引入文件路径携带query参数匹配时,也将加载该loader
{
resourceQuery: /vue=true/,
loader: path.resolve(__dirname, './test-loader.js')
}
// 下面两个文件会经test-loader处理
import './test.js?vue=true'
import Foo from './source.vue?vue=true'三、vue-loader原理
本次分析的版本为v15.9.8。
vue-loader的使用方式如下,注意要同时配置VueLoaderPlugin。
// webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
module: {
rules: [
// ...
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
},
plugins: [
new VueLoaderPlugin()
]
}1. VueLoaderPlugin
先看下VueLoaderPlugin的作用:
// plugin-webpack4.js
const RuleSet = require('webpack/lib/RuleSet')
class VueLoaderPlugin = {
apply(compiler) {
// use webpack's RuleSet utility to normalize user rules
const rawRules = compiler.options.module.rules
const { rules } = new RuleSet(rawRules)
// for each user rule (except the vue rule), create a cloned rule
// that targets the corresponding language blocks in *.vue files.
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
]
}
}
module.exports = VueLoaderPluginVueLoaderPlugin先获取了webpack原来的rules,然后创建了pitcher规则,其作用是对query中包含vue的文件,使用./loaders/pitcher中的loader,也就是PitcherLoader。
然后为携带了?vue&lang=xx这种query参数的文件,创建和.xx文件一样的规则。比如query中携带?vue&lang=ts,则复制并应用用户为.ts定义的规则,比如ts-loader。这些复制的规则称为clonedRules。
然后将[pitcher, ...clonedRules, ...rules]作为新的rules。
2. 第一阶段
vue-loader的入口文件是lib/index.js,其导出了一个方法。webpack处理vue文件的过程中,会调用两次此方法。
第一次是是通过parse方法,将.vue文件按照teplate/script/style类型分为多个块。
const { parse } = require('@vue/component-compiler-utils')
function loadTemplateCompiler() {
return require('vue-template-compiler')
}
module.exports = function (source) {
const {
resourceQuery = '',
resourcePath
} = loaderContext = this;
const descriptor = parse({
source,
compiler: loadTemplateCompiler(loaderContext),
})
// 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,
)
}
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`
code += `\nexport default component.exports`
return code
}@vue/component-compiler-utils的parse方法如下:
function parse(options) {
const {
source,
filename = '',
compiler,
compilerParseOptions = { pad: 'line' },
sourceRoot = '',
needMap = true
} = options
const cacheKey = hash(
filename + source + JSON.stringify(compilerParseOptions)
)
let output = cache.get(cacheKey)
if (output) return output
output = compiler.parseComponent(source, compilerParseOptions)
if (needMap) {
if (output.script && !output.script.src) {
output.script.map = generateSourceMap()
}
if (output.styles) {
output.styles.forEach(style => {
if (!style.src) {
style.map = generateSourceMap()
}
})
}
}
cache.set(cacheKey, output)
return output
}parse方法先判断是否有缓存,有的话直接返回缓存内容,否则,调用compiler.parseComponent(source)获取output,然后如果需要sourcemap,则在output.script和output.styles中赋值map属性,最后返回output。
这里的compiler就是vue-template-compiler导出的compiler方法。
@vue/component-compiler-utils这个库的名称副其实,只是个工具,核心逻辑还是在其他库中,比如这里的vue-template-compiler。
上面的compiler.parseComponent方法在vue/src/sfc/parser.js中,其会返回如下结构的对象:
{
template: null,
script: null,
styles: [],
customBlocks: [],
errors: []
}vue-loader对descriptor的template/script/style等部分做判断,拼接出各自新的引用路径,其导出的内容示例如下:
import { render, staticRenderFns } from "./empty-state.vue?vue&type=template&id=619de588&scoped=true&"
import script from "./empty-state.vue?vue&type=script&lang=js&"
export * from "./empty-state.vue?vue&type=script&lang=js&"
import style0 from "./empty-state.vue?vue&type=style&index=0&id=619de588&scoped=true&lang=scss&"
/* normalize component */
import normalizer from "!../../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
script,
render,
staticRenderFns,
false,
null,
"619de588",
null
)
export default component.exports3. 第二阶段
上面讲到的VueLoaderPlugin中,如果文件query中包含vue,则会应用pitcherLoader。
第一阶段导出的内容带有vue参数,会触发pitcherLoader的调用。看下pitcherLoader的源码:
const templateLoaderPath = require.resolve('./templateLoader')
const isPitcher = l => l.path !== __filename
const isPreLoader = l => !l.pitchExecuted
const isPostLoader = l => l.pitchExecuted
module.exports = code => code
module.exports.pitch = function () {
const query = qs.parse(this.resourceQuery.slice(1))
let loaders = this.loaders
// remove self
loaders = loaders.filter(isPitcher)
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)
loaderStrings.push(request)
}
})
return loaderUtils.stringifyRequest(this, '-!' + [
...loaderStrings,
this.resourcePath + this.resourceQuery
].join('!'))
}
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 query.module
? `export { default } from ${request}; export * from ${request}`
: `export * from ${request}`
}
}
if (query.type === `template`) {
const preLoaders = loaders.filter(isPreLoader)
const postLoaders = loaders.filter(isPostLoader)
const request = genRequest([
...cacheLoader,
...postLoaders,
templateLoaderPath + `??vue-loader-options`,
...preLoaders
])
return `export * from ${request}`
}
const request = genRequest(loaders)
return `import mod from ${request}; export default mod; export * from ${request}`
}pitcherLoader的noraml loader部分未做任何操作,直接返回了之前的code,所以核心在pitch方法上。
由于pitch方法返回了非undefined,且它是第一个loader,所以会跳过之后的loader。
pitcherLoader的作用是将之前文件的query,根据参数type,替换成相应的带loader的query,也就是上面提到的内联loader。
对于type=template文件的引用,比如:
import { render, staticRenderFns } from "./create-team-dialog.vue?vue&type=template&id=127d8294&scoped=true&"则pitcherLoader会将该文件的引用替换为:
export * from "-!../../../../../../node_modules/cache-loader/dist/cjs.js?{\"cacheDirectory\":\"node_modules/.cache/vue-loader\",\"cacheIdentifier\":\"2f391a00-vue-loader-template\"}!../../../../../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./create-team-dialog.vue?vue&type=template&id=127d8294&scoped=true&"行内loader的执行顺序是从右到左,也就是依次执行vue-loader、cache-loader、templateLoader、cache-loader。
下面是一个type为style的文件转化结果:
import mod from "-!../../../../../node_modules/vue-style-loader/index.js??ref--6-oneOf-1-0!../../../../../node_modules/css-loader/dist/cjs.js??ref--6-oneOf-1-1!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-2!../../../../../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-3!../../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./gp.vue?vue&type=style&index=0&id=6e34f811&scoped=true&lang=css&";
export default mod;
export * from "/*引用地址同上*/"对于上述样式文件,loader的执行顺序为:vue-loader、cache-loader、postcss-loader * 2、vue-loader/stylePostLoader、css-loader、vue-style-loader。
对于type=script的文件来说,会走到最后一个逻辑,把原来的loaders通过genRequest转为request参数,覆盖之前的文件引用。
const request = genRequest(loaders)
return `import mod from ${request}; export default mod; export * from ${request}`pitcherLoader对query参数中含vue的文件,进行了上述引用的替换,替换后会依次调用query上的内联loader。内联的第一个loader是vue-loader,所以会再次调用vue-loader。
4. 第三阶段
这一次vue-loader做的事情比较简单,仅是根据query.type去执行下一个loader。
module.exports = function (source) {
const incomingQuery = qs.parse(resourceQuery.slice(1))
if (!incomingQuery.type) {
// 第二次进入vue-loader时
return selectBlock(
descriptor,
loaderContext,
incomingQuery,
)
}
}
function selectBlock() {
if (query.type === `template`) {
if (appendExtension) {
loaderContext.resourcePath += '.' + (descriptor.template.lang || 'html')
}
loaderContext.callback(
null,
descriptor.template.content,
descriptor.template.map
)
return
}
if (query.type === `script`) {
}
if (query.type === `style`) {
}
if (query.type === `custom`) {
}
//...
}对于type=template的部分来说,pitcherLoader中插入了templateLoader,该loader在lib/loaders/templateLoader.js中:
const { compileTemplate } = require('@vue/component-compiler-utils')
module.exports = function (source) {
// ...
const compiled = compileTemplate(finalOptions)
const { code } = compiled
return code + `\nexport { render, staticRenderFns }`
}templateLoader调用了@vue/component-compiler-utils的compileTemplate方法,返回一个对象,包含了render方法,其实这就是vue-loader最核心的功能,即把组件转为render函数。
下面是一个例子:
var render = function() {
var _vm = this;
var _h = _vm.$createElement;
var _c = _vm._self._c || _h;
return _c('div', {
staticClass: "wrap"
},
[_c('a', {
staticClass: "tip-toc-commbtn tip-btn-primary",
on: {
"click": function($event) {
$event.stopPropagation();
return _vm.enterGame($event)
}
}
},
[_vm._v("进入游戏")])])
}
var staticRenderFns = []
export { render, staticRenderFns }对于type=style的部分来说,pitcherLoader中插入了stylePostLoader,该loader在lib/loaders/stylePostLoader.js中:
const { compileStyle } = require('@vue/component-compiler-utils')
module.exports = function (source, inMap) {
const query = qs.parse(this.resourceQuery.slice(1))
const { code, map, errors } = compileStyle({
source,
filename: this.resourcePath,
id: `data-v-${query.id}`,
map: inMap,
scoped: !!query.scoped,
trim: true
})
if (errors.length) {
this.callback(errors[0])
} else {
this.callback(null, code, map)
}
}stylePostLoader调用了compileStyle,它的一个作用是,对含有scoped属性style中的属性选择器前加上[data-v-hash]。
style部分后面会经过style-loader添加到head中,或者通过miniCssExtractPlugin提取到一个公共的css文件中。
获取到script、render和staticRenderFns,在运行时会调用normalizeComponent,返回component,其包含options和exports。
四、总结
下面总结下vue-loader的工作流程:
- 将
.vue文件分割成template/script/styles三个部分 template部分经过pitcherLoader、templateLoader,最终会通过compile生成render和staticRenderFns- 获取
script部分,命名为script,在后面的normalizeComponent中会用到,并导出script。 styles部分经过pitcherLoader、stylePostLoader,最终会通过css-loader、vue-style-loader添加到head中,或者通过css-loader、miniCssExtractPlugin提取到一个公共的css文件中。- 使用
vue-loader的normalizeComponent方法,合并script、render和staticRenderFns,返回component,其包含options和exports。
看下scoped css工作流程:
vue-loader在处理.vue文件的template部分时,会根据文件路径和文件内容生成hash值。- 如果
.vue文件中有scoped的style标签,则生成一个scopedId,形如data-v-hash,这里的hash就是上面的hash值。 - 对于
vue中的style部分,vue-loader会在css-loader前增加自己的stylePostLoader,stylePostLoader会给每个选择器增加属性[data-v-hash],然后通过style-loader把css添加到head中,或通过miniCssExtractPlugin把css提取成单独的文件。 vue-loader的normalizeComponent方法,判断如果vue文件中有scoped的style,则其返回的options._scopeId为上面的scopedId.- 上面的
_scopedId在vnode渲染生成 DOM 的时候会在dom元素上增增加scopedId,也就是增加data-v-hash。
经过上面的过程,实现了CSS的模块私有化。
另外简要说一下css modules原理:
vue-loader在处理.vue文件时,遇到含有module的style标签,会在生成的code中注入injectStyles方法,该方法会执行this["a"] = (style0.locals || style0)或者this["$style"] = (style1.locals || style1),从而在vue文件中可以使用this.$style.class0等引入模块化的类和id。css-loader对vue文件中style部分解析,导出locals属性,将原来的类名和id转为唯一的值。normalizeComponent中判断如果含有injectStyles,则会将render方法包装成含有injectStyles的renderWithStyleInjection方法。vue实例化的时候,会首先执行injectStyles方法,然后执行原来的render方法。这样vue实例上就能拿到$style的类名和id了,也就实现了CSS的模块化。