用pixi.js实现fabric.js(七):框选、ActiveObject和控制点

友情提示

  • 请先看这里👉前言
  • 相关代码👉地址,本文相关的代码在interaction分支上,请切到这个分支查看代码
  • 本系列文章提到的fabric均为v7版本,pixijs均为v8版本
  • 本系列文章对应的内容已经发布到了npm,通过npm i pixijs-fabric来安装,使用方式和fabric一样

1. 前言

前面我们已经实现了StaticCanvas,让我们能够看到各个元素出现在画布上的效果,然后我们实现了事件系统,让画布上的元素可以拖拽,但是,编辑器最重要的操作:编辑元素、框选元素等,我们还没有实现,而这一章,我们将逐步实现这些编辑功能,让这个画布的功能更加完善,进一步靠近fabric。

2. ActiveObject

ActiveObject代表当前要操作的单个元素,它可以是任意一个FabricObject的子类。fabric会将ActiveObject高亮出来(加上一个border、以及控制点)。

2.1 ActiveObject的border

ActiveObject的border是一个矩形,这个矩形的4个点分别是对象的的aCoords(代码)的4个点,aCoords的计算方式,是(-w, -h)、(w, -h)、(-w, h)、(w, h)4个点,乘以对象的localTransform(代码)。

但是,aCoords仅仅是基于对象的localTransform的,在编辑器里,对象可能层层嵌套,并且画布整体可能会有缩放,为了保证ActiveObject的border在视觉上一直跟随ActiveObject,我们还需要对aCoords叠加对象的parent的worldTransform,得到这4个点相对于视窗的全局坐标。

实际绘制代码,在InteractiveObject的_renderControls函数内,如下:

ts 复制代码
_renderControls(ctx: CanvasRenderingContext2D, shouldDrawCps: boolean) {
  // activeObjCpGrap是pixi的Graphics,专门用来绘制ActiveObject的border
  const g = this.canvas.activeObjCpGrap;

  // 获取对象的parent的worldTransform,如无parent,则取canvas的viewportTransform
  const matrix =
    this.group?.pixiContent?.getGlobalTransform() ||
    this.canvas.getViewportTransform();
  const { a, b, c, d, tx, ty } = matrix;

  const { tl, tr, br, bl } = this.aCoords;
  const tlX = tl.x * a + tl.y * c + tx;
  const tlY = tl.x * b + tl.y * d + ty;
  const trX = tr.x * a + tr.y * c + tx;
  const trY = tr.x * b + tr.y * d + ty;
  const brX = br.x * a + br.y * c + tx;
  const brY = br.x * b + br.y * d + ty;
  const blX = bl.x * a + bl.y * c + tx;
  const blY = bl.x * b + bl.y * d + ty;

  // 旋转控制点的坐标
  const topMidX = (tlX + trX) / 2;
  const topMidY = (tlY + trY) / 2;
  const centerX = (tlX + trX + brX + blX) / 4;
  const centerY = (tlY + trY + brY + blY) / 4;
  const dirX = topMidX - centerX;
  const dirY = topMidY - centerY;
  const dirLen = Math.hypot(dirX, dirY) || 1;
  const rotateCpX = topMidX + (dirX / dirLen) * 40;
  const rotateCpY = topMidY + (dirY / dirLen) * 40;

  const {
    borderScaleFactor,
    cornerColor,
    cornerStrokeColor,
    cornerStyle,
    cornerSize,
    transparentCorners,
    borderColor,
  } = this;

  if (this.hasBorders) {
    g.moveTo(tlX, tlY)
      .lineTo(trX, trY)
      .lineTo(brX, brY)
      .lineTo(blX, blY)
      .closePath();

    if (shouldDrawCps) {
      g.moveTo(topMidX, topMidY).lineTo(rotateCpX, rotateCpY);
    }

    g.stroke({
      width: borderScaleFactor,
      color: borderColor,
      alpha: this.isMoving ? this.borderOpacityWhenMoving : 1,
    });
  }
  
  // ...
}

上述代码除了绘制出了一个矩形,还绘制了一条线段,这条线段是为了旋转控制点而绘制的,将矩形的middle top和旋转控制点连接起来。

2.2 ActiveObject的控制点

