普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月19日首页

Canvas签名功能常见的几种问题

2025年5月18日 23:52

. 如何实现基础签名功能?


<!DOCTYPE html>
<canvas id="signature" width="500" height="300"></canvas>
<script>
  const canvas = document.getElementById('signature');
  const ctx = canvas.getContext('2d');
  let isDrawing = false;
  
  canvas.addEventListener('mousedown', startDrawing);
  canvas.addEventListener('mousemove', draw);
  canvas.addEventListener('mouseup', stopDrawing);
  canvas.addEventListener('mouseout', stopDrawing);

  function startDrawing(e) {
    isDrawing = true;
    ctx.beginPath();
    ctx.moveTo(e.offsetX, e.offsetY);
  }

  function draw(e) {
    if (!isDrawing) return;
    ctx.lineTo(e.offsetX, e.offsetY);
    ctx.stroke();
  }

  function stopDrawing() {
    isDrawing = false;
  }
</script>

1. 如何检测签名是否为空?

function isCanvasBlank(canvas) {
  // 获取画布像素数据
  const context = canvas.getContext('2d');
  const pixelBuffer = new Uint32Array(
    context.getImageData(0, 0, canvas.width, canvas.height).data.buffer
  );
  
  // 检查是否有非透明像素
  return !pixelBuffer.some(color => color !== 0);
}

2. 如何处理不同设备DPI问题?

function setupHighDPICanvas(canvas) {
  // 获取设备像素比
  const dpr = window.devicePixelRatio || 1;
  
  // 获取CSS显示尺寸
  const rect = canvas.getBoundingClientRect();
  
  // 设置实际尺寸为显示尺寸乘以像素比
  canvas.width = rect.width * dpr;
  canvas.height = rect.height * dpr;
  
  // 缩放上下文以匹配CSS尺寸
  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  
  // 设置CSS尺寸保持不变
  canvas.style.width = `${rect.width}px`;
  canvas.style.height = `${rect.height}px`;
}

3. 如何实现撤销/重做功能?

class SignatureHistory {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.history = [];
    this.currentStep = -1;
  }
  
  saveState() {
    // 截取当前画布状态
    const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    
    // 如果当前不是最新状态,截断历史
    if (this.currentStep < this.history.length - 1) {
      this.history = this.history.slice(0, this.currentStep + 1);
    }
    
    this.history.push(imageData);
    this.currentStep++;
  }
  
  undo() {
    if (this.currentStep > 0) {
      this.currentStep--;
      this.ctx.putImageData(this.history[this.currentStep], 0, 0);
    }
  }
  
  redo() {
    if (this.currentStep < this.history.length - 1) {
      this.currentStep++;
      this.ctx.putImageData(this.history[this.currentStep], 0, 0);
    }
  }
}

4. 如何添加签名压力感应效果?

// 监听指针事件(支持压力感应设备)
canvas.addEventListener('pointerdown', startDrawing);
canvas.addEventListener('pointermove', drawWithPressure);
canvas.addEventListener('pointerup', stopDrawing);

function drawWithPressure(e) {
  if (!isDrawing) return;
  
  // 获取压力值(0-1),默认0.5用于鼠标
  const pressure = e.pressure || 0.5;
  
  // 根据压力调整线条宽度
  ctx.lineWidth = pressure * 10 + 2; // 2-12px范围
  
  ctx.lineTo(e.offsetX, e.offsetY);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(e.offsetX, e.offsetY);
}

5. 如何防止签名图片被篡改?

function generateSignatureHash(canvas) {
  // 获取画布数据
  const imageData = canvas.toDataURL('image/png');
  
  // 使用SHA-256生成哈希
  return crypto.subtle.digest('SHA-256', new TextEncoder().encode(imageData))
    .then(hash => {
      // 转换为十六进制字符串
      const hashArray = Array.from(new Uint8Array(hash));
      return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
    });
}

6. 如何加密存储签名数据?

async function encryptSignatureData(canvas) {
  // 获取画布数据
  const imageData = canvas.toDataURL('image/png');
  
  // 生成加密密钥
  const key = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
  
  // 加密数据
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    new TextEncoder().encode(imageData)
  );
  
  return {
    key,
    iv,
    encryptedData: Array.from(new Uint8Array(encrypted))
  };
}

7. 如何实现多人协同签名?

class CollaborativeSignature {
  constructor(canvas, socket) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.socket = socket;
    
    // 本地绘制事件
    canvas.addEventListener('mousedown', this.handleLocalDrawStart.bind(this));
    canvas.addEventListener('mousemove', this.handleLocalDrawing.bind(this));
    canvas.addEventListener('mouseup', this.handleLocalDrawEnd.bind(this));
    
    // 远程绘制事件
    socket.on('remote-draw-start', this.handleRemoteDrawStart.bind(this));
    socket.on('remote-drawing', this.handleRemoteDrawing.bind(this));
    socket.on('remote-d
昨天 — 2025年5月18日首页

小红书一面:长达一个小时的拷打😭

作者 Danta
2025年5月18日 22:40

前言

兄弟也是好起来了,又又有大厂面试了。


面试过程:

一、自我介绍

这个自我介绍我之前在面试文章中提到过,大家可以翻翻查看。

二、实习经历

面试官看到我目前在一家公司实习,于是让我聊了聊我的业务内容。


三、项目方面

1. 你为什么选用 Tailwind CSS?能说说有什么好处吗?

  • 原子化设计:Tailwind CSS 是一种原子化 CSS 框架,将样式拆分为最小的功能单元,每个类只负责一个特定的样式属性。
  • 开发效率高:像写内联类一样快速编写样式,无需额外创建 CSS 文件。
  • 响应式友好:支持大量响应式类,例如 md:w-1/2lg:w-1/4lg:flex-row 等。
  • 样式隔离性强:在 Vue 单文件组件中使用 Tailwind 类,避免传统 CSS 中的样式冲突问题,比如 TabBar、ShowProducts 等组件各自维护自己的样式。
  • 技术广度体现:其实当时用这个是想展示一下对现代前端工具链的理解。
  • 缺点
    • 学习曲线较陡,需要记忆大量类名和约定;
    • 熟练后开发效率更高。
  • 拓展思考
    • 便于 AI 辅助开发:原子类不依赖嵌套或继承,减少 AI 理解上下文的压力;
    • 降低样式覆盖风险,减少了 CSS 的“层叠”问题和选择器冲突。

2. 你的项目用到了组件懒加载,讲讲好处?

  • 在路由懒加载中使用了组件懒加载,实现 按需加载,只有当用户导航到特定路由时,才会加载相应的组件。
  • 如果不使用懒加载,打包时会把所有页面打包成一个文件,首页一次性加载全部资源,导致加载速度慢,用户体验差。
  • 使用路由懒加载后,首页资源被拆分为多个 chunk 文件(如 app.js, home.js),CSS 同样被拆分。
  • 文章参考链接:前端性能优化
面试官追问:你知道为什么会这样吗?

我当时没回答上来,但后来查资料得知:

  • import() 的调用处被视为代码分割点,被请求模块及其子模块会被分离为独立的 chunk。
  • Webpack 等构建工具识别 import(),并将动态导入的模块单独打包,从而减小初始加载体积。
  • 总体积不变,但首屏加载资源减少,提升用户体验。

3. 聊聊你项目中的动态组件

  • 在实现一个礼物推荐助手时,我需要展示用户提问与 AI 回答。
  • 为此我封装了两个组件:一个是用户消息组件,一个是 AI 回复组件。
  • 每次对话内容存储在一个数组中,根据标志属性判断渲染哪个组件。
  • 最终通过 Vue 内置的 <component> 标签结合 :is 属性实现了动态组件切换。

4. 你实现 keep-alive 的目的,以及和 v-if / v-show 的区别?应用场景?

  • keep-alive 目的
    • 缓存组件状态(如表单输入、滚动位置);
    • 避免组件频繁销毁重建;
    • 减少 API 请求,提高性能和用户体验。
  • 缓存控制
    • 使用 include="cachedComponents" 属性,只缓存设置了 meta.cache = true 的组件。
v-ifv-show 的区别:
对比项 keep-alive v-if v-show
是否保持状态 ✅ 是 ❌ 否 ✅ 是
渲染机制 组件缓存 条件为 false 不渲染 切换 display 属性
性能 切换成本低,适合频繁切换 初始化开销小 切换快,初始渲染全量
适用场景 多 tab 切换、表单缓存 不常切换、复杂组件 高频切换简单元素

5. 自定义图片懒加载怎么实现的?

流程如下:

scrollTop + offsetTop
=> getBoundingClientRect()
=> IntersectionObserver

从手动计算逐步过渡到现代浏览器 API,性能越来越好。

又问:你了解 HTML 中原生的 lazy 吗?能否讲讲?
  • 原生 HTML 支持懒加载:<img loading="lazy"><iframe loading="lazy">
  • 优点:简单易用,无需 JS,现代浏览器原生支持;
  • 缺点:兼容性一般,IE 不支持,功能有限;
  • 定制性不强,更高级的需求建议使用 IntersectionObserver 自定义实现。

6.响应式布局这方面,你是怎么做的?

  1. 我的项目中,有一个商品展示的功能,使用的是wc-waterfall ,动态的绑定gap和cols两个属性,通过生命周期挂载,添加事件监听,根据屏幕的大小,调整相应的值,来实现响应式布局。
  2. 通过@media声明在不同尺寸下微调样式细节
  3. 商城项目,经常用得到商品的展示,所以我会将它封装成一个组件,方便复用

四、场景题

1.setimeout

这个是一个面试经常问到的题目,但是他问的很细

for(var i=0;i<4;i++){
    setTimeout(function(){
        console.log(i);
    },1000)
}
首先问你输出什么?

由于 var i 的声明具有函数作用域(在这里指全局作用域),所有的 setTimeout 回调函数实际上引用的是同一个 i 变量。当定时器触发时(即循环已经结束),i 的值已经是 4,因此所有回调打印的结果都是 4。在整个循环过程中只有一个 i,最后连着输出四个4.

又问大概在什么时候输出: 因为定时器不一定准,所以是大概的时间,可能就会回答在4秒后了,实际上,执行同步代码的循环后,定时器四个任务,相继执行,因为间隔时间很短,所以就很像四个定时器并发了一样,实际上还是一个又一个执行的。在大概一秒后。

如何输出 0 1 2 3呢?

var => let

  • 块级作用域:在每次 for 循环迭代中,都会创建一个新的 i 实例。这些 i 变量被限制在循环体的块级作用域内。
  • 延迟执行的回调函数:每个 setTimeout 回调函数捕获的是它对应的那个特定的 i 实例。因此,当定时器触发并执行回调函数时,它们能够访问到正确的 i 值,而不是所有回调都指向同一个 i
那我想要每隔大概一秒输出一个数字呢?
  1. 当时我想到了是:
for (let i = 0,time=1000; i < 4; i++,time+=1000) {
    setTimeout(function () {
        console.log(new Date())
        console.log(i);
    }, time);
}
2025-05-18T14:14:54.808Z
0
2025-05-18T14:14:55.806Z
1
2025-05-18T14:14:56.814Z
2
2025-05-18T14:14:57.800Z
3
  1. 还有就是使用闭包加立即执行函数了:
for (var i = 0; i < 4; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i);
        }, 1000*i);
    })(i);
}

  1. 进阶:

