[深入探索] VueJS Scoped CSS 實現原理

Lionad-Morotar發表於2019-04-18

使用VueJS進行應用開發, 脫離不了對應用間的模組進行拆分, 將大塊介面拆解為元件的過程. 我們可以很方便的在單檔案中使用<template>塊維護元件的檢視, 使用<script>維護元件的邏輯部分, 使用<style>維護元件的樣式. 在我們編寫 VueJS 元件樣式時, 不得忽略的一點就是樣式汙染.

樣式汙染產生原因

提及樣式汙染, 主要要追溯到WebpackCSS檔案的打包過程, 這裡我們以Vue-Element-Admin中的Webpack配置項舉例:

const webpackConfig = merge(baseWebpackConfig, {
	plugins: [
		new MiniCssExtractPlugin({
          filename: utils.assetsPath('css/[name].[contenthash:8].css'),
          chunkFilename: utils.assetsPath('css/[name].[contenthash:8].css')
        }),
	]
})
複製程式碼

Webpack 使用 MiniCssExtractPlugin 外掛, 將檔案(如Vue單檔案元件)中的CSS程式碼, 經過處理後, 分離到形如app.hash1234.css的單獨的CSS檔案:

[深入探索] VueJS Scoped CSS 實現原理

如果沒有加入防止樣式汙染的措施的同時, 專案中存在了大量的同名 ClassName, 那麼可能會產生意想不到的CSS選擇器權重覆蓋. 這可能使後檔案中某部分選擇器權重更高的類影響整個應用, 而此過程通常發生在元件的編寫中, 所以一般稱之為元件樣式汙染.

Webpack & Vue SFC Object

對於 Vue 專案而言, 使用 Webpack 將極大的優化了工作流程, 因為通過Vue Loader, Vue 單檔案元件能很好的融合進 Webpack 工作流中. 通過跟蹤原始碼, 可以發現, 我們寫的單檔案元件都被處理為了SFC物件, 即包含了單個HTML模組, 單個指令碼模組, 一個或多個樣式模組, 一個或多個自定義模組的物件:

// vue-loader/index.js
const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(),
    filename,
    sourceRoot,
    needMap: sourceMap
})

// vuejs/component-compiler-utils/index.js
function parse(options) {
    const { compiler } = options
    output = compiler.parseComponent(source, compilerParseOptions)
    return output
}

// vue.js
function parseComponent(content, options) {
	// ...
    var sfc = {
        template: null,
        script: null,
        styles: [],
        customBlocks: []
    }
    // ...
    return sfc
}
複製程式碼

我們可以將SFC結構融合到Webpack進行開發的過程成中, 主要有這幾點影響:

  • 允許為 Vue 元件的每個部分使用其它的 webpack loader,例如在 <style>的部分使用 Sass Loader , 在 <customBlocks>的部分使用自定義 Loader
  • 使用 webpack loader 將 <style><template> 中引用的資源當作模組依賴來處理
  • 模擬 Scoped CSS
  • 在開發過程中使用熱過載來保持狀態

以下主要介紹Scoped CSS的原理.

Scoped CSS

大白話版本之 Scoped CSS 原理

通過 Webpack 呼叫 VueJS 中相應 Loader , 給元件HTML模板新增自定義屬性 (Attribute) data-v-x, 以及給元件內CSS選擇器新增對應的屬性選擇器 (Attribute Selector) [data-v-x], 達到元件內樣式只能生效與元件內HTML的效果, 程式碼效果如下:

<div class='lionad' data-v-lionad></div>
<style>
.lionad[data-v-lionad] {
  background: @tiger-orange;
}
</style>
複製程式碼

原始碼跟蹤

Webpack 使用其它 CSS Loader 處理 VueJS 中對應 CSS 程式碼之前, Vue Loader 已經替我們做了一層簡單的處理, 如果元件中 style 塊包含了 scoped 屬性:

<!-- 某個VueJS元件中 -->
<template>
    <div class='lionad'></div>
</template>
<style lang="scss" scoped>
    .lionad {
        background: @tiger-orange;
    }
</style>
複製程式碼

下程式碼即判斷當前SFC物件樣式塊中是否有scoped屬性, 並插入用於 query 中, 順帶一提, 每個單檔案元件被解析後, 都會生成對應元件ID, ID主要以生產/開發環境做區分, 通過檔案路徑+原始碼或是檔案路徑的值作為雜湊特徵值的形式生成, 如下:

