阅读视图

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

前端图形引擎架构设计:可扩展双渲染引擎架构设计-支持自定义渲染器

ECS渲染引擎架构文档

写在前面

之前写过一篇ECS文章,为什么还要再写一个,本质上因为之前的文档,截止到目前来说,变化巨大,底层已经改了很多很多,所以有必要把一些内容拎出来单独去说。

由于字体文件较大,加载时间会比较久😞,可以把项目clone下来本地跑会比较快。

另外如果有性能问题,我会及时修复,引擎改造时间太仓促,只要不是内存泄漏,暂时没去处理。

还有很多东西要做。

体验地址:baiyuze.github.io/design/#/ca…

image.png

image.png

image.png

项目概览

Duck-Core 是一个基于 ECS(Entity-Component-System)架构构建的高性能 Canvas 渲染引擎,专为复杂图形编辑场景设计。引擎的核心特色在于双渲染后端架构插件化系统设计极致的渲染性能优化

核心技术栈

  • CanvasKit-WASM - Google Skia 图形库的 WebAssembly 移植版
  • Canvas2D API - 浏览器原生渲染接口

架构核心亮点

ECS 架构模式 - 数据驱动的实体组件系统,实现逻辑与数据完全解耦

双引擎架构 - Canvas2D 与 CanvasKit 双渲染后端,运行时无缝切换

插件化设计 - 开放式扩展点,支持自定义渲染器、系统和组件

极致性能 - 颜色编码拾取、离屏渲染、渲染节流等多重优化


整体架构设计

整个引擎采用分层架构,从底层的渲染抽象到顶层的用户交互,每一层职责清晰且可独立替换。

graph TB
    subgraph "应用层"
        A[React 组件] --> B[Canvas 画布组件]
    end
    
    subgraph "引擎核心层"
        B --> C[Engine 引擎实例]
        C --> D[Core 状态管理器]
        C --> E[Camera 相机控制]
        C --> F[Entity Manager 实体管理]
    end
    
    subgraph "系统层 - System"
        C --> G[EventSystem 事件系统]
        G --> H[InputSystem 输入系统]
        G --> I[RenderSystem 渲染系统]
        G --> J[PickingSystem 拾取系统]
        G --> K[DragSystem 拖拽系统]
        G --> L[SelectionSystem 选择系统]
        G --> M[ZoomSystem 缩放系统]
        G --> M[FpsSystem FPS]
    end
    
    subgraph "渲染层 - Renderer"
        I --> N[RendererManager 渲染管理器]
        N --> O{选择渲染后端}
        O -->|Canvas2D| P[Canvas2D 渲染器组]
        O -->|CanvasKit| Q[CanvasKit 渲染器组]
        P --> R[RectRender]
        P --> S[EllipseRender]
        P --> T[TextRender]
        Q --> U[RectRender]
        Q --> V[EllipseRender]
        Q --> W[TextRender]
    end
    
    subgraph "数据层 - Component"
        X[StateStore 状态仓库]
        X --> Y[Position]
        X --> Z[Size]
        X --> AA[Color]
        X --> AB[Rotation]
        X --> AC[Selected]
    end
    
    D <--> X
    I --> X
    J --> X
    K --> X
    L --> X
    
    style C fill:#4A90E2,color:#fff
    style N fill:#E94B3C,color:#fff
    style X fill:#6ECB63,color:#fff
    style G fill:#F39C12,color:#fff

ECS 架构深度解析

什么是 ECS 架构?

ECS(Entity-Component-System)是一种源自游戏引擎的设计模式,它彻底改变了传统面向对象的继承体系,转而采用组合优于继承的理念。

三大核心概念:

  1. Entity(实体) - 仅是一个唯一 ID,不包含任何数据和逻辑
  2. Component(组件) - 纯数据结构,描述实体的属性(如位置、颜色、大小)
  3. System(系统) - 纯逻辑处理单元,操作特定组件组合的实体
graph TB
    subgraph "传统 OOP 继承方式"
        A1[GameObject]
        A1 --> A2[Rectangle]
        A1 --> A3[Circle]
        A1 --> A4[Text]
        A2 --> A5[DraggableRectangle]
        A3 --> A6[SelectableCircle]
        style A1 fill:#ff9999
    end
    
    subgraph "ECS 组合方式"
        B1[Entity 123] -.拥有.-> B2[Position]
        B1 -.拥有.-> B3[Size]
        B1 -.拥有.-> B4[Color]
        
        B5[Entity 456] -.拥有.-> B6[Position]
        B5 -.拥有.-> B7[Font]
        B5 -.拥有.-> B8[Selected]
        
        B9[RenderSystem] --> B2 & B3 & B4
        B10[DragSystem] --> B2
        B11[SelectionSystem] --> B8
        
        style B1 fill:#99ccff
        style B5 fill:#99ccff
        style B9 fill:#99ff99
        style B10 fill:#99ff99
        style B11 fill:#99ff99
    end

ECS 架构的核心优势

1. 极致的解耦性