function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

(async function () {
    for (var i = 0; i < 4; i++) {
        await delay(1000);
        console.log(i);
    }
})();

2. 数组求和

const nestedObj = [1, [2, [3, [4, 5]]], 6, 7], 求和。前几天刚好看到了数组和对象的扁平化,刚好就能用上了,不过面试官好像想让我用更简单的方法,我没想出来。

let sum=0
function flattenObject(obj ) {
    for (const item of obj) {
        if (Array.isArray(item)) {
            flattenObject(item);
        } else {
            sum=sum+item;
        }
    }
    return sum;
}
console.log(flattenObject(nestedObj));// [ 1, 2, 3, 4, 5, 6, 7]

学习点其他简单的方法:

  1. 递归
function sum(arr) {
    return arr.reduce((total, item) => {
        return total + (Array.isArray(item) ? sum(item) : item);
    }, 0);
}

const nestedObj = [1, [2, [3, [4, 5]]], 6, 7];
console.log(sum(nestedObj)); // 输出: 28

结语

面了几家大厂后,也有一些心得:

大厂面试一定是穷追猛打,问到你不会为止。所以有些难题回答不出来也没关系。

而面试其实就是一场表演,大家可以在项目中准备几个亮点,自己演练几遍,在面试时流畅表达出来,体现出自己的深度和思考能力,这才是关键!

深入解析:基于 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

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

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 渲染线程开始执行

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能不用就不用,防止变量污染和意外覆盖。

iOS 如何找到那个方法?消息机制底层探秘

作者 布多
2025年5月18日 15:43

前言

消息机制在 iOS 开发中扮演着至关重要的角色,它为开发者提供了强大的动态性和灵活性,使得代码在运行时能够根据需要进行调整和 扩展。

本文将深入探讨 iOS 消息机制的底层实现原理,从方法调用到消息转发,揭示 Runtime 如何在运行时动态查找和执行方法。通过源码分析,帮助开发者更好地理解和运用这一核心机制。

阅读本文需要你具备以下基础知识:

  • 了解消息发送和 objc_msgSend 的关系;
  • 熟悉 ObjC 的类和对象的底层结构;
  • 熟悉类的方法列表结构;

如果你对以上知识点还不够熟悉,建议先阅读相关文章打好基础。

本文将以对象方法举例,类方法的调用流程逻辑和对象方法基本一致。

消息查找过程

在 ObjC 中,方法调用本质上是一个消息发送的过程。当我们写下 [object method] 这样的代码时,编译器会在编译期将其转换为 objc_msgSend(object, @selector(method)) 的形式。这个转换过程是 ObjC 消息机制的基础,它使得我们能够在运行时动态地查找和执行方法。

要深入理解消息发送的底层实现,我们需要从 Runtime 源码入手。在 Runtime 项目中,我们可以找到 objc_msgSend 的具体实现。下面让我们来看看这个函数的核心实现:

我在 这里 维护了一个可以直接运行调试的 Runtime 项目,方便大家直接调试源码。

MSG_ENTRY _objc_msgSend
cmpp0, #0
  // 检查接收者是否为 nil,如果是的话就执行 LNilOrTagged,其内部会返回 nil。
b.leLNilOrTagged

ldrp14, [x0]
  // 获取对象的 isa 指针。
GetClassFromIsa_p16 p14, 1, x0

LGetIsaDone:
  // 查找方法缓存,如果找到了就调用,如果没找到就调用 __objc_msgSend_uncached。
CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached

END_ENTRY _objc_msgSend

以上是 objc_msgSend 的汇编实现,我对其进行了精简,并添加了注释。从源码中我们可以总结出消息查找的基本流程:

  1. 首先对消息接收者进行 nil 检查,如果接收者为 nil,则调用 LNilOrTagged 函数直接返回 nil,避免后续无意义的查找;
  2. 通过对象的 isa 指针获取其类对象,这是查找方法实现的第一步,因为方法实现都存储在类对象中;
  3. 在类对象的方法缓存列表中查找目标方法,如果命中缓存则直接调用方法实现,否则调用 __objc_msgSend_uncached 进入慢速查找。

在慢速查找流程中,系统会调用 lookUpImpOrForward 函数进行更深入的方法查找(由于篇幅原因,我没有展开所有代码的调用细节,感兴趣的同学可以自行阅读源码)。以下是精简后的 lookUpImpOrForward 源码实现:

IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior) {
    const IMP forward_imp = (IMP)_objc_msgForward_impcache;
    IMP imp = nil;
    Class curClass;
    
    if (!cls->isInitialized()) {
        behavior |= LOOKUP_NOCACHE;
    }
    
    checkIsKnownClass(cls);
    
    cls = realizeAndInitializeIfNeeded_locked(inst, cls, behavior & LOOKUP_INITIALIZE);
    curClass = cls;
    
    if (!cls || !cls->ISA()) {
        imp = _objc_returnNil;
        goto done;
    }
    
    for (unsigned attempts = unreasonableClassCount();;) {
        // 从类的方法列表中查找方法实现。
        method_t *meth = getMethodNoSuper_nolock(curClass, sel);
        if (meth) {
            // 找到了方法实现并调用。
            imp = meth->imp(false);
            goto done;
        }
        
        // 获取 curClass 的父类,如果父类为 nil 的话,就跳出循环。
        if ((curClass = curClass->getSuperclass()) == nil) {
            imp = forward_imp;
            break;
        }
        
        // 检查继承链是否存在循环情况。
        if (--attempts == 0) {
            _objc_fatal("Memory corruption in class list.");
        }
        
        // 从父类的方法缓存列表中查找方法实现。
        imp = cache_getImp(curClass, sel);
        
        if (imp == forward_imp) {
            break;
        }
        
        if (imp) {
            // 从父类的方法缓存列表中找到了方法实现
            goto done;
        }
        
        /* 如果从方法缓存列表中未找到方法实现,
         则回到循环起点,从父类的方法列表中查找方法实现。*/
    }
    
    // 来到这里就说明没有找到方法实现。
    
    // 判断是否执行过方法解析?如果未执行的话就执行方法解析。
    if (behavior & LOOKUP_RESOLVER) {
        behavior ^= LOOKUP_RESOLVER;
        return resolveMethod_locked(inst, sel, cls, behavior);
    }
    
done:
    if ((behavior & LOOKUP_NOCACHE) == 0) {
        // 将方法加入 cls(当前类) 的方法缓存列表
        log_and_fill_cache(cls, imp, sel, inst, curClass);
    }
    
    if ((behavior & LOOKUP_NIL) && imp == forward_imp) {
        return nil;
    }
    
    return imp;
}

