從vue-loader源碼分析CSS Scoped的實現

本文同步在我的博客shymean.com上,歡迎關注css

雖然寫了很長一段時間的Vue了,對於CSS Scoped的原理也大體瞭解,但一直不曾關注過其實現細節。最近在從新學習webpack,所以查看了vue-loader源碼,順便從vue-loader的源碼中整理CSS Scoped的實現。vue

本文展現了vue-loader中的一些源碼片斷,爲了便於理解,稍做刪減。參考node

相關概念

CSS Scoped的實現原理

在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

  • 渲染的HTML標籤上的data-v-xxx屬性是如何生成的
  • CSS代碼中的添加的屬性選擇器是如何實現的

resourceQuery

在此以前,須要瞭解首一下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進行處理。

loader.pitch

參考

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。

VueLoaderPlugin

接下來看看與vue-loader配套的插件:VueLoaderPlugin,該插件的做用是:

將在webpack.config定義過的其它規則複製並應用到 .vue 文件裏相應語言的塊中。

其大體工做流程以下所示

  • 獲取項目webpack配置的rules項,而後複製rules,爲攜帶了?vue&lang=xx...query參數的文件依賴配置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的文件路徑將被應用對應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
  • 可使用scssless編寫style,而後配置相關預處理器loader

可見在VueLoaderPlugin主要作的兩件事,一個是註冊公共的pitcher,一個是複製webpack的rules

vue-loader

接下來咱們看看vue-loader作的事情。

pitcher

前面提到在VueLoaderPlugin中,該loader在pitch中會根據query.type注入處理對應標籤的loader

  • 當type爲style時,在css-loader後插入stylePostLoader,保證stylePostLoader在execution階段先執行
  • 當type爲template時,插入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的具體做用咱們後面再研究。

vueLoader

接下來看看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。

templateLoader

回到開頭提到的第一個問題:當前組件中,渲染出來的每一個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.jsbaseCompile方法,追着源碼一致翻下去,能夠發現

// 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

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的工做流程

  • 首先須要在webpack配置中註冊VueLoaderPlugin
    • 在插件中,會複製當前項目webpack配置中的rules項,當資源路徑包含query.lang時經過resourceQuery匹配相同的rules並執行對應loader時
    • 插入一個公共的loader,並在pitch階段根據query.type插入對應的自定義loader
  • 準備工做完成後,當加載*.vue時會調用vue-loader
    • 一個單頁面組件文件會被解析成一個descriptor對象,包含templatescriptstyles等屬性對應各個標籤,
    • 對於每一個標籤,會根據標籤屬性拼接src?vue&query引用代碼,其中src爲單頁面組件路徑,query爲一些特性的參數,比較重要的有langtypescoped
      • 若是包含lang屬性,會匹配與該後綴相同的rules並應用對應的loaders
      • 根據type執行對應的自定義loader,template將執行templateLoaderstyle將執行stylePostLoader
  • templateLoader中,會經過vue-template-compiler將template轉換爲render函數,在此過程當中,
    • 會將傳入的scopeId追加到每一個標籤的segments上,最後做爲vnode的配置屬性傳遞給createElemenet方法,
    • 在render函數調用並渲染頁面時,會將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 ModuleStyle Component等方式,感受都比較繁瑣。相比而言,在Vue中單頁面組件中寫CSS要方便不少。

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

  • webpack中Rules.resourceQuerypitch loader的使用
  • Vue單頁面文件中css scoped的實現原理
  • PostCSS插件的做用

雖然一直在使用webpack和PostCSS,但也僅限於勉強會用的階段,好比我甚至歷來沒有過編寫一個PostCSS插件的想法。儘管目前大部分項目都使用了封裝好的腳手架,但對於這些基礎知識,仍是頗有必要去了解其實現的。

相關文章
相關標籤/搜索