传统 OOP 中,功能通过继承链紧密耦合。而 ECS 中,系统只依赖组件接口,实体的行为完全由组件组合决定。

// ❌ 传统方式:紧耦合的继承链
class Shape {
  render() { /* ... */ }
}
class DraggableShape extends Shape {
  drag() { /* ... */ }
}
class SelectableDraggableShape extends DraggableShape {
  select() { /* ... */ }
}

// ✅ ECS 方式:组件自由组合
const rect = createEntity()
addComponent(rect, Position, { x: 100, y: 100 })
addComponent(rect, Size, { width: 200, height: 150 })
addComponent(rect, Draggable, {})  // 可拖拽
addComponent(rect, Selected, {})   // 可选中
2. 强大的可扩展性

新增功能无需修改现有代码,只需添加新的组件和系统:

image.png

3. 天然的并行处理能力

系统之间无共享状态,可以安全地并行执行:

// 多个系统可以同时读取同一个组件
async function updateFrame() {
  await Promise.all([
    physicsSystem.update(),   // 读取 Position
    renderSystem.update(),    // 读取 Position
    collisionSystem.update(), // 读取 Position
  ])
}
System 系统架构

系统负责处理逻辑,通过查询 StateStore 获取需要的组件数据:

abstract class System {
  abstract update(stateStore: StateStore): void
}

class RenderSystem extends System {
  update(stateStore: StateStore) {
    // 查询所有拥有 Position 组件的实体
    for (const [entityId, position] of stateStore.position) {
      const size = stateStore.size.get(entityId)
      const color = stateStore.color.get(entityId)
      const type = stateStore.type.get(entityId)
      
      // 根据类型调用对应的渲染器
      this.renderMap.get(type)?.draw(entityId)
    }
  }
}

系统完整列表:

graph TB
    A[EventSystem<br/>事件总线] --> B[InputSystem<br/>输入捕获]
    A --> C[HoverSystem<br/>悬停检测]
    A --> D[ClickSystem<br/>点击处理]
    A --> E[DragSystem<br/>拖拽逻辑]
    A --> F[SelectionSystem<br/>选择管理]
    A --> G[ZoomSystem<br/>缩放控制]
    A --> H[ScrollSystem<br/>滚动平移]
    A --> I[PickingSystem<br/>图形拾取]
    A --> J[RenderSystem<br/>渲染绘制]
    A --> K[FpsSystem<br/>性能监控]
    
    style A fill:#F39C12,color:#fff
    style J fill:#E74C3C,color:#fff
    style I fill:#3498DB,color:#fff

双引擎架构设计

架构设计理念

不同的应用场景对渲染引擎有不同的需求:

  • 简单场景:需要快速启动、体积小、兼容性好
  • 复杂场景:需要高性能、丰富特效、大量图形

传统方案通常只支持单一渲染后端,难以兼顾两者。本引擎采用双引擎可切换架构,在运行时动态选择最优渲染后端。

graph TB
    A[应用启动] --> B{检测场景复杂度}
    B -->|简单场景<br/>< 100 图形| C[Canvas2D 引擎]
    B -->|复杂场景<br/>> 100 图形| D[CanvasKit 引擎]
    B -->|用户手动指定| E[用户选择]
    
    C --> F[浏览器原生 API]
    D --> G[Skia WASM 引擎]
    
    C --> H[渲染输出]
    D --> H
    
    I[运行时切换] -.->|热切换| C
    I -.->|热切换| D
    
    style C fill:#90EE90
    style D fill:#87CEEB
    style H fill:#FFD700

渲染后端对比

特性 Canvas2D CanvasKit (Skia)
启动速度 ⚡️ 即时(0ms) 🐢 需加载 WASM(~2s)
包体积 ✅ 0 KB ⚠️ ~1.5 MB
浏览器兼容性 ✅ 100% ⚠️ 需支持 WASM
渲染性能 🟡 中等 🟢 优秀
复杂路径渲染 🟡 一般 🟢 优秀
文字渲染 🟡 质量一般 🟢 亚像素级
滤镜特效 ❌ 有限 ✅ 丰富
离屏渲染 ✅ 支持 ✅ 支持
最佳场景 简单图形、快速原型 复杂设计、高性能需求

RendererManager 渲染管理器

RendererManager 是双引擎架构的核心枢纽,负责渲染器的注册、切换和调度:

class RendererManager {
  rendererName: 'Canvas2D' | 'Canvaskit' = 'Canvaskit'
  
  // 渲染器映射表
  renderer: {
    rect: typeof RectRender
    ellipse: typeof EllipseRender
    text: typeof TextRender
    img: typeof ImgRender
    polygon: typeof PolygonRender
  }
  
  // 切换渲染后端
  setRenderer(name: 'Canvas2D' | 'Canvaskit') {
    this.rendererName = name
    
    if (name === 'Canvas2D') {
      this.renderer = Canvas2DRenderers
    } else {
      this.renderer = CanvaskitRenderers
    }
  }
}

渲染器切换流程:

