普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月11日首页

经验分享2:SSR 项目中响应式组件的闪动陷阱与修复实践

作者 刀疤
2026年1月11日 13:42

背景

最近在开发公司官网的响应式 Banner 组件时,遇到了一个移动端首屏加载闪动的问题。 一套代码做PC端和移动端的适配,作为团队新人,我最初采用了 JS 动态判断的方案,但被 TL 指出存在性能问题。在这次代码 Review 中,我深刻理解了 SSR 项目中响应式适配的正确姿势。

一、组件设计:从 Props 接收到样式应用

1.1 组件设计思路

我们需要一个通用的 Banner 组件,支持:

  • PC 端和移动端使用不同的图片
  • PC 端和移动端使用不同的高度
  • 未提供移动端资源时,自动降级使用 PC 端资源

组件代码:

<script lang="ts" setup>

import { imgUrlPatch } from '~/util'

defineOptions({ name: 'HeroBanner' })

defineProps<{
  // 图像两侧不足时填充的背景色
  bgColor?: string
  // 最小高度
  height?: any
  // 图像网址
  url?: string
}>()
</script>
<template lang="pug">
.page-banner(
  :style="{ backgroundColor: bgColor, backgroundImage: url ? `url(${imgUrlPatch(url)})` : '', minHeight: height && (isFinite(height) ? height + 'px' : height) }"
)
  slot
</template>

<style lang="less">
.page-banner {
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto 100%;
}
</style>

二、踩坑:首屏加载的高度闪动

2.1 问题复现

最初我在页面中这样使用组件:

<script lang="ts" setup>
const isMobileDevice = ref(false)

onMounted(() => {
  isMobileDevice.value = window.innerWidth < 768
})

const bannerHeight = computed(() => {
  return isMobileDevice.value ? '180px' : '360px'
})
</script>

<template>
  <HeroBanner 
    class="company-banner" 
    background-color="#2B5A8E" 
    :height="bannerHeight" 
    image="hero/company-banner.webp" 
  />
</template>

现象: 在移动端首次加载时,Banner 会出现明显的高度跳变(360px → 180px)。

2.2 TL 的修复方案

//父组件使用
<template>
  <HeroBanner 
    class="company-banner" 
    background-color="#2B5A8E" 
    :pc-height="360" 
    :mobile-height="180"
    pc-image="hero/company-banner.webp" 
  />
</template>

//子组件:HeroBanner 组件代码,背景图组件,自适应宽度,居中对齐
<script lang="ts" setup>
import { processImageUrl } from '~/utils'

defineOptions({ name: 'HeroBanner' })

const props = defineProps<{
  // 背景填充色
  backgroundColor?: string
  // PC 端高度
  pcHeight?: any
  // 移动端高度
  mobileHeight?: any
  // 移动端图片地址
  mobileImage?: string
  // PC 端图片地址
  pcImage?: string
}>()

// PC 端高度处理
const pcHeight = computed(() => {
  const h = props.pcHeight
  return h && (isFinite(h) ? h + 'px' : h)
})

// 移动端高度处理:优先使用 mobileHeight,否则降级使用 pcHeight
const mobileHeight = computed(() => {
  const h = props.mobileHeight || props.pcHeight
  return h && (isFinite(h) ? h + 'px' : h)
})

// 移动端图片:优先使用 mobileImage,否则降级使用 pcImage
const mobileImage = computed(() => {
  const img = props.mobileImage || props.pcImage
  return img ? `url(${processImageUrl(img)})` : ''
})

// PC 端图片
const pcImage = computed(() => {
  const img = props.pcImage
  return img ? `url(${processImageUrl(img)})` : ''
})
</script>

<template lang="pug">
.hero-banner(:style="{ backgroundColor }")
  slot
</template>

<style lang="less">
.hero-banner {
  background-color: v-bind(backgroundColor);
  background-image: v-bind(mobileImage);
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto 100%;
  min-height: v-bind(mobileHeight);

  @media (min-width: 768px) {
    background-image: v-bind(pcImage);
    min-height: v-bind(pcHeight);
  }
}
</style>

删掉了所有 JS 判断逻辑,问题神奇地消失了。

三、原因深度剖析

3.1 错误方案的执行时序

我的方案(JS 动态判断):

1. SSR 服务端渲染
   └─ 服务端无法获取屏幕宽度
   └─ isMobileDevice 默认为 false
   └─ 输出 HTML: height="360px"

2. 浏览器接收 HTML
   └─ 用户看到 360px 高度的 Banner ⚠️

3. JavaScript 水合(Hydration)
   └─ onMounted 执行
   └─ window.innerWidth < 768true
   └─ isMobileDevice 变为 true
   └─ 触发响应式更新 → height="180px"
   └─ 用户看到高度跳变 ⚡

时间差: 从 HTML 渲染到 JS 执行完成,通常有 100-500ms 延迟。

3.2 正确方案的执行时序

TL 的方案(CSS 媒体查询):

1. SSR 服务端渲染
   └─ 同时输出两套高度值
   └─ 生成 CSS:
       min-height: 180px;  /* 默认 */
       @media (min-width: 768px) {
         min-height: 360px;  /* PC 端 */
       }

2. 浏览器接收 HTML
   └─ 浏览器原生解析 CSS
   └─ 媒体查询立即生效
   └─ 移动端直接显示 180px ✅
   └─ PC 端直接显示 360px ✅

3. JavaScript 水合
   └─ 无需任何操作
   └─ 样式已经正确 ✅

零时间差: CSS 在浏览器渲染引擎层面就已确定,不依赖 JS 执行。

3.3 核心差异对比表

对比维度 JS 动态判断(我的方案) CSS 媒体查询(TL 方案)
判断时机 JavaScript 执行后 浏览器解析 CSS 时
SSR 兼容 ❌ 服务端无法判断设备 ✅ 样式同时输出
首屏表现 需等待 hydration 立即应用正确样式
性能开销 有 JS 计算 + 响应式更新 浏览器原生能力
CLS 影响 有布局偏移 无布局偏移

四、经验总结

4.1 响应式适配的黄金法则

在 Nuxt/Vue SSR 项目中:

✅ 静态配置 → CSS 媒体查询
   - 固定高度、宽度
   - 静态资源路径
   - 固定颜色、字号

❌ 动态配置 → JS 运行时判断
   - API 返回的数据
   - 用户交互状态
   - 复杂业务逻辑

4.2 isFinite() 的实用技巧

遇到需要支持多种单位的场景,用原生 API 优雅处理:

function normalizeSize(value: any) {
  return value && (isFinite(value) ? `${value}px` : value)
}

// 使用
normalizeSize(100)      // '100px'
normalizeSize('50vh')   // '50vh'
normalizeSize('100%')   // '100%'
normalizeSize(null)     // null

4.3 组件设计的降级思维

提供 mobile*pc* 两套 props 时,始终实现降级逻辑:

const mobileValue = computed(() => {
  return props.mobileValue || props.pcValue  // 降级逻辑
})

这让组件使用更灵活,既支持"一套配置通用",也支持"精细化适配"。


五、延伸思考

5.1 什么时候必须用 JS 判断?

遇到以下场景,CSS 无法胜任,必须用 JS:

// ❌ CSS 无法实现:根据设备加载不同的组件
const DynamicComponent = computed(() => {
  return isMobile.value ? MobileChart : PCChart
})

// ❌ CSS 无法实现:根据屏幕尺寸调整 Swiper 配置
const swiperConfig = computed(() => ({
  slidesPerView: isMobile.value ? 1 : 3,
  spaceBetween: isMobile.value ? 10 : 30
}))

5.2 如何避免 SSR 水合不一致?

核心原则:服务端渲染的内容必须与客户端首次渲染一致

// ❌ 错误:服务端和客户端结果不一致
const currentTime = new Date().toLocaleString()

// ✅ 正确:仅在客户端执行
const currentTime = ref('')
onMounted(() => {
  currentTime.value = new Date().toLocaleString()
})

总结

这次代码 Review 让我深刻认识到:

  1. 性能优化不仅是算法,更是架构选择 - CSS 能做的事不要用 JS
  2. SSR 项目需要时序思维 - 区分服务端、客户端、水合三个阶段
  3. 组件设计要考虑降级 - 让开发者用得爽,而不是记一堆规则

