普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月18日掘金 前端

深入解析:基于 React 与 Konva.js 实现高级图片编辑功能(附源码解读)

作者 睡着学
2025年5月18日 19:44

深入解析:基于 React 与 Konva.js 实现高级图片编辑功能(附源码解读)

引言:打造交互式图片编辑体验

在现代 Web 应用中,图片的处理与编辑功能扮演着越来越重要的角色。无论是社交媒体的滤镜、电商的产品展示,还是在线教育的课件标注,用户对于图片编辑的实时性、交互性和功能丰富性都有着较高的期待。本文将带领大家深入探索一个基于 React 和 Konva.js 实现的高级图片编辑组件。我们将从实际代码出发,详细解读其核心功能,包括图片加载与蒙版处理、自由绘制(画笔与橡皮擦)、图片拖拽与缩放、操作历史记录(撤销与重做)以及最终的图片保存等。通过本文,你不仅能了解到 Konva.js 在复杂图形操作中的应用技巧,还能学习到如何将这些技术整合到 React 组件中,构建出高效、可维护的前端应用。让我们一起揭开这个强大图片编辑器的神秘面纱吧!

核心技术栈:React 与 Konva.js 的强强联合

在深入探讨具体功能实现之前,我们首先来了解一下这个图片编辑组件所依赖的核心技术栈:React 和 Konva.js。React 作为当前最流行的前端框架之一,以其组件化、声明式编程和高效的 Virtual DOM 更新机制,为构建复杂用户界面提供了坚实的基础。而 Konva.js 则是一个强大的 HTML5 2D Canvas 库,它专注于提供高性能的图形绘制、动画以及交互能力,特别适合处理复杂的图形编辑场景。

React:构建用户界面的基石

在这个项目中,React 主要负责以下几个方面:

  • 组件化架构:整个图片编辑器被设计为一个 React 组件 (ModalComponent),这使得它可以方便地被复用和集成到其他应用中。组件内部又可以根据功能划分为更小的、可管理的单元,例如工具栏、画布区域等(虽然在提供的代码片段中,这些子组件没有显式拆分,但可以通过 React 的组织方式进行扩展)。
  • 状态管理:React 的 useState Hook 被广泛用于管理组件的各种状态,例如当前选中的工具 (tool)、画笔/橡皮擦的大小 (penSize, eraserSize)、图片的位置 (imagePosition) 和缩放比例 (scaleRatio)、历史记录 (history, historyStep) 等。这种声明式的状态管理方式使得组件的逻辑更加清晰和易于维护。
  • 事件处理:React 的事件处理机制被用于响应用户的各种操作,例如鼠标按下 (handleMouseDown)、移动 (handleMouseMove)、松开 (handleMouseUp)、滚轮滚动 (handleWheel) 等。这些事件最终会触发状态的更新,进而重新渲染画布。
  • 生命周期与副作用管理useEffect Hook 用于处理组件的副作用,例如在组件挂载或特定依赖项变化时加载图片、初始化蒙版、创建离屏 Canvas、以及监听图片加载状态等。这确保了在合适的时机执行必要的操作。

Konva.js:赋能复杂图形操作

Konva.js 在这个项目中扮演了至关重要的角色,它使得在浏览器中进行复杂的图形绘制和交互成为可能:

  • 分层画布 (Stage & Layer) :Konva.js 引入了 Stage(舞台)和 Layer(图层)的概念。Stage 是所有图形内容的顶层容器,而 Layer 则可以包含具体的图形元素(Shape),如图片、线条、圆形等。这种分层结构有助于组织复杂的场景,并且可以独立地对不同图层进行操作和重绘,从而提高性能。在代码中,我们通过 StageLayer 组件(来自 react-konva)来创建和管理画布。
  • 图形对象 (Shape) :Konva.js 提供了丰富的内置图形对象,如 Image(用于显示图片和蒙版)、Line(用于绘制画笔轨迹)、Circle(用于绘制画笔的单个点)。这些对象都具有丰富的属性(如位置、大小、颜色、透明度等)和事件处理能力。
  • 事件系统:Konva.js 拥有自己独立的事件系统,可以监听图形对象上的各种事件,如 mousedown, mousemove, mouseup, wheel 等。react-konva 将这些事件很好地集成到了 React 的事件处理方式中,使得我们可以像处理普通 DOM 元素事件一样处理 Konva 图形对象的事件。
  • 离屏 Canvas 与性能优化:虽然 Konva.js 本身已经做了很多性能优化,但在处理大量绘制操作(如自由画笔)时,直接在 Konva 的 Layer 上频繁创建和销毁 Shape 对象可能会导致性能瓶颈。代码中巧妙地引入了原生的离屏 Canvas (drawingCanvasRef) 来处理画笔和橡皮擦的绘制。用户的绘制操作首先在离屏 Canvas 上完成,然后将离屏 Canvas 的内容作为一个整体的图像绘制到 Konva 的 Layer 上。这种方式可以显著提升绘制的流畅性。此外,历史记录中的快照 (maskSnapshot) 也是通过 ImageData 的形式保存离屏 Canvas 的状态,进一步优化了撤销/重做操作的性能。

通过 React 的组件化和状态管理能力,结合 Konva.js 强大的 2D 图形处理能力,我们可以构建出功能丰富且具有良好用户体验的图片编辑应用。在接下来的章节中,我们将详细剖析这些技术是如何协同工作,以实现编辑器的各项核心功能的。

功能实现详解:一步步构建高级图片编辑器

在了解了核心技术栈之后,现在让我们深入到具体的代码实现中,逐一解析图片编辑器的各项核心功能是如何实现的。我们将重点关注图片与蒙版加载、绘图操作、图片变换、历史记录以及保存等关键环节。

1. 图片与蒙版加载及预处理:奠定编辑基础

任何图片编辑操作的第一步都是加载待编辑的图片以及可能存在的初始蒙版。在这个组件中,图片和蒙版的加载主要依赖于 use-image 这个第三方 Hook 以及 React 的 useEffect

 // ... (imports)
 import useImage from 'use-image';
 
 // ... (interface definitions)
 
 const ModalComponent: React.FC<IModalComponentProps> = ({ imagePath, maskImage, jobDomain, width, height, updateParams, onClose }) => {
   // ... (other state variables)
   const [image] = useImage(jobDomain + imagePath, 'anonymous');
   const [mask] = useImage(jobDomain + maskImage, 'anonymous');
 
   // ... (refs)
   const maskCanvasRef = useRef<HTMLCanvasElement | null>(null);
   // ...
 
   // 初始化蒙层
   useEffect(() => {
     if (image && mask) {
       const canvas = document.createElement('canvas');
       canvas.width = width;
       canvas.height = height;
       const ctx = canvas.getContext('2d');
 
       if (!ctx) return;
 
       ctx.drawImage(mask, 0, 0, width, height);
       const imageData = ctx.getImageData(0, 0, width, height);
       const data = imageData.data;
 
       // 只保留白色像素为紫色,其余像素完全透明
       for (let i = 0; i < data.length; i += 4) {
         if (data[i] > 200 && data[i + 1] > 200 && data[i + 2] > 200 && data[i + 3] > 0) {
           data[i] = 114; // R
           data[i + 1] = 46;  // G
           data[i + 2] = 209; // B
           data[i + 3] = 255; // A (opaque purple)
         } else {
           // 非白色像素完全透明
           data[i + 3] = 0; // A (fully transparent)
         }
       }
 
       ctx.putImageData(imageData, 0, 0);
       maskCanvasRef.current = canvas; // 保存处理后的蒙版 Canvas
 
       // 将蒙层绘制到 drawingCanvas 并记录初始快照 (部分逻辑)
       // ... (this part also involves drawingCanvasRef and history initialization)
       if (!drawingCanvasRef.current) drawingCanvasRef.current = document.createElement('canvas');
       const dCanvas = drawingCanvasRef.current;
       dCanvas.width = width;
       dCanvas.height = height;
       const dCtx = dCanvas.getContext('2d');
       if (dCtx) {
         dCtx.clearRect(0, 0, width, height);
         dCtx.drawImage(maskCanvasRef.current!, 0, 0, width, height);
         const snap = dCtx.getImageData(0, 0, width, height);
         setHistory([{ paintLines: [], maskSnapshot: snap }]);
         setHistoryStep(0);
         setMaskCleared(false);
         stageRef.current?.draw(); // 触发 Konva 更新
       }
     }
   }, [image, mask, width, height]);
 
   // ... (other useEffects and functions)
 }

图片加载: 组件通过 useImage(jobDomain + imagePath, 'anonymous')useImage(jobDomain + maskImage, 'anonymous') 来异步加载主图片和蒙版图片。useImage Hook 会返回一个包含图片加载状态和图片对象的数组。当图片成功加载后,imagemask变量会分别持有对应的 HTMLImageElement 对象。设置 'anonymous' 参数是为了支持跨域图片的加载,这在处理来自 CDN 或其他域的图片时非常重要。

蒙版预处理: 在 useEffect Hook 中,当 imagemask 都成功加载后,会进行蒙版的预处理。这段逻辑的核心目标是将蒙版图片中的特定颜色(这里是白色)转换为一种醒目的颜色(紫色 rgba(114, 46, 209, 255)),并将其他非白色像素设置为完全透明。这个过程如下:

  1. 创建一个临时的 HTMLCanvasElement (canvas),其尺寸与主图片一致。

  2. 获取该 Canvas 的 2D 渲染上下文 (ctx)。

  3. 将加载的原始蒙版图片 (mask) 绘制到这个临时 Canvas 上。

  4. 使用 ctx.getImageData() 获取临时 Canvas 上所有像素的数据。imageData.data 是一个 Uint8ClampedArray,其中每四个连续的元素代表一个像素的 R、G、B、A(红、绿、蓝、透明度)值。

  5. 遍历像素数据:

    • 如果一个像素的 R、G、B 值都大于 200(近似白色)且其 Alpha 值大于 0(非完全透明),则将其颜色修改为紫色 (R=114, G=46, B=209) 并保持不透明 (A=255)。
    • 否则,将该像素的 Alpha 值设置为 0,使其完全透明。
  6. 使用 ctx.putImageData() 将修改后的像素数据写回到临时 Canvas。

  7. 最后,将这个处理过的临时 Canvas (canvas) 存储在 maskCanvasRef.current 中,以备后续在主绘图区域使用。

初始状态设置: 蒙版预处理完成后,代码还会将这个处理好的蒙版绘制到 drawingCanvasRef.current(这是用于实际绘制操作的离屏 Canvas)。同时,会捕获 drawingCanvasRef 的当前状态作为历史记录的初始快照 (maskSnapshot),并初始化历史记录栈。setMaskCleared(false) 确保了初始蒙版是可见的。最后调用 stageRef.current?.draw() 来刷新 Konva 画布,将初始的蒙版(通过离屏 Canvas)渲染出来。

通过这样的加载和预处理流程,组件确保了在用户开始编辑之前,图片和经过特殊处理的蒙版已经准备就绪,为后续的绘制和编辑操作提供了清晰的视觉基础和正确的初始状态。

2. 核心绘图功能:画笔与橡皮擦的丝滑体验

图片编辑器的核心在于其绘图功能,允许用户在图片上自由涂鸦或擦除特定区域。该组件巧妙地结合了 Konva.js 的事件处理和离屏 Canvas 技术,实现了高效且灵活的画笔和橡皮擦功能。

2.1 离屏 Canvas:绘图性能的保障

直接在 Konva 的 Layer 上频繁创建和更新大量的线条或点对象(尤其是在鼠标快速移动时)可能会导致性能问题,造成卡顿。为了解决这个问题,代码引入了一个原生的 HTML5 Canvas 元素作为“离屏 Canvas” (drawingCanvasRef)。用户的画笔和橡皮擦操作实际上是先在这个离屏 Canvas 上进行的,然后整个离屏 Canvas 的内容会作为一个图像被绘制到 Konva 的 Stage 上。这种做法大大减少了 Konva 需要管理的图形对象数量,从而提升了绘图的流畅度。

 // ... (state and refs)
 const drawingCanvasRef = useRef<HTMLCanvasElement | null>(null);
 const [paintLines, setPaintLines] = useState<any[]>([]); // 存储所有绘制的线条/点
 const [currentLine, setCurrentLine] = useState<any[]>([]); // 当前正在绘制的线条
 const [isDrawing, setIsDrawing] = useState(false);
 const [tool, setTool] = useState("move"); // 'pen', 'eraser', 'move'
 const [penSize, setPenSize] = useState(30);
 const [eraserSize, setEraserSize] = useState(30);
 // ... (imagePosition, scaleRatio)
 
 // 初始化离屏Canvas (部分逻辑在之前的useEffect中已展示)
 useEffect(() => {
   if (!drawingCanvasRef.current) drawingCanvasRef.current = document.createElement("canvas");
   const canvas = drawingCanvasRef.current;
   canvas.width = width; // 图片原始宽度
   canvas.height = height; // 图片原始高度
   const ctx = canvas.getContext("2d");
   if (ctx) {
     ctx.clearRect(0, 0, width, height);
     // 初始快照也在这里创建和记录
   }
 }, [width, height]);
 
 // 每次 paintLines 或 maskCleared 状态变化时,重新绘制离屏 Canvas
 useEffect(() => {
   if (!drawingCanvasRef.current) return;
   const canvas = drawingCanvasRef.current;
   const ctx = canvas.getContext("2d");
   if (!ctx) return;
 
   ctx.clearRect(0, 0, width, height); // 清空画布
 
   // 1. 先绘制蒙版 (如果未被清除)
   if (maskCanvasRef.current && !maskCleared) {
     ctx.globalCompositeOperation = "source-over";
     ctx.drawImage(maskCanvasRef.current, 0, 0, width, height);
   }
 
   // 2. 绘制所有历史线条和圆点
   paintLines.forEach(line => {
     // 根据是画笔还是橡皮擦,设置不同的合成操作
     ctx.globalCompositeOperation = line.type === "eraser" ? "destination-out" : "source-over";
     if (line.type === "circle") { // 单点绘制 (圆点)
       ctx.fillStyle = line.color === "#000000" ? "#000000" : "rgba(114, 46, 209)"; // 橡皮擦用黑色,画笔用紫色
       // 对于橡皮擦的圆点,也需要是 "destination-out" 才能擦除蒙版或之前的画笔痕迹
       ctx.globalCompositeOperation = line.color === "#000000" ? "destination-out" : "source-over";
       ctx.beginPath();
       ctx.arc(line.points[0], line.points[1], line.width / 2, 0, Math.PI * 2);
       ctx.fill();
     } else { // 连续线条绘制
       ctx.strokeStyle = line.color; // 画笔颜色或橡皮擦的'擦除色'
       ctx.lineWidth = line.width;
       ctx.lineCap = "round";
       ctx.lineJoin = "round";
       ctx.beginPath();
       ctx.moveTo(line.points[0], line.points[1]);
       for (let i = 2; i < line.points.length; i += 2) {
         ctx.lineTo(line.points[i], line.points[i + 1]);
       }
       ctx.stroke();
     }
   });
   // 每次离屏 Canvas 更新后,需要通知 Konva Stage 重绘
   stageRef.current?.getLayer()?.batchDraw(); // 或者 stageRef.current?.draw()
 }, [paintLines, maskCleared, width, height, maskCanvasRef.current]);

在上述 useEffect 中,每当 paintLines (存储所有绘制操作的数组) 或 maskCleared (蒙版是否被清除的标志) 发生变化时,都会触发离屏 Canvas 的重绘。重绘过程包括:

  1. 清空离屏 Canvas。

  2. 如果蒙版 (maskCanvasRef.current) 存在且未被清除 (!maskCleared),则先将预处理过的蒙版绘制到离屏 Canvas 上。这里 globalCompositeOperation 设置为 source-over,表示新绘制的内容会覆盖在原有内容之上。

  3. 遍历 paintLines 数组,将每一条历史绘制操作(线条或圆点)重新应用到离屏 Canvas 上。

    • 关键点:globalCompositeOperation

      • 对于画笔操作 (line.type === "line"line.type === "circle" 且颜色不是黑色),globalCompositeOperation 设置为 source-over,画笔颜色为紫色 rgba(114, 46, 209)。这意味着画笔的痕迹会叠加在蒙版或之前的画笔痕迹之上。
      • 对于橡皮擦操作 (line.type === "eraser"line.type === "circle" 且颜色为黑色 #000000),globalCompositeOperation 设置为 destination-out。这是一种非常重要的 Canvas 合成模式,它使得新绘制的内容(橡皮擦的轨迹)会“擦除”掉目标 Canvas 上已有的内容,即新绘制区域会变成透明,从而实现橡皮擦的效果。橡皮擦的 strokeStylefillStyle 通常设为任意不透明颜色(代码中是黑色),因为 destination-out 关心的是形状和位置,而不是颜色。

2.2 鼠标事件处理与绘制逻辑

用户的绘制行为是通过监听 Konva Stage 上的鼠标事件来捕捉和处理的:handleMouseDownhandleMouseMovehandleMouseUp

 // ... (inside ModalComponent)
 const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (tool === "move") { /* ...移动逻辑... */ return; }
 
   setIsDrawing(true);
   const stage = e.target.getStage();
   const pos = stage?.getPointerPosition(); // 获取鼠标在 Stage 上的原始坐标
   if (pos) {
     // 将 Stage 坐标转换为图片原始坐标系下的坐标
     const x = pos.x / (scaleRatio / 100) - imagePosition.x;
     const y = pos.y / (scaleRatio / 100) - imagePosition.y;
 
     if (isPointInImage(x, y)) { // 确保只在图片范围内开始绘制
       setCurrentLine([x, y]); // 初始化当前线条的起始点
     }
   }
 };
 
 const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (!isDrawing || tool === "move") return;
 
   const stage = e.target.getStage();
   const pos = stage?.getPointerPosition();
   if (!pos) return;
 
   const x = pos.x / (scaleRatio / 100) - imagePosition.x;
   const y = pos.y / (scaleRatio / 100) - imagePosition.y;
 
   if (isPointInImage(x, y)) {
     setCurrentLine(prev => [...prev, x, y]); // 将新点追加到当前线条
   }
 };
 
 const handleMouseUp = () => {
   setIsDrawing(false);
   if (tool === "move" || currentLine.length === 0) {
     setCurrentLine([]);
     return;
   }
 
   // 鼠标抬起,一条绘制操作完成
   const ctx = drawingCanvasRef.current!.getContext("2d")!;
   // 再次在离屏 Canvas 上应用当前的绘制操作 (这一步是为了生成快照)
   ctx.globalCompositeOperation = tool === "eraser" ? "destination-out" : "source-over";
   ctx.strokeStyle = tool === "eraser" ? "#000000" : "rgba(114, 46, 209)";
   ctx.lineWidth = tool === "eraser" ? eraserSize : penSize;
   ctx.lineCap = "round";
   ctx.lineJoin = "round";
   ctx.beginPath();
   ctx.moveTo(currentLine[0], currentLine[1]);
   for (let i = 2; i < currentLine.length; i += 2) {
     ctx.lineTo(currentLine[i], currentLine[i + 1]);
   }
   ctx.stroke();
 
   const snapshot = ctx.getImageData(0, 0, width, height); // 获取离屏 Canvas 快照
 
   let newLineEntry;
   if (currentLine.length === 2) { // 单点点击,视为画一个圆点
     newLineEntry = {
       type: "circle",
       points: [currentLine[0], currentLine[1]],
       color: tool === "eraser" ? "#000000" : "rgba(114, 46, 209)",
       width: tool === "eraser" ? eraserSize : penSize
     };
   } else { // 连续拖动,视为画一条线
     newLineEntry = {
       type: tool === "eraser" ? "eraser" : "line",
       points: currentLine,
       color: tool === "eraser" ? "#000000" : "rgba(114, 46, 209)",
       width: tool === "eraser" ? eraserSize : penSize
     };
   }
 
   const newPaintLines = [...paintLines, newLineEntry];
   setPaintLines(newPaintLines);
 
   // 更新历史记录
   const newHistory = [...history.slice(0, historyStep + 1), { paintLines: newPaintLines, maskSnapshot: snapshot }];
   setHistory(newHistory);
   setHistoryStep(newHistory.length - 1);
 
   setCurrentLine([]); // 清空当前线条,为下一次绘制做准备
 };
 
 // Konva Stage 定义,其中包含了离屏 Canvas 的 Image 对象
 // <Stage ... onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} onWheel={handleWheel}>
 //   <Layer>
 //     <Image image={image} x={imagePosition.x * (scaleRatio / 100)} y={imagePosition.y * (scaleRatio / 100)} width={width * (scaleRatio / 100)} height={height * (scaleRatio / 100)} />
 //     {drawingCanvasRef.current && (
 //       <Image image={drawingCanvasRef.current} x={imagePosition.x * (scaleRatio / 100)} y={imagePosition.y * (scaleRatio / 100)} width={width * (scaleRatio / 100)} height={height * (scaleRatio / 100)} listening={false} />
 //     )}
 //   </Layer>
 // </Stage>
  • 坐标转换:由于图片可能被缩放 (scaleRatio) 或移动 (imagePosition),鼠标在 Stage 上的原始坐标需要转换为图片原始尺寸下的坐标。转换公式为: imageCoordX = stageMouseX / (scaleRatio / 100) - imagePosition.x imageCoordY = stageMouseY / (scaleRatio / 100) - imagePosition.y isPointInImage(x, y) 函数用于检查转换后的坐标是否在图片范围内,避免在图片外部绘制。

  • handleMouseDown:当用户按下鼠标且当前工具不是“移动”时,设置 isDrawingtrue,并记录当前鼠标在图片坐标系下的位置作为 currentLine 的起始点。

  • handleMouseMove:如果 isDrawingtrue 且工具不是“移动”,则随着鼠标移动,不断将新的图片坐标系下的点追加到 currentLine 数组中。此时并不会立即更新 paintLines 或重绘离屏 Canvas,以保证拖动过程的流畅性。Konva Stage 上的视觉反馈是实时更新的(如果直接在 Konva Layer 上绘制线条的话),但在这个实现中,由于主要依赖离屏 Canvas,实时预览效果可能需要额外的处理(例如,在 handleMouseMove 中也临时在离屏 Canvas 上绘制 currentLine,但这部分代码未显式提供,通常为了性能,仅在 mouseUp 时最终确认绘制)。不过,由于 paintLines 改变后 useEffect 会重绘离屏 Canvas,而 Konva 的 Image 组件会显示这个 drawingCanvasRef.current,所以用户还是能看到绘制过程。

  • handleMouseUp:当用户松开鼠标时,表示一条绘制操作(画线或画点)完成。

    1. 设置 isDrawingfalse
    2. 如果 currentLine 为空(例如只是点击移动工具后松开),则直接返回。
    3. 在离屏 Canvas 上最终确认绘制:将 currentLine 中的点连接成线(或画一个点),并根据当前是画笔还是橡皮擦,使用相应的 globalCompositeOperation、颜色和线宽,在 drawingCanvasRef.current 上绘制出来。这一步是必要的,因为它确保了历史记录中保存的 maskSnapshot (ImageData) 是包含了当前这次绘制操作之后的状态。
    4. 获取快照:使用 ctx.getImageData()drawingCanvasRef.current 获取当前完整的像素数据,作为本次操作后的画布快照 (snapshot),用于历史记录。
    5. 创建新的绘制记录:根据 currentLine 的长度(如果只有两个坐标点,说明是单击,创建一个 type: "circle" 的记录;否则是 type: "line"type: "eraser" 的记录),包含点坐标、颜色、宽度等信息。
    6. 更新 paintLines:将新的绘制记录追加到 paintLines 数组中。这个状态的改变会触发前面提到的 useEffect,从而使用最新的 paintLines 完整重绘离屏 Canvas。
    7. 更新历史记录:将新的 paintLines 数组和刚才获取的 snapshot 存入 history 数组,并更新 historyStep
    8. 清空 currentLine,为下一次绘制做准备。

Konva Stage 结构 在 JSX 结构中,Konva 的 Stage 组件包裹了一个 LayerLayer 中包含两个 Image 组件:

  1. 第一个 Image 用于显示原始的背景图片 (image={image})。它的位置和尺寸会根据 imagePositionscaleRatio 进行调整。
  2. 第二个 Image 用于显示我们的离屏 Canvas (image={drawingCanvasRef.current})。它与背景图片保持相同的位置和缩放,确保绘制内容能准确叠加在背景图上。listening={false} 属性表示这个 Image 对象不响应鼠标事件,所有鼠标事件都由其父级 Stage 或其他可交互的 Konva 对象处理。

通过这种离屏 Canvas 与 Konva 结合的方式,以及精细的鼠标事件处理和状态管理,组件实现了流畅且功能完备的画笔和橡皮擦功能,同时为后续的撤销/重做操作打下了坚实的基础。

huiling1.gif

3. 图片操作:自由移动与精准缩放

除了核心的绘图功能,一个优秀的图片编辑器还应该允许用户方便地移动和缩放图片,以便更好地观察细节或调整编辑区域。该组件通过监听鼠标事件和滚轮事件,实现了图片的拖拽移动和中心缩放功能。

3.1 图片拖拽移动

图片的拖拽移动功能主要在 tool 状态为 "move" 时激活。相关的事件处理主要在 handleMouseDownhandleMouseMove 中。

 // ... (state variables: imagePosition, lastMousePosition, scaleRatio, tool)
 
 const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (tool === "move") {
     setIsDrawing(true); // 虽然不是绘图,但借用 isDrawing 状态来标记拖拽开始
     const stage = e.target.getStage();
     const pos = stage?.getPointerPosition();
     if (pos) {
       setLastMousePosition({ x: pos.x, y: pos.y }); // 记录当前鼠标按下时的 Stage 坐标
     }
     return;
   }
   // ... (drawing logic)
 };
 
 const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
   if (!isDrawing) return;
 
   const stage = e.target.getStage();
   const pos = stage?.getPointerPosition(); // 获取当前鼠标在 Stage 上的坐标
   if (!pos) return;
 
   if (tool === "move") {
     if (lastMousePosition) {
       // 计算鼠标在 Stage 坐标系下的位移
       const deltaX = pos.x - lastMousePosition.x;
       const deltaY = pos.y - lastMousePosition.y;
 
       // 更新图片位置。注意,imagePosition 是在图片原始(未缩放)坐标系下的偏移量
       // 所以,Stage 上的位移需要除以当前的缩放比例,才能正确应用到 imagePosition 上
       setImagePosition({
         x: imagePosition.x + deltaX / (scaleRatio / 100),
         y: imagePosition.y + deltaY / (scaleRatio / 100)
       });
 
       setLastMousePosition({ x: pos.x, y: pos.y }); // 更新上一次鼠标位置为当前位置
     }
     return;
   }
   // ... (drawing logic)
 };
 
 const handleMouseUp = () => {
   setIsDrawing(false);
   if (tool === "move") {
     setLastMousePosition(null); // 清除上一次鼠标位置
   }
   // ... (drawing logic, including setCurrentLine([]))
 };
  • handleMouseDown:当 tool"move" 时,鼠标按下会设置 isDrawingtrue (这里复用了 isDrawing 状态来表示拖拽操作正在进行),并记录下当前鼠标在 Konva Stage 上的位置 (lastMousePosition)。

  • handleMouseMove:如果 isDrawingtruetool"move",则在鼠标移动时:

    1. 获取当前鼠标在 Stage 上的位置。
    2. lastMousePosition 比较,计算出鼠标在 Stage 坐标系中的水平和垂直位移 (deltaX, deltaY)。
    3. 核心逻辑:更新 imagePositionimagePosition 存储的是图片左上角相对于其“原始”未缩放状态下的容器左上角的偏移量。由于用户在 Stage 上看到的图片是经过缩放的,因此 Stage 上的鼠标位移量需要根据当前的 scaleRatio 进行反向缩放,才能得到在图片原始坐标系下的正确位移量。所以,deltaXdeltaY 都需要除以 (scaleRatio / 100)
    4. 更新 lastMousePosition 为当前鼠标位置,为下一次移动计算做准备。
  • handleMouseUp:鼠标松开时,设置 isDrawingfalse,并清空 lastMousePosition

3.2 图片滚轮缩放(以画布中心为焦点)

图片的缩放功能通过监听 Konva Stage 上的 wheel 事件(即鼠标滚轮事件)来实现。缩放的焦点被设计为当前画布的中心点,这意味着无论图片当前如何移动和缩放,滚轮操作都会使得画布中心点在图片上的对应位置保持不变,从而提供一种自然的缩放体验。

 // ... (state variables: scaleRatio, imagePosition)
 // ... (refs: stageRef)
 
 const handleWheel = (e: KonvaEventObject<WheelEvent>) => {
   e.evt.preventDefault(); // 阻止浏览器默认的滚轮行为(如页面滚动)
 
   let newScale = scaleRatio;
   if (e.evt.deltaY < 0) { // 滚轮向上,放大
     newScale = Math.min(500, scaleRatio + 5); // 限制最大缩放比例为 500%
   } else { // 滚轮向下,缩小
     newScale = Math.max(25, scaleRatio - 5); // 限制最小缩放比例为 25%
   }
 
   const stage = stageRef.current?.getStage();
   if (stage) {
     const stageWidth = stage.width();
     const stageHeight = stage.height();
 
     // 1. 计算当前画布中心点在图片原始坐标系下的位置 (centerX, centerY)
     // stageWidth / 2 是画布中心在 Stage 坐标系下的 x 坐标
     // (stageWidth / 2) / (scaleRatio / 100) 将其转换到缩放前的 Stage 坐标尺度
     // 再减去 imagePosition.x 得到在图片原始坐标系下的 x 坐标
     const centerX = stageWidth / 2 / (scaleRatio / 100) - imagePosition.x;
     const centerY = stageHeight / 2 / (scaleRatio / 100) - imagePosition.y;
 
     // 2. 计算新的 imagePosition,使得缩放后,上述 (centerX, centerY) 这一点
     // 在新的缩放比例下,仍然位于画布中心。
     // 我们希望:stageWidth / 2 / (newScale / 100) - newImagePosition.x = centerX
     // 变形得到:newImagePosition.x = stageWidth / 2 / (newScale / 100) - centerX
     const newImagePosition = {
       x: stageWidth / 2 / (newScale / 100) - centerX,
       y: stageHeight / 2 / (newScale / 100) - centerY
     };
 
     setScaleRatio(newScale);
     setImagePosition(newImagePosition);
   } else {
     // 如果 stage 获取不到,仅更新缩放比例(这种情况理论上不应发生)
     setScaleRatio(newScale);
   }
 };
  • 阻止默认行为e.evt.preventDefault() 用于防止滚轮事件触发浏览器的默认滚动行为。

  • 计算新缩放比例:根据 e.evt.deltaY 的正负(向上滚动 deltaY < 0,向下滚动 deltaY > 0)来增加或减少 scaleRatio。缩放比例被限制在 25% 到 500% 之间。

  • 保持中心点不变的核心逻辑

    1. 获取 Konva Stage 的当前尺寸 (stageWidth, stageHeight)。

    2. 计算不变点:找出当前 Stage 中心点(stageWidth / 2, stageHeight / 2)在图片原始坐标系中所对应的点 (centerX, centerY)。这个计算考虑了当前的 scaleRatioimagePosition

      • stageWidth / 2 / (scaleRatio / 100):这是 Stage 中心点在 “未平移的、但已按 scaleRatio 缩放的图片” 坐标系中的 X 坐标。
      • 减去 imagePosition.x:将其转换到图片自身的原始坐标系中。
    3. 计算新的 imagePosition:当应用新的缩放比例 newScale 后,我们希望之前计算出的 centerX, centerY 这一点,在新的视图下仍然显示在 Stage 的中心。因此,我们需要反向计算出新的 imagePosition

      • stageWidth / 2 / (newScale / 100):这是 Stage 中心点在 “未平移的、但已按 newScale 缩放的图片” 坐标系中的 X 坐标。
      • 用这个值减去 centerX,就得到了新的 imagePosition.x
  • 更新状态:最后,调用 setScaleRatio(newScale)setImagePosition(newImagePosition) 来应用新的缩放比例和图片位置。

