聊聊 CSS 编译和 scoped 实现

问题一 CSS 是如何被解析的?

答:CSS 在构建时由 PostCSS 解析为 CSS AST 供插件做代码转换,输出 CSS 字符串后在运行时交由浏览器自己的解析器构建 CSSOM 参与渲染


问题解析:

在开始之前,我们看一个时间线:

复制代码
构建时(webpack/vite)                    运行时(浏览器)
    ────────                              ─────────
模板管线:                                         
<template> 原始字符串                               
     ↓                                             
HTML 解析器解析为模板 AST        
     ↓                                             
优化(标记静态节点)                                 
     ↓                                             
代码生成 → render 函数字符串                        
     ↓                                             
(打包进 JS bundle)          ──→  JS 执行,render 构建 DOM 树

样式管线: 
<style> 原始 CSS                             
       ↓                                       
PostCSS 解析为 CSS AST          
       ↓                                       
scoped 插件加 [data-v-xxx]                   
trim 插件处理空白                             
cssVars 插件转 v-bind()                      
       ↓                                       
PostCSS 把 AST 重新序列化回 CSS 字符串            
       ↓                                       
(注入到页面 <style> 标签)     ──→    浏览器拿到 CSS 文本
                                              ↓
                                  浏览器自己的解析器构建 CSSOM 树
                                              ↓
                                   DOM + CSSOM → Render Tree

Vue 单文件组件(Single-File Components,简称 SFC)

在构建阶段,Vue SFC 的编译分两条独立管线:模板管线和样式管线

模板管线 ,我们在 Vue 源码篇 模板编译 中聊到了:

  • 在模板解析阶段,通过调用解析函数,将模板字符串解析为抽象语法树 AST

样式管线,将 <style> 内容依赖 PostCSS 解析为 CSS AST,在 CSS AST 上每个节点都有类型:

  • Rule → .foo { color: red; } 这种规则
  • AtRule → @media print { ... } 这种 at-rule
  • Declaration → color: red; 这种声明
  • 选择器由 postcss-selector-parser 解析,能识别所有 CSS 选择器语法

在浏览器运行阶段,通过 render 函数构建出 DOM 树,浏览器利用自己的 CSS 解析器构建 CSSOM 树,最终合并为 Render Tree

怎么合并 Render Tree?

假如我的 DOM 树和 CSSOM 树是这样:

复制代码
DOM 树           CSSOM 树
  html              body
  ├─ head           ├─ p { color: red }
  └─ body           └─ span { font-size: 14px }
     ├─ p
     └─ span

浏览器从 DOM 树的根节点开始遍历,对每个可见节点,去 CSSOM 中查找匹配的样式规则,组合成一个 Render Object,挂到 Render Tree 上。

复制代码
Render Tree
  body (display: block, ...)
  ├─ p (color: red, display: block, ...)
  └─ span (font-size: 14px, display: inline, ...)

关键点

  • 不可见节点被丢弃:<head>、<meta>、<script>、display: none 的元素都不会进入 Render Tree
  • visibility: hidden 会进入:它占空间,只是看不见,所以保留在 Render Tree 中
  • display: none 不进入:既不占空间也不渲染,DOM 树里有但 Render Tree 里没有
  • 每个进入 Render Tree 的节点都带着最终计算后的样式值(经过层叠、继承、默认值合并后的结果)

最后

Render Tree → Layout(计算位置和大小)→ Paint(绘制像素)→ Composite(合成图层),页面就呈现出来了

问题二 <style scoped> 是怎么实现样式私有的?

答:CSS 解析交给 PostCSS;scoped 是编译时给选择器加 data-v-xxx + 运行时给元素加 data-v-xxx 属性,双端配合实现隔离


准备工作:

如果需要拉取 Vue 源码可以参考 源码篇 剖析 Vue2 双向绑定原理,下面的内容我将会涉及到一部分源码内容,但不对源码做具体分析
源码位置:packages/compiler-sfc/src/stylePlugins/scoped.ts

在源码中编写了几个插件用于给 CSS 做代码转换:

  • scopedPlugin 遍历 Rule 节点,改写选择器
  • cssVarsPlugin 遍历 Declaration 节点,替换 v-bind()
  • trimPlugin 处理空白

在这个问题中用到了 scopedPlugin


问题解析:

1.在编译阶段,scoped 插件对每条 CSS 规则的选择器做改写:

css 复制代码
/* 写的样式 */
.foo { color: red; }
.foo:after { content: ''; }
@media print { .bar { color: blue; } }

/* 编译后 */
.foo[data-v-7ba5bd90] { color: red; }
.foo[data-v-7ba5bd90]:after { content: ''; }
@media print { .bar[data-v-7ba5bd90] { color: blue; } }

