阅读视图

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

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打地鼠游戏:零基础入门实践

打造你的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绘制时间轴

前言

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

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

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

时间轴

根据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 的“企业后台常用组件”用法扫盲

同学们好,我是 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篇

在学习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 到底在争什么

微前端沙箱隔离: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 的答案是"我让浏览器帮你隔开"。两个思路没有绝对的高下,只有场景的匹配度。

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

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

副标题:适配 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",立刻获取隐藏仓库地址。

Vue 3 Composition API深度解析:构建可复用逻辑的终极方案

引言

Vue 3的Composition API是Vue框架最重大的更新之一,它提供了一种全新的组件逻辑组织方式。与传统的Options API相比,Composition API让我们能够更灵活地组织和复用代码逻辑。本文将深入探讨Vue 3 Composition API的8大核心特性,帮助你掌握这个构建可复用逻辑的终极方案。

setup函数基础

1. setup函数的基本使用

setup函数是Composition API的入口点,它在组件创建之前执行。

import { ref, reactive } from 'vue';

export default {
  setup() {
    // 定义响应式数据
    const count = ref(0);
    const user = reactive({
      name: 'Vue 3',
      version: '3.0'
    });

    // 定义方法
    const increment = () => {
      count.value++;
    };

    // 返回给模板使用
    return {
      count,
      user,
      increment
    };
  }
};

2. setup函数的参数

setup函数接收两个参数:props和context。

export default {
  props: {
    title: String,
    initialCount: {
      type: Number,
      default: 0
    }
  },
  setup(props, context) {
    // props是响应式的,不能解构
    console.log(props.title);
    
    // context包含attrs、slots、emit等
    const { attrs, slots, emit } = context;
    
    // 触发事件
    const handleClick = () => {
      emit('update', props.initialCount + 1);
    };
    
    return { handleClick };
  }
};

响应式API详解

3. ref与reactive的选择

ref和reactive是创建响应式数据的两种方式,各有适用场景。

import { ref, reactive, toRefs } from 'vue';

// ref - 适合基本类型和单一对象
const count = ref(0);
const message = ref('Hello');

// 访问ref的值需要.value
console.log(count.value);
count.value++;

// reactive - 适合复杂对象
const state = reactive({
  count: 0,
  user: {
    name: 'Vue',
    age: 3
  }
});

// 访问reactive的值不需要.value
console.log(state.count);
state.count++;

// 在模板中自动解包,不需要.value
// <template>
//   <div>{{ count }}</div>
//   <div>{{ state.count }}</div>
// </template>

4. toRefs的使用

当需要从reactive对象中解构属性时,使用toRefs保持响应性。

import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({
      count: 0,
      name: 'Vue 3',
      isActive: true
    });

    // 不推荐 - 失去响应性
    // const { count, name } = state;

    // 推荐 - 使用toRefs保持响应性
    const { count, name, isActive } = toRefs(state);

    const increment = () => {
      count.value++;
    };

    return {
      count,
      name,
      isActive,
      increment
    };
  }
};

计算属性与侦听器

5. computed计算属性

computed用于创建计算属性,支持getter和setter。

import { ref, computed } from 'vue';

export default {
  setup() {
    const firstName = ref('John');
    const lastName = ref('Doe');

    // 只读计算属性
    const fullName = computed(() => {
      return firstName.value + ' ' + lastName.value;
    });

    // 可写计算属性
    const writableFullName = computed({
      get() {
        return firstName.value + ' ' + lastName.value;
      },
      set(value) {
        const [first, last] = value.split(' ');
        firstName.value = first;
        lastName.value = last;
      }
    });

    return {
      firstName,
      lastName,
      fullName,
      writableFullName
    };
  }
};

6. watch与watchEffect

watch和watchEffect用于侦听数据变化。

import { ref, reactive, watch, watchEffect } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const user = reactive({
      name: 'Vue',
      age: 3
    });

    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      console.log(`Count is: ${count.value}`);
      console.log(`User is: ${user.name}`);
    });

    // watch - 显式指定侦听源
    watch(count, (newValue, oldValue) => {
      console.log(`Count changed from ${oldValue} to ${newValue}`);
    });

    // 侦听多个源
    watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
      console.log(`Count: ${oldCount} -> ${newCount}, Name: ${oldName} -> ${newName}`);
    });

    // watch的配置选项
    watch(
      () => user.name,
      (newValue) => {
        console.log(`Name changed to: ${newValue}`);
      },
      {
        immediate: true,  // 立即执行
        deep: true        // 深度侦听
      }
    );

    return { count, user };
  }
};

生命周期钩子

7. 生命周期钩子的使用

Composition API中的生命周期钩子以on开头。

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount
} from 'vue';

export default {
  setup() {
    onBeforeMount(() => {
      console.log('组件挂载前');
    });

    onMounted(() => {
      console.log('组件已挂载');
      // 可以在这里访问DOM
    });

    onBeforeUpdate(() => {
      console.log('组件更新前');
    });

    onUpdated(() => {
      console.log('组件已更新');
    });

    onBeforeUnmount(() => {
      console.log('组件卸载前');
    });

    onUnmounted(() => {
      console.log('组件已卸载');
      // 清理工作
    });

    return {};
  }
};

自定义组合函数

8. 创建可复用的逻辑

自定义组合函数是Composition API的核心优势,让我们能够提取和复用逻辑。

// useCounter.js - 计数器逻辑
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  const double = computed(() => count.value * 2);

  return {
    count,
    increment,
    decrement,
    reset,
    double
  };
}

// useMouse.js - 鼠标位置追踪
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  const update = (event) => {
    x.value = event.pageX;
    y.value = event.pageY;
  };

  onMounted(() => {
    window.addEventListener('mousemove', update);
  });

  onUnmounted(() => {
    window.removeEventListener('mousemove', update);
  });

  return { x, y };
}

// 在组件中使用
import { useCounter, useMouse } from './composables';

export default {
  setup() {
    const { count, increment, decrement, double } = useCounter(10);
    const { x, y } = useMouse();

    return {
      count,
      increment,
      decrement,
      double,
      x,
      y
    };
  }
};

依赖注入

9. provide与inject

provide和inject用于跨组件层级传递数据。

// 父组件
import { provide, ref } from 'vue';

export default {
  setup() {
    const theme = ref('dark');
    const user = ref({
      name: 'Vue User',
      role: 'admin'
    });

    // 提供数据
    provide('theme', theme);
);
    provide('user', user);

    return { theme };
  }
};

// 子组件
import { inject } from 'vue';

export default {
  setup() {
    // 注入数据
    const theme = inject('theme');
    const user = inject('user');

    // 提供默认值
    const config = inject('config', {
      debug: false,
      version: '1.0'
    });

    return { theme, user, config };
  }
};

模板引用

10. 使用ref获取DOM元素

在Composition API中使用ref获取模板引用。

import { ref, onMounted } from 'vue';

export default {
  setup() {
    // 创建模板引用
    const inputRef = ref(null);
    const listRef = ref(null);

    onMounted(() => {
      // 访问DOM元素
      inputRef.value.focus();
      
      // 访问组件实例
      console.log(listRef.value.items);
    });

    const focusInput = () => {
      inputRef.value.focus();
    };

    return {
      inputRef,
      listRef,
      focusInput
    };
  }
};

// 模板中使用
// <template>
//   <input ref="inputRef" />
//   <MyList ref="listRef" />
// </template>

实战案例

11. 表单处理组合函数

// useForm.js
import { ref, reactive } from 'vue';

export function useForm(initialValues, validationRules) {
  const values = reactive({ ...initialValues });
  const errors = reactive({});
  const touched = reactive({});

  const validate = () => {
    let isValid = true;
    
    for (const field in validationRules) {
      const rules = validationRules[field];
      const value = values[field];
      
      for (const rule of rules) {
        if (rule.required && !value) {
          errors[field] = rule.message || '此字段必填';
          isValid = false;
          break;
        }
        
        if (rule.pattern && !rule.pattern.test(value)) {
          errors[field] = rule.message || '格式不正确';
          isValid = false;
          break;
        }
        
        if (rule.validator && !rule.validator(value)) {
          errors[field] = rule.message || '验证失败';
          isValid = false;
          break;
        }
      }
    }
    
    return isValid;
  };

  const handleChange = (field) => (event) => {
    values[field] = event.target.value;
    touched[field] = true;
    
    if (errors[field]) {
      validate();
    }
  };

  const handleBlur = (field) => () => {
    touched[field] = true;
    validate();
  };

  const reset = () => {
    Object.assign(values, initialValues);
    Object.keys(errors).forEach(key => {
      errors[key] = '';
    });
    Object.keys(touched).forEach(key => {
      touched[key] = false;
    });
  };

  const submit = (callback) => () => {
    if (validate()) {
      callback(values);
    }
  };

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
    submit
  };
}