此外,组件还提供了 reduceImageSizeaddImageSize 两个辅助函数,它们通过按钮触发,功能与滚轮缩放类似,也是以画布中心为焦点进行固定步长的缩放,并同样更新 scaleRatioimagePosition

 // 减少图片放大比例
 const reduceImageSize = () => {
   if (scaleRatio <= 25) return;
   const newScale = scaleRatio - 5;
   // ... (与 handleWheel 中类似的中心点保持逻辑)
   // ... 更新 scaleRatio 和 imagePosition
 };
 
 // 增加图片放大比例
 const addImageSize = () => {
   if (scaleRatio >= 500) return;
   const newScale = scaleRatio + 5;
   // ... (与 handleWheel 中类似的中心点保持逻辑)
   // ... 更新 scaleRatio 和 imagePosition
 };

通过这些精心设计的事件处理和坐标转换逻辑,用户可以流畅地拖动图片,并以画布中心为焦点进行缩放,极大地提升了图片编辑的操作便利性和用户体验。

huiling5.gif

4. 历史记录:轻松实现撤销与重做

对于任何编辑器而言,撤销 (Undo) 和重做 (Redo) 功能都是不可或缺的,它们给予用户试错和修改的自由。该图片编辑组件实现了一套基于状态快照的历史记录系统,能够准确地回溯和重放用户的绘制操作。

4.1 历史记录的数据结构与状态管理

历史记录的核心是 history 状态数组和 historyStep 状态变量:

 // ... (state variables)
 const [paintLines, setPaintLines] = useState<any[]>([]); // 当前的绘制线条/点数据
 const [history, setHistory] = useState<{ paintLines: any[]; maskSnapshot: ImageData | null }[]>([]); // 历史记录栈
 const [historyStep, setHistoryStep] = useState(0); // 当前在历史记录中的步骤索引
 
 // drawingCanvasRef for getting ImageData snapshots
 const drawingCanvasRef = useRef<HTMLCanvasElement | null>(null);
 // ...
  • history: 这是一个数组,数组中的每个元素都是一个对象,代表一个历史状态。每个历史状态对象包含两个关键属性:

    • paintLines: 该历史步骤完成时,paintLines 数组的完整副本。paintLines 存储了所有的绘制指令(如线条的坐标、颜色、宽度,圆点的坐标、颜色、半径等)。
    • maskSnapshot: 该历史步骤完成时,离屏 Canvas (drawingCanvasRef.current) 的 ImageData 快照。这个快照捕获了当时离屏 Canvas 上包括蒙版和所有已绘制内容的完整像素级状态。
  • historyStep: 一个整数,表示当前用户界面显示的是 history 数组中第 historyStep 个索引对应的状态。当用户进行新的绘制操作时,historyStep 会指向最新的历史记录;当用户执行撤销时,historyStep 会减小;执行重做时,historyStep 会增大。

初始化历史记录: 在组件加载并初始化蒙版和离屏 Canvas 时,会创建历史记录的第一个条目,代表画布的初始状态(通常是只有蒙版的状态,或者一个空白状态)。

 // In the useEffect hook for mask initialization (and also for initial blank snapshot)
 // ... (after drawing initial mask or clearing canvas for initial blank state)
 if (dCtx) { // dCtx is the context of drawingCanvasRef.current
   // ... draw initial content (e.g., mask) onto dCtx ...
   const snap = dCtx.getImageData(0, 0, width, height);
   setHistory([{ paintLines: [], maskSnapshot: snap }]); // Initial history entry
   setHistoryStep(0);
 }

记录新的历史步骤: 每当用户完成一次有效的绘制操作(即在 handleMouseUp 中,当 currentLine 非空时),一个新的历史条目会被创建并添加到 history 数组中。

 // In handleMouseUp, after a drawing operation is completed:
 const handleMouseUp = () => {
   // ... (drawing logic on drawingCanvasRef.current ...)
   // ... (obtaining currentLine data ...)
 
   if (currentLine.length > 0) {
     const ctx = drawingCanvasRef.current!.getContext("2d")!;
     // ... (final draw of currentLine on offscreen canvas for snapshot accuracy)
     const snapshot = ctx.getImageData(0, 0, width, height); // Capture snapshot AFTER current draw
 
     const newLineEntry = { /* ... details of the new line/circle ... */ };
     const newPaintLines = [...paintLines, newLineEntry];
     setPaintLines(newPaintLines); // Update current paintLines
 
     // Create new history entry
     // If undo operations were performed, slice history to discard redo stack
     const newHistory = [       ...history.slice(0, historyStep + 1),       { paintLines: newPaintLines, maskSnapshot: snapshot }     ];
     setHistory(newHistory);
     setHistoryStep(newHistory.length - 1); // Point to the new latest state
 
     setCurrentLine([]);
   }
 };

关键在于 history.slice(0, historyStep + 1):如果在执行新的绘制操作之前,用户已经进行了一些撤销操作(即 historyStep 不是指向 history 数组的最后一个元素),那么所有在 historyStep 之后的“未来”历史(即重做栈)都应该被丢弃。新的绘制操作将成为新的历史终点。

4.2 撤销 (Undo) 功能实现

撤销操作 (handleUndo) 会将当前状态回退到历史记录中的上一个步骤。

 const handleUndo = () => {
   if (historyStep > 0) { // Ensure there is a previous step to undo to
     const newStep = historyStep - 1;
     setHistoryStep(newStep);
 
     const entry = history[newStep]; // Get the historical state entry
 
     // 1. Restore paintLines data
     setPaintLines(entry.paintLines);
 
     // 2. Restore offscreen canvas snapshot
     const ctx = drawingCanvasRef.current!.getContext("2d")!;
     if (entry.maskSnapshot) {
       ctx.putImageData(entry.maskSnapshot, 0, 0);
     }
 
     // 3. Trigger Konva stage redraw to reflect changes
     stageRef.current?.draw(); // Or stageRef.current?.getLayer()?.batchDraw();
   }
 };

0. 检查 historyStep > 0,确保当前不是历史记录的起点。

  1. historyStep 减 1。
  2. history 数组中获取索引为 newStep 的历史条目 entry
  3. 恢复 paintLines:调用 setPaintLines(entry.paintLines),将当前的绘制指令集恢复到该历史步骤的状态。这个更新会触发重绘离屏 Canvas 的 useEffect,但为了更直接和准确地恢复像素状态,我们还会用到快照。
  4. 恢复离屏 Canvas 快照:获取 drawingCanvasRef.current 的 2D 上下文,并使用 ctx.putImageData(entry.maskSnapshot, 0, 0) 将该历史步骤保存的 ImageData 快照直接绘制回离屏 Canvas。这确保了离屏 Canvas 的像素级内容与历史状态完全一致,包括蒙版和所有当时的绘制痕迹。
  5. 触发 Konva 重绘:调用 stageRef.current?.draw() 来刷新 Konva Stage,使其显示更新后的离屏 Canvas 内容。

4.3 重做 (Redo) 功能实现

重做操作 (handleRedo) 允许用户恢复之前被撤销的操作,即前进到历史记录中的下一个步骤。

 const handleRedo = () => {
   if (historyStep < history.length - 1) { // Ensure there is a next step to redo to
     const newStep = historyStep + 1;
     setHistoryStep(newStep);
 
     const entry = history[newStep]; // Get the historical state entry
 
     // 1. Restore paintLines data
     setPaintLines(entry.paintLines);
 
     // 2. Restore offscreen canvas snapshot
     const ctx = drawingCanvasRef.current!.getContext("2d")!;
     if (entry.maskSnapshot) {
       ctx.putImageData(entry.maskSnapshot, 0, 0);
     }
 
     // 3. Trigger Konva stage redraw
     stageRef.current?.draw();
   }
 };

重做逻辑与撤销非常相似:

  1. 检查 historyStep < history.length - 1,确保当前不是历史记录的终点。
  2. historyStep 加 1。
  3. 获取新的 historyStep 对应的历史条目 entry
  4. 恢复 paintLines 和离屏 Canvas 的 maskSnapshot,与撤销操作中的步骤相同。
  5. 触发 Konva Stage 重绘。

通过这种方式,组件有效地管理了操作历史。paintLines 的恢复确保了逻辑上的绘制状态正确(例如,如果后续有基于 paintLines 的分析或导出操作),而 maskSnapshot 的恢复则直接保证了视觉上画布的精确回溯。这种双重恢复机制使得撤销和重做功能既准确又高效。

huiling3.gif

5. 一键清新:画布清除功能

在编辑过程中,用户可能需要重新开始或者清除当前所有的绘制内容和蒙版效果。为此,组件提供了一个 clearCanvas 功能,可以将画布恢复到初始的空白状态(或者说,一个没有绘制痕迹、也没有原始蒙版的状态)。

 // ... (state variables: paintLines, history, historyStep, maskCleared)
 // ... (refs: drawingCanvasRef, stageRef)
 // ... (image dimensions: width, height)
 
 const clearCanvas = () => {
   // 1. Clear paintLines array
   setPaintLines([]);
 
   // 2. Set maskCleared to true, indicating the original mask should also be hidden
   setMaskCleared(true);
 
   // 3. Clear the offscreen canvas (drawingCanvasRef)
   const ctx = drawingCanvasRef.current!.getContext("2d")!;
   ctx.clearRect(0, 0, width, height);
 
   // 4. Create a snapshot of the cleared canvas for history
   const snapshot = ctx.getImageData(0, 0, width, height);
 
   // 5. Reset history to a single entry representing the cleared state
   setHistory([{ paintLines: [], maskSnapshot: snapshot }]);
   setHistoryStep(0);
 
   // 6. Clear the Konva stage (optional, as redrawing with empty drawingCanvasRef will also clear it)
   // stageRef.current?.clear(); // This would remove all children from all layers
 
   // 7. Trigger Konva stage redraw to show the cleared state
   stageRef.current?.draw();
 };

清除画布的步骤详解

  1. 清空绘制数据 (setPaintLines([])) :将存储所有画笔和橡皮擦操作的 paintLines 数组设置为空。这将导致在下一次离屏 Canvas 重绘时(由 useEffect 监听 paintLines 变化触发),不会再绘制任何历史线条或圆点。

  2. 标记蒙版已清除 (setMaskCleared(true))maskCleared 是一个布尔状态,用于控制是否在离屏 Canvas 上绘制初始的蒙版。将其设置为 true 后,在离屏 Canvas 的重绘逻辑中,if (maskCanvasRef.current && !maskCleared) 条件将不再满足,因此初始蒙版也不会被绘制。

  3. 清空离屏 Canvas (ctx.clearRect(0, 0, width, height)) :直接调用离屏 Canvas (drawingCanvasRef.current) 的 2D 上下文的 clearRect 方法,将整个离屏 Canvas 的内容擦除,使其变为完全透明的空白状态。

  4. 创建空白快照 (const snapshot = ctx.getImageData(0, 0, width, height)) :在清空离屏 Canvas 后,立即获取其 ImageData 快照。这个快照代表了画布被彻底清除后的状态。

  5. 重置历史记录

    • setHistory([{ paintLines: [], maskSnapshot: snapshot }]): 将 history 数组重置为只包含一个条目的新数组。这个唯一的条目代表了画布清除后的状态,其 paintLines 为空,maskSnapshot 为刚刚获取的空白快照。
    • setHistoryStep(0): 将历史步骤指针也重置为 0,指向这个新的初始状态。 这样做意味着“清除画布”操作本身会成为历史记录的新起点,之前的撤销/重做栈都会被清除。用户如果想恢复到清除前的状态,需要依赖应用层面的其他机制(如果设计了的话),或者重新加载原始图片和蒙版。
  6. Konva Stage 清理 (可选) :代码中注释掉了 stageRef.current?.clear()Konva.Stage.clear() 方法会移除舞台上所有层中所有子节点。在这个组件的实现中,由于 Konva Image 组件的内容是直接来自 drawingCanvasRef.current,当 drawingCanvasRef.current 被清空并且 paintLines 也为空时,Konva Image 自然会显示为空白。因此,显式调用 stageRef.current?.clear() 可能不是绝对必要的,但如果 Stage 上还有其他不由 drawingCanvasRef 控制的临时图形元素,则可能需要它。

  7. 触发 Konva 重绘 (stageRef.current?.draw()) :最后,调用 Konva Stage 的 draw 方法,使其重新渲染。此时,由于 drawingCanvasRef.current 已经是空白的,Konva Image 组件会显示一个空白的画布,达到了清除的效果。

通过以上步骤,clearCanvas 函数能够有效地将用户的编辑区域恢复到一个干净的状态,同时正确地重置了相关的状态和历史记录,为用户提供了重新开始的便捷途径。

huiling4.gif

6. 保存最终成果:生成与上传蒙版图片

当用户完成所有编辑操作后,最终需要将编辑结果保存下来。在这个组件中,“保存”操作特指根据用户的绘制(涂抹和擦除)生成一个新的蒙版图片,并将其上传到服务器。handleSave 函数负责这一系列复杂的流程。

 // ... (state: uploading, width, height, maskCleared, paintLines)
 // ... (refs: maskCanvasRef -- though its direct use in save logic for drawing is superseded by reloading maskImage)
 // ... (props: jobDomain, maskImage, updateParams, onClose)
 
 const handleSave = async () => {
   setUploading(true); // 开始保存,设置上传状态为 true,可以用于显示加载指示
 
   // 1. 创建一个新的目标 Canvas 用于生成最终的蒙版图片
   const exportCanvas = document.createElement('canvas');
   exportCanvas.width = width;  // 使用原始图片的尺寸
   exportCanvas.height = height;
   const ctx = exportCanvas.getContext('2d');
 
   if (!ctx) {
     setUploading(false);
     toast.warning('无法创建画布用于保存');
     return;
   }
 
   // 2. 设置纯黑背景
   // 最终生成的蒙版通常是二值的(例如黑白),黑色代表背景或未选中区域。
   ctx.fillStyle = '#000000';
   ctx.fillRect(0, 0, width, height);
 
   // 3. 创建一个临时的 Konva Stage 来合成蒙版和用户绘制内容
   // 这样做的好处是可以使用 Konva 的图形对象和层级管理来精确控制绘制顺序和效果,
   // 而不影响主显示画布。
   const tempStage = new Konva.Stage({
     container: document.createElement('div'), // Konva Stage 需要一个容器元素
     width: width,
     height: height
   });
   const tempLayer = new Konva.Layer();
   tempStage.add(tempLayer);
 
   // 异步操作的 Promise 包装,确保所有绘制完成后再导出
   const drawingPromise = new Promise<void>((resolve, reject) => {
     let operationsPending = 0;
 
     const checkCompletion = () => {
       if (operationsPending === 0) {
         resolve();
       }
     };
 
     // 3.1 可选:绘制原始蒙版 (如果未被用户清除)
     // 注意:这里是重新加载原始的 maskImage,而不是使用预处理过的紫色蒙版。
     // 这意味着如果原始蒙版有非纯白区域,它们也会被绘制到这个黑色背景上。
     // 用户后续的白色涂抹会覆盖它,橡皮擦会擦除它。
     if (!maskCleared && maskImage && jobDomain) { // 确保 maskImage 和 jobDomain 有效
       operationsPending++;
       const originalMaskObj = new window.Image();
       originalMaskObj.crossOrigin = 'anonymous';
       originalMaskObj.src = jobDomain + maskImage;
       originalMaskObj.onload = () => {
         const konvaMask = new Konva.Image({
           image: originalMaskObj,
           width: width,
           height: height
         });
         tempLayer.add(konvaMask);
         operationsPending--;
         checkCompletion();
       };
       originalMaskObj.onerror = () => {
         console.error('原始蒙版加载失败 for save');
         operationsPending--;
         checkCompletion(); // 即使失败也继续,后续绘制用户笔迹
       };
     } else {
       // 如果没有原始蒙版或已被清除,直接进入下一步
     }
 
     // 3.2 绘制用户的涂鸦和擦除痕迹
     // 用户的“画笔”操作(在屏幕上显示为紫色)在保存时会被转换为纯白色。
     // 用户的“橡皮擦”操作会使用 'destination-out' 合成模式,实现擦除效果。
     const drawingGroup = new Konva.Group();
     paintLines.forEach(line => {
       let shape;
       const isEraser = line.type === 'eraser' || (line.type === 'circle' && line.color === '#000000');
 
       if (line.type === 'circle') {
         shape = new Konva.Circle({
           x: line.points[0],
           y: line.points[1],
           radius: line.width / 2,
           fill: '#FFFFFF', // 所有用户绘制区域(非橡皮擦)在最终蒙版上为白色
           globalCompositeOperation: isEraser ? 'destination-out' : 'source-over'
         });
       } else { // 'line' or 'eraser'
         shape = new Konva.Line({
           points: line.points,
           stroke: '#FFFFFF', // 所有用户绘制线条(非橡皮擦)在最终蒙版上为白色
           strokeWidth: line.width,
           lineCap: 'round',
           lineJoin: 'round',
           globalCompositeOperation: isEraser ? 'destination-out' : 'source-over'
         });
       }
       drawingGroup.add(shape);
     });
     tempLayer.add(drawingGroup);
 
     // 如果没有异步加载原始蒙版,立即 resolve
     if (operationsPending === 0) {
         resolve();
     }
   });
 
   try {
     await drawingPromise; // 等待所有 Konva 绘制操作完成
     tempStage.batchDraw(); // 确保所有内容都绘制到临时 Stage
 
     // 4. 从临时 Konva Stage 导出图像数据 (Data URL)
     const dataURL = tempStage.toDataURL({ mimeType: 'image/png' }); // 通常保存为 PNG 以支持透明度
 
     // 5. 将 Data URL 转换为 File 对象以便上传
     const byteString = atob(dataURL.split(',')[1]);
     const mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0];
     const ab = new ArrayBuffer(byteString.length);
     const ia = new Uint8Array(ab);
     for (let i = 0; i < byteString.length; i++) {
       ia[i] = byteString.charCodeAt(i);
     }
     const blob = new Blob([ab], { type: mimeString });
     const timestamp = new Date().getTime();
     const fileName = `mask_${timestamp}.png`;
     const fileToUpload = new File([blob], fileName, { type: mimeString });
 
     // 6. 调用上传函数 (uploadImage 是组件内部定义的,它会调用 API)
     // 注意:uploadImage 期望一个 FileList,所以传递 [fileToUpload]
     if (files && files.length > 0) { // This is from the original uploadImage function signature, adapting here
         const file = fileToUpload; // Assuming uploadImage can take a single file or we adapt it
         const formdata = new FormData();
         formdata.append('file', file);
 
         const res = await OssApi.upload(formdata); // OssApi is an external dependency
         if (res.code === 0 && res.data) {
           updateParams('maskImage', res.data.path); // 更新父组件中的蒙版路径
           toast.success('蒙版保存并上传成功!');
         } else {
           toast.warning(res.info || '蒙版上传失败');
         }
     } else { // Fallback if the structure of uploadImage was different
         // This part needs to align with how `uploadImage` is actually defined and used elsewhere.
         // The provided snippet has `uploadImage(files: FileList | null)`
         // So, we'd call it like this:
         await uploadImageInternal([fileToUpload]); // Assuming uploadImageInternal is the refactored version of the logic in the original snippet
     }
 
   } catch (error) {
     toast.warning(`保存失败: ${String(error)}`);
   } finally {
     setUploading(false); // 无论成功或失败,结束上传状态
     onClose(); // 关闭编辑模态框
     tempStage.destroy(); // 清理临时 Konva Stage 资源
   }
 };
 
 // Helper function based on the original snippet's upload logic
 const uploadImageInternal = async (files: FileList | File[]) => { // Made flexible
     try {
       if (files && files.length > 0) {
         const file = files[0];
         if (!file) return;
         const formdata = new FormData();
         formdata.append('file', file);
 
         const res = await OssApi.upload(formdata);
         if (res.code === 0 && res.data) {
           updateParams('maskImage', res.data.path);
           console.log(res.data.domain + res.data.path, 'new maskImage path');
           toast.success('蒙版已更新');
         } else {
           toast.warning(res.info || '上传新蒙版失败');
         }
       }
     } catch (error) {
       toast.warning(String(error));
     }
     // `finally` block with `setUploading(false)` and `onClose()` is in `handleSave`
 };