sequenceDiagram
    participant U as 用户操作
    participant E as Engine
    participant RM as RendererManager
    participant RS as RenderSystem
    participant R1 as Canvas2D Renderer
    participant R2 as CanvasKit Renderer
    
    U->>E: setRenderer('Canvas2D')
    E->>RM: setRenderer('Canvas2D')
    RM->>RM: 加载 Canvas2D 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 Canvas2D.RectRender
    RS->>R1: 调用 draw() 方法
    R1->>R1: 使用 ctx.fillRect()
    
    Note over U,R2: 用户再次切换引擎
    
    U->>E: setRenderer('Canvaskit')
    E->>RM: setRenderer('Canvaskit')
    RM->>RM: 加载 CanvasKit 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 CanvasKit.RectRender
    RS->>R2: 调用 draw() 方法
    R2->>R2: 使用 canvas.drawRect()

渲染器统一接口

所有渲染器实现相同的接口,保证可替换性:

abstract class BaseRenderer extends System {
  constructor(protected engine: Engine) {
    super()
  }
  
  // 统一的渲染接口
  abstract draw(entityId: string): void
  
}

自定义渲染器扩展

引擎支持用户自定义渲染器,只需实现 System 接口:

// 1. 创建自定义渲染器
class CustomStarRender extends System {
  draw(entityId: string) {
    const points = this.getComponent<Polygon>(entityId, 'polygon')
    const color = this.getComponent<Color>(entityId, 'color')
    
    // 自定义绘制逻辑
    const ctx = this.engine.ctx
    ctx.beginPath()
    points.points.forEach((p, i) => {
      i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)
    })
    ctx.closePath()
    ctx.fillStyle = color.fill
    ctx.fill()
  }
}
const customRenderMap = {
  star: CustomStarRender
}
// 2. 注册到引擎
new RendererRegistry().register({
  "custom": customRenderMap
})


字体渲染优化

CanvasKit 需要预加载字体文件,引擎实现了字体管理器:

async function loadFonts(CanvasKit: any) {
  const fontsBase = import.meta.env?.MODE === 'production' 
    ? '/design/fonts/' 
    : '/fonts/'

  const [robotoFont, notoSansFont] = await Promise.all([
    fetch(`${fontsBase}Roboto-Regular.ttf`).then(r => r.arrayBuffer()),
    fetch(`${fontsBase}NotoSansSC-VariableFont_wght_2.ttf`).then(r => r.arrayBuffer()),
  ])

  const fontMgr = CanvasKit.FontMgr.FromData(robotoFont, notoSansFont)
  return fontMgr
}

// 在 CanvasKit 初始化时调用
export async function createCanvasKit() {
  const CanvasKit = await initCanvasKit()
  const FontMgr = await loadFonts(CanvasKit)
  return { CanvasKit, FontMgr }
}

引擎工厂模式

使用工厂函数创建不同配置的引擎实例:

export function createCanvasRenderer(engine: Engine) {
  // Canvas2D 引擎创建器
  const createCanvas2D = (config: DefaultConfig) => {
    const canvas = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    canvas.style.width = config.width + 'px'
    canvas.style.height = config.height + 'px'
    canvas.width = config.width * dpr
    canvas.height = config.height * dpr
    
    const ctx = canvas.getContext('2d', {
      willReadFrequently: true,
    }) as CanvasRenderingContext2D
    ctx.scale(dpr, dpr)
    
    config.container.appendChild(canvas)
    
    return { canvasDom: canvas, canvas: ctx, ctx }
  }

  // CanvasKit 引擎创建器
  const createCanvasKitSkia = async (config: DefaultConfig) => {
    const { CanvasKit, FontMgr } = await createCanvasKit()
    const canvasDom = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    
    canvasDom.style.width = config.width + 'px'
    canvasDom.style.height = config.height + 'px'
    canvasDom.width = config.width * dpr
    canvasDom.height = config.height * dpr
    canvasDom.id = 'canvasKitCanvas'
    
    config.container.appendChild(canvasDom)
    
    const surface = CanvasKit.MakeWebGLCanvasSurface('canvasKitCanvas')
    const canvas = surface!.getCanvas()
    
    return {
      canvasDom,
      surface,
      canvas: canvas,
      FontMgr: FontMgr,
      ck: CanvasKit,
    }
  }

  return {
    createCanvas2D,
    createCanvasKitSkia,
  }
}

Engine 引擎核心

Engine 类是整个渲染系统的中枢,协调所有子系统的运行:

class Engine implements EngineContext {
  camera: Camera = new Camera()
  entityManager: Entity = new Entity()
  SystemMap: Map<string, System> = new Map()
  rendererManager: RendererManager = new RendererManager()
  
  canvas!: Canvas  // 渲染画布(类型取决于渲染后端)
  ctx!: CanvasRenderingContext2D
  ck!: CanvasKit
  
  constructor(public core: Core, rendererName?: string) {
    // 初始化渲染器
    this.rendererManager.rendererName = rendererName || 'Canvaskit'
    this.rendererManager.setRenderer(this.rendererManager.rendererName)
  }
  
