普通视图

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

寿司郎回应有消费者反映吃出金枪鱼寄生虫卵

2026年3月7日 17:24
近日,有消费者反映“自己在门头沟区某商场寿司郎吃出金枪鱼寄生虫卵”。 3月7日,寿司郎就此事回应媒体:“公司内部对此事件也非常重视,正在配合市场监督局进行相关检查,一切等检查结果确认。寿司郎一直视食品安全为品牌的生命线,食材的采购、运输以及店内存储严格按照国家规定的食品安全标准执行。如发现异常,我们必将刻不容缓进行整改。”(界面新闻)

2026全球开发者先锋大会本月将在上海举办

2026年3月7日 16:56
2026全球开发者先锋大会将于3月27日至29日在上海举行。这场面向全球开发者的盛会,将汇聚前沿技术与产业资源,推动人工智能领域的创新交流。 日前举行的大会媒体通气会上介绍,上海人工智能产业保持快速增长,2025年规上人工智能企业产业规模突破6300亿元,同比增长近四成。截至目前,上海已完成149款生成式人工智能服务备案,产业正成为拉动城市经济增长的重要引擎。 本届大会将以开幕式为核心,围绕科学智能、AI应用、智能终端等领域,邀请开发者分享实践经验。大会期间还将举办开发者赛事、主题工作坊和公开课,聚焦智能体落地、数字内容生态等前沿议题。互动体验区将集中呈现AI赋能的实际应用场景,让参会者近距离感受技术如何改变生活。(央视新闻)

上交所总经理蔡建春:科创板要落实好关键核心技术领域企业常态化上市机制

2026年3月7日 16:27
政府工作报告中提到“提升直接融资、股权融资比例”,全国政协委员、上交所总经理蔡建春3月7日在政协经济界别小组会议上表示,在市场稳定前提下,要进一步提升资本市场的功能发挥,重点支持科技创新类与转型升级类企业的融资并购,特别是在科创板,落实好关键核心技术领域的科技型企业的常态化上市机制,进一步强化常态化融资功能。(证券时报)

华软科技拟3.24亿元收购莱恩光电67.411%股权

2026年3月7日 15:58
3月7日,金陵华软科技股份有限公司(以下简称“华软科技”)发布公告称,公司拟以3.24亿元收购山东莱恩光电科技股份有限公司(以下简称“莱恩光电”)67.411%的股权,交易完成后莱恩光电将成为其控股子公司并纳入合并财务报表范围。本次交易不构成关联交易及重大资产重组。(证券日报)

wc Cheatsheet

Basic Syntax

Core wc command forms.

Command Description
wc file.txt Show lines, words, and bytes for a file
wc file1 file2 Show counts per file and a total line
wc *.log Count all matching files
command | wc Count output from another command
wc --help Show available options

Common Count Flags

Use flags to request specific counters.

Command Description
wc -l file.txt Count lines only
wc -w file.txt Count words only
wc -c file.txt Count bytes only
wc -m file.txt Count characters only
wc -L file.txt Show longest line length

Useful Pipelines

Practical wc combinations for quick shell checks.

Command Description
ls -1 | wc -l Count directory entries (one per line)
grep -r "ERROR" /var/log | wc -l Count matching log lines
find . -type f | wc -l Count files recursively
ps aux | wc -l Count process list lines
cat file.txt | wc -w Count words from stdin

Multi-File Counting

Summarize multiple files and totals.

Command Description
wc -l *.txt Line count for each file plus total
wc -w docs/*.md Word count per Markdown file and total
wc -c part1 part2 part3 Byte count per file and combined total
wc -m *.csv Character count for each CSV file
wc -L *.log Max line length per file and max total

Script-Friendly Patterns

Extract numeric output safely in scripts.

Command Description
count=$(wc -l < file.txt) Capture pure line count without filename
words=$(wc -w < file.txt) Capture word count only
bytes=$(wc -c < file.txt) Capture byte count only
if [ "$(wc -l < file.txt)" -gt 1000 ]; then ... fi Threshold check in scripts
printf '%s\n' "$text" | wc -m Count characters in a variable

Troubleshooting

Quick checks for common wc confusion.

Issue Check
Count includes filename Use input redirection: wc -l < file
Unexpected word count Confirm whitespace and delimiters in the file
Character and byte counts differ Use -m for characters and -c for bytes
Total line missing with many files Ensure shell glob matches at least one file
Pipeline count seems off by one Some commands add headers; account for that

Related Guides

Use these guides for complete text-processing workflows.

Guide Description
linux wc Command Full wc guide with detailed examples
head Command in Linux Show first lines of files
tail Command in Linux Show last lines and follow logs
grep Command in Linux Search and filter matching lines
find Files in Linux Build file lists for counting

打造你的HTML5打地鼠游戏:零基础入门实践

作者 奇迹_h
2026年3月7日 13:58

打造你的HTML5打地鼠游戏:零基础入门实践

创建游戏结构

1. HTML布局

首先,我们需要创建一个基本的HTML页面,它将包含游戏的布局和地鼠洞。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>洛可可白⚡️打地鼠</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div id="game-container">
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <!-- 更多地鼠洞 -->
    </div>
    <script src="script.js"></script>
</body>
</html>

设计游戏样式

2. CSS样式

接下来,我们将使用CSS来美化我们的游戏界面。

      /* styles.css */
      * {
        box-sizing: border-box;
      }

      h1 {
        text-align: center;
        line-height: 30px;
      }

      .bigBox {
        width: 60%;
        height: 400px;
        margin: 20px auto;
        background-color: #cbbb3e;
      }

      .wam-container {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        align-items: center;
        height: 260px;
      }

      .wam-hole {
        position: relative;
        width: 100px;
        height: 100px;
        margin: 0 20px;
        background-color: #f5732d;
      }

      .wam-mole {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        /* 地鼠 */
        background-image: url("https://pic.52112.com/180516/EPS180516_57/9jagBhddHW_small.jpg");
        background-size: 100% 100%;
        display: none;
      }

      .wam-mole--up {
        display: block;
      }

      .wam-score {
        font-size: 2rem;
        text-align: center;
      }

      .wam-message {
        font-size: 1rem;
        text-align: center;
        margin-top: 20px;
        cursor: pointer;
      }

/* 你可以添加更多的CSS来美化地鼠洞和地鼠 */

实现游戏逻辑

3. JavaScript编程

现在,我们将使用JavaScript来添加游戏逻辑。

const container = document.querySelector(".wam-container");
      const scoreBoard = document.querySelector(".wam-score");
      const message = document.querySelector(".wam-message");
      const moles = Array.from(container.querySelectorAll(".wam-hole"));

      let lastHole;
      let score = 0;
      let isPlaying = false;
      let timeUp = false;

      // 随机时间生成地鼠
      function popUpMole() {
        if (timeUp) return;
        const time = Math.random() * (1500 - 500) + 500;
        const hole = randomHole(moles);
        hole.querySelector("div").classList.add("wam-mole--up");
        setTimeout(() => {
          hole.querySelector("div").classList.remove("wam-mole--up");
          if (!timeUp) popUpMole();
        }, time);
      }

      // 随机选择一个地鼠洞
      function randomHole(holes) {
        const idx = Math.floor(Math.random() * holes.length);
        const hole = holes[idx];
        if (hole === lastHole) return randomHole(holes);
        lastHole = hole;
        return hole;
      }

      // 点击地鼠
      function whackMole(e) {
        if (!e.isTrusted) return; // 防止作弊
        if (!isPlaying) return;
        if (!e.target.matches(".wam-mole")) return;
        score++;
        scoreBoard.textContent = `分数: ${score}`;
        e.target.parentNode
          .querySelector("div")
          .classList.remove("wam-mole--up");
      }
      // 开始游戏
      function startGame() {
        score = 0;
        scoreBoard.textContent = "分数: 0";
        isPlaying = true;
        timeUp = false;
        message.textContent = "";
        popUpMole();
        setTimeout(() => {
          isPlaying = false;
          timeUp = true;
          message.textContent = `一分钟您的得分是: ${score};点我再来一次!`;
        }, 60000);
      }

      // 初始化地鼠洞
      moles.forEach((mole) => mole.addEventListener("click", whackMole));
      document
        .querySelector(".wam-message")
        .addEventListener("click", startGame);

这段代码创建了一个简单的游戏循环,每秒钟随机显示一个地鼠,并在用户点击地鼠时给予反馈。你可以根据需要调整地鼠出现的速度和游戏的其他方面。

全部代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>洛可可白⚡️打地鼠</title>
    <style>
      * {
        box-sizing: border-box;
      }

      h1 {
        text-align: center;
        line-height: 30px;
      }

      .bigBox {
        width: 60%;
        height: 400px;
        margin: 20px auto;
        background-color: #cbbb3e;
      }

      .wam-container {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        align-items: center;
        height: 260px;
      }

      .wam-hole {
        position: relative;
        width: 100px;
        height: 100px;
        margin: 0 20px;
        background-color: #f5732d;
      }

      .wam-mole {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        /* 地鼠 */
        background-image: url("https://pic.52112.com/180516/EPS180516_57/9jagBhddHW_small.jpg");
        background-size: 100% 100%;
        display: none;
      }

      .wam-mole--up {
        display: block;
      }

      .wam-score {
        font-size: 2rem;
        text-align: center;
      }

      .wam-message {
        font-size: 1rem;
        text-align: center;
        margin-top: 20px;
        cursor: pointer;
      }
    </style>
  </head>

  <body>
    <h1>打地鼠</h1>
    <div class="bigBox">
      <div class="wam-container">
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
        <div class="wam-hole">
          <div class="wam-mole"></div>
        </div>
      </div>
      <div class="wam-score">分数: 0</div>
      <div class="wam-message">准备好了吗?点击我开始</div>
    </div>

    <script>
      const container = document.querySelector(".wam-container");
      const scoreBoard = document.querySelector(".wam-score");
      const message = document.querySelector(".wam-message");
      const moles = Array.from(container.querySelectorAll(".wam-hole"));

      let lastHole;
      let score = 0;
      let isPlaying = false;
      let timeUp = false;

      // 随机时间生成地鼠
      function popUpMole() {
        if (timeUp) return;
        const time = Math.random() * (1500 - 500) + 500;
        const hole = randomHole(moles);
        hole.querySelector("div").classList.add("wam-mole--up");
        setTimeout(() => {
          hole.querySelector("div").classList.remove("wam-mole--up");
          if (!timeUp) popUpMole();
        }, time);
      }

      // 随机选择一个地鼠洞
      function randomHole(holes) {
        const idx = Math.floor(Math.random() * holes.length);
        const hole = holes[idx];
        if (hole === lastHole) return randomHole(holes);
        lastHole = hole;
        return hole;
      }

      // 点击地鼠
      function whackMole(e) {
        if (!e.isTrusted) return; // 防止作弊
        if (!isPlaying) return;
        if (!e.target.matches(".wam-mole")) return;
        score++;
        scoreBoard.textContent = `分数: ${score}`;
        e.target.parentNode
          .querySelector("div")
          .classList.remove("wam-mole--up");
      }
      // 开始游戏
      function startGame() {
        score = 0;
        scoreBoard.textContent = "分数: 0";
        isPlaying = true;
        timeUp = false;
        message.textContent = "";
        popUpMole();
        setTimeout(() => {
          isPlaying = false;
          timeUp = true;
          message.textContent = `一分钟您的得分是: ${score};点我再来一次!`;
        }, 60000);
      }

      // 初始化地鼠洞
      moles.forEach((mole) => mole.addEventListener("click", whackMole));
      document
        .querySelector(".wam-message")
        .addEventListener("click", startGame);
    </script>
  </body>
</html>

如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴

作者 贾铭
2026年3月7日 13:25

前言

《实践论》中讲认识从实践始,经过实践得到了理论的认识,还须再回到实践去。

理论的东西之是否符合于客观真理性这个问题,在前面说的由感性到理性之认识运动中是没有完全解决的,也不能完全解决的。

要完全地解决这个问题,只有把理性的认识再回到社会实践中去,应用理论于实践,看它是否能够达到预想的目的。

时间轴

根据mdn文档所述,canvas有最大的宽高的限制

image.png

我们的视频缩略图和音频波形图是通过canvas绘制的,如果缩放时间轴,可能会超过这个最大宽度(画布会崩溃)

有如下方案:

  • 无界云剪是将缩略图通过图片拼接成一个很长的图片
  • 剪映是通过将canvas固定在一个最大宽度内,然后通过滚动+translate使canvas一直显示在视口
  • clideo是拆分成多个canvas
  • pro.diffusion.studio是整个时间轴通过canvas绘制出来

本文最终选取使用canvas把整个时间轴画出来这种方案

本文最终实现的效果如下

  1. 时间轴缩放(ctrl+滑轮)
  2. 视频轴、音频轴、文本轴的裁剪
  3. 轨道的对齐
  4. 视频缩略图、音频波形图的实现

动画1.gif

视频轨道

本节将实现基本的视频轨道绘制、视频缩略图的绘制

动画.gif

本节将使用上一篇文章介绍的mediabunny来进行视频抽帧

mediabunny最大的亮点是:将webcodecs回调模式读取VideoFrame转换为迭代器模式

  const sink = new CanvasSink(videoTrack, {
    width: this.thumbnailWidth,
    height: Math.round(thumbHeight),
    fit: 'contain'
  });
  for (let t = 0; t <= this.duration; t += DEFAULT_THUMBNAIL_STEP) {
    const result = await sink.getCanvas(t);
  }