保存流程解析

  1. 状态初始化setUploading(true) 用于触发 UI 上的加载状态,告知用户操作正在进行中。

  2. 创建导出画布:首先创建一个与原图等大的 HTMLCanvasElement (exportCanvas),并将其背景填充为纯黑色。这是生成蒙版的基础色,通常在二值蒙版中,黑色代表不被选中的区域。

  3. 临时 Konva Stage:为了灵活地将原始蒙版(如果需要)和用户的绘制痕迹合成到最终图片上,代码创建了一个临时的、不可见的 Konva Stage (tempStage) 和一个 Layer (tempLayer)。

    • 绘制原始蒙版(可选) :如果 !maskCleared (用户未清除初始蒙版) 且原始蒙版图片路径 (maskImage) 有效,则会异步加载这个原始蒙版图片,并将其作为一个 Konva.Image 对象添加到 tempLayer。注意,这里加载的是未经预处理的原始蒙版,而不是之前在画布上显示的紫色蒙版。

    • 绘制用户编辑内容:遍历 paintLines 数组,将用户的每一笔画(线条或圆点)在 tempLayer 上重现。关键在于:

      • 所有“画笔”性质的涂抹(在界面上可能是紫色)都会被绘制成纯白色 (#FFFFFF)。
      • 所有“橡皮擦”性质的操作,其对应的 Konva 图形对象的 globalCompositeOperation 会被设置为 destination-out。这使得橡皮擦的轨迹能够“擦除”掉 tempLayer 上已经存在的内容(无论是黑色背景、原始蒙版还是之前绘制的白色笔迹),使得这些区域在最终导出的 PNG 图片中可能表现为透明(如果背景是透明的)或者显露出更底层的颜色(在这个场景中,是黑色背景,所以擦除后是黑色)。最终目标是生成一个主要由黑色和白色构成的蒙版。
  4. 等待绘制并导出:通过 Promise 确保所有(包括异步加载的原始蒙版)绘制操作在 tempStage 上完成后,调用 tempStage.toDataURL({ mimeType: 'image/png' }) 将整个 tempStage 的内容导出为一个 Base64 编码的 PNG 图片数据字符串。

  5. 转换为 File 对象:将 Data URL 字符串转换为一个标准的 File 对象,这是因为文件上传接口通常需要 File 对象或 Blob 对象。

  6. 上传文件:调用 uploadImageInternal (一个根据原代码片段中上传逻辑封装的辅助函数,它内部使用 OssApi.upload),将生成的蒙版 File 对象上传到服务器。上传成功后,会调用 updateParams('maskImage', res.data.path) 来更新父组件中记录的蒙版图片路径,并给出用户提示。

  7. 清理与收尾:在 finally 块中,设置 setUploading(false) 来结束加载状态,调用 onClose() 关闭当前的编辑弹窗,并销毁临时的 Konva Stage (tempStage.destroy())以释放资源。

通过这一系列步骤,handleSave 函数不仅准确地将用户的编辑意图(在黑色背景上用白色标记区域,用橡皮擦调整)转换成了一个新的蒙版图片,还完成了图片的上传和状态更新,形成了一个完整的闭环操作。这种在保存时重新合成图像的策略,确保了最终输出的蒙版是干净且符合预期格式的(例如,特定的背景色,以及将用户友好的显示颜色转换为标准的蒙版颜色)。

image.png

Snipaste_2025-05-18_19-47-06.jpg

代码组织与状态管理:构建可维护的编辑器组件

一个功能复杂的组件,其代码组织和状态管理的优劣直接影响到可维护性和可扩展性。在这个图片编辑组件 (ModalComponent) 中,虽然所有逻辑都集中在一个文件中,但通过 React Hooks 和合理的变量命名,仍然保持了一定的清晰度。我们来分析一下其代码组织和状态管理方面的一些特点和可以探讨的点。

1. 组件结构与 Props

该组件被设计为一个模态框 (ModalComponent),通过 Props 接收外部传入的必要数据和回调函数:

  • imagePath: 原始图片的路径。
  • maskImage: 初始蒙版的路径。
  • width, height: 图片的原始尺寸,这是进行各种坐标计算和画布初始化的基础。
  • jobDomain: 图片和蒙版资源所在的域名或基础路径。
  • updateParams: 一个回调函数,用于在保存新蒙版后,通知父组件更新蒙版图片的路径。
  • onClose: 一个回调函数,用于在操作完成或取消时关闭模态框。

这种接口设计使得组件具有较好的封装性,父组件只需关心输入和输出,无需了解内部复杂的实现细节。

2. 状态管理 (State Management)

组件的核心状态都通过 React useState Hook 进行管理。主要的状体包括:

  • 绘图相关状态

    • paintLines: 存储所有绘制操作(线条、圆点)的数组,是重绘离屏 Canvas 和实现历史记录的关键。
    • currentLine: 存储当前正在绘制的线条的点坐标。
    • isDrawing: 布尔值,标记当前是否处于绘制或拖拽状态。
    • tool: 字符串,表示当前选中的工具(如 "pen", "eraser", "move")。
    • penSize, eraserSize: 数字,分别表示画笔和橡皮擦的粗细。
    • maskCleared: 布尔值,标记初始蒙版是否已被用户清除。
  • 图片变换状态

    • imagePosition: 对象 { x, y },表示图片在画布容器中的偏移量(相对于原始未缩放状态)。
    • scaleRatio: 数字,表示图片的缩放比例(百分比)。
    • lastMousePosition: 对象 { x, y }null,用于图片拖拽时计算位移。
  • 历史记录状态

    • history: 数组,存储每个操作步骤的快照(paintLinesmaskSnapshot)。
    • historyStep: 数字,指向 history 数组中的当前步骤。
  • UI 与交互状态

    • uploading: 布尔值,标记是否正在上传保存的蒙版。
    • stageDimensions: 对象 { width, height },存储 Konva Stage 的实际渲染尺寸,用于响应式布局(尽管其更新逻辑在提供的代码中未完全展示如何动态适应容器变化)。
    • canvasCursorPos: 对象 { x, y }null,用于显示自定义光标或调试鼠标位置(代码中声明了但未见明显使用)。

使用 useState 管理这些状态使得组件的更新能够自动触发 React 的重新渲染,保证了数据与视图的同步。对于更复杂的应用,可能会考虑使用如 Redux, Zustand 或 React Context API 进行更集中的状态管理,但对于单个组件而言,useState 通常足够灵活。

3. 副作用处理 (Side Effects with useEffect)

useEffect Hook 在组件中被广泛用于处理各种副作用:

  • 初始化离屏 Canvas (drawingCanvasRef) :在组件挂载或图片尺寸变化时创建或更新离屏 Canvas 的尺寸。
  • 加载和预处理蒙版 (maskCanvasRef) :当 imagemask 图片加载成功后,进行蒙版的颜色处理并将其存储。
  • 重绘离屏 Canvas:当 paintLinesmaskCleared、图片尺寸或 maskCanvasRef.current 发生变化时,重新在 drawingCanvasRef.current 上绘制所有内容(蒙版 + 用户笔迹)。这是实现视觉更新的核心环节。
  • 初始化历史记录:在蒙版加载或画布首次创建时,生成历史记录的初始条目。
  • 监听图片加载useImage 本身就是处理图片加载副作用的 Hook,组件也通过 useEffect 监听 image 对象的变化来更新 stageDimensions

useEffect 的依赖项数组被精确设置,以确保副作用函数仅在必要时执行,避免不必要的计算和重绘。

4. Refs 的使用

useRef Hook 主要用于:

  • containerRef, sideToolRef, topToolRef: 获取 DOM 元素的引用,用于计算 Konva Stage 的可用尺寸。这体现了与 DOM 的直接交互,以实现动态布局。
  • stageRef: 获取 Konva Stage 实例的引用,用于调用 Stage 的方法(如 draw(), getStage(), toDataURL())。
  • maskCanvasRef, drawingCanvasRef: 持有离屏 Canvas 元素的引用。这些 Canvas 不是由 React 直接渲染到 DOM 中的,而是通过 JavaScript动态创建和操作,useRef 提供了在组件的多次渲染之间持久化这些引用的方式。

5. 代码组织与可读性

  • 函数划分:核心功能如鼠标事件处理 (handleMouseDown, handleMouseMove, handleMouseUp, handleWheel)、历史操作 (handleUndo, handleRedo)、清除 (clearCanvas)、保存 (handleSave) 等都被封装在独立的函数中,提高了代码的模块化程度。
  • 常量与变量命名:变量和函数名大多具有较好的自描述性,有助于理解代码意图。
  • 注释:代码中有一些注释,解释了部分逻辑,但对于复杂的算法(如缩放时的中心点保持、globalCompositeOperation 的运用),更详尽的注释会更有帮助。

可以进一步优化的思考点

  • 自定义 Hooks:一些相关的状态和逻辑(例如,处理图片缩放和移动的逻辑,或者历史记录管理的逻辑)可以被抽取到自定义 Hook 中,使主组件更简洁。
  • 组件拆分:工具栏、画布区域等可以考虑拆分为独立的子组件,各自管理其内部状态和逻辑,通过 Props 和回调与父组件通信。这对于更大型的应用尤为重要。
  • 常量管理:一些魔术数字(如缩放限制 25, 500;画笔颜色 rgba(114, 46, 209))可以定义为常量,提高可维护性。
  • 类型定义:虽然使用了 TypeScript (interface IProps, KonvaEventObject 等),但 paintLines 的类型是 any[],可以定义更精确的类型来描述线条和圆点对象的结构,增强类型安全。
  • 错误处理与用户反馈:代码中使用 sonner (toast) 进行了一些用户反馈,这是好的实践。可以进一步完善错误边界和更细致的错误提示。

总体而言,该组件在 React 的框架下,通过 Hooks 有效地组织了状态和副作用,实现了复杂的图片编辑功能。虽然存在一些可以进一步模块化和精细化的地方,但其核心逻辑清晰,是学习 React 与 Canvas 结合应用的一个很好的实例。

总结与展望:构建更强大的前端图形编辑器

本文深入剖析了一个基于 React 和 Konva.js 实现的高级图片编辑组件。我们从其核心技术栈出发,详细解读了图片与蒙版加载、预处理机制,探讨了如何利用离屏 Canvas 和 Konva.js 实现流畅的画笔与橡皮擦功能,分析了图片拖拽移动和中心缩放的算法,并揭示了基于状态快照的历史记录(撤销/重做)系统的构建方法。此外,我们还研究了画布清除以及最终蒙版生成与上传的完整流程,最后对组件的代码组织和状态管理策略进行了梳理。

通过这个实例,我们可以看到现代前端技术在构建复杂交互式应用方面的强大能力:

  • React 的声明式 UI 与组件化为构建可维护、可复用的用户界面提供了坚实基础,其 Hooks 系统(useState, useEffect, useRef)使得状态管理和副作用处理更为直观和灵活。
  • Konva.js 的专业图形处理能力简化了在 HTML5 Canvas 上的复杂图形操作、事件处理和性能优化,使得开发者可以更专注于实现核心编辑逻辑。
  • 离屏 Canvas 技术在处理频繁绘制操作时,作为一种有效的性能优化手段,能够显著提升用户体验。
  • 精巧的算法设计(如坐标转换、中心点保持缩放、globalCompositeOperation 的妙用)是实现精确、自然交互效果的关键。

主要收获与关键技术点回顾:

  1. 图片与蒙版处理:通过 useImage 加载图片,利用原生 Canvas API 进行像素级操作实现蒙版预处理。
  2. 高效绘图:结合离屏 Canvas 与 Konva Image 对象,实现高性能的画笔和橡皮擦功能,并通过 globalCompositeOperation 控制绘制模式。
  3. 交互式变换:精确的坐标计算实现了图片的自由拖拽和以画布中心为基准的平滑缩放。
  4. 可靠的历史记录:通过存储 paintLines 数据和离屏 Canvas 的 ImageData 快照,构建了稳健的撤销/重做体系。
  5. 结果输出:在保存时,通过临时 Konva Stage 重新合成图像,确保输出蒙版的准确性和格式规范,并结合异步上传流程完成闭环。

未来展望与功能拓展:

尽管该组件已经具备了相当完善的核心功能,但仍有许多可以拓展和优化的方向,使其成为一个更通用的、功能更强大的前端图形编辑器:

  • 更多绘图工具

    • 形状工具:如矩形、圆形、箭头、多边形等。
    • 文本工具:允许用户在图片上添加和编辑文字,支持字体、大小、颜色等设置。
    • 滤镜效果:如模糊、锐化、灰度、亮度/对比度调整等,可以利用 Canvas 的 filter 属性或 WebGL 实现。
  • 高级选择与编辑

    • 选区工具:如矩形选框、套索工具,允许用户选择特定区域进行操作。
    • 图层管理:引入类似 Photoshop 的图层概念,允许用户对不同元素进行独立编辑和层级调整。
  • 性能优化

    • 局部重绘:对于非常大的画布或非常复杂的操作,可以研究更精细的局部重绘策略,而不是每次都完整重绘离屏 Canvas。
    • WebGL 加速:对于某些计算密集型操作(如复杂滤镜、大量粒子效果),可以考虑引入 WebGL 进行硬件加速。
  • 用户体验提升

    • 实时光标预览:根据画笔/橡皮擦大小和形状,动态改变鼠标光标样式。
    • 更丰富的自定义选项:如画笔颜色选择器、透明度控制等。
    • 国际化与主题化:支持多语言,允许自定义编辑器界面风格。
  • 导出与集成

    • 多种导出格式:除了 PNG 蒙版,还可以支持导出为 JPG、SVG 或包含编辑状态的项目文件。
    • 与其他应用集成:提供更友好的 API,方便嵌入到各种内容管理系统、在线协作工具中。
  • 代码架构

    • 进一步模块化:将工具栏、画布、属性面板等拆分为更小的、独立的 React 组件或自定义 Hooks,提高代码的可维护性和可测试性。
    • 状态管理方案:对于更复杂的应用,可以引入 Zustand、Redux Toolkit 等状态管理库,或者更深入地使用 React Context API。

总而言之,前端图形编辑是一个充满挑战和机遇的领域。通过不断学习和实践,结合优秀的前端框架和图形库,我们可以构建出越来越强大、用户体验越来越出色的在线编辑工具。希望本文的解析能为你在这方面的探索提供一些有益的启示和参考。

面试之道——手写call、apply和bind

作者 哆啦美玲
2025年5月18日 18:30

嗨嗨嗨~这里是哆啦美玲分享的知识点,一起来学呀!

这次的文章基于我之前写的this的显式绑定的文章,有不懂的可以倒回去看看哦——搞懂this,如此简单 - 掘金

image.png

callapplybind 都是 JavaScript 中函数的调用方法,它们的作用是改变函数的上下文 (this) 和传递参数,但它们之间有一些不同点。

首先我们看下面这段代码:

let obj = {
    a: 1
}

function foo(x, y) {
    console.log(this.a, x + y);
    return 'hello'
}
console.log(foo(1, 2)); // undefined 3 hello

我们声明了一个对象obj和函数foo,在独立调用foo时,this指向的是全局,所以this.a会返回undefiend。那我们如何实现this指向obj呢?

一、call

1. call的特点

  • call 方法立即调用一个函数,并且可以指定 this 的值,同时传入参数。
  • 参数是按顺序传递的,多个参数使用逗号分隔。
const res = foo.call(obj, 3, 4) // call的this指向foo,foo的this指向obj
console.log(res); // 1 7 hello

2. 手写myCall(context, ...args)

在手写myCall之前我们需要分析:myCall写哪里? image.png 如图的代码结果,foo既是函数也是对象,但是使用对象obj调用call方法会报错:call is not a function,所以myCall方法写在构造函数Function的原型(Function.prototype)。

因为函数也是对象,所以foo.call()会导致call的this指向foo;call的执行会让obj调用foo,让foo的this指向obj;

foo在声明前需要多少参数call并不清楚,所以call可以使用...args的形式接收剩余参数;另外,我们写的foo是有返回值的,在call的调用后会返回foo的结果,所以我们手写call的时候也要写返回值。

我们再看,如果call传入的参数中第一位不是对象,会得到什么? image.png 从图上代码的输出结果可以看出来:call会把传入的参数除去第一位后按顺序交给foo,且第一位必须是对象才能改变this的指向。

根据分析,手写代码如下:

// 手写call
Function.prototype.myCall = function (context, ...args) { // foo.__proto__ == Function.prototype
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window

    const key = Symbol('fn') // 唯一的key
    context[key] = this // this = foo
    const res = context[key](...args) // 隐式绑定 this = context
    delete context[key]
    return res
}
console.log(foo.myCall(obj, 2, 3)); // 1 5 hello

代码第6行 context = context || window 的意思是:如果 context 变量已经有值(即不是 null 或 undefined),那么就使用 context 的值;如果 context 没有值(即为 null 或 undefined),就使用 window 作为默认值。

代码第8-9行是给foo函数创建一个唯一的key值,确保不会修改掉函数内部原本的属性值。

最后为了不修改原对象,要记得把新增的属性删除!

二、apply

1. apply的特点

  • apply方法也立即调用一个函数,并指定 this 的值,同时传入参数。
  • 参数传递方式是将一个数组或类数组对象作为参数列表传入。
const res1 = foo.apply(obj, [3, 4]) // apply的this指向foo, foo的this指向obj
console.log(res1); // 1 7 hello

2. 手写myApply

apply与call的用法是一样的,唯一不同的就是接收的参数不一样,所以只需要修改一点点就可以了,代码如下:

// 手写apply
Function.prototype.myApply = function (context, args) { // foo.__proto__ == Function.prototype
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window

    const key = Symbol('fn')
    context[key] = this // this = foo
    const res = context[key](...args) // 隐式绑定 this = context
    delete context[key]
    return res
}

console.log(foo.myApply(obj, [2, 4])); // 1 6 hello

三、bind

1. bind的特点

  • bind方法并不立即调用函数,而是返回一个新的函数,新的函数会绑定指定的 this 和参数。
  • 这个返回的新函数可以在之后的某个时刻被调用,且新函数也可以接收零散参数。
  • 当新函数被 new 调用时, 返回的是调用 bind 的那个函数的实例对象
const fn = foo.bind(obj, 4)
const res2 = fn(4)
console.log(res2); // 1 8 hello

const f = new fn(4)
console.log(f); // undefined 8 foo {}

从代码中可以看出:bind函数调用时,foo函数在接收参数时会先在bind传入的参数里面按顺序找,如果不够再去找bind返回的新函数f传入的参数找。

另外,在代码的5-6行我们会发现,在new fn()时,本来应该返回一个fn的实例对象fn{},但实际返回的却是foo的实例对象foo{},并且foo中的this指向了全局,所以在new的过程会导致this指向全局,fn()执行返回foo的实例对象。

2. 手写myBind

bind与前面两个方法很不一样,第一需要注意的点就是调用bind后会返回一个新的函数体。

接下来我们看看:如果bind传入的第一个不是参数,新函数会是什么? image.png

从图中的结果可知:foo.bind(123)返回的是一个foo的实例对象,所以this指向的是全局。

另外,在前面我们已经说了如果我们new foo.bind(obj)得到的新函数,也会得到一个foo的实例对象。所以bind返回的函数体不能是箭头函数,因为箭头函数里面没有this,不能被new。

这就需要我们区分new fn()fn():因为new会使函数的this直接指向得到的实例对象,且让实例对象的隐式原型等于构造函数的显式原型,所以我们采用判断this.__proto__ === F.prototype来判断是否被new。

代码如下:

Function.prototype.myBind = function (context, ...args) {
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window
    const self = this // 存储this的值 foo

    return function F(...args2) { 
        if(this.__proto__ === F.prototype){ // 被 new 直接返回 foo 实例,所以这里不能是箭头函数
            return new self(...args, ...args2) // 返回foo 的实例对象
        }else{
            return self.apply(context, [...args, ...args2])  // foo指向obj,foo执行且返回值接收并返回
        }
    }
}

const fo = foo.myBind(obj, 1, 2)
console.log(fo(),'//////'); // 1 3 hello //////

const fun = new fo()
console.log( 'new fo() 得到的结果:', fun); // undefined 3  new fo() 得到的结果:foo {}

好啦,本次知识点分享完毕,家人们下次见!!!

喜欢这次的文章就麻烦点个赞赞啦~谢谢大家!

image.png

tauri2项目动态添加 Sidecar可行性方案(运行时配置)

作者 1024小神
2025年5月18日 18:28

tauri2官方文档:Embedding External Binaries | Tauri

Tauri 的 Sidecar 功能允许你将外部二进制文件(External Binaries)与你的 Tauri 应用程序捆绑在一起,并在运行时调用它们。根据你提供的链接和 Tauri 的文档,以下是关于 Sidecar 路径配置和动态添加的解答:


1. Sidecar 的路径配置方式

在 Tauri 中,Sidecar 二进制文件的路径可以通过以下方式配置:

  • 固定路径(编译时绑定)
    在 tauri.conf.json 中直接指定二进制文件的路径,这些路径会在编译时解析,并打包到最终应用中。例如:

    <pre>
    

    { "tauri": { "bundle": { "sidecar": [ { "path": "/path/to/your/binary", // 可以是绝对路径或相对路径 "name": "my-binary" // 运行时调用的名称 } ] } } }

    <p>这种方式适合那些<strong>已知且不会变动</strong>的二进制文件。</p>
    </li>
    <li>
    <p><strong>环境变量或相对路径</strong><br />
    你可以通过环境变量动态设置路径(需在编译时能解析),例如:</p>
    
    <pre>
    

    "path": "${ENV_VAR_NAME}/subpath/binary"


2. 动态添加 Sidecar(运行时配置)

Tauri 默认不支持在运行时动态从配置文件(如外部的 config.json)添加或删除 Sidecar。原因是:

  1. 安全限制:Sidecar 的路径在应用构建时会被硬编码到打包结果中(尤其是 macOS 的 .app 或 Windows 的安装包)。
  2. 签名验证:某些平台(如 macOS)要求所有二进制文件在打包时签名,动态加载未签名的二进制文件可能导致应用被拒绝运行。

替代方案

如果你需要动态调用外部二进制文件,可以考虑以下方法:

方案 1:通过配置文件调用外部二进制(非 Sidecar)

  • 将二进制文件放在可执行文件同级目录(如 resources/)或用户指定路径。
  • 使用 Tauri 的 Command API 直接调用这些二进制文件(而不是通过 Sidecar):
    import { Command } from '@tauri-apps/api/shell';
    const binaryPath = await loadPathFromConfig(); // 从 config.json 读取路径
    const command = Command.sidecar(binaryPath, [args]);
    const output = await command.execute();
    
    <blockquote>
    <p><strong>注意</strong>:这种方式需要手动处理二进制文件的路径解析和平台兼容性(如 Windows 的&nbsp;<code>.exe</code>&nbsp;后缀)。</p>
    </blockquote>
    </li>
    

方案 2:预置多个 Sidecar,按需启用

  • 在 tauri.conf.json 中预定义所有可能的 Sidecar 二进制文件。
  • 通过应用逻辑决定调用哪一个(例如根据配置文件选择):
    const command = Command.sidecar('binary-name-from-config', [args]);
    

方案 3:动态下载二进制文件

  • 在应用启动时从远程服务器或本地 config.json 中获取二进制文件的 URL 或路径。
  • 下载并保存到用户目录(如 appDataDir),然后用 Command 调用。

关键限制

  • 安全策略:动态加载外部二进制文件可能触发安全警告(尤其是 macOS 的 Gatekeeper 或 Windows Defender)。
  • 打包约束:Sidecar 的路径必须在构建时确定,无法在运行时从完全任意的路径加载。

总结

  • 直接动态添加 Sidecar:不支持,因 Tauri 的设计和平台限制。
  • 推荐替代方案
    使用 Command API 调用外部二进制文件,或预置多个 Sidecar 通过逻辑选择。

如果你的需求是“用户自定义插件式二进制”,可能需要自行实现二进制管理逻辑(如下载、验证、路径解析等)。

从浏览器进程层面理解事件循环

2025年5月18日 17:49

现代浏览器的进程架构

进程之间是独立存在的,因此为了防止浏览器 tab 连续崩坏的情况,如今已经演变成了多进程架构,每个进程都会有个独立的内存空间

一般就是下面这六个进程:

  1. 浏览器主进程(Browser Process)
  • 负责管理浏览器的界面显示(比如导航栏)、用户交互、子进程管理
  • 处理书签、历史记录、下载等功能
  • 协调其他进程
  1. 渲染进程(Renderer Process)
  • 负责网页内容的渲染
  • 每个标签页通常会有自己的渲染进程(沙箱隔离)
  • 包含多个线程:
    • 主线程:处理JavaScript执行、DOM解析、CSS计算等
    • 合成线程:处理图层合成
    • 工作线程:处理Web Workers
    • 光栅化线程:将图形转换为位图
  1. GPU进程(GPU Process)
  • 处理GPU任务,加速图形渲染
  • 负责将渲染进程的输出合成并显示到屏幕上
  1. 网络进程(Network Process)
  • 处理网络请求
  • 实现HTTP/HTTPS协议
  • 管理缓存
  1. 插件进程(Plugin Process)
  • 运行浏览器插件(如Flash)
  • 每个插件通常有自己的进程
  1. 实用程序进程(Utility Process)
  • 处理一些辅助功能,如音频服务、打印等

1.png 在 windows 下,我们可以通过快捷键 Ctrl + Shift + Esc 来打开 windows 的实时进程,其实浏览器也有对应的界面,我们可以通过 Shift + Esc 来打开浏览器的实时进程

2.png

比如我这里开了两个 tab,首先每个 tab 都会存在一个独立的进程,这个进程就是我们说的渲染进程,图中可以看到 第一个 Chrome 图标的那个就是浏览器的主进程,第二个为 GPU 进程,第三个为 网络进程,第四个为 实用程序进程,然后后面几个 service worker 其实就是后台的特殊进程

其实早在 2018年,Chrome 就已经更新了一个名为 站点隔离(Site Isolation)的机制,这意味着不再是简单地一个标签页一个进程,而是按照网站的源(协议 + 域名 + 端口)来分配进程,这么做对于很多用户来讲应该可以很大程度减少 Chrome 内存占用,像是开发过程中你可能 csdn 会有很多 tab 同时存在,不过Chrome 会够根据用户的硬件设备调整不同的进程架构,像是站点隔离在内存受限的设备上就不会生效

实用程序进程这里可以看到是个 storage service ,这就是他处理存储的功能,当我们看 B站 视频时,就会存在一个 audio service,就说明在发挥它的 音频 功能

六个进程中我们了解大概就差不多了,但是渲染进程我们需要单独聊聊,这对前端仔来讲还是非常重要的

因为我们的前端代码均是在这个进程程执行的

渲染进程

渲染进程负责将 HTML,CSS 和 JS 转换为用户可以看到的网页内容和交互

渲染进程核心的职责就是我们熟知的下面五个步骤:

  1. 解析HTML和CSS:将HTML转换为DOM树,将CSS转换为CSSOM树
  2. 执行JavaScript:运行页面中的JavaScript代码
  3. 布局计算:确定每个元素在屏幕上的确切位置和大小
  4. 绘制:将元素绘制到内存中的位图
  5. 合成:将不同的绘制层合成为最终显示的图像

若将渲染进程进行线程拆分,那么它主要是靠三个线程:主线程、合成线程和光栅线程

还有 工作线程(Web Workers),定时器线程,事件触发线程

3.png 我们的前端代码其实就是在 渲染进程中的 主线程 执行的,接下来我们引入事件循环来结合讲解

渲染主线程

渲染主线程负责渲染进程的大部分工作,所以它需要处理许多任务,执行 js 是它,绘制页面又是它,可能发生性能瓶颈,说 js 代码阻塞其实就是因为渲染主线程只有一个,无法同时处理两份属于自己的工作,渲染主线程的主要工作如下:

  • 执行 JS 代码
  • 处理 DOM 操作
  • 处理用户事件(如 Click)
  • 处理计时器回调
  • 处理网络请求回调
  • 执行微任务和宏任务

这个时候其实你可能会很好奇为何渲染主线程要做的东西这么多,也确实容易出现问题,那他为何不分配一些新的线程来做呢,比如专门给一个 js 线程来执行 js 代码,专门一个 dom 线程来处理 dom 操作

其实这个问题会有很多原因,其中我们最容易理解的就是因为 js 可以操作 dom,多个线程同时修改 dom 会导致难以预测的竞态条件;还有就是历史原因,js 最初就是个单线程语言,web 本身就非常向后兼容,改变这个线程模型就会破坏现有网站;再一个就是实际上浏览器已经采取了多线程优化方案,比如 WebWorker 新开了一个线程执行 js,但是这些 js 又不能直接访问 dom

事件循环(event-loop / message-loop)我们已经很熟悉了,但是想要真正理解透彻我们应该将 UI 渲染这一步骤结合进来

事件循环出来的目的也就是因为 js 执行变得复杂,此前单线程的原因并没有考虑这个问题,后来才逐步引入的机制,比如一个 for 循环很多次,执行的过程中某个定时器的回调也到了时间应该如何调度呢,如何调度其实就是事件循环,通俗理解这个东西就是让任务之间进行排队

事件循环在主线程上的工作流程如下:

  1. 执行当前的 JS 调用栈中的所有同步代码
  2. 检查微任务队列(microtask queue),执行所有微任务直到队列清空
  3. 执行一个宏任务(macrotask)
  4. 再次检查微任务队列,执行所有微任务
  5. 如果需要,执行UI渲染
  6. 返回步骤3,继续循环

我们再来复习下常见的宏微任务有哪些:

微任务

  • Promise回调(.then/.catch/.finally)
  • MutationObserver回调
  • queueMicrotask() API
  • 处理优先级较高,在每个宏任务之后立即执行

宏任务

  • Script 标签
  • setTimeout/setInterval回调
  • 用户交互事件(点击、键盘输入等)
  • 网络请求回调(XHR, fetch)
  • MessageChannel
  • requestAnimationFrame
  • I/O操作

也许有人会争 宏任务比 同步先执行,这么说也是对的,因为本身 Script 就是个宏任务

4.png 执行 js 代码过程中,不一定会是当前执行代码所引入的 回调 进入消息队列,也有可能来自浏览器进程监听的用户交互,比如 click 事件,浏览器进程虽然不会执行 js 代码,js 执行是在渲染进程的渲染主线程中,浏览器进程可以监听事件然后放入消息队列,js 再从消息队列拿回调来执行

所以我们现在可以明白浏览器如何处理这些事件的,我们可以临时往消息队列中塞任务,但是执行就不一定是立即执行,因为他需要先等当前的调用栈执行完毕后,再依次检查消息队列中前面的事件是否执行完毕再去执行这个临时加的任务

5.png

event-loop 就是为了解决 js 单线程问题,异步的目的就是为了不让页面卡死,因为渲染这个关键步骤也是在 event-loop 之中或者说由渲染主线程负责

消息队列的优先级

消息队列可以理解为任务队列,我们在面试时常常会说宏任务队列,微任务队列,但是大家也清楚,其实宏任务这个概念官方已经抛弃了,此前宏任务就是 macrotask,现在官方换成了 task ,就是一个 任务,微任务依旧为 microtask

我也不清楚为何要换个名字,其实 task 和 macrotask 没有本质区别,因此我们完全可以沿用这个宏任务概念

对于 js 而言,任务是没有优先级的,它只管从 消息队列 依次去获取任务执行,这个任务就是回调,其实每个任务都会被浏览器包装成一个对象或者说是一个块

但是消息队列是具有优先级的,task queue 其实就是宏任务队列,因为浏览器的复杂程度越来越高,宏任务队列其实会被划分成不同的队列,有可能定时器会专门有一个定时器的队列,然后事件回调会有一个事件回调的队列,ui 渲染会专门有一个 渲染队列,这些队列具有优先级,另外,每种任务其实会专门放到对应的任务队列中,但是其实也可以放到其他不同的任务队列,比如监听事件的回调可以放到定时器队列,但是这样做后,它就不能放到其余的队列中了

这是 w3c 给出的规定,同一个类型必须在同一个队列,也可以分属不同的队列

宏任务细分下去的任务队列优先级其实不需要我们关注,我们只需要清楚微任务队列的优先级永远是最高的

不过对于宏任务队列而言,一般用户交互的事件队列是最高级的,比如点击,键盘,鼠标,其次再是 ui 渲染,这个也很好理解,为用户体验着想,总不可能用户点击后这个任务还有延迟执行吧,后面的优先级详细内容请看下面图示

6.png

有个地方需要特别留意,微任务优先级最高,这就意味着 高于 了 ui 渲染这个队列

另外,这里也可以看到定时器哪怕在宏任务队列中都是优先级较低的存在,就算不管微任务队列这个最高优先级,他的执行都是靠后的,因此他的计时肯定是不准的,因为他的回调需要等待前面的任务执行完毕才能继续执行,另外在 w3c 中有个 规定,定时器嵌套(nesting)层级超过了 5 层,后面的定时器就会增加 4 ms 的误差,其实浏览器的定时器实现本身就是调用的操作系统,操作系统本身的计时也是存在误差的,这点无法避免,真正准时的永远是原子钟

最后

总结下本文主要内容

浏览器有很多进程,但是对于前端仔来讲主要关注渲染进程,这个进程其实主要发挥作用的又是渲染主线程,由于前端代码都是在这个进程运行的,因此可以说 js 是个单线程语言,渲染其实也是个 task queue 类型,他的优先级还是比较高的,因此在一轮 event-loop 结束后,就是 ui 渲染线程开始执行

2025 年应该怎样初始化 Angular 项目

2025年5月18日 17:12

Angular 在近两年发布了多个版本,不断更新和改进。如果你是一个 Angular 开发者,你可能会发现 Angular 的生态系统变得越来越庞大,新的工具和技术层出不穷。那么,如果你要开始一个新的 Angular 项目,你应该使用哪些工具和技术呢?

初始化 Angular 项目

首先推荐使用 pnpm。

使用 pnpm 创建一个新的 Angular 项目:

pnpm create @angular@latest --experimental-zoneless --ssr false --style scss [yourProjectName]

@angular/create 也是 Angular 官方提供的脚手架工具,不需要全局安装 Angular CLI,就可以创建 Angular 应用,并且支持所有 ng new 的选项和功能。

  • --experimental-zoneless 参数表示使用 zoneless 模式
  • --ssr false 表示不启用服务端渲染
  • --style scss 表示使用 SCSS 作为样式预处理器。

创建完成之后在 angular.json 中添加 "changeDetection": "OnPush",具体路径位于 projects.[yourProjectName].schematics.@schematics/angular:component

UI

接下来是选择 UI 组件库。Angular 的 UI 组件库有很多,最常用的有:

这里推荐使用 Angular Material,因为它是 Angular 官方提供的 UI 组件库,和 Angular 的生态系统兼容性最好,文档也很完善。

pnpm ng add @angular/material

除了组件库之外,我们还希望配置 Tailwind CSS 来编写样式。Tailwind CSS 是一个功能类优先的 CSS 框架,允许我们使用类名来构建样式,在 Angular 中使用也非常简单。

pnpm install tailwindcss @tailwindcss/postcss postcss

在项目根目录下创建 .postcssrc.json 文件,内容如下:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}

添加 @import "tailwindcss";src/styles.scss 文件中。

Linting

代码规范和格式化是团队开发中非常重要的一部分。我们使用 angular-eslintprettierhuskylint-staged 来实现代码规范和格式化。

首先安装 angular-eslint

pnpm ng add angular-eslint

angular-eslint 会自动为我们配置 ESLint。

接下来安装 prettierhuskylint-staged

pnpm i -D husky prettier lint-staged

简单说一下这几个工具的作用:

  • prettier:代码格式化工具,可以自动格式化代码,在代码提交之前通过 husky 钩子自动运行。
  • husky:Git 钩子工具,可以在 Git 提交和推送时执行一些脚本,比如运行 ESLint 和 Prettier。
  • lint-staged:可以在 Git 提交时只检查和格式化暂存区的文件,避免检查和格式化所有文件,提高效率。

初始化 husky

pnpm husky init

这会在项目根目录下创建一个 .husky 文件夹,并在其中创建一个 pre-commit 钩子文件。

创建 .lintstagedrc 文件,内容如下:

{
  "*.{js,ts,json,html,scss,css,md}": ["prettier --write"]
}

.husky/pre-commit 文件中添加以下内容:

pnpm ng lint
pnpm ng test --watch=false
pnpm lint-staged --allow-empty

这样在提交代码时会自动运行 ESLint 和 Prettier,并且 Prettier 只格式化暂存区的文件。

一文解决Babel 插件

2025年5月18日 17:27

Babel 是一个强大的 JavaScript 编译器,它允许开发者在项目中使用最新的 JavaScript 特性,并将其转换为向后兼容的版本,以便在当前和旧版浏览器或环境中运行。Babel 的核心功能之一就是其插件化的架构,开发者可以编写自定义插件来扩展 Babel 的功能,实现各种代码转换需求。

Babel 的工作流程

Babel 的转换过程主要分为三个阶段:

  1. 解析 (Parse) :

    • 输入: 源代码字符串。
    • 过程: Babel 使用解析器(默认为 @babel/parser,它是 babylon 的一个分支,而 babylon 又基于 acorn)将源代码字符串转换成一种叫做抽象语法树(Abstract Syntax Tree, AST)的中间表示形式。AST 是一种树形结构,它以结构化的方式表示代码的语法。树上的每个节点都代表代码中的一个构造,例如变量声明、函数调用、表达式等。
    • 输出: AST 对象。
  2. 转换 (Transform) :

    • 输入: 上一阶段生成的 AST。
    • 过程: 这是 Babel 插件工作的核心阶段。Babel 会遍历 AST,并在遍历过程中调用注册的插件。插件可以检查、修改、添加或删除 AST 节点,从而改变代码的结构和行为。多个插件会按照它们在 Babel 配置中声明的顺序依次执行。
    • 输出: 经过插件处理后的新的 AST。
  3. 生成 (Generate) :

    • 输入: 转换后的 AST。
    • 过程: Babel 使用代码生成器(默认为 @babel/generator)将修改后的 AST 转换回 JavaScript 代码字符串。在这个过程中,还可以生成 Source Map,用于在调试时将转换后的代码映射回原始代码。
    • 输出: 转换后的 JavaScript 代码字符串和可选的 Source Map。

你可以使用 AST Explorer 这个在线工具来查看不同代码片段对应的 AST 结构,这对于理解和编写 Babel 插件非常有帮助。

Babel 插件拿到的是什么,输出的是什么?

  • 拿到的是 (输入) :
    Babel 插件主要通过访问者模式 (Visitor Pattern) 来操作 AST。当 Babel 遍历 AST 时,如果遇到特定类型的节点,并且有插件注册了对该类型节点的访问者函数,那么这个函数就会被调用。
    访问者函数会接收两个主要的参数:

    1. path (类型为 NodePath): 这是一个非常重要的对象,它代表了 AST 中两个节点之间的链接。path 对象包含了当前节点 (path.node) 的信息,以及该节点在 AST 中的位置、作用域、父节点等上下文信息。更重要的是,path 对象提供了大量用于操作 AST 的方法,例如替换节点 (replaceWith)、删除节点 (remove)、在节点前后插入新节点 (insertBefore, insertAfter) 等。
    2. state: 这是一个可选的状态对象,它可以在遍历过程中传递数据。通常,插件的选项(通过 Babel 配置传入)会挂载到 state.opts 上。state 还可以用来在插件的不同访问者函数之间共享信息,或者在插件的 prepost 函数中使用。
  • 输出的是 (效果) :
    Babel 插件本身通常不直接“输出”一个值(除非是插件工厂函数返回插件对象)。它们通过修改传入的 path 对象所代表的 AST 结构来产生效果。这些修改会直接作用于 Babel 正在处理的 AST。当所有插件都执行完毕后,最终修改过的 AST 会被传递给代码生成阶段。

    所以,插件的“输出”是对 AST 的副作用修改

Babel 插件的核心 API 和概念

  1. 插件结构:
    一个 Babel 插件本质上是一个 JavaScript 函数,这个函数接收 Babel 的核心对象(通常命名为 babelapi)作为参数,并返回一个包含 visitor 对象的对象。

    export default function (babel) {
      // 可以通过 babel.types 或 api.types 访问 @babel/types
      const { types: t } = babel;
    
      return {
        // 可选的插件名称,用于调试和错误信息
        name: "my-custom-plugin",
    
        // 可选的 pre(state) 函数,在遍历 AST 之前执行
        pre(file) {
          // console.log("Plugin pre-execution for file:", file.opts.filename);
          // 可以在这里初始化一些插件级别的状态
          this.somePluginState = new Map();
        },
    
        // visitor 对象是插件的核心
        visitor: {
          // 键是 AST 节点的类型 (例如 'Identifier', 'BinaryExpression', 'FunctionDeclaration')
          // 值是处理该类型节点的函数或对象
          Identifier(path, state) {
            // 当 Babel 遍历到 Identifier 节点时,此函数会被调用
            // path: NodePath 对象,代表当前 Identifier 节点及其上下文
            // state: 插件的状态对象,包含插件选项 (state.opts)
            // console.log("Visiting Identifier:", path.node.name);
          },
    
          // 对于某些节点类型,可以提供 enter 和 exit 方法
          FunctionDeclaration: {
            enter(path, state) {
              // 在进入 FunctionDeclaration 节点时调用
              // console.log("Entering FunctionDeclaration:", path.node.id.name);
            },
            exit(path, state) {
              // 在退出 FunctionDeclaration 节点时调用 (所有子节点都已访问完毕)
              // console.log("Exiting FunctionDeclaration:", path.node.id.name);
            }
          },
    
          // 还可以使用 | 分隔多个节点类型,用同一个函数处理
          "BinaryExpression|LogicalExpression"(path, state) {
            // 处理二元表达式或逻辑表达式
          }
        },
    
        // 可选的 post(state) 函数,在遍历 AST 之后执行
        post(file) {
          // console.log("Plugin post-execution for file:", file.opts.filename);
          // 可以在这里进行一些清理工作
          // console.log("Plugin state:", this.somePluginState);
          // this.somePluginState.clear();
        },
    
        // 可选:如果插件需要处理继承的 visitor (不常用)
        // inherits: require("@babel/plugin-syntax-jsx"), // 例如
      };
    }
    
  2. visitor 对象:
    这是插件的核心。它的键是 AST 节点的类型名称(符合 ESTree 规范,Babel 在此基础上有所扩展,例如 JSX 相关的节点类型)。当 Babel 的 traverse 模块遍历 AST 时,遇到匹配类型的节点,就会调用相应的访问者函数。

  3. path (NodePath) 对象:
    NodePath 是理解 Babel 插件的关键。它不仅仅是对 AST 节点的简单包装,更提供了节点间的关系以及操作 AST 的丰富 API。

    • path.node: 当前访问的 AST 节点。

    • path.parent: 父 AST 节点。

    • path.parentPath: 父节点的 NodePath 对象。

    • path.scope: 当前节点所处的作用域信息,可以用来查找变量绑定、检查变量是否被引用等。

    • path.type: 当前节点的类型 (字符串,如 "Identifier")。

    • path.key: 当前节点在其父节点属性中的键名。

    • path.listKey: 如果当前节点是父节点某个数组属性的一员,此为该数组属性的键名。

    • path.inList: 布尔值,表示当前节点是否是列表的一部分。

    • path.get(key): 获取子路径。

    • path.isNodeType(type): 检查节点类型,如 path.isIdentifier()

    • path.findParent(callback): 向上查找符合条件的父路径。

    • 常用操作方法:

      • path.replaceWith(newNode): 用一个新节点替换当前节点。
      • path.replaceWithMultiple(nodesArray): 用多个新节点替换当前节点。
      • path.insertBefore(nodes): 在当前节点前插入一个或多个节点。
      • path.insertAfter(nodes): 在当前节点后插入一个或多个节点。
      • path.remove(): 删除当前节点。
      • path.skip(): 跳过当前节点的子节点的遍历。
      • path.stop(): 停止整个 AST 遍历。
  4. state 对象:

    • state.opts: 访问插件配置中传递的选项。例如,如果在 .babelrc.js 中配置插件:

      // .babelrc.js
      module.exports = {
        plugins: [
          ["./path/to/my-plugin.js", { option1: true, option2: "value" }]
        ]
      };
      

      在插件中可以通过 state.opts.option1state.opts.option2 来访问这些值。

    • state.file: 表示当前正在处理的文件,包含文件名等信息。

    • state.cwd: 当前工作目录。

    • 插件可以在 pre 函数中向 this (插件实例) 添加属性,这些属性在 visitorpost 函数中可以通过 this 访问,也可以通过 state 传递(但不推荐直接修改 state 对象本身,而是使用 this 来存储插件实例的状态)。

  5. @babel/types (通常别名为 ttypes) :
    这个模块提供了大量的工具函数,用于:

    • 创建 AST 节点: 例如 t.identifier("myVar") 创建一个标识符节点,t.binaryExpression("+", leftNode, rightNode) 创建一个二元表达式节点。
    • 检查节点类型: 例如 t.isIdentifier(node)t.isStringLiteral(node, { value: "hello" })
    • 断言节点类型: 例如 t.assertIdentifier(node),如果节点不是指定类型则抛出错误。
    • 其他辅助函数。

    在插件开头通常这样引入:

    export default function ({ types: t }) {
      // 现在可以使用 t.identifier, t.isBinaryExpression 等
      return {
        visitor: { /* ... */ }
      };
    }
    
  6. @babel/template:
    当需要创建复杂的 AST 节点结构时,手动使用 @babel/types 逐个创建节点会非常繁琐且容易出错。@babel/template 允许你用类似代码模板的字符串来生成 AST。

    • template(codeString, options): 返回一个函数,调用此函数并传入占位符的实际 AST 节点,即可生成对应的 AST 结构。
    import template from "@babel/template";
    import { types as t } from "@babel/core"; // 或者从插件参数中获取 types
    
    const buildTryCatch = template(`
      try {
        %%BODY%%
      } catch (%%ERROR_PARAM%%) {
        %%CATCH_HANDLER%%
      }
    `);
    
    // 在 visitor 中使用
    // const tryCatchAst = buildTryCatch({
    //   BODY: path.node.body, // 一个 BlockStatement
    //   ERROR_PARAM: t.identifier("e"),
    //   CATCH_HANDLER: t.blockStatement([
    //     t.expressionStatement(
    //       t.callExpression(
    //         t.memberExpression(t.identifier("console"), t.identifier("error")),
    //         [t.identifier("e")]
    //       )
    //     )
    //   ])
    // });
    // path.replaceWith(tryCatchAst);
    

    占位符可以是 %%NAME%% (大写,用于替换为 AST 节点) 或 $$NAME$$ (用于替换为字符串,生成标识符)。

详细代码讲解

下面我们将通过几个示例插件来详细讲解如何编写 Babel 插件。

准备工作

为了运行和测试这些插件,你需要安装 @babel/core。如果你想从命令行运行,还需要 @babel/cli

npm install --save-dev @babel/core
# 或者
yarn add --dev @babel/core

你可以创建一个 transform.js 文件来测试插件:

// transform.js
const babel = require('@babel/core');

function transformCode(code, plugins, presets = []) {
  const result = babel.transformSync(code, {
    plugins: plugins,
    presets: presets,
    configFile: false, // 忽略项目中的 babel 配置文件,以便独立测试
    babelrc: false,    // 同上
  });
  return result.code;
}

module.exports = { transformCode };

示例插件 1: 将 var 声明替换为 let

这个插件会遍历代码,找到所有的 var 变量声明,并将它们改为 let

// plugins/var-to-let-plugin.js

/**
 * Babel 插件:将 var 声明转换为 let 声明。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块,用于 AST节点的 TypeScript 定义和构建函数。
 * @returns {Object} Babel 插件对象,包含 visitor。
 */
export default function ({ types: t }) {
  // 打印插件加载信息,有助于调试
  // console.log("var-to-let-plugin loaded");

  return {
    name: "var-to-let", // 插件名称,可选,但推荐

    visitor: {
      /**
       * 访问 VariableDeclaration (变量声明) 节点。
       * 例如:var a = 1; const b = 2; let c = 3;
       *
       * @param {NodePath} path - 当前 VariableDeclaration 节点的路径对象。
       * @param {Object} state - 插件状态对象,包含插件选项 (state.opts) 等。
       */
      VariableDeclaration(path, state) {
        // path.node 是当前的 AST 节点
        // console.log("Visiting VariableDeclaration:", path.node.kind);

        // 检查当前变量声明的类型是否为 'var'
        if (path.node.kind === "var") {
          // console.log(`Found 'var' declaration at line ${path.node.loc.start.line}, column ${path.node.loc.start.column}`);
          // console.log("Original AST node:", JSON.stringify(path.node, null, 2));

          // 直接修改节点的 kind 属性
          // 这是一个简单的修改,对于更复杂的转换,可能需要创建新节点并替换
          path.node.kind = "let";

          // console.log(`Changed 'var' to 'let'`);
          // console.log("Modified AST node:", JSON.stringify(path.node, null, 2));

          // 注意:这种直接修改是有效的,因为 Babel 的遍历器允许在遍历过程中修改 AST。
          // 修改后,后续的插件或代码生成阶段将使用这个修改后的节点。

          // 如果需要更复杂的操作,例如基于某些条件决定是否转换,
          // 或者转换成完全不同的结构,就需要使用 path.replaceWith() 等方法。

          // 示例:如果插件有选项控制是否转换
          // if (state.opts && state.opts.enableVarToLet === false) {
          //   console.log("var-to-let conversion is disabled by plugin options.");
          //   path.node.kind = "var"; // 恢复,或者一开始就不修改
          //   return; // 提前退出当前节点的处理
          // }
        }

        // 演示如何访问插件选项 (如果配置了)
        // if (state.opts.myCustomOption) {
        //   console.log("Plugin option myCustomOption:", state.opts.myCustomOption);
        // }
      },

      // 你可以添加其他访问者来处理不同类型的节点
      // 例如,记录所有函数名称
      // FunctionDeclaration(path) {
      //   if (path.node.id) {
      //     console.log("Found function:", path.node.id.name);
      //   }
      // }
    },

    // pre 和 post 函数示例
    pre(file) {
      // console.log(`[var-to-let-plugin] Starting transformation for: ${file.opts.filename || 'unknown file'}`);
      // this.declarationsChanged = 0; // 初始化插件实例的状态
    },

    post(file) {
      // console.log(`[var-to-let-plugin] Finished transformation for: ${file.opts.filename || 'unknown file'}`);
      // if (this.declarationsChanged > 0) {
      //   console.log(`[var-to-let-plugin] Total var declarations changed to let: ${this.declarationsChanged}`);
      // }
      // delete this.declarationsChanged; // 清理状态
    }
  };
}

// 为了在 Node.js 环境中直接 require 这个文件 (如果使用 ES Modules 语法 export default)
// 如果你的测试环境或 Babel 配置期望 CommonJS 模块,可以这样做:
// module.exports = function(babel) { /* ... plugin code ... */ };
// 或者在 package.json 中设置 "type": "module"

测试 var-to-let-plugin.js:

// test-var-to-let.js
const { transformCode } = require('./transform'); // 假设 transform.js 在同级目录
const varToLetPlugin = require('./plugins/var-to-let-plugin').default; // 注意 .default

const code = `
  var x = 10;
  function foo() {
    var y = 20;
    if (true) {
      var z = 30; // var 会被提升
    }
    console.log(y, z);
  }
  var a = 1, b = 2;
  const c = 3; // const 不应被修改
  let d = 4;   // let 不应被修改
`;

console.log("Original Code:\n", code);

const transformedCode = transformCode(code, [
  [varToLetPlugin, { /* 插件选项,这里为空 */ }]
]);

console.log("\nTransformed Code:\n", transformedCode);

/*
预期输出:
Original Code:
... (原始代码) ...

Transformed Code:
let x = 10;
function foo() {
  let y = 20;
  if (true) {
    let z = 30; // var 会被提升
  }
  console.log(y, z);
}
let a = 1, b = 2;
const c = 3; // const 不应被修改
let d = 4;   // let 不应被修改
*/

运行 node test-var-to-let.js 查看结果。

示例插件 2: 移除所有的 console.log 语句

这个插件会查找所有 console.log(...) 这样的调用表达式,并将它们从代码中移除。

// plugins/remove-console-log-plugin.js

/**
 * Babel 插件:移除所有的 console.log 调用。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("remove-console-log-plugin loaded");

  return {
    name: "remove-console-log",

    visitor: {
      /**
       * 访问 CallExpression (函数调用表达式) 节点。
       * 例如:func(a, b), obj.method(), console.log("hello")
       *
       * @param {NodePath} path - 当前 CallExpression 节点的路径对象。
       * @param {Object} state - 插件状态对象。
       */
      CallExpression(path, state) {
        const node = path.node;

        // 检查调用者 (callee) 是否是 console.log
        // console.log 的 AST 结构通常是:
        // {
        //   type: "CallExpression",
        //   callee: {
        //     type: "MemberExpression",
        //     object: { type: "Identifier", name: "console" },
        //     property: { type: "Identifier", name: "log" },
        //     computed: false
        //   },
        //   arguments: [ ... ]
        // }

        const callee = node.callee;

        // 方式一:直接检查属性 (不够健壮,如果 console 被重命名或赋值给其他变量则无效)
        // if (
        //   t.isMemberExpression(callee) &&
        //   t.isIdentifier(callee.object, { name: "console" }) &&
        //   t.isIdentifier(callee.property, { name: "log" })
        // ) {
        //   console.log(`Found console.log call at line ${node.loc.start.line}. Removing it.`);
        //   path.remove(); // 从 AST 中移除当前 CallExpression 节点
        // }

        // 方式二:使用 path.matchesPattern (更简洁,但要注意模式的精确性)
        // `path.matchesPattern("console.log")` 可以匹配 console.log()
        // `path.matchesPattern("console.error")` 可以匹配 console.error()
        if (path.get("callee").matchesPattern("console.log")) {
          // console.log(`Found console.log call via matchesPattern at line ${node.loc.start.line}. Removing it.`);
          // console.log("Original AST node for CallExpression:", JSON.stringify(node, null, 2));

          // 检查插件选项,例如,只有在特定模式下才移除
          const removeIfOnlySpecificArgument = state.opts.removeIfArgumentIs;
          if (removeIfOnlySpecificArgument) {
            if (node.arguments.length === 1 && t.isStringLiteral(node.arguments[0], { value: removeIfOnlySpecificArgument })) {
              // console.log(`Removing console.log with specific argument: "${removeIfOnlySpecificArgument}"`);
              path.remove();
            } else {
              // console.log(`Skipping console.log removal, argument does not match "${removeIfOnlySpecificArgument}".`);
            }
          } else {
            // 默认移除所有 console.log
            path.remove();
          }
        }

        // 也可以移除其他 console 方法,例如 console.error, console.warn
        // else if (path.get("callee").matchesPattern("console.error")) {
        //   console.log(`Found console.error call at line ${node.loc.start.line}. Removing it.`);
        //   path.remove();
        // }

        // 进阶:处理 console 被解构或重命名的情况
        // const calleePath = path.get("callee");
        // if (calleePath.isMemberExpression()) {
        //   const objectPath = calleePath.get("object");
        //   const propertyName = calleePath.node.property.name;
        //
        //   if (propertyName === 'log' || propertyName === 'warn' || propertyName === 'error') {
        //     const binding = objectPath.scope.getBinding(objectPath.node.name);
        //     // 检查 objectPath.node.name (例如 'console') 是否确实指向全局的 console 对象
        //     // 这比较复杂,因为 console 可能被局部变量覆盖
        //     // 一个简单(但不完全可靠)的检查是看它是否是全局绑定
        //     if (objectPath.isIdentifier({ name: "console" }) && (!binding || binding.path.scope.isGlobal)) {
        //       console.log(`Found console.${propertyName} call. Removing it.`);
        //       path.remove();
        //     }
        //   }
        // }
      },
    },
  };
}

测试 remove-console-log-plugin.js:

// test-remove-console-log.js
const { transformCode } = require('./transform');
const removeConsoleLogPlugin = require('./plugins/remove-console-log-plugin').default;

const code = `
  function greet(name) {
    console.log("Entering greet function"); // 将被移除
    const message = "Hello, " + name;
    console.log("Message:", message);       // 将被移除
    if (name === "error") {
      console.error("Error case detected!"); // 如果插件也处理 error,则移除
    }
    return message;
  }

  console.log("Script started"); // 将被移除
  greet("World");
  console.log("Script finished"); // 将被移除

  const myLogger = console;
  myLogger.log("Logged with myLogger"); // 这个默认不会被简单模式移除

  console.log("debug"); // 用于测试选项
`;

console.log("Original Code:\n", code);

// 测试1: 移除所有 console.log
const transformedCode1 = transformCode(code, [
  [removeConsoleLogPlugin]
]);
console.log("\nTransformed Code (all console.log removed):\n", transformedCode1);

// 测试2: 只移除 console.log("debug")
const transformedCode2 = transformCode(code, [
  [removeConsoleLogPlugin, { removeIfArgumentIs: "debug" }]
]);
console.log("\nTransformed Code (only console.log('debug') removed):\n", transformedCode2);


/*
预期输出 (transformedCode1):
function greet(name) {
  const message = "Hello, " + name;
  if (name === "error") {
    console.error("Error case detected!"); // 假设插件只移除 console.log
  }
  return message;
}
greet("World");

const myLogger = console;
myLogger.log("Logged with myLogger");

预期输出 (transformedCode2):
function greet(name) {
  console.log("Entering greet function");
  const message = "Hello, " + name;
  console.log("Message:", message);
  if (name === "error") {
    console.error("Error case detected!");
  }
  return message;
}
console.log("Script started");
greet("World");
console.log("Script finished");

const myLogger = console;
myLogger.log("Logged with myLogger");
*/

示例插件 3: 自动为函数体包裹 try...catch

这个插件会找到所有的函数声明和函数表达式,并将它们的主体代码块包裹在一个 try...catch 语句中,用于统一的错误处理。这是一个更复杂的例子,涉及到创建新的 AST 节点。

// plugins/auto-try-catch-plugin.js
import template from "@babel/template"; // 需要 npm install --save-dev @babel/template

/**
 * Babel 插件:自动为函数体包裹 try...catch 语句。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("auto-try-catch-plugin loaded");

  // 使用 @babel/template 来构建 try...catch 结构
  // %%BODY%% 是一个占位符,将被实际的函数体替换
  // %%ERROR_IDENTIFIER%% 是错误对象的标识符
  // %%CATCH_HANDLER_BODY%% 是 catch 块内部的语句
  const buildTryCatch = template.statements(`
    try {
      %%BODY%%
    } catch (%%ERROR_IDENTIFIER%%) {
      %%CATCH_HANDLER_BODY%%
    }
  `);

  // 默认的 catch 处理逻辑:console.error(e)
  const defaultCatchHandler = template.statement(`
    console.error(%%ERROR_IDENTIFIER%%);
  `);

  // 如果希望 catch 块更复杂,可以定义更复杂的模板或手动构建
  // const complexCatchHandler = template.statements(`
  //   console.error("An error occurred in function:", %%FUNCTION_NAME%%);
  //   console.error(%%ERROR_IDENTIFIER%%.message);
  //   reportErrorToServer(%%ERROR_IDENTIFIER%%);
  // `);


  return {
    name: "auto-try-catch",

    visitor: {
      /**
       * 访问函数声明、函数表达式和箭头函数表达式。
       * 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'
       * 也可以分开写:
       * FunctionDeclaration(path, state) { /* ... */ },
       * FunctionExpression(path, state) { /* ... */ },
       * ArrowFunctionExpression(path, state) { /* ... */ },
       */
      'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression': function (path, state) {
        const node = path.node;

        // 0. 检查插件选项,例如是否启用,或者特定的函数名才处理
        if (state.opts.disable === true) {
          // console.log("Auto try-catch is disabled by plugin options.");
          return;
        }

        const onlyWrapNamedFunctions = state.opts.onlyWrapFunctionsNamed;
        if (onlyWrapNamedFunctions && Array.isArray(onlyWrapNamedFunctions)) {
          let functionName = "";
          if (t.isFunctionDeclaration(node) && node.id) {
            functionName = node.id.name;
          } else if (t.isFunctionExpression(node) && node.id) {
            functionName = node.id.name;
          } else if (t.isArrowFunctionExpression(node) && path.parentPath.isVariableDeclarator() && t.isIdentifier(path.parentPath.node.id)) {
            // const myFunction = () => { ... }
            functionName = path.parentPath.node.id.name;
          }
          // console.log("Checking function name:", functionName);
          if (!onlyWrapNamedFunctions.includes(functionName)) {
            // console.log(`Skipping function ${functionName || '(anonymous)'} as it's not in the whitelist.`);
            return;
          }
        }


        // 1. 获取函数体 (BlockStatement)
        let body = node.body;

        // 2. 如果是箭头函数且函数体不是块语句 (例如: const add = (a, b) => a + b;),
        //    需要先将其转换为块语句。
        if (!t.isBlockStatement(body)) {
          // 例如: () => expr  变成  () => { return expr; }
          // console.log("Arrow function with expression body found. Converting to block statement.");
          const expression = body;
          body = t.blockStatement([t.returnStatement(expression)]);
          // 更新节点,确保后续操作基于新的块语句体
          node.body = body;
        }

        // 3. 检查函数体是否为空或者已经是一个 try...catch 包裹的结构 (避免重复包裹)
        if (body.body && body.body.length === 0) {
          // console.log("Function body is empty. Skipping try-catch wrapping.");
          return; // 空函数体,无需包裹
        }
        if (body.body && body.body.length === 1 && t.isTryStatement(body.body[0])) {
          // console.log("Function body is already a try statement. Skipping.");
          return; // 已经有 try 语句了,不再包裹
        }

        // console.log(`Wrapping function ${node.id ? node.id.name : (path.parentPath.isVariableDeclarator() && path.parentPath.node.id ? path.parentPath.node.id.name : '(anonymous)')} with try...catch.`);
        // console.log("Original function body AST:", JSON.stringify(body, null, 2));

        // 4. 定义错误标识符和 catch 处理逻辑
        const errorIdentifier = path.scope.generateUidIdentifier("e"); // 生成一个唯一ID,避免冲突

        let catchHandlerBody;
        if (state.opts.customCatchHandler && typeof state.opts.customCatchHandler === 'string') {
          // 允许用户通过选项传入自定义的 catch 处理代码字符串
          // 注意:这种方式直接执行用户传入的字符串作为代码模板,需要谨慎处理安全问题。
          // 更安全的方式是让用户传入一个函数,该函数返回 AST 节点数组。
          try {
            const customHandlerTemplate = template.statements(state.opts.customCatchHandler);
            catchHandlerBody = customHandlerTemplate({
              ERROR_IDENTIFIER: errorIdentifier,
              // 可以传递更多上下文给模板,例如函数名
              // FUNCTION_NAME: t.stringLiteral(node.id ? node.id.name : 'anonymous')
            });
          } catch (err) {
            console.warn(`[auto-try-catch-plugin] Failed to parse customCatchHandler: ${err.message}. Falling back to default.`);
            catchHandlerBody = defaultCatchHandler({ ERROR_IDENTIFIER: errorIdentifier });
          }
        } else {
          catchHandlerBody = defaultCatchHandler({ ERROR_IDENTIFIER: errorIdentifier });
        }


        // 5. 使用 template 构建新的 try...catch 结构
        // 注意:template.statements 返回的是一个 AST 节点数组
        // 而函数体 node.body 需要的是一个 BlockStatement
        // 所以我们将 template 返回的数组作为新 BlockStatement 的 body
        const tryCatchAstNodes = buildTryCatch({
          BODY: body.body, // 直接传递原函数体的语句数组
          ERROR_IDENTIFIER: errorIdentifier,
          CATCH_HANDLER_BODY: catchHandlerBody,
        });

        // 6. 将原函数体替换为新的包含 try...catch 的块语句
        node.body = t.blockStatement(tryCatchAstNodes);

        // console.log("New function body AST with try-catch:", JSON.stringify(node.body, null, 2));

        // 如果函数是异步函数 (async function)
        // 它的返回值会被隐式包裹在 Promise.resolve() 中。
        // 如果 try 块中的代码抛出错误,且没有 return 语句,
        // catch 块执行后,函数会隐式返回 undefined (被 Promise.resolve(undefined) 包裹)。
        // 如果 catch 块中 rethrow 了错误,或者抛出了新错误,则 Promise 会 reject。
        // 这里的简单包裹对于异步函数的错误捕获是有效的。
        // 如果需要更复杂的异步错误处理(例如,确保 Promise总是 resolve 或 reject 特定值),
        // catch 块的逻辑会更复杂。

        // 标记路径已更改,有助于 Babel 进行优化 (某些情况下)
        // path.stop(); // 如果你确定这个节点处理完后不需要再访问其子节点或同级其他访问器
      }
    }
  };
}

