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

# 二、前置知识
# 1. Pitching Loader (opens new window)
loader
用于对模块的源代码进行转换,因为webpack
只能识别JS文件,loader
的作用一般是将其他文件转为JS文件。
loader
的调用顺序是从右到左,从下到上。但在实际执行loader
之前,会从左到右,从上到下调用loader
的pitch (opens new window)方法。
下面是官网 (opens new window)的一个例子。
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 execution
pitching 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 execution
# 2. 内联loader (opens new window)
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
,但不禁用postLoader
import Styles from '-!style-loader!css-loader?modules!./styles.css';
# 3. resourceQuery (opens new window)
在配置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 (opens new window)。
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 = VueLoaderPlugin
VueLoaderPlugin
先获取了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.exports
# 3. 第二阶段
上面讲到的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的模块化。
# 五、相关资料
- vue-loader 深入学习 (opens new window)
- 一文读懂 vue-loader 原理 (opens new window)
- 深入 vue-loader 原理 (opens new window)
- 从vue-loader源码分析CSS Scoped的实现 (opens new window)
- css-loader style-loader原理探究 (opens new window)
- less-loader、css-loader、style-loader实现原理 (opens new window)
- webpack的几个常见loader源码浅析 (opens new window)