阅读视图

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

“能说说事件循环吗?”—— 我从候选人回答中看到的浏览器与Node.js核心差异

前言

面试官:"能解释一下 JavaScript 的事件循环吗?" 候选人:"就是先执行同步代码,然后微任务,然后宏任务..." 面试官:"那么 Node.js 的事件循环和浏览器有什么不同?" 候选人:"..."

作为一名前端面试官,我有一个经典问题,它像一把尺子,能准确衡量出候选人对 JavaScript 运行机制的理解深度:"能详细说说事件循环吗?"

大多数候选人能够背出"先同步、再微任务、再宏任务"的口诀,他们对 Promise、setTimeout 的基本执行顺序对答如流。

当我接着追问:"那么 Node.js 中的事件循环阶段具体是怎样的?与浏览器有何不同?"

对话往往在这里陷入僵局,这让我深感遗憾。

因为在今天这个追求高性能、高并发前端应用的时代,理解事件循环意味着你能写出更高效的异步代码,能避免潜在的竞态条件,能真正驾驭 JavaScript 这门单线程语言的并发能力。对于一个有志于成为资深前端开发的工程师而言,深入理解事件循环不再是一个加分项,而是一项至关重要的核心竞争力。

它代表着你能理解代码的真正执行顺序,能优化复杂异步流程的性能,能在遇到诡异 bug 时快速定位问题根源。

所以,这篇博客,我想和你彻底聊透这个在面试中"区分水平"的技术点。我们不仅会回顾那些你"熟悉的顺序",更将深入事件循环的底层机制,从浏览器到 Node.js,让你真正理解:

  • 为什么说 JavaScript 是单线程的,却能处理高并发?
  • 浏览器事件循环与 Node.js 事件循环的核心差异是什么?
  • 如何在真实项目中避免事件循环带来的陷阱?

别再让你的理解停留在"先微任务后宏任务"的表面。让我们开始这次探索,希望在下一次面试中,当谈到异步编程时,你能自信地剖析事件循环,从浏览器娓娓道来,最终在 Node.js 的细节上展现出扎实的技术功底。

事件循环:从浏览器到 Node.js 的深度剖析

在 JavaScript 开发中,异步编程是一个绕不开的话题。从简单的定时器到复杂的异步操作,我们都需要理解代码的执行时机。你可能用过 setTimeout,用过 Promise,但今天我们要深入探讨的是 JavaScript 并发模型的基石——事件循环

一、 为什么需要事件循环?

JavaScript 被设计为单线程语言,这意味着它只有一个调用栈,同一时间只能做一件事。如果没有事件循环,一个耗时的操作(如网络请求)就会阻塞整个页面。

事件循环的解决方案

  • 将耗时操作交给其他线程处理(浏览器或 Node.js 的环境)
  • 主线程继续执行其他任务
  • 当耗时操作完成时,通过回调函数通知主线程

这就是所谓的"非阻塞 I/O"模型。

二、 浏览器中的事件循环:微任务与宏任务

让我们先来看看相对简单的浏览器事件循环。

核心概念

  1. 调用栈:正在执行的代码形成的栈结构
  2. 任务队列:等待执行的任务队列
  3. 微任务队列:优先级更高的特殊队列

执行顺序

console.log('1. 同步代码开始');

setTimeout(() => {
  console.log('6. setTimeout - 宏任务');
}, 0);

Promise.resolve().then(() => {
  console.log('4. Promise - 微任务');
});

console.log('2. 同步代码结束');

Promise.resolve().then(() => {
  console.log('5. 另一个 Promise - 微任务');
});

console.log('3. 最后的同步代码');

// 执行结果:
// 1. 同步代码开始
// 2. 同步代码结束  
// 3. 最后的同步代码
// 4. Promise - 微任务
// 5. 另一个 Promise - 微任务
// 6. setTimeout - 宏任务

宏任务 vs 微任务

类型 常见API 优先级
宏任务 setTimeout, setInterval, I/O, UI渲染
微任务 Promise.then, MutationObserver, queueMicrotask

浏览器事件循环的简化流程

  1. 执行同步代码(调用栈)
  2. 执行所有微任务(清空微任务队列)
  3. 渲染页面(如果需要)
  4. 执行一个宏任务
  5. 回到步骤2,循环执行

三、 Node.js 中的事件循环:更复杂的多阶段模型

Node.js 基于 libuv 实现了更复杂的事件循环机制,包含六个有序的阶段

六个阶段详解

// 让我们通过代码理解各个阶段的执行顺序
const fs = require('fs');

console.log('1. 同步代码 - Timers阶段前');

// Timer 阶段
setTimeout(() => {
  console.log('7. setTimeout - Timers阶段');
}, 0);

// I/O 回调阶段  
fs.readFile(__filename, () => {
  console.log('10. readFile - I/O回调阶段');
  
  // 在I/O回调中设置的微任务
  Promise.resolve().then(() => {
    console.log('11. Promise in I/O - 微任务');
  });
});

// Idle/Prepare 阶段(内部使用,开发者无法直接干预)

// Poll 阶段
// 这个阶段会检查是否有新的I/O事件,并执行相关回调

// Check 阶段
setImmediate(() => {
  console.log('9. setImmediate - Check阶段');
  
  // 在setImmediate中的微任务
  Promise.resolve().then(() => {
    console.log('10. Promise in setImmediate - 微任务');
  });
});

// Close 回调阶段
process.on('exit', () => {
  console.log('13. exit事件 - Close回调阶段');
});

// 微任务 - nextTick有最高优先级
process.nextTick(() => {
  console.log('3. process.nextTick - 微任务(最高优先级)');
});

// 微任务 - Promise
Promise.resolve().then(() => {
  console.log('5. Promise - 微任务');
});

console.log('2. 同步代码结束');

// 另一个nextTick
process.nextTick(() => {
  console.log('4. 另一个nextTick - 微任务');
});

// 另一个Promise
Promise.resolve().then(() => {
  console.log('6. 另一个Promise - 微任务');
});

// 执行结果大致为:
// 1. 同步代码 - Timers阶段前
// 2. 同步代码结束  
// 3. process.nextTick - 微任务(最高优先级)
// 4. 另一个nextTick - 微任务
// 5. Promise - 微任务
// 6. 另一个Promise - 微任务
// 7. setTimeout - Timers阶段
// 8. setImmediate - Check阶段
// 9. Promise in setImmediate - 微任务
// 10. readFile - I/O回调阶段
// 11. Promise in I/O - 微任务
// 12. exit事件 - Close回调阶段

Node.js 事件循环的六个阶段

  1. Timers 阶段:执行 setTimeoutsetInterval 的回调
  2. I/O Callbacks 阶段:执行几乎所有的回调(除了close、timer、setImmediate)
  3. Idle/Prepare 阶段:Node.js 内部使用
  4. Poll 阶段
    • 检索新的 I/O 事件
    • 执行与 I/O 相关的回调
    • 适当情况下会阻塞在这个阶段
  5. Check 阶段:执行 setImmediate 的回调
  6. Close Callbacks 阶段:执行关闭事件的回调,如 socket.on('close')

四、 浏览器 vs Node.js:核心差异对比

特性 浏览器 Node.js
架构 相对简单 复杂的6阶段模型
微任务优先级 Promise.then, MutationObserver process.nextTick > Promise.then
API 差异 requestAnimationFrame setImmediate, process.nextTick
I/O 处理 有限(主要UI相关) 完整文件、网络I/O支持
渲染时机 每个宏任务之后可能渲染 无渲染概念

五、 实战:避免常见的事件循环陷阱

陷阱1:阻塞事件循环

// ❌ 错误示例:同步阻塞操作
function calculatePrimes(max) {
  const primes = [];
  for (let i = 2; i <= max; i++) {
    let isPrime = true;
    for (let j = 2; j < i; j++) {
      if (i % j === 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) primes.push(i);
  }
  return primes;
}

// 这会阻塞整个事件循环
console.log(calculatePrimes(1000000));

// ✅ 正确示例:使用异步分片
async function calculatePrimesAsync(max, chunkSize = 1000) {
  const primes = [];
  
  for (let start = 2; start <= max; start += chunkSize) {
    await new Promise(resolve => setTimeout(resolve, 0));
    
    const end = Math.min(start + chunkSize, max);
    for (let i = start; i <= end; i++) {
      let isPrime = true;
      for (let j = 2; j < i; j++) {
        if (i % j === 0) {
          isPrime = false;
          break;
        }
      }
      if (isPrime) primes.push(i);
    }
  }
  
  return primes;
}

// 这不会阻塞事件循环
calculatePrimesAsync(1000000).then(console.log);

陷阱2:微任务无限递归

// ❌ 危险:微任务递归会导致阻塞
function dangerousRecursion() {
  Promise.resolve().then(dangerousRecursion);
}

// ✅ 安全:使用宏任务避免阻塞
function safeRecursion() {
  setTimeout(safeRecursion, 0);
}

陷阱3:错误的执行顺序假设

// ❌ 错误的顺序假设
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// 输出顺序是不确定的!

// ✅ 在I/O回调中顺序是确定的
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate')); // 这个先执行
});

六、 现代开发的最佳实践

  1. CPU 密集型任务:使用 Web Workers(浏览器)或 Worker Threads(Node.js)
  2. 合理使用微任务:避免微任务队列过长影响响应性
  3. 理解执行环境:区分浏览器和 Node.js 的不同行为
  4. 性能监控:使用 Performance API 监控任务执行时间
// 性能监控示例
function monitorPerformance(taskName, taskFn) {
  const start = performance.now();
  
  return Promise.resolve(taskFn()).then(result => {
    const duration = performance.now() - start;
    console.log(`${taskName} 耗时: ${duration.toFixed(2)}ms`);
    return result;
  });
}

// 使用
monitorPerformance('数据计算', () => {
  return calculatePrimes(10000);
});

结语

事件循环是 JavaScript 并发模型的核心,理解它意味着你真正理解了 JavaScript 的运行时行为。虽然浏览器和 Node.js 的实现有所不同,但其核心理念一致:在单线程中通过事件驱动的方式实现非阻塞 I/O。

对于追求卓越的前端开发者而言,深入掌握事件循环不仅能让你在面试中游刃有余,更能帮助你在实际项目中写出更高效、更健壮的异步代码。下次当你面对复杂的异步流程时,希望你能自信地分析其执行顺序,在事件循环的迷雾中找到清晰的路径。

记住:真正优秀的开发者,不仅知道代码怎么写,更知道代码何时执行、为何这样执行。

女朋友又给我出难题了:解锁网页禁用复制 + 一键提取图片文字

女朋友做广告策划,每天要从海量网站和素材中摘抄文案。

微信或飞书截图都有 OCR,但她总要“切微信/飞书 → 识别 → 复制 → 切回浏览器”,来回折腾好麻烦,经常被打断思路。

两个最常见的烦恼

  • 禁止复制的页面:设计灵感站、素材站、文库类网站,明明能看到文字,就是选不了、右键也没用,只能手敲。

  • 图片里的文字无法快速提取:看到一张图,得切到微信、点“提取文字”、等识别、复制、再切回来粘贴。

一张图,好几个步骤,来回切换三次。

有天晚上她在赶方案,一边操作一边念叨:“太麻烦了,思路都断了……”

我说:“要不我给你写个插件?”

于是周末两天,做了这个 「图文解锁器」

  1. 一键解除网页限制 —— 禁选中、禁右键的网站,点一下就能正常复制
  2. 浏览器里直接拖框识别 —— 不用切微信,看到哪里框哪里,几秒出结果
  3. 所有操作在侧边栏完成 —— 不遮挡页面,不用来回切窗口

周一她用上之后的评价:“终于顺手了!那些恶心的禁止复制网站现在随便复制,图片识别也不用跳来跳去了。”

下面讲讲开发过程和技术实现 👇

效果演示: 解锁网页禁用复制 + 一键提取图片文字

copy-everything.gif

功能特性

1. 解除网页限制

  • 一键解除复制限制
  • 恢复右键菜单
  • 允许文本选中
  • 支持动态加载的网页内容解除限制

2. OCR 文字识别(方式与特性)

方式 说明 使用场景
页面截图 快速截取当前可见区域 一键识别网页全部内容
自选区域 拖拽选择任意区域 只识别你选中的特定区域
点击上传 选择本地图片文件 识别本地图片中的文字
拖拽上传 拖入图片即可识别 方便上传图片并识别文字
  • 基于腾讯云 OCR API(每月 1000 次免费额度)
  • 内置图片预览,识别前可确认内容
  • 识别结果一键复制,支持后续粘贴使用
  • 识别流程:选择方式 → 预览 → 开始识别 → 复制/下载

快速开始(安装使用)

1. 获取代码

2. 安装扩展(本地加载)

  1. 打开 Chrome,地址栏输入 chrome://extensions/
  2. 右上角开启“开发者模式”
  3. 点击“加载已解压的扩展程序”
  4. 选择插件目录
  5. 安装完成

小贴士:Side Panel 依赖较新版本 Chrome,建议 114+。

安装copy-everything.gif

3. 首次使用

  • 点击插件图标打开侧边栏
  • 点击「一键解除」即可解除页面复制限制
  • 使用截图功能(页面截图 / 自选区域)或本地上传(点击上传 / 拖拽上传)进行 OCR 识别
  • 首次体验可直接使用以下账号(每月 1000 次免费额度):
    • SecretId: AKIDLUQ7aqsjNmwufWFm590d1BxXs0xgBRTH
    • SecretKey: c09OVP4aw75oIYZMvFO8j5C5uiIgspIc
    • 将 SecretId 和 SecretKey 填入插件侧边栏后保存设置,即可开始图文识别

示例界面:

注:如免费额度用完,请根据下方指导申请属于你自己的账号哦。👇

腾讯云 OCR 开通与配置

插件支持腾讯云 OCR,每月有约 1000 次免费额度,足够日常使用。首次识别前,请按以下步骤开通并配置:

  1. 进入 腾讯云文字识别控制台console.cloud.tencent.com/ocr/v2/over… ,勾选条款并开通服务。
  2. 前往 API 密钥管理console.cloud.tencent.com/cam/capi 获取自己的 SecretId 和 SecretKey。
  3. 将 SecretId 和 SecretKey 填入插件侧边栏并保存设置,即可开始图文识别。

注意事项

  1. API 费用:腾讯云 OCR 每个月有1000个请求的免费额度,超出后按量计费

  2. 权限说明

    • activeTab:访问当前标签页
    • scripting:注入脚本
    • storage:保存配置
    • sidePanel:侧边栏功能
  3. 兼容性

    • Chrome 114+ (Side Panel API 要求)
    • 部分网站可能有额外防护

技术实现原理

核心技术栈

  • Manifest V3:Chrome 扩展最新规范
  • Side Panel API:侧边栏界面
  • Content Scripts:页面注入脚本
  • Tencent Cloud OCR:腾讯云文字识别 API
  • Canvas API:图片裁剪

架构设计

copy-everything
├── manifest.json         # 插件配置
├── icons                 # 插件图标
├── background.js         # 后台服务
├── content.js            # 内容脚本(核心功能)
├── sidepanel.html        # 侧边栏界面
├── sidepanel.js          # 侧边栏界面逻辑
├── sidepanel.css         # 侧边栏样式
└── tencentOCR.js         # OCR SDK

核心代码解析

1. 解除复制限制 (五层防护)

这是插件的核心功能之一,通过多层防护确保解除限制:

image.png

实现原理

通过五层防护确保解除限制

第一层:CSS 强制覆盖

const style = document.createElement('style');
style.textContent = `
  html, body, * {
    -webkit-user-select: text !important;
    -moz-user-select: text !important;
    user-select: text !important;
  }
`;
document.head.appendChild(style);

作用:强制允许文本选中,覆盖网站的 CSS 限制。

第二层:事件拦截(捕获阶段)

// 在捕获阶段拦截所有限制事件
const restrictedEvents = [
  'copy', 'cut', 'paste', 'contextmenu', 
  'selectstart', 'dragstart', 'keydown', 'keyup', 'keypress'
];

const stopEvent = (e) => {
  e.stopPropagation();
  e.stopImmediatePropagation?.(); // 阻止其他监听器执行
};

// 在 window、document、documentElement、body 四个层级同时监听
[window, document, document.documentElement, document.body].forEach(target => {
  if (target) {
    restrictedEvents.forEach(eventType => {
      target.addEventListener(eventType, stopEvent, true); // ← 捕获阶段
    });
  }
});

关键点

  • 捕获阶段拦截(优先级最高)
  • 使用 stopImmediatePropagation() 阻止其他监听器
  • 在四个层级监听(window → document → html → body)

为什么要用捕获阶段?

事件流:捕获阶段 → 目标阶段 → 冒泡阶段 
        ↓
  我们在这里拦截(优先级最高)

第三层:移除内联事件属性

// 移除所有事件属性(如 oncopy="return false")
const eventAttrs = [
  'oncopy', 'oncut', 'onpaste', 'oncontextmenu',
  'onselectstart', 'onkeydown', 'ondragstart'
];

document.querySelectorAll(eventAttrs.map(a => `[${a}]`).join(','))
  .forEach(el => {
    eventAttrs.forEach(attr => el.removeAttribute(attr));
  });