  // 添加系统
  addSystem(system: System) {
    this.system.push(system)
    this.SystemMap.set(system.constructor.name, system)
  }
  
  // 获取系统
  getSystemByName<T extends System>(name: string): T | undefined {
    return this.SystemMap.get(name) as T
  }
  
  // 清空画布(适配双引擎)
  clear() {
    const canvas = this.canvas as any
    if (canvas?.clearRect) {
      // Canvas2D 清空方式
      canvas.clearRect(0, 0, this.defaultSize.width, this.defaultSize.height)
    } else {
      // CanvasKit 清空方式
      this.canvas.clear(this.ck.WHITE)
    }
  }
}

插件化系统设计

系统即插件

引擎的所有功能都以 System 形式实现,每个 System 都是独立的插件。这种设计带来极高的灵活性:

graph TB
    A[Engine 核心] --> B{System Manager}
    
    B --> C[核心系统]
    B --> D[可选系统]
    B --> E[自定义系统]
    
    C --> C1[EventSystem<br/>必需]
    C --> C2[RenderSystem<br/>必需]
    
    D --> D1[DragSystem<br/>拖拽功能]
    D --> D2[ZoomSystem<br/>缩放功能]
    D --> D3[FpsSystem<br/>性能监控]
    
    E --> E1[UndoRedoSystem<br/>撤销重做]
    E --> E2[SnappingSystem<br/>吸附对齐]
    E --> E3[AnimationSystem<br/>动画播放]
    
    style C1 fill:#e74c3c,color:#fff
    style C2 fill:#e74c3c,color:#fff
    style D1 fill:#3498db,color:#fff
    style D2 fill:#3498db,color:#fff
    style D3 fill:#3498db,color:#fff
    style E1 fill:#2ecc71,color:#fff
    style E2 fill:#2ecc71,color:#fff
    style E3 fill:#2ecc71,color:#fff

核心系统详解

1. EventSystem - 事件总线

EventSystem 是整个引擎的调度中枢,协调所有其他系统的执行:

class EventSystem extends System {
  private eventQueue: Event[] = []
  
  update(stateStore: StateStore) {
    // 执行系统更新顺序
    this.executeSystem('InputSystem')      // 1. 捕获输入
    this.executeSystem('HoverSystem')      // 2. 检测悬停
    this.executeSystem('ClickSystem')      // 3. 处理点击
    this.executeSystem('DragSystem')       // 4. 处理拖拽
    this.executeSystem('ZoomSystem')       // 5. 处理缩放
    this.executeSystem('SelectionSystem')  // 6. 更新选择
    this.executeSystem('PickingSystem')    // 7. 更新拾取缓存
    this.executeSystem('RenderSystem')     // 8. 最后渲染
  }

}
2. RenderSystem - 渲染系统

RenderSystem 负责将实体绘制到画布:

class RenderSystem extends System {
  private renderMap = new Map<string, BaseRenderer>()
  
  constructor(engine: Engine) {
    super()
    this.engine = engine
    this.initRenderMap()
  }
  
  // 初始化渲染器映射
  initRenderMap() {
    Object.entries(this.engine.rendererManager.renderer).forEach(
      ([type, RendererClass]) => {
        this.renderMap.set(type, new RendererClass(this.engine))
      }
    )
  }
  
  async update(stateStore: StateStore) {
    // 清空画布
    this.engine.clear()
    
    // 应用相机变换
    this.engine.canvas.save()
    this.engine.canvas.translate(
      this.engine.camera.translateX,
      this.engine.camera.translateY
    )
    this.engine.canvas.scale(
      this.engine.camera.zoom,
      this.engine.camera.zoom
    )
    
    // 遍历所有实体进行渲染
    for (const [entityId, pos] of stateStore.position) {
      this.engine.canvas.save()
      this.engine.canvas.translate(pos.x, pos.y)
      
      const type = stateStore.type.get(entityId)
      await this.renderMap.get(type)?.draw(entityId)
      
      this.engine.canvas.restore()
    }
    
    this.engine.canvas.restore()
  }
}

DSL 配置系统


DSL 配置系统

设计目标

DSL(Domain Specific Language)模块的目标是将图形场景序列化为 JSON 格式,实现:

  1. 场景持久化 - 保存到数据库或本地存储
  2. 场景传输 - 前后端数据交换
  3. 场景快照 - 撤销/重做功能的基础
  4. 模板复用 - 创建可复用的图形模板

配置结构

interface DSLParams {
  type: 'rect' | 'ellipse' | 'text' | 'img' | 'polygon'
  id?: string
  position: { x: number; y: number }
  size?: { width: number; height: number }
  color?: { fill: string; stroke: string }
  rotation?: { value: number }
  scale?: { value: number }
  zIndex?: { value: number }
  selected?: { isSelected: boolean }
  // 形状特定属性
  font?: { family: string; size: number; weight: string }
  radius?: { value: number }
  polygon?: { points: Point[] }
}

DSL 解析器