// 使用示例
export default {
  setup() {
    const { values, errors, handleChange, handleBlur, submit } = useForm(
      {
        username: '',
        email: '',
        password: ''
      },
      {
        username: [
          { required: true, message: '用户名必填' },
          { pattern: /^[a-zA-Z0-9_]{3,20}$/, message: '用户名格式不正确' }
        ],
        email: [
          { required: true, message: '邮箱必填' },
          { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
        ],
        password: [
          { required: true, message: '密码必填' },
          { validator: (value) => value.length >= 6, message: '密码至少6位' }
        ]
      }
    );

    const handleSubmit = submit((formData) => {
      console.log('表单提交:', formData);
      // 发送API请求
    });

    return {
      values,
      errors,
      handleChange,
      handleBlur,
      handleSubmit
    };
  }
};

12. 异步数据获取组合函数

// useAsyncData.js
import { ref, onMounted } from 'vue';

export function useAsyncData(fetchFn, options = {}) {
  const {
    immediate = true,
    initialData = null,
    onSuccess,
    onError
  } = options;

  const data = ref(initialData);
  const loading = ref(false);
  const error = ref(null);

  const execute = async (...args) => {
    loading.value = true;
    error.value = null;

    try {
      const result = await fetchFn(...args);
      data.value = result;
      
      if (onSuccess) {
        onSuccess(result);
      }
      
      return result;
    } catch (err) {
      error.value = err;
      
      if (onError) {
        onError(err);
      }
      
      throw err;
    } finally {
      loading.value = false;
    }
  };

  if (immediate) {
    onMounted(execute);
  }

  return {
    data: data,
    loading: loading,
    error: error,
    execute: execute,
    refresh: execute
  };
}

// 使用示例
export default {
  setup() {
    const { data, loading, error, refresh } = useAsyncData(
      async (userId) => {
        const response = await fetch(`/api/users/${userId}`);
        return response.json();
      },
      {
        immediate: true,
        onSuccess: (data) => {
          console.log('数据加载成功:', data);
        },
        onError: (error) => {
          console.error('数据加载失败:', error);
        }
      }
    );

    return {
      data,
      loading,
      error,
      refresh
    };
  }
};

总结

Vue 3 Composition API为我们提供了更强大、更灵活的代码组织方式:

核心优势

  1. 逻辑复用:通过自定义组合函数轻松复用逻辑
  2. 代码组织:相关逻辑可以组织在一起,而不是分散在options中
  3. 类型推断:更好的TypeScript支持
  4. 灵活性:更灵活的代码组织方式

最佳实践

  1. 合理使用ref和reactive:基本类型用ref,复杂对象用reactive
  2. 提取组合函数:将可复用逻辑提取为独立的组合函数
  3. 保持单一职责:每个组合函数只负责一个功能
  4. 善用toRefs:解构reactive对象时使用toRefs保持响应性
  5. 合理使用生命周期:在setup中正确使用生命周期钩子

学习路径

  1. 掌握setup函数和响应式API
  2. 学习computed和watch的使用
  3. 理解生命周期钩子
  4. 实践自定义组合函数
  5. 掌握依赖注入和模板引用

Composition API不仅是一种新的API,更是一种新的思维方式。它让我们能够以更函数式、更模块化的方式组织代码,提高了代码的可维护性和可测试性。开始在你的项目中使用Composition API吧,体验Vue 3带来的全新开发体验!


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

大道至简 - Juejin Notifier - 掘金消息通知小助手

1.png

在说正事之前,还是要祝各位彦祖亦菲在新的一年里,身体如龙马般精神健硕,事业如朝阳般蒸蒸日上;愿家中灯火可亲,有爱人相伴,有暖茶在心;愿前路浩浩荡荡,万事尽可期待,所求皆如愿,所行化坦途,岁岁常欢愉,年年皆胜意。新年快乐,阖家安康!

好了,收。RT,这个扩展只做一件事:掘金消息通知,就只是告诉你哪类消息有几条未读,无了。

开发初衷:作为一名合格的牛马,必然是天天坐在电脑前认真的搬砖,能够方便的知道有新消息来了,然后再摸着去网站上看一眼就够了。扩展就应该做扩展该做的事情,如果发文章这类事情都交给扩展来做,那 web 站用来做什么?所以这次扩展来掘城只办三件事:通知通知还是TMD通知。

Juejin Notifier 是一款 Chrome 扩展,通过掘金的官方 API 获取消息通知(顺道显示了一下头像和昵称,咱就确认一下是自己的账号就行了,别整了半天是别人的号)。无需频繁的打开掘金 web 站,即可第一(也可能是第二?)时间获取赞和收藏、评论、新增粉丝、私信、系统通知五类消息动态。

代码已开源,点击这里跳转到 GitHub 仓库 ↗

功能

  • 消息通知:及时获取新消息,不错过任何互动,快速查看各类消息通知条数。

  • 个性化设置:可忽略不关心的消息类型;自定义刷新时间间隔;也可手动刷新。

  • 多主题支持(跟随系统 / 浅色 / 深色)。

截图预览

1.png2.png3.png4.png

安装方法

方式一:Chrome Web Store 安装(推荐)

访问 Chrome Web Store 上的 Juejin Notifier ↗ 页面安装。

image.png

方式二:本地安装(开发者模式)

克隆项目,打开 Chrome 浏览器,进入扩展管理页面 (chrome://extensions/),开启右上角的“开发者模式”,点击“加载已解压的扩展程序”,选择扩展文件夹即可。

写在最后

如果各位彦祖亦菲还有什么过分的需求,尽管开口,我努力做到。

也不是我吹牛,反正在座的各位今年一定百事百顺,父母一定长命百岁,做什么事一定手气爆棚。

如果这款扩展真的帮助到了你,还请给个好评 ↗。如果没帮到你,说明我还有很大的进步空间,也请给个好评 ↗以资鼓励。数据只存在本地,代码已开源,可以点击这里跳转到 GitHub 仓库 ↗查看。

前端权限控制设计

一、展示控制

前端权限控制的目的是,根据当前用户的身份控制其能访问的页面和可执行的操作。需要注意的是:前端权限控制主要是为了提升用户体验(如隐藏无权限的菜单,按钮),正真的数据安全必须依赖后端实现。

二、RBAC

业界主流的权限管理模型是RBAC(基于角色的访问控制),其核心思想是将"权限"授予"角色",将"角色"授予"用户",实现了用户与权限的逻辑分离,极大的简化了权限的分配与管理。

三、主要流程

主要包括用户身份认证、权限分配、权限校验和页面展示控制。

  • 用户登录后,前端从后端获取用户的权限列表。
  • 前端根据用户权限信息,决定展示哪些菜单或按钮。
  • 路由级别做权限校验,未授权用户访问受限页面时自动跳转到无权限提示页或登录页。
  • 组件级别做权限控制,操作按钮或表单项根据权限动态展示或禁用。

四、实现要点

1.获取用户权限信息

// context/AuthProvider

const AuthContext = createContext(undefined);

export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  // 从本地存储中恢复用户权限信息
  useEffect(() => {
    const user = localStorage.getItem('user');
    if (user) {
      setUser(JSON.parse(user));
    }
  }, []);

  const login = async (username, password) => {
    const user = await loginApi(username,password);
    setUser(user);
    // 登录后缓存用户权限信息
    localStorage.setItem('user', JSON.stringify(user));
  };

  const logout = () => {
    setUser(null);
    // 登出后清除本地缓存
    localStorage.removeItem('user');
  };

  const hasPermission = (permission: string | string[]): boolean => {
    if (!user) return false;
    if (Array.isArray(permission)) {
      return permission.some(p => user.permissions.includes(p));
    }
    
    return user.permissions.includes(permission);
  };

  const value = {
    user,
    login,
    logout,
    hasPermission
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

2.封装路由权限校验组件

// components/AuthRoute.js
import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

const AuthRoute = ({ children, meta }) => {
  const { user, hasPermission } = useAuth();

   // 用户未登录,重定向到登录页面
  if (meta.requiresAuth && !user) {
    return <Navigate to="/login" replace />;
  }

  // 用户没有权限,重定向到未授权页面
  if (meta.permission && !hasPermission(meta.permission)) {
    return <Navigate to="/403" replace />;
  }

  // 权限通过,渲染子组件
  return children;
};

export default AuthRoute;

3.创建路由

// router/index.js
import AuthRoute from '../components/AuthRoute';

const Router = () => {
  const element = routes.map(({ path, element:Component, meta }) => ({
      path,
      element: (
        <AuthRoute meta={meta}>
          <Component />
        </AuthRoute>
      )
  }));
  return <RouterProvider router={createBrowserRouter(routers)} />;
};

export default Router;

4.封装按钮权限校验组件


import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

export const AuthButton = ({
  permission,
  children,
  onClick,
}) => {
  const { hasPermission } = useAuth();
  const hasAccess = hasPermission(permission);

  if (!hasAccess) {
    return null;
  }

  return (
    <button 
      onClick={onClick} 
    >
      {children}
    </button>
  );
};

5.按钮权限控制

import { AuthButton } from '../components/AuthButton';

export const ContentManagement = () => {
  
  return (
     <AuthButton 
        permission="content.edit"
        onClick={() => handleEdit(item.id)}
     >
        编辑
     </AuthButton>
  );
};

五、技术难点

1.多粒度权限控制

  • 页面级权限控制:通过前端路由守卫实现,例如,React Router的高阶组件、Vue Router 的beforeEach钩子。
  • 组件级权限控制:通过条件渲染隐藏或禁用无权限的按钮。

2.细粒度权限控制

按钮、表单项等细粒度权限控制,难点在于检查点分散,如果每个按钮都要添加额外的权限控制逻辑,维护成本高;另外权限检查函数频繁执行(如在列表中渲染几十个按钮),可能造成性能问题。

常用的做法是封装自定义 Hook(如 usePermission)或高阶组件,并且缓存组件的权限检查结果。

3.状态管理的复杂性

用户权限信息需要全局共享且保持一致性。难点在于:

  • 初始化时机:页面渲染时可能还没拿到用户信息,容易导致未授权页面闪现。
  • Token 过期:接口返回Token过期,需要自动跳转登录,同时清空本地缓存。
  • 多标签页同步:如果一个标签页登出,其他标签页也需要更新状态,否则可能操作报错。

解决方案通常是利用 Context全局共享,使用webStorage本地缓存,利用广播实现多标签页同步。

4.前后端权限一致性

前端权限控制本质是提升用户体验,正真的数据安全必须依赖后端实现。但难点在于:

  • 双重校验的一致性:前端隐藏了按钮,用户仍可能通过直接访问 API 进行操作,所以后端必须对所有接口做权限校验。
  • 数据同步滞后:如果后端修改了用户权限,前端可能仍保留旧的权限缓存,导致用户看到不应看到的操作或无法访问新功能。需要设计合适的刷新机制(如定时拉取、权限变更后强制刷新)。

事件循环底层原理:从 V8 引擎到浏览器实现

前阵子面试被问到:async/await 被编译成什么样了?

我答不上来。面试官说:你用了这么久 async/await,连它怎么实现的都不知道?

回来研究了 V8 源码和 ECMAScript 规范,才发现异步编程的水比想象中深得多。

一、async/await 不是语法糖

很多人说 async/await 是 Promise 的语法糖,严格来说不对。

它更接近 Generator + Promise 的自动执行器。V8 引擎会把 async 函数编译成状态机。

看这段代码:

async function foo() {
  console.log(1);
  await bar();
  console.log(2);
}

V8 编译后大致等价于:

function foo() {
  return new Promise(resolve => {
    const stateMachine = {
      state: 0,
      next(value) {
        switch (this.state) {
          case 0:
            console.log(1);
            this.state = 1;
            return Promise.resolve(bar()).then(v => this.next(v));
          case 1:
            console.log(2);
            resolve();
            return;
        }
      }
    };
    stateMachine.next();
  });
}

每个 await 把函数分成不同的状态,执行完一个 await 就切换到下一个状态。

这就是为什么 await 后面的代码会被放进微任务队列——因为它实际上是 .then() 的回调。

面试追问:为什么 async/await 比 Promise.then 性能好?

因为 V8 对 async/await 做了优化,减少了 Promise 对象的创建。手写 .then().then().then() 会创建多个 Promise 实例,而 async/await 内部可能只创建一个。

二、微任务队列的真实实现

网上都说"微任务队列",但实际上不止一个队列。

根据 HTML 规范,浏览器有:

  1. 微任务队列(Microtask Queue)

    • Promise.then/catch/finally
    • MutationObserver
    • queueMicrotask
  2. Job Queue(ECMAScript 层面)

    • Promise Jobs
    • 这是 ES 规范定义的,比 HTML 规范更底层

Node.js 更复杂:

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

Node.js 输出:nextTickpromisetimeoutimmediate

Node.js 有多个队列:

  • nextTick Queue(优先级最高)
  • Promise Queue
  • Timer Queue(setTimeout/setInterval)
  • Check Queue(setImmediate)
  • Poll Queue(I/O)
  • Close Queue

这是一个很多人不知道的点:Node.js 和浏览器的事件循环实现完全不同。

浏览器:HTML 规范定义,一个微任务队列 + 一个宏任务队列

Node.js:libuv 实现,多个阶段,每个阶段有自己的队列

三、MutationObserver 为什么是微任务?

MutationObserver 用来监听 DOM 变化:

const observer = new MutationObserver(() => {
  console.log('DOM changed');
});
observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));
console.log('sync');

输出:syncDOM changed

DOM 变化后,回调不是立即执行,而是放进微任务队列。

为什么这样设计?

假设一个循环里改了 100 次 DOM:

for (let i = 0; i < 100; i++) {
  document.body.appendChild(document.createElement('div'));
}

如果每次 DOM 变化都触发回调,会执行 100 次。但如果放进微任务队列,100 次修改完成后只执行一次回调(批量处理)。

这是性能优化的经典设计。

四、Promise 的 then 为什么返回新 Promise?

看这道题:

const p = Promise.resolve(1);
const p2 = p.then(val => val + 1);

console.log(p === p2); // false

then 返回的是新 Promise,不是原来的。

为什么?

为了链式调用。如果返回同一个 Promise,链就会断掉:

Promise.resolve(1)
  .then(val => val + 1) // 返回新 Promise,resolve(2)
  .then(val => val + 2) // 拿到上一个 then 返回的 Promise
  .then(console.log);   // 4

每个 then 都返回新 Promise,形成一条链。

深层问题:then 返回的 Promise 什么时候 settle?

const p = new Promise(resolve => {
  setTimeout(() => resolve('done'), 1000);
});

const p2 = p.then(val => val + '!');

p2 不是立即 settle 的,而是等 p resolve 后,then 的回调执行完,p2 才 resolve。

这涉及 Promise Resolution Procedure(Promise 解决过程),是 ES 规范里最复杂的部分之一。

五、手写 Promise 的核心难点

网上手写 Promise 的文章很多,但大部分都漏了关键点。

1. then 的回调可以返回 Promise

Promise.resolve(1)
  .then(val => Promise.resolve(val + 1))
  .then(console.log); // 2

then 的回调如果返回 Promise,要等这个 Promise settle 后,外层 then 返回的 Promise 才 settle。

then(onFulfilled) {
  return new Promise((resolve, reject) => {
    const result = onFulfilled(this.value);
    // 关键:如果 result 是 Promise,要等它
    if (result instanceof Promise) {
      result.then(resolve, reject);
    } else {
      resolve(result);
    }
  });
}

2. then 可以被调用多次

const p = Promise.resolve(1);
p.then(console.log); // 1
p.then(console.log); // 1
p.then(console.log); // 1

每个 then 都要执行,所以要维护一个回调数组:

class MyPromise {
  constructor(executor) {
    this.callbacks = [];
    
    const resolve = value => {
      this.value = value;
      this.callbacks.forEach(cb => cb(value));
    };
    
    executor(resolve);
  }
  
  then(onFulfilled) {
    this.callbacks.push(onFulfilled);
  }
}

3. 错误穿透

Promise.reject('error')
  .then(val => val + 1)
  .then(val => val + 2)
  .catch(err => console.log(err)); // error

错误会沿着链传递,直到遇到 catch。

then(onFulfilled, onRejected) {
  return new Promise((resolve, reject) => {
    const handle = () => {
      if (this.state === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      } else if (this.state === 'rejected') {
        if (onRejected) {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        } else {
          // 错误穿透:没有 onRejected 就继续传递
          reject(this.reason);
        }
      }
    };
    
    if (this.state) {
      // 已 settle,异步执行
      queueMicrotask(handle);
    } else {
      // pending,加入队列
      this.callbacks.push(handle);
    }
  });
}

六、性能优化:避免 Promise 地狱

问题:Promise 创建是有开销的

// 不好:创建大量不必要的 Promise
async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await Promise.resolve(item).then(x => x * 2);
    results.push(result);
  }
  return results;
}

// 好:直接处理
async function processItems(items) {
  return items.map(item => item * 2);
}

问题:微任务队列堆积