作用:移除 HTML 标签上的内联事件(如 oncopy="return false"

第四层:API 劫持(重写 preventDefault)

// 注入到页面上下文,重写原生方法
const script = document.createElement('script');
script.textContent = `
  (function() {
    if (window.__preventDefaultDisabled) return;
    window.__preventDefaultDisabled = true;
    
    const blockedEvents = new Set(${JSON.stringify(restrictedEvents)});
    const originalPreventDefault = Event.prototype.preventDefault;
    
    Event.prototype.preventDefault = function() {
      if (blockedEvents.has(this.type)) return; // ← 禁用 preventDefault
      return originalPreventDefault.call(this);
    };
  })();
`;
document.documentElement.appendChild(script);
script.remove();

为什么要注入 <script> 标签?

  • Content Script 运行在隔离环境(Isolated World)
  • 无法直接修改页面的 Event.prototype
  • 通过 <script> 标签注入到页面上下文(Main World)

第五层:动态内容监听

// 监听 DOM 变化,自动处理动态加载的元素
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes?.forEach((node) => {
      if (node instanceof Element) {
        // 移除事件属性
        eventAttrs.forEach(attr => node.removeAttribute(attr));
        // 强制允许选中
        node.style?.setProperty('user-select', 'text', 'important');
      }
    });
  });
});

observer.observe(document.documentElement, {
  childList: true,
  subtree: true
});

作用:监听 DOM 变化,自动处理动态加载的元素(如 React/Vue 渲染的内容)。

2. 自选区域截图

三步完成截图

  1. 按下按钮 → 屏幕变暗
  2. 拖动框 → :按住鼠标拖出一个框,选出你想要的区域
  3. 松开手 → 自动截取框内内容

实现步骤

 这背后发生了什么?

步骤 1:创建选框工具

// 创建遮罩层(就像给屏幕盖了一层磨砂玻璃)
const overlay = document.createElement('div');
overlay.style.cssText = `
  position: fixed;
  left: 0; top: 0;
  width: 100vw; height: 100vh;
  background: rgba(0,0,0,0.5);  /* 半透明黑色 */
  cursor: crosshair;             /* 十字准星 */
  z-index: 999999;
`;
document.body.appendChild(overlay);

当你点击"自选区域"后,插件会:

  • 在屏幕上盖一层半透明黑色遮罩(让你看清楚要选什么)
  • 鼠标变成十字准星(提示你可以开始拖框了)
  • 准备好一个绿色边框(等你拖出来就显示)

2. 跟随你的鼠标画框(实时反馈)

// 鼠标按下 → 记录起点
function handleMouseDown(e) {
  startX = e.clientX;  // 记住你点击的 X 坐标
  startY = e.clientY;  // 记住你点击的 Y 坐标
}

// 鼠标移动 → 实时更新框的大小
function handleMouseMove(e) {
  currentX = e.clientX;
  currentY = e.clientY;
  
  // 计算框的位置和大小(支持反向拖拽)
  const left = Math.min(startX, currentX);   // 取最小值作为左边界
  const top = Math.min(startY, currentY);    // 取最小值作为上边界
  const width = Math.abs(currentX - startX); // 宽度 = 绝对值
  const height = Math.abs(currentY - startY);// 高度 = 绝对值
  
  // 更新绿色边框的位置
  selectionBox.style.left = left + 'px';
  selectionBox.style.top = top + 'px';
  selectionBox.style.width = width + 'px';
  selectionBox.style.height = height + 'px';
}

当你按住鼠标拖动时:

  • 记录你按下的位置(起点)
  • 记录你当前的位置(终点)
  • 用这两个点的横坐标和纵坐标的差值画出一个矩形框

支持反向拖拽:不管你从左上往右下拖,还是从右下往左上拖,都能正确识别!

举个例子

你从 (100, 100) 拖到 (300, 300)
→ 框的位置:left=100, top=100, width=200, height=200 ✅

你从 (300, 300) 拖到 (100, 100)(反向)
→ 框的位置:left=100, top=100, width=200, height=200

3. 精准裁剪(像剪刀一样裁图)

当你松开鼠标后,插件会:

  1. 先截取整个页面(就像拍了一张全屏照片)
  2. 再裁剪出你选中的部分(就像用剪刀剪出你要的区域)

关键技术:Canvas 画布裁剪

// 1. 创建一个画布(就像准备一张白纸)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

// 2. 设置画布大小 = 你选中区域的大小
canvas.width = width;
canvas.height = height;

// 3. 把全屏截图的"选中部分"画到画布上
ctx.drawImage(
  fullScreenImage,  // 全屏截图
  left, top,        // 从哪里开始裁剪(你选中区域的左上角)
  width, height,    // 裁剪多大(你选中区域的宽高)
  0, 0,             // 画到画布的哪里(从画布左上角开始)
  width, height     // 画多大(填满整个画布)
);

// 4. 导出成图片
const croppedImage = canvas.toDataURL('image/png');

用生活场景类比

  • 全屏截图 = 拍了一张班级照
  • 你选中的区域 = 你只想要照片里的某个人
  • Canvas 裁剪 = 用剪刀把那个人剪下来

步骤 4:四遮罩高亮效果

问题:如果只用一个全屏遮罩,选中的区域也会被遮住,用户看不清选了什么。

解决方案:用 4 个遮罩块围绕选区。

┌─────────────────────────────────┐
│    上遮罩(半透明黑色)            │
├──────┬──────────────┬───────────┤
│ 左遮罩│   选区(高亮) │  右遮罩   │
├──────┴──────────────┴───────────┤
│    下遮罩(半透明黑色)            │
└─────────────────────────────────┘

代码实现

// 创建 4 个遮罩块
const masks = ['top', 'right', 'bottom', 'left'].map(position => {
  const mask = document.createElement('div');
  mask.style.cssText = `
    position: fixed;
    background: rgba(0,0,0,0.5);
    pointer-events: none; /* 不阻挡鼠标事件 */
  `;
  return mask;
});

// 根据选区位置更新遮罩块大小
function updateMasks(left, top, width, height) {
  masks[0].style.cssText += `left:0; top:0; width:100vw; height:${top}px;`; // 上
  masks[1].style.cssText += `left:${left+width}px; top:${top}px; width:${window.innerWidth-left-width}px; height:${height}px;`; // 右
  masks[2].style.cssText += `left:0; top:${top+height}px; width:100vw; height:${window.innerHeight-top-height}px;`; // 下
  masks[3].style.cssText += `left:0; top:${top}px; width:${left}px; height:${height}px;`; // 左
}

高分屏适配(让图片更清晰)

问题:Retina 屏幕(如 MacBook)的像素密度是普通屏幕的 2 倍,如果不处理会导致截图模糊。

解决方案:乘以设备像素比

const scale = window.devicePixelRatio || 1; // Retina 屏幕 = 2,普通屏幕 = 1

// 设置画布大小时要乘以 scale
canvas.width = width * scale;
canvas.height = height * scale;

// 裁剪时也要乘以 scale
ctx.drawImage(
  fullScreenImage,
  left * scale, top * scale,     // ← 乘以 scale
  width * scale, height * scale, // ← 乘以 scale
  0, 0,
  width * scale, height * scale
);

完整流程

image.png

时序图

sequenceDiagram
  participant SP as Sidepanel
  participant CT as Content Script
  participant BG as Background

  SP->>CT: startAreaCapture
  CT->>CT: createCaptureUI + 事件绑定
  CT->>CT: 用户拖拽/更新选区
  CT->>CT: mouseup → captureSelectedArea
  CT->>BG: captureVisible
  BG-->>CT: DataURL(整页可见区域)
  CT->>CT: Canvas 裁剪(考虑 devicePixelRatio)
  CT->>SP: areaCaptureDone(DataURL)
  SP->>SP: 预览 + OCR 识别

3. 本地上传图片

方式一:点击上传

HTML

<input type="file" id="uploadInput" accept="image/*" style="display:none">
<button onclick="document.getElementById('uploadInput').click()">
  📁 上传图片
</button>

JavaScript

document.getElementById('uploadInput').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  
  const reader = new FileReader();
  reader.onload = async (event) => {
    const base64 = event.target.result.split(',')[1];
    await recognizeText(base64);
  };
  reader.readAsDataURL(file);
});

方式二:拖拽上传

const dropZone = document.getElementById('dropZone');

// 1. 阻止默认行为
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
  dropZone.addEventListener(eventName, (e) => {
    e.preventDefault();
    e.stopPropagation();
  });
});

// 2. 视觉反馈
['dragenter', 'dragover'].forEach(eventName => {
  dropZone.addEventListener(eventName, () => {
    dropZone.classList.add('drag-over');
  });
});

// 3. 处理文件
dropZone.addEventListener('drop', (e) => {
  const file = e.dataTransfer.files[0];
  if (!file.type.startsWith('image/')) {
    alert('请上传图片文件!');
    return;
  }
  
  const reader = new FileReader();
  reader.onload = async (event) => {
    const base64 = event.target.result.split(',')[1];
    await recognizeText(base64);
  };
  reader.readAsDataURL(file);
});

CSS

#dropZone {
  border: 2px dashed #ccc;
  border-radius: 8px;
  padding: 20px;
  text-align: center;
  transition: all 0.3s;
}

#dropZone.drag-over {
  border-color: #4CAF50;
  background: rgba(76, 175, 80, 0.1);
}

  • 作用:把图片拖进上传图片区域 ,拦住浏览器默认行为,在 drop 时读文件并识别或预览。

 4.页面截图

const capturePageBtn = document.getElementById('capturePageBtn');

capturePageBtn.addEventListener('click', async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  
  const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
    format: 'png'
  });
  
  const base64 = dataUrl.split(',')[1];
  await recognizeText(base64);
});

直接使用chrome插件的截图功能 权限配置(manifest.json):

{ "permissions": ["activeTab", "tabs"] }

5. OCR 识别

使用腾讯云 OCR API,通过 TC3-HMAC-SHA256 签名调用 GeneralBasicOCR 接口,每月 1000 次免费额度。

// 核心调用代码
const ocr = new TencentOCR(secretId, secretKey);
const text = await ocr.recognizeText(imageBase64);

总结

这个插件解决了日常浏览网页时的两大痛点:

  1. 解除限制:让你自由复制任何内容
  2. OCR 识别:快速提取图片文字

技术亮点

技术点 难点 解决方案
解除限制 网站多层防护 五层拦截 + API 劫持
自选截图 高分屏模糊 devicePixelRatio 适配
拖拽上传 视觉反馈 CSS 过渡动画
OCR 识别 API 签名 TC3-HMAC-SHA256

希望这个插件能帮到大家!如果有问题或建议,欢迎在评论区交流~

🔗 相关链接

🚀 Vue3 高效技巧:10 行代码实现表单防抖 + 实时验证,复用率拉满!

在前端开发中,表单是高频场景,而「输入防抖」和「实时验证」几乎是必备需求 —— 比如搜索框输入时避免频繁接口请求、注册页面实时校验手机号格式、登录表单防重复提交。如果每个表单都单独写一遍逻辑,不仅冗余还容易出错。

今天分享一个 Vue3 组合式 API 小技巧,用 10 行核心代码封装通用的「防抖 + 验证」工具,支持任意表单复用,兼顾性能和开发效率,完全适配实际项目场景!

一、场景痛点

先看看我们平时遇到的问题:

  1. 输入框实时校验时,输入过快导致频繁触发验证函数(比如每输入一个字符就校验手机号),浪费性能;
  2. 多个表单(登录、注册、搜索)需要重复写防抖逻辑,代码冗余;
  3. 验证规则不统一,后续维护成本高。

而用组合式 API 封装后,只需一行代码即可接入,完美解决以上问题!

二、核心实现:封装 useDebounceValidate 工具

直接上代码,核心逻辑基于 lodash.debounce 简化(也可以手写防抖函数,下文附原生实现),兼顾灵活性和易用性:

javascript

运行

// src/hooks/useDebounceValidate.js
import { ref, watch, unref } from 'vue';
import debounce from 'lodash.debounce'; // 也可以手写防抖(下文附原生实现)

/**
 * 表单防抖+实时验证组合式工具
 * @param {Ref} valueRef - 输入值的响应式引用
 * @param {Function} validator - 验证函数(返回布尔值/错误信息)
 * @param {number} delay - 防抖延迟时间(默认300ms)
 * @returns {Object} { debouncedValue, isValid, errorMsg }
 */
export function useDebounceValidate(valueRef, validator, delay = 300) {
  const debouncedValue = ref(unref(valueRef)); // 防抖后的值
  const isValid = ref(true); // 验证结果(true=通过)
  const errorMsg = ref(''); // 验证错误信息

  // 防抖处理函数
  const debounceHandler = debounce((val) => {
    debouncedValue.value = val;
    const result = validator(val); // 执行自定义验证规则
    isValid.value = typeof result === 'boolean' ? result : true;
    errorMsg.value = typeof result === 'string' ? result : '';
  }, delay);

  // 监听输入值变化,触发防抖
  watch(valueRef, (newVal) => debounceHandler(newVal), { immediate: false });

  // 组件卸载时取消防抖(避免内存泄漏)
  const cleanup = () => debounceHandler.cancel();

  return { debouncedValue, isValid, errorMsg, cleanup };
}

