普通视图

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

Lottie-web 源码解析(一):从 JSON Schema 认识 Lottie 动画的本质📒

作者 哨卫哥
2026年1月13日 10:34

Lottie 的本质是 After Effects 动画的 Web 播放器,其工作流程清晰简洁:

  1. 设计 → 设计师在 After Effects(AE) 中制作矢量动画
  2. 导出 → 通过 Bodymovin 插件将动画导出为轻量的 data.json 文件
  3. 播放 → web 开发者使用 lottie-web 库解析该 JSON,并在浏览器中实时渲染动画

若想深入理解 Lottie 的动画机制,从 JSON Schema 入手是一条清晰的路径。Lottie 是一个完全由数据驱动的动画系统,掌握其 JSON 结构,就等于握住了理解动画渲染逻辑的钥匙。当然,Lottie JSON 的配置项较为丰富,不必追求一次性全部记住。本文旨在帮助大家建立初步认知,为后续学习渲染引擎打下基础,做到“知其然,亦知其所以然”。

一、初识 data.json :动画数据的顶层结构

Lottie 动画的本质是一套由数据驱动的图形绘制指令集,而 data.json 文件正是这套指令的完整载体。其顶层结构定义了动画的全局元信息,如同电影的“导演台本”,设定了动画播放的画布尺寸、时间线与基础环境。理解这些字段,是后续深入解析图层、关键帧等复杂属性的根基。

顶层元数据:动画的全局定义

下表列出了 data.json 根对象(Root Object)中的核心元数据字段,它们共同框定了动画的时空属性与渲染基础。

字段 类型 描述 示例值 单位/备注
v string Bodymovin 插件版本:生成此 JSON 所用的插件版本号,不同版本可能支持不同的特性。 "5.7.4" -
fr number 帧率(Frame Rate) :动画的播放速率,直接决定动画的流畅度与时间计算基准。 3060 帧/秒 (fps)
ip number 起始帧(In Point) :动画时间轴开始的绝对帧编号。 0
op number 结束帧(Out Point) :动画时间轴结束的绝对帧编号。动画总时长 = (op - ip) / fr 秒。 90
w number 画布宽度:动画合成(Composition)的逻辑宽度。 800 像素 (px)
h number 画布高度:动画合成的逻辑高度。 600 像素 (px)
nm string 合成名称(Name) :对应 After Effects 中合成的名称,便于识别。 "元数据演示动画" -
ddd integer 3D 图层标识:标识此合成中是否包含 3D 图层或摄像机0 代表否(默认),1 代表是。 0 -

综合以上字段,一个定义了800x600画布、30fps、时长3秒的2D动画,其顶层结构示例如下:

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "nm": "元数据演示动画",
  "ddd": 0,
}

核心组成:图层、资源与字形

assetschars 与 layers 是构成 Lottie 动画内容的三个核心数据数组。它们分别定义了可复用的素材、矢量字形以及图层的序列与属性,共同描述了动画的全部视觉信息与动态行为。

assets - 可复用资源库

assets 数组用于集中定义动画中可被多次引用的静态或动态资源。此设计实现了资源复用,有助于优化文件体积并保证引用的一致性。

资源类型 类型标识 (ty) 内容描述 引用字段 主要用途
图像资源 "img" 包含图像ID、尺寸及文件路径或Base64编码数据。 refId 静态位图元素,如图标、背景。
预合成资源 "precomp" 一个完整的子合成,包含独立的图层(layers)与时间轴。 refId 可重复使用的动画片段,如通用动效组件。

引用机制:图层通过其 refId 属性与 assets 中对应ID的资源进行关联。渲染时,引擎据此查找并绘制相应资源。

chars - 矢量字形定义

chars 数组存储了将文字转换为矢量形状后的数据。当动画需要对文字进行路径变形、描边动画等超越普通文本渲染能力的操作时,会使用此数组。

  • 产生条件:在After Effects中对文本图层执行“从文字创建形状”,或在导出时启用“字符形状”选项。
  • 数据结构:每个项定义了字符、字体信息及其矢量路径(data),该路径数据包含轮廓、描边与填充等属性。
  • 核心用途:实现对文字字符的精细化控制,用于制作字形变形、笔画动画等效果。

layers - 动画图层序列

layers 是一个有序数组,定义了所有图层的叠加顺序、基本属性及关键帧动画。它是组织动画渲染逻辑的主体。

  • 渲染顺序:数组索引从0开始,图层按索引升序从底层到顶层依次渲染。
  • 核心属性:每个图层对象包含类型(如形状、图像、文本)、空间变换属性、蒙版以及关键帧动画数据。
  • 关键作用assets 和 chars 中定义的资源需在此被引用,并通过图层的动画属性驱动,才能成为最终动画的一部分。

协作关系assets 和 chars 作为资源定义,为 layers 提供素材;layers 则负责组织这些素材,并通过其时间轴和属性控制动画的最终表现。一个完整的结构示例如下:

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "nm": "元数据演示动画",
  "ddd": 0,
  "assets": [],
  "layers": [],
  "chars": []
}

二、图层系统 - 理解 Layers 结构⭐️

layers 数组是 Lottie JSON 中承载所有动画图层信息的主体。它定义了图层的堆叠顺序、属性及关键帧动画,是构成动画视觉呈现与动态行为的核心数据结构。Lottie 主要包含以下六种基础图层类型:

类型标识 (ty) 图层类型 核心描述
0 预合成图层 (Precomp Layer) 嵌套合成层,通过 refId 引用 assets 中定义的预合成资源,实现动画复用与模块化管理。
1 纯色图层 (Solid Layer) 纯色填充层,定义固定颜色的矩形区域,常用作背景或遮罩。
2 图像图层 (Image Layer) 静态图片层,通过 refId 引用 assets 中定义的图像资源。
3 空对象图层 (Null Layer) 不可见辅助层,主要用于通过其 ks 属性(变换数据)驱动子图层动画,或作为动画控制器。
4 形状图层 (Shape Layer) 矢量图形层,由路径、描边、填充等属性构成,是制作变形、路径动画的基础。
5 文本图层 (Text Layer) 文字图层,可定义字体、颜色、段落样式,并支持逐字符动画。

所有图层共享一组基础属性,用于定义其在合成中的基本状态与时空关系。

以下属性为所有图层类型共有的基础属性:

属性 类型 必选 描述 示例值 默认值
ty number 图层类型标识,决定图层的行为和数据结构(见上表) 0-5 -
nm string / number 图层名称,在 After Effects 中设置,用于表达式和调试 "按钮背景", "Layer 1" -
ind number 图层索引,在整个合成中唯一,用于父子关联和表达式引用 1, 2, 3 -
ip number 入点 (In Point),图层开始显示的帧数 0, 30 -
op number 出点 (Out Point),图层结束显示的帧数 90, 180 -
st number 起始时间 (Start Time),图层的时间偏移量 0, 10 -
sr number 时间拉伸 (Stretch),控制图层播放速度的系数 1 (正常), 0.5 (减速), 2 (加速) 1
ks object 变换属性 (Transform),包含位置、缩放、旋转、透明度等关键帧数据 见下方详解 -
ao number 自动定向 (Auto-Orient),沿路径自动调整图层方向(布尔值:0/1) 0 (关闭), 1 (开启) 0
ddd number 3D 图层标识,标记该图层是否为 3D 图层(布尔值:0/1) 0 (2D), 1 (3D) 0
parent number 父图层索引,指向父图层的 ind 值,实现层级控制和联动变换 1, 5 -
cl string CSS 类名 (Class),在 SVG/HTML 渲染器中作为 HTML class "background", "icon" -
ln string HTML ID,在 SVG/HTML 渲染器中作为 HTML id 属性 "layer-bg", "main-icon" -

以上13个通用属性中,有 3个ipopnm)与根对象中的概念完全一致,仅作用域不同。1个ddd)与根对象概念相似但有区别:根对象的 ddd 表示合成“是否包含”3D元素,而图层的 ddd 表示其“本身是否为”3D图层。其余 9个tyindstsrksaoparentclln)则是图层专有的核心属性,它们共同定义了图层在时间与空间中的基本状态。

接下来,我们将首先深入解析其中最核心的 ks(变换属性)  对象,它是驱动所有图层运动与变化的关键。

三、变换属性 - 位置、旋转、缩放⭐️

ks 对象概览

ks(Transform)是所有图层的核心动画容器,它定义了图层在空间中的位置、大小、旋转、透明度等基础变换状态。其名称 ks 是 Keyable Styled Properties(可关键帧的样式化属性)  的缩写,这精准概括了其两大核心特性:

  • k(Keyable) :指这些属性可被设置为关键帧,是实现所有逐帧动画的基础。
  • s(Styled) :指这些属性可被动态驱动或赋予样式,支持通过数据或表达式进行复杂控制。

