詳解從vue-loader源碼分析CSS Scoped的實(shí)現(xiàn)
雖然寫了很長一段時(shí)間的Vue了,對于CSS Scoped的原理也大致了解,但一直未曾關(guān)注過其實(shí)現(xiàn)細(xì)節(jié)。最近在重新學(xué)習(xí)webpack,因此查看了vue-loader源碼,順便從vue-loader的源碼中整理CSS Scoped的實(shí)現(xiàn)。
本文展示了vue-loader中的一些源碼片段,為了便于理解,稍作刪減。參考
Vue CSS SCOPED實(shí)現(xiàn)原理
Vue loader官方文檔
相關(guān)概念
CSS Scoped的實(shí)現(xiàn)原理
在Vue單文件組件中,我們只需要在style標(biāo)簽上加上scoped屬性,就可以實(shí)現(xiàn)標(biāo)簽內(nèi)的樣式在當(dāng)前模板輸出的HTML標(biāo)簽上生效,其實(shí)現(xiàn)原理如下
- 每個(gè)Vue文件都將對應(yīng)一個(gè)唯一的id,該id可以根據(jù)文件路徑名和內(nèi)容hash生成
- 編譯template標(biāo)簽時(shí)時(shí)為每個(gè)標(biāo)簽添加了當(dāng)前組件的id,如<div class="demo"></div>會被編譯成<div class="demo" data-v-27e4e96e></div>
- 編譯style標(biāo)簽時(shí),會根據(jù)當(dāng)前組件的id通過屬性選擇器和組合選擇器輸出樣式,如.demo{color: red;}會被編譯成.demo[data-v-27e4e96e]{color: red;}
了解了大致原理,可以想到css scoped應(yīng)該需要同時(shí)處理template和style的內(nèi)容,現(xiàn)在歸納需要探尋的問題
- 渲染的HTML標(biāo)簽上的data-v-xxx屬性是如何生成的
- CSS代碼中的添加的屬性選擇器是如何實(shí)現(xiàn)的
resourceQuery
在此之前,需要了解首一下webpack中Rules.resourceQuery的作用。在配置loader時(shí),大部分時(shí)候我們只需要通過test匹配文件類型即可
{
test: /\.vue$/,
loader: 'vue-loader'
}
// 當(dāng)引入vue后綴文件時(shí),將文件內(nèi)容傳輸給vue-loader進(jìn)行處理
import Foo from './source.vue'
resourceQuery提供了根據(jù)引入文件路徑參數(shù)的形式匹配路徑
{
resourceQuery: /shymean=true/,
loader: path.resolve(__dirname, './test-loader.js')
}
// 當(dāng)引入文件路徑攜帶query參數(shù)匹配時(shí),也將加載該loader
import './test.js?shymean=true'
import Foo from './source.vue?shymean=true'
vue-loader中就是通過resourceQuery并拼接不同的query參數(shù),將各個(gè)標(biāo)簽分配給對應(yīng)的loader進(jìn)行處理。
loader.pitch
參考
pitching-loader官方文檔
webpack的pitching loader
webpack中l(wèi)oaders的執(zhí)行順序是從右到左執(zhí)行的,如loaders:[a, b, c],loader的執(zhí)行順序是c->b->a,且下一個(gè)loader接收到的是上一個(gè)loader的返回值,這個(gè)過程跟"事件冒泡"很像。
但是在某些場景下,我們可能希望在"捕獲"階段就執(zhí)行l(wèi)oader的一些方法,因此webpack提供了loader.pitch的接口。
一個(gè)文件被多個(gè)loader處理的真實(shí)執(zhí)行流程,如下所示
a.pitch -> b.pitch -> c.pitch -> request module -> c -> b -> a
loader和pitch的接口定義大概如下所示
// loader文件導(dǎo)出的真實(shí)接口,content是上一個(gè)loader或文件的原始內(nèi)容
module.exports = function loader(content){
// 可以訪問到在pitch掛載到data上的數(shù)據(jù)
console.log(this.data.value) // 100
}
// remainingRequest表示剩余的請求,precedingRequest表示之前的請求
// data是一個(gè)上下文對象,在上面的loader方法中可以通過this.data訪問到,因此可以在pitch階段提前掛載一些數(shù)據(jù)
module.exports.pitch = function pitch(remainingRequest, precedingRequest, data) {
data.value = 100
}}
正常情況下,一個(gè)loader在execution階段會返回經(jīng)過處理后的文件文本內(nèi)容。如果在pitch方法中直接返回了內(nèi)容,則webpack會視為后面的loader已經(jīng)執(zhí)行完畢(包括pitch和execution階段)。
在上面的例子中,如果b.pitch返回了result b,則不再執(zhí)行c,則是直接將result b傳給了a。
VueLoaderPlugin
接下來看看與vue-loader配套的插件:VueLoaderPlugin,該插件的作用是:
將在webpack.config定義過的其它規(guī)則復(fù)制并應(yīng)用到 .vue 文件里相應(yīng)語言的塊中。
其大致工作流程如下所示
- 獲取項(xiàng)目webpack配置的rules項(xiàng),然后復(fù)制rules,為攜帶了?vue&lang=xx...query參數(shù)的文件依賴配置xx后綴文件同樣的loader
- 為Vue文件配置一個(gè)公共的loader:pitcher
- 將[pitchLoder, ...clonedRules, ...rules]作為webapck新的rules
// vue-loader/lib/plugin.js
const rawRules = compiler.options.module.rules // 原始的rules配置信息
const { rules } = new RuleSet(rawRules)
// cloneRule會修改原始rule的resource和resourceQuery配置,攜帶特殊query的文件路徑將被應(yīng)用對應(yīng)rule
const clonedRules = rules
.filter(r => r !== vueRule)
.map(cloneRule)
// vue文件公共的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
}
}
// 更新webpack的rules配置,這樣vue單文件中的各個(gè)標(biāo)簽可以應(yīng)用clonedRules相關(guān)的配置
compiler.options.module.rules = [
pitcher,
...clonedRules,
...rules
]
因此,為vue單文件組件中每個(gè)標(biāo)簽執(zhí)行的lang屬性,也可以應(yīng)用在webpack配置同樣后綴的rule。這種設(shè)計(jì)就可以保證在不侵入vue-loader的情況下,為每個(gè)標(biāo)簽配置獨(dú)立的loader,如
- 可以使用pug編寫template,然后配置pug-plain-loader
- 可以使用scss或less編寫style,然后配置相關(guān)預(yù)處理器loader
可見在VueLoaderPlugin主要做的兩件事,一個(gè)是注冊公共的pitcher,一個(gè)是復(fù)制webpack的rules。
vue-loader
接下來我們看看vue-loader做的事情。
pitcher
前面提到在VueLoaderPlugin中,該loader在pitch中會根據(jù)query.type注入處理對應(yīng)標(biāo)簽的loader
- 當(dāng)type為style時(shí),在css-loader后插入stylePostLoader,保證stylePostLoader在execution階段先執(zhí)行
- 當(dāng)type為template時(shí),插入templateLoader
// pitcher.js
module.exports = code => code
module.exports.pitch = function (remainingRequest) {
if (query.type === `style`) {
// 會查詢cssLoaderIndex并將其放在afterLoaders中
// loader在execution階段是從后向前執(zhí)行的
const request = genRequest([
...afterLoaders,
stylePostLoaderPath, // 執(zhí)行l(wèi)ib/loaders/stylePostLoader.js
...beforeLoaders
])
return `import mod from ${request}; export default mod; 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`, // 執(zhí)行l(wèi)ib/loaders/templateLoader.js
...preLoaders
])
return `export * from ${request}`
}
// ...
}
由于loader.pitch會先于loader,在捕獲階段執(zhí)行,因此主要進(jìn)行上面的準(zhǔn)備工作:檢查query.type并直接調(diào)用相關(guān)的loader
- type=style,執(zhí)行stylePostLoader
- type=template,執(zhí)行templateLoader
這兩個(gè)loader的具體作用我們后面再研究。
vueLoader
接下來看看vue-loader里面做的工作,當(dāng)引入一個(gè)x.vue文件時(shí)
// vue-loader/lib/index.js 下面source為Vue代碼文件原始內(nèi)容
// 將單個(gè)*.vue文件內(nèi)容解析成一個(gè)descriptor對象,也稱為SFC(Single-File Components)對象
// descriptor包含template、script、style等標(biāo)簽的屬性和內(nèi)容,方便為每種標(biāo)簽做對應(yīng)處理
const descriptor = parse({
source,
compiler: options.compiler || loadTemplateCompiler(loaderContext),
filename,
sourceRoot,
needMap: sourceMap
})
// 為單文件組件生成唯一哈希id
const id = hash(
isProduction
? (shortFilePath + '\n' + source)
: shortFilePath
)
// 如果某個(gè)style標(biāo)簽包含scoped屬性,則需要進(jìn)行CSS Scoped處理,這也是本章節(jié)需要研究的地方
const hasScoped = descriptor.styles.some(s => s.scoped)
處理template標(biāo)簽,拼接type=template等query參數(shù)
if (descriptor.template) {
const src = descriptor.template.src || resourcePath
const idQuery = `&id=${id}`
// 傳入文件id和scoped=true,在為組件的每個(gè)HTML標(biāo)簽傳入組件id時(shí)需要這兩個(gè)參數(shù)
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)
// type=template的文件會傳給templateLoader處理
templateImport = `import { render, staticRenderFns } from ${request}`
// 比如,<template lang="pug"></template>標(biāo)簽
// 將被解析成 import { render, staticRenderFns } from "./source.vue?vue&type=template&id=27e4e96e&lang=pug&"
}
處理script標(biāo)簽
let scriptImport = `var script = {}`
if (descriptor.script) {
// vue-loader沒有對script做過多的處理
// 比如vue文件中的<script></script>標(biāo)簽將被解析成
// import script from "./source.vue?vue&type=script&lang=js&"
// export * from "./source.vue?vue&type=script&lang=js&"
}
處理style標(biāo)簽,為每個(gè)標(biāo)簽拼接type=style等參數(shù)
// 在genStylesCode中,會處理css scoped和css moudle
stylesCode = genStylesCode(
loaderContext,
descriptor.styles,
id,
resourcePath,
stringifyRequest,
needsHotReload,
isServer || isShadow // needs explicit injection?
)
// 由于一個(gè)vue文件里面可能存在多個(gè)style標(biāo)簽,對于每個(gè)標(biāo)簽,將調(diào)用genStyleRequest生成對應(yīng)文件的依賴
function genStyleRequest (style, i) {
const src = style.src || resourcePath
const attrsQuery = attrsToQuery(style.attrs, 'css')
const inheritQuery = `&${loaderContext.resourceQuery.slice(1)}`
const idQuery = style.scoped ? `&id=${id}` : ``
// type=style將傳給stylePostLoader進(jìn)行處理
const query = `?vue&type=style&index=${i}${idQuery}${attrsQuery}${inheritQuery}`
return stringifyRequest(src + query)
}
可見在vue-loader中,主要是將整個(gè)文件按照標(biāo)簽拼接對應(yīng)的query路徑,然后交給webpack按順序調(diào)用相關(guān)的loader。
templateLoader
回到開頭提到的第一個(gè)問題:當(dāng)前組件中,渲染出來的每個(gè)HTML標(biāo)簽中的hash屬性是如何生成的。
我們知道,一個(gè)組件的render方法返回的VNode,描述了組件對應(yīng)的HTML標(biāo)簽和結(jié)構(gòu),HTML標(biāo)簽對應(yīng)的DOM節(jié)點(diǎn)是從虛擬DOM節(jié)點(diǎn)構(gòu)建的,一個(gè)Vnode包含了渲染DOM節(jié)點(diǎn)需要的基本屬性。
那么,我們只需要了解到vnode上組件文件的哈希id的賦值過程,后面的問題就迎刃而解了。
// templateLoader.js
const { compileTemplate } = require('@vue/component-compiler-utils')
module.exports = function (source) {
const { id } = query
const options = loaderUtils.getOptions(loaderContext) || {}
const compiler = options.compiler || require('vue-template-compiler')
// 可以看見,scopre=true的template的文件會生成一個(gè)scopeId
const compilerOptions = Object.assign({
outputSourceRange: true
}, options.compilerOptions, {
scopeId: query.scoped ? `data-v-${id}` : null,
comments: query.comments
})
// 合并compileTemplate最終參數(shù),傳入compilerOptions和compiler
const finalOptions = {source, filename: this.resourcePath, compiler,compilerOptions}
const compiled = compileTemplate(finalOptions)
const { code } = compiled
// finish with ESM exports
return code + `\nexport { render, staticRenderFns }`
}
關(guān)于compileTemplate的實(shí)現(xiàn),我們不用去關(guān)心其細(xì)節(jié),其內(nèi)部主要是調(diào)用了配置參數(shù)compiler的編譯方法
function actuallyCompile(options) {
const compile = optimizeSSR && compiler.ssrCompile ? compiler.ssrCompile : compiler.compile
const { render, staticRenderFns, tips, errors } = compile(source, finalCompilerOptions);
// ...
}
在Vue源碼中可以了解到,template屬性會通過compileToFunctions編譯成render方法;在vue-loader中,這一步是可以通過vue-template-compiler提前在打包階段處理的。
vue-template-compiler是隨著Vue源碼一起發(fā)布的一個(gè)包,當(dāng)二者同時(shí)使用時(shí),需要保證他們的版本號一致,否則會提示錯(cuò)誤。這樣,compiler.compile實(shí)際上是Vue源碼中vue/src/compiler/index.js的baseCompile方法,追著源碼一致翻下去,可以發(fā)現(xiàn)
// elementToOpenTagSegments.js
// 對于單個(gè)標(biāo)簽的屬性,將拆分成一個(gè)segments
function elementToOpenTagSegments (el, state): Array<StringSegment> {
applyModelTransform(el, state)
let binding
const segments = [{ type: RAW, value: `<${el.tag}` }]
// ... 處理attrs、domProps、v-bind、style、等屬性
// _scopedId
if (state.options.scopeId) {
segments.push({ type: RAW, value: ` ${state.options.scopeId}` })
}
segments.push({ type: RAW, value: `>` })
return segments
}
以前面的<div class="demo"></div>為例,解析得到的segments為
[
{ type: RAW, value: '<div' },
{ type: RAW, value: 'class=demo' },
{ type: RAW, value: 'data-v-27e4e96e' }, // 傳入的scopeId
{ type: RAW, value: '>' },
]
至此,我們知道了在templateLoader中,會根據(jù)單文件組件的id,拼接一個(gè)scopeId,并作為compilerOptions傳入編譯器中,被解析成vnode的配置屬性,然后在render函數(shù)執(zhí)行時(shí)調(diào)用createElement,作為vnode的原始屬性,渲染成到DOM節(jié)點(diǎn)上。
stylePostLoader
在stylePostLoader中,需要做的工作就是將所有選擇器都增加一個(gè)屬性選擇器的組合限制,
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}`, // 同一個(gè)單頁面組件中的style,與templateLoader中的scopeId保持一致
map: inMap,
scoped: !!query.scoped,
trim: true
})
this.callback(null, code, map)
}
我們需要了解compileStyle的邏輯
// @vue/component-compiler-utils/compileStyle.ts
import scopedPlugin from './stylePlugins/scoped'
function doCompileStyle(options) {
const { filename, id, scoped = true, trim = true, preprocessLang, postcssOptions, postcssPlugins } = options;
if (scoped) {
plugins.push(scopedPlugin(id));
}
const postCSSOptions = Object.assign({}, postcssOptions, { to: filename, from: filename });
// 省略了相關(guān)判斷
let result = postcss(plugins).process(source, postCSSOptions);
}
最后讓我們在了解一下scopedPlugin的實(shí)現(xiàn),
export default postcss.plugin('add-id', (options: any) => (root: Root) => {
const id: string = options
const keyframes = Object.create(null)
root.each(function rewriteSelector(node: any) {
node.selector = selectorParser((selectors: any) => {
selectors.each((selector: any) => {
let node: any = null
// 處理 '>>>' 、 '/deep/'、::v-deep、pseudo等特殊選擇器時(shí),將不會執(zhí)行下面添加屬性選擇器的邏輯
// 為當(dāng)前選擇器添加一個(gè)屬性選擇器[id],id即為傳入的scopeId
selector.insertAfter(
node,
selectorParser.attribute({
attribute: id
})
)
})
}).processSync(node.selector)
})
})
由于我對于PostCSS的插件開發(fā)并不是很熟悉,這里只能大致整理,翻翻文檔了,相關(guān)API可以參考Writing a PostCSS Plugin。
至此,我們就知道了第二個(gè)問題的答案:通過selector.insertAfter為當(dāng)前styles下的每一個(gè)選擇器添加了屬性選擇器,其值即為傳入的scopeId。由于只有當(dāng)前組件渲染的DOM節(jié)點(diǎn)上上面存在相同的屬性,從而就實(shí)現(xiàn)了css scoped的效果。
小結(jié)
回過頭來整理一下vue-loader的工作流程
首先需要在webpack配置中注冊VueLoaderPlugin
- 在插件中,會復(fù)制當(dāng)前項(xiàng)目webpack配置中的rules項(xiàng),當(dāng)資源路徑包含query.lang時(shí)通過resourceQuery匹配相同的rules并執(zhí)行對應(yīng)loader時(shí)
- 插入一個(gè)公共的loader,并在pitch階段根據(jù)query.type插入對應(yīng)的自定義loader
準(zhǔn)備工作完成后,當(dāng)加載*.vue時(shí)會調(diào)用vue-loader,
- 一個(gè)單頁面組件文件會被解析成一個(gè)descriptor對象,包含template、script、styles等屬性對應(yīng)各個(gè)標(biāo)簽,
- 對于每個(gè)標(biāo)簽,會根據(jù)標(biāo)簽屬性拼接src?vue&query引用代碼,其中src為單頁面組件路徑,query為一些特性的參數(shù),比較重要的有l(wèi)ang、type和scoped
- 如果包含lang屬性,會匹配與該后綴相同的rules并應(yīng)用對應(yīng)的loaders
- 根據(jù)type執(zhí)行對應(yīng)的自定義loader,template將執(zhí)行templateLoader、style將執(zhí)行stylePostLoader
在templateLoader中,會通過vue-template-compiler將template轉(zhuǎn)換為render函數(shù),在此過程中,
- 會將傳入的scopeId追加到每個(gè)標(biāo)簽的segments上,最后作為vnode的配置屬性傳遞給createElemenet方法,
- 在render函數(shù)調(diào)用并渲染頁面時(shí),會將scopeId屬性作為原始屬性渲染到頁面上
在stylePostLoader中,通過PostCSS解析style標(biāo)簽內(nèi)容,同時(shí)通過scopedPlugin為每個(gè)選擇器追加一個(gè)[scopeId]的屬性選擇器
由于需要Vue源碼方面的支持(vue-template-compiler編譯器),CSS Scoped可以算作為Vue定制的一個(gè)處理原生CSS全局作用域的解決方案。除了 css scoped之外,vue還支持css module,我打算在下一篇整理React中編寫CSS的博客中一并對比整理。
小結(jié)
最近一直在寫React的項(xiàng)目,嘗試了好幾種在React中編寫CSS的方式,包括CSS Module、Style Component等方式,感覺都比較繁瑣。相比而言,在Vue中單頁面組件中寫CSS要方便很多。
本文主要從源碼層面分析了Vue-loader,整理了其工作原理,感覺收獲頗豐
- webpack中Rules.resourceQuery和pitch loader的使用
- Vue單頁面文件中css scoped的實(shí)現(xiàn)原理
- PostCSS插件的作用
雖然一直在使用webpack和PostCSS,但也僅限于勉強(qiáng)會用的階段,比如我甚至從來沒有過編寫一個(gè)PostCSS插件的想法。盡管目前大部分項(xiàng)目都使用了封裝好的腳手架,但對于這些基礎(chǔ)知識,還是很有必要去了解其實(shí)現(xiàn)的。
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
vue+element-ui前端使用print-js實(shí)現(xiàn)打印功能(可自定義樣式)
Print.js主要是為了幫助我們直接在瀏覽器中開發(fā)打印功能,下面這篇文章主要給大家介紹了關(guān)于vue+element-ui前端使用print-js實(shí)現(xiàn)打印功能(可自定義樣式)的相關(guān)資料,需要的朋友可以參考下2022-11-11
安裝vue-cli報(bào)錯(cuò) -4058 的解決方法
這篇文章主要介紹了安裝vue-cli報(bào)錯(cuò) -4058 的解決方法,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-10-10
vue使用v-if v-show頁面閃爍,div閃現(xiàn)的解決方法
在頁面層次結(jié)構(gòu),數(shù)據(jù)較多的時(shí)候,用v-if或者v-show就會出現(xiàn)div閃現(xiàn),或者部分閃爍的結(jié)果。怎么處理這樣的問題呢,下面小編給大家?guī)砹藇ue使用v-if v-show頁面閃爍,div閃現(xiàn)的解決方法,一起看看吧2018-10-10
Vue組件實(shí)現(xiàn)數(shù)字滾動抽獎效果
這篇文章主要為大家詳細(xì)介紹了Vue組件實(shí)現(xiàn)數(shù)字滾動抽獎效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-03-03
Vue3中如何使用v-model高級用法參數(shù)綁定傳值
本文給大家介紹Vue3中使用v-model高級用法參數(shù)綁定傳值的相關(guān)知識,包括單個(gè)輸入框傳值和多個(gè)輸入框傳值,一個(gè)組件接受多個(gè)v-model值,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),需要的朋友參考下吧2023-10-10
vue3+高德地圖只展示指定市、區(qū)行政區(qū)域的地圖以及遮罩反向鏤空其他地區(qū)
vue大屏項(xiàng)目開發(fā),客戶覺得地圖上的文字標(biāo)注太多了,要求地圖上只顯示省市等主要城市的標(biāo)注,這篇文章主要給大家介紹了關(guān)于vue3+高德地圖只展示指定市、區(qū)行政區(qū)域的地圖以及遮罩反向鏤空其他地區(qū)的相關(guān)資料,需要的朋友可以參考下2024-02-02