感谢 TL 的耐心指导,以后写响应式组件会优先考虑 CSS 方案了!


参考资料:

首屏渲染中的hydration(水合)是现代前端框架(如React、Vue)在服务端渲染(SSR)或静态生成(SSG)中,将服务器生成的静态HTML内容激活为可交互应用的关键过程。

Hydration的核心作用是结合SSR和客户端渲染(CSR)的优势:

  • 服务器预先渲染完整的HTML并发送到浏览器,使用户快速看到内容,提升首次内容绘制(FCP)和SEO;
  • 随后客户端JavaScript下载并执行,通过对比虚拟DOM与现有真实DOM结构,将事件监听器和状态绑定到DOM元素上,使页面从静态视图变为可交互应用。

Hydration过程涉及以下关键步骤:

  1. 服务端渲染:服务器执行组件逻辑生成初始HTML和数据;
  2. 客户端激活:浏览器下载JavaScript bundle后,框架重新运行渲染逻辑生成虚拟DOM,与真实DOM对比并匹配结构,若一致则附加交互功能,不一致则触发hydration mismatch错误。

写 CSS 用 px?这 3 个单位能让页面自动适配屏幕

2026年1月11日 13:27

在网页开发中,CSS 单位是控制元素尺寸、间距和排版的基础。

长期以来,px(像素)因其直观、精确而被广泛使用。

然而,随着设备屏幕尺寸和用户需求的多样化,单纯依赖 px 已难以满足现代 Web 对可访问性灵活性响应式能力的要求。

什么是 px?

px 是 CSS 中的绝对长度单位,代表像素(pixel)。

在标准密度屏幕上,1px 通常对应一个物理像素点。

开发者使用 px 可以精确控制元素的大小,例如:

.container {
  width: 320px;
  font-size: 16px;
  padding: 12px;
}

这种写法简单直接,在固定尺寸的设计稿还原中非常高效。但问题也正源于它的绝对性。

px 存在哪些问题?

1. 缺乏响应能力

px 的值是固定的,不会随屏幕宽度、容器大小或用户设置而变化。

在一个 320px 宽的手机上显示良好的按钮,在 4K 显示器上可能显得微不足道,反之亦然。

2. 不利于可访问性

许多用户(尤其是视力障碍者)会调整浏览器的默认字体大小。

但使用 px 定义的字体不会随之缩放,导致内容难以阅读。

相比之下,使用相对单位(如 rem)能尊重用户的偏好设置。


更好的选择

为解决上述问题,CSS 提供了一系列更智能、更灵活的单位和功能。以下是几种核心方案:

1. 相对单位:rem 与 em

  • rem(root em):相对于根元素(<html>)的字体大小。默认情况下,1rem = 16px,但可通过设置 html { font-size: 18px } 改变基准。
  • em:相对于当前元素或其父元素的字体大小,常用于局部缩放。

示例:

html {
  font-size: 16px; /* 基准 */
}

.title {
  font-size: 1.5rem; /* 24px */
  margin-bottom: 1em; /* 相对于自身字体大小 */
}

优势:支持用户自定义缩放,便于构建比例一致的排版系统。

2. 视口单位:vw、vh、vmin、vmax

这些单位基于浏览器视口尺寸:

  • 1vw = 视口宽度的 1%
  • 1vh = 视口高度的 1%
  • vmin 取宽高中较小者,vmax 取较大者

用途:适合全屏布局、动态高度标题等场景。

示例:

.hero {
  height: 80vh; /* 占视口高度的 80% */
  font-size: 5vw; /* 字体随屏幕宽度缩放 */
}

注意:在移动端,vh 可能受浏览器地址栏影响,需谨慎使用。

3. clamp() 函数:实现流体响应

clamp() 是 CSS 的一个重要进步,允许你在一个属性中同时指定最小值、理想值和最大值:

font-size: clamp(16px, 4vw, 32px);

含义:

  • 在小屏幕上,字体不小于 16px;
  • 在中等屏幕,按 4vw 动态计算;
  • 在大屏幕上,不超过 32px。

这行代码即可替代多个 @media 查询,实现平滑、连续的响应效果。

更推荐结合相对单位使用:

font-size: clamp(1rem, 2.5vw, 2rem);

这样既保留了可访问性,又具备响应能力。

4. 容器查询(Container Queries)

过去,响应式布局只能基于整个视口(通过 @media)。

但组件常常需要根据自身容器的大小来调整样式——这就是容器查询要解决的问题。

使用步骤:

  1. 为容器声明 container-type
.card-wrapper {
  container-type: inline-size; /* 基于内联轴(通常是宽度) */
}
  1. 使用 @container 编写查询规则:
@container (min-width: 300px) {
  .card-title {
    font-size: 1.25rem;
  }
}

@container (min-width: 500px) {
  .card-title {
    font-size: 1.75rem;
  }
}

现在,只要 .card-wrapper 的宽度变化,内部元素就能自动响应,无需关心页面整体布局。这对构建可复用的 UI 组件库至关重要。

容器查询已在主流浏览器(Chrome 105+、Firefox 116+、Safari 16+)中得到支持。


建议

  • 避免在字体大小、容器宽度、内边距等关键布局属性中使用纯 px
  • 优先使用 rem 作为全局尺寸基准,em 用于局部比例。
  • 对需要随屏幕缩放的元素,使用 clamp() + vw/rem 组合。
  • 构建组件时,考虑启用容器查询,使其真正“自适应”。
  • 保留 px 仅用于不需要缩放的场景,如边框(border: 1px solid)、固定图标尺寸等。

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

📌往期内容

写前端久了,我用 Node.js 给自己造了几个省力小工具

我也是写了很久 TypeScript,才意识到这些写法不对

ThreadLocal 在实际项目中的 6 大用法,原来可以这么简单

重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计

昨天 — 2026年1月10日首页

推荐几个国外比较流行的UI库(上)

2026年1月10日 14:45

1、Tailwind CSS

现在写样式的时候,我基本已经离不开 Tailwind CSS 了。最开始接触它的时候,其实挺不适应的,感觉类名又多又杂,全写在标签上。但真正用顺了之后,反而不太想回到以前那种来回切 CSS 文件的方式。

我比较喜欢的是它处理响应式的方式,断点直接写在类名前面,逻辑非常直观。页面在不同尺寸下怎么变,一眼就能看出来。再加上配置文件可以统一管颜色、间距、字体这些东西,对我来说维护起来反而更轻松。

缺点当然也有,比如结构看起来不那么“干净”,但这个在我这里已经不算什么问题了。

下面我们来实现一个瀑布流

<div class="columns-3 ..."> 
    <img class="aspect-3/2 ..." src="/img/mountains-1.jpg" /> 
    <img class="aspect-square ..." src="/img/mountains-2.jpg" /> 
    <img class="aspect-square ..." src="/img/mountains-3.jpg" /> 
    <!-- ... -->
</div>

效果 image.png


2、Bootstrap

虽然 Bootstrap 已经很多年了,但说实话,在一些需求明确、节奏比较快的项目里,它依然很好用。栅格、常见组件基本都有,直接拼就能出页面,几乎不用想太多。

image.png


3、Foundation

Foundation 是一个开源的响应式前端框架,用于构建结构清晰、视觉一致的网页界面。它提供了完整的工具体系,包括响应式网格系统、设计模板,以及基于 HTML、CSS 和 SASS 的样式方案。同时,框架内置了按钮、导航、表单、排版等常见 UI 能力,并支持通过 JavaScript 扩展进一步增强交互功能。

Foundation 采用移动优先的设计理念,与 Bootstrap 类似,布局从小屏设备开始构建,再逐步扩展到更大的屏幕尺寸。这种方式使页面能够自然适配不同设备,无需额外处理复杂的适配逻辑,从而在手机、平板和桌面端之间保持一致且流畅的体验。

在布局层面,Foundation 提供了基于 Flexbox 的 12 列响应式网格系统。页面结构可以通过行与列的组合快速搭建,而网格系统会自动处理不同断点下的尺寸变化与内容堆叠,使整体布局保持简洁、直观且易于维护。