因此,ks 对象不仅描述图层的静态空间状态,更是承载其所有运动与变换动画的数据载体。所有基础动画(如移动、缩放、旋转、淡入淡出)都通过在此对象内定义关键帧来实现。

ks 的完整属性表

属性 全称 默认值 作用
a Anchor Point [0, 0, 0] 锚点:图层变换的基准中心点(支点)。
p Position [0, 0, 0] 位置:图层在画布中的坐标。
s Scale [100, 100, 100] 缩放:图层的尺寸比例(以百分比表示)。
r Rotation 0 旋转:图层的旋转角度(单位为度)。
o Opacity 100 不透明度:图层的透明度(100为完全不透明,0为完全透明)。
sk Skew 0 倾斜:图层的倾斜角度(单位为度)。
sa Skew Axis 0 倾斜轴:定义倾斜操作所沿的轴向角度(单位为度)。

 3D 图层特殊形式:当 ddd=1 时,p 可能被拆分为 px、py、pz 三个独立属性。

属性值的两种数据形态

每个变换属性的值均以统一的结构表示,分为 静态值 与 动画值 两种形态,通过 a 字段区分。

1. 静态值 (a: 0)

属性在整个时间轴上保持不变,结构简洁。

{
  "r": {
    "a": 0,        // 静态标识
    "k": 45        // 固定值:旋转45度
  }
}

2. 动画值 (a: 1)

属性值随时间变化,通过关键帧数组 (k) 定义。

{
  "r": {
    "a": 1,        // 动画标识
    "k": [         // 关键帧数组
      {
        "t": 0,    // 时间点(帧)
        "s": [0],  // 起始值
        "e": [360] // 结束值
      },
      {
        "t": 60,   // 下一关键帧时间点
        "s": [360] // 该帧的数值(结束时省略"e")
      }
    ]
  }
}

关键帧核心字段说明

  • t (time) :关键帧所处的时间点(帧序号)。
  • s (start value) :此关键帧的起始值。
  • e (end value) :朝向下一关键帧的目标值。最后一个关键帧可省略。
  • i / o:定义属性变化速率(缓动)的贝塞尔曲线控制点,将在后续章节详述。

此结构是 Lottie 实现所有基础运动(位移、缩放、旋转、淡入淡出)的通用数据范式。

🎨小试牛刀:动手验证核心概念

通过前面的学习,我们已经掌握了 Lottie JSON 的顶层结构和核心属性。现在,让我们通过一个具体的例子,动手验证如何配置一个基础的静态场景。

在本例中,我们将使用一张图片,创建一个静态的图片图层。您将在下图中看到(一个静态图,一个旋转图):

录屏2026-01-07 11.51.03 (1).gif

说明:上图的 GIF 展示了包含旋转动画的完整效果。而下面的 JSON 配置,我们将首先完成左侧静态图片的搭建。这能让我们专注于已学的静态属性配置a: 0)。右侧的旋转动画,将在我们学习了关键帧系统后,通过简单地修改 r(旋转)属性即可实现。

以下是完整的 Lottie JSON 配置,已附上详尽的注释:

{
  // ========== 顶层元数据:动画的全局定义 ==========
  "v": "5.7.4",              // Bodymovin 插件版本
  "fr": 30,                  // 帧率:30fps
  "ip": 0,                   // 起始帧:从第0帧开始
  "op": 90,                  // 结束帧:到第90帧结束(总时长 = (90-0)/30 = 3秒)
  "w": 800,                  // 画布宽度:800像素
  "h": 600,                  // 画布高度:600像素
  "nm": "变换属性演示",        // 合成名称
  "ddd": 0,                  // 3D标识:0表示不包含3D图层
  
  // ========== Assets:可复用资源库 ==========
  "assets": [
    {
      "id": "image_0",       // 资源ID,供图层通过 refId 引用
      "w": 500,              // 图片原始宽度
      "h": 500,              // 图片原始高度
      "u": "",               // 图片URL基础路径(空表示使用完整路径)
      "p": "https://img11.360buyimg.com/img/jfs/t1/383242/14/16667/276460/695dd5ccF565013e8/02761c81c82b92d4.png",  // 图片完整URL
      "e": 0                 // 是否嵌入:0表示外部链接
    }
  ],
  
  // ========== Layers:图层序列(从下到上渲染) ==========
  "layers": [
    // ---------- 图层1:静态图片(展示静态值 a:0) ----------
    {
      "ddd": 0,              // 该图层不是3D图层
      "ind": 1,              // 图层索引:1(唯一标识)
      "ty": 2,               // 图层类型:2 = Image Layer(图像图层)
      "nm": "静态图片",       // 图层名称
      "refId": "image_0",    // 引用 assets 中 id 为 "image_0" 的资源
      "sr": 1,               // 时间拉伸:1 = 正常速度
      
      // ===== ks:变换属性(Transform)=====
      "ks": {
        // ----- o:不透明度(Opacity)-----
        "o": {
          "a": 0,            // 静态值标识(a=0 表示不动画)
          "k": 100           // 固定值:100%不透明
        },
        
        // ----- r:旋转(Rotation)-----
        "r": {
          "a": 0,            // 静态值标识
          "k": 45            // 固定值:旋转45度
        },
        
        // ----- p:位置(Position)-----
        "p": {
          "a": 0,            // 静态值标识
          "k": [250, 300, 0] // 固定位置:[x, y, z] = 左侧250px,顶部300px
        },
        
        // ----- a:锚点(Anchor Point)-----
        "a": {
          "a": 0,            // 静态值标识
          "k": [250, 250, 0] // 锚点在图片中心(500x500图片的中心点)
        },
        
        // ----- s:缩放(Scale)-----
        "s": {
          "a": 0,            // 静态值标识
          "k": [30, 30, 100] // 缩放到30%(显示为150x150)
        }
      },
      
      "ao": 0,               // 自动定向:关闭
      "ip": 0,               // 图层入点:第0帧开始显示
      "op": 90,              // 图层出点:第90帧结束显示
      "st": 0,               // 起始时间偏移:0(无偏移)
      "bm": 0                // 混合模式:0 = Normal(正常)
    }
  ],
  
  // ========== Chars:矢量字形定义(本例未使用)==========
  "chars": []
}

这个示例是一个“知识检查点”,清晰地展示了:

  • 顶层元数据 (vfrwh...) 定义了动画的舞台(3秒、800x600的画布)。
  • 资源 (assets)  定义了唯一可用的图片素材,并通过 id 标识。
  • 图层 (layers)  通过 refId 引用该资源,将其实例化到舞台上。
  • ks 变换对象 决定了这个实例的最终状态:位于画布左侧,缩小至30%,并旋转了45度。所有属性均以静态值 (a: 0)  定义。

至此,您已经掌握了如何构建一个 Lottie 动画的静态骨架——定义舞台、准备素材、放置元素并设置其初始状态。在上方的预览图中,您也看到了动画的潜力:只需将 r(旋转)属性的 a 值从 0 改为 1,并配上关键帧数据,静态图片就能旋转起来。

那么,a: 1 模式下的 k 数组究竟如何定义?多个关键帧之间如何平滑过渡?动画的运动节奏又由什么控制?接下来,我们就将深入动画的核心,解析关键帧、插值与缓动的完整系统。


四、深入动画 - 关键帧、插值与缓动⭐️

在上一章的"小试牛刀"中,我们看到了静态图片与旋转图片的对比效果。右侧的旋转效果正是通过关键帧动画实现的。现在,让我们深入解析这个旋转动画的完整JSON配置,揭开Lottie动画系统的核心机制。

录屏2026-01-07 11.51.03 (1).gif

让我们对比一下 demo 中两个图层的配置,看看静态图片是如何"动"起来的:

静态图片(左侧):

"r": {
  "a": 0,            // 静态值标识
  "k": 45            // 固定值:旋转45度
}

旋转图片(右侧):

"r": {
  "a": 1,            // 动画值标识(a=1 表示有关键帧动画)
  "k": [             // 关键帧数组
    {
      "i": { "x": [0.667], "y": [1] },   // 入缓动
      "o": { "x": [0.333], "y": [0] },   // 出缓动
      "t": 0,                            // 时间点:第0帧
      "s": [0]                           // 起始值:0度
    },
    {
      "t": 90,                           // 时间点:第90帧
      "s": [360]                         // 结束值:360度
    }
  ]
}

核心差异总结:

特征 静态值 (a: 0) 动画值 (a: 1)
k 的类型 单个数值或数组 关键帧对象数组
时间轴 无时间概念,始终保持固定值 在时间轴上定义多个状态点
缓动控制 通过 io 控制运动节奏
适用场景 静态属性(如固定位置、固定角度) 需要随时间变化的属性(移动、旋转、缩放等)

关键帧数组结构解析

当属性设置为动画值(a: 1)时,k 字段不再是一个简单值,而是一个关键帧对象数组。每个关键帧对象定义了动画在特定时间点的状态。

核心字段说明