2.2.1 ControlPoint类

这个类表示对象的控制点,它继承于pixi的Graphics类:

ts 复制代码
export class ControlPoint extends Graphics{
  corner: 'mt' | 'ml' | 'mr' | 'mb' | 'tl' | 'tr' | 'bl' | 'br' | 'mtr' = 'mt';
  actionName: 'scale' | 'rotate' = 'scale';
  // ...
}

corner表示控制点的方位,actionName表示控制点将对对象进行何种操作。

2.2.2 创建控制点

在StaticCanvas类上创建控制点,一共9个控制点:

ts 复制代码
export class StaticCanvas{
  // ...
  private cpTl = new ControlPoint({
    corner: 'tl',
    cursor: 'nw-resize',
    canvas: this,
  });
  private cpTr = new ControlPoint({
    corner: 'tr',
    cursor: 'ne-resize',
    canvas: this,
  });
  private cpBr = new ControlPoint({
    corner: 'br',
    cursor: 'se-resize',
    canvas: this,
  });
  private cpBl = new ControlPoint({
    corner: 'bl',
    cursor: 'sw-resize',
    canvas: this,
  });
  private cpL = new ControlPoint({
    corner: 'ml',
    cursor: 'w-resize',
    canvas: this,
  });
  private cpT = new ControlPoint({
    corner: 'mt',
    cursor: 'n-resize',
    canvas: this,
  });
  private cpR = new ControlPoint({
    corner: 'mr',
    cursor: 'e-resize',
    canvas: this,
  });
  private cpB = new ControlPoint({
    corner: 'mb',
    cursor: 's-resize',
    canvas: this,
  });
  private cpAbove = new ControlPoint({
    corner: 'mtr',
    cursor: 'crosshair',
    canvas: this,
  });
  // ...
}

2.2.3 绘制控制点

前面说了,ActiveObject的border是一个矩形,并且我们已经求出了这个矩形的4个顶点的全局坐标,而控制点处于这个矩形的4个顶点,以及这个矩形的4条边的中点上,以及,处于这个矩形正上方(旋转控制点),一共9个控制点。

基于前面的求ActiveObject的border的逻辑,我们只需要添加少量逻辑,就能完成控制点的绘制。

绘制控制点的逻辑,也在_renderControls函数里,用pixi的Graphics类来进行绘制:

ts 复制代码
_renderControls(ctx: CanvasRenderingContext2D, shouldDrawCps: boolean) {
  // ...
  const { cps } = this.canvas;
  if (this.hasControls) {
    // 取到9个控制点,依次绘制
    const { cpTl, cpTr, cpBr, cpBl, cpL, cpT, cpR, cpB, cpAbove } = this.canvas;
    cpTl.position.set(tlX, tlY);
    cpTr.position.set(trX, trY);
    cpBr.position.set(brX, brY);
    cpBl.position.set(blX, blY);
    cpT.position.set(topMidX, topMidY);
    cpL.position.set((tlX + blX) / 2, (tlY + blY) / 2);
    cpB.position.set((blX + brX) / 2, (blY + brY) / 2);
    cpR.position.set((trX + brX) / 2, (trY + brY) / 2);
    cpAbove.position.set(rotateCpX, rotateCpY);
    for (let i = 0; i < cps.length; i++) {
      const cp = cps[i];

      cp.angle = this.angle ?? 0;

      if (cornerStyle === 'circle') {
        cp.circle(0, 0, cornerSize / 2);
      } else {
        cp.rect(-cornerSize / 2, -cornerSize / 2, cornerSize, cornerSize);
      }

      cp.stroke({
        width: borderScaleFactor,
        color: cornerStrokeColor || cornerColor,
      });

      if (!transparentCorners) {
        cp.fill({ color: cornerColor });
      }

      const h = cp.h;
      h.x = -cornerSize / 2;
      h.y = -cornerSize / 2;
      h.width = cornerSize;
      h.height = cornerSize;
    }
  }
}

3. ActiveSelection

ActiveSelection其实也是ActiveObject,和第2节的ActiveObject不同的是,第2节的ActiveObject代表单个元素,ActiveSelection代表一组元素。

ActiveSelection继承于Group