通过分析 lookUpImpOrForward 函数的源码实现,我们可以看到 Runtime 在查找方法实现时采用了多层次的查找策略,主要包括以下几个步骤:

  1. 方法查找流程:

    • 首先在类的方法缓存中快速查找,这是最高效的查找方式
    • 缓存未命中时,会遍历类的方法列表进行查找
    • 如果当前类中未找到,则沿着继承链向上查找,对每个父类重复上述两步操作
    • 找到方法实现后,会通过 log_and_fill_cache 将其缓存到当前类中,以提升后续调用性能
    • 如果遍历完整个继承链仍未找到,则进入方法动态解析阶段
  2. 方法动态解析: 当常规查找流程无法找到方法实现时,Runtime 会尝试通过动态方法解析机制来处理,这部分内容我们将在下一节详细讨论。

补充说明:

  • getMethodNoSuper_nolock 函数负责在方法列表中查找目标方法,其内部采用了二分查找(已排序)和线性查找(未排序)两种策略,以平衡查找效率和排序开销。
  • cache_getImp 函数则通过散列表实现方法缓存的快速查找,使用 SEL 作为键值,通过哈希算法将方法选择器映射到对应的实现地址。

这些优化策略共同构成了 ObjC 高效的消息查找机制,既保证了方法调用的性能,又维持了运行时的灵活性。

消息动态解析

当常规方法查找流程(包括缓存查找、方法列表查找和父类查找)都无法找到目标方法的实现时,Runtime 会进入方法动态解析阶段,调用 resolveMethod_locked 函数尝试动态添加方法实现。这个函数的核心实现如下:

static IMP
resolveMethod_locked(id inst, SEL sel, Class cls, int behavior) {
    // 执行对象方法解析逻辑。
    if (!cls->isMetaClass()) {
        // 这行代码等同于:[cls resolveInstanceMethod:sel]
        resolveInstanceMethod(inst, sel, cls);
    } else {// 执行类方法解析逻辑。
        // 这行代码等同于:[cls resolveClassMethod:sel]
        resolveClassMethod(inst, sel, cls);
    }

    // 函数内部最终会调用 lookUpImpOrForward 函数。
    return lookUpImpOrForwardTryCache(inst, sel, cls, behavior);
}

通过前面的源码分析,我们已经完整地了解了 Runtime 的消息查找和动态解析机制。从 objc_msgSend 的快速查找,到 lookUpImpOrForward 的慢速查找,再到 resolveMethod_locked 的动态方法解析,我们看到了 Runtime 是如何一步步尝试找到并执行目标方法的。如果这些步骤都无法找到方法实现,Runtime 就会进入最后一道防线:消息转发机制。

接下来,让我们深入探讨消息转发机制的具体实现。

消息转发机制

消息转发机制是 ObjC Runtime 中处理未实现方法的最后一道防线,它包含快速转发和完整转发两个阶段。快速转发允许对象将消息转发给其他对象处理,而完整转发则提供了更灵活的消息处理方式。虽然消息转发的核心实现是由汇编代码完成的,但通过分析 Runtime 源码和相关资料,我们可以将其核心逻辑整理为以下伪代码实现:

int __forwarding__(void *frameStackPointer, int isStret) {
    id receiver = *(id *)frameStackPointer;
    SEL sel = *(SEL *)(frameStackPointer + 8);
    Class receiverClass = object_getClass(receiver);
    
    // 调用 forwardingTargetForSelector: 方法。
    if (class_respondsToSelector(receiverClass, @selector(forwardingTargetForSelector:))) {
        id forwardingTarget = [receiver forwardingTargetForSelector:sel];
        if (forwardingTarget && forwardingTarget != receiver) {
            return objc_msgSend(forwardingTarget, sel, ...);
        }
    }
    
    // 调用 methodSignatureForSelector 获取方法签名后再调用 forwardInvocation。
    if (class_respondsToSelector(receiverClass, @selector(methodSignatureForSelector:))) {
        NSMethodSignature *methodSignature = [receiver methodSignatureForSelector:sel];
        if (methodSignature) {
            if (class_respondsToSelector(receiverClass, @selector(forwardInvocation:))) {
                NSInvocation *invocation = [NSInvocation _invocationWithMethodSignature:methodSignature frame:frameStackPointer];
                [receiver forwardInvocation:invocation];
                
                void *returnValue = NULL;
                [invocation getReturnValue:&returnValue];
                return returnValue;
            }
        }
    }
    
    // 如果以上两个方法都没有处理消息,则调用 doesNotRecognizeSelector 方法。
    if (class_respondsToSelector(receiverClass,@selector(doesNotRecognizeSelector:))) {
        [receiver doesNotRecognizeSelector:sel];
    }
    
    kill(getpid(), 9);
}

在消息转发机制中,有一个容易被忽视的重要细节:类方法其实也支持消息转发。虽然 Xcode 在代码提示时只会显示 - (id)forwardingTargetForSelector: 等实例方法的实现,但实际上 + (id)forwardingTargetForSelector: 等类方法同样可以用于消息转发。

实现类方法的消息转发非常简单:

  1. 将转发方法声明为类方法(使用 + 号);
  2. 在转发方法中使用类对象而不是实例对象。

总结

本文深入分析了 Runtime 中消息发送的核心实现,包括 objc_msgSend 的汇编实现以及 loopUpImpOrForward 函数的工作原理。但要完全理解 ObjC 的消息机制,还需要了解以下几个关键点:

  1. 消息查找过程:类是如何从方法列表中定位目标方法的?getMethodNoSuper_nolock 函数在其中扮演什么角色?
  2. 方法缓存机制:类是如何通过 cache_getImp 函数从缓存中快速获取方法实现的?
  3. 对象内存结构:包括 isa 指针、类指针、属性列表、方法列表等底层数据结构。

这些知识点涉及 ObjC 对象的内存布局,建议读者结合 Runtime 源码深入学习。

另外,在实际开发中,我们经常使用 respondsToSelector: 来检查对象是否实现了某个方法。但这个方法存在一个局限性:它无法检测到通过消息转发机制实现的方法。为此,我实现了一个支持消息转发检测的 respondsToSelector 方法,代码如下:

@interface NSObject (WXL)
- (BOOL)wxl_respondsToSelectorIncludingForwarding:(SEL)aSel;
@end

@implementation NSObject (WXL)
- (BOOL)wxl_respondsToSelectorIncludingForwarding:(SEL)aSel {
    if ([self respondsToSelector:aSel]) {
        return YES;
    }
    
    // 检查消息转发是否能处理消息。
    if ([self respondsToSelector:@selector(forwardingTargetForSelector:)]) {
        id forwardingTarget = [self forwardingTargetForSelector:aSel];
        if (forwardingTarget && forwardingTarget != self) {
            return YES;
        }
    }
    
    if ([self respondsToSelector:@selector(methodSignatureForSelector:)]) {
        NSMethodSignature *signature = [self methodSignatureForSelector:aSel];
        if (signature && [self respondsToSelector:@selector(forwardInvocation:)]) {
            return YES;
        }
    }
    
    return NO;
}
@end

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 开发者的关键技能之一。

《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坑分享到这里!这几天会逐步更新完这个系列,欢迎大家留言和指导~