字段 类型 必选 描述
t number 时间点(Time):该关键帧在时间轴上的位置,单位为帧。
s array 起始值(Start Value):该关键帧的属性值。对于旋转是 [角度],位置是 [x, y, z]
e array 结束值(End Value):从当前关键帧到下一关键帧的目标值。通常省略,渲染器会自动使用下一关键帧的 s 值。
i object 入缓动(In Tangent):定义进入当前关键帧时的速度变化曲线(贝塞尔控制点)。
o object 出缓动(Out Tangent):定义离开当前关键帧时的速度变化曲线(贝塞尔控制点)。
h number 保持帧(Hold):值为 1 时,表示该关键帧到下一关键帧之间不插值,保持当前值(阶跃动画)。

说明

  1. s 的数据类型:虽然官方 schema 中定义为 number,但在实际使用中,s 通常是数组(如 [0] 表示旋转 0 度,[100, 200, 0] 表示位置)。这是为了统一处理单维和多维属性。
  2. e 字段的实际使用:在大多数情况下,e 字段会被省略,渲染器会自动从下一个关键帧的 s 值推断目标值。只有在需要显式控制插值目标时才会使用。
  3. 最后一帧:最后一个关键帧通常只有 ts,不需要 ioe(因为没有下一个关键帧)。

缓动控制点的结构

io 对象定义了贝塞尔曲线的控制点,用于控制动画的加速度:

{
  "i": {
    "x": [0.667],  // 入缓动的 X 轴控制点(时间维度,范围 0-1)
    "y": [1]       // 入缓动的 Y 轴控制点(数值维度,通常 0-1,但可超出)
  },
  "o": {
    "x": [0.333],  // 出缓动的 X 轴控制点
    "y": [0]       // 出缓动的 Y 轴控制点
  }
}
  • x 数组:控制时间进度的变化率(水平方向)
  • y 数组:控制数值变化的速率(垂直方向)
  • 数组长度:对于单维属性(如旋转 r),数组长度为 1;对于多维属性(如位置 p),数组长度对应维度数(如 [0.5, 0.3] 表示 X、Y 两个维度的独立控制)

前端知识关联i 与 o 定义的贝塞尔曲线,其作用与 CSS 中的 transition-timing-function 或 animation-timing-function 完全一致,用于创造非匀速的动画效果。区别在于,CSS 使用一个二维的 cubic-bezier(x1, y1, x2, y2),而 Lottie 的 i 和 o 允许为每个属性维度单独定义控制点,控制更为精细。

工具推荐:如果您需要直观地创建或理解贝塞尔曲线,强烈推荐使用在线工具 cubic-bezier.com。您可以在那里调整曲线并获取对应的 cubic-bezier() 值,其原理与 Lottie 的 i/o 控制点相通,是理解和调试动画缓动的绝佳助手。

关键帧的工作流程

以 demo 中的旋转动画为例,让我们逐步拆解渲染器如何处理关键帧:

第 1 步:解析关键帧数组

"k": [
  {
    "t": 0,        // 第0帧
    "s": [0],      // 旋转角度 0°
    "i": { "x": [0.667], "y": [1] },
    "o": { "x": [0.333], "y": [0] }
  },
  {
    "t": 90,       // 第90帧
    "s": [360]     // 旋转角度 360°
  }
]

渲染器识别出:

  • 动画从第 0 帧开始,到第 90 帧结束
  • 起始角度 0°,结束角度 360°
  • 需要在这 90 帧之间进行插值计算

第 2 步:插值计算(以第 45 帧为例)