Foundation 的工具包体系也是其重要特性之一。框架内置了可直接使用的网页与邮件组件,使项目在启动阶段不必从零搭建基础结构。这种方式在多平台场景下有助于维持统一的视觉风格,并显著减少重复性工作。

在灵活性方面,Foundation 并未强制绑定特定的设计语言或样式规范。默认配置可根据项目需求进行调整或覆盖,从而在不受框架限制的前提下实现定制化界面设计。这种设计思路在效率与自由度之间取得了较好的平衡。

从整体特性来看,Foundation 对无障碍访问和移动优先设计的重视,使其在构建现代化、包容性网页体验时具有明显优势。模块化架构与 SASS 集成提升了组件定制的效率,也使复杂布局的原型构建更加顺畅。

相对而言,Foundation 的学习成本高于 Bootstrap 等更大众化的方案,对初学者存在一定门槛。此外,其社区规模和生态资源不及 Tailwind 和 Bootstrap 丰富,可直接复用的第三方资源相对有限。在功能完整度较高的同时,对于体量较小的项目而言,可能会引入不必要的复杂度。

特点

1.  **响应式**:先做好手机,再适配平板和电脑。
2.  **网格灵活**:12 列 Flexbox 布局,布局复杂也能处理好。
3.  **组件齐全**:带 JS 插件,交互也有现成的(弹窗、菜单等)。

适合谁:想快速搭复杂页面,有交互,又想用框架自带组件的人。

缺点:学习稍复杂,功能多了,小项目可能显得重。

下面我们来使用它的按钮样式

<!-- Anchors (links) --> 
<a href="about.html" class="button">Learn More</a> 
<a href="#features" class="button">View All Features</a> 

<!-- Buttons (actions) --> 
<button class="submit success button">Save</button> 
<button type="button" class="alert button">Delete</button>

效果

image.png


4、Bulma

Bulma 是那种一看就懂、上手很快的框架。类名语义清楚,布局基于 Flexbox,用起来很顺。

它不依赖 JavaScript 这一点,拿来配合任何技术栈都很方便。不过也正因为这样,一些交互相关的东西需要自己补,这点在用之前心里要有预期。

特点

1.  **响应式**:移动优先,Flexbox 网格布局。
2.  **轻量**:按钮、卡片、表单都有样式,但没有 JS。
3.  **易用**:学习成本低,改样式很方便。

适合谁:只需要快速搭页面、布局和样式固定、不需要框架自带交互的人。

缺点:没有交互组件,复杂行为要自己写。

下面我们来写一个简单的表单

<form class="box">
  <div class="field">
    <label class="label">Email</label>
    <div class="control">
      <input class="input" type="email" placeholder="e.g. alex@example.com" />
    </div>
  </div>

  <div class="field">
    <label class="label">Password</label>
    <div class="control">
      <input class="input" type="password" placeholder="********" />
    </div>
  </div>

  <button class="button is-primary">Sign in</button>
</form>

效果

image.png


昨天以前首页

Markdown 宽表格突破容器边界滚动方案

2026年1月9日 01:38

在聊天/文档类应用中,实现宽表格突破内容区域限制,利用更多屏幕空间进行水平滚动的技术方案。

背景与问题

在开发类似 ChatGPT、DeepSeek 等 AI 对话应用时,Markdown 渲染是核心功能之一。当用户或 AI 生成包含多列的宽表格时,会遇到一个常见问题:

内容区域通常有最大宽度限制(如 800px),以保证文字阅读体验。但宽表格在这个限制内显示时,要么被截断,要么需要在很小的区域内滚动,用户体验很差。

理想效果

观察 DeepSeek 等产品的实现,可以发现一个优雅的解决方案:

  1. 普通内容:保持在限宽区域内(如 800px)
  2. 窄表格:和普通内容一样左对齐,不做特殊处理
  3. 宽表格:突破内容区域限制,可以利用整个视口宽度进行滚动

image.png

技术挑战

挑战 1:overflow 冲突

最直观的想法是让表格容器突破父级宽度。但如果父级有垂直滚动(overflow-y: auto),根据 CSS 规范,overflow-x: visible 会被强制转为 auto,导致无法突破。

/* 这样不行! */
.chat-messages {
  overflow-y: auto;    /* 垂直滚动 */
  overflow-x: visible; /* 会被强制转为 auto */
}

挑战 2:负 margin 与居中布局

常见的居中方式是 margin: 0 auto,但这种方式下,子元素使用负 margin 无法有效突破。

挑战 3:表格初始位置对齐

如果表格容器扩展到整个视口宽度,表格会从视口最左边开始显示,而不是和内容区域对齐。

解决方案

核心思路

  1. 用 padding 代替 margin 实现居中:这样子元素可以用负 margin 突破 padding
  2. 条件性突破:只有宽表格才突破,窄表格正常显示
  3. 初始滚动位置:设置 scrollLeft 让表格初始位置对齐内容区域

布局结构设计

┌─────────────────────────────────────────────────────────────┐
│ .chat-page (100vw, overflow-x: hidden)                      │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ .chat-scroll-area (overflow-y: auto)                  │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │ .chat-content (padding 居中,而非 margin)        │  │  │
│  │  │                                                 │  │  │
│  │  │   .message                                      │  │  │
│  │  │     └─ .table-breakout-wrapper (负 margin 突破)  │  │  │
│  │  │                                                 │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

实现代码

1. 容器布局(ChatBox.vue)

<template>
  <div class="chat-page">
    <div class="chat-scroll-area">
      <div class="chat-content">
        <ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
      </div>
    </div>
  </div>
</template>

<style scoped>
/* 页面容器 - 防止水平滚动条 */
.chat-page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100vw;
  overflow-x: hidden;
}

/* 滚动区域 - 处理垂直滚动 */
.chat-scroll-area {
  flex: 1;
  width: 100vw;
  overflow-y: auto;
  overflow-x: hidden;
}

/* 关键:用 padding 居中,而不是 margin */
.chat-content {
  --content-max-width: 800px;
  --content-padding: max(16px, calc((100vw - var(--content-max-width)) / 2));
  width: 100%;
  padding-left: var(--content-padding);
  padding-right: var(--content-padding);
  box-sizing: border-box;
}
</style>

要点

  • .chat-content 使用 padding 而不是 margin: 0 auto 居中
  • 使用 CSS max() 函数确保小屏幕下有最小 padding
  • 父级 overflow-x: hidden 防止出现水平滚动条

2. 表格渲染(marked 自定义 renderer)

import { marked } from 'marked'

const renderer = new marked.Renderer()

renderer.table = function(table) {
  // 构建表格 HTML...
  const tableHtml = `<table>...</table>`

  // 包裹容器结构
  return `
    <div class="table-breakout-wrapper">
      <div class="table-scroll-box">
        <div class="table-scroll-content">${tableHtml}</div>
        <div class="table-scroll-gutter">
          <div class="table-scroll-bar"></div>
        </div>
      </div>
    </div>
  `
}

marked.use({ renderer })

3. 突破边界逻辑(核心 JS)

// 计算突破边界的偏移量
const calculateBreakoutOffsets = () => {
  const messageRect = messageRef.value.getBoundingClientRect()
  const viewportWidth = window.innerWidth
  const pagePadding = 16 // 保留边距

  return {
    // 消息区域左边到视口左边的距离
    leftOffset: Math.max(0, messageRect.left - pagePadding),
    // 视口右边到消息区域右边的距离
    rightOffset: Math.max(0, viewportWidth - messageRect.right - pagePadding)
  }
}

// 应用突破样式
const applyBreakoutStyles = (wrapper, content) => {
  const { leftOffset, rightOffset } = calculateBreakoutOffsets()

  // 获取表格实际宽度
  const table = content.querySelector('table')
  const tableWidth = table.scrollWidth
  const containerWidth = messageRef.value.getBoundingClientRect().width

  // 关键判断:表格没超出容器,不需要突破
  if (tableWidth <= containerWidth) {
    wrapper.style.marginLeft = ''
    wrapper.style.marginRight = ''
    content.scrollLeft = 0
    return
  }

  // 表格超出容器,应用突破样式
  wrapper.style.marginLeft = `-${leftOffset}px`
  wrapper.style.marginRight = `-${rightOffset}px`

  // 设置初始滚动位置,让表格左边对齐内容区域
  if (!wrapper.dataset.scrollInitialized) {
    wrapper.dataset.scrollInitialized = 'true'
    content.scrollLeft = leftOffset
  }
}