我们选取1s为间隔抽取缩略图,并将缩略图转为ImageBitmap存在map中(这一步还能进行优化,可以将ImageBitmap降低分辨率,可以节省更多内存)

时间轴进行缩放时,取最近的缓存时间点缩略图,避免重复解码

const key = Math.round(time / step) * step;
const img = this.thumbnailCache.get(key);

完整代码如下:

import { Rect } from 'fabric';
import { ALL_FORMATS, BlobSource, CanvasSink, Input } from 'mediabunny';
import { ClipType } from '../types';

/** 默认缩略图高度(像素) */
const DEFAULT_THUMBNAIL_HEIGHT = 52;
/** 默认视频宽高比 */
const DEFAULT_ASPECT_RATIO = 16 / 9;
/** 缩略图抽帧步长(秒) */
const DEFAULT_THUMBNAIL_STEP = 1;
/** 默认视频 URL */
const DEFAULT_VIDEO_URL = new URL(
  '../../../assets/test.mp4',
  import.meta.url
).toString();
/** 视频背景色 */
const VIDEO_BACKGROUND = '#1e1b4b';
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;

type VideoClipOptions = {
  id: string;
  left: number;
  top: number;
  width: number;
  height: number;
  src?: string;
};

export class VideoClip extends Rect {
  clipType: ClipType = 'video';
  elementId: string;
  /** 视频资源地址 */
  src: string;
  /** 视频源总时长(秒),用于裁剪边界约束 */
  sourceDuration = 0;
  /** 当前裁剪起点(秒),相对视频源时间轴 */
  trimStart = 0;
  /** 当前裁剪终点(秒),相对视频源时间轴 */
  trimEnd = 0;
  /** 预解码的缩略图列表与缓存 */
  private thumbnails: Array<{ time: number; image: CanvasImageSource }> = [];
  private thumbnailCache = new Map<number, CanvasImageSource>();
  /** 避免重复请求与解码 */
  private isLoading = false;
  /** 视频真实时长 */
  private duration = 0;
  /** 真实宽高比(用于缩略图铺排) */
  private aspectRatio = DEFAULT_ASPECT_RATIO;
  /** 单张缩略图宽度(像素) */
  private thumbnailWidth = 0;

  constructor(options: VideoClipOptions) {
    super({
      left: options.left,
      top: options.top,
      width: options.width,
      height: options.height,
      fill: VIDEO_BACKGROUND,
      stroke: null,
      strokeWidth: 0,
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: true,
      lockRotation: true,
      lockScalingY: true,
      lockScalingFlip: true,
      objectCaching: false,
      hoverCursor: 'move'
    });

    this.elementId = options.id;
    this.src = options.src ?? DEFAULT_VIDEO_URL;
    this.thumbnailWidth = Math.max(
      1,
      Math.round(
        (options.height || DEFAULT_THUMBNAIL_HEIGHT) * this.aspectRatio
      )
    );

    // 仅保留左右缩放控制点
    this.setControlsVisibility({
      tl: false,
      tr: false,
      bl: false,
      br: false,
      mt: false,
      mb: false,
      mtr: false,
      ml: true,
      mr: true
    });

    // 初始化缩略图加载,完成后会触发重绘
    this.loadThumbnails();
  }

  async loadThumbnails() {
    if (this.isLoading) return;
    this.isLoading = true;
    try {
      const response = await fetch(this.src);
      const blob = await response.blob();
      const input = new Input({
        formats: ALL_FORMATS,
        source: new BlobSource(blob)
      });

      // 读取视频真实时长,并同步裁剪边界
      this.duration = (await input.computeDuration()) || 0;
      this.sourceDuration = this.duration;
      // 初始化 trimEnd 为源时长,避免裁剪窗口超出视频长度
      if (this.trimEnd === 0 || this.trimEnd > this.sourceDuration) {
        this.trimEnd = this.sourceDuration;
      }
      // 若 trimStart 越界,则回退到 0
      if (this.trimStart > this.trimEnd) {
        this.trimStart = 0;
      }
      const videoTrack = await input.getPrimaryVideoTrack();
      if (!videoTrack) return;

      const canDecode = await videoTrack.canDecode();
      if (!canDecode) return;

      if (videoTrack.displayWidth && videoTrack.displayHeight) {
        this.aspectRatio = videoTrack.displayWidth / videoTrack.displayHeight;
      }

      const thumbHeight = this.height || DEFAULT_THUMBNAIL_HEIGHT;
      this.thumbnailWidth = Math.max(
        1,
        Math.round(thumbHeight * this.aspectRatio)
      );

      const sink = new CanvasSink(videoTrack, {
        width: this.thumbnailWidth,
        height: Math.round(thumbHeight),
        fit: 'contain'
      });

      // 均匀采样缩略图并缓存,避免每次 render 重复解码
      const thumbnails: Array<{ time: number; image: CanvasImageSource }> = [];
      const thumbnailCache = new Map<number, CanvasImageSource>();
      for (let t = 0; t <= this.duration; t += DEFAULT_THUMBNAIL_STEP) {
        const result = await sink.getCanvas(t);
        if (!result) continue;
        const canvas = result.canvas;
        const image = await createImageBitmap(canvas);
        const time = result.timestamp ?? t;
        thumbnails.push({ time, image });
        const key =
          Math.round(time / DEFAULT_THUMBNAIL_STEP) * DEFAULT_THUMBNAIL_STEP;
        thumbnailCache.set(key, image);
      }

      this.thumbnails = thumbnails;
      this.thumbnailCache = thumbnailCache;
      this.canvas?.requestRenderAll();
    } catch (error) {
      console.error('VideoClip loadThumbnails error:', error);
    } finally {
      this.isLoading = false;
    }
  }

  _render(ctx: CanvasRenderingContext2D) {
    ctx.save();

    // 反向缩放,让绘制逻辑用屏幕像素坐标
    const scaleX = this.scaleX || 1;
    const scaleY = this.scaleY || 1;
    ctx.scale(1 / scaleX, 1 / scaleY);

    const width = (this.width || 0) * scaleX;
    const height = (this.height || 0) * scaleY;
    const radius = this.rx || 6;

    // 以圆角矩形作为裁剪区域
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.clip();

    // 绘制底色,缩略图缺失时仍有可视背景
    ctx.fillStyle = VIDEO_BACKGROUND;
    ctx.fillRect(-width / 2, -height / 2, width, height);

    if (this.thumbnails.length > 0 && width > 0 && height > 0) {
      // 以裁剪窗口作为缩略图采样范围
      const trimStart = Math.max(0, this.trimStart || 0);
      const trimEnd = Math.max(trimStart, this.trimEnd || 0);
      const trimDuration = trimEnd - trimStart;
      if (trimDuration <= 0) {
        ctx.restore();
        return;
      }
      // 依据显示高度与视频宽高比计算单张缩略图宽度
      const thumbWidth = Math.max(1, Math.round(height * this.aspectRatio));
      // 根据显示宽度计算可容纳的缩略图数量
      const visibleCount = Math.max(1, Math.ceil(width / thumbWidth));
      const step = DEFAULT_THUMBNAIL_STEP;
      // 在裁剪区间内均匀采样对应数量的时间点
      const timeStep = trimDuration / visibleCount;

      for (let i = 0; i < visibleCount; i += 1) {
        const time = trimStart + i * timeStep;
        // 取最近的缓存时间点缩略图,避免重复解码
        const key = Math.round(time / step) * step;
        const img = this.thumbnailCache.get(key);
        if (!img) continue;
        // 缩略图按等宽平铺,保持宽高比不变
        const x = -width / 2 + i * thumbWidth;
        const drawWidth = Math.min(thumbWidth, width - i * thumbWidth);
        if (drawWidth <= 0) continue;
        ctx.drawImage(img, x, -height / 2, drawWidth, height);
      }
    }

    ctx.restore();

    // 绘制边框(在裁剪区域外,确保边框宽度不随缩放变化)
    ctx.save();
    ctx.scale(1 / scaleX, 1 / scaleY);
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();
    ctx.restore();
  }
}

最小使用demo:

import { Canvas } from 'fabric';
import { useEffect, useRef } from 'react';
import { VideoClip } from '../../core/timeline/clips/video-clip';