当播放到第 45 帧时,渲染器需要计算此时的旋转角度:

  1. 计算时间进度progress = (45 - 0) / (90 - 0) = 0.5(已完成 50%)
  2. 应用缓动函数:根据 io 的贝塞尔控制点,将线性进度 0.5 转换为缓动后的进度(假设为 0.55
  3. 计算属性值angle = 0 + (360 - 0) × 0.55 = 198°

第 3 步:渲染当前帧

渲染器将计算出的 198° 应用到图层的旋转属性,完成该帧的绘制。

关键帧的连续性

在多个关键帧的场景中,渲染器会:

  • 定位当前区间:找到当前时间点所在的关键帧区间(如第 30 帧位于第 0 帧和第 90 帧之间)
  • 使用对应缓动:应用该区间起始关键帧的 o(出缓动)和结束关键帧的 i(入缓动)
  • 独立插值:对于多维属性(如位置 [x, y, z]),每个维度独立进行插值计算

本章小结

至此,我们已经掌握了 Lottie 动画系统的“动力源”:关键帧、插值与缓动

  • 静态与动画的开关:通过 a 字段 (0 或 1) 切换属性的静态与动态模式,是理解 Lottie 动画逻辑的第一课。
  • 关键帧定义状态k 数组中的每个关键帧对象,通过 t (时间) 和 s (数值) 在时间轴上锚定了动画的各个“关键时刻”。
  • 缓动赋予灵魂i (入缓动) 与 o (出缓动) 所定义的贝塞尔曲线,控制了数值变化的速率与节奏,是让动画摆脱机械感、获得生命力的关键。
  • 渲染器执行插值:在关键帧之间,Lottie 渲染器会依据缓动曲线,为每一帧实时计算出精确的属性值,从而创造出平滑的动画效果。

您已经了解了从定义到渲染的完整链条。关于贝塞尔曲线的数学原理多维属性的独立插值策略以及更复杂的表达式动画,都属于更深入的话题。掌握了本章的核心数据模型,您已经具备了自行解析绝大多数 Lottie 动画、并理解其运动逻辑的能力。


五、形状系统 - 矢量图形

在 Lottie 的六种基础图层中,形状图层(Shape Layer,ty: 4  占据着独特而核心的地位。其他图层类型——如空对象图层(用于控制)、预合成图层(用于嵌套)、图像图层(静态位图)和文本图层(字形动画)——虽然在特定场景下不可或缺,但它们在数据结构和动画能力上相对简单。

形状图层(ty: 4)是 Lottie 实现复杂矢量动画的核心。与其他图层类型相比,它拥有最丰富的专有属性和最强的动画表现力。

形状图层的结构:ty: 4 与 shapes 数组

当一个图层的 ty 值为 4 时,它就是一个形状图层。其核心数据容器是 shapes 数组(在早期的 Lottie 版本中可能标记为 it)。该数组是一个有序的列表,定义了构成最终矢量图形的所有基础元素,例如路径、描边、填充等。

  • 有序渲染与叠加shapes 数组中的元素严格遵循数组索引顺序(从 0 到 n-1)进行渲染。在视觉上,这意味着索引值更大的元素(后渲染)会叠加在索引值更小的元素(先渲染)之上。例如,shapes[2] 会覆盖在 shapes[0] 和 shapes[1] 之上。这一规则是组织复杂图形层级的基础。
  • 元素类型:每个元素都是一个独立的对象,并通过 ty 字段来声明自己的类型(如 gr 表示组,sh 表示路径等)。

四种基础形状类型速览

shapes 数组中可以包含多种图形元素,其中基础形状主要有以下四种:

类型标识 (ty) 名称 描述 核心动画属性
rc 矩形 定义矩形或圆角矩形。 p (位置), s (尺寸), r (圆角半径)
el 椭圆 定义圆形或椭圆形。 p (中心点), s (半径/尺寸)
sr 星形/多边形 定义星形或多边形,可控制角数、内外径等。 p (中心点), ir/or (内/外半径), pt (角数)
sh 自由路径 由贝塞尔曲线构成的任意形状路径,是矢量图形的基础。 ks (路径数据,包含顶点与贝塞尔控制点)

说明rcelsr 本质上是参数化形状,它们会在导出时被转换为最终的 sh(路径)数据。但在 JSON 中,它们作为逻辑元素存在,便于理解和编辑。

自由路径的核心:顶点与贝塞尔控制点

自由路径(ty: ‘sh‘)是最基础、最灵活的形状元素。其核心数据存储在 ks 属性中,它定义了一系列顶点(Vertex)  以及连接这些顶点的贝塞尔曲线

一个路径的关键帧数据通常包含以下字段:

{
  “a”: 1, // 表示路径数据是动画的
  “k”: {
    “i”: [[0, 0]], // 入控制点 (In Tangent)
    “o”: [[0, 0]], // 出控制点 (Out Tangent)
    “v”: [[50, 50]], // 顶点 (Vertex)
    “c”: true // 路径是否闭合 (Closed)
  }
}
  • v (顶点) :一个二维数组,定义了路径在空间中经过的关键点坐标 [x, y]
  • i (入控制点) :定义曲线进入当前顶点时的方向与张力。
  • o (出控制点) :定义曲线离开当前顶点时的方向与张力。
  • c (闭合) :布尔值,true 表示路径的首尾顶点应连接,形成封闭图形。

工作原理v 定义了“骨架”,i 和 o 则定义了连接骨架的“肌肉”曲线。通过为这些数据添加关键帧,即可实现路径的形变、绘制等复杂动画。

知识关联:两种贝塞尔曲线
您可能已经发现,这里的 i 和 o 与第四章关键帧缓动中的 i 和 o 字段同名,且都代表贝塞尔曲线的控制点。这是 Lottie 中贝塞尔曲线的两种核心应用:

维度 空间贝塞尔曲线 (本章) 时间贝塞尔曲线 (第四章)
作用 定义空间中的图形形状 定义时间上的属性变化速率(缓动)。
控制点 (i/o) 控制顶点处曲线的方向与曲率,决定路径形态。 控制关键帧处动画速度的快慢,决定运动节奏。
坐标空间 位于画布的二维/三维空间 (如 [x, y])。 位于时间-进度二维空间,X轴是时间(0-1),Y轴是进度(0-1)。
直观感受 拖拽控制柄,改变的是线的弯曲程度 拖拽控制柄,改变的是动画的先快后慢

底层一致性:尽管应用不同,但两者都基于三次贝塞尔曲线的数学模型。理解这一点后,无论是调整路径平滑度还是动画缓动,您操作的都是同一种“控制点”逻辑。这也解释了为何可视化工具 cubic-bezier.com 对理解两者都有帮助:虽然它主要用于缓动(时间),但其对曲线形态的直观展示,同样有助于您想象空间路径中控制点对形状的影响。

形状组(gr):嵌套与组织

为了管理复杂的图形,Lottie 引入了 形状组(ty: ‘gr‘ 。组可以将多个形状元素(包括其他组)打包为一个逻辑整体。

  • it 数组:组的核心属性,是一个数组,用于包含其子元素(如路径、填充、描边或其他组)。

  • 作用

    1. 层次化管理:像文件夹一样组织图形,使结构清晰。
    2. 统一变换:组可以拥有自己的 ks(变换)属性。对该组应用的变换(如移动、缩放)会同时影响其内部所有子元素。
    3. 动画复用:通过控制组的变换,可以轻松实现整个图形模块的动画。

六、形状样式 - 填充与描边

在上一章中,我们探索了形状图层如何通过路径、椭圆、矩形等元素定义图形的几何骨架。然而,只有几何形状是“不可见”的。要让图形真正被渲染出来,就需要为其赋予样式

本章将介绍形状系统中负责视觉呈现的两大核心样式元素:填充(Fill)  与描边(Stroke) 。它们为形状的内部轮廓提供颜色、渐变和不透明度等视觉效果,是矢量图形从“线框”变为“画面”的关键。

6.1 样式的作用:让形状可见

在 shapes 数组中,样式元素(如 flst)与形状元素(如 shel)地位平等,通过渲染顺序相互结合。

  • 堆叠规则it 数组中的元素按索引顺序依次绘制。这意味着索引更大的元素(后绘制的)会叠加在索引更小的元素(先绘制的)之上。在图形构建中,通常先定义"形状"元素(如 elsh),再定义为其着色的"样式"元素(如 flst)。
  • 独立性与组合性:样式元素是独立的,可以自由组合。一个形状可以同时拥有填充和描边,也可以只有其中一种。它们共同附着于其上方最近且未闭合的图形元素或组。

填充(Fill):纯色与渐变

填充用于为形状的内部区域着色。Lottie 支持两种填充类型。

纯色填充(ty: 'fl')

这是最基础的填充类型,使用单一颜色。

{
  "ty": "fl", // 类型:Fill (纯色填充)
  "c": {      // Color (颜色)
    "a": 0,
    "k": [0.2, 0.6, 1, 1] // RGBA 数组,值范围 0-1
  },
  "o": { ... } // Opacity (不透明度)
}

渐变填充(ty: 'gf')

Lottie 支持线性渐变和径向渐变,为填充带来丰富的色彩过渡。

{
  "ty": "gf", // 类型:Gradient Fill (渐变填充)
  "t": 1,     // 渐变类型:1-线性,2-径向
  "g": {
      "p": 2,
      "k": {
        "a": 0,
        "k": [0, 0.2, 0.6, 1, 1, 1, 0, 1, 0, 1]
      }
    }
  "s": { ... }, // 起点 (线性) / 起始点 (径向)
  "e": { ... }  // 终点 (线性) / 结束点 (径向)
}
  • g 对象:是渐变的核心。p 定义色标数量,k 是一个扁平的数组,每 5 个数字为一组,表示一个色标的 [位置, R, G, B, A]。所有色标数据按顺序连接。

描边(Stroke):轮廓与样式

描边用于绘制形状的轮廓线。它拥有比填充更丰富的属性来控制线条的视觉表现。

纯色描边(ty: 'st')

{
  "ty": "st", // 类型:Stroke (描边)
  "c": { ... }, // 颜色,同填充
  "w": {      // Width (线宽)
    "a": 0,
    "k": 5    // 线宽为5像素
  },
  "lc": 2,    // Line Cap (线帽): 1-平头, 2-圆头, 3-方头
  "lj": 2,    // Line Join (连接): 1-斜接, 2-圆角, 3-斜面
  "ml": 10,   // Miter Limit (斜接限制)
  "o": { ... } // 不透明度
}
  • w (Width) :描边的粗细,支持动画。
  • lc (Line Cap) :定义线段端点的样式。
  • lj (Line Join) :定义线段转折处的连接样式。
  • ml (Miter Limit) :当 lj 为 1 (斜接) 时,控制斜接长度与线宽的比例上限,防止尖角过长。

渐变描边(ty: 'gs')

渐变描边的数据结构与渐变填充 (gf) 高度相似,包含 t (类型)、g (渐变数据) 等属性,区别在于它应用于轮廓线而非填充区域。

样式的通用属性

填充和描边共享一些控制其最终呈现效果的通用属性:

属性 类型 描述
o object 不透明度 (Opacity) 。通过 a 和 k 控制,值范围为 0 到 100(100 为完全不透明)。这与 CSS 中 0-1 的范围不同,请注意区分。
bm number 混合模式 (Blend Mode) 。定义当前样式如何与下方已有的像素进行混合。常见值:0 (正常)、1 (相乘)、2 (屏幕) 等,对应 After Effects 中的混合模式。

🎨小试牛刀:画个圆

现在,让我们综合运用以上概念,创建一个最简单的形状:一个蓝色的实心圆。我们将通过 JSON 配置,清晰地展示形状图层的 shapes 数组是如何组织起来的。

image.png

以下是实现该圆形的精简版 JSON 配置,我们省略了前面章节已详细讲解的通用图层属性(如 ks 变换),将焦点完全放在形状图层特有的 shapes 数组上:

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "layers": [
    {
      "ty": 4, // ⭐️ 核心标识:这是一个形状图层
      "nm": "带描边圆形",
      "ind": 1,
      // 🔽 此处省略了图层的 `ks` 变换属性(如 p, a, s, r, o)
      // 在完整文件中,它们用于将整个图层定位在画布中心,例如:
      // "ks": { "p": { "a": 0, "k": [400, 300, 0] }, ... }
      
      "shapes": [ // ⭐️ 本章核心:形状与样式数组
        {
          "ty": "gr", // 类型:gr (Group),一个形状组
          "nm": "圆形组",
          "it": [ // 组内元素列表,按索引顺序 0→1→2→3 渲染
            // 1. 形状定义:椭圆 (el) - 先绘制,定义几何轮廓
            {
              "ty": "el",
              "nm": "椭圆路径",
              "p": { "a": 0, "k": [0, 0] }, // 位置:相对于组的中心
              "s": { "a": 0, "k": [100, 100] } // 尺寸:宽高100px,即圆形
            },
            // 2. 样式定义:填充 (fl) - 其次绘制,为形状内部着色
            {
              "ty": "fl",
              "nm": "蓝色填充",
              "c": { 
                "a": 0, 
                "k": [0.2, 0.6, 1, 1] // 颜色:RGBA,此为蓝色
              },
              "o": { "a": 0, "k": 100 } // 不透明度:100%
            },
            // 3. 样式定义:描边 (st) - 最后绘制,为形状轮廓添加边线
            {
              "ty": "st",
              "nm": "白色描边",
              "c": { 
                "a": 0, 
                "k": [1, 1, 1, 1] // 颜色:白色
              },
              "w": { "a": 0, "k": 5 }, // 线宽:5像素
              "lc": 2, // 线帽:2 = 圆头
              "lj": 2  // 连接:2 = 圆角
            },
            // 4. 必需的组变换 (tr) - 必须放在最后,控制整个组的变换
            {
              "ty": "tr",
              "nm": "组变换"
              // 其内部属性 (p, a, s, r, o) 通常保持默认值 [0, 0, 100, 0, 100]
            }
          ]
        }
      ]
    }
  ]
}

七、形状修改器 - Trim、Repeater

在掌握了形状的构建与样式之后,我们迎来了形状系统的最后一块拼图:修改器(Modifiers) 。它们不是独立形状,而是附加在现有形状或组之上的“效果处理器”,能够以非破坏性的方式动态改变图形的外观或行为,是实现复杂、程式化动画的关键。

修改器的概念:作用范围与顺序

修改器是一种特殊类型的元素,其 ty 值定义在 shapes 或 it 数组中。它们不直接渲染,而是像一个处理器,作用于排列在它之前的特定元素

  • 作用逻辑:在 it 数组中,修改器按照从前往后的顺序执行,每个修改器会作用于它之前已定义的特定元素。例如,Trim Path 裁剪其之前的路径,Repeater 重复其之前的整个形状组合。
  • 顺序关键:修改器的位置决定了其作用范围和最终效果。例如,[形状 → 样式 → Trim] 会裁剪已着色的形状;而 [形状 → Trim → 样式] 会先裁剪路径,再为裁剪后的部分着色。

Trim Path(tm):路径裁剪动画

Trim Path(路径裁剪)是最常用的修改器之一,它通过控制路径的“起止点”来创造笔触绘制、擦除、扫描等动画效果。

核心属性:

{
  "ty": "tm", // 类型:Trim Paths
  "s": { // Start(起点百分比)
    "a": 1,
    "k": [{ "t":0, "s":[0] }, { "t":90, "s":[100] }] // 从0%到100%
  },
  "e": { // End(终点百分比)
    "a": 1,
    "k": [{ "t":0, "s":[100] }, { "t":90, "s":[100] }] // 保持100%
  },
  "o": { // Offset(偏移)
    "a": 0,
    "k": 0 // 范围0-360°,整体偏移裁剪区域
  }
}
  • s (Start) :路径可见部分的起点,范围 0-100%。动画化此属性可实现“生长”动画。
  • e (End) :路径可见部分的终点,范围 0-100%。通常 e >= s
  • o (Offset)裁剪区域的整体偏移量,范围 0-360°。它可以让裁剪的起止点沿路径循环移动,常用于创建“追逐”或“循环扫描”效果。

典型应用:通过动画 s 和 e 属性,可以实现经典的“笔画书写”或“进度条填充”效果。

Repeater(rp):重复器

Repeater(重复器)能将它之前的所有图形元素(包括形状、样式甚至其他修改器)复制多次,并对每个副本应用递增的变换,快速创建阵列、放射状、循环等复杂图案。

核心属性:

{
  "ty": "rp", // 类型:Repeater
  "c": { // Copies(副本数量)
    "a": 0,
    "k": 5 // 生成5个副本(包含原始图形)
  },
  "o": { // Offset(副本索引偏移)
    "a": 0,
    "k": 0 // 控制从哪个“虚拟副本”开始渲染,可用于动画
  },
  "m": 1, // Composite(合成模式):1=Above(后续副本在上方),2=Below(后续副本在下方)
  "tr": { // Transform(每个副本的增量变换)
    "p": { "a":0, "k":[20, 0] }, // 位置增量:每个副本右移20px
    "s": { "a":0, "k":[90, 90] }, // 缩放增量:每个副本缩小至90%
    "r": { "a":0, "k":[30] } // 旋转增量:每个副本旋转30度
  }
}
  • c (Copies) :生成的副本总数(包含原始图形)。
  • o (Offset) :控制从哪个“虚拟副本”开始渲染,可用于动画。
  • m (Composite) :控制副本的堆叠顺序。1 表示新副本叠在上方,2 表示新副本叠在下方。
  • tr (Transform) :定义每个新副本相对于前一个副本的变换增量,是创造规律性变化的关键。

其他修改器简介

除了上述两个,Lottie 还提供了其他实用的修改器来扩展图形能力:

类型标识 (ty) 名称 核心作用 关键属性与备注
rd Round Corners (圆角) 将路径的所有尖角转换为指定半径的圆角。 r:圆角半径。
mm Merge Paths (合并路径) 将多个路径合并为一个(类似布尔运算)。 mm:合并模式(如相加、相减、交集等)。 ⚠️ 注意:官方文档标注此功能目前不被支持,使用时需谨慎测试。

🎨小试牛刀:loading动画

让我们将学到的 Trim Path 知识应用到实践中,制作一个经典的 loading 动画。下面的示例将展示如何通过动画 o(偏移)属性来创建持续旋转的圆环进度效果。

录屏2026-01-08 17.48.53.gif

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "nm": "Trim Path演示",
  "layers": [
    {
      "ty": 4,
      "nm": "圆形绘制动画",
      "ind": 1,
      "ks": {
        "p": { "a": 0, "k": [400, 300, 0] }
      },
      "shapes": [
        {
          "ty": "gr",
          "nm": "圆形组",
          "it": [
            // 1. 圆形路径
            {
              "ty": "el",
              "nm": "圆形路径",
              "p": { "a": 0, "k": [0, 0] },
              "s": { "a": 0, "k": [200, 200] }
            },
            // 2. 描边样式
            {
              "ty": "st",
              "nm": "描边",
              "c": { "a": 0, "k": [0.2, 0.8, 1, 1] },
              "w": { "a": 0, "k": 12 },
              "lc": 2,
              "lj": 2
            },
            // 3. Trim Path 修改器 ⭐️
            {
              "ty": "tm",
              "nm": "Trim Path",
              "s": {  // 起点百分比 (0-100)
                "a": 1,
                "k": [
                  { "t": 0, "s": [0], "e": [100] },
                  { "t": 90, "s": [100] }
                ]
              },
              "e": {  // 终点百分比 (0-100)
                "a": 0,
                "k": 100
              },
              "o": {  // 偏移角度 (0-360)
                "a": 0,
                "k": 0
              }
            },
            // 4. 组变换
            { "ty": "tr" }
          ]
        }
      ]
    }
  ]
}

八、高级特性 - 蒙版、效果、表达式

本章将简要介绍 Lottie 中几个高级但常用的特性。理解这些概念有助于您阅读和分析更复杂的动画文件,但在实际创作中,请注意它们在不同平台和渲染器中的支持程度可能有所差异。

蒙版(Mask):masksProperties 数组

蒙版用于控制图层的显示区域,实现剪切、遮罩等效果。在图层对象中,通过 masksProperties 数组定义。

json

"masksProperties": [
  {
    "mode": "a",           // 蒙版模式:a=相加,s=相减,i=相交等
    "pt": {                // 路径(Path),定义蒙版形状
      "a": 0,
      "k": { "v": [[0,0], [100,0], [100,100], [0,100]], "c": true }
    },
    "o": { "a": 0, "k": 100 },  // 不透明度(Opacity)
    "inv": false,                // 是否反转(Inverted)
    "nm": "蒙版1"                // 名称(Name)
  }
]

关键属性:

  • mode:蒙版混合模式。除了常用的 "a"(Add,相加)和 "s"(Subtract,相减),Lottie Schema 还定义了其他模式,但并非所有都被完全支持。

    模式代码 名称 (英文) 作用效果
    "n" None (无) 禁用蒙版
    "a" Add (相加) 合并多个蒙版区域
    "s" Subtract (相减) 从现有区域中减去
    "i" Intersect (相交) 只保留蒙版重叠区域
    "l" Lighten (变亮) 保留较亮区域
    "d" Darken (变暗) 保留较暗区域
    "f" Difference (差异) 显示颜色差异区域
  • pt:蒙版路径,其数据结构(含 vio)与形状图层中的自由路径(sh)完全相同。

  • o:蒙版的不透明度。

  • inv:布尔值,为 true 时反转蒙版区域。

效果(Effects):ef 数组

Lottie 支持部分 After Effects 内置效果,通过图层的 ef 数组定义。请注意,支持的效果非常有限,且并非所有AE效果都能被完美支持或渲染。

根据官方 Schema 文档,有明确定义的效果类型包括:

  • 填充 (Fill): ty: 21
  • 描边 (Stroke): ty: 22
  • 色调 (Tint): ty: 20
  • 三色调/专业色阶 (Tritone/Pro Levels): ty: 23

json

"ef": [{
  "ty": 21,                 // 效果类型:21 = 填充 (Fill)
  "nm": "颜色叠加",
  "en": 1,                  // 启用 (Enabled)
  "ef": [{
    "ty": 10, 
    "nm": "颜色", 
    "v": { "a": 0, "k": [1, 0, 0, 1] } // 红色
  }]
}]

重要说明:“高斯模糊 (ty: 29)”和“发光 (ty: 27)”等效果在公开的官方 Schema 文档中未有明确定义。它们可能由 Bodymovin 插件导出,但不一定被所有 Lottie 渲染器支持,在实际使用前必须进行充分的兼容性测试。

表达式(Expression):x 字段

表达式是 After Effects 中用于创建属性间动态关联的脚本语言。在 Lottie 中,表达式可以存储在属性的 x 字段中。

json

"p": {
  "a": 1,
  "k": [{"t":0, "s":[0,0]}, {"t":30, "s":[100,100]}],
  "x": "loopOut('cycle')"  // 表达式:循环播放动画
}

表达式的作用与局限性

  • 作用:可以创建循环 (loopOut)、随机 (wiggle)、数学关联等复杂动画逻辑,无需大量关键帧。
  • 局限性平台支持极不完整。仅有少数最基础的表达式可能在部分平台上被识别,复杂的表达式通常会被忽略或导致动画错误。在需要跨平台稳定播放的动画中,应尽量避免使用表达式。

混合模式(Blend Mode):bm 字段

混合模式控制当前图层如何与下层图层进行颜色混合。在图层(根对象)或形状样式元素(如 flst)中通过 bm 字段定义。

json

"bm": 3  // 叠加模式 (Overlay)

混合模式值速查表 (根据官方 /helpers/blendMode.json):

模式 (英文) 模式 (英文)
0 Normal (正常) 8 Hard Light (强光)
1 Multiply (相乘) 9 Soft Light (柔光)
2 Screen (屏幕) 10 Difference (差值)
3 Overlay (叠加) 11 Exclusion (排除)
4 Darken (变暗) 12 Hue (色相)
5 Lighten (变亮) 13 Saturation (饱和度)
6 Color Dodge (颜色减淡) 14 Color (颜色)
7 Color Burn (颜色加深) 15 Luminosity (明度)

注意:混合模式在 SVG/HTML 渲染器中支持较好,在其他渲染器(如 Canvas)或某些移动端平台中可能需要降级处理或不被支持。


九、结语

本文系统性地解析了 Lottie JSON 的核心数据结构。以下是全文知识要点的回顾与总结:

核心数据结构总览

模块 关键对象/字段 核心作用与要点
顶层结构 vfripopwh 定义动画全局信息:版本、帧率、时间轴、画布尺寸。
assetslayerschars 三大数据支柱:可复用资源、图层序列、矢量字形。
图层系统 ty (0-5) 标识六种图层类型:预合成、纯色、图像、空对象、形状、文本。
indparentipopst 控制图层索引、父子关系、时间属性(入点、出点、起始时间)。
ks 变换属性容器,包含锚点(a)、位置(p)、缩放(s)、旋转(r)、透明度(o)等。
动画系统 ks 下的 a 字段 属性动画开关:0为静态值,1为动画值(关键帧数组)。
关键帧 k 数组 定义动画轨迹,包含时间(t)、值(s/e)、缓动(i/o)。
形状系统 shapes 数组 形状图层的核心容器,元素按索引顺序渲染叠加。
ty: el/rc/sr/sh 基础图形:椭圆、矩形、星形、自由路径(贝塞尔曲线定义)。
ty: gr (组) 使用 it 数组组织子元素,实现层级管理与统一变换。
样式系统 ty: fl (填充) 定义形状填充色(纯色或渐变)。
ty: st (描边) 定义轮廓线样式,包括线宽(w)、端点(lc)、连接(lj)。
修改器 ty: tm (Trim) 路径裁剪,通过动画起点(s)、终点(e)、偏移(o)实现绘制效果。
ty: rp (Repeater) 图形重复器,通过副本数(c)和增量变换(tr)创建阵列。
高级特性 masksProperties 蒙版数组,通过路径(pt)和模式(mode)控制图层显示区域。
ef (效果), bm (混合模式) 实现滤镜与图层混合,需注意平台支持度
x (表达式) 支持简单表达式驱动属性,跨平台支持有限

十、写在最后:笔者的思考💡

在整理 Lottie 的技术细节时,我反复思考一个核心问题:我们看到的这套 JSON 结构,到底是由什么决定的?

最直接的答案是“为了在网页上播放”。但这只是目的,并未解释其形态。我的思路分两步推进:第一,是 Web 的渲染能力(如 Canvas)限制了它的设计;第二,是否有更底层的蓝本在主导结构。

通过逐项对比,我找到了更关键的依据。Lottie JSON 的结构,本质上是对 After Effects 内部动画数据模型的直接翻译。  例如,JSON 中的 ks 对象精确对应了 AE 图层的“变换”属性组,shapes 数组则完全复现了 AE 形状层的堆叠逻辑。设计者的首要任务,是为 AE 的动画状态提供一个无损且精确的数据描述格式

那么,Web 技术(如 Canvas/SVG)的作用是什么?我认为它主要扮演了  “支持度评估”与“性能优化”的角色。它并未改变数据描述的根本方式,而是基于实现难度与性能成本,划定了哪些 AE 高级功能可以(或不可以)被包含在这个格式中。例如,一些复杂的实时滤镜可能因性能考量而被排除。

这自然引向更深一层:AE 自身的这套强大模型又是如何建立的?  它并非凭空创造,而是对更早行业的数字化融合。其“合成”与“图层”概念源自电影工业的胶片叠加流程;“关键帧”动画继承自传统手绘动画的生产方式;而所有视觉变换的根基,则是计算机图形学提供的数学工具(如坐标变换、贝塞尔曲线)。

因此,学习 Lottie 最有效的方法,并非孤立记忆 JSON 字段,而是理解它作为“AE 模型的数据接口”这一定位。掌握 AE 的核心概念,就能理解 Lottie 绝大部分的设计逻辑。这揭示了一种高效的学习路径:当面对一个出色的“技术转译层”时,直接研究它所转译的源系统,往往是理解其设计最快的方式。

那个写 width: 33.33% 的前端,终于被 flex: 1 拯救了

作者 NEXT06
2026年1月14日 12:30

告别百分比计算:从文档流到 Flex 弹性布局的进化之路

在 CSS 的世界里,布局方式的演进就像是一场对“控制权”的争夺战。从最初顺其自然的文档流,到精打细算的 inline-block,再到如今游刃有余的 Flexbox,我们的代码变得越来越优雅。

一、 随波逐流:HTML 文档流

一切布局的起点,都是文档流(Document Flow)

HTML 元素默认就像水流一样:

  • 块级元素 (display: block) :如 div,霸道地独占一行,从上到下垂直排列。适合做容器,但无法并排。
  • 行内元素 (display: inline) :如 span,顺从地从左到右排列,但它有个致命弱点——无法设置宽高,这让它不适合做布局容器。

二、 进阶的烦恼:Inline-block 的爱与恨

为了让元素既能并排(像 inline),又能设置宽高(像 block),开发者们曾大量使用 display: inline-block。

CSS

.item {
    display: inline-block;
    width: 33.33%; /* 经典的百分比计算 */
}

这种方案看似完美,实则暗藏玄机。

它的痛点在于:

  1. 计算繁琐:通过百分比(33.33%)凑成一行,永远无法达到真正的 100% 精确。
  2. 幽灵空白节点:HTML 代码中的换行符会被浏览器解析为空格,导致原本计算好的布局莫名其妙换行。

三、 降维打击:Flex 弹性布局

为了解决上述痛点,CSS3 为我们带来了弹性布局(Flexbox) 。它不再关注具体的百分比,而是关注**“剩余空间”的分配**。

1. 开启上帝视角

只需在父容器上声明一个属性,即可接管子元素的布局规则:

CSS

.box {
    display: flex; /* 开启弹性布局 */
    /* 子元素默认变成“弹性项目”,且默认水平排列 */
}

2. 核心魔法:flex: 1

在提供的代码中,我们看到了这样一行关键代码:

CSS

.item {
    flex: 1; /* 核心代码 */
    background-color: green;
}

flex: 1 到底做了什么?

它相当于告诉浏览器:“不要管我原本有多宽,把父容器剩下的空间平均分给我。”

  • 如果有 3 个 .item,每个盒子自动获得 1/3 的宽度。
  • 如果有 4 个 .item,每个自动获得 1/4 的宽度。

对比优势:

  • 无需计算:不需要手写 33.33% 或 25%。
  • 自动填充:无论增加还是减少子元素,布局自动填满整行,不会有缝隙,也不会溢出。

四、 总结

从 inline-block 到 flex,不仅仅是属性的变化,更是布局思维的转变。

  • 传统布局:我们需要做算术题,小心翼翼地计算像素和百分比。
  • 弹性布局:我们将控制权交给浏览器,声明“分配规则”(如 flex: 1),让布局自动适应容器。

前端开发就是这样,用最少的代码,实现最灵活的效果。下次布局时,记得给容器加一个 display: flex。

前端即导演:用纯 CSS3 原力复刻《星球大战》经典开场

作者 NEXT06
2026年1月14日 12:12

🌌 致敬经典:用纯 CSS3 导演一场“星球大战”开场秀

“前端是代码界的导演。”

我们不需要摄像机,只需要 HTML 构建骨架,CSS 渲染光影。今天,我们就用几行 CSS3 代码,复刻经典的《星球大战》开场 3D 特效。

Video Project.gif

🎬 剧本规划(HTML 结构)

为了还原电影海报的经典站位,我们将结构分为三层:顶部的 "STAR",底部的 "WARS",以及中间那一排神秘的副标题。

codeHtml

<div class="starwars">
    <img src="./star.svg" alt="star" class="star">
    <img src="./wars.svg" alt="wars" class="wars">
    <h2 class="byline">
        <!-- 每个字母单独包裹,为了后续的翻转动画 -->
        <span>T</span><span>h</span><span>e</span>...
    </h2>
</div>

这里有一个细节:副标题 h2 中的每个字母都用 span 包裹,这是为了让每个字母能独立进行 3D 旋转表演。

🎥 搭建舞台(核心 CSS)

1. 完美的绝对居中

在全屏黑背景下,我们需要让 logo 稳稳地悬浮在宇宙中心。这里使用了经典的“绝对定位 + Transform”大法:

CSS

.starwars {
    width: 34em;
    height: 17em;
    position: absolute;
    top: 50%;
    left: 50%;
    /* 自身宽高的一半向回移动,实现精准居中 */
    transform: translate(-50%, -50%);
}

2. 开启上帝视角(3D 景深)

这是本案例的灵魂所在。普通的平面动画无法表现星战字幕“飞向深空”的震撼。我们需要在父容器上开启 3D 空间:

CSS

.starwars {
    /* 视距:模拟人眼距离屏幕 800px 的位置 */
    perspective: 800px;
    /* 保持子元素的 3D 空间关系 */
    transform-style: preserve-3d;
}
  • perspective: 决定了“近大远小”的程度,数值越小,透视感越强烈。
  • transform-style: preserve-3d: 确保子元素在 3D 空间中变换,而不是被压扁在 2D 平面里。

🎞️ 动作设计(关键帧动画)

Step 1: 巨物消逝(Logo 动画)

STAR 和 WARS 两张图片需要经历:透明 -> 出现 -> 缩小复位 -> 飞向深渊 的过程。

我们利用 translateZ 来控制 Z 轴距离,负值越大,离我们越远。

CSS

@keyframes star {
  0% {
    opacity: 0;
    transform: scale(1.5) translateY(-0.75em); /* 初始放大且位置靠上 */
  }
  20% { opacity: 1; } /* 显形 */
  89% {
    opacity: 1;
    transform: scale(1); /* 恢复正常大小 */
  }
  100% {
    opacity: 0;
    transform: translateZ(-1000em); /* 瞬间飞向宇宙深处! */
  }
}

Step 2: 文字起舞(副标题动画)

中间的 The Force Awake 需要有一种“翻转浮现”的神秘感。

注意:span 默认是行内元素,无法应用 Transform,所以必须设置为 display: inline-block。

CSS

.byline span {
  display: inline-block;
  animation: spin-letters 10s linear infinite;
}

@keyframes spin-letters {
  0%, 100% {
    opacity: 0;
    transform: rotateY(90deg); /* 侧身 90 度,相当于隐身 */
  }
  30% { opacity: 1; }
  70% {
    transform: rotateY(0); /* 正对观众 */
    opacity: 1;
  }
}

配合父容器 .byline 的 Z 轴推进动画,文字不仅在自转,还在向镜头推进,层次感瞬间拉满。

🏁 杀青

通过 perspective 构建空间,利用 translateZ 制造纵深,再配合 rotateY 增加动感。不需要复杂的 JS 库,几十行 CSS 就能致敬经典。

前端开发的乐趣,往往就在这些像素的腾挪转移之间。愿原力与你的代码同在!May the code be with you.

源代码

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>html5&css3星球大战</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <div class="starwars">
        <img src="./star.png" alt="star" class="star">
        <img src="./wars.png" alt="wars" class="wars">
        <h2 class="byline" id="byline">
            <span>T</span>
            <span>H</span>
            <span>E</span>
            <span>F</span>
            <span>O</span>
            <span>R</span>
            <span>C</span>
            <span>E</span>
            <span>A</span>
            <span>W</span>
            <span>A</span>
            <span>K</span>
            <span>E</span>
        </h2>
        </div>
    </div>
</body>
</html>

CSS

/*
  标准 CSS Reset
  基于 Eric Meyer 的 Reset 并结合现代浏览器特性
*/

/* 所有元素应用 border-box 模型,方便布局 */
*,
*::before,
*::after {
  box-sizing: border-box;
}

/* 重置所有元素的内外边距、边框、字体等 */
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
}