核心逻辑

  1. 条件判断tableWidth <= containerWidth 时不做任何处理
  2. 负 margin 突破marginLeft = -leftOffset 抵消父级的 padding-left
  3. 初始滚动位置scrollLeft = leftOffset 让表格视觉上对齐内容区域

4. 样式定义

/* 突破容器 */
.table-breakout-wrapper {
  position: relative;
  margin-top: 16px;
  margin-bottom: 16px;
  box-sizing: border-box;
}

/* 滚动内容区域 */
.table-scroll-content {
  overflow-x: auto;
  overflow-y: hidden;
  /* 隐藏原生滚动条 */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.table-scroll-content::-webkit-scrollbar {
  display: none;
}

/* 表格样式 */
table {
  border-collapse: collapse;
  width: max-content; /* 关键:宽度由内容决定 */
  font-size: 14px;
}

th, td {
  padding: 12px 16px;
  white-space: nowrap;
  border-bottom: 1px solid #e8e8e8;
}

要点

  • width: max-content 让表格宽度由内容决定,不会被压缩
  • white-space: nowrap 防止单元格内容换行

image.png

5. 自定义滚动条(可选)

const initScrollBar = (content, gutter, bar) => {
  const updateBar = () => {
    const scrollWidth = content.scrollWidth
    const clientWidth = content.clientWidth
    const maxScroll = scrollWidth - clientWidth

    if (scrollWidth <= clientWidth) {
      gutter.style.display = 'none'
      return
    }

    gutter.style.display = 'block'

    // 滚动条宽度
    const ratio = clientWidth / scrollWidth
    const barWidth = Math.max(clientWidth * ratio, 40)
    bar.style.width = barWidth + 'px'

    // 滚动条位置
    const maxBarLeft = clientWidth - barWidth
    const scrollRatio = maxScroll > 0 ? content.scrollLeft / maxScroll : 0
    bar.style.left = (scrollRatio * maxBarLeft) + 'px'
  }

  content.addEventListener('scroll', updateBar)
  window.addEventListener('resize', updateBar)
  updateBar()
}

原理图解

负 margin 突破原理

正常状态(margin 居中):
┌──────────────────────────────────────────┐
│          ┌────────────────┐              │
│  margincontent 800pxmargin      │
│          └────────────────┘              │
│          子元素无法突破 margin            │
└──────────────────────────────────────────┘

padding 居中 + 负 margin:
┌──────────────────────────────────────────┐
│ padding  ┌────────────────┐  padding     │
│ ←──────  │  content 800px │  ──────→     │
│          └────────────────┘              │
│                                          │
│ ┌────────────────────────────────────┐   │
│ │  子元素 margin-left: -padding       │   │
│ │  成功突破到视口边缘                   │   │
│ └────────────────────────────────────┘   │
└──────────────────────────────────────────┘

初始滚动位置对齐

容器突破后,表格从最左边开始:
│ leftOffset │    content    │ rightOffset │
│←──────────→│               │←───────────→│
┌────────────┬───────────────┬─────────────┐
│[表格从这开始...]                          │
└──────────────────────────────────────────┘
            ↑ 但我们希望表格从这里开始

设置 scrollLeft = leftOffset 后:
┌────────────┬───────────────┬─────────────┐
│  滚动隐藏   │[表格对齐这里]  │  可继续滚动  │
└────────────┴───────────────┴─────────────┘
             ↑ 视觉上对齐内容区域

关键技术点总结

技术点 说明
padding 居中 使用 padding 而非 margin: 0 auto,让子元素可以突破
负 margin 子元素 margin-left: -padding 突破到视口边缘
条件判断 只有 tableWidth > containerWidth 时才突破
scrollLeft 对齐 设置初始滚动位置让表格视觉上对齐内容区域
overflow-x: hidden 最外层容器防止出现水平滚动条
width: max-content 表格宽度由内容决定,不被压缩

兼容性

  • 现代浏览器完全支持
  • CSS max() 函数需要 Chrome 79+、Firefox 75+、Safari 11.1+
  • 可使用 calc() 配合媒体查询作为降级方案

应用场景

  • AI 对话应用(ChatGPT、Claude、DeepSeek 等)
  • 在线文档工具(Notion、语雀、飞书文档)
  • Markdown 编辑器/预览器
  • 任何需要展示宽表格的内容型应用

参考

  • CSS Overflow Module Level 3
  • CSS Box Model Module Level 3
  • marked.js 自定义渲染器文档

本方案在 Vue 3 + Vite + marked.js 环境下实现和测试。

告别全局污染:深入解析现代前端的模块化 CSS 演进之路

作者 San30
2026年1月8日 23:44

在前端开发的蛮荒时代,CSS(层叠样式表)就像一匹脱缰的野马。它的“层叠”特性既是强大的武器,也是无数 Bug 的根源。每个前端工程师可能都经历过这样的噩梦:当你为了修复一个按钮的样式而修改了 .btn 类,结果却发现隔壁页面的导航栏莫名其妙地崩了。

这就是全局命名空间污染

随着现代前端工程化的发展,React 和 Vue 等框架的兴起,组件化成为了主流。既然 HTML 和 JavaScript 都可以封装在组件里,为什么 CSS 还要流落在外,互相打架呢?今天,我们就结合实际代码,深入探讨前端界是如何通过模块化 CSS 来彻底解决“样式冲突”这一世纪难题的。

一、 从 Bug 说起:为什么我们需要模块化?

在传统的开发模式中,CSS 是没有“作用域”(Scope)概念的。所有的类名都暴露在全局环境下。

1.1 命名冲突的灾难

想象一下,在一个大型多人协作的项目中。

  • 开发 A 负责写一个通用的提交按钮,他给按钮起名叫 .button,设置了蓝底白字。
  • 开发 B 负责写一个侧边栏的开关按钮,他也随手起名叫 .button,设置了红底黑字。

当这两个组件被引入到同一个页面(App)时,CSS 的“层叠”规则(Cascading)就会生效。谁的样式在最后加载,或者谁的优先级(Specificity)更高,谁就会赢。结果就是:要么 A 的按钮变红了,要么 B 的按钮变蓝了。

1.2 传统的妥协:BEM 命名法

为了解决这个问题,以前我们发明了 BEM(Block Element Modifier)命名法,比如写成 .article__button--primary。这种方法虽然有效,但它本质上是靠开发者的自觉冗长的命名来模拟作用域。这并不是真正的技术约束,而是一种君子协定。

我们需要更硬核的手段:让工具帮我们生成独一无二的名字

二、 React 中的解决方案:CSS Modules

React 社区对于这个问题的标准答案之一是 CSS Modules。它的核心思想非常简单粗暴:既然人取名字会重复,那就让机器来取名字。

2.1 什么是 CSS Modules?

在你的项目中,你可能看到过后缀为 .module.css 的文件。这不仅仅是一个命名约定,更是构建工具(如 Webpack 或 Vite)识别 CSS Module 的标志。

让我们看一个实际的例子。假设我们需要两个不同的按钮组件:ButtonAnotherButton

Button.module.css:

.button {
    background-color: lightblue;
    color: black;
    padding: 10px 20px;
}

.txt {
    color: red;
}

AnotherButton.module.css:

.button {
    background-color: #008c8c;
    color: white;
    padding: 10px 20px;
}

请注意,这两个文件中都定义了 .button 类。在传统 CSS 中,这绝对会冲突。但在 CSS Modules 中,这两个 .button 是完全隔离的。

2.2 编译原理:哈希(Hash)魔法

当我们在 React 组件中引入这些文件时,并没有直接引入 CSS 字符串,而是引入了一个对象

Button.jsx:

// module.css 是 css module 的文件
// react 将 css 文件 编译成 js 对象
import styles from './Button.module.css';

console.log(styles); // 让我们看看这里打印了什么

export default function Button() {
    return (
        <>
            <h1 className={styles.txt}>你好,世界!!!</h1>
            <button className={styles.button}>My Button</button>
        </>
    )
}

如果你在浏览器控制台查看 console.log(styles),你会发现输出的是类似这样的对象:

{
  button: "Button_button__3a8f",
  txt: "Button_txt__5g9d"
}

核心机制:

  1. 编译转换:构建工具读取 CSS 文件,将类名作为 Key。
  2. 哈希生成:工具会根据文件名、类名和文件内容,生成一个唯一的 Hash 字符串(例如 3a8f),将其拼接成新的类名作为 Value。
  3. 替换引用:在 JSX 中,我们使用 {styles.button},实际上渲染到 HTML 上的是 <button class="Button_button__3a8f">

2.3 真正的样式隔离

现在我们再看看 AnotherButton.jsx

import styles from './antherButton.module.css';

export default function AnotherButton() {
    // 这里的 styles.button 对应的是完全不同的哈希值
    return <button className={styles.button}>Another Button</button>
}

App.jsx 中同时引入这两个组件:

import Button from './components/Button.jsx';
import AnotherButton from './components/AnotherButton.jsx';

export default function App() {
  return (
    <>
      {/* 这里的样式互不干扰,因为它们的最终类名完全不同 */}
      <Button />
      <AnotherButton />
    </>
  )
}

总结 CSS Modules 的优势:

  • 安全性:彻底杜绝了全局污染,每个组件的样式都是私有的。
  • 零冲突:多人协作时,你完全不需要担心你的类名和同事的重复。
  • 自动化:不需要人工维护复杂的 BEM 命名,构建工具自动处理。

三、 Vue 中的解决方案:Scoped CSS

Vue 采用了另一种更符合直觉的策略。Vue 的设计哲学是“单文件组件”(SFC),即 HTML、JS、CSS 全部写在一个 .vue 文件中。为了实现样式隔离,Vue 提供了 scoped 属性。

3.1 scoped 的工作原理

看看这个 HelloWorld.vue 组件:

<template>
  <h1 class="txt">你好,世界!!!</h1>
  <h2 class="txt2">一点点</h2>
</template>

<style scoped>
.txt {
  color: pink;
}
.txt2 {
  color: palevioletred;
}
</style>

当你给 <style> 标签加上 scoped 属性时,Vue 的编译器(通常是 vue-loader@vitejs/plugin-vue)会做两件事:

  1. HTML 标记:给模板中的每个 DOM 元素添加一个独一无二的自定义属性,通常以 data-v- 开头,例如 data-v-7ba5bd90
  2. CSS 重写:利用 CSS 的属性选择器,将样式规则重写。

编译后的 CSS 变成了这样:

.txt[data-v-7ba5bd90] {
  color: pink;
}
.txt2[data-v-7ba5bd90] {
  color: palevioletred;
}

编译后的 HTML 变成了这样:

<h1 class="txt" data-v-7ba5bd90>你好,世界!!!</h1>

3.2 样式穿透与父子组件

Vue 的 Scoped 样式有一个有趣的特性。看 App.vue 的例子:

<template>
<div>
  <h1 class="txt">Hello world in App</h1>
  <HelloWorld />
</div>
</template>

<style scoped>
.txt {
  color: #008c8c;
}
</style>

这里 App.vue 也有一个 .txt 类。但是,由于 App.vue 会生成一个不同的 data-v-hash ID,它的 CSS 选择器会变成 .txt[data-v-app-hash],而 HelloWorld 组件内部的 .txt 只有 .txt[data-v-helloworld-hash] 才能匹配。

这意味着:父组件的样式默认不会泄露给子组件,子组件的样式也不会影响父组件。

Vue Scoped 的优势:

  • 可读性好:类名在开发工具中依然保持原样(.txt),只是多了一个属性,调试起来比 CSS Modules 的乱码类名更友好。
  • 性能:只生成一次 Hash ID,利用浏览器原生的属性选择器,性能开销极低。
  • 开发体验:无需像 React 那样 import styles,直接写类名即可,符合传统 HTML/CSS 开发习惯。

四、 进阶玩法:CSS-in-JS (Styled-Components)

如果我们再激进一点呢?既然 JavaScript 统治了世界,为什么不把 CSS 也变成 JavaScript 的一部分?这就诞生了 CSS-in-JS,其中最著名的库就是 styled-components

这种方案在 React 社区非常流行,它将“组件”和“样式”彻底融合了。

4.1 万物皆组件

在提供的 APP.jsx (Styled-components 版本) 示例中,我们不再写 .css 文件,而是直接定义带样式的组件:

import styled from 'styled-components';

// 创建一个名为 Button 的样式组件
// 这是一个包含了样式的 React 组件
const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

注意到了吗?这里的 CSS 是写在反引号(` `)里的,这在 ES6 中叫做标签模板字符串(Tagged Template Literals)

4.2 动态样式的威力

CSS Modules 和 Vue Scoped 虽然解决了作用域问题,但它们本质上还是静态的 CSS 文件。如果你想根据组件的状态(比如 primarydisabledactive)来改变样式,通常需要动态拼接类名。

但在 styled-components 中,CSS 变成了逻辑

background: ${props => props.primary ? 'blue' : 'white'};

这行代码意味着:如果在使用组件时传递了 primary 属性,背景就是蓝色,否则是白色。

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

当 React 渲染这两个按钮时,styled-components 会动态生成两个不同的 CSS 类名,并将对应的样式注入到页面的 <style> 标签中。

CSS-in-JS 的优势:

  • 动态性:样式可以像 JS 变量一样灵活,直接访问组件的 Props。
  • 删除无用代码:既然样式是绑定在组件上的,如果组件没被使用,样式也不会被打包。
  • 维护性:你永远不用去寻找“这个类名定义在哪里”,因为它就在组件的代码里。

五、 总结:如何选择?

在现代前端开发中,我们有多种武器来对抗样式冲突:

  1. CSS Modules (React 推荐)

    • 适用场景:大型 React 项目,团队习惯传统的 CSS/SCSS 编写方式,追求极致的性能(编译时处理)。
    • 特点:通过 Hash 类名实现隔离,输出 JS 对象。
    • 关键词.module.css, import styles, 安全, 零冲突。
  2. Vue Scoped Styles (Vue 默认)

    • 适用场景:绝大多数 Vue 项目。
    • 特点:通过 data-v- 属性选择器实现隔离,代码更简洁,可读性更高。
    • 关键词<style scoped>, 属性选择器, 简单易用。
  3. CSS-in-JS (Styled-components / Emotion)

    • 适用场景:需要高度动态主题、复杂的交互样式,或者团队偏好“All in JS”的 React 项目。
    • 特点:样式即逻辑,运行时生成 CSS。
    • 关键词styled.div, 动态 Props, 逻辑复用。

回到开头的问题:

不管是 CSS Modules 的哈希乱码,还是 Vue 的属性标记,或者是 Styled-components 的动态注入,它们的终极目标都是一样的——让样式为组件服务,而不是让组件去迁就样式。

在你的下一个项目中,请务必抛弃全局 CSS,拥抱模块化。这不仅是为了避免 Bug,更是为了写出更优雅、更健壮、更易于维护的代码。

希望这篇文章能帮你彻底理解前端样式的模块化演进! Happy Coding!

CSS 新特性!瀑布流布局的终极解决方案

作者 冴羽
2026年1月8日 17:11

前言

前端开发一直有一个老大难的问题,那就是——瀑布流布局。

效果需求并不复杂:卡片错落,参差有致,看起来高级,滚动起来流畅。

就是这样一个看似简单的效果,其实已经困扰了前端开发者好多年。

要引入 JavaScript 库,要让内容智能填充,要实现响应式布局,写无数个媒体查询,要实现无限滚动加载,要用 JavaScript 处理复杂的布局逻辑……

现在,经过 Mozilla、苹果 WebKit 团队、CSS 工作组和所有浏览器的多轮讨论,它终于有了终极解决方案!

这就是 CSS Grid Lanes

且让我们先翻译它为“CSS 网格车道”吧。

之所以叫车道,想象一下高速公路:有好几条车道,车辆会自动选择最短的那条车道排队。

CSS Grid Lanes 就是这个原理——你先定义好有几条“车道”(列),网页内容会自动填充到最短的那一列,就像车辆自动选择最不拥堵的车道一样。

具体使用起来也很简单,三行代码就能实现:

.container {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 16px;
}

实现原理

现在,让我们来细致讲解下如何实现开头图中的瀑布流效果。

首先是 HTML 代码:

<main class="container">
  <figure><img src="photo-1.jpg" /></figure>
  <figure><img src="photo-2.jpg" /></figure>
  <figure><img src="photo-3.jpg" /></figure>
  <!-- etc -->
</main>

然后是 CSS 代码:

.container {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 16px;
}

代码一共 3 行。

display: grid-lanes 创建网格容器,使用瀑布流布局。

grid-template-columns 创建车道,我们将值设为 repeat(auto-fill, minmax(250px, 1fr))意思是至少 250 像素宽的灵活列。浏览器决定创建多少列,并填充所有可用空间。

gap: 16px表示车道之间有 16px 的间歇。

就是这么简单。

3 行 CSS 代码,无需任何媒体查询或容器查询,我们就创建了一个适用于所有屏幕尺寸的灵活布局。


更绝的是,这种布局能让用户通过 Tab 键在各个栏目之间切换,访问所有当前可见的内容(而不是像以前那样,先滚动到第一列底部,然后再返回第二列顶部)。

它也支持你实现无限循环加载,随着用户滚动页面,内容无限加载,而无需使用 JavaScript 来处理布局。

功能强大

不同车道尺寸

Grid Lanes 充分利用了 CSS Grid 的强大功能 grid-template-*来定义车道,所以很容易创建出富有创意的布局。

例如,我们可以创建一个布局,其中窄列和宽列交替出现——即使列数随视口大小而变化,第一列和最后一列也始终是窄列。

实现也很简单:

grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr) minmax(16rem, 2fr)) minmax(8rem, 1fr);

