打破框架枷锁:如何打造真正跨框架组件

188 阅读6分钟

提前说声:不是 Web Component 方式实现的!!!

文章结尾附上源码地址

开篇:当我们在谈论跨框架组件时,究竟在解决什么?

在Vue、React、Angular三分天下的今天,你是否经历过这样的困境?

  • 精心开发的组件库因技术栈更迭被迫重构
  • 业务需求变更导致多框架版本维护成本激增
  • 或是面对新兴框架时陷入重复造轮子的循环

据统计,超过67%的前端团队同时维护两个以上技术栈项目,而跨框架组件复用需求正以每年23%的速度增长。

本文将为你揭示一种突破性的解决方案——通过 编译时转换 实现真正意义上的 跨框架组件。不同于传统的Web Components方案,我们将借助 Svelte 的独特优势,打造一个能在React、Vue、Angular等任意框架中即插即用的画板组件。更令人振奋的是,这个方案 已在生产环境稳定运行

一、跨框架组件的本质探索

1.1 概念重定义:不只是代码复用

跨框架组件绝非简单的代码拷贝复用,其核心在于运行时零依赖框架特性解耦。真正的跨框架组件应具备:

  • 原子化运行能力:不依赖任何框架运行时
  • 标准化接口规范:统一的API设计与事件机制
  • 自适应渲染机制:自动适配宿主框架生命周期
  • 样式沙箱隔离:CSS作用域自动管控

1.2 价值矩阵:从成本控制到架构演进

维度传统方案痛点跨框架组件优势
开发成本多版本并行开发单代码库维护
升级维护版本碎片化严重统一技术债务管理
团队协作技术栈壁垒阻碍协作标准化组件接口
技术演进框架绑定风险渐进式架构升级
性能表现运行时开销叠加编译时优化输出

二、方案选型:从Web Components到编译时转换

2.1 传统方案的局限性

Web Components的三大困局

  1. 事件系统割裂:需手动注册Custom Events
  2. 属性传递障碍:复杂对象序列化难题
  3. 样式污染风险:Shadow DOM的封闭性反噬

开发跨框架的 展示类组件 还行(例如: ),涉及到多事件组件或者暴露组件内部方法等复杂组件就捉襟见肘。

适配器模式的隐藏成本

  • 底层逻辑适配层膨胀
  • 框架特性差异导致API妥协
  • 版本迭代的连锁更新压力

看着好像只需要关注各框架等渲染层,但是共享底层逻辑因为要适配,所以需要各种兼容,得不偿失!

2.2 Svelte的破局之道

Svelte编译时革命的三大杀器

  1. 零运行时开销:将组件编译为Vanilla JS,消除框架 运行时包袱
  2. 原子化打包:按需生成UMD/IIFE/ES模块, 适配任意构建体系
  3. 原生DOM操控:直接操作原生DOM元素, 规避虚拟DOM的性能损耗
// 核心架构示意图
[ Svelte组件 ] -> [ Svelte编译器 ] -> [ Vite打包: 纯净JS Bundle ] -> [ 任意框架环境 ]

三、实战:跨框架画板组件开发全流程

3.1 组件展示环节

画符文.gif 线上Vue项目引入使用的实例!

方法一:使用 npm/yarn安装,各框架中实例化使用

npm install usb-paint
import USBPaint from "usb-paint";
new USBPaint({
  target: document.querySelector("#root"),
});

方法二:使用 CDN 直接插入到 HTML,实例化使用

<script src="<https://cdn.jsdelivr.net/npm/usb-paint@latest/dist/usbpaint.iife.js>" type="text/javascript"></script>
<script>
  let a = new USBPaint({
    target: document.querySelector("#root"),
  });
</script>

3.2 核心实现解析

3.2.1 框架适配层