export default function VideoClipDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 200,
      backgroundColor: '#0f172a'
    });

    const videoClip = new VideoClip({
      id: 'demo-video-1',
      left: 50,
      top: 70,
      width: 300,
      height: 60
    });

    canvas.add(videoClip);
    canvas.setActiveObject(videoClip);

    return () => {
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

音频轨道

上篇文章中,我们使用konva完成了音频波形图的绘制,在这一节中将会对它进行优化

原始音频本质是 PCM 采样数据(一秒可能 44100 个点),
如果你直接一个点一个点画,性能会炸。

所以这里做了一件非常关键的事:降采样 + 取峰值

extractWaveformData() 里做了三件事:

  1. 只取第一个声道
  2. 每秒固定抽 100 个“波形点”
  3. 每个点不存所有数据,而是只存:这一小段里的 最小值最大值 [min, max, min, max, min, max...]

这样做的好处是:数据量大幅减少,并且视觉上还能保留波形“形状”

动画.gif

import { Rect } from 'fabric';
import { ALL_FORMATS, BlobSource, Input } from 'mediabunny';
import { ClipType } from '../types';

/** 默认音频文件 URL */
const DEFAULT_AUDIO_URL = new URL(
  '../../../assets/1.wav',
  import.meta.url
).toString();

/** 波形颜色(绿色) */
const WAVEFORM_COLOR = '#22c55e';
/** 波形背景颜色(深绿色) */
const WAVEFORM_BACKGROUND = '#14532d';
/** 每秒采样的波形数据点数 */
const WAVEFORM_SAMPLES_PER_SECOND = 100;
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;

/** AudioClip 构造选项 */
type AudioClipOptions = {
  id: string;
  left: number;
  top: number;
  width: number;
  height: number;
  src?: string;
};

export class AudioClip extends Rect {
  clipType: ClipType = 'audio';
  /** 对应业务 Clip 的唯一标识 */
  elementId: string;
  /** 音频资源地址 */
  src: string;
  /** 音频源总时长(秒),用于裁剪边界约束 */
  sourceDuration = 0;
  /** 当前裁剪起点(秒),相对音频源时间轴 */
  trimStart = 0;
  /** 当前裁剪终点(秒),相对音频源时间轴 */
  trimEnd = 0;
  /** 预解码的波形数据(每个采样点包含 min 和 max 两个值) */
  private waveformData: Float32Array | null = null;
  /** 加载状态标记,避免重复加载 */
  private isLoading = false;
  /** 音频缓冲区,用于提取波形数据 */
  private audioBuffer: AudioBuffer | null = null;

  constructor(options: AudioClipOptions) {
    super({
      left: options.left,
      top: options.top,
      width: options.width,
      height: options.height,
      fill: WAVEFORM_BACKGROUND,
      stroke: null,
      strokeWidth: 0,
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: true,
      lockRotation: true,
      lockScalingY: true,
      lockScalingFlip: true,
      objectCaching: false,
      hoverCursor: 'move'
    });

    this.elementId = options.id;
    this.src = options.src ?? DEFAULT_AUDIO_URL;

    // 仅保留左右缩放控制点,允许裁剪式缩放
    this.setControlsVisibility({
      tl: false,
      tr: false,
      bl: false,
      br: false,
      mt: false,
      mb: false,
      mtr: false,
      ml: true,
      mr: true
    });

    this.loadAudio();
  }

  async loadAudio() {
    if (this.isLoading) return;
    this.isLoading = true;

    try {
      const response = await fetch(this.src);
      const blob = await response.blob();

      const input = new Input({
        formats: ALL_FORMATS,
        source: new BlobSource(blob)
      });

      this.sourceDuration = (await input.computeDuration()) || 0;

      // 初始化裁剪窗口,确保不超过音频时长
      if (this.trimEnd === 0 || this.trimEnd > this.sourceDuration) {
        this.trimEnd = this.sourceDuration;
      }
      if (this.trimStart > this.trimEnd) {
        this.trimStart = 0;
      }

      const arrayBuffer = await blob.arrayBuffer();
      const audioContext = new AudioContext();
      this.audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
      audioContext.close();

      // 提取波形数据
      this.extractWaveformData();
      this.canvas?.requestRenderAll();
    } catch (error) {
      console.error('AudioClip loadAudio error:', error);
    } finally {
      this.isLoading = false;
    }
  }

  /**
   * 从音频缓冲区提取波形数据
   * 将原始音频采样降采样为固定数量的峰值点,用于高效渲染
   */
  private extractWaveformData() {
    if (!this.audioBuffer || this.sourceDuration <= 0) return;

    // 获取第一个声道的音频数据
    const channelData = this.audioBuffer.getChannelData(0);
    const samples = channelData.length;
    // 计算目标采样点数(每秒 100 个点)
    const targetSamples = Math.ceil(
      this.sourceDuration * WAVEFORM_SAMPLES_PER_SECOND
    );

    // 每个采样点存储 min 和 max 两个值
    this.waveformData = new Float32Array(targetSamples * 2);

    // 计算每个目标采样点对应的原始采样数
    const samplesPerPeak = Math.floor(samples / targetSamples);

    // 遍历所有目标采样点,计算每个区间的峰值
    for (let i = 0; i < targetSamples; i++) {
      const start = i * samplesPerPeak;
      const end = Math.min(start + samplesPerPeak, samples);

      let min = 0;
      let max = 0;

      // 在当前区间内查找最小值和最大值
      for (let j = start; j < end; j++) {
        const value = channelData[j];
        if (value < min) min = value;
        if (value > max) max = value;
      }

      // 存储峰值数据
      this.waveformData[i * 2] = min;
      this.waveformData[i * 2 + 1] = max;
    }
  }

  /**
   * 重写渲染逻辑,绘制音频波形
   * 根据裁剪窗口只显示 trimStart 到 trimEnd 区间的波形
   */
  _render(ctx: CanvasRenderingContext2D) {
    ctx.save();

    // 反向缩放,让绘制逻辑用屏幕像素坐标
    const scaleX = this.scaleX || 1;
    const scaleY = this.scaleY || 1;
    ctx.scale(1 / scaleX, 1 / scaleY);

    const width = (this.width || 0) * scaleX;
    const height = (this.height || 0) * scaleY;
    const radius = this.rx || 6;

    // 以圆角矩形作为裁剪区域
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.clip();

    // 绘制背景色
    ctx.fillStyle = WAVEFORM_BACKGROUND;
    ctx.fillRect(-width / 2, -height / 2, width, height);

    // 绘制波形数据
    if (this.waveformData && this.sourceDuration > 0) {
      // 获取裁剪窗口
      const trimStart = Math.max(0, this.trimStart || 0);
      const trimEnd = Math.max(trimStart, this.trimEnd || 0);
      const trimDuration = trimEnd - trimStart;

      if (trimDuration > 0) {
        const totalSamples = this.waveformData.length / 2;
        // 计算裁剪区间对应的采样点范围
        const startSample = Math.floor(
          (trimStart / this.sourceDuration) * totalSamples
        );
        const endSample = Math.ceil(
          (trimEnd / this.sourceDuration) * totalSamples
        );
        const visibleSamples = endSample - startSample;

        const centerY = 0;
        const halfHeight = height / 2 - 4;

        ctx.fillStyle = WAVEFORM_COLOR;

        // 绘制裁剪区间内的波形
        for (let i = 0; i < visibleSamples; i++) {
          const sampleIndex = startSample + i;
          if (sampleIndex * 2 + 1 >= this.waveformData.length) break;

          const min = this.waveformData[sampleIndex * 2];
          const max = this.waveformData[sampleIndex * 2 + 1];

          // 计算当前波形条的 x 坐标
          const x = -width / 2 + (i / visibleSamples) * width;
          const barWidth = Math.max(1, width / visibleSamples);

          // 计算波形条的 y 坐标范围
          const minY = centerY + min * halfHeight;
          const maxY = centerY + max * halfHeight;

          // 绘制波形条
          ctx.fillRect(x, minY, barWidth, maxY - minY);
        }
      }
    } else if (this.isLoading) {
      // 加载中显示提示文字
      ctx.fillStyle = 'rgba(255,255,255,0.5)';
      ctx.font = '12px Inter, sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('Loading...', 0, 0);
    }

    ctx.restore();

    // 绘制边框(在裁剪区域外,确保边框宽度不随缩放变化)
    ctx.save();
    ctx.scale(1 / scaleX, 1 / scaleY);
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();
    ctx.restore();
  }

  /**
   * 获取音频缓冲区
   * 可用于音频播放等功能
   */
  getAudioBuffer(): AudioBuffer | null {
    return this.audioBuffer;
  }

  /**
   * 获取音频源总时长
   * 用于裁剪边界约束
   */
  getSourceDuration(): number {
    return this.sourceDuration;
  }
}
import { Canvas } from 'fabric';
import { useEffect, useRef } from 'react';
import { AudioClip } from '../../core/timeline/clips/audio-clip';

export default function AudioClipDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 200,
      backgroundColor: '#0f172a'
    });

    const audioClip = new AudioClip({
      id: 'demo-audio-1',
      left: 50,
      top: 70,
      width: 300,
      height: 60
    });

    canvas.add(audioClip);
    canvas.setActiveObject(audioClip);

    return () => {
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

文本轨道

import { Rect } from 'fabric';
import { ClipType } from '../types';

/** 文本 Clip 背景色 */
const TEXT_CLIP_BACKGROUND = '#134e4a';
/** 边框颜色 */
const BORDER_COLOR = 'rgba(255,255,255,0.3)';
/** 边框宽度 */
const BORDER_WIDTH = 1;

export class TextClip extends Rect {
  clipType: ClipType = 'text';
  elementId: string;
  /** 显示在块内的文字内容 */
  label: string;

  constructor(options: {
    id: string;
    text: string;
    left: number;
    top: number;
    width: number;
    height: number;
  }) {
    super({
      left: options.left,
      top: options.top,
      width: options.width,
      height: options.height,
      fill: TEXT_CLIP_BACKGROUND,
      stroke: null,
      strokeWidth: 0,
      rx: 8,
      /** 圆角 Y */
      ry: 8,
      selectable: true,
      hasControls: true,
      lockRotation: true,
      /** 锁定纵向缩放 */
      lockScalingY: true,
      /** 禁止缩放翻转(避免控制块反向导致的 clip 翻转) */
      lockScalingFlip: true,
      /** 禁用缓存,保证 _render 反向缩放逻辑直接作用于主画布 */
      objectCaching: false,
      hoverCursor: 'move'
    });
    this.elementId = options.id;
    this.label = options.text;

    // 仅保留左右缩放控制点,避免垂直方向缩放
    this.setControlsVisibility({
      tl: false,
      tr: false,
      bl: false,
      br: false,
      mt: false,
      mb: false,
      mtr: false,
      ml: true,
      mr: true
    });
  }

  /**
   * 重写渲染逻辑,在矩形块中绘制文本
   * 手动绘制圆角矩形背景和边框,确保缩放时不变形
   */
  _render(ctx: CanvasRenderingContext2D) {
    ctx.save();

    // 反向缩放,让绘制逻辑用屏幕像素坐标
    const scaleX = this.scaleX || 1;
    const scaleY = this.scaleY || 1;
    ctx.scale(1 / scaleX, 1 / scaleY);

    const width = (this.width || 0) * scaleX;
    const height = (this.height || 0) * scaleY;
    const radius = this.rx || 8;

    // 手动绘制圆角矩形背景,确保圆角不随缩放变形
    ctx.beginPath();
    ctx.roundRect(-width / 2, -height / 2, width, height, radius);
    ctx.fillStyle = TEXT_CLIP_BACKGROUND;
    ctx.fill();

    // 绘制边框,确保边框宽度不随缩放变化
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();

    // 绘制文本
    ctx.fillStyle = 'rgba(255,255,255,0.9)';
    ctx.font = '12px Inter, sans-serif';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';

    // 移动到左边缘 8 像素,垂直居中位置
    ctx.fillText(this.label, -width / 2 + 8, 0);

    ctx.restore();
  }
}

滚动条

动画.gif

滑块宽度怎么算?barWidth = (视口宽度 / 内容宽度) * 轨道宽度

同时还加了:minWidth = 40防止内容太多时滑块小到点不到

滑块位置怎么算?leftOffset = (当前滚动 / 最大滚动距离) * 可滑动距离 可滑动距离 = 轨道总宽度 - 滑块自身宽度,可滑动距离也就是:滑块在轨道上“真正能移动的那一段距离”

import { Canvas } from 'fabric';
import { ITimeline, PointerEventLike } from '../types';

export type ScrollbarBar = {
  /** 滑块左边界 X 坐标 */
  left: number;
  /** 滑块右边界 X 坐标 */
  right: number;
  /** 滑块上边界 Y 坐标 */
  top: number;
  /** 滑块下边界 Y 坐标 */
  bottom: number;
  /** 最大可滚动距离(内容宽度 - 视口宽度) */
  maxOffset: number;
  /** 滚动轨道总宽度 */
  trackWidth: number;
  /** 滑块宽度 */
  barWidth: number;
};

/**
 * 1. 滚动条绘制在 Canvas 的顶层上下文(contextTop)上,不受 viewportTransform 影响
 * 2. 通过拦截 Canvas 的鼠标事件实现滚动条的拖拽交互
 * 3. 滑块宽度根据内容与视口的比例自动计算
 * 4. 当内容完全在视口内时自动隐藏滚动条
 */
export class HorizontalScrollbar {
  timeline: ITimeline;
  /** 滚动条滑块的高度(像素) */
  size = 8;
  /** 滚动条与画布边缘的间距(像素) */
  scrollSpace = 4;
  /** 滑块最小宽度,确保滑块始终可点击 */
  minWidth = 40;
  /** 滑块填充颜色 */
  fill = 'rgba(255,255,255,0.3)';
  /** 滑块边框颜色 */
  stroke = 'rgba(255,255,255,0.1)';
  /** 边框线宽 */
  lineWidth = 1;
  bar: ScrollbarBar | null = null;
  /** 是否处于拖拽滚动条状态 */
  dragging = false;
  /** 拖拽开始时的鼠标 X 坐标 */
  dragStartX = 0;
  /** 拖拽开始时的滚动位置 */
  dragStartScroll = 0;

  private originalMouseDown: ((e: PointerEventLike) => void) | null = null;
  private originalMouseMove: ((e: PointerEventLike) => void) | null = null;
  private originalMouseUp: ((e: PointerEventLike) => void) | null = null;

  constructor(timeline: ITimeline) {
    this.timeline = timeline;
    const canvas = timeline.canvas;

    const canvasInternal = canvas as unknown as {
      __onMouseDown?: (e: PointerEventLike) => void;
      _onMouseMove?: (e: PointerEventLike) => void;
      _onMouseUp?: (e: PointerEventLike) => void;
    };
    this.originalMouseDown = canvasInternal.__onMouseDown || null;
    this.originalMouseMove = canvasInternal._onMouseMove || null;
    this.originalMouseUp = canvasInternal._onMouseUp || null;

    canvasInternal.__onMouseDown = this.mouseDownHandler.bind(this);
    canvasInternal._onMouseMove = this.mouseMoveHandler.bind(this);
    canvasInternal._onMouseUp = this.mouseUpHandler.bind(this);

    this.beforeRenderHandler = this.beforeRenderHandler.bind(this);
    this.afterRenderHandler = this.afterRenderHandler.bind(this);
    canvas.on('before:render', this.beforeRenderHandler);
    canvas.on('after:render', this.afterRenderHandler);
  }

  dispose() {
    const canvas = this.timeline.canvas;
    const canvasInternal = canvas as unknown as {
      __onMouseDown?: (e: PointerEventLike) => void;
      _onMouseMove?: (e: PointerEventLike) => void;
      _onMouseUp?: (e: PointerEventLike) => void;
    };

    if (this.originalMouseDown)
      canvasInternal.__onMouseDown = this.originalMouseDown;
    if (this.originalMouseMove)
      canvasInternal._onMouseMove = this.originalMouseMove;
    if (this.originalMouseUp) canvasInternal._onMouseUp = this.originalMouseUp;

    // 移除渲染事件监听
    canvas.off('before:render', this.beforeRenderHandler);
    canvas.off('after:render', this.afterRenderHandler);
  }

  /**
   * 渲染前处理
   *
   * 重置 Canvas 顶层上下文的变换矩阵为单位矩阵。
   *
   * 为什么需要这样做?
   *
   * Fabric.js 在渲染时会应用 viewportTransform(用于实现滚动效果),
   * 这个变换会影响所有后续的绘制操作。但滚动条应该始终固定在视口底部,
   * 不应该随着内容滚动而移动。
   *
   * 通过在渲染前重置变换矩阵,我们确保滚动条的绘制坐标系
   * 始终与视口坐标系一致,不受滚动影响。
   */
  beforeRenderHandler() {
    const ctx = this.timeline.canvas.contextTop;
    if (!ctx) return;
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.restore();
  }

  /**
   * 渲染后处理 - 绘制滚动条
   *
   * 在 Canvas 主内容渲染完成后,在顶层上下文绘制滚动条滑块。
   * 滑块的宽度和位置根据内容与视口的比例计算。
   *
   * 计算公式:
   * 滑块宽度 = (视口宽度 / 内容宽度) * 轨道宽度
   * 滑块位置 = (当前滚动位置 / 最大滚动距离) * 可滑动距离
   */
  afterRenderHandler() {
    const canvas = this.timeline.canvas;
    const ctx = canvas.contextTop;
    if (!ctx) return;

    const contentWidth = this.timeline.contentWidth;

    /**
     * 当内容宽度不超过视口宽度时,隐藏滚动条
     * 这意味着所有内容都可见,不需要滚动。
     */
    if (contentWidth <= canvas.width) {
      this.bar = null;
      // 清除之前可能绘制的滚动条区域
      ctx.clearRect(
        0,
        canvas.height - this.size - this.scrollSpace - this.lineWidth,
        canvas.width,
        this.size + this.scrollSpace + this.lineWidth
      );
      return;
    }

    /**
     * 计算滚动轨道宽度
     * 轨道是滑块可滑动的区域,两侧留出间距
     */
    const trackWidth = canvas.width - this.scrollSpace * 2;

    /**
     * 计算滑块宽度
     * 滑块宽度反映视口占内容的比例:
     * - 内容越多,滑块越小
     * - 但最小不低于 minWidth,确保始终可点击
     */
    const barWidth = Math.max(
      Math.floor((canvas.width / contentWidth) * trackWidth),
      this.minWidth
    );

    /**
     * 计算最大可滚动距离
     * 即内容超出视口的部分
     */
    const maxOffset = contentWidth - canvas.width;

    /**
     * 计算滑块位置
     * 滑块位置 = 间距 + (滚动比例 * 可滑动距离)
     * 滚动比例 = 当前滚动位置 / 最大滚动距离
     * 可滑动距离 = 轨道宽度 - 滑块宽度
     */
    const leftOffset =
      (this.timeline.scrollX / maxOffset) * Math.max(0, trackWidth - barWidth);
    const left = this.scrollSpace + leftOffset;

    /**
     * 计算滑块垂直位置
     * 滑块位于画布底部,与底部边缘保持间距
     */
    const top = canvas.height - this.size - this.scrollSpace;

    /**
     * 保存滚动条几何信息
     * 用于后续的命中检测(判断鼠标是否点击在滑块上)
     */
    this.bar = {
      left,
      right: left + barWidth,
      top,
      bottom: top + this.size,
      maxOffset,
      trackWidth,
      barWidth
    };

    ctx.clearRect(
      0,
      canvas.height - this.size - this.scrollSpace - this.lineWidth,
      canvas.width,
      this.size + this.scrollSpace + this.lineWidth
    );

    ctx.save();
    ctx.fillStyle = this.fill;
    ctx.strokeStyle = this.stroke;
    ctx.lineWidth = this.lineWidth;
    ctx.beginPath();
    ctx.roundRect(left, top, barWidth, this.size, this.size / 2);
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }

  /**
   * 鼠标按下事件处理
   * 判断鼠标是否点击在滚动条滑块上:
   * - 如果是,进入拖拽模式,阻止事件继续传播
   * - 如果不是,调用 Canvas 原始的鼠标按下处理
   *
   */
  mouseDownHandler(e: PointerEventLike) {
    const canvas = this.timeline.canvas;

    /**
     * 获取鼠标在视口坐标系中的位置
     * getViewportPoint 返回的是相对于画布左上角的坐标,
     * 不受 viewportTransform 影响,适合用于滚动条命中检测
     */
    const p = canvas.getViewportPoint(e);

    if (this.bar) {
      /**
       * 命中检测:判断鼠标坐标是否在滑块矩形范围内
       */
      const hit =
        p.x >= this.bar.left &&
        p.x <= this.bar.right &&
        p.y >= this.bar.top &&
        p.y <= this.bar.bottom;

      if (hit) {
        /**
         * 进入拖拽模式
         * 记录拖拽起始状态:
         * - dragStartX: 鼠标起始 X 坐标
         * - dragStartScroll: 起始滚动位置
         *
         * 后续在 mouseMoveHandler 中根据鼠标移动距离计算新的滚动位置
         */
        this.dragging = true;
        this.dragStartX = p.x;
        this.dragStartScroll = this.timeline.scrollX;
        return; // 阻止事件继续传播,不调用原始处理函数
      }
    }

    /**
     * 未命中滚动条,调用 Canvas 原始的鼠标按下处理
     * 通过原型链调用原始方法,确保 Fabric.js 的正常交互(如选择对象)不受影响
     */
    const proto = Canvas.prototype as unknown as {
      __onMouseDown: (e: PointerEventLike) => void;
    };
    return proto.__onMouseDown.call(canvas, e);
  }

  /**
   * 鼠标移动事件处理
   * 如果处于拖拽模式,根据鼠标移动距离更新滚动位置;
   * 否则调用 Canvas 原始的鼠标移动处理。
   */
  mouseMoveHandler(e: PointerEventLike) {
    /**
     * 非拖拽状态,调用原始处理函数
     */
    if (!this.dragging || !this.bar) {
      const proto = Canvas.prototype as unknown as {
        _onMouseMove: (e: PointerEventLike) => void;
      };
      return proto._onMouseMove.call(this.timeline.canvas, e);
    }

    const canvas = this.timeline.canvas;
    const p = canvas.getViewportPoint(e);

    /**
     * 计算滚动位置
     * 滚动距离映射:
     * - 鼠标移动距离(像素) -> 滚动距离(像素)
     * - 比例 = 鼠标移动距离 / 可滑动距离
     * - 滚动距离 = 比例 * 最大滚动距离
     *
     * 这样可以实现滑块移动 1 像素,内容滚动相应比例的距离
     */
    const delta = p.x - this.dragStartX;
    const maxOffset = this.bar.maxOffset;
    const trackAvailable = Math.max(1, this.bar.trackWidth - this.bar.barWidth);
    const scrollDelta = (delta / trackAvailable) * maxOffset;

    /**
     * 更新滚动位置
     * setScrollX 内部会处理边界约束(不超过最大滚动距离)
     */
    this.timeline.setScrollX(this.dragStartScroll + scrollDelta);
  }

  /**
   * 鼠标抬起事件处理
   * 如果处于拖拽模式,结束拖拽;
   * 否则调用 Canvas 原始的鼠标抬起处理。
   */
  mouseUpHandler(e: PointerEventLike) {
    /**
     * 非拖拽状态,调用原始处理函数
     */
    if (!this.dragging) {
      const proto = Canvas.prototype as unknown as {
        _onMouseUp: (e: PointerEventLike) => void;
      };
      proto._onMouseUp.call(this.timeline.canvas, e);
    }

    /**
     * 重置 dragging 标志,后续鼠标移动不再触发滚动
     */
    this.dragging = false;
  }
}
import { Canvas, Rect } from 'fabric';
import { useEffect, useRef } from 'react';
import { HorizontalScrollbar } from '../../core/timeline/scrollbar';

export default function ScrollBarDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 200,
      backgroundColor: '#0f172a',
      selection: false
    });

    const timeline = {
      canvas,
      contentWidth: 2000,
      scrollX: 0,
      setScrollX(x: number) {
        this.scrollX = Math.max(
          0,
          Math.min(x, this.contentWidth - canvas.width)
        );
        canvas.setViewportTransform([1, 0, 0, 1, -this.scrollX, 0]);
        canvas.requestRenderAll();
      }
    } as any;

    const scrollbar = new HorizontalScrollbar(timeline);

    const rect1 = new Rect({
      left: 50,
      top: 50,
      width: 200,
      height: 60,
      fill: '#134e4a',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: true
    });

    rect1.on('moving', () => {
      const right = rect1.left! + rect1.width!;
      const newContentWidth = Math.max(canvas.width, right + 50);
      timeline.contentWidth = newContentWidth;
      canvas.requestRenderAll();
    });

    canvas.add(rect1);

    return () => {
      scrollbar.dispose();
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

参考线绘制

image.png

整体流程是怎样的?可以理解成 5 步:

  1. 清掉旧的辅助线
  2. 收集画布上所有“可当参照物”的边
  3. 计算当前拖拽物体的边
  4. 找最近的一条线(距离小于 10px)
  5. 画辅助线 + 修正位置(吸附)

第一步:清理旧辅助线

clearAuxiliaryObjects()每次拖动都会重新计算吸附线,所以必须先把旧的删掉,避免画布上越画越多线,它的做法是:

  • 遍历所有对象
  • 找到带 isAlignmentAuxiliary 标记的
  • 删除

第二步:收集“所有可吸附的边”

getLineGuideStops()它做的事情是:

  • 遍历画布所有可见对象
  • 跳过当前拖动对象
  • 跳过辅助线本身
  • 获取每个对象的 boundingRect

最终得到一个列表:

[
  { val: 100 },
  { val: 250 },
  { val: 300 },
  ...
]

第三步:计算当前对象的吸附边

getObjectSnappingEdges()它只算两个东西:当前对象的左边、当前对象的右边

并记录:

guide   // 当前边的位置
offset  // 实际坐标偏移
snap    // 是 start 还是 end

第四步:找最近的一条线

diff = Math.abs(lineGuide.val - itemBound.guide)

如果:diff < 10说明已经足够接近,然后把所有满足条件的候选放进数组进行排序,取最小的那个,这样可以避免多条线同时吸附导致抖动

resultV.sort((a, b) => a.diff - b.diff)[0]

第五步:画对齐线

new Line([x, 0, x, 2000])

import { Line, type Canvas, type FabricObject } from 'fabric';
import { AlignmentAuxiliary, LineGuide, TimelineObject, Guide } from '../types';

/**
 * 清除画布上的所有辅助对齐线
 */
export const clearAuxiliaryObjects = (
  canvas: Canvas,
  allObjects: FabricObject[]
) => {
  allObjects.forEach(obj => {
    if ((obj as AlignmentAuxiliary).isAlignmentAuxiliary) canvas.remove(obj);
  });
};

/**
 * 计算对象的对齐停靠点
 * 返回对象左边界与右边界的可吸附位置
 */
export const getStopsForObject = (
  start: number,
  distance: number,
  drawStart: number,
  drawDistance: number
) => {
  const stops = [start, start + distance];
  return stops.map(stop => ({
    val: stop,
    start: drawStart,
    end: drawStart + drawDistance
  }));
};

/**
 * 获取画布上所有可用作对齐基准的停靠点
 * 仅收集可见的 Clip,对齐线本身不会参与计算
 */
export const getLineGuideStops = (skipShapes: FabricObject[], canvas: Canvas) => {
  const vertical: LineGuide[] = [];
  canvas
    .getObjects()
    .filter(o => o.visible && (o as TimelineObject).elementId)
    .forEach(guideObject => {
      if (
        skipShapes.includes(guideObject) ||
        (guideObject as AlignmentAuxiliary).isAlignmentAuxiliary
      ) {
        return;
      }
      const box = guideObject.getBoundingRect();
      vertical.push(
        ...getStopsForObject(box.left, box.width, box.top, box.height)
      );
    });
  return { vertical, horizontal: [] as LineGuide[] };
};

/**
 * 获取当前拖拽对象的吸附边缘
 * 只计算水平吸附(左边界、右边界)
 */
export const getObjectSnappingEdges = (target: FabricObject) => {
  const rect = target.getBoundingRect();
  return {
    vertical: [
      {
        guide: Math.round(rect.left),
        offset: Math.round((target.left || 0) - rect.left),
        snap: 'start'
      },
      {
        guide: Math.round(rect.left + rect.width),
        offset: Math.round((target.left || 0) - rect.left - rect.width),
        snap: 'end'
      }
    ],
    horizontal: [] as Array<{ guide: number; offset: number; snap: string }>
  };
};

/**
 * 计算当前位置最接近的引导对齐线
 * 仅返回最接近的垂直引导,避免多条线干扰
 */
export const getGuides = (
  lineGuideStops: { vertical: LineGuide[]; horizontal: LineGuide[] },
  itemBounds: {
    vertical: { guide: number; offset: number; snap: string }[];
    horizontal: { guide: number; offset: number; snap: string }[];
  }
) => {
  const resultV: Array<{ lineGuide: number; diff: number; offset: number }> =
    [];
  lineGuideStops.vertical.forEach(lineGuide => {
    itemBounds.vertical.forEach(itemBound => {
      const diff = Math.abs(lineGuide.val - itemBound.guide);
      if (diff < 10) {
        resultV.push({
          lineGuide: lineGuide.val,
          diff,
          offset: itemBound.offset
        });
      }
    });
  });
  const guides: Guide[] = [];
  const minV = resultV.sort((a, b) => a.diff - b.diff)[0];
  if (minV) {
    guides.push({
      lineGuide: minV.lineGuide,
      offset: minV.offset,
      orientation: 'V'
    });
  }
  return guides;
};

/**
 * 在画布上绘制对齐线
 * 线条绘制在主画布之上,并标记为辅助对象
 */
export const drawGuides = (guides: Guide[], canvas: Canvas) => {
  guides.forEach(lineGuide => {
    if (lineGuide.orientation === 'V') {
      const line = new Line(
        [lineGuide.lineGuide, 0, lineGuide.lineGuide, 2000],
        {
          strokeWidth: 2,
          stroke: '#ffffff',
          strokeLineCap: 'square',
          selectable: false,
          evented: false,
          objectCaching: false
        }
      );
      (line as AlignmentAuxiliary).isAlignmentAuxiliary = true;
      canvas.add(line);
    }
  });
};
import { Canvas, Rect } from 'fabric';
import { useEffect, useRef } from 'react';
import {
  clearAuxiliaryObjects,
  drawGuides,
  getGuides,
  getLineGuideStops,
  getObjectSnappingEdges
} from '../../core/timeline/utils/guidelines';

export default function GuidelinesDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const canvas = new Canvas(canvasRef.current, {
      width: 800,
      height: 300,
      backgroundColor: '#0f172a',
      selection: false
    });

    const rect1 = new Rect({
      left: 100,
      top: 100,
      width: 150,
      height: 60,
      fill: '#134e4a',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: false
    });
    (rect1 as any).elementId = 'rect1';

    const rect2 = new Rect({
      left: 350,
      top: 100,
      width: 200,
      height: 60,
      fill: '#14532d',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: false
    });
    (rect2 as any).elementId = 'rect2';

    const rect3 = new Rect({
      left: 600,
      top: 100,
      width: 120,
      height: 60,
      fill: '#1e1b4b',
      rx: 6,
      ry: 6,
      selectable: true,
      hasControls: false
    });
    (rect3 as any).elementId = 'rect3';

    canvas.add(rect1, rect2, rect3);

    canvas.on('object:moving', e => {
      const target = e.target;
      if (!target) return;

      clearAuxiliaryObjects(canvas, canvas.getObjects());

      const lineGuideStops = getLineGuideStops([target], canvas);
      const itemBounds = getObjectSnappingEdges(target);
      const guides = getGuides(lineGuideStops, itemBounds);

      if (guides.length > 0) {
        const guide = guides[0];
        target.set({
          left: guide.lineGuide + guide.offset
        });
        target.setCoords();
        drawGuides(guides, canvas);
      }
    });

    canvas.on('mouse:up', () => {
      clearAuxiliaryObjects(canvas, canvas.getObjects());
    });

    return () => {
      canvas.dispose();
    };
  }, []);

  return <canvas ref={canvasRef} />;
}