测试 auto-try-catch-plugin.js:

// test-auto-try-catch.js
const { transformCode } = require('./transform');
const autoTryCatchPlugin = require('./plugins/auto-try-catch-plugin').default;

const code = `
  function syncFunction(a, b) {
    console.log("syncFunction called");
    if (a < 0) {
      throw new Error("Negative input for 'a'");
    }
    return a + b;
  }

  const arrowFunc = (x) => {
    if (x === 0) throw new Error("Zero division");
    return 100 / x;
  };

  const arrowExprFunc = (y) => y * y;

  async function asyncFunction(name) {
    console.log("asyncFunction called");
    if (!name) {
      throw new Error("Name is required");
    }
    await new Promise(resolve => setTimeout(resolve, 10));
    return `Hello, ${name}`;
  }

  // 已经有 try-catch 的函数,不应该被再次包裹
  function alreadyWrapped() {
    try {
      console.log("Already wrapped");
    } catch (e) {
      // ignore
    }
  }

  // 空函数
  function emptyFunction() {}

  // 测试插件选项
  function processData(data) {
    if (!data) throw new Error("No data");
    return data.toUpperCase();
  }
  function ignoreThis(data) {
    return data;
  }
`;

console.log("Original Code:\n", code);

// 测试1: 默认行为
const transformedCode1 = transformCode(code, [
  [autoTryCatchPlugin]
]);
console.log("\nTransformed Code (default catch handler):\n", transformedCode1);

// 测试2: 使用自定义 catch handler (字符串模板)
const customHandler = `
  console.warn("Custom error handler caught:", %%ERROR_IDENTIFIER%%.message);
  Sentry.captureException(%%ERROR_IDENTIFIER%%);
`;
const transformedCode2 = transformCode(code, [
  [autoTryCatchPlugin, { customCatchHandler: customHandler }]
]);
console.log("\nTransformed Code (custom catch handler):\n", transformedCode2);

// 测试3: 只包裹特定名称的函数
const transformedCode3 = transformCode(code, [
  [autoTryCatchPlugin, { onlyWrapFunctionsNamed: ["processData", "syncFunction"] }]
]);
console.log("\nTransformed Code (only 'processData' and 'syncFunction' wrapped):\n", transformedCode3);


/*
预期输出 (transformedCode1 会比较长,这里只展示 syncFunction 的大致结构):
function syncFunction(a, b) {
  try {
    console.log("syncFunction called");
    if (a < 0) {
      throw new Error("Negative input for 'a'");
    }
    return a + b;
  } catch (_e) { // _e 是生成的唯一ID
    console.error(_e);
  }
}

const arrowFunc = x => {
  try {
    if (x === 0) throw new Error("Zero division");
    return 100 / x;
  } catch (_e2) {
    console.error(_e2);
  }
};

const arrowExprFunc = y => {
  try {
    return y * y;
  } catch (_e3) {
    console.error(_e3);
  }
};

async function asyncFunction(name) {
  try {
    console.log("asyncFunction called");
    if (!name) {
      throw new Error("Name is required");
    }
    await new Promise(resolve => setTimeout(resolve, 10));
    return `Hello, ${name}`;
  } catch (_e4) {
    console.error(_e4);
  }
}

function alreadyWrapped() { // 不变
  try {
    console.log("Already wrapped");
  } catch (e) {
    // ignore
  }
}

function emptyFunction() {} // 不变

// processData 会被包裹, ignoreThis 不会 (在 transformedCode3 中)
*/

这个 auto-try-catch-plugin 示例展示了:

  • 使用 @babel/template 创建复杂的 AST 结构。
  • 处理不同类型的函数(声明、表达式、箭头函数)。
  • 转换箭头函数的表达式体为块语句体。
  • 使用 path.scope.generateUidIdentifier 生成唯一的变量名以避免冲突。
  • 通过插件选项 state.opts 自定义插件行为。
  • 避免重复处理(例如已经有 try...catch 的函数)。

示例插件 4: 简单的国际化 (i18n) 文本替换

这个插件演示如何读取插件选项(一个字典),并替换代码中特定的函数调用(例如 __('greeting'))为其在字典中对应的值。

// plugins/i18n-plugin.js

/**
 * Babel 插件:简单的国际化文本替换。
 * 替换形如 __('key') 或 i18n('key', 'default value') 的调用。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("i18n-plugin loaded");

  let translations = {}; // 用于存储从选项加载的翻译
  let defaultLocale = 'en';
  let translationFunctionName = '__'; // 默认的翻译函数名

  return {
    name: "simple-i18n",

    // pre 函数在遍历前执行,适合用来处理插件选项
    pre(state) {
      // console.log("i18n-plugin: pre() hook");
      // console.log("Plugin options received:", JSON.stringify(this.opts, null, 2)); // this.opts 是 state.opts 的别名

      if (this.opts.translations) {
        translations = this.opts.translations;
      } else {
        console.warn("[i18n-plugin] 'translations' option not provided. Plugin may not work as expected.");
        translations = {};
      }

      if (this.opts.defaultLocale) {
        defaultLocale = this.opts.defaultLocale;
      }

      if (this.opts.functionName) {
        translationFunctionName = this.opts.functionName;
      }
      // console.log(`[i18n-plugin] Using function name: '${translationFunctionName}', default locale: '${defaultLocale}'`);
      // console.log("[i18n-plugin] Loaded translations:", JSON.stringify(translations, null, 2));
    },

    visitor: {
      /**
       * 访问 CallExpression (函数调用表达式) 节点。
       * @param {NodePath} path - 当前 CallExpression 节点的路径对象。
       * @param {Object} state - 插件状态对象 (这里我们主要用 pre 中设置好的 this.opts)。
       */
      CallExpression(path, state) {
        const node = path.node;
        const callee = node.callee;

        // 检查是否是我们定义的翻译函数调用,例如 __() 或 i18n()
        if (t.isIdentifier(callee, { name: translationFunctionName })) {
          // console.log(`Found translation function call: ${translationFunctionName}() at line ${node.loc.start.line}`);

          if (node.arguments.length === 0) {
            // console.warn(`[i18n-plugin] Call to ${translationFunctionName}() with no arguments. Skipping.`);
            return;
          }

          const firstArg = node.arguments[0];
          if (!t.isStringLiteral(firstArg)) {
            // console.warn(`[i18n-plugin] First argument to ${translationFunctionName}() must be a string literal (the key). Skipping.`);
            return;
          }

          const translationKey = firstArg.value;
          // console.log(`Translation key: "${translationKey}"`);

          let translatedString = null;

          // 尝试从当前语言的翻译中获取
          if (translations[defaultLocale] && translations[defaultLocale].hasOwnProperty(translationKey)) {
            translatedString = translations[defaultLocale][translationKey];
          } else if (translations.hasOwnProperty(translationKey)) {
            // 如果顶层直接是键值对 (没有按语言组织)
            // 或者作为一种回退机制,如果特定语言没有,尝试从通用翻译中找
            // 这种结构通常不推荐,最好按语言组织
            // console.log(`[i18n-plugin] Key "${translationKey}" not found in locale "${defaultLocale}", trying root.`);
            // translatedString = translations[translationKey];
          }


          // 如果找不到翻译,并且提供了默认值参数
          if (translatedString === null || typeof translatedString !== 'string') {
            if (node.arguments.length > 1 && t.isStringLiteral(node.arguments[1])) {
              const defaultValue = node.arguments[1].value;
              // console.log(`[i18n-plugin] Key "${translationKey}" not found for locale "${defaultLocale}". Using provided default value: "${defaultValue}"`);
              translatedString = defaultValue;
            } else {
              // console.warn(`[i18n-plugin] Key "${translationKey}" not found for locale "${defaultLocale}" and no default value provided. Replacing with key itself or an indicator.`);
              // 如果没有翻译也没有默认值,可以选择替换为 key 本身,或者一个标记,或者抛出错误
              // 这里我们替换为 "KEY_NOT_FOUND:key"
              translatedString = state.opts.missingKeyPrefix ? `${state.opts.missingKeyPrefix}${translationKey}` : `KEY_NOT_FOUND:${translationKey}`;
            }
          }

          if (typeof translatedString === 'string') {
            // console.log(`Replacing call with string literal: "${translatedString}"`);
            // 用翻译后的字符串字面量替换整个函数调用表达式
            path.replaceWith(t.stringLiteral(translatedString));
          } else {
            // console.warn(`[i18n-plugin] Could not resolve translation for key "${translationKey}". Original call remains.`);
          }
        }
      }
    },

    post(state) {
      // console.log("i18n-plugin: post() hook");
      // 清理,虽然在这个简单例子中非必需,但好习惯
      translations = {};
      defaultLocale = 'en';
      translationFunctionName = '__';
    }
  };
}

测试 i18n-plugin.js:

// test-i18n-plugin.js
const { transformCode } = require('./transform');
const i18nPlugin = require('./plugins/i18n-plugin').default;

const code = `
  const greeting = __("greeting.hello");
  const farewell = __("farewell", "Goodbye for now!");
  const missing = __("missing.key");
  const customName = i18n("custom.message");

  function showMessages() {
    console.log(greeting);
    console.log(farewell);
    console.log(missing);
    console.log(customName);
    console.log(__("inline.usage", "Inline default"));
  }
`;

const pluginOptions1 = {
  functionName: "__", // 明确指定函数名
  defaultLocale: "en_US",
  translations: {
    "en_US": {
      "greeting.hello": "Hello, World!",
      "farewell": "Farewell, Friend!",
      "inline.usage": "Used inline"
      // "missing.key" is intentionally missing
    },
    "es_ES": {
      "greeting.hello": "¡Hola, Mundo!"
    }
  },
  missingKeyPrefix: "[MISSING] " // 自定义未找到键的前缀
};

const pluginOptions2 = {
  functionName: "i18n", // 改变翻译函数名
  defaultLocale: "fr_FR",
  translations: {
    "fr_FR": {
      "custom.message": "Message personnalisé"
    }
  }
};


console.log("Original Code:\n", code);

// 测试1: 使用 __ 和 en_US 翻译
const transformedCode1 = transformCode(code, [
  [i18nPlugin, pluginOptions1]
]);
console.log("\nTransformed Code (en_US with __):\n", transformedCode1);

// 测试2: 使用 i18n 和 fr_FR 翻译 (只影响 i18n() 调用)
// 注意:由于 Babel 插件是按顺序应用的,如果想让两个不同配置的 i18n 插件都生效,
// 需要分别应用它们,或者让一个插件能够处理多种配置。
// 这里我们假设只应用一个,所以 __() 调用在这次转换中不会被 pluginOptions2 处理。
const transformedCode2 = transformCode(code, [
  [i18nPlugin, pluginOptions2] // 这个配置只会处理 i18n()
]);
console.log("\nTransformed Code (fr_FR with i18n, __ calls untouched by this specific plugin instance):\n", transformedCode2);

// 更实际的场景是,你可能只配置一次插件,或者链式调用:
const transformedCodeCombined = transformCode(code, [
  [i18nPlugin, pluginOptions1], // 先处理 __
  [i18nPlugin, pluginOptions2]  // 再处理 i18n
]);
console.log("\nTransformed Code (Combined - __ then i18n):\n", transformedCodeCombined);


/*
预期输出 (transformedCode1):
const greeting = "Hello, World!";
const farewell = "Farewell, Friend!";
const missing = "[MISSING] missing.key";
const customName = i18n("custom.message"); // 不会被 pluginOptions1 处理

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log("Used inline");
}

预期输出 (transformedCode2):
const greeting = __("greeting.hello"); // 不会被 pluginOptions2 处理
const farewell = __("farewell", "Goodbye for now!"); // 不会被 pluginOptions2 处理
const missing = __("missing.key"); // 不会被 pluginOptions2 处理
const customName = "Message personnalisé";

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log(__("inline.usage", "Inline default")); // 不会被 pluginOptions2 处理
}

预期输出 (transformedCodeCombined):
const greeting = "Hello, World!";
const farewell = "Farewell, Friend!";
const missing = "[MISSING] missing.key";
const customName = "Message personnalisé";

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log("Used inline");
}
*/

这个 i18n-plugin 示例展示了:

  • pre 钩子中处理和准备插件选项。
  • 根据插件选项动态改变插件的行为(如翻译函数名、区域设置)。
  • 从选项中加载数据(翻译字典)。
  • 替换函数调用节点为字符串字面量节点。
  • 处理参数和提供回退机制(默认值、未找到键的标记)。
  • post 钩子中进行清理(可选)。

AST Explorer 的使用