作用域链和闭包(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…

走出变量提升的迷雾:10分钟彻底搞懂JavaScript执行机制与作用域

2025年5月18日 13:23

前言

JavaScript作为一门灵活的编程语言,其执行机制和变量声明规则有着诸多特性。理解这些特性对于编写高质量的JavaScript代码至关重要。本文将深入探讨JavaScript的执行机制、作用域、变量提升以及ES6引入的新特性,帮助开发者避免常见陷阱。

1. JavaScript代码的执行机制

JavaScript代码执行分为两个关键阶段:

1.1 编译阶段

当JavaScript引擎(如Chrome的V8)拿到代码后,首先进入编译阶段:

  • 代码从硬盘读入内存
  • 语法检测
  • 创建执行上下文环境
  • 处理变量声明和函数定义
// 编译阶段会创建类似这样的结构
currentVariable {
  showName: <function reference>,
  myName: undefined,
  // ...其他变量
}

1.2 执行阶段

编译完成后,代码按顺序执行,完成实际的赋值和函数调用操作。

2. 作用域:变量查找的规则

作用域决定了变量的可访问性和生命周期,JavaScript中包含三种主要作用域:

  • 全局作用域:在最外层定义的变量
  • 函数作用域:函数内部定义的变量
  • 块级作用域:ES6引入,在{}内使用letconst定义的变量

2.1 作用域链

当访问一个变量时,JavaScript会按照"冒泡查找"的规则:

  1. 先在当前作用域查找
  2. 找不到则向上层作用域查找
  3. 直到找到变量或到达全局作用域
  4. 全局作用域也没有则报错

这种查找路径构成了作用域链:当前作用域 → 父作用域 → ... → 全局作用域

2.2 词法作用域

JavaScript采用的是词法作用域(Lexical Scope),也称为静态作用域,这意味着函数的作用域在函数定义时就已确定,而非函数调用时:

let globalVar = 'global';

function outer() {
  let outerVar = 'outer';
  
  function inner() {
    console.log(outerVar); // 访问的是定义时的外部变量
    console.log(globalVar); // 同样可以访问更外层的全局变量
  }
  
  return inner;
}

const innerFn = outer();
innerFn(); // 输出: "outer" 和 "global"

词法作用域的特点:

  1. 静态确定:函数的作用域在编写代码时(词法分析阶段)就已确定
  2. 嵌套关系:内部函数可以访问外部函数中声明的变量
  3. 闭包基础:正是因为词法作用域,JavaScript才能实现强大的闭包功能

2.2.1 词法作用域与动态作用域的区别

为了理解词法作用域的特性,我们来看一个对比示例:

let value = 'global';

function foo() {
  console.log(value);
}

function bar() {
  let value = 'local';
  foo();
}

bar(); // 在词法作用域下输出: "global"
       // 如果是动态作用域则会输出: "local"

在上面的例子中,foo函数中的value引用的是全局变量,而非bar函数中的局部变量,这正是词法作用域的体现。

3. 变量提升(Hoisting)现象

3.1 var的变量提升

使用var声明的变量会在编译阶段被"提升"到当前作用域的顶部,但只提升声明,不提升赋值:

console.log(myName); // 输出:undefined
var myName = '曾小贤';

实际执行顺序相当于:

var myName; // 声明被提升
console.log(myName); // undefined
myName = '曾小贤'; // 赋值保留在原位置

3.2 函数声明的提升

函数声明会被完整提升到作用域顶部,包括函数体:

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

这就是为什么在示例代码中,showName()可以在函数声明之前调用。

3.3 变量提升的问题

showName(); // 函数执行了
console.log(myName); // undefined
var myName = '曾小贤';
function showName() {
    let b = 2;
    console.log('函数执行了');
}

变量提升会导致代码执行结果与阅读顺序不一致,造成困惑,是JavaScript设计上的一个争议点。

4. let/const与暂时性死区(TDZ)

4.1 TDZ现象

ES6引入的letconst解决了变量提升的混乱,它们声明的变量不会被提升,相反会创建"暂时性死区":

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

从变量声明的块作用域开始,到该变量被赋值之前的区域,称为"暂时性死区"。在这个区域内,访问该变量会抛出错误。

4.2 块级作用域

{
  let blockVar = 'block scope';
  var functionVar = 'function scope';
}
console.log(functionVar); // "function scope"
console.log(blockVar); // ReferenceError: blockVar is not defined

letconst声明的变量严格遵循块级作用域,而var则遵循函数作用域。

5. var与let的关键区别

特性 var let
作用域 函数作用域 块级作用域
变量提升 会提升声明,初始值为undefined 不提升,有TDZ
重复声明 允许在同一作用域重复声明 禁止在同一块作用域重复声明
全局声明 成为window对象属性 不会成为window对象属性

6. 执行上下文示意图

从图中可以看到,执行上下文包含:

  • 全局上下文:最外层的执行环境
    • 变量环境:存储var声明的变量和函数声明
      a = undefined
      fn=function
      
    • 词法环境:存储let和const声明的变量

这种设计使得var声明的变量会出现提升现象,而letconst则不会。

7. 最佳实践建议

  1. 优先使用let和const:避免var的提升问题
  2. 默认使用const:如果变量不需要重新赋值,增加代码可靠性
  3. 理解TDZ:养成先声明后使用的习惯
  4. 合理划分作用域:减少作用域链查找,提高性能

总结

JavaScript的执行机制和变量声明规则看似复杂,实则有迹可循。理解编译和执行的双阶段过程、作用域链的查找规则、变量提升的工作原理以及ES6引入的新特性,不仅能帮助我们写出更可靠的代码,也能在面试中脱颖而出。

变量提升是JavaScript的一个历史包袱,但通过使用let和const,我们可以避开大多数陷阱。在现代JavaScript开发中,遵循"先声明后使用"的原则,合理利用块级作用域,才能充分发挥这门语言的优势。

正则表达式与文本处理的艺术

作者 BitCat
2025年5月18日 11:50

引言

在前端开发领域,文本处理是一项核心技能。正则表达式作为一种强大的模式匹配工具,能够帮助我们高效地处理各种复杂的文本操作任务。

正则表达式基础

什么是正则表达式?

正则表达式是一种用于匹配字符串中字符组合的模式。它由一系列字符和特殊符号组成,用于定义搜索模式。

// 基本示例:匹配所有数字
const numberPattern = /\d+/g;
const text = "我有23个苹果和45个橙子";
const numbers = text.match(numberPattern); // 结果: ["23", "45"]

基本语法元素

元素 描述 示例
. 匹配任意单个字符 /a.c/ 匹配 "abc", "axc" 等
[] 字符集,匹配方括号内的任意字符 /[abc]/ 匹配 "a", "b", 或 "c"
[^] 否定字符集,匹配任何不在方括号内的字符 /[^abc]/ 匹配除 "a", "b", "c" 之外的字符
\d 匹配任意数字,等价于 [0-9] /\d{3}/ 匹配三个连续数字
\w 匹配任意字母、数字或下划线,等价于 [A-Za-z0-9_] /\w+/ 匹配一个或多个字母数字字符
\s 匹配任意空白字符 /\s/ 匹配空格、制表符等

量词

量词决定了模式应该匹配多少次。

量词 描述 示例
* 匹配前一个元素零次或多次 /a*/ 匹配 "", "a", "aa", ...
+ 匹配前一个元素一次或多次 /a+/ 匹配 "a", "aa", ... 但不匹配 ""
? 匹配前一个元素零次或一次 /a?/ 匹配 "" 或 "a"
{n} 精确匹配前一个元素n次 /a{3}/ 匹配 "aaa"
{n,} 匹配前一个元素至少n次 /a{2,}/ 匹配 "aa", "aaa", ...
{n,m} 匹配前一个元素n至m次 /a{1,3}/ 匹配 "a", "aa", 或 "aaa"

锚点

锚点用于指定匹配的位置。

// 使用锚点匹配行首和行尾
const pattern = /^开始.*结束$/;
console.log(pattern.test("开始这是中间内容结束")); // true
console.log(pattern.test("这不是开始的内容结束")); // false

贪婪与惰性匹配

正则表达式的默认行为是贪婪匹配,它会尽可能多地匹配字符。相比之下,惰性匹配则尽可能少地匹配字符。

贪婪匹配

// 贪婪匹配示例
const htmlText = "<div>内容1</div><div>内容2</div>";
const greedyPattern = /<div>.*<\/div>/;
const greedyMatch = htmlText.match(greedyPattern);
console.log(greedyMatch[0]); // 结果: "<div>内容1</div><div>内容2</div>"

贪婪模式下,.* 会匹配尽可能多的字符,导致整个字符串都被匹配。

惰性匹配

// 惰性匹配示例
const htmlText = "<div>内容1</div><div>内容2</div>";
const lazyPattern = /<div>.*?<\/div>/g;
const lazyMatches = htmlText.match(lazyPattern);
console.log(lazyMatches); // 结果: ["<div>内容1</div>", "<div>内容2</div>"]

通过在量词后添加问号 ?,可以将贪婪匹配转为惰性匹配。惰性模式下,正则表达式引擎会尽可能少地匹配字符,在第一次找到完整匹配后就停止。

性能对比

// 贪婪匹配性能测试
const longText = "<div>".repeat(1000) + "</div>".repeat(1000);
console.time('greedy');
const greedyResult = /<div>.*<\/div>/.test(longText);
console.timeEnd('greedy'); // 可能需要很长时间甚至超时

// 惰性匹配性能测试
console.time('lazy');
const lazyResult = /<div>.*?<\/div>/.test(longText);
console.timeEnd('lazy'); // 通常比贪婪匹配快得多

在处理长文本时,惰性匹配通常比贪婪匹配有更好的性能,因为它避免了过度回溯。

捕获组

捕获组允许我们提取模式的特定部分,这在需要处理复杂文本时尤为有用。

基本捕获组

// 基本捕获组
const dateString = "今天是2023-05-15";
const datePattern = /(\d{4})-(\d{2})-(\d{2})/;
const match = dateString.match(datePattern);
console.log(match[0]); // "2023-05-15"(完整匹配)
console.log(match[1]); // "2023"(第一个捕获组)
console.log(match[2]); // "05"(第二个捕获组)
console.log(match[3]); // "15"(第三个捕获组)

命名捕获组

命名捕获组使代码更易理解,特别是在复杂模式中。

// 命名捕获组
const dateString = "今天是2023-05-15";
const datePattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = dateString.match(datePattern);
console.log(match.groups.year);  // "2023"
console.log(match.groups.month); // "05"
console.log(match.groups.day);   // "15"

非捕获组

当我们只需要分组但不需要捕获匹配内容时,可以使用非捕获组。

// 非捕获组
const text = "HTML和CSS都是前端必备技能";
const pattern = /(?:HTML|CSS)和(?:HTML|CSS)/;
console.log(pattern.test(text)); // true

反向引用

反向引用允许我们在模式中引用之前的捕获组。

// 反向引用
const htmlWithAttrs = '<div class="container">内容</div>';
const pattern = /<(\w+)([^>]*)>(.*?)<\/\1>/;
const match = htmlWithAttrs.match(pattern);
console.log(match[1]); // "div"(标签名)
console.log(match[2]); // ' class="container"'(属性)
console.log(match[3]); // "内容"(内容)

性能优化技巧

避免过度使用贪婪模式

贪婪模式可能导致大量回溯,降低性能。在适当的情况下,使用惰性匹配可以显著提高效率。

// 不推荐(在大文本中可能很慢)
const slowPattern = /<div>.*<\/div>/;

// 推荐
const fastPattern = /<div>.*?<\/div>/;

优先使用更具体的模式

// 不推荐(太宽泛)
const emailCheck1 = /.*@.*/;

// 推荐(更具体)
const emailCheck2 = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;

避免嵌套量词

嵌套量词如 (a+)+ 可能导致指数级的性能下降,被称为"灾难性回溯"。

// 危险模式,可能导致回溯爆炸
const badPattern = /^(a+)*$/;
const input = "aaaaaaaaaaaaaaa!"; // 以感叹号结尾
console.time('test');
badPattern.test(input); // 可能导致浏览器挂起
console.timeEnd('test');

使用原子组优化

在支持原子组的环境中,可以使用原子组 (?>...) 来控制回溯。

// 在某些正则实现中支持原子组(JavaScript标准还不支持)
// const atomicGroup = /(?>a+)b/;

实际应用案例

表单验证

// 邮箱验证
function validateEmail(email) {
  const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  return pattern.test(email);
}

// 密码复杂度验证(至少8位,包含大小写字母、数字和特殊字符)
function validatePassword(password) {
  const pattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+])[A-Za-z\d!@#$%^&*()_+]{8,}$/;
  return pattern.test(password);
}

// 手机号验证(中国大陆)
function validatePhone(phone) {
  const pattern = /^1[3-9]\d{9}$/;
  return pattern.test(phone);
}

高亮文本匹配

// 搜索关键词高亮
function highlightKeywords(text, keyword) {
  const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const pattern = new RegExp(`(${escapedKeyword})`, 'gi');
  return text.replace(pattern, '<span class="highlight">$1</span>');
}

// 使用示例
const searchResult = highlightKeywords(
  "JavaScript是一种用于网页交互的编程语言",
  "javascript"
);
console.log(searchResult); // "<span class="highlight">JavaScript</span>是一种用于网页交互的编程语言"

URL解析

// 提取URL参数
function getUrlParams(url) {
  const params = {};
  const pattern = /[?&]([^=&#]+)=([^&#]*)/g;
  let match;
  
  while ((match = pattern.exec(url)) !== null) {
    params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
  }
  
  return params;
}

// 使用示例
const url = "https://example.com/search?q=正则表达式&page=1&sort=desc";
const params = getUrlParams(url);
console.log(params); // {q: "正则表达式", page: "1", sort: "desc"}

代码格式化

// 格式化数字为千分位表示
function formatNumber(num) {
  return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

// 使用示例
console.log(formatNumber(1234567)); // "1,234,567"

边缘情况和限制

正则表达式的局限性

正则表达式不适合处理一些特定的文本结构,如HTML解析或嵌套结构。

// 错误的做法:使用正则表达式解析HTML
const htmlContent = '<div><p>文本1</p><p>文本2 <a href="#">链接</a></p></div>';
const badPattern = /<p>(.*?)<\/p>/g; // 不能正确处理嵌套标签

// 更好的做法:使用DOM解析
function extractParagraphText(html) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  const paragraphs = doc.querySelectorAll('p');
  return Array.from(paragraphs).map(p => p.textContent);
}

处理Unicode字符

JavaScript正则表达式对Unicode的支持有限,需要使用u标志。

// 没有u标志,无法正确处理Unicode
console.log(/^.$/.test('😊')); // false(表情符号被视为两个字符)

// 使用u标志正确处理Unicode
console.log(/^.$/u.test('😊')); // true

避免过度依赖正则表达式

有时候,使用字符串方法或专门的解析库可能是更好的选择。

// 对于简单的字符串操作,使用内置方法可能更清晰
// 不推荐
const csv = "a,b,c";
const values1 = csv.match(/([^,]+),([^,]+),([^,]+)/);

// 推荐
const values2 = csv.split(',');

对比分析

正则表达式 vs. 字符串方法

方法 优势 劣势
正则表达式 强大的模式匹配能力,简洁的代码 学习曲线陡峭,调试困难,性能问题
字符串方法 直观易懂,性能可预测 复杂模式匹配需要更多代码
// 提取域名 - 正则表达式方法
function getDomainRegex(url) {
  const match = url.match(/^https?:\/\/([^/]+)/);
  return match ? match[1] : null;
}

// 提取域名 - 字符串方法
function getDomainString(url) {
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    return null;
  }
  const withoutProtocol = url.replace(/^https?:\/\//, '');
  const firstSlash = withoutProtocol.indexOf('/');
  return firstSlash === -1 ? withoutProtocol : withoutProtocol.substring(0, firstSlash);
}

浏览器兼容性

大多数现代浏览器支持ES2018中引入的正则表达式功能(如命名捕获组),但在支持旧浏览器的项目中需要注意。

// 命名捕获组(在较旧的浏览器中不支持)
const datePattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;

// 向后兼容的替代方案
const oldDatePattern = /(\d{4})-(\d{2})-(\d{2})/;
const match = "2023-05-15".match(oldDatePattern);
const [_, year, month, day] = match;

结论

正则表达式是前端开发中强大而必不可少的工具。通过深入理解贪婪与惰性匹配、捕获组、性能优化等核心概念,我们可以编写出高效、可读的正则表达式,解决各种文本处理问题。虽然学习曲线较陡,但掌握这一技能将极大提升我们的开发效率和代码质量。

正则表达式的精髓在于找到复杂性和可读性之间的平衡。一个好的正则表达式应当既能解决问题,又便于其他人理解和维护。

学习资源


如果你觉得这篇文章有帮助,欢迎点赞收藏,也期待在评论区看到你的想法和建议!👇

终身学习,共同成长。

咱们下一期见

💻

React Hooks 的优势和使用场景

作者 Riesenzahn
2025年5月18日 07:37

React Hooks 的优势和使用场景

核心优势

  1. 简化组件逻辑
    • 告别 class 组件的繁琐生命周期
    • 将相关逻辑聚合到单个 Hook 中
    • 消除 this 绑定问题
// 类组件
class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return <button onClick={this.handleClick}>点击 {this.state.count} 次</button>;
  }
}