时间轴缩放

核心代码:

const timeAtMouse = mouseX / oldPixelsPerSecond;
const newMouseX = timeAtMouse * this.pixelsPerSecond;
const newScrollX = newMouseX - (mouseX - this.scrollX);

第一步:算出鼠标指向的时间点时间 = 像素 / 像素每秒

第二步:缩放后,这个时间应该在哪个像素?新像素 = 时间 * 新像素每秒

第三步:算需要补偿多少滚动newScrollX = 新像素位置 - 视口中的鼠标位置

// 监听滚轮事件,支持横向滚动与 Ctrl + 滚轮缩放
this.canvas.on('mouse:wheel', opt => {
  const e = opt.e;
  if (e.ctrlKey) {
    // Ctrl + 滚轮:以鼠标位置为锚点缩放,保持时间点对齐
    const delta = e.deltaY;
    const pointer = this.canvas.getPointer(e);
    this.handleZoom(delta, pointer.x);
  } else {
    // 普通滚轮:横向滚动(优先横向 delta)
    const delta =
      Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    this.setScrollX(this.scrollX + delta);
  }
  e.preventDefault();
  e.stopPropagation();
});
  /**
   * 处理时间轴缩放逻辑
   * @param delta 滚轮增量
   * @param mouseX 鼠标在画布上的 X 坐标(包含滚动偏移)
   */
  handleZoom(delta: number, mouseX: number) {
    const zoomFactor = 1.1;
    const oldPixelsPerSecond = this.pixelsPerSecond;

    // 计算新的缩放比例
    if (delta > 0) {
      this.pixelsPerSecond /= zoomFactor;
    } else {
      this.pixelsPerSecond *= zoomFactor;
    }

    /** 最小缩放(像素/秒) */
    const minPixelsPerSecond = 10;
    /** 最大缩放(像素/秒),用于支持帧级显示 */
    const maxPixelsPerSecond = 3000;
    this.pixelsPerSecond = Math.max(
      minPixelsPerSecond,
      Math.min(maxPixelsPerSecond, this.pixelsPerSecond)
    );

    if (Math.abs(oldPixelsPerSecond - this.pixelsPerSecond) < 0.01) return;

    // 关键逻辑:保持鼠标指针下的时间点在缩放后位置不变
    // 时间点 = (mouseX) / oldPixelsPerSecond
    // 缩放后的像素位置 = 时间点 * newPixelsPerSecond
    // 滚动补偿 = 缩放后的像素位置 - (mouseX - scrollX)
    const timeAtMouse = mouseX / oldPixelsPerSecond;
    const newMouseX = timeAtMouse * this.pixelsPerSecond;
    const newScrollX = newMouseX - (mouseX - this.scrollX);

    // 更新所有 Clip 的位置和宽度
    this.updateClipsVisualsFromTime();

    // 更新内容宽度(轨道背景也会随之更新)
    this.updateContentWidth();

    // 应用新的滚动位置
    this.setScrollX(newScrollX);

    this.canvas.requestRenderAll();
  }
  /**
   * 设置时间轴横向滚动位置
   * 通过 viewportTransform 将所有对象整体平移
   */
  setScrollX(value: number) {
    const maxScroll = Math.max(0, this.contentWidth - this.canvas.width);
    const next = Math.max(0, Math.min(maxScroll, value));
    if (Math.abs(next - this.scrollX) < 0.5) return;
    this.scrollX = next;
    const vpt = (
      this.canvas.viewportTransform || ([1, 0, 0, 1, 0, 0] as Mat2D)
    ).slice(0) as Mat2D;
    // 使用 viewportTransform 平移内容
    vpt[4] = -this.scrollX;
    vpt[5] = 0;
    this.canvas.setViewportTransform(vpt);
    // this.canvas.getObjects().forEach(obj => {
    //   // 修正控制点位置,避免滚动时偏移
    //   if (obj.hasControls) obj.setCoords();
    // });

    if (this.ruler) this.ruler.render(); // 同步更新刻度尺

    this.canvas.requestRenderAll();
  }