3.1 ActiveSelection的border

ActiveSelection不仅要绘制自己的border,还要绘制里面的子元素的border

ts 复制代码
export class ActiveSelection extends Group {
  // ...
  _renderControls(ctx: CanvasRenderingContext2D, shouldDrawCps: boolean) {
    for (let i = 0; i < this._objects.length; i++) {
      const obj = this._objects[i];
      obj._renderControls(ctx, false);
    }
    super._renderControls(ctx, true);
  }
  // ...
}

3.2 ActiveSelection的控制点

ActiveSelection的控制点和ActiveObject的逻辑一样,只需要直接用ActiveObject的逻辑就行了。

4. 框选

通过框选,来选中一组元素,选中的元素,会被设置为ActiveObject

4.1 记录选框起点

鼠标按下后,记录选框起点

ts 复制代码
private _onMouseDown = (e: FederatedPointerEvent) => {
  // ...
  this._groupSelector = {
    x: e.globalX,
    y: e.globalY,
    deltaY: 0,
    deltaX: 0,
  };
  // ...
}

4.2 记录选框终点

鼠标移动的时候,记录选框的终点,并将选框绘制出来

ts 复制代码
private __onMouseMove(e: FederatedPointerEvent) {
  // ...
  const groupSelector = this._groupSelector;
  if (groupSelector) {
    groupSelector.deltaX = e.globalX - groupSelector.x;
    groupSelector.deltaY = e.globalY - groupSelector.y;

    this.renderTop();
  }
}

使用pixi的Graphics类来绘制选框

ts 复制代码
protected _drawSelection(): void {
  const { x, y, deltaX, deltaY } = this._groupSelector!;
  const startX = x;
  const startY = y;
  const extentX = x + deltaX;
  const extentY = y + deltaY;
  const strokeOffset = this.selectionLineWidth / 2;
  let minX = Math.min(startX, extentX),
    minY = Math.min(startY, extentY),
    maxX = Math.max(startX, extentX),
    maxY = Math.max(startY, extentY);

  const grah = this.selectionGrap;
  grah.clear();

  if (this.selectionColor) {
    grah.rect(minX, minY, maxX - minX, maxY - minY).fill(this.selectionColor);
  }

  if (!this.selectionLineWidth || !this.selectionBorderColor) {
    return;
  }

  minX += strokeOffset;
  minY += strokeOffset;
  maxX -= strokeOffset;
  maxY -= strokeOffset;
  grah.rect(minX, minY, maxX - minX, maxY - minY).stroke({
    width: this.selectionLineWidth,
    color: this.selectionBorderColor,
  });

  this.requestRenderAll();
}

4.3 完成框选

鼠标抬起时,完成框选

ts 复制代码
private _onMouseUp = (e: FederatedPointerEvent) => {
  // ...
  this.handleSelection(e);
}

完成框选后,会根据框选的区域,将选中的元素设置为ActiveObject,如果是单个元素,则直接将该元素设置为ActiveObject,如果是多个元素,则将这一组元素放到一个ActiveSelection里,并将这个ActiveSelection设置为ActiveObject:

ts 复制代码
protected handleSelection(e: FederatedPointerEvent) {
  // ...
  if (objects.length === 1) {
    // set as active object
    this.setActiveObject(objects[0], e);
  } else if (objects.length > 1) {
    // add to active selection and make it the active object
    const klass =
      classRegistry.getClass<typeof ActiveSelection>('ActiveSelection');
    const as = new klass(objects, { canvas: this });
    this.topLayer.addChild(as.pixiContent);
    this.setActiveObject(as, e);
  }
}

5. 旋转控制点

用旋转控制点来控制对象的旋转角度,无论对象的锚点处于什么位置,都要让对象以自身内容区域中心为旋转中心旋转。

5.1 mousedown时记录对象的初始状态

当在控制点上触发mousedown时,记录对象的初始状态,以便mousemove的时候做diff。