效果如下:

跨车道

由于我们拥有网格布局的全部功能,我们当然也可以跨越车道。

效果如下:

实现代码:

main {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(20ch, 1fr));
  gap: 2lh;
}
article {
  grid-column: span 1;
}
@media (1250px < width) {
  article:nth-child(1) {
    grid-column: span 4;
  }
  article:nth-child(2),
  article:nth-child(3),
  article:nth-child(4),
  article:nth-child(5),
  article:nth-child(6),
  article:nth-child(7),
  article:nth-child(8) {
    grid-column: span 2;
  }
}

放置项目

我们也可以在使用网格车道时显式地放置项目。这时,无论有多少列,标题始终位于最后一列。

实现代码为:

main {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(24ch, 1fr));
}
header {
  grid-column: -3 / -1;
}

改变方向

网格车道也可以双向排列!

上面的所有示例创建的是“瀑布式”布局,内容以列的形式排列。

网格车道也可以用于创建另一种方向的布局,即“砖块式”布局。

当使用 grid-template-columns定义列时,浏览器会自动创建瀑布式布局,如下所示:

.container {
  display: grid-lanes;
  grid-template-columns: 1fr 1fr 1fr 1fr;
}

如果你想要反方向的砖块布局,使用 grid-template-rows

.container {
  display: grid-lanes;
  grid-template-rows: 1fr 1fr 1fr;
}