class DSL {
  constructor(params: DSLParams) {
    this.type = params.type
    this.id = params.id || this.generateId()
    this.position = new Position(params.position)
    this.size = params.size ? new Size(params.size) : new Size()
    this.color = params.color ? new Color(params.color) : new Color()
    // ... 初始化其他组件
  }
  
  // 转换为纯数据对象
  toJSON(): DSLParams {
    return {
      type: this.type,
      id: this.id,
      position: { x: this.position.x, y: this.position.y },
      size: { width: this.size.width, height: this.size.height },
      color: { fill: this.color.fill, stroke: this.color.stroke },
      // ...
    }
  }
}

低耦合架构实践

依赖方向

整个引擎严格遵循依赖倒置原则:

graph TB
    A[应用层<br/>React 组件] --> B[引擎接口<br/>Engine API]
    B --> C[系统层<br/>System]
    C --> D[组件层<br/>Component]
    C --> E[实体层<br/>Entity]
    
    F[渲染层<br/>Renderer] --> G[渲染接口<br/>BaseRenderer]
    C --> G
    
    style B fill:#f39c12,color:#fff
    style G fill:#f39c12,color:#fff

关键设计:

  • 上层依赖接口,不依赖具体实现
  • System 不直接依赖 Renderer,通过 RendererManager 解耦
  • Component 纯数据,零依赖

总结

Duck-Core 前端渲染引擎通过以下设计实现了高性能、高扩展性:

核心优势

  1. ECS 架构 - 数据与逻辑完全分离,组件自由组合
  2. 双引擎架构 - Canvas2D 与 CanvasKit 可热切换,兼顾兼容性与性能
  3. 插件化系统 - 所有功能以 System 形式实现,按需加载
  4. 低耦合设计 - 接口隔离、依赖倒置、事件驱动
  5. 极致性能 - 渲染节流、离屏缓存、视口裁剪、内存优化

从面条代码到抽象能力:一个小表单场景里的前端成长四阶段

在日常业务开发里,有一类场景出现频率极高:

  • 页面里有一个子表单组件(用 ref 引用)
  • 提交前要让子表单先做一轮校验
  • 校验通过后,从当前组件的数据里组装一个 payload 发给后端

看起来再普通不过,比如下面这样一段代码:

