曲线遮罩裁剪问题复盘

本文完整记录 iOS/Chrome 下曲线连接与遮罩裁剪失效的问题调查、根因分析、修复方案与关键代码变更,便于后续回溯与参考。

背景

组件:src/components/story-line
功能:通过 CSS mask + 内联 SVG <mask>,在左右转弯段实现“外圆角 + 内切角”的镂空轨道效果,使整条时间线的渐变背景在曲线处呈现空心轨道视觉。

正常示例(左转弯):mask 能生效,显示正确。
异常示例(右转弯):在 iPhone/iOS Safari 初期不生效;在修复后又出现 Chrome 背景丢失等兼容差异。

现象与平台差异

  • iPhone(iOS Safari/WebView)最初:右侧 mask 不生效,左侧生效
  • Android/PC(Chrome/Edge):初期均正常;后续调整中出现 Chrome 背景“被剪没”
  • Safari 正常、Chrome 异常的交替出现,显示实现受实现细节影响较大

根本原因分析

  1. SVG <mask> 坐标系与单位不一致

    • 初始实现用 userSpaceOnUse + rect width="100%" height="100%"。在 0x0 或非匹配 viewBox 的 SVG 中,Chrome 将 100% 解析到 SVG 自身坐标(极小画布),导致后续像素路径落到画布外,结果整体透明,背景“被剪没”。Safari 在该场景更宽松(可能按元素 bbox 推断),因此不易复现。
  2. 定义时序与重复定义

    • 每个曲线节点内都内联 <svg><defs><mask id="cutout-mask">。渲染过程中可能在样式应用时引用到了尚未可见的 <defs>,或同 ID 被后续相同 ID 的定义覆盖/替换,导致引用偶发失败。虽然 Shadow DOM 隔离 ID,但在同一组件内多次插入仍可能出现解析竞态。
  3. 右侧曲线路径参数与内外路径不一致

    • 右侧 innerPath 早期使用了 outWidth 而非 innerWidth 生成,存在边界/比例不匹配,叠加单位/画布问题更易使 Chrome 判定路径越界从而失效。
  4. -webkit 前缀与 mask 组合属性的差异

    • iOS 需要 -webkit-mask 前缀更稳;mask-composite/mask-mode 组合在不同浏览器支持度不一,错误组合可能直接让遮罩失效。
  5. mask 画布尺寸与容器缩放不匹配

    • 应用 mask-size: contain 等缩放会导致路径与画布错位,Chrome 按严格规范处理,路径跑出画布区域时整体透明。
  6. 内层填充元素的视觉误解

    • .left-inner-curvebackground-color 实际受遮罩影响不可见;当使用不透明颜色时,遮蔽了轨道“内切”视觉,造成“未裁剪”的错觉;当使用变量值为透明/近透明时,反而看起来“裁剪成功”。

解决方案与实施

为同时兼容 iOS Safari 与 Chrome/Edge,采取如下策略:

  1. 唯一化 mask ID,且每个组件实例只渲染一次 <defs>

    • 在类中生成 uid,派生 leftMaskId/rightMaskId
    • 在组件 render 顶部一次性插入两个 <mask> 定义,避免时序与覆盖
  2. 与元素像素坐标对齐的遮罩画布

    • <svg> 使用 width={outWidth} height=165 viewBox="0 0 outWidth 165"
    • <mask> 使用 maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" mask-type="alpha"
    • rect 采用像素尺寸 width={outWidth} height=165
  3. 路径修正

    • 右侧 innerPath 改为使用 innerWidth 保持与左侧一致的“内外”间隙关系
  4. CSS 侧的兼容设置

    • 同时设置 mask-webkit-mask
    • 移除 mask-composite/mask-mode
    • 设定 mask-repeat: no-repeat; mask-size: auto; mask-origin/clip: border-box
  5. 避免 0x0 定义与过度缩放

    • 避免 0 大小 SVG;不使用 contain 缩放,使用 auto
  6. 对内层填充的建议

    • .left-inner-curve/.right-inner-curve 背景不用于可见色,保持透明或与主题变量一致但尽量不影响遮罩视觉;遮罩效果源自容器背景与 mask 的组合,而非内层块的背景色。

关键代码变更