// 🌟 原生防抖函数(无需依赖lodash,直接替换使用)
export function debounceNative(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

核心逻辑解析:

  1. 响应式封装:接收输入值的响应式引用(valueRef),返回防抖后的值、验证结果、错误信息;
  2. 灵活验证validator 参数支持自定义验证规则(返回布尔值表示是否通过,或字符串表示错误信息);
  3. 内存安全:提供 cleanup 方法,组件卸载时取消防抖计时器,避免内存泄漏;
  4. 无依赖可选:支持使用 lodash.debounce 或原生防抖函数,按需选择。

三、实际应用:登录表单示例

在 Vue3 组件中直接使用,一行代码接入防抖 + 验证,逻辑清晰且复用性拉满:

vue

<!-- src/components/LoginForm.vue -->
<template>
  <div class="login-form">
    <h3>登录表单</h3>
    <!-- 手机号输入框 -->
    <div class="form-item">
      <label>手机号:</label>
      <input
        v-model="phone"
        type="tel"
        placeholder="请输入手机号"
        class="input"
      />
      <!-- 实时显示错误信息 -->
      <span class="error-msg" v-if="!phoneIsValid">{{ phoneErrorMsg }}</span>
    </div>

    <!-- 密码输入框(仅防抖,不验证格式) -->
    <div class="form-item">
      <label>密码:</label>
      <input
        v-model="password"
        type="password"
        placeholder="请输入密码"
        class="input"
      />
    </div>

    <!-- 按钮状态:手机号验证通过+密码不为空才可用 -->
    <button 
      class="login-btn"
      :disabled="!phoneIsValid || !password"
      @click="handleLogin"
    >
      登录
    </button>
  </template>

<script setup>
import { ref, onUnmounted } from 'vue';
import { useDebounceValidate } from '@/hooks/useDebounceValidate';

// 1. 定义表单响应式数据
const phone = ref('');
const password = ref('');

// 2. 手机号验证规则(自定义:返回错误信息或true)
const validatePhone = (val) => {
  if (!val) return '手机号不能为空';
  const reg = /^1[3-9]\d{9}$/;
  return reg.test(val) ? true : '请输入正确的手机号格式';
};

// 3. 一行代码接入防抖+验证(延迟300ms)
const { 
  isValid: phoneIsValid, // 验证结果
  errorMsg: phoneErrorMsg, // 错误信息
  cleanup: cleanupPhoneDebounce // 清理防抖计时器
} = useDebounceValidate(phone, validatePhone, 300);

// 4. 登录提交逻辑(密码也可加防抖防重复提交)
const handleLogin = () => {
  console.log('登录请求:', { phone: phone.value, password: password.value });
  // 实际项目中可在此处调用登录接口
};

// 5. 组件卸载时清理防抖(避免内存泄漏)
onUnmounted(() => {
  cleanupPhoneDebounce();
});
</script>

<style scoped>
.login-form {
  width: 300px;
  margin: 50px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
.form-item {
  margin-bottom: 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
.error-msg {
  color: #ff4d4f;
  font-size: 12px;
}
.login-btn {
  width: 100%;
  padding: 10px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.login-btn:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

效果演示:

  • 输入手机号时,快速输入不会立即触发验证,等待 300ms 无输入后才执行校验;
  • 手机号格式错误时实时显示错误信息,格式正确后错误信息自动消失;
  • 登录按钮只有在手机号验证通过且密码不为空时才可用,避免无效提交。

四、扩展场景:搜索框防抖请求

除了表单验证,搜索框的接口请求也能直接复用这个工具,无需额外写防抖逻辑:

vue

<template>
  <div class="search-box">
    <input
      v-model="searchKey"
      type="text"
      placeholder="搜索商品..."
      class="search-input"
    />
  </div>
</template>

<script setup>
import { ref, onUnmounted } from 'vue';
import { useDebounceValidate } from '@/hooks/useDebounceValidate';
import { fetchSearchGoods } from '@/api/goods'; // 搜索接口

const searchKey = ref('');

// 搜索框:防抖500ms后请求接口(验证规则简化为“非空”)
const { 
  debouncedValue: searchValue,
  cleanup: cleanupSearchDebounce
} = useDebounceValidate(
  searchKey,
  (val) => { 
    // 验证规则:非空则执行搜索请求
    if (val) fetchSearchGoods(val).then(res => console.log('搜索结果:', res));
    return true; // 无需错误信息,返回true即可
  },
  500
);

onUnmounted(() => cleanupSearchDebounce());
</script>

五、核心优势总结

  1. 极致复用:一个工具适配所有表单(登录、注册、搜索、设置页面),减少重复代码;
  2. 性能优化:防抖避免频繁触发验证 / 接口请求,降低性能消耗;
  3. 灵活扩展:验证规则完全自定义,支持多字段联动、异步验证(比如校验手机号是否已注册);
  4. 上手简单:Vue3 组合式 API 风格,一行代码接入,新手也能快速使用;
  5. 内存安全:提供清理防抖计时器的方法,避免组件卸载后内存泄漏。

六、进阶优化(可选)

如果需要支持异步验证(比如校验手机号是否已被注册),只需修改验证函数为异步即可:

javascript

运行

// 异步验证示例:校验手机号是否已注册
const validatePhoneAsync = async (val) => {
  if (!val) return '手机号不能为空';
  const reg = /^1[3-9]\d{9}$/;
  if (!reg.test(val)) return '请输入正确的手机号格式';
  // 调用接口校验手机号是否已注册
  const isRegistered = await fetchCheckPhone(val);
  return isRegistered ? '该手机号已注册' : true;
};

// 工具内部只需将防抖函数改为支持异步:
const debounceHandler = debounce(async (val) => {
  debouncedValue.value = val;
  const result = await validator(val); // 等待异步验证结果
  isValid.value = typeof result === 'boolean' ? result : true;
  errorMsg.value = typeof result === 'string' ? result : '';
}, delay);

最后

这个小技巧看似简单,但实际项目中能大幅提升开发效率,尤其适合中大型项目的表单统一管理。如果觉得有用,欢迎点赞、收藏,评论区分享你的使用场景~ 也可以关注我,后续会分享更多 Vue3/React 实用技巧和性能优化方案!

Vscode 如何修改插件默认安装地址

前言

Vscode 插件默认安装在C盘上,所有插件都安装到extensions文件下,C盘空间有限,所以要更换插件默认安装路径地址。

移动(剪切)extensions文件

移动(剪切)插件文件到自定义目录。插件默认安装路径在C:\Users{个人用户名}.vscode\extensions目录下,找到『extensions』文件夹,右键→剪切

注意:这里必须使用剪切

image.png 我这里是已经操作完的文件状态,文件夹上有个快捷方式箭头。 我的文件存放路径是:C:\Users\baimi\.vscode\extensions

粘贴的文件路径是:D:\run\vsCode\extensions 我这边是从C盘放到了D盘,根据个人安装路径进行操作。

两个文件夹进行软连接

管理员权限下的命令提示符CMD 必须是管理员打开方式,不能使用Powershell

mklink /D "C:\Users\baimi\.vscode\extensions" "D:\run\vsCode\extensions"

image.png

再次打开C盘extensions 查看目标路径已经在D盘

image.png

总结

其实就是创建快捷方式,同时进行两个文件进行软连接。

使用 ECharts + ECharts-GL 生成 3D 环形图

本文系统总结在项目中用 ECharts 与 ECharts-GL 手工生成 3D 环形图(Donut)的全过程:从原理、实现步骤、关键配置,到常见坑位与解决方案,以及可复用的代码片段与使用流程。


为什么选择 ECharts-GL 手工实现 3D

  • 原生 ECharts 饼图是 2D;3D 需要借助 ECharts-GL 的 series.surface 与参数方程自定义曲面形状。
  • 手工实现可精细控制:切片厚度、内外径比例、标签引导线、视角与后处理特效(高光、SSAO等)。
  • 可与数据规模、性能要求做平衡:通过参数步进控制网格密度,避免过多面元导致性能瓶颈。

适用场景:数据可视化展示、营销演示、报告图表对比;不适用场景:强交互且需要大量点击选择的复杂图表(ECharts-GL 事件交互相对有限)。


环境与依赖

  • echarts:基础图表库
  • echarts-gl:3D 能力与曲面支持
  • 可选:echarts-for-react(在 React 项目中便捷渲染)

安装示例:

yarn add echarts echarts-gl echarts-for-react

在 React 组件中引入:

import EChartsReact from 'echarts-for-react';
import 'echarts-gl';

原理概述:参数方程生成“环形切片”

3D 环形图的每个扇形切片本质上是一个通过参数方程描述的曲面。我们为每个数据点计算其在圆环上的起止比例(startRatio / endRatio),然后用参数方程将该角段“弯折”到环面上。

关键参数:

  • k:由内外径比换算得到的辅助参数,控制环的厚度(默认约 1/3)。
  • h:切片高度(厚度),与数据值成比例,避免某项过大导致“超高”。
  • startRatio / endRatio:每个扇形在圆周上的起止比例,来源于数据总和与各项值。

我们在 getParametricEquation 中将 (u, v) 两个参数映射到三维空间 (x, y, z),并控制边界(区间外使用边缘角度),从而形成饼图扇形段的立体曲面。


实现步骤(核心流程)

  1. 计算比例与厚度
  • 累加数据得到 sumValue,为每个数据计算 startRatio / endRatio
  • 取数据最大值作为基准,按比例将值映射为高度 h
const heightFromVal = (v: number) => {
  if (!maxValue || maxValue <= 0) return minH;
  const ratio = v / maxValue;
  return minH + (maxH - minH) * ratio;
};
  1. 为每个数据构造 series.surface
  • type: 'surface'parametric: true,设置 parametricEquation 为曲面方程。
  • 将颜色与透明度通过 itemStyle.color / itemStyle.opacity 传入(与 3D 视觉保持一致)。
  1. 标签与引导线(3D版本)
  • 使用两条 line3D + 一个 scatter3D(文本)构成“引导线 + 标签”。
  • 文本通过 scatter3D.label.formatter 输出 {name}\n{value}元 两行样式。
  • endPosArr 的计算要考虑象限(通过中径角判断朝向),以避免文本与线段穿插扭曲:
const flag = (midRadianInFirstOrFourthQuadrant) ? 1 : -1;
const endPosArr = [
  posX * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),
  posY * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),
  posZ * 2,
];
  1. 透明“支撑环”
  • 额外添加一个透明 surface,用于近似实现高亮/鼠标交互的承载,避免直接与扇形交互造成干扰。
  1. 场景配置
  • grid3D 控制视角与后处理:开启 postEffect.bloomSSAO 增强质感,同时合理设置 viewControl 的旋转/缩放灵敏度(大多数展示场景禁用交互)。

完整示例(精简版)

源自项目文件 Pie3DChart.tsx,保留核心逻辑并做少量注释:

import EChartsReact from 'echarts-for-react';
import 'echarts-gl';

// 支持通过 props 传入切片高度范围,默认 [8, 20]
const Pie3DChart = ({ dataList, sliceHeightRange = [8, 20] }) => {
  function getParametricEquation(startRatio, endRatio, isSelected, isHovered, k, h) {
    const midRatio = (startRatio + endRatio) / 2;
    const startRadian = startRatio * Math.PI * 2;
    const endRadian = endRatio * Math.PI * 2;
    const midRadian = midRatio * Math.PI * 2;
    if (startRatio === 0 && endRatio === 1) isSelected = false;
    k = typeof k !== 'undefined' ? k : 1 / 3;
    const offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;
    const offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0;
    const hoverRate = isHovered ? 1.05 : 1;
    return {
      u: { min: -Math.PI, max: Math.PI * 3, step: Math.PI / 32 },
      v: { min: 0, max: Math.PI * 2, step: Math.PI / 20 },
      x(u, v) {
        if (u < startRadian) return offsetX + Math.cos(startRadian) * (1 + Math.cos(v) * k) * hoverRate;
        if (u > endRadian) return offsetX + Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate;
        return offsetX + Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate;
      },
      y(u, v) {
        if (u < startRadian) return offsetY + Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate;
        if (u > endRadian) return offsetY + Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate;
        return offsetY + Math.sin(u) * (1 + Math.cos(v) * k) * hoverRate;
      },
      z(u, v) {
        if (u < -Math.PI * 0.5) return Math.sin(u);
        if (u > Math.PI * 2.5) return Math.sin(u) * h * 0.1;
        return Math.sin(v) > 0 ? 1 * h * 0.1 : -1;
      },
    };
  }

  function getPie3D(pieData, internalDiameterRatio, heightRange) {
    const [minH, maxH] = heightRange || [8, 20];
    let series = [];
    let sumValue = 0;
    let startValue = 0;
    const k = typeof internalDiameterRatio !== 'undefined'
      ? (1 - internalDiameterRatio) / (1 + internalDiameterRatio)
      : 1 / 3;

    const maxValue = (pieData || []).reduce((m, d) => Math.max(m, d?.value || 0), 0);
    const heightFromVal = (v) => (!maxValue ? minH : minH + (maxH - minH) * (v / maxValue));

    for (let i = 0; i < pieData.length; i++) {
      const { value = 0, name, itemStyle } = pieData[i] || {};
      sumValue += value;
      const seriesItem = {
        name: typeof name === 'undefined' ? `series${i}` : name,
        type: 'surface', parametric: true, wireframe: { show: false }, pieData: pieData[i],
        pieStatus: { selected: false, hovered: false, k },
      };
      if (itemStyle) seriesItem.itemStyle = { color: itemStyle.color, opacity: itemStyle.opacity };
      series.push(seriesItem);
    }

    const linesSeries = [];
    let endValue = 0;
    for (let i = 0; i < series.length; i++) {
      const { pieData } = series[i] || {};
      const val = pieData?.value || 0;
      endValue = startValue + val;
      series[i].pieData.startRatio = startValue / sumValue;
      series[i].pieData.endRatio = endValue / sumValue;
      series[i].parametricEquation = getParametricEquation(
        series[i].pieData.startRatio,
        series[i].pieData.endRatio,
        false,
        false,
        k,
        heightFromVal(val),
      );
      startValue = endValue;

      // 计算标签位置与引导线(两段线)
      const midRadian = (series[i].pieData.endRatio + series[i].pieData.startRatio) * Math.PI;
      const posX = Math.cos(midRadian) * (1 + Math.cos(Math.PI / 2));
      const posY = Math.sin(midRadian) * (1 + Math.cos(Math.PI / 2));
      const posZ = Math.log(Math.abs(val + 1)) * 0.1;
      const flag = (midRadian >= 0 && midRadian <= Math.PI / 2) ||
                   (midRadian >= (3 * Math.PI) / 2 && midRadian <= Math.PI * 2) ? 1 : -1;
      const color = pieData?.itemStyle?.color;
      const endPosArr = [posX * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),
                         posY * 1.8 + i * 0.1 * flag + (flag < 0 ? -0.5 : 0),
                         posZ * 2];

      linesSeries.push(
        { type: 'line3D', coordinateSystem: 'cartesian3D', lineStyle: { color }, data: [[posX, posY, posZ], endPosArr] },
        { type: 'scatter3D', coordinateSystem: 'cartesian3D', label: { show: true, formatter: '{b}' }, symbolSize: 0, data: [{ name: series[i].name + '\n' + val + '元', value: endPosArr }] },
      );
    }
    series = series.concat(linesSeries);

    // 透明支撑环
    series.push({ name: 'mouseoutSeries', type: 'surface', parametric: true, wireframe: { show: false }, itemStyle: { opacity: 0 }, parametricEquation: {/* ...略 */} });

    return {
      legend: { bottom: 0, icon: 'circle' },
      tooltip: { trigger: 'item', axisPointer: { type: 'none' } },
      xAxis3D: { min: -1, max: 1 }, yAxis3D: { min: -1, max: 1 }, zAxis3D: { min: -1, max: 1 },
      grid3D: {
        show: false, boxHeight: 10,
        viewControl: { alpha: 40, rotateSensitivity: 0, zoomSensitivity: 0, panSensitivity: 0, autoRotate: false },
        postEffect: { enable: true, bloom: { enable: true, bloomIntensity: 0.1 }, SSAO: { enable: true, quality: 'medium', radius: 2 } },
      },
      series,
    };
  }

  return <EChartsReact option={getPie3D(dataList, 0.71, sliceHeightRange)} style={{ height: '100%' }} />;
};

export default Pie3DChart;

使用流程与集成

  1. 准备数据
const dataList = [
  { name: '成本', value: 1200, itemStyle: { opacity: 0.7, color: '#4FA8A4' } },
  { name: '收益', value: 250, itemStyle: { opacity: 0.7, color: '#3570af' } },
  { name: '2收益', value: 158, itemStyle: { opacity: 0.7, color: '#FDAA56' } },
];
  1. 组件调用(React)
<Pie3DChart dataList={dataList} sliceHeightRange={[8, 20]} />
  1. 页面集成与 2D 标签对齐(微协同场景)

微协同页面使用 2D 饼图的 label/labelLine 控制两行标签与两段引导线长度,便于与 3D 效果保持视觉一致:

label: {
  show: true,
  formatter(params) { return `{name|${params.name}}\n{value|${formatAmount(params.value)}}`; },
  rich: {
    name: { color: '#6A7570', fontSize: 12, lineHeight: 18 },
    value: { color: '#6A7570', fontSize: 12, lineHeight: 18 },
  },
},
labelLine: { show: true, length: 12, length2: 40, lineStyle: { color: '#C2C8C5', width: 1 } },

注意事项与踩坑实录

  1. 性能与细节平衡
  • 参数方程的步进值(u.step, v.step)越小,曲面越精细但性能越差;在业务场景下推荐 u.step ≈ π/32, v.step ≈ π/20
  • 切片高度过大导致遮挡:通过 sliceHeightRange 做归一化,避免某一项过度突出。
  1. 标签与引导线(3D)
  • 3D 文本与线条在某些角度可能被遮挡;可微调 endPosArrx/y/z 加上微小偏移,或降低 viewControl.alpha
  • 不建议开启自由旋转:旋转后标签可能穿模或与曲面错位。
  1. 透明支撑环与 tooltip 冲突
  • 透明 surface 可能拦截事件;通过 tooltip.axisPointer.type = 'none'、以及将透明环的 name 设为特殊值并在 formatter 里跳过,可规避无意义提示。
  1. 颜色与图例对齐
  • 图例项来源于 series.name,确保与数据 name 一致;颜色建议集中在数据的 itemStyle.color,避免后续混乱。
  1. 视角与后处理
  • postEffect.SSAO 在低端设备上可能产生锯齿或性能问题;可在移动端降级关闭或降低质量级别。
  1. 容器尺寸变化
  • 容器大小变化时需重新渲染或触发图表 resize,否则曲面比例失衡;在 React 中可监听容器尺寸变化并调用 chart.resize()
  1. SSR / 首屏渲染
  • ECharts-GL 依赖浏览器 WebGL 环境,服务端渲染需延迟到客户端挂载后再初始化。
  1. 数据边界与数值格式化
  • 当数据为空时,避免渲染假数据;页面上用 -- 做占位(见 useLeftContent.tsx)。
  • 金额格式统一用千分位与“元”,可复用 formatAmount 方法:
const formatAmount = (n: number) => {
  const v = Number(n) || 0;
  const isInt = Number.isInteger(v);
  const str = (isInt ? v : v.toFixed(2)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  return `${str}元`;
};

配置项说明(精选)

ECharts-GL(本项目使用)

  • series.surface.parametricEquation: 参数方程,决定曲面形状。
  • series.surface.itemStyle.color/opacity: 切片颜色与透明度。
  • line3D / scatter3D: 标签引导线与文本,放置在三维坐标系中。
  • grid3D.viewControl: 视角控制,如 alpha 旋转角、交互灵敏度。
  • grid3D.postEffect: 后处理,含 bloom 高光与 SSAO 环境光遮蔽。

自定义(本项目扩展)

  • internalDiameterRatiok:内外径比例换算为参数方程辅助参数。
  • sliceHeightRange:映射数据到切片厚度,避免高度失衡。
  • endPosArr 算法:考虑象限与偏移,保证标签分布均衡。

2D 饼图标签(微协同页面)

  • label.rich:两行文本样式(名称/金额),统一字号与颜色。
  • labelLine.length/length2:两段引导线长度;lineStyle 控制颜色与宽度。

常见问题 Q&A

  1. 3D 表面有锯齿?
  • 降低 u/v 步进密度、开启 postEffect.bloom 并调整强度;在低端设备关闭 SSAO
  1. 标签被遮挡或穿模?
  • 微调 endPosArr,减小 alpha 值,或固定视角不允许旋转。
  1. 性能偏慢?
  • 减少数据项、降低步进密度、禁用不必要的后处理;在 React 中避免频繁重渲染。
  1. 想实现高亮/选择?
  • 3D 下选择较难稳定,推荐在 2D 饼图实现交互逻辑;3D 仅作视觉展示。

React函数组件与Hooks的实现原理

前言

我们在编写 React 应用的时候,会使用函数组件配合 Hooks 实现状态管理及其他能力,然而大家是否有深究过为何函数组件需要使用各种 Hooks 来实现其他能力,以及 Hooks 的本质究竟是什么?为什么它们的使用需要严格按照规范,这背后的原理究竟是什么?

本文将针对上述问题展开探讨,分析 Hooks 的本质,以及它们如何与函数组件进行结合,从而辅助函数组件实现各种功能的。

Hooks 的前世今生

React16.8 前的组件

在 React16.8 版本之前,React 有两种类型的组件,分别是类组件函数式组件

它们根本上的区别是,类组件有状态管理、生命周期、副作用等 React 能力,而函数组件则没有。

函数组件用普通的 JS 函数编写,只接收 props 并返回 JSX,不具备状态管理等 React 能力,所以也称为无状态函数组件(Stateless Functional Components)。

// helloworld.js
export default (props) => (
  <div>
    <p>{props.greeting}</p>
  </div>
);

// Example use
<HelloWorld greeting="Hello World!" />;

所以在 React16.8 版本之前,大部分 React 组件都需要使用类组件来编写,而函数组件只能参与小部分的纯 UI 编写。

类组件的不足之处

但是承担着主要作用的类组件,却存在三个不足之处

组件间难以复用状态逻辑

类组件中没有实现逻辑复用的原生方案,如果需要在多个组件中复用逻辑,只能通过 HOC(高阶组件)Render Props 辅助,代码实现比较麻烦,而且多层嵌套后还会形成“嵌套地狱”的问题。

逻辑分散,复杂组件变得难以理解

在某些业务场景中,我们可能需要监听某个变量改变从而执行后续操作,或需要在组件挂载/卸载时执行某些操作。在类组件中,我们需要借助 componentDidUpdate、componentWillUnmount 等生命周期钩子辅助完成。

其中的问题是,假设有一个业务 A,需要使用到上述两个钩子,那么对应的逻辑则需要分别放在它们的回调函数中,造成一个业务的逻辑(在代码中)分散在两个地方,且会与其他业务的代码混杂在一起,使得代码难以阅读和理解。

class 语义复杂

类组件使用 JS 原生的 class 语法进行编写,然而使用 class 语法就必须先理解 JS 中的 this 的工作原理,在事件处理函数绑定的时候,需要绑定当前的 this 值。这些复杂的原理和繁杂的使用方式,使类组件难以上手及增加平时使用的负担。

Hooks 与函数组件

Hooks 的目的

出于对上述类组件的三个不足及 React 长期发展的考虑,React 团队希望推出一种新特性,与无状态函数组件(Stateless Functional Components)配合使用,使其拥有类组件所拥有的能力(即保持状态、处理副作用等 React 特性)。使得无状态函数组件 + Hooks == 类组件,从而实现抛弃类组件也能编写 React 应用的目的。

Hooks 的本质

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

如上片段是React 官方文档对 Hooks 的总结性描述。

Hooks 的本质是普通的 JS 函数,它只能在函数组件或另一个 Hook 中调用,而不能在类组件中使用。

且在使用各种 Hooks 的时候,必须遵守如下规则:Hooks只能在函数组件或自定义Hook的最顶层调用,即不能在条件语句、循环语句或其他嵌套函数内调用 Hook,必须保障函数组件每次调用时,各个Hooks的调用顺序一致

接下来本文将会围绕以下三点展开:

  1. Hooks 的来源(针对不同时机调用不同版本的 Hooks)
  2. Hooks 是如何被存储的(为什么需要保持调用顺序一致)
  3. 以 useState 和 useEffect 为例,展示 Hooks 的执行流程。

Hooks 的来源

React 组件被调用时,会处于以下两个阶段中的一个:

  • mount 阶段(组件初次挂载时)
  • update 阶段(已经挂载过的组件更新时)

而处于不同阶段的组件,所引用的 Hooks 其实是不一样的。

如下代码所示,Count 组件第一次挂载时和后续更新时,所引用执行的 useState 函数是不一样的(即使它们都叫做 useState),useEffect 同理。

import { useState, useEffect } from "react";

function Count() {
  // 不同阶段所引用的 useState 函数是不一样的
  const [count, setCount] = useState(0);

  // useEffect 同理
  useEffect(() => {
    console.log(`count:${count}`);
  }, [count]);

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default Count;

每个 Hook 都会有两套版本,即 mount 版本和 update 版本,分别存放在 HooksDispatcherOnMount 对象和 HooksDispatcherOnUpdate 对象中。

// 存放所有mount时期需要执行的Hooks
const HooksDispatcherOnMount = {
  // ... some code .. 其他hooks基本同理

  // mount时期需要执行的useEffect
  useEffect: mountEffect,
  useRef: mountRef,
  useState: mountState,

  // ... some code ...
};

// 存放所有update时期需要执行的Hooks
const HooksDispatcherOnUpdate: Dispatcher = {
  // ... some code .. 其他hooks基本同理

  useEffect: updateEffect,
  useRef: updateRef,
  useState: updateState,

  // ... some code ...
};

而 React 组件是如何知道在当前阶段应该从哪个对象中取出所需的 Hooks 呢?答案就是 ReactCurrentDispatcher ,现在我们可以将其理解为一个“Hooks 调度器”,他的 current 属性会指向组件当前阶段所对应的 Hooks 集合(即上述的 HooksDispatcherOnMount 或 HooksDispatcherOnUpdate)

const ReactCurrentDispatcher = {
  // 此current属性将会动态指向 HooksDispatcherOnMount 或 HooksDispatcherOnUpdate
  current: null,
};

当 ReactCurrentDispatcher 的 current 通过某种方式(下文将会提到)指向了某个 Hooks 集合之后,各个 Hooks 在执行时就会从所指的集合中取出对应 Hook。以 useState 和 useEffect 为例,它们的源码分别如下所示:

export function useState(initialState) {
  // 获取ReactCurrentDispatcher.current的指向
  const dispatcher = resolveDispatcher();
  // 从正确的Hooks集合中取出对应的Hook
  return dispatcher.useState(initialState);
}

export function useEffect(create, deps) {
  // 获取ReactCurrentDispatcher.current的指向
  const dispatcher = resolveDispatcher();
  // 从正确的Hooks集合中取出对应的Hook
  return dispatcher.useEffect(create, deps);
}

从上述 useState 和 useEffect 的例子可见,一般情况下,一个 Hook 函数被调用时,不会立即执行对应 Hook 的“本体”,而是先通过 ReactCurrentDispatcher.current 获取到当前组件所需的 Hooks 集合(HooksDispatcherOnMount 或 HooksDispatcherOnUpdate),然后从中取出真正的“本体”执行。

而上述 useState 和 useEffect 中的 resolveDispatcher 函数,本质就是返回 ReactCurrentDispatcher.current 的指向,源码如下所示:

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return dispatcher;
}

如下图展示了 ReactCurrentDispatcher.current 的赋值及 Hooks 提取并执行的过程

image.png

ReactCurrentDispatcher.current 的指向

现在我们知道了 ReactCurrentDispatcher.current 会根据当前被调用的组件所处的阶段,指向 HooksDispatcherOnMount 或 HooksDispatcherOnUpdate 对象。接下来我们将探讨 ReactCurrentDispatcher.current 是如何指向正确的 Hooks 集合的。

在开始之前,我们需要知道在 React 的双缓存架构中,维护着 current fiber tree 和 workInProgress fiber tree 这两颗树,它们分别代表当前页面的 fiber 节点所组成的树,和下一个页面的 fiber 节点所组成的树。

而对于某个组件而言,当它在初始化挂载的时候,是没有对应的 current fiber 节点的,只有当挂载完毕之后,才会将 workInprogress fiber 节点赋值给 current fiber 节点。所以 React 判断当前组件是否是处于初始化挂载阶段,采用的是判断其 current fiber 节点是否为空,反之则为更新阶段。

除了上述判断条件之外,我们还需引入另一个条件进行辅助判断:当前组件是否已经初始化过 Hooks。代码中的体现为判断 current.memoizedState 属性是否为空,此属性用于当前组件存储自身所有的 Hooks(以链表的方式存储,本文后续将会提到)。使用 memoizedState 进行辅助判断的原因是 React 的渲染过程是可以中断的,可能出现的一种情况为,已经为当前组件创建了 fiber 节点但没有提交,此时渲染中断,等到后续重新渲染的时候,可以复用之前的 fiber 节点,但是其 Hooks 是没有初始化,即 memoizedState 属性为空。

归纳一下,ReactCurrentDispatcher.current 只有如下两种情况会指向 HooksDispatcherOnMount,其余情况都指向 HooksDispatcherOnUpdate:

  1. 当前组件 fiber 节点没有初始化过
  2. 当前组件 fiber 节点的 memoizedState 属性为空(即 Hooks 链表为空)

节选代码如下所示:

export function renderWithHooks(
  current, // current Fiber
  workInProgress, // workInProgress Fiber
  Component // 函数组件本身
  // ... 其他形参 ...
) {
  // ... some code ...

  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null // current.memoizedState === null 代表当前组件没有使用过任何Hooks
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // ... some code ...
}

Hook 的数据结构及储存方式

Hook 的数据结构

接下来我们聊一下 Hooks 执行之后,会生成什么样的数据结构进行存储。
在源码中,我们可以看到执行之后的 Hook 会以对象的形式存储。而每个 Hook 对象可能会拥有不同的属性,或同名属性有不同的用途,这需要根据具体 Hooks 具体分析。

如下展示的是 useState 的 hook 对象,拥有如下 5 个属性:

export type Hook = {
  memoizedState: any; // 当前渲染后保存的state的值
  baseState: any; // 上一次未被跳过的基础state
  baseQueue: Update<any, any> | null; // 保存之前未被处理的更新链表
  queue: any; // 当前useState对应的更新链表
  next: Hook | null; // 指向下一个hook对象
};
  • memoizedState: 保存当前 Hook 的状态值,不同类型的 Hook 保存不同的信息。(如 useState 中 保存 state 信息、useEffect 中 保存着 effect 对象、useRef 中保存的是 ref 对象)。
  • baseState:保存在上一次 render 中未被跳过的 state 基准值(主要用于 useState 和 useReducer 的批量更新或中断更新场景。如:在并发模式(Concurrent Mode)下,如果某个更新被中断或跳过,React 需要一个“基础状态”来重新计算后续更新。baseState 就是这个起点。)
  • baseQueue:保存那些尚未被处理(或被跳过)的更新队列。(低优先级更新被高优先级更新打断后,这些被挂起的更新会被暂存在 baseQueue 中,等到合适的时机再重新应用。)
  • queue:(一般只在 useState 和 useReducer 中发挥作用)以循环链表的方式储存当前 Hook 的更新对象
  • next:指向当前组件的下一个 Hook 对象的引用。

Hook 对象在 React 的存储方式

当我们大概了解 Hook 的结构后,再聊聊它们是如何与对应的组件进行绑定及存储的。

函数组件不像类组件那样有自己的实例,它们是以 fiber 节点的形式存在的。其中 Fiber 对象中有一个关键的memoizedState属性,此属性储存当前函数组件所有 Hook 对象所组成的链表(在类组件中,此属性储存的则是当前组件的状态)。

当我们在一个组件中调用多个 Hooks 时,React 会为每个 Hook 创建一个对象,并按顺序挂载到 fiber.memoizedState 中:

fiber.memoizedState
  ↓
Hook1 (useState)
  ↓
Hook2 (useEffect)
  ↓
Hook3 (useRef)

在函数组件重新执行的时候,就会顺着这条链表去“对号入座”,为每个 Hook 匹配正确的 Hook 对象。

第一个 Hook → 第一个 Hook 节点
第二个 Hook → 第二个 Hook 节点
...以此类推

这也可以解释,为什么 Hooks 必须在组件的顶层使用,而不能在条件分支中调用。因为如果每次组件重新执行时,Hooks 的数量不一致,那么 fiber.memoizedState 的链表就会错乱,无法正确匹配。

梳理执行流程:从函数组件的执行说起

函数组件执行

接下来我们将从函数组件的执行出发,梳理 Hooks 是如何被创建及发挥其作用的。

函数组件的本质就是一个 JS 函数,那么它们是如何被调用的呢?

答案就是上一节所提到的renderWithHooks函数,此函数会接收如下几个参数:

renderWithHooks(
  current, // current Fiber
  workInProgress, // workInProgress Fiber
  Component, // 函数组件本身
  props, // props
  context, // 上下文
  renderExpirationTime // 渲染 ExpirationTime
);

其中第三个参数Component就是函数组件本身(即一个 JS 函数),将会在上述 renderWithHook 函数中被调用。

整体宏观流程如下图所示:

image.png

整个流程可分为三个步骤:

  1. 首先根据传入的 current 判断当前组件是否是初次渲染,从而决定 ReactCurrentDispatcher.current 的指向。
  2. 执行 Component 函数,执行函数组件,遇到 Hooks 时从 ReactCurrentDispatcher.current 获取并执行
  3. 将 ReactCurrentDispatcher.current 赋值为 ContextOnlyDispatcher

接下来我们将从上述三点展开阐述:

  • 第一点中对 ReactCurrentDispatcher.current 的赋值相信大家通过前文已经有所了解,就是通过 current 参数判断当前组件是否是第一次挂载,从而决定指向 HooksDispatcherOnMount 还是 HooksDispatcherOnUpdate。

  • 第二点主要阐述各个 Hooks 是如何运行的,虽然各个 Hooks 的具体逻辑有所不同,但大致过程是相同的。这一点会在下文详细展开。

  • 第三点中提到当 Component 执行完毕之后,会将 ReactCurrentDispatcher.current 指向 ContextOnlyDispatcher 对象。此对象就是类似 HooksDispatcherOnMount 和 HooksDispatcherOnUpdate 的 Hooks 集合,只不过里面的 Hooks 大都指向同一个 throwInvalidHookError 函数。

export const ContextOnlyDispatcher: Dispatcher = {
  // ... some code ...
  useEffect: throwInvalidHookError,
  useState: throwInvalidHookError,
  // ... some code ...
};

throwInvalidHookError 函数如下所示,执行时会抛出错误,以提示“hooks 只能在函数组件内部使用”。

function throwInvalidHookError() {
  throw new Error(
    "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for" +
      " one of the following reasons:\n" +
      "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" +
      "2. You might be breaking the Rules of Hooks\n" +
      "3. You might have more than one copy of React in the same app\n" +
      "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem."
  );
}

其作用是确保 Hooks 只能在函数内部被调用,否则就会抛出错误。回看上面的图片会发现,当函数组件将要被调用时,会经历三个阶段赋值ReactCurrentDispatcher.current -> 执行Component函数 -> 赋值ReactCurrentDispatcher.current,即 ReactCurrentDispatcher.current 只有在函数组件被执行的期间才会正确指向 HooksDispatcherOnMount 或 HooksDispatcherOnUpdate,其他时间都会指向 ContextOnlyDispatcher。
这就是为什么如果在函数组件之外调用 Hooks 那么就会报错的原因。

Hooks 执行

接下来我们将展开当函数组件遇到 Hooks 时是如何执行的。

Hooks 的执行流程可以宏观地分为两个阶段:

  1. 获取 Hook 对象
  2. 执行 Hook 个性化逻辑

如上步骤所示,每个 Hook 执行之前都会拿到当前的 Hook 对象,这一步的逻辑是统一的,后续才是根据不同类型的 Hook 执行不同的逻辑。

获取 hook 对象

那么接下来我们将展开聊聊 Hook 执行时是如何获取当前的 Hook 对象的。

首次渲染 - mountWorkInProgressHook

对于第一次渲染的组件而言,它们获取每个 Hook 对象的方式是调用 mountWorkInProgressHook 函数,此函数会创建一个全新的 hook 对象,并将其挂载到当前组件 fiber 对象的 memoizedState 链表上。

function mountWorkInProgressHook() {
  // 创建一个新的 hook 对象
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };
  // 判断当前hook是否是当前函数组件的第一个hook
  if (workInProgressHook === null) {
    // 将hook对象直接挂载到当前fiber.memoizedState
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 移动指针保存当前hook对象(hook对象在fiber.memoizedState上以链表方式存储)
    workInProgressHook = workInProgressHook.next = hook;
  }
  // 返回当前hook对象的引用
  return workInProgressHook;
}
组件更新 - updateWorkInProgressHook

对于是 update 时重新渲染的组件,它们的各个 Hook 已经拥有了各自的 hook 对象并挂载到 currnet fiber 的 memoizedState 链表上,所以现在我们需要根据 currnet fiber 树中的 hooks 链表生成当前渲染的 workInProgress fiber 树的 Hooks 链表,使组件更新前后 Hooks 链表结构一致。

具体做法是维护以下两个指针辅助 workInProgress fiber 树生成 Hooks 链表:

  • currentHook:指向 current Fiber 树(上次渲染)的 Hook 链表当前位置
  • workInProgressHook:指向 workInProgress Fiber 树(本次渲染)已构建链表的末尾
function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: null | Hook;

  if (currentHook === null) {
    // 说明这是当前组件的第一个 Hook
    const current = currentlyRenderingFiber.alternate; // 获取 current fiber 树的引用
    if (current !== null) {
      // 指向 current fiber 树 的 memoizedState(即Hook 链表的链头)
      nextCurrentHook = current.memoizedState;
    } else {
      // 若不存current fiber 树(可能处于首轮挂载过程中的特殊重渲染分支),则无 current hooks链可对齐
      nextCurrentHook = null;
    }
  } else {
    // 常规情况:沿着 current hook 链推进到下一个 Hook
    nextCurrentHook = currentHook.next;
  }

  // 计算 workInProgress 链表中下一个应当使用/复用的节点
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    // 若还未创建任何 workInProgress Hook 节点,则从 fiber.memoizedState(即链头)开始
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    // 否则从已构建的 workInProgress 链表的下一个节点尝试复用
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 情况 1:已经存在对应的 workInProgress Hook,直接复用
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // 情况 2:没有可复用的 workInProgress hook 节点,需要从 currentHook 克隆一个新的 Hook 节点
    if (nextCurrentHook === null) {
      // 若连 current 的对应节点也没有,说明当前渲染调用的 Hook 数量“超过了上一轮的数量”
      // 违反“Hook 调用顺序与数量在渲染间保持一致”的约束,直接抛错
      throw new Error("Rendered more hooks than during the previous render.");
    }

    // 将 current 指针推进到下一个
    currentHook = nextCurrentHook;

    // 基于 currentHook 克隆一个新的 Hook 节点到 workInProgress 的hooks 链中
    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null,
    };

    if (workInProgressHook === null) {
      // 若这是本轮渲染的第一个 Hook
      // 将 fiber.memoizedState 指向该新 Hook,作为 workInProgress 树的hook 链头
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // 挂载到 workInProgress 树hooks链表的末尾,并推进指针
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }

  // 返回本次对应的 wip Hook 节点。
  return workInProgressHook;
}