拖拽的核心代码(包括轨道的裁剪)

/**
   * 配置所有拖拽、缩放交互逻辑及约束
   * 包含缩放约束、防重叠、对齐辅助线与轨道吸附
   */
  setupDragSnapping() {
    /**
     * 缩放事件处理
     * 核心功能:
     * 1. 约束最小宽度,避免 Clip 过小
     * 2. 防止 Clip 跨越相邻 Clip(防重叠)
     * 3. 对于视频/音频 Clip,实现裁剪式缩放(拖动端点改变裁剪窗口)
     * 4. 约束裁剪范围不超过媒体源时长
     */
    this.canvas.on('object:scaling', opt => {
      const target = opt.target as TimelineObject;
      if (!target || !target.elementId) return;

      const transform = opt.transform;
      if (!transform) return;

      // 只处理左右控制点
      const corner = transform.corner;
      if (corner !== 'ml' && corner !== 'mr') return;

      const originalWidth = target.width || 0;
      if (originalWidth === 0) return;

      const timelineTarget = target as TimelineObject;
      const isMediaClip = ['video', 'audio'].includes(timelineTarget.clipType);

      if (isMediaClip) {
        const mediaTarget = target as TimelineObject;
        if (mediaTarget.trimStart === undefined) mediaTarget.trimStart = 0;
        if (mediaTarget.trimEnd === undefined || mediaTarget.trimEnd === 0) {
          mediaTarget.trimEnd = mediaTarget.duration ?? 0;
        }
        // 记录缩放开始时的裁剪窗口,用于计算裁剪增量
        // 这样可以确保"回拉"操作不会超过原始裁剪量
        if (mediaTarget.__trimStartOriginal === undefined) {
          mediaTarget.__trimStartOriginal = mediaTarget.trimStart ?? 0;
        }
        if (mediaTarget.__trimEndOriginal === undefined) {
          mediaTarget.__trimEndOriginal = mediaTarget.trimEnd ?? 0;
        }
      }

      // 获取同一轨道上的其他 Clip,用于防重叠检测
      const trackIndex = this.getTrackIndexForObject(target);
      const siblings = this.canvas
        .getObjects()
        .filter(obj => (obj as TimelineObject).elementId && obj !== target)
        .map(obj => obj as TimelineObject)
        .filter(obj => this.getTrackIndexForObject(obj) === trackIndex)
        .map(obj => ({ obj, ...this.getClipBounds(obj) }))
        .sort((a, b) => a.left - b.left);

      // 记录缩放开始时的位置和尺寸
      const startLeft = transform.original.left;
      const startScaleX = transform.original.scaleX || 1;
      const startRight = startLeft + originalWidth * startScaleX;

      // 查找左右相邻的 Clip
      let leftNeighbor: { left: number; right: number } | null = null;
      let rightNeighbor: { left: number; right: number } | null = null;

      for (const clip of siblings) {
        if (clip.left < startLeft) {
          leftNeighbor = clip;
          continue;
        }
        rightNeighbor = clip;
        break;
      }

      // 计算最小缩放比例,确保 Clip 不会太小
      const minScale = MIN_CLIP_WIDTH / originalWidth;

      // ========== 右侧控制点缩放(mr)==========
      // 拖动右侧控制点:左边界固定,改变右边界
      // 对于媒体类型:trimStart 保持不变,trimEnd 随宽度变化
      if (corner === 'mr') {
        // 计算最大右边界(受相邻 Clip 或内容宽度限制)
        const maxRight = rightNeighbor ? rightNeighbor.left : this.contentWidth;
        const maxWidth = maxRight - startLeft;
        let maxScale = maxWidth / originalWidth;

        // 媒体类型额外约束:不能超过源文件末尾
        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimStart = mediaTarget.__trimStartOriginal ?? 0;
          const sourceDuration = mediaTarget.sourceDuration || 0;
          if (sourceDuration > 0) {
            // 从当前 trimStart 到源文件末尾的剩余时长
            const maxDurationBySource = sourceDuration - baseTrimStart;
            const maxScaleBySource =
              (maxDurationBySource * this.pixelsPerSecond) / originalWidth;
            maxScale = Math.min(maxScale, maxScaleBySource);
          }
        }

        // 约束缩放比例在有效范围内
        let newScaleX = timelineTarget.scaleX || 1;
        if (newScaleX < minScale) newScaleX = minScale;
        if (newScaleX > maxScale) newScaleX = maxScale;

        // 应用缩放:左边界锚定,只改变宽度
        target.set({
          scaleX: newScaleX,
          left: startLeft
        });

        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimStart = mediaTarget.__trimStartOriginal ?? 0;
          const sourceDuration = mediaTarget.sourceDuration || 0;
          const finalWidth =
            (timelineTarget.width || 0) * (timelineTarget.scaleX || 1);
          const finalDuration = finalWidth / this.pixelsPerSecond;
          // 右侧缩放:trimStart 固定,trimEnd 随宽度增加
          mediaTarget.trimStart = baseTrimStart;
          mediaTarget.trimEnd =
            sourceDuration > 0
              ? Math.min(baseTrimStart + finalDuration, sourceDuration)
              : baseTrimStart + finalDuration;
        }
      } else if (corner === 'ml') {
        // ========== 左侧控制点缩放(ml)==========
        // 拖动左侧控制点:右边界固定,改变左边界
        // 对于媒体类型:trimEnd 保持不变,trimStart 随宽度变化

        // 计算最小左边界(受相邻 Clip 或 0 限制)
        const minLeft = leftNeighbor ? leftNeighbor.right : 0;
        const maxWidth = startRight - minLeft;
        let maxScale = maxWidth / originalWidth;

        // 媒体类型额外约束:不能超过源文件开头
        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimEnd = mediaTarget.__trimEndOriginal ?? 0;
          const sourceDuration = mediaTarget.sourceDuration || 0;
          // 从源文件开头到当前 trimEnd 的最大可用时长
          const maxDurationBySource = sourceDuration
            ? Math.min(baseTrimEnd || sourceDuration, sourceDuration)
            : baseTrimEnd;
          if (maxDurationBySource > 0) {
            const maxScaleBySource =
              (maxDurationBySource * this.pixelsPerSecond) / originalWidth;
            maxScale = Math.min(maxScale, maxScaleBySource);
          }
        }

        // 约束缩放比例在有效范围内
        let newScaleX = timelineTarget.scaleX || 1;
        if (newScaleX < minScale) newScaleX = minScale;
        if (newScaleX > maxScale) newScaleX = maxScale;

        // 应用缩放:右边界锚定,改变左边界位置
        target.set({
          scaleX: newScaleX,
          left: startRight - originalWidth * newScaleX
        });

        // 更新媒体类型的裁剪窗口
        if (isMediaClip) {
          const mediaTarget = target as TimelineObject;
          const baseTrimEnd = mediaTarget.__trimEndOriginal ?? 0;
          const finalWidth =
            (timelineTarget.width || 0) * (timelineTarget.scaleX || 1);
          const finalDuration = finalWidth / this.pixelsPerSecond;
          if (baseTrimEnd > 0) {
            // 左侧缩放:trimEnd 固定,trimStart 随宽度变化
            // 向左拖动 = 扩展开头 = trimStart 减小
            // 向右拖动 = 裁剪开头 = trimStart 增加
            mediaTarget.trimEnd = baseTrimEnd;
            mediaTarget.trimStart = Math.max(0, baseTrimEnd - finalDuration);
          }
        }
      }

      // 同步更新时间属性(将像素转换为秒)
      const finalWidth = (target.width || 0) * (target.scaleX || 1);
      target.startTime = (target.left || 0) / this.pixelsPerSecond;
      target.duration = finalWidth / this.pixelsPerSecond;

      // 更新内容宽度并重新渲染
      this.updateContentWidth();
      target.setCoords();
      this.canvas.requestRenderAll();
    });

    // 2. 移动过程中:执行辅助线吸附和重叠修正
    this.canvas.on('object:moving', opt => {
      const target = opt.target as TimelineObject;
      if (!target || !target.elementId) return;

      // 辅助对齐线吸附逻辑
      const allObjects = this.canvas.getObjects();
      const lineGuideStops = getLineGuideStops([target], this.canvas);
      const itemBounds = getObjectSnappingEdges(target);
      const guides = getGuides(lineGuideStops, itemBounds);

      clearAuxiliaryObjects(this.canvas, allObjects);
      if (guides.length > 0) drawGuides(guides, this.canvas);

      guides.forEach(lineGuide => {
        if (lineGuide.orientation === 'V') {
          target.set('left', lineGuide.lineGuide + lineGuide.offset);
        }
      });

      // 实时防重叠修正
      const previousLeft = target.__prevLeft;
      const currentLeft = target.left || 0;
      const direction =
        previousLeft === undefined || currentLeft >= previousLeft ? 1 : -1;
      this.resolveClipOverlap(target, direction);
      target.__prevLeft = target.left || 0;

      // 同步更新时间属性
      target.startTime = (target.left || 0) / this.pixelsPerSecond;

      this.updateContentWidth(); // 拖拽时实时更新内容宽度
      target.setCoords();
      this.canvas.requestRenderAll();
    });

    // 3. 交互结束后:处理轨道增删、回弹及坐标校准
    this.canvas.on('object:modified', (opt: TimelineEvent) => {
      const target = opt.target as TimelineObject;
      if (!target || !target.elementId) return;

      const width = (target.width || 0) * (target.scaleX || 1);
      const height = (target.height || 0) * (target.scaleY || 1);
      const centerY = (target.top || 0) + height / 2;

      // --- 动态轨道判定逻辑 ---
      const firstTrackTop = this.trackTops[0];
      const lastTrackTop = this.trackTops[this.trackCount - 1];

      if (centerY < firstTrackTop) {
        // 拖动到顶部边缘以上:在最上方插入新轨道
        this.canvas.getObjects().forEach(obj => {
          const t = obj as TimelineObject;
          if (t.elementId && t.trackIndex !== undefined) {
            t.trackIndex += 1;
          }
        });
        target.trackIndex = 0;
      } else if (centerY > lastTrackTop + TRACK_HEIGHT) {
        // 拖动到底部边缘以下:在最下方新增轨道
        target.trackIndex = this.trackCount;
      } else {
        // 落在现有轨道范围内:吸附到最近轨道
        target.trackIndex = this.getClosestTrackIndex(centerY);
      }

      const trackTop = this.getTrackTop(target.trackIndex);
      target.set({
        width: Math.max(MIN_CLIP_WIDTH, width),
        top: trackTop + (TRACK_HEIGHT - CLIP_HEIGHT) / 2,
        scaleX: 1
      });

      // 最终重叠检测:若空间仍不足,触发回弹逻辑
      const fits = this.resolveClipOverlap(target, 1);
      if (!fits && target.__originalLeft !== undefined) {
        target.set({
          left: target.__originalLeft,
          top: target.__originalTop
        });
        // 恢复后同步 trackIndex 并执行对齐
        const oldCenterY = (target.top || 0) + height / 2;
        target.trackIndex = this.getClosestTrackIndex(oldCenterY);
        this.resolveClipOverlap(target, 1);
      }

      // 执行轨道清理及重新排列
      this.syncTrackIndices();
      this.updateContentWidth(); // 交互结束后同步内容宽度

      // 同步最终的时间属性
      const finalWidth = (target.width || 0) * (target.scaleX || 1);
      target.startTime = (target.left || 0) / this.pixelsPerSecond;
      target.duration = finalWidth / this.pixelsPerSecond;

      // 清理交互临时属性
      target.__originalLeft = undefined;
      target.__originalTop = undefined;
      target.__prevLeft = undefined;
      // 清理裁剪交互基准,避免影响下一次缩放
      target.__trimStartOriginal = undefined;
      target.__trimEndOriginal = undefined;
      target.setCoords();
      this.canvas.requestRenderAll();
    });

    // 4. 鼠标抬起:清除辅助线
    this.canvas.on('mouse:up', () => {
      clearAuxiliaryObjects(this.canvas, this.canvas.getObjects());
      this.canvas.requestRenderAll();
    });
  }
  /**
 * 核心防重叠逻辑:
 * 在移动或缩放过程中,检测并修正位置,确保 Clip 不会与其他 Clip 发生重叠
 * @param target 当前操作的对象
 * @param direction 移动方向(1:向右,-1:向左)
 * @returns 是否能完整放下该对象
 */