// 函数组件 + Hooks
function Example() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>点击 {count} 次</button>;
}
  1. 逻辑复用
    • 自定义 Hook 实现跨组件逻辑复用
    • 替代高阶组件和 render props 模式
// 自定义 Hook
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => setSize({
      width: window.innerWidth,
      height: window.innerHeight
    });
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 在组件中使用
function MyComponent() {
  const { width } = useWindowSize();
  return <div>窗口宽度: {width}px</div>;
}
  1. 性能优化
    • 细粒度的状态更新控制
    • 避免不必要的渲染
function ExpensiveComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // 只有 count 变化时才重新计算
  const computedValue = useMemo(() => {
    return expensiveCalculation(count);
  }, [count]);

  // 只有在组件挂载时执行
  useEffect(() => {
    fetchInitialData();
  }, []);

  return (
    <div>
      <p>{computedValue}</p>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
}

主要使用场景

  1. 状态管理
    • useState: 基础状态管理
    • useReducer: 复杂状态逻辑
function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, []);

  function handleAddTodo(text) {
    dispatch({ type: 'ADD_TODO', text });
  }

  // ...
}
  1. 副作用处理
    • useEffect: 数据获取、订阅、手动 DOM 操作
    • useLayoutEffect: DOM 变更后同步执行
function DataFetcher({ id }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let ignore = false;
    
    async function fetchData() {
      const result = await fetch(`/api/data/${id}`);
      if (!ignore) {
        setData(await result.json());
      }
    }

    fetchData();
    
    return () => { ignore = true; };
  }, [id]); // id 变化时重新获取

  // ...
}
  1. 性能优化
    • useMemo: 缓存计算结果
    • useCallback: 缓存函数引用
function Parent() {
  const [count, setCount] = useState(0);
  
  // 避免子组件不必要的重渲染
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return (
    <>
      <Child onClick={handleClick} />
      <div>计数: {count}</div>
    </>
  );
}
  1. 访问 DOM 元素
    • useRef: 获取 DOM 引用或保存可变值
function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  
  const onButtonClick = () => {
    inputEl.current.focus();
  };

  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>聚焦输入框</button>
    </>
  );
}
  1. 上下文访问
    • useContext: 简化上下文使用
const ThemeContext = React.createContext('light');

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button style={{ background: theme === 'dark' ? '#333' : '#eee' }}>按钮</button>;
}

最佳实践

  1. Hook 使用规则

    • 只在 React 函数组件顶层调用 Hook
    • 不要在循环、条件或嵌套函数中调用 Hook
    • 自定义 Hook 必须以 "use" 开头
  2. 组织代码

    • 将复杂逻辑拆分为多个小 Hook
    • 相关逻辑组织在一起
  3. 性能考量

    • 合理使用依赖数组
    • 避免不必要的 effect 执行
    • 大型列表考虑虚拟化
  4. 测试策略

    • 使用 @testing-library/react-hooks 测试自定义 Hook
    • 模拟依赖项进行隔离测试

常见问题解决方案

  1. 无限循环

    // 错误示例
    useEffect(() => {
      setCount(count + 1); // 会导致无限循环
    }, [count]);
    
    // 正确方式
    useEffect(() => {
      setCount(c => c + 1); // 使用函数式更新
    }, []); // 空依赖数组
    
  2. 过时闭包

    function Counter() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        const id = setInterval(() => {
          setCount(count + 1); // 总是使用初始值
        }, 1000);
        return () => clearInterval(id);
      }, []); // 缺少 count 依赖
    
      // 正确方式
      useEffect(() => {
        const id = setInterval(() => {
          setCount(c => c + 1); // 使用函数式更新
        }, 1000);
        return () => clearInterval(id);
      }, []);
    }
    
  3. 条件执行

    // 错误示例
    if (condition) {
      useEffect(() => { ... }); // 违反 Hook 规则
    }
    
    // 正确方式
    useEffect(() => {
      if (condition) {
        // 在 effect 内部进行条件判断
      }
    }, [condition]);
    

React Hooks 通过简化组件逻辑、提高代码复用性和优化性能,已经成为现代 React 开发的标准方式。合理运用各种 Hook 可以显著提升开发效率和代码可维护性。

如何提高前端应用的性能?

作者 Riesenzahn
2025年5月18日 07:37

如何提高前端应用的性能

1. 代码优化

1.1 减少不必要的DOM操作

// 差: 频繁操作DOM
for(let i=0; i<100; i++) {
  document.getElementById('list').innerHTML += `<li>${i}</li>`;
}

// 好: 使用文档片段批量操作
const fragment = document.createDocumentFragment();
for(let i=0; i<100; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  fragment.appendChild(li);
}
document.getElementById('list').appendChild(fragment);

1.2 使用事件委托

// 差: 为每个元素绑定事件
document.querySelectorAll('.btn').forEach(btn => {
  btn.addEventListener('click', handleClick);
});

// 好: 使用事件委托
document.getElementById('container').addEventListener('click', (e) => {
  if(e.target.classList.contains('btn')) {
    handleClick(e);
  }
});

2. 资源优化

2.1 图片优化

  • 使用WebP格式替代JPEG/PNG
  • 实现懒加载(Lazy Loading)
  • 使用响应式图片(srcset)
  • 压缩图片(TinyPNG等工具)

2.2 代码分割

// 动态导入实现代码分割
const module = await import('./module.js');

2.3 缓存策略

  • 设置合理的Cache-Control头
  • 使用Service Worker实现离线缓存
  • 资源文件使用内容哈希命名

3. 网络优化

3.1 使用CDN

  • 将静态资源部署到CDN
  • 选择离用户最近的CDN节点

3.2 启用HTTP/2

  • 多路复用减少连接数
  • 头部压缩减少传输量
  • 服务器推送预加载资源

3.3 预加载关键资源

<link rel="preload" href="critical.css" as="style">
<link rel="prefetch" href="next-page.js" as="script">

4. 渲染优化

4.1 减少重排和重绘

// 获取布局信息前进行批量修改
const width = element.offsetWidth; // 触发重排
element.style.width = width + 10 + 'px'; 

