溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

CSS Scoped的實(shí)現(xiàn)原理分析

發(fā)布時間:2021-06-29 14:48:13 來源:億速云 閱讀:964 作者:小新 欄目:web開發(fā)

這篇文章主要介紹CSS Scoped的實(shí)現(xiàn)原理分析,文中介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們一定要看完!

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)原理如下

  • 每個Vue文件都將對應(yīng)一個唯一的id,該id可以根據(jù)文件路徑名和內(nèi)容hash生成

  • 編譯template標(biāo)簽時時為每個標(biāo)簽添加了當(dāng)前組件的id,如<div class="demo"></div>會被編譯成<div class="demo" data-v-27e4e96e></div>

  • 編譯style標(biāo)簽時,會根據(jù)當(dāng)前組件的id通過屬性選擇器和組合選擇器輸出樣式,如.demo{color: red;}會被編譯成.demo[data-v-27e4e96e]{color: red;}

了解了大致原理,可以想到css scoped應(yīng)該需要同時處理template和style的內(nèi)容,現(xiàn)在歸納需要探尋的問題

  • 渲染的HTML標(biāo)簽上的data-v-xxx屬性是如何生成的

  • CSS代碼中的添加的屬性選擇器是如何實(shí)現(xiàn)的

resourceQuery

在此之前,需要了解首一下webpack中Rules.resourceQuery的作用。在配置loader時,大部分時候我們只需要通過test匹配文件類型即可

{
 test: /\.vue$/,
 loader: 'vue-loader'
}
// 當(dāng)引入vue后綴文件時,將文件內(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ù)匹配時,也將加載該loader
import './test.js?shymean=true'
import Foo from './source.vue?shymean=true'

vue-loader中就是通過resourceQuery并拼接不同的query參數(shù),將各個標(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,且下一個loader接收到的是上一個loader的返回值,這個過程跟"事件冒泡"很像。

但是在某些場景下,我們可能希望在"捕獲"階段就執(zhí)行l(wèi)oader的一些方法,因此webpack提供了loader.pitch的接口。
一個文件被多個loader處理的真實(shí)執(zhí)行流程,如下所示

a.pitch -> b.pitch -> c.pitch -> request module -> c -> b -> a

loader和pitch的接口定義大概如下所示

// loader文件導(dǎo)出的真實(shí)接口,content是上一個loader或文件的原始內(nèi)容
module.exports = function loader(content){
 // 可以訪問到在pitch掛載到data上的數(shù)據(jù)
 console.log(this.data.value) // 100
}
// remainingRequest表示剩余的請求,precedingRequest表示之前的請求
// data是一個上下文對象,在上面的loader方法中可以通過this.data訪問到,因此可以在pitch階段提前掛載一些數(shù)據(jù)
module.exports.pitch = function pitch(remainingRequest, precedingRequest, data) {
 data.value = 100
}}

正常情況下,一個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文件配置一個公共的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單文件中的各個標(biāo)簽可以應(yīng)用clonedRules相關(guān)的配置
compiler.options.module.rules = [
 pitcher,
 ...clonedRules,
 ...rules
]

因此,為vue單文件組件中每個標(biāo)簽執(zhí)行的lang屬性,也可以應(yīng)用在webpack配置同樣后綴的rule。這種設(shè)計(jì)就可以保證在不侵入vue-loader的情況下,為每個標(biāo)簽配置獨(dú)立的loader,如

  1. 可以使用pug編寫template,然后配置pug-plain-loader

  2. 可以使用scss或less編寫style,然后配置相關(guān)預(yù)處理器loader

可見在VueLoaderPlugin主要做的兩件事,一個是注冊公共的pitcher,一個是復(fù)制webpack的rules。

vue-loader

接下來我們看看vue-loader做的事情。

pitcher

前面提到在VueLoaderPlugin中,該loader在pitch中會根據(jù)query.type注入處理對應(yīng)標(biāo)簽的loader

  • 當(dāng)type為style時,在css-loader后插入stylePostLoader,保證stylePostLoader在execution階段先執(zhí)行

  • 當(dāng)type為template時,插入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

這兩個loader的具體作用我們后面再研究。

vueLoader

接下來看看vue-loader里面做的工作,當(dāng)引入一個x.vue文件時

// vue-loader/lib/index.js 下面source為Vue代碼文件原始內(nèi)容

// 將單個*.vue文件內(nèi)容解析成一個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
)
// 如果某個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,在為組件的每個HTML標(biāo)簽傳入組件id時需要這兩個參數(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)簽,為每個標(biāo)簽拼接type=style等參數(shù)

// 在genStylesCode中,會處理css scoped和css moudle
stylesCode = genStylesCode(
 loaderContext,
 descriptor.styles, 
 id,
 resourcePath,
 stringifyRequest,
 needsHotReload,
 isServer || isShadow // needs explicit injection?
)