执行各个 hook 具体逻辑

当执行了上述的 mountWorkInProgressHook 或 updateWorkInProgressHook 函数获取到 hook 对象之后,就正式开始执行各个 hook 的具体逻辑了。

接下来会以常用的 useState 和 useEffect 进行举例说明它们在组件首次渲染和更新时发生了什么。

useState
函数组件首次渲染: mountState

组件第一次渲染时,useState 的“本体”是 mountState 函数,代码如下所示

function mountState(initialState) {
  // 步骤一:为当前Hook创建Hook对象并挂载
  const hook = mountWorkInProgressHook();

  // 步骤二:计算初始 state 并挂载保存
  // 当useState的参数为函数时,执行它并将返回值作为state的值
  if (typeof initialState === "function") {
    initialState = initialState();
  }
  // 将初始state的值分别挂载到hook对象的baseState和memoizedState属性上
  hook.memoizedState = hook.baseState = initialState;

  // 步骤三:初始化 hook.queue 属性
  // 初始化hook对象的queue属性,方便后续在其上面挂载update对象
  const queue = {
    pending: null, // 用于存放update对象链表
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer, // 基础reducer函数(下文会提到)
    lastRenderedState: initialState,
  };
  hook.queue = queue;

  // 步骤四:创建 setXXX 函数
  // 创建setXXX函数,为其绑定当前Fiber节点及update对象链表
  const dispatch = (queue.dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue
  ));

  return [hook.memoizedState, dispatch];
}