resolveClipOverlap(target: TimelineObject, direction: number): boolean {
  const trackIndex = this.getTrackIndexForObject(target);
  const bounds = this.getClipBounds(target);

  // 获取同一轨道上的所有其他 Clip 并按左边界排序
  const siblings = this.canvas
    .getObjects()
    .filter(obj => (obj as TimelineObject).elementId && obj !== target)
    .map(obj => obj as TimelineObject)
    .filter(obj => this.getTrackIndexForObject(obj) === trackIndex)
    .map(obj => ({ obj, ...this.getClipBounds(obj) }))
    .sort((a, b) => a.left - b.left);

  let leftNeighbor: { left: number; right: number } | null = null;
  let rightNeighbor: { left: number; right: number } | null = null;

  // 寻找左右最近邻居
  for (const clip of siblings) {
    if (clip.left < bounds.left) {
      leftNeighbor = clip;
      continue;
    }
    rightNeighbor = clip;
    break;
  }

  // 计算可用空间范围
  const leftBound = leftNeighbor ? leftNeighbor.right : 0;
  const rightBound = rightNeighbor
    ? rightNeighbor.left - bounds.width
    : Number.POSITIVE_INFINITY;

  let nextLeft = bounds.left;
  /** 检测空间是否足够 */
  const fits = rightBound >= leftBound;
  if (!fits) {
    // 空间不足时,根据移动方向推送到边界
    nextLeft = direction >= 0 ? rightBound : leftBound;
  } else {
    // 空间足够时,确保不越过邻居边界
    if (nextLeft < leftBound) nextLeft = leftBound;
    if (nextLeft > rightBound) nextLeft = rightBound;
  }

  // 时间轴总范围约束(允许拖拽到整个时间轴容量范围)
  // const absoluteMaxRight = this.contentWidth;
  // const maxLeft = Math.max(0, absoluteMaxRight - bounds.width);
  if (nextLeft < 0) nextLeft = 0;
  // if (nextLeft > maxLeft) nextLeft = maxLeft;

  target.set('left', nextLeft);
  return fits;
}

Vue生态精选篇:Element Plus 的“企业后台常用组件”用法扫盲

作者 SuperEugene
2026年3月7日 12:53

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、选型与定位

  • Element Plus:面向 Vue 3 + TypeScript 的 UI 组件库,适合管理后台、中台、后台系统。
  • 为什么用组件库而不是手写? 统一规范、减少重复开发、内置表单校验、表格、弹窗等常见能力。
  • 本文涉及组件:Form、Table、Dialog、Message/MessageBox、Upload。

二、表单 Form:数据收集与校验

2.1 核心概念

Form 的作用:收集、校验、提交 数据,包含输入框、选择器、日期等。

表单的三层结构:

  1. el-form:表单容器,绑定数据和校验规则
  2. el-form-item:单个表单项,承载 label、校验、布局
  3. el-input / el-select 等:具体输入控件

2.2 正确用法示例

<template>
  <el-form 
    ref="formRef" 
    :model="form" 
    :rules="rules" 
    label-width="100px"
    @submit.prevent
  >
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" placeholder="请输入用户名" />
    </el-form-item>
    
    <el-form-item label="密码" prop="password">
      <el-input v-model="form.password" type="password" placeholder="请输入密码" />
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
      <el-button @click="handleReset">重置</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  username: '',
  password: ''
})

// 校验规则:字段名要与 form 中的属性、el-form-item 的 prop 完全一致
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' }
  ]
}

const handleSubmit = async () => {
  // validate 返回 Promise,通过则无参数,失败则返回校验错误
  try {
    await formRef.value.validate()
    console.log('校验通过,提交数据:', form)
    // 这里调用接口提交
  } catch (error) {
    console.log('校验失败')
  }
}

const handleReset = () => {
  formRef.value.resetFields()
}
</script>

说明要点:

  • :model="form" 绑定表单数据,注意是 :model,不是 v-model
  • :rules="rules" 绑定校验规则
  • prop="username" 绑定到表单项,用于关联 rules 中的字段
  • @submit.prevent 防止回车键意外提交表单

2.3 常见踩坑

错误写法 正确写法
Form 绑定 v-model="form" :model="form"
不写 prop <el-form-item> 无 prop <el-form-item prop="username">
prop 写错位置 写在 el-input 必须写在 el-form-item
prop 与 rules 不一致 rules 里是 name,prop 是 username 两者字段名完全一致

记住:el-form 用 :model、el-form-item 必须有 prop、prop 与 rules 字段名一致

2.4 常用 API

  • validate():整表校验
  • validateField(prop):校验单个字段
  • resetFields():重置表单
  • clearValidate():清除校验状态

三、表格 Table:列表展示

3.1 核心概念

Table 用于展示列表数据,支持排序、分页、选择、展开等。

3.2 基础用法示例

<template>
  <el-table 
    :data="tableData" 
    stripe 
    border
    style="width: 100%"
    @selection-change="handleSelectionChange"
  >
    <!-- 多选列 -->
    <el-table-column type="selection" width="55" />
    
    <!-- 普通列 -->
    <el-table-column prop="name" label="姓名" width="120" />
    <el-table-column prop="age" label="年龄" width="80" />
    <el-table-column prop="address" label="地址" show-overflow-tooltip />
    
    <!-- 自定义列 -->
    <el-table-column label="状态" width="100">
      <template #default="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
    </el-table-column>
    
    <!-- 操作列 -->
    <el-table-column label="操作" width="180" fixed="right">
      <template #default="{ row }">
        <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
        <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup>
import { ref } from 'vue'

const tableData = ref([
  { id: 1, name: '张三', age: 28, address: '上海市浦东新区某某路100号', status: 1 },
  { id: 2, name: '李四', age: 32, address: '北京市朝阳区某某大街200号', status: 0 }
])

const handleSelectionChange = (selection) => {
  console.log('选中的行:', selection)
}

const handleEdit = (row) => {
  console.log('编辑', row)
}

const handleDelete = (row) => {
  console.log('删除', row)
}
</script>

说明要点:

  • :data 绑定数据数组,每一行是一个对象
  • prop 对应数据字段名,决定显示哪个字段
  • show-overflow-tooltip:内容过长时显示省略号并悬浮显示完整内容
  • #default="{ row }":插槽提供当前行数据

3.3 配置选型建议

场景 推荐配置
数据较多 heightmax-height 固定高度,出现纵向滚动
树形数据 使用 row-key + tree-props
需要合计 show-summary + summary-method
列宽不稳定 设置 widthmin-width,避免抖动
多选 type="selection" + @selection-change

3.4 常见踩坑

  • 表格数据不更新:确保 tableData 是响应式的(如 ref),修改后要触发更新
  • 树形表格:必须设置 row-key 为唯一字段(如 id
  • 固定列fixed="right"fixed="left" 时,注意右侧固定列写在最后

四、弹窗 Dialog:模态对话框

4.1 核心概念

Dialog 用于在保留当前页面的前提下,弹出一个模态层展示内容,常用于表单弹窗、详情、确认等。

4.2 基础用法示例

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <el-dialog
    v-model="dialogVisible"
    title="编辑用户"
    width="500px"
    :close-on-click-modal="false"
    :before-close="handleBeforeClose"
    @opened="handleOpened"
  >
    <!-- 弹窗内容 -->
    <el-form ref="formRef" :model="form" :rules="rules">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
    </el-form>
    
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, watch } from 'vue'

const dialogVisible = ref(false)
const formRef = ref()
const form = reactive({ username: '' })
const rules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] }

// 弹窗关闭前:可做二次确认、校验等
const handleBeforeClose = (done) => {
  // 简单示例:直接关闭
  done()
  // 如需确认:ElMessageBox.confirm('确定关闭?').then(() => done()).catch(() => {})
}

// 弹窗打开动画结束后
const handleOpened = () => {
  formRef.value?.clearValidate()
}

// 关闭时清空表单(按需)
watch(dialogVisible, (val) => {
  if (!val) {
    form.username = ''
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    // 提交逻辑
    dialogVisible.value = false
  } catch (e) {
    // 校验失败
  }
}
</script>

说明要点:

  • v-model="dialogVisible" 控制显示/隐藏
  • :close-on-click-modal="false":点击遮罩不关闭,避免误关
  • before-close:可做二次确认、阻止关闭
  • #footer:自定义底部按钮

4.3 常见配置选型

配置 说明 建议
destroy-on-close 关闭时销毁内容 表单弹窗建议开启,避免数据残留
close-on-click-modal 点击遮罩关闭 表单弹窗建议关闭
append-to-body 挂载到 body 有嵌套弹窗时建议开启

五、消息 Message 与 MessageBox

5.1 ElMessage:轻量提示

用于操作后的简单反馈(成功、失败、警告等),通常显示几秒后自动消失。

import { ElMessage } from 'element-plus'

// 成功
ElMessage.success('保存成功')

// 错误
ElMessage.error('保存失败,请重试')

// 警告
ElMessage.warning('请先填写必填项')

// 自定义
ElMessage({
  message: '操作成功',
  type: 'success',
  duration: 3000,
  showClose: true
})

5.2 ElMessageBox:确认与输入

用于需要用户确认或输入的场景,比 Dialog 更轻量。

import { ElMessageBox } from 'element-plus'

// 确认删除
const handleDelete = async (row) => {
  try {
    await ElMessageBox.confirm(
      `确定要删除「${row.name}」吗?`,
      '提示',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    // 用户点击确定
    await deleteApi(row.id)
    ElMessage.success('删除成功')
  } catch (e) {
    // 用户点击取消或关闭
  }
}

// 简单提示(类似 alert)
ElMessageBox.alert('操作完成', '提示')

5.3 选型建议

场景 用 Message 用 MessageBox
保存成功、失败提示
删除前确认
需要用户输入 ✅(prompt)
复杂表单、多内容 改用 Dialog

六、上传 Upload:文件上传

6.1 核心概念

Upload 支持自动上传和手动上传:自动上传是选完即传,手动上传是选完后由按钮触发上传。

6.2 自动上传(选完即传)

<template>
  <el-upload
    action="/api/upload"
    :headers="uploadHeaders"
    :on-success="handleSuccess"
    :on-error="handleError"
    :before-upload="beforeUpload"
  >
    <el-button type="primary">点击上传</el-button>
  </el-upload>
</template>

<script setup>
import { reactive } from 'vue'

// 请求头,常用于 Token
const uploadHeaders = reactive({
  Authorization: `Bearer ${localStorage.getItem('token')}`
})

// 上传前:校验格式、大小
const beforeUpload = (file) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传 JPG/PNG 格式')
    return false  // 阻止上传
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过 2MB')
    return false
  }
  return true
}