// 这段代码会导致微任务队列堆积,阻塞渲染
async function bad() {
  while (true) {
    await Promise.resolve();
    // 这个循环会永远执行,UI 会卡死
  }
}

微任务不会让出执行权给渲染,所以长时间运行的微任务会让页面卡顿。

解决方案:偶尔让出控制权

async function good() {
  while (true) {
    await new Promise(resolve => setTimeout(resolve, 0));
    // 让出控制权,让浏览器有机会渲染
  }
}

setTimeout(0) 会创建宏任务,每次宏任务之间浏览器有机会渲染。

七、冷门但重要的知识点

1. Promise 的构造函数是同步执行的

const p = new Promise(resolve => {
  console.log('executor');
  resolve(1);
});

console.log('after new');

// 输出:executor → after new

Promise 构造函数里的代码是同步执行的,只有 then 回调是异步的。

2. unhandledrejection 事件

Promise.reject('error');

window.addEventListener('unhandledrejection', event => {
  console.log('未处理的 rejection:', event.reason);
});

Promise 被 reject 但没有 catch,会触发这个事件。

Node.js 类似:

process.on('unhandledRejection', (reason, promise) => {
  console.log('未处理的 rejection:', reason);
});

3. Promise.finally 的特殊行为

Promise.resolve(1)
  .finally(() => {
    console.log('finally');
    return 2; // 返回值被忽略
  })
  .then(console.log); // 1,不是 2

finally 不改变传递的值,只执行副作用。

但如果 finally 返回 rejected Promise:

Promise.resolve(1)
  .finally(() => {
    return Promise.reject('error');
  })
  .then(
    val => console.log(val),
    err => console.log(err) // error
  );

4. async 函数的隐式 try-catch

async function foo() {
  throw new Error('fail');
}

foo();
// 错误被包装成 rejected Promise,不会抛到全局

等价于:

function foo() {
  return new Promise((resolve, reject) => {
    try {
      throw new Error('fail');
    } catch (err) {
      reject(err);
    }
  });
}

八、调试异步代码的技巧

1. Chrome DevTools 的 Async Stack Trace

勾选 Console 的 "Async" 选项,可以看到异步调用栈:

async function a() {
  await b();
}

async function b() {
  await c();
}

async function c() {
  console.log('here');
  throw new Error('fail');
}

a();

不开启 Async Stack Trace,调用栈只有 c。

开启后,可以看到 a → b → c 的完整调用链。

2. Node.js 的 --async-stack-traces

node --async-stack-traces app.js

Node.js 12+ 支持,让异步错误堆栈更清晰。

总结

异步编程的难点不在 API,而在于:

  1. 理解底层机制 — V8 如何编译 async/await,事件循环如何调度
  2. 知道边界情况 — Node.js 和浏览器的差异,微任务堆积问题
  3. 能写出正确实现 — Promise 的 resolve procedure,then 的链式调用

面试时,面试官问你"async/await 怎么实现的",不是让你背答案,而是看你是否真的理解原理。


参考资料:

跨域获取 iframe 选中文本?自己写个代理中间层,再也不求后端!

RAGFlow 跨域文本选中无法获取?自己写个代理中间层,零后端搞定!

教育志项目需要嵌入 RAGFlow 的原文预览,并获取用户选中的文本插入编辑器。RAGFlow 无后端接口、无法修改代码,跨域三要素全占,怎么办?自己写个 Node 代理中间层,轻松破局!

前言

最近参与了一个教育志编修项目,核心需求是多人协同编写教育年鉴,并依赖 RAGFlow 对原始文献进行切片管理。作者在编写文档时,需要随时检索、查看 RAGFlow 中的原始文献,并能够将原文中选中的片段直接插入到正在编写的文档中

技术方案很自然:在编辑器旁边通过 iframe 嵌入 RAGFlow 的原文预览页面,用户选中文字,点击“引用”按钮,即可将选中内容插入编辑器。然而,现实给了我们一记重拳——跨域

RAGFlow 是独立部署的系统,与教育志项目的主应用完全不同源(协议、域名、端口三要素全占)。更棘手的是:RAGFlow 没有提供任何后端接口,没有技术支持,我们无法修改它的代码,也没有办法通过后端代理去抓取页面(因为涉及动态交互) 。浏览器同源策略像一堵无法逾越的墙,父窗口无法通过 contentWindow.document 访问 iframe 内的 DOM,更别说监听选中事件了。

常规方案纷纷失效

方案 为什么不行
postMessage 需要目标页面内配合发送消息,但 RAGFlow 代码无法修改
CORS 跨域资源共享 只适用于接口请求,对 DOM 操作无效
服务器端代理 由后端抓取页面再返回,但 RAGFlow 页面是动态交互的,无法模拟用户选中行为

项目工期紧,前端必须自己杀出一条血路。最终,我们采用了一个“骚操作”——自建 Node 代理中间层,在代理层动态修改 HTML,注入我们需要的脚本,让 iframe 和父窗口“同源”,从而实现跨域 DOM 操作。

本文将完整还原这一方案,并附上可直接运行的源码。无论你遇到的是 RAGFlow 还是任何其他跨域页面,只要你想获取 iframe 内的用户选区,这套方法都能帮你“曲线救国”。

最终效果

我们搭建的代理服务运行在本地 3002 端口,前端只需将 iframe 的 src 指向代理地址,例如:

html

<iframe src="http://localhost:3002/ragflow/docs/123.html"></iframe>

当用户在 iframe 内选中任何文本,父窗口就能收到包含文本内容、位置、上下文等详细信息的消息:

json

{
  "type": "TEXT_SELECTED",
  "text": "光绪二十四年(1898年),京师大学堂成立...",
  "context": {
    "before": "此前,中国近代教育...",
    "after": "此后,各省纷纷设立学堂..."
  },
  "position": { "x": 150, "y": 200 },
  "meta": { "charCount": 48, "wordCount": 9 }
}

父窗口收到消息后,可以立即将文本插入编辑器中,整个过程对用户透明,RAGFlow 无需任何改动,教育志项目后端也无需介入

原理图解

整个方案的核心是:利用 Node.js 创建一个代理服务器,将 RAGFlow 的页面“偷”回来,然后在返回前注入我们自己的脚本

text

浏览器 (教育志项目) 
    │
    │ iframe src="http://localhost:3002/ragflow/docs/..."
    ▼
代理服务 (Node.js)  ← 这是我们自己写的,独立部署
    │
    │ 1. 向 RAGFlow 服务器发起请求(无任何修改)
    ▼
RAGFlow 服务器 (https://ragflow.example.com)  ← 完全不知情
    │
    │ 2. 返回 HTML 内容
    ▼
代理服务
    │
    │ 3. 解压、修改 HTML
    │    ├─ 插入 <base> 标签(修正资源路径)
    │    └─ 注入自定义脚本(不仅限于文本选中,可以是任意你需要的脚本)
    │ 4. 返回修改后的 HTML 给 iframe
    ▼
iframe 加载修改后的页面,注入的脚本开始工作
    │
    │ 5. 根据注入脚本的功能执行操作(如监听 mouseup、捕获选中文本)
    │ 6. 通过 window.parent.postMessage 发送给父窗口
    ▼
父窗口收到消息,将文本插入编辑器

通过这种方式,iframe 的源变成了代理服务的源(例如 http://localhost:3002),与父窗口同源,postMessage 通信畅通无阻,且脚本可以自由操作 iframe 的 DOM。整个过程对 RAGFlow 完全透明,它甚至不知道自己被代理了。

更关键的是:注入的脚本不限于文本选择——你可以利用这个能力,在目标页面中植入任何你想要的功能,例如:

  • 自动填充表单
  • 追踪用户点击行为
  • 修改页面样式
  • 劫持 Ajax 请求
  • 甚至是一个完整的调试工具

代理层就像是一个“中间人”,让你在不修改原始页面的前提下,为它增加任意前端能力。

核心代码逐段解析

1. 启动 HTTP 服务器

javascript

const http = require("http");
const url = require("url");

const PORT = process.env.PROXY_PORT || 3002;
const TARGET_HOST = process.env.TARGET_HOST || "ragflow.example.com"; // 你的 RAGFlow 域名

const server = http.createServer((req, res) => {
  const parsed = url.parse(req.url, true);
  const pathname = parsed.pathname;

  // 健康检查
  if (pathname === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok" }));
  } else {
    // 其他所有请求都交给代理函数处理
    proxyRequest(pathname + (parsed.search || ""), res).catch(err => {
      res.writeHead(502);
      res.end("Proxy Error: " + err.message);
    });
  }
});

server.listen(PORT, () => {
  console.log(`Proxy running at http://localhost:${PORT}`);
});

2. 代理请求函数 proxyRequest

这是最核心的部分,负责向 RAGFlow 发起请求,并根据返回内容做不同处理。

javascript

const https = require("https");
const zlib = require("zlib");

async function proxyRequest(targetPath, res) {
  const options = {
    hostname: TARGET_HOST,
    port: 443,
    protocol: "https:",
    path: targetPath,
    headers: {
      "User-Agent": "Mozilla/5.0 ...",
      "Accept-Encoding": "gzip, deflate, br",
      // ... 其他头
    },
    rejectUnauthorized: false, // 忽略证书错误(调试用)
  };

  return new Promise((resolve, reject) => {
    const proxyReq = https.request(options, async (proxyRes) => {
      // 收集数据
      const chunks = [];
      proxyRes.on("data", chunk => chunks.push(chunk));
      proxyRes.on("end", async () => {
        const buffer = Buffer.concat(chunks);
        const encoding = proxyRes.headers["content-encoding"];
        const decompressed = await decompress(buffer, encoding);

        const contentType = proxyRes.headers["content-type"] || "";
        const statusCode = proxyRes.statusCode;

        // 处理重定向
        if (statusCode >= 300 && statusCode < 400 && proxyRes.headers.location) {
          const location = proxyRes.headers.location;
          const newPath = location.startsWith("http")
            ? url.parse(location).path
            : location;
          return proxyRequest(newPath, res).then(resolve).catch(reject);
        }

        // 非200错误
        if (statusCode !== 200) {
          res.writeHead(statusCode, { "Content-Type": "text/plain" });
          res.end("Error: " + statusCode);
          return resolve();
        }

        // 判断是否为 HTML(RAGFlow 的原文页面通常是 HTML)
        const isHtml = contentType.includes("text/html");

        const headers = { "Access-Control-Allow-Origin": "*" };

        if (isHtml) {
          // 修改 HTML 并注入脚本
          let html = decompressed.toString("utf-8");
          html = modifyHtml(html, TARGET_HOST);
          headers["Content-Type"] = "text/html; charset=utf-8";
          headers["Content-Length"] = Buffer.byteLength(html);
          res.writeHead(200, headers);
          res.end(html);
        } else {
          // 非 HTML 资源(CSS、JS、图片等)直接透传
          headers["Content-Type"] = contentType || "application/octet-stream";
          res.writeHead(200, headers);
          res.end(decompressed);
        }
        resolve();
      });
    });

    proxyReq.on("error", reject);
    proxyReq.on("timeout", () => {
      proxyReq.destroy();
      reject(new Error("Timeout"));
    });
    proxyReq.end();
  });
}

3. 解压函数 decompress

支持 gzip、deflate、br 解压。

javascript

function decompress(buffer, encoding) {
  return new Promise((resolve, reject) => {
    if (!encoding || encoding === "identity") resolve(buffer);
    else if (encoding === "gzip") zlib.gunzip(buffer, (e, r) => e ? reject(e) : resolve(r));
    else if (encoding === "deflate") zlib.inflate(buffer, (e, r) => e ? reject(e) : resolve(r));
    else if (encoding === "br") zlib.brotliDecompress(buffer, (e, r) => e ? reject(e) : resolve(r));
    else resolve(buffer);
  });
}

4. 修改 HTML 并注入脚本 modifyHtml

这里做了两件事:替换相对路径为绝对路径(防止资源加载失败),并注入我们的自定义脚本。你可以把脚本换成任何你需要的功能,不局限于文本选择。

javascript

// 注入脚本 - 这里以文本选择捕获为例
// 你可以根据需求替换为其他任意功能
const INJECTED_SCRIPT = `<script>
(function() {
    if (window.__knowledgeProxyInjected) return;
    window.__knowledgeProxyInjected = true;

    console.log('[RAGFlow Proxy] 脚本已注入');

    // 示例:监听文本选择
    document.addEventListener('mouseup', function(e) {
        const selection = window.getSelection();
        const text = selection.toString().trim();
        if (!text) return;

        // 获取选区位置、上下文等信息
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        // 提取上下文(前后各100字符)
        const container = range.commonAncestorContainer;
        const fullText = container.textContent || '';
        const index = fullText.indexOf(text);
        const before = index > 0 ? fullText.substring(Math.max(0, index - 100), index) : '';
        const after = index + text.length < fullText.length ? fullText.substring(index + text.length, index + text.length + 100) : '';

        window.parent.postMessage({
            type: 'TEXT_SELECTED',
            text: text,
            context: { before, after },
            position: {
                x: e.clientX,
                y: e.clientY,
                rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
            },
            meta: {
                charCount: text.length,
                wordCount: text.split(/\s+/).filter(w => w.length > 0).length,
                timestamp: Date.now()
            }
        }, '*');
    });

    // 也可以注入其他功能,比如:
    // - 监听点击事件并上报
    // - 自动填充表单
    // - 修改页面样式
    // - 劫持 fetch 请求
    // - 添加调试面板

    // 通知父窗口 iframe 已就绪
    window.parent.postMessage({ type: 'IFRAME_READY' }, '*');
})();
</script>`;

function modifyHtml(html, targetHost) {
  // 替换相对路径为绝对路径
  html = html.replace(/(href|src)=["']/([^"']+)["']/gi, '$1="https://' + targetHost + '/$2"');
  html = html.replace(/url(["']?/([^"')]+)["']?)/gi, 'url(https://' + targetHost + '/$1)');

  // 插入 base 标签和脚本
  const baseTag = '<base href="https://' + targetHost + '/">';
  const headEndIndex = html.toLowerCase().indexOf('</head>');
  if (headEndIndex !== -1) {
    html = html.slice(0, headEndIndex) + baseTag + INJECTED_SCRIPT + html.slice(headEndIndex);
  } else {
    html = baseTag + INJECTED_SCRIPT + html;
  }
  return html;
}

5. (可选)DOCX 等二进制文件的友好处理

RAGFlow 中可能包含 Word 文档,浏览器无法直接预览,我们可以返回一个下载提示页,并提供“请求转换”的扩展点(用于调用后端转换服务)。

javascript

function generateDocxPage(targetHost, targetPath) {
  return `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>文档下载</title><style>...</style></head>
<body>
  <div class="box">
    <h2>Word 文档</h2>
    <p>该文档为 DOCX 格式,无法直接在浏览器中预览</p>
    <a class="btn" href="https://${targetHost}${targetPath}" download>下载文档</a>
    <button class="btn" onclick="window.parent.postMessage({type:'REQUEST_DOCX_CONVERT',url:window.location.href},'*')">请求转换</button>
  </div>
</body>
</html>`;
}

如何集成到教育志项目中

  1. 部署代理服务
    将上述 server.js 部署到服务器(或本地开发环境),通过环境变量 TARGET_HOST 指定 RAGFlow 的域名,例如:

    bash

    export TARGET_HOST=ragflow.example.com
    node server.js
    

    服务默认运行在 3002 端口。

  2. 修改前端代码
    在需要展示原文的页面中,将 iframe 的 src 指向代理地址:

    html

    <iframe id="ragflowPreview" src="http://your-proxy-domain:3002/ragflow/path/to/document"></iframe>
    
  3. 监听消息并插入编辑器
    在父窗口中监听 message 事件,收到 TEXT_SELECTED 消息后,将文本插入编辑器(如 TinyMCE、Quill 或自定义编辑器):

    javascript

    window.addEventListener('message', (event) => {
      if (event.data.type === 'TEXT_SELECTED') {
        editor.insertText(event.data.text); // 根据实际编辑器 API 调整
      }
    });
    

整个过程完全无侵入:RAGFlow 不需要任何改动,教育志项目后端也不需要提供新接口,前端只需要修改 iframe 的 src 地址即可。

为什么不用其他方案?(再次强调)

方案 问题
postMessage 需要 RAGFlow 页面内添加代码,不可能
CORS 只适用于接口,不适用于 DOM
后端代理抓取 需要后端配合,且无法模拟用户交互(选中文本)
浏览器插件 需要用户安装,不现实

而我们的代理中间层方案,独立部署、零侵入、纯前端集成,完美解决了所有痛点。

进阶功能:注入任意脚本,扩展无限可能

代理层的核心价值在于:你可以在目标页面中执行任何你想要的 JavaScript 代码。除了文本选择捕获,你还可以:

  • 用户行为分析:监听点击、滚动、停留时间,上报给父窗口进行埋点。
  • 动态样式调整:根据父窗口的主题,动态修改 iframe 内的 CSS,实现视觉统一。
  • 表单自动填充:为 RAGFlow 的搜索框自动填入关键词(父窗口传递)。
  • 请求拦截与修改:劫持 iframe 内的 fetch/XHR 请求,添加认证头或修改返回值。
  • 注入调试工具:在开发环境中注入 Eruda 或 vConsole,方便调试。

你只需要修改 INJECTED_SCRIPT 的内容,就可以像操作自己的页面一样操作跨域 iframe 内的所有内容。这为前端开发打开了无限的可能性。

注意事项

  • CSP 限制:如果 RAGFlow 页面有严格的 Content-Security-Policy,可能阻止内联脚本执行。此时需要更复杂的处理(如通过 nonce 或动态创建 script 标签),但大多数系统不会设置如此严格的策略。
  • 证书问题:如果 RAGFlow 使用自签名证书,设置 rejectUnauthorized: false 可临时绕过,生产环境建议妥善配置证书。
  • 性能优化:代理会缓冲整个响应体,对于超大 HTML 可能占用内存。可考虑流式转发,但修改 HTML 需要完整内容,此处不再展开。

完整源码

最后,附上整合了以上所有功能的 server.js 完整源码(可直接运行):

javascript

// server.js - 教育志 RAGFlow 代理中间层
const http = require("http");
const https = require("https");
const url = require("url");
const zlib = require("zlib");

const PORT = process.env.PROXY_PORT || 3002;
const TARGET_HOST = process.env.TARGET_HOST || "ragflow.example.com";

// 注入脚本 - 你可以根据需要自由修改!
const INJECTED_SCRIPT = `<script>
(function() {
    if (window.__knowledgeProxyInjected) return;
    window.__knowledgeProxyInjected = true;

    console.log('[RAGFlow Proxy] 脚本已注入');

    // 示例:监听文本选择
    document.addEventListener('mouseup', function(e) {
        const selection = window.getSelection();
        const text = selection.toString().trim();
        if (!text) return;

        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();

        // 提取上下文
        const container = range.commonAncestorContainer;
        const fullText = container.textContent || '';
        const index = fullText.indexOf(text);
        const before = index > 0 ? fullText.substring(Math.max(0, index - 100), index) : '';
        const after = index + text.length < fullText.length ? fullText.substring(index + text.length, index + text.length + 100) : '';

        window.parent.postMessage({
            type: 'TEXT_SELECTED',
            text: text,
            context: { before, after },
            position: {
                x: e.clientX,
                y: e.clientY,
                rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
            },
            meta: { charCount: text.length, wordCount: text.split(/\s+/).filter(w => w.length > 0).length }
        }, '*');
    });

    // 你可以在这里注入任意其他功能:
    // - 监听点击事件并上报
    // - 自动填充表单
    // - 修改页面样式
    // - 劫持 fetch 请求
    // - 添加调试面板

    window.parent.postMessage({ type: 'IFRAME_READY' }, '*');
})();
</script>`;

// 解压函数
function decompress(buffer, encoding) {
  return new Promise((resolve, reject) => {
    if (!encoding || encoding === "identity") resolve(buffer);
    else if (encoding === "gzip") zlib.gunzip(buffer, (err, result) => err ? reject(err) : resolve(result));
    else if (encoding === "deflate") zlib.inflate(buffer, (err, result) => err ? reject(err) : resolve(result));
    else if (encoding === "br") zlib.brotliDecompress(buffer, (err, result) => err ? reject(err) : resolve(result));
    else resolve(buffer);
  });
}

// 修改 HTML
function modifyHtml(html, targetHost) {
  html = html.replace(/(href|src)=["']/([^"']+)["']/gi, '$1="https://' + targetHost + '/$2"');
  html = html.replace(/url(["']?/([^"')]+)["']?)/gi, 'url(https://' + targetHost + '/$1)');

  const baseTag = '<base href="https://' + targetHost + '/">';
  const headEndIndex = html.toLowerCase().indexOf('</head>');
  if (headEndIndex !== -1) {
    html = html.slice(0, headEndIndex) + baseTag + INJECTED_SCRIPT + html.slice(headEndIndex);
  } else {
    html = baseTag + INJECTED_SCRIPT + html;
  }
  return html;
}

// 代理请求
async function proxyRequest(targetPath, res) {
  const options = {
    hostname: TARGET_HOST,
    port: 443,
    protocol: "https:",
    path: targetPath,
    method: "GET",
    headers: {
      "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
      "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
      "Accept-Language": "zh-CN,zh;q=0.9",
      "Accept-Encoding": "gzip, deflate, br",
      "Connection": "keep-alive",
    },
    timeout: 30000,
    rejectUnauthorized: false,
  };

  return new Promise((resolve, reject) => {
    const proxyReq = https.request(options, async (proxyRes) => {
      try {
        const chunks = [];
        proxyRes.on("data", (chunk) => chunks.push(chunk));

        const buffer = await new Promise((resolve, reject) => {
          proxyRes.on("end", () => resolve(Buffer.concat(chunks)));
          proxyRes.on("error", reject);
        });

        const encoding = proxyRes.headers["content-encoding"];
        const decompressed = await decompress(buffer, encoding);

        const contentType = proxyRes.headers["content-type"] || "";
        const statusCode = proxyRes.statusCode;

        // 处理重定向
        if (statusCode >= 300 && statusCode < 400 && proxyRes.headers.location) {
          const location = proxyRes.headers.location;
          const newPath = location.startsWith("http") ? url.parse(location).path : location;
          return proxyRequest(newPath, res).then(resolve).catch(reject);
        }

        if (statusCode !== 200) {
          res.writeHead(statusCode, { "Content-Type": "text/plain" });
          res.end("Error: " + statusCode);
          return resolve();
        }

        const isHtml = contentType.includes("text/html");
        const headers = { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" };

        if (isHtml) {
          let html = decompressed.toString("utf-8");
          html = modifyHtml(html, TARGET_HOST);
          headers["Content-Type"] = "text/html; charset=utf-8";
          headers["Content-Length"] = Buffer.byteLength(html);
          res.writeHead(200, headers);
          res.end(html);
          console.log("[Proxy] HTML 已处理并注入脚本");
        } else {
          headers["Content-Type"] = contentType || "application/octet-stream";
          res.writeHead(200, headers);
          res.end(decompressed);
        }
        resolve();
      } catch (err) {
        reject(err);
      }
    });

    proxyReq.on("error", reject);
    proxyReq.on("timeout", () => {
      proxyReq.destroy();
      reject(new Error("Timeout"));
    });
    proxyReq.end();
  });
}

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  const parsed = url.parse(req.url, true);
  const pathname = parsed.pathname;

  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");

  if (req.method === "OPTIONS") {
    res.writeHead(200);
    return res.end();
  }

  if (pathname === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok", target: TARGET_HOST }));
  } else {
    proxyRequest(pathname + (parsed.search || ""), res).catch((err) => {
      console.error("[Proxy Error]", err);
      res.writeHead(502, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: err.message }));
    });
  }
});

server.listen(PORT, () => {
  console.log(`[教育志 RAGFlow 代理] 运行在 http://localhost:${PORT}`);
  console.log(`目标主机: ${TARGET_HOST}`);
});

总结

通过自建 Node 代理中间层,我们在零后端配合的情况下,完美实现了跨域 iframe 中选中文本的捕获,并将文本实时传递到教育志项目的编辑器中。但更重要的是,这个方案为你打开了在任意第三方网页上执行任意脚本的大门——注入文本选择只是其中一个小小例子。

当你再次面对跨域 iframe DOM 操作难题时,不妨试试这个“中间人”思路。代码在手,跨域我有!


希望这篇文章能帮助到遇到类似问题的同行。有任何疑问或改进建议,欢迎在评论区留言交流。

Vue3 命令式弹窗原理和 provide/inject 隔离机制详解

Vue 3 命令式弹窗组件 这篇文章是 Vue3 命令式弹窗的实现,本文针对实现进行原理讲解。

核心问题

问题:通过命令式方法(如render函数)创建多个弹窗组件实例时,为什么每个实例调用provide()时数据不会互相污染?

关键代码与机制

1. 创建命令式弹窗的核心代码

export const useCommandComponent = (Component) => {
  const parentInstance = getCurrentInstance()
  
  // 创建新的 appContext,继承父级上下文
  const appContext = Object.create(parentInstance?.appContext)
  
  // 关键步骤:设置 appContext.provides 为父级的 provides
  if (appContext) {
    Reflect.set(appContext, 'provides', parentInstance?.provides)
  }
  
  const container = document.createElement('div')
  
  const CommandComponent = (options = {}) => {
    const vNode = createVNode(Component, options)
    vNode.appContext = appContext  // 我们设置的 appContext
    
    // 注意:render 函数没有传入 parentComponent
    // 这意味着命令式组件被当作"根组件"处理
    render(vNode, container)
    
    document.body.appendChild(container)
    return vNode
  }
  
  return CommandComponent
}