容差

“容差”是为 Grid Lanes 创建的一个新概念。它允许你调整布局算法在决定放置项目位置时的精确度。

回到高速公路的比喻:

假设 1 号车道前面的车比 2 号车道长了 1 厘米,下一辆车要排到哪条车道?

如果严格按“哪条短选哪条”,它会选 2 号车道。但 1 厘米的差距根本不重要!这样来回切换车道反而让人困惑。

“容差”就是告诉系统:“差距小于这个值,就当作一样长”。

容差默认值是 1em(大约一个字的高度)。

为什么容差很重要呢?

因为用键盘 Tab 键浏览网页的人(比如视障用户)会按内容顺序跳转。

如果布局乱跳,他们会很迷惑。合适的容差能让浏览体验更流畅。

现在能用吗?

目前可以在 Safari 技术预览版 234 中体验,其他浏览器还在开发中。

苹果 WebKit 团队从 2022 年中就开始实现这个功能,现在基本语法已经稳定了。虽然还有些细节在讨论(比如属性命名),但核心用法不会变。

你可以访问 webkit.org/demos/grid3 看各种实际例子。

最后

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

查看 Base64 编码的字体包对应的字符集

2026年1月7日 14:39

Base64 转换为 字体文件 .woff

若从代码中看到Base64 编码的字体包,不好确认包含哪些字体,可以通过截取的方式进行解析:

@font-face {
    font-family: 'SourceHanSerifCN-Regular';
    src: url('data:font/woff2;charset=utf-8;base64,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') format('woff2'),
        url('data:font/woff;charset=utf-8;base64,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') format('woff');
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}

截取Base64的部分

@font-face {
  font-family: 'MyFont';
  src: url(data:font/woff2;base64,你的base64字符串) format('woff2');
  font-weight: normal;
  font-style: normal;
}

截取后通过JS解析(可以直接在浏览器端运行)

// 解码 Base64 为二进制数据
function base64ToFont(base64String, filename = 'font.woff') {
  // 移除 data URL 前缀(如果有)
  const base64Data = base64String.replace(/^data:font\/\w+;base64,/, '');
  
  // 解码 Base64
  const binaryString = atob(base64Data);
  const bytes = new Uint8Array(binaryString.length);
  
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  
  // 创建 Blob 对象
  const blob = new Blob([bytes], { type: 'font/woff' });
  
  // 创建下载链接
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  link.click();
  
  // 清理
  URL.revokeObjectURL(url);
}

比如上述的文件