// 由于一個vue文件里面可能存在多個style標(biāo)簽,對于每個標(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中,主要是將整個文件按照標(biāo)簽拼接對應(yīng)的query路徑,然后交給webpack按順序調(diào)用相關(guān)的loader。

templateLoader

回到開頭提到的第一個問題:當(dāng)前組件中,渲染出來的每個HTML標(biāo)簽中的hash屬性是如何生成的。

我們知道,一個組件的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)建的,一個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的文件會生成一個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ā)布的一個包,當(dāng)二者同時使用時,需要保證他們的版本號一致,否則會提示錯誤。這樣,compiler.compile實(shí)際上是Vue源碼中vue/src/compiler/index.js的baseCompile方法,追著源碼一致翻下去,可以發(fā)現(xiàn)

// elementToOpenTagSegments.js
// 對于單個標(biāo)簽的屬性,將拆分成一個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,拼接一個scopeId,并作為compilerOptions傳入編譯器中,被解析成vnode的配置屬性,然后在render函數(shù)執(zhí)行時調(diào)用createElement,作為vnode的原始屬性,渲染成到DOM節(jié)點(diǎn)上。

stylePostLoader

在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 });
 // 省略了相關(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等特殊選擇器時,將不會執(zhí)行下面添加屬性選擇器的邏輯

    // 為當(dāng)前選擇器添加一個屬性選擇器[id],id即為傳入的scopeId
    selector.insertAfter(
     node,
     selectorParser.attribute({
      attribute: id
     })
    )
   })
  }).processSync(node.selector)
 })
})

由于我對于PostCSS的插件開發(fā)并不是很熟悉,這里只能大致整理,翻翻文檔了,相關(guān)API可以參考Writing a PostCSS Plugin。

至此,我們就知道了第二個問題的答案:通過selector.insertAfter為當(dāng)前styles下的每一個選擇器添加了屬性選擇器,其值即為傳入的scopeId。由于只有當(dāng)前組件渲染的DOM節(jié)點(diǎn)上上面存在相同的屬性,從而就實(shí)現(xiàn)了css scoped的效果。

小結(jié)

回過頭來整理一下vue-loader的工作流程

首先需要在webpack配置中注冊VueLoaderPlugin

  1. 在插件中,會復(fù)制當(dāng)前項(xiàng)目webpack配置中的rules項(xiàng),當(dāng)資源路徑包含query.lang時通過resourceQuery匹配相同的rules并執(zhí)行對應(yīng)loader時

  2. 插入一個公共的loader,并在pitch階段根據(jù)query.type插入對應(yīng)的自定義loader

準(zhǔn)備工作完成后,當(dāng)加載*.vue時會調(diào)用vue-loader,

  • 一個單頁面組件文件會被解析成一個descriptor對象,包含template、script、styles等屬性對應(yīng)各個標(biāo)簽,

  • 對于每個標(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追加到每個標(biāo)簽的segments上,最后作為vnode的配置屬性傳遞給createElemenet方法,

  • 在render函數(shù)調(diào)用并渲染頁面時,會將scopeId屬性作為原始屬性渲染到頁面上

在stylePostLoader中,通過PostCSS解析style標(biāo)簽內(nèi)容,同時通過scopedPlugin為每個選擇器追加一個[scopeId]的屬性選擇器

由于需要Vue源碼方面的支持(vue-template-compiler編譯器),CSS Scoped可以算作為Vue定制的一個處理原生CSS全局作用域的解決方案。除了 css scoped之外,vue還支持css module,我打算在下一篇整理React中編寫CSS的博客中一并對比整理。

小結(jié)

最近一直在寫React的項(xiàng)目,嘗試了好幾種在React中編寫CSS的方式,包括CSS Module、Style Component等方式,感覺都比較繁瑣。相比而言,在Vue中單頁面組件中寫CSS要方便很多。

本文主要從源碼層面分析了Vue-loader,整理了其工作原理,感覺收獲頗豐

  1. webpack中Rules.resourceQuery和pitch loader的使用

  2. Vue單頁面文件中css scoped的實(shí)現(xiàn)原理

  3. PostCSS插件的作用

雖然一直在使用webpack和PostCSS,但也僅限于勉強(qiáng)會用的階段,比如我甚至從來沒有過編寫一個PostCSS插件的想法。盡管目前大部分項(xiàng)目都使用了封裝好的腳手架,但對于這些基礎(chǔ)知識,還是很有必要去了解其實(shí)現(xiàn)的。

以上是“CSS Scoped的實(shí)現(xiàn)原理分析”這篇文章的所有內(nèi)容,感謝各位的閱讀!希望分享的內(nèi)容對大家有幫助,更多相關(guān)知識,歡迎關(guān)注億速云行業(yè)資訊頻道!

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI