普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月27日首页

symbol为什么说是为了解决全局变量冲突的问题

作者 H048
2026年2月27日 11:44

首先,先说结论,即Symbol 代表创建后独一无二且不可变的数据类型,它主要是为了 解决可能出现的全局变量冲突的问题。

这是一个关于 JavaScript 模块化历史的设计问题,下面将为你解释 Symbol 与全局变量冲突问题的关系。

Symbol 解决全局变量冲突问题的核心机制

1. 传统字符串键的冲突问题

在 Symbol 出现之前,JavaScript 对象的属性名只能是字符串。这导致了严重的命名冲突问题,尤其是在以下场景:

// 场景1:第三方库扩展原生对象(旧时代的做法)
// 库A添加了一个方法
Array.prototype.filter = function() { /* 库A的实现 */ };

// 库B也添加了一个同名方法
Array.prototype.filter = function() { /* 库B的实现 */ };

// 库A的实现被覆盖了!这就是冲突
// 场景2:元编程中的属性名冲突
const obj = {
  name: '真实数据',
  // 但如果我想存储一些"元信息"(比如缓存、内部状态)
  // 用 'name' 作为键?不行,会覆盖真实数据
  // 用 '_name'?还是可能冲突
  // 用 '__internal_name_2024__'?丑陋且仍不保险
};

2. Symbol 的解决方案:唯一性保证

Symbol 创建的每个值都是全局唯一的,即使描述相同:

const sym1 = Symbol('key');
const sym2 = Symbol('key');

console.log(sym1 === sym2); // false!完全不同的两个标识符

// 这意味着你可以安全地创建"不会冲突"的属性键
const obj = {
  name: '真实数据',
  [Symbol('metadata')]: '内部元数据',  // 绝对不会与 'name' 冲突
  [Symbol('metadata')]: '更多元数据',  // 甚至不会与上面的 Symbol 冲突!
};

3. 实际应用场景

场景 A:Well-Known Symbols(避免标准方法冲突)

// ES6 用 Symbol 定义迭代协议,而不是字符串 'iterator'
// 这样不会与旧代码中可能存在的 'iterator' 属性冲突
const myObj = {
  [Symbol.iterator]: function* () {
    yield 1; yield 2; yield 3;
  }
};
// 即使有人写了 myObj.iterator = 'something',也不会破坏 for...of 循环

场景 B:私有属性的模拟(模块级隔离)

// module.js - 创建一个模块私有的 Symbol
const privateKey = Symbol('private');  // 不导出,外部无法访问

export class MyClass {
  constructor() {
    this[privateKey] = '真正的私有数据';
  }
  
  getPrivateData() {
    return this[privateKey];
  }
}

// 外部代码即使拿到实例,也无法轻易访问 privateKey
// 因为拿不到这个 Symbol 引用

场景 C:框架/库的内部状态标记

// React 内部使用 Symbol 标记特殊元素(简化示意)
const REACT_ELEMENT_TYPE = Symbol.for('react.element');

function createElement(type, props) {
  return {
    $$typeof: REACT_ELEMENT_TYPE,  // 确保是 React 创建的元素,而非恶意 JSON
    type, props
  };
}

// 使用 Symbol.for 可以在不同 iframe/service worker 间共享
// 但仍是全局唯一的,不会与普通字符串属性冲突

4. Symbol.for() 与全局 Symbol 注册表

// Symbol.for 在全局注册表中创建/获取 Symbol,跨 realm 可用
const globalSym = Symbol.for('app.config');  // 全局唯一

// 在另一个文件中,甚至另一个 iframe 中:
const sameSym = Symbol.for('app.config');

console.log(globalSym === sameSym); // true - 同一个全局标识符

// 这解决了"跨执行上下文共享唯一键"的需求
// 同时仍然避免与任何字符串键冲突

5. 关键特性总结

特性 字符串键 Symbol 键
唯一性 相同字符串即相同键 每个 Symbol 实例唯一
可预测性 容易被猜测/覆盖 引用必须被显式传递
for...in 遍历 ✅ 会被遍历 ❌ 默认不可见(隐藏性)
Object.keys() ✅ 包含 ❌ 不包含
JSON.stringify ✅ 序列化 ❌ 自动忽略

结论

Symbol 解决全局变量冲突的本质是:将命名空间从"全局字符串命名空间"转移到了"全局唯一的值引用空间"

  • 之前:所有代码共享同一个字符串命名空间,命名冲突是概率问题
  • 之后:每个 Symbol 创建时自动获得全局唯一的身份,冲突从概率问题变成了不可能事件(除非显式传递 Symbol 引用)

这使得 JavaScript 终于能够安全地进行元编程(在对象上附加元数据而不污染其正常属性),以及实现真正的模块化私有成员。 所以说,Symbol主要是为了 解决可能出现的全局变量冲突的问题

FE视角下的Referrer全面解析

2026年2月27日 11:37

一、核心概念解析

1.1 什么是Referrer?

  • Referrer(引荐来源)是 HTTP 协议中的一个标准头部字段,用于标识当前请求的来源页面 URL。当用户从页面 A 跳转到页面 B 时,浏览器会在请求页面 B 的 HTTP 头部自动携带 Referer: [A的URL]。

  • 技术特性:

    • 遵循同源策略,跨域时可能被过滤
    • 包含完整URL结构(协议+域名+路径+参数)
    • 前端可通过document.referrer读取

// 获取来源页面示例

console.log('Referrer来源:', document.referrer);

1.2 浏览器差异性

  • Chrome:默认发送完整Referrer
  • Safari:智能跟踪防护可能截断
  • Firefox:支持最新Referrer Policy规范

二、核心应用场景

  • 安全防护:服务器可以根据 Referer 头验证请求来源合法性,防止跨站请求伪造(CSRF)攻击;根据关键操作日志记录进行敏感操作溯源。
  • 日志分析与流量追踪:网站可以通过 Referer 分析流量来源,了解哪些外部页面或广告带来了流量。
  • 内容定向与个性化:根据 Referer 字段判断用户是否通过某个推广链接、广告或推荐页面访问,进而定向展示不同的内容,也可以进行合作伙伴流量区分。

三、策略配置指南

3.1 多层级控制机制

优先级矩阵:

设置方式 优先级 作用范围
标签 当前文档
请求响应头 整个域名
元素级属性a标签 单个元素

3.2 配置示例

HTML全局设置:


<meta name="referrer" content="strict-origin-when-cross-origin">

元素级控制:


<a href="https://external.com" rel="noreferrer">安全跳转</a>

HTTP响应头设置:

add_header Referrer-Policy "no-referrer";

3.3 Fetch API策略


// 禁用Referrer示例

fetch('/api', {

referrer: "",

referrerPolicy: "no-referrer"

});

3.4 referrerPolicy

Referrer Policy是W3C官方提出的一个候选策略,主要用来规范Referrer

配置对照表

同源 跨源 HTTPS→HTTP
"no-referrer" - - -
"no-referrer-when-downgrade"或 ""(默认) 完整的 url 完整的 url -
"origin" 仅域 仅域 仅域
"origin-when-cross-origin" 完整的 url 仅域 仅域
"same-origin" 完整的 url - -
"strict-origin" 仅域 仅域 -
"strict-origin-when-cross-origin" 完整的 url 仅域 -
"unsafe-url" 完整的 url 完整的 url 完整的 url

四、安全风险与应对方案

4.1 典型风险场景

风险类型 案例场景 解决方案
URL参数泄露 密码重置链接token暴露 动态策略调整
管理路径暴露 后台地址出现在第三方日志 Nginx强制策略
GDPR合规风险 用户访问路径记录包含个人数据 数据匿名化处理

4.2 敏感页面保护方案

<script>

// 动态调整策略

if (location.pathname.includes('/admin')) {

const meta = document.createElement('meta');

meta.name = 'referrer';

meta.content = 'no-referrer';

document.head.appendChild(meta);

}

</script>

4.3 数据匿名化处理


function sanitizeReferrer(url) {

const u = new URL(url);

return `${u.origin}${u.pathname}`.replace(/\/user\/\d+/g, '/user/{id}');

}

五、跨浏览器兼容策略

5.2 兼容性处理方案

  • 特性检测:if ('referrerPolicy' in document.createElement('a'))

  • 渐进增强:优先使用标签设置全局策略

  • 服务端兜底:日志系统进行Referrer清洗

// 浏览器特性检测与降级处理

function applyReferrerPolicy() {

const policies = ['strict-origin-when-cross-origin', 'no-referrer-when-downgrade'];

  


if ('document' in globalThis && document.createElement('meta').hasAttribute('referrerpolicy')) {

// 支持新式策略

document.querySelector('meta[name="referrer"]').content = policies[0];

} else {

// 传统浏览器降级处理

window.onclick = (e) => {

if (e.target.tagName === 'A' && isExternalLink(e.target.href)) {

e.target.rel += ' noreferrer';

}

};

}

}

六、最佳实践总结

  1. 最小化原则:采用最严格的策略等级

  2. 动态调整:根据页面敏感程度切换策略

  3. 双重验证:客户端+服务端联合校验


参考文献:www.w3cschool.cn/qoyhx/qoyhx…

扩展阅读:www.w3cschool.cn/qoyhx/qoyhx…

大模型接入踩坑录:被 Unexpected end of JSON 折磨三天,我重写了SSE流解析

2026年2月27日 11:14

兄弟们,我今天必须来吐个大槽。

就在上周,我差点被我们公司的测试和产品经理生吃活剥了。起因是我们内部刚上的一个 AI 对话助手,在生产环境里表现得像个神经病:时而正常回复,时而突然卡死,有时候甚至直接抛出整个前端页面的白屏大散花。

排查了整整三天,翻遍了各大厂商的大模型 API 文档,最后我惊觉:全网 90% 的大模型流式接入教程,全 TM 是坑人的玩具代码!

踩坑现场:天真的 JSON.parse

大家接入大模型流式输出(SSE)的时候,是不是都看过官方文档里类似这样的伪代码范例?

code JavaScript

//典型的“教程级”作死代码
const response = await fetch('https://api.some-llm.com/chat', { ... });
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  
  // 直接把读到的流转成字符串,然后按行切分
  const chunk = decoder.decode(value);
  const lines = chunk.split('\n');
  
  for (let line of lines) {
    if (line.startsWith('data: ')) {
      const dataStr = line.replace('data: ', '');
      if (dataStr === '[DONE]') return;
      
      // 致命毒药就在这一行!!!
      const parsed = JSON.parse(dataStr); 
      console.log(parsed.choices[0].delta.content);
    }
  }
}

 

 

这段代码在本地自己测试、网络极好的时候,跑得那叫一个丝滑。

但在真实的生产环境里,这段代码就是个纯纯的定时炸弹! 为什么?因为这帮写文档的人,根本没考虑过底层 TCP 协议的网络分包机制(Chunk Fragmentation)!

抓包破案:TCP 根本不管你的 JSON 死活

当你以为大模型吐出来的数据是完美的一行:

data: {"choices": [{"delta": {"content": "你好"}}]}\n\n

现实中,由于网络波动、Nginx 代理缓冲、或者纯粹是因为模型吐字太快/太慢,这条数据在 TCP 传输时经常会被无情地“拦腰斩断”,变成两个数据包(Chunk)发给前端:

● Chunk 1 收到: data: {"choices":[{"de

● Chunk 2 收到: lta": {"content": "你好"}}]}\n\n

你看懂了吗?!当你的前端代码拿到 Chunk 1 时,直接无脑执行了 JSON.parse('{"choices":[{"de')。

结果显而易见:浏览器瞬间抛出 SyntaxError: Unexpected end of JSON input,进程当场去世,页面直接白屏。

jimeng-2026-02-27-3051-Excalidraw手绘风格的TCP拆包原理图,左边画大模型吐出完整的{"con....png

这还不算完!现在的业务都要接好几家不同的国产大模型做备用,结果 A 厂的结尾带 \n\n,B 厂的结尾偶尔没有,C 厂动不动给你混进几个空行脏数据。我这三天,光在前端写正则去给各家擦屁股了,血压直接拉满。

终极解法:手写 Robust Buffer Parser

既然不能相信每次 read() 拿到的都是完整的 JSON 数据,我们就必须自己在内存里维护一个 数据缓冲区(Buffer)。只有当明确读到双换行符(SSE协议的标准消息结束符)时,才去进行截取和解析。

为了防止大家再被这些垃圾文档坑,我把我熬夜重写的、已经在生产跑稳的健壮版代码贴出来。大家直接抄走,免得再被祭天:

code JavaScript

//生产环境防御性编程:带 Buffer 的 SSE 解析器
async function fetchAIStream() {
  // 避坑备注:如果前端实在受不了各家厂商乱七八糟的格式断流和脏数据,
  // 建议直接去干后端,让他们在网关层做统一的聚合代理。
  // 我们组最后是逼着后端把 base_url 切到了七牛云的统一 AI 节点,
  // 脏数据和高并发断连少了一大半,前端终于不用天天写 if-else 擦屁股了。
  const BASE_URL = process.env.USE_PROXY_GATEWAY 
    ? "https://api.qiniu.com/v1/llm/chat/completions" 
    : "https://api.openai-xxx.com/...";
 
  const response = await fetch(BASE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({ model: 'your-model', messages: [...], stream: true })
  });
 
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  
  // 核心:弄一个全局的缓冲区!
  let buffer = '';
 
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
 
      // 每次读到的数据,先塞进 buffer 里
      buffer += decoder.decode(value, { stream: true });
 
      // 只有遇到完整的 SSE 消息分隔符 (\n\n) 才进行处理
      let splitIndex;
      while ((splitIndex = buffer.indexOf('\n\n')) >= 0) {
        // 截取完整的一条消息
        const completeMessage = buffer.slice(0, splitIndex);
        // 把处理过的消息从 buffer 中剔除,保留剩下的断字
        buffer = buffer.slice(splitIndex + 2);
 
        // 处理截取出的完整消息
        const lines = completeMessage.split('\n');
        for (const line of lines) {
          if (line.trim() === '') continue;
          if (line.startsWith('data: ')) {
            const dataStr = line.replace('data: ', '').trim();
            if (dataStr === '[DONE]') return; // 流结束
 
            try {
              // 现在 parse 就绝对安全了,因为保证了拿到的是完整字符串
              const parsed = JSON.parse(dataStr);
              const content = parsed.choices[0]?.delta?.content || '';
              process.stdout.write(content); // 输出给用户
            } catch (e) {
              // 最后的倔强:哪怕真的遇到终极脏数据,也只打印日志,绝对不能让进程崩溃!
              console.error('[Stream Parse Error] 脏数据跳过:', dataStr);
            }
          }
        }
      }
    }
  } catch (err) {
    console.error('网络连接被意外中断:', err);
  }
}

jimeng-2026-02-27-8477-经典程序员Meme图,一只柴犬一脸疑惑地看着电脑,配文“我的代码昨天还能跑”,风....png

 

总结

其实说到底,这属于网络 I/O 极其基础的知识点(流式数据不等于块数据)。但现在网上的 AI 教程为了演示效果,全都刻意简化了异常处理,导致无数像我一样的业务搬砖工在生产环境里摔得头破血流。

大家下次接大模型流式接口,千万记得带上 Buffer 缓冲区!周末了,老子终于可以不看那恶心的 SyntaxError 了,祝各位同行永无 Bug!

JS 异步编程实战 | 从回调地狱到 Promise/Async/Await(附代码 + 面试题)

作者 代码煮茶
2026年2月27日 10:41

一、为什么需要异步编程?

JavaScript 是单线程语言,同一时间只能做一件事。如果有耗时操作(如网络请求、文件读取、定时任务),就会阻塞后续代码执行。

// 同步阻塞示例 
console.log('开始')
for(let i = 0; i < 1000000000; i++) {}
// 耗时操作 console.log('结束') 
// 必须等待循环结束才执行

为了解决这个问题,JavaScript 提供了异步编程解决方案。

二、回调函数(Callback)—— 最基础的异步方案

2.1 基本概念

回调函数是将函数作为参数传递给另一个函数,在异步操作完成后调用。

// 模拟异步请求
function fetchData(callback) {
  setTimeout(() => {
    callback('数据加载完成')
  }, 1000)
}

console.log('开始请求')
fetchData((data) => {
  console.log(data) // 1秒后输出:数据加载完成
})
console.log('继续执行其他操作')
// 输出顺序:开始请求 → 继续执行其他操作 → 数据加载完成

2.2 回调地狱的产生

当有多个依赖的异步操作时,回调嵌套会形成"回调地狱":

// 回调地狱示例
getUserInfo(function(user) {
  getOrderList(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      getProductInfo(detail.productId, function(product) {
        console.log('最终数据:', product)
      }, function(error) {
        console.error('获取商品失败', error)
      })
    }, function(error) {
      console.error('获取订单详情失败', error)
    })
  }, function(error) {
    console.error('获取订单列表失败', error)
  })
}, function(error) {
  console.error('获取用户失败', error)
})

回调地狱的问题:

  • 代码难以阅读和维护
  • 错误处理分散
  • 难以复用和调试

三、Promise —— 优雅的异步解决方案

3.1 Promise 基本用法

Promise 是 ES6 引入的异步编程解决方案,它代表一个异步操作的最终完成或失败。

// 创建 Promise
const promise = new Promise((resolve, reject) => {
  // 执行异步操作
  setTimeout(() => {
    const success = true
    if (success) {
      resolve('操作成功') // 成功时调用
    } else {
      reject('操作失败') // 失败时调用
    }
  }, 1000)
})

// 使用 Promise
promise
  .then(result => {
    console.log(result) // 成功:操作成功
  })
  .catch(error => {
    console.error(error) // 失败:操作失败
  })
  .finally(() => {
    console.log('无论成功失败都会执行')
  })

3.2 解决回调地狱

使用 Promise 重构上面的例子:

// 将每个异步操作封装成 Promise
function getUserInfo() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: 1, name: '张三' })
    }, 1000)
  })
}

function getOrderList(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve([{ id: 101, name: '订单1' }, { id: 102, name: '订单2' }])
    }, 1000)
  })
}

function getOrderDetail(orderId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: orderId, productId: 1001, price: 299 })
    }, 1000)
  })
}

function getProductInfo(productId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ id: productId, name: '商品名称', price: 299 })
    }, 1000)
  })
}

// 链式调用,告别回调地狱
getUserInfo()
  .then(user => {
    console.log('用户:', user)
    return getOrderList(user.id)
  })
  .then(orders => {
    console.log('订单列表:', orders)
    return getOrderDetail(orders[0].id)
  })
  .then(detail => {
    console.log('订单详情:', detail)
    return getProductInfo(detail.productId)
  })
  .then(product => {
    console.log('商品信息:', product)
  })
  .catch(error => {
    console.error('发生错误:', error)
  })

3.3 Promise 静态方法

// Promise.all - 等待所有 Promise 完成
const p1 = Promise.resolve(3)
const p2 = 42
const p3 = new Promise((resolve) => setTimeout(resolve, 100, 'foo'))

Promise.all([p1, p2, p3]).then(values => {
  console.log(values) // [3, 42, "foo"]
})

// Promise.race - 返回最先完成的 Promise
const promise1 = new Promise(resolve => setTimeout(resolve, 500, 'one'))
const promise2 = new Promise(resolve => setTimeout(resolve, 100, 'two'))

Promise.race([promise1, promise2]).then(value => {
  console.log(value) // "two" (因为 promise2 更快)
})

// Promise.allSettled - 等待所有 Promise 完成(无论成功失败)
const promises = [
  Promise.resolve('成功1'),
  Promise.reject('失败2'),
  Promise.resolve('成功3')
]

Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

// Promise.any - 返回第一个成功的 Promise
const pErr = new Promise((resolve, reject) => reject('总是失败'))
const pSlow = new Promise(resolve => setTimeout(resolve, 500, '最终完成'))
const pFast = new Promise(resolve => setTimeout(resolve, 100, '很快完成'))

Promise.any([pErr, pSlow, pFast]).then(value => {
  console.log(value) // "很快完成"
})

四、Async/Await —— 同步方式的异步编程

4.1 基本语法

Async/Await 是 ES2017 引入的语法糖,让异步代码看起来像同步代码。

// async 函数返回一个 Promise
async function getData() {
  return '数据'
}

getData().then(result => console.log(result)) // 数据

// 使用 await 等待 Promise 完成
async function fetchUserData() {
  try {
    const user = await getUserInfo()
    console.log('用户:', user)
    
    const orders = await getOrderList(user.id)
    console.log('订单:', orders)
    
    const detail = await getOrderDetail(orders[0].id)
    console.log('详情:', detail)
    
    const product = await getProductInfo(detail.productId)
    console.log('商品:', product)
    
    return product
  } catch (error) {
    console.error('出错了:', error)
  }
}

// 调用 async 函数
fetchUserData().then(result => {
  console.log('最终结果:', result)
})

4.2 实战示例:模拟数据请求

// 模拟 API 请求函数
const mockAPI = (url, delay = 1000) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.1) { // 90% 成功率
        resolve({
          status: 200,
          data: { url, timestamp: Date.now() }
        })
      } else {
        reject(new Error(`请求 ${url} 失败`))
      }
    }, delay)
  })
}

// 使用 async/await 实现并发请求
async function fetchMultipleData() {
  try {
    // 并发请求
    const [userData, productData, orderData] = await Promise.all([
      mockAPI('/api/user', 800),
      mockAPI('/api/product', 1200),
      mockAPI('/api/order', 600)
    ])
    
    console.log('所有数据加载完成:')
    console.log('用户数据:', userData.data)
    console.log('商品数据:', productData.data)
    console.log('订单数据:', orderData.data)
    
    return { userData, productData, orderData }
  } catch (error) {
    console.error('数据加载失败:', error.message)
  }
}

// 串行请求(依赖关系)
async function fetchDependentData() {
  console.time('串行请求耗时')
  
  const user = await mockAPI('/api/user', 1000)
  console.log('第一步完成:', user.data)
  
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  console.log('第二步完成:', orders.data)
  
  const details = await mockAPI(`/api/orders/${orders.data.url}/details`, 1000)
  console.log('第三步完成:', details.data)
  
  console.timeEnd('串行请求耗时')
  // 总耗时约 3000ms
}

// 优化:并行处理不依赖的数据
async function fetchOptimizedData() {
  console.time('优化后耗时')
  
  // 同时发起两个独立请求
  const [user, products] = await Promise.all([
    mockAPI('/api/user', 1000),
    mockAPI('/api/products', 1000)
  ])
  
  console.log('用户和商品数据已获取')
  
  // 依赖用户数据的请求
  const orders = await mockAPI(`/api/user/${user.data.url}/orders`, 1000)
  
  // 可以并行处理的请求
  const [detail1, detail2] = await Promise.all([
    mockAPI(`/api/orders/${orders.data.url}/detail1`, 500),
    mockAPI(`/api/orders/${orders.data.url}/detail2`, 500)
  ])
  
  console.timeEnd('优化后耗时')
  // 总耗时约 2500ms
}

4.3 错误处理最佳实践

// 统一的错误处理函数
const handleAsyncError = (asyncFn) => {
  return async (...args) => {
    try {
      return [await asyncFn(...args), null]
    } catch (error) {
      return [null, error]
    }
  }
}

// 使用错误处理包装器
const safeFetchUser = handleAsyncError(fetchUserData)

async function main() {
  const [user, error] = await safeFetchUser()
  
  if (error) {
    console.error('操作失败:', error.message)
    return
  }
  
  console.log('操作成功:', user)
}

// 带超时的 Promise
function withTimeout(promise, timeout = 5000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), timeout)
  })
  
  return Promise.race([promise, timeoutPromise])
}

async function fetchWithTimeout() {
  try {
    const result = await withTimeout(mockAPI('/api/data', 3000), 2000)
    console.log('数据:', result)
  } catch (error) {
    console.error('超时或失败:', error.message)
  }
}

五、手写实现(面试高频)

5.1 手写 Promise

class MyPromise {
  constructor(executor) {
    this.state = 'pending'
    this.value = undefined
    this.reason = undefined
    this.onFulfilledCallbacks = []
    this.onRejectedCallbacks = []

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled'
        this.value = value
        this.onFulfilledCallbacks.forEach(fn => fn())
      }
    }

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected'
        this.reason = reason
        this.onRejectedCallbacks.forEach(fn => fn())
      }
    }

    try {
      executor(resolve, reject)
    } catch (error) {
      reject(error)
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value
    onRejected = typeof onRejected === 'function' ? onRejected : error => { throw error }

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      }

      if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })

        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason)
              this.resolvePromise(promise2, x, resolve, reject)
            } catch (error) {
              reject(error)
            }
          }, 0)
        })
      }
    })

    return promise2
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      reject(new TypeError('Chaining cycle detected'))
    }

    if (x && (typeof x === 'object' || typeof x === 'function')) {
      let called = false
      try {
        const then = x.then
        if (typeof then === 'function') {
          then.call(
            x,
            y => {
              if (called) return
              called = true
              this.resolvePromise(promise2, y, resolve, reject)
            },
            error => {
              if (called) return
              called = true
              reject(error)
            }
          )
        } else {
          resolve(x)
        }
      } catch (error) {
        if (called) return
        called = true
        reject(error)
      }
    } else {
      resolve(x)
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected)
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value
    return new MyPromise(resolve => resolve(value))
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason))
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const result = []
      let count = 0
      
      for (let i = 0; i < promises.length; i++) {
        MyPromise.resolve(promises[i]).then(
          value => {
            result[i] = value
            count++
            if (count === promises.length) resolve(result)
          },
          reject
        )
      }
    })
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      for (const promise of promises) {
        MyPromise.resolve(promise).then(resolve, reject)
      }
    })
  }
}

5.2 手写 async/await 的简单实现

// 使用 Generator 模拟 async/await
function asyncToGenerator(generatorFn) {
  return function() {
    const gen = generatorFn.apply(this, arguments)
    
    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result
        try {
          result = gen[key](arg)
        } catch (error) {
          reject(error)
          return
        }
        
        const { value, done } = result
        
        if (done) {
          resolve(value)
        } else {
          Promise.resolve(value).then(
            val => step('next', val),
            err => step('throw', err)
          )
        }
      }
      
      step('next')
    })
  }
}

// 使用示例
const fetchData = function() {
  return new Promise(resolve => {
    setTimeout(() => resolve('数据'), 1000)
  })
}

const getData = asyncToGenerator(function* () {
  const data1 = yield fetchData()
  console.log('data1:', data1)
  
  const data2 = yield fetchData()
  console.log('data2:', data2)
  
  return '完成'
})

getData().then(result => console.log(result))

六、面试高频题

6.1 输出顺序题

// 题目1
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
console.log('4')

// 输出:1, 4, 3, 2
// 解释:同步代码先执行,微任务(Promise)先于宏任务(setTimeout)

// 题目2
async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2')
}

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

6.2 错误处理题

// 题目:如何捕获 async/await 的错误?
async function getData() {
  try {
    const data = await Promise.reject('出错了')
    console.log(data)
  } catch (error) {
    console.log('捕获到:', error)
  }
}

// 或使用 .catch
async function getData2() {
  const data = await Promise.reject('出错了').catch(err => {
    console.log('处理错误:', err)
    return '默认值'
  })
  console.log(data) // 默认值
}

// 题目:Promise.all 的错误处理
const promises = [
  Promise.resolve(1),
  Promise.reject('错误'),
  Promise.resolve(3)
]

Promise.all(promises)
  .then(console.log)
  .catch(console.error) // 输出:错误

// 如何让 Promise.all 即使有错误也返回所有结果?
Promise.allSettled(promises).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value)
    } else {
      console.log('失败:', result.reason)
    }
  })
})

6.3 并发控制题

// 题目:实现一个并发控制器,限制同时执行的 Promise 数量
class PromiseQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }
  
  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this.run()
    })
  }
  
  run() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      this.running++
      
      Promise.resolve(task())
        .then(resolve, reject)
        .finally(() => {
          this.running--
          this.run()
        })
    }
  }
}

// 使用示例
const queue = new PromiseQueue(2)

for (let i = 0; i < 5; i++) {
  queue.add(() => 
    new Promise(resolve => {
      setTimeout(() => {
        console.log(`任务${i}完成`)
        resolve(i)
      }, 1000)
    })
  )
}
// 每2个任务并行执行

6.4 重试机制题

// 题目:实现一个函数,请求失败时自动重试
async function retryRequest(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      console.log(`第${i + 1}次尝试`)
      const result = await fn()
      console.log('请求成功')
      return result
    } catch (error) {
      console.log(`第${i + 1}次失败`)
      if (i === maxRetries - 1) {
        throw error
      }
      // 等待延迟时间后重试
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

// 使用示例
let attempt = 0
const request = () => {
  return new Promise((resolve, reject) => {
    attempt++
    if (attempt < 3) {
      reject('模拟失败')
    } else {
      resolve('成功')
    }
  })
}

retryRequest(request, 3, 1000)
  .then(console.log)
  .catch(console.error)

七、总结与建议

7.1 异步编程演进

  • 回调函数:基础但容易形成"回调地狱"
  • Promise:链式调用,错误统一处理
  • Async/Await:语法糖,代码更直观

7.2 使用建议

  1. 优先使用 async/await,代码更清晰
  2. 并发请求使用 Promise.all,提高性能
  3. 注意错误处理,不要吞掉错误
  4. 避免回调地狱,及时重构代码
  5. 理解事件循环,掌握执行顺序

7.3 面试准备

  • 掌握三种异步方案的原理和用法
  • 能够手写简单的 Promise
  • 理解宏任务和微任务的执行顺序
  • 熟悉常见的异步编程场景和解决方案
  • 能够处理并发控制和错误重试

异步编程是 JavaScript 的核心特性,掌握好这块内容不仅对面试有帮助,更能提升实际开发中的代码质量。

手写一个无限画布 #3:如何在Canvas 层上建立事件体系

作者 光头老石
2026年2月27日 10:29

你以为你点中了一个圆,其实你只是点中了一堆毫无意义的像素点。在画布里,所谓的“选中”,不过是一场精密的数学与色彩幻术。

上一篇我们终于搞定了渲染层,并明确选择了 Konva (Canvas 2D) 作为我们的底层渲染基石。现在,我们的屏幕上终于可以丝滑地渲染出极具表现力的图形了。

但是,当你试图把鼠标悬停在其中一个图形上,或者想拖拽一条连线时,你会遭遇一个巨大的反直觉打击:浏览器完全不知道你点的是什么。

在传统的前端开发中,原生 DOM 是一棵界限分明的树。鼠标移入一个 <div>,浏览器引擎会在底层自动做碰撞检测,并把 mouseenterclick 事件准确无误地派发给这个节点。如果你给 <div> 加了圆角(border-radius)甚至复杂的 clip-path,浏览器依然能完美识别出精确的边缘。这种体验太理所当然,以至于我们从未思考过背后的代价。

但在 Canvas 的世界里,这套秩序完全失效了。

对于浏览器来说,不管你在 Canvas 里画了多少个圆圈、多复杂的文字,它看到的永远只有一个扁平的 <canvas> 标签。 当用户点击屏幕时,浏览器的原生 Event 对象只能递给你一个冷冰冰的坐标:{ clientX: 500, clientY: 400 }。至于这个坐标下是空气、是红色正方形,还是三个交叠在一起的半透明多边形,对不起,只能你自己算。

要在毫无知觉的像素油盆上,重新赋予图形被“感知”的能力,这就是 命中测试(Hit Testing) 的核心命题。

直觉陷阱:纯算几何碰撞

面对这个问题,多数人脑海里冒出的第一个念头一定是算数学题。

“既然我知道画布上每个方块的长宽、每个圆的半径,那鼠标点下去的时候,去遍历所有图形做个碰撞测试不就好了?”

比如点矩形,就看鼠标坐标是不是在它的上下左右边界内;点圆,就算勾股定理看距离是不是小于半径;如果是多边形,大不了掏出大学计算机图形学里教的“射线法(Ray-Casting)”,看看射线和多边形交点是奇数还是偶数。

在很多游戏开发新手教程里,这确实是讲解命中测试的第一课。

但只要你真的在业务里动手写过,就会立刻体会到这种朴素算法带来的“工程绝望”:

如果是最基础的方块和圆还好,可你在白板工具(如 Excalidraw / Figma)里,最常面对的是用户鼠标画出的一条粗细不均、极度扭曲的自由手绘墨迹(Freehand Draw)。成百上千个点连出来的畸形曲线,你拿什么算交点?

即使你咬着牙把每根线段都算了,还有图形的中空与穿透问题。当用户点在一个空心圆环的正中间,或者字母 "O" 的空白处时,根据最粗糙的外围包围盒(Bounding Box),它是被命中的;但这根本违反了用户“我明明点在透明的地方,我想点它背后元素”的心理预期。哪怕你真算出了鼠标确实落在图形线条上,你又怎么确保,这层图形的正上方,没有被另一个半透明的阴影盖住呢?

别忘了最绝杀的性能噩梦。不仅是点击,鼠标每在屏幕上划过一个像素,就会高频触发 mousemove。如果同屏有几千个杂乱的图形交叠,每移动一毫米就要把所有多边形的射线方程重新算一遍,你的 CPU 风扇会直接起飞,页面帧率瞬间崩盘。

想靠纯写 if-else 的几何穷举来搞定一个不仅带各种圆角、线宽、自交错,还带层级遮挡的生产级别白板交互,可以说是直接在给 CPU 判死刑。


优雅的黑魔法:离屏 Canvas 与 Color Picking

针对纯正向几何数学算不通的情况,业界的顶级绘图引擎往往会使用一招极度聪明且优雅的逆向黑魔法:利用颜色查表法(Color Picking)。这也是 Konva 最为核心的看家本领机制。

hit-test-color-picking.png

它的核心逻辑堪称“暗度陈仓”,分为以下几个精妙的步骤:

1. 建立影分身(Hidden Canvas)

在内存中,创建一个跟主屏幕尺寸完全一致的隐藏 Canvas(用户看不见它)。主屏幕负责渲染展现给用户看的漂亮图形,而这个“影分身”只专门用来做苦力——命中测试。

2. 分配身份色(Color Hash)

当我们要往主屏幕画一个崭新的图形(比如一个带有高斯模糊阴影的蓝色虚线圈)时,引擎会在内存里给这个图形分配一个全局唯一、随机生成的 RGB 颜色值(比如 #000001)。 然后在内存的隐藏 Canvas 的同样坐标处,用这个唯一颜色 #000001 画一个同样轮廓的圆。无论主画布上的圆有多花哨,隐藏画布上的圆统统画成没有阴影、没有抗锯齿的纯色实心/实线

与此同时,维护一个字典(Hash Map),记录:#000001 映射到 蓝色虚线图对象引用

3. O(1) 的降维打击:只读一个像素

见证奇迹的时刻到了。 当前的场景是:主画布上画了成千上万个复杂的图形。隐藏画布上也用同样的布局画了成千上万个纯粹色块。

当用户在主屏幕上点击 (x: 500, y: 400) 时,引擎不去做任何数学几何碰撞计算,除了获取坐标外只做极其底层的一步:

  1. 走到隐藏 Canvas 面前。
  2. 精确地读取它 (500, 400) 这个坐标点上的 1 个像素的 RGB 颜色值getImageData)。
  3. 如果读出来的颜色是黑色(完全透明),说明没点中任何东西。
  4. 如果读出来的颜色是 #000001,引擎立刻去 Hash Map 里查表——破案了!对应的是那个蓝色的虚线圈对象。

为什么这个方案是统治级的?

  1. 彻底无视几何形状的难度。不管你画的是自由手绘还是残缺的文字轮廓,只要它被渲染引擎画在屏幕上,那对应的颜色像素就实打实地落在了隐藏画布上。它巧妙地利用底层的 GPU 渲染规则来替你完成极度复杂的轮廓光栅化判定。
  2. 天然解决重叠遮挡。主画布怎么叠加层级的,隐藏画布也是按同样顺序绘制的。你在隐藏画布上读出来的那个带颜色像素,必然是最顶层、没被别人遮挡的那个对象的颜色。完全不需要自己遍历判断层级。
  3. 极端的性能空间换时间。把原本复杂的 O(N×几何顶点数)O(N \times 几何顶点数) 的每帧遍历计算,直接降维成了读取内存图像一个单像素点的 O(1)O(1) 常数级查表时间。即使屏幕上有十万个对象,鼠标在上面疯狂移动也是绝对丝滑的。

站在巨人的肩膀:这就是 Konva

要在原生 Canvas 上实现一个可用于生产环境的稳健命中测试系统基建,工作量是极其庞大的。你要自己去维护那个巨大的离屏画布上下文同步、自己分配十六进制颜色、自己实现局部重绘优化、还要自己派发所有的模拟 DOM 冒泡事件。

这正是我们放弃从零手写引擎底层,转而选型采用 Konva 的终极原因。

Konva 在底层极其克制且优雅地封装了这套“离屏颜色拾取算法”。在开发者眼里,你完全感受不到那个诡异的“彩色隐藏画布”的存在。

它直接把这套脏活累活,包装成了我们最熟悉的、一如在写原生 DOM 一样的前端语法范式。这就让我们能够完全剥离繁复的数学几何泥潭,将精力投入在画布“事件分发与交互流控制”上:

// 这种久违的、确定的秩序感,对于开发无穷交互的白板来说是极其珍贵的。
import Konva from "konva";

const rect = new Konva.Rect({
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  fill: "blue",
  draggable: true, // 开启拖拽!底层所有复杂的变换全自动运算并重绘画布。
});

// 你仿佛重新拥有了原生的 DOM 事件绑定系统
rect.on("mouseenter", () => {
  document.body.style.cursor = "pointer";
  rect.fill("red"); // 悬浮触发变色响应
});

rect.on("mouseleave", () => {
  document.body.style.cursor = "default";
  rect.fill("blue");
});

// 即使有成百上千个图形交叠,它也能极速计算,精准捕捉顶层响应
rect.on("click", (e) => {
  console.log("极速且精准地点中了我:", e.target);
});

有了 Konva 兜底解决“感知盲区”,我们终于补齐了跨越无限画布最重要、也是最难缠的一块技术栈拼图。

我们不再是在冷冰冰的像素点数组上作画,而是真正在操控和编排一个个有边界、能响应手势、知晓自身存在的“实体对象”

经历三篇的文章,我们已经打通了从“坐标系”、“底层渲染引擎选型博弈”到“重建事件分发秩序”的全部技术基建。

接下来,我们将长驱直入应用数据的深水区:在这块充满感知能力的画布上,我们该如何用正确的数据结构来对这些可被协同、可被导出、可被反序列化的对象进行定义?

Vue3+Element Plus 通用表格组件封装与使用实践

作者 _AaronWong
2026年2月27日 10:27

在中后台项目开发中,表格是高频使用的核心组件,基于 Element Plus 的el-table封装通用表格组件,能够统一表格样式、简化重复代码、提升开发效率。本文将详细讲解一款通用表格组件的封装思路、完整实现及使用方式,该组件兼顾了通用性与灵活性,适配日常开发中的各类表格场景。

一、封装思路

本次封装的核心目标是打造一款「基础能力通用化、个性化配置灵活化」的表格组件:

  1. 抽离表格通用配置(如高度、高亮行、合并单元格方法)作为基础 Props;
  2. el-tableel-pagination的原生属性 / 事件通过透传方式交给父组件控制,保留原生组件的灵活性;
  3. 统一列渲染逻辑,支持自定义render函数实现复杂单元格内容展示;
  4. 整合表格标题、分页等常用元素,形成完整的表格模块。

二、通用表格组件完整实现(MineTable.vue)

<template>
    <el-card class="mine-table">
        <!-- 表格标题 -->
        <el-text class="table-name">{{ tableName }}</el-text>
        <!-- 核心表格容器 -->
        <el-table 
            ref="elTable" 
            class="base-table" 
            :highlight-current-row="currentRow" 
            :preserve-expanded-content="true" 
            :span-method="spanMethod"
            :data="data" 
            :height="height"
            v-bind="tableProps"   <!-- 透传el-table原生属性 -->
            v-on="tableEvents"    <!-- 透传el-table原生事件 -->
        >
            <el-table-column 
                v-for="(item, index) in columnsData" 
                :key="index" 
                v-bind="item"      <!-- 透传列配置属性 -->
            >
                <!-- 展开列自定义渲染 -->
                <template v-if="item.type === 'expand'" #default="scope">
                    <component :is="item.render" v-bind="scope"></component>
                </template>
            </el-table-column>
        </el-table>

        <!-- 分页组件 -->
        <el-pagination 
            class="base-pagination" 
            layout="total, sizes, prev, pager, next, jumper"
            :page-sizes="[5, 10, 20, 30, 40, 50]" 
            background
            v-bind="paginationProps"  <!-- 透传el-pagination原生属性 -->
            v-on="paginationEvents"   <!-- 透传el-pagination原生事件 -->
        />
    </el-card>
</template>

<script setup>
import { computed, ref } from "vue"

// 关闭默认属性透传,避免属性泄露到外层DOM节点
defineOptions({
    inheritAttrs: false
})

// 定义组件Props
const props = defineProps({
    // 表格基础配置
    tableName: { type: String, default: "", description: "表格标题" },
    currentRow: { type: Boolean, default: false, description: "是否高亮当前行" },
    height: { type: String, default: "60vh", description: "表格高度" },
    data: { type: Array, default: () => [], description: "表格数据源" },
    columns: { type: Array, default: () => [], description: "列配置项" },
    spanMethod: { type: Function, default: () => {}, description: "单元格合并方法" },
    
    // el-table原生属性透传(支持所有el-table属性)
    tableProps: { type: Object, default: () => ({}) },
    // el-table原生事件透传(支持所有el-table事件)
    tableEvents: { type: Object, default: () => ({}) },
    
    // el-pagination原生属性透传(支持所有el-pagination属性)
    paginationProps: { type: Object, default: () => ({}) },
    // el-pagination原生事件透传(支持所有el-pagination事件)
    paginationEvents: { type: Object, default: () => ({}) },
})

// 暴露表格Ref,方便父组件调用el-table的原生方法
const elTable = ref(null)
defineExpose({ elTable })

// 列数据格式化处理,统一支持render函数渲染
const columnsData = computed(() => {
    return props.columns.map(item => ({
        formatter: (row, column, cellValue, index) => formatter(item, row, column, cellValue, index),
        ...item
    }))
})

// 单元格内容格式化逻辑
const formatter = (item, row, column, cellValue, index) => {
    // 优先级:行数据中的render函数 > 列配置中的render函数 > 默认值
    if (row?.[column.property]?.render) {
        return row[column.property].render(row, column, cellValue, index)
    } else if (item?.render) {
        return item.render(row, column, cellValue, index)
    }
    return row[column.property]
}
</script>

<style lang="scss" scoped>
.mine-table {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    
    .table-name {
        font-size: 18px;
        font-weight: bold;
        margin-bottom: 12px;
        display: flex;
        align-items: center;
        &::after {
            content: "";
            width: 5px;
            height: 100%;
            background-color: var(--el-color-primary);
            margin-right: 12px;
        }
    }

    .base-table {
        width: 100%;
        margin: 0 auto;
        min-width: 0;
        border: var(--el-table-border);
        border-radius: 4px;
    }

    .base-pagination {
        margin-top: 12px;
    }
}
</style>

核心封装点说明

  1. 属性 / 事件透传:通过tableProps/tableEventspaginationProps/paginationEvents分别透传el-tableel-pagination的原生属性与事件,既保留了原生组件的全部能力,又无需在组件内重复定义中转逻辑。
  2. 统一列渲染:封装了formatter函数,支持两种自定义渲染方式 —— 列配置中的render函数、行数据中的render函数,满足复杂单元格的展示需求。
  3. 基础样式整合:内置了表格标题、表格容器、分页的统一样式,无需在业务页面重复编写样式代码。
  4. Ref 暴露:将el-table的 Ref 暴露给父组件,方便调用clearSelectiontoggleRowSelection等原生方法。

三、组件使用示例

1. 基础使用(仅核心配置)

这是最常用的场景,只需配置表格数据、列配置、基础样式即可:

<template>
  <div class="demo-container">
    <!-- 通用表格组件使用 -->
    <MineTable
      height="200px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
    />
  </div>
</template>

<script setup>
import { ref } from "vue"
import MineTable from "@/components/MineTable.vue"
import { ElMessage, ElPopconfirm, ElButton, ElText } from "element-plus"

// 表格数据源
const tableData = ref([
  { id: 1, name: "张三", email: "zhangsan@example.com" },
  { id: 2, name: "李四", email: "lisi@example.com" },
  { id: 3, name: "王五", email: "wangwu@example.com" }
])

// 列配置项
const tableColumns = ref([
  { type: "index", label: "序号", width: 80 }, // 序号列
  {
    label: "用户名称",
    prop: "name",
    // 自定义单元格渲染
    render: (row) => <ElText type="primary">{row.name}</ElText>
  },
  {
    label: "操作",
    width: 100,
    // 操作列:带确认弹窗的删除按钮
    render: (row) => {
      const deleteUser = () => {
        // 模拟删除逻辑
        tableData.value = tableData.value.filter(item => item.id !== row.id)
        ElMessage.success(`已删除用户:${row.name}`)
      }

      return (
        <ElPopconfirm 
          title="确定删除吗?" 
          onConfirm={deleteUser}
          confirmButtonText="确定" 
          cancelButtonText="取消"
          v-slots={{
            reference: () => <ElButton type="danger" size="small" link>删除</ElButton>
          }}
        />
      )
    }
  }
])
</script>

<style scoped>
.demo-container {
  width: 800px;
  margin: 20px auto;
}
</style>

2. 进阶使用(透传原生属性 / 事件)

如果需要使用el-tableel-pagination的原生能力(如斑马纹、行点击事件、分页回调等),可通过透传 Props 实现:

<template>
  <div class="demo-container">
    <MineTable
      height="300px"
      tableName="用户列表"
      :data="tableData"
      :columns="tableColumns"
      <!-- 透传el-table原生属性 -->
      :table-props="{
        border: true,        // 显示表格边框
        stripe: true,        // 斑马纹效果
        showHeader: true     // 显示表头
      }"
      <!-- 透传el-table原生事件 -->
      :table-events="{
        'row-click': (row) => ElMessage.info(`点击了${row.name}的行`), // 行点击事件
        'sort-change': (val) => console.log('排序变更:', val)       // 排序变更事件
      }"
      <!-- 透传el-pagination原生属性 -->
      :pagination-props="{
        currentPage: 1,      // 当前页码
        pageSize: 10,        // 每页条数
        total: 100           // 总条数
      }"
      <!-- 透传el-pagination原生事件 -->
      :pagination-events="{
        'size-change': (size) => console.log('每页条数变更:', size), // 页大小变更
        'current-change': (page) => console.log('页码变更:', page)   // 页码变更
      }"
    />
  </div>
</template>

四、总结

本次封装的通用表格组件具备以下特点:

  1. 通用性强:整合了表格标题、分页等常用元素,统一了基础样式和渲染逻辑;
  2. 灵活性高:通过属性 / 事件透传,保留了 Element Plus 原生组件的全部能力,适配各类个性化需求;
  3. 易用性好:使用方式简洁,基础场景只需配置数据和列,进阶场景可透传原生属性 / 事件;
  4. 可扩展:在此基础上可进一步扩展空状态、加载状态、列宽自适应等通用能力,适配更多业务场景。

该组件能够有效减少中后台项目中表格相关的重复代码,提升开发效率,同时保持了足够的灵活性,满足不同业务场景的个性化需求。

Diff算法基础:同层比较与key的作用

作者 wuhen_n
2026年2月27日 09:37

在上一篇文章中,我们深入探讨了 patch 算法的完整实现。今天,我们将聚焦于 Diff 算法的核心思想——为什么需要它?它如何工作?key 又为什么如此重要?通过这篇文章,我们将彻底理解 Diff 算法的基础原理。

前言:从生活中的例子理解Diff

想象一下,假如我们有一排积木:

A B C D

然后我们想把它变成这样:

A C D B

这时,我们应该怎么做呢?

  • 方式一:全部推倒重来:移除所有,按照我们想要的顺序重新摆放

  • 方式二:只调整变化的部分:移动位置,替换积木,即:我们只需要调整 B C D 三块积木的位置即可。

很显然,方式二的做法更高效。这就是 Diff 算法的本质——找出最小化的更新方案。

为什么需要 Diff 算法?

没有 Diff 算法会怎样?

假设我们有一个简单的列表:

<!-- 旧列表 -->
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>橙子</li>
</ul>

<!-- 新列表(只改了最后一个) -->
<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>橘子</li>
</ul>

上述两个列表中,新列表只改了最后一项数据,如果没有 Diff 算法,我们只能按照 前言 中的方式一处理:删除整个 ul,重新创建:

const oldUl = document.querySelector('ul');
oldUl.remove();

const newUl = document.createElement('ul');
newUl.innerHTML = `
  <li>苹果</li>
  <li>香蕉</li>
  <li>橘子</li>
`;
container.appendChild(newUl);

这种方式虽然可以解决问题,但存在很大的风险:

  1. 性能极差:即使只改一个字,也要重建整个 DOM 树
  2. 状态丢失:输入框内容、滚动位置都会丢失
  3. 浪费资源:创建了大量不必要的 DOM 节点

此时 Diff 算法的重要性就凸显出来了!

Diff 算法的目标

Diff 算法的核心目标可以概括为三点:

  1. 尽可能复用已有节点
  2. 只更新变化的部分
  3. 最小化 DOM 操作

还是以上述 ul 结构为例,理想中的 Diff 操作应该是:

  1. 更新第三个 li 的文本内容:将 <li>橙子</li> 替换成 <li>橘子</li>
  2. 其他节点完全复用,不作任何更改

传统 Diff 算法

function diff(oldList, newList){
  for(let i = 0; i < oldList.length; i++){
    for(let j = 0; j < newList.length; j++){
      if(oldList[i] === newList[j]){
        // 找到相同的节点,进行复用
        console.log('找到了相同的节点', oldList[i]);
        break;
      } else {
        // 没找到相同的节点,进行新增
        console.log('需要新增节点', newList[j]);
      }
    }
  }
}

上述代码的时间复杂度为:O(n²);如果再考虑到移动、删除、新增等操作,其时间复杂度可以达到:O(n³)。这显然是不合理的。

同层比较的核心思想

为了解决传统 Diff 算法的时间复杂度问题,Vue 团队通过两个关键思想,将 Diff 算法的时间复杂降低到了:O(n):

  1. 同层比较,即只比较同一层级的节点
  2. 类型相同,即不同类型节点直接替换

什么是同层比较?

同层比较的意思是:只比较同一层级的节点,不跨层级移动。 我们来看一个简单的例子: 同层比较 上图两个新旧 VNode 树中,对比过程是这样的: 同层比较示例图

为什么不跨层级比较?

我们可以再来一个更复杂的示例:

<!-- 旧列表 -->
<ul>
  <li>li-1</li>
  <li>li-2</li>
  <li>
    <span>
      <a>
        li-3
      </a>
    </span>
  </li>
</ul>

<!-- 新列表 -->
<ul>
  <li>li-1</li>
  <li>li-2</li>
  <li>
    <a>
      li-3
    </a>
  </li>
</ul>

假设新旧两个列表是这样的,如果支持跨层级比较和移动,那么上述列表应该进行如下操作:

  1. 发现旧列表中 a 标签位于 span 标签下,新列表中直接位于 li 标签下;
  2. 记录这个操作差异,保存 a 标签,删除 span 标签,再把 a 标签挂载到 li 标签下;
  3. 更新父子节点关系。

这种操作会让算法变得极其复杂,而且实际开发中,跨层级移动节点的情况非常罕见。所以 Vue 选择简化问题:如果节点跨层级了,就视为不同类型,直接替换。

function patch(oldVNode, newVNode) {
  // 如果节点类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    unmount(oldVNode);
    mount(newVNode);
    return;
  }
  
  // 同类型节点,进行深度比较
  patchChildren(oldVNode, newVNode);
}

同层比较的优势

优势 说明 示例
算法简单 只需要比较同一层 树形结构简化为线性比较
性能可控 复杂度O(n) 1000个节点只需比较1000次
实现可靠 边界情况少 不需要处理复杂移动

key在节点复用中的作用

为什么需要key?

我们来看一个简单的代办列表:

<!-- 旧列表 -->
<li>学习Vue</li>
<li>写文章</li>
<li>休息一下</li>

<!-- 新列表(删除了中间项 写文章) -->
<li>学习Vue</li>
<li>休息一下</li>

如果没有 key,Vue 会如何进行 diff 比较呢:

  1. 比较位置0:都是"学习Vue",直接复用;
  2. 比较位置1:旧的是"写文章",新的是"休息一下" ,更新文本进行替换
  3. 比较位置2:旧的有"休息一下",新的没有,则删除

这样操作过程中,更新了一个 li 的文本,删除了一个 li 。 这个过程看起来是没有问题的,但是如果上述列表有状态呢?

<!-- 带输入框的列表 -->
<li>
  <input value="学习Vue" />
  学习Vue
</li>
<li>
  <input value="写文章" />
  写文章
</li>
<li>
  <input value="休息一下" />
  休息一下
</li>

<!-- 删除中间项后 -->
<li>
  <input value="学习Vue" />  <!-- 输入框内容被保留了 -->
  学习Vue
</li>
<li>
  <input value="休息一下" />  <!-- 这里会是"休息一下"吗? -->
  休息一下
</li>

这时候问题就出现了:输入框的内容被错误地复用了!由于没有 key 的情况下,Vue 只按位置比较,最后的实际结果是:

<li>
  <input value="学习Vue" />  <!-- 输入框内容被保留了 -->
  学习Vue
</li>
<li>
  <input value="写文章" />  <!-- label变成了"写文章" -->
  休息一下
</li>

这个例子也同样解释了为什么不推荐,或者说不能用 index 作为 key 的原因。正确的做法是使用唯一的、稳定的标识作为 key。

key的作用图解

key的作用可以这样理解: key的作用图解

手写实现:简单Diff算法

class SimpleDiff {
  constructor(options) {
    this.options = options;
  }
  
  /**
   * 执行diff更新
   * @param {Array} oldChildren 旧子节点数组
   * @param {Array} newChildren 新子节点数组
   * @param {HTMLElement} container 父容器
   */
  diff(oldChildren, newChildren, container) {
    // 1. 创建key到索引的映射(如果有key)
    const oldKeyMap = this.createKeyMap(oldChildren);
    const newKeyMap = this.createKeyMap(newChildren);
    
    // 2. 记录已处理的节点
    const processed = new Set();
    
    // 3. 第一轮:尝试复用有key的节点
    this.patchKeyedNodes(oldChildren, newChildren, oldKeyMap, newKeyMap, processed, container);
    
    // 4. 第二轮:处理剩余节点
    this.processRemainingNodes(oldChildren, newChildren, processed, container);
  }
  
  /**
   * 创建key到索引的映射
   */
  createKeyMap(children) {
    const map = new Map();
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      if (child.key != null) {
        map.set(child.key, i);
      }
    }
    return map;
  }
  
  /**
   * 处理有key的节点
   */
  patchKeyedNodes(oldChildren, newChildren, oldKeyMap, newKeyMap, processed, container) {
    // 遍历新节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i];
      
      // 如果新节点没有key,跳过第一轮处理
      if (newVNode.key == null) continue;
      
      // 尝试在旧节点中找相同key的节点
      const oldIndex = oldKeyMap.get(newVNode.key);
      
      if (oldIndex !== undefined) {
        const oldVNode = oldChildren[oldIndex];
        
        // 标记为已处理
        processed.add(oldIndex);
        
        // 执行patch更新
        this.patchVNode(oldVNode, newVNode, container);
      } else {
        // 没有找到对应key,说明是新增节点
        this.mountVNode(newVNode, container);
      }
    }
  }
  
  /**
   * 处理剩余节点
   */
  processRemainingNodes(oldChildren, newChildren, processed, container) {
    // 1. 卸载未处理的旧节点
    for (let i = 0; i < oldChildren.length; i++) {
      if (!processed.has(i)) {
        this.unmountVNode(oldChildren[i]);
      }
    }
    
    // 2. 挂载新节点中未处理的节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i];
      
      // 如果没有key或者key不在旧节点中,需要挂载
      if (newVNode.key == null) {
        this.mountVNode(newVNode, container);
      } else {
        const oldIndex = oldChildren.findIndex(old => old.key === newVNode.key);
        if (oldIndex === -1) {
          this.mountVNode(newVNode, container);
        }
      }
    }
  }
  
  /**
   * 更新节点
   */
  patchVNode(oldVNode, newVNode, container) {
    console.log(`更新节点: ${oldVNode.key || '无key'}`);
    
    // 复用DOM元素
    newVNode.el = oldVNode.el;
    
    // 更新属性
    this.updateProps(newVNode.el, oldVNode.props, newVNode.props);
    
    // 更新子节点
    if (newVNode.children !== oldVNode.children) {
      newVNode.el.textContent = newVNode.children;
    }
  }
  
  /**
   * 挂载新节点
   */
  mountVNode(vnode, container) {
    console.log(`挂载新节点: ${vnode.key || '无key'}`);
    
    // 创建DOM元素
    const el = document.createElement(vnode.type);
    vnode.el = el;
    
    // 设置属性
    this.updateProps(el, {}, vnode.props);
    
    // 设置内容
    if (vnode.children) {
      el.textContent = vnode.children;
    }
    
    // 插入到容器
    container.appendChild(el);
  }
  
  /**
   * 卸载节点
   */
  unmountVNode(vnode) {
    console.log(`卸载节点: ${vnode.key || '无key'}`);
    if (vnode.el && vnode.el.parentNode) {
      vnode.el.parentNode.removeChild(vnode.el);
    }
  }
  
  /**
   * 更新属性
   */
  updateProps(el, oldProps = {}, newProps = {}) {
    // 移除不存在的属性
    for (const key in oldProps) {
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }
    
    // 设置新属性
    for (const key in newProps) {
      if (oldProps[key] !== newProps[key]) {
        el.setAttribute(key, newProps[key]);
      }
    }
  }
}

// 创建VNode的辅助函数
function h(type, props = {}, children = '') {
  return {
    type,
    props,
    key: props.key,
    children,
    el: null
  };
}

结语

理解 Diff 算法的基础原理,就像掌握了Vue 更新 DOM 的"思维模式"。知道它如何思考、如何决策,才能写出与框架配合最好的代码。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

patch算法:新旧节点的比对与更新

作者 wuhen_n
2026年2月27日 09:33

在前面的文章中,我们深入探讨了虚拟 DOM 的创建和组件的挂载过程。当数据变化时,Vue 需要高效地更新 DOM。这个过程的核心就是 patch 算法——新旧虚拟 DOM 的比对与更新策略。本文将带你深入理解 Vue3 的 patch 算法,看看它如何以最小的代价完成 DOM 更新。

前言:为什么需要patch?

想象一下,你有一个展示用户列表的页面。当某个用户的名字改变时,我们会怎么做?

  • 粗暴方式:重新渲染整个列表(性能差)
  • 聪明方式:只更新那个改变的用户名(性能好)

patch 算法就是 Vue 采用的"聪明方式"。它的核心思想是:找出新旧 VNode 的差异,只更新变化的部分,而不是重新渲染整个 DOM 树:

patch 过程图

patch函数的核心逻辑

patch的整体架构

patch 函数是整个更新过程的总调度器,它根据节点类型分发到不同的处理函数:

function patch(oldVNode, newVNode, container, anchor = null) {
  // 如果是同一个引用,无需更新
  if (oldVNode === newVNode) return;
  
  // 如果类型不同,直接替换
  if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
    unmount(oldVNode);
    oldVNode = null;
  }
  
  const { type, shapeFlag } = newVNode;
  
  // 根据类型分发处理
  switch (type) {
    case Text:
      processText(oldVNode, newVNode, container, anchor);
      break;
    case Comment:
      processComment(oldVNode, newVNode, container, anchor);
      break;
    case Fragment:
      processFragment(oldVNode, newVNode, container, anchor);
      break;
    case Static:
      processStatic(oldVNode, newVNode, container, anchor);
      break;
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.TELEPORT) {
        processTeleport(oldVNode, newVNode, container, anchor);
      } else if (shapeFlag & ShapeFlags.SUSPENSE) {
        processSuspense(oldVNode, newVNode, container, anchor);
      }
  }
}

patch 的分发流程图

patch的分发流程图

判断节点类型的关键:isSameVNodeType

function isSameVNodeType(n1, n2) {
  // 比较类型和key
  return n1.type === n2.type && n1.key === n2.key;
}

为什么需要key?

我们看看下面的例子:

<!-- 旧列表 -->
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>

<!-- 新列表 -->
<li key="a">A</li>
<li key="c">C</li>
<li key="b">B</li>

<!-- 有key: 只移动节点,不重新创建 -->
<!-- 无key: 全部重新创建,性能差 -->

不同类型节点的处理策略

文本节点的处理

文本节点是最简单的节点类型,处理逻辑也最直接:

function processText(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    const textNode = document.createTextNode(newVNode.children);
    newVNode.el = textNode;
    container.insertBefore(textNode, anchor);
  } else {
    // 更新
    const el = (newVNode.el = oldVNode.el);
    if (newVNode.children !== oldVNode.children) {
      // 只有文本变化时才更新
      el.nodeValue = newVNode.children;
    }
  }
}

文本节点更新过程

文本节点更新过程

注释节点的处理

注释节点基本不需要更新,因为用户通常不关心注释的变化:

function processComment(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    const commentNode = document.createComment(newVNode.children);
    newVNode.el = commentNode;
    container.insertBefore(commentNode, anchor);
  } else {
    // 注释节点很少变化,直接复用
    newVNode.el = oldVNode.el;
  }
}

元素节点的处理

元素节点的更新是最复杂的,需要处理属性和子节点:

function processElement(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    mountElement(newVNode, container, anchor);
  } else {
    // 更新
    patchElement(oldVNode, newVNode);
  }
}

function patchElement(oldVNode, newVNode) {
  const el = (newVNode.el = oldVNode.el);
  
  // 1. 更新props
  patchProps(el, oldVNode.props, newVNode.props);
  
  // 2. 更新children
  patchChildren(oldVNode, newVNode, el);
}

function patchProps(el, oldProps, newProps) {
  oldProps = oldProps || {};
  newProps = newProps || {};
  
  // 移除旧props中不存在于新props的属性
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProp(el, key, oldProps[key], null);
    }
  }
  
  // 添加或更新新props
  for (const key in newProps) {
    const old = oldProps[key];
    const next = newProps[key];
    if (old !== next) {
      patchProp(el, key, old, next);
    }
  }
}

子节点的比对策略

子节点的比对是 patch 算法中最复杂、也最关键的部分。Vue3 根据子节点的类型,采用不同的策略。

子节点类型组合的处理策略

下表总结了所有可能的子节点类型组合及对应的处理方式:

旧子节点 新子节点 处理策略 示例
文本 文本 直接替换文本内容 "old" → "new"
文本 数组 清空文本,挂载数组 "text" → [vnode1, vnode2]
文本 清空文本 "text" → null
数组 文本 卸载数组,设置文本 [vnode1, vnode2] → "text"
数组 数组 执行核心diff [a,b,c] → [a,d,e]
数组 卸载所有子节点 [a,b,c] → null
文本 设置文本 null → "text"
数组 挂载数组 null → [a,b,c]

当新旧节点都为数组时,需要执行 diff 算法,diff 算法的内容在后面的文章中会专门介绍。

Fragment和Text节点的特殊处理

Fragment的处理

Fragment 是 Vue3 新增的节点类型,用于支持多根节点:

function processFragment(oldVNode, newVNode, container, anchor) {
  if (oldVNode == null) {
    // 首次挂载
    mountFragment(newVNode, container, anchor);
  } else {
    // 更新
    patchFragment(oldVNode, newVNode, container, anchor);
  }
}

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    const textNode = document.createTextNode(children);
    vnode.el = textNode;
    container.insertBefore(textNode, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container, anchor);
    
    // 设置el和anchor
    vnode.el = children[0]?.el;
    vnode.anchor = children[children.length - 1]?.el;
  }
}

function patchFragment(oldVNode, newVNode, container, anchor) {
  const oldChildren = oldVNode.children;
  const newChildren = newVNode.children;
  
  // Fragment本身没有DOM,直接patch子节点
  patchChildren(oldVNode, newVNode, container);
  
  // 更新el和anchor
  if (Array.isArray(newChildren)) {
    newVNode.el = newChildren[0]?.el || oldVNode.el;
    newVNode.anchor = newChildren[newChildren.length - 1]?.el || oldVNode.anchor;
  }
}

文本节点的优化

Vue3 对纯文本节点做了特殊优化,避免不必要的 VNode 创建:

// 模板:<div>{{ message }}</div>
// 编译后:
function render(ctx) {
  return h('div', null, ctx.message, PatchFlags.TEXT);
}

// 在patch过程中:
if (newVNode.patchFlag & PatchFlags.TEXT) {
  // 只需要更新文本内容,不需要比较其他属性
  const el = oldVNode.el;
  if (newVNode.children !== oldVNode.children) {
    el.textContent = newVNode.children;
  }
  newVNode.el = el;
  return;
}

手写实现:完整的patch函数基础版本

基础工具函数

// 类型标志
const ShapeFlags = {
  ELEMENT: 1,
  FUNCTIONAL_COMPONENT: 1 << 1,
  STATEFUL_COMPONENT: 1 << 2,
  TEXT_CHILDREN: 1 << 3,
  ARRAY_CHILDREN: 1 << 4,
  SLOTS_CHILDREN: 1 << 5,
  TELEPORT: 1 << 6,
  SUSPENSE: 1 << 7,
  COMPONENT_SHOULD_KEEP_ALIVE: 1 << 8,
  COMPONENT_KEPT_ALIVE: 1 << 9
};

// 特殊节点类型
const Text = Symbol('Text');
const Comment = Symbol('Comment');
const Fragment = Symbol('Fragment');

// 判断是否同类型节点
function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

完整的patch函数

class Renderer {
  constructor(options) {
    this.options = options;
  }
  
  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    // 处理不同类型的节点
    if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
      this.unmount(oldVNode);
      oldVNode = null;
    }
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    switch (type) {
      case Text:
        this.processText(oldVNode, newVNode, container, anchor);
        break;
      case Comment:
        this.processComment(oldVNode, newVNode, container, anchor);
        break;
      case Fragment:
        this.processFragment(oldVNode, newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          this.processElement(oldVNode, newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          this.processComponent(oldVNode, newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          this.processTeleport(oldVNode, newVNode, container, anchor);
        }
    }
  }
  
  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      // 挂载
      this.mountElement(newVNode, container, anchor);
    } else {
      // 更新
      this.patchElement(oldVNode, newVNode);
    }
  }
  
  mountElement(vnode, container, anchor) {
    const { type, props, children, shapeFlag } = vnode;
    
    // 创建元素
    const el = this.options.createElement(type);
    vnode.el = el;
    
    // 设置属性
    if (props) {
      for (const key in props) {
        this.options.patchProp(el, key, null, props[key]);
      }
    }
    
    // 处理子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.options.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 插入
    this.options.insert(el, container, anchor);
  }
  
  patchElement(oldVNode, newVNode) {
    const el = (newVNode.el = oldVNode.el);
    const oldProps = oldVNode.props || {};
    const newProps = newVNode.props || {};
    
    // 更新属性
    this.patchProps(el, oldProps, newProps);
    
    // 更新子节点
    this.patchChildren(oldVNode, newVNode, el);
  }
  
  patchChildren(oldVNode, newVNode, container) {
    const oldChildren = oldVNode.children;
    const newChildren = newVNode.children;
    
    const oldShapeFlag = oldVNode.shapeFlag;
    const newShapeFlag = newVNode.shapeFlag;
    
    // 新子节点是文本
    if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.unmountChildren(oldChildren);
      }
      if (oldChildren !== newChildren) {
        this.options.setElementText(container, newChildren);
      }
    }
    // 新子节点是数组
    else if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (oldShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.options.setElementText(container, '');
        this.mountChildren(newChildren, container);
      } else if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.patchKeyedChildren(oldChildren, newChildren, container);
      }
    }
    // 新子节点为空
    else {
      if (oldShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.unmountChildren(oldChildren);
      } else if (oldShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.options.setElementText(container, '');
      }
    }
  }
  
  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.options.createText(newVNode.children);
      newVNode.el = textNode;
      this.options.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        this.options.setText(el, newVNode.children);
      }
    }
  }
  
  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountFragment(newVNode, container, anchor);
    } else {
      this.patchFragment(oldVNode, newVNode, container, anchor);
    }
  }
  
  mountFragment(vnode, container, anchor) {
    const { children, shapeFlag } = vnode;
    
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      const textNode = this.options.createText(children);
      vnode.el = textNode;
      this.options.insert(textNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, container, anchor);
      vnode.el = children[0]?.el;
      vnode.anchor = children[children.length - 1]?.el;
    }
  }
  
  mountChildren(children, container, anchor) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container, anchor);
    }
  }
  
  unmount(vnode) {
    const { shapeFlag, el } = vnode;
    
    if (shapeFlag & ShapeFlags.COMPONENT) {
      this.unmountComponent(vnode);
    } else if (shapeFlag & ShapeFlags.FRAGMENT) {
      this.unmountFragment(vnode);
    } else if (el) {
      this.options.remove(el);
    }
  }
}

Vue2 与 Vue3 的 patch 差异

核心差异对比表

特性 Vue2 Vue3 优势
数据劫持 Object.defineProperty Proxy Vue3可以监听新增/删除属性
编译优化 全量比较 静态提升 + PatchFlags Vue3跳过静态节点比较
diff算法 双端比较 最长递增子序列 Vue3移动操作更少
Fragment 不支持 支持 多根节点组件
Teleport 不支持 支持 灵活的DOM位置控制
Suspense 不支持 支持 异步依赖管理
性能 中等 优秀 Vue3更新速度提升1.3-2倍

PatchFlags 带来的优化

Vue3 通过 PatchFlags 标记动态内容,减少比较范围:

const PatchFlags = {
  TEXT: 1,           // 动态文本
  CLASS: 2,          // 动态class
  STYLE: 4,          // 动态style
  PROPS: 8,          // 动态属性
  FULL_PROPS: 16,    // 全量props
  HYDRATE_EVENTS: 32, // 事件
  STABLE_FRAGMENT: 64, // 稳定Fragment
  KEYED_FRAGMENT: 128, // 带key的Fragment
  UNKEYED_FRAGMENT: 256, // 无key的Fragment
  NEED_PATCH: 512,   // 需要非props比较
  DYNAMIC_SLOTS: 1024, // 动态插槽
  
  HOISTED: -1,       // 静态节点
  BAIL: -2           // 退出优化
};

结语

理解 patch 算法,就像是掌握了 Vue 更新 DOM 的"手术刀"。知道它如何精准地找到需要更新的部分,以最小的代价完成更新,这不仅能帮助我们写出更高效的代码,还能在遇到性能问题时快速定位和优化。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

一周重写 Next.js?Cloudflare 和 AI 做到了😍😍😍

作者 Moment
2026年2月27日 09:04

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

上周,一名工程师和一套 AI 模型从零重写了最流行的前端框架。产物叫 vinext(读作 "vee-next"),是基于 Vite 的 Next.js 替代实现,一条命令就能部署到 Cloudflare Workers。早期基准测试里,生产构建快至多约 4 倍,客户端包体积最多小约 57%。已有客户在生产环境跑它。整件事大约花了一千一百美元左右的 token 成本。

Next.js 的部署难题

Next.js 是最流行的 React 框架,数百万开发者在用,也支撑着大量线上站点,原因很简单:开发体验很好。

但在更广的 serverless 生态里,Next.js 的部署是个问题。工具链完全是自成一派的:Next.js 在 Turbopack 上投入很大,可如果你要部署到 Cloudflare、Netlify 或 AWS Lambda,就得把构建产物再"捏"成目标平台能跑的样子。

你可能会想:"OpenNext 不就是干这个的吗?"没错。OpenNext 就是为解决这个问题而生的,包括 Cloudflare 在内的多家厂商都往里投了不少工程资源。它能用,但很快就会撞到各种限制,变成打地鼠游戏。

在 Next.js 的构建产物之上再建一层,被证明既难又脆。OpenNext 需要反推 Next.js 的构建输出,结果就是版本之间难以预测的变动,修起来很费劲。

Next.js 一直在做一等公民的 adapters API,Cloudflare 也在和他们协作。但这仍是早期工作,而且即便有了 adapters,你依然建立在 Turbopack 这套专属工具链上。Adapters 只覆盖构建和部署;开发阶段里,next dev 只在 Node.js 里跑,没法插别的运行时。如果你的应用用了 Durable Objects、KV、AI bindings 这类平台能力,在开发环境里想测这些代码,就得搞一堆变通。

vinext 是什么

换个思路:与其去适配 Next.js 的产出,不如在 Vite 上直接重写一套 Next.js 的 API。Vite 是 Next.js 之外大多数前端生态用的构建工具,Astro、SvelteKit、Nuxt、Remix 等都基于它。要的是干净的重实现,而不是包一层或写个 adapter。他们一开始也没把握能成,但现在是 2026 年,造软件的成本已经彻底变了。

结果比预期走得远得多。

把脚本里的 next 换成 vinext,其余基本不用动。现有的 app/pages/next.config.js 都能直接用。安装方式如下:

npm install vinext

常用命令和 Next 类似,只是把 next 换成 vinext

vinext dev    # 开发服务器,带 HMR
vinext build  # 生产构建
vinext deploy # 构建并部署到 Cloudflare Workers

这不是包在 Next.js 和 Turbopack 输出外面的一层皮,而是对同一套 API 的另一种实现:路由、服务端渲染、React Server Components、server actions、缓存、中间件,全部作为 Vite 插件搭在 Vite 之上。更重要的是,借助 Vite Environment API,Vite 的产出可以在任意平台上跑。

数据表现

早期基准测试看起来不错。他们用一套共 33 个路由的 App Router 应用,对比了 vinext 和 Next.js 16。两边做的是同一类事:编译、打包、准备服务端渲染路由。在 Next.js 的构建里关掉了 TypeScript 类型检查和 ESLint(Vite 构建阶段本来也不跑这些),并对 Next.js 使用了 force-dynamic,避免多花时间预渲染静态路由,否则会不公平地拉低 Next 的数字。目标只衡量打包和编译速度。

生产构建时间大致如下(原文表格,此处保留结构):

框架 平均耗时 相对 Next.js
Next.js 16.1.6 (Turbopack) 7.38s 基线
vinext (Vite 7 / Rollup) 4.64s 约 1.6 倍快
vinext (Vite 8 / Rolldown) 1.67s 约 4.4 倍快

客户端包体积(gzip 后):

框架 Gzip 后 相对 Next.js
Next.js 16.1.6 168.9 KB 基线
vinext (Rollup) 74.0 KB 约小 56%
vinext (Rolldown) 72.9 KB 约小 57%

这些数字测的是编译和打包速度,不是线上服务性能;测试用例是单个 33 路由应用,不能代表所有生产场景。他们预期三个项目继续演进后数字会变。完整方法论和历史结果 是公开的,可以当作方向性参考,而非定论。

方向是令人鼓舞的。Vite 的架构,尤其是 Rolldown(Vite 8 里即将到来的 Rust 打包器),在构建性能上有结构性优势,在这里已经能看出来。

部署到 Cloudflare Workers

vinext 把 Cloudflare Workers 当作首选部署目标。一条命令从源码到线上 Worker:

在项目里执行即可完成构建、自动生成 Worker 配置并完成部署:

vinext deploy

App Router 和 Pages Router 都能在 Workers 上跑,包括完整的客户端注水、交互组件、客户端导航和 React 状态。

生产缓存方面,vinext 自带 Cloudflare KV 的缓存处理器,开箱即用 ISR(增量静态再生成)。在代码里设置一次即可:

import { KVCacheHandler } from "vinext/cloudflare";
import { setCacheHandler } from "next/cache";
setCacheHandler(new KVCacheHandler(env.MY_KV_NAMESPACE));

对多数应用来说 KV 就够用了,但缓存层设计成可插拔的。setCacheHandler 意味着你可以换成任何合适的后端,例如大缓存体或不同访问模式更适合用 R2。他们也在改进 Cache API,目标是少配置也能有强缓存能力。总之是尽量灵活,按应用选策略。

当前已有线上示例:App Router Playground、Hacker News 克隆、App Router 与 Pages Router 最小示例等,见 vinext 文档与仓库。还有一例 Cloudflare Agents 在 Next 风格应用里跑,不再需要 getPlatformProxy 之类的变通,因为整个应用在开发与部署阶段都跑在 workerd 里,Durable Objects、AI bindings 等 Cloudflare 能力都可以直接使用,示例见 vinext-agents-example

框架是团队协作的事

当前部署目标是 Cloudflare Workers,但这只占一小部分。vinext 里大约 95% 是纯 Vite:路由、模块 shim、SSR 管线、RSC 集成,没有 Cloudflare 专属逻辑。

Cloudflare 希望和其他托管方一起,让这套工具链也能服务他们的用户(迁移成本很低,他们在 Vercel 上不到 30 分钟就跑通了一个 PoC)。这是开源项目,长期看需要和生态里的伙伴一起投入。欢迎其他平台的 PR;若要加部署目标,可以 提 issue 或直接联系。

状态:实验性

vinext 目前是实验性的。诞生不到一周,还没有经过有规模的流量验证。若你要在生产应用里评估,请保持适当谨慎。

另一方面,测试覆盖面不小:超过 1,700 个 Vitest 用例和 380 个 Playwright E2E,包括从 Next.js 和 OpenNext 的 Cloudflare 一致性套件移植的测试。他们对照 Next.js App Router Playground 做过验证,对 Next.js 16 API 的覆盖约 94%。已有真实客户在试,反馈不错;例如 National Design Studio 在 beta 站点 CIO.gov 上已经用 vinext 跑生产,构建时间和包体积都有明显改善。

README 里老实写了 不打算支持以及不会支持的内容已知限制,尽量坦诚、少过度承诺。

预渲染呢?

vinext 已经支持增量静态再生成(ISR),首访某页后会被缓存并在后台再验证,和 Next.js 行为一致。这部分已经可用。

vinext 目前还不支持构建时静态预渲染。Next.js 里没有动态数据的页面会在 next build 时渲染成静态 HTML;有动态路由时用 generateStaticParams() 枚举要提前构建的页面。vinext 暂时不做这件事。

这是发布时的刻意取舍,路线图上有计划。若你的站是 100% 预生成静态 HTML,眼下从 vinext 获益可能有限。反过来说,若一名工程师花一千多美元 token 就能重写一版 Next.js,你大概也能花很少成本迁到 Astro 这类为静态内容设计的 Vite 系框架(Astro 也能部署到 Cloudflare Workers)。

对非纯静态站点,他们想做得比"构建时全量预渲染"更好一点。

流量感知预渲染(TPR)

Next.js 会在构建时把 generateStaticParams() 列出的页面都预渲染一遍。一万个商品页就意味着构建时渲染一万次,哪怕其中 99% 可能永远不会被请求。构建时间随页面数近似线性增长,这也是为什么大型 Next.js 站点的构建会拖到三十分钟级别。

于是他们做了"流量感知预渲染"(Traffic-aware Pre-Rendering,TPR)。目前是实验性的,计划在更多真实场景验证后作为默认选项。

思路很简单。Cloudflare 已经是站点的反向代理,拥有流量数据,知道哪些页面真的被访问。所以既不必全预渲染,也不必完全不预渲染:在部署时查 Cloudflare 的 zone 分析,只预渲染真正重要的页面。

使用方式是在部署时打开实验开关:

vinext deploy --experimental-tpr

输出会包含类似:分析最近 24 小时流量、统计独立路径数、按流量覆盖(例如 90%)选出要预渲染的页面数量、预渲染耗时并写入 KV 缓存等。

对于十万级商品页的站点,幂律分布下往往 50~200 个页面就覆盖了 90% 的流量。这些页面几秒内预渲染完,其余走按需 SSR,首访后再通过 ISR 缓存。每次部署都会根据当前流量重新算一遍集合,突然爆红的页面会被自动纳入。全程不需要 generateStaticParams(),也不用把构建和线上数据库绑死。

用 AI 再挑战一次 Next.js

这类项目通常要一个团队做几个月甚至几年。多家公司都试过,范围实在太大。Cloudflare 自己也试过一次。两套路由、三十多个模块 shim、服务端渲染管线、RSC 流式、文件系统路由、中间件、缓存、静态导出……没人做成是有原因的。

这次他们在一周内做到了。一名工程师(头衔是工程经理)带着 AI 一起干。

首笔提交在 2 月 13 日。当晚 Pages Router 和 App Router 都有了基础 SSR,中间件、server actions 和流式也跑通了。第二天下午,App Router Playground 已经能渲染 11 个路由里的 10 个。第三天,vinext deploy 能把应用完整部署到 Cloudflare Workers,包括客户端注水。后面几天主要是收口:修边界情况、扩测试、把 API 覆盖拉到约 94%。

和以前几次尝试相比,变的是 AI 强了很多。

为什么这个问题适合交给 AI

不是所有项目都适合这么搞。这次能成,是因为几件事同时满足。

Next.js 有清晰、成文的规范:文档多、用户多、Stack Overflow 和教程里到处都是,API 表面在训练数据里很常见。让 Claude 实现 getServerSideProps 或解释 useRouter 怎么用,它不会乱编,因为它"见过" Next 是怎么工作的。

Next.js 有庞大的测试套件。Next.js 仓库 里有大量 E2E,覆盖各种功能和边界。他们直接移植了其中的测试(代码里有注明来源),等于拿到一份可以机械验证的规格。

Vite 是很好的底座。Vite 解决了前端工具里最难的那块:快 HMR、原生 ESM、清晰的插件 API、生产打包。不需要从零做打包器,只要教它"说" Next.js。@vitejs/plugin-rsc 还在早期,但已经能提供 React Server Components 支持,不必自己实现一整套 RSC。

模型能力跟上了。他们认为哪怕早几个月都很难做成。以前的模型在这么大代码库上很难保持连贯;新模型能把整体架构放在上下文里,推理模块间关系,并经常写出正确代码,让迭代能持续下去。有时会看到它钻进 Next、Vite、React 内部去查 bug。当前最好的模型已经足够好用,而且还在变好。

这几条必须同时成立:目标 API 文档好、测试全、底层构建工具靠谱、模型真的能驾驭这种复杂度。少一条,效果都会打折扣。

实际是怎么做的

vinext 里几乎每一行都是 AI 写的。但更关键的是,每一行都过同样的质量关:人类写的代码也会走的那些门。项目里有 1,700+ Vitest、380 Playwright E2E、通过 tsgo 的完整 TypeScript 检查、通过 oxlint 的 lint,CI 在每个 PR 上全跑一遍。定好这些护栏,是让 AI 在代码库里高效的前提。

流程从规划开始。作者在 OpenCode 里和 Claude 花了几小时来回推敲架构:建什么、什么顺序、用什么抽象。那份计划成了北极星。之后就是固定循环:

  1. 定义一个任务(例如"实现 next/navigation 的 shim,包含 usePathnameuseSearchParamsuseRouter")。
  2. 让 AI 写实现和测试。
  3. 跑测试。
  4. 过了就合并,不过就把错误输出给 AI 继续改。
  5. 重复。

他们还接了 AI 做 Code Review:PR 打开后有 agent 审,审完的评论由另一个 agent 改。反馈环大部分是自动的。

并不是每次都对。有些 PR 就是错的,AI 会很有把握地实现一个"看起来对"但和 Next.js 实际行为不一致的东西。作者经常要纠偏。架构决策、优先级、判断什么时候 AI 在走死胡同,都是人在做。给 AI 好的方向、上下文和护栏,它可以很出活,但掌舵的还得是人。

浏览器级测试用了 agent-browser,用来验证真实渲染结果、客户端导航和注水行为。单测会漏掉很多浏览器侧的细节,这样能补上。

整个项目在 OpenCode 里跑了超过 800 次会话,总成本大约一千一百美元(Claude API token)。

对软件意味着什么

我们为什么有这么多层?这个项目逼着作者认真想这个问题,以及 AI 会怎么改变答案。

软件里大多数抽象的存在,是因为人需要帮忙。我们没法把整个系统装进脑子,于是用一层层东西来管理复杂度,每一层让下一个人的工作轻松一点。框架叠框架、包装库、成千上万行胶水代码,就是这么来的。

AI 没有同样的限制。它可以把整个系统放在上下文里,直接写代码,不需要中间框架来"帮人类理清思路",只需要规格和一块可建的底座。

哪些抽象是真正的基础设施,哪些只是人类认知的拐杖,现在还不清楚。这条线未来几年会大幅移动。但 vinext 是一个数据点:他们拿了一份 API 契约、一个构建工具和一个 AI 模型,中间全是 AI 写的,没有额外的中间框架。他们认为这种模式会在很多软件上重演,我们多年来叠上去的层,不会全部留下。

致谢

感谢 Vite 团队。Vite 是整个项目的基础。@vitejs/plugin-rsc 虽还在早期,但提供了 RSC 支持,否则要从零实现 RSC 会直接卡死。作者把插件推到以前没人测过的场景时,Vite 维护者响应很快、帮了很多忙。

也感谢 Next.js 团队。他们用多年把 React 开发的标杆拉高,API 文档和测试套件如此完善,是 vinext 能做成的重要前提。没有他们立下的标准,就没有 vinext。

试试看

vinext 提供 Agent Skill,可以帮你做迁移,支持 Claude Code、OpenCode、Cursor、Codex 等。安装后打开 Next.js 项目,让 AI 执行迁移即可。

安装 vinext 的 Agent Skill(在支持的工具里执行):

npx skills add cloudflare/vinext

然后在任意支持的工具里打开 Next.js 项目,对 AI 说:

"把这个项目迁移到 vinext"

Skill 会做兼容检查、依赖安装、配置生成和开发服务器启动,并标出需要人工处理的部分。

若想手动迁移,可以用:

npx vinext init   # 从现有 Next.js 项目迁移
npx vinext dev    # 启动开发服务器
npx vinext deploy # 部署到 Cloudflare Workers

源码在 github.com/cloudflare/…,欢迎提 issue、PR 和反馈。

昨天 — 2026年2月26日首页

常见的内存泄漏有哪些?

2026年2月26日 17:34

在 JavaScript 中,内存泄漏指的是应用程序不再需要某块内存,但由于某种原因,垃圾回收机制(GC, Garbage Collection)无法将其回收,导致内存占用持续升高,最终可能引发性能下降或崩溃。

以下是 JavaScript 中导致内存泄漏的最常见情况及示例:

1. 意外的全局变量

在 JavaScript 中,如果未声明的变量被赋值,它会自动成为全局对象的属性(浏览器中是 window,Node.js 中是 global)。全局变量在页面关闭前永远不会被垃圾回收。

function leak() {
  // 忘记了使用 let/const/var
  secretData = "这是一段敏感数据"; // 变成了 window.secretData
}
leak();

解决方案:

  • 使用严格模式 ('use strict') 来避免意外的全局变量。
  • 使用完后手动设置为 null

2. 被遗忘的定时器或回调函数

如果代码中设置了 setIntervalsetTimeout,但忘记清除(clear),且定时器内部引用了外部变量,那么这些变量无法被释放。

const someResource = hugeData(); // 很大的数据

setInterval(function() {
  // 这个回调引用了 someResource
  console.log(someResource);
}, 1000);

// 如果没有调用 clearInterval,someResource 会一直留在内存中

解决方案:

  • 在组件卸载或页面关闭时,清除定时器:clearInterval(id)

3. 闭包(Closures)的不当使用

闭包是 JavaScript 的强大特性,但如果闭包长期持有父函数的变量,而这些变量又很大,就会造成泄漏。

function outer() {
  const largeArray = new Array(1000000).fill('data');

  return function inner() {
    // inner 函数引用了 outer 作用域的 largeArray
    // 只要 inner 函数还存在,largeArray 就无法被回收
    console.log(largeArray.length);
  };
}

const innerFunc = outer(); // largeArray 被保留
// 如果后续没有释放 innerFunc,内存就会泄漏

解决方案:

  • 确保不再需要的函数被释放(innerFunc = null)。
  • 在闭包外尽量避免引用大对象。

4. DOM 引用未被清理

当把 DOM 元素存储为 JavaScript 对象或数据结构时,即使该元素已从 DOM 树中移除,只要 JS 中还有引用,该 DOM 元素连同其事件监听器就不会被释放。

const elements = {
  button: document.getElementById('button')
};

function removeButton() {
  document.body.removeChild(document.getElementById('button'));
  // 注意:elements.button 仍然指向那个 DOM 对象,所以它无法被回收
}

解决方案:

  • 移除 DOM 节点后,同时将变量设置为 null

5. 事件监听器未移除

向 DOM 元素添加了事件监听器,但在移除该元素前没有移除监听器。现代浏览器(尤其是针对原生 DOM 的监听器)处理得比以前好,但在单页应用(SPA, Single Page Application)中,如果频繁添加和移除元素,累积的监听器仍会导致泄漏。

const element = document.getElementById('button');
element.addEventListener('click', onClick);

// 如果后来 element 被移除了,但没有 removeEventListener
// 并且 onClick 函数引用了外部变量,就会造成泄漏

解决方案:

  • 在移除元素前调用 removeEventListener
  • 使用框架(如 React、Vue)时,框架的生命周期通常会自动处理,但要注意在 useEffect 的清理函数中移除原生监听器。

6. 脱离 DOM 树的引用(DOM 树内部引用)

这通常发生在给 DOM 元素添加自定义属性时。如果两个 DOM 元素相互引用,即使从文档流中移除,也可能因为循环引用导致泄漏(在老版本 IE 中常见,现代浏览器有所改进,但仍需注意)。

7. Map 或 Set 的不当使用

使用对象作为 MapSet 的 key,如果只把 key 置为 null,而没有从 Map 中删除它,key 依然被 Map 引用着,无法被回收。

let obj = {};
const map = new Map();
map.set(obj, 'some value');

obj = null; // 这里 obj 被置为 null
// 但 map 里仍然有对原对象的引用,所以原对象无法被回收

解决方案:

  • 使用 WeakMapWeakSet。它们的 key 是弱引用,不会阻止垃圾回收。

8. console.log 的影响

在开发环境调试时打印对象,如果线上环境忘记删除 console.log,控制台会一直持有对象的引用(特别是打印复杂对象时),导致对象无法被回收。现代浏览器在处理 console.log 时有所优化,但仍需注意。

建议:

  • 生产环境打包时移除所有 console.log

总结:如何避免内存泄漏?

  1. 使用 WeakMapWeakSet 存储对象引用。
  2. 及时清理:清除定时器、取消订阅、解绑事件。
  3. 避免全局变量,使用 let/const 和严格模式。
  4. 合理使用闭包,避免在闭包中持有大量数据的引用。
  5. 善用工具
    • 使用 Chrome DevTools 的 Memory 面板拍摄堆快照(Heap Snapshot),分析内存占用。
    • 使用 Performance 面板监控内存变化。

什么是事件循环?调用堆栈和任务队列之间有什么区别?

2026年2月26日 17:18

事件循环 (Event Loop)

事件循环是 JavaScript 运行时处理异步操作的核心机制,它使得 JavaScript 虽然是单线程的,但能够非阻塞地处理 I/O 操作和其他异步任务。

主要组成部分

  1. 调用堆栈 (Call Stack)

    • 一个后进先出(LIFO)的数据结构
    • 用于跟踪当前正在执行的函数
    • 当函数被调用时,会被推入堆栈;执行完毕后弹出
  2. 任务队列 (Task Queue)

    • 一个先进先出(FIFO)的数据结构
    • 存储待处理的消息(异步操作的回调)
    • 包括宏任务队列和微任务队列

调用堆栈 vs 任务队列

特性 调用堆栈 (Call Stack) 任务队列 (Task Queue)
结构 LIFO (后进先出) FIFO (先进先出)
内容 同步函数调用 异步回调函数
执行时机 立即执行 等待调用堆栈为空时才执行
优先级
溢出 可能导致"栈溢出"错误 不会溢出,但可能导致内存问题

事件循环的工作流程

  1. 执行调用堆栈中的同步代码
  2. 当调用堆栈为空时,事件循环检查任务队列
  3. 如果有待处理的任务,将第一个任务移到调用堆栈执行
  4. 重复这个过程

微任务队列 (Microtask Queue)

  • 比普通任务队列优先级更高
  • 包含 Promise 回调、MutationObserver 等
  • 在当前任务完成后、下一个任务开始前执行
  • 会一直执行直到微任务队列为空
console.log('1'); // 同步代码,直接执行

setTimeout(() => console.log('2'), 0); // 宏任务,放入任务队列

Promise.resolve().then(() => console.log('3')); // 微任务,放入微任务队列

console.log('4'); // 同步代码,直接执行

// 输出顺序: 1, 4, 3, 2

理解事件循环和这些队列的区别对于编写高效、无阻塞的 JavaScript 代码至关重要。

处理 I/O 操作的含义

I/O(Input/Output,输入/输出)操作是指程序与外部资源进行数据交换的过程。在JavaScript中,处理I/O操作特别重要,因为JavaScript是单线程的,而I/O操作通常是阻塞的(需要等待响应)。

常见的I/O操作类型

  1. 文件系统操作

    • 读写文件
    • 例如Node.js中的fs.readFile()
  2. 网络请求

    • HTTP/HTTPS请求
    • WebSocket通信
    • 例如fetch()XMLHttpRequest
  3. 数据库操作

    • 查询或更新数据库
    • 例如MongoDB、MySQL等数据库操作
  4. 用户输入

    • 键盘输入
    • 鼠标点击等交互事件

JavaScript如何处理I/O操作

JavaScript通过异步非阻塞方式处理I/O:

  1. 非阻塞特性

    • 发起I/O请求后,不等待结果立即继续执行后续代码
    • 避免线程被阻塞
  2. 回调机制

    • I/O完成后通过回调函数处理结果
    • 例如:
      fs.readFile('file.txt', (err, data) => {
        if (err) throw err;
        console.log(data);
      });
      
  3. Promise/async-await

    • 更现代的异步处理方式
    • 例如:
      async function fetchData() {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
      }
      

为什么需要特殊处理I/O

  1. 性能考虑:I/O操作通常比CPU操作慢得多

    • 磁盘读取:毫秒级(10^-3秒)
    • 网络请求:可能达到秒级
  2. 单线程限制:JavaScript只有一个主线程

    • 如果同步等待I/O,整个程序会卡住
  3. 用户体验:在浏览器中,阻塞会导致页面无响应

事件循环中的I/O处理

当I/O操作完成时:

  1. 相应的回调函数被放入任务队列
  2. 事件循环在调用栈为空时从队列中取出回调执行
  3. 这使得JavaScript能够高效处理大量并发I/O
console.log('开始请求'); // 同步代码

// 异步I/O操作
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log('收到数据:', data)); // 回调

console.log('请求已发起,继续执行其他代码'); // 立即执行

// 可能的输出顺序:
// 开始请求
// 请求已发起,继续执行其他代码
// 收到数据: {...}

这种机制使得JavaScript特别适合I/O密集型应用(如Web服务器),能够高效处理大量并发请求而不需要创建多个线程。

从 8 个实战场景深度拆解:为什么资深前端都爱柯里化?

2026年2月25日 09:21

你一定见过无数臃肿的 if-else 和重复嵌套的逻辑。在追求 AI-Native 开发的今天,代码的“原子化”程度直接决定了 AI 辅助重构的效率。

柯里化(Currying) 绝不仅仅是面试时的八股文,它是实现逻辑复用、配置解耦的工业级利器。通俗地说,它把一个多参数函数拆解成一系列单参数函数:f(a,b,c)f(a)(b)(c)f(a, b, c) \rightarrow f(a)(b)(c)

以下是 8 个直击前端实战痛点的柯里化应用案例。


1. 差异化日志系统:环境与等级的解耦

在web系统中,我们经常需要根据不同环境输出不同等级的日志。

JavaScript

const logger = (env) => (level) => (msg) => {
  console.log(`[${env.toUpperCase()}][${level}] ${msg} - ${new Date().toLocaleTimeString()}`);
};

const prodError = logger('prod')('ERROR');
const devDebug = logger('dev')('DEBUG');

prodError('支付接口超时'); // [PROD][ERROR] 支付接口超时 - 10:20:00

2. API 请求构造器:预设 BaseURL 与 Header

不用每次请求都传 Token 或域名,通过柯里化提前“锁死”配置。

JavaScript

const request = (baseUrl) => (headers) => (endpoint) => (params) => {
  return fetch(`${baseUrl}${endpoint}?${new URLSearchParams(params)}`, { headers });
};

const apiWithAuth = request('https://api.finance.com')({ 'Authorization': 'Bearer xxx' });
const getUser = apiWithAuth('/user');

getUser({ id: '888' }); 

3. DOM 事件监听:优雅传递额外参数

在 Vue 或 React 模板中,我们常为了传参写出 () => handleClick(id)。柯里化可以保持模板整洁并提高性能。

JavaScript

const handleMenuClick = (menuId) => (event) => {
  console.log(`点击了菜单: ${menuId}`, event.target);
};

// 模板中直接绑定:@click="handleMenuClick('settings')"

4. 复合校验逻辑:原子化验证规则

将复杂的表单校验拆解为可组合的原子。

JavaScript

const validate = (reg) => (tip) => (value) => {
  return reg.test(value) ? { pass: true } : { pass: false, tip };
};

const isMobile = validate(/^1[3-9]\d{9}$/)('手机号格式错误');
const isEmail = validate(/^\w+@\w+.\w+$/)('邮箱格式错误');

console.log(isMobile('13800138000')); // { pass: true }

5. 金融汇率换算:固定基准率

在处理多币种对账时,柯里化能帮你固定变动较慢的参数。

JavaScript

const convertCurrency = (rate) => (amount) => (amount * rate).toFixed(2);

const usdToCny = convertCurrency(7.24);
const eurToCny = convertCurrency(7.85);

console.log(usdToCny(100)); // 724.00

6. 动态 CSS 类名生成器:样式逻辑解耦

配合 CSS Modules 或 Tailwind 时,通过柯里化快速生成带状态的类名。

JavaScript

const createCls = (prefix) => (state) => (baseCls) => {
  return `${prefix}-${baseCls} ${state ? 'is-active' : ''}`;
};

const navCls = createCls('nav')(isActive);
const btnCls = navCls('button'); // "nav-button is-active"

7. 数据过滤管道:可组合的 Array 操作

在处理海量 AI Prompt 列表时,将过滤逻辑函数化,方便链式调用。

JavaScript

const filterBy = (key) => (value) => (item) => item[key].includes(value);

const filterByTag = filterBy('tag');
const prompts = [{ title: 'AI助手', tag: 'Finance' }, { title: '翻译机', tag: 'Tool' }];

const financePrompts = prompts.filter(filterByTag('Finance'));

8. AI Prompt 模板工厂:多层上下文注入

为你正在开发的 AI Prompt Manager 设计一个分层注入器:先注入角色,再注入上下文,最后注入用户输入。

JavaScript

const promptFactory = (role) => (context) => (input) => {
  return `Role: ${role}\nContext: ${context}\nUser says: ${input}`;
};

const financialExpert = promptFactory('Senior Financial Analyst')('Analyzing 2026 Q1 Report');
const finalPrompt = financialExpert('请总结该季报风险点');

LeetCode 530. 二叉搜索树的最小绝对差:两种解法详解(迭代+递归)

作者 Wect
2026年2月26日 16:50

LeetCode 上一道经典的二叉搜索树(BST)题目——530. 二叉搜索树的最小绝对差,这道题看似简单,却能很好地考察我们对 BST 特性的理解,以及二叉树遍历方式的灵活运用。下面我会从题目分析、核心思路、两种解法拆解,到代码细节注释,一步步帮大家搞懂这道题,新手也能轻松跟上。

一、题目解读

题目很直白:给一个二叉搜索树的根节点 root,返回树中任意两个不同节点值之间的最小差值,差值是正数(即两值之差的绝对值)。

这里有个关键前提——二叉搜索树的特性:中序遍历二叉搜索树,得到的序列是严格递增的(假设树中没有重复值,题目未明确说明,但测试用例均满足此条件)。

这个特性是解题的核心!因为递增序列中,任意两个元素的最小差值,一定出现在相邻的两个元素之间。比如序列 [1,3,6,8],最小差值是 3-1=2,而不是 8-1=7 或 6-3=3。所以我们不需要暴力枚举所有两两组合,只需要在中序遍历的过程中,记录前一个节点的值,与当前节点值计算差值,不断更新最小差值即可。

二、核心解题思路

  1. 利用 BST 中序遍历为递增序列的特性,将“任意两节点的最小差值”转化为“中序序列中相邻节点的最小差值”;

  2. 遍历过程中,维护两个变量:min(记录当前最小差值,初始值设为无穷大)、pre(记录前一个节点的值,初始值设为负无穷大,避免初始值影响第一次差值计算);

  3. 遍历每个节点时,用当前节点值与pre 计算绝对值差值,更新 min,再将 pre 更新为当前节点值;

  4. 遍历结束后,min 即为答案。

接下来,我们用两种最常用的遍历方式实现这个思路:迭代中序遍历(解法1)和递归中序遍历(解法2)。

三、解法一:迭代中序遍历(非递归)

迭代遍历的核心是用“栈”模拟递归的调用过程,避免递归深度过深导致的栈溢出(虽然这道题的测试用例大概率不会出现,但迭代写法更通用,适合处理大型树)。

3.1 代码实现(带详细注释)

// 先定义 TreeNode 类(题目已给出,此处复用)
class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

// 解法1:迭代中序遍历
function getMinimumDifference_1(root: TreeNode | null): number {
  // 边界处理:空树返回0(题目中树至少有一个节点?但严谨起见还是判断)
  if (!root) return 0;
  let min = Infinity; // 最小差值,初始为无穷大
  let pre = -Infinity; // 前一个节点的值,初始为负无穷大
  const stack: TreeNode[] = []; // 用于模拟中序遍历的栈
  let curr: TreeNode | null = root; // 当前遍历的节点

  // 第一步:将左子树所有节点压入栈(中序遍历:左 -> 根 -> 右)
  while (curr) {
    stack.push(curr);
    curr = curr.left; // 一直向左走,直到最左节点
  }

  // 第二步:弹出栈顶节点,处理根节点,再遍历右子树
  while (stack.length) {
    const node = stack.pop(); // 弹出栈顶(当前要处理的根节点)
    if (!node) continue; // 防止空节点(理论上不会出现)

    // 处理右子树:将右子树的所有左节点压入栈
    if (node.right) {
      let right: TreeNode | null = node.right;
      while (right) {
        stack.push(right);
        right = right.left;
      }
    }

    // 计算当前节点与前一个节点的差值,更新最小差值
    min = Math.min(min, Math.abs(pre - node.val));
    // 更新pre为当前节点值,为下一个节点做准备
    pre = node.val;
  }

  return min;
};

3.2 思路拆解

  1. 初始化:栈用于存储待处理的节点,curr指向根节点,先将根节点的所有左子节点压入栈(因为中序遍历要先访问左子树);

  2. 弹出栈顶节点(此时该节点的左子树已处理完毕),先处理其右子树(将右子树的所有左节点压入栈,保证下一次弹出的是右子树的最左节点);

  3. 计算当前节点与pre的差值,更新min,再将pre更新为当前节点值;

  4. 重复上述过程,直到栈为空,遍历结束。

优势:不依赖递归栈,避免递归深度过大导致的栈溢出,空间复杂度由递归的 O(h)(h为树高)优化为 O(h)(栈的最大深度也是树高),实际运行更稳定。

四、解法二:递归中序遍历

递归写法更简洁,代码量少,思路也更直观,适合树的深度不大的场景。核心是用递归函数实现中序遍历的“左 -> 根 -> 右”顺序。

4.1 代码实现(带详细注释)

// 解法2:递归中序遍历
function getMinimumDifference_2(root: TreeNode | null): number {
  if (!root) return 0;
  let min = Infinity; // 最小差值
  let pre = -Infinity; // 前一个节点的值

  // 递归函数:实现中序遍历
  const dfs = (node: TreeNode) => {
    if (!node) return; // 递归终止条件:节点为空

    // 1. 遍历左子树(左)
    if (node.left) dfs(node.left);

    // 2. 处理当前节点(根):计算差值,更新min和pre
    min = Math.min(min, Math.abs(pre - node.val));
    pre = node.val;

    // 3. 遍历右子树(右)
    if (node.right) dfs(node.right);
  }

  // 从根节点开始递归
  dfs(root);
  return min;
};

4.2 思路拆解

  1. 定义递归函数 dfs,参数为当前节点,负责遍历以该节点为根的子树;

  2. 递归终止条件:当前节点为空,直接返回;

  3. 先递归遍历左子树(保证左子树先被处理);

  4. 处理当前节点:计算当前节点与pre的差值,更新min,再将pre更新为当前节点值;

  5. 最后递归遍历右子树;

  6. 从根节点调用dfs,完成整个树的遍历,返回min。

优势:代码简洁,思路直观,容易理解和编写;劣势:当树的深度很大时(如链式树),会出现递归栈溢出,此时迭代写法更合适。

五、两种解法对比与总结

解法 遍历方式 时间复杂度 空间复杂度 优势 劣势
解法1 迭代中序 O(n)(每个节点遍历一次) O(h)(h为树高,栈的最大深度) 稳定,无栈溢出风险,通用 代码稍长,需要手动维护栈
解法2 递归中序 O(n)(每个节点遍历一次) O(h)(递归栈深度) 代码简洁,思路直观,易编写 深度过大时会栈溢出

关键总结

  1. 这道题的核心是利用 BST 中序遍历为递增序列,将“任意两节点最小差值”转化为“相邻节点最小差值”,避免暴力枚举;

  2. 两种解法的核心逻辑一致,只是遍历方式不同,可根据树的深度选择:树深较小时用递归,树深较大时用迭代;

  3. 注意初始值的设置:min设为无穷大(保证第一次差值能更新min),pre设为负无穷大(避免初始值与第一个节点值计算出不合理的差值);

  4. 边界处理:空树返回0(题目中树至少有一个节点,但严谨起见必须判断)。

六、拓展思考

如果这道题不是 BST,而是普通二叉树,该怎么解?

答案:先遍历所有节点,将节点值存入数组,再对数组排序,计算相邻元素的最小差值。时间复杂度 O(n log n)(排序耗时),空间复杂度 O(n)(存储所有节点值),效率低于本题的解法,由此可见利用数据结构特性解题的重要性。

好了,这道题的两种解法就讲解完毕了。希望大家能通过这道题,加深对 BST 特性和二叉树中序遍历的理解,下次遇到类似题目能快速想到解题思路。

Modern.js 3.0 发布:聚焦 Web 框架,拥抱生态发展

作者 WebInfra
2026年2月25日 11:44

前言

Modern.js 2.0 发布 至今,已过去三年时间,感谢社区开发者们对 Modern.js 的使用和信任。Modern.js 一直保持稳定的迭代,累计发布了超过 100 个版本。

在字节内部,Modern.js 已成为 Web 开发的核心框架。在全公司活跃的 Web 项目中,使用占比已从 2025 年初的 40% 增长至目前接近 70%。

这三年中,我们不断扩充新特性,持续进行代码重构与优化,也收到了非常多的反馈,这些经验成为了 3.0 版本改进的重要参考。经过慎重考虑,我们决定发布 Modern.js 3.0,对框架进行一次全面的升级。

Modern.js 2.0 到 3.0 的演变

从 Modern.js 2.0 到 3.0,有两个核心转变:

更聚焦,专注于 Web 框架

  • Modern.js 2.0:包含 Modern.js App、Modern.js Module、Modern.js Doc
  • Modern.js 3.0:只代表 Modern.js App,Modern.js Module 和 Modern.js Doc 已孵化为 RslibRspress

更开放,积极面向社区工具

  • Modern.js 2.0:内置各类工具、框架独特的 API 设计
  • Modern.js 3.0:强化插件体系,完善接入能力,推荐社区优质方案

Modern.js 3.0 新特性

React Server Component

TL;DR:Modern.js 3.0 集成 React Server Component,支持 CSR 和 SSR 项目,并支持渐进式迁移。

什么是 React Server Component

React Server Components(服务端组件)是一种新的组件类型,它允许组件逻辑完全在服务端执行,并直接将渲染后的 UI 流式传输到客户端。与传统的客户端组件相比,服务端组件带来了以下特性:

特性 说明
零客户端包体积 组件代码不包含在客户端 JS Bundle 中,仅在服务端执行,加快首屏加载与渲染速度
更高的内聚性 组件可直接连接数据库、调用内部 API、读取本地文件,提高开发效率
渐进增强 可与客户端组件无缝混合使用,按需下放交互逻辑到客户端,在保持高性能的同时,支持复杂交互体验

需要明确的是,RSC 和 SSR 是截然不同的概念

  • RSC:描述的是组件类型,即组件在哪里执行(服务端 vs 客户端)
  • SSR:描述的是渲染模式,即 HTML 在哪里生成(服务端 vs 客户端)

两者可以组合使用:Server Component 可以在 SSR 项目下使用,也可以在 CSR 项目下使用。在 Modern.js 3.0 中,我们同时支持这两种模式,开发者可以根据需求选择。

开箱即用

在 Modern.js 3.0 中,只需在配置中启用 RSC 能力:

modern.config.ts

export default defineConfig({
  server: {
    rsc: true,
  }
});

配置启用后,所有的路由组件都会默认成为 Server Component。项目中可能存在无法在服务端运行的组件,你可以先为这些组件添加 'use client' 标记,以保持原有行为,再逐步迁移。

RSC 效果演示视频:lf3-static.bytednsdoc.com/obj/eden-cn…

Modern.js 3.0 的 RSC 特性

Modern.js 一直选择 React Router 作为路由解决方案。去年,React Router v7 宣布支持 React Server Component,这为 Modern.js 提供了在 SPA 应用下实现 RSC 的基础。

相比于社区其他框架,Modern.js 对 RSC 做了几点优化:

  • 使用 Rspack 最新的 RSC 插件构建,显著提升 RSC 项目构建速度;并进一步优化了产物体积。
  • 不同于社区主流框架只支持 RSC + SSR,Modern.js 3.0 的 RSC 同样支持 CSR 项目
  • 在路由跳转时,框架会自动将多个 Data Loader 和 Server Component 的请求合并为单个请求,并流式返回,提升页面性能
  • 在嵌套路由场景下,路由组件类型不受父路由组件类型的影响,开发者可以从任意路由层级开始采用 Server Component

渐进式迁移

基于灵活的组件边界控制能力,Modern.js 3.0 提供了渐进式的迁移方式。Modern.js 3.0 允许基于路由组件维度的 Server Component 迁移,无需迁移整条组件树链路。

更多 React Server Component 的详细内容,可以参考:React Server Component


拥抱 Rspack

TL;DR:Modern.js 3.0 移除了对 webpack 的支持,全面拥抱 Rspack,并升级到最新的 Rspack & Rsbuild 2.0。

在 2023 年,我们开源了 Rspack,并在 Modern.js 中支持将 Rspack 作为可选的打包工具。在字节内部,超过 60% 的 Modern.js 项目已经切换到 Rspack 构建。

经过两年多发展,Rspack 在社区中的月下载量已超过 1000 万次,成长为行业内被广泛使用的打包工具;同时,Modern.js 的 Rspack 构建模式也得到持续完善。

Rspack 下载量

在 Modern.js 3.0 中,我们决定移除对 webpack 的支持,从而使 Modern.js 变得更加轻量和高效,并能更充分地利用 Rspack 的新特性。

更顺畅的开发体验

Modern.js 3.0 在移除 webpack 后,能够更好地遵循 Rspack 最佳实践,在构建性能、安装速度等方面均有提升:

底层依赖升级

Modern.js 3.0 将底层依赖的 Rspack 和 Rsbuild 升级至 2.0 版本,并基于新版本优化了默认构建配置,使整体行为更加一致。

参考以下文档了解底层行为变化:

更快的构建速度

Modern.js 通过 Rspack 的多项特性来减少构建耗时:

  • 默认启用 Barrel 文件优化:构建组件库速度提升 20%
  • 默认启用持久化缓存:非首次构建的速度提升 50%+

更快的安装速度

移除 webpack 相关依赖后,Modern.js 3.0 的构建依赖数量和体积均明显减少:

  • npm 依赖数量减少 40%
  • 安装体积减少 31 MB

更小的构建产物

Modern.js 现在默认启用 Rspack 的多项产物优化策略,能够比 webpack 生成更小的产物体积,例如:

增强 Tree shaking

增强了 tree shaking 分析能力,可以处理更多动态导入语法,例如解构赋值:

// 参数中的解构访问
import('./module').then(({ value }) => {
  console.log(value);
});

// 函数体内的解构访问
import('./module').then((mod) => {
  const { value } = mod;
  console.log(value);
});

常量内联

对常量进行跨模块内联,有助于压缩工具进行更准确的静态分析,从而消除无用的代码分支:

// constants.js
export const ENABLED = true;

// index.js
import { ENABLED } from './constants';
if (ENABLED) {
  doSomething();
} else {
  doSomethingElse();
}

// 构建产物 - 无用分支被消除
doSomething();

全链路可扩展

TL;DR:Modern.js 3.0 正式开放完整插件体系,提供运行时、服务端插件,同时支持灵活处理应用入口。

Modern.js 2.0 提供了 CLI 插件与内测版本的运行时插件,允许开发者对项目进行扩展。但在实践过程中,我们发现现有的能力不足以支撑复杂的业务场景。

Modern.js 3.0 提供了更灵活的定制能力,允许为应用编写全流程的插件,帮助团队统一业务逻辑、减少重复代码:

  • CLI 插件:在构建阶段扩展功能,如添加命令、修改配置
  • Runtime 插件:在渲染阶段扩展功能,如数据预取、组件封装
  • Server 插件:在服务端扩展功能,如添加中间件、修改请求响应

运行时插件

运行时插件在 CSR 与 SSR 过程中都会运行,新版本提供了两个核心钩子:

  • onBeforeRender:在渲染前执行逻辑,可用于数据预取、注入全局数据
  • wrapRoot:封装根组件,添加全局 Provider、布局组件等

你可以在 src/modern.runtime.ts 中注册插件,相比在入口手动引入高阶组件,运行时插件可插拔、易更新,在多入口场景下无需重复引入:

src/modern.runtime.tsx

import { defineRuntimeConfig } from "@modern-js/runtime";

export default defineRuntimeConfig({
  plugins: [
    {
      name: "my-runtime-plugin",
      setup: (api) => {
        api.onBeforeRender((context) => {
          context.globalData = { theme: "dark" };
        });
        api.wrapRoot((App) => (props) => <App {...props} />);
      },
    },
  ],
});

更多 Runtime 插件使用方式,请查看文档:Runtime 插件

服务端中间件

在实践过程中我们发现,部分项目需要扩展 Web Server,例如鉴权、数据预取、降级处理、动态 HTML 脚本注入等。

在 Modern.js 3.0 中,我们使用 Hono 重构了 Web Server,并正式开放了服务端中间件与插件的能力。开发者可以使用 Hono 的中间件来完成需求:

server/modern.server.ts

import { defineServerConfig, type MiddlewareHandler } from "@modern-js/server-runtime";

const timingMiddleware: MiddlewareHandler = async (c, next) => {
  const start = Date.now();
  await next();
  const duration = Date.now() - start;
  c.header('X-Response-Time', `${duration}ms`);
};

const htmlMiddleware: MiddlewareHandler = async (c, next) => {
  await next();
  const html = await c.res.text();
  const modified = html.replace(
    "<head>",
    '<head><meta name="generator" content="Modern.js">'
  );
  c.res = c.body(modified, { status: c.res.status, headers: c.res.headers });
};

export default defineServerConfig({
  middlewares: [timingMiddleware],
  renderMiddlewares: [htmlMiddleware],
});

更多服务端插件使用方式,可以查看文档:自定义 Web Server

自定义入口

在 Modern.js 3.0 中,我们重构了自定义入口,相比于旧版 API 更加清晰灵活:

src/entry.tsx

import { createRoot } from '@modern-js/runtime/react';
import { render } from '@modern-js/runtime/browser';

const ModernRoot = createRoot();

async function beforeRender() {
  // 渲染前的异步操作,如初始化 SDK、获取用户信息等
}

beforeRender().then(() => {
  render(<ModernRoot />);
});

更多入口使用方式,请查看文档:入口


路由优化

TL;DR:Modern.js 3.0 内置 React Router v7,提供配置式路由能力与 AI 友好的调试方式。

内置 React Router v7

在 Modern.js 3.0 中,我们统一升级到 React Router v7,并废弃了对 v5 和 v6 的内置支持。这一决策基于以下考虑:

版本演进与稳定性

React Router v6 是一个重要的过渡版本,它引入了许多新特性(如数据加载、错误边界等)。而 v7 在保持 v6 API 兼容性的基础上,进一步优化了性能、稳定性和开发体验。随着 React Router 团队将 Remix 定位为独立框架,React Router 核心库可能会在 v7 版本上长期维护,使其成为更可靠的选择。

升级路径

  • 从 v6 升级:React Router v7 对 v6 开发者来说是无破坏性变更的升级。在 Modern.js 2.0 中,我们已提供了 React Router v7 插件支持,你可以通过插件方式渐进式升级,验证兼容性后再迁移到 Modern.js 3.0。
  • 从 v5 升级:v5 到 v7 存在较大的 API 变化,建议参考 React Router 官方迁移指南 进行迁移。

配置式路由

在 Modern.js 中,我们推荐使用约定式路由来组织代码。但在实际业务中,开发者偶尔遇到以下场景:

  • 多路径指向同一组件
  • 灵活的路由控制
  • 条件性路由
  • 遗留项目迁移

因此,Modern.js 3.0 提供了完整的配置式路由支持,可以与约定式路由一起使用,或两者分别单独使用。

src/modern.routes.ts

import { defineRoutes } from "@modern-js/runtime/config-routes";

export default defineRoutes(({ route, layout, page }) => {
  return [
    route("home.tsx", "/"),
    route("about.tsx", "about"),
    route("blog.tsx", "blog/:id"),
  ];
});

更多配置式路由使用方式,请查看文档:配置式路由

路由调试

运行 npx modern routes 命令即可在 dist/routes-inspect.json 文件中生成完整的路由结构分析报告。

报告中会显示每个路由的路径、组件文件、数据加载器、错误边界、Loading 组件等完整信息,帮助开发者快速了解项目的路由配置,快速定位和排查路由相关问题。结构化的 JSON 格式也便于 AI agent 理解和分析路由结构,提升 AI 辅助开发的效率。

具体使用方式,请查看文档:路由调试


服务端渲染

TL;DR:Modern.js 3.0 重做了 SSG 能力,提供了灵活的缓存能力,对降级策略也进行了进一步的完善。

静态站点生成(SSG)

在 Modern.js 2.0 中,我们提供了静态站点生成的能力。这个能力非常适合用在可以静态渲染的页面中,能极大的提升页面首屏性能。

在新版本中,我们对 SSG 进行了重新设计:

  • 数据获取使用 Data Loader,与非 SSG 场景保持一致
  • 简化了 API,降低理解成本
  • 与约定式路由更好地结合

在新版本中,你可以通过 data loader 进行数据获取,与非 SSG 场景保持一致。然后在 ssg.routes 配置中即可直接指定要渲染的路由:

modern.config.ts

export default defineConfig({
  output: {
    ssg: {
      routes: ['/blog'],
    },
  },
});

routes/blog/page.data.ts

export const loader = async () => {
  const articles = await fetchArticles();
  return { articles };
};

更多 SSG 的使用方式,请查看文档:SSG

缓存机制

Modern.js 3.0 中提供了不同维度的缓存机制,帮助项目提升首屏性能。所有缓存均支持灵活配置,比如可以支持类似 HTTP 的 stale-while-revalidate 策略:

渲染缓存

支持将 SSR 结果进行整页的缓存,在 server/cache.ts 中配置:

server/cache.ts

import type { CacheOption } from '@modern-js/server-runtime';

export const cacheOption: CacheOption = {
  maxAge: 500, // ms
  staleWhileRevalidate: 1000, // ms
};

使用渲染缓存,请查看文档:渲染缓存

数据缓存

我们在新版本中提供了 cache 函数,相比渲染缓存它提供了更精细的数据粒度控制。当多个数据请求依赖同一份数据时,cache 可以避免重复请求:

server/loader.ts

import { cache } from "@modern-js/runtime/cache";
import { fetchUserData, fetchUserProjects, fetchUserTeam } from "./api";

// 缓存用户数据,避免重复请求
const getUser = cache(fetchUserData);

const getProjects = async () => {
  const user = await getUser("test-user");
  return fetchUserProjects(user.id);
};

const getTeam = async () => {
  const user = await getUser("test-user"); // 复用缓存,不会重复请求
  return fetchUserTeam(user.id);
};

export const loader = async () => {
  // getProjects 和 getTeam 都依赖 getUser,但 getUser 只会执行一次
  const [projects, team] = await Promise.all([getProjects(), getTeam()]);
  return { projects, team };
};

更多数据缓存的使用方式,请查看文档:数据缓存

灵活的降级策略

在实践过程中,我们沉淀了多维度的降级策略:

类型 触发方式 降级行为 使用场景
异常降级 Data Loader 执行报错 触发 ErrorBoundary 数据请求异常兜底
组件渲染报错 服务端渲染异常 降级到 CSR,复用已有数据渲染 服务端渲染异常兜底
业务降级 Loader 抛出 throw Response 触发 ErrorBoundary,返回对应 HTTP 状态码 404、权限校验等业务场景
配置 Client Loader 配置 Client Loader 绕过 SSR,直接请求数据源 需要在客户端直接获取数据的场景
强制降级 Query 参数 ?__csr=true 跳过 SSR,返回 CSR 页面 调试、临时降级
强制降级 请求头 x-modern-ssr-fallback 跳过 SSR,返回 CSR 页面 网关层控制降级

轻量 BFF

TL;DR:Modern.js 3.0 基于 Hono 重构了 Web Server,提供基于 Hono 的一体化函数,同时支持跨项目调用。

Hono 一体化函数

在 Modern.js 3.0 中,我们使用 Hono 作为 BFF 的运行时框架,开发者可以基于 Hono 生态扩展 BFF Server,享受 Hono 轻量、高性能的优势。

通过 useHonoContext 可以获取完整的 Hono 上下文,访问请求信息、设置响应头等:

api/lambda/user.ts

import { useHonoContext } from '@modern-js/server-runtime';

export const get = async () => {
  const c = useHonoContext();
  const token = c.req.header('Authorization');
  c.header('X-Custom-Header', 'modern-js');
  const id = c.req.query('id');

  return { userId: id, authenticated: !!token };
};

跨项目调用

在过去,Modern.js BFF 只能在当前项目中使用,而我们陆续收到开发者反馈,希望能够在不同项目中使用。这多数情况是由于开发者的迁移成本、运维成本造成的,相比于抽出原有代码再部署一个,显然复用已有服务更加合理。

为了保证开发者能得到与当前项目一体化调用类似的体验,我们提供了跨项目调用的能力。

更多 BFF 的使用方式,请查看文档:BFF


Module Federation 深度集成

TL;DR:Modern.js 3.0 与 Module Federation 2.0 深度集成,支持 MF SSR 和应用级别模块导出。

MF SSR

Modern.js 3.0 支持在 SSR 应用中使用 Module Federation,组合使用模块联邦和服务端渲染能力,为用户提供更好的首屏性能体验。

modern.config.ts

export default defineConfig({
  server: {
    ssr: {
      mode: 'stream',
    },
  },
});

配合 Module Federation 的数据获取能力,每个远程模块都可以定义自己的数据获取逻辑:

src/components/Button.data.ts

export const fetchData = async () => {
  return {
    data: `Server time: ${new Date().toISOString()}`,
  };
};

src/components/Button.tsx

export const Button = (props: { mfData: { data: string } }) => {
  return <button>{props.mfData?.data}</button>;
};

应用级别模块

不同于传统的组件级别共享,Modern.js 3.0 支持导出应用级别模块——具备完整路由能力、可以像独立应用一样运行的模块。这是微前端场景中的重要能力。

生产者导出应用

src/export-App.tsx

import '@modern-js/runtime/registry/index';
import { render } from '@modern-js/runtime/browser';
import { createRoot } from '@modern-js/runtime/react';
import { createBridgeComponent } from '@module-federation/modern-js/react';

const ModernRoot = createRoot();
export const provider = createBridgeComponent({
  rootComponent: ModernRoot,
  render: (Component, dom) => render(Component, dom),
});

export default provider;

消费者加载应用

src/routes/remote/$.tsx

import { createRemoteAppComponent } from '@module-federation/modern-js/react';
import { loadRemote } from '@module-federation/modern-js/runtime';

const RemoteApp = createRemoteAppComponent({
  loader: () => loadRemote('remote/app'),
  fallback: ({ error }) => <div>Error: {error.message}</div>,
  loading: <div>Loading...</div>,
});

export default RemoteApp;

通过通配路由 $.tsx,所有访问 /remote/* 的请求都会进入远程应用,远程应用内部的路由也能正常工作。

更多 Module Federation 的使用方式,请查看文档:Module Federation


技术栈更新

TL;DR:Modern.js 3.0 升级 React 19,最低支持 Node.js 20。

React 19

Modern.js 3.0 新项目默认使用 React 19,最低支持 React 18。

如果你的项目仍在使用 React 16 或 React 17,请先参考 React 19 官方升级指南 完成版本升级。

Node.js 20

随着 Node.js 不断推进版本演进,Node.js 18 已经 EOL。在 Modern.js 3.0 中,推荐使用 Node.js 22 LTS,不再保证对 Node.js 18 的支持。

Storybook Rsbuild

在 Modern.js 3.0 中,我们基于 Storybook Rsbuild 实现了使用 Storybook 构建 Modern.js 应用。

通过 Storybook Addon,我们将 Modern.js 配置转换合并为 Rsbuild 配置,并通过 Storybook Rsbuild 驱动构建,让 Storybook 调试与开发命令保持配置对齐。

更多 Storybook 使用方式,请查看文档:使用 Storybook

使用 Biome

随着社区技术不断发展,更快、更简洁的工具链已经成熟。在 Modern.js 3.0 中,新项目默认使用 Biome 作为代码检查和格式化工具。


从 Modern.js 2.0 升级到 3.0

主要变更

升级 Modern.js 3.0 意味着拥抱更轻量、更标准的现代化开发范式。通过全面对齐 Rspack 与 React 19 等主流生态,彻底解决历史包袱带来的维护痛点,显著提升构建与运行性能。

未来,我们也会基于 Modern.js 3.0 提供更多的 AI 集成与最佳实践,配合灵活的全栈插件系统,让开发者能以极低的学习成本复用社区经验,实现开发效率的质变与应用架构的现代化升级。

更多改进与变更,请查看文档:升级指南

反馈和社区

最后,再次感谢每一位给予我们反馈和支持的开发者,我们将继续与大家保持沟通,在相互支持中共同成长。

如果你在使用过程中遇到问题,欢迎通过以下方式反馈:

🧠 空数组的迷惑行为:为什么 every 为真,some 为假?

2026年2月25日 09:37

一、前言

Hello~大家好。我是秋天的一阵风

在 JavaScript 开发中,everysome是我们日常处理数组时高频用到的两个数组方法,用法简单、逻辑直观,一直是前端处理数组判断的好帮手。但不少开发者在接触空数组的场景时,都会对一个现象感到困惑:

console.log([].every(item => item > 0)); // true 
console.log([].some(item => item > 0)); // false

同样是空数组,调用两个逻辑相近的方法,结果却截然相反。这并不是 JavaScript 的设计漏洞,而是背后遵循了严谨的数学逻辑。

与其只记着 “空数组 every 返回 true、some 返回 false” 这个结论就完事,不如跟着这篇内容,从数学逻辑到手写源码,把这个知识点掰扯透。

我之前还写过一篇《给我十分钟,手把手教你实现 Javascript 数组原型对象上的七个方法》,里面把 forEach、map、reduce 这些常用数组方法的实现思路拆得明明白白,和这篇讲的内容是一个思路,看完这篇再去翻那篇,能把 JS 数组的底层逻辑摸得更透。

二、解开疑惑

很多人第一次发现这个现象时,会觉得是 JavaScript 的特殊约定,其实不然,everysome的返回值逻辑,本质上是继承了数理逻辑中的量词规则,这也是这两个方法设计的底层依据。

1. every:对应全称量词的 “平凡真”

every的核心语义是 “数组中所有元素都满足某个条件”,对应数学中的全称量词(∀) 。在数理逻辑里有个 “平凡真” 的概念,简单说就是:如果一个集合是空集,那么 “这个集合里所有元素满足某条件” 这个说法,本身是成立的。

举个通俗的例子,我们说 “空盒子里的所有苹果都是红的”,因为盒子里根本没有苹果,也就不存在 “非红色的苹果” 来推翻这个说法,所以这个命题自然是真的。这也是[].every(...)会返回 true 的根本原因,是逻辑上的必然结果。

2. some:对应存在量词的 “平凡假”

some的核心语义是 “数组中至少有一个元素满足某个条件” ,对应数学中的存在量词(∃) 。同理,空集合里没有任何元素,自然不可能找到满足条件的那个元素,就像说 “空盒子里有一个红苹果”,显然是不成立的。所以[].some(...)返回 false,也是存在判断的必然结果。

光懂理论还不够,对于开发者来说,看得见的代码实现远比抽象的概念更易理解。接下来我们就用原生 JS 复刻这两个方法的核心实现,从代码层面看清楚背后的逻辑。

三、源码拆解:兜底值,是结果不同的关键

ECMAScript 规范中,对Array.prototype.everyArray.prototype.some的执行逻辑有明确定义,我们复刻的核心实现完全贴合原生逻辑,这也是理解原生方法最直接的方式 —— 亲手实现一遍,比看十遍文档更管用。

1. 复刻 Array.prototype.every

every的核心思路很简单:

  • 先给一个 “真” 的初始兜底值,遍历数组时只要遇到一个不满足条件的元素,就立刻把结果置为假并终止遍历;
  • 如果遍历完都没有反例,就保留初始的真。
Array.prototype.myEvery = function (callback, thisArg) {
  // 校验回调函数的合法性
  if (typeof callback !== 'function') {
    throw new TypeError(`${callback} is not a function`);
  }

  const arr = this;
  const len = arr.length;
  // 核心:初始兜底值设为true
  let result = true;

  // 空数组的len为0,会直接跳过这个循环
  for (let i = 0;< len; i++) {
    // 处理稀疏数组,跳过不存在的索引
    if (!arr.hasOwnProperty(i)) continue;
    // 执行回调并绑定this指向
    const isPass = callback.call(thisArg, arr[i], i, arr);
    // 有一个不满足,直接置假并终止遍历
    if (!isPass) {
      result = false;
      break;
    }
  }

  // 空数组直接返回初始的兜底值true
  return result;
};

// 测试,和原生方法结果完全一致
console.log([].myEvery(item => item > 0)); // true
console.log([1,2,3].myEvery(item => item > 0)); // true
console.log([1,-2,3].myEvery(item => item > 0)); // false

从代码里能清晰看到,空数组因为长度为 0,会直接跳过遍历循环,最终返回一开始设定的兜底值 true,这就是空数组调用 every 返回 true 的代码实锤。

2. 复刻 Array.prototype.some

some的实现思路和every呼应,只是初始兜底值做了调整:先给一个 “假” 的初始兜底值,遍历数组时只要遇到一个满足条件的元素,就立刻把结果置为真并终止遍历;如果遍历完都没有正例,就保留初始的假。

Array.prototype.mySome = function (callback, thisArg) {
  // 校验回调函数的合法性
  if (typeof callback !== 'function') {
    throw new TypeError(`${callback} is not a function`);
  }

  const arr = this;
  const len = arr.length;
  // 核心:初始兜底值设为false
  let result = false;

  // 空数组同样会直接跳过循环
  for (let i = 0; i< len; i++) {
    // 处理稀疏数组,跳过不存在的索引
    if (!arr.hasOwnProperty(i)) continue;
    // 执行回调并绑定this指向
    const isPass = callback.call(thisArg, arr[i], i, arr);
    // 有一个满足,直接置真并终止遍历
    if (isPass) {
      result = true;
      break;
    }
  }

  // 空数组直接返回初始的兜底值false
  return result;
};

// 测试,和原生方法结果完全一致
console.log([].mySome(item => item > 0)); // false
console.log([1,2,3].mySome(item => item > 5)); // false
console.log([1,6,3].mySome(item => item > 5)); // true

对比两个方法的实现代码,唯一的核心差异就是初始兜底值

  • every以 true 为兜底,没遇到反例就一直为真;
  • some以 false 为兜底,没遇到正例就一直为假。

空数组因为跳过了遍历,直接返回兜底值,这就是二者结果不同的根本原因。

四、开发中要注意的业务逻辑细节

理解了理论和源码,最终还是要落地到实际开发中。空数组的这个特性,在表单校验、列表筛选、数据判断等场景中,很容易因为忽略而引发小 bug,只要稍作处理就能避免。

典型场景:空列表的条件判断

举个电商开发的例子,我们需要校验购物车中的商品是否全部满足包邮条件(价格 > 100),满足的话就显示包邮按钮。如果直接写判断,就容易出问题:

// 考虑不周的写法:未判断数组是否为空
const cartList = []; // 用户还没加购任何商品
if (cartList.every(item => item.price > 100)) {
  showFreeShippingBtn(); // 会执行!因为空数组every返回true
}

显然,用户购物车为空时,不应该显示包邮按钮,这就是把逻辑上的 “真”,和业务上的 “合法” 搞混了。

正确解法:先校验数组非空,再做条件判断

无论使用every还是some,只要业务场景要求 “有数据的集合”,就先判断数组的长度,再执行后续的条件校验,这是最稳妥的方式。

// 严谨的写法:先判断数组非空,再执行判断
const cartList = [];
if (cartList.length > 0 && cartList.every(item => item.price > 100)) {
  showFreeShippingBtn();
} else if (cartList.length === 0) {
  showEmptyCartTip(); // 给用户展示空购物车提示,体验更好
}

再比如用some判断列表中是否有过期优惠券,虽然空数组返回 false 本身符合 “没有过期优惠券” 的逻辑,但如果需要区分 “空列表” 和 “有列表但无过期”,还是要单独判断:

const coupons = [];
if (coupons.some(item => item.isExpired)) {
  showExpiredTip();
} else if (coupons.length === 0) {
  showNoCouponTip(); // 空优惠券列表的专属提示
} else {
  showAllValidTip(); // 有优惠券且都未过期的提示
}

五、总结

其实空数组下every返真、some返假的现象,一点都不复杂,总结起来就是两层核心逻辑:

  1. 数学层面every是全称判断,空集合满足 “平凡真”;some是存在判断,空集合满足 “平凡假”,这是方法设计的底层依据;
  2. 代码层面every的初始兜底值为 true,some为 false,空数组会跳过遍历,直接返回兜底值。

希望看完这篇文章,你再遇到everysome的空数组场景时,能不再困惑,从容应对~

TypeScript 类型体操:如何精准控制可选参数的“去留”

作者 火车叼位
2026年2月26日 14:15

在 TypeScript 的日常开发中,我们经常为了灵活性而将接口(Interface)或类型(Type)的属性定义为可选(使用 ? 修饰符)。但在某些特定场景下,例如配置初始化完成、表单提交前验证或 API 响应处理后,我们需要确保这些属性已经存在,即将其转换为“必选”状态。

这种转换不仅能提供更好的代码提示,还能在编译阶段规避大量的 nullundefined 检查。本文将由浅入深介绍四种主流的转换方案。

1. 全局转换:使用内置工具类型 Required<T>

TypeScript 自 2.8 版本起引入了 Required<T>,这是最直接的方案。它会遍历类型 T 的所有属性,并移除每个属性末尾的可选修饰符。

interface UserProfile {
  id: string;
  name?: string;
  email?: string;
}

// 转换后:id, name, email 全部变为必选
type StrictUser = Required<UserProfile>;

const user: StrictUser = {
  id: "001",
  name: "张三",
  email: "zhangsan@example.com" // 缺少任何一个都会报错
};

适用场景:当你需要对整个对象进行“严格化”处理时,这是首选方案。


2. 精准打击:仅转换特定属性为必选

在实际业务中,我们往往只需要确保某几个关键字段存在,而保留其他字段的可选性。这时可以结合 PickOmitRequired 构建一个复合工具类型。

我们可以定义一个通用的 MarkRequired 类型:

/**
 * T: 原类型
 * K: 需要转为必选的键名联合类型
 */