ts 复制代码
protected _setupCurrentTransform(
  e: FederatedPointerEvent,
  target: FabricObject,
  cp: ControlPoint,
): void {
  // ...
  cp.setTarget(target);
  const {
    localTransform,
    mouseDownPoint,
    scale,
    fixedAnchor,
    tl,
    tr,
    bl,
    br,
  } = cp.initAttr;
  localTransform.copyFrom(target.pixiContent.localTransform);
  mouseDownPoint.copyFrom(e.global);
  scale.set(target.scaleX, target.scaleY);
  const ax = resolveOrigin(origin.x) * target.width;
  const ay = resolveOrigin(origin.y) * target.height;
  fixedAnchor.set(ax, ay);
  cp.initAttr.rotation = degreesToRadians(target.angle);
  const { aCoords } = target;
  tl.set(aCoords.tl.x, aCoords.tl.y);
  tr.set(aCoords.tr.x, aCoords.tr.y);
  bl.set(aCoords.bl.x, aCoords.bl.y);
  br.set(aCoords.br.x, aCoords.br.y);
  // ...
}

我们会:

  • 记录初始localTransform
  • 记录鼠标的视窗坐标
  • 记录初始缩放(缩放控制点会用到)
  • 记录旋转/缩放锚点(在进行旋转或缩放时,这个点保持不动)
  • 记录初始旋转角度(旋转控制点会用到)
  • 记录初始4个顶点的坐标(缩放控制点会用到,用来计算缩放因子)

5.2 mousemove时作diff

mousedown时,会记录控制对象的初始状态,并标记当前处于操作控制点的状态,mousemove时,如果有标记,则开始进行diff,每次触发mousemove,都会拿当前鼠标坐标和初始坐标作diff。

旋转控制点里要求的diff,是旋转角度,假设旋转控制点为P,旋转锚点为O,鼠标落点为Q,那么角POQ就是我们要求的diff,如图所示:

其中,绿色矩形是我们的控制对象,角θ就是我们要求的diff。

计算这个diff的方式,可以查看用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互的3.4节。

5.3 计算新的localTransform

在mousedown时,我们已经记录了控制对象的一些初始状态,包括localTransform,旋转锚点fixedAnchor,我们可以来算一下,fixedAnchor经过了旧的localTransform变换后,会出现在哪个点:

ts 复制代码
const fixedAnchorLocal = localTransform.apply(
  new PixiPoint(fixedAnchor.x, fixedAnchor.y),
);

fixedAnchor经过旧的localTransform变换,出现在了fixedAnchorLocal点,由于fixedAnchor是旋转锚点,所以它出现在屏幕上的位置是固定不动的,也就是说,fixedAnchor经过了新的localTransform之后,也会出现在fixedAnchorLocal点,也就是上面我们计算出来的点,通过这一点,我们可以得出一个矩阵方程:

假设新的localTransform为:
a c tx b d ty 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1

fixedAnchor为:
faX faY 1 \begin{bmatrix} faX \\ faY \\ 1 \end{bmatrix} faXfaY1

fixedAnchorLocal为:
falX falY 1 \begin{bmatrix} falX \\ falY \\ 1 \end{bmatrix} falXfalY1

那么有:
a c tx b d ty 0 0 1 × faX faY 1 = falY falY 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} faX \\ faY \\ 1 \end{bmatrix} = \begin{bmatrix} falY \\ falY \\ 1 \end{bmatrix} ab0cd0txty1 × faXfaY1 = falYfalY1

里面的faX、faY、falY、falY都是已知数,只有a、b、c、d、tx、ty是未知数。

在5.2中我们得到了diff值,也就是旋转角度θ,我们给旧的localTransform叠加一个旋转矩阵(旋转角度为θ),就可以得到a、b、c、d的值了:

ts 复制代码
const newMatrix = localTransform.clone();
newMatrix.rotate(r); // r就是θ

接下来,方程中的未知数,只剩下tx、ty了,把方程展开,已知数全部移到右边,就可以得到tx、ty了:

ts 复制代码
newMatrix.tx =
  fixedAnchorLocal.x -
  newMatrix.a * fixedAnchor.x -
  newMatrix.c * fixedAnchor.y;
newMatrix.ty =
  fixedAnchorLocal.y -
  newMatrix.d * fixedAnchor.y -
  newMatrix.b * fixedAnchor.x;

至此,我们就得到了新的localTransform。

5.4 把新的localTransform应用到元素上

5.4.1 不能直接setFromMatrix

