本文同步在我的博客shymean.com上,歡迎關注css
雖然寫了很長一段時間的Vue了,對於CSS Scoped
的原理也大體瞭解,但一直不曾關注過其實現細節。最近在從新學習webpack,所以查看了vue-loader
源碼,順便從vue-loader
的源碼中整理CSS Scoped
的實現。vue
本文展現了vue-loader
中的一些源碼片斷,爲了便於理解,稍做刪減。參考node
在Vue單文件組件中,咱們只須要在style
標籤上加上scoped
屬性,就能夠實現標籤內的樣式在當前模板輸出的HTML標籤上生效,其實現原理以下webpack
每一個Vue文件都將對應一個惟一的id,該id能夠根據文件路徑名和內容hash生成git
編譯template
標籤時時爲每一個標籤添加了當前組件的id,如<div class="demo"></div>
會被編譯成<div class="demo" data-v-27e4e96e></div>
github
編譯style
標籤時,會根據當前組件的id經過屬性選擇器和組合選擇器輸出樣式,如.demo{color: red;}
會被編譯成.demo[data-v-27e4e96e]{color: red;}
web
瞭解了大體原理,能夠想到css scoped應該須要同時處理template和style的內容,如今概括須要探尋的問題api
data-v-xxx
屬性是如何生成的在此以前,須要瞭解首一下webpack中Rules.resourceQuery的做用。在配置loader時,大部分時候咱們只須要經過test
匹配文件類型便可app
{
test: /\.vue$/,
loader: 'vue-loader'
}
// 當引入vue後綴文件時,將文件內容傳輸給vue-loader進行處理
import Foo from './source.vue'
複製代碼
resourceQuery
提供了根據引入文件路徑參數的形式匹配路徑less
{
resourceQuery: /shymean=true/,
loader: path.resolve(__dirname, './test-loader.js')
}
// 當引入文件路徑攜帶query參數匹配時,也將加載該loader
import './test.js?shymean=true'
import Foo from './source.vue?shymean=true'
複製代碼
vue-loader
中就是經過resourceQuery
並拼接不一樣的query參數,將各個標籤分配給對應的loader進行處理。
參考
webpack中loaders的執行順序是從右到左執行的,如loaders:[a, b, c]
,loader的執行順序是c->b->a
,且下一個loader接收到的是上一個loader的返回值,這個過程跟"事件冒泡"很像。
可是在某些場景下,咱們可能但願在"捕獲"階段就執行loader的一些方法,所以webpack提供了loader.pitch
的接口。
一個文件被多個loader處理的真實執行流程,以下所示
a.pitch -> b.pitch -> c.pitch -> request module -> c -> b -> a
複製代碼
loader和pitch的接口定義大概以下所示
// loader文件導出的真實接口,content是上一個loader或文件的原始內容
module.exports = function loader(content){
// 能夠訪問到在pitch掛載到data上的數據
console.log(this.data.value) // 100
}
// remainingRequest表示剩餘的請求,precedingRequest表示以前的請求
// data是一個上下文對象,在上面的loader方法中能夠經過this.data訪問到,所以能夠在pitch階段提早掛載一些數據
module.exports.pitch = function pitch(remainingRequest, precedingRequest, data) {
data.value = 100
}}
複製代碼
正常狀況下,一個loader在execution階段會返回通過處理後的文件文本內容。若是在pitch方法中直接返回了內容,則webpack會視爲後面的loader已經執行完畢(包括pitch和execution階段)。
在上面的例子中,若是b.pitch
返回了result b
,則再也不執行c,則是直接將result b
傳給了a。
接下來看看與vue-loader
配套的插件:VueLoaderPlugin
,該插件的做用是:
將在
webpack.config
定義過的其它規則複製並應用到.vue
文件裏相應語言的塊中。
其大體工做流程以下所示
webpack
配置的rules
項,而後複製rules
,爲攜帶了?vue&lang=xx...
query參數的文件依賴配置xx
後綴文件一樣的loaderpitcher
[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的文件路徑將被應用對應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單文件中的各個標籤能夠應用clonedRules相關的配置
compiler.options.module.rules = [
pitcher,
...clonedRules,
...rules
]
複製代碼
所以,爲vue單文件組件中每一個標籤執行的lang屬性,也能夠應用在webpack配置一樣後綴的rule。這種設計就能夠保證在不侵入vue-loader的狀況下,爲每一個標籤配置獨立的loader,如
pug
編寫template,而後配置pug-plain-loader
scss
或less
編寫style,而後配置相關預處理器loader可見在VueLoaderPlugin
主要作的兩件事,一個是註冊公共的pitcher
,一個是複製webpack的rules
。
接下來咱們看看vue-loader
作的事情。
前面提到在VueLoaderPlugin
中,該loader在pitch中會根據query.type
注入處理對應標籤的loader
css-loader
後插入stylePostLoader
,保證stylePostLoader
在execution階段先執行templateLoader
// pitcher.js
module.exports = code => code
module.exports.pitch = function (remainingRequest) {
if (query.type === `style`) {
// 會查詢cssLoaderIndex並將其放在afterLoaders中
// loader在execution階段是從後向前執行的
const request = genRequest([
...afterLoaders,
stylePostLoaderPath, // 執行lib/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`, // 執行lib/loaders/templateLoader.js
...preLoaders
])
return `export * from ${request}`
}
// ...
}
複製代碼
因爲loader.pitch
會先於loader,在捕獲階段執行,所以主要進行上面的準備工做:檢查query.type
並直接調用相關的loader
type=style
,執行stylePostLoader
type=template
,執行templateLoader
這兩個loader的具體做用咱們後面再研究。
接下來看看vue-loader
裏面作的工做,當引入一個x.vue
文件時
// vue-loader/lib/index.js 下面source爲Vue代碼文件原始內容
// 將單個*.vue文件內容解析成一個descriptor對象,也稱爲SFC(Single-File Components)對象
// descriptor包含template、script、style等標籤的屬性和內容,方便爲每種標籤作對應處理
const descriptor = parse({
source,
compiler: options.compiler || loadTemplateCompiler(loaderContext),
filename,
sourceRoot,
needMap: sourceMap
})
// 爲單文件組件生成惟一哈希id
const id = hash(
isProduction
? (shortFilePath + '\n' + source)
: shortFilePath
)
// 若是某個style標籤包含scoped屬性,則須要進行CSS Scoped處理,這也是本章節須要研究的地方
const hasScoped = descriptor.styles.some(s => s.scoped)
複製代碼
處理template標籤
,拼接type=template
等query參數
if (descriptor.template) {
const src = descriptor.template.src || resourcePath
const idQuery = `&id=${id}`
// 傳入文件id和scoped=true,在爲組件的每一個HTML標籤傳入組件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)
// type=template的文件會傳給templateLoader處理
templateImport = `import { render, staticRenderFns } from ${request}`
// 好比,<template lang="pug"></template>標籤
// 將被解析成 import { render, staticRenderFns } from "./source.vue?vue&type=template&id=27e4e96e&lang=pug&"
}
複製代碼
處理script
標籤
let scriptImport = `var script = {}`
if (descriptor.script) {
// vue-loader沒有對script作過多的處理
// 好比vue文件中的<script></script>標籤將被解析成
// import script from "./source.vue?vue&type=script&lang=js&"
// export * from "./source.vue?vue&type=script&lang=js&"
}
複製代碼
處理style
標籤,爲每一個標籤拼接type=style
等參數
// 在genStylesCode中,會處理css scoped和css moudle
stylesCode = genStylesCode(
loaderContext,
descriptor.styles,
id,
resourcePath,
stringifyRequest,
needsHotReload,
isServer || isShadow // needs explicit injection?
)
// 因爲一個vue文件裏面可能存在多個style標籤,對於每一個標籤,將調用genStyleRequest生成對應文件的依賴
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進行處理
const query = `?vue&type=style&index=${i}${idQuery}${attrsQuery}${inheritQuery}`
return stringifyRequest(src + query)
}
複製代碼
可見在vue-loader
中,主要是將整個文件按照標籤拼接對應的query路徑,而後交給webpack按順序調用相關的loader。
回到開頭提到的第一個問題:當前組件中,渲染出來的每一個HTML標籤中的hash屬性是如何生成的。
咱們知道,一個組件的render方法返回的VNode,描述了組件對應的HTML標籤和結構,HTML標籤對應的DOM節點是從虛擬DOM節點構建的,一個Vnode包含了渲染DOM節點須要的基本屬性。
那麼,咱們只須要了解到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的文件會生成一個scopeId
const compilerOptions = Object.assign({
outputSourceRange: true
}, options.compilerOptions, {
scopeId: query.scoped ? `data-v-${id}` : null,
comments: query.comments
})
// 合併compileTemplate最終參數,傳入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 }`
}
複製代碼
關於compileTemplate
的實現,咱們不用去關心其細節,其內部主要是調用了配置參數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
源碼一塊兒發佈的一個包,當兩者同時使用時,須要保證他們的版本號一致,不然會提示錯誤。這樣,compiler.compile
其實是Vue源碼中vue/src/compiler/index.js
的baseCompile
方法,追着源碼一致翻下去,能夠發現
// elementToOpenTagSegments.js
// 對於單個標籤的屬性,將拆分紅一個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
中,會根據單文件組件的id,拼接一個scopeId
,並做爲compilerOptions
傳入編譯器中,被解析成vnode的配置屬性,而後在render函數執行時調用createElement
,做爲vnode的原始屬性,渲染成到DOM節點上。
在stylePostLoader
中,須要作的工做就是將全部選擇器都增長一個屬性選擇器的組合限制,
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}`, // 同一個單頁面組件中的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 });
// 省略了相關判斷
let result = postcss(plugins).process(source, postCSSOptions);
}
複製代碼
最後讓咱們在瞭解一下scopedPlugin
的實現,
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等特殊選擇器時,將不會執行下面添加屬性選擇器的邏輯
// 爲當前選擇器添加一個屬性選擇器[id],id即爲傳入的scopeId
selector.insertAfter(
node,
selectorParser.attribute({
attribute: id
})
)
})
}).processSync(node.selector)
})
})
複製代碼
因爲我對於PostCSS
的插件開發並非很熟悉,這裏只能大體整理,翻翻文檔了,相關API能夠參考Writing a PostCSS Plugin。
至此,咱們就知道了第二個問題的答案:經過selector.insertAfter
爲當前styles下的每個選擇器添加了屬性選擇器,其值即爲傳入的scopeId。因爲只有當前組件渲染的DOM節點上上面存在相同的屬性,從而就實現了css scoped
的效果。
回過頭來整理一下vue-loader
的工做流程
VueLoaderPlugin
query.lang
時經過resourceQuery
匹配相同的rules並執行對應loader時pitch
階段根據query.type
插入對應的自定義loader*.vue
時會調用vue-loader
,
descriptor
對象,包含template
、script
、styles
等屬性對應各個標籤,src?vue&query
引用代碼,其中src
爲單頁面組件路徑,query爲一些特性的參數,比較重要的有lang
、type
和scoped
lang
屬性,會匹配與該後綴相同的rules並應用對應的loaderstype
執行對應的自定義loader,template
將執行templateLoader
、style
將執行stylePostLoader
templateLoader
中,會經過vue-template-compiler
將template轉換爲render函數,在此過程當中,
scopeId
追加到每一個標籤的segments
上,最後做爲vnode的配置屬性傳遞給createElemenet
方法,scopeId
屬性做爲原始屬性渲染到頁面上stylePostLoader
中,經過PostCSS解析style標籤內容,同時經過scopedPlugin
爲每一個選擇器追加一個[scopeId]
的屬性選擇器因爲須要Vue源碼方面的支持(vue-template-compiler
編譯器),CSS Scoped能夠算做爲Vue定製的一個處理原生CSS全局做用域的解決方案。除了 css scoped以外,vue還支持css module
,我打算在下一篇整理React中編寫CSS的博客中一併對比整理。
最近一直在寫React的項目,嘗試了好幾種在React中編寫CSS的方式,包括CSS Module
、Style Component
等方式,感受都比較繁瑣。相比而言,在Vue中單頁面組件中寫CSS要方便不少。
本文主要從源碼層面分析了Vue-loader
,整理了其工做原理,感受收穫頗豐
Rules.resourceQuery
和pitch loader
的使用css scoped
的實現原理雖然一直在使用webpack和PostCSS,但也僅限於勉強會用的階段,好比我甚至歷來沒有過編寫一個PostCSS插件的想法。儘管目前大部分項目都使用了封裝好的腳手架,但對於這些基礎知識,仍是頗有必要去了解其實現的。