const handleSuccess = (response, file, fileList) => {
  ElMessage.success('上传成功')
  // response 一般为后端返回的 URL 等
}

const handleError = () => {
  ElMessage.error('上传失败')
}
</script>

6.3 手动上传(和表单一起提交)

<template>
  <el-form :model="form">
    <el-form-item label="附件">
      <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :limit="3"
        :on-exceed="handleExceed"
        :on-change="handleChange"
      >
        <el-button type="primary">选择文件</el-button>
      </el-upload>
    </el-form-item>
    <el-button @click="submitForm">提交表单(含文件)</el-button>
  </el-form>
</template>

<script setup>
import { ref } from 'vue'

const uploadRef = ref()
const form = ref({ files: [] })

// 手动上传时,选中的文件会进入 fileList,需要自己调用接口上传
const handleChange = (file, fileList) => {
  form.value.files = fileList
}

const handleExceed = () => {
  ElMessage.warning('最多上传 3 个文件')
}

const submitForm = async () => {
  const formData = new FormData()
  form.value.files.forEach(f => {
    formData.append('files', f.raw)
  })
  // 再 append 其他表单字段...
  // await uploadApi(formData)
}
</script>

说明要点:

  • :auto-upload="false" 关闭自动上传
  • on-change 拿到选中的文件列表
  • 手动上传时用 FormData 组装并调用自己的接口

6.4 常见踩坑

原因 处理
before-upload 返回 false 仍上传 理解错误 返回 falsePromise.reject() 会阻止上传
上传后列表不更新 未绑定 file-list v-model:file-list:file-list 绑定
跨域、Cookie 未带凭证 设置 :with-credentials="true"
需要 Token 接口要鉴权 通过 :headers 传入

七、小结

  • Form:用 :model + prop + rules,三者字段名一致
  • Tableprop 对数据字段,复杂展示用 #default 插槽
  • Dialog:用 v-model 控制显隐,表单弹窗建议 destroy-on-close
  • Message:轻量提示;MessageBox:确认、输入
  • Upload:自动上传用 action + 钩子;手动上传用 :auto-upload="false" + 自定义提交

按上述方式选型和编码,可以避开大部分常见坑。如果你希望我按某一块(比如 Form、Table、Upload)再单独细化成一篇更长的教程,可以说明一下侧重点(例如:复杂表单、动态表格、多图上传等)。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

JavaScript回归基本功之---类型判断--typeof篇

作者 Neptune1
2026年3月7日 12:33

在学习JavaScript的过程中,我们不难发现一件事,类型判断在我们日常的开发中是最基础也最容易踩坑的知识点之一,我们有时会产生一些困惑

1. 明明被判断的那个数据是一个数组,但 typeof 却返回了 object

2. 使用 instanceof 判断基本类型时得到了一个 false

3. 不知道该用什么方法能够精准的判断一个值的类型

别急,本系列文章中我将带你重新复习一遍 JavaScript 中类型判断的各种方法,从 typeof 到 Object.prototype.toString.call()这些使用场景及底层实现原理,以及开发过程会遇到的难题,让你能够 回归基本功 ,再一次重新认识我们的老朋友 --JavaScript

一、学会类型判断首先我们来了解一下JS中的各种数据类型

1.1 基本类型(原始类型)

  • string : 字符串类型,如: "hello world"
  • number : 数字类型 "123" , "1.1"
  • boolean : 布尔类型 ,true 或 false,
  • undefined : 未定义类型,变量声明但为赋值时的类型
  • null : 空类型,表示一个空对象指针
  • symbol :符号类型,表示唯一且不可变的值
  • bigint: 大整数类型,用于表示超过 number 范围的整数

1.2 引用类型

  • Object :普通对象
  • Array:数组对象
  • Function:函数对象
  • Date:日期对象

1.3 基本类型与引用类型的区别

  • 基本类型存储在栈的内存中,值不可改变。
  • 引用类型存储在堆内存中,变量存储的是指向堆内存的引用地址
  • 基本类型的比较是值的比较,引用类型的比较是引用的比较

二、了解了各种数据类型后,让我们来了解一些常用的类型判断方法

js中的类型判断方法有:

  1. typeof
  2. instanceof
  3. Object.prototype.toString.call()
  4. constructor
  5. Array.isArray

三、本篇文章着重要讲的就是 typeof 这一类型判断方法,typeof 是 JavaScript 中最基础的类型判断操作符,传入一个数据后,它会返回一个表示该传入数据类型的字符串。

image.png

typeof的工作原理

1. typeof 的判断基于 JavaScript 引擎内部的类型标签,每个值在内存中都有一个类型标签:

0: undefined

1: null

2: boolean

3: number

4: string

5: symbol

6: bigint

7: object

8: function

这就能解释为什么 typeof(null) "object" ,因为在js中,null的类型标签为 0 ,于是返回了"object" ,这是一个远古的 bug ,为了向后兼容一直保留至今没有进行修改

2. 什么时候选择使用typeof,为什么?

  1. 对于基本类型进行判断时(null与array除外),如:string、number、boolean、undefined、symbol 或 bigint 类型时,优先使用 typeof。

image.png

原因:typeof 对这些基本类型的判断精准且可靠,语法简单,可读性高,且由于typeof 能直接访问 js 引擎内部的类型标签,是最快的类型判断方法

  1. 当你需要判断一个值是否为函数类型时

image.png

原因: typeof 可以精确识别所有函数类型 ,包括普通函数,箭头函数,生成器函数,异步函数和类,相比于instanceof等其它判断方法,typeof 不需要考虑原型链的问题,且语法更简洁

3. 当你需要检查一个变量是否已经被声明,避免抛出 ReferenceError时

image.png

原因:typeof 是唯一可以安全检查未声明变量的方法,不会抛出 ReferenceError ,这是 typeof 独有的特性,是其它的类型判断方法都不具备的

  1. 当遇到性能敏感的场景时,即需要进行频繁的类型判断且对性能要求高的时候时

image.png

原因:typeof 对这些基本类型的判断精准且可靠,语法简单,可读性高,且由于typeof 能直接访问 js 引擎内部的类型标签,是最快的类型判断方法

3. 什么时候我们不使用typeof

  1. 当需要对 null 类型进行判断时

image.png

  1. 当需要对不同类型的对象进行区分时

image.png

四、总结一下typeof该类型判断方法

  1. 优先用于基本类型判断(除了null 以外)
  2. 用于函数类型判断
  3. 用于检查变量是否已经声明
  4. 性能敏感场景时优先使用
  5. 避免在对复杂对象类型判断时使用(优先使用 instanceof 或 Object.prototype.toString.call())

微前端沙箱隔离:qiankun 和 wujie 到底在争什么

2026年3月7日 11:10

微前端沙箱隔离:qiankun 和 wujie 到底在争什么

上个月接手一个老项目,四个团队各写各的,技术栈从 Vue2 到 React18 都有。领导一句"用微前端整合一下",我就开始了长达两周的沙箱隔离踩坑之旅。

问题的起点很简单:子应用 A 往 window 上挂了个 globalConfig,子应用 B 也挂了一个,然后就打架了。更离谱的是,子应用 C 的 CSS 里写了个 body { font-size: 14px !important },直接把主应用的样式干碎了。

这两个问题,一个是 JS 沙箱的事,一个是 CSS 隔离的事。qiankun 和 wujie 给出了完全不同的解法,背后的设计哲学也截然不同。

JS 沙箱:快照、代理、还是直接换个 window?

最朴素的思路:快照沙箱

qiankun 最早的沙箱方案简单粗暴——进子应用之前,把 window 上所有属性拍个快照存起来;子应用卸载时,把 window 恢复回去。

class SnapshotSandbox {
  private snapshot: Record<string, any> = {}
  private modifications: Record<string, any> = {}

  activate() {
    // 进场前:把当前 window 拍个照
    for (const key in window) {
      this.snapshot[key] = (window as any)[key]
    }
  }

  deactivate() {
    // 离场时:记录子应用改了啥,然后恢复 window
    for (const key in window) {
      if ((window as any)[key] !== this.snapshot[key]) {
        this.modifications[key] = (window as any)[key] // 存下改动
        ;(window as any)[key] = this.snapshot[key]     // 还原
      }
    }
  }
}

能跑。但问题也明显——同一时间只能激活一个子应用。因为大家共用一个 window,你在上面改,我也在上面改,没法并行。

这就是 qiankun 早期单实例模式的限制来源。

Proxy 代理沙箱:qiankun 的主力方案

为了支持多个子应用同时运行,qiankun 搞了 ProxySandbox。思路是给每个子应用造一个"假 window":

class ProxySandbox {
  private fakeWindow: Record<string, any> = {}

  proxy: WindowProxy

  constructor() {
    const fakeWindow = this.fakeWindow

    this.proxy = new Proxy(fakeWindow, {
      get(target, key) {
        // 先从 fakeWindow 找,找不到再去真 window
        return key in target ? target[key] : (window as any)[key]
      },

      set(target, key, value) {
        target[key] = value // 写操作全部拦截到 fakeWindow
        return true
      },

      has(target, key) {
        return key in target || key in window
      }
    })
  }
}

子应用里写 window.xxx = 123,实际写到了 fakeWindow 上,不会污染真正的 window。读的时候先找 fakeWindow,找不到再降级到真 window

这个方案解决了多实例问题,但有个绕不开的麻烦:子应用的代码怎么让它用 proxy 而不是真 window?

qiankun 的做法是拿到子应用的 JS 代码文本,用 (function(window, self, globalThis) { ... }).call(proxy, proxy, proxy, proxy) 包一层执行。等于在运行时把子应用代码的 window 引用偷梁换柱了。

听着挺巧妙,但实际用起来坑不少。

我在项目里踩过的 Proxy 沙箱的坑

有一次子应用里用了个第三方地图 SDK,它内部用 window.addEventListener 绑了一堆事件。问题是这个 SDK 的代码不是通过 qiankun 的 entry 加载的,而是在 HTML 里用 <script> 标签直接引的 CDN。

结果这部分代码跑在真 window 上,而子应用自己的代码跑在 proxy 上。两边的 window 不是同一个对象,事件监听和业务逻辑之间怎么通信就成了问题。排查了大半天,最后的解法是把 SDK 改成动态 import 的方式加载,让它也走 qiankun 的沙箱。

还有个经典问题:window.location 是不能被 Proxy 完整代理的(涉及到浏览器安全策略),qiankun 对这块做了特殊处理,但偶尔还是会有奇怪的表现。

wujie 的思路:直接用 iframe 的 window

wujie 选了一条完全不同的路——用 iframe 来跑 JS

不是把子应用渲染在 iframe 里(那就回到原始时代了),而是创建一个隐藏的 iframe,让子应用的 JS 在 iframe 的 window 环境下执行,但 DOM 操作代理到主应用的文档上。

// wujie 的核心思路(简化版)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)

// JS 跑在 iframe 里 → 天然隔离,每个 iframe 有自己的 window
const sandboxWindow = iframe.contentWindow

// 但 DOM 操作要代理出去:
// sandboxWindow.document → 指向主应用中子应用的挂载容器
Object.defineProperty(sandboxWindow, 'document', {
  get: () => proxyDocument // 指向主应用中的 shadow DOM 容器
})

这个设计很聪明。iframe 的 window 是浏览器原生隔离的,不需要自己实现沙箱逻辑。setTimeoutsetInterval、事件监听、location 这些全都是天然独立的。

qiankun 用 Proxy 模拟了一个不完美的 window,wujie 直接拿了一个真的。

CSS 隔离:这块的差距更大

JS 沙箱好歹都有方案,CSS 隔离才是真正让人头疼的地方。

qiankun 的 CSS 隔离:三种模式

动态样式表切换(默认):子应用激活时插入样式,卸载时移除。能防止子应用之间互相影响,但子应用的样式可能影响主应用。

Scoped CSS(experimentalStyleIsolation):运行时给子应用的所有 CSS 选择器加前缀。

/* 原始样式 */
.header { color: red; }
body { font-size: 14px; }

/* 加前缀后 */
div[data-qiankun="app1"] .header { color: red; }
div[data-qiankun="app1"] body { font-size: 14px; } /* body 选择器加前缀后其实没啥用 */