强烈推荐使用 AST Explorer (astexplorer.net)

  1. 选择 JavaScript 作为语言。
  2. 选择 @babel/parser 作为解析器。
  3. 选择 "Transform" -> "Babel Plugin" (或 v7/v8)。
  4. 在左上角面板输入你的源代码。
  5. 在右上角面板可以看到生成的 AST 结构。点击代码中的部分会高亮对应的 AST 节点,反之亦然。
  6. 在左下角面板可以编写你的 Babel 插件代码。
  7. 在右下角面板可以看到插件转换后的代码。

这对于理解特定代码结构对应的 AST 节点类型、属性以及试验插件逻辑非常有帮助。

Babel 插件的测试

虽然上面的例子中我们用了简单的 transformCode 函数来手动测试,但在实际项目中,建议使用更专业的测试框架,如 Jest。

你可以使用 @babel/coretransformtransformSync 方法在测试用例中运行你的插件,并断言输出是否符合预期。

一个简单的 Jest 测试用例可能如下:

// my-plugin.test.js
import pluginTester from 'babel-plugin-tester'; // 一个流行的测试工具 npm install --save-dev babel-plugin-tester
import myCustomPlugin from '../plugins/my-custom-plugin'; // 你的插件

pluginTester({
  plugin: myCustomPlugin,
  pluginName: 'my-custom-plugin', // 与插件中的 name 对应
  // fixture: path.join(__dirname, 'fixtures', 'my-test-case', 'code.js'), // 从文件加载测试用例
  // outputFixture: path.join(__dirname, 'fixtures', 'my-test-case', 'output.js'),
  tests: [
    {
      title: 'should replace var with let',
      code: 'var x = 1;',
      output: 'let x = 1;',
      pluginOptions: { /* 插件选项 */ }
    },
    {
      title: 'should remove console.log',
      code: 'console.log("hello"); var a = 1;',
      output: 'var a = 1;', // 假设这是另一个插件或组合
      // 如果是测试 remove-console-log-plugin
      // plugin: require('../plugins/remove-console-log-plugin').default,
      // output: 'var a = 1;',
    },
    // 更多测试用例
    {
      title: 'should not change const',
      code: 'const y = 2;',
      output: 'const y = 2;', // 或者 snapshot: true
    },
    {
      title: 'error case',
      code: 'var 123invalid = "test";', // 无效代码
      error: /SyntaxError/ // 或特定的错误信息/类型
    }
  ],
});

babel-plugin-tester 提供了很好的结构来组织测试用例,包括快照测试、错误测试等。

其他相关知识

  1. 插件顺序:

    • Babel 插件的执行顺序很重要。在 Babel 配置中,插件按数组顺序从前到后执行。
    • Preset(预设,插件的集合)中的插件会在自定义插件之前执行。Preset 的顺序是反向的(从后到前)。
    • 如果一个插件的转换依赖于另一个插件的结果,你需要确保它们的顺序正确。
  2. Preset (预设) :

    • Preset 是一组预先配置好的 Babel 插件和/或选项。例如 @babel/preset-env 可以根据你指定的目标环境自动确定需要的插件和 polyfill。
    • @babel/preset-react 包含了转换 JSX 的插件。
    • @babel/preset-typescript 包含了转换 TypeScript 的插件。
    • 你可以创建自己的 Preset。
  3. 宏 (Macros - babel-plugin-macros) :

    • 宏允许你在构建时执行代码生成,并且不需要用户在 Babel 配置中添加插件,只需导入宏即可。它们提供了更零配置的插件体验。例如 styled-components/macro
  4. 性能注意事项:

    • 避免在访问者函数中进行昂贵的操作,特别是那些会被频繁调用的节点类型(如 Identifier)。
    • 如果可能,在 pre 函数中进行一次性的计算或设置。
    • 合理使用 path.skip() 来跳过不需要处理的子树。
    • 缓存计算结果(如果适用)。
    • Babel 内部有一些优化,但插件的写法仍然对性能有影响。
  5. 作用域 (Scope) :

    • path.scope 对象非常有用,它提供了关于当前节点作用域内变量绑定、引用等信息。
    • scope.hasBinding("varName"): 检查变量是否在此作用域或父作用域中声明。
    • scope.getBinding("varName"): 获取变量的绑定信息 (Binding 对象),包含声明节点、引用等。
    • scope.generateUidIdentifier("prefix"): 生成一个在当前作用域中唯一的标识符,避免命名冲突。
    • 理解作用域对于进行安全的变量重命名、注入新变量等操作至关重要。
  6. 与打包工具 (Webpack, Rollup) 的集成:

    • Babel 通常作为这些打包工具的一个加载器 (loader) 或插件来使用(例如 babel-loader for Webpack)。打包工具负责读取文件,然后将文件内容传递给 Babel 进行转换。
  7. Source Maps:

    • Babel 可以生成 Source Map,将转换后的代码映射回原始代码,这对于调试非常重要。代码生成阶段 (@babel/generator) 负责此事,通常可以通过 Babel 的选项来控制 Source Map 的生成。
  8. Helper 函数:

    • 某些转换(例如类、异步函数)可能需要一些辅助函数(helpers)来模拟新特性。Babel 可以将这些 helpers 内联到每个需要它们的文件中,或者通过 @babel/plugin-transform-runtime@babel/runtime 将它们提取为共享模块,以减小代码体积。

总结

编写 Babel 插件是一个强大但复杂的过程。核心在于理解 AST 的结构、掌握 NodePath API 以及如何使用 @babel/types@babel/template 来操纵 AST。

  • 流程: Parse -> Transform (插件工作区) -> Generate。
  • 输入: 插件通过 visitor 模式接收 path (NodePath) 和 state
  • 输出: 插件通过修改 path 对应的 AST 节点来产生效果。
  • 关键工具: NodePath API, @babel/types, @babel/template
  • 实践: 多使用 AST Explorer,多写测试。

前端 SEO 优化

2025年5月18日 17:09

前端 SEO 优化的核心目标:

  1. 可抓取性 (Crawlability) : 确保搜索引擎的爬虫能够发现并访问你网站上的所有重要内容。
  2. 可索引性 (Indexability) : 确保爬虫能够理解并正确地将你的页面内容添加到搜索引擎的索引库中。
  3. 内容质量与相关性 (Content Quality & Relevance) : 提供高质量、与用户搜索意图相关的内容,并使用合适的关键词。
  4. 用户体验 (User Experience - UX) : 提供快速、易用、移动友好的体验,因为搜索引擎越来越重视用户满意度。
  5. 技术实现 (Technical Implementation) : 采用搜索引擎友好的技术和编码规范。

以下是详细的前端 SEO 优化点和相关代码示例:

一、基础 HTML 标签优化 (On-Page SEO Fundamentals)

这是 SEO 的基石,确保每个页面都有清晰的元信息。

1. <title> 标签

  • 作用: 页面标题,显示在浏览器标签页、书签以及搜索引擎结果页的标题位置。是影响排名的最重要因素之一。

  • 优化建议:

    • 每个页面都应有唯一且描述性的标题。
    • 长度通常建议在 50-60 个字符(中文大约 25-30 个汉字)以内,超出部分在 SERP 中可能被截断。
    • 包含核心关键词,自然地融入标题。
    • 重要关键词尽量靠前。
    • 品牌名可以放在标题末尾,用分隔符(如 -|)隔开。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF--8">
    <!-- 示例1: 首页标题 -->
    <title>XX公司官网 - 领先的AI解决方案提供商</title>

    <!-- 示例2: 产品页标题 -->
    <title>智能聊天机器人XYZ - 提升客户服务效率 | XX公司</title>

    <!-- 示例3: 文章页标题 -->
    <title>如何有效进行前端性能优化 - 实践指南 | XX公司博客</title>

    <!-- 其他 meta 标签... -->
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

2. <meta name="description"> 标签

  • 作用: 页面描述,显示在搜索引擎结果页的标题下方,作为页面的摘要。虽然不直接影响排名,但会极大地影响点击率 (CTR)。

  • 优化建议:

    • 每个页面都应有唯一且吸引人的描述。
    • 长度通常建议在 150-160 个字符(中文大约 75-80 个汉字)以内。
    • 准确概括页面内容,包含核心关键词。
    • 使用有号召性的语言,鼓励用户点击。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>智能聊天机器人XYZ - 提升客户服务效率 | XX公司</title>

    <!-- 示例: 产品页描述 -->
    <meta name="description" content="了解XX公司的智能聊天机器人XYZ如何通过自然语言处理和机器学习技术,7x24小时自动化客户咨询,显著提升服务效率并降低运营成本。立即获取演示!">

    <!-- 其他 meta 标签... -->
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

3. <meta name="keywords"> 标签 (基本已废弃)

  • 作用: 早期用于告诉搜索引擎页面关键词。
  • 现状: Google 等主流搜索引擎已基本忽略此标签,因为容易被滥用。可以不写,或者简单写几个核心词。
<!-- 示例 (可选,作用不大) -->
<meta name="keywords" content="智能聊天机器人, AI客服, 客户服务自动化">

4. <meta name="robots"><meta name="googlebot"> 标签

  • 作用: 指示搜索引擎爬虫如何处理当前页面。

  • 常用值:

    • index: 允许索引此页面。
    • noindex: 不允许索引此页面。
    • follow: 允许跟踪此页面上的链接。
    • nofollow: 不允许跟踪此页面上的链接。
    • all (默认): 等同于 index, follow
    • none: 等同于 noindex, nofollow
    • noarchive: 不在搜索结果中显示缓存链接。
    • nosnippet: 不在搜索结果中显示此页面的文本摘要或视频预览。
    • notranslate: 不提供此页面的翻译版本。
    • noimageindex: 不索引此页面上的图片。
    • unavailable_after: [RFC 850 date/time string]: 在指定日期后不再显示此页面。
<head>
    <!-- 示例1: 允许索引和跟踪 (默认行为,可不写) -->
    <meta name="robots" content="index, follow">

    <!-- 示例2: 禁止索引但允许跟踪链接 (例如,用于某些用户协议页面) -->
    <meta name="robots" content="noindex, follow">

    <!-- 示例3: 禁止索引和跟踪 (例如,用于后台管理页面或测试页面) -->
    <meta name="robots" content="noindex, nofollow">
    <!-- 或者 -->
    <meta name="robots" content="none">

    <!-- 示例4: 针对 Google 特定的指令 -->
    <meta name="googlebot" content="nosnippet, max-snippet:-1, max-image-preview:large, max-video-preview:-1">
    <!--
        max-snippet:-1: 不限制摘要长度
        max-image-preview:large: 允许显示大图预览
        max-video-preview:-1: 不限制视频预览时长
    -->
</head>

5. 语义化 HTML5 标签

  • 作用: 使用正确的 HTML5 语义化标签(如 <header>, <nav>, <main>, <article>, <section>, <aside>, <footer>, <figure>, <figcaption>)可以帮助搜索引擎更好地理解页面结构和内容层次。

  • 优化建议:

    • 合理组织页面结构,使用语义化标签包裹相应内容。
    • 确保内容的可读性和逻辑性。
<body>
    <header>
        <h1><a href="/">网站主标题 (通常是Logo和网站名称)</a></h1>
        <nav>
            <ul>
                <li><a href="/products">产品</a></li>
                <li><a href="/solutions">解决方案</a></li>
                <li><a href="/blog">博客</a></li>
                <li><a href="/about">关于我们</a></li>
            </ul>
        </nav>
    </header>

    <main>
        <article>
            <header>
                <h2>文章标题:前端SEO深度解析</h2>
                <p>发布日期: <time datetime="2025-05-17">2025年5月17日</time> 作者: SEO专家</p>
            </header>
            <section id="introduction">
                <h3>1. 引言</h3>
                <p>前端SEO对于现代网站至关重要...</p>
            </section>
            <section id="html-tags">
                <h3>2. HTML标签优化</h3>
                <p>详细介绍title, meta description等...</p>
                <figure>
                    <img src="images/html-structure.png" alt="良好HTML结构示例图">
                    <figcaption>图1:一个语义化的HTML页面结构示例。</figcaption>
                </figure>
            </section>
            <!-- 更多 section -->
            <footer>
                <p>标签: <a href="/tags/seo">SEO</a>, <a href="/tags/frontend">前端</a></p>
            </footer>
        </article>

        <aside>
            <h3>相关文章</h3>
            <ul>
                <li><a href="/blog/performance-optimization">前端性能优化技巧</a></li>
                <li><a href="/blog/structured-data-guide">结构化数据指南</a></li>
            </ul>
            <h3>广告位</h3>
            <div id="ad-slot-1"></div>
        </aside>
    </main>

    <footer>
        <p>&copy; 2025 XX公司. 保留所有权利.</p>
        <nav>
            <a href="/privacy-policy">隐私政策</a> | <a href="/terms-of-service">服务条款</a>
        </nav>
    </footer>
</body>

6. 标题标签 (H1-H6)

  • 作用: 定义内容的层级结构,<h1> 是最重要的标题,通常用于页面主标题,每个页面应只有一个 <h1><h2><h6> 表示子标题,层级递减。

  • 优化建议:

    • 逻辑清晰地使用 H 标签,反映内容的组织结构。
    • <h1> 包含核心关键词,与 <title> 呼应但可以不完全相同。
    • 不要跳级使用(例如 <h1> 直接到 <h3>)。
    • 不要滥用 H 标签来仅仅为了样式,样式应由 CSS 控制。
<!-- 在上面的语义化HTML示例中已包含 H 标签的正确使用 -->
<main>
    <article>
        <header>
            <h1>文章主标题 (H1)</h1> <!-- 页面唯一 H1 -->
        </header>
        <section>
            <h2>章节标题 (H2)</h2>
            <p>内容...</p>
            <h3>子章节标题 (H3)</h3>
            <p>内容...</p>
            <h4>更细分的子章节 (H4)</h4>
            <p>内容...</p>
        </section>
        <section>
            <h2>另一个章节标题 (H2)</h2>
            <p>内容...</p>
        </section>
    </article>
</main>

7. 图片优化 (<img> 标签)

  • 作用: 图片是内容的重要组成部分,优化图片有助于图片搜索排名,并提升页面加载速度。

  • 优化建议:

    • alt 属性: 必须为所有有意义的图片提供描述性的 alt 文本。这不仅帮助视障用户理解图片内容,也帮助搜索引擎理解图片。如果图片纯粹是装饰性的,alt 可以为空 (alt="")。
    • 文件名: 使用描述性的文件名(例如 ai-chatbot-dashboard.jpg 而不是 img123.jpg)。
    • 图片压缩: 使用工具(如 TinyPNG, ImageOptim)压缩图片大小而不显著降低质量。
    • 响应式图片: 使用 <picture> 元素或 <img>srcsetsizes 属性提供不同尺寸的图片以适应不同设备。
    • 图片格式: 选择合适的图片格式(JPEG 适合照片,PNG 适合透明背景或简单图形,WebP 提供更好的压缩和质量)。
    • 延迟加载 (Lazy Loading) : 对非首屏图片使用 loading="lazy" 属性,或通过 JavaScript 实现。
<!-- 基本图片优化 -->
<img src="images/smart-ai-assistant.jpg"
     alt="一位用户正在与智能AI助手进行交互的场景图"
     width="800"
     height="600">

<!-- 响应式图片与延迟加载 -->
<img src="images/product-feature-small.jpg"
     srcset="images/product-feature-small.jpg 480w,
             images/product-feature-medium.jpg 800w,
             images/product-feature-large.jpg 1200w"
     sizes="(max-width: 600px) 480px,
            (max-width: 900px) 800px,
            1200px"
     alt="产品核心功能展示图"
     loading="lazy">

<!-- 使用 <picture> 元素提供不同格式或裁剪的图片 -->
<picture>
   <source srcset="images/hero-banner.webp" type="image/webp">
   <source srcset="images/hero-banner.jpg" type="image/jpeg">
   <img src="images/hero-banner.jpg" alt="网站首页的英雄横幅广告" loading="lazy">
</picture>

8. 链接优化 (<a> 标签)

  • 作用: 内部链接和外部链接对于 SEO 都很重要。

  • 优化建议:

    • 锚文本 (Anchor Text) : 使用描述性的锚文本,包含目标页面的关键词,而不是通用的“点击这里”。
    • title 属性: (可选) 为链接提供额外的上下文信息。
    • rel="nofollow" : 如果不希望传递权重给某个链接(例如,用户评论中的链接、广告链接),可以使用此属性。
    • rel="noopener"rel="noreferrer" : 当链接使用 target="_blank" 打开新窗口时,出于安全和隐私考虑,建议添加。noopener 防止新页面通过 window.opener 访问原始页面对象,noreferrer 则不发送 Referer HTTP 头。
    • 内部链接策略: 合理规划内部链接,帮助搜索引擎发现深层内容,并传递权重。面包屑导航是好的实践。
<!-- 良好锚文本 -->
<p>了解更多关于我们的<a href="/services/ai-consulting">AI咨询服务</a></p>

<!-- 避免使用 -->
<p>了解更多<a href="/services/ai-consulting">点击这里</a></p>

<!-- Nofollow 示例 (例如,付费链接或不可信链接) -->
<a href="https://example.com/advertisement" rel="nofollow">赞助商链接</a>

<!-- 新窗口打开链接的安全实践 -->
<a href="https://external-site.com" target="_blank" rel="noopener noreferrer">访问外部资源</a>

<!-- 面包屑导航示例 -->
<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="/">首页</a></li>
    <li class="breadcrumb-item"><a href="/products">产品</a></li>
    <li class="breadcrumb-item active" aria-current="page">智能聊天机器人XYZ</li>
  </ol>
</nav>

二、可抓取性与可索引性优化

1. robots.txt 文件

  • 作用: 放在网站根目录的文本文件,用于告知搜索引擎爬虫哪些页面或目录可以抓取,哪些不可以。

  • 位置: https://yourdomain.com/robots.txt

  • 优化建议:

    • 确保其可访问。
    • 正确配置 User-agentDisallow / Allow 指令。
    • 可以指定 Sitemap 的位置。
# robots.txt for https://yourdomain.com

User-agent: * # 表示对所有爬虫生效
Disallow: /admin/          # 禁止抓取 /admin/ 目录下的所有内容
Disallow: /tmp/            # 禁止抓取 /tmp/ 目录
Disallow: /private-page.html # 禁止抓取特定私有页面
Allow: /public/           # 明确允许抓取 /public/ 目录 (如果父目录被Disallow)
Allow: /important-page.html # 明确允许抓取某个重要页面

User-agent: Googlebot     # 仅对 Googlebot 生效的规则
Disallow: /google-specific-ignore/
Crawl-delay: 1           # (可选,部分爬虫支持) 抓取间隔,单位秒,谨慎使用

User-agent: Bingbot
Disallow: /bing-specific-ignore/

# 指定Sitemap位置 (非常重要)
Sitemap: https://yourdomain.com/sitemap.xml
Sitemap: https://yourdomain.com/sitemap_images.xml # 如果有图片站点地图

2. XML 站点地图 (Sitemap)

  • 作用: 一个 XML 文件,列出网站上希望搜索引擎索引的所有重要 URL。帮助搜索引擎更快、更全面地发现网站内容,特别是对于新网站或内容层级较深的网站。

  • 位置: 通常在根目录,并通过 robots.txt 或搜索引擎站长平台提交。

  • 优化建议:

    • 包含所有规范的、可索引的 URL。
    • 保持更新,当有新内容或内容变更时及时更新站点地图。
    • 可以包含 <lastmod> (最后修改时间), <changefreq> (更新频率), <priority> (相对优先级) 等可选标签,但搜索引擎主要关注 URL 和 lastmod
    • 对于大型网站,可以使用站点地图索引文件 (Sitemap Index File) 来管理多个站点地图。
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> <!-- 图片站点地图命名空间 -->

  <url>
    <loc>https://yourdomain.com/</loc>
    <lastmod>2025-05-15T10:00:00+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://yourdomain.com/products</loc>
    <lastmod>2025-05-10T12:30:00+00:00</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.9</priority>
  </url>
  <url>
    <loc>https://yourdomain.com/products/smart-chatbot</loc>
    <lastmod>2025-05-12T08:45:15+00:00</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
    <!-- 图片站点地图示例 -->
    <image:image>
      <image:loc>https://yourdomain.com/images/smart-chatbot-promo.jpg</image:loc>
      <image:caption>智能聊天机器人XYZ产品宣传图</image:caption>
      <image:geo_location>北京, 中国</image:geo_location>
      <image:title>智能聊天机器人XYZ</image:title>
      <image:license>https://yourdomain.com/image-license</image:license>
    </image:image>
    <image:image>
      <image:loc>https://yourdomain.com/images/smart-chatbot-ui.png</image:loc>
    </image:image>
  </url>
  <url>
    <loc>https://yourdomain.com/blog/frontend-seo-guide</loc>
    <lastmod>2025-05-17T14:20:05+00:00</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>
  <!-- 更多 URL -->

</urlset>

3. 规范化 URL (<link rel="canonical">)

  • 作用: 当网站上存在内容相同或高度相似的多个 URL 时(例如,带 www 和不带 www,HTTP 和 HTTPS,带参数和不带参数的 URL),使用规范标签告诉搜索引擎哪个是“首选”或“官方”版本,以避免重复内容问题并集中权重。

  • 优化建议:

    • 每个可索引的页面都应该有一个指向自身的规范标签(自引用规范标签),或者指向其规范版本的标签。
    • 确保规范 URL 是绝对路径。
<head>
    <!-- 示例1: 页面自身的规范 URL -->
    <link rel="canonical" href="https://yourdomain.com/products/smart-chatbot">

    <!-- 示例2: 如果当前页面是 https://yourdomain.com/products/smart-chatbot?source=ads -->
    <!-- 并且你希望 https://yourdomain.com/products/smart-chatbot 是规范版本 -->
    <link rel="canonical" href="https://yourdomain.com/products/smart-chatbot">

    <!-- 示例3: 移动版页面指向对应的桌面版规范URL (如果内容相同) -->
    <!-- (假设当前是 m.yourdomain.com/product-a) -->
    <!-- <link rel="canonical" href="https://www.yourdomain.com/product-a"> -->
    <!-- 同时,桌面版页面应有: <link rel="alternate" media="only screen and (max-width: 640px)" href="https://m.yourdomain.com/product-a"> -->
    <!-- 对于响应式设计,通常是自引用规范标签 -->
</head>

4. 处理单页面应用 (SPA) 的 SEO

  • 挑战: 传统 SPA(如 React, Vue, Angular 构建的应用)通常在客户端渲染内容,初始 HTML 可能只有一个空的 <div>。这对于某些搜索引擎爬虫来说难以抓取和索引内容。

  • 解决方案:

    • 服务器端渲染 (SSR - Server-Side Rendering) : 在服务器上渲染完整的 HTML 页面并发送给浏览器(和爬虫)。Next.js (React), Nuxt.js (Vue), Angular Universal 是流行的 SSR 框架。
    • 预渲染 (Pre-rendering) : 在构建时为特定路由生成静态 HTML 文件。适用于内容不经常变化的页面。可以使用 react-snap, prerender-spa-plugin 等工具。
    • 动态渲染 (Dynamic Rendering) : 检测请求是否来自爬虫,如果是,则返回一个服务器端渲染或预渲染的版本;如果是普通用户,则返回客户端渲染的 SPA。需要配置服务器或使用第三方服务。
    • History API: 确保 SPA 使用 HTML5 History API (pushState, replaceState) 来管理路由,并为每个“页面”提供唯一的、可分享的 URL。服务器需要配置为将所有相关路由都指向应用的入口 HTML 文件。

SSR/预渲染概念 (不展示完整框架代码,仅示意)

  • Next.js (React) 页面文件 (pages/products/[id].js) :
// pages/products/[id].js (Next.js 示例)
import Head from 'next/head';

function ProductPage({ product }) {
  if (!product) {
    return <div>Loading... or Product not found</div>;
  }

  return (
    <div>
      <Head>
        <title>{product.name} - My E-commerce Site</title>
        <meta name="description" content={product.description} />
        <link rel="canonical" href={`https://yourdomain.com/products/${product.id}`} />
        {/* 其他 meta 标签, JSON-LD 结构化数据等 */}
        <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(product.jsonLdData) }} />
      </Head>
      <h1>{product.name}</h1>
      <img src={product.imageUrl} alt={product.name} />
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      {/* ...更多产品详情... */}
    </div>
  );
}

// getServerSideProps 用于 SSR: 在每次请求时在服务器端获取数据
export async function getServerSideProps(context) {
  const { id } = context.params;
  // 假设 fetchProductData 是一个从API或数据库获取产品数据的函数
  const productData = await fetchProductData(id);

  if (!productData) {
    return { notFound: true }; // 返回404页面
  }

  // 准备 JSON-LD 数据
  const jsonLdData = {
    "@context": "https://schema.org/",
    "@type": "Product",
    "name": productData.name,
    "image": productData.imageUrl,
    "description": productData.description,
    "sku": productData.sku,
    "mpn": productData.mpn,
    "brand": {
      "@type": "Brand",
      "name": productData.brandName
    },
    "offers": {
      "@type": "Offer",
      "url": `https://yourdomain.com/products/${productData.id}`,
      "priceCurrency": "USD",
      "price": productData.price,
      "availability": productData.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
      "seller": {
        "@type": "Organization",
        "name": "My E-commerce Site"
      }
    }
    // 可以添加评论 (aggregateRating, review) 等
  };

  return {
    props: {
      product: {
        ...productData,
        jsonLdData // 将 JSON-LD 数据传递给组件
      },
    },
  };
}

// 模拟数据获取函数
async function fetchProductData(id) {
  // 在实际应用中,这里会调用 API
  console.log(`Fetching product data for ID: ${id} on server...`);
  // 模拟 API 延迟
  await new Promise(resolve => setTimeout(resolve, 100));
  const sampleProducts = {
    "123": { id: "123", name: "Awesome Gadget", description: "The most awesome gadget ever.", price: "99.99", imageUrl: "/images/gadget.jpg", sku: "AG-001", mpn: "98765", brandName: "GadgetCorp", inStock: true },
    "456": { id: "456", name: "Super Widget", description: "A truly super widget for all your needs.", price: "49.50", imageUrl: "/images/widget.jpg", sku: "SW-002", mpn: "54321", brandName: "WidgetCo", inStock: false },
  };
  return sampleProducts[id] || null;
}

export default ProductPage;
  • Prerender SPA Plugin (webpack.config.js 示例) :
// webpack.config.js (示意,具体配置依项目而定)
const path = require('path');
const PrerenderSPAPlugin = require('prerender-spa-plugin');
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer; // 使用 Puppeteer 进行渲染

module.exports = {
  // ...其他 webpack 配置
  plugins: [
    // ...其他插件
    new PrerenderSPAPlugin({
      staticDir: path.join(__dirname, 'dist'), // 构建输出目录
      routes: [ '/', '/about', '/products/123', '/products/456' ], // 需要预渲染的路由列表
      renderer: new Renderer({
        // 可选配置:
        // injectProperty: '__PRERENDER_INJECTED',
        // inject: { foo: 'bar' },
        renderAfterDocumentEvent: 'render-event', // 等待自定义事件触发后再渲染
        // renderAfterTime: 5000, // 等待固定时间后渲染
        // headless: false, // (调试用) 显示浏览器窗口
      }),
      postProcess (renderedRoute) {
        // 可选: 对渲染后的 HTML 进行后处理
        // 例如,移除不必要的脚本标签
        renderedRoute.html = renderedRoute.html.replace(/<script.*?src=".*?bundle.js.*?></script>/i, '');
        return renderedRoute;
      }
    })
  ]
};

注意: 上述 Next.js 和 prerender-spa-plugin 的代码是概念性的,实际实现会更复杂。

三、结构化数据 (Structured Data / Schema Markup)

  • 作用: 使用 Schema.org 词汇表以 JSON-LD (推荐)、Microdata 或 RDFa 格式向搜索引擎提供关于页面内容的明确信息。这可以帮助搜索引擎更好地理解内容,并可能在搜索结果中显示丰富的摘要 (Rich Snippets),从而提高可见性和点击率。

  • 优化建议:

    • 为适用的内容类型添加结构化数据(例如文章、产品、食谱、事件、FAQ、本地商家、面包屑等)。
    • 使用 Google 的结构化数据测试工具进行验证。

JSON-LD 示例:

  1. 文章 (Article / BlogPosting) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "BlogPosting", // 或 "Article", "NewsArticle"
      "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "https://yourdomain.com/blog/frontend-seo-guide"
      },
      "headline": "前端SEO深度解析:原理与代码实践",
      "description": "本文详细介绍了前端SEO的各项优化策略,包括HTML标签、可抓取性、结构化数据、性能优化等,并提供了丰富的代码示例。",
      "image": [
        "https://yourdomain.com/images/seo-guide-banner.jpg",
        "https://yourdomain.com/images/seo-guide-social.png"
       ],
      "author": {
        "@type": "Person", // 或 "Organization"
        "name": "张三",
        "url": "https://yourdomain.com/authors/zhangsan"
      },
      "publisher": {
        "@type": "Organization",
        "name": "XX公司博客",
        "logo": {
          "@type": "ImageObject",
          "url": "https://yourdomain.com/images/logo.png",
          "width": 600,
          "height": 60
        }
      },
      "datePublished": "2025-05-17T09:00:00+08:00",
      "dateModified": "2025-05-18T10:30:00+08:00",
      "articleSection": "技术分享",
      "keywords": "SEO, 前端, 优化, 搜索引擎优化, JavaScript SEO"
    }
    </script>
</head>
  1. 产品 (Product) : (已在 Next.js 示例中包含)
  2. FAQ 页面 (FAQPage) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "FAQPage",
      "mainEntity": [{
        "@type": "Question",
        "name": "什么是前端SEO?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "前端SEO是指在网站的前端开发过程中,通过一系列技术和策略优化,使得网站内容更容易被搜索引擎发现、抓取、理解和索引,从而提升在搜索结果中的自然排名。"
        }
      }, {
        "@type": "Question",
        "name": "服务器端渲染 (SSR) 对SEO有什么好处?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "SSR可以确保搜索引擎爬虫在首次请求时就能获取到完整的HTML内容,避免了因客户端JavaScript执行问题导致的内容抓取不全,从而显著改善SPA(单页面应用)的SEO效果。"
        }
      }, {
        "@type": "Question",
        "name": "如何选择合适的图片格式进行SEO优化?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "选择图片格式时应考虑图片内容和压缩效率。JPEG适合照片类图像,PNG适合需要透明背景或颜色较少的图形,WebP则通常能在保证质量的前提下提供比JPEG和PNG更小的文件体积,有利于页面加载速度,对SEO有积极影响。确保为所有图片提供描述性的alt文本。"
        }
      }]
    }
    </script>
</head>
  1. 面包屑 (BreadcrumbList) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "BreadcrumbList",
      "itemListElement": [{
        "@type": "ListItem",
        "position": 1,
        "name": "首页",
        "item": "https://yourdomain.com/"
      },{
        "@type": "ListItem",
        "position": 2,
        "name": "产品中心",
        "item": "https://yourdomain.com/products"
      },{
        "@type": "ListItem",
        "position": 3,
        "name": "智能聊天机器人XYZ"
        // "item" is optional for the last item if it's the current page
      }]
    }
    </script>
</head>
  1. 本地商家 (LocalBusiness) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "Restaurant", // 或 "Store", "Dentist", etc.
      "name": "美味中餐厅",
      "image": "https://yourdomain.com/images/restaurant-front.jpg",
      "@id": "https://yourdomain.com/restaurant-beijing", // 可选,页面的规范URL
      "url": "https://yourdomain.com/restaurant-beijing",
      "telephone": "+86-10-12345678",
      "priceRange": "$$", // 或 "¥¥", "€€"
      "menu": "https://yourdomain.com/menu",
      "servesCuisine": "中餐",
      "acceptsReservations": "True",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "朝阳区建国路123号",
        "addressLocality": "北京市",
        "addressRegion": "北京",
        "postalCode": "100022",
        "addressCountry": "CN"
      },
      "geo": {
        "@type": "GeoCoordinates",
        "latitude": 39.9042,
        "longitude": 116.4074
      },
      "openingHoursSpecification": [{
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": [
          "Monday",
          "Tuesday",
          "Wednesday",
          "Thursday",
          "Friday"
        ],
        "opens": "11:00",
        "closes": "22:00"
      },{
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": [
          "Saturday",
          "Sunday"
        ],
        "opens": "10:00",
        "closes": "23:00"
      }],
      "aggregateRating": { // 如果有用户评分
        "@type": "AggregateRating",
        "ratingValue": "4.5",
        "reviewCount": "250"
      }
    }
    </script>