/* HTML5 语义化元素设为块级 */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
  display: block;
}

/* 重置列表样式 */
ol,
ul {
  list-style: none;
}

/* 重置表格样式 */
table {
  border-collapse: collapse;
  border-spacing: 0;
}

/* 重置图片、视频等替换元素 */
img,
video,
canvas,
audio,
svg {
  display: block;
  max-width: 100%;
}

/* 重置表单元素 */
button,
input,
select,
textarea {
  /* 继承字体和颜色 */
  font: inherit;
  color: inherit;
  /* 移除默认边框和轮廓 */
  border: none;
  outline: none;
  /* 清除默认样式 */
  background: transparent;
  /* 统一垂直对齐 */
  vertical-align: middle;
}

/* 链接重置 */
a {
  text-decoration: none;
  color: inherit; /* 继承父元素颜色 */
}

/* 防止字体缩放 */
body {
  line-height: 1;
  -webkit-text-size-adjust: 100%;
}

/* 清除浮动(可选) */

.clearfix::after {
  content: "";
  display: table;
  clear: both;
}

/* 业务代码 */
body {
  height: 100vh;
  background:#000 url(./bg.jpg);
}
.starwars {
    /* 声明 支持3D */
  perspective: 800px;
  /* 保持3D 变换 */
  transform-style: preserve-3d;
  /* 相对单位,相对于自身的字体大小 
    默认字体大小是16
  */
  width: 34em;
  height: 17em;
  /* 绝对定位 */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* css 调试手法, 背景颜色调试大法 */
  /* background-color: red; */
}
img {
    /* 高度等比例缩放 */
  width: 100%;
}
.star, .wars, .byline {
  position: absolute;
}
.star {
  top: -0.75em;
}
.wars {
  bottom: -0.5em;
}
.byline {
  left: -2em;
  right: -2em;
  top: 45%;
  /* background: green; */
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.3em;
  font-size: 1.6em;
  color: white;
}
.star{
    /* 动画属性 
    star 动作脚本
    10s animation-duration
    ease-out animation-timing-function
    */
    animation: star 10s ease-out infinite;
}
.wars{
    animation: wars 10s ease-out infinite;
}
.byline{
    animation: move-byline 10s linear infinite;
}
.byline span{
    display: inline-block;
    animation: spin-letters 10s linear infinite;
}