2. Vue 内部渲染逻辑

// Vue 内部的 render 函数简化逻辑
const render = (vnode, container) => {
  // 第5个参数是 parentComponent,对于命令式组件,这里传的是 null
  patch(null, vnode, container, null, null, null, namespace)
  //                                   ↑
  // 这个 null 就是 parent,意味着命令式组件没有父组件
}

3. 组件实例创建的关键逻辑

// Vue 源码:createComponentInstance
function createComponentInstance(vnode, parent, suspense) {
  const instance = {
    // 关键:命令式组件的 parent 是 null!
    parent: parent,  // 对于命令式组件,parent = null
    
    appContext: vnode.appContext,  // 我们设置的 appContext
    
    // 最关键的部分:provides 的初始化方式
    provides: parent 
      ? parent.provides  // 有 parent 时,直接继承(标准组件)
      : Object.create(vnode.appContext.provides)  // 无 parent 时,创建新对象
      //   ↑
      // 命令式组件走到这个分支
      // 创建一个以 vnode.appContext.provides 为原型的新空对象
  }
  return instance
}

为什么不会互相污染?

1. 实例创建过程

// 创建弹窗1时
const instance1 = createComponentInstance(vNode1, parent = null, suspense)
// 因为 parent = null,所以:
instance1.provides = Object.create(vnode.appContext.provides)
// 结果:instance1.provides 是一个新的空对象 {}
// 但这个对象的 __proto__ 指向 vnode.appContext.provides(即父组件的 provides)

// 创建弹窗2时
const instance2 = createComponentInstance(vNode2, parent = null, suspense)
// 同样因为 parent = null:
instance2.provides = Object.create(vnode.appContext.provides)
// 结果:instance2.provides 是另一个新的空对象 {}
// 注意:instance1.provides 和 instance2.provides 是两个不同的对象

2. 实例状态对比

// 弹窗1实例的状态
instance1 = {
  parent: null,  // 没有父组件
  provides: {},  // 全新的空对象1
  
  // 关键:这个空对象的原型指向父组件的 provides
  // provides.__proto__ === 父组件的provides
}

// 弹窗2实例的状态
instance2 = {
  parent: null,  // 同样没有父组件
  provides: {},  // 全新的空对象2
  
  // 注意:instance2.provides 和 instance1.provides 是不同的对象!
  // 但它们的原型都指向同一个父组件的 provides
  // provides.__proto__ === 父组件的provides
}

3. 调用 provide 时的行为

// 弹窗1调用 provide
provide('key1', 'value1')
// 实际执行:instance1.provides.key1 = 'value1'
// 结果:instance1.provides = { key1: 'value1' }

// 弹窗2调用 provide
provide('key2', 'value2')
// 实际执行:instance2.provides.key2 = 'value2'
// 结果:instance2.provides = { key2: 'value2' }

// 重要:这两个操作完全独立
// instance1.provides 和 instance2.provides 是两个不同的对象
// 所以不会互相影响

内存结构可视化

父组件
  │
  ├── provides: { parentKey: 'parentValue' }
  │
  ├── 弹窗1实例
  │     ├── parent: null
  │     ├── provides: { key1: 'value1' }  ← 这是自有属性
  │     │
  │     └── provides.__proto__
  │              ↓
  │         { parentKey: 'parentValue' } ← 父组件的 provides
  │
  └── 弹窗2实例
        ├── parent: null
        ├── provides: { key2: 'value2' }  ← 这是自有属性
        │
        └── provides.__proto__
                 ↓
            { parentKey: 'parentValue' } ← 父组件的 provides

关键点总结

1. 为什么 parent 是 null?

  • 命令式组件不是通过父组件模板渲染的
  • 而是通过 render()函数直接挂载到 DOM
  • Vue 内部将其视为独立的"根组件"
  • 所以 parent 参数为 null

2. 为什么 provides 是独立的对象?

  • parentnull时,Vue 会执行:

    provides: Object.create(vnode.appContext.provides)
    
  • 这创建了一个新的空对象,其原型指向父组件的 provides

  • 每个命令式组件实例都会执行这个操作

  • 所以每个实例都有自己独立的 provides对象

3. 如何实现数据共享?

  • 虽然每个实例的 provides 是独立的对象

  • 但这些对象的原型都指向同一个父组件的 provides

  • 当调用 inject()查找数据时:

    // 简化版的 inject 逻辑
    function inject(key) {
      // 对于命令式组件,instance.parent 为 null
      const provides = instance.parent == null
        // 走这个分支,刚好 appContext.provides 就是父组件的 provides
        ? instance.vnode.appContext.provides  
        : instance.parent.provides
    
      if (provides && key in provides) {
        return provides[key]
      }
    }
    
  • 所以所有命令式组件都能访问父组件提供的数据

4. 为什么不会互相污染?

  • 每个实例的 provides 是不同的对象
  • 调用 provide() 时,数据写入各自实例的 provides 对象
  • 实例A写入的数据在实例A的 provides 对象上
  • 实例B写入的数据在实例B的 provides 对象上
  • 它们之间没有直接联系,所以不会互相影响

实际示例

// 父组件提供配置
provide('appConfig', { theme: 'dark', version: '1.0' })

// 创建命令式弹窗
const showModal = useCommandComponent(Modal)

// 打开两个弹窗
const modal1 = showModal({ title: '弹窗1' })
const modal2 = showModal({ title: '弹窗2' })

// 在 Modal 组件内部:
setup() {
  // 两个弹窗都能获取到父组件的 appConfig
  const config = inject('appConfig')
  // config = { theme: 'dark', version: '1.0' }
  
  // 弹窗1 provide 数据
  provide('modalData', 'data from modal1')
  // 这个数据只在 modal1 内部有效
  // modal2 无法访问到
}

如何优雅地处理 iframe 跨域通信?这是我的开源方案

一、开篇破局:被误解的iframe,从未真正退场

在微前端大行其道的今天,很多人觉得 iframe 已经过时了。但每当业务遇到绝对的安全沙箱隔离、第三方老旧系统接入、跨域广告/挂件嵌入时,大家转了一圈还是会乖乖回到 iframe 的怀抱——毕竟它是浏览器原生的、最彻底的隔离方案。 究其原因,无外乎它是浏览器原生支持、隔离性最彻底的方案,没有之一。但凡事皆有两面性,iframe的隔离有多极致,跨域通信就有多棘手,这也是无数开发者对它又爱又恨的核心原因。

但是,iframe 的隔离有多完美,它的跨域通信就有多让人头疼! 但凡用原生window.postMessage开发过稍复杂的跨域业务,大概率都踩过这些让人崩溃的坑,堪称前端开发的“隐形绊脚石”:

  • 回调地狱:发出去了消息,不知道对方收没收到,只能满屏幕写 addEventListener 去匹配消息 ID。

  • 时序问题:父页面急着发数据,子页面还没 onload,消息直接石沉大海。

  • 恶心的双滚动条:子页面内容变多被撑开,父页面无法感知,高度死活对不上。

  • 状态同步灾难:父页面切了深色模式,子页面还是亮瞎眼的白色,状态完全割裂。

“原生长篇大论的事件监听代码” vs “iframe-js 一行 await 代码” 的对比截图对比:

// 原生 postMessage 跨域获取数据
function fetchRemoteData(userId) {
    return new Promise((resolve, reject) => {
        const messageId = 'req_' + Date.now();

        // 1. 必须注册全局监听器
        const handler = (event) => {
            // 安全第一:手动死磕 origin 校验
            if (event.origin !== 'https://target-domain.com') return;

            // 必须通过唯一 ID 匹配,不然会串线
            if (event.data?.id === messageId && event.data?.action === 'USER_INFO_RES') {
                clearTimeout(timer);
                window.removeEventListener('message', handler); // 极易忘写导致内存泄漏
                resolve(event.data.result);
            }
        };
        window.addEventListener('message', handler);

        // 2. 发送请求
        const targetIframe = document.getElementById('my-iframe').contentWindow;
        targetIframe.postMessage({
            action: 'USER_INFO_REQ',
            id: messageId,
            payload: { userId }
        }, 'https://target-domain.com');

        // 3. 手动处理超时逻辑
        const timer = setTimeout(() => {
            window.removeEventListener('message', handler);
            reject(new Error('跨域请求超时'));
        }, 5000);
    });
}
// 使用 iframe-js 的 RPC 远程调用
async function fetchRemoteData(userId) {
    try {
        // 就像调用本地异步函数一样丝滑!
        const userInfo = await iframeApp.callRemote('getUserInfo', { userId }, 5000);
        return userInfo;
    } catch (error) {
        // 完美捕获超时或对方抛出的异常
        console.error('调用失败:', error.message);
    }
}

二、破局方案:iframe-js 2.2.1开源,降维打击通信痛点

为了彻底消灭这些恶心人的痛点,我重构并开源了 iframe-js(目前最新版本 2.2.1)。它不是对 postMessage 的简单封装,而是将 iframe 通信直接拉升到了现代前端工程化的标准。iframe-js 的四大杀手锏功能

他的核心思路就是抛弃传统的发布订阅,直接用现代前端的思维(RPC、状态机、Promise 回执)去降维打击这些痛点。今天开源出来,给大家分享一下。

三、四大核心功能:彻底解决iframe通信难题

1. 像调用本地函数一样跨域:RPC 远程调用

这是我个人最喜欢的功能。以前你想让子页面去查个数据,得先 postMessage 过去,子页面查完再 postMessage 回来,逻辑被严重撕裂。 现在,你可以用 RPC (Remote Procedure Call) 模式,直接用 async/await 拿到跨域函数的返回值!

提供方(如父页面):

// 暴露一个名为 'getUserInfo' 的异步服务
iframeApp.expose('getUserInfo', async (params) => {
  const res = await fetch(`/api/user/${params.id}`);
  return await res.json(); // 直接 return 即可!
});

调用方(如子页面):

// 像调用本地函数一样丝滑,天然支持超时控制和 try/catch 错误穿透!
try {
  const userInfo = await childApp.callRemote('getUserInfo', { id: 1001 }, 5000);
  console.log('跨域拿到数据啦:', userInfo);
} catch(err) {
  console.error('调用超时或报错:', err);
}

2. 彻底告别双滚动条:自动高度适应 (Auto Resize)

同域下我们可以直接读 DOM 高度,跨域下怎么办?iframe-js 内置了基于现代浏览器 ResizeObserver 的高度同步机制。性能极致,零 CPU 轮询消耗,甚至连 display: none 导致的 0px 高度塌陷陷阱都在底层帮你规避了。

父页面一行代码授权:

iframeApp.enableAutoResize();

子页面一行代码开启探测:

// 当内部存在图片懒加载、列表下拉导致 DOM 撑开时,父页面的 iframe 标签会自动随之伸缩!
childApp.startAutoResizer({ offset: 20 }); // 还能额外补偿 20px 底部间距

3. 跨越 Iframe 的状态机:全局状态共享 (State Sync)

业务里经常遇到父子页面需要共享上下文的情况(主题色、语言包、当前登录用户信息)。与其用事件发来发去,不如直接用微缩版“Pinia/Vuex”。 不管子页面加载有多慢,只要它一 onload,父页面的最新状态就会自动全量同步过去。

// 父页面随时更新状态
iframeApp.setState({ theme: 'dark', lang: 'zh-CN' });

// 子页面响应式监听
childApp.onStateChange((newState) => {
  if (newState.theme === 'dark') {
    document.body.classList.add('dark-mode');
  }
});

4. 绝对可靠的送达:Promise ACK 与内置队列

原生的 postMessage 是典型的“Fire-and-Forget(发后不理)”。 而在 iframe-js 中,你可以使用 emitWithAck。底层会自动为你分配唯一 ID 并追踪回执。

// 如果返回 true,说明不仅发过去了,而且对方的代码已经成功执行了业务逻辑!
const isSuccess = await parentApp.emitToChildWithAck('updateData', { a: 1 });

更绝的是内置队列机制:如果父页面初始化后立刻发消息,而子页面还没准备好,消息绝不会丢!

iframe-js 会自动将消息存入内存队列,等子页面打通连接的瞬间,依次重发。 怎么用?

四、极简上手:开箱即用,全链路TS支持

iframe-js无需复杂配置,开箱即用,全面支持TypeScript类型推导,兼顾开发效率与类型安全,一行命令即可安装:

npm install iframe-js

五、Live Demo实测:眼见为实,上手即体验

文字描述再详尽,不如直接上手实操。我针对核心功能打造了3大极限测试场景Demo,打开F12控制台查看底层日志,更能直观感受通信流程的丝滑:

六、写在最后

开发iframe-js的初衷,就是想让开发者在处理微前端嵌套、低代码平台渲染区、第三方系统接入等场景时,摆脱iframe跨域通信的繁琐痛点,少踩坑、少加班,专注核心业务开发。