上述操作我们可以简单归纳为四个步骤:

  1. hook 对象创建与挂载
  2. 计算初始 state 并挂载保存
  3. 初始化 hook.queue 属性
  4. 创建 setXXX 函数,为其绑定当前 fiber 节点与 update 链表 queue

第一点的创建 hook 对象,需要使用前文提到的函数 mountWorkInProgressHook。

第二点即是根据参数类型,通过计算或直接获取 state 值,并挂载到 hook.memoizedState 上。

第三点则是初始化 hook.queue 属性,用于后续保存 update 对象链表等信息(此 queue 属性一般只在 useState 和 useReducer 的 hook 对象中被使用)

第四点会创建 dispatch 函数,此函数其实就是 useState 返回的数组的第二个元素(即 setXXX 函数)。

前三个步骤都以相对容易理解的,接下来需要对第四步的 dispatch 函数展开聊聊。

dispatchSetState

dispatch 函数实际上就是 dispatchSetState 函数调用 bind 绑定了两个实参后返回的新函数。/ 接下来我们看看这个 dispatchSetState 究竟做了什么。

它的作用可简单归纳为三个步骤:

  1. 创建 update 对象。
  2. eager state 优化:决定是否重新渲染组件
  3. 将 update 对象挂载至 hook.queue 上
  4. 执行调度函数,调度更新的执行。

至于它是如何知道要挂载到哪个 hook 的 queue 上的,答案就在于其参数上。

如下是精简后的 dispatchSetState 的伪代码:
此函数实际上需要接收三个参数,而我们平时调用 setXXX 函数时,只需传入具体的值或一个回调函数。此时我们传入的其实是第三个参数,前两个参数会在 mountState 执行时,使用 bind 帮我们绑定,把对应的 fiber 节点和 hook.queue 绑定。 这样就能确保调用 setXX 函数时,如何正确更新对应的 state 了。

function dispatchSetState(
  fiber,
  queue,
  action // setXXX的参数,可为函数或普通值
) {
  // 步骤一:创建update对象
  const update = {
    expirationTime,
    suspenseConfig,
    action,
    eagerReducer: null,
    eagerState: null,
    next: null,
  };

  // 步骤二:Eager state 优化
  const baseState = queue.lastRenderedState; // 上一次渲染完成时的状态
  const eagerReducer = queue.lastRenderedReducer; // 用于计算新的state的reducer函数(下文会再展开)
  if (eagerReducer !== null) {
    try {
      // 直接计算新 state(不重新渲染组件)
      const eagerState = eagerReducer(baseState, action);

      if (Object.is(eagerState, baseState)) {
        // 结果没变,直接返回,不触发调度
        return;
      }

      // 记录提前计算的结果,方便下次渲染使用
      update.eagerState = eagerState;
      update.hasEagerState = true;
    } catch {
      // eager 计算出错时忽略
    }
  }

  // 步骤三:挂载update对象(update对象链表会以环形链表的方式储存)
  const pending = queue.shared.pending; // update队列
  if (pending === null) {
    // 是第一次更新
    update.next = update; // 当前update对象指向自己,形式环状
  } else {
    // 不是第一次更新
    update.next = pending.next; // 当前update对象指向pending.next(即第一个加入的update)
    pending.next = update; // pending.next指向当前update对象(链表中最迟加入的的update指向当前update)
  }
  queue.pending = update; // pending 指向当前update

  // 步骤四:调度更新操作(并非同步执行,具体调度逻辑由 scheduler 执行)
  scheduleUpdateOnFiber(fiber, expirationTime);
}

拓展:上面代码提到的,update 对象链表以环形链表存放于 fiber 节点的 queue.shared.pending 属性上。

示意图如下所示,其中三个 update 对象的加入顺序为:update1 -> update2 -> update3

image.png

关键点为:

  • queue.shared.pending 指向最后加入的 update
  • queue.shared.pending.next 指向第一个加入的 update
函数组件更新:updateState

在函数组件更新时,useState 的“本体”是 updateState 函数,它的源码如下所示

function updateState(initialState) {
  //  实质上是调用 useReducer 的 updateReducer 函数
  return updateReducer(basicStateReducer, initialState);
}

从上述代码中我们可以看到,updateState 实际上是调用 useReducer 在组件更新时执行的本体——updateReducer。也就是说,在函数组件更新时,useState 和 useReducer 所执行的逻辑是一样的。

首先我们从 useState 和 useReducer 使用的角度来看看,为什么 useState 能使用 useReducer 的逻辑,以及上面的 basicStateReducer 参数究竟是什么。

如下为 useState 的使用方式,可以使用两种传参方式去修改 state 的值

const [count, setCount] = useState(0);
// 方式一:直接传入目标值
setCount(100);
// 方式二:传入计算函数
setCount((n) => n + 1);

而实际上,我们使用 useReducer 也能实现上述目标

function reducer(state, action) {
  if (typeof action === "function") {
    // action为函数时,将当前state传入,并将其返回值视为新的state
    return action(state);
  } else {
    // action不为函数,则直接将其设置为新的state
    return action;
  }
}

const [count, dispatch] = useReducer(reducer, 0);
// 直接传入具体值修改count
dispatch(100);
// 传入计算函数
dispatch((n) => n + 1);

从上面 useState 和 useReducer 的使用对比,可以看出 useState 实际上就是一个简易版的 useReducer。是一个 React 帮我们写好 reducer 函数,并且没有 action.type(即 reducer 中没有多种逻辑)的功能单一的 useReducer。

上面 updateState 的源码中提到了,updateState 执行时,会返回 updateReducer(basicStateReducer, initialState)的返回值。其中 initialState 参数就是 useState 的参数,而 basicStateReducer 实际上就是上面提到的“React 帮我们写好的 reducer 函数”,逻辑与我们上面编写的 reducer 函数一样,源码如下所示

// 最基础的reducer,action是“普通值”或“接收当前值并返回新值的函数”。(即setXXX的参数)
function basicStateReducer(prevState, action) {
  return typeof action === "function" ? action(prevState) : action;
}

接下来我们首先从宏观上理解为什么 useState 能帮助函数组件记忆并计算出最新 state 值。

useState 在函数组件更新时,执行的“本体”的 updateState 函数,从宏观上它会执行如下操作,计算出最新的 state,储存并返回出来供函数组件使用:

  1. 取出基础值:updateState 从 hook.baseState 取出上一次的 state 值(因可能存在因优先级不匹配而跳过更新的情况,baseState 记录上一次跳过更新后的基础状态,而 hook.memoizedState 记录的是上次渲染结果的状态)。
  2. 获取更新链表:使用 hook.baseQueue(储存可能存在的上次渲染因优先级不匹配而被跳过的更新对象链表)与 hook.queue.pending(本次渲染的新的更新链表)合并,然后按照 lanes 优先级过滤(即假设本次渲染为高优先渲染,则过滤掉优先级不匹配的更新对象),将被过滤掉但需要保留的更新对象链表存储回 baseQueue 中(供下次渲染时使用),得出最终要更新的 update 链表。
  3. 根据 baseState 及最终的 update 链表,依次计算,并得出 state 的最终值,写到 hook.memoizedState 和 hook.baseState 上。
  4. 将最终值返回,供函数组件使用。

如下是 updateReducer 的伪代码,旨在梳理 updateState 的主体流程

function updateReducer(
  hook,
  reducer,
  renderLane, // 本次渲染的优先级(车道)
) {
  const queue = hook.queue;

  // 1) 合并“待处理”的 pending(环形)与历史的 baseQueue(线性)
  const pending = queue.pending;
  let first = mergeQueuesLinear(hook.baseQueue, pending);

  // 清空 pending(已并入)
  queue.pending = null;

  // 如果没有任何更新,直接复用上次的 memoizedState
  if (!first) {
    return hook.memoizedState;
  }

  // 2) 回放更新链
  let newState = hook.baseState;   // 从 baseState 起算
  let newBaseState = newState;     // 如果发生“跳过”,会更新它
  let newBaseQueueHead = null;
  let newBaseQueueTail = null;

  let u = first;
  while (u) {
    // 计算本次当前更新对象是否匹配当前更新优先级
    const shouldProcess = includesLane(renderLane, u.lane);

    if (shouldProcess) {
      // 满足本次优先级:真正“吃掉并计算”
      newState = reducer(newState, u.action);
    } else {
      // 优先级不够:跳过,但要“克隆”到 newBaseQueue,未来保留
      const clone: Update<S> = { lane: u.lane, action: u.action, next: null };

      if (!newBaseQueueHead) {
        newBaseQueueHead = newBaseQueueTail = clone;
        // 一旦有跳过,未来的回放“起点”必须更新为当前已算出的 newState
        newBaseState = newState;
      } else {
        newBaseQueueTail!.next = clone;
        newBaseQueueTail = clone;
      }
    }
    // 指向下一个update对象
    u = u.next;
  }

  // 3) 写回 hook 各字段(这就是 useState 在本次渲染后的可见结果)
  hook.memoizedState = newState;           // 本次渲染最终 state
  hook.baseState = newBaseState;           // 未来回放起点(若无跳过,等于 newState)
  hook.baseQueue = newBaseQueueHead;       // 未来要继续处理的“跳过更新”链
  queue.lastRenderedState = newState;      // 记录用

  return newState;
}
useEffect
函数组件首次渲染: mountEffect

当组件挂载时,useEffect 的本体是 mountEffect 函数。

function mountEffect(
  create, // useEffect 第一个参数,副作用函数
  deps // useEffect 第二个参数,依赖项数组
) {
  // mountEffect可简单理解为执行了mountEffectImpl函数
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect,
    HookPassive,
    create,
    deps
  );
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  // 创建并挂载hook对象
  const hook = mountWorkInProgressHook();
  // 获取依赖项数组
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  // 创建并挂载effect对象
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps
  );
}

此函数先调用 mountWorkInProgressHook 创建了当前 hook 的 hook 对象。

然后将传入的副作用函数和依赖数组作为参数传递给 pushEffect 函数并调用。

pushEffect 的返回值是当前 useEffect 的 effect 对象,并将其挂载至 hook.memoizedState 上。(不同的 hook 的 memoizedState 记录着不同信息,useState 记录当前 state 的值,useEffect 记录当前的 effect 对象)。

至于 pushEffect 的具体作用,可以归纳为两点:

  1. 创建 effect 对象(记录副作用函数和依赖数组等信息)
  2. 将当前 effect 对象作为链表节点,挂载到 workInProgress fiber 节点的 updateQueue 属性的链表上。(即当前 effect 对象既单独保存在 hook.memoizedState 上,又会与其他 useEffect 的 effect 对象以链表的形式存储的 fiber.updateQueue 上)
function pushEffect(tag, create, destroy, deps) {
  // effect 副作用对象
  const effect: Effect = {
    tag, // 标识 effect 类型与是否需要执行
    create, // 副作用函数
    destroy, // cleanup函数
    deps, // 依赖项数组
    next: (null: any),
  };

  // 获取当前workInProgress节点的updateQueue属性
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);

  if (componentUpdateQueue === null) {
    // 当fiber的effect链表为空,则此effect是第一个effect,初始化fiber.updateQueue
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // 如前面已有effect,则作为链表节点插入fiber.updateQueue中
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

需要注意的是 fiber.updateQueue 中,也是以环形链表的方式存储,并且其 lastEffect 属性指向最后一个 effect 对象。
假如有三个effect对象依次加入,则它们的储存结构如下所示:

image.png

函数组件更新:updateEffect

在组件更新重新渲染时,useEffect 的“本体”是 updateEffect 函数。

updateEffect 的逻辑是根据判断依赖项数组中的依赖项是否更新:

  • 如果没有更新则代表当前副作用无需执行,调用 pushEffect 函数将原 effect 对象挂载到 fiber.updateQueue 链表上并赋值给 hook.memoizedState。
  • 如果依赖项发生了更新,则需要更新 effect 对象(主要更新 effect.tag 属性,将其标记为当前副作用需要执行),然后调用 pushEffect 函数挂载到 fiber.updateQueue 链表,并且更新 hook.memoizedState 属性。
function updateEffect(
  create, // 副作用函数
  deps // 依赖项数组
) {
  // updateEffect 只调用了updateEffectImpl函数
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
  // 获取当前hook的hook对象。
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      // 依赖项不为空,比较依赖项是否改变
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖项没有发生改变,创建原effect对象副本
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;
  // 依赖项发生改变,标记effect.tag为需要执行副作用
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags, // effect.tag参数
    create,
    destroy,
    nextDeps
  );
}

其实 mountEffect 和 updateEffect 的执行时间在 React 的 render 阶段。它们的目的在于创建、更新 effect 对象,并将其挂载到 hook.memoizedState 和 fiber.updateQueue 链表上。而不会直接执行执行副作用函数和 cleanup 函数。
在 commit 阶段,React 会遍历 fiber.updateQueue 链表,根据每个 effect 的 tag 属性判断是否需要执行清理函数和副作用函数。从而完成整个 useEffect 的执行流程。

总结

本文主要围绕 Hooks 梳理了如下几点的知识:

因为类组件的使用不便及功能缺失,React 团队希望推出一种新特性与无状态函数组件配合使用,以达到在不使用类组件的情况下也能编写 React 应用的目的,于是 Hooks 便应运而生。

Hooks 本质是普通 JS 函数,在函数组件执行过程中被调用,而在 React 内部逻辑中,这些 Hooks 会以 hook 对象的形式形成一条单向链表,挂载到对应组件的 fiber 节点的 memoizedState 属性上。

而每种类型的 Hooks 所对应的 hook 对象,会有不同的属性,或相同属性会有不同的用途。

在组件初次渲染和更新时,每个 hook 会执行不同的“本体”,一般命名为 mountXXX 和 updateXXX,而 React 会通过 ReactCurrentDispatcher.current 指向当前所需的“hooks 本体集合”,从中取出所需的“本体函数”。

然后我们以 useState 和 useEffect 为例子,探讨了 Hooks 在组件首次渲染时和更新时的表现。梳理了:

  • Hooks 必须要在函数组件顶部使用而不能在条件语句等语句中使用的原因是,hooks 会以链表的方式存储在 fiber.memoizedState 上,每次函数组件的执行,都会拿着 Hooks 链条与 Hooks 一一匹配,如果 Hooks 嵌套在其他语句中使用,则可能出现匹配错乱的问题。
  • hook 对象分别由 mountWorkInProgressHook 和 updateWorkInProgressHook 来创建及获取。
  • 函数组件状态管理和副作用管理的逻辑。

React 19 高薪技术从入门到进阶 - 实战课程- 慕课网

React 19 高薪技术从入门到进阶 - 实战课程- 慕课网---youkeit.xyz/16048/

技术解码:React 19 Compiler 如何自动优化性能?高清同步训练营从入门讲透

在 React 19 的生态中,Compiler(编译器)  是颠覆性能优化的核心武器。它通过静态分析代码结构,自动插入记忆化(Memoization)逻辑,彻底解放开发者从手动使用 useMemouseCallback 和 React.memo 的繁琐工作。本文将结合高清同步训练营的实战案例,从原理到代码,拆解 Compiler 如何实现“零手动优化”的性能飞跃。


一、性能痛点:React 的“渲染地狱”

在 React 18 及之前版本中,开发者需手动优化组件渲染性能,常见场景包括:

  1. 列表渲染:未优化的列表组件会因父组件状态更新而全量重渲染。
  2. 复杂计算:高频调用的计算函数(如过滤、排序)需手动缓存结果。
  3. 函数与对象:内联函数和对象每次渲染都会重新创建,触发子组件不必要的更新。

传统优化代码示例

jsx
// 需手动记忆化的列表组件
const ExpensiveList = React.memo(({ items, filterText }) => {
  const filteredItems = useMemo(() => 
    items.filter(item => item.name.includes(filterText)), 
  [items, filterText]
  );
  
  const handleClick = useCallback((id) => {
    console.log(`Clicked ${id}`);
  }, []);

  return (
    <ul>
      {filteredItems.map(item => (
        <ListItem 
          key={item.id} 
          item={item} 
          onClick={handleClick} 
        />
      ))}
    </ul>
  );
});

二、React 19 Compiler:自动优化的黑科技

Compiler 的核心原理是通过静态分析(Static Analysis)  和代码转换(Code Transformation) ,在构建阶段自动插入优化逻辑。其工作流程分为三步:

1. 抽象语法树(AST)解析

Compiler 将 JSX 和 JavaScript 代码转换为 AST,深度遍历节点以识别:

  • 变量声明:区分稳定值(如 const 常量)和动态值(如 state)。
  • 控制流:分析条件语句、循环对渲染的影响。
  • JSX 结构:追踪组件嵌套关系和 props 传递路径。

2. 依赖图构建

基于 AST 分析,Compiler 构建细粒度依赖图(Dependency Graph),标记哪些计算或渲染节点依赖于动态值。例如:

jsx
function MyComponent({ items, filterText }) {
  // 依赖图:filterText → filteredItems → visibleItems → ListItem
  const filteredItems = items.filter(item => item.name.includes(filterText));
  const visibleItems = filteredItems.slice(0, 10);
  
  return (
    <ul>{visibleItems.map(item => <ListItem item={item} />)}</ul>
  );
}

3. 自动记忆化插入

Compiler 根据依赖图,自动为高成本计算和子组件 props 插入 useMemo 或 React.memo。上述代码经 Compiler 转换后等效于:

jsx
function _compiled_MyComponent({ items, filterText }) {
  const $memoCache = useMemoCache(2); // 编译器生成的缓存池
  
  // 自动记忆化过滤逻辑
  let $filteredItems;
  if ($memoCache[0]?.items !== items || $memoCache[0]?.filterText !== filterText) {
    $filteredItems = items.filter(item => item.name.includes(filterText));
    $memoCache[0] = { items, filterText, $filteredItems };
  } else {
    $filteredItems = $memoCache[0].$filteredItems;
  }

  // 自动记忆化切片逻辑
  let $visibleItems;
  if ($memoCache[1]?.$filteredItems !== $filteredItems) {
    $visibleItems = $filteredItems.slice(0, 10);
    $memoCache[1] = { $filteredItems, $visibleItems };
  } else {
    $visibleItems = $memoCache[1].$visibleItems;
  }

  // 自动记忆化子组件
  const MemoizedListItem = React.memo(({ item }) => <ListItem item={item} />);
  
  return (
    <ul>{$visibleItems.map(item => <MemoizedListItem key={item.id} item={item} />)}</ul>
  );
}

三、实战案例:从手动优化到 Compiler 驱动

案例1:动态列表渲染优化

场景:渲染 10,000 条数据的列表,支持实时搜索过滤。

传统方案(React 18):
jsx
function ManualOptimizedList({ data, searchTerm }) {
  const filteredData = useMemo(() => 
    data.filter(item => item.name.includes(searchTerm)), 
  [data, searchTerm]
  );

  return (
    <ul>
      {filteredData.map(item => (
        <OptimizedListItem key={item.id} item={item} />
      ))}
    </ul>
  );
}

const OptimizedListItem = React.memo(({ item }) => (
  <li>{item.name}</li>
));
Compiler 方案(React 19):
jsx
function AutoOptimizedList({ data, searchTerm }) {
  // 编译器自动优化过滤逻辑和子组件
  const filteredData = data.filter(item => item.name.includes(searchTerm));
  
  return (
    <ul>
      {filteredData.map(item => (
        <li key={item.id}>{item.name}</li> // 编译器自动包裹 React.memo
      ))}
    </ul>
  );
}

性能对比

  • 首次渲染:Compiler 方案提速 15%(减少记忆化包装开销)。
  • 更新渲染:Compiler 方案提速 40%(精准跳过无关节点计算)。
  • 内存占用:减少 20%(避免冗余缓存)。

案例2:复杂表单处理

场景:多步骤表单,每步依赖前序数据计算。

传统方案(React 18):
jsx
function ComplexForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({});

  // 手动记忆化计算逻辑
  const derivedData = useMemo(() => {
    if (step === 1) return { summary: "Step 1" };
    if (step === 2) return { summary: `Step 2: ${formData.input}` };
    return {};
  }, [step, formData]);

  return (
    <div>
      <p>{derivedData.summary}</p>
      {/* ...其他表单字段 */}
    </div>
  );
}
Compiler 方案(React 19):
jsx
function AutoOptimizedForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({});

  // 编译器自动记忆化计算逻辑
  const derivedData = step === 1 
    ? { summary: "Step 1" } 
    : step === 2 
      ? { summary: `Step 2: ${formData.input}` } 
      : {};

  return (
    <div>
      <p>{derivedData.summary}</p>
      {/* ...其他表单字段 */}
    </div>
  );
}

优势

  • 代码简洁性:减少 50% 的 useMemo 代码。
  • 维护性:无需手动管理依赖数组。
  • 安全性:编译器静态分析避免遗漏依赖。

四、高清同步训练营:从入门到精通

1. 环境配置

  • 安装 Compiler

    bash
    npm install react@19 react-compiler@latest
    
  • Vite 集成

    js
    // vite.config.js
    import reactCompiler from 'vite-plugin-react-compiler';
    
    export default {
      plugins: [reactCompiler()],
    };
    
  • ESLint 规则

    js
    // .eslintrc.js
    module.exports = {
      plugins: ['react-compiler'],
      rules: {
        'react-compiler/no-manual-memoization': 'error', // 禁止手动使用 useMemo
      },
    };
    

2. 调试技巧

  • 开启调试模式

    js
    // 在开发环境中查看编译器优化日志
    if (import.meta.env.DEV) {
      window.__REACT_COMPILER_DEBUG__ = true;
    }
    
  • 性能监控

    jsx
    import { usePerformance } from 'react-compiler';
    
    function MyComponent() {
      const { renderCount, cacheHits } = usePerformance();
      return (
        <div>
          <p>Render Count: {renderCount}</p>
          <p>Cache Hits: {cacheHits}</p>
        </div>
      );
    }
    

3. 迁移指南

  • 渐进式采用

    1. 先在非关键路径组件中启用 Compiler。
    2. 使用 /* @react-compiler ignore */ 注释跳过特定组件的优化。
    3. 逐步替换现有 useMemo 和 React.memo
  • 兼容性注意

    • Class 组件:需手动添加 UNSAFE_ 前缀的遗留方法。
    • 动态 Props:避免在渲染中动态生成对象(如 style={{ color: 'red' }}),改用稳定引用。

五、未来展望:Compiler 的进化方向

  1. 更智能的缓存策略:基于使用频率动态调整缓存大小。
  2. 跨组件缓存共享:全局缓存高频计算结果(如国际化翻译文本)。
  3. 与 Server Components 深度集成:自动优化服务端渲染逻辑。

React 19 Compiler 的出现,标志着前端开发从“手动优化时代”迈向“智能优化时代”。通过高清同步训练营的实战训练,开发者可以快速掌握这一利器,构建出更高效、更易维护的 React 应用。

别再瞎用 Context 了,该上 Zustand 的时候就别犹豫

你有没有遇到过这种情况:项目里用着 Context API 管理状态,结果随着业务复杂度上升,组件重渲染越来越频繁,性能开始拉胯。然后你开始怀疑:是不是该换个状态管理方案了?

今天咱们就把话说清楚:Context API 和 Zustand 到底该怎么选,什么时候该用谁。

先说结论:它们根本不是竞争关系

很多人把 Context API 和 Zustand 放在一起对比,好像它们是二选一的关系。其实不是。

Context API 是 React 内置的数据传递机制,设计初衷是解决 props drilling(属性透传)问题。你可以把它理解为一个"数据管道",让深层组件不用通过中间组件一层层传 props。

Zustand 是一个专门的状态管理库,设计目标是提供高性能、低心智负担的全局状态解决方案。它不仅能传递数据,还内置了性能优化和强大的状态操作能力。

所以准确的说法是:Context API 是基础设施Zustand 是专业工具。就像你可以用螺丝刀拧螺丝,但电钻会更高效。


代码对比:一眼看出区别

Context API 的标准写法

// 第一步:创建 Context
const TokenContext = createContext(undefined);

// 第二步:写个 Provider 组件
export function TokenProvider({ children }) {
  const [count, setCount] = useState(0);
  
  return (
    <TokenContext.Provider value={{ count, setCount }}>
      {children}
    </TokenContext.Provider>
  );
}

// 第三步:封装个 Hook
export function useToken() {
  const context = useContext(TokenContext);
  if (!context) throw new Error("必须在 Provider 内使用");
  return context;
}

// 第四步:在 App 里包裹 Provider
<TokenProvider>
  <App />
</TokenProvider>

// 第五步:终于能用了
function MyComponent() {
  const { count, setCount } = useToken();
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

你看这个流程:创建 Context → 写 Provider → 封装 Hook → 包裹组件 → 使用。五步才能跑起来,代码散落在多个地方。

Zustand 的写法

// 一步到位:创建 store
import { create } from 'zustand';

export const useTokenStore = create((set) => ({
  count: 0,
  setCount: (count) => set({ count }),
}));

// 直接用,没了
function MyComponent() {
  const { count, setCount } = useTokenStore();
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

注意看,没有 Provider,不需要包裹任何东西。创建 store → 使用,就这么简单。

这不是语法糖的区别,而是设计理念的不同。Context API 需要你手动搭建整个数据流,Zustand 直接给你一个开箱即用的状态容器。

性能差距:这才是关键

很多人觉得 Context 够用了,直到遇到性能问题。

Context API 的性能陷阱

const TokenProvider = ({ children }) => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  
  return (
    <TokenContext.Provider value={{ count, setCount, name, setName }}>
      {children}
    </TokenContext.Provider>
  );
};

function Counter() {
  const { count } = useToken();
  console.log('Counter 重渲染了');
  return <div>{count}</div>;
}

function UserName() {
  const { name } = useToken();
  console.log('UserName 重渲染了');
  return <div>{name}</div>;
}

问题来了:当你调用 setName 修改名字时,Counter 组件会重渲染吗?

会。

尽管 Counter 只用了 count,但只要 Provider 的 value 对象发生任何变化,所有消费该 Context 的组件都会重新渲染。这是 Context API 的工作机制决定的——它无法做到精确订阅。

你可以用 useMemo 优化 Provider 的 value,或者拆分成多个 Context,但这会让代码变得更复杂。这就是 Context API 在处理复杂状态时的本质问题:粒度太粗。

Zustand 的精确订阅

const useStore = create((set) => ({
  count: 0,
  name: '',
  setCount: (count) => set({ count }),
  setName: (name) => set({ name }),
}));

function Counter() {
  const count = useStore((state) => state.count);
  console.log('Counter 重渲染了');
  return <div>{count}</div>;
}

function UserName() {
  const name = useStore((state) => state.name);
  console.log('UserName 重渲染了');
  return <div>{name}</div>;
}

现在调用 setName 修改名字,Counter 还会重渲染吗?

不会。

Zustand 通过 selector(选择器)实现精确订阅。Counter 只订阅了 countname 的变化不会触发它的重渲染。这不是什么黑科技,就是状态管理库该有的基本能力。

使用场景:别什么都往一个筐里装

Context API 适合的场景

Context API 不是不好,而是要用对地方。

主题切换:

const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <MyApp />
    </ThemeContext.Provider>
  );
}

主题数据有什么特点?简单不常变逻辑少。用户不会每秒切换十次主题,整个应用的主题状态就是一个字符串加一个 setter。这种场景下 Context API 完全够用,引入 Zustand 反而是过度设计。

用户信息:

const UserContext = createContext();

用户登录后,用户信息基本不变。偶尔更新个头像、修改个昵称,频率很低。这种配置型数据用 Context 管理,简洁明了。

语言设置:

const I18nContext = createContext();

国际化配置同理,切换语言是低频操作,不需要复杂的状态管理。

总结: Context API 适合配置型数据,特点是更新频率低、逻辑简单、状态扁平。

Zustand 适合的场景

购物车:

const useCartStore = create((set) => ({
  items: [],
  total: 0,
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
    total: state.total + item.price
  })),
  removeItem: (id) => set((state) => {
    const newItems = state.items.filter(i => i.id !== id);
    const removedItem = state.items.find(i => i.id === id);
    return {
      items: newItems,
      total: state.total - removedItem.price
    };
  }),
  clearCart: () => set({ items: [], total: 0 }),
}));

购物车是什么状态?频繁增删改、有业务逻辑多字段联动。用户可能疯狂加购、删除、修改数量,每次操作都要同步更新 total、items、可能还有优惠券计算。这种复杂度用 Context 管理,代码会变得非常臃肿。

Token 统计(实时更新):

const useTokenStore = create((set) => ({
  tokenData: null,
  loading: false,
  updateTokens: (newMessage) => set((state) => {
    if (!state.tokenData) return state;
    
    const tokenIncrease = newMessage.token_counts || 0;
    const newBreakdown = { ...state.tokenData.token_breakdown };
    
    if (newMessage.sender === 'ai') {
      newBreakdown.ai_responses += tokenIncrease;
    } else if (newMessage.sender === 'agent') {
      newBreakdown.agent_responses += tokenIncrease;
    }
    
    return {
      tokenData: {
        ...state.tokenData,
        total_tokens: state.tokenData.total_tokens + tokenIncrease,
        token_breakdown: newBreakdown,
        message_count: state.tokenData.message_count + 1,
        last_updated: new Date().toISOString(),
      }
    };
  }),
  refreshTokens: async (sessionId) => {
    set({ loading: true });
    const response = await fetchTokens(sessionId);
    set({ tokenData: response.data, loading: false });
  }
}));

Token 统计需要:每次消息发送后立即更新、区分不同类型的 token、计算总量、保存时间戳。这是典型的业务状态,逻辑复杂、更新频繁、需要跨多个组件访问(Header 显示总量、侧边栏显示详情、聊天面板触发更新)。

WebSocket 消息流:

const useMessageStore = create((set) => ({
  messages: [],
  connected: false,
  addMessage: (msg) => set((state) => ({
    messages: [...state.messages, { ...msg, id: Date.now() }]
  })),
  setConnected: (status) => set({ connected: status }),
}));

WebSocket 每秒可能接收多条消息,高频更新。用 Context 的话,每次新消息都会触发所有消费组件重渲染,性能灾难。

表单状态(多字段联动):

const useFormStore = create((set) => ({
  email: '',
  password: '',
  confirmPassword: '',
  errors: {},
  setEmail: (email) => set({ email, errors: {} }),
  setPassword: (password) => set((state) => {
    const errors = { ...state.errors };
    if (password.length < 8) {
      errors.password = '密码至少8位';
    } else {
      delete errors.password;
    }
    return { password, errors };
  }),
  validate: () => {
    // 复杂的表单验证逻辑
  }
}));

表单验证涉及字段联动、实时校验、错误提示,逻辑比较重。Zustand 可以把所有逻辑封装在 store 里,组件只负责展示。

总结: Zustand 适合业务数据,特点是更新频繁、逻辑复杂、需要跨组件操作、可能需要在组件外调用。

组件外调用:Context 做不到的事

这是个很容易被忽略但非常实用的特性。

Context API 的限制

// ❌ 这样写会报错
import { useToken } from './token-context';

export async function handleNewMessage(message) {
  const { updateTokens } = useToken(); // 错误!Hook 只能在组件内调用
  updateTokens(message);
}

Context API 基于 React 的 Hook 系统,必须在组件内部调用。如果你想在工具函数、API 请求、事件监听器里更新状态,做不到。

你可能会说:"那我把函数传进组件里调用不就行了?"可以,但这会让代码变得很别扭:

function SomeComponent() {
  const { updateTokens } = useToken();
  
  useEffect(() => {
    // 把 updateTokens 传给外部函数
    setupWebSocket(updateTokens);
  }, [updateTokens]);
}

这种写法不仅丑陋,还容易出现依赖追踪问题。

Zustand 的灵活性

// ✅ 随便调
import { useTokenStore } from './token-store';

export async function handleNewMessage(message) {
  useTokenStore.getState().updateTokens(message);
}

// 在 WebSocket 回调里
socket.on('message', (data) => {
  useTokenStore.getState().addMessage(data);
});

// 在 API 中间件里
axios.interceptors.response.use(response => {
  if (response.headers['x-token-count']) {
    useTokenStore.getState().updateTokens({
      token_counts: parseInt(response.headers['x-token-count'])
    });
  }
  return response;
});

Zustand 的 store 是一个普通的 JavaScript 对象,通过 getState() 可以在任何地方访问和修改状态。这在处理异步逻辑、第三方库集成时非常有用。

迁移成本:从 Context 到 Zustand

如果你现在项目里用的是 Context,想换成 Zustand,成本大不大?

不大。 核心逻辑几乎可以直接复制粘贴。

决策树:5 秒钟做出正确选择

你的状态需要跨组件共享吗?
├─ 不需要 → 用 useState(组件内状态)
└─ 需要 ↓
    这个状态每分钟会更新超过 5 次吗?
    ├─ 不会 → Context API
    │          (主题、语言、用户信息)
    └─ 会 ↓
        这个状态有复杂的计算逻辑或副作用吗?
        ├─ 没有 → Context API 勉强可以
        └─ 有 → Zustand
                (购物车、消息列表、Token 统计)

更简单的判断标准:

  • 配置数据(主题、语言、用户信息)→ Context API
  • 业务数据(购物车、消息、统计)→ Zustand
  • 拿不准 → 先用 Context,发现性能问题再换 Zustand

常见误区

误区一:"Context API 够用就不要引入新依赖"

这话没错,但要看"够用"的定义。如果你的应用只有几个页面,状态简单,那确实够用。但如果你已经开始用 useMemo 优化 Provider、拆分多个 Context、为性能问题头疼,那就是"不够用"的信号。

误区二:"Zustand 是大型项目才需要的"

恰恰相反。Zustand 的设计目标就是降低心智负担,小项目用起来比 Context 还简单。你不需要理解 Provider、不需要担心重渲染、不需要写一堆模板代码。

真正的问题是:很多人习惯了 Context 的写法,懒得换。

误区三:"用了 Zustand 就不能用 Context"

它们可以共存。实际项目中经常是:

  • Context 管理主题、语言等配置
  • Zustand 管理购物车、消息等业务状态

混用示例:

// Context 管理主题
<ThemeProvider>
  <App />
</ThemeProvider>

// Zustand 管理业务
const cart = useCartStore();
const messages = useMessageStore();

没有冲突,各司其职。

最后说两句

技术选型没有绝对的对错,只有合不合适。

Context API 是 React 生态的基础设施,它的存在有其合理性。但当你的应用状态管理变得复杂时,专业的工具会让你的工作轻松很多。