base64ToFont('d09GMgABAAAAABEcAA8AAAAAH6AAABDAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0ZGVE0cBlYAgkoIBBEICq08pBwLcAABNgIkA3YEIAWDbAeEMBckGHYbiRkjkpPaFpD9ZfJgDD3UkS0ShGvVBPNHa81DfmMfdiJsq+ZWNXkwlSw2Sriib3gwX8v+zJn/nmynWVkuMkpyoJhNGbIqOHYxMO2901tPgB+QZLtMh8tnC7fN8NsjpqGSEymBKlw65SM//3UMhqe5/bvdBhjzdtvoUjZgVI4RJx4H/RHJkSU2NjrJkCr1C0r4MZP6I2r07PrB9/Pw3+17d6Zmpv7kkaSSWJTyAq6udc7q2DBWxlGI/3/XysrM7v4jcAVAYatqfI3JJEtJbmaBMwdM2SIJVSMJWO6pvuqyq9AVhoyrMnWyFbKXbXaLpCIWUyxAp88TmwIA2Ajh2R7J+Yw7A/9aUs5hcj35FFjQfYjxA2B7+LxGDGtIYwYELAOm+Q1d4/9RUGv+W+Nd8xC2d0LAMEZG2wdtqkBUaELPwgSYXcn/C8IE4x7KHsJtZQmY0PU7t5w81WrEmvB/CNhHaFvQrIzE6Vy6TUsa/QUEqWlGNslRLpANkTw70zip4mvFN8I0jYYuWvU67Jr1SNo561OEborRee5i8MumJPREPujCbTZQnx0RujZ4j8BGWzTDXrUcd9iSpxoUck4N29tgQ0FAfXQz9VOHqypbOeUYixFlvmLXyRVenrKuwUniaR6G4Ok3Rvm+o9jAQEplpzQrPYxuYvHWI7scijTNRBGtPCb+E6fsbrWrz4zgKaOagF/XoPqE+I+ZpNn4EsAm80vU16SBaxMq6/uigZrZHcT0wBShoQr4dQCtqd2q4JlTRZ6sMxZlDhVHbrntNNQQaAAM0ejkFlESrvI7B4/r+7QUydOf2OWPOonQrhpBJoeK70Ab8sZCoI5GvlgKkRSHina9ZVj7aN0+0MfVTkE51HIFvIDNTq7izfPHqMnh3/6NB9VHt4KbDyv315YfhDfAu36/8rCyv1L8SDFNwC7HGOyjgRxUfeDfV2tXEJqWomVERmDYmriOKb4ScdTLA8a0EId8rMxcuvb5+99HtOoLLTxyyQblB+Bp/lX4U3h1kBLh8kzg3WcVmbXTod1asACeVWIxjluDlmqiJQ1emEFflEzUeuhl+FBWxbEy4T9mYZlYVdp1b6jYcMuoJ5GlJxQXLahrZeTcuyMi1kFo4wZSaP1GaFun9oHndOs6PI3R5b+GKZptAvsaKUNzrcErQmjVqAqwNCsVo8GNXSyiAOc43rnmZAiEK6hMLym5XGdIfCHdu4GWJM+NBmGNxNpRatzz4c5IDOTvnaUocYOeTFZWFBnXXVYLWCmbSedGcVBY6s2pvQ3KnzgoLozTLOoVnpFYBDo2jAEJhLl+0kpPtPOEHMH5o0szjDuQI156VdeQFlURckakS7ialeSP5SWYyQpyoV/TLOvDT6VTJU/7CFp5iMXYjbYkoKLaug7HWCXFxBMt21ncUIp4MBnAU5Sb6PQVZkxyoF4qg+Ufqkz/Oq9uop+JcEhJAmiI9lAC2AbuAOJIYmdfS0IwvF52JsSAlvgZAC99Z4+F9lQNggtbnmCTFAp8j5h7wv58Amnti3L7P9T119Vbwc3Kdb8zrCMFbfnUqLCM7KWsiJvrzdOoujMp3GkFEWlnvF9jMplFwTPhQCGePp1SJm0BRQ+bqJhTOZehGmTW66IzokHRzdopB3KmZaSsla5mGCtiouNCrnXZjH0vY00esey1azJWr0UOz0tQ08HObzSpN0Zbu8Zo/5tdn8tsow4NpoIlOm7+6W6dBV71SDW7y9de77/Nfj8WETwTFyHtZ3dXdopQnI6LUPX2hmxgnOblMLHQ1XZuueutLh/1o9FJ+pbIvamL1Vwd/na8UZcD/ObpUTl9925ovpIW/dnprJ0VlJjrPm1wagIXI/BwXUoDPCxGAN+7jb/OTXiYPsBJXhjaZ3jt62jC7tt/509YJNtLeUayN7vpF6lHssMO+PhHZuvcrPAAXz2ruG7hHgF2vqbd0GhbWpt823xyal1xYmRn3e4g/BkCDTakNUCDzxAQMRYXx4gdwoUIPHo6rQ4eFiJEByMpQUehCX7cTZYb3PZym9wQ+T4sfABCK851Y9DatfAPuNz42nX48UsUT8eFprDaW0EleplJ8SCj94f7FQa87lryIeKbTVOXWsaT0VSD/sRzl6LVR/dJevCNDAo87kp7H/Xy9jiC3s13i1qirFCk3cHOnO6nc1SHj0H8UB0s9ngifAwe1G/Mp17jNEEcIsjld4vCi7ZMfNMKfL53j8uh8S7BuBCFHnMiYcdgEFexRvzNSk3dYo/GMZ1Kcu/SrRelqkXJxUpaV+8bCbx1DpX98QYWshECRIcHTyJ4KH6LekW0QVEa6pyOrhQjsFsSxFQik5ie3cvuUxy/rirxAhW+B1HqRTEyhR/xZ1zomVVF/H0zIW2wcAokrgR/RvASXIxQDK3c1F02uqtPkYBWy2sGbox/YABt22mUOKbBXFPXWismnku1qwM7c9YI9BIB5XyM3XPtKxtR3A9/hwKKYYMYJbJton5Ur0MIoFQ20nLUiXSVT7q6x6MCZ9L1i1/v29gqnTI4mG545fuDZP8b/Wu8/0X4fiXD8MmgTApwpZxRh0bb9n95I34ykKw0YfPlN2jgfxSvx8UonLOaJoZPiBHi9mbl190WP0zfs1uAhapjefj5PTfc0vfaFpO8HtFMH1wMfzkyWxyn+kyY4vlH7w8UoBsqf+/K89mvANhldKNzj65XOZ2UIf9nNol/QfFoRnB6PI+JC0JUHQ3Z9rz1LDid/P1HiFe01cWi4TQYiv6/lnj6i/r4btnI1G0VeXdNeX3XBm1g25c96LBLqzU4OFVnz7YR8yWagTiXZpKgItfoPXVrtb4gq7ii0/yW5T3rO+NNOWXXv21vOPXIMtJBSitFZd1hbnXvbFXeNOBLn3sAjXak1UGDVBSfxKkoNFiX1gGNPtAyT6AK/BwBVRAHMqbWiGGqibORh2rBrGtjpe+/XRN10LAYIQqITwjJyHJM/s2a+MMKVV9RRCVAVvsaMY1qSJ/br2nWPU0lThFfEOVvn9ryD0soAY5UrBEPUdmsc5ky5H+k2/7JlMvq7qIShsQ9hGF6h+l3ZxIh3IlphHn8Hr3/zn2UAJFeoSIUD8bvIyVuXVsXqbguLqK+y124/BAh/Igxaq7daRsRQjCIQWT2AIj8XqecaRvwjnwLQvE7OITCNfQAZV97EULkEWJqzWrdohgl9AkQ8flkOPPEqJ2Df7KnsXk4XdDs6OCflA28hXOnHmgmqPUY1O+LDj8emx0x4Ppks6FD3JHgoLD1wUPXKY1XS3ICvQ9OJl3S7dcpl+NdPHW4M2efqUqEQhK29aNGtkzRiHisf/Afvp6LoQ5glUU15h+8c3YGnl8a/9QbWMv77eCx5gvfREpGEoie9dunVe3UwoNUbAGF39forkg6pXK4IExtdbhh1uXsutyadRe6miVkBb2d34u0s5luXD10UGOPj/bhHU2Oe3e7DaTNkCQfvwA+RjNlOFbrGNItghcWhZ96d9Q7kdgLKHaCCle2dH1NGMAFBR4zx3uLN37JrRSXdvqtSDqlc9QpdqA1JWfLJjtBd3dV+/QK52jAsGvsl4Sq0C5WVWjqSYfbGcci5toKpjyyXsH/3nR9k0ZKJ/oPpdRaBrwDev36L5H1N/nf4vy2Wj82jfFLN3i1xZGt46yeNxop7+HsjnGH4DtnEjrRXzkwhKBYHnYXgY9KgBfs06/vr+o4Iahu37e/sl1woqJDMB6fiMvVmXrbpM8dGJ59lZtasd7ZZ6cSxSP4ZNurP4V5Yey3W/QcY1wPBB7b6OF1ojrhh10ttKH5iDd/v69eks0+Zzzdx6Im7lbby1Hh9azfOB4gvCnwKoJtwS4h8OvGPzsy8rMnnvR4NcKvBxCMhV1DyPphuquSTmkcLoXXfyD7FT+ntffa1JtGwPueZ4w5m3IwJ2NTVyeOmSsP9jcUM7Aj2FMG/Lo2cYzy+zzMPcBtgpUda8HW53lG7s6mxu48I1NMMPUYL/zAPu5FijJD00jaZfvxPnLN0+Y0ustWH4UK6e08JefAKh9DL12usvhGf6vk3CwMgi8GLzGwIIH/4LUVKOJKYHOvIvD7WtvOi1usezNM4+nz3tAcdIDLSuBtut8e20rKvQed5XVlctQpPOIByPAK7EMwdawHIWtGr2gsNRc0hZj9iiZrXkAwRexCDaz5TmYZr9vu4B2A+qQC1kX4tmRNUhp6YK4zt4kycHpS9NVufEfqAD/+7JkbY+8aq3AWHdp15ZOy+iVuDsBcN65Q4t7g4u6O2pjU0aZ1lwRhi23549verfEen/2CS/f9o80zJiREk+vS0koRYH3tIcve6JoiLSwJu4aUUKpae77kf69brN+Iy1v7ZaEDm1D3JvcFBP67Lk8sWTwKc8O4wEc40ybY80fPEjS3IvzSv6PWScJ3AXEvcP8bhaFjIy1dn5IuewiKPGZzjvvNl5U99sp8X9EZW2FAYfscAfamDlJUTPCsOROL1Odx5i24wUbGZ80PD/+qhV8zpF0vuooZ8PuarFeSuZOwW4INsNHI+2lwRr2odm+VRP//P+6nQP8m6ywX2g819KRFeabGlSqt6Jlbfyv61vrm8UzIBlGy2rVsTnllS3Sge1IUCLxY/bz4xMi1jsvQgCwUq/vxBfYasfXdTfHvSGhHsosTj6T67NEAobcDHyDYng3QaGTy+N5Dg3cekz7DzsMbnOKUqiUdQ9oLSk5dv3Lz7tBdQbzKmZcDkpgWvD9oyozPfKW+9w+YvG28O0yZezyZr263aTywv83CpYeePcn7QKZmNqMvO1A28+MFmou+y2Ua7etGVHP9LfowC/VFA+83CE0LuotiZlg1lXPQ+WADFVvExpHjPRmzNza5sFxqNsns0NheRXMZcxHSIgpBqpYF7yFNnVwQNvlmoDkg0ywiG3Pl6fkaxGoVVTmW6IWqMOWmmbwg3l2mbGagbF/wXRoP7Gj2iaZ50+1MfAeqDH9GSlI5X+w4ff3nnYDlxa4iMLzeSo73g3sK5TPNzCSMsxm2vr63SnhzOwkB2Hz2B9Xq5zDE9mdvaeB9vsUl/n3U5EvvZEgCySmMbzEhGJcF/f8/Y5AHddloki9nyCDnULLhC9xf4ORSHXp5uUwmOo2lhd0whOs9s4Kz5Qk3BosgAZ8gstsPCOtDlZ4uz3Jmeb1L3B5xLaBeRE3Jb+TUHFxhBebaWghzR3AB00AFPloTsRHxhMcAZZTQ6VwWGGBvYIEFG3BtSIBmfFgcMocggZYLFsBwgNUCCsj4casS4JpC+IgWK0uSCBlYuCiRJm7G+MSeO8WLBP4DXrB+mVrOj94Z/zgITJcqxc6DLsViOTuq4WAzOjDrDfMlsayBFtmAiZDywKLRsY8Xg4fAsus0w8wYzHJTyGEkjYHfwU4anhEbrcZsIzXrv8X5ElhEim90RnwMj2AFqwtUmrEsWR+kWABIP6xM8QWw1Bh7bYDPs/8EsI2A97sK/CKxpcv1/g/WYCPgXUQgYZJ1ekXVDEaT2WK12aHoGJikyZAlR54CRUqUqVClRp0GTVq06dClR99mWxhgYTNkxBiHCVNmNevjXbimfoCAhIKGgXVcrhkBgEFAweDBB4eAhIKGgXVcrjkBwCEgoaBhYB2Xa0EAYBBQMHjwwSEgoaBhYB2Xa0mDAaCGQW71mi+0qv8eIV5220GYmgyKyE4C27AKutl5Dh7Yyg0pqGbr/6ug9SdWvQe+eqvASjFH1JIhAAAA')