/* 设计动作 动画的关键帧 */
@keyframes star {
    /* 每个关键帧写它的属性 */
    0%{
        opacity: 0;
        transform: scale(1.5) translateY(-0.75em);
    }
    20%{
        opacity: 1;

    }
    89%{
        opacity: 1;
        transform: scale(1);
    }
    100%{
        opacity: 0;
        transform: translateZ(-1000em);
    }
    
}
@keyframes wars{
    0%{
        opacity: 0;
        transform: scale(1.5) translateY(0.5em);
    }
    20%{
        opacity: 1;

    }
    /* 模拟真实效果 不同步 更像是人在操控飞船 */
    90%{
        opacity: 1;
        transform: scale(1);
    }
    100%{
        opacity: 0;
        transform: translateZ(-1000em);
    }
}
@keyframes spin-letters {
   0%,10%{
        opacity: 0;
        /* 钢管舞 */
        transform: rotateY(90deg);
   }
  30%{
        opacity: 1;
      
  }
  70%,86%{
    transform: rotateY(0deg);
    opacity: 1;
  }
  95%,100%{
    opacity: 0;
  }
}
@keyframes move-byline {
    0%{
        transform: translateZ(5em);
    }
    100%{
        transform: translateZ(0);
    }
}

bg.jpg

昨天 — 2026年1月13日首页
昨天以前首页