Zustand 解决的核心问题只有一个:让状态管理回归简单。

不需要学 Redux 的 action、reducer、middleware;不需要写 Context 的 Provider、useContext、useMemo;不需要操心性能优化、不需要在组件树里层层包裹。

创建一个 store,在需要的地方用,就这么简单。

参考资源

面试官说: "你怎么把 Radio 组件玩出花的,教教我! “

前言

面试官问我 radio 组件知道怎么实现吗?我把我的组件库 radio 网页丢给它,我说我这个 radio 组件什么样式都支持,你看这是传统样式:

image.png

你看,这是自定义样式,理论上什么样式都可以:

image.png

image.png

然后我嘿嘿一笑说:"我的 radio 组件本质上不涉及任何样式,只负责岁月静好,使用者负责貌美如花!"

最后面试完毕,面试官很满意,并问这个怎么实现的,我就写一篇文章来说说吧!

这是上面组件的网站地址

更多组件库教程,欢迎在 github 上给个 star,加群交流哦!

本文章及更多案例已经在官网上了,大家也可以去看 本文链接

可自定义的核心

正常的 radio 组件主要是通过 <input> 标签属性 type="radio" 实现的,浏览器都会显示一个默认的样式

image.png

所以我们要自定义样式,就不能使用原生的 radio样式,那么问题来了,你是不是只要实现了 radio 内在的逻辑,其实也就是实现了 radio 组件,有人会问?内在的逻辑是什么呢?

  • 其一,选中态逻辑,就是多个元素,我们只要点击,就是选中态(checked)
  • 其二,传入 disabled 参数,那么这个元素就不能点击(或者 cursor(也就是鼠标的状态)设置为 not-allowed 也就是不可选中)
  • 其三,原生 radio 并不支持 readyonly 状态,但我们自定义组件应该实现,在表单中, readyonly 是一种很常见的业务需求。所以传入 readyonly 参数,那么这个元素就不能改变状态,只能看。
  • 最后最最重要的逻辑是多个 radio 元素组合时,你只能选中其中一个,即单选的逻辑。

所以我们只要实现了上述逻辑,那就是跟原生的单选就没有区别了。

但问题来了,能不能既保持 radio 组件的原本的逻辑,还能支持自定义呢?也就是用户如果还是希望使用原生 radio 我们支持,自定义也支持呢?这样的话,语义性完好的基础上还能自由拓展,简直完美!

答案是肯定的,我们的组件就是这样的。

在介绍核心逻辑之前,我们先说一个小技巧。

radio 组件基本结构

image.png

首先先介绍一一个小技巧,如上图,一般 radio 包含了一个圆圈表示是否选中,圆圈的右边是文字,正常来说,我们点击圆圈才能选中,可这些组件库如何实现的点击圆圈右边的文字也能选中圆圈呢?这就涉及到 <label> 标签了。

如上图是 MDN 中的方式:

  <div>
    <input type="radio" id="huey" name="drone" value="huey" checked />
    <label for="huey">Huey</label>
  </div>
  • 首先需要在 input 上使用 id 属性
  • 然后在 label 组件上使用 for属性,跟 id 的值一致即可实现点击 label 标签就能选中对应的 radio

但这种方式比较繁琐,我们的组件使用的另一种方式达到同样的效果,就是 labelinput 标签包裹就好:

<label>
 <input type="radio" value="huey" />
huey
</label>

好了,接下来我们梳理一下状态的切换逻辑,这样我们实现起来就有一个蓝图:

  • 首先点击 label ,也就是绑定在 label 上的 onClick 事件
  • 然后这个 onClick 事件会触发 input(radio) 上的 onClick 事件
  • input 上的 onClick 事件会触发自身的 onChange 事件,也就是选中的value 值在 onChange 的时候可以设置为点击的 input 的值,
    • 这里有些新同学可能不知道 input 这类表单元素,目的就是收集值,也就是可以传入 value 属性。

整体逻辑很清晰,也很简单,但其中的坑不少,我们把坑介绍完,基本上你就可以实现一个自己的 radio 组件了

核心逻辑梳理

如上所述,我们第一步是给 label 标签绑定 onClick 事件。

  const onLabelClick = function (e) {
    // 只读或禁用时,阻止点击产生任何行为
    if (disabled || readonly) {
      e.preventDefault();
      return;
    }
    rest?.onClick?.(e);
  };

需要注意

  • 当外界传入 disabled 或者 readonly 参数的时候,我们直接 return
  • 注意要使用 e.preventDefault(); 来组织默认事件,默认事件就是点击 label 触发 inputonClick 事件,从而阻止input 上值被选中

然后需要给 input 绑定 onClick 事件

onClick={(e) => {
   // 阻止 input 的点击事件冒泡,避免重复处理
   e.stopPropagation();
}}

这里需要注意的是

  • 调用 e.stopPropagation(); 防止用户在直接点击 input(radio) 的时候,事件冒泡到 label 标签,从而多次触发 labelonClick 事件。

最后,就是在 input 绑定 onChange 事件

 const [checked, setChecked] = useMergeValue(false, {
    value: propsChecked,
    defaultValue: mergeProps.defaultChecked,
  });
  
  const onChange = (e) => {
    e.persist();
    e.stopPropagation();

    // 禁用或只读都不改变状态,不触发外部 onChange
    if (disabled || readonly) return;

    if (context.group) {
      context?.onChangeValue?.(value, e);
    } else if (!('checked' in props) && !checked) {
      setChecked(true);
    }
    if (!checked) {
      propsOnChange?.(true, e);
    }
  };

其中细节很多,我们简单说一下:

  • e.persist();react 17 之前需要(现在都已经 19 版本,是可以删掉这行代码的),大概介绍下它的作用
// React 16 及之前版本的问题,在 setTimeout 中无法获得正常的事件对象的值
const handleClick = (e) => {
  console.log(e.type); // 正常访问
  
  setTimeout(() => {
    console.log(e.type); // null 或 undefined!
    console.log(e.target); // null!
  }, 1000);
};
  • e.stopPropagation(); 阻止事件冒泡
    • 如果 Radio 嵌套 Radio,点击里面的 Radio 就会让 onChnage 事件冒泡到外层去,这不是我们希望的,所以隔离一下
  • if (disabled || readonly) return; 检查是否是 disabled 或者 readonly 状态,这样的状态不能让它触发 onChange 事件
if (context.group) {
  context?.onChangeValue?.(value, e);
}

这个我们暂且不讲,是要配合 Radio.Group 组件使用,这个 context 是使用 useContext api 获取的 Radio.Group 透传的数据。也就是状态最终会被 Radio.Group 接管。

} else if (!('checked' in props) && !checked) {
  setChecked(true);
}
  • 作用:独立使用时的状态管理

  • !('checked' in props):检查是否是非受控组件,这是识别是否是受控还是非受控组件的关键。

    • 没有传入 checked prop → 组件自己管理状态

这里非常非常细节,有人可能疑惑了,为什么要这么做,而不是用 checked === undefined 来判断,因为不传的值默认不是 undefined 吗?我们来解释一下

大家需要注意,假设你这样传入 checked 参数

<Radio checked={undefiend} />
  • 'checked' in props 得到的是 true 因为你还是传了 undefined

只有这样

<Radio />
  • 'checked' in props 得到的是 false, 也就是什么也没传,代表是非受控组件、
} else if (!('checked' in props) && !checked) {
  setChecked(true);
}

然后 setChecked(true); 这个很关键,有的人说,!('checked' in props) 不是代表非受控组件吗,非受控组件是组件自己控制值,怎么还有直接 setCheck 控制,这不是受控组件的控制的方式吗?

这里需要解释两点

  • 首先,很多组件库,一般都会用受控的形式来模拟非受控,为什么呢?因为我们要确确实实拿到 Radio 组件的 checked(选中) 还是 非 checked 状态,如果都交给原生,我们获取很不方便

  • 其次 useMergeValue 是组件库很常用函数,它把受控和非受控组合起来,是个非常实用的函数,我们来介绍一下逻辑。相信你写组件库也一定会用到。

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';

export function useMergeValue<T>(
  defaultStateValue: T,
  props?: {
    defaultValue?: T;
    value?: T;
  },
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
  const { defaultValue, value } = props || {};
  const firstRenderRef = useRef(true);
  const prevPropsValue = usePrevious(props?.value);

  const [stateValue, setStateValue] = useState<T>(
    !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
  );

  // 受控转为非受控的时候,需要做转换处理
  useEffect(() => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      return;
    }
    if (value === undefined && prevPropsValue !== value) {
      setStateValue(value);
    }
  }, [value]);

  const mergedValue = isUndefined(value) ? stateValue : value;

  return [mergedValue, setStateValue, stateValue];
}

这里简单解释一下,其实就是你传了 value 我就认为你是受控组件,然后 value 就透传出去, defaultValue 或者组件库想默认给个默认值, 我会用这个值其初始化 stateValue 然后传出去。并且 setStateValue 方法能改变其值。

setStateValue 其实在传入 value 的情况下,也没什么用,因为改变不了 value 的值。

如何将状态传递给子组件

我们可以使用 context api,将最终的 checked, disabled, readonly 状态让子组件使用 useContext 来获取。

    <RadioContext.Provider value={{ checked, disabled, readonly }}>
      <label {...rest} onClick={onLabelClick} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-checked={!!checked}>
        {/* 为什么没有 readonly 状态, 标准里本来也没有 */}
        <input type="radio">
        {children}
      </label>
    </RadioContext.Provider>

如何保持语义性

我们只需要将 input 组件依然接受之前我们的状态,就能保持原生 radio 组件的语义性,所以我们完善一下 input 组件,也就是把之前的状态传递过去即可:

    <RadioContext.Provider value={{ checked, disabled, readonly }}>
      <label {...rest} onClick={onLabelClick} aria-disabled={!!disabled} aria-readonly={!!readonly} aria-checked={!!checked}>
        {/* 为什么没有 readonly 状态, 标准里本来也没有 */}
        <input
          ref={inputRef}
          disabled={!!disabled}
          value={value}
          type="radio"
          checked={!!checked}
          onChange={onChange}
          onClick={(e) => {
            // 阻止 input 的点击事件冒泡,避免重复处理
            e.stopPropagation();
          }}
          aria-readonly={!!readonly}
        />
        {children}
      </label>
    </RadioContext.Provider>

Radio Group 逻辑

这里简单介绍一下如何使用 Radio Group 组件包裹上面我们完成的 Radio 组件。 核心逻辑为, 使用同样是 useContext api,我们命名为 RadioGroupContext 来把当前选中的 Radio 标签的 value 传递即可 :

    <div role="radiogroup" {...rest}>
      <RadioGroupContext.Provider
        value={{
          onChangeValue,
          type,
          value,
          disabled,
          readonly,
          group: true,
          name,
        }}
      >
        {children}
      </RadioGroupContext.Provider>
    </div>

小结

文章把主要的核心逻辑梳理了一下,并没有过多解释每行代码。如果你想讨论关于如何实现自己组件库的的内容,欢迎加群一起讨论,组件还在不断拓展中,最终会对标大厂组件库。

其实对于一个前端来说,组件库算是囊括所有日常常见的前端技术了,无论是学习还是面试,都是绝佳的项目。

C#从数组到集合的演进与最佳实践

一、 数组 (Array)

数组是C#中最基础、最原始的数据集合形式。理解它,是理解所有高级集合的起点。

1. 数组的本质:连续的内存空间

当你声明一个数组,如 int[] numbers = new int[5];,你实际上是在内存中请求了一块连续的、未被分割的空间,其大小足以存放5个int类型的数据。

  • 连续性 (Contiguous):这是数组最重要的特性。数据肩并肩地存储在一起,就像一排有编号的停车位。
  • 固定大小 (Fixed-Size):一旦数组被创建,其大小就不能再改变。你不能让一个长度为5的数组突然容纳第6个元素。
  • 类型统一 (Homogeneous):一个数组只能存储相同类型的元素。

2. 数组的声明与使用

// 1. 声明并初始化大小
int[] scores = new int[5]; // 在内存中分配了5个int的空间,默认值为0

// 2. 通过索引访问 (从0开始)
scores[0] = 95;
scores[1] = 88;
int firstScore = scores[0];

// 3. 声明并直接初始化内容
string[] names = new string[] { "Alice", "Bob", "Charlie" };
// 或者更简洁的语法
string[] namesShort = { "Alice", "Bob", "Charlie" };

// 4. 遍历数组
foreach (string name in names)
{
    Console.WriteLine(name);
}

// 5. 获取长度
int nameCount = names.Length; // 结果是 3

3. 数组的优缺点

优点:

  • 极高的访问性能:由于内存是连续的,通过索引 array[i] 访问元素是一个 O(1) 操作。CPU可以直接通过 基地址 + i * 单个元素大小 的公式计算出内存地址,无需任何遍历。这使得数组在需要频繁随机读取的场景下无与伦比。
  • 内存效率高:除了数据本身,几乎没有额外的开销。

缺点:

  • 大小不可变:这是数组最大的“原罪”。在创建时必须知道所需空间,这在许多动态场景下是不现实的。
  • 插入和删除效率低下:在数组中间插入或删除一个元素,需要移动该位置之后的所有元素来填补空位或腾出空间,这是一个 O(n) 操作,非常耗时。

二、List<T> - 动态数组

1. List<T> 初识

List<T> 的内部其实就封装了一个数组。它之所以能“动态”增长,是因为它实现了一套巧妙的**容量管理(Capacity Management)**机制。

  • Capacity vs. Count:
    • Count: 列表实际包含的元素数量。
    • Capacity: 内部数组能够容纳的元素数量。Capacity >= Count 恒成立。

当你向一个List<T>添加元素时,如果Count即将超过CapacityList<T>会自动执行以下操作:

  1. 创建一个新的、更大的数组(通常是当前容量的两倍)。
  2. 将旧数组中的所有元素复制到新数组中。
  3. 丢弃旧数组,将内部引用指向新数组。
  4. 在新数组的末尾添加新元素。
List<int> numbers = new List<int>(); // 初始Capacity通常为0
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");

numbers.Add(1); // 添加元素
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");  // 创建Capacity为4的容器
numbers.Add(2);
numbers.Add(3);
numbers.Add(4);
numbers.Add(5); // 此时会触发内部数组的扩容和复制!此时Capacity为8

Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");

2. List<T> 的使用

List<T> 提供了比数组更加丰富的API方法:

1. 添加和插入元素 (Adding and Inserting Elements)

方法 (Method) 说明 (Description) 示例 (Example) List<string> fruits = new List<string>();
Add(T item) 在列表的末尾添加一个元素。 fruits.Add("Apple"); // ["Apple"]
AddRange(IEnumerable<T> collection) 将一个集合中的所有元素添加到列表的末尾。 var moreFruits = new[] { "Banana", "Cherry" };
fruits.AddRange(moreFruits); // ["Apple", "Banana", "Cherry"]
Insert(int index, T item) 在列表的指定索引处插入一个元素。 fruits.Insert(1, "Orange"); // ["Apple", "Orange", "Banana", "Cherry"]
InsertRange(int index, IEnumerable<T> collection) 在列表的指定索引处插入一个集合的所有元素。 var tropical = new[] { "Mango", "Pineapple" };
fruits.InsertRange(2, tropical);
Contains(T item) 判断列表中是否包含指定的元素。返回 bool bool hasApple = fruits.Contains("Apple"); // true
Exists(Predicate<T> match) 判断列表中是否存在满足指定条件的元素。使用Lambda表达式。 bool hasShortName = fruits.Exists(f => f.Length < 6); // true ("Apple")
Find(Predicate<T> match) 搜索满足指定条件的第一个元素并返回它。如果找不到,返回该类型的默认值(如引用类型为null)。 string bFruit = fruits.Find(f => f.StartsWith("B")); // "Banana"
FindAll(Predicate<T> match) 检索所有满足指定条件的元素,并返回一个包含它们的新 List<T> var longNameFruits = fruits.FindAll(f => f.Length > 5); // ["Orange", "Banana"]
IndexOf(T item) 搜索指定元素,并返回其第一次出现的索引。如果找不到,返回 -1 int index = fruits.IndexOf("Banana"); // 2
LastIndexOf(T item) 搜索指定元素,并返回其最后一次出现的索引。如果找不到,返回 -1 // If fruits = ["A", "B", "A"], LastIndexOf("A") is 2
Remove(T item) 从列表中移除第一次出现的指定元素。成功移除返回 true fruits.Remove("Apple"); // ["Orange", "Banana", "Apple"]
RemoveAt(int index) 移除列表中指定索引处的元素。 fruits.RemoveAt(1); // ["Apple", "Banana", "Apple"]
RemoveAll(Predicate<T> match) 移除所有满足指定条件的元素。返回被移除的元素数量。 int removedCount = fruits.RemoveAll(f => f == "Apple"); // ["Orange", "Banana"]
RemoveRange(int index, int count) 从指定索引开始,移除指定数量的元素。 fruits.RemoveRange(1, 2); // ["Apple", "Apple"]
Clear() 从列表中移除所有元素。Count 变为 0。 fruits.Clear(); // []
Sort() 使用默认比较器对列表中的元素进行就地排序(In-place sort)。 fruits.Sort(); // ["Apple", "Banana", "Cherry"]
Sort(Comparison<T> comparison) 使用指定的委托(通常是Lambda)对元素进行就地排序 fruits.Sort((a, b) => a.Length.CompareTo(b.Length)); // Sort by length
Reverse() 将列表中的元素顺序进行就地反转 fruits.Reverse(); // ["Banana", "Apple", "Cherry"]
ForEach(Action<T> action) 对列表中的每个元素执行指定的操作。 fruits.ForEach(f => Console.WriteLine(f.ToUpper()));
ToArray() 将列表中的元素复制到一个新的数组中。 string[] fruitArray = fruits.ToArray();
GetRange(int index, int count) 创建一个新列表,其中包含源列表中从指定索引开始的指定数量的元素。 var subList = fruits.GetRange(0, 1); // New List containing ["Apple"]

