阅读视图

发现新文章,点击刷新页面。

提升页面质感:CSS 重复格子背景的实用技巧

重复格子背景

最近发现不少开发类的网站都在首页使用上了重复格子的背景,例如下图中左侧以 relay.app 为例子的截图。

这种背景主要是为了在现在大量留白的设计风格下,避免页面过于空旷,增加一些视觉上的丰富性,不然就会如下图中右侧的截图那样在去掉背景后略显单调。

背景有无效果对比

接下来具体讲一讲如何用 CSS 实现这种重复格子背景,主要有两种方法:

  • 局部格子图片 + 背景重复
  • CSS 渐变 + 背景重复(推荐)

方法一:局部格子图片 + 背景重复

relay.app 使用的就是这种方法,这应该是最常见也是最容易想到的方法,就是先准备一张格子图片,例如:

格子图片

然后通过 background 相关属性进行设置即可,关键在于:

  • background-repeat 需要设置为 repeat,保证图片的重复。
  • background-size 可用于调整背景中格子的密度。
background-image: url(https://framerusercontent.com/images/9c47fOR3CNoSsEtr6IEYJoKM.svg?width=126&height=126);
background-size: 63px auto;
background-repeat: repeat;
background-position: left top;

下面是一个前端组件使用后的效果示例 (访问 我的博客 可以进行交互式体验):

SVG 图片实现效果

方法二:CSS 渐变 + 背景重复(推荐)

我们还可以通过 CSS 渐变来替代方法一中依赖的外部图片资源,从而实现更快的加载和更灵活的控制,一个简单的例子如下:

background-image:
    linear-gradient(to right, rgba(59, 130, 246, 0.08) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(59, 130, 246, 0.08) 1px, transparent 1px);
background-size: 40px 40px;
transform: rotate(-3deg) scale(1.2);

代码的关键在于:

  • 通过 linear-gradient 创建一个只有1px宽度的渐变条,颜色为 rgba(59, 130, 246, 0.08) 的部分占 1px,其余部分透明,to right 控制竖向的线条,to bottom 控制横向的线条。
  • 通过 background-size 控制格子的大小。
  • 通过 transform 可以制造倾斜的效果,更加有趣一些。

下面是一个前端组件使用后的效果示例 (访问 我的博客 可以进行交互式体验):

CSS 渐变实现效果

我最近开发的 JTool.dev 首页就是通过方法二实现的效果,也欢迎大家体验:

JTool.dev 首页截图

CSS选择器与层叠机制

CSS(层叠样式表)作为网页设计的核心技术之一,不仅决定了网页的外观和布局,还通过其独特的选择器系统和层叠机制实现了样式的精确控制。本文将通过分析多个HTML和CSS示例,深入探讨CSS选择器的类型、优先级计算以及层叠原理。

一、CSS基础结构

CSS的基本组成单位是"属性-值"对的声明,多个声明构成声明块,声明块通过选择器与HTML元素关联,最终形成完整的样式规则。

css

复制下载

p {
  color: blue;
  font-size: 16px;
}

上述代码中,color: blue;font-size: 16px;是两个声明,它们共同组成了一个声明块,p是选择器,用于指定这些样式将应用于哪些HTML元素。

二、CSS选择器类型与优先级

1. 基础选择器

基础选择器包括元素选择器、类选择器和ID选择器:

css

复制下载

/* 元素选择器 */
p {
  color: blue;
}

/* 类选择器 */
.container {
  width: 100%;
}

/* ID选择器 */
#main {
  margin: 0 auto;
}

2. 优先级计算模型

CSS选择器的优先级通常被描述为一个四位数的权重系统,按"个十百千"从低到高排列:

  • 千位:行内样式(style属性)
  • 百位:ID选择器
  • 十位:类选择器、属性选择器和伪类
  • 个位:元素选择器和伪元素

在1.html示例中,我们可以清楚地看到不同选择器的优先级表现:

html

复制下载运行

<style>
p {
  color: blue; /* 优先级:1 (个位) */
}
.container p {
  color: red; /* 优先级:11 (十位+个位) */
}
#main p {
  color: green; /* 优先级:101 (百位+个位) */
}
</style>

<div id="main" class="container">
  <p>这是一个段落</p>
</div>

最终段落文字显示为绿色,因为ID选择器(#main p)的优先级最高。这个例子直观地展示了CSS优先级计算规则。

3. 关系选择器

关系选择器根据元素在文档树中的位置关系进行选择:

css

复制下载

/* 后代选择器 */
.container p {
  text-decoration: underline;
}

/* 子选择器 */
.container > p {
  color: pink;
}

/* 相邻兄弟选择器 */
h1 + p {
  color: red;
}

/* 通用兄弟选择器 */
h1 ~ p {
  color: blue;
}

在3.html中,这些关系选择器的效果得到了充分展示:

html

复制下载运行

<style>
h1 + p { color: red; } /* 紧接在h1后的p元素 */
p + p { color: green; } /* 紧接在p后的p元素 */
h1 ~ p { color: blue; } /* h1后面的所有p元素 */
.container > p { color: pink; } /* .container的直接子p元素 */
.container p { text-decoration: underline; } /* .container的所有后代p元素 */
</style>

<div class="container">
  <p>这是第二段文字</p> <!-- 粉色、下划线 -->
  <h1>标题</h1>
  <p>这是第一段文字。</p> <!-- 蓝色、红色(被蓝色覆盖)、下划线 -->
  <p>这是第二段文字。</p> <!-- 蓝色、绿色(被蓝色覆盖)、下划线 -->
  <a href="#">链接</a>
  <span>这是一个span元素。</span>
  <div class="inner">
    <p>这是内部段落。</p> <!-- 仅下划线 -->
  </div>
</div>

这个例子展示了不同关系选择器的应用范围和优先级关系。

4. 属性选择器

属性选择器根据元素的属性及属性值进行选择:

css

复制下载

/* 匹配具有特定属性值的元素 */
[data-category="科幻"] {
  background-color: #1e0216;
  color: rgb(169, 137, 158);
}

/* 匹配属性值以特定字符串开头的元素 */
[title^="入门"] h2::before {
  content: "🌟";
}

在2.html中,属性选择器被用于为不同类别的书籍设置不同的样式:

html

复制下载运行

<div class="book" data-category="科幻">
  <h2>三体</h2>
  <p>作者:刘慈欣</p>
</div>
<div class="book" data-category="历史">
  <h2>明朝那些事儿</h2>
  <p>作者:当年明月</p>
</div>

5. 伪类与伪元素

伪类用于选择处于特定状态的元素,而伪元素则用于创建不在文档树中的抽象元素:

css

复制下载

/* 伪类 */
button:active {
  color: red;
}

p:hover {
  background-color: yellow;
}

input:checked + label {
  font-weight: bold;
}

/* 反选伪类 */
li:not(:last-child) {
  margin-bottom: 10px;
}

/* 结构化伪类 */
li:nth-child(odd) {
  background-color: lightgray;
}

/* 伪元素 */
.more::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 2px;
  background-color: white;
}

.more::after {
  content: "\2192";
  margin-left: 5px;
}

4.html和6.html展示了伪类和伪元素的应用:

html

复制下载运行

<!-- 4.html中的伪类示例 -->
<button>点击我</button> <!-- 点击时变红 -->
<p>鼠标悬浮在这里</p> <!-- 悬浮时背景变黄 -->
<ul>
  <li>列表项1</li> <!-- 灰色背景,底部间距 -->
  <li>列表项2</li> <!-- 无背景,底部间距 -->
  <li>列表项3</li> <!-- 灰色背景,底部间距 -->
  <li>列表项4</li> <!-- 无背景,无底部间距 -->
</ul>

<!-- 6.html中的伪元素示例 -->
<a href="#" class="more">查看更多</a> <!-- 有箭头图标,悬停时有下划线动画 -->

6. :nth-child与:nth-of-type的区别

这两个伪类经常被混淆,但它们的选择逻辑有本质区别:

css

复制下载

/* 选择.container的第4个子元素,且该元素必须是p标签 */
.container p:nth-child(4) {
  background-color: yellow;
}

/* 选择.container的第3个p类型子元素 */
.container p:nth-of-type(3) {
  background-color: orange;
}

在5.html中,这两种选择器的差异得到了清晰展示:

html

复制下载运行

<div class="container">
  <h1>nth-child vs nth-of-type 实例</h1> <!-- 第1个子元素 -->
  <p>这是一个段落。</p> <!-- 第2个子元素,第1个p元素 -->
  <div>这是一个div。</div> <!-- 第3个子元素 -->
  <p>这是第二个段落。</p> <!-- 第4个子元素,第2个p元素 - 黄色背景 -->
  <p>这是第三个段落。</p> <!-- 第5个子元素,第3个p元素 - 橙色背景 -->
  <div>这是第二个div。</div> <!-- 第6个子元素 -->
</div>

:nth-child(n)选择的是父元素的第n个子元素,且必须同时满足其他选择条件;而:nth-of-type(n)选择的是父元素下同类型元素的第n个。

三、CSS层叠机制

1. 样式来源与优先级

CSS样式有三个主要来源,按优先级从高到低排列:

  1. 作者样式表:网页开发者编写的样式
  2. 用户样式表:浏览器用户自定义的样式
  3. 浏览器默认样式表:浏览器的默认样式

在作者样式表中,又有不同的引入方式和优先级:

html

复制下载运行

<!-- 外联样式 -->
<link rel="stylesheet" href="theme.css">

<!-- 内嵌样式 -->
<style>
.text p {
  color: red;
}
</style>

<!-- 行内样式 -->
<button style="background: pink;">Click</button>

2. 层叠规则

当多个规则应用于同一元素时,CSS通过以下顺序决定最终样式:

  1. 重要性:带有!important的声明
  2. 来源:作者样式表 > 用户样式表 > 浏览器默认样式
  3. 选择器特异性:按千位、百位、十位、个位比较
  4. 代码顺序:后出现的规则覆盖先出现的规则

在7.html中,我们可以观察到这些规则的相互作用:

html

复制下载运行

<style>
.text p { color: red; } /* 优先级:11 */
div p { color: blue; } /* 优先级:2 */
#main p { color: green; } /* 优先级:101 */
.container #main p { color: orange; } /* 优先级:201 */
</style>

<div class="text">
  <p>Hello</p> <!-- 红色:.text p (11) > div p (2) -->
</div>

<div class="container">
  <div id="main">
    <p>hello</p> <!-- 橙色:.container #main p (201) > #main p (101) -->
  </div>
</div>

<button class="btn" style="background: pink;">Click</button>
<!-- 粉色:行内样式 (1000) > .btn (10) -->

3. 继承与初始值

某些CSS属性会自动从父元素继承到子元素,如colorfont-family等。对于那些不能继承的属性,每个元素都有初始值。

css

复制下载

body {
  color: blue; /* 所有body内的文本元素都会继承这个颜色 */
}

div {
  border: 1px solid black; /* border不会继承给子元素 */
}

四、CSS实践中的注意事项

1. 盒模型与边距重叠

在CSS盒模型中,相邻元素的上下边距会发生重叠,取两者中的较大值作为实际间距:

css

复制下载

.box1 {
  margin-bottom: 20px;
}

.box2 {
  margin-top: 30px;
}
/* 实际间距为30px,而不是50px */

2. 小数像素处理

当使用小数像素值时,不同浏览器的处理方式可能不同。一般来说,浏览器会进行亚像素渲染,但实际显示效果可能因浏览器和操作系统而异。

3. 行内元素的限制

行内元素(inline)在某些情况下不支持某些CSS属性,如transform。如果需要使用这些属性,可以将元素设置为inline-blockblock

css

复制下载

.inline-element {
  display: inline-block; /* 使行内元素支持transform */
  transform: rotate(10deg);
}

五、CSS选择器最佳实践

  1. 避免过度使用ID选择器:由于ID选择器的高特异性,后续难以覆盖,不利于维护。
  2. 优先使用类选择器:类选择器具有适中的特异性,易于复用和覆盖。
  3. 避免使用!important:除非必要,否则应避免使用!important,因为它会破坏正常的层叠顺序。
  4. 保持选择器简洁:过于复杂的选择器不仅难以理解,还可能影响性能。
  5. 利用CSS自定义属性:使用CSS变量提高样式的可维护性:

css

复制下载

:root {
  --primary-color: #007bff;
  --spacing: 10px;
}

.button {
  background-color: var(--primary-color);
  padding: var(--spacing);
}

六、结语

CSS选择器和层叠机制是CSS强大功能的核心。通过深入理解不同类型选择器的特性和优先级计算规则,开发者可以编写出更加精确、高效和可维护的样式代码。同时,掌握层叠原理有助于解决样式冲突,实现预期的视觉效果。随着CSS标准的不断发展,选择器的功能和性能也在持续优化,为网页设计带来更多可能性。

在实际开发中,建议结合开发者工具进行样式调试,直观地观察选择器的匹配情况和样式覆盖关系,这将大大提高CSS代码的编写效率和准确性

🌊 深入理解 CSS:从选择器到层叠的艺术

CSS(Cascading Style Sheets,层叠样式表)是网页“颜值”的缔造者。它通过将 属性(property)值(value) 配对成声明,再由多个声明组成 声明块,最终通过 选择器 将这些样式精准地应用到 HTML 元素上。

p {
  color: blue;
  font-size: 16px;
}

上面这段代码就是一个典型的 CSS 规则:p 是选择器,花括号内是声明块,包含两个声明。


🔍 选择器优先级:谁说了算?