我被 border-image 坑了一天,总算解决了

作者 吴敬悦
2026年1月12日 16:53

你不会还不知道 border-image 吧,那我跟你普及一下:

在元素的边框位置画图像,而不是常见的 solid ,dashed 这些线条,线段等。具体使用请参考# border-image 详解

现在才明白, border-image 如果理解错了,可能就要多花费很久的时间,就跟我这次一样。

先说避坑指南:

  1. border-image-slice 用设计稿尺寸,应该使用图片中的像素;
  2. 没有认真分析图片直接开切,弄明白哪些需要拉伸,哪些不需要,然后再去得到尺寸;
  3. 如果你切的尺寸不同,需要弄明白 border-image-width 绘制宽度。

故事的开始是这样的。

设计图是这样的:

刚开始的思路有:

  • 内容部分和外面的 QA 圆圈分开,也就是里面内容写上边框,但是我发现右下角边框只是占了一点点,并不是全部,而 border 设置边框要不就是一边,所以这种方法行不通;
  • 全部使用绝对定位弄上去,因为外面我可以使用 svg 整体,但是这样存在一个问题,就是里面的内容并不是高度一致的,当高度变高或者变窄了就会出现拉伸,当然 svg 默认不拉伸而是居中显示,当然也是不符合我的要求,所以这种方法也行不通;
  • 最笨的方法就是分成几块绝对定位,也就是 Q边框A 和对应的的那个下边线 ,可以实现,但是不够优雅,所以这种方法暂不考虑;
  • 可以发现这个都在边框的位置,那么可以使用 border-image 来实现,顺便把中间的背景白色也弄成图中的一部分,这样里面的内容就不需要再设置 padding 了,理论可行于是我就开始实践。