文件:src/components/story-line/index.tsx

  • 实例化唯一 mask ID(类字段)

    1
    2
    3
    4
    // src/components/story-line/index.tsx:12-23, after class start
    private uid: string = Math.random().toString(36).slice(2);
    private get leftMaskId() { return `cutout-mask-${this.uid}` }
    private get rightMaskId() { return `cutout-mask-rotated-${this.uid}` }
  • 修正右侧 innerPath、一次性 defs、像素对齐 viewBox

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // src/components/story-line/index.tsx:248-275, 267-276
    const getRightInnerPathTemplate = (width: number) => {
    return `M ${width},20 L ${width - 25},20 A 25,25 0 0 0 ${width - 50},35 L ${width - 50},130 A 25,25 0 0 0 ${width - 25},145 L ${width},145 Z`;
    };

    return (
    <svg width={String(outWidth)} height="165" viewBox={`0 0 ${outWidth} 165`} style={{ position: 'absolute', opacity: '0' }} aria-hidden="true">
    <defs>
    <mask id={dir === 'left' ? this.leftMaskId : this.rightMaskId} maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" mask-type="alpha">
    <rect x="0" y="0" width={String(outWidth)} height="165" fill="white"/>
    <path d={dir === 'left' ? getOutPathTemplate(outWidth) : getRightOutPathTemplate(outWidth)} fill="white"/>
    <path d={dir === 'left' ? getInnerPathTemplate(innerWidth) : getRightInnerPathTemplate(innerWidth)} fill="black"/>
    </mask>
    </defs>
    </svg>
    );
  • defs 只在 render 顶部渲染一次

    1
    2
    3
    // src/components/story-line/index.tsx:420-424
    {this.renderMaskSvg('left')}
    {this.renderMaskSvg('right')}
  • 曲线容器应用统一、兼容的 mask 样式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // left: src/components/story-line/index.tsx:326-345
    <div class='left-curve' style={{
    background: bgColor,
    mask: `url(#${this.leftMaskId})`,
    WebkitMask: `url(#${this.leftMaskId})`,
    maskRepeat: 'no-repeat', WebkitMaskRepeat: 'no-repeat',
    maskSize: 'auto', WebkitMaskSize: 'auto',
    maskOrigin: 'border-box', WebkitMaskOrigin: 'border-box',
    maskClip: 'border-box', WebkitMaskClip: 'border-box'
    }}>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // right: src/components/story-line/index.tsx:348-370
    <div class='right-curve' style={{
    background: bgColor,
    mask: `url(#${this.rightMaskId})`,
    WebkitMask: `url(#${this.rightMaskId})`,
    maskRepeat: 'no-repeat', WebkitMaskRepeat: 'no-repeat',
    maskSize: 'auto', WebkitMaskSize: 'auto',
    maskOrigin: 'border-box', WebkitMaskOrigin: 'border-box',
    maskClip: 'border-box', WebkitMaskClip: 'border-box'
    }}>