如果我们在使用pixi直接写一个编辑器引擎的话,这一步的工作就非常简单了,只需要调用:

ts 复制代码
target.transform.setFromMatrix(newMatrix)

pixi就会根据newMatrix帮我们计算出元素的x、y、scale、rotation等属性。

但是我们在实现fabric,所以我们要用fabric的模式,来计算元素的新属性。

5.4.2 要求的量

在用旋转控制点旋转元素时,我们要改的属性有3个:角度、x、y,对应到fabric的属性,就是angle、left、top。

5.4.3 求angle

angle就是元素的初始rotation加上前面求出来的diff(θ角):

ts 复制代码
const newAngle = radiansToDegrees(rotation + θ); // fabric用角度制

5.4.4 求left、top

fabric的left、top,是锚点的left、top,锚点会出现在left、top这个位置上,所以,我们可以根据这一点来推算出left、top。

先用新的localTransform来计算出锚点会出现的位置:

ts 复制代码
const originX = resolveOrigin(target.originX) * target.width;
const originY = resolveOrigin(target.originY) * target.height;
const { x, y } = newMatrix.apply(new PixiPoint(originX, originY));

锚点经过新的localTransform变换后,出现在了,所以,x、y就是元素的新left、top。

5.4.5 设置元素的新属性

把前面计算出来的angle、left、top直接set给元素,就完成了旋转:

ts 复制代码
target.set({
  angle: newAngle,
  left: newLeft,
  top: newTop,
});

6. 缩放控制点

用缩放控制点来控制对象的缩放,缩放时,要让对角线上的那个缩放控制点保持不动。

6.1 mousedown时记录对象的初始状态

和旋转控制点一样,在mousedown时,我们需要保存一些对象的初始信息,以便mousemove的时候做diff,这一块的代码(_setupCurrentTransform函数)是复用的。

6.2 mousemove时作diff

缩放控制点的diff,是一个缩放因子,用旧的scale乘以这个缩放因子,就是新的scale。

这里以右下角的缩放控制点为例,假设右下角控制点为T,左上角控制点为O,鼠标落点为P,从P点向OT所在直线作垂线,垂线与OT所在直线的交点为Q,那么OQ/OT就是这个缩放因子:

计算这个diff的方式,可以查看用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互的3.5节。

6.3 计算新的localTransform

在mousedown时,我们已经记录了控制对象的一些初始状态,包括localTransform,旋转锚点fixedAnchor,我们可以来算一下,fixedAnchor经过了旧的localTransform变换后,会出现在哪个点:

ts 复制代码
const fixedAnchorLocal = localTransform.apply(
  new PixiPoint(fixedAnchor.x, fixedAnchor.y),
);

fixedAnchor经过旧的localTransform变换,出现在了fixedAnchorLocal点,由于fixedAnchor是旋转锚点,所以它出现在屏幕上的位置是固定不动的,也就是说,fixedAnchor经过了新的localTransform之后,也会出现在fixedAnchorLocal点,也就是上面我们计算出来的点,通过这一点,我们可以得出一个矩阵方程:

假设新的localTransform为:
a c tx b d ty 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1

fixedAnchor为:
faX faY 1 \begin{bmatrix} faX \\ faY \\ 1 \end{bmatrix} faXfaY1

fixedAnchorLocal为:
falX falY 1 \begin{bmatrix} falX \\ falY \\ 1 \end{bmatrix} falXfalY1

那么有:
a c tx b d ty 0 0 1 × faX faY 1 = falY falY 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} faX \\ faY \\ 1 \end{bmatrix} = \begin{bmatrix} falY \\ falY \\ 1 \end{bmatrix} ab0cd0txty1 × faXfaY1 = falYfalY1

里面的faX、faY、falY、falY都是已知数,只有a、b、c、d、tx、ty是未知数。

在6.2中我们得到了diff值,也就是缩放因子,我们给旧的localTransform叠加一个缩放矩阵(缩放大小为缩放因子),就可以得到a、b、c、d的值了:

ts 复制代码
const newMatrix = localTransform.clone().scale(factor, factor);

接下来,方程中的未知数,只剩下tx、ty了,把方程展开,已知数全部移到右边,就可以得到tx、ty了:

