聊聊 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 TreeVue 单文件组件Single-File Components简称 SFC在构建阶段Vue SFC 的编译分两条独立管线模板管线和样式管线模板管线我们在 Vue 源码篇 模板编译 中聊到了在模板解析阶段通过调用解析函数将模板字符串解析为抽象语法树 AST样式管线将 style 内容依赖 PostCSS 解析为 CSS AST在 CSS AST 上每个节点都有类型Rule → .foo { color: red; } 这种规则AtRule → media print { ... } 这种 at-ruleDeclaration → 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 Treevisibility: hidden 会进入它占空间只是看不见所以保留在 Render Tree 中display: none 不进入既不占空间也不渲染DOM 树里有但 Render Tree 里没有每个进入 Render Tree 的节点都带着最终计算后的样式值经过层叠、继承、默认值合并后的结果最后Render Tree → Layout计算位置和大小→ Paint绘制像素→ Composite合成图层页面就呈现出来了问题二 style scoped 是怎么实现样式私有的答CSS 解析交给 PostCSSscoped 是编译时给选择器加 [data-v-xxx] 运行时给元素加>/* 写的样式 */ .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) → .foo[data-v-xxx] .bar属性插在 deep 之前后面的选择器不再加属性/* 写的样式 */ .parent :deep(.child) { color: red; } .parent ::v-deep(.child) { color: red; } /* 改写后 */ .parent[data-v-xxx] .child { color: red; }:global(.bar) → .bar完全不加属性/* 写的样式 */ .parent :global(.child) { color: red; } .parent ::v-global(.child) { color: red; } /* 改写后 */ .parent .child { color: red; }2.在运行阶段通过 setScope 函数给 DOM 元素加属性每次创建 DOM 元素时nodeOps.setStyleScope(vnode.elm, scopeId) // 实际就是 node.setAttribute(data-v-7ba5bd90, )它会沿着 vnode 的 parent 链向上查找所有祖先组件的 _scopeId逐个设置到元素上。插槽内容还会额外拿到宿主组件的 _scopeId举个栗子Parent 组件scopeId:>Child p classslot-text我是插槽内容/p /ChildChild 组件scopeId:>div classchild-root slot/slot /div渲染出的 DOMdiv classchild-root>const staticClass getAndRemoveAttr(el, class) // 静态 class const classBinding getBindingAttr(el, class, false) // 动态 :classstyle — src/platforms/web/compiler/modules/style.ts:const staticStyle getAndRemoveAttr(el, style) // 静态 style const styleBinding getBindingAttr(el, style, false) // 动态 :styleid — src/compiler/parser/index.ts 的 processAttrs作为普通属性处理addAttr(el, name, JSON.stringify(value), list[i])style 和 class 被标记为保留属性src/platforms/web/util/attrs.ts由专门模块处理不走通用属性流程。getAndRemoveAttr 从 AST 元素的 attrsList 中查找并移除getBindingAttr 则额外检查 v-bind: 或 : 前缀的绑定形式