这个方案的问题:运行时解析和改写 CSS 有性能开销,而且有些选择器处理不了——比如 bodyhtml@keyframes 名字冲突。我之前项目里子应用用了 Ant Design,那个全局样式改写出来的效果,一言难尽。

Shadow DOM(strictStyleIsolation):用 Shadow DOM 包裹子应用。理论上完美隔离,但实际上问题更多:

// qiankun 的 Shadow DOM 模式
const container = document.getElementById('app-container')
const shadow = container.attachShadow({ mode: 'open' })
// 子应用渲染到 shadow 内部

// 问题来了:
// 1. 子应用里的弹窗(Modal)通常 append 到 document.body
//    → 跑到 Shadow DOM 外面了 → 样式丢失
// 2. 子应用里用 document.querySelector → 查不到 shadow 内的元素
// 3. React 17 之前的事件委托挂在 document 上 → shadow 内事件冒泡有问题

所以 qiankun 官方文档对 Shadow DOM 模式的态度是"谨慎使用"。很多团队实际上在用的是动态样式表 + BEM 命名约定这种半自动的隔离。

wujie 的 CSS 隔离:Web Component + Shadow DOM

wujie 用的也是 Shadow DOM,但配合它的 iframe JS 执行方案,体验好很多。

子应用的 DOM 渲染在一个 Web Component 的 Shadow DOM 里,JS 跑在 iframe 里。iframe 里的 document 被代理到 Shadow DOM 容器上,所以子应用调 document.querySelector 查到的是 Shadow DOM 内的元素,弹窗也能 append 到正确的位置。

// wujie 的 Web Component(简化)
class WujieApp extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' })
    // 子应用的 HTML/CSS 都渲染在这个 shadow 里
  }
}
customElements.define('wujie-app', WujieApp)

// iframe 里的 document 操作被劫持:
// document.body.appendChild(modal)
// → 实际 append 到 shadow DOM 内的 body 容器
// → 样式不会丢失

弹窗问题解决了吗?大部分场景是的。但也不是完全没坑——有些组件库会往 window.document.body(注意是 window 上取的 document)上挂东西,如果恰好绕过了 proxy,还是会逃逸。

设计哲学对比

两个框架的取舍逻辑,核心就一句话:

qiankun 选择在同一个页面上下文里做隔离,wujie 选择用原生隔离能力再做桥接。

qiankun 的路线:共享 window → Proxy 拦截 → 运行时改写 CSS → 在一个上下文里模拟多个沙箱。好处是子应用和主应用天然在同一个 DOM 树里,通信方便、路由同步简单。代价是隔离不彻底,边界情况多,要处理的 hack 也多。

wujie 的路线:iframe 跑 JS → Shadow DOM 渲染 UI → 通过 proxy 桥接两侧。好处是隔离干净,很多 qiankun 的历史坑天然不存在。代价是架构复杂度高,iframe 和主应用之间的 DOM 代理逻辑如果出 bug,排查成本不低。

画个表可能更清楚:

维度 qiankun wujie
JS 隔离 Proxy 模拟 fakeWindow iframe 原生 window
CSS 隔离 动态样式 / Scoped / Shadow DOM Web Component Shadow DOM
多实例 ProxySandbox 支持 天然支持(每个 iframe 独立)
弹窗逃逸 Shadow DOM 模式下有问题 基本解决(document 被代理)
子应用改造成本 需要导出生命周期钩子 相对较低
通信复杂度 低(同上下文) 中(跨 iframe)
社区生态 成熟,用的人多 较新,踩坑资料少

选型的时候怎么想

我个人的判断标准比较简单粗暴:

子应用技术栈比较统一(比如都是 React 或都是 Vue),团队对微前端有经验,选 qiankun。它的坑多但资料也多,大部分问题都有现成的解法。

子应用技术栈混乱(jQuery、Vue2、React18 啥都有),或者子应用是那种不太可能配合改造的老系统,wujie 的隔离能力会省很多事。iframe 原生隔离把很多脏活揽过去了。

如果是新项目,说实话我会先考虑要不要用微前端。Module Federation 或者简单的 iframe 嵌入能不能解决问题?微前端引入的复杂度不低,别为了用而用。

还有一点——沙箱隔离只是微前端的一个维度。路由同步、应用通信、公共依赖管理、构建部署流程,这些加在一起才是完整的工程决策。只看沙箱就选型,容易后面翻车。

回过头看

沙箱隔离这个问题,本质上是在问:多个独立应用塞到一个页面里,怎么让它们互不干扰?

qiankun 的答案是"我在 JS 层面给你隔开",wujie 的答案是"我让浏览器帮你隔开"。两个思路没有绝对的高下,只有场景的匹配度。

不过有一点是确定的:不管用哪个框架,上线前一定要跑一轮子应用并行加载的测试,重点关注全局变量污染和样式冲突。这两个问题不在开发阶段暴露,就一定会在生产环境暴露。到时候定位起来,比一开始就解决要痛苦十倍。

深交所理事长沙雁:做好创业板改革的准备工作

2026年3月7日 15:30
3月7日,全国人大代表、深交所理事长沙雁在广东团小组审议会议上表示,证监会将创业板改革作为今年推动资本市场改革的一个重要举措,深交所正在按照证监会统一部署,紧锣密鼓地做好相应的准备工作,下一步深交所将落实好质量优先、循序渐进的要求,更好发挥服务实体经济高质量发展的功能作用。(证券时报)

金隅集团与河北港口集团签订合作协议

2026年3月7日 15:02
近日,金隅集团与河北港口集团签订战略合作协议。河北港口集团党委书记、董事长曹子玉表示,希望双方在港口物流、园区建设、临港产业等领域开展更深层次、更高水平的合作。

美团联合联想百应上线OpenClaw远程部署服务

2026年3月7日 14:35
3月7日消息,美团近日联合联想旗下IT服务品牌联想百应推出OpenClaw远程部署服务,面向全国用户提供一站式远程部署解决方案。据美团数据,截至目前,“OpenClaw”相关搜索量环比增长超3倍。(界面新闻)

林俊旸告别千问:今天 Last day,不是这几天我不知道这世界这么多人爱我

2026年3月7日 14:08
3月7日消息,近日引发广泛争议的千问核心负责人林俊旸于朋友圈发文告别Qwen道:“今天 last day,当大家为我鼓掌那一下,我真是忍住了泪水,不管别人说我什么,我至少内心里真觉得做到了为兄弟们好为阿里云好为集团好,虽然很多真没做到位抱歉。” “不是这几天,我都不知道这世界这么多人爱我。”林俊旸嘱咐道,“要加油啊,坤坤!还有很多事情没做呢!”(新浪科技)

蜂巢能源1月全球动力电池装机量升至第八

2026年3月7日 13:55
根据SNEResearch最新数据,2026年1月蜂巢能源全球动力电池装机量1.9GWh,同比增长16.3%,市占率提升至2.7%,排名上升至全球第八。海外市场持续发力成为增长主因。依托宝马MINI独家供应、Stellantis短刀电池批量交付及VinFast配套放量,蜂巢能源海外装机高速增长,全球化战略加速落地。

西井科技更新IPO辅导备案

2026年3月7日 13:41
证监会官网信息显示,上海西井科技股份有限公司(简称“西井科技”)已于近日更新A股IPO辅导备案,辅导机构为申万宏源证券。西井科技成立于2015年,是一家正向自研无人驾驶商用车的中国科技企业,致力于通过AI智能化能力为大物流行业客户提供数字化升级和绿色转型解决方案。西井科技2025年底完成F+轮融资,由申万投资领投,建投投资、中银资产等机构联合参投。(财联社)

俄研究机构训练人工智能识别早期乳腺癌

2026年3月7日 13:13
俄罗斯研究机构近日训练出一种能够快速识别计算机断层扫描(CT)影像的人工智能,可以协助医生诊断早期乳腺癌。据塔斯社报道,俄罗斯圣彼得堡国立电子技术大学与阿尔马佐夫国家医学研究中心共同开发了一款医疗诊断辅助软件系统,其核心是神经网络这种人工智能技术。经过训练的神经网络能够自动处理上传至服务器的患者CT影像,标记出具有肿瘤特征的区域。标记后的影像会被发送给医生,由医生作出最终诊断。研究人员表示,过去解读一张CT影像通常需要一天,而这项技术的影像分析过程仅需几分钟,还可将临床误诊的概率降低约20%。(新华社)

后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!

2026年3月7日 12:59

副标题:适配 Bun 运行时,万级数据映射性能提升 200%,前后端解耦的终极方案。

01. 痛点共鸣:你是不是也在写这种代码?

很多前端同学在处理接口时,业务代码里塞满了这种逻辑:

const name = res.data.u_info_v2_name || "未知";
const status = res.data.state === 1;
const tags = res.data.raw_str ? res.data.raw_str.split(",") : [];

这种硬编码的后果:

  1. 脆弱:后端改一个字段名,前端全屏报错。
  2. 难看:业务逻辑被数据清洗逻辑淹没。
  3. 性能:大规模循环转换时,Node.js 的 GC 压力巨大。

02. 核心方案:BFFDataAdapter 架构逻辑

这不是简单“封装几个工具函数”,而是一层可维护、可演进的数据契约层:

  • Schema 驱动:字段映射配置化,后端字段变化时优先改配置。
  • 双向转换:toClient 负责展示态,toAPI 负责提交态。
  • 内置校验:字段缺失、格式错误直接在转换层拦截。
flowchart LR
  A[后端原始 JSON] --> B[BFFDataAdapter]
  B --> C[Schema 映射]
  C --> D[Transformer 转换]
  D --> E[Validator 校验]
  E --> F[前端干净对象]
  F -->|提交| B
  B --> G[API 请求体]

03. 场景化 Demo:电商订单数据处理

我们把后端杂乱字段映射为前端可读结构,同时做金额格式化和手机号校验。

核心代码

1-bff/BFFDataAdapter.js 提供适配器能力:

  • registerSchema(name, schema):注册数据契约。
  • registerTransformer(name, fn):注册转换器。
  • registerValidator(name, fn):注册校验器。
  • toClient(schema, payload):后端 -> 前端。
  • toAPI(schema, payload):前端 -> 后端。

同时提供 TypeScript 类型定义文件:1-bff/BFFDataAdapter.d.ts

1-bff/example-order.js 是完整示例,直接运行可看到双向转换效果。

JSON 自动生成 Schema

为了减少手写映射成本,提供了 CLI:1-bff/generate-schema.js

# 方式 1:直接传 JSON 字符串
bun 1-bff/generate-schema.js '{"order_id_long":"123","amount_fen":19900,"user":{"profile":{"nickname":"iDao"}}}' --name orderDetail

# 方式 2:从文件读取 JSON
bun 1-bff/generate-schema.js --file ./payload.json --name orderDetail

输出是可直接复制的 schema JSON(包含 schemaNameschema.fields)。

运行方式

# 运行业务示例
bun 1-bff/example-order.js

# JSON 自动生成 Schema
bun 1-bff/generate-schema.js '{"foo_bar":1,"user":{"name":"A"}}' --name demoSchema

# 跑万级数据压测(默认 10000 条)
bun 1-bff/benchmark.js

# 自定义条数
bun 1-bff/benchmark.js 50000

如果你想和 Node.js 对比:

node 1-bff/benchmark.js 10000
bun 1-bff/benchmark.js 10000

04. 性能进阶:为什么 Bun 环境更猛?

在 BFF 层做大批量数据映射时,性能瓶颈通常来自两块:

  • JSON 解析与对象分配
  • 字段级转换与校验循环

在这类场景里,Bun 在 JSON 处理和整体运行时开销上通常更有优势。你可以直接用 1-bff/benchmark.js 在本机得到真实数字,避免“玄学优化”。

提示:真实性能和机器配置、数据形态、转换逻辑复杂度相关。建议在你的真实数据样本上测。

05. 代码示例(节选)

import { createBFFAdapter } from "./BFFDataAdapter.js";

const adapter = createBFFAdapter();
adapter.registerTransformer("money", (val) => `¥${(val / 100).toFixed(2)}`);

adapter.registerSchema("orderDetail", {
  fields: {
    id: { source: "order_id_long", required: true },
    price: { source: "amount_fen", transform: "money" },
    customerName: { source: "user.profile.nickname", default: "匿名用户" },
    contact: { source: "service_phone", validate: "phone" },
    statusText: {
      source: "state_code",
      transform: (val) => (val === 1 ? "待发货" : "已完成"),
    },
  },
});

获取完整“全栈提效包”

我已经把这套适配器整理成支持 TypeScript 自动推导的进阶版(告别 any)。

资料包内含:

  1. BFFDataAdapter 完整源码及 TS 类型定义。
  2. 自动生成工具:输入一段 JSON,自动生成 Schema 配置。
  3. 性能压测脚本:亲自对比 Node vs Bun 的极限。

关注我的掘金/公众号 [iDao技术魔方],后台私信回复关键字 "BFF",立刻获取隐藏仓库地址。

文化和旅游部部长:2025年中国入境游客人次超过1.5亿,同比增长超17%

2026年3月7日 12:58
十四届全国人大四次会议今天举行民生主题记者会。 文化和旅游部部长孙业礼在会上介绍,中国政府推出一系列政策,繁荣入境旅游市场。2025年,中国入境游客人次超过1.5亿,同比增长超过17%,花费超过1300亿美元;其中免签入境的外国人超过3000万人次。(央视新闻)
❌
❌