if (this.reviewContent?.length) {
  // 先让子表单组件校验
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调接口……
  • 有内容就校验;
  • 校验不过就提示;
  • 校验通过就拼一个 payload 去提交。

很多前端的“表单生涯”,就是从这种线性、直接、带一点点面条味的代码开始的。

有意思的是:
同样一个需求,不同水平的前端,会写出完全不同层次的代码。
从这个小例子出发,我们刚好可以串一条:初级 → 中级 → 高级 → 架构 的成长路径。


一、初级前端:能把流程串起来,就是胜利 🎯

典型写法

还是这段最“朴素”的代码:

if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调用接口,比如:api.submit(payload)

逻辑完全按脑子里的流程来:

  1. 有自评内容吗?(this.reviewContent?.length
  2. 有的话就调用子表单的 validate()
  3. 校验没过就弹一条错误消息
  4. 校验通过,拼一个请求体对象
  5. 调接口

这个阶段的特点

  • ✅ 优点:

    • 写起来非常快;
    • 读起来也很直接——业务同学都能看懂。
  • ❌ 问题:

    • 强耦合在当前组件

      • 假设 $refs.reviewForm 一定存在;
      • 假设 validate() 返回 { ok, message }
      • 假设消息提示必须在这里做。
    • 逻辑、UI 提示、payload 结构全部糊在一起;

    • 若别的页面也要搞“先校验子表单再组装 payload”,往往是复制粘贴一份再改改字段。

初级阶段的核心目标其实只有一个: “我能把功能做出来。”
想到哪写到哪,是完全正常的。


二、中级前端:开始对“重复”和“耦合”过敏 🧩

当你写了第三、第四个类似的提交流程后,会开始皱眉头:

  • “怎么又是先 validate 再拼对象?”
  • “为什么到处都有同样的错误提示逻辑?”
  • “这要是修改字段名,不得全项目搜索一遍?”

这时候,就会自然走向第一步抽象:提取工具函数

第一步:按“模块”抽函数

比如我们为这块自评表单单独写一个工具文件:

// utils/reviewForm.js

// 校验自评表单
export function validateReview(vm) {
  if (!vm.reviewContent?.length) {
    // 没填内容,视为不需要校验
    return { ok: true };
  }

  const form = vm.$refs.reviewForm;
  if (!form || typeof form.validate !== 'function') {
    // 看业务需求,这里也可以认为是异常
    return { ok: true };
  }

  const res = form.validate();

  if (!res || res.ok === false) {
    const msg = res?.message || '自评信息校验未通过';
    vm.$message.error(msg);
    return { ok: false, message: msg };
  }

  return { ok: true };
}

// 构建自评 payload
export function buildReviewPayload(vm) {
  return {
    reviewContent: vm.reviewContent,
    attachmentList: vm.attachmentList,
  };
}

组件里就可以这样用:

import { validateReview, buildReviewPayload } from '@/utils/reviewForm';

async onSubmit() {
  const { ok } = validateReview(this);
  if (!ok) return;

  const payload = buildReviewPayload(this);
  await api.submit(payload);
}

中级前端的思维变化

  • 不再满足于“能跑”,开始追求“以后好改一点”;
  • 能意识到:可以把公共逻辑抽成函数,避免重复粘贴;
  • 但抽象粒度通常还是按业务模块划分
    reviewFormUtilsbaseInfoUtilspriceUtils……

封装有了,代码好了不少,但还停留在“每个业务模块一套”的阶段:
每加一个新表单,又是一组新的 validateXxx + buildXxxPayload


三、高级前端:从“封装”走向“抽象模式” 🧠

再往前走一步,你会开始问更有意思的问题:

  • 这些校验 + payload 的套路,本质上是不是一样的?
  • 哪些是“流程”,哪些是“策略/细节”?
  • 我能不能写一份通用逻辑,让所有表单都用?

1)提炼通用流程:getPayload

观察会发现,每个表单提交流程几乎都是:

  1. 根据某个 ref 拿到子表单组件;
  2. 调用 validate() 做校验;
  3. 如果失败 → 提示并中断;
  4. 如果成功 → 根据当前组件数据构造 payload。

于是可以写出一个只关心流程的函数:

// utils/getPayload.js

/**
 * @param {Vue} vm - 当前组件实例
 * @param {Function} buildPayload - (vm) => payload 对象
 * @param {String} refName - 子表单的 ref 名
 */
export function getPayload(vm, buildPayload, refName) {
  const form = vm.$refs?.[refName];

  // 没有这个表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    // 支持 Promise 风格的 validate
    if (maybe && typeof maybe.then === 'function') {
      return maybe.then(handle).catch((e) => {
        const msg = e?.message || '校验异常';
        vm.$message.error(msg);
        return { ok: false, payload: null, message: msg };
      });
    }

    // 同步返回
    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件使用示例:

import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => ({
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    }),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

此时,getPayload

  • 不再关心 payload 结构;
  • 不再关心具体业务,只负责:
    “找到表单 → 校验 → 错误处理 → 调用参数构造 payload”

2)这里已经有设计模式的影子

  • getPayload 像是一个小号的 模板方法模式(Template Method):

    • 固定了流程:校验 → 错误处理 → 构建 payload
    • 把“payload 怎么构”这一段留给调用方(作为“模板中的可变步骤”)。
  • buildPayload 实际上也符合 策略模式(Strategy)的思路:

    • 同一个处理流程,根据不同策略函数(buildXxxPayload),构建不同业务数据。

高级前端的特点是:

  • 不只是“会封装”,而是会从逻辑里识别出模式
  • 能把“稳定的部分”和“易变的部分”拆开,分别对待;
  • 会刻意让代码有可扩展点,而不是为了当前需求把东西都焊死。

四、前端架构:把“表单校验 + payload”升级成一种通用能力 🏗️

再往上一个段位,前端架构考虑的不只是“这段代码写得好不好看”,而是:

“这种模式在整个项目范围内,要怎么用、怎么演进?”

如果全站有 N 个子表单,每个都要:

  • ref + validate
  • 再拼各自的 payload

那么架构会更倾向于做一件事:

把这种模式正式命名、抽象成一类‘能力’,然后由所有页面共同复用。

1)用配置表达“表单 → payload”的映射关系

先把“这个 ref 对应什么 payload”抽成配置:

// config/formPayloadMap.js

export const formPayloadMap = {
  // 自评表单
  reviewForm(vm) {
    return {
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    };
  },

  // 基础信息表单
  baseForm(vm) {
    return {
      baseInfo: vm.baseInfo,
      projectId: vm.projectId,
    };
  },

  // 报价表单
  priceForm(vm) {
    return {
      priceList: vm.priceList,
    };
  },

  // ……
};

2)getPayload:只需要传 refName

现在改造 getPayload,变成只需要 vm + refName

// utils/getPayload.js
import { formPayloadMap } from '@/config/formPayloadMap';

export function getPayload(vm, refName) {
  const buildPayload = formPayloadMap[refName];

  if (!buildPayload) {
    console.warn(`[getPayload] 未配置 ref "${refName}" 对应的 payload 构造函数`);
    return { ok: false, payload: null, message: `未配置 ${refName} 映射` };
  }

  const form = vm.$refs?.[refName];

  // 没有表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    if (maybe && typeof maybe.then === 'function') {
      return maybe
        .then(handle)
        .catch((e) => {
          const msg = e?.message || '校验异常';
          vm.$message.error(msg);
          return { ok: false, payload: null, message: msg };
        });
    }

    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件里的使用体验就变成:

import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(this, 'reviewForm');
  if (!res.ok) return;

  await api.submit(res.payload);
}

调用方只需要关心:

  • “我这块用的是哪个 ref 的表单?”