通过类封装实现框架无关实例化(core.ts

import Core from "./core.svelte";
import type { SvelteComponent } from "svelte";
import type { USBPaintOptionsType } from "./core.interface";

export class USBPaint {
  public option: USBPaintOptionsType = {};

  protected mainInstance: SvelteComponent | undefined;

  constructor(opt?: USBPaintOptionsType) {
	  // 接受参数
    this.option = opt || {};
    
		this._initRun();
  }

  // init || 初始化
  private _initRun(): void {
    // 节点
    let target = document.querySelector(this.option.target) as HTMLElement
    // 设置参数
	  ···
		// 挂载并实例化 Svelte实现的画板功能组件(import Core from "./core.svelte";)
    this.mainInstance = new Core({
      target: target, // 挂载的DOM节点
      props: {
	       parentDom: target,
         scale: this.option.scale || 10,
         ···
      } // 传递参数
    });
  }
}

3.2.2 画板功能实现

在具体功能实现组件(core.svelte)中接收参数,实例化canvas,实现画板的各种功能()

  • 接收参数(通过export修饰变量直接接收core.ts实例化组件过程中放到props中的参数)

    <script lang="ts">
      ···
      export let scale: number = 10;
      export let parentDom: HTMLElement;
    
    </script>
    
  • canvas实例化画板(Canvas渲染优化);

    <div style={usbdScale} class="usb-paint-container">
      <canvas id="usb-paint-canvas" style="touch-action: none;"></canvas>
    </div>
    
    <style lang="scss">
      .usb-paint-container {
        height: 100%;
        width: 100%;
      }
    </style>
    
     <script lang="ts">
    	 onMount(() => {
    	    canvasEl = document.getElementById("usb-paint-canvas") as HTMLCanvasElement;
    	    if (canvasEl) {
    	      // 实例化canvas元素
    	      ctx2d = canvasEl.getContext("2d") as CanvasRenderingContext2D;
    	      transRatio(canvasEl, ctx2d);
    	    }
    	  });
    	  
    	  // Canvas渲染优化:设置canvas的分辨率,使线条清晰
    	  let transRatio = (cvs: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
    	    const width = parentDom.offsetWidth;
    	    const height = parentDom.offsetHeight;
    	    const ratio = window.devicePixelRatio || 1;
    	    cvs.width = width * ratio;
    	    cvs.height = height * ratio;
    	    cvs.style.width = width + "px";
    	    cvs.style.height = height + "px";
    	    ctx.scale(ratio, ratio);
    	  };
      </script>
    
  • 实现绘画、橡皮擦、删除、导出图片等功能;

    • 画笔工具(笔触算法优化

         import { getStroke } from "perfect-freehand";
          
        // 画笔工具
        const freedomPaint = () => {
          if (canvasEl && ctx2d) {
            colorBrightness = calcColorBrightness(canvasEl, ctx2d);
            let points: number[][] = [];
            ctx2d.globalCompositeOperation = "source-over";
            // 设置绘制样式
      			ctx2d.fillStyle = "xxx";
            ctx2d.lineCap = "round";
            ctx2d.lineJoin = "round";
            ctx2d.imageSmoothingEnabled = true;
            
            // 开始绘制
            canvasEl.addEventListener("pointerdown", e => {
              e.preventDefault();
              points = [[e.offsetX, e.offsetY]];
              // 捕获指针事件,以便在指针移出画布时仍然能继续绘制
              canvasEl?.setPointerCapture(e.pointerId);
            });
      
            // 绘制过程
            canvasEl.addEventListener("pointermove", e => {
              e.preventDefault();
              points.push([e.offsetX, e.offsetY]);
             
              // 笔触算法优化:手绘风格画笔样式设置
              const pathData = getStroke(points, {
                simulatePressure: true,
                size: isEraser ? eraserLineWidth : lineOptions.lineWidth, // 线条宽度
                smoothing: lineOptions.lineSmooth, // 平滑度
                thinning: lineOptions.lineThin, // 线条随压力变化
                streamline: lineOptions.lineStream, // 线条流畅度
                start: { taper: lineOptions.lineStart }, // 开始处圆滑过渡
                end: { taper: lineOptions.lineEnd }, // 结束处圆滑过渡
                easing: t => Math.sin((t * Math.PI) / 2), // 缓动函数
              });
              ctx2d.beginPath();
              pathData.forEach(([x, y], index) => {
                if (index === 0) {
                  ctx2d.moveTo(x, y);
                } else {
                  ctx2d.lineTo(x, y);
                }
              });
              ctx2d.fill();
              ctx2d.closePath();
            });
      
            // 停止绘制
            canvasEl.addEventListener("pointerup", e => {
              e.preventDefault();
              canvasEl?.releasePointerCapture(e.pointerId); // 释放指针捕获
            });
      
            // 离开画布时停止绘制(可选,防止异常情况下未释放)
            canvasEl.addEventListener("pointerleave", e => {
              e.preventDefault();
            });
          }
        };
      
    • 橡皮擦工具

        // 橡皮工具
        const eraserTool = () => {
            ctx2d.globalCompositeOperation = "destination-out";
        };
      
    • 删除工具

      ctx2d.clearRect(0, 0, canvasEl.width, canvasEl.height);
      
    • 导出图片工具

        // 导出为图片
        const exportToFile = (canvas: HTMLCanvasElement) => {
          if (canvas) {
            let imgType = "image/png";
            const imageData = canvas.toDataURL(imgType);
            return imageData;
          } else {
            return "";
          }
        };
      

3.2.3 导出和多格式打包

  • 组件导出供第三方实例化使用(main.ts)

    // polyfill
    import "core-js/stable/symbol";
    import "core-js/stable/promise";
    
    import { USBPaint } from "./core.ts";
    
    // export
    export default USBPaint;
    
  • vite打包构建(vite.config.ts)

    使用vite进行打包构建,设置打包的时候生成多格式js,满足不同引入的要求

    import { defineConfig } from "vite";
    ···
    export default defineConfig({
      ···
      build: {
        minify: "terser",
        lib: {
          entry: resolve(__dirname, "./src/main.ts"), // 入口文件,根据你的项目路径调整
          name: "USBPaint",
          fileName: "usbpaint",
          formats: ["umd", "iife", "es"], // 使用 IIFE 格式生成一个自包含的文件
        },
        rollupOptions: {
          // input: {
          //   main: resolve(__dirname, "./src/main.ts"),
          // },
          output: {
            inlineDynamicImports: true, // 确保动态导入也在同一个文件中
          },
        },
      },
    });
    

    运行 npm run build 进行编译打包,生成 "umd", "iife", "es" 三种格式的纯javascript代码,最后发布的 npm 进行管理,大功告成!

当然,使用Svelte方案也是有局限性的,他开发的框架构组件无法像Web Components组件一样标签化使用,每次使用需要先实例化。

终极总结:跨框架组件的破局之道

通过本次实战,我们验证了编译时方案的关键优势:

真·零依赖:最终产物为纯净JS

开发友好:保留现代框架的开发体验

性能卓越:超越传统方案的渲染效率

生态兼容:无缝接入现有构建工具链

当你在为多框架支持苦恼时,不妨尝试这种**「一次编译,处处运行」**的新范式。这不仅是一个组件的实现方案,更是一种打破技术壁垒的思维方式。

技术没有银弹,但有最优解。选择能最大化技术投资回报的方案,才是工程智慧的体现。

源码地址