避坑指南1: border-image-slice 用设计稿尺寸

border: 36px solid transparent;
border-image: url(./images/qa.png) 36;
/*
是以下的简写:
border-image-source: url(./images/qa.png);
border-image-slice: 36;
*/

于是就变成了下面这个样子:

这是啥,咋成这样了;难道是我 border-image-slice 不对嘛,可是设计图就是 36 呀;于是我再次检查了设计图,发现的确是这么多,那可能是我对这个属性的理解不对,先看看 border-image-slice 文档。

表示到图像边缘的偏移量,在位图中的单位为像素点,在矢量图中则是坐标。对于矢量图,<number> 值与元素大小相关,而非矢量图的原始大小。因此,使用矢量图时,使用百分比值(<percentage>)更可取。

原来是图片的偏移量,像素点,不是设计图的,于是我根据图片比例算了算得到了 36 / (352/1259) = 128.76136363636363

border: 36px solid transparent;
border-image: url(./images/qa.png) 128.76136363636363;
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: 128.76136363636363;
*/

首先 Q 正常了,下边的 A 明显有变形,同时中间的白色并没有覆盖。

避坑指南2: 不分析图片直接开切

拿到图片要分析哪些部分需要拉伸,哪些部分不需要拉伸。

首先思考 A 为啥会变形,我们知道 slice 是将图片分割成 9 部分,拉伸除了四个角的其他部分,而我 slice 设置的是一个值,一个值代表四边都是这么多,很明显图片 A ,也就是右边包含 A 部分要大,所以右边部分还需要单独设置。

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
*/

可以看到右边仍然变形,只不过跟之前相比被挤压了,为啥??于是我把对应的 9 宫格画出来研究一下,结果不画不知道,一画就发现了问题:

避坑指南3: 当切的宽度不同时,需要考虑绘制宽度,不然就会问不是说好的 1/2/3/4 不拉伸嘛

根据上图看到由于边框大小都是 36 ,即便我把右边的 slice 改大了,但是仍然是在 36 这个大小内绘制,既然可绘制的宽度大小没变,那么要想画完整要么拉伸,要么缩小,而这里采用的就是拉伸,我猜为啥不采用缩小,是因为要保证图画的连续性,比较跟图片明显拼接相比,拉伸至少还知道是同一张图片。既然是右边的边框宽度不够导致的,那么我设置后边长度的宽度。

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-right-width: 57.5px;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
*/

现在看起来就没问题了;但是看到右边的边框由于宽度太宽,导致当内容过多的时候会提前换行,并没有做到跟左边差不多,所以这样是不行的,于是我又去学习了一波 border-image-width ,这个属性是调整图片绘制宽度的,于是我改成了这样:

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice) / 1 57.5px 1 1;
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice);
border-image-width: 1 57.5px 1 1; 
*/

感觉跟上面完全一样,但实际上这个时候能变长就会正常了,我把内容增加就能看到了。

现在就剩下中间部分了,默认情况下 border-image 是不会绘制到除了 border 以外的地方的,如果需要铺满则需要 slice 中添加 fill 属性。

fill

保留图像的中心区域并将其作为背景图像显示出来,但其会堆叠在 background 之上。它的宽度和高度分别对应顶部和左侧图像切片的宽度和高度。

也就是这样设置:

--slice: calc(36 / (352/1259));
border: 36px solid transparent;
border-image: url(./images/qa.png) var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice) fill / 1 57.5px 1 1;
/*
相当于下面的写法:
border-image-source: url(./images/qa.png);
border-image-slice: var(--slice) calc(57.5 / (352/1259)) var(--slice) var(--slice) fill;
border-image-width: 1 57.5px 1 1; 
*/

下面就是成果:

可算是解决了。其实右上角的圆角还存在一定的瑕疵,因为圆角的那个位置发生了拉伸,我只需要将 slice 上边调整大一些就解决了。

教训

通过我的惨痛教训,我们必须要记住,这样大家就不会再遇到,即便遇到了也可以通过我的避坑指南快速解决。

  • 必须要先分析图片,哪些应该拉伸,哪些不拉伸;
  • border-image-slice 的数值,永远基于图片原始尺寸,而不是设计稿;
  • 还有一点我没说到,也就是当使用百分比设置 border-image-slice 的时候,上下使用原图片的高度,左右使用图片的宽度。

告别手写礼簿!一款开源免费的电子红白喜事礼簿系统!

作者 Java陈序员
2026年1月12日 09:31

大家好,我是 Java陈序员

无论是儿女结婚的喜宴,还是亲友离世的白事,礼金记账都是绕不开的环节。

传统手写礼簿,不仅考验书写速度和细心程度,还面临着“记重了、算错了、丢了账本”的风险,既费人力又不省心。

而市面上的电子记账工具,要么依赖网络,要么数据存在云端,总担心隐私泄露。

今天,给大家推荐一款纯本地运行的电子礼簿系统,不用连网、不用注册、数据加密存储、安全又好用,红白喜事都适配!

项目介绍

gift-book —— 一款纯本地、零后端、完全本地运行的单页 Web 应用,旨在为各类红白喜事提供一个现代化、安全、高效的礼金(份子钱)管理解决方案。

功能特色

  • 无需联网:纯 HTML 单页应用,不依赖服务器,单页 Web 应用拔网线也能正常记账,数据 100% 存储在本地设备
  • 数据金融级加密保护:全量数据采用 AES-256 加密落库,管理密码通过 SHA-256 哈希保护,即使设备丢失、文件被拷贝,数据也无法破解
  • 秒级记账:姓名、金额、渠道(微信/支付宝/现金)全键盘操作,回车即录,支持实时检测重名、重复金额,并提供语音播报核对功能
  • 双色主题:内置 “喜庆红”(喜事)、“肃穆灰”(白事)两套皮肤,完美适配不同场景的氛围需求
  • 双屏互动:支持开启副屏页面,实时投射数据到外接屏幕/电视,副屏自动开启隐私模式,且支持自定义上传展示收款码
  • 专业级报表与归档:内置专业 PDF 引擎,生成的电子礼簿支持自定义字体、封面图、背景纹理,支持导出加密数据文件,跨设备可全量恢复
  • 开箱即用:普通用户免部署,无需安装任何环境,双击即可运行,同时可部署到服务器上,通过浏览器在线访问

快速上手

gift-book 由纯静态文件组成,无需安装任何环境。

1、打开下载地址,下载 Windows 预编译应用(gift-book.exe)

https://github.com/jingguanzhang/gift-book/releases

2、双击运行 gift-book.exe

3、初始化:创建新事项

设置事项名称及管理密码(请务必牢记,丢失无法找回)。

4、记账:录入数据

5、归档:活动结束后,务必导出 Excel 或 PDF 文件到电脑,微信收藏或云盘永久保存

功能体验

  • 礼金录入

  • 副屏

  • 礼簿

  • 礼金统计详情

本地开发

需要依赖代码编辑器(推荐 VS Code)和浏览器(Chrome/Edge)。

1、克隆或下载项目源码

git clone https://github.com/jingguanzhang/gift-book.git

2、在 VS Code 中打开项目代码

3、代码目录结构

gift-book
├── index1.html             # v1.1 专业版主入口(核心代码均内嵌于此,方便单文件分发)
├── index.html              # v1.0 基础版主入口
├── static/                 # 静态资源目录
    ├── tailwindcss.js      # 样式引擎
    ├── xlsx.full.min.js    # Excel 导出库
    ├── pdf-lib.min.js      # PDF 生成引擎
    ├── crypto-js.min.js    # 加密库
    └── fontkit & .ttf      # 字体文件(用于 PDF 生成)
└── guest-screen.html       # 副屏显示页面

4、右键 index.html 并选择 "Open with Live Server" 运行程序

需要在 VS Code 中提前安装插件 Live Server.

5、部署上线:无需编译,直接将所有文件上传至 GitHub Pages、Vercel、Nginx 或任何静态文件服务器即可

可以说,gift-book 这款纯本地电子礼簿,没有复杂的操作门槛,没有数据泄露的顾虑,只用简单的方式把账记准、记清、存好。快去试试吧~

项目地址:https://github.com/jingguanzhang/gift-book

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


经验分享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项目后,总结出这套稳定高效的架构设计

推荐几个国外比较流行的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


❌
❌