</head>

四、性能优化 (Core Web Vitals & Page Speed)

页面加载速度和用户体验是重要的排名因素。Google 的核心 Web 指标 (Core Web Vitals) 包括:

  • LCP (Largest Contentful Paint) : 最大内容绘制时间,衡量加载性能。目标:2.5 秒以内。
  • FID (First Input Delay) / INP (Interaction to Next Paint) : 首次输入延迟 / 下一次绘制交互,衡量交互性。FID 目标:100 毫秒以内。INP 是 FID 的演进,目标:200 毫秒以内。
  • CLS (Cumulative Layout Shift) : 累积布局偏移,衡量视觉稳定性。目标:0.1 以内。

前端优化措施:

  1. 优化图片: 已在前述提及(压缩、响应式、WebP、懒加载)。

  2. 代码压缩与精简 (Minification & Uglification) :

    • 使用 Webpack, Rollup, Parcel 等构建工具压缩 HTML, CSS, JavaScript 文件。
    • 移除未使用的 CSS 和 JavaScript (Tree Shaking)。
    // webpack.config.js (部分示例)
    const TerserPlugin = require('terser-webpack-plugin');
    const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
    
    module.exports = {
      // ...
      optimization: {
        minimize: true,
        minimizer: [
          new TerserPlugin({ // 压缩 JS
            terserOptions: {
              compress: {
                drop_console: true, // 生产环境移除 console
              },
            },
          }),
          new CssMinimizerPlugin(), // 压缩 CSS
        ],
        splitChunks: { // 代码分割
          chunks: 'all',
        },
      },
      // ...
    };
    
  3. 启用 Gzip 或 Brotli 压缩: 在服务器端配置,减小传输文件大小。

  4. 浏览器缓存 (Browser Caching) :

    • 通过 HTTP 头部(Cache-Control, Expires, ETag)指示浏览器缓存静态资源。
    • Service Worker 缓存:PWA 的核心技术,可以实现更精细的缓存控制和离线访问。
    // service-worker.js (非常简化的缓存优先策略示例)
    const CACHE_NAME = 'my-site-cache-v1';
    const urlsToCache = [
      '/',
      '/styles/main.css',
      '/scripts/main.js',
      '/images/logo.png'
      // ...其他核心资源
    ];
    
    self.addEventListener('install', event => {
      event.waitUntil(
        caches.open(CACHE_NAME)
          .then(cache => {
            console.log('Opened cache');
            return cache.addAll(urlsToCache);
          })
      );
    });
    
    self.addEventListener('fetch', event => {
      event.respondWith(
        caches.match(event.request)
          .then(response => {
            // 缓存命中 - 返回响应
            if (response) {
              return response;
            }
            // 未命中 - 从网络获取,并添加到缓存
            return fetch(event.request).then(
              networkResponse => {
                if(!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
                  return networkResponse;
                }
                const responseToCache = networkResponse.clone();
                caches.open(CACHE_NAME)
                  .then(cache => {
                    cache.put(event.request, responseToCache);
                  });
                return networkResponse;
              }
            );
          })
      );
    });
    
    self.addEventListener('activate', event => {
      const cacheWhitelist = [CACHE_NAME]; // 新版本缓存名
      event.waitUntil(
        caches.keys().then(cacheNames => {
          return Promise.all(
            cacheNames.map(cacheName => {
              if (cacheWhitelist.indexOf(cacheName) === -1) {
                return caches.delete(cacheName); // 删除旧缓存
              }
            })
          );
        })
      );
    });
    

    注册 Service Worker (在主 JS 文件中):

    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js')
          .then(registration => {
            console.log('ServiceWorker registration successful with scope: ', registration.scope);
          })
          .catch(error => {
            console.log('ServiceWorker registration failed: ', error);
          });
      });
    }
    
  5. 使用 CDN (Content Delivery Network) : 将静态资源分发到离用户更近的服务器,加快加载速度。

  6. 减少 HTTP 请求: 合并 CSS 和 JS 文件(构建工具会自动处理),使用 CSS Sprites 或 SVG Sprites。

  7. 优化关键渲染路径 (Critical Rendering Path) :

    • 内联关键 CSS (Above-the-fold CSS) 到 HTML <head> 中,以快速渲染首屏内容。
    • 异步加载非关键 CSS 和 JavaScript (<link rel="preload" as="style">, <script async>, <script defer>)。
    <head>
        <style>
            /* critical-path.css - 内联的关键CSS */
            body { font-family: sans-serif; margin: 0; }
            .header { background-color: #333; color: white; padding: 1em; }
            /* ...更多首屏渲染必需的样式... */
        </style>
        <!-- 异步加载剩余CSS -->
        <link rel="preload" href="styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
        <noscript><link rel="stylesheet" href="styles/main.css"></noscript>
    </head>
    <body>
        <!-- ...内容... -->
        <script src="scripts/main-bundle.js" defer></script>
        <script src="scripts/analytics.js" async></script>
    </body>
    
  8. 字体优化:

    • 使用 Web 字体时,确保字体文件尽可能小。
    • 使用 font-display: swap;font-display: optional; 避免 FOIT (Flash of Invisible Text)。
    • 预加载关键字体文件 (<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>)。

五、移动友好性与可访问性 (Mobile-Friendliness & Accessibility - A11y)

  1. 响应式网页设计 (Responsive Web Design - RWD) : 确保网站在各种设备(桌面、平板、手机)上都有良好的显示和用户体验。使用媒体查询 (@media)。

    /* 基础样式 */
    .container { width: 90%; margin: 0 auto; }
    .column { float: left; width: 100%; margin-bottom: 1em; }
    
    /* 中等屏幕 (例如平板) */
    @media (min-width: 768px) {
      .container { width: 80%; }
      .column.half { width: 50%; padding: 0 1%; box-sizing: border-box; }
    }
    
    /* 大屏幕 (例如桌面) */
    @media (min-width: 1024px) {
      .container { width: 1200px; max-width: 90%; }
      .column.third { width: 33.33%; padding: 0 1%; box-sizing: border-box; }
      .column.two-thirds { width: 66.66%; padding: 0 1%; box-sizing: border-box; }
    }
    

    Viewport Meta 标签 (必须) :

    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
  2. 可访问性 (A11y) :

    • 虽然不直接是排名因素,但良好的可访问性通常与良好的用户体验相关,并可能间接影响SEO。
    • 使用语义化 HTML。
    • 为图片提供 alt 文本。
    • 确保键盘可导航。
    • 足够的颜色对比度。
    • 使用 ARIA (Accessible Rich Internet Applications) 属性增强动态内容的可访问性(谨慎使用,优先使用原生 HTML 语义)。
    <!-- ARIA 示例: 一个可折叠区域 -->
    <button aria-expanded="false" aria-controls="collapsible-section" id="toggle-button">
      显示详情
    </button>
    <div id="collapsible-section" hidden>
      <p>这里是详细内容...</p>
    </div>
    
    <script>
      const toggleButton = document.getElementById('toggle-button');
      const collapsibleSection = document.getElementById('collapsible-section');
      toggleButton.addEventListener('click', () => {
        const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true' || false;
        toggleButton.setAttribute('aria-expanded', !isExpanded);
        collapsibleSection.hidden = isExpanded;
        toggleButton.textContent = isExpanded ? '显示详情' : '隐藏详情';
      });
    </script>
    

六、其他重要前端SEO注意事项

  1. HTTPS: 必须使用 HTTPS,Google 将其作为排名信号。

  2. 避免内容隐藏技巧 (Cloaking) : 不要向搜索引擎爬虫显示与用户看到的不同内容。

  3. 处理404错误: 创建一个友好的自定义404页面,并确保服务器对不存在的页面返回正确的404状态码。

    <!-- custom-404.html -->
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <title>页面未找到 (404) - XX公司</title>
        <meta name="robots" content="noindex"> <!-- 404页面不应被索引 -->
        <style>
            body { font-family: sans-serif; text-align: center; padding-top: 50px; }
            h1 { font-size: 3em; }
            p { font-size: 1.2em; }
            a { color: #007bff; text-decoration: none; }
        </style>
    </head>
    <body>
        <h1>:( 404 - 页面未找到</h1>
        <p>抱歉,您要查找的页面不存在或已被移动。</p>
        <p>您可以尝试:</p>
        <ul>
            <li><a href="/">返回首页</a></li>
            <li>使用站内搜索</li>
            <li>检查您输入的网址是否正确</li>
        </ul>
    </body>
    </html>
    

    服务器配置 (例如 Nginx):

    server {
        # ...
        error_page 404 /custom-404.html;
        location = /custom-404.html {
            root /path/to/your/html/files; # 指向你的404文件所在目录
            internal;
        }
        # ...
    }
    
  4. 301重定向: 对于永久移动的页面,使用301(永久重定向)将旧URL指向新URL,以传递权重。

  5. 国际化SEO (hreflang) : 如果网站有多种语言或地区版本,使用 hreflang 标签告知搜索引擎不同版本之间的关系。

    <head>
        <!-- ... -->
        <link rel="alternate" hreflang="en" href="https://yourdomain.com/en/page.html" />
        <link rel="alternate" hreflang="de" href="https://yourdomain.com/de/page.html" />
        <link rel="alternate" hreflang="zh-CN" href="https://yourdomain.com/zh/page.html" />
        <link rel="alternate" hreflang="x-default" href="https://yourdomain.com/en/page.html" /> <!-- 默认版本 -->
    </head>
    

    也可以在 XML 站点地图或 HTTP 头部中实现 hreflang

  6. 避免使用 Flash 或 Silverlight 等过时技术: 搜索引擎难以解析其内容。

  7. JavaScript 链接: 如果使用 JavaScript 生成链接 (例如 window.location.href 或框架的路由跳转),确保它们最终也呈现为带有 href 属性的标准 <a> 标签,以便爬虫能够发现和跟踪。

    // 不太好的方式 (爬虫可能不执行JS或不识别为链接)
    // <div onclick="navigateTo('/product/123')">产品123</div>
    
    // 更好的方式 (即使JS失效或爬虫不执行JS,仍是可访问链接)
    // <a href="/product/123" onclick="handleNavigation(event, '/product/123')">产品123</a>
    // function handleNavigation(event, url) {
    //   event.preventDefault(); // 阻止默认跳转
    //   // 使用JS进行路由跳转 (例如,React Router, Vue Router)
    //   router.push(url);
    // }
    

总结

前端 SEO 是一个持续优化的过程,涉及技术细节、内容策略和用户体验的方方面面。核心在于理解搜索引擎的工作原理,并为用户和爬虫都提供最佳的体验和最清晰的信息。建议结合使用 Google Search Console, Bing Webmaster Tools, Lighthouse, PageSpeed Insights 等工具来监控和改进你的 SEO 效果。

【开源软件推荐】 so-novel,一个超强的聚合式小说下载工具

作者 极客密码
2025年5月18日 16:34

📚 前言:网文阅读的困境与解法

你是否曾遇到这样的困扰:想看最新连载小说,要么被迫付费,要么忍受广告横飞的笔趣阁,要么被限制在特定平台的APP中?

虽然市面上已有不少阅读方案,但总有些难以满足的需求:有人嫌弃界面设计太丑,有人吐槽功能不够完善,还有人厌倦了被特定平台限制。在这种情况下,高度可定制化的阅读体验成为了许多书友的终极追求。

今天要介绍的"So Novel"正是为解决这些痛点而生的利器。它能够将全网流行小说(包括连载中和已完本的)一键下载为主流电子书格式,让你可以在任何喜爱的设备上实现真正的离线阅读体验。

🎯 谁适合使用So Novel?

  • 免费(白嫖)看正版网文 ,又追求 极致阅读体验 的资深书友
  • 习惯用手机阅读的iOS用户(由于无法使用"阅读"APP,可通过So Novel下载后导入Apple Books)
  • 喜欢在电脑或大屏设备上阅读的Windows、macOS、Linux用户
  • 钟爱专业电子书阅读器的发烧友
  • 讨厌在线阅读依赖性,习惯将书籍存储在本地的"囤书党"
  • 经常处于无网络环境需要离线阅读的通勤党
  • 追求"开箱即用",不想折腾复杂配置的普通用户

✨ So Novel 介绍

So Novel是一款交互式小说下载器,真正做到了跨平台、无门槛使用:

  • 🖥️ 跨系统支持:windows、maacos、linux皆可使用
  • 📦 开箱即用:下载解压后即可使用,无需任何额外配置
  • 🔍 多源聚合:内置多个书源,支持聚合搜索,无需手动切换
  • 🚀 极速下载:特别优化无限流书源的下载速度
  • 📑 多格式支持:可导出EPUB、TXT、HTML(支持翻页)、PDF等格式
  • 📚 批量下载:同时下载多部作品,效率翻倍
  • 🔄 簡繁转换:内置简繁互转功能,满足不同阅读习惯

如何下载

在 so-novel 官方 Github Releases 下载对应操作系统版本即可:

github.com/freeok/so-n…

注:如果访问不到Github或者下载很慢,这里同时也提供给百度网盘的下载链接:

链接: pan.baidu.com/s/1EfH5_nMT…
提取码: xfdm

我这里是 macos intel芯片,下载 sonovel-macos_x64.tar.gz 就可以,M系列芯片的下载 arm64 版本的

安装使用

下载后将压缩包解压

直接将当前目录拖到 终端 里去(windows 版本的解压后有个 exe 文件,直接双击就可以打开)

然后输入如下命令后,按回车

bash ./run-macos.sh

您可能会遇到这样一个无法验证开发者的警告信息,不用管它,点击取消:

点击 系统设置,找到 隐私与安全性

找到安全性,并改为 APP Store和被认可的开发者

点击仍然允许

再次回到终端,执行上次的命令回车即可打开。

接下来就是按提示输入序号,按回车就行了

需要聚合搜索,输入 q ,按回车,输入书名,按回车:

输入下载序号,按回车:

输入1 下载全本

下载完成后,会自动合并为 epub 格式

合并成功后的 epub 文件位于软件目录下的 downloads 文件夹

导入喜欢的阅读软件,开始享受阅读的乐趣吧!

闲着周末写了一个react vue3双版本的右键菜单插件

作者 codefishsss
2025年5月18日 16:17

前言

在Web应用开发中,右键菜单是一个常见的功能。今天,我要分享一个我开发的跨框架右键菜单插件 context-menu-plugin,它不仅支持Vue 3和React,还具备了丰富的功能特性和优秀的交互体验。

核心特性

1. 跨框架支持

这个插件最大的特点是同时支持Vue 3和React两大主流框架。通过抽象共享逻辑和样式,实现了框架无关的核心功能,同时又保持了框架特定的最佳实践。

// Vue 3 使用示例
<template>
<div v-context-menu="menuOptions"> 右键点击此区域 </div> </template> 

// React 使用示例
const [contextMenu] = useContextMenu(); 
<div onContextMenu={contextMenu}> 右键点击此区域 </div>

2. 丰富的功能支持

多级子菜单

支持无限层级的子菜单,每个子菜单都继承父菜单的主题和样式。

主题支持

内置四种主题:

  • Light(默认):清爽的白色主题

  • Dark:深色主题

  • Blue:蓝色主题

  • Green:绿色主题

通过CSS变量实现主题切换,方便用户自定义:

:root {
--cm-light-bg: #ffffff;
--cm-light-text: #333333;
--cm-light-hover: #f5f5f5;
--cm-light-border: #e8e8e8;
}

完整的键盘支持

  • ↑/↓:导航菜单项

  • →:打开子菜单

  • ←:返回上级菜单

  • Enter/Space:选择菜单项

  • Esc:关闭菜单

3. 优秀的交互体验

智能定位

自动检测视口边界,确保菜单始终在可见区域内显示:

// 位置计算逻辑
const position = { x: Math.min(event.clientX, window.innerWidth - menuWidth), y: Math.min(event.clientY, window.innerHeight - menuHeight) };

平滑动画

使用CSS动画提供流畅的过渡效果:

.context-menu { 
animation-duration: var(--cm-animation-duration); animation-name: cmFadeIn;
} 
@keyframes cmFadeIn { 
from { opacity: 0; transform: scale(0.95); } 
to { opacity: 1; transform: scale(1); } }

4. 可访问性支持

遵循WCAG准则,确保菜单对所有用户都可用:

<div role="menu" aria-orientation="vertical" aria-label="上下文菜单" > <div role="menuitem" aria-disabled={disabled} tabIndex={0} > 菜单项 </div>

5. 响应式设计

自动适配移动设备:

@media (max-width: 768px) { .context-menu { min-width: 160px; font-size: 16px; // 更大的字体 .cm-item { padding: 10px 16px; // 更大的点击区域 } } }

技术实现亮点

1. Portal/Teleport技术

为了避免菜单被父元素的overflowz-index影响,使用Portal/Teleport将菜单渲染到body层级:

// React版本
return ReactDOM.createPortal( <ContextMenu />, document.body ); 

// Vue 3版本
<Teleport to="body"> <ContextMenu /> </Teleport>

2. 共享核心逻辑

通过抽象共享代码,确保两个框架版本的行为一致:

src/ 
├── shared/ 
│   ├── contextAware.js  // 上下文感知逻辑 
│   ├── i18n.ts         // 国际化支持 
│   ├── types.ts       // TypeScript类型定义
│   └── utils.js      // 通用工具函数 
├── react/           // React实现 
└── vue3/           // Vue 3实现

3. 性能优化

  • 使用防抖处理窗口调整事件
  • 使用事件委托处理菜单项点击
  • 懒加载子菜单内容

使用方法

安装

Vue 3使用示例

<script setup>
import { useContextMenu } from 'context-menu-plugin/vue3'; 
const menuOptions = { 
items: [ 
{ label: '新建', icon: '📄', 
children: [ 
{ label: '文件', onClick: () => {} },
{ label: '文件夹', onClick: () => {} } ] }, 
{ type: 'separator' }, 
{ label: '刷新', shortcut: 'Ctrl+R', onClick: () => {} } ] }; 
</script> 
<template> 
<div v-context-menu="menuOptions"> 右键点击此区域 </div> 
</template>

React使用示例

import { useContextMenu } from 'context-menu-plugin/react'; 
function App() { 
const [contextMenu] = useContextMenu({ 
items: [ 
{ label: '新建', icon: '📄', children: [ { label: '文件', onClick: () => {} }, 
{ label: '文件夹', onClick: () => {} } ] }, 
{ type: 'separator' }, 
{ label: '刷新', shortcut: 'Ctrl+R', onClick: () => {} } ] }); 
return ( <div onContextMenu={contextMenu}> 右键点击此区域 </div> ); 
}

总结

这个右键菜单插件通过跨框架支持、丰富的功能特性、优秀的交互体验和完善的可访问性支持,为Web应用开发提供了一个强大而灵活的解决方案。无论是Vue 3还是React项目,都能轻松集成并提供一致的用户体验。

未来计划

  1. 添加更多主题预设
  2. 支持更多自定义选项
  3. 添加更多动画效果
  4. 优化移动端体验
  5. 添加更多辅助功能支持

JavaScript执行栈和执行上下文

2025年5月18日 16:16

在JavaScript中,执行栈和执行上下文是理解代码执行流程和作用域链的关键概念。它们决定了代码如何执行以及变量和函数如何被查找和访问。本文将详细介绍执行上下文的生命周期、执行栈的工作原理以及它们在实际编程中的应用。

一、执行上下文

(一)什么是执行上下文?

执行上下文(Execution Context)是JavaScript代码执行的环境。它是一个抽象的概念,用于描述代码在运行时的状态。每当JavaScript代码运行时,它都在某个执行上下文中运行。

(二)执行上下文的类型

JavaScript中有三种执行上下文类型:

  1. 全局执行上下文:这是默认的上下文,任何不在函数内部的代码都在全局上下文中运行。全局执行上下文在页面加载时创建,当页面关闭时销毁。
  2. 函数执行上下文:每当一个函数被调用时,都会为该函数创建一个新的执行上下文。函数执行上下文在函数调用时创建,函数执行完成后销毁。
  3. eval函数执行上下文eval函数内部的代码也有自己的执行上下文。不过,eval函数的使用并不推荐,因为它会带来安全问题和性能问题。

(三)执行上下文的生命周期

执行上下文的生命周期分为两个阶段:

  1. 创建阶段:当代码执行进入一个环境时,会创建一个执行上下文。在这个阶段,执行上下文会进行以下操作:

    • 创建变量对象(Variable Object,VO):包括函数的形参、arguments对象、函数声明和变量声明。
    • 确定this的指向。
    • 确定作用域链。
  2. 执行阶段:在执行阶段,代码开始执行,变量被赋值,函数被调用,其他代码按顺序执行。

二、执行栈

(一)什么是执行栈?

执行栈(Call Stack)是JavaScript运行时用来管理执行上下文的一种数据结构。它是一个后进先出(LIFO)的栈结构,用于跟踪函数调用的顺序。

(二)执行栈的工作原理

  1. 入栈:当代码执行进入一个新的环境时,对应的执行上下文会被推入执行栈中。
  2. 出栈:当函数执行完成时,对应的执行上下文会被从执行栈中弹出,控制权交由下一个执行上下文。

(三)执行栈的特点

  • 后进先出:最后进入执行栈的执行上下文最先被弹出。
  • 栈顶是当前执行的上下文:执行栈的栈顶总是当前正在执行的函数的执行上下文。

(四)执行栈的图解

以下是一个具体的代码示例及其对应的执行栈图解:

function foo() { 
    function bar() {        
        return 'I am bar';
    }
    return bar();
}
foo();

对应的执行栈图解如下:

执行栈图解

(五)执行栈的数量限制

虽然执行上下文的数量没有明确的限制,但如果超出栈分配的空间,会造成堆栈溢出。常见于递归调用,没有终止条件造成死循环的场景。

// 递归调用自身
function foo() {
    foo();
}
foo();
// 报错:Uncaught RangeError: Maximum call stack size exceeded

三、执行上下文的生命周期

(一)创建阶段

在创建阶段,执行上下文会进行以下操作:

  1. 创建变量对象(VO)

    • 确定函数的形参(并赋值)。
    • 初始化arguments对象(并赋值)。
    • 确定普通字面量形式的函数声明(并赋值)。
    • 变量声明,函数表达式声明(未赋值)。
  2. 确定this的指向this的值由调用者决定。

  3. 确定作用域:由词法环境决定,哪里声明定义,就在哪里确定。

(二)执行阶段

在执行阶段,执行上下文会进行以下操作:

  1. 变量对象赋值

    • 变量赋值。
    • 函数表达式赋值。
  2. 调用函数

  3. 顺序执行其他代码

四、变量对象

变量对象(Variable Object,VO)是执行上下文的一个重要组成部分,它是一个包含变量、函数声明和形参的对象。在创建阶段,变量对象会被初始化,包括以下内容:

  • arguments对象:包含函数调用时传入的参数。
  • 形参:函数的形参会被赋值。
  • 函数声明:函数声明会被提升并赋值。
  • 变量声明:变量声明会被提升,但未赋值。

(一)变量对象的示例

以下是一个具体的代码示例及其对应的变量对象:

const foo = function(i) {
    var a = "Hello";
    var b = function privateB() {};
    function c() {}
}
foo(10);

在创建阶段,变量对象如下:

fooExecutionContext = {
    variableObject: {
        arguments: {0: 10, length: 1}, // 确定Arguments对象
        i: 10, // 确定形参
        c: pointer to function c(), // 确定函数引用
        a: undefined, // 局部变量初始值为undefined
        b: undefined // 局部变量初始值为undefined
    },
    scopeChain: {},
    this: {}
}

在执行阶段,变量对象如下:

fooExecutionContext = {
    variableObject: {
        arguments: {0: 10, length: 1},
        i: 10,
        c: pointer to function c(),
        a: "Hello", // a变量被赋值为Hello
        b: pointer to function privateB() // b变量被赋值为privateB()函数
    },
    scopeChain: {},
    this: {}
}

五、总结

执行上下文和执行栈是JavaScript中非常重要的概念。理解它们的工作原理和生命周期,可以帮助你更好地理解代码的执行流程和作用域链。

变量声明需谨慎!!!💣这几种声明变量的方式(var、let、const)还有作用域,绝不能含糊!

2025年5月18日 15:56

引言

JavaScript作为一门动态脚本语言,其变量声明机制和作用域规则一直是我们需要深入理解的核心内容。从早期的var到ES6引入的letconst,JavaScript的变量管理方式经历了显著的变化。今天,我们从多个角度,全面解析varletconst的异同。

一、JS代码的执行机制

对于给定的JS代码文件,首先要做的是将其从硬盘读入内存,然后开始执行。JavaScript代码的执行依赖于引擎,如Chrome的V8引擎。V8负责将代码从硬盘读入内存后,进行解析、编译和优化。其核心流程分为两个阶段:

  • 编译阶段:引擎对代码进行词法分析、语法分析,并确定作用域规则。
  • 执行阶段:逐行执行代码,处理变量赋值、函数调用等操作。

二、作用域与作用域链

2.1 作用域的类型

作用域是变量和函数的可访问性规则,JavaScript中分为三类:

  1. 全局作用域:在函数或代码块外声明的变量,全局可访问。
  2. 函数作用域:在函数内部声明的变量,仅函数内可见。
  3. 块级作用域(ES6新增):由{}包裹的代码块(如iffor),使用letconst声明的变量仅块内有效。

2.2 作用域链的运作机制

当访问一个变量时,引擎会按照作用域链逐层查找:

当前作用域 → 父级作用域1 → 父级作用域2 → ... → 全局作用域
(ps:父作用域可嵌套)

这种链式结构确保了变量的层级隔离性。

function outer() {
    let a = 10;
    function inner() {
        console.log(a); // 通过作用域链找到outer的a
    }
    inner();
}
outer(); // 输出10

image.png

三、变量提升(Hoisting)

3.1 var的变量提升

在编译阶段,var声明的变量会被提升到作用域顶部,并初始化为undefined,而赋值操作保留在执行阶段。

示例

console.log(x); // undefined
var x = 5;
console.log(x); // 5

等效于:

var x; // 提升声明
console.log(x); // undefined
x = 5; // 赋值
console.log(x); // 5

3.2 let的变量提升

let声明的变量也会被提升到其所在的作用域顶部,但与var不同,let声明的变量在初始化之前会进入一个“暂时性死区”(Temporal Dead Zone, TDZ)。在这个区域内,变量是“提升”了,但尚未初始化,因此不能被访问,任何尝试访问这些变量的操作都会抛出[ReferenceError]

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;

3.3 函数声明提升

函数声明整体被提升,提升的是定义,而不是调用,因此可以在声明前调用:

showName() // 驼峰式命名
console.log(myName);

var myName = 'wym'
function showName() {
  let b = 2;
  console.log(myName)
  console.log('函数执行了')
}

运行结果:

image.png 解析:这段代码等效于

function showName() { 
  let b = 2; 
  console.log(myName) 
  console.log('函数执行了') 
}

showName()
console.log(myName)
var myName = 'wym'

注意:函数和变量之间,函数通常先于变量提升,即:优先提升函数,后提升变量。

四、var、let、const的全面对比

4.1 作用域差异

关键字 作用域 重复声明 变量提升 必须初始化
var 函数/全局 允许
let 块级 不允许 否(TDZ)
const 块级 不允许 否(TDZ)

4.2 使用场景分析

  • var:ES6之前的主要声明方式,因作用域和提升问题,现不推荐使用。
  • let:适用于需要重新赋值的块级变量(如循环计数器)。
  • const:声明常量或引用类型(对象、数组),确保变量指向不变。

五、建议

定义变量时如果后续不需要修改了,建议优先使用const,提高代码可读性和安全性;次选let,杜绝重复声明报错,作用域更安全,其独特的TDZ机制也能够有效防止在声明前访问变量;var能不用就不用,防止变量污染和意外覆盖。

React 中的 Immutable

2025年5月18日 15:41

React 中的 Immutable 概念

Immutable(不可变)是 React 开发中的一个重要概念,指的是数据一旦创建就不能被直接修改。在 React 中,正确处理不可变性对于性能优化和状态管理至关重要。

为什么需要 Immutable

  1. 性能优化:React 依赖浅比较(shallow comparison)来判断组件是否需要重新渲染
  2. 可预测性:不可变数据使状态变化更易于追踪和调试
  3. 时间旅行调试:可以轻松实现撤销/重做功能

在 React 中实践 Immutable

1. 状态更新

错误做法(直接修改状态):

javascript
复制
// ❌ 错误 - 直接修改状态
this.state.comments.push({id: 1, text: "Hello"});
this.setState({comments: this.state.comments});

正确做法(创建新对象/数组):

javascript
复制
// ✅ 正确 - 创建新数组
this.setState({
  comments: [...this.state.comments, {id: 1, text: "Hello"}]
});

2. 常见不可变操作

数组
javascript
复制
// 添加元素
const newArray = [...oldArray, newItem];

// 删除元素
const newArray = oldArray.filter(item => item.id !== idToRemove);

// 更新元素
const newArray = oldArray.map(item => 
  item.id === idToUpdate ? {...item, ...updatedProps} : item
);
对象
javascript
复制
// 更新属性
const newObj = {...oldObj, key: newValue};

// 嵌套对象更新
const newObj = {
  ...oldObj,
  nested: {
    ...oldObj.nested,
    key: newValue
  }
};

3. 使用 Immutable.js 库

Facebook 提供的 Immutable.js 提供了专门的不可变数据结构:

javascript
复制
import { List, Map } from 'immutable';

const list1 = List([1, 2, 3]);
const list2 = list1.push(4); // 返回新列表,不修改原列表

const map1 = Map({ a: 1, b: 2 });
const map2 = map1.set('a', 3); // 返回新映射

性能考虑

对于大型数据结构,使用扩展运算符(...)可能会产生性能问题,因为需要复制整个对象/数组。这时可以考虑:

  1. 使用 Immutable.js 或类似的库
  2. 使用 Immer 库(提供更方便的不可变更新语法)
javascript
复制
import produce from 'immer';

const nextState = produce(currentState, draft => {
  draft.todos.push({id: 1, text: "Learn Immutable"});
});

总结

在 React 中遵循不可变原则可以:

  • 避免意外的副作用
  • 优化组件渲染性能
  • 简化复杂的状态管理
  • 实现更可靠的调试功能

正确使用不可变更新是成为高效 React 开发者的关键技能之一。

redux中为什么要Immutable

2025年5月18日 15:37

1. 核心原因

(1) 可预测性(Predictability)

  • Redux 的核心原则:状态变更必须通过 纯函数(Reducer) 显式声明,禁止直接修改原状态。

  • 为什么?

    • 如果允许直接修改状态(Mutable),多个代码片段可能同时修改同一对象,导致难以追踪变化来源。
    • Immutable 确保每次状态变更都是显式的,只能通过 dispatch(action) 触发,使数据流更清晰。

(2) 性能优化(Shallow Comparison)

  • React-Redux 的 connect 或 useSelector 依赖浅比较(shallow equality check)

    javascript
    复制
    // React-Redux 内部逻辑(伪代码)
    function useSelector(selector) {
      const newState = selector(store.getState());
      if (newState === prevState) {
        return prevState; // 如果引用相同,跳过重新渲染
      }
      // 否则触发组件更新
    }
    
    • 如果直接修改原状态(Mutable)newState === prevState 始终为 true,组件不会更新。
    • Immutable 确保每次变更返回新对象,使浅比较能正确检测变化。

(3) 时间旅行调试(Time-Travel Debugging)

  • Redux DevTools 的核心功能:记录所有状态快照,允许回溯到任意历史状态。

    • 如果状态可变(Mutable)

      • 历史状态会被后续修改污染,无法正确还原。
    • Immutable 保证每次状态独立,快照机制才能正常工作。


2. 违反 Immutable 的后果

❌ 错误示例:直接修改状态

javascript
复制
const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      state.count++; // ❌ 直接修改原状态
      return state;  // 返回相同的引用
    default:
      return state;
  }
};

问题

  1. Redux DevTools 无法记录正确历史(所有快照指向同一对象)。
  2. React-Redux 不会触发重新渲染(浅比较发现 state === newState)。
  3. 代码难以维护:其他代码可能依赖未被克隆的旧状态。

✅ 正确做法:返回新对象

javascript
复制
const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 }; // ✅ 新对象
    default:
      return state;
  }
};

3. 如何实现 Immutable?