// 使用requestAnimationFrame优化动画
function animate() {
  // 动画逻辑
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

4.2 使用CSS硬件加速

.transform-element {
  transform: translateZ(0);
  will-change: transform;
}

5. 监控与分析

5.1 性能指标

  • First Contentful Paint (FCP)
  • Largest Contentful Paint (LCP)
  • Cumulative Layout Shift (CLS)
  • Time to Interactive (TTI)

5.2 性能工具

  • Lighthouse
  • WebPageTest
  • Chrome DevTools Performance面板
  • 真实用户监控(RUM)

6. 框架优化

6.1 React优化

// 使用React.memo避免不必要渲染
const MemoComponent = React.memo(MyComponent);

// 使用useCallback/useMemo缓存计算结果
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

6.2 Vue优化

// 使用v-once处理静态内容
<div v-once>{{ staticContent }}</div>

// 合理使用计算属性
computed: {
  filteredList() {
    return this.list.filter(item => item.active);
  }
}

7. 构建优化

7.1 Tree Shaking

// package.json配置sideEffects
{
  "sideEffects": ["*.css", "*.scss"]
}

7.2 压缩代码

  • 使用Terser压缩JavaScript
  • 使用CSSNano压缩CSS
  • 使用HTMLMinifier压缩HTML

8. 移动端优化

8.1 减少首屏资源

  • 关键CSS内联
  • 非关键JS延迟加载
  • 使用骨架屏提升感知性能

8.2 优化触摸事件

/* 禁用触摸高亮 */
button {
  -webkit-tap-highlight-color: transparent;
}

/* 优化滚动性能 */
.scroll-container {
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
}

最佳实践总结

  1. 测量优先:使用性能工具找出瓶颈
  2. 渐进增强:确保核心功能在低端设备可用
  3. 按需加载:只加载当前需要的资源
  4. 持续监控:建立性能基准和报警机制
  5. 团队协作:将性能指标纳入开发流程

通过综合应用以上技术,可以显著提升前端应用的加载速度、交互流畅度和整体用户体验。性能优化是一个持续的过程,需要定期评估和调整策略。

Mac 显示器相关

作者 Augustine
2025年5月17日 21:49

使用Mac系电脑,并且是外接显示器使用时。 Macbook retina屏幕是5K的,通过HIDPI渲染成 2K 布局。 用MacMini 外接27寸4K屏幕,通过HIDPI 渲染成 1K 布局。

外接显示器如何调出更多【分辨率】和【刷新率】

打开【系统设置】=>【显示器】
可以手动设置屏幕的【分辨率】。
最右侧的选项(更多空间)就是不做 HIDPI 的显示效果。

此时,只提供了几个分辨率选项供我们选择。 其实还有很多选项被隐藏起来了。
按住 alt键盘/option 键 点击缩放 可以展示这台显示器所能支持的所有分辨率选项(不需要第三方软件,这是Mac自己的技巧)。
下面还有个选项框【显示低分辨率模式】
低分辨率就是不做HIDPI

选 2560 * 1440 =》显示器用4K的性能渲染出2K的画面,也就是开了HIDPI。 选 2560 * 1440(低分辨率)=》显示器真的只使用这个分辨率。

查看显示器【物理分辨率】以及当前采用的【逻辑分辨率】

打开【System Report】=> 【Graphics/Displays】

macMini-displays.png 红色是显示器的【物理分辨率】。
蓝色则是常说的【逻辑分辨率】。
即,此时 Mac 将4K的屏幕通过 HIDPI 把UI视图渲染为 1K 屏幕的显示布局。
视觉上看起来就是,拥有4K级清晰度的超高清1K屏幕


vue 入门到实战 一

2025年5月18日 13:11

第1章 初始Vue.js

1.1 网站交互方式

Web网站有单页应用程序(SPA,Single-page Application)和多页应用程序(MPA,Multi-page Application)两种交互方式。

多页应用程序,顾名思义是由多个页面组成的站点。在多页应用程序中,每个网页在每次收到相应的请求时都会重新加载。多页应用程序很大,由于不同页面的数量和层数,有时甚至可以认为很麻烦,我们可以在大多数电子商务网站上找到MPA的示例。

多页应用程序以服务端为主导,前后端混合开发,例如:.php、.aspx、.jsp。技术堆栈包括HTML、CSS、JavaScript、jQuery,有时还包括AJAX。

图片

单页应用程序,就是只有一张Web页面的应用。单页应用程序是加载单个HTML页面并在用户与应用程序交互时,动态更新该页面的Web应用程序。浏览器一开始会加载必需的HTML、CSS和JavaScript,所有的操作都在这张页面上完成,都由JavaScript来控制。因此,对单页应用来说模块化的开发和设计显得相当重要。单页应用开发技术复杂,所以诞生了许多前端开发框架:Angular.js、React.js、Vue.js等。

选择单页应用程序开发时,软件工程师通常采用以下技术堆栈:HTML5、Angular.js、React.js、Vue.js、Ember.js、AJAX等。

图片

1.2 MVVM模式

MVVM是Model-View-ViewModel的缩写,它是一种基于前端开发的架构模式,其核心是提供对View和ViewModel的双向数据绑定,这使得ViewModel的状态改变可以自动传递给View,即所谓的数据双向绑定。

在MVVM架构下,View和Model 之间并没有直接的联系,而是通过ViewModel进行交互,Model和ViewModel之间的交互是双向的,因此View数据的变化会同步到Model中,而Model数据的变化也会立即反应到View上。

图片

1.2.1、Model

模型(Model):对应data中的数据,一般JS对象

data: {a'',address: '',name: ''}

data中书写的Key:Value都会出现在Vue实例VM身上

图片

1.2.2、View

视图(View):对应模板(DOM)

<div id="root"><h2>{  { a }}</h2><h2>{{ address }}</h2><h2>{  { name }}</h2></div>

1.2.3、ViewModel

视图模型(ViewModel):对应Vue实例对象(VM)

3、ViewModel

视图模型(ViewModel):对应Vue实例对象(VM)<script type="text/javascript">// ViewModelnew Vue({.   // Viewel: '#root', // Modeldata: {  a: '',address: '', name: ''})</script>

1.3 Vue.js是什么

Vue(读音/vjuː/,类似于view)是一套构建用户界面的渐进式框架。与其它重量级框架不同的是,Vue.js采用自底向上增量开发的设计。

Vue.js本身只是一个JS库,它的目标是通过尽可能简单的API实现响应的数据绑定和组合的视图组件。Vue.js可以轻松构建SPA(Single Web Application)应用程序,通过指令扩展HTML,通过表达式将数据绑定到HTML,最大程度解放DOM操作。

1.4 安装Vue.js

将Vue.js添加到项目中有4种主要方法:本地独立版本方法、CDN方法、NPM方法以及命令行工具(CLI)方法。

本地独立版本方法

可通过地址“unpkg.com/vue@next”将最…

首先安装一个live server插件,在helllovue.html代码上点击右键出现一个名为Open with Live Server的选项,自动打开浏览器,默认端口号是5500。

图片图片

CDN方法

可通过CDN(Content Delivery Network,内容分发网络)引入最新版本的Vue.js库。

图片

NPM方法

在使用Vue.js构建大型应用时推荐使用NPM安装最新稳定版的Vue.js,因为NPM能很好地和webpack模块打包器配合使用。示例如下:

npm install vue@next

命令行工具(CLI)方法

Vue.js提供一个官方命令行工具(Vue CLI),为单页面应用快速搭建繁杂的脚手架。对于初学者不建议使用NPM和Vue CLI方法安装Vue.js。

具体步骤可以参考下面的链接;

aistudy.baidu.com/okam/pages/…

1.5 第一个Vue.js程序

可通过“code.visualstudio.com”地址下载VSCode,本书使用的安装文件是VSCodeUserSetup-x64-1.52.1.exe(双击即可安装)。

图片图片

const vueApp = Vue.createApp({        //数据        data() {            return {                title"Vue3.0 使用 Vue.createApp() 创建一个应用程序 ",                userInfo: {} //定义用户对象            }        },        //初始化的入口        created: function () {            //调用方法:获取用户信息            this.getUserInfo();        },        //方法        methods: {            //获取用户信息            getUserInfo: function () {                this.userInfo = {                    userId1,                    userName"vivi的博客",                    blogName"您好,欢迎访问 vivi的博客",                    blogUrl"https://blog.csdn.net/vivi"                }            }        }        //使用 mount() 方法,装载应用程序实例的根组件    }).mount('#app'); 

每个Vue.js应用都是通过用createApp函数创建一个新实例开始,具体语法如下:

  const app = Vue.createApp({ /* 选项 */ }) 通过上面那个图片,可以看出这个选项可以定义一个参数,也可以包裹一个data数据,所以选项是选择。

传递给createApp的选项用于配置根组件(渲染的起点)。Vue.js应用创建后,调用mount方法将Vue.js应用挂载到一个DOM元素(HTML元素或CSS选择器)中,例如,如果把一个Vue.js应用挂载到

上,应传递#app。示例代码如下:

  const HelloVueApp = {}//配置根组件

  const vueApp = Vue.createApp(HelloVueApp)//创建Vue实例

  const vm = vueApp.mount('#hello-vue')//将Vue实例挂载到#app

1.6 插值与表达式

Vue的插值表达式“{ { }}”的作用是读取Vue.js中data数据,显示在视图中,数据更新,视图也随之更新。“{ { }}”里只能放表达式(有返回值),不可以放语句,例如,{ { var a = 1 }}与{ { if (ok) { return message } }}都是无效的。

数据绑定最常见的形式就是使用“Mustache(小胡子)”语法(双花括号)的文本插值,它将绑定的数据实时显示出来。例如,{ { counter }},无论何时,绑定的Vue.js实例的counter属性值发生改变,插值处的内容都将更新。

“{ { }}”将数据解释为普通文本,而非HTML代码。当我们需要输出真正的HTML代码时,可使用v-html指令。

假如,Vue.js实例的data为:

data() {            return {                rawHtml'<hr>'            }    }

则“

无法显示HTML元素内容: { { rawHtml }}

”显示的结果是
;而“

可正常显示HTML元素内容:

”显示的结果是一条水平线。

对于所有的数据绑定,Vue.js都提供了完全的JavaScript表达式支持。示例如下:

{ { number + 1 }}

{ { isLogin? 'True' : 'False' }}

{ { message.split('').reverse().join('')}}

HTML Element 的 alt 属性详解 —— 渐进式 Web 可访问性实践

2025年5月18日 11:00

在 Web 开发领域, HTML 元素 中的 alt 属性 是一项关键技术,其作用涉及到浏览器渲染、搜索引擎优化以及无障碍访问。 alt 属性 通常应用于 img 标签 上,其主要功能是为图像提供替代文本,以便在图像无法正常加载或用户使用屏幕阅读器时,仍能获取图像所表达的信息。下文将通过严谨的推理与分步分析,结合真实世界的案例,深入探讨 alt 属性 的各个方面。

在理解 alt 属性 之前,有必要认识到图像在 Web 页面中占据的重要地位。 Web 页面的图像能够传递丰富的信息与情感,但同时也存在图像加载失败、网络延迟或设备兼容性问题等风险。考虑到这一点, alt 属性 的诞生即为解决这些问题而设计。它不仅使页面内容在图像缺失时仍然保持可读性,同时也是提升 Web 可访问性和用户体验的基础措施之一。

在解释 alt 属性 的工作原理时,可以将其视为图像的备用说明。设想某个电子商务网站展示产品图片,但在网络状况不佳或图片资源被阻止加载的情况下,用户会看到一段简洁的文字描述,而不是空白区域或破损图标。此时, alt 属性 就发挥了关键作用。借助 alt 属性,浏览器能够在无法渲染图像时,显示由开发者预先定义的文本,从而保证用户仍能理解该部分内容。这一机制不仅提升了用户体验,也为搜索引擎提供了识别页面内容的依据,间接助力页面排名和搜索优化。

通过对 alt 属性 工作机制的深入分析,可以发现浏览器内核在渲染页面时,会首先加载 HTML 代码并解析各个标签,遇到 img 标签 时便会检查其 alt 属性 是否存在。如果图像资源加载成功, alt 属性 中的文本通常不会直接呈现;但当图像加载失败或者用户启用了无图模式时,浏览器会显示 alt 属性 的文本内容。屏幕阅读器 在为视障用户朗读页面内容时,同样会调用 alt 属性 的文本,使得用户能够理解图像所要表达的信息。这样一来,无论用户处于何种环境, alt 属性 都能够保证页面信息传达的完整性。

考虑到实际应用情景,不少知名企业均将 alt 属性 作为无障碍设计的重要组成部分。例如,某国际知名电商平台在产品详情页中,不仅为产品图片添加了详细描述,而且在图片为装饰性用途时,会将 alt 属性 设置为空字符串(alt=``""``")以避免冗余信息被屏幕阅读器朗读。这种实践经过反复验证,显著提升了页面对各类用户的友好度。以实际案例来看,一家主打家居产品的企业,在设计网站时,专门聘请了无障碍设计专家,通过对每个 img 标签 设置符合语义要求的 alt 属性,最终实现了访问量和转化率的双提升,用户反馈中对页面易用性给予了高度评价。

对 alt 属性 的最佳实践之一是确保替代文本的描述应尽可能精确与简洁。开发者在撰写 alt 属性 文本时,应避免使用模糊或无意义的词语。换句话说, alt 属性 的文本内容需要能够客观、准确地传达图像信息。设想一家旅游网站展示风景照片,如果 alt 属性 文本仅仅写成 图片,便难以让用户感知图像所蕴含的具体内容;而如果能够写成 夕阳西下 海边悬崖,则能更好地传递情感与场景。因而在实际操作中,合理描述图像内容显得尤为重要。

在讨论 alt 属性 的应用时,还需要注意其与其他 HTML 属性 的关系。 Web 开发过程中常常会使用到 title 属性、 aria-label 属性等,这些属性各有侧重。 title 属性 提供了鼠标悬停提示信息,而 aria-label 属性 则用于无障碍辅助技术,两者与 alt 属性 有相互补充的作用。与此同时,开发者需要区分装饰性图像与功能性图像之间的差异。对于纯粹起装饰效果的图像, alt 属性 通常应设置为空字符串(例如: <img src=decorative.jpg alt=```` />),以免干扰屏幕阅读器的内容输出。反之,对于承载主要信息的图像, alt 属性 的文本需要包含足够的信息细节,使得不使用图像的用户也能理解页面的意图。

回顾 alt 属性 的历史背景,可以发现早期的 Web 标准对无障碍访问的要求较低,但随着无障碍立法和用户需求的不断提升, alt 属性 的重要性逐步凸显。开发者们逐渐认识到,任何忽视 alt 属性 设计的网页,往往难以满足现代 Web 用户的多样化需求。国际标准组织( W3C )在其 Web 可访问性指南( WCAG )中,对 alt 属性 的使用提出了详细建议。透过这些指导原则,开发者可以确保页面在视觉、听觉和认知等各个层面都具有良好的可访问性,从而实现真正的包容性设计。

对浏览器内核而言, alt 属性 的处理机制体现了软件设计中容错与降级加载的思想。具体而言,当图像资源无法呈现时,内核会自动调用 alt 属性 中的文本作为占位符,这种设计不仅提高了系统的健壮性,还确保了用户体验的一致性。开发者在编写 HTML 代码时,应充分考虑到这一点,从而构建出既美观又实用的 Web 页面。举例来说,在新闻网站上,编辑在发布文章时,经常会上传配图;若因网络故障或图片链接失效, alt 属性 中的描述文本便成为文章内容的重要组成部分,使得读者仍能领略文章所要表达的意境。

在真实项目中, alt 属性 的设置经常涉及跨部门协作。设计师、开发者与内容编辑需要密切配合,确保每个图像都能准确反映其语义。例如,某大型在线教育平台在设计课程介绍页面时,不仅在产品原型阶段就明确规定了图像 alt 属性 的填写要求,还在上线后定期通过无障碍测试工具检测页面是否存在缺失或错误的 alt 属性。该平台借助详细的规范和自动化测试手段,成功降低了因图像描述不当导致的用户困惑和无障碍障碍风险,进一步巩固了品牌在用户心中的专业形象。

在技术实现上, alt 属性 与浏览器渲染引擎 的协作体现了 Web 开发中“优雅降级”与“渐进增强”的设计理念。渲染引擎在解析 HTML 代码时,会优先确定页面结构,然后根据各标签的属性值进行资源加载与显示。当 img 标签 中存在 alt 属性 时,无论图像是否加载成功,页面最终都能呈现出对用户友好的文本信息。对开发者而言,理解这一点可以帮助其在出现异常情况时迅速定位问题,并对症下药。举例而言,某网站在服务器负载高峰期出现部分图片加载失败,通过检测 HTML 源码发现部分 img 标签 缺失 alt 属性,故而在后续版本中及时修正,最终使得用户体验得到明显改善。

在讨论 alt 属性 的同时,还应关注到其在搜索引擎优化( SEO )中的作用。搜索引擎在对网页进行爬取和索引时,会分析 alt 属性 中的文本,从而判断图像所代表的内容是否与页面主题相关。合理使用 alt 属性 能够提升网页在相关搜索结果中的排名,进而吸引更多目标用户访问。比如,一家美食博客在展示菜肴图片时,通过在 alt 属性 中描述菜名与主要原料,成功使页面在美食搜索关键词中获得较高排名,从而带来大量精准流量。

在项目实践过程中, alt 属性 的编写需要遵循统一标准与规范。部分开发团队会借助静态代码分析工具检测 HTML 文件中的 alt 属性 是否合理存在,确保没有遗漏或错误。团队内部通常会制定详细的文档说明,规定何时应设置 alt 属性、何时应将其置为空以及如何描述图像信息。某科技公司在制定前端开发规范时,将 alt 属性 的设置作为代码审核的重要一环,并通过代码提交钩子( hook )自动检查这一问题。经过这种流程管控,团队大大降低了因 alt 属性 忽略导致的用户体验问题,并为后续的无障碍测试提供了有力的数据支持。

针对不同类型的图像, alt 属性 的描述策略也有所区别。功能性图像,例如按钮或图标,应当通过 alt 属性 明确传达其操作意义;而纯装饰性图像则可以将 alt 属性 设置为空字符串,从而让辅助技术忽略这些无关信息。以一个社交平台为例,其在设计消息通知图标时,会在 img 标签 中设置 alt=``""提醒图标"",而对于背景装饰图片则使用 alt=``""`。这种区分处理既保证了视觉效果,又提升了页面在无障碍环境下的使用流畅度。

综上所述, alt 属性 在 HTML 元素 中具有不可替代的重要性,它不仅是图像加载失败时的备用文本,更是提升无障碍访问与搜索引擎优化的重要手段。通过对浏览器内核渲染流程的理解、对无障碍设计需求的关注以及对真实项目案例的借鉴,开发者可以更好地掌握 alt 属性 的使用技巧,从而打造出兼具美观与实用的 Web 页面。无论是面向广泛用户群体的商业网站,还是注重用户体验的个人博客, alt 属性 的合理设置都将成为成功 Web 开发的重要基石。通过不断实践与改进,开发团队能够在提升页面加载效率的同时,保障所有用户均能获得一致且优质的体验,最终实现包容性设计与高效沟通的目标。

❌
❌