当多个规则作用于同一个元素时,CSS 需要决定“听谁的”——这就是 层叠(Cascading) 的核心机制。优先级按“个十百千”来记忆:

  • 千位:元素选择器(如 p)、伪元素(如 ::before
  • 百位:类选择器(.class)、属性选择器([type="text"])、伪类(:hover
  • 十位:ID 选择器(#id
  • 个位:行内样式(style="..."
  • 特殊存在!important —— 它拥有最高权限,但请慎用⚠️,容易破坏样式的可维护性。

举个例子:

<p id="intro" class="highlight">这是一段文字。</p>

如果同时有:

p { color: black; }           /* 千位 */
.highlight { color: green; }  /* 百位 */
#intro { color: red; }        /* 十位 */

最终文字会是 红色,因为 ID 选择器优先级更高。


🧪 伪类 vs 伪元素:别再混淆!

伪类(Pseudo-classes)

描述元素的状态,比如:

  • :hover(鼠标悬停)
  • :focus(获得焦点)
  • :nth-child(n) / :nth-of-type(n)(选第几个子元素)

💡 小知识:

  • :nth-child(2) 选的是父元素下的第二个子元素,不管类型;
  • :nth-of-type(2) 选的是同类型中的第二个
    例如在 <div><p>1</p><div>A</div><p>2</p></div> 中,p:nth-child(3) 能选中第二个 <p>,但 p:nth-child(2) 选不到任何东西!

伪元素(Pseudo-elements)

用于创建不存在于 HTML 中的内容,常用:

  • ::before / ::after:在元素前后插入内容
  • ::first-line / ::first-letter:美化首行或首字
a::after {
  content: " ➡️";
  color: gray;
}

这样每个链接后面都会自动加上一个箭头图标,非常适合做“查看更多”这类提示 ✨


🧱 布局与定位:inline 的小秘密

你可能注意到,有些 inline 元素(如 <span>不支持 transformwidth/height。这是因为 inline 元素只占据内容所需宽度,不能设置盒模型属性。

但有趣的是:当你给一个 inline 元素设置 position: absolute,它会自动变成 inline-block 行为!这意味着你可以自由设置宽高、使用 transform 等——这是浏览器的隐式转换机制。

<span style="position: absolute; transform: rotate(10deg);">旋转我!</span>

✅ 这样是有效的!


⚖️ margin 重叠:布局中的“幽灵现象”

在垂直方向上,相邻块级元素的上下 margin 会发生合并(collapse) ,最终间距取两者中的最大值,而不是相加。

<div style="margin-bottom: 20px;">A</div>
<div style="margin-top: 30px;">B</div>

A 和 B 之间的实际间距是 30px,不是 50px!这是很多初学者踩过的坑 🕳️

解决方法包括:

  • 使用 padding 代替部分 margin
  • 给父容器设置 overflow: hidden
  • 使用 Flex 或 Grid 布局(它们不会发生 margin 重叠)

📏 单位小谈:px 是怎么处理的?

px(像素)是最常用的绝对单位。虽然叫“绝对”,但在现代浏览器中,它其实是相对于设备像素比(DPR)进行缩放的逻辑像素。比如在 Retina 屏上,1px 可能对应 2 个物理像素,但开发者无需关心,浏览器会自动处理。

对于响应式设计,更推荐使用相对单位:

  • em / rem:基于字体大小
  • %:基于父元素
  • vw / vh:基于视口

💡 总结:CSS 是一门“层叠的艺术”

从选择器的精准匹配,到优先级的微妙博弈;从伪类的状态响应,到伪元素的内容增强;再到布局中的细节陷阱……CSS 不只是“调颜色”,而是一套精密的视觉控制语言

掌握它,你就能让静态的 HTML “活”起来,像海浪一样层层推进,又井然有序 🌊✨

记住:好的 CSS = 清晰的选择器 + 合理的层叠 + 对盒模型的深刻理解。

继续加油,前端之路,风景这边独好!🚀

这些 CSS 小细节没处理好,你的页面就会“闪、抖、卡”——渲染机制深度拆解

前端开发中最容易忽略的性能细节:页面为何会“卡顿、闪动、抖”?从渲染机制深度拆解

在前端开发中,性能问题往往不是来自你写了多少 JS、用了多少 DOM 操作,而是来自更隐蔽的点——渲染细节

你可能遇到过:

  • 页面加载时闪一下
  • 滑动列表总感觉不够流畅
  • 图片突然出现导致其他内容被“挤走”
  • 某些 UI 样式导致明显掉帧
  • CSS 文件加载顺序干扰页面首屏呈现

这些现象的根源,常常不是“写法不规范”,而是对浏览器渲染机制的误解或忽视

本文将围绕三大高频细节深入讲解:

  • 图片尺寸缺失导致的 CLS
  • 复杂视觉效果导致的掉帧
  • @import 的阻塞机制

一、图片不写 width/height:为什么会引发 CLS(布局抖动)?

许多开发者以为只要 CSS 里设置了宽高就够了,但实际上:

浏览器必须提前知道图片占多大空间,才能正确分配页面布局。

如果你在 HTML 中不写尺寸:

<img src="/banner.jpg">

浏览器在下载图片之前不知道它真实大小,因此只能先渲染一个“未知高度”的框架。

等图片加载完成后,它又会根据实际尺寸重新计算布局 → 于是页面出现跳动(Cumulative Layout Shift)

什么是 CLS?

CLS(累计布局偏移)是 Web Vitals 重要指标,衡量页面因元素变化而产生的视觉位移。

表现为:

  • 文字突然被图片挤开
  • 按钮被推走导致用户点错
  • 页面加载时上下跳动

尤其严重影响用户体验,也影响 SEO。

为什么 HTML 属性比 CSS 更重要?

CSS 是在渲染树生成过程中才参与布局。
HTML width/height 属性能在图片下载之前就提供布局信息,浏览器能立即给出占位。

这就是为什么即使业务用 CSS 控制大小,HTML 中仍建议写:

<img src="/banner.jpg" width="800" height="400">

现代浏览器会自动按比例缩放,不会被固定死。

工程化最佳实践

  • 全部图片必须写 w/h(包括组件库内部 image)
  • next/image、uniapp、webp loader 等框架本质都帮你做了这件事
  • 设计稿已给尺寸,就直接写入
  • 若为响应式布局,可用 CSS max-width 调整,而不是删除 HTML 尺寸

二、慎用 box-shadowfilterbackdrop-filter:它们为何会让页面掉帧?

在视觉效果上,这些属性很常见:

box-shadow: 0 4px 20px rgba(0,0,0,0.2);
filter: blur(20px);
backdrop-filter: blur(10px);

但它们有一个共同特征:

可能触发独立合成层或高成本绘制 → 导致 GPU/CPU 压力增大。

尤其是在移动端或低端设备上,掉帧极其明显。

1. 为什么 box-shadow 会让页面卡顿?

因为阴影计算需要:

  • 多次模糊运算
  • 扩散边缘处理
  • GPU 合成层的额外绘制

当一个列表有几十个卡片,每个都带 box-shadow,性能会直接下降。

2. filter: blur() 的成本更高

滤镜需要像素级处理(per-pixel),属于渲染链路的重任务。

大面积模糊相当于“实时在浏览器中跑 Photoshop”,不慢才怪。

3. backdrop-filter 成本更高

它需要:

  • 获取元素背后的像素
  • 动态模糊背景
  • 不断重绘(尤其在滚动时)

Safari、Chrome 都曾因此出现性能问题。

可视化效果不等于不能用,而要“合理用”

  • 不要对列表项、滚动内容、频繁变化元素使用滤镜
  • 阴影尽量轻、浅、简短,减少模糊半径
  • 界面需要大模糊时,应使用位图模糊背景图模拟
  • 避免多层滤镜叠加

工程化优化思路

  • 超过 15px 的 blur 几乎一定掉帧,尽量避免
  • 重度阴影可用伪元素 + 轻量图片替代
  • 避免嵌套阴影
  • 根据分辨率用媒体查询开关效果

三、为什么生产环境必须避免 @import

许多初学者喜欢这样写:

/* main.css */
@import url("reset.css");
@import url("color.css");
@import url("layout.css");

看似简洁,但它是加载阻塞的噩梦

原因 1:@import 会阻塞 CSSOM 构建

浏览器加载 main.css → 发现 @import → 停下来去下载子 CSS → 再继续解析
而 CSS 阻塞渲染,这意味着:

首屏渲染推迟,白屏时长增加。

原因 2:嵌套导入会指数级拖慢加载

像:

@import "a.css";
/* a.css 中又有 */
@import "b.css";

每一层都是阻塞链。

原因 3:HTTP/2 并不能完全解决

即使多路复用存在,浏览器仍然按“解析顺序”等待 CSSOM,这不是网络问题,而是渲染机制决定的

正确做法

  • 用构建工具将 CSS 打包成一个文件
  • 使用 <link> 替代 @import
<link rel="stylesheet" href="/css/main.css">

浏览器可并行加载 CSS,且不阻塞解析链。


性能问题从来不“写太多”,而是“写错了”

页面闪动、卡顿、迟滞、掉帧,往往有一个共同根源:

开发者忽略了浏览器渲染机制下的细节行为。

当这些关键细节被妥善处理后,页面将具备 更稳健的布局结构、更顺滑的动画与滚动体验、更快速的首屏呈现、更友好的用户交互感受,以及更健康的 SEO 指标。这些并不是微不足道的优化项,而是直接决定产品品质的工程能力体现。关注细节,持续打磨,正是前端工程真正的价值所在。

深入理解 CSS 选择器与层叠机制:从基础语法到实战应用

作者:前端工程师
技术栈:HTML5 / CSS3 / Web 标准
适用人群:初级至中级前端开发者
关键词:CSS 选择器、层叠规则、优先级计算、伪类与伪元素、样式调试


在现代 Web 开发中,CSS 是构建用户界面不可或缺的一环。而 选择器(Selector)层叠(Cascading) 则是 CSS 的两大核心机制。本文将围绕你提供的多个代码示例,系统性地解析 CSS 选择器的分类、优先级规则、层叠行为,并结合真实场景给出最佳实践建议。


一、CSS 基础结构回顾

CSS 的基本组成单位如下:

  • 声明(Declaration) :一个属性与值的键值对,如 color: red;
  • 声明块(Declaration Block) :多个声明用 {} 包裹
  • 选择器(Selector) :决定声明块作用于哪些 HTML 元素
  • CSS 规则(CSS Rule)  = 选择器 + 声明块
  • 样式表(Stylesheet)  = 多个 CSS 规则的集合
css
编辑
/* 示例:一条完整的 CSS 规则 */
p {
  color: blue;
  font-size: 16px;
}

二、CSS 层叠(Cascading)机制详解

层叠指的是当多个规则同时作用于同一个元素时,浏览器如何决定最终应用哪条样式。

2.1 层叠的三大依据

  1. 来源顺序(越后定义的样式优先级越高)
  2. 选择器优先级(ID > Class > Element)
  3. !important(最高优先级,但应慎用)

2.2 优先级计算:个十百千法

类型 权重
内联样式(style="" 1000
ID 选择器(#id 100
类/伪类/属性选择器(.class[attr]:hover 10
元素/伪元素选择器(p::before 1

口诀个十百千 —— 从右往左看:元素(1) → 类(10) → ID(100) → 内联(1000)

实战案例:优先级冲突分析

html
预览
<div id="main" class="container">
  <p>这是一个段落</p>
</div>
css
编辑
p { color: blue; }                /* 权重:1 */
.container p { color: red; }      /* 权重:10 + 1 = 11 */
#main p { color: green; }         /* 权重:100 + 1 = 101 */

结果:文字为 绿色,因为 #main p 优先级最高。

image.png

⚠️ 注意:即使 .container p 写在后面,也无法覆盖 #main p,因为优先级更高。


三、CSS 选择器全解析(附实战代码)

3.1 基础选择器

类型 示例 说明
元素选择器 p 选择所有 <p>
类选择器 .book 选择 class 为 book 的元素
ID 选择器 #main 选择 id 为 main 的元素(唯一)
通配符 * 选择所有元素(性能差,慎用)

属性选择器实战

css
编辑
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        * {
            margin: 0;
            padding: 0;
        }
        /* 基础样式 */
        .book { 
            margin: 10px;
            padding: 15px;
            border: 1px solid #ccc;
        }
        [data-catrgory="科幻"] {
            background-color: #007bff;
            color: white;
        }
        [data-catrgory="历史"] {
            background-color: #8b4513;
            color: #fffdd0;
        }
        /*^= 代表以什么开头*/
        [title^="入门"] h2::before {
            content: "🌟";
            margin-right: 5px;
            font-size: 1.2em;
        }
    </style>
</head>
<body>
    <div class="book" data-catrgory="科幻">
        <h2>三体</h2>
        <p>作者: 刘慈欣</p>
    </div>
    <div class="book" data-catrgory="历史">
        <h2>明朝那些事</h2>
        <p>作者: 当年明月</p>
    </div>
    <div class="book" data-catrgory="小说">
        <h2>活着</h2>
        <p>余华</p>
    </div>
    <div class="book" data-catrgory="语言学习" title="入门日语初级课程">
        <h2>日语初级课程</h2>
        <p>学习日常对话和基本语法</p>
    </div>
</body>
</html>
  • 这里选择属性选择器来为不同类别的图书卡片设置独特的背景色和文字颜色。

💡 注意:^= 代表以什么开头,不用补充后面的内容

h2::before的意思是在h2之前加一个🌟

margin-right: 5px; 它是离好h2左边5px,即它到h2还有5px,而不是相对与div盒子来说

image.png


3.2 关系选择器

选择器 含义 示例
> 子元素 .container > p
空格 后代元素 .container p
+ 相邻兄弟 h1 + p
~ 通用兄弟 h1 ~ p

效果对比(基于你的代码)

html
预览
<div class="container">
        <p>这是h1前面的文字</p>
        <h1>标题</h1>
        <p>这是第一段文字。</p>
        <p>这是第二段文字。</p>
        <a href="#">链接</a>
        <span>这是一个span元素。</span>
        <div class="inner">
            <p>这是内部段落。</p>
        </div>
    </div>
<style>
        /* 选择器一定是最后的元素 */
        /* + 是相邻元素选择器 */
        h1 + p { 
            color: red;
        }
        /* 相邻兄弟元素选择器 */
        p + p { 
            color: green;
        }
        /* ~是兄弟元素选择器 */
        h1 ~ p { 
            color: blue;
        }
        /* > 子元素选择器 */
        .container > p {
            color: pink;
        }
        /* 空格 是所有后代选择器 */
        .container p {
            text-decoration: underline;
        }
    </style>
  • h1 + p → 仅选中 B
  • h1 ~ p → 选中 B、C(同级后续所有 <p>
  • .container > p → 选中 A、B、C(直接子元素)
  • .container p → 选中 A、B、C、D(所有后代)

image.png


3.3 伪类 vs 伪元素

类型 语法 用途
伪类 单冒号 :hover 描述元素状态
伪元素 双冒号 ::before 创建虚拟内容

伪类实战

css
编辑
li:not(:last-child) { margin-bottom: 10px; }
li:nth-child(odd) { background: lightgray; }
input:checked + label { font-weight: bold; }

:nth-child(n) 要求“第 n 个子元素且是该标签”
:nth-of-type(n) 仅考虑同类兄弟中的第 n 个

伪元素动画效果(你提供的“查看更多”按钮)

css
编辑
.more::before {
  content: '';
  position: absolute;
  bottom: 0; left: 0;
  width: 100%; height: 2px;
  background: yellow;
  transform: scaleX(0);
  transition: transform .3s;
}
.more:hover::before {
  transform: scaleX(1); /* 动画展开下划线 */
}
.more::after {
            display: inline-block; /*  添加这个属性,才能显示图标 */
            content: "\2192";
            margin-left: 5px;
            transition: transform .3s ease;
        }
        .more:hover::after {
            transform: translateX(5px);
        }

💡 技巧:transform-origin: bottom left 控制缩放起点,实现从左到右动画。 为啥只有添加display: inline-block;才能实现动态效果呢? 它让元素既具备 inline 元素的 “同行排列” 特性,又具备 block 元素的 “可设置宽高、margin/padding” 特性很多时候,我们需要这种布局来实现一些视觉上的动态效果,比如让元素并排显示、控制元素大小和间距,或者配合其他属性(如 transitiontransform 等)实现动画


四、常见陷阱与注意事项

4.1 margin 重叠(Margin Collapse)

  • 现象:相邻块级元素的上下 margin 会合并为较大者

  • 解决方案

    • 使用 padding 代替部分 margin
    • 创建 BFC(如 overflow: hidden
    • 使用 Flex/Grid 布局避免传统流式布局问题

4.2 小数 px 的处理

  • 浏览器会将 0.5px 等小数像素四舍五入为整数
  • 在高清屏(Retina)上,可通过 transform: scale(0.5) 模拟 0.5px 边框
css
编辑
.hairline::after {
  content: '';
  position: absolute;
  top: 0; left: 0;
  width: 200%; height: 200%;
  border: 1px solid #ccc;
  transform: scale(0.5);
  transform-origin: 0 0;
}

4.3 transform 对 inline 元素无效

  • inline 元素(如 <span>)不支持 transform
  • 解决:改为 inline-block 或 block
css
编辑
span {
  display: inline-block; /* 必须! */
  transform: rotate(10deg);
}

五、总结要点

主题 关键点
选择器优先级 记住“个十百千”,避免滥用 !important
层叠顺序 来源顺序 + 优先级共同决定最终样式
关系选择器 > vs 空格、+ vs ~ 功能差异大
伪类/伪元素 状态用 :,内容生成用 ::
调试技巧 DevTools 中查看“Computed Styles”和“Matched CSS Rules”

六、拓展思考:CSS 架构与工程化

在大型项目中,仅靠选择器优先级容易导致“样式战争”。推荐:

  • 使用 BEM 命名规范(如 .card__title--highlight
  • 采用 CSS Modules 或 Scoped CSS(Vue)隔离样式
  • 引入 Tailwind CSS 等原子化框架减少自定义选择器

🌐 延伸阅读MDN CSS Specificity


七、结语

CSS 看似简单,但选择器与层叠机制是其精髓所在。掌握这些底层原理,不仅能写出更健壮的样式代码,还能在调试时快速定位问题。希望本文能帮助你从“会用 CSS”进阶到“理解 CSS”。

🔔 互动提问:你在项目中是否遇到过因选择器优先级导致的样式覆盖问题?欢迎评论区分享!

CSS选择器优先级计算你真的会吗?


CSS权重计算

选择器优先级

  • 基本选择器优先级(简单情况)

    !important>行内(内嵌样式)>id选择器>类选择器>元素选择器>通配符选择器

  • 复合选择器优先级(复杂情况)

    一、检查选择器应用方式

    • 检查是否有**!important*提高选择器优先级,有则该选择器优先级最高
    • 检查选择器的应用的样式表,依据:内联>内嵌=外联 的优先级排序选择器
    • 若处于同一样式表,则用三元组法计算权重

    二、权重表示方式

    设定以三元组(a, b, c)来表示选择器的权重,其中各元素含义如下:

    • a:对应 ID 选择器的数量,每发现一个此类选择器,a 的数值相应加 1。
    • b:对应 类选择器、伪类选择器、属性选择器的数量总和,每发现一个此类选择器,b 的数值相应加 1。
    • c:对应 元素(标签)选择器、伪元素选择器的数量总和,每发现一个此类选择器,c 的数值相应加 1。

    三、优先级比较逻辑

    对于需要判定优先级的两个复合选择器,分别记其权重为(a₁, b₁, c₁)和(a₂, b₂, c₂),按以下逻辑步骤比较优先级(相同样式表情况下):

    1. 比较 a 值

    • 若 a₁ > a₂,则权重优先级为(a₁, b₁, c₁)>(a₂, b₂, c₂)对应的选择器,比较终止,无需再考虑 b 值与 c 值情况。

    • 若 a₁ < a₂,则权重优先级为(a₂, b₂, c₂)>(a₁, b₁, c₁)对应的选择器,比较终止,无需再考虑 b 值与 c 值情况。

    • 若 a₁ = a₂,则进入下一步比较 b 值。

    1. 比较 b 值(当 a 值相等时)

    • 若 b₁ > b₂,则权重优先级为(a₁, b₁, c₁)>(a₂, b₂, c₂)对应的选择器,比较终止,无需再考虑 c 值情况。

    • 若 b₁ < b₂,则权重优先级为(a₂, b₂, c₂)>(a₁, b₁, c₁)对应的选择器,比较终止,无需再考虑 c 值情况。

    • 若 b₁ = b₂,则进入下一步比较 c 值。

    1. 比较 c 值(当 a 值与 b 值均相等时)

    • 若 c₁ > c₂,则权重优先级为(a₁, b₁, c₁)>(a₂, b₂, c₂)对应的选择器。

    • 若 c₁ < c₂,则权重优先级为(a₂, b₂, c₂)>(a₁, b₁, c₁)对应的选择器。

    • 若 c₁ = c₂,则表明两个选择器优先级相同

    1. 当a,b,c,值均相等时

    • 比较选择器处于代码中的顺序,依据CSS的层叠规则,后来者居上的优先级更高

CSS选择器的简单理解

在网页开发过程中,浏览器本身并不知道如何呈现网页中各种元素的样式,这就需要人为地去指定。而实现这一目的的关键工具就是 CSS 选择器。CSS 选择器的作用类似于给网页元素贴上特定的 “标识” 。通过这些 “标识”,我们可以准确地告知浏览器哪些元素需要应用特定的样式

具体来说,不同类型的 CSS 选择器能够精准地定位到我们想要修改样式的元素,无论是通过元素本身的标签名、自定义的类名,还是唯一的 ID 标识等方式,CSS 选择器都能够帮助我们在众多网页元素中找到目标元素,并对其样式进行设置。

css选择器的分类

CSS 选择器分为基本选择器复合选择器两类。基本选择器是学习css选择器的必备内容,可实现基本元素选择,但在复杂项目里,因元素繁多、结构复杂,仅用基本选择器选取元素会非常繁琐。而复合选择器作为基本选择器的升级版,能更高效便捷地定位元素,极大提升在复杂项目中的元素选中效率,以适应复杂项目开发需求。 (菜鸟教程)

基本选择器

  • 元素选择器(标签选择器)
  • 类选择器
  • id选择器
  • 通配符选择器

复合选择器

  • 交集选择器
  • 并集选择器
  • 兄弟选择器
  • 属性选择器
  • 后代选择器
  • 伪类选择器
  • 伪元素选择器

基本选择器的使用

元素选择器(标签选择器)

  • 功能 其功能能够一次性对页面中所有指定类型的标签内容样式进行修改。在上述示例中,由于使用了p标签选择器并设置color: red的样式,所以页面中所有的<p>标签内的文字颜色都被修改为了红色。这使得在样式设计过程中,当多个相同标签需要统一的样式设置时,可以方便快捷地通过标签选择器来实现。
  • 基本格式
<head>
 <style>
        p{
            color: red;
        }
    </style>
</head>
<body>
   <P>标签选择器01</P>
  <P>标签选择器02</P>
  <h1>标签选择器03</h1>
</body>

类选择器

  • 功能 编码者能够依据设计需求与元素的功能、样式特点等,为特定元素赋予自定义的类名,从而将这些元素划分到相应的类别中。之后,在 CSS 样式表中,借助类选择器(其语法形式为以. 开头,后跟自定义的类名),可以精准地选中页面内所有被标记为同一类名的元素。一旦选中,就能够方便地对这些同类元素统一进行各种样式属性的修改与设定
  • 基本格式
<head>
<style>
       .xx{
           color: red;
       }
   </style>
</head>
<body>
 <P class="xx">类选择器01</P>
 <P>类选择器02</P>
 <h1 class="xx">类选择器03</h1>

</body>

id选择器

  • 功能 ID 选择器的主要作用是为页面中某一个特定且唯一的元素指定独一无二的 ID 名称。在 HTML 文档中,通过元素的id属性赋予其 ID 值,之后,在 CSS 样式表中,就能够使用#加上该特定的 ID 名称的 ID 选择器精准地选中与之对应的这个唯一的元素,从而可以对其单独设置样式,使其在页面中呈现出独特的样式效果,与其他元素区分开来。
  • 基本格式:
<head>
 <style>
       #xx{
           color: red;
       }
   </style>
</head>
<body>
 <P id="xx">id选择器01</P>
 <P>id选择器02</P>
 <h1>id选择器03</h1>

</body>

通配符选择器

  • 功能 通配符选择器的功能在于它能够选中页面内的所有元素,无论元素是什么类型、具有何种属性或者处于何种层级结构中,都能被其涵盖。这使得在一些需要对整个页面的元素进行全局性样式重置或者统一基础样式设置的情况下非常有用,比如去除所有元素默认的浏览器样式等,以此来构建一个样式基础相对统一的页面样式起点,方便后续进一步针对具体元素进行个性化的样式添加和调整。
  • 基本格式
<head>
   <style>
       * {
           margin: 0;
           padding: 0;
           color:red;
       }
   </style>
</head>
<body>
   <p>段落内容</p>
   <h1>标题内容</h1>
   <div>这是一个 div 元素</div>
   <a href="#">链接元素</a>
   <!-- 页面中无论还有其他什么元素,都会应用上面通配符选择器设置的样式 -->
</body>

复合选择器的使用

交集选择器(并列选择器)

  • 功能 交集选择器主要用于精确筛选页面中的元素。其具体的筛选规则是,仅选中那些同时符合两个或更多特定条件的元素,这些条件可以是相同的标签名与类名的组合,或者是相同的标签名与 ID 名的组合等。
  • 基本格式
<style>
 <head>
    <style>
        p.beauty{
            color: red;
            font-size: 20px;
        }
    </style>
</head>
<body>
   <h1 class="rich">土豪张三</h1>
   <h2 class="beauty">明星李四</h2>
   <p class="rich">小狗旺财</p>
   <p class="beauty">小猪佩奇</p>
</body>

并集选择器

  • 功能 并集选择器用于在页面中选取多个不同组别的元素,这些元素只需满足其中一个特定条件即可被选中。其特定条件可以是相同的标签名、类名或者 ID 名等。 例如,若使用并集选择器 h1, p.aaa, #xx,那么页面中的所有 <h1> 标签、具有 aaa 类名的 <p> 标签以及具有 xx ID 名的元素都会被选中。
  • 基本格式
  <head>
    <style>
        h1,#xx,.aaa{
            color: red;
            font-size: 20px;
        }
    </style>
</head>
<body>
   <h1>这个是个h1标签</h1>
   <h2>这个是个h2标签</h2>
   <h3 id="xx">这个是个h3标签</h3>
   <h4 class="aaa">这个是个h4标签</h4>
   <P>这是个p标签</P>
   <p class="aaa">这是个p(aaa)标签</p>

</body>

兄弟选择器

  • 功能 兄弟选择器主要用于依据元素间的兄弟关系来选取特定元素。分为相邻兄弟选择器和通用兄弟选择器。

    • 相邻兄弟选择器,能够选中紧跟在指定元素之后的同层级且为特定类型的第一个元素。例如,h1 + p 会选中紧挨着 <h1> 标签之后的第一个 <p> 标签。
    • 通用兄弟选择器,则可以选中指定元素之后同层级的所有特定类型的元素。比如,h1 ~ p 会选中 <h1> 标签之后的所有同层级的 <p> 标签。这两种兄弟选择器在对具有兄弟关系的元素进行样式设置和布局调整时,能够提供精准且灵活的元素选取方式,以满足多样化的页面设计需求。
  • 基本格式 相邻兄弟选择器

 <head>
    <style>
      h1 +div{
            color: red;
        }
    </style>
</head>
<body>

  <h1>h1标题元素</h1>
 <div>div元素1</div>
 <p>p段落元素1</p>
 <p>p段落元素2</p>
 <p>p段落元素3</p>
 <div>div元素2</div>

</body>

通用兄弟选择器

html
    <head>
    <style>
      h1 ~ span{
            color: red;
        }
    </style>
</head>
<body>

 <h1>h1标题元素</h1>
 <div>div元素1</div>
 <p>p段落元素1</p>
 <p>p段落元素2</p>
 <p>p段落元素3
  <span>
      我是p元素的子span元素
  <span/>
 </p>
 <span>div元素2</span>
</body>

属性选择器

  • 功能

    属性选择器能够依据元素所具有的特定属性或者特定的属性值来精准地选取元素。它为开发者在 CSS 样式设置过程中提供了一种灵活且强大的手段,通过指定元素的某个属性名或属性值作为筛选条件,从而有针对性地对符合条件的元素应用样式,以实现多样化的页面设计效果与布局需求

  • 存在属性选择器

    用于选择具有指定属性的所有元素,而不管该属性的值是什么。例如,[class]会选中所有带有class属性的元素,这在需要对具有某类属性的元素统一设置样式时非常有用,如[class] { color :red ; }会给所有有class属性的元素字体变为红色。

    • 基本格式:
    <style>
       [class]{
            color: red;
        }
    </style>
</head>
<body>

    <h1>h1标题元素</h1>
    <h2 class="aa">h2标签元素</h2>
    <p class="bb"> p标签元素</p>

</body>
</html>
  • 精确匹配属性值选择器 它会选择属性值完全等于指定值的元素。比如[class="aa"]会选中所有class属性值为aa的元素,然后可以对这些元素进行特定的样式设置,如[class="aa"]{color: red;}会将所有的class属性aa属性值的元素标红.

    • 基本结构
    <style>
       [class="aa"]{
            color: red;
        }
    </style>
</head>
<body>
    <h1>h1标题元素</h1>
    <h2 class="aa">h2标签元素(aa)</h2>
    <p class="bb"> p标签元素(bb)</p>

</body>
</html>
  • 部分匹配属性值选择器

    • 以指定值开头匹配:选择属性值以指定值开头的元素。例如,a[href^="https"]会选中所有href属性值以https开头的a链接元素,可用于对不同协议开头的链接设置不同样式。
    • 以指定值结尾匹配:选择属性值以指定值结尾的元素。如img[src$=".jpg"]会选中所有src属性值以.jpg结尾的图片元素,方便对特定格式的图片进行统一处理。
    • 包含指定值匹配: 选择属性值中包含指定值的元素。例如,p[class*="important"]会选中所有class属性值中包含important的段落元素,可针对包含特定关键词的类名进行样式调整。
  • 属性值包含特定单词选择器:用于选择属性值包含以空格分隔的特定单词的元素。例如,p[class~="warning"]会选中class属性值包含warning单词的段落元素,即使class属性值还有其他单词,如class="alert warning"也会被选中。

  • 属性值以连字符分隔包含特定单词选择器:当属性值是以连字符分隔的单词列表时,选择以指定值开头的元素。例如,img[alt|="product"]会选中alt属性值为product或以product-开头的图片元素,常用于处理具有特定命名规范的属性值。

关系选择器

关系选择器是 CSS 中用于根据元素之间的特定关系来选择元素的一类选择器,它可以更精准地对页面中的元素进行样式控制

关系选择器分为:子代选择器和后代选择器

子代选择器

  • 功能

    子代选择器用于选择某个元素的直接子元素。它通过 “>” 符号来连接父元素和子元素的选择器。

  • 基本格式

  <head>
    <style>
      p> span{
            color: red;
        }
    </style>
</head>
<body>
 <h1>h1标题元素</h1>
 <p>p段落元素1
  <span>
      我是p元素的子span元素
      <p>
      我是p元素的子span的子p元素
    </P>
  <span/>
 </p>
</body>

后代选择器

  • 功能 后代选择器用于选择某个元素的所有后代元素,包括子元素、孙元素、曾孙元素等,无论嵌套层次有多深。
  • 基本结构
    <style>
      p span{
            color: red;
        }
    </style>
</head>
<body>

 <h1>h1标题元素</h1>
 <div>div元素1</div>
 <p> p段落元素1
  <span>
      我是p元素的子span元素
      <span>
      我是p元素的子span的子p元素
      <p>
        我是p元素的子span的子p元素的子p元素
      </span>
    </P>
  <span/>
 </p>
</body>

伪元素选择器

  • 功能

    伪元素选择器在 CSS 中具有独特而重要的功能,它主要用于向页面中的元素添加一些特殊的效果或内容,而无需在 HTML 结构中直接添加额外的元素,以下是其具体功能:

    • 添加装饰性内容

      ::before和::after:这两个伪元素选择器可以在被选元素的内容之前或之后插入生成的内容。例如,可以使用::before在段落开头添加一个特殊的图标或装饰性符号,或者使用::after在链接文本后添加一个箭头图标,增强视觉效果和引导用户交互。这些生成的内容可以通过content属性进行定义,并且可以设置其样式,如颜色、大小、位置等,与被选元素的样式相互配合,实现丰富多样的页面装饰效果。

    • 实现文本修饰效果

      ::first-letter:用于选择元素内文本的第一个字母,并对其进行特殊的样式设置。比如,可以将段落的第一个字母放大、改变颜色或设置为特殊字体,形成首字下沉或突出显示的效果,增加文本的视觉吸引力和可读性。

      ::first-line:它能够选择元素内文本的第一行,对其进行单独的样式设置。例如,将第一行文本设置为加粗、变色或添加下划线等,可用于突出显示重要信息或创建独特的文本排版效果,常用于文章标题、段落开头等位置。

伪类选择器

  • 功能:伪类选择器是 CSS 中一种特殊的选择器,它的主要功能是根据元素的特定状态或位置等条件来选择和应用样式,而不需要在 HTML 文档中为这些状态或位置单独添加类名或 ID。以下是其具体功能:

    • 链接相关状态选择(一定要按如下顺序编写):link可以为未点击过的链接设置样式,如颜色、下划线等;:visited则可对已访问过的链接进行样式修改,通常为了区分访问状态会将已访问链接的颜色设置得与未访问链接不同,这有助于用户识别哪些链接已经点击过。:hover用于当鼠标指针悬停在元素上方时改变元素的样式,如改变按钮的背景颜色、文本颜色或添加阴影效果等,能为用户提供即时的视觉反馈,增强交互性;:active是在元素被激活时应用样式,例如鼠标按下按钮时的样式变化;:focus则主要用于当元素获得焦点时的样式设置,如表单输入框获得焦点时改变边框颜色或显示提示信息等,方便用户明确当前操作的元素。
    • 基本结构
     <head>
     <style>
    a:link
    {
       color: red;
    }
    a:visited
    {
       color: green;
    }
    a:hover
    {
       color: rgb(0, 0, 0);
    }
    a:active
    {
       color: yellow;
    }
    </style>
    </head>
    <body>
    <a href="<https://juejin.cn/>" target="_blank">这是一个测试链接
    
    </a>
    
    <span class="aa">
    100
    </span>
    
    </body>
    

CSS选择器优先级权重计算

CSS层叠样式表

CSS有多种样式应用方法

优先级:内联>内嵌=外联

以下是一些常见的方式:

内联样式

  • 语法:在 HTML 元素的style属性中直接定义 CSS 样式规则。例如:
<p style="color: red; font-size: 16px;">这是一段红色、16px大小的文字</p>`
  • 特点

    • 优先级高:内联样式的优先级高于内部样式表和外部样式表中的同名样式,因此可以直接对单个元素进行精确的样式控制,覆盖其他样式定义。
    • 作用范围窄:仅作用于当前元素,不会影响其他元素的样式,具有很强的针对性和独立性。
    • 维护性差:当需要对多个元素应用相同样式时,需要在每个元素的style属性中重复编写样式规则,代码冗余度高,不利于后期的修改和维护。

内嵌样式

  • 语法:在 HTML 文档的<head>标签内使用<style>标签来定义 CSS 样式规则。例如:
<head>
  <style>
    p {
      color: blue;
      font-size: 18px;
    }
  </style>
</head>
<body>
  <p>这是一段蓝色、18px大小的文字</p>
</body>
  • 特点

    • 局部作用域:样式规则仅适用于当前 HTML 文档,不会影响其他 HTML 文件,可针对特定页面进行样式定制。
    • 方便修改:所有样式定义集中在<style>标签内,便于对当前页面的所有样式进行统一管理和修改,相较于内联样式,减少了代码的重复度。
    • 不利于复用:如果多个 HTML 页面需要相同的样式,需要在每个页面的<style>标签中重复定义,无法实现样式的复用,增加了开发和维护的工作量。

外联样式

  • 语法:将 CSS 样式代码写在一个独立的.css文件中,然后通过 HTML 文档中的<link>标签将该 CSS 文件引入到 HTML 页面中。例如,在styles.css文件中定义样式:
p {
  color: green;
  font-size: 20px;
}

在 HTML 文件中引入:

<head>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <p>这是一段绿色、20px大小的文字</p>
</body>
  • 特点

    • 样式复用:可以在多个 HTML 页面中引入同一个外部样式表文件,实现样式的复用,提高了代码的可维护性和可复用性,当需要修改样式时,只需修改外部样式表文件,所有引用该文件的页面样式都会随之改变。
    • 结构与样式分离:将样式代码从 HTML 文档中分离出来,使得 HTML 文件专注于页面结构的构建,CSS 文件专注于样式的设计,降低了代码的耦合度,提高了代码的可读性和可维护性,便于团队协作开发。
    • 缓存机制:浏览器会对外部样式表文件进行缓存,当用户再次访问引用该样式表的页面时,浏览器可以直接从缓存中读取样式表,加快页面的加载速度,提高用户体验。

MJML邮件如何随宽度变化动态切换有几列📮

需求:邮件中需要展示数组信息,每个模块宽高固定不变,在PC端(600px)三列展示在移动端(400px)两列展示,且该mjml格式邮件样式在GMail中可以正常显示。

MJML官方文档:MJML - The Responsive Email Framework

MJML在现示例查看:Email Editor

一、效果展示及完整代码

1.1. 效果展示

PC端(宽度600px)

移动端(宽度400px)

1.2. 完整代码

注:下列代码请在支持解析MJML文件的项目下运行查看


<mjml>
  <mj-head>
    <mj-style inline="inline">
      .card-content {
        width: 100%;
        text-align: left;
        font-size:0;
        background: red;
      }
      .fixed-item {
        display: inline-block !important;
        width: 180px !important;
        height: 100px !important;
        margin: 10px !important;
        color: #000;
        font-size: 14px;
        line-height: 100px;
        background: #f0f0f0 !important;
        text-align: center !important;
        vertical-align: top !important;
      }
      .item-image {
        float: left;
        width: 42%;
        height: 100%;
      }
      .item-image img {
        width: 100%;
      }
      .item-details {
        float: left;
        width: 58%;
        height: 100%;
        font-family: PingFang SC;
        text-align: left;
      }
      .item-details-text-title {
        margin: 15px 10px 5px 10px;
        height: 24px;
        line-height: 24px;
        font-size: 18px;
        font-weight: 600;
        color: #13171D;
      }
      .item-details-text-subtitle {
        margin: 0 10px;
        height: 24px;
        line-height: 24px;
        font-size: 14px;
        color: #6d6d6d;
      }
    </mj-style>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-raw>
          <div class="card-content">
            <!-- 固定宽高元素会自动换行 -->
            <div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字1</div>
                <div class="item-details-text-subtitle">这是一段描述文字「1」</div>
              </div>
            </div>
            <div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字2</div>
                <div class="item-details-text-subtitle">这是一段描述文字「2」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字3</div>
                <div class="item-details-text-subtitle">这是一段描述文字「3」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字4</div>
                <div class="item-details-text-subtitle">这是一段描述文字「4」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字5</div>
                <div class="item-details-text-subtitle">这是一段描述文字「5」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字6</div>
                <div class="item-details-text-subtitle">这是一段描述文字「6」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字7</div>
                <div class="item-details-text-subtitle">这是一段描述文字「7」</div>
              </div>
            </div><div class="fixed-item">
              <div class="item-image">
                <img src="https://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" />
              </div>
              <div class="item-details">
                <div class="item-details-text-title">名字8</div>
                <div class="item-details-text-subtitle">这是一段描述文字「8」</div>
              </div>
            </div>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

二、实现方法及逻辑解析

需求整理:

  • PC端(宽度600px)下三列显示,移动端(400px)下两列展示
  • 数组的每个元素宽高固定不变,不会随着宽高变化而比例性 压缩/拉伸
  • GMail中样式内容正常显示
  • MJML中不支持javascrip逻辑,MJML智能单纯的显示同步显示的值

方法调研

方法一( ❌ 不可行)

基于以上需求调研发现GMail不支持CSS3样式语法,这样下来display:flexdisplay:girdposition等诸多样式均不可使用

方法二( ❌ 不可行)

MJML中不支持写入javascrip逻辑,所以试用javascrip 操控/监听 DOM的方法是行不通的

方法三( ❌ 不可行)

MJML标签中有一个<mj-fixed-column width="33.3%">可以设置一行有几列,最后将<mj-fixed-column width="33.3%">标签包裹在<mj-section padding="0">

    1. 但是因为不能使用javascrip语言来监听尺寸变化,所以不能动态切换<mj-fixed-column>标签中width何时为 50% 何时为 33.3%
    2. 所以通过网络上查询发现可以考虑使用@media screen and (max-width: 480px),来实现屏幕尺寸变化时,来通过class样式来改变元素宽度
    3. 但是配置后发现MJML不能识别 或 运行@media screen and (max-width: 480px)这种代码,类似于MJML不能运行javascrip一样
方法四( ✅ 可行)

故基于以上,思路需要调整为如何让数组元素在GMail支持的样式配置中,跟随宽度变化自动换行,这样使得宽度为600px时三列显示,在宽度为400px时两列显示

通过配置如下代码:


<mjml>
  <mj-head>
    <mj-style inline="inline">
      .fixed-item {
        display: inline-block !important;
        width: 100px !important;
        height: 100px !important;
        margin: 10px !important;
        background: #f0f0f0 !important;
        text-align: center !important;
        vertical-align: top !important;
      }
    </mj-style>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-raw>
          <div style="text-align: left; font-size: 0;">
            <!-- 固定宽高元素会自动换行 -->
            <div class="fixed-item">项目1</div>
            <div class="fixed-item">项目2</div>
            <div class="fixed-item">项目3</div>
            <div class="fixed-item">项目4</div>
            <div class="fixed-item">项目5</div>
            <div class="fixed-item">项目6</div>
            <div class="fixed-item">项目7</div>
            <div class="fixed-item">项目8</div>
          </div>
        </mj-raw>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

CSS实现高级流光按钮动画,这几行代码堪称神来之笔

大家好,我是大华!今天分享一个CSS流光按钮效果。这种效果在现代网站设计中非常流行,能够明细的提升用户体验和页面视觉吸引力。

先看一下最终效果

在这里插入图片描述

当鼠标悬停在按钮上时,按钮会上升并显示流动的彩色边框,同时内部会有高光扫过,实现了流光溢彩的视觉效果。

HTML结构

首先,我们来看HTML结构,它非常简洁明了:

<div class="container">
    <h1>这个标题也是有效果的哦~</h1>
    
    <p class="description">
        这是一个完全使用CSS创建的流光效果...
    </p>
    
    <div class="button-container">
        <a href="#" class="btn btn-primary"><span>开始体验</span></a>
        <a href="#" class="btn btn-secondary"><span>了解更多</span></a>
        <a href="#" class="btn btn-tertiary"><span>立即下载</span></a>
    </div>
</div>

CSS样式

1. 流光文字效果

h1 {
    color: transparent;
    background: linear-gradient(90deg, #ff0080, #00ffcc, #ff0080);
    background-size: 200% auto;
    background-clip: text;
    -webkit-background-clip: text;
    margin-bottom: 30px;
    font-size: 2.8rem;
    animation: textShine 5s linear infinite;
}

@keyframes textShine {
    0%, 100% {
        background-position: 0% center;
    }
    50% {
        background-position: 100% center;
    }
}

这里使用了几个关键技巧:

  • color: transparent让文字本身透明
  • 创建一个线性渐变背景,包含粉色和青蓝色
  • background-clip: text让背景只显示在文字区域
  • 通过动画改变背景位置,创造出流光效果

2. 按钮基础样式

.btn {
    position: relative;
    width: 240px;
    height: 70px;
    line-height: 70px;
    text-align: center;
    text-decoration: none;
    text-transform: uppercase;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 1px;
    color: #fff;
    background: rgba(20, 20, 40, 0.8);
    border-radius: 12px;
    z-index: 1;
    transition: all 0.4s ease;
    overflow: hidden;
    box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}

按钮的基础样式设置了:

  • 相对定位,为后面的伪元素定位做准备
  • 固定的宽度和高度
  • 深色半透明背景
  • 圆角边框
  • 阴影增加立体感
  • 过渡效果,让状态变化更平滑

3. 流光边框效果(核心实现)

.btn::before {
    content: "";
    position: absolute;
    top: -2px;
    left: -2px;
    right: -2px;
    bottom: -2px;
    background: linear-gradient(45deg, #ff0080, #00ffcc, #0066ff, #ff0080);
    background-size: 400% 400%;
    border-radius: 14px;
    z-index: -1;
    opacity: 0;
    transition: opacity 0.4s ease;
    animation: borderGlow 6s ease infinite;
}

@keyframes borderGlow {
    0%, 100% {
        background-position: 0% 50%;
    }
    50% {
        background-position: 100% 50%;
    }
}

这是实现流光边框的关键:

  • 使用::before伪元素创建边框
  • 设置top/left/right/bottom为-2px,让它比按钮本身大一点
  • 创建多彩渐变背景,并设置较大的背景尺寸
  • 初始状态透明度为0,悬停时变为1
  • 通过动画不断改变背景位置,创造出流动效果

4. 内部高光效果

.btn::after {
    content: "";
    position: absolute;
    top: 0;
    left: -100%;
    width: 100%;
    height: 100%;
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
    transition: left 0.7s ease;
    z-index: 0;
}

这个效果:

  • 使用::after伪元素创建内部高光
  • 初始位置在按钮左侧外部(left: -100%)
  • 悬停时移动到右侧外部(left: 100%)
  • 创建一个透明-半透明白色-透明的渐变,模拟高光

5. 悬停效果

.btn:hover {
    transform: translateY(-5px);
    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}

.btn:hover::before {
    opacity: 1;
}

.btn:hover::after {
    left: 100%;
}

当鼠标悬停时:

  • 按钮向上移动5像素
  • 阴影变大,增强立体感
  • 显示流光边框
  • 触发内部高光动画

完整源码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>流光按钮效果</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: radial-gradient(circle at center, #0f1b33, #000);
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
            overflow-x: hidden;
        }
        
        .container {
            text-align: center;
            max-width: 900px;
            width: 100%;
        }
        
        h1 {
            color: transparent;
            background: linear-gradient(90deg, #ff0080, #00ffcc, #ff0080);
            background-size: 200% auto;
            background-clip: text;
            -webkit-background-clip: text;
            margin-bottom: 30px;
            font-size: 2.8rem;
            animation: textShine 5s linear infinite;
        }
        
        .description {
            color: #a0aec0;
            margin-bottom: 50px;
            line-height: 1.6;
            font-size: 1.1rem;
            max-width: 700px;
            margin-left: auto;
            margin-right: auto;
        }
        
        .button-container {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 30px;
            margin-top: 40px;
        }
        
        /* 按钮基础样式 */
        .btn {
            position: relative;
            width: 240px;
            height: 70px;
            line-height: 70px;
            text-align: center;
            text-decoration: none;
            text-transform: uppercase;
            font-size: 18px;
            font-weight: 600;
            letter-spacing: 1px;
            color: #fff;
            background: rgba(20, 20, 40, 0.8);
            border-radius: 12px;
            z-index: 1;
            transition: all 0.4s ease;
            overflow: hidden;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
        }
        
        /* 流光边框效果 */
        .btn::before {
            content: "";
            position: absolute;
            top: -2px;
            left: -2px;
            right: -2px;
            bottom: -2px;
            background: linear-gradient(45deg, #ff0080, #00ffcc, #0066ff, #ff0080);
            background-size: 400% 400%;
            border-radius: 14px;
            z-index: -1;
            opacity: 0;
            transition: opacity 0.4s ease;
            animation: borderGlow 6s ease infinite;
        }
        
        /* 内部流光效果 */
        .btn::after {
            content: "";
            position: absolute;
            top: 0;
            left: -100%;
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
            transition: left 0.7s ease;
            z-index: 0;
        }
        
        .btn:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
        }
        
        .btn:hover::before {
            opacity: 1;
        }
        
        .btn:hover::after {
            left: 100%;
        }
        
        .btn span {
            position: relative;
            z-index: 2;
        }
        
        /* 不同按钮的颜色变化 */
        .btn-primary::before {
            background: linear-gradient(45deg, #ff0080, #ff3399, #ff0080);
        }
        
        .btn-secondary::before {
            background: linear-gradient(45deg, #00ffcc, #33ffd6, #00ffcc);
        }
        
        .btn-tertiary::before {
            background: linear-gradient(45deg, #0066ff, #3399ff, #0066ff);
        }
        
        @keyframes borderGlow {
            0%, 100% {
                background-position: 0% 50%;
            }
            50% {
                background-position: 100% 50%;
            }
        }
        
        @keyframes textShine {
            0%, 100% {
                background-position: 0% center;
            }
            50% {
                background-position: 100% center;
            }
        }
        
    </style>
</head>
<body>
    <div class="container">
        <h1>这个标题也是有效果的哦~</h1>
        
        <p class="description">
            这是一个完全使用CSS创建的流光效果。按钮具有动态流光边框和内部高光动画,当鼠标悬停时,按钮会上升并显示流动的光效,带来沉浸式的视觉体验。
        </p>
        
        <div class="button-container">
            <a href="#" class="btn btn-primary"><span>开始体验</span></a>
            <a href="#" class="btn btn-secondary"><span>了解更多</span></a>
            <a href="#" class="btn btn-tertiary"><span>立即下载</span></a>
        </div>
        
    </div>
</body>
</html>

总结

1. 伪元素的使用::before::after伪元素让我们可以在不添加额外HTML元素的情况下创建复杂的视觉效果。

2. CSS渐变:线性渐变(linear-gradient)是创建流光效果的核心,通过设置多个颜色停止点创造出丰富的色彩过渡。

3. CSS动画:通过@keyframesanimation属性,我们可以创建平滑的动画效果,而不需要JavaScript。

4. 背景裁剪background-clip: text是一个很有用的属性,可以让背景只显示在文字区域。

5. Z-index层级管理:正确设置z-index确保各个元素按正确的顺序堆叠。

扩展思路

你可以尝试:

  • 改变渐变色创建不同的主题
  • 调整动画速度和方向
  • 添加点击效果

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《MySQL 为什么不推荐用雪花ID 和 UUID 做主键?》

《用html写了个超好用的网页主题切换插件》

《SpringBoot3+Vue3实现的数据库文档工具,自动生成Markdown/HTML》

重新思考 weapp-tailwindcss 的未来

root-3787121_1280.jpg

重新思考 weapp-tailwindcss 的未来

大家好,我是 weapp-tailwindcssweapp-vite 的作者 icebreaker。

最近我一直在思考 weapp-tailwindcss 的未来,以至于都没有怎么玩,最近回归的星际争霸2。

巨大的阻碍

为什么?因为之前有一个很重要的问题,严重阻碍了 weapp-tailwindcss 发展的脚步。

那就是 tailwind-merge / class-variance-authority / tailwind-variants 这些极其重要的原子化样式基础包,没有什么很好的办法在小程序里使用。

为什么无法在小程序里使用?

简短一点来说,核心原因是,小程序 wxml 类名中,不允许很多特殊字符串,比如 ![]# 等字符。

所以 weapp-tailwindcss 根据这个设计,在编译时,就对 tailwindcss 类名进行转换,从而达到了兼容市面上众多小程序的编译插件。

比如用户写的是 bg-[#123456],被 weapp-tailwindcss 捕获到了之后,在编译的时候,就会同时把 wxmljswxss 里面的这个类名转换成小程序可以接受的 bg-_h123456_

tailwind-merge 它们都是在运行时进行计算的,那时候它们接收到的,已经是 bg-_h123456_ 这种转译之后的字符串,自然合并不了,导致到处出错。

为了兼容,我做了非常多的尝试!给大家展示一下我的受苦之路吧!

1. tailwind-merge plugin / createTailwindMerge

最直观的念头,就是给 tailwind-merge 写一个 weapp-tailwindcss 专用插件就好了!

于是我开始阅读 tailwind-merge 源代码,并尝试使用 extendTailwindMergecreateTailwindMerge 完全创建出一个属于我自己的 weapp-tailwind-merge 来。

在尝试过程中,我把 tailwind-merge 的内部冲突表导出,尝试用自定义 escape hook 覆盖那些非法字符;甚至写了一个半成品的 createTailwindMerge 变体,希望能在编译阶段就生成完全符合小程序命名规则的类名。

然而,现实很快给了我当头棒喝:tailwind-merge运行时字符串的依赖极强,部分字符是强依赖,根本无法替换。

下面这几个字符串都是写在常量里的,无法通过配置更换

export const IMPORTANT_MODIFIER = '!' // 小程序不行
const MODIFIER_SEPARATOR = ':' // 小程序不行

详见 github.com/dcastil/tai…

所以这已经不是 extendTailwindMergecreateTailwindMerge 能够解决的问题了。

摆在我面前的,是一条看不到未来的路:为了强行兼容,我需要重写它的核心,fork 一个全新的包,这个成本是巨大的。

2. 编译期豁免

第二条路看起来更务实:沿用我熟悉的编译期管线,给 twMerge / twJoin / cva 等函数做“豁免处理”。

我当时是这样想的,只要在编译时忽略它们内部的转义,运行时拿到的就是完整的 class 字符串,那 tailwind-merge 不就能工作了吗?

然后我再包装一下 twMerge 函数,让它获取最后的结果的时候 escape 不就行了吗?

大概长这样:

export function cn(...inputs: ClassValue[]) {
  const result = twMerge(inputs)
  return escape(result)
}

然后我让 cn 里面的字面量和模板字符串跳过转义不就行了吗?

// 第一个是字符串,第二个是模板字符串,它们对应的 ast 类型不同,需要分开处理
// 里面的不转译
cn('bg-[#123456]', `bg-[#987654]`)

// 假如转译那么,结果如下
// cn('bg-_h123456_',`bg-_h987654_`)

看上去运行良好,然而情况正在变得越来越复杂:

嘿,变量引用来了:

const a = 'bg-[#123456]'
cn(a, 'xx', 'yy')

嘿嘿,变量引用 + 表达式来了:

const a = 'bg-[#123456]' + ' bb' + ` text-[#123456]`
cn(a, 'xx', 'yy')

嘿嘿嘿,变量引用链路 + 表达式 + 模板插值来了:

const b = 'after:xx'; const a = 'bg-[#123456]' + ' bb' + `${b} text-[#123456]`
cn(a, 'xx', 'yy')

哈哈,只是在考验我操作 ast 进行预编译的水平而已!

吃我一拳:ASTNodePathWalker + scope.getBinding + WeakMap,哈哈轻松消灭!

于是我以为这条思路可行,编写了 @weapp-tailwindcss/merge 的 v1 版本。

直到用户提交了新的 case!

新的挑战

什么,怎么还有你们这种相互引用的情况!

// shared2.js
export const ddd = 'bg-[#123456]'

const a = 'bg-[#123456]'

export {
  a as default
}
// shared.js
export const a = 'bg-[#123456]'

const b = 'bg-[#123456]'

const c = 'bg-[#123456]'

const d = 'bg-[#123456]'

export default d

export {
  b
}

export {
  c as xaxaxaxa,
}

export * from './shared2'
// main.js
import cc, { b as aa, a as bb } from './shared'
import * as shared from './shared'

cn(bb, cc, aa, shared.default, shared.a, '[]', '()')

……我吐了,这是要我自己去实现一个 webpack / rollup 打包器嘛?有点搞不定啊!

不过困难怕什么,我要迎难而上!于是我仿照了 rollup 的思路,收集了每个模块的 import / export 这里面大量的 ast 节点,并构建出了一个 ModuleGraph

另外表面上看这条路是可行的,我甚至找到了几个 demo 可以跑通,我还把豁免名单抽离出来,变成了 ignoreCallExpressionIdentifiers 配置项,以为自己解决了问题。

然而理想很丰满,现实很骨感

这套方案高度依赖 AST 解析和构建工具的配合,我写的插件无法保证运行时得到的类名永远完整。构建链路上的任一环节——Terser、esbuild、rollup 插件甚至手写 Babel 宏——都可能把函数名或模板字符串的标识符压缩重命名,导致最后留给运行时的是一个残缺的字符串。

用人话说就是 cn , twMerge, tv 这种方法,在产物里面被重命名成 e/a/c 这种玩意,所以我必须在压缩之前就进行豁免操作,但是那时候我似乎无法去准确收集产物的模块依赖情况(可能是水平不够导致的)。

那一刻我意识到,所谓“编译期豁免”只是在延迟爆炸时间,而不是解除危机。

在两条路都走到尽头之后,只剩下一个选择:彻底重构 merge,让逃逸逻辑回归运行时,让编译阶段恢复简单纯粹。

为什么要重写 merge?

复盘 1.x 旧版 merge,我发现我当时的设计基于两个假设:一是 tailwind-merge 的输入输出始终可控,二是编译器可以精准标记所有“需要放行”的调用。 这两个假设已经被现实击碎。

早期的 @weapp-tailwindcss/merge 主要目标是“把 tailwind-merge 的结果变成小程序合法类名”。我采取的策略是:

  • 继续使用 tailwind-merge 做冲突解析;
  • 在编译阶段通过函数名黑名单 ignoreCallExpressionIdentifiers 跳过对 twMerge / twJoin / cva 等调用的转义;
  • 把责任交给开发者:运行时得到的类名包含非法字符,需要手动再 escape。

这种模式在 Tailwind CSS v3 勉强能用,但一到 v4 就崩溃了:

  1. 编译期豁免并不等于安全 twMerge('text-[#ececec]', 'text-(--my-custom-color)') 最终仍然输出原始字符串。稍微复杂一点的条件拼接、链式调用、动态导入,编译器根本判断不出来该不该跳过。
  2. 函数名黑名单无法覆盖新的 API 新版本开始导出 create()、variants(tv)等工厂,调用形式千奇百怪,编译阶段根本匹配不到。
  3. 任意值语法越来越灵活 Tailwind v4 的任意值可以是 text-[theme(my.scale.foo)] 这种无法静态推断类型的写法。靠黑名单永远落后,反而让用户更困惑。

新版 merge 的核心思路

决定“把锅背回运行时”以后,我做的第一件事就是把入口全部进行统一:twMerge / twJoin / createTailwindMerge / extendTailwindMerge / cva / variants……统统绑进同一套 transformer 里。

思路很简单:先找出它们共有的“进场”和“退场”动作,再把逃逸拆成前后两个钩子, escapeunescape

const transformers = resolveTransformers(options)

const aggregators = {
  escape: transformers.escape,
  unescape: transformers.unescape,
}

在实现里我刻意把 escape 和 unescape 拆成两个“齿轮”。不管是用户直接手点 twMerge,还是 variants 工厂兜一圈回来,都会先进统一的预处理,再丢给 tailwind-merge。

这等于在运行时补了一层“语义编译器”。

双向处理链

所以现在每次 merge 现在都得过一遍 unescape -> tailwind-merge -> escape 这样的流程:

const normalized = transformers.unescape(clsx(...inputs))
return transformers.escape(fn(normalized))

但是这样还不够,为了实现 escapeunescape 我还必须从源头上出发,更改 @weapp-core/escape 的转译规则,才能让每一个字符串映射变得独一无二

重写 @weapp-core/escape

老 escape 工具一直挂在 @weapp-core/escape 上,它走的是“多对一”映射,贴一段旧代码大家感受一下:

export const MappingChars2String: MappingStringDictionary = {
  '[': '_',
  ']': '_',
  // for tailwindcss v4
  '(': 'y',
  ')': 'y',
  '{': 'z',
  '}': 'z',
  '+': 'a',
  ',': 'b',
  ':': 'c',
  '.': 'd',
  '=': 'e',
  ';': 'f',
  '>': 'g',
  '#': 'h',
  '!': 'i',
  '@': 'j',
  '^': 'k',
  '<': 'l',
  '*': 'm',
  '&': 'n',
  '?': 'o',
  '%': 'p',
  '\'': 'q',
  '$': 'r',
  '/': 's',
  '~': 't',
  '|': 'u',
  '`': 'v',
  '\\': 'w',
  '"': 'x',
}

问题马上就来了:它完全做不到配对 unescape[] 被一起砸成 _( / ){ / } 也全堆在同一个值上,运行时根本还原不回去。举个让人头疼的例子:escape('[bg:red]') === '__bg_red_'

所以我直接把 @weapp-core/escape 推倒重练,写成一个可逆的“状态机”。每个非法字符都分到独一无二的逃逸片段,还带长度前缀,跑完 unescape(escape(input)) 就一定回到原样。为了防止它在极端输入上翻车,我拉了十几组 property-based 测试,emoji、空格、重复 escape 全安排上写了大量的单元测试,确保往返都符合预期。

下面是当前版本的核心映射表,展示了我如何为每个非法字符分配唯一的 escape 片段,便于和旧版多对一的写法做对比:

export const MappingChars2String = {
  '[': '_b',
  ']': '_B',
  '(': '_p',
  ')': '_P',
  '#': '_h',
  '!': '_e',
  '/': '_f',
  '\\': '_r',
  '.': '_d',
  ':': '_c',
  '%': '_v',
  ',': '_m',
  '\'': '_a',
  '"': '_q',
  '*': '_x',
  '&': '_n',
  '@': '_t',
  '{': '_k',
  '}': '_K',
  '+': '_u',
  ';': '_j',
  '<': '_l',
  '~': '_w',
  '=': '_z',
  '>': '_g',
  '?': '_Q',
  '^': '_y',
  '`': '_i',
  '|': '_o',
  '$': '_s',
} as const

文章里我只放这份“简化表”,因为它才是运行时默认用的版本,开发者平时看到的也是它。更复杂的兼容映射我留在文档和测试里。

运行时配置

新的 create() 可以随手关掉任意环节,这是和社区聊得最多的诉求。有团队想“开箱默认就好”,也有老项目背着一堆历史包袱,得慢慢迁移。所以我直接给了一排明确开关,想保守就保守,想激进就激进。

const { twMerge: passthrough } = create({ escape: false, unescape: false })

配合 SSR 或老数据兼容的时候,也不用再额外写工具函数:服务端直接把 escape 全关掉,只做 merge 校验;到小程序再开回完整逃逸步奏,迁移过程就能一步一步踩稳。

另外还开放了 map 字段,用于统一用自己的字符映射。

发布 4.7.x 版本

绕了这么多弯,所有成果最终都塞进了 weapp-tailwindcss@4.7.x@weapp-tailwindcss/merge@2.x 中。算是 weapp-tailwindcss 运行时时代的第一声号角。

欢迎大家把新版 @weapp-tailwindcss/merge 用到真实项目里,更欢迎在社区继续砸想法,我会把这些反馈当作下一轮迭代的燃料,让 Tailwind CSS 在小程序世界里始终“开箱即用”。

有时候我也在想,为小程序这个逐渐感觉不怎么活跃的生态,花了这么多时间,感觉有点不值。但是转念一想,起码在我这个领域我已经通过不断的学习,真的掌握了很多东西。

起码,对 Tailwind CSS 进行符合中国小程序技术特色的改造方面,我也算是第一人了吧。每每想到这,就感觉自己好像还稍微有这么一点点自豪呢,哈哈哈。

如果你也在思考工具链,编译,AST 等等方面的问题,希望这篇文章能给你一点启发。

源代码附录

从字符串到像素:深度解析 HTML/CSS/JS 的页面渲染全过程

每天我们打开浏览器浏览网页时,背后都发生着一套精密的 "魔术"—— 浏览器把一堆 HTML/CSS/JS 字符串,变成了我们看到的图文并茂的页面。作为前端开发者,理解这套渲染机制不仅能帮我们写出更高效的代码,更是性能优化的核心前提。今天就带大家从底层原理到实践技巧,彻底搞懂页面渲染的来龙去脉。

一、浏览器渲染:从输入到输出的黑盒拆解

我们先从宏观视角看一下浏览器渲染的完整链路:

输入:HTML 字符串(结构)、CSS 字符串(样式)、JS 代码(交互逻辑)处理者:浏览器渲染引擎(以 Chrome 的 Blink 为例)输出:每秒 60 帧(60fps)的连续画面(人眼感知流畅的临界值)

这套流程看似简单,实则包含了多个相互协作的子过程。想象一下:当浏览器拿到 HTML 文件时,它面对的是一堆无序的字符串,既不能直接理解<div>的含义,也无法识别color: red的样式规则。所以第一步,就是把这些 "raw data" 转化为浏览器能理解的数据结构。

二、DOM 树:HTML 的结构化表达

为什么需要 DOM 树?

浏览器无法直接处理 HTML 字符串 —— 就像我们无法直接从一堆乱码中快速找到某个信息。因此,渲染引擎做的第一件事,就是把 HTML 字符串转化为树状结构(DOM,Document Object Model)。

这个过程叫做 "DOM 构建",本质是递归解析

  • <html>标签开始,将每个标签解析为 "节点"(Node)
  • 文本内容成为文本节点,属性成为节点属性
  • 按照标签嵌套关系,形成父子节点层级

比如这段 HTML:

html

预览

<p>
  <span>介绍<span>渲染流程</span></span>
</p>

会被解析成这样的 DOM 结构:

plaintext

Document
└── html
    └── body
        └── p(元素节点)
            └── span(元素节点)
                ├── "介绍"(文本节点)
                └── span(元素节点)
                    └── "渲染流程"(文本节点)

最终形成的 DOM 树,就是我们通过document.getElementById等 API 操作的基础 —— 整个文档的根节点就是document对象。

写好 HTML:不止规范,更影响渲染效率

DOM 树的构建效率,直接取决于 HTML 的结构质量。这里不得不提语义化标签的重要性:

  1. 结构语义化标签:用header(页头)、footer(页脚)、main(主内容)、aside(侧边栏)、section(区块)等标签替代无意义的div,让 DOM 树的层级关系更清晰。浏览器在解析时能更快识别节点角色,减少解析耗时。
  2. 功能语义化标签h1-h5(标题层级)、code(代码块)、ul>li(列表)等标签,不仅让 DOM 结构更具可读性,更能帮助搜索引擎(如百度蜘蛛)理解页面内容(这就是 SEO 的核心)。
  3. 节点顺序优化:主内容优先出现在 HTML 中(而非通过 CSS 调整顺序)。比如main标签放在aside前面,浏览器会优先解析主内容节点,减少用户等待核心内容的时间。如果需要调整视觉顺序,可用 CSS 的order属性(如aside { order: -1 }),不影响 DOM 解析顺序。

三、CSSOM 树:样式规则的结构化映射

HTML 解决了 "页面有什么",CSS 则解决了 "页面长什么样"。但浏览器同样无法直接理解 CSS 字符串,因此需要构建CSSOM(CSS Object Model)树

CSSOM 的构建逻辑

CSSOM 是样式规则的树状集合,每个节点包含该节点对应的所有样式规则。它的构建过程:

  • 解析 CSS 选择器(如div .containerheader h1
  • 计算每个节点的最终样式(考虑继承、优先级、层叠规则)
  • 形成与 DOM 节点对应的样式树

比如这段 CSS:

css

body { background: #f4f4f4; }
header { background: #333; color: #fff; }

会被解析为:

plaintext

CSSOM
├── body
   └── background: #f4f4f4
└── header
    ├── background: #333
    └── color: #fff

DOM 与 CSSOM 的结合:渲染树(Render Tree)

单独的 DOM 树和 CSSOM 树都无法直接用于渲染,必须将两者结合成渲染树

  • 遍历 DOM 树,为每个可见节点(排除display: none的节点)匹配 CSSOM 中的样式规则
  • 计算节点的几何信息(位置、大小)—— 这个过程叫做 "布局(Layout)" 或 "回流(Reflow)"

四、从渲染树到像素:绘制与合成

有了渲染树和布局信息,浏览器就可以开始生成像素画面了,这包含两个关键步骤:

  1. 绘制(Paint) :根据渲染树和布局结果,将节点的样式(颜色、阴影等)绘制到图层上。比如把header的背景涂成#333,文字涂成#fff
  2. 合成(Composite) :浏览器会将多个图层(如视频层、动画层、普通内容层)合并成最终画面,显示在屏幕上。这一步是性能优化的关键 —— 合理使用图层(如will-change: transform)可避免整体重绘。

五、实战:语义化标签如何影响渲染与 SEO?

看一个完整的语义化页面示例(简化版):

html

预览

<header>
  <h1>技术博客</h1>
</header>
<div class="container">
  <main>
    <section>
      <h2>核心内容</h2>
      <p>用<code>&lt;main&gt;</code>标记主内容</p>
    </section>
  </main>
  <aside class="aside-left">左侧导航</aside>
  <aside class="aside-right">推荐内容</aside>
</div>
<footer>版权信息</footer>

对渲染的优化:

  • main在 HTML 中优先出现,浏览器先解析主内容节点,减少用户等待时间
  • 语义化标签让 DOM 树层级更清晰,CSS 选择器匹配(如header {})更高效,减少 CSSOM 构建时间
  • 配合 Flex 布局(order: -1)调整视觉顺序,不影响 DOM 解析优先级

对 SEO 的提升:

  • 搜索引擎蜘蛛会优先解析mainh1-h2等标签,快速识别页面核心内容
  • 语义化标签明确了内容权重(如h1h2重要),帮助搜索引擎判断内容相关性
  • 结构化的 DOM 树让蜘蛛爬取更高效,避免因混乱的div嵌套导致核心内容被忽略

六、性能优化:从渲染流程反推最佳实践

理解了渲染流程,我们就能针对性地优化性能:

  1. 减少 DOM 节点数量:过多的嵌套节点会增加 DOM 构建和布局时间(比如避免divdiv的冗余结构)。
  2. 优化 CSS 选择器:复杂选择器(如div:nth-child(2) > .class ~ span)会增加 CSSOM 匹配时间,尽量使用简单选择器(如类选择器.header)。
  3. 避免频繁回流重绘:DOM 操作(如offsetWidth)和样式修改(如width)会触发回流,尽量批量操作(可先display: none再修改)。
  4. 利用语义化提升加载效率:主内容优先加载,非关键内容(如广告)后置,减少首屏渲染时间。

总结

页面渲染是 HTML/CSS/JS 协同工作的过程:从 HTML 构建 DOM 树,CSS 构建 CSSOM 树,到两者结合生成渲染树,最终通过布局、绘制、合成呈现为像素画面。理解这套流程后会发现:语义化标签不仅是 "规范",更是提升渲染效率和 SEO 的利器;合理的代码结构,能从源头减少浏览器的 "计算负担"。

作为前端开发者,我们写的每一个标签、每一行样式,都在影响着浏览器的渲染效率。从今天起,不妨用 "渲染视角" 审视自己的代码 —— 毕竟,流畅的体验永远是用户最直观的感受。

TemPad Dev:设计与实现

TemPad Dev 是一个浏览器扩展,它让普通用户在 Figma 的只读页面中也能查看和复制设计元素的样式。由于 Inspect 面板在推出 Dev Mode 以后被调整到付费墙后了,而且只读模式下我们也无法运行 Figma 插件,所以我们希望通过浏览器扩展,为只读模式用户提供基础的设计交付能力。

在实现过程中,我们遇到了不少限制与挑战。在这里我想简单回顾一下,和大家简单分享一下里面涉及的一些有意思的问题,以及我们解决问题的思路和方法。

基础流程:选区与样式读取

Figma 的画布是使用 WebGL 通过 <canvas> 渲染的,所以我们无法直接从 DOM 上得到用户的交互信息比如当前选区。这一切都需要依赖 Figma 在每个页面上的 figma 全局对象。Figma 在官方文档上提示我们,这个 API 是在每个 Figma 的页面上都存在的:

developers.figma.com/docs/plugin…

在 TemPad Dev 诞生前,设计工程师 Hal 就在他的 PoC 项目 Figma viewer chrome plugin 中实现了最基本的 D2C 流程。米其林在逃主厨 zouhang 也开发了 fubukicss-tool 项目,提供了更为完善的 CSS、UnoCSS 支持。

类似的,参考他们的实现,TemPad 的核心流程其实也很简单:当用户在画布中选择某个节点时,通过 figma.currentPage.selection 获取当前选区。随后通过 selection[0].getAsyncCSS() 就可以异步调用 Figma 的 WASM 模块,得到包含该节点样式信息的、以标准 CSS 属性和值构成的 JavaScript 对象。我们可以直接将其序列化,展示到我们想展示的地方。在 TemPad Dev 中,我们会以 CSS 源码和 JavaScript 对象两种方式输出,方便用户分别在样式代码或者 JSX 中进行使用。

Inspect 面板:原生的交互体验

我们希望 Inspect 面板可以自然融入整个设计交付的流程中,就像是原生支持这个功能。如果让面板界面直接采用 Figma 本身的设计语言,让使用者感受不到这里有一个额外的第三方插件在工作,整个流程就能丝滑许多。

基于这样的概念,我们除了保持和 Figma 一样的设计以外,还直接在开发 TemPad Dev 面板时全面使用了 Figma 页面上自带的全局 CSS 变量,比如 --color-border--color-text--color-bg-brand 等等。 这样做除了可以保持样式的统一融入,还额外“免费”地自动支持了暗色模式。当用户切换至暗色模式时,由于 Figma 自身的这些变量值会自动变化,扩展的界面也随之适配,无需额外逻辑。

↑ 自动支持暗色模式

除了直接可见的扩展面板,我们希望如 tooltip 或者 toast 提示也能和 Figma 保持一致。通过检查 Figma 的各种 tooltip 的触发元素我们发现,它们均带有 data-tooltip-typedata-tooltip 属性。过往的开发经验告诉我们,这个功能是通过事件代理的方式实现的,所以当我们为 TemPad Dev 自身的交互元素添加这些属性时,也可以调起原生的 tooltip。实际情况也是如此。

↑ 调起 Figma 原生的 tooltip

对于 toast 提示,Figma 在 Plugin API 直接提供了 figma.notify() 方法,我们可以直接调起使用。

Inspect 面板最重要的部分无疑是代码片段了。除了基础的尺寸、字号等样式以外,如何能和 Figma 原生的代码区块保持一致的代码高亮规则呢?我们发现,Figma 在前端使用的是 Prism 库进行的代码高亮,而 Prism 会在页面的全局命名空间暴露 Prism 对象以及它的所有 API,所以我们直接调用它不仅可以直接和 Figma 原生代码区块保持相同的代码高亮,甚至还避免了自己打包加载一个代码高亮库,节省了扩展的体积。同时我们还可以直接从 jsDelivr 这样的服务直接加载额外的 Prism 语言支持包,来增强全局的 Prism,从而可以扩展支持更多 Figma 本身没有处理高亮的语言,比如 SASS(SCSS)、Less、Stylus 等。

关于如何伴随 Figma 从 UI2 迁移到 UI3 之类的过程,在这里就不再赘述了。

深度选择与测量模式

在只读模式下,Figma 选择图层的交互并没有像在 Dev Mode 下那样进行过优化。我们无法直接选中嵌套层级最深处的图层,而是需要通过每次双击下钻一层的方式逐层向下直到选到所需要的图层。而许多开发者并不知道的是,Figma 提供一个快捷方式,可以按住 点击鼠标来直接选中最深层节点。同时,测量不同元素间的距离,也需要用户按住 并移动鼠标。这些操作首先并不被开发者广泛了解,也需要额外按键才能调起,不像 Dev Mode 中那样被特别优化过。这也侧面说明,Figma 真的知道开发者们要的是什么,但是因为有 Dev Mode,所以任何提升交付效率的功能,都得藏到 Dev Mode 里。

那么我们有没有什么办法可以提升这方面的体验呢?一个很自然的想法是:我们想办法让 Figma 认为用户正在按下 键,那么鼠标移动时就能达到相同的效果。但毕竟我们无法真正为用户按下快捷键,那要如何做到这一点呢?我们转变一下思路,很容易想象这一功能要如何实现:监听 mousemove 事件,当事件触发时,去检测 event.metaKeyevent.altKey 的值,在 true 时就触发相应的功能。那么实现方案也就呼之欲出了:我们使用 Reflect.defineProperty 来为 MouseEvent.prototypemetaKeyaltKey 挂载一个强制返回 true 的 getter,这样我们就可以骗过 Figma 的事件处理器,模拟用户按键了。

const metaKey = Reflect.getOwnPropertyDescriptor(MouseEvent.prototype, 'metaKey')!
const altKey = Reflect.getOwnPropertyDescriptor(MouseEvent.prototype, 'altKey')!

export function setLockMetaKey(lock: boolean) {
  if (lock) {
    Reflect.defineProperty(MouseEvent.prototype, 'metaKey', {
      get: () => true
    })
  } else {
    Reflect.defineProperty(MouseEvent.prototype, 'metaKey', metaKey)
  }
}

export function setLockAltKey(lock: boolean) {
  if (lock) {
    Reflect.defineProperty(MouseEvent.prototype, 'altKey', {
      get: () => true
    })
  } else {
    Reflect.defineProperty(MouseEvent.prototype, 'altKey', altKey)
  }
}

不过,永远锁定修饰键会破坏许多原生交互。例如按住空格可以拖动画布、按住 滚动滚轮可以缩放视图。如果这些键被强制为按下状态,用户的正常操作可能发生异常。为此我们一旦检测到用户鼠标移出画布、正在拖动画布或滚动页面,便暂时恢复修饰键的真实状态 。当操作结束后,再自动进行恢复。我们把这两个功能做成了设置,用户可以根据需要选择启用。

至此,TemPad Dev 最基础的功能流程就都能跑通了。但如同上面说的,这些方案得以生效,都依赖于官方在只读页面提供 Plugin API,既 window.figma 对象。我们在开发 TemPad Dev 的过程中,也曾担心 Figma 为了售卖 Dev Mode,剥夺使用者在只读页面访问 window.figma 的能力。但又转念一想,对于这么拳头的 Dev Mode 功能,Figma 不至于没信心到专门针对我们这种通过调用 Plugin API 就能实现的小工具。但是我们错了。

Quirks Mode:另辟蹊径

2024 年 3 月,Figma 在只读页面中移除了 window.figma,导致 TemPad Dev 无法通过官方 API 获取节点属性。为了让用户可以继续使用,我们只能暂时建议用户通过“Duplicate to your drafts”功能把设计稿复制成自己可编辑的版本,从而可以继续使用 Plugin API。但是这个操作非常繁琐,而且复制出来的草稿将无法自动同步上游设计稿的后续改动,可能造成很大的沟通障碍。

我在 Twitter 以及 Figma 的论坛上发帖(Figma removed window.figma on view-only pages today),对他们的这一举动提出质疑。过了不久,Figma 的设计运营分别在 Twitter 和论坛都对我进行了答复,表示移除 window.figma 是一个意外,并且承诺几周以后就会修复。

↑ Figma 的设计运营 Tom Lowry 的答复

然而,两周过去,官方运营人员在 Figma 论坛上的口径从“未来几周”修改为了“未来几个月”,实质上就意味着会无限期搁置。社区关注度上来了大方承诺,等热度过去了长期搁置,只能说他们是懂互联网的。

如前文所述,复制到草稿的解决方法短期内可以解决燃眉之急,但是长期来看依然很不理想。为了寻找替代途径,我们开始寻找替代的方案。通过在浏览器的全局对象中逐个检查 window 上暴露的非原生字段,我们发现了一个名为 DebuggingHelpers 的对象,其中有一个 logSelected() 会用一种自定义的格式输出包含节点大部分属性的文本 。尽管这些文本并非公开标准格式,只是一种内部序列化的字符串,但它为我们提供了一个突破口:在不依赖 Plugin API 的情况下仍然能够访问部分样式信息。大致的日志格式如下:

logging node state for 12:34

{
  name: <ImmutableString: "Button / Primary">
  type: <NodeType: E::TEXT>
  parent-index: <ImmutableString: "0:567;12:34;">
  x: <float: 120.5>
  y: <float: 48>
  size: <TVector2<float>: VectorF(80, 24)>
  text-auto-resize: <TextAutoResize: E::WIDTH_AND_HEIGHT>
  align-x: <AlignX: E::LEFT>
  align-y: <AlignY: E::TOP>

  fill-paint-data: <Immutable<PaintData>: PaintData(
    SolidPaint(rgba(34, 197, 94, 1), opacity 1),
    GradientPaint(<stops...>, opacity 1)
  )>
  stroke-paint-data: <Immutable<PaintData>: PaintData()>
  blend-mode: <BlendMode: E::NORMAL>
  effect-data: <Immutable<EffectData>: EffectData[2]>

  transform: <AffineTransformF: AffineTransformF(1, 0, 0, 0, 1, 0)>

  plugin-data: <PluginData: [
    {"pluginID":"1126010039932614529","key":"foo","value":"bar"},
    {"pluginID":"","key":"my-namespace-myKey","value":"baz"}
  ]>
}

通过解析日志内容,理论上我们可以大致还原出来当前被选中的节点的样式信息。

但是,日志并不包含全部设计信息,而且 Figma 节点的属性值与 CSS 需要的也并非一一对应。通过一系列猜测和测试,我们最终还是可以保证正确解析出大多数的 CSS 样式信息。对于文本节点,日志输出的字体名称并非 font-family 所需要的,我们会根据字体名称进行一系列猜测和转换,并使用启发式方法获取字重。

模式名称 “Quirks” 来自浏览器历史上的“怪异模式”:当一个页面缺少标准声明时,浏览器会进入一种兼容模式渲染页面。TemPad Dev 的 Quirks Mode 也承担着类似使命——当官方 Plugin API 无法使用时,通过解析调试日志的方式尽可能还原样式。由于这一模式依赖的是未公开接口,功能难免有限,但在 API 缺失的情况下依然可以提供基本的样式查看能力。

在相当长的一段时间内,Quirks Mode 都很好地完成了它的历史使命:在 window.figma 不可用时,保证了 TemPad Dev 基本的可用性。2025 年 4 月,Figma 在 DebuggingHelpers 中移除了 logSelected() 方法,Quirks Mode 也随即正式失效。

插件机制:不止 CSS

众所周知,样式编写的方式有一百种。所以我们提供了一种在 Chrome 扩展中加载、执行 JavaScript 插件的机制,让用户可以在标准的 TemPad Dev 代码区块中以自己需要的方式输出并消费代码。

↑ 安装 TemPad Dev 插件进行组件代码生成

要实现一个插件机制,我们主要要解决下面几个问题:

  1. 运行机制与环境
  2. 插件 API
  3. 发布与安装

这三个问题几乎决定了整个插件系统的形态。

运行机制与环境

TemPad Dev 将插件代码传入 Web Worker 中,以和主线程隔离的方式运行,暴露给它的仅是必要的上下文:选中节点的数据、getAsyncCSS() 已经生成的 CSS 对象以及通过白名单方式允许访问的部分全局 API。在这个环境中插件无法主动与主线程通信、无法访问 DOM、无法发起网络请求。

TemPad Dev 在代码生成主流程完成后,在将代码输出到 Inspect 面板中前,我们会对 Worker 中的代码生成模块发送节点数据和 CSS 对象,在等待插件对输出进行转换后,再渲染到代码区块中进行展示。

理论上我们还可以提供一个运行时间监控并且主动杀死超时的 Worker 并暂时禁用相关的插件,来进一步进行管控,但目前看来暂时还没有进一步引入复杂度的必要。

插件 API

TemPad Dev 插件的核心 API 非常简单:

export function definePlugin(plugin: Plugin): Plugin {
  return plugin
}

definePlugin() 是一个提供了完整插件类型定义的 identity function,用户只需要定义一个 Plugin 类型的对象作为入参,就能完成一个 TemPad Dev 插件的开发。

在插件对象中,用户可以定义 transformtransformVariabletransformPx 这几个钩子介入处理流程,重写 CSS 字符串、变量、转换像素单位或组件结构,而无需关心底层实现。通过这些简单的转换钩子,已经有社区开发者提供了 UnoCSS、Tailwind、React Native 等基于 CSS 的格式转换插件。

一个最简单的 Stylus 转换插件只要简单的几行代码就能实现:

import { definePlugin } from '@tempad-dev/plugins'

export default definePlugin({
  name: 'My Plugin',
  code: {
    css: {
      title: 'Stylus', // Custom code block title
      lang: 'stylus', // Custom syntax highlighting language
      transform({ style }) {
        return Object.entries(style)
          .map(([key, value]) => `${key} ${value}`)
          .join('\n')
      }
    },
    js: false // Hides the built-in JavaScript code block
  }
})

对于组件的代码生成,Figma Dev Mode 支持一套重编译期的方案 Code Connect,可以帮助开发者关联设计系统中的 Figma 组件和前端组件库实现。其工具链比较复杂,通过静态分析 TypeScript 编写的如下的连接代码来理解转换逻辑,把 Figma 组件的变体和前端组件的 props 做映射:

import figma from '@figma/code-connect/react'

figma.connect(Button, 'https://...', {
  props: {
    label: figma.string('Text Content'),
    disabled: figma.boolean('Disabled'),
    size: figma.enum('Size', {
      Large: 'large',
      Medium: 'medium',
      Small: 'small',
    }),
  },
  example: ({ disabled, label, size }) => {
    return (
      <Button size={size} disabled={disabled}>
        <Text>{label}</Text>
      </Button>
    )
  },
})

TemPad Dev 采取了另一种方案,让插件开发者编写从 Figma 组件到 VDOM 的映射,在插件运行时来进行转换:

import { h } from '@tempad-dev/plugins'

export function Button(component) {
  const {
    Label,
    Disabled,
    Size
  } = component.properties

  return h(
    'Button',
    {
      size: Size.toLowerCase(),
      disabled: Disabled,
    },
    [Label]
  )
}

这样,我们通过简洁的桥接模块,就可以完成组件级别的代码生成了。关于 Code Connect 使用的编译期静态模板 + CLI 的方案和 TemPad Dev 的插件运行时方案的比较,我在这里就不详细展开了。有兴趣的朋友可以自行了解,也欢迎找我讨论。

发布与安装

如果有人开发了一个插件,要如何发布并让其他用户安装使用呢?我们希望整套方案足够轻量级,不为 TemPad Dev 本身带来维护负担,而且又要方便插件开发者维护和发布。几经权衡,我们采取了如下的方案:

  1. 插件的产物形态,是一个有 named export plugin 的 ESM 文件,插件开发者直接编写一个单文件插件,也可以使用任何打包器或者编译工具产出这样一个插件文件;
  2. TemPad Dev 在插件配置中,可以通过一个可公开访问、允许跨域请求的 URL,来下载插件文件;
  3. 在 TemPad Dev 的仓库中有一个插件索引文件,插件的开发者可以向 TemPad Dev 仓库发起 Pull request 来修改这个文件,把自己开发的插件“注册”到官方目录中,被 TemPad Dev 仓库收录的插件会有一个名称,在用户安装插件时就可以使用如 @pluginName 这样的标记来加载插件代码。

本着一切从简的原则,我们没有引入版本号的概念,也不会自动更新,用户可以手动通过更新按钮重新拉取插件的最新版本。可公开访问且支持跨域请求的服务很多,通常来说 GitHub raw 文件或者 Gist 就已经足够方便插件开发者上传维护了,只需要把打包编译后的插件文件也同步进代码库即可。 这种设计让发布和分发完全去中心化,同时保持安装的易用性。

生态与应用

目前 TemPad Dev 的插件目录中已经包含了多个社区贡献的 UnoCSS、TailwindCSS 和 React Native 插件,也有官方(我)实现的基于 Kong Design System(我目前供职的公司使用的设计系统)和 Nuxt UI 实现的组件级别代码生成插件。

NuxtLabs 的创始人 Sebastien Chopin 在 VueJS Amsterdam 2025 大会的演讲中也对 TemPad Dev 的 Nuxt UI 插件进行了展示

↑ 在 VueJS Amsterdam 2025 上的展示

脚本重写:柳暗花明

Figma 关于“恢复 window.figma 访问”的承诺至今都没有兑现。Quirks Mode 在 window.figma 被移除后短时间内缓解了问题,但它也有明显局限:除了前文所述的一些限制以外,调试日志并未输出组件级信息,无法满足组件级代码生成的需求。所以,在 TemPad Dev 插件支持 transformComponent 钩子之前,我们还是得找到让 Plugin API 重新“复活”的办法。

经过调试我们发现,无论是编辑模式还是只读模式,Figma 在前端加载的仍然是同一套 JavaScript 资源;只是只读场景在创建 window.figma 之前走了另一条逻辑分支。所以,我们只要能想办法拦截并重写相关判断条件,就可以在只读模式下把 Plugin API 构建起来,帮助 Figma 完成他们的承诺。

如何在浏览器扩展中完成对特定脚本的重写呢?在 Manifest V2 时代,扩展可以使用 chrome.webRequest API 以阻塞方式拦截并修改网络请求。但随着 Chrome 扩展架构升级到 Manifest V3,阻塞式的 webRequestBlocking 权限不再可用,官方建议使用声明式的 declarativeNetRequest(DNR)规则。

在 DNR 规则下,我们也无法直接去动态修改 request body,而只能声明式地进行过滤、取消、重定向、修改 HTTP 头等操作。而且 Figma 前端是 webpack 构建的(现已切换到 Rspack),生成的多数 chunk 文件都使用了纯 hash 字符串作为文件名,我们并不能通过文件路径判断一个文件是否是我们需要关心的。那我们要如何找到需要替换的文件,拦截并且替换呢?

加载器策略

答案是,把所有可能包含需要改写代码的文件全部重定向到同一个固定的 rewrite.js 文件。看到这里你可能会有疑惑:全都重定向到一个固定文件,要怎么执行 Figma app 原来的程序逻辑?

事实上,我们可以通过如下的流程加载并执行所有重定向的脚本:

  1. 通过 document.currentScript.src 得到当前正在执行的 <script> 元素获取原始请求地址;
  2. fetch 对应的脚本内容;
  3. 按规则替换逻辑分支和检测语句;
  4. 最后用 new Function() 执行修正过的脚本。

实施了这一套方案以后,我们终于成功地让 window.figma 在只读模式下重见天日。

抽象规则、热更新与自动检测

为了应对 Figma 频繁的前端构建更新,我们把替换逻辑抽象成规则集——每条规则定义要匹配的模式和替换模板。每次因为改版或者打包造成替换规则失效时,只要经过快速的单步调试和修改规则,就能较快地修复问题。为了避免用户在修复前的窗口期发现 TemPad Dev 的 Inspect 面板不渲染而产生困惑,我们添加了错误提示,将用户导流到我们的 Discord 频道GitHub repo,方便我们快速发现和解决问题。

↑ 在错误警告视图对用户进行引导

同时,我们利用 GitHub Actions 中,定时检查我们的替换规则对 Figma 当前版本是否生效,一旦失效我们就可以快速监控到。

由于从扩展发版到用户更新还是需要比较长的时间,我们利用 browser.declarativeNetRequest.updateDynamicRules() 来热更新 DNR 规则文件,并且把加载器文件也从扩展中剥离,并且最终决定把这两个文件都托管在 GitHub Pages 上。这可以让我们通过一个简单的 GitHub Actions 工作流就完成自动更新部署,而且不依赖第三方服务,响应也能包含正确的 Content-Type 头(对比 GitHub Raw 就不行)。

来源检测

2025 年 8 月,部分用户反馈 TemPad Dev 提示暂不可用,但是我们的 Actions 任务并没有显示任何异常。原来 Figma 灰度发布了一个改动,导致有部分用户遇到 TemPad Dev 无法读取到 window.figma 的情况。但是奇怪的是,这些用户在浏览器控制台通过 window.figma 可以正常访问 Plugin API。这是怎么回事呢?

我的第一感觉是我们被 Figma 精确狙击了,猜测可能是通过如下的方式:

const figma = { /* Plugin API */ }
Object.defineProperty(window, 'figma', {
  enumerable: false,
  configurable: false,
  get() {
    if (document.currentScript?.src?.startsWith('chrome-extension://')) {
      return undefined
    }
    return figma
  }
})

找能够复现的朋友帮忙查看,果然发现一段类似的代码,只不过用了 Error().stack 来检查脚本来源:

↑ Figma 说好的恢复 window.figma 访问变成了进一步限制

里面有两个看起来怪怪的字符串:dispnf.fyufotjpo;00np{.fyufotjpo;0。原来 Figma 为了不让人发现他们专门针对浏览器扩展做了限制,还贴心地把 chrome-extension://moz-extension:// 在 ASCII 表上偏移了一位 。但由于我们有重写脚本的能力,所以只要再扩展一下规则,就可以让扩展继续正常工作。

处理异步 chunk

前面的一系列策略使得 TemPad Dev 可以在只读视图下正常运行起来了,但是我们发现浏览器控制台时有 chunk loading error 类的报错;而且一些可能是核心流程依赖异步 chunk 的功能比如 Prototype 和 Slides 都会直接无法使用。我们仔细思考了当前策略,发现这是因为在把异步 chunk 重定向到加载器时,加载/执行/回调的时序与原有逻辑不一致。

当我们的加载器加载完成时,浏览器会立刻认为被重定向的这个 <script> 元素已经加载并执行。而 webpack / Rspack runtime 在 onload 触发后,就可以马上执行依赖这个 chunk 的模块逻辑。问题在于,我们的加载器 fetch、替换、执行脚本,这个过程也是异步的。所以 runtime 认为请求的异步 chunk 中的模块已经执行完毕,而实际上此时加载器仍在执行,从而导致引用错误甚至页面崩溃。

有没有不走 DNR 重定向到加载器,也能重写脚本的办法呢?我不知道大家有没有了解过类似 qiankun 这种微前端框架的工作原理,它会通过重写 Element.prototype 上的 appendChildinsertBefore 等方法,拦截所有插入 <script> 的操作,通过 fetch 加载 src 指定的脚本文件,然后用 new Function() 运行,来把 window 指定到自己创建的 Proxy 对象上,以构建一个沙箱环境。

我们这里也可以用类似的方案,唯一的区别是执行前先应用我们的转换规则。虽然这种方法对内联在 HTML 里的 <script> 无效,但我们只需要处理异步脚本,刚好和 DNR 重定向各司其职;而我们指定的 DNR 规则只对 JS 资源请求生效,刚好也能避开 fetch 请求,从而避免重复处理。

应用了这一套静态重定向 + 拦截动态插入混合的策略以后,chunk loading error 的问题就完美解决了。

最后

本文分享了 TemPad Dev 在实现只读视图 Inspect 能力过程中用到的一些技术细节和思路。如果能读到这里,你一定是一个依然抱有好奇心的开发者。我相信除了所有人都在讨论的 AI 之外,我们也有别的东西可以探讨和分享。希望 TemPad Dev 和这篇文章本身能对你有所帮助。

GitHub · Chrome Web Store · Discord

这个扩展的名字为什么叫 TemPad Dev? 这个名字的灵感来自漫威漫画中时间管理局(Time Variance Authority)所使用的设备——TemPad。那个装置能穿梭时间线、访问任何分支世界。彼时我正在百度做一些 Design to Code(D2C)方面的探索,我们最初开发了一个名为 TemPad 的 Figma 插件给设计师使用,它可以将 React/Vue 组件在 web 端真实渲染后插入 Figma 设计稿,以保证设计与实现的一致性。这个名字寓意让它成为设计和开发这两个世界之间的“时空桥”。TemPad Figma 插件负责生产端,而 TemPad Dev 则是面向开发者的消费端。

本文原载于知乎:zhuanlan.zhihu.com/p/196340231…

❌