跨域场景复杂多变,如果你在使用过程中遇到奇葩报错,或是有点击穿透拦截、快捷键透传等个性化需求,欢迎前往GitHub仓库提Issue交流,一起完善工具生态。

开源地址: github.com/1503963513/…,如果这款工具帮你解决了实际问题,欢迎点亮Star支持!

腾讯域名拦截查询 在线工具核心JS实现

这篇只讲功能层 JavaScript/TypeScript 实现,围绕“输入一个域名,得到可读的拦截状态”这一条主链路展开。

工具有两条查询通道(第三方接口):

  • QQ通道:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}
  • 微信通道:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

在线工具网址:see-tool.com/tencent-dom…
工具截图:
工具截图.png

1. 输入规范化是第一道关口

这个工具不直接信任用户输入,而是统一走 normalizeInput

const normalizeInput = (value) => {
  const rawValue = String(value || '').trim()
  if (!rawValue) return ''

  const cleaned = rawValue.replace(/\s+/g, '')
  const withProtocol = /^https?:\/\//i.test(cleaned) ? cleaned : `http://${cleaned}`

  try {
    const url = new URL(withProtocol)
    if (!['http:', 'https:'].includes(url.protocol)) return ''
    return url.toString()
  } catch {
    return ''
  }
}

这里做了三件关键事:去空白、补协议、用 URL 做结构化校验。后续所有请求都只使用规范化后的值。

2. 查询动作编排

点击查询时,动作顺序是固定的:

  1. 判空
  2. 规范化
  3. 清理旧结果
  4. 标记查询通道
  5. 进入对应通道请求
const startQqQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'qq'
  submitQqQuery(normalized, ticket, randstr)
}

const startWeChatQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'wx'
  submitWeChatQuery(normalized, captchaPayload)
}

这一层不做网络请求细节,只负责把交互状态整理干净。

3. 请求提交与异常回传

真正请求在 submit 函数里,统一处理 loading、异常捕获和结果写入。两条通道分别请求不同第三方 API。

const submitQqQuery = async (url, ticket, randstr) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url=${encodeURIComponent(
      url
    )}&ticket=${encodeURIComponent(ticket)}&randstr=${encodeURIComponent(randstr)}&_=${Date.now()}123`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = parseJsonp(text)
    if (!data || data.reCode !== 0 || !data.data?.results) {
      throw new Error(data?.data || '查询失败')
    }

    resultData.value = buildQqResult(url, data.data.results)
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

const submitWeChatQuery = async (url, captchaPayload) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url=${encodeURIComponent(url)}`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = JSON.parse(text)
    const isBlocked = data.data === 'ok'

    resultData.value = {
      url,
      status: {
        type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
      }
    }
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

前端只认统一的返回结构:{ status: 'ok', data: ... }

4. 服务端 URL 清洗与请求拦截

服务端入口先拦截无效请求,再代理到上游接口:

const normalizeUrl = (input: string) => {
  const rawValue = String(input || '').trim()
  if (!rawValue) return ''

  const cleaned = rawValue.replace(/\s+/g, '')
  const withProtocol = /^https?:\/\//i.test(cleaned) ? cleaned : `http://${cleaned}`

  try {
    const target = new URL(withProtocol)
    if (!['http:', 'https:'].includes(target.protocol)) return ''
    return target.toString()
  } catch {
    return ''
  }
}

if (!url) {
  setResponseStatus(event, 400)
  return { status: 'error', message: 'Invalid url' }
}

这样可以保证前后端都执行相同的输入约束,避免脏数据直接进入上游请求。

5. QQ通道:JSONP 解析与状态映射

QQ通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}

QQ通道返回的是 JSONP,不是纯 JSON,所以先解包:

const parseJsonp = (jsonpStr: string) => {
  const match = jsonpStr.match(/url_query\((.+)\)/)
  if (match && match[1]) {
    try {
      return JSON.parse(match[1])
    } catch {
      return null
    }
  }
  return null
}

拿到对象后,再把复杂字段折叠成前端可消费的数据模型:

if (result.whitetype === 3 || result.whitetype === 4) {
  data.status.type = 'whitelist'
} else if (result.whitetype === 2) {
  data.status.type = 'blocked'
  data.status.wordingTitle = result.WordingTitle || ''
  data.status.wording = result.Wording || ''
} else if (result.whitetype === 1) {
  if (result.eviltype === 2800 || result.eviltype === 2804) {
    data.status.type = 'qq_blocked'
  } else if (result.eviltype && result.eviltype !== 0) {
    data.status.type = 'other_blocked'
    data.status.evilType = result.eviltype
  } else {
    data.status.type = 'safe'
  }
}

这一步的重点不是“原样透传”,而是“转成稳定业务语义”。

6. 微信通道:返回值压平

微信通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

微信查询通道返回结构更简单,核心逻辑就是把上游标记转成统一状态:

const isBlocked = data.data === 'ok'

return {
  status: 'ok',
  data: {
    url,
    status: {
      type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
    }
  }
}

两条通道虽然来源不同,但最终都对齐到 data.status.type,前端渲染就能复用同一套逻辑。

7. 结果渲染:从对象到行数据

页面不直接硬编码每一行,而是先把结果对象转换成 resultRows

const resultRows = computed(() => {
  const data = resultData.value
  if (!data) return []

  const rows = []
  const addRow = (key, label, value, extra = {}) => {
    if (value === undefined || value === null || value === '') return
    rows.push({ key, label, value, ...extra })
  }

  addRow('url', '检测地址', data.url)
  addRow('status', '检测结果', statusText.value, { isStatus: true, toneClass: statusTone.value })

  if (data.status?.wordingTitle) addRow('reasonTitle', '原因标题', data.status.wordingTitle)
  if (data.status?.wording) addRow('reasonDetail', '原因详情', data.status.wording)

  return rows
})

这种“先标准化、再渲染”的模式,能让字段增减时只改一处映射逻辑。

到这里,核心链路就是:输入标准化 → 查询编排 → 服务端映射 → 统一结果模型 → 页面渲染。

从 URL 输入到页面展示:一场跨越进程与协议的“装修”大戏

摘要:春招季将至,“从 URL 输入到页面展示”是前端与后端面试中出场率高达 80% 的“八股文”之王。很多候选人习惯堆砌知识点,却难以串联成线。本文将摒弃枯燥的列表式回答,以“装修房子”为喻,结合浏览器多进程架构、操作系统原理、网络协议栈及 DNS 解析机制,为你构建一套清晰、深刻且通俗易懂的知识体系。这不仅是一次面试通关指南,更是一次对计算机底层逻辑的深度巡礼。


引言:不仅仅是“回车”那么简单

当你在浏览器地址栏输入 www.geekbang.org 并按下回车键的那一刻,看似平静的操作背后,实则上演着一场横跨应用层、网络层、传输层乃至操作系统内核的宏大交响乐。

在面试中,如果你只回答“DNS 解析 -> TCP 握手 -> 发送请求 -> 渲染”,考官可能会觉得你只是背了书。真正的高手,能够像项目经理一样,清晰地描述出浏览器主进程如何调度、网络进程如何采购、渲染进程如何在沙箱中施工,以及底层操作系统如何分配资源。

今天,我们就把整个页面加载过程比作一次**“装修房子”**,带你深入这场技术大戏的幕后。


第一幕:项目经理接单(浏览器主进程)

在现代浏览器(如 Chrome)的多进程架构中,浏览器主进程(Browser Process) 扮演着“项目经理”的角色。它不直接干活(不渲染页面,不下载数据),但它负责指挥、调度、验收以及处理用户交互。

1.1 接收指令与导航启动

当你输入 URL 时,主进程首先介入:

  • URL 补全与预处理:如果你只输入了关键词,主进程会将其交给默认搜索引擎;如果输入的是域名,它会尝试补全 http://https://
  • 历史管理:主进程会将此次导航记录压入“后退栈”(Backward Stack),并清空“前进栈”(Forward Stack)。这就是为什么刷新后无法“前进”的原因。
  • 状态反馈:界面立刻显示 Loading 图标,告知用户“工程已启动”。

1.2 安全拦截:beforeunload

在正式动工前,主进程会检查当前页面是否有未保存的数据。它会通知旧的渲染进程触发 beforeunload 事件。如果页面返回了拦截信号,浏览器会弹出原生确认框:“您确定要离开吗?未保存的修改可能会丢失。”这是防止用户误操作导致数据丢失的最后一道防线。

一旦确认无误,主进程正式将 URL 转发给网络进程,准备开始“采购材料”。


第二幕:采购员出动与地址查询(网络进程 & DNS)

网络进程(Network Process) 是浏览器的“采购员 + 物流司机”。它的核心任务是搞定网络连接,把服务器上的资源(HTML、CSS、图片等)拉取回来。但在此之前,它必须知道“仓库”在哪里。

2.1 DNS 解析:分布式的全球电话簿

计算机之间通信靠的是 IP 地址,而不是人类可读的域名。因此,第一步是将域名转换为 IP。DNS(Domain Name System)是一个巨大的分布式数据库。