至于:

  • 要不要校验;
  • 校验怎么提示;
  • payload 字段怎么组装;

全部由统一机制 + 配置来处理。

3)架构视角下,多了几件事要考虑

在这个阶段,思考重心变成了:

  • 一致性

    • 表单校验行为统一;
    • 错误提示统一;
    • payload 结构的调整有统一入口。
  • 可配置 / 可扩展

    • 新增表单只需要:

      1. 约定好 ref 名;
      2. formPayloadMap 里加一个构造函数;
    • 对核心流程无侵入。

  • 领域化

    • 不再把子表单当成“某个页面的实现细节”,
      而是当成领域里的一个“实体”:
      reviewFormbaseFormpriceForm……
    • 每个实体都有“校验 + 构造请求体”的统一接口。
  • 长期演进成本

    • 将来如果:

      • 改用新的 UI 表单库;
      • 数据结构升级;
      • Vue2 升 Vue3;
    • ——绝大多数改动都可以限制在小范围内完成。


五、同一个需求,不同段位的差别到底是什么?

用一句话概括每个阶段的心智模式:

  • 初级前端:

    “我能把这个流程串起来,让它跑起来。”

  • 中级前端:

    “这里有重复,我抽一个函数出来,大家都用。”

  • 高级前端:

    “这是一种模式。
    哪些是稳定流程?哪些是可变策略?
    我怎么设计抽象,让一份逻辑服务多个业务?”

  • 前端架构:

    “这不仅是个工具函数,而是一类‘通用能力’。
    我要用机制 + 配置,把它变成整个项目的基础设施。”

而最初那段看起来有点“面条味”的代码,其实只是起点。

当你开始嫌弃这类代码,开始思考“能不能抽象、能不能通用”的那一刻,你就已经在从“写代码的人”,向“设计代码的人”迈进了。


如果你现在项目里正好到处都是:

const res = this.$refs.xxx.validate();
// if (!res.ok) ...
// const payload = { ... }

不妨找一个最典型的提交流程,从:

直接写 → 提取函数 → 通用流程 + 策略 → 配置化

这四步路径里,选你觉得当前团队能接受的一步先落地。
技术成长很多时候不是换框架、追新库,而是搞定这种“看起来很小,但无处不在”的模式。


六、如果业务继续变复杂:往“建造者风格”进化 🧱

前面那套 getPayload + formPayloadMap,足以覆盖大部分常规业务表单场景:

  • 每个表单的 payload 结构相对固定;
  • 只要从 vm 上摘几个字段拼一下就行。

但真实项目有时候会长成这样:

  • 某些字段是「勾选了某个开关才需要拼进去」
  • 某些片段要按不同的业务类型组合(比如:普通流程 / 加急流程 / 审批流)
  • 有时还需要先异步拿一部分数据,再参与构建 payload

这时候,简单的:

formPayloadMap[refName](vm) {
  return { ... };
}

就可能开始变得又长又丑了:里面充满 if / switch / 三元运算符。

这个时候,就可以考虑往**“建造者风格(Builder-style)”**上进化:
把一个“大 payload”拆成多个可组合的构建步骤。

1)一个简化版 PayloadBuilder 示例

先来个最小可用的版本:

// builders/PayloadBuilder.js
export class PayloadBuilder {
  constructor(vm) {
    this.vm = vm;
    this.data = {};
  }

  withReview() {
    if (this.vm.reviewContent?.length) {
      this.data.reviewContent = this.vm.reviewContent;
    }
    return this;
  }

  withAttachments() {
    if (Array.isArray(this.vm.attachmentList)) {
      this.data.attachmentList = this.vm.attachmentList;
    }
    return this;
  }

  withBaseInfo() {
    if (this.vm.baseInfo) {
      this.data.baseInfo = this.vm.baseInfo;
    }
    return this;
  }

  // 你可以继续加更多 withXxx 模块…

  build() {
    return this.data;
  }
}

使用方式(举个场景):

import { PayloadBuilder } from '@/builders/PayloadBuilder';
import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => new PayloadBuilder(vm)
      .withReview()
      .withAttachments()
      .withBaseInfo()
      .build(),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

这里发生了几件事:

  • getPayload 仍然负责:
    找到 ref → 校验 → 错误处理

  • 具体 payload 构建过程交给 PayloadBuilder

    • 每个 withXxx() 负责一个独立模块;
    • build() 返回最终对象。

好处是:

  • 可以非常自然地按业务组合:

    • 某些场景只要 .withReview().withAttachments()
    • 另一些场景再 .withBaseInfo().withSomethingElse()
  • 单个 withXxx 内部逻辑变复杂也不怕,不会把一个函数搞成 200 行 if-else

2)什么时候才值得用 Builder 风格?

简单粗暴的判断:

  • 值得上 Builder 的情况

    • payload 真的是**「很多块拼起来」**的;
    • 不同业务场景需要「选择性启用某些块」;
    • 每块内部逻辑都可能变得很复杂(多条件、多分支、甚至异步)。
  • 没必要上 Builder 的情况

    • 只是把 3~5 个字段丢进对象里;
    • 变动很少,大部分字段都是 1:1 映射;
    • 没有复杂的组合逻辑。