(1) 原生 JavaScript(适合简单结构)

  • 对象{ ...oldObj, key: newValue }

  • 数组

    • 添加:[...arr, newItem]
    • 删除:arr.filter(item => item.id !== id)
    • 更新:arr.map(item => item.id === id ? newItem : item)

(2) 使用 Immer(推荐)

javascript
复制
import produce from 'immer';

const reducer = (state, action) => 
  produce(state, draft => {
    switch (action.type) {
      case 'UPDATE':
        draft.user.name = 'New Name'; // ✅ 看似直接修改,实际生成新对象
        break;
    }
  });

优点

  • 语法更直观,自动处理深层嵌套。
  • Redux Toolkit 已内置 Immer

(3) Immutable.js(较复杂)

  • 提供 MapList 等不可变数据结构,但需配合其 API 使用:

    javascript
    复制
    import { Map } from 'immutable';
    const state = Map({ count: 0 });
    const newState = state.set('count', 1); // 返回新对象
    

4. 总结

原因 解释
可预测性 确保状态变更只能通过 action → reducer 显式触发,避免隐蔽的副作用。
性能优化 浅比较(shallow comparison)依赖引用变化检测,Immutable 是必要条件。
时间旅行调试 Redux DevTools 需要完整的状态快照,Mutable 会导致历史记录污染。

最佳实践

  • 简单场景:用扩展运算符(...)或数组方法。
  • 复杂场景:用 Immer(Redux Toolkit 默认支持)。
  • 历史项目:可考虑 Immutable.js,但学习成本较高。

Immutable 是 Redux 架构的基石,理解它能帮助你写出更健壮、可维护的状态管理代码。

**

**

**

**

《JavaScript语言精粹》读书笔记之第3章:对象Object

作者 小飞悟
2025年5月18日 15:30

小飞悟申明:小编的笔记只针对强者!!!

一、对象字面量 Object Literals

属性名可以是包括空字符串在内的任何字符串。在对象字面量中,如果属性名是一个合法的JavaScript标识符且不是保留字,则并不强制要求用引号括住属性名。所以用引号括住"first-name"是必需的,但是否括住first_name则是可选的。逗号用来分隔多个“名/值”对。

  1. 合法标识符 :
  • 如果属性名是合法的JavaScript标识符且不是保留字,可以不加引号。
- 例如:
const person = {
    firstName: 'John',
    lastName: 'Doe'
};