解析过程遵循“就近原则”,层层递进:

  1. 浏览器缓存:Chrome 内部有独立的 DNS 缓存(可通过 chrome://net-internals/#dns 查看)。这是最快的路径。

  2. 操作系统缓存:如果浏览器没找到,会查询操作系统的 DNS 缓存。这里涉及一个特殊的文件——Hosts 文件(Windows 位于 C:\Windows\System32\drivers\etc\hosts)。开发者常在此配置本地域名映射(如 127.0.0.1 www.douyin.com)进行本地测试。

    • 面试题深挖:为什么修改 Hosts 文件后有时不生效?因为浏览器有自己的缓存机制,甚至可能复用了之前的 TCP 长连接(Keep-Alive)。此时需清除浏览器 DNS 缓存或重启浏览器。
  3. 本地 DNS 服务器(LDNS) :通常由 ISP(如抚州电信)提供。

  4. 根域名服务器与顶级域名服务器:如果 LDNS 也没有,请求会逐级向上,经过根服务器(.)、顶级域服务器(.org),最终找到权威域名服务器,拿到目标 IP。

负载均衡的奥秘
DNS 返回的往往不是一个 IP,而是一组 IP 数组。这背后是负载均衡技术在起作用。就像“媒婆”介绍对象,DNS 会根据你的地域(地域特性机房)、服务器负载情况(轮询算法 Round Robin),将你引导至离你最近、压力最小的服务器集群(Nginx 反向代理)。

2.2 建立连接:三次握手

拿到 IP 后,网络进程需要与服务器建立可靠的传输通道。这就用到了 TCP 协议

  • 为什么是 TCP? 网页内容要求完整无误,不能像视频流(UDP)那样允许丢包。TCP 提供了可靠性保证。

  • 三次握手

    1. 客户端发送 SYN:我想和你聊天。
    2. 服务器回复 SYN + ACK:好的,我也想和你聊,我准备好了。
    3. 客户端回复 ACK:收到,那我们开始吧。

    这三次握手确保了双方都具备发送和接收能力,并同步了初始序列号,为后续数据传输打下基础。

2.3 发送请求与接收响应

连接建立后,网络进程发送 HTTP 请求:

  • 请求行GET /index.html HTTP/1.1
  • 请求头:携带 Cookie(会话信息)、Authorization(JWT 令牌)、User-Agent 等关键信息。

服务器处理后返回响应:

  • 状态码

    • 200 OK:成功。
    • 301/302:重定向。例如访问 http://time.geekbang.org 会被强制跳转到 https:// 版本。
    • 404:资源未找到。
    • 500:服务器内部错误。
  • Content-Type:告诉浏览器接下来收到的数据是什么。如果是 text/html,浏览器就知道要准备渲染了;如果是 image/jpeg,则直接下载展示。


第三幕:沙箱中的施工队(渲染进程)

当网络进程拿到 HTML 数据流后,它不能直接渲染,而是通过 IPC(进程间通信) 将数据交给渲染进程(Renderer Process)

3.1 为什么要用沙箱?

渲染进程是浏览器的“施工队”,负责画图、砌墙(解析 DOM/CSS)、刷漆(合成图层)。但它运行在**安全沙箱(Sandbox)**中。

  • 最小权限原则:沙箱不是操作系统送的,而是浏览器利用 OS 底层机制(Windows Token、Linux Seccomp-BPF、macOS Seatbelt)主动构建的“牢房”。
  • 限制:渲染进程不能直接读写磁盘、不能直接访问网络、不能调用敏感系统 API。
  • 意义:即使渲染进程加载了恶意代码被黑客攻破,黑客也仅仅控制了“牢房”里的内容,无法窃取用户硬盘数据或控制系统。所有的网络请求和文件读写,都必须通过 IPC 请求主进程或网络进程代劳。

3.2 提交文档与解析

  1. 提交文档:渲染进程向主进程发送“确认提交”消息。主进程收到后,移除旧文档,更新 UI 状态。
  2. 构建 DOM 树:渲染进程接收 HTML 字节流,将其解析为 DOM 树(Document Object Model)。这是页面的骨架。
  3. 构建 CSSOM 树:同时,解析 CSS 文件,生成 CSSOM 树(CSS Object Model)。这是页面的样式规则。
  4. 生成渲染树(Render Tree) :将 DOM 和 CSSOM 合并,剔除不可见节点(如 display: none),形成渲染树。
  5. 布局(Layout) :计算每个节点在屏幕上的确切位置和大小。
  6. 绘制(Paint) :将渲染树转换为像素,生成位图。
  7. 合成(Composite) :如果有多个图层(如视频、固定定位元素),GPU 会将它们合成为最终的图像展示给用户。

在这个过程中,如果遇到 <script> 标签,解析可能会暂停(除非标记为 asyncdefer),去加载并执行 JavaScript。JS 可以修改 DOM 和 CSSOM,导致重新布局(Reflow)和重绘(Repaint)。


第四幕:底层基石与协议深析

在上述流程中,有几个核心的计算机基础概念支撑着整个大厦。

4.1 操作系统:进程与线程

  • 进程(Process) :资源分配的最小单元。浏览器的每个标签页通常对应一个独立的渲染进程,互不干扰。一个标签页崩溃不会影响其他标签页。
  • 线程(Thread) :CPU 调度的最小单元。一个进程内包含多个线程,如主线程(负责 JS 执行、DOM 操作)、合成线程(负责图层合成)、网络线程等。
  • 进程间通信(IPC) :由于进程隔离,主进程、网络进程、渲染进程之间必须通过 IPC 传递消息。这是多进程架构的开销所在,也是安全性的保障。

4.2 OSI 七层模型与 TCP/IP

虽然实际应用中常用 TCP/IP 四层模型,但理解 OSI 七层有助于厘清职责:

  1. 物理层:比特流传输(光纤、网线)。

  2. 数据链路层:MAC 地址寻址,帧传输。

  3. 网络层:IP 地址寻址,路由选择(路由器工作在此层)。

  4. 传输层:TCP/UDP 协议,端到端连接,流量控制,差错重传。

    • 丢包重传:TCP 通过序号和确认应答机制,确保数据包丢失后能重发,保证文件不损坏。
  5. 会话层:管理会话(如保持登录状态)。

  6. 表示层:数据格式转换(加密、压缩)。

  7. 应用层:HTTP、DNS 等协议,直接面向用户。

4.3 正向代理 vs 反向代理

  • 正向代理(代购) :客户端主动配置代理,代表客户端去访问服务器。服务器不知道真实客户端是谁,只知道代理。场景:翻墙、突破内网限制。
  • 反向代理(前台) :服务端部署代理,代表服务器接收请求。客户端不知道真实服务器是谁,只知道代理。场景:负载均衡、隐藏后端架构、SSL 卸载。Nginx 是最典型的反向代理服务器。

结语:从知识点到知识体系

回顾整个过程,从用户在地址栏敲下第一个字符,到页面绚丽地展现在眼前:

  1. 浏览器主进程像项目经理一样统筹全局,管理历史、处理交互、调度子进程。
  2. 网络进程像精明的采购员,通过复杂的 DNS 层级找到目标,利用 TCP 三次握手建立可靠通道,并通过负载均衡策略获取最优资源。
  3. 渲染进程像被关在沙箱中的专业施工队,在严格的安全限制下,将 HTML/CSS 代码一步步转化为像素图像。
  4. 底层的操作系统提供了进程隔离、线程调度和 IPC 机制,保障了系统的稳定与安全。
  5. 网络协议栈则像精密的交通规则,确保数据包在全球网络中准确、有序地抵达。

在春招面试中,当你能够用这样一条清晰的逻辑线,配合生动的比喻,将操作系统、计算机网络、浏览器原理串联起来时,你就不再是一个只会背诵“八股文”的考生,而是一个具备系统观的工程师。

记住,技术不仅仅是知识点的堆砌,更是万物互联的逻辑之美。 祝各位在春招中旗开得胜,Offer 多多!


作者注:本文基于 Chromium 架构及通用网络原理编写。实际浏览器实现可能因版本不同略有差异,但核心思想一致。希望这篇文章能成为你面试路上的坚实护城河。

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

引言:为什么选择 TypeScript + React?

React 作为当下最流行的前端库,以其组件化和声明式开发著称。而 TypeScript 作为 JavaScript 的超集,带来了静态类型检查强大的 IDE 支持。两者结合,堪称黄金搭档。

在纯 JavaScript 的 React 项目中,我们常常遇到:

  • 组件 props 类型不确定,传错属性难以及时发现;
  • 状态更新时,不小心修改了不该改的数据;
  • 调用自定义 Hook 返回的方法时,参数类型模糊不清。

TypeScript 可以完美解决这些问题。它让你在编写代码时就能发现错误,并且提供精准的代码补全和文档提示。今天我们就通过一个经典的 Todo 应用,来体验 TypeScript 在 React 项目中的魅力。


一、项目初始化

首先创建一个 React + TypeScript 项目(使用 Create React App):

npx create-react-app todo-ts --template typescript
cd todo-ts

项目结构我们会按照功能模块组织:

src/
├── components/
│   ├── TodoInput.tsx
│   ├── TodoItem.tsx
│   └── TodoList.tsx
├── hooks/
│   └── useTodos.ts
├── types/
│   └── todo.ts
├── utils/
│   └── storages.ts
└── App.tsx

二、定义核心类型:Todo 接口

数据是整个应用的核心,TypeScript 通过接口(interface) 来约束数据的形状。

src/types/todo.ts

export interface Todo {
    id: number;          // 唯一标识
    title: string;       // 标题
    completed: boolean;  // 是否完成
}

这个接口将被多个组件和 Hook 使用,确保整个应用中 Todo 的数据结构始终一致。


三、封装 localStorage 工具函数(泛型实战)

为了方便存取数据,我们封装两个工具函数,并利用 TypeScript 的泛型让它们支持任意类型。

src/utils/storages.ts

export function getStorage<T>(key: string, defaultValue: T): T {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
}

export function setStorage<T>(key: string, value: T): void {
    localStorage.setItem(key, JSON.stringify(value));
}

泛型 <T> 的作用:

  • getStorage 的返回值类型与 defaultValue 类型一致,调用时可以明确知道返回的是什么类型。
  • setStoragevalue 参数类型为 T,确保存入的数据类型与取出时的预期相符。

例如,当我们存储 Todo 数组时,可以这样调用:

const todos = getStorage<Todo[]>('todos', []);
setStorage<Todo[]>('todos', todos);

如果传入了错误类型,TypeScript 会立即报错。


四、自定义 Hook:useTodos(核心业务逻辑)

useTodos 负责管理待办事项的状态,并与 localStorage 同步。我们看看 TypeScript 如何让这个 Hook 变得健壮。

src/hooks/useTodos.ts

import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';

const STORAGE_KEY = 'todos';

export default function useTodos() {
    // 1. 初始化状态,使用泛型指定状态类型,并懒加载从 localStorage 读取数据
    const [todos, setTodos] = useState<Todo[]>(() => 
        getStorage<Todo[]>(STORAGE_KEY, [])
    );

    // 2. 自动同步到 localStorage
    useEffect(() => {
        setStorage<Todo[]>(STORAGE_KEY, todos);
    }, [todos]);

    // 3. 添加待办
    const addTodo = (title: string) => {
        const newTodo: Todo = {
            id: +new Date(),      // 使用时间戳作为简单 ID
            title,
            completed: false,
        };
        setTodos([...todos, newTodo]);
    };

    // 4. 切换完成状态
    const toggleTodo = (id: number) => {
        const newTodos = todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        );
        setTodos(newTodos);
    };

    // 5. 删除待办
    const removeTodo = (id: number) => {
        const newTodos = todos.filter(todo => todo.id !== id);
        setTodos(newTodos);
    };

    return {
        todos,
        addTodo,
        toggleTodo,
        removeTodo,
    };
}

关键点解析

  • useState<Todo[]>:明确状态类型,后续 setTodos 只能传入 Todo[] 类型数据。
  • 懒初始化函数() => getStorage<Todo[]>(...) 确保 localStorage 读取只在首次渲染时执行。
  • useEffect 依赖 [todos]:每当 todos 变化,自动调用 setStorage 同步到本地。
  • 操作方法参数类型addTodo 接收 stringtoggleTodoremoveTodo 接收 number,杜绝传错参数的可能。
  • 返回对象:TypeScript 会自动推断返回值的类型,在组件中使用时能得到完整的类型提示。

五、编写 React 组件

1. TodoInput:受控输入框

src/components/TodoInput.tsx

import * as React from 'react';

interface Props {
    onAdd: (title: string) => void;   // 回调函数类型
}

const TodoInput: React.FC<Props> = ({ onAdd }) => {
    const [value, setValue] = React.useState<string>('');

    const handleAdd = () => {
        if (!value.trim()) return;      // 忽略空输入
        onAdd(value.trim());
        setValue('');                    // 清空输入框
    };

    return (
        <div>
            <input
                value={value}
                onChange={e => setValue(e.target.value)}
            />
            <button onClick={handleAdd}>添加</button>
        </div>
    );
};

export default TodoInput;

TypeScript 亮点

  • React.FC<Props> 定义了函数组件的 props 类型。
  • onAdd 的类型是 (title: string) => void,确保调用时传入正确的参数。
  • useState<string> 显式声明状态类型(虽然可以推导,但写上更清晰)。

2. TodoItem:单个待办项

src/components/TodoItem.tsx

import type { Todo } from '../types/todo';
import * as React from 'react';

interface Props {
    todo: Todo;
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, onToggle, onRemove }) => {
    return (
        <li>
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                {todo.title}
            </span>
            <button onClick={() => onRemove(todo.id)}>删除</button>
        </li>
    );
};

export default TodoItem;

TypeScript 亮点

  • todo: Todo 明确传入的对象符合 Todo 接口。
  • onToggleonRemove 的参数类型明确为 number,使用 todo.id 时类型匹配。

3. TodoList:渲染列表

src/components/TodoList.tsx

import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';
import * as React from 'react';

interface Props {
    todos: Todo[];
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
    return (
        <ul>
            {todos.map(todo => (
                <TodoItem
                    key={todo.id}
                    todo={todo}
                    onToggle={onToggle}
                    onRemove={onRemove}
                />
            ))}
        </ul>
    );
};

export default TodoList;

TypeScript 亮点

  • todos: Todo[] 明确数组元素类型。
  • map 循环中,todo 自动推导为 Todo 类型。

六、组合应用:App 组件

src/App.tsx

import useTodos from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';

export default function App() {
    const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

    return (
        <div>
            <h1>TodoList</h1>
            <TodoInput onAdd={addTodo} />
            <TodoList
                todos={todos}
                onToggle={toggleTodo}
                onRemove={removeTodo}
            />
        </div>
    );
}

在 App 中,我们从 useTodos 获取状态和方法,然后直接传递给子组件。由于所有类型都已定义,这里传参时完全类型安全,如果 addTodo 需要传入 number 类型,TypeScript 会立刻报错。


七、TypeScript 带来的好处总结

通过这个简单的 Todo 应用,我们可以看到 TypeScript 在 React 项目中的实际价值:

  1. 接口即文档TodoProps 等接口清晰地描述了数据结构,新成员加入项目能快速理解。
  2. 类型安全的状态管理useState<Todo[]> 确保状态始终符合预期,不会意外混入错误数据。
  3. 精确的事件处理onToggle={(id: number) => ...} 让调用方明确知道需要传递什么参数。
  4. 自动补全与重构:在 VS Code 中,输入 todo. 会立刻弹出 idtitlecompleted 提示;修改接口字段后,所有用到的地方都会报错,重构零风险。
  5. 减少运行时错误:很多低级错误(如传错参数类型、访问不存在的属性)在编译阶段就被捕获。

八、扩展思考

这个 Todo 应用虽然简单,但已经涵盖了 TypeScript + React 的核心实践。在此基础上,你可以继续探索:

  • 更高级的泛型:比如封装通用的请求函数,使用泛型约束返回数据类型。
  • 类型工具PartialPickOmit 等工具类型,用于灵活地处理类型变换。
  • Redux Toolkit + TypeScript:大型状态管理中的类型安全。
  • 类型定义文件(.d.ts):为第三方无类型库编写声明。

结语

TypeScript 并不是一个陌生的新语言,它只是为 JavaScript 添加了一层“安全网”。在 React 项目中使用 TypeScript,初期可能会觉得有些繁琐,但一旦你习惯了类型带来的自信和效率,就很难再回到纯 JavaScript 的开发方式。

希望这篇文章能帮助你迈出 TypeScript + React 的第一步。如果你有任何问题或想法,欢迎在评论区留言交流!


效果图

屏幕录制 2026-03-06 193319.gif参考资料TypeScript 官方文档 | React 官方类型定义

跨域方案汇总