解析后浏览器自动下载了 font.woff 文件

image.png

查看字体包的 Character set

Wakamai Fondue: Drop A Font ! 只需要把 font.woff 文件传上去,即可查看字体文件对应的字符集(如图)

SourceHanSerifCN-Regular from 1.0 css.png

从图中就可以查看到对应的字符集包含哪些内容,更清晰了解到哪些字体不在字体包内。

字符集说明

需要注意一点,字符集对应的编码是【十六进制】

如上图所看到的字符 【A】 对应的编码是 【FF21】 (说明是个特殊字符)

这跟我们平时键盘敲出来的字符A是不同的,键盘 【A】 的是 【0x41】 (如下图所示)

image.png

要确认清楚字符集的对应,这样才能更好地了解清楚,避免错认。

大写字母A-Z编码

字符 十进制 十六进制 二进制
A 65 0x41 01000001
B 66 0x42 01000010
C 67 0x43 01000011
D 68 0x44 01000100
E 69 0x45 01000101
F 70 0x46 01000110
G 71 0x47 01000111
H 72 0x48 01001000
I 73 0x49 01001001
J 74 0x4A 01001010
K 75 0x4B 01001011
L 76 0x4C 01001100
M 77 0x4D 01001101
N 78 0x4E 01001110
O 79 0x4F 01001111
P 80 0x50 01010000
Q 81 0x51 01010001
R 82 0x52 01010010
S 83 0x53 01010011
T 84 0x54 01010100
U 85 0x55 01010101
V 86 0x56 01010110
W 87 0x57 01010111
X 88 0x58 01011000
Y 89 0x59 01011001
Z 90 0x5A 01011010

小写字母a-z编码

字符 十进制 十六进制 二进制
a 97 0x61 01100001
b 98 0x62 01100010
c 99 0x63 01100011
d 100 0x64 01100100
e 101 0x65 01100101
f 102 0x66 01100110
g 103 0x67 01100111
h 104 0x68 01101000
i 105 0x69 01101001
j 106 0x6A 01101010
k 107 0x6B 01101011
l 108 0x6C 01101100
m 109 0x6D 01101101
n 110 0x6E 01101110
o 111 0x6F 01101111
p 112 0x70 01110000
q 113 0x71 01110001
r 114 0x72 01110010
s 115 0x73 01110011
t 116 0x74 01110100
u 117 0x75 01110101
v 118 0x76 01110110
w 119 0x77 01110111
x 120 0x78 01111000
y 121 0x79 01111001
z 122 0x7A 01111010

0-9的数字编码如下

字符 十进制 十六进制 二进制
0 48 0x30 00110000
1 49 0x31 00110001
2 50 0x32 00110010
3 51 0x33 00110011
4 52 0x34 00110100
5 53 0x35 00110101
6 54 0x36 00110110
7 55 0x37 00110111
8 56 0x38 00111000
9 57 0x39 00111001

CSS终于支持渐变色的过渡了🎉

作者 JIE_
2026年1月6日 10:33

背景

在做项目时,总会遇到UI给出渐变色的卡片或者按钮,但在做高亮的时候,由于没有过渡,显得尤为生硬。

过去的解决方案

在过去,我们如果要实现渐变色的过渡,通常会使用如下几种方法:

  1. 添加遮罩层,通过改变遮罩层的透明度做出淡入淡出的效果,实现过渡。
  2. 通过background-size/position使得渐变色移动,实现渐变色移动的效果。
  3. 通过filter: hue-rotate滤镜实现色相旋转,实现过渡。

但这几种方式都有各自的局限性:

  1. 遮罩层的方式看似平滑,但不是真正的过渡,差点意思。
  2. background-size/position的方式需要计算好background-sizebackground-position,否则会出现渐变不完整的情况。并且只是实现了渐变的移动,而不是过渡。
  3. filter: hue-rotate也需要计算好旋转角度,实现复杂度高,过渡的也不自然。

@property新规则

@property规则可以定义一个自定义属性,并且可以指定该属性的语法、是否继承、初始值等。

@property --color {
  syntax: '<color>';
  inherits: false;
  initial-value: #000000;
}

我们只需要把这个自定义属性--color应用到linear-gradient中,在特定的时候改变它的值,非常轻松就可以实现渐变色的过渡了。 我们再看看@property规则中这些属性的含义。

Syntax语法描述符

Syntax用于描述自定义属性的数据类型,必填项,常见值包括:

  • <number> 数字(如0,1,2.5)
  • <percentage> 百分比(如0%,50%,100%)
  • <length> 长度单位(如px,em,rem)
  • <color> 颜色值
  • <angle> 角度值(如deg,rad)
  • <time> 时间值(如s,ms)
  • <image> 图片
  • <*> 任意类型

Inherits继承描述符

Inherits用于描述自定义属性是否从父元素继承值,必填项:

  • true 从父元素继承值
  • false 不继承,每个元素独立

Initial-value初始值描述符

Initial-value用于描述自定义属性的初始值,在Syntax为通用时为可选。

兼容性

@property目前仍是实验性规则,但主流浏览器较新版本都已支持。 b70bdd98-15d5-4aa3-a3c4-b4d08a7aba9c.png

总结与展望

@property规则的出现,标志着CSS在动态样式控制方面迈出了重要一步。它不仅解决了渐变色过渡的技术难题,更为未来的CSS动画和交互设计开辟了新的可能性。 随着浏览器支持的不断完善,我们可以期待:

  • 更丰富的动画效果
  • 更简洁的代码实现
  • 更好的性能表现
❌
❌