var flight = {
       airline: "Oceanic",
       number: 815,
       departure: {
          IATA: "SYD",
          time: "2004-09-22 14:55",
          city: "Sydney"
       },
  1. 非法标识符 :
  • 如果属性名包含特殊字符、空格或不是合法标识符,必须加引号。
- 例如
const person = {
    "first-name": 'John',
    "last name": 'Doe'
};

二、检索 Retrieval

要检索对象里包含的值,可以采用在[ ]后缀中括住一个字符串表达式的方式。如果字符串表达式是一个字符串字面量,而且它是一个合法的JavaScript标识符且不是保留字,那么也可以用.表示法代替。优先考虑使用.表示法,因为它更紧凑且可读性更好。

    stooge["first-name"]    // "Jerome"
    flight.departure.IATA   // "SYD"

运算符可以用来填充默认值:

    var middle = stooge["middle-name"] || "(none)";
    var status = flight.status || "unknown";

尝试从undefined的成员属性中取值将会导致TypeError异常。这时可以通过&&运算符来避免错误。

    flight.equipment                            // undefined
    flight.equipment.model                      // throw "TypeError"
    flight.equipment && flight.equipment.model  // undefined

三、引用 Reference

对象通过引用来传递。它们永远不会被复制:

理解对象引用

在JavaScript中,对象是通过引用来传递的,这意味着当将一个对象赋值给另一个变量时,两个变量实际上指向同一个对象。以下是对代码的详细解释:

  1. 对象引用 :
var x = stooge;
x.nickname = 'Curly';
var nick = stooge.nickname;
  • x 和 stooge 指向同一个对象,因此修改 x 的属性也会影响 stooge 。
  • nick 的值为 'Curly' ,因为 x 和 stooge 是同一个对象的引用。
  1. 多个对象引用 :
var a = {}, b = {}, c = {};
  • a 、 b 和 c 分别引用不同的空对象。
  1. 同一对象引用 :
a = b = c = {};
  • a 、 b 和 c 现在都引用同一个空对象。

四、原型 Prototype(简单介绍,后续会细讲)

每个对象都连接到一个原型对象,并且它可以从中继承属性。所有通过对象字面量创建的对象都连接到Object.prototype,它是JavaScript中的标配对象。 原型连接只有在检索值的时候才被用到。如果我们尝试去获取对象的某个属性值,但该对象没有此属性名,那么JavaScript会试着从原型对象中获取属性值。如果那个原型对象也没有该属性,那么再从它的原型中寻找,依此类推,直到该过程最后到达终点Object.prototype。如果想要的属性完全不存在于原型链中,那么结果就是undefined值。这个过程称为委托。

  • 这里仅是定义,后续会详细讲

刚开始有点难理解很正常,建议先看看blog.csdn.net/flyingpig20…

五、反射 Refelection

  • 用typeof操作符来确定属性类型很有帮助。
    typeof flight.number      // 'number'
    typeof flight.status      // 'string'
    typeof flight.arrival     // 'object'
    typeof flight.manifest    // 'undefined'
  • 在JavaScript中,原型链上的属性(如 toString 和 constructor )可能会产生值,但这些值通常是函数,可能并非我们需要的。以下是两种处理这些不需要的属性的方法:
    typeof flight.toString     // 'function'
    typeof flight.constructor  // 'function'
  1. 检查并丢弃函数值 :

    • 在程序中检查属性值是否为函数,如果是则丢弃。
    • 适用于需要动态获取对象信息且仅关注数据的场景。
  2. 使用 hasOwnProperty 方法 :

    • hasOwnProperty 方法用于检查对象是否拥有独有的属性(不检查原型链)。
    • 示例:
      flight.hasOwnProperty
      ('number');      // true
      flight.hasOwnProperty
      ('constructor'); // false
      
    • 适用于需要区分对象自身属性和继承属性的场景。 通过这两种方法,可以更精确地处理对象属性,避免不必要的函数值干扰。

六、枚举Enumeration

for in 语句的总结

for in 语句用于遍历对象的所有属性名,包括原型链中的属性。为了过滤掉不需要的属性,可以使用 hasOwnProperty 方法或 typeof 来排除函数。

示例 1:过滤函数属性

var name;
for (name in another_stooge) {
  if (typeof another_stooge[name] !== 'function') {
    document.writeln(name + ': ' + another_stooge[name]);
  }
}

示例 2:按特定顺序遍历属性

var i;
var properties = ['first-name', 'middle-name', 'last-name', 'profession'];
for (i = 0; i < properties.length; i += 1) {
  document.writeln(properties[i] + ': ' + another_stooge[properties[i]]);
}

总结:for in 语句遍历对象属性时,属性顺序不确定。如果需要特定顺序,可以使用数组存储属性名,并通过 for 循环遍历。

七、删除Delte

删除对象属性的总结

delete 运算符用于删除对象的属性,但它不会影响原型链中的属性。如果删除的对象属性存在于原型链中,删除后原型链中的属性会“透现”出来。

示例

const stooge = {
  nickname: 'Curly'
};
const another_stooge = Object.create(stooge);
another_stooge.nickname = 'Moe';
console.log(another_stooge.nickname); // 'Moe'

delete another_stooge.nickname;
console.log(another_stooge.nickname); // 'Curly'(来自原型链)

解释

  1. another_stooge对象继承了stooge对象的nickname属性。
  2. 通过delete删除了another_stooge自身的nickname属性后,原型链中的nickname属性(值为'Curly')会显示出来。

通过这个示例,可以更好地理解delete运算符的作用及其对原型链的影响。

关键点

  1. 删除属性delete 删除对象自身的属性。
  2. 原型链delete 不会影响原型链中的属性。
  3. 透现属性:如果删除的属性存在于原型链中,删除后原型链中的属性会显示出来。

八、减少全局变量污染 Global Abatement

减少全局变量污染

在JavaScript中,全局变量(var)会削弱程序的灵活性,应尽量避免使用。最小化全局变量污染的一种方法是创建一个唯一的全局变量作为应用的容器,将所有全局性资源纳入该名称空间下。

示例

var MYAPP = {};
MYAPP.stooge = {
  "first-name": "Joe",
  "last-name": "Howard"
};
MYAPP.flight = {
  airline: "Oceanic",
  number: 815,
  departure: {
    IATA: "SYD",
    time: "2004-09-22 14:55",
    city: "Sydney"
  },
  arrival: {
    IATA: "LAX",
    time: "2004-09-23 10:42",
    city: "Los Angeles"
  }
};


通过将全局资源集中在一个名称空间下,可以显著降低程序与其他应用程序、组件或类库之间的冲突风险,同时提高代码的可读性和维护性。但方法不止一种,ES6推出了let,const,在后面章节将会详细介绍,先看下区别:

let 、var 和const的区别

let和const 与var有什么区别

let和const 是es6的新语法,在函数预编译的时候会进行变量提升,这样在变量还没有赋值的时候就可以进行访问。

但是let和const不会,而且let和const遇到{}会形成块级作用域,并且let和const在声明之前是不能访问的,也不能访问

外部具有相同名字的变量因为会形成暂时性死区。这就是let、const和var的区别。

let和const的区别

它们两个的区别主要在let是声明变量,而const是声明常量的。

结语:

本文简单讲了下JavaScript中对象操作的核心概念,包括对象字面量、属性检索、原型链、反射、枚举、删除操作以及减少全局变量污染等关键点。现阶段建议读者继续深入了解下原型链和代理模式,后续小编还会奉上精彩好文!!!

前端实习踩过的坑之CSS系列

作者 秦盼儿
2025年5月18日 15:16

【前言】

时间过得真的很快,作为25届毕业生的我即将结束在南京接近6个月的实习。现在处于投简历背八股找工作状态,分享下我在这次充实的实习之旅中遇到的问题和解决方案,在梳理所学知识的同时也能帮助和我一样的前端新人。

1️⃣第一个坑不要太熟悉哦

没错,它就是外边距塌陷问题,我解决这个问题也很简单粗暴,

解决方法: 自己算一下外边距,尽量只设置一个边距

2️⃣渐变色边框导致边框圆角失效

很多朋友都遇到过使用渐变色边框的时候导致圆角失效,那么怎么解决呢?直接上代码截图 效果图:

image.png

这样写边框圆角是不生效的

image.png

解决方法: 使用 background-clip 实现 设置两层渐变

    border: 1px solid transparent;
    border-radius: 20px;
    background-image: linear-gradient(
        54deg,
        #fa7332 0%,
        #ed395f 34%,
        #ea2837 100%
      ),
      linear-gradient(rgba(249, 162, 156, 1) 0%, rgba(255, 129, 122, 0.43) 100%);
    background- origin: border-box;//使图片完整展示
    background- clip: content-box, border-box;
    //第一个表示裁剪至内容区值,第二个表示裁剪至边框取值

3️⃣图片叠加问题

效果图:

image.png

解决方法: 绝对定位和z-Index

.swiper_item_cover {
        position: relative;
        width: 160.6px;
        height: 170px;
        
        .swiper_item_cover_1 {
          position: absolute;
          z- index: 2;
          top: 0;
          left: 0;
          width: 106px;
          height: 170px;
        }
        .swiper_item_cover_2 {
          position: absolute;
          z- index: 1;
          top:25px;
          right: 0;
          width: 77px;
          height: 134px;
        }
      }

4️⃣子元素继承父元素的透明度

解决方法: 利用rgba 间接设置透明度

5️⃣兄弟元素的默认行为导致层级覆盖

原因: 如果兄弟元素没有设置position,默认情况下,它的position的值是static。 static元素不参与堆叠上下文 ,因此它们的z-index默认值是auto,这意味着它们会在所有absolute定位元素之上。

解决方法: 给另一个兄弟元素设置 position:relative

6️⃣文本溢出显示省略号

解决方法:

      text-overflow: ellipsis;
      overflow: hidden;
      word-break: break-all;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 1;/*修改这个数字可以变成多行省略*/
      line-clamp: 1;/*这个也要改,scss中可以用@mixin封装成一个方法*/

scss封装示例

@mixin ellicpsisN($lineCount) {
  text-overflow: ellipsis;
  overflow: hidden;
  word-break: break-all;
  display: -webkit-box;
  -webkit-box-orient: vertical;//弹性盒模型
  -webkit-line-clamp: $lineCount;//弹性盒模型方向垂直//弹性盒模型方向垂直
  -webkit-line-clamp: 1;//限制显示的行数
}
//用法
@include ellicpsisN(1);

7️⃣自定义滚动条的样式

UI给图滚动条样式要调整,别急,它来啦

解决方法:

//设置滚动条的宽度高度和背景颜色
    ::-webkit-scrollbar { 
      width: 6px;
      height: 8px;
      background- color: #ebeef5;
    }
//设置滚动条滑块的阴影和背景颜色
    ::-webkit-scrollbar-thumb {
      box- shadow: inset 0 0 6px rgba(0, 0, 0, .3);
      -webkit-box- shadow: inset 0 0 6px rgba(0, 0, 0, .3);
      background- color: #ccc;
    }
//设置滚动条轨道的阴影圆角和背景颜色
    ::-webkit-scrollbar-track{
      box- shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
      border- radius: 3px;
      background: rgba(255, 255, 255, 1);
}

8️⃣ 通知弹窗内部自定义html样式

效果图

image.png

解决方法:

const handleClickEmployee = (row) => {
      const h = this.$createElement;
      this.$notify({
        title: '提示',
        message: h(
          'i',
          { style: 'color: teal' },
          'This is a reminder'
        ),
      });
    };
//利用elment-plus 的组件
 ElNotification({
    title: 'Title',
    message: h('i', { style: 'color: teal' }, 'This is a reminder'),
  })

9️⃣ 利用i标签和elment-plus来快速渲染图标

官网上有更多图标可以选,这是几个使用示例哦

解决方法:

image.png

🔟移动端项目禁用点击高亮和文本选择

解决方法:

body {
  -webkit-tap-highlight- color: transparent;//禁用点击高亮
  outline: 0;//移除默认轮廓线
  user-select: none;//禁用文本选择
  -webkit-user-select: none;
  -webkit-text-size-adjust: 100%;//调整文字大小为100%
  width: 100%;
}

篇幅限制,本期前端新手容易遇到的CSS坑分享到这里!这几天会逐步更新完这个系列,欢迎大家留言和指导~

从权限管理到编译原理

作者 GaoNengwWw
2025年5月18日 14:40

77406616_p0_master1200.jpg

背景图来自: ああもんどCheytac M200

本文共预计阅读时间 5分钟+

大家好我是 GaoNeng-wWw. 前几天在用Nest.js写SSO后端的时候涉及到一些复杂的权限管理. 例如 如果有 A 和 B 权限 或 有 A 和 C 权限 那么则允许访问. 解决方案是写一个修饰器, 接受一个对象, 用于描述权限表达式, 类似于AST. 该修饰器作用于接口, 启动时为成员设置元信息, 接收到请求后 Guard 反射读取表达式对象, 而后调用 judge 函数判断用户是否满足该表达式。

绝望的表达式.png

考虑到后面可能会接入更多的权限, 为了保护开发人员的大脑和个人安全, 不得不含泪搓了一个普拉特解析器。

演示.png

这样一来, 开发人员的心智不仅得到了保护, 就连自己的个人安全也得到了保证。等写完后端后,便将该功能迁移到了前端。

阅读完本文, 你将能够有以下的收获

  1. v-permission 的封装
  2. 编译原理基础知识
  3. 如何写一个 Pratt 解析器
  4. 如何写一个简单的Vite插件

文中所有的代码(包括课后习题答案)都可以在 Permission-Compiler中找到.

封装一个完备的 v-permission

在这里, 当我们讨论 完备 的时候, 实际上我们在讨论能不能满足基本逻辑运算. 即: 与、或、非。

如果单单的使用数组来表示很难做到表达基本的逻辑运算. 所以我们可以使用一个或多个的对象组合而成. 下面的代码块则是一个完备的 PermissionExpression.

type And = {
  lhs: PermissionExpr;
  rhs: PermissionExpr;
  type: 'AND';
}
type Or = {
  lhs: PermissionExpr;
  rhs: PermissionExpr;
  type: 'OR';
}
type Not = {
  expr: PermissionExpr;
  type: 'NOT';
}
type Has = {
  val: string;
  type: 'HAS'
}
type PermissionExpr = Has | And | Or | Not;

这样我们就定义了一个完备的权限表达式对象. 其中 And, Or, Not 对应的是与或非. Has 类型表示的是, 某个用户是否拥有某个权限.

当且仅当用户存在权限 a 时候 {type: 'Has', val: 'a'} 会为true.

有了上述的类型,我们便可以封装一个较为完备的 v-permission 了

// directive/v-permission.ts
export default {
  created: (el: Element, binding)=>{
    if (!isValid(binding.value)){
      el.innerHTML = '<!-- -->'
    }
  },
} as Directive<Element, PermissionExpr>;

对于 isValid 函数, 实现起来也非常容易。因为 PermissionExpr 本质就是一颗树。所以我们可以很轻松的给出 isValid 的实现

const judge = (expr: PermissionExpr, userPermission: string[]):boolean => {
  if (expr.type === 'HAS') {
    return userPermission.includes(expr.val);
  }
  if (expr.type === 'AND'){
    return judge(expr.lhs, userPermission) && judge(expr.rhs, userPermission);
  }
  if (expr.type === 'OR') {
    return judge(expr.lhs, userPermission) || judge(expr.rhs, userPermission);
  }
  if (expr.type === 'NOT'){
    return !judge(expr.expr, userPermission);
  }
  return false;
}

const isValid = (node:PermissionExpr) => {
  const { permissions } = useAccount();
  return judge(node);
}

回到我们的 vue 文件中. 我们可以直接定义权限表达式看一下效果, 如果没问题,应该显示 完 全 勝 利 四个字

<!-- App.vue -->
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>
<template>
  <span v-permission="{type: 'HAS', val: 'a'}">完 全 勝 利</span>
</template>
// mock-store.ts
import { ref, type Ref } from "vue"

const permissions:Ref<string[]> = ref(['a']);

export const useAccount = () => {
  const add = (permission: string) => {
    permissions.value.push(permission);
  }
  const remove = (permission: string) => {
    permissions.value = permissions.value.filter(p => p === permission);
  }
  const has = (permission: string) => permissions.value.some(p => p === permission);
  return { add, remove, has, permissions };
}

完全胜利.png

完 全 勝 利

表达式解析

对于复杂的权限, 手写表达式对象显然不太现实. 所以我们可以传入一个字符串, 编译时将该字符串解析为一个表达式对象, 权限判断依然留在运行时. 流程图大概如下

flowchart TB
    S
    Scanner
    Parser
    AST
    S[开始] --> Scanner[扫描v-permission]
    Scanner --> Parser[解析字符串]
    Parser --> AST[生成AST]

编译流程

将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序. 某种意义上, 我们将字符串编译权限表达式对象这个过程也可以称作为编译。

我们都知道, 语言分为: 编译型语言、解释性语言。二者最本质的不同便是 编译型语言会将源代码转为机器语言.

stateDiagram-v2
    [*] --> 分词器: 字符串
    分词器 --> 解析器: Token流
    state 解释性语言 {
        [*] --> 执行
    }
    解析器 --> 解释性语言: AST
    state 编译型语言 {
        [*] --> 中间代码
        中间代码 --> 机器码
    }
    解析器 --> 编译型语言: AST

无论是解释性语言还是编译型语言, 整体都需要将用户输入的字符串信息分割为Token, 而后将Token组合为AST。

形式语言

在形式语言理论中,文法(formal grammar)是形式语言中字符串的一套产生式规则(production rule)。这些规则描述了如何用语言的字母表生成符合句法(syntax)的有效的字符串

形式语言语法

产生是的规则。正式定义为 G=(N,Σ,P,S)G=(N,\Sigma,P,S)

  • NN 是有限的非终结符集合,与GG生成的字符串无交, 换句话说 NG=N \cap G = \emptyset
  • Σ\Sigma 是有限的终结符集合, ΣN=\Sigma \cap N = \emptyset
  • PP 是产生式规则集合 (ΣN)N(ΣN)(ΣN)(\Sigma \cup N)^{*}N(\Sigma \cup N)^* \rightarrow (\Sigma \cup N)^{*}
  • SS 是开始符号 SNS \in N

其中 * 为 克莱尼星号

产生式

语法是由产生式规定。它规定了那些符号可以替换为哪些符号. 例如 XZX \rightarrow Z 表示, 符号 XX 可以被替换为 ZZ。读作 XX 可以重写/替换为 ZZ

终结符与非终结符

终结符是在产生式中不能被分解为更小单位的的基本符号。例如

xaabx \rightarrow a \\ a \rightarrow b

其中bb是一个终结符, 因为bb不会再推导出任何的符号. 但aa, xx 都是非终结符, 因为 aa 可以推导出 bbxx 可以推导出 aa

上下文无关文法

是一种形式化、精确描述语言语法的工具. 在应用一个产生式进行推到时,前后推导的结果就是上下文.

倘若有一个文法 GG, 文法中每一个产生式左侧的非终结符是单独出现的, 那么我们可以说这个文法是上下文无关文法.

例如

S1SVOS小明VO猪肉鸡肉羊肉\begin{matrix} S1 & \rightarrow S & V & O \\ S & \rightarrow 你 & 我 & 小明 \\ V & \rightarrow 吃 & 喝 & 玩 \\ O & \rightarrow 猪肉 & 鸡肉 & 羊肉\\ \end{matrix}

那么 S1S1 组成的集合就是 S1={你吃猪肉,你吃鸡肉,你吃羊肉,你喝猪肉,...小明玩鸡肉}S1=\{你吃猪肉,你吃鸡肉,你吃羊肉,你喝猪肉,...小明玩鸡肉\} [1]^{[^1]}

虽然推导出来的句子挺鬼畜的, 但至少意思到了

上下文有关文法

任何产生式规则的左手端和右手端都可以被终结符和非终结符构成的上下文所围绕。

例如

S1SVOS小明VO猪肉鸡肉羊肉O手机\begin{matrix} S1 & \rightarrow S & V & O \\ S & \rightarrow 你 & 我 & 小明 \\ V & \rightarrow 吃 & 喝 & 玩 \\ O & \rightarrow 猪肉 & 鸡肉 & 羊肉\\ 玩O & \rightarrow 手机 \\ \end{matrix}

这样一来推导过程就是 S1SVOVO你玩O你玩手机S1 \rightarrow SVO \rightarrow 你VO \rightarrow 你玩O \rightarrow 你玩手机

词法分析

分词器主要将用户输入的字符串转为一个个Token. 这个过程叫做 Tokenization. (说真的我不知道这个怎么翻译, 就理解为标记化吧). 该阶段被称之为词法分析阶段.

词法分析阶段并不关注Token之间的关系。 例如 (() 可以通过词法阶段, 但不一定能通过语法分析阶段.

例如对于下列 JavaScript 代码

const a = 1;

可以标记化为

Token TokenKind
const 常量关键词
a 标识符
= 等号
1 数字常量
; 分号

词法分析器

写一个词法分析器其实并不困难, 我们并不需要逐字符的去遍历然后拼接,而是可以使用更加高效的正则表达式完成. 因为我们的词法分析器目的是为了生成Token, 所以我们应该先设定Token的类型


enum TokenKind {
  EOF,
  IDENTIFIER,
  LEFT_PARENTHESIS,
  RIGHT_PARENTHESIS,
  AND,
  OR,
  NOT,
}

type Token = {
  // Token类型
  kind: TokenKind;
  // 人类可读的类型, TokenKind[kind]
  humanKind: string;
  // 实际字符
  char: string;
}

有了Token类型我们就可以来设计分词器了.

type RuleHandle = (
  args: {
    tokens: Token[];
    match: string;
    advanceN: (val: number)=>void
  }
) => void;

type Rule = [RegExp, RuleHandle]

export const tokenizer = (
  code: string.
  rule: Rule[]
): Token[] => {}

上述代码中, rule参数可能比较难理解. 这么做的原因主要是为了扩展性考虑, 用户可以通过传入rule参数来直接个性化的定制分词逻辑.

接下来我们就要开始写分词器了. 事实上整个分词器非常简单, 分词器一共三种情况

  1. 到达末尾了
    1. 为Token流插入 EOF Token
  2. 没有到达末尾, 没有通过任何一个正则表达式
    1. 行为: 抛出错误, Bad Input ${code}
  3. 没有达到末尾, 通过了至少一个正则表达式
    1. 调用指定规则的 handle
// directive/permission-lexer.ts
export const tokenizer = (
  code: string.
  rule: Rule[]
): Token[] => {
  const tokens: Token[] = [];
  let input = code;
  let pos = 0;
  let matched = false;
  const isEnd = () => pos < input.length;
  const advanceN = (val: number) => {
    pos += val;
  }
  // 消耗掉 [0,pos) 的字符串
  // 换句话说 丢弃 [0,pos) 这个区间
  const reminder = () => input.slice(pos);
  while (!isEnd()) {
    for (const [regExp, handle] of rules) {
      if (regExp.test(input)){
        matched = true;
      }
      const match = regExpr.exec(reminder());
      if (!match) {
        // 这个地方纯粹是为了避免as强转
        // 如果有需要可以抛出错误而不是continue
        continue;
      }
      handle({tokens, match: match[0] })
    }
    if (!matched) {
      throw new Error(`Bad Input ${input}`);
    }
  }
  tokens.push({kind: TokenKind.EOF, humanKind: 'EOF', char: '0'});
  return tokens;
}

const defaultHandle = (kind: TokenKind)=>{
  return (({tokens, advanceN, match}) => {
    tokens.push({kind, char: match, humanKind: TokenKind[kind]});
    advanceN(match.length)
  }) as RuleHandle;
}
const skip:RuleHandle = ({match,advanceN}) => {
  advanceN(match.length);
}
export const rules: Rule[] = [
  [/^\ /, skip],
  [/^And|^&&|^AND|^\&/, defaultHandle(TokenKind.AND)],
  [/^Or|^\|\||^OR|^\|/, defaultHandle(TokenKind.OR)],
  [/^Not|^!|^NOT/, defaultHandle(TokenKind.NOT)],
  [/^\(/, defaultHandle(TokenKind.LEFT_PARENTHESIS)],
  [/^\)/, defaultHandle(TokenKind.RIGHT_PARENTHESIS)],
  [/^,/, defaultHandle(TokenKind.COMMA)],
  [/^[a-zA-Z_][a-zA-Z0-9_]*/, defaultHandle(TokenKind.IDENTIFIER)]
]

注意, 这里的正则表达式全部都是 /^..../ 而不是 /.../. 我们修改下代码来看看效果

// directive/v-permission.ts
+ import {rules, tokenizer} from './permission-lexer.ts';
- const isValid = (node:PermissionExpr) => {
+ const isValid = (node:PermissionExpr | string) => {
     const { permissions } = useAccount();
-    return judge(node);
+    if (typeof node === 'string') {
+      const tokens = tokenizer(node, rules);
+      console.log(tokens);
+      return true;
+    } else {
+      return judge(node);
+    }
}

export default {
  created: (el: Element, binding)=>{
    if (!isValid(binding.value)){
      el.innerHTML = '<!-- -->'
    }
  },
-} as Directive<Element, PermissionExpr>;
+} as Directive<Element, string | PermissionExpr>
//App.vue
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>


<template>
-  <span v-permission="{type: 'HAS', val: 'a'}">完 全 勝 利</span>
+  <span v-permission="'a || b'">完 全 勝 利</span>
</template>

修改完成后, 在控制台中我们应该能够看到下图

2.png

语法分析

语法分析阶段的任务是接受来自词法分析阶段的Token流, 根据语法规则来建立语法树。本文采用的是普拉特解析法(Pratt Parser). Pratt Parser可以很有效的解析中缀表达式和优先级。不仅如此,Pratt Parser也更加灵活(后文中你会看到Pratt Parser到底有多灵活)

Nud, Led

Nud (The Null Denotation), 如果一个Token可以放在开头, 那么我们称这个Token叫做Nud (非严格定义的话可以叫做prefix). 例如一元运算 (-, !, ~)

Led (The Left Denotation), 如果一个Token必须知道左边的表达式,那么我们称它是一个Led (非严格定义的话可以叫infix). 例如二元运算符 (&&, ||, +-*/)

不过我们要注意, 有些Token可以是 Nud 也可以是 Led. 比如说当 「-」

基础结构

Pratt基础结构非常简单, 核心不会超出30行代码

// v-permission/permission-parser.ts
export const enum BP {
  DEFAULT_BP,
  COMMA,
  LOGICAL,
  UNARY,
  PRIMARY
}
type LedHandle = (lhs: Expr, bp:BP) => Node;
type NudHandle = () => Node;
export class Parser {
      constructor(
        private tokens: Token[]=[],
        private pos=0,
        private nudMap = new Map<TokenKind, NudHandle>(),
        private ledMap = new Map<TokenKind, LedHandle>(),
        private bpMap=new Map<TokenKind, BP>()
      ){
        this.setup();
      }
/*01*/parseExpr(bp: number){
/*02*/  const token = this.peek();
/*03*/  const tokenKind = token.kind;
/*  */  // 从nud开始
/*04*/  const nud = this.nudMap.get(tokenKind);
/*05*/  if (!nud) {
/*06*/    throw new Error(`Except token ${TokenKind[token.kind]}`);
/*07*/  }
/*08*/  let lhs = nud();
/*09*/  while (
/*10*/    this.bpMap.get(this.peek().kind) !== undefined && 
          // bp实际上是限制了led解析
          // 换句话说我们只解析比当前token绑定力更大的token
          // 比如 -1*2
          // 假设我们给 「-」 设定绑定力为3, 「*」的绑定力为2
          // 1就会和「-」绑定而不是「*」
/*11*/    this.bpMap.get(this.peek().kind)! > bp
/*12*/  ) {
/*13*/    const cur = this.peek();
/*14*/    const tk = cur.kind;
/*15*/    const led = this.ledMap.get(tk);
/*16*/    if(!led){
/*17*/      throw new Error(`Except for token ${cur.humanKind}`);
/*18*/    }
/*19*/    lhs = led(lhs, bp);
/*20*/  }
/*21*/  return lhs;
/*22*/}
/*23*/peek(){
/*24*/  return this.tokens[this.pos]
/*25*/}
      setup(){}
}

Identifier 与 二元表达式 的解析

在上文中我们说过 Pratt Parser 的扩展能力非常强. 接下来让我们完善Identifier的解析.

首先我们来定义一下Identifier的类型

export type Expr = BinaryExpr | Identifier;
export interface Identifier {
  type: 'Identifier',
  name: string;
}
export interface BinaryExpr {
  type: 'BinaryExpr',
  operator: Token;
  // 因为二元表达式允许符号左右两侧都是子表达式
  // [1,2,3].every(val => typeof val === 'number') && [1,2,3].some(val => val % 2 === 0)
  // 上述代码显然是合法的
  lhs: Expr; 
  rhs: Expr;
}

之后我们需要实现几个函数

  • next: 获取当前token, 向前步进一个token
    • 换句话说就是吃掉当前token, 然后返回吃掉的token
  • nud
    • 注册 Nud Token的处理函数和绑定力
  • led
    • 注册 Led Token的处理函数和绑定力
  • setup
    • 初始化一些处理函数, callback需要显式调用 bind 函数声明this指向.
next(){
  const token = this.peek();
  this.pos += 1;
  return token;
}
nud(kind: TokenKind, f: NudHandle){
  this.bpMap.set(kind, BP.PRIMARY);
  this.nudMap.set(kind, f);
}
led(bp: BP, kind: TokenKind, f: LedHandle) {
  this.bpMap.set(kind, bp);
  this.ledMap.set(kind, f);
}
setup(){
  // 待会实现
}

有了工具函数接下来我们先实现最简单的 Identifier 解析

/*01*/ parsePrimary(){
/*02*/   const name = this.next().char;
/*03*/   return {
/*04*/     type: 'Identifier',
/*05*/     name,
/*06*/   } as Identifier;
/*07*/ }
/*08*/ parseBinary(lhs: Expr){
/*09*/   const operator = this.next();
/*10*/   const rhs = this.parseExpr(BP.LOGICAL);
/*11*/   return {
/*12*/     type: 'BinaryExpr',
/*13*/     lhs,
/*14*/     rhs,
/*15*/     operator,
/*16*/   } as BinaryExpr;
/*17*/ }
/*18*/ setup(){
/*19*/   this.led(BP.LOGICAL,TokenKind.AND,this.parseBinary.bind(this) );
/*20*/   this.led(BP.LOGICAL,TokenKind.OR,this.parseBinary.bind(this) );
/*21*/   this.nud(TokenKind.IDENTIFIER,this.parsePrimary.bind(this) );
/*22*/ }

好了, 追加完上述22行代码后我们就完成了对Identifier和二元表达式的解析. 接下来我们稍微的修改一下代码

// v-permission.ts
+ import {rules, tokenizer} from './permission-lexer.ts';
+ import {Parser} from './permission-parser.ts';
- const isValid = (node:PermissionExpr) => {
+ const isValid = (node:PermissionExpr | string) => {
     const { permissions } = useAccount();
+    if (typeof node === 'string') {
+      const tokens = tokenizer(node, rules);
-      console.log(tokens);
+      const parser = new Parser(tokens);
+      const ast = parser.run();
+      console.log(ast);
+      return true;
+    } else {
+      return judge(node);
+    }
// App.vue

<template>
  <span v-permission="'a || b'">完 全 勝 利</span>
</template>

修改完成后刷新一下页面, 理论上控制台应该输出

二元表达式的解析.png

分组表达式

对于一元表达式和分组表达式, 实际上更加的简单. 因为我们不会(至少这篇文章不会)涉及到函数调用. 我们先来实现以下分组表达式, 分组表达式不需要定义类型.

解析器只需要吃掉左括号然后重新开始解析表达式就可以,解析完成后吃掉右括号,然后返回解析好的表达式。

// 获取当前 token 的 TokenKind. 如果不为我们预期, 则直接抛出错误
expect(kind: TokenKind) {
  const token = this.peek();
  if (token.kind !== kind) {
    throw new Error(`Expcetion ${TokenKind[kind]} but find ${TokenKind[token.kind]}`);
  }
  return this.next();
}
parseGroup(){
  // 吃掉左括号, 没有就报错
  this.expect(TokenKind.LEFT_PARENTHESIS);
  // 重新解析所有表达式
  const expr = this.parseExpr(BP.DEFAULT_BP);
  // 吃掉右括号, 没有就报错
  this.expect(TokenKind.RIGHT_PARENTHESIS);
  return expr;
}
setup(){
  // ...
  this.nud(TokenKind.LEFT_PARENTHESIS, this.parseGroup.bind(this));
}

接下来让我我们修改一下代码, 看一下分组表达式的结果

<span v-permission="'a || (b || c)'">完 全 勝 利</span>

分组表达式的胜利.png

如果不加括号的话, 默认是左结合. 当解析器遇到左括号后会重新开始解析子表达式.

前缀表达式的解析

前缀表达式是我们最终要解决的问题,当然他并不难,恰恰相反,反而是因为太简单了到最后随手就能实现. 先让我们来定义一下前缀表达式的类型

interface PrefixExpr {
  type: 'PrefixExpr';
  operator: Token;
  expr: Expr;
}
parsePrefix(){
  const operator = this.next();
  const expr = this.parseExpr(BP.UNARY);
  return {
    type:  'PrefixExpr',
    operator,
    expr
  } as PrefixExpr
}
setup(){
  // ...
  this.nud(TokenKind.NOT, this.parsePrefix.bind(this) );
}

现在让我们修改一下代码看一下效果

- <span v-permission="'a || (b || c)'">完 全 勝 利</span>
+ <span v-permission="'!a || (b || c)'">完 全 勝 利</span>

一元表达式.png

然后我们来完善一下类型

export type Node = Expr;
export type Expr = BinaryExpr | Identifier | PrefixExpr;

代码生成

本章节的目的在于将先前我们生成好的 Expr 转为我们 封装一个完备的v-permission中定义好的 PermissionExpr

const objectGenerate = (node:Node): PermissionExpr | null => {
  // ?
  return null;
}

IdentifierPrefix 都太简单了. 唯一有一点难度的只有 BinaryExpression. 所以我们这里只写 BinaryExpression. 对于其他 (包括课后习题) 都可以在 Permission-Compiler中找到

// directive/codegen.ts
const isPrefix = (node:Node) => node.type === 'PrefixExpr';
const isIdentifier = (node:Node) => node.type === 'Identifier';
const isBinaryExpr = (node:Node) => node.type === 'BinaryExpr';
export const binaryExprGen = (expr: BinaryExpr) => {
  const {lhs,rhs,operator} = expr;

  // 这里判断主要是为了收窄 operator.kind 只能是 AND 和 OR
  if (operator.kind !== TokenKind.AND && operator.kind !== TokenKind.OR) {
    throw new Error('Operator only support && or ||, or you can use keywords `and` or `or`.');
  }
  return {
    lhs: objectGenerate(lhs),
    rhs: objectGenerate(rhs),
    type: TokenKind[operator.kind]
  } as And | Or
}
export const objectGenerate = (node: Node):PermissionExpr|null => {
    if (isIdentifier(node)) {
    // ...
  }
  if (isBinaryExpr(node)){
    return binaryExprGen(node);
  }
  if (isPrefix(node)) {
    // ...
  }
  return null;
}

现在我们稍微的修改一下代码, 看看能不能生成 PermissionExpr

// v-permission.ts
+import { objectGenerate } from './codegen';
const isValid = (value: string | PermissionExpr) => {
  const { permissions } = useAccount();
  if (typeof value === 'string') {
    const parser = new Parser(tokens);
+   const ast = parser.run();
+   const permissionExpr = objectGenerate(ast);
+   if (!permissionExpr){
+     throw new Error('Unknown Error')
+   }
+   console.log(permissionExpr)
+   return judge(permissionExpr, permissions.value);

// App.vue
+ <span v-permission="'!a || (b || !c)'">完 全 勝 利</span>

刷新页面后我们应该在控制台看到

终于啊终于.png

页面上应该也可以看到

赢了但是没完全赢.png

这里之所以能看到是因为我们权限写的是 没有A 或者 (B 或者 没有C) 因为或运算符只要有一个是true, 则对应的表达式就是true. 我们在 mock-store.tspermissions 变量写的是 ['a']. 显然满足了 !c, 那么 b || !c 就是true, 那么 !a || (b||!c) 也是true, 那么就可以显示

插件

实际上整个文章到这里确实可以结束了, 因为我们完成了最终的目标 输入一段字符串, 解析为权限表达式对象.

但在这里我们可以稍作思考, 如果我们给 v-permission 传入一个静态的字符串, 那么其实没有必要在运行时进行解析, 而是可以直接在编译时进行解析, 使用解析产物替换掉传入的静态字符串.

所以我们的整体历程便是

stateDiagram-v2 
    direction LR    
    S1: 搜索v-permission指令
    [*] --> S1
    S1 --> [*]: 不存在
    S1 --> 提取指令值
    提取指令值 --> [*]: 不是一个常量
    提取指令值 --> transform
    state transform {
        direction LR
        [*] --> 分词
        分词 --> 解析
        解析 --> 代码生成
        代码生成 --> 替换指令值
    }
    transform --> [*]

写个简单的插件

// plugin/permission.ts
import type {Plugin} from 'vite';
import permissionTransform from './permission-transform';

const permission = ()=>{
  return {
    name: 'vite-plugin-vue-permission',
    transform(code, id, options) {
      if (!id.endsWith('vue')){
        return code;
      }
      return permissionTransform(code, id);
    },
    enforce: 'pre',
  } as Plugin;
}

export default permission;

// vite.config.ts
import permission from './plugin/permission';
import inspect from 'vite-plugin-inspect';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    inspect(),
    vue(),
    permission()
  ],
})

实现 permissionTransform

考虑到我们编译器的主体结构已经实现, 所以 permissionTransform 的难度主要在于

  1. 如何将vue代码转为ast
  2. 如何将ast转为代码

第一个问题相对来说比较好解决, vue 官方提供了 @vue/compiler-sfc 来负责解析。

第二个问题似乎没有什么标准答案, 掘金上也有不少人是自己根据ast写了代码生成. 但个人感觉其实没有必要, 我们完全可以对 loc.start.offset 进行降序排序, 而后切分重组字符串。

提取 v-permission

提取 v-permission 是最简单的一个部分, 本质就是遍历template下所有的dom元素, 提取后再提取存在 namepermission 的指令就可以。

// plugin/permission-transform.ts
export default (
  code: string, id:string
)=>{
  const sfcAST = useSFC({code,id});
  const elements:BaseElementNode[] = [];
  const template = sfcAST.template;
  if (!template?.ast) {
    return code;
  }
  walkSFC(template.ast, {
    enter: (node) => {
      if (node.type === NodeTypes.ELEMENT ) {
        const _node:BaseElementNode = node as BaseElementNode;
        elements.push(_node);
      }
    }
  })
  if (!elements.length) {
    return code;
  }
  const directives:DirectiveNode[] = [];
  for (const ele of elements) {
    const props = ele.props;
    const allDirectives = props.filter(p => p.type === NodeTypes.DIRECTIVE);
    if (!allDirectives.length){
      continue;
    }
    directives.push(...allDirectives.filter(d => d.name === 'permission'))
  }
  directives.sort((a,b) => b.loc.start.offset - a.loc.start.offset);
  return code;
}

解析静态字符串

// plugin/permission-transform.ts
const parseStaticPermission = (
  _ast: SimpleExpressionNode
) => {
  const vueAST = _ast.ast;
  if (!vueAST){
    return ;
  }
  let text = '';
  if(vueAST.type === 'StringLiteral'){
    text = vueAST.value;
  }
  if (vueAST.type === 'TemplateLiteral') {
    throw new Error('Not implment Template parse yet.');
  }
  const tokens = tokenizer(text, rules);
  const parser = new Parser(tokens);
  const ast = parser.run();
  const expr = objectGenerate(ast);
  if (!expr) {
    throw new Error('Unknown Error');
  }
  return expr;
}
export default (
  code: string, id:string
)=>{
  // ...
  for (const directive of directives) {
    if (!directive.exp || !directive.exp.ast){
      continue;
    }
    if (directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION){
      continue;
    }
    if (directive.exp.ast.type !== 'StringLiteral') {
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
  }
}

前三个if拆开写纯粹是为了不让自己心智负担太大. 这边我们对 objectGenerate 产出的代码叫做了 Intermediate language.

重组字符串

这里有一个小坑,我们需要对 directive.exp.loc.start.offset 进行倒序排序, 换句话说我们要倒着修改字符串. 如果不这么做, 就会陷入到 修改完第一个字符串结果第二个字符串的位置偏移了。

export default (
  code: string, id:string
)=>{
  // ...
  for (const directive of directives) {
    if (!directive.exp || !directive.exp.ast){
      continue;
    }
    if (directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION){
      continue;
    }
    if (directive.exp.ast.type !== 'StringLiteral') {
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
+   const l = directive.exp.loc.start.offset;
+   const r = directive.exp.loc.end.offset;
+   code = `${code.slice(0,l-1)}"${JSON.stringify(permissionExprAstIR).replaceAll('"',"'")}"${code.slice(r+1)}`
  }
  return code;
}

Try It

接下来, 我们就可以再 vite.config.ts 中使用我们的插件了.

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import permission from './plugin/permission';
import inspect from 'vite-plugin-inspect';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    inspect(),
    vue(),
    permission()
  ],
})

接下来我们修改下 App.vue 访问 http://localhost:5173/__inspect

<!-- App.vue -->
<!-- 还是沿用了先前的App.vue -->
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>

<template>
  <span v-permission="'!a || (b || !c)'">完 全 勝 利</span>
</template>

访问 inspect 之后, 我们点击 App.vue 后在弹出的 Drawer 中点击我们编写的插件, 可以看到确实已经替换成功了

diff.png

课后习题

  • 实现函数解析

Ref

  1. www.zhihu.com/question/21…

Trae 插件 Builder 模式:从 0 到 1 开发天气查询小程序,解锁 AI 编程新体验

2025年5月18日 14:37

我正在参加Trae「超级体验官」创意实践征文,本文所使用的 Trae 免费下载链接:www.trae.com.cn/?utm_source… 在这里插入图片描述

在软件开发领域,效率与创新始终是开发者追求的核心目标。Trae 插件(原 MarsCode 编程助手)Builder 模式的全面上线,无疑为开发者带来了全新的解决方案。它不仅同时支持 VS Code、JetBrains IDEs 等主流开发环境,还能让开发者通过全自然语言,轻松实现从 0 到 1 开发完整项目,真正实现了从 “编程助手” 到高度智能的 “AI 工程师” 的进化。本文将以开发一个简单的 “天气查询小程序” 为例,深入测评 Trae 插件 Builder 模式的功能亮点,分享使用技巧,并与国内外 AI 编程工具进行对比,展现其独特优势。

一、Trae 插件 Builder 功能测评及使用技巧

Trae插件获取:www.trae.com.cn/plugin

在这里插入图片描述

对于开发者而言,Trae 插件的 Builder 模式 堪称“效率加速器”。无论是搭建一个小型游戏还是工具类应用,用户只需用自然语言描述需求,插件便能自动生成可运行的基础代码。这不仅省去了手动创建文件、配置环境的麻烦,还大幅降低了初期开发的试错成本。开发者得以从“造轮子”中解脱,将精力投入到更具创造性的业务实现上——这正是 Builder 模式的真正价值。

1.1 功能亮点

在这里插入图片描述

  1. 全自然语言交互:Trae 插件 Builder 模式最大的亮点在于其强大的自然语言理解能力。开发者无需使用复杂的编程指令,只需用日常语言描述需求,如 “创建一个能查询天气的小程序,用户输入城市名,就能显示当前天气状况、温度和风力”,Trae 插件便能迅速解析需求,自动生成对应的代码框架与逻辑。这种交互方式极大降低了编程门槛,即使是非专业开发者,也能轻松表达开发意图。
  2. 多环境无缝支持:无论是使用 VS Code 进行轻量级开发,还是借助 JetBrains IDEs 进行大型项目构建,Trae 插件 Builder 模式都能完美适配。开发者可以在熟悉的开发环境中,享受统一的 AI 编程体验,无需在不同工具间频繁切换,有效提升开发效率。
  3. 智能代码生成与优化:除了基础代码生成,Trae 插件还具备智能优化功能。在生成天气查询小程序代码时,它会自动考虑代码的可读性、可维护性和性能。例如,合理封装天气查询的 API 调用逻辑,避免重复代码;根据不同平台特性,优化界面渲染效果,确保小程序在各种设备上都能流畅运行。
  4. 实时反馈与协作:在开发过程中,Trae 插件会实时反馈代码执行情况和潜在问题。当输入的自然语言需求存在歧义或代码运行出现错误时,它会以通俗易懂的方式给出提示,并提供修改建议。此外,插件还支持团队协作,不同成员可以在同一项目中使用 Builder 模式,共同推进开发进程,实现高效沟通与协作。

1.2 使用技巧

在这里插入图片描述

  1. 精准描述需求:为了获得更符合预期的代码,在使用自然语言描述需求时,尽量提供详细信息。比如在开发天气查询小程序时,明确说明 “使用 OpenWeatherMap 的 API 获取天气数据”“小程序界面采用简洁的卡片式设计” 等,让 Trae 插件更精准地理解开发意图。
  2. 灵活调整与迭代:开发过程并非一蹴而就,当生成的代码不符合预期时,不要急于推翻重来。可以通过逐步修改自然语言描述,让 Trae 插件迭代优化代码。例如,如果小程序的界面布局不理想,可以输入 “将天气信息显示区域调整为居中对齐,字体增大一号”,插件会快速响应并更新代码。
  3. 学习生成代码:Trae 插件生成的代码是学习编程的宝贵资源。开发者在使用过程中,可以仔细研读生成的代码,学习其中的编程逻辑和设计模式。通过这种方式,不仅能完成项目开发,还能提升自身的编程水平。

二、Trae插件安装与使用步骤

要在 VSCode 中安装 Trae 插件,请先打开扩展面板(Ctrl+Shift+X),在搜索栏输入“Trae”并回车,找到官方插件后点击安装按钮即可。安装完成后,你就能立即体验它强大的开发辅助功能。

在这里插入图片描述在这里插入图片描述

要使用Builder模式,请先将Trae插件更新至最新版本,然后在界面顶部导航栏点击"Builder"标签页,即可从Chat模式切换至Builder模式开始协作开发。最新版本确保您能使用完整的Builder功能。

三、天气查询小程序实战案例

在这里插入图片描述

3.1 项目需求分析

我们要开发的天气查询小程序,核心功能是让用户输入城市名称,小程序通过调用天气 API 获取该城市的实时天气信息,包括天气状况(如晴、多云、雨等)、温度(摄氏度)和风力,并将这些信息以友好的界面展示给用户。同时,为了提升用户体验,小程序需要具备简洁美观的界面设计和流畅的交互效果。

在这里插入图片描述

3.2 使用 Trae 插件 Builder 模式开发过程

  1. 启动 Trae 插件:在 VS Code 或 JetBrains IDEs 中打开项目,激活 Trae 插件 Builder 模式。
  2. 输入自然语言需求:在插件的交互界面输入 “创建一个天气查询小程序,用户可以在输入框输入城市名,点击查询按钮后,通过 OpenWeatherMap 的 API 获取该城市的天气状况、温度和风力,并在页面上以卡片形式展示。小程序使用 HTML、CSS 和 JavaScript 进行开发,界面风格简约现代”。
  3. 查看与调整代码:Trae 插件迅速生成包含 HTML 页面结构、CSS 样式表和 JavaScript 逻辑的代码。开发者可以查看代码,检查是否符合需求。如果发现问题,如 API 密钥未配置,可输入 “添加 OpenWeatherMap 的 API 密钥配置代码”,插件会自动补充相关代码。
  4. 测试与优化:运行生成的小程序,输入城市名称进行测试。若发现界面显示不完整或数据获取错误,通过修改自然语言描述,让 Trae 插件进一步优化代码。经过几次迭代,一个功能完善、界面美观的天气查询小程序便开发完成。

实操流程图片】:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.3 项目成果展示

最终完成的天气查询小程序,界面简洁清晰,用户输入城市名称并点击查询按钮后,能快速准确地显示天气信息。例如输入 “北京”,页面会展示 “天气状况:晴,温度:25℃,风力:微风” 等内容,完全满足最初的项目需求。

在这里插入图片描述

在这里插入图片描述

【可能出现问题】:

script.js 文件里,API_KEY 需要替换成你从 OpenWeatherMap 官网获取的真实 API 密钥。

// 请将此处替换为你自己的 OpenWeatherMap API 密钥
const API_KEY = 'your_openweathermap_api_key';

解决办法:前往 OpenWeatherMap 注册账号,获取 API 密钥,然后把 your_openweathermap_api_key 替换成真实的密钥

在这里插入图片描述

四、与竞品对比:Trae 插件 Builder 模式的优势

当前AI编程工具主要分为三类:1)代码补全型(如GitHub Copilot),2)对话辅助型(如Amazon CodeWhisperer),3)项目生成型(如本次分析的Trae插件Builder模式。随着自然语言处理技术的突破,开发者对工具的期待已从单纯的效率工具转向能理解复杂意图、完成系统工程的全流程解决方案。在此背景下,项目级代码生成能力正成为新一代AI编程助手的核心竞争点。

在这里插入图片描述

对比维度 GitHub Copilot Cursor 国内同类产品 Trae插件Builder模式
核心功能 代码补全/片段生成 自然语言编程 基础项目生成 全流程项目构建
项目级支持 需手动搭建框架 部分功能受限 需求理解易偏差 从0到1完整生成
代码质量 片段级优化 优化能力有限 需频繁调试 智能环境适配优化
开发效率 节省30%编码时间 中等效率提升 反复修改耗时 节省70%+初始开发时间
环境支持 多IDE兼容 依赖特定环境 功能受限 VS Code/JetBrains无缝支持
典型案例 需手动完成天气小程序80%基础代码 生成代码需二次优化 可能误解界面风格需求 一次输入生成完整天气小程序(含API/UI)

场景体验】:以开发"天气查询小程序"为例.

我实际体验了Trae插件的Builder模式开发"天气查询小程序",发现确实有四大优势:第一,它能准确理解我的复合需求,包括API调用和UI风格要求;第二,生成的核心代码框架直接就能运行,省去了很多搭建工作;第三,在不同IDE上都能流畅使用,完全适配我的开发环境;最惊喜的是,原本需要3-5小时的项目初始化工作,现在30分钟就能搞定,效率提升非常明显。

五、总结与使用体验

在这里插入图片描述

综上所述,Trae 插件 Builder 模式凭借其强大的全自然语言交互、多环境支持、智能代码生成与优化等功能,以及在与竞品对比中展现出的显著优势,成为开发者从 0 到 1 开发项目的得力助手。无论是开发简单的小程序,还是复杂的大型应用,Trae 插件都能为开发者带来全新的编程体验,助力提升开发效率与质量。如果你也想体验高效智能的编程方式,不妨尝试使用 Trae 插件 Builder 模式,开启属于你的 AI 编程之旅。

作用域链和闭包(clousre)拆解(3)

2025年5月18日 14:00

一、作用域链

1.1 代码分析:

看看输出啥。

function cat() {
  console.log(myName);
}
function dog() {
  var myName = "王中王";
  function dogking() {
    console.log(myName);
  }
  dogking();
  cat();
}
var myName = "旺中旺";
dog();

1.2 调用栈分析

在这里插入图片描述

全局执行上下文和dog函数执行上下文中都包含myname 变量,dogKing和cat函数中的myname变量会取哪个呢? 其实两个函数输出结果并不相同。 dogKing函数输出为“王中王”,cat函数输出为“旺中旺”。 为什么呢?

1.3 作用域链

首先,变量的查找是根据作用域链的规则来的。

那么作用域链是什么呢?作用域链是js引擎用来解析变量的机制。查找变量时,js引擎会先在当前作用域查找,如果没找到,继续向外层查找,直至全局作用域。这个从内向外的查找链条就是作用域链。

那按照这个概念理解,dogKing函数的输出是按照作用域链查找的,cat函数则不是。因为dog和dogKing函数组成了一个闭包,闭包比较特殊,dogKing的外级作用域就是dog函数。

js调用栈中,每个执行上下文中都包含全局执行上下文的引用,我们把这个引用称为outer。 在这里插入图片描述 cat和dog函数查找变量时,首先在当前的执行上下文中查找,没有找到,会继续查询outer指向的全局执行上下文中进行查找。

那为什么cat的外部引用时全局执行上下文,而不是dog函数执行上下文呢?这是因为在执行过程中,作用域链是根据词法作用域决定的。

1.4 词法作用域

词法作用域是js中作用域的静态结构。在代码编写时确定,与代码执行无关。是由函数的嵌套结构确定,与函数调用无关。 因此在函数定义时,根据词法作用域,dog和cat函数的上级作用域都是全局作用域。

1.5 练习

块级作用域变量查找同理。

function cat() {
  let age1 = 20
  console.log(age);
}
function dog() {
  var myName = "王中王";
  function dogking() {
    console.log(myName);
  }
  let age = 18
  dogking();
  cat();
}
var myName = "旺中旺";
let age = 10
dog();

试着根据调用栈分析下cat函数中输出的值。

在这里插入图片描述 这是这段程序的调用栈。var声明的变量和函数声明在变量环境中,let和const声明在词法环境中。 在这里插入图片描述 变量查找时, ①查找当前作用域的词法环境,从栈顶到栈底 ②查找当前作用域的变量环境 ③查找全局作用域的词法环境,从栈顶到栈底,找到age=10 ④找到,结束。

二、闭包

2.1 上代码

function func() {
    var myName = "李三岁"
    let num1 = 1
    const num2 = 2
    var innerFunc = {
        getName:function(){
            console.log(num1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerFunc
}
var tem = func()
tem.setName("李逍遥")
console.log(tem.getName())

首先,我们看当执行到func函数结尾时的调用栈情况: 在这里插入图片描述

上述代码中,innerFunc对象中包含getName和setName两个方法,定义在func函数内部。根据词法作用域,即声明时,getName和setName方法可以顺着作用域链访问到func函数中的变量。因此可以引用myName和num1两个变量。 接着innerFunc返回给tem变量后,虽然func函数执行完毕,但是此时依然引用func函数中的两个变量,因此并不会被回收。此时的调用栈情况为:

在这里插入图片描述 func函数执行完成以后,其执行上下文从栈顶弹出,但myName和num1变量还没getName和setName方法使用,因此还保存在内存中。无论在哪里调用这两个方法,都可以访问这两个变量,其他任何方法都访问不到这两个变量。因此这两个方法和变量就组成了闭包。

2.2 定义

mdn中闭包的定义为:闭包是由捆绑起来的(封闭的)函数和函数周围状态(词法环境)的引用组合而成。 因此,闭包可以让内部函数访问其外部作用域,即使外部函数执行结束,内部函数引用的外部函数的变量依然保存在内存中,内部函数及其引用的变量组成闭包。其实宽泛理解,在js中所有的函数都是一个闭包。

2.3 变量查找

闭包中的函数执行后,变量查找时,js引擎会沿着:setName函数执行上下文>func闭包>全局执行上下文的顺序查找。 在这里插入图片描述 在浏览器中打断点后,我们看开发者工具的信息: 在这里插入图片描述 当给myName赋值时,Scope项体现了作用链为:Local>Closure>Block>Global。因为我在vue3的项目中运行,如果在单独的js文件中运行,Block中的变量会在Global中,没有Block这一环。Local就是当前setName方法的作用域。Closure(func)就是func函数的闭包。Global为全局作用域。

2.4 闭包回收

  • 如果引用闭包的变量是个局部变量,等该作用域销毁后,下次gc执行垃圾回收时,进行是否还在使用的判断和内存回收。
  • 如果引用闭包的变量是个全局变量,那么该闭包会一直存在知道页面关闭。若以后闭包不再使用,会造成内存泄漏。(总的内存大小不变,可用的内存大小变小了)这也是闭包的一大缺点。

2.5 闭包的用途

2.5.1 数据封装和私有化

上代码

function createPerson(age) {
  const privateAge = age;   // 私有变量
  
  return {
    getAge: function() {
      return privateAge;
    },
    setAge: function(newAge) {
      if (typeof newAge === 'number' && newAge > 0) {
        privateAge = newAge;
      }
    }
  };
}

const person = createPerson(30);
console.log(person.getAge());  // 输出: 30
person.setAge(35);
console.log(person.getAge());  // 输出: 35

上述代码中,person变量可以通过闭包访问privateAge变量,但外部代码不能访问。 同时也可以作为缓存,保存在内存中。

因此闭包可以用来创建私有变量和方法,防止外部直接访问和修改。

2.5.2 防抖和节流

防抖:

function shake(){
let timer = null

function func(){
if(timer != null) clearTimeout(timer)

timer = setTimeout(()=>{
// todo want to do
},200)
}
}

节流

function throttle(){
let timer = null

function func(){
if(timer != null) return

timer = setTimeout(()=>{
// todo want to do
timer = null
},200)
}
}

防抖和节流都是通过闭包使变量存在于内存中,借助变量实现想要的功能。

三、做个题

var obj = {
    myName:"time",
    printName: function () {
        console.log(myName)
    }    
}
function func() {
    let myName = "李三岁"
    return obj.printName
}
let myName = "刘大哥"
let _printName = func()
_printName()
obj.printName()

分析一下,当执行到func()未return时的调用栈: 在这里插入图片描述 执行完成后弹出栈顶: 在这里插入图片描述 此时_printName被赋值,执行时: 在这里插入图片描述 查找myName变量:_printName函数执行上下文词法环境>_printName函数执行上下文变量环境>全局词法环境,找到,输出“刘大哥”。 执行至obj.printName()时,情况相同,obj.printName函数执行上下文中的词法环境和变量环境中均为空,所以查找到全局执行上下文中。 因此printName函数的myName变量是属于全局作用域下的,此作用域链由词法作用域决定。

总结:此段程序中并没有生成闭包。obj不是一个函数,其中的myName和printName是他的两个属性,彼此并没有联系。若想产生联系,需要加上this关键字。否则printName会通过词法作用域链查找myName

文章参考:time.geekbang.org/column/intr… zhuanlan.zhihu.com/p/683323392 juejin.cn/post/737617…

CSRF和跨域问题CORS

作者 屁__啦
2025年5月18日 13:27

原来只知道有CORS是因为浏览器同源策略引起的,所以如果前端调用后端Restful API的时候浏览器会报跨域的问题;

image.png 看完CSRF相关的内容,跨站点请求伪造,即便有同源策略,浏览器会拦截响应,但是该执行的API还是已经执行过了。

CORS 是浏览器为了解决跨域请求的问题而设计的一种机制,后端通过设置 HTTP 响应头(如 Access-Control-Allow-Origin)告诉浏览器:我允许这个域的请求访问我的资源。

“简单请求”:

符合以下条件的请求,浏览器直接发起请求,不会预检

  • 方法是 GET、POST 或 HEAD;
  • Content-Type 限于:application/x-www-form-urlencoded, multipart/form-data, text/plain
  • 无自定义请求头;

这些请求如果不被 CORS 允许,浏览器会拦截响应(但请求已经发送了)

🔍 示例:简单请求跨域时依旧执行 API

const formData = new URLSearchParams();
formData.append('email', email);
formData.append('name', name);
formData.append('password', password);

fetch(`${API_URL}/auth/register`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded', // ✔️ 属于简单请求
  },
  body: formData,
});

上面的请求是跨域的,而且会有跨域问题,但是仍旧会创建一个新的用户,用户会被保存到数据库; 这是一个典型的“简单请求”,即使跨域时 CORS 没设置允许,浏览器也会把请求发送出去!只是响应内容会被拦截,JavaScript 拿不到响应数据。

⚠️ “非简单请求”:

如使用 PUT、DELETE 方法或带自定义请求头的 POST 请求,会先发送一个预检请求(OPTIONS) ,询问服务器是否允许,再决定是否发送真正的请求。

🔥 和 CORS 的关系?

  • CSRF 攻击通常是“同源策略绕不过”的,因为攻击的页面和目标 API 是跨域的。
  • 但是跨域的 POST 请求(简单请求)是会直接发出的,哪怕响应被浏览器拦截,后端逻辑已经执行了

🔐 安全建议

same site cookie只允许顶级域名相同的情况下携带cookie,不适用于前后端分离且前后端域名不同的情况; 这时后端设置的sameSite是None;

//前端代码
fetch("https://api.backend.com/api/xxx", {
  method: "POST",
  credentials: "include", // 👈 关键点:允许浏览器带上 Cookie
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(data),
});
//后端代码
res.cookie("backendToken", token, {
  httpOnly: true,
  secure: true,         // 必须是 HTTPS 才能使用 SameSite=None
  sameSite: "None",     // 明确允许跨站携带 Cookie
});

由于你在跨站传递 Cookie,浏览器已不再默认信任行为,所以要注意:

  • 使用 https://,不要部署在 HTTP 上。
  • 后端验证 OriginReferer 头,配合 CSRF Token 做双重验证(提高安全性)。
  • 如果使用 JWT,尽量使用短有效期 + Refresh Token 机制。

🔐 前端安全防御方法汇总

1. 防止 XSS(跨站脚本攻击)

  • 输入过滤 + 输出转义

    • 所有用户输入进行严格校验(白名单优先)。
    • 对动态插入 DOM 的内容使用转义(如 innerText 替代 innerHTML)。
  • 使用安全框架

    • React、Vue 默认对绑定内容进行转义。
  • Content Security Policy(CSP)

  • 限制可执行的脚本来源,例如:

  • HTTP-ONLY cookie防止cookie泄漏

Content-Security-Policy: script-src 'self'

2. 防止 CSRF(跨站请求伪造)

  • 使用 SameSite Cookie 策略

    • 推荐设置:
    Set-Cookie: token=xxx; SameSite=Strict; HttpOnly; Secure
    
    • Strict: 拒绝第三方携带 Cookie,前后端不分离时使用。

    • Lax: 支持 GET 导航请求,适合大多数表单场景。

  • 配合 Token 校验

    • 前端每次请求带上 CSRF Token,在服务端验证。
  • 禁止第三方表单提交

    • 使用 X-Requested-With: XMLHttpRequest 检测是否来自前端代码。
  • 正确使用DOM操作

    • 使用 textContent / innerText,不要用 innerHTML 插入不可信内容。
❌
❌