核心逻辑在 rewriteSelector 函数:遍历选择器的每个节点,找到最后一个非伪元素、非组合器的节点,在其后面插入 data-v-xxx 属性选择器

对于 @keyframes 则是直接改名为 color-7ba5bd90

还有穿透机制:

  • :deep(.bar) → .foodata-v-xxx .bar(属性插在 deep 之前,后面的选择器不再加属性)
css 复制代码
/* 写的样式 */
.parent :deep(.child) { color: red; }
.parent ::v-deep(.child) { color: red; }

/* 改写后 */
.parent[data-v-xxx] .child { color: red; }
  • :global(.bar) → .bar(完全不加属性)
css 复制代码
/* 写的样式 */
.parent :global(.child) { color: red; }
.parent ::v-global(.child) { color: red; }

/* 改写后 */
.parent .child { color: red; }

2.在运行阶段,通过 setScope 函数给 DOM 元素加属性,每次创建 DOM 元素时:

javascript 复制代码
nodeOps.setStyleScope(vnode.elm, scopeId)
// 实际就是:
node.setAttribute('data-v-7ba5bd90', '')

它会沿着 vnode 的 parent 链向上查找所有祖先组件的 _scopeId,逐个设置到元素上。插槽内容还会额外拿到宿主组件的 _scopeId

举个栗子:

Parent 组件(scopeId: data-v-parent):

html 复制代码
<Child>
  <p class="slot-text">我是插槽内容</p>
</Child>

Child 组件(scopeId: data-v-child):

html 复制代码
<div class="child-root">
  <slot></slot>
</div>

渲染出的 DOM

html 复制代码
<div class="child-root" data-v-child data-v-parent>
  <p class="slot-text" data-v-child data-v-parent>我是插槽内容</p>
</div>

为什么 <p> 需要两个?

<p> 是在 Parent 的模板里写的,Parent 的 scoped CSS 可能要样式化它:.slot-textdata-v-parent → 需要 data-v-parent

<p> 又渲染在 Child 内部,Child 的 scoped CSS 通过 :deep() 也可能要样式化它:.child-rootdata-v-child .slot-text → 需要 data-v-child

所以一个元素可能需要同时满足多个组件的 scoped 选择器。

setAttribute 是追加不是覆盖

因此,CSS 选择器要求 data-v-xxx,DOM 元素上也确实有这个属性,两者匹配上才有样式。其他组件的元素没有这个属性就匹配不上

问题三 怎么找到 template 里元素的 style/class/id?

答:在模板编译阶段,Vue 有专用 module 从 AST 的 attrsList 中提取 style/class/id


class --- src/platforms/web/compiler/modules/class.ts:

javascript 复制代码
const staticClass = getAndRemoveAttr(el, 'class')      // 静态 class
const classBinding = getBindingAttr(el, 'class', false) // 动态 :class

style --- src/platforms/web/compiler/modules/style.ts:

javascript 复制代码
const staticStyle = getAndRemoveAttr(el, 'style')       // 静态 style
const styleBinding = getBindingAttr(el, 'style', false)  // 动态 :style

id --- src/compiler/parser/index.ts 的 processAttrs,作为普通属性处理:

javascript 复制代码
addAttr(el, name, JSON.stringify(value), list[i])

style 和 class 被标记为保留属性(src/platforms/web/util/attrs.ts),由专门模块处理,不走通用属性流程。getAndRemoveAttr 从 AST 元素的 attrsList 中查找并移除,getBindingAttr 则额外检查 v-bind: 或 : 前缀的绑定形式

相关推荐
object not found1 小时前
Node.js fs 常用 API 整理:node:fs/promises、node:fs、fs 到底怎么用
开发语言·前端·javascript
LiuJun2Son1 小时前
Angular 快速入门:服务和依赖注入
前端·javascript·angular.js
kidding7231 小时前
BMI 健康测量仪工具类小程序
前端·微信小程序·小程序
KaMeidebaby1 小时前
卡梅德生物技术快报|兔单克隆抗体应用实战:禽源病原 IFA 检测全流程拆解
前端·人工智能·物联网·算法·百度
lulu12165440781 小时前
OpenAI 如何用开源前端生态为 GPT-5.6 铺路? - 微元算力(weytoken)
java·前端·人工智能·python·gpt·开源·ai编程
问心无愧051310 小时前
ctf show web入门160 161
前端·笔记
李小白6610 小时前
第四天-WEB服务器基本原理,IIS服务
运维·服务器·前端
humcomm10 小时前
AI编程时代新前端职位
前端·ai编程
好家伙VCC11 小时前
Web Components主题热切换方案揭秘
java·前端