三、Dictionary等特殊集合

1. Dictionary<TKey, TValue> -- 字典

当你需要通过一个唯一的“键”(Key)来快速查找一个“值”(Value)时,Dictionary是你的首选。

  • 本质:基于哈希表(Hash Table)实现。它通过一个哈希函数将Key转换成一个索引,从而实现近乎O(1)的查找、插入和删除性能。
  • 核心特性:Key必须是唯一的,且不可为null
Dictionary<string, int> studentAges = new Dictionary<string, int>();

// 添加键值对
studentAges.Add("Alice", 20);
studentAges["Bob"] = 22; // 更方便的索引器语法

// 查找
if (studentAges.TryGetValue("Alice", out int age))
{
    Console.WriteLine($"Alice's age is {age}"); // 推荐用法,避免异常
}

// 遍历
foreach (var pair in studentAges)
{
    Console.WriteLine($"Key: {pair.Key}, Value: {pair.Value}");
}

2. HashSet<T> - 唯一数组

当你只关心一个元素是否存在于集合中,并且需要保证集合中没有重复元素时,HashSet<T>是完美的选择。

  • 本质:同样基于哈希表,但只存储Key(元素本身),没有Value。
  • 核心特性:元素唯一,查找效率极高(O(1))。支持高效的集合运算(交集、并集、差集)。
HashSet<string> uniqueNames = new HashSet<string>();
uniqueNames.Add("Alice");
uniqueNames.Add("Bob");
uniqueNames.Add("Alice"); // 添加失败,因为 "Alice" 已存在

Console.WriteLine(uniqueNames.Count); // 输出: 2

bool hasBob = uniqueNames.Contains("Bob"); // 极快

3. Queue<T>Stack<T> - 队列和堆栈

  • Queue<T> (队列)先进先出 (FIFO - First-In, First-Out)

    • Enqueue(): 入队(添加到队尾)。
    • Dequeue(): 出队(从队首移除并返回)。
  • Stack<T> (栈)后进先出 (LIFO - Last-In, First-Out)

    • Push(): 入栈(添加到栈顶)。
    • Pop(): 出栈(从栈顶移除并返回)。

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

Flutter 3.38 真的更快吗?基准测试与对比

将营销炒作与可衡量的现实区分开来——基准测试实际揭示了 Flutter 最新版本哪些信息

Flutter 3.38 于 2025 年 11 月 12 日发布,包含 145 位贡献者的 825+ 次提交。当然,有些文章声称它会带来立竿见影的巨大性能优势,但实际体验如何呢?营销炒作可以天马行空,但在可衡量的现实面前却并非如此。

欢迎关注我的公众号:OpenFlutter,感谢

🗣️ 现实检验:别光听宣传!

Flutter 3.38 到底有没有变快?官方文档没把性能提升当成这次发布会主打的卖点

跟以前的版本不一样:

  • Flutter 3.0 主打渲染优化,让界面跑得更快。
  • Flutter 3.7 专注于内存管理,让 App 占用更小。

这次 3.38 主要关注的是这些方面:

  • Dart 3.10 的“点”语法: 让代码写起来更简洁。
  • 小组件预览器(Widget Previewer)加速: 让你开发 UI 更快。
  • 系统兼容性: 支持最新的 iOS 18(UIScene)和 Android 的 16KB 页面大小。
  • 颜色和覆盖层: 引入了广色域支持和 Overlay 管理的一些重要变动。

📊 实际数字到底说明了什么?

🔧 编译性能 (Build Performance)

虽然有些文章提到了构建系统有改进(主要是 Linux 上的 GTK 渲染),但官方没有正式的基准测试数据,来证明那些“编译速度快 40%”之类的说法是真的。

实际开发者测试结果:

  • 从 3.35 升级到 3.38 后,首次冷编译时间基本没差(差异在 5% 以内)。
  • **热重载(Hot Reload)热重启(Hot Restart)**的速度也差不多。

🏃 运行时性能 (Runtime Performance)

3.38 在底层引擎确实做了一些优化:

  • 改进了 Linux 桌面应用的 GTK 渲染
  • 优化了 OpenGL 的合成
  • Impeller 渲染引擎继续在进步(但还在完善中)。

影响分析: 这些改进主要对 Linux 桌面应用有帮助。对于我们日常使用的 Android 和 iOS 手机应用来说,运行速度的提升感受不明显

💾 内存管理 (Memory Management)

3.38 修复了一些图片加载和释放的 Bug,可以防止某些场景下的内存泄漏。但 App 基础的内存占用量,和以前的版本是持平的


📈 纵观 Flutter 性能变化史

我们来看看 3.38 在性能系列中的位置:

  • Flutter 3.0 (2022 年 5 月): 宣布 Web 渲染性能提升,FPS 提升 11%–28% 。这是最后一次“大”性能版本
  • Flutter 3.7 (2023 年 1 月): 通过内存优化,App 占用体积减少了 10%–15%
  • Flutter 3.10–3.35 系列: 主要是性能微调、稳定性和 Bug 修复。
  • Flutter 3.38 (2025 年 11 月): 更注重开发者效率和平台集成,而不是追求更高的原始性能。

✨ 真正重要的性能改进

3.38 没有承诺大幅提高运行速度,但它增加了一些可以间接提升开发体验的功能:

  1. 小组件预览器(Widget Previewer)升级: 速度更快,功能更多。你可以瞬间看到 UI 变化,不需要完整重新编译。这能节省大量迭代设计的时间
  2. “点”语法(Dot Shorthand): Dart 3.10 引入的这个语法,让常用代码的冗余度减少了大约 20% 。代码更清晰,自然就更容易理解和维护——这也是另一种形式的“性能”提升!
// Before
Column(
  mainAxisAlignment: MainAxisAlignment.start,
  crossAxisAlignment: CrossAxisAlignment.center,
)

// After (Flutter 3.38 + Dart 3.10)
Column(
  mainAxisAlignment: .start,
  crossAxisAlignment: .center,
)

3. 平台稳定性

为了让应用能在最新的 iOS 18Android 设备上正常运行,减少崩溃,并表现得更流畅——可靠性本身也是一种性能

基准测试您的应用

所以,问题来了:Flutter 3.38 真的优化了您的应用性能吗?以下是衡量方法:

# Benchmark frame rendering
flutter run --profile --trace-skia

# Measure app startup time
flutter run --profile --trace-startup

# Memory profiling
flutter run --profile
# Then use DevTools Observatory

保持代码和测试条件在不同版本间的恒定

在运行 Flutter 3.35(或您现有的版本)和 3.38 时,保持代码和测试条件恒定不变,然后对比结果。

结论

Flutter 3.38 更快吗?没有戏剧性的提升,但对大多数开发者来说肯定有一点点

  • Linux 桌面应用: 在某些渲染场景中有所改进。
  • Android/iOS 应用: 基本上与 3.35–3.37 相同
  • Web 应用: 没有显著变化。
  • 开发流程: 通过 Widget Previewer简洁的语法实现了“页面优先”的开发。

Flutter 3.38 是一个可靠、完善的版本,它将开发者体验平台兼容性置于纯粹的性能之上。如果您期望性能有 40% 的飞跃,那么您可能会失望。但是,如果您追求整洁的代码、更好的工具稳定的平台支持,那么您会感到满意。

您应该升级吗?

如果满足以下条件,请升级:

  • 需要 iOS 18Android 上的 16KB 页面大小支持。
  • 需要开发者工具的改进(例如 Widget Previewer)。
  • 您正在构建 Linux 桌面应用

如果满足以下条件,请等待:

  • 您目前使用的稳定版本对您来说运行良好。
  • 性能是您唯一的顾虑
  • 您使用的 API 需要迁移(需要更多时间)。

Flutter 团队的工作仍在稳步推进中。3.38 在性能方面算不上一次革命,但它是框架发展过程中值得信赖的一步。最好的更新有时就是那些仅仅是让一切运行得更好的更新,即便这些改进不足以登上新闻头条。

用 Gemini 3 复刻了 X 上爆火的复古拍立得,AI 也能写小程序了?

最近看到 X 上有位小姐姐使用 Gemini 3 做了一个复古拍立得相机,被 Gemini 3 的前端能力震撼到了。然后又看到了很多复刻的版本,但做的都是 web 版,在和朋友聊得时候,他说做个小程序版就好了,小程序更容易疯传。

PixPin_2025-11-25_09-35-49.png

之前一直怀疑 AI 的小程序代码能力,毕竟外国人搞出来的东西,能学习到我们国人的精髓吗?肯定会水土不服。趁着这个机会,刚好来试一下。

说干就干,使用宝玉的 prompt 我微调了一下(其实就只是改了技术栈)。

Please generate a single-file React application for a "Retro Camera Web App" with the following specifications:

1. Visual Layout & Container Strategy
- Theme: Retro aesthetic with a "Handwritten" font style for all text.
- Title: "Bao Retro Camera" displayed at the top center.
- Instructions: Display usage instructions at the bottom right.
- Main Camera Container: 
    - Create a fixed wrapper `div` that acts as the parent for the camera image, viewfinder, shutter button, and photo ejection slot.
    - Positioning: This container must be fixed at exactly 64px from the bottom and 64px from the left of the viewport (`bottom: 64px; left: 64px;`).
    - Dimensions: Width 450px, Height 450px.
    - Z-index: 20
    - All subsequent positioning coordinates (percentages) for camera elements are relative to this container.
- Background Image within Container:
    - Image Source: `https://s.baoyu.io/images/retro-camera.webp`
    - Size: 100% width and height of the container.
    - Position: Left 0, Bottom 0

2. Camera Functionality (The Viewfinder)
- Access the user's webcam.
- Viewfinder Position: The live video feed must be masked to a circle and positioned exactly over the camera lens.
- CSS for Video (Relative to Container): `bottom: 32%; left: 62%; transform: translateX(-50%); width: 27%; height: 27%; border-radius: 50%;z-index: 30`.
- Layering: The video must sit *above* the camera base image visually but within the container.

3. Shutter & Photo Interaction
- Shutter Button: Create an invisible clickable area over the camera's button.
- CSS for Button (Relative to Container): `bottom: 40%; left: 18%; width: 11%; height: 11%; cursor: pointer;z-index: 30`.
- Action: When clicked, play a shutter sound effect and trigger the "Photo Ejection" animation.

4. Photo Ejection & Development Animation
- Aspect Ratio: The generated Polaroid-style photo card must strictly follow a 3:4 portrait aspect ratio (Vertical).
- Ejection Animation: The photo paper slides UPWARDS (negative Y) from behind the camera body.
- Layering: The photo must appear to emerge from *inside* the camera (start with z-index(10) lower than camera body, then animate out).
- Ejection Container Origin CSS: `transform: translateX(-50%); top: 0; left: 50%; width: 35%;height: 100%;` (start position relative to the camera container).
- Ejection Container anmiation position from: ` translateY(0)` to ` translateY(-40%)`
- Developing Effect: Once the photo is taken, the image on the paper should transition from white/blurry to clear/sharp over a few seconds.

5. Drag & Drop "Photo Wall"
- Interaction: The user must be able to drag the ejected photo *out* of the camera slot and drop it anywhere on the rest of the screen (the "Photo Wall").
- Drag Handle: The entire Polaroid card (the white frame and the photo) must be interactive. The user should be able to click and drag from any part of the card to move it.
- Logic: While developing, the photo is attached to the camera container. Once dragged, it becomes absolutely positioned on the main screen body.
- Freedom: Once on the wall, photos can be dragged and repositioned freely.

6. AI Integration & Text Interactions
- Caption Generation: Use the Gemini Flash API to analyze the captured image content.
- Prompt: Generate a warm, short blessing or nice comment based on the photo content.
- Language Requirement: The generated text language must match the user's browser language.
- Footer Layout: The bottom of the Polaroid paper (below the image) should display the current date and the AI-generated text.
- Text Interaction & Icons:
    - When hovering specifically over the text area, display two small icons:
        1. Pencil Icon: Enters edit mode.
        2. Refresh Icon: Re-triggers the AI generation for that specific photo to get a new caption.
- Editing Logic:
    - Trigger: Edit mode can be entered by clicking the Pencil icon OR by double-clicking the text itself.
    - Behavior: When editing, replace the text display with an input/textarea showing the raw text.
    - Controls: Pressing Enter saves the changes. Pressing Esc cancels the edit and reverts to the previous text.

7. Photo Controls (Card Level)
- Hover Actions: When hovering over a developed photo card on the wall (general hover), show a small toolbar at the top of the photo with:
    - Download Button: When clicked, this must render the entire Polaroid card (including the white frame, the photo, the date, and the handwritten caption) into a single image file and download it. (Recommended: use `html2canvas` or similar logic).
    - Delete Button: Removes the photo from the screen.

Technical Stack
- uni-app + vue3 + typescript.
- canvas.

使用这个 prompt,其实 one shot 就已经跑通了整体的流程,后续我微调了一下样式(果然有点水土不服),还增加了相纸选择和分享的功能,然后上线了小程序,最终的效果可以扫码查看一下:

代码也放到 github 上了,100% AI 打造:github.com/HeftyKoo/re…

# vue3 使用 echarts 展示某省份各区市数据

vue3 使用 echarts 展示某省份各区市数据

这个很简单,直接贴代码了

代码

echarts 使用的是最新版:"echarts": "^6.0.0"

<template>
  <div class="ed-map-div">
    <div class="ed-map-model" ref="echartRef"></div>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'

const echartRef = ref(null)
const actBtn = ref(0)

let echartInstance = null;

let option = {
  backgroundColor: 'transparent',
  tooltip: {
    trigger: 'item',
    formatter: '{b}: {c}'
  },
  // 添加视觉映射组件,用于展示数据
  visualMap: {
    min: 0,
    max: 700,
    left: 'left',
    top: 'bottom',
    text: ['高', '低'],
    calculable: true,
    inRange: {
      color: ['#e0f3ff', '#0066cc']
    }
  },
  series: [{
    type: 'map',
    map: '辽宁',
    roam: true,
    label: {
      show: true,
      color: '#FFF',
      fontSize: 14,
      fontWeight: 'bold',
      fontFamily: '微软雅黑'
    },
    emphasis: {
      label: {
        show: true,
        fontSize: 14,
        color: '#FFF',
        fontWeight: 'bold'
      },
      itemStyle: {
        areaColor: '#ffcc00'
      }
    },
    data: [
      {
        value: 650,
        name: '沈阳市',
      },
      {
        value: 450,
        name: '大连市',
      },
      {
        value: 420,
        name: '鞍山市',
      },
      {
        value: 400,
        name: '抚顺市',
      },
      {
        value: 500,
        name: '本溪市',
      },
      {
        value: 600,
        name: '丹东市',
      },
      {
        value: 700,
        name: '铁岭市',
      },
      {
        value: 700,
        name: '阜新市',
      },
      {
        value: 700,
        name: '锦州市',
      },
      {
        value: 700,
        name: '朝阳市',
      },
      {
        value: 700,
        name: '盘锦市',
      },
      {
        value: 700,
        name: '葫芦岛市',
      },
      {
        value: 700,
        name: '营口市',
      },
      {
        value: 700,
        name: '辽阳市',
      },
    ]
  }]
}

onMounted(() => {
  echartInstance = echarts.init(echartRef.value)

  // 正确的地图数据加载方式
  import('../../json/210000.json').then(mapJson => {
    // 直接使用 JSON 数据注册地图
    echarts.registerMap('辽宁', mapJson)

    // 配置 ECharts 选项
    echartInstance.setOption(option)
  }).catch(error => {
    console.error('加载地图数据失败:', error)
  })

  // 添加窗口大小变化监听
  window.addEventListener('resize', () => {
    echartInstance.resize()
  })
})


</script>
<style scoped lang="scss">
.ed-map-div {
  width: 100%;
  height: 100%;
  position: relative;

  .ed-map-model {
    width: 100%;
    height: 100%;
  }
}
</style>

其中省份的 json 文件从这个网站可以下载:datav.aliyun.com/portal/scho…

最后完成的效果:

在这里插入图片描述

🧭 前端周刊第441期(2025年11月17日–11月23日)

📢 宣言

我已经计划并开始实践:每周逐期翻译《前端周刊》内的每篇文章,并将其整理发布到 GitHub 仓库中,持续更深度的分享。
欢迎大家访问:github.com/TUARAN/fron…
顺手点个 ⭐ star 支持,是我持续输出的续航电池🔋✨!

Banner

每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~


💬 推荐语

本期周刊聚焦前端生态的持续演进:从 AI 辅助可访问性规范编写,到 WebAssembly 十年后的前端应用扩展;从原生 JavaScript 模块的构建自由,到 React 19.2 的异步革命。CSS 世界也在不断突破,3D 图像、动画标准化、Grid 布局等新特性让开发体验更上一层楼。


🗂 本期精选目录

🧭 Web 开发

🛠️ 工具

🎬 Demo 演示

🎨 CSS

💡 JavaScript

⚛️ React

🅰️ Angular


📌 小结

从 AI 辅助开发工具到原生模块化,从 CSS 数学函数到 React 异步革命,这一周的前端生态展现出"工具智能化 + 标准原生化"的双重趋势。开发者正在从复杂的构建工具中解放出来,同时获得更强大的原生能力支持。


✅ OK,以上就是本次分享,欢迎加我威 atar24,备注「前端周刊」,我会邀请你进交流群👇

🚀 每周分享技术干货
🎁 不定期抽奖福利
💬 有问必答,群友互助

❌