换句话说:

Builder 风格的价值,在于把一个复杂构建过程拆成多个可组合的小模块
如果你的业务没有复杂到这个程度,
现在这套 formPayloadMap[refName](vm) + 少量 if,其实已经刚刚好。

3)和前面几级抽象的关系

可以把这几层理解成「渐进增强」:

  • Lv.3 高级前端:

    getPayload(vm, buildPayload, refName)

    • 通用流程 + 策略函数,已经很好用了。
  • Lv.4/架构阶段:

    配一个 formPayloadMap[refName] = buildPayload

    • 把“谁负责构建什么”集中管理。
  • Builder 风格:

    在单个 buildPayload(vm) 的实现内部,如果逻辑变复杂,再引入 PayloadBuilder

    • 是对某一个领域的构建细节做进一步拆分,而不是推翻整个体系。

也就是说,Builder 不是替代前面的抽象,而是为「某个复杂 payload」加的一层“精细化构建工具”。


七、这条路还能怎么走?一些扩展方向思路 🚀

最后顺带聊几个可以继续进化的方向,你可以根据项目实际情况慢慢加,不用一口吃胖子。

1)配合 TypeScript 做强类型约束

当前的写法都是 JS 靠自觉:

  • formPayloadMap 的 key/返回结构完全靠约定;
  • getPayload 的返回 { ok, payload } 也没有类型提示。

如果用 TS,可以做几件事:

  • formPayloadMap 建立一个统一的类型 Map:

    • 比如:type FormPayloadMap = { reviewForm: ReviewPayload; baseForm: BasePayload; ... }
  • getPayload 根据 refName 返回不同的 payload 类型(泛型 + 索引类型);

  • PayloadBuilder 每个 withXxx() 加上返回类型约束,防止漏字段/写错字段名。

好处是:一旦后端改了字段,TS 能第一时间把相关代码全标红,你就不需要靠“全局搜索 + 祈祷”。

2)统一成“多表单聚合”的流程

很多真实页面不是只有一个子表单,而是:

顶层页面 → N 个子块(基础信息、自评、报价、附件……)
最后统一点一个「提交」,要校验所有子块,组合所有 payload。

在现有基础上,可以设计一个“多表单聚合器”,伪代码例如:

async function collectAllPayloads(vm, configList) {
  const allPayload = {};

  for (const cfg of configList) {
    const { refName, mountPoint } = cfg;
    const res = await getPayload(vm, refName);
    if (!res.ok) return { ok: false };

    // mountPoint 决定这块 payload 挂在最终对象的哪里
    allPayload[mountPoint] = res.payload;
  }

  return { ok: true, payload: allPayload };
}

调用时:

const res = await collectAllPayloads(this, [
  { refName: 'baseForm', mountPoint: 'baseInfo' },
  { refName: 'reviewForm', mountPoint: 'review' },
  { refName: 'priceForm', mountPoint: 'price' },
]);

if (!res.ok) return;
await api.submit(res.payload);

这时:

  • getPayload单个表单的能力
  • collectAllPayloads多表单聚合的能力
  • 再配合 Builder,你就有了一条从“字段级 → 模块级 → 表单级 → 全页级”的构建链路。

3)把“校验 + 构建”做成可插拔中间件

现在,getPayload 里,校验逻辑顺序是写死的:

校验 → 错误提示 → 构建 payload

如果业务越来越复杂,可以考虑做成类似“中间件管线”的模式,比如:

const pipeline = [
  validateFormStep,
  extraAsyncCheckStep,
  normalizeDataStep,
  buildPayloadStep,
];

runPipeline(vm, refName, pipeline);

每个 step 接收 context(比如 { vm, form, payload }),按顺序处理。
这样你可以:

  • 在某些表单插入额外风控校验;
  • 在某些表单前面加数据归一化逻辑;
  • 保持主流程一致但允许个性化“插片”。

这就已经非常接近「前端领域里自己的 mini-framework」了。

4)跨框架 / 跨项目复用

你现在的设计,其实已经很容易跨栈:

  • ref 概念:React 可以用 useRef + forwardRef 来模拟;
  • validate:绝大多数表单库都有类似 API;
  • payload 构造:和框架无关,本身就是纯函数/Builder。

如果你有多个项目(Vue2/Vue3/React 混合),理论上可以:

  • 把“构建规则”和“校验规则”抽到一个独立 npm 包;
  • 每个项目只写一层很薄的“外壳”,适配自己的 ref/组件体系。

这就是从“一个项目里的抽象”,升级成“多项目共享的领域包”。


你现在这整套,从最开始那段:

if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}
const payload = { ... };

一路演进到:

  • 通用 getPayload
  • 配置化 formPayloadMap
  • 按需引入 PayloadBuilder 做复杂构建
  • 再往外是多表单聚合、类型约束、流水线、跨项目复用
❌