关于 .left-inner-curve 背景色与“是否裁剪”的关系

  • 本质:可见效果由“容器背景 + mask”决定,内层块的背景色通常被遮罩裁掉或不参与最终可见像素。因此:
    • 当将内层背景设为不透明颜色(如 #f00)时,你看到的仍是容器背景的渐变色,而不是红色;这是因为 mask 限制了显示区域,红色被遮在不可见区域内
    • 当变量 --viz-timeline-curve-cover-color 取值为透明或近透明时,整体看起来“裁剪更成功”;若该变量变为不透明色,会遮挡轨道内切视觉,像“未裁剪”
  • 建议:保持内层为透明或仅用于结构占位,不依赖其颜色影响遮罩视觉;轨道“外观”由 mask 负责

验证建议

  • iPhone Safari 与 iOS WebView:检查左右曲线均正确镂空
  • Chrome/Edge(桌面/Android):确保背景不被整体剪空;观察缩放/响应式阈值下仍正确
  • 多实例/多行:验证多个 story-line 实例不会互相干扰(因 mask ID 唯一化)

可能的替代方案(若仍遇到个别设备问题)

  • 使用 <clipPath> + 伪元素叠加“内切”遮罩层,放弃 mask 的 alpha 合成路径
  • 预渲染曲线为位图/九宫切图,根据尺寸拼接;牺牲少量矢量灵活性换取一致性

结论

问题源于多因素叠加:mask 画布与路径坐标系不匹配、定义时序/重复、右曲线内外路径不一致以及跨浏览器对 mask 行为的差异。通过唯一化 ID、一次 defs、像素对齐 viewBox、修正路径与统一 CSS 掩膜参数,已实现在 iOS Safari 与 Chrome/Edge 上的一致行为。

追加:实际裁剪机制与经验教训(黑暗模式与 DIV 裁剪)

最终确认:本组件的“裁剪”视觉并非依赖 SVG 遮罩本身,而是利用 .left-inner-curve.right-inner-curve 两个 DIV 作为遮挡层来“裁剪”。做法为:

  • 将该 DIV 的背景色设置为与页面背景一致的颜色,使其成为“遮挡色”
  • 将真实需要显示的文本层级抬高(z-index 更大),避免被遮挡层覆盖
  • 通过这些层叠关系,达成类似“被裁剪”的视觉效果

因此,以下结论十分关键:

  • 遮罩视觉由层叠与背景色共同决定。若遮挡层背景色错用为非页面背景色,在不同主题(如黑暗模式)下会凸显出“块状遮挡”,看似裁剪失败
  • 即使引入 SVG mask,若本质机制是用 DIV 遮挡实现的,mask 的任何变化都难以从根本解决问题

原因复盘

  • 未在问题初期梳理页面结构、图层目的与“裁剪原理”,导致在 SVG/CSS mask 上投入过多尝试
  • 在黑暗模式下,背景色或变量未统一,遮挡层与页面背景不一致,出现“看似裁剪失败”的假象

建议实施

  • 明确遮挡层即“裁剪层”:
    • .left-inner-curve/.right-inner-curve 背景应绑定为“当前页面背景”的变量(light/dark 双态)
    • 相关文本/内容图层 z-index 应保证高于遮挡层
    • 组件内部不要用不透明的内层背景去覆盖轨道区域,以免与遮挡层职责混淆

移动端兼容开发经验与原则(建议在项目初期参考)

  1. 明确显示原理再选技术路线
  • 先画出层级树:每个元素的职责(背景、遮挡、内容、交互)、是否参与点击命中、是否参与滚动合成
  • 确定“裁剪”的实现机制:CSS mask、clip-path、混合模式、还是遮挡层(DIV)视觉模拟
  • 选择对目标平台最稳的方案;若使用 CSS/SVG 特性,先查 MDN/Can I use 的平台支持与注意事项
  1. 统一坐标与基准
  • 使用 SVG 时,统一 viewBox 与路径坐标;决定 objectBoundingBox 还是 userSpaceOnUse,并保持 rect/path 的一致基准
  • 使用 CSS mask/clip-path 时,明确 mask-size/mask-origin/mask-clip 的含义,避免缩放错位
  1. 定义与引用时序
  • defs(mask/clipPath/gradient)应在使用前就绪;多实例场景为每个实例生成唯一 ID,避免覆盖
  • 尽可能在组件 render 顶层集中定义,子节点只做引用
  1. 跨浏览器前缀与降级
  • iOS/Safari 往往需要 -webkit-mask;保持 mask-webkit-mask 同步
  • 避免使用兼容性差的合成属性(mask-composite/mode);必要时加特性检测与回退样式
  1. 背景与主题一致性
  • 涉及“遮挡”视觉时,遮挡层背景必须与页面背景保持一致(light/dark 双态变量),否则会形成不期望的“色块”
  • 主题切换必须驱动这些变量同步更新
  1. 最小化依赖与简化实现
  • 若 DIV 遮挡足以达到目标视觉,不要引入复杂的 SVG mask;相反若需要精确掩膜,再采用 mask/clip-path
  • 在移动端优先选择绘制路径短、重排少的方案,减少重绘/合成负担
  1. 验证策略
  • 以“平台矩阵”验证:iOS Safari、iOS WebView、Android Chrome、桌面 Chrome/Edge/Safari
  • 验证多实例、多行、多尺寸阈值(响应式)场景
  • 检查主题切换、深浅模式下的视觉一致性
  1. 性能与命中区域
  • 遮挡层不应遮挡交互命中:必要时 pointer-events: none;或保证交互元素 z-index 更高
  • 避免 0x0 SVG 与频繁 reflow 的动画型遮罩;必要时静态化或缓存路径
  1. 调试方法
  • 打开“图层边界/合成层”调试(Chrome DevTools Layers、Safari Develop 菜单)
  • 在 Elements 中定位 mask/clipPath 的最终尺寸、坐标与生效情况;对比 computed style 中 -webkit 与标准属性
  1. 文档化与复盘
  • 将“原理”与“结构图”写入组件文档:说明视觉效果是通过哪一种机制实现(遮挡/掩膜/裁剪)
  • 记录已知平台差异与回退策略,避免后续踩坑

以上原则可在新组件开发初期作为检查清单,减少移动端兼容与显示差异带来的返工成本。