一、先理解核心概念:什么是跨域?

跨域是浏览器的同源策略导致的安全限制:当请求的协议(http/https)、域名、端口三者任意一个与当前页面不一致时,就会触发跨域拦截。比如:

  • http://localhost:3000 访问 http://localhost:8080(端口不同)
  • http://www.a.com 访问 http://api.a.com(子域名不同)
  • http://a.com 访问 https://a.com(协议不同)

二、跨域请求的主流解决方案

1. CORS(跨域资源共享)- 最推荐的方案

核心原理:后端在响应头中添加允许跨域的配置,明确告知浏览器哪些域名、请求方法、头信息可以访问资源,是 W3C 标准,也是现代浏览器最支持的方案。

适用场景:前后端分离项目(Vue/React+Node/Java/PHP 等),后端可修改的场景。

实现示例

  • 后端配置(以 Node.js/Express 为例)

    const express = require('express');
    const app = express();
    
    // 全局跨域中间件
    app.use((req, res, next) => {
      // 允许指定域名跨域(* 表示允许所有,生产环境不推荐)
      res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
      // 允许的请求方法
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
      // 允许的自定义请求头
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
      // 允许携带cookie(需前后端同时配置)
      res.setHeader('Access-Control-Allow-Credentials', 'true');
      // 预检请求(OPTIONS)的缓存时间,避免重复发送
      res.setHeader('Access-Control-Max-Age', '86400');
    
      // 处理预检请求(OPTIONS)
      if (req.method === 'OPTIONS') {
        return res.sendStatus(200);
      }
      next();
    });
    
    // 接口示例
    app.get('/api/data', (req, res) => {
      res.json({ code: 200, data: '跨域成功' });
    });
    
    app.listen(8080, () => console.log('后端服务运行在8080端口'));
    
  • 前端请求(以 Axios 为例)

    import axios from 'axios';
    
    axios({
      url: 'http://localhost:8080/api/data',
      method: 'GET',
      withCredentials: true, // 如需携带cookie,必须开启
    }).then(res => console.log(res.data));
    

2. 代理服务器 - 开发环境首选

核心原理:浏览器有跨域限制,但服务器之间没有。通过本地开发服务器(如 Webpack Dev Server、Vite)做代理,将前端请求转发到目标后端,规避跨域问题。

适用场景:本地开发阶段(生产环境需配置 Nginx 代理)。

实现示例

  • Vite 配置(vite.config.js)

    export default {
      server: {
        proxy: {
          // 匹配以/api开头的请求
          '/api': {
            target: 'http://localhost:8080', // 目标后端地址
            changeOrigin: true, // 开启跨域代理
            // rewrite: (path) => path.replace(/^/api/, ''), // 可选:去掉/api前缀
          },
        },
      },
    };
    
  • 前端请求(无需写完整域名)

    axios.get('/api/data').then(res => console.log(res.data));
    
  • 生产环境 Nginx 配置

    server {
      listen 80;
      server_name www.frontend.com;
    
      # 代理后端接口
      location /api/ {
        proxy_pass http://localhost:8080/api/; # 转发到后端地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
      }
    
      # 前端静态资源
      location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html; # 适配SPA路由
      }
    }
    

3. JSONP - 兼容老旧浏览器

核心原理:利用<script>标签不受同源策略限制的特性,通过动态创建 script 标签请求后端接口,后端返回一段带回调函数的 JS 代码,前端执行回调获取数据。

局限:仅支持 GET 请求,安全性较低(可能存在 XSS 风险),仅推荐兼容老旧浏览器时使用。

实现示例

  • 后端(Node.js/Express)

    app.get('/api/jsonp', (req, res) => {
      const { callback } = req.query; // 获取前端传的回调函数名
      const data = { code: 200, data: 'JSONP跨域成功' };
      // 返回回调函数+数据(格式:callback(data))
      res.send(`${callback}(${JSON.stringify(data)})`);
    });
    
  • 前端

    function jsonpRequest(url, callbackName) {
      return new Promise((resolve) => {
        // 1. 创建script标签
        const script = document.createElement('script');
        script.src = `${url}?callback=${callbackName}`;
        // 2. 定义回调函数
        window[callbackName] = (data) => {
          resolve(data);
          document.body.removeChild(script); // 执行完移除script
          delete window[callbackName]; // 清理全局函数
        };
        // 3. 插入到页面
        document.body.appendChild(script);
      });
    }
    
    // 调用
    jsonpRequest('http://localhost:8080/api/jsonp', 'handleJsonp').then(res => {
      console.log(res);
    });
    

4. postMessage - 页面间跨域通信

核心原理:HTML5 新增的 API,允许不同域名的页面(如 iframe、多窗口)之间安全地传递数据,不是用于 AJAX 请求,而是页面间通信。

适用场景:iframe 嵌套跨域页面、多窗口通信。

实现示例

  • 父页面(a.com

    <iframe id="iframe" src="http://b.com"></iframe>
    <script>
      const iframe = document.getElementById('iframe');
      // 等子页面加载完成后发送消息
      iframe.onload = () => {
        iframe.contentWindow.postMessage(
          { type: 'data', content: '来自a.com的消息' },
          'http://b.com' // 只允许发送给该域名,* 表示所有
        );
      };
      // 接收子页面的回复
      window.addEventListener('message', (e) => {
        if (e.origin === 'http://b.com') { // 验证来源,防止恶意消息
          console.log('收到回复:', e.data);
        }
      });
    </script>
    
  • 子页面(b.com

    // 接收父页面消息
    window.addEventListener('message', (e) => {
      if (e.origin === 'http://a.com') {
        console.log('收到消息:', e.data);
        // 回复父页面
        e.source.postMessage({ type: 'reply', content: '已收到消息' }, e.origin);
      }
    });
    

5. 其他补充方案

  • WebSocket:不受同源策略限制,适用于实时通信(如聊天、推送),协议是 ws/wss,而非 http。
  • document.domain:仅适用于主域名相同、子域名不同的场景(如a.comapi.a.com),需双方页面设置document.domain = 'a.com',但兼容性差,不推荐。
  • CORS 的预检请求:PUT/DELETE/ 带自定义头的 POST 请求会先发送 OPTIONS 预检请求,后端需正确处理(如上面 CORS 示例中的 OPTIONS 逻辑)。

总结

  1. 核心推荐方案:开发环境用代理服务器,生产环境用CORS(后端配置)+ Nginx 代理,覆盖 99% 的场景;
  2. 特殊场景:老旧浏览器兼容用JSONP,页面间通信用postMessage,实时通信用WebSocket
  3. 关键原则:跨域的本质是浏览器的限制,服务器之间无跨域,因此 “代理” 和 “后端配置 CORS” 是最根本的解决思路。

选择方案时优先看场景:能改后端就用 CORS,开发阶段用代理,仅 GET 请求且需兼容老浏览器才用 JSONP。

javascript 结构化克隆

Node.js 支持 structuredClone API。这是一个全局可用的函数,用于执行深拷贝,自 Node.js 17.0.0 版本开始提供原生支持。

🚀 如何在 Node.js 中使用

structuredClone 是一个全局函数,你可以在任何地方直接调用,无需引入任何模块。

基本语法:

const clonedObject = structuredClone(originalObject);

它还可以接受一个可选的 options 参数,用于转移可转移对象(如 ArrayBuffer),而不是克隆它们。

structuredClone(value, { transfer: [transferableObjects] });

📝 使用示例

1. 基础深拷贝 这是最常用的方式,用于创建一个与原对象完全独立的副本。

const original = {
  name: "Node.js",
  types: ["JavaScript", "C++"],
  details: { stable: true }
};

// 使用 structuredClone 进行深拷贝
const cloned = structuredClone(original);

// 修改克隆对象的属性
cloned.types.push("Rust");
cloned.details.stable = false;

console.log(original.types); // 输出: ['JavaScript', 'C++'] (原数组未改变)
console.log(original.details.stable); // 输出: true (原对象未改变)
console.log(cloned.types); // 输出: ['JavaScript', 'C++', 'Rust']

2. 处理复杂数据类型 structuredClone 的强大之处在于它能正确处理许多 JSON.stringify 无法处理的类型。

const complexOriginal = {
  date: new Date(),
  regex: /hello/gi,
  map: new Map([['key', 'value']]),
  set: new Set([1, 2, 3]),
  circular: {} 
};
complexOriginal.circular.self = complexOriginal; // 循环引用

const complexClone = structuredClone(complexOriginal);

console.log(complexClone.date instanceof Date); // 输出: true (而 JSON 方法会将其转为字符串)
console.log(complexClone.regex instanceof RegExp); // 输出: true (而 JSON 方法会将其转为空对象 {})
console.log(complexClone.map instanceof Map); // 输出: true
console.log(complexClone.set instanceof Set); // 输出: true
console.log(complexClone.circular.self === complexClone); // 输出: true (循环引用被保留)

3. 转移 ArrayBuffer 当处理大型二进制数据时,可以使用 transfer 选项将数据的所有权从原对象转移到克隆对象,这是一种零拷贝操作,性能更好。

const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
int32View[0] = 42;

const transferred = structuredClone({ buf: buffer }, { transfer: [buffer] });

console.log(transferred.buf.byteLength); // 输出: 16 (克隆对象中的 buffer 仍可用)

// 尝试访问原 buffer 会导致错误,因为它已被转移
// console.log(buffer.byteLength); // 抛出 TypeError: Cannot perform operation on a detached ArrayBuffer

⚠️ 重要限制

尽管功能强大,structuredClone 无法克隆所有内容。以下类型会导致抛出 DataCloneError 异常或被忽略:

  • 函数Function
  • DOM 节点(在 Node.js 中不适用,但在浏览器环境中需要注意)
  • 属性描述符settergetter
  • 原型链(克隆后的对象不再继承自原对象的原型)
  • Symbol
  • WeakMapWeakSet

🔧 兼容旧版本 Node.js

如果你的 Node.js 版本低于 17,直接使用会报错。可以编写一个回退函数来保证代码兼容性:

function deepClone(obj) {
  if (typeof structuredClone === 'function') {
    return structuredClone(obj);
  } else {
    // 注意:这是一个功能受限的回退方案
    return JSON.parse(JSON.stringify(obj));
  }
}

对于更完整的兼容性,也可以考虑使用第三方 polyfill,如 @ungap/structured-clone

🌐 浏览器支持详情

structuredClone API 在浏览器中也得到了广泛的支持

所有现代浏览器(包括 Chrome、Firefox、Safari、Edge)都从 2022 年 3 月起,在稳定版本中提供了对该方法的支持。你可以像在 Node.js 中一样,在浏览器的主线程或 Web Worker 中直接使用它。

根据最新的标准文档和浏览器兼容性数据,各主流浏览器的支持情况如下:

浏览器 支持版本 备注
Chrome 98+ 从 v98 开始支持
Edge 98+ 从 v98 开始支持
Firefox 94+ 从 v94 开始支持
Safari 15.4+ 从 v15.4 开始支持
Opera 84+ 基于 Chromium,对应支持
Internet Explorer 不支持 任何版本都不支持

这个 API 目前已被广泛使用,你可以在 Can I use 上查看最新的统计数据。

💡 使用方式与限制

在浏览器中使用时,其语法和功能与 Node.js 完全一致:

// 创建一个包含各种类型数据的对象
const original = {
  name: '浏览器',
  date: new Date(),
  map: new Map([['key', 'value']])
};

// 进行深拷贝
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true

主要限制(与 Node.js 环境相同):

  • 无法克隆 函数 (Function)
  • 无法克隆 DOM 节点
  • 不会复制对象的原型链
  • 不会复制属性描述符、setter/getter 等元数据

📦 如何处理旧版本浏览器?

如果你的用户群体可能还在使用较老的浏览器版本(如 Safari 15.4 以下),或者需要支持 Internet Explorer,你可以使用 polyfill 来提供降级方案。

  1. 核心 polyfill 库:推荐使用 core-js 提供的稳定 polyfill 。

    npm install core-js
    

    然后在你的代码入口处引入:

    import 'core-js/stable/structured-clone';
    // 或者
    require('core-js/stable/structured-clone');
    
  2. 自定义回退函数:你也可以自己编写一个简单的降级逻辑,但需要注意,JSON.parse(JSON.stringify()) 这种方式无法处理 DateMapSet、循环引用等复杂情况。

    function safeStructuredClone(obj) {
      if (typeof structuredClone === 'function') {
        return structuredClone(obj);
      } else {
        // 警告:这是一个功能受限的降级方案,仅适用于简单对象
        try {
          return JSON.parse(JSON.stringify(obj));
        } catch (e) {
          console.error('当前环境不支持深拷贝该对象', e);
          return null;
        }
      }
    }
    

总的来说,对于绝大多数现代浏览器项目,你可以放心地直接使用 structuredClone。如果你需要支持非常老的浏览器,或者有兼容性方面的顾虑,可以告诉我,我们再一起看看具体的解决方案。

❌