ts 复制代码
newMatrix.tx =
  fixedAnchorLocal.x -
  newMatrix.a * fixedAnchor.x -
  newMatrix.c * fixedAnchor.y;
newMatrix.ty =
  fixedAnchorLocal.y -
  newMatrix.d * fixedAnchor.y -
  newMatrix.b * fixedAnchor.x;

至此,我们就得到了新的localTransform。

6.4 把新的localTransform应用到元素上

6.4.1 不能直接setFromMatrix

和前面的旋转控制点一样,我们不能直接用setFromMatrix,我们需要基于fabric的那套逻辑,计算出新的scaleX、scaleY、left、top。

6.4.2 要求的量

在用缩放控制点缩放元素时,我们要改的属性有2个:缩放值、坐标值,对应到fabric的属性,就是scaleX、scaleY、left、top。

6.4.3 计算scaleX、scaleY

这两个属性的计算,只需要用旧的scaleX、scaleY乘以缩放因子就可以了:

ts 复制代码
const newScaleX = scale.x * factor;
const newScaleY = scale.y * factor;

6.4.4 计算left、top

和5.4.4一样,我们利用锚点和left、top的关系,来推算出新的left、top:

ts 复制代码
const originX = resolveOrigin(target.originX) * target.width;
const originY = resolveOrigin(target.originY) * target.height;
const { x: newLeft, y: newTop } = newMatrix.apply(new PixiPoint(originX, originY));

6.4.5 设置元素的新属性

把计算出来的newScaleX、newScaleY、newLeft、newTop直接set给元素,就完成了缩放:

ts 复制代码
target.set({
  scaleX: newScaleX,
  scaleY: newScaleY,
  left: newLeft,
  top: newTop,
});

7. 测试一下

7.1 测试框选画布上的元素

框住画布上的一些元素,看一下效果

测试代码地址👉codesandbox地址

pixijs-fabric效果

fabric效果

7.2 15000个矩形渲染的缩放测试

渲染15000个矩形在画布上,并保证它们全部出现在视窗内,然后缩放画布,查看性能

测试代码地址👉codesandbox地址

pixijs-fabric效果

可以看到,每个task的时间,在20ms左右,FPS维持在了50左右,属于流畅状态。

fabric效果

如同之前的测试一样,fabric的每个task来到了500多ms,FPS掉到了不足2,属于极度卡顿不可用状态。

7.2 框选15000个矩形的缩放测试

渲染15000个矩形在画布上,然后将它们全部框选住,并保证它们全部出现在视窗内,再缩放画布,查看性能

测试代码地址👉codesandbox地址

pixijs-fabric效果

由于框选住元素后,需要绘制大量的stroke,所以性能出现了急剧下降,每个task从20ms来到了80ms,FPS掉到了不到20,比较卡顿。

fabric效果

比较神奇的是,框选中15000个元素后,fabric的每个task还是维持在了500ms,不过,依然是处于不可用状态。

结尾

到这里,我们已经实现了fabric的核心交互功能了,但是有一些周边功能,由于时间原因,作者并没有完全实现,如果大家有需要或者有兴趣,可以去补足一下这部分功能,让这个'fabric'更加完善。

相关推荐
云浪1 小时前
手把手教你用 fetch 读取 SSE 流,给 AI 聊天加上打字机效果
前端·javascript·vue.js
Csvn1 小时前
Tailwind 动态拼接类名失效?JIT 引擎正在"静态分析"你
前端
柳杉1 小时前
我用Threejs 搓了一个 3D 中国地图设计器,开箱即用
前端·three.js·数据可视化
DJ斯特拉1 小时前
Tlias智能学习辅助系统(前端部分)
前端·javascript·学习
码云数智-大飞1 小时前
Go Channel 详解:并发通信的正确姿势
前端·数据库·git
蜡台2 小时前
uni-indexed-list 之扩展组件实现城市列表带索引查询过滤功能
前端·vue.js·uniapp·uni-indexed
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-06-16
前端·人工智能·经验分享·chatgpt·html
snow@li2 小时前
前端:构建工具(Vite / Webpack)的 文件指纹(File Hash) 机制 / 浏览器缓存控制
前端·webpack·哈希算法