// vue-loader/index.js
const id = hash(isProduction  (shortFilePath + '\n' + source) : shortFilePath)
const hasScoped = descriptor.styles.some(s => s.scoped)
const query = `? vue&type=template${idQuery}${scopedQuery}`
const request = templateRequest = stringifyRequest(src + query)
templateImport = `import { render, staticRenderFns } from ${request}`
複製程式碼

HTML模板處理

在用於處理SFC結構中HTML模板的 templateLoader 中, 我們可以得知, query 中所設定的引數將合併為 loader options 經由 Webpack 轉交 templateLoader 再轉交 @vue/component-compiler-utils.compileTemplate 處理:

// vue-loader/templateLoader.js
const query = qs.parse(this.resourceQuery)
const { id } = query
const compilerOptions = Object.assign({}, options.compilerOptions, {
    scopeId: query.scoped ? `data-v-${id}` : null
})
const compiled = compileTemplate({ compilerOptions })
複製程式碼

實際 compileTemplate 函式在處理內容時, 編譯函式使用的是 query 中的 compiler 或 vue-template-compiler, 後者會將模板文字轉換成為 JavaScript 渲染函式, 大致如下:

  1. 從HTML模版轉換為AST(虛擬語法樹)
  2. AST優化,處理靜態模版與動態模板
  3. 生成JS函式,用於在執行時執行時生成純HTML

程式碼分別對應:

// vue-template-compiler/build.js/createCompilerCreator
var ast = parse(template.trim(), options)
optimize(ast, options)
var code = generate(ast, options)
複製程式碼

先前我們的元件ID在 parse 階段解析開始標籤時就會被推入內部儲存的資料結構中:

function elementToOpenTagSegments (el, state) {
  var segments = [{ type: RAW, value: ("<" + (el.tag)) }]
  // _scopedId
  if (state.options.scopeId) {
    segments.push({ type: RAW, value: (" " + (state.options.scopeId)) })
  }
  segments.push({ type: RAW, value: ">" })
  return segments
}
複製程式碼

先前我們的HTML模板 <div class='lionad'></div> 中開始標籤會被轉換成如下資料結構:

[
    { type: RAW, value: '<div' },
    { type: RAW, value: 'class=lionad' },
    { type: RAW, value: 'data-v-xxxxxx' },
    { type: RAW, value: '>' },
]
複製程式碼

樣式模板處理

與 HTML Template 解析的過程類似, 通過 Webpack 將樣式模板轉交 stylePostLoader 進行處理, 處理邏輯主要引用了 @vue/component-compiler-utils 中的 compileStyle 部分, 後者對樣式模板進行解析的過程中, 將會對含 scoped 標記的模板引入外掛 stylePlugins/scoped.js, scoped.jsdata-v-xxxxxx 新增到選擇器末尾的過程如下:

selectors.each((selector) => {
    selector.each((n) => {
        if (n.value === '::v-deep' || n.value === '>>>' || n.value === '/deep/') {
            return false;
        }
    });
    selector.insertAfter(node, selectorParser.attribute({
        attribute: id
    }))
})
複製程式碼

題外話, 通過以上程式碼, 我們發現噹噹前處理到三種特定型別選擇器會終止迴圈, 停止將 data-v-xxx 新增到選擇器末尾:

  1. 偽類 ::v-deep
  2. 選擇器 >>>
  3. 選擇器 /deep/

我們可以利用這個特徵, 在元件中寫樣式穿透, 即內部元件影響外部元件樣式 (ε=ε=ε=┏(゜ロ゜;)┛ 主動樣式汙染), 當然這在特定的情境下是有用的, 比如當我們想主動覆蓋第三方UI元件框架的樣式, 卻不想引入新的CSS檔案, 或不想寫非 Scoped CSS 模板的時候.

最後

本人前端菜得捉急, 文中不詳盡或有錯的地方, 歡迎各位大佬斧正. 如果本文對你有所幫助, 那是再好不過, 看到這裡都是真愛啊(笑.jpg) 勞煩點贊 收藏 關注 三連擊!!!

為了方便理解, 或是防止陷入令人頭疼的細節, 文中原始碼片段有部分刪減

相關文章