type MarkRequired<T, K extends keyof T> = 
  Omit<T, K> & Required<Pick<T, K>>;

interface Config {
  host?: string;
  port?: number;
  protocol?: 'http' | 'https';
}

// 示例:仅让 host 变为必选,port 和 protocol 依然可选
type EssentialConfig = MarkRequired<Config, 'host'>;

const myConfig: EssentialConfig = {
  host: "localhost" // port 和 protocol 可选填
};

原理解析:该方法先用 Omit 剔除目标属性,再用 Pick 选出目标属性并通过 Required 转为必选,最后通过交叉类型 & 进行合并。


3. 深入底层:使用映射类型中的 -? 符号

如果你正在尝试编写自己的类型库,了解映射类型(Mapped Types)的修饰符至关重要。在 TypeScript 中,+- 可以作为前缀应用于 ?readonly 修饰符。

type MyRequired<T> = {
  // -? 表示显式地移除可选属性标记
  [P in keyof T]-?: T[P];
};

// 与此相对,+?(通常简写为 ?)用于增加可选标记
type MyPartial<T> = {
  [P in keyof T]+?: T[P];
};

技术要点:使用 -?Required<T> 的底层实现原理。它不仅能去除问号,在处理一些复杂的条件类型映射时,这种手动控制的能力非常强大。


4. 函数参数与深度嵌套处理

函数参数转换

对于函数,最稳妥的方法是在重载或重新定义时直接移除 ?。但在高阶函数或泛型约束中,如果你想约束传入的函数必须接受必选参数,可以利用上述类型工具。

深度嵌套(Deep Required)

内置的 Required 只能处理第一层属性。如果对象是深层嵌套的,你需要递归处理:

type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object 
    ? DeepRequired<T[P]> 
    : T[P];
};

interface NestedConfig {
  db?: {
    user?: string;
    pwd?: string;
  }
}

type StrictNested = DeepRequired<NestedConfig>;

建议:在处理极其复杂的深层转换时,推荐使用社区成熟的库如 ts-essentials,其 DeepRequired 经过了大量边缘情况的验证。


结论与行动建议

根据不同的工程需求,建议采取以下策略:

  1. 立即可做:检查项目中的配置对象或 API 聚合层,使用 Required<T> 替代繁琐的非空断言(!)。
  2. 最佳实践:为了保持代码的 DRY(Don't Repeat Yourself)原则,建议在项目的 types/utils.d.ts 中收藏 MarkRequired 工具类型,用于处理部分属性必选的场景。
  3. 注意性能:过度使用复杂的递归类型(如 DeepRequired)可能会增加 TypeScript 编译器的负担,在大型项目中应谨慎评估其影响范围。

深入React源码:解析setState的批量更新与异步机制

作者 QLuckyStar
2026年2月26日 13:48

在 React 中,setState 的同步或异步行为取决于其调用的上下文环境。以下是详细分析:


一、同步更新场景

  1. 原生事件或非 React 控制的异步回调

    • 原生 DOM 事件:如 addEventListener('click', ...) 绑定的事件处理函数中调用 setState,会直接同步更新状态。

    • 定时器或 Promise:在 setTimeoutsetInterval 或 Promise.then() 中调用 setState,由于脱离 React 的控制流,状态更新会立即执行。

    • 示例

      // 原生事件中同步更新
      componentDidMount() {
        document.addEventListener('click', () => {
          this.setState({ count: 1 }, () => console.log('同步更新:', this.state.count));
        });
      }
      
  2. 直接修改 state 的引用
    若通过 this.state 直接修改(不推荐),会绕过 React 的状态管理机制,表现为同步,但可能导致不可预测的渲染问题。


二、异步更新场景

  1. React 控制的合成事件或生命周期方法

    • 合成事件:如 onClickonChange 等 React 封装的事件处理函数中,setState 会被批量处理,更新延迟到事件循环末尾。

    • 生命周期方法:如 componentDidMountshouldComponentUpdate 中调用 setState,同样触发批量更新。

    • 示例

      // 合成事件中异步更新
      handleClick = () => {
        this.setState({ count: this.state.count + 1 });
        console.log('异步更新前:', this.state.count); // 输出旧值
      };
      
  2. React 18 的自动批处理(Automatic Batching)

    • React 18 默认对所有更新(包括 Promise、原生事件等)进行批处理,即使是非 React 控制的上下文,setState 也可能表现为异步。

    • 需通过 flushSync 强制同步更新:

      import { flushSync } from 'react-dom';
      flushSync(() => {
        this.setState({ count: 1 });
      });
      

三、控制同步/异步的机制

  1. **批量更新标志 isBatchingUpdates**

    • React 内部通过 isBatchingUpdates 变量控制是否合并更新。默认情况下,React 控制的上下文中该值为 true,触发异步更新;其他场景为 false,直接同步更新。
    • 批量更新优化了性能,避免多次渲染(如连续多次 setState 仅触发一次渲染)。
  2. 函数式更新与回调函数

    • 函数式更新:通过传入函数 (prevState) => newState,可确保基于最新状态更新,避免因异步导致的竞态条件。

    • 回调函数:在 setState 的第二个参数中传入回调函数,可在状态更新完成后执行逻辑。

      this.setState(
        { count: this.state.count + 1 },
        () => console.log('更新完成:', this.state.count)
      );
      

四、React 18 的变化

  • 自动批处理增强:React 18 进一步扩大了批处理范围,即使是非 React 控制的异步操作(如 PromiseMutationObserver)也会合并更新。
  • **flushSync 的必要性**:若需强制同步更新,需显式调用 flushSync,但需谨慎使用以避免性能问题。

总结

场景 同步/异步 原因
原生事件、定时器、Promise 同步 脱离 React 控制流,无批量更新机制。
合成事件、生命周期方法 异步 React 控制上下文,启用批量更新优化性能。
函数式更新或回调函数 逻辑同步 函数式更新基于最新状态,回调函数在更新完成后执行。

通过理解上下文和机制,开发者可合理选择同步或异步策略,避免状态更新引发的渲染问题。

程序员都该掌握的“质因数分解”

作者 JYeontu
2026年2月26日 11:55

说在前面

还记得小学数学课上的“质因数分解”吗?这个看似基础的概念,实际上是现代数论的基石。在草稿纸上进行 质因数分解 大家应该都会,那怎么通过代码来实现呢?它又能解决什么问题?

什么是质因数分解?

概念

质因数分解 = 把一个合数,拆成「若干个质数相乘」的形式

例子

12 = 2 × 2 × 3
  • 2、3 都是质数(只能被 1 和自己整除)
  • 12 是合数(能继续拆)
  • 拆到不能再拆,只剩质数,就叫质因数分解

定理

数学里有一条超级重要的定理:

任何一个大于 1 的整数,只有唯一一种质因数分解方式

怎么做质因数分解?

最实用、最好用的方法:短除法

步骤

  • 1.从最小的质数 2 开始试
  • 2.能除就除,除到不能除为止
  • 3.再换下一个质数 3、5、7、11…
  • 4.直到最后结果是 1

例子

180 进行质因数分解

180 ÷ 2 = 90
90  ÷ 2 = 45
45  ÷ 3 = 15
15  ÷ 3 = 5
5   ÷ 5 = 1

所以: 180 = 2² × 3² × 5¹

质因数分解有什么用?

1. 将“乘除”降维成“加减”

在编程中进行算数乘除运算很容易会遇到两个问题:

  • 数字溢出:几个数一相乘,结果可能超出计算机能表示的最大整数范围

  • 精度丢失:一旦引入除法,就可能出现小数,而浮点数的存储和比较天生存在精度误差
1 / 6 * 5 * 5 * 2 * 3

上面这个式子我们快速过一遍不难看出最后的结果应该是 25,但是电脑算出来的结果却是 24.999999999999996

质因子分解 便可以比较优雅的避免这两个问题

例子

我们可以把每个数字“升维”,用一个指数向量来表示它:

12 = 2² × 3¹ × 5⁰ => 向量 [2, 1, 0]
10 = 2¹ × 3⁰ × 5¹ => 向量 [1, 0, 1]
  • 乘法 → 向量加法 12 × 10 = 120 对应的向量运算是:[2, 1, 0] + [1, 0, 1] = [3, 1, 1]

    验证一下:120 = 8 × 3 × 5 = 2³ × 3¹ × 5¹。向量正是 [3, 1, 1]

  • 除法 → 向量减法 120 / 10 = 12 对应的向量运算是:[3, 1, 1] - [1, 0, 1] = [2, 1, 0]

    结果 [2, 1, 0] 正是 12 的向量表示

通过质因数分解,我们可以将复杂的、易出错的乘除法,转换成了简单、精确的整数加减法。

2.最大公因数、最小公倍数

辗转相除法 求最大公因数大家都知道吧,那质因数分解 也能求最大公因数你们知道吗?

比如求 1830GCDLCM

分解

18 = 2¹ × 3²
30 = 2¹ × 3¹ × 5¹

求最大公因数 (GCD)

取每个公共质因子的最低次幂,然后相乘

  • 公共质因子是 23
  • 2 的最低次幂是 min(1, 1) = 1
  • 3 的最低次幂是 min(2, 1) = 1
  • GCD = 2¹ × 3¹ = 6

求最小公倍数 (LCM)

取所有出现过的质因子的最高次幂,然后相乘

  • 所有质因子是 2, 3, 5
  • 2 的最高次幂是 max(1, 1) = 1
  • 3 的最高次幂是 max(2, 1) = 2
  • 5 的最高次幂是 max(0, 1) = 1
  • LCM = 2¹ × 3² × 5¹ = 90

3.现代密码学的基石

我们每天都在使用的 HTTPS、网上银行、数字签名,其安全性的根基,都与质因数分解的“不对称性”有关。

RSA 加密算法。其核心思想可以通俗地理解为:

给你两个巨大的质数 pq,让你把它们乘起来得到 N,这在计算上非常容易。 但是,反过来,只告诉你乘积 N,让你找出原始的 pq 是什么,这在计算上极其困难。

代码实现

说了这么多,那我们如何用代码来实现质因数分解呢?其实非常简单:

/**
 * 对一个正整数进行质因数分解
 * @param {number} n - 需要分解的正整数
 * @returns {Map<number, number>} - 返回一个 Map,键是质因子,值是其指数
 */
function primeFactorize(n) {
  if (n <= 1) {
    return new Map();
  }
  const factors = new Map();
  // 不断除以2,处理所有偶数因子
  while (n % 2 === 0) {
    factors.set(2, (factors.get(2) || 0) + 1);
    n /= 2;
  }
  // 从3开始遍历奇数,直到 n 的平方根
  // 如果 n 有一个大于其平方根的因子,必然会有一个小于其平方根的因子
  for (let i = 3; i * i <= n; i += 2) {
    while (n % i === 0) {
      factors.set(i, (factors.get(i) || 0) + 1);
      n /= i;
    }
  }
  // 如果最后 n 还大于1,那么 n 本身也是一个质数
  if (n > 1) {
    factors.set(n, (factors.get(n) || 0) + 1);
  }
  return factors;
}
console.log(primeFactorize(120)); 
// 输出: Map { 2 => 3, 3 => 1, 5 => 1 }
console.log(primeFactorize(999));
// 输出: Map {3 => 3, 37 => 1}

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容~

发送 加群 还能加入前端交流群,和大家一起讨论技术、分享经验,偶尔也能摸鱼聊天~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

有了HTML、CSS、JS为什么还需要React?

2026年2月26日 11:46

在前端开发的日常工作中,HTML负责结构、CSS负责样式、JavaScript负责交互,三者似乎已经构成了完整的开发体系。但随着Web应用复杂度的提升,开发者逐渐发现,传统的开发方式在处理动态交互、状态管理等场景时,会遇到越来越多的挑战。React的出现,为这些问题提供了全新的解决方案。本文将从简单功能入手,逐步深入,探讨React相比原生开发的核心优势。

一、简单部分:从基础计数器说起

我们先从一个最基础的功能——“点击按钮增加计数”说起,对比原生HTML+JS与React的实现差异。

1. 原生HTML+JS实现(命令式编程)

<!-- c:\Users\Administrator\Desktop\React-review\react-html\1.html#L1-25 -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>计数器</title>
</head>
<body>
    <div class="card">
        <button id="countBtn">count is 0</button>
    </div>
    <script>
        let count = 0;
        const btn = document.getElementById('countBtn');
        btn.addEventListener('click', function () {
            count = count + 1;
            btn.textContent = `count is ${count}`;
        })
    </script>
</body>
</html>

分析
在原生实现中,我们需要:

  • 声明一个全局变量 count 存储状态;
  • 通过 getElementById 获取DOM元素;
  • 使用 addEventListener 绑定点击事件;
  • 在事件回调中手动更新 count 值,并通过 textContent 修改DOM内容。

这种方式是命令式编程:开发者需要明确告诉浏览器“每一步该做什么”——如何获取元素、如何更新状态、如何修改DOM。

2. React实现(声明式编程)

// c:\Users\Administrator\Desktop\React-review\react-html\react-demo\src\App.jsx#L1-15
import { useState } from 'react'

function App() {
  const [count, setCount] = useState(0)

  return (
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>

  </div>
  )
}

export default App

分析
在React实现中,我们需要:

  • 通过 useState 钩子声明状态 count 和更新函数 setCount
  • 在JSX中直接使用 {count} 渲染状态值;
  • 通过 onClick 属性绑定事件,调用 setCount 更新状态。

这种方式是声明式编程:开发者只需描述“UI应该是什么样的”——当状态 count 变化时,按钮文本应显示新的计数,而不需要关心“如何更新DOM”。React会自动处理状态变化到UI更新的过程。

3. 简单场景下React的优势

对比两种实现,即使是最基础的计数器功能,React也展现出明显优势:

  • 代码更简洁:无需手动获取DOM元素、绑定事件监听器或修改DOM内容,JSX语法将结构与逻辑融合,减少冗余代码。
  • 状态管理更清晰:通过 useState 钩子管理状态,避免了全局变量的使用(全局变量在复杂应用中易引发冲突)。
  • 事件处理更直观:通过 onClick 等属性直接绑定事件回调,无需调用 addEventListener,代码可读性更高。
  • 自动DOM更新:当状态变化时,React会自动重新渲染组件,开发者无需手动操作DOM,减少了出错的可能性。

二、复杂部分:从“单功能”到“复杂应用”

当应用功能从“基础计数器”扩展到“带历史记录的计数器”时,原生开发与React的差异会更加明显,React的优势也会进一步凸显。

1. 原生HTML+JS实现复杂功能的挑战

假设我们要为计数器添加“历史记录”功能(记录每次点击后的计数),原生实现可能会像这样(参考之前的完整代码):

let count = 0;
let history = [];
const countEl = document.getElementById('count');
const historyEl = document.getElementById('history');

function update() {
  countEl.textContent = count;  // 更新计数
  
  historyEl.innerHTML = '';   // 清空历史列表
  history.forEach((num, i) => {
    const li = document.createElement('li');
    li.textContent = `#${i + 1}: ${num}`;
    historyEl.appendChild(li);  // 逐个重建
  });
}

document.getElementById('add').onclick = () => {
  count++;
  history.push(count);
  update();  // 每次都要手动调用更新
};

document.getElementById('clear').onclick = () => {
  history = [];
  update();
};

挑战

  • 手动DOM操作繁琐:每次状态变化都需要调用 update() 函数,手动清空并重建历史列表,代码冗余且易出错。
  • 状态与UI同步复杂:当状态(counthistory)变化时,需要开发者手动确保所有相关UI元素(计数显示、历史列表)同步更新。
  • 代码可维护性差:随着功能增加,状态管理和DOM操作会混杂在一起,代码会变得越来越难以理解和维护。

2. React实现复杂功能的简洁性

同样的“带历史记录的计数器”功能,React的实现如下(参考之前的完整代码):

import { useState } from 'react';

function App() {
  const [count, setCount] = useState(0);
  const [history, setHistory] = useState([]);

  return (
    <>
      <p>当前: {count}</p>
      <button onClick={() => {
        setCount(count + 1);
        setHistory([...history, count + 1]);
      }}>+1</button>
      <button onClick={() => setHistory([])}>清空历史</button>

      <ul>
        {history.map((num, i) => (
          <li key={i}>#{i + 1}: {num}</li>
        ))}
      </ul>
    </>
  );
}

export default App;

优势

  • 自动UI更新:当 counthistory 状态变化时,React会自动重新渲染组件,无需手动调用更新函数。
  • 状态管理集中:通过 useState 分别管理 counthistory 状态,逻辑清晰,互不干扰。
  • 声明式渲染:历史列表通过 history.map() 直接渲染,无需手动创建和追加DOM元素,代码更简洁。
  • 组件化思想:相关的状态和逻辑被封装在 App 组件中,便于复用和维护(例如,可将历史列表拆分为独立组件)。

3. React的核心优势:从“功能实现”到“架构设计”

当应用复杂度进一步提升时,React的核心优势会更加突出:

  • 虚拟DOM与性能优化:React通过虚拟DOM技术,只更新变化的部分(而非整个DOM树),大幅提升渲染性能。例如,当历史列表增加一项时,React只会添加新的 <li> 元素,而不会重建整个列表。
  • 组件化与代码复用:React的组件化思想允许将UI拆分为独立、可复用的组件(如按钮、列表项等),减少重复代码,提高开发效率。例如,一个复杂的表单可以拆分为输入框、下拉菜单等多个组件。
  • 生态系统与工具链:React拥有庞大的生态系统,如React Router(路由管理)、Redux(状态管理)、Material-UI(UI组件库)等,为开发复杂应用提供了完整的解决方案。此外,Create React App、Vite等工具简化了项目搭建和构建过程。
  • 跨平台能力:通过React Native,开发者可以使用React的语法和思想构建原生移动应用(iOS/Android),实现“一次编写,多处运行”,降低了跨平台开发的成本。

三、结论:React的价值与适用场景

从基础的计数器功能到复杂的Web应用,React通过声明式编程、自动DOM更新、组件化思想等核心特性,大幅简化了前端开发流程,提高了代码的可维护性和应用性能。

当然,这并不意味着React适用于所有场景:

  • 对于简单的静态页面(如公司官网、个人博客),原生HTML+JS可能仍然是更直接的选择,因为React的脚手架和依赖会增加项目的复杂度。
  • 对于需要频繁交互、处理复杂状态的现代Web应用(如电商平台、管理系统、社交应用),React则能充分发挥其优势,显著提升开发效率和用户体验。

总之,React的出现不是为了替代HTML、CSS、JS,而是为了在这些基础技术之上,提供一种更高效、更可维护的开发方式。正如我们从“简单计数器”到“复杂应用”的分析中看到的,React的价值在于它让开发者能够更专注于业务逻辑的实现,而不是陷入繁琐的DOM操作和状态管理中——这也是它成为当今最流行的前端框架之一的根本原因。

❌
❌