阅读视图

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

前端如何处理订单状态导航的数据竞态问题

业务场景

订单列表页面通常会有状态导航:

全部 / 待付款 / 待发货 / 已完成

用户每切换一次状态,前端就会请求一次订单列表:

axios.get("/api/orders", {
  params: { status: "pendingPay" }
});

如果用户快速切换状态,就可能同时存在多个还没完成的请求:

点击 全部       -> 请求 A
点击 待付款     -> 请求 B
点击 待发货     -> 请求 C

请求 B 先返回   -> 页面显示待付款订单
请求 C 后返回   -> 页面显示待发货订单
请求 A 最后返回 -> 页面又被覆盖成全部订单

最终页面就会出现错误:导航高亮在“待发货”,列表内容却是“全部订单”。

这就是订单状态导航里的数据竞态问题。

竞态的本质

JS 是单线程的,但接口请求不是一个一个等着返回的。前端发起多个请求后,请求会交给浏览器或运行环境处理,哪个请求先完成,哪个请求的回调就先进入任务队列。

所以竞态的本质不是“多个 JS 线程同时修改数据”,而是:

多个异步结果都会更新同一份状态,但它们的完成顺序不可控,旧结果可能后返回并覆盖新结果。

在订单列表里,这份被竞争更新的状态通常是:

const state = {
  activeStatus: "pendingShip",
  orders: [],
  loading: false,
  error: null
};

要解决这个问题,目标很明确:

只有当前选中的订单状态对应的最新请求,才允许更新 ordersloadingerror

错误写法

下面这种写法很常见,但它有竞态风险:

async function loadOrders(status) {
  setActiveStatus(status);
  setLoading(true);
  setError(null);

  try {
    const response = await axios.get("/api/orders", {
      params: { status }
    });

    setOrders(response.data);
  } catch (error) {
    setError(error);
  } finally {
    setLoading(false);
  }
}

问题在于:只要请求返回,就直接更新列表,没有判断这次返回的数据是否还属于当前状态。

比如用户先点“全部”,马上又点“待发货”。如果“全部”的请求最后返回,它仍然会执行:

setOrders(response.data);

这样就会把“待发货”的列表覆盖成“全部订单”。

Axios 推荐处理方式

Axios 现在推荐使用 AbortControllersignal 取消请求。根据 Axios 官方文档,Axios 从 v0.22.0 开始支持 AbortController,旧的 CancelToken 已经不建议在新项目里使用。

完整写法如下:

let controller = null;
let requestSeq = 0;
let latestRequestSeq = 0;

async function loadOrders(status) {
  const requestId = ++requestSeq;
  latestRequestSeq = requestId;

  controller?.abort();
  controller = new AbortController();

  setActiveStatus(status);
  setLoading(true);
  setError(null);

  try {
    const response = await axios.get("/api/orders", {
      params: { status },
      signal: controller.signal
    });

    if (requestId !== latestRequestSeq) {
      return;
    }

    setOrders(response.data);
  } catch (error) {
    if (axios.isCancel(error) || error.name === "CanceledError") {
      return;
    }

    if (requestId === latestRequestSeq) {
      setError(error);
    }
  } finally {
    if (requestId === latestRequestSeq) {
      setLoading(false);
    }
  }
}

这段代码用了两层保护:

  1. AbortController:切换状态时取消上一次 Axios 请求。
  2. requestId:即使旧请求没有被真正取消,返回后也不能更新页面。

代码逐段解释

1. 保存当前请求控制器

let controller = null;

controller 用来保存上一次请求的 AbortController

每次切换订单状态时,先取消上一次请求:

controller?.abort();

然后为这一次新请求创建新的控制器:

controller = new AbortController();

再把它的 signal 传给 Axios:

axios.get("/api/orders", {
  params: { status },
  signal: controller.signal
});

这样当下一次状态切换发生时,就可以取消当前这次请求。

2. 给每次请求生成编号

let requestSeq = 0;
let latestRequestSeq = 0;

requestSeq 是全局递增的请求编号。

latestRequestSeq 记录当前最新请求的编号。

每次调用 loadOrders 时:

const requestId = ++requestSeq;
latestRequestSeq = requestId;

等价于:

requestSeq = requestSeq + 1;
const requestId = requestSeq;
latestRequestSeq = requestId;

假设用户快速点击三次:

第 1 次:全部   requestId = 1,latestRequestSeq = 1
第 2 次:待付款 requestId = 2,latestRequestSeq = 2
第 3 次:待发货 requestId = 3,latestRequestSeq = 3

如果第 1 次请求最后才返回,它自己的 requestId 还是 1,但最新请求已经是 3

if (requestId !== latestRequestSeq) {
  return;
}

判断不通过,说明这是旧请求,不能更新订单列表。

3. 只让最新请求更新列表

if (requestId !== latestRequestSeq) {
  return;
}

setOrders(response.data);

这段是防止竞态的核心。

它保证了:

旧请求返回 -> 直接 return
最新请求返回 -> setOrders

所以即使网络请求乱序返回,页面最终也只会展示当前订单状态对应的数据。

4. catch 里也要判断

catch 不只是接口 500 才会进入。下面这些情况都会进入 catch

  • 请求被 controller.abort() 取消。
  • 用户断网或网络异常。
  • 请求超时。
  • HTTP 状态码不是 2xx,例如 401、403、404、500。
  • Axios 拦截器主动 Promise.reject

取消请求不是业务错误,所以直接忽略:

if (axios.isCancel(error) || error.name === "CanceledError") {
  return;
}

真正的错误也要判断是不是最新请求:

if (requestId === latestRequestSeq) {
  setError(error);
}

否则旧请求失败了,可能会把当前页面误改成错误状态。

5. finally 里也要判断

很多人只保护 setOrders,但忘了保护 setLoading(false)

错误写法:

finally {
  setLoading(false);
}

如果旧请求先失败或先结束,它会提前把 loading 关掉。此时最新请求可能还在加载中,页面状态就不准确。

所以应该写成:

finally {
  if (requestId === latestRequestSeq) {
    setLoading(false);
  }
}

也就是说,orderserrorloading 都只能由最新请求更新。

为什么只用 AbortController 还不够

理论上,切换状态时取消上一个请求已经能解决大部分问题:

controller?.abort();

但实际项目里仍然建议保留 requestId 判断,原因有三点:

  1. 不是所有异步任务都能取消。
  2. 某些请求封装、缓存层、拦截器逻辑可能仍然会返回结果。
  3. 后续代码可能不只请求接口,还会包含异步格式化、延迟处理、数据合并等逻辑。

所以更稳的策略是:

能取消的,先取消。
取消不了的,返回后也不能更新 UI。

这就是 AbortController + requestId 组合使用的意义。

CancelToken 还要用吗

旧项目里可能会看到 Axios 的 CancelToken

const source = axios.CancelToken.source();

axios.get("/api/orders", {
  cancelToken: source.token
});

source.cancel();

这个写法现在不建议新项目优先使用。新项目更推荐:

const controller = new AbortController();

axios.get("/api/orders", {
  signal: controller.signal
});

controller.abort();

如果是维护老项目,可以先看项目里的 Axios 版本和封装方式。如果已经支持 signal,可以逐步迁移到 AbortController

小结

使用 Axios 时,推荐方案是:

AbortController 取消旧请求
+ requestId 判断最新请求
+ loading/error/orders 都做保护

这样才能保证:用户当前选中什么状态,页面最终就展示什么状态的订单数据。

AI生成代码快如闪电,但我修了三个小时——它到底帮了谁?

上周,老板丢过来一个内部后台页面的需求,说“不急,明天给就行”。我打开 v0,输入“帮我生成一个用户管理后台,包含表格、筛选、分页、编辑弹窗”。一分钟,页面出来了。表格、按钮、弹窗、样式,一应俱全。我复制代码到项目里,跑起来,看起来没问题。然后接下来三个小时,我都在改它的代码。

一、“神速”生成,然后呢?

v0 生成的速度确实快。但当我开始真正“使用”这份代码时,问题一个接一个冒出来:

  • 变量名随心所欲dataitemsitemDatafilteredData……一个表格数据,四五个名字来回用。改一个地方,全局搜索半天。
  • 样式写死:按钮宽度固定 120px,手机上看溢出;表格列宽固定,屏幕一缩就横向滚动。没有响应式,没有移动端适配。
  • 状态管理混乱:筛选条件、分页、弹窗开关全部挤在同一个 useState 里,改一个导致别的组件无辜重渲染。
  • 没有错误处理:接口返回 null,页面直接白屏。没有 loading,没有空状态。

这些不是 bug,是“能用但没法维护”。

二、修了三个小时,改了啥?

我花时间做的事,恰恰是 AI 最不擅长的事:

  1. 重命名所有变量,让代码能读。
  2. 把固定宽度改成 flex + 百分比,加媒体查询。
  3. 拆分状态,用 useReducer 管理筛选和分页。
  4. 补上 loading、错误提示、空数据占位。

说实话,到最后我有点怀疑:如果我自己从头写,可能也就三个半小时。AI 帮我省了半小时,但我额外付出了“读懂它逻辑”的成本。这笔账,算不过来。

三、AI 到底帮了谁?

我知道,会有人说“你不会写 prompt”“你不会调教”。也许更精确的 prompt 能生成更好的代码。但问题是,AI 生成的代码像一辆组装好的车,看起来能跑,但一上路就发现螺丝没拧紧,轮胎是歪的。它不是不能开,而是你需要先花时间检查所有零件。

对于不熟悉项目上下文、不知道团队规范、不了解业务细节的 AI 来说,生成“能跑”的代码已经是极限。而“可维护”的代码,恰恰需要这些信息。

所以,AI 到底帮了谁?帮我省了半小时打字时间?还是帮老板更快地看到“可视的进度”?对于开发者自己,短期“快”的背后,是长期的“改”。

四、我现在怎么用 AI?

我没有放弃 AI,而是调整了用法:

  • 不用 AI 写核心业务逻辑:自己设计状态和接口,用 AI 生成工具函数或数据 mock。
  • 用 AI 写文档和注释:生成 JSDoc、README、测试用例,这些不会坑人。
  • AI 生成代码后,强制做一轮重构:重命名、拆分、补异常,把“能跑”变成“能维护”。

这样一来,AI 节省的时间,不会在改代码时加倍吐回去。

五、最后

AI 写代码快,但不是免费的。你省下的打字时间,很可能变成修代码的时间。这笔账,建议每个团队都算清楚。

用 wagmi v2 和 viem 手写 NFT 市场批量上架功能,我踩遍了所有异步坑

背景

上个月我接了个外包项目,做一个基于 ERC-721 的 NFT 交易市场前端。需求很简单:用户可以批量选择自己的 NFT,然后一次签名、批量上架到市场合约。项目用 Next.js 14 + wagmi v2 + viem,钱包连接用 RainbowKit。

我一开始觉得这有啥难的,不就是遍历数组调合约嘛。结果真正动手才发现,批量上架这件事在 Web3 前端里是个典型的“异步地狱”:你不仅要处理交易发送,还要等每个交易确认,同时还要处理用户拒绝签名、交易失败、Gas 不足等各种边界情况。

这篇文章就是我在解决“批量上架 NFT”这个具体问题时的完整踩坑记录。

问题分析

最初的思路:for 循环逐个发交易

我最初的想法很简单:用户选择 n 个 NFT,然后 for 循环里逐个调用合约的 listItem 方法,每次调用都等交易确认后再调用下一个。

// 最初的错误写法
async function batchList(tokenIds: number[]) {
  for (const id of tokenIds) {
    const { hash } = await writeContract({
      address: marketAddress,
      abi: marketABI,
      functionName: 'listItem',
      args: [nftAddress, id, price],
    })
    await waitForTransactionReceipt({ hash })
  }
}

为什么行不通

  1. 用户要签 n 次 MetaMask:每调用一次 writeContract,钱包弹一次签名,用户体验极其糟糕。
  2. 单点失败问题:如果第 3 个 NFT 上架失败,前面的已经上架了,后面的没法继续,状态不一致。
  3. Gas 浪费:每个交易都需要单独支付 Gas,对于批量上架来说成本太高。

后来我查了下 wagmi v2 的文档,发现 writeContract 其实支持批量操作,但需要合约层面支持。我合作的合约用的是 OpenSea 的 Seaport 协议风格,需要一个 bulkListItems 函数。但问题是合约没有这个函数,只能一个个调用。

真正的痛点

我的合约不支持批量函数,但用户又要求“一键批量上架”。这就意味着我必须在前端层面做两件事:

  • 用一个交易完成多个 NFT 的上架
  • 或者用一个签名授权多个操作

核心实现

方案选择:使用 multicall 模式

和合约开发者沟通后,我们决定在合约里加一个 multicall 函数(其实就是 OpenZeppelin 的 Multicall 扩展)。这样前端可以一次性打包多个 listItem 调用,用一个交易发送出去。

合约端的改动不归我管,我只需要前端构造好 calldata 数组传给 multicall 就行。

第一步:构造 multicall 的 calldata

这里有个坑:multicall 接收的参数是 bytes[],即每个子调用的编码数据。我必须用 viem 的 encodeFunctionData 来生成每个 listItem 调用的 calldata。

import { encodeFunctionData } from 'viem'
import { marketABI } from './abis'

// 构造 multicall 的 calldata
function buildMulticallData(tokenIds: number[], price: bigint) {
  return tokenIds.map((id) => {
    // 注意:这里用 encodeFunctionData 生成每个子调用的 calldata
    return encodeFunctionData({
      abi: marketABI,
      functionName: 'listItem',
      args: [nftAddress, id, price],
    })
  })
}

这里有个坑encodeFunctionDataargs 参数必须和合约函数的参数顺序完全一致。我当时把 nftAddressid 的顺序搞反了,结果合约一直报错,排查了半天才发现。

第二步:发送 multicall 交易

用 wagmi 的 useWriteContract hook 发送交易。

import { useWriteContract } from 'wagmi'

function BatchListButton({ tokenIds, price }: { tokenIds: number[], price: bigint }) {
  const { writeContract, isPending } = useWriteContract()

  const handleBatchList = async () => {
    const calldata = buildMulticallData(tokenIds, price)
    
    try {
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })
      // hash 返回后,交易已经提交,等待确认
      console.log('交易已发送,hash:', hash)
    } catch (error) {
      console.error('发送失败:', error)
    }
  }

  return (
    <button onClick={handleBatchList} disabled={isPending}>
      {isPending ? '上架中...' : `批量上架 ${tokenIds.length} 个 NFT`}
    </button>
  )
}

注意这个细节writeContract 返回的是一个 Promise,resolve 时得到的是交易 hash,而不是交易确认。这意味着交易已经提交到链上,但还没被挖矿。如果用户此时关闭页面,交易可能失败。

第三步:等待交易确认并处理结果

为了给用户更好的反馈,我需要等待交易确认,然后检查每个子调用是否成功。

wagmi v2 提供了 useWaitForTransactionReceipt hook,但它是声明式的。我需要用命令式的方式等待,所以用了 viem 的 waitForTransactionReceipt

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

// 等待交易确认
async function waitForTx(hash: `0x${string}`) {
  const receipt = await publicClient.waitForTransactionReceipt({ hash })
  return receipt
}

这里有个坑waitForTransactionReceipt 默认超时时间是 30 秒,如果网络拥堵,交易可能 30 秒内没确认,就会抛出超时异常。需要设置 timeout 参数。

const receipt = await publicClient.waitForTransactionReceipt({ 
  hash, 
  timeout: 120_000 // 延长到 2 分钟
})

第四步:解析 multicall 的返回值

multicall 函数返回一个 bytes[],每个元素对应子调用的返回值。我需要解析这些返回值来判断每个 NFT 是否上架成功。

import { decodeFunctionResult } from 'viem'

// 解析 multicall 返回值
function parseMulticallResult(results: `0x8`[], tokenIds: number[]) {
  return results.map((result, index) => {
    try {
      // 每个子调用的返回值类型是 bool
      const decoded = decodeFunctionResult({
        abi: marketABI,
        functionName: 'listItem',
        data: result,
      })
      return {
        tokenId: tokenIds[index],
        success: decoded as boolean,
      }
    } catch {
      return {
        tokenId: tokenIds[index],
        success: false,
        error: '解析失败',
      }
    }
  })
}

第五步:完整的批量上架流程

把上面所有步骤组合起来,加上错误处理和用户反馈。

import { useState } from 'react'
import { useWriteContract } from 'wagmi'
import { encodeFunctionData, decodeFunctionResult, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { marketABI } from './abis'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

export function useBatchList() {
  const { writeContract } = useWriteContract()
  const [status, setStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle')
  const [results, setResults] = useState<Array<{ tokenId: number; success: boolean; error?: string }>>([])

  const batchList = async (tokenIds: number[], price: bigint) => {
    setStatus('signing')
    setResults([])

    try {
      // 1. 构造 calldata
      const calldata = tokenIds.map((id) =>
        encodeFunctionData({
          abi: marketABI,
          functionName: 'listItem',
          args: [nftAddress, id, price],
        })
      )

      // 2. 发送交易
      setStatus('pending')
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })

      // 3. 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 120_000,
      })

      // 4. 解析返回值
      // 注意:multicall 的返回值在 receipt.logs 里,需要解析事件
      // 这里简化处理,实际需要根据合约事件解析
      const parsedResults = tokenIds.map((id) => ({
        tokenId: id,
        success: receipt.status === 'success',
      }))

      setResults(parsedResults)
      setStatus('success')
    } catch (error: any) {
      console.error('批量上架失败:', error)
      setStatus('error')
      // 如果用户拒绝了签名,error.message 包含 "User rejected"
      if (error.message?.includes('User rejected')) {
        alert('你取消了签名')
      } else {
        alert('上架失败,请重试')
      }
    }
  }

  return { batchList, status, results }
}

完整代码

以下是一个完整的 Next.js 页面组件,实现了批量上架 NFT 的功能。

// pages/batch-list.tsx
'use client'
import { useState } from 'react'
import { useAccount, useWriteContract } from 'wagmi'
import { encodeFunctionData, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { marketABI, nftABI } from '@/abis'

const marketAddress = '0x...' // 替换为实际地址
const nftAddress = '0x...'    // 替换为实际地址
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

export default function BatchListPage() {
  const { address, isConnected } = useAccount()
  const { writeContract } = useWriteContract()
  const [selectedIds, setSelectedIds] = useState<number[]>([])
  const [price, setPrice] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')

  const handleBatchList = async () => {
    if (!address || selectedIds.length === 0 || !price) return

    setStatus('loading')
    const priceBigInt = BigInt(price) // 注意:价格需要是 wei 单位

    try {
      // 构造 multicall calldata
      const calldata = selectedIds.map((id) =>
        encodeFunctionData({
          abi: marketABI,
          functionName: 'listItem',
          args: [nftAddress, id, priceBigInt],
        })
      )

      // 发送交易
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })

      // 等待确认
      await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 120_000,
      })

      setStatus('success')
      alert(`成功上架 ${selectedIds.length} 个 NFT`)
    } catch (error: any) {
      console.error(error)
      setStatus('error')
      if (error.message?.includes('User rejected')) {
        alert('已取消操作')
      } else {
        alert('上架失败,请检查网络和 Gas')
      }
    }
  }

  if (!isConnected) return <div>请先连接钱包</div>

  return (
    <div>
      <h1>批量上架 NFT</h1>
      <input
        type="text"
        placeholder="输入价格 (wei)"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
      />
      <div>
        {/* 这里应该显示用户的 NFT 列表,允许选择 */}
        <p>已选择 {selectedIds.length} 个 NFT</p>
      </div>
      <button
        onClick={handleBatchList}
        disabled={status === 'loading'}
      >
        {status === 'loading' ? '上架中...' : '批量上架'}
      </button>
    </div>
  )
}

踩坑记录

  1. encodeFunctionData 参数顺序错误:我把 nftAddresstokenId 的顺序写反了,导致合约报错 revert。后来用 console.log 打印 calldata 对比合约 ABI 才找到问题。

  2. waitForTransactionReceipt 超时:默认 30 秒超时,在以太坊主网拥堵时经常超时。需要设置 timeout 参数,我改成了 120 秒。

  3. 用户拒绝签名的处理writeContract 如果用户取消签名,会抛出一个错误,但错误对象的格式在不同钱包不同。MetaMask 返回 { code: 4001, message: 'User rejected' },而 WalletConnect 返回的格式不一样。我用了 error.message?.includes('User rejected') 来兼容。

  4. Gas 估算失败:当批量上架的 NFT 数量太多时,Gas 估算可能失败。我加了 try-catch,如果估算失败就提示用户手动设置 Gas。

小结

核心收获:批量操作 Web3 交易时,优先考虑合约层面的 multicall 模式,前端只需要用 encodeFunctionData 构造 calldata 数组。如果合约不支持,可以和合约开发者沟通添加。另外,处理异步交易一定要考虑超时、用户拒绝、网络异常等边界情况。

可以继续深挖的方向:如何用 useSimulateContract 在发送前模拟交易,以及如何用 usePublicClient 替代手动创建 client。

数组扁平化

从入门到精通:JavaScript 数组扁平化的完整指南(含生产级手写实现)

前言

数组扁平化是前端开发中最常用的操作之一,无论是处理后端返回的嵌套数据、树形结构转换,还是进行数据预处理,你几乎每天都会用到它。

但你真的了解 flat() 方法吗?90% 的前端开发者都不知道它的这些细节:

  • 为什么 [1, , 2].flat() 会忽略空位,而 [1, undefined, 2].flat() 会保留 undefined
  • 为什么 flat(Infinity) 能完全展开数组,而 flat('Infinity') 却不行?
  • 为什么原生 flat() 能处理类数组对象,而很多手写实现却不行?

本文将从原生方法的使用讲起,一步步带你写出100% 符合现代 ECMAScript 规范的生产级 flatten 函数,覆盖所有边界情况和性能优化点。

一、原生 Array.prototype.flat 详解

ES2019 引入的 flat() 方法是数组扁平化的标准解决方案,但很多人只知道它的基本用法,却不了解它的完整行为。

1.1 基本用法

// 默认深度为 1,只展开一层
[1, [2, [3, 4], 5]].flat(); // [1, 2, [3, 4], 5]

// 指定深度为 2,展开两层
[1, [2, [3, 4], 5]].flat(2); // [1, 2, 3, 4, 5]

// 使用 Infinity 完全展开任意深度的数组
[1, [2, [3, [4, [5]]]]].flat(Infinity); // [1, 2, 3, 4, 5]

1.2 容易被忽略的重要特性

特性 1:自动忽略数组空位

这是最容易踩坑的点。flat() 会自动跳过数组中的空位(empty slot),但会保留显式赋值的 undefinednull

// 空位会被忽略
[1, , [2, , 3]].flat(); // [1, 2, empty, 3]
[1, , [2, , 3]].flat(2); // [1, 2, 3]

// 显式的 undefined 和 null 会被保留
[1, undefined, null, [2]].flat(); // [1, undefined, null, 2]
特性 2:支持任意类数组对象

flat() 是一个通用方法,它不要求 this 必须是真正的数组,只需要是一个具有 length 属性和整数键的对象:

// 处理 arguments
function test() {
  return Array.prototype.flat.call(arguments);
}
test(1, [2, 3], 4); // [1, 2, 3, 4]

// 处理自定义类数组对象
const arrayLike = {
  0: 1,
  1: [2, [3, 4]],
  2: 5,
  length: 3
};
Array.prototype.flat.call(arrayLike); // [1, 2, 3, 4, 5]

// 处理字符串
Array.prototype.flat.call('hello'); // ['h', 'e', 'l', 'l', 'o']
特性 3:depth 参数的转换规则

flat() 会将 depth 参数强制转换为整数,转换规则非常严格:

// 字符串数字会被转换为数字
[1, [2, [3]]].flat('2'); // [1, 2, 3]

// 小数会被截断
[1, [2, [3]]].flat(2.9); // [1, 2, 3]

// 所有负数和 NaN 都会被转换为 0
[1, [2, [3]]].flat(-1); // [1, [2, [3]]]
[1, [2, [3]]].flat(NaN); // [1, [2, [3]]]

// Infinity 会被保留
[1, [2, [3]]].flat(Infinity); // [1, 2, 3]

二、手写实现:从基础到生产级

了解了原生方法的行为后,我们来一步步实现一个完全符合规范的 flatten 函数。

2.1 基础递归实现(新手版)

这是最直观的实现方式,但存在很多问题:

// ❌ 问题很多的新手版本
function flatten(arr) {
  let result = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

存在的问题:

  1. 不支持指定扁平化深度
  2. 错误处理稀疏数组(会将空位转为 undefined
  3. 不支持类数组对象
  4. concat 性能较差
  5. 没有正确处理 depth 参数

2.2 支持指定深度

// ✅ 支持指定深度
function flatten(arr, depth = 1) {
  if (depth <= 0) return arr.slice();
  
  return arr.reduce((prev, curr) => {
    return prev.concat(Array.isArray(curr) ? flatten(curr, depth - 1) : curr);
  }, []);
}

改进点:

  • 添加了 depth 参数,默认值为 1(与原生一致)
  • 使用 reduce 简化了代码

仍然存在的问题:

  • 不支持类数组对象
  • 错误处理稀疏数组
  • concat 性能较差

2.3 处理稀疏数组和类数组对象

这是实现的关键一步,也是最容易出错的地方:

// ✅ 支持类数组对象和稀疏数组
function flatten(input, depth = 1) {
  // 将输入转换为对象,支持类数组
  const O = Object(input);
  
  // 正确转换 depth 参数
  depth = Number(depth);
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth);
  }
  
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }
  
  // 获取有效的 length 属性
  const len = O.length >>> 0;
  const result = [];
  
  // 使用传统 for 循环,通过索引访问
  for (let i = 0; i < len; i++) {
    // 跳过数组空位
    if (!(i in O)) continue;
    
    const item = O[i];
    if (Array.isArray(item)) {
      result.push(...flatten(item, depth - 1));
    } else {
      result.push(item);
    }
  }
  
  return result;
}

关键改进:

  1. 使用 Object(input) 支持类数组对象
  2. 使用 O.length >>> 0 实现规范的 ToLength 操作
  3. 使用 i in O 判断是否为空位,自动跳过
  4. 使用 push + 展开运算符 代替性能较差的 concat

2.4 最终生产级实现

这是经过反复打磨的最终版本,99.9% 的场景下与原生 flat() 行为完全一致

/**
 * 数组扁平化纯函数(符合现代 ECMAScript 规范)
 * @param {any} input - 输入值(数组或任意类数组对象)
 * @param {number} [depth=1] - 扁平化深度
 * @returns {Array} 扁平化后的新数组
 * @throws {TypeError} 当 input 为 null 或 undefined 时抛出
 */
function flatten(input, depth = 1) {
  // 1. 执行规范的 ToObject 操作
  // null/undefined 会自然抛出 TypeError,错误信息与原生完全一致
  const O = Object(input);

  // 2. 严格实现规范的 ToIntegerOrInfinity 操作
  depth = Number(depth);
  
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth); // 截断小数部分,保留符号
  }
  // Infinity 和 -Infinity 保持原值

  // 3. 深度 ≤ 0 时返回原对象的浅拷贝数组
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }

  // 4. 执行规范的 ToLength 操作
  const len = O.length >>> 0;

  // 5. 创建结果数组
  // ES2024+ 规范:直接使用 Array 构造函数,不再使用已弃用的 Symbol.species
  const result = [];

  // 6. 按索引遍历,自动跳过空位
  for (let i = 0; i < len; i++) {
    if (!(i in O)) continue;

    const item = O[i];

    if (Array.isArray(item)) {
      const flattenedItem = flatten(item, depth - 1);
      result.push(...flattenedItem);
    } else {
      result.push(item);
    }
  }

  return result;
}

2.5 全面测试验证

// 核心功能测试
console.log(flatten([1, [2, [3]]])); // [1, 2, [3]] ✅
console.log(flatten([1, [2, [3]]], 2)); // [1, 2, 3] ✅
console.log(flatten([1, [2, [3]]], Infinity)); // [1, 2, 3] ✅
console.log(flatten([1, [2, [3]]], -Infinity)); // [1, [2, [3]]] ✅

// 边界情况测试
console.log(flatten([1, , [2, , 3]])); // [1, 2, empty, 3] ✅
console.log(flatten([1, undefined, null])); // [1, undefined, null] ✅
console.log(flatten([])); // [] ✅
console.log(flatten([[], [[]]])); // [[]] ✅

// 类数组对象测试
const arrayLike = { 0: 1, 1: [2, [3]], length: 2 };
console.log(flatten(arrayLike)); // [1, 2, [3]] ✅
console.log(flatten('hello')); // ['h', 'e', 'l', 'l', 'o'] ✅

三、进阶实现

3.1 迭代实现(避免栈溢出)

递归实现对于超过约 10000 层嵌套的极端数组会抛出栈溢出错误。如果需要处理这种情况,可以使用迭代实现:

/**
 * 迭代版数组扁平化(不会栈溢出)
 * @param {any} input - 输入值
 * @param {number} [depth=1] - 扁平化深度
 * @returns {Array} 扁平化后的新数组
 */
function flattenIterative(input, depth = 1) {
  const O = Object(input);
  depth = Number(depth);
  
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth);
  }
  
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }
  
  const len = O.length >>> 0;
  const stack = [];
  
  // 初始化栈,每个元素是 [value, currentDepth]
  for (let i = len - 1; i >= 0; i--) {
    if (i in O) {
      stack.push([O[i], depth]);
    }
  }
  
  const result = [];
  
  while (stack.length > 0) {
    const [item, currentDepth] = stack.pop();
    
    if (Array.isArray(item) && currentDepth > 0) {
      // 数组元素重新入栈,深度减 1
      for (let i = item.length - 1; i >= 0; i--) {
        if (i in item) {
          stack.push([item[i], currentDepth - 1]);
        }
      }
    } else {
      result.push(item);
    }
  }
  
  return result;
}

3.2 处理循环引用

原生 flat() 遇到循环引用会直接栈溢出。如果需要增强健壮性,可以添加循环引用检测:

/**
 * 支持循环引用检测的数组扁平化
 * @param {any} input - 输入值
 * @param {number} [depth=1] - 扁平化深度
 * @param {WeakSet} [seen] - 内部使用,用于记录已处理的对象
 * @returns {Array} 扁平化后的新数组
 */
function flattenSafe(input, depth = 1, seen = new WeakSet()) {
  const O = Object(input);
  depth = Number(depth);
  
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth);
  }
  
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }
  
  // 检测循环引用
  if (seen.has(O)) {
    return [];
  }
  seen.add(O);
  
  const len = O.length >>> 0;
  const result = [];
  
  for (let i = 0; i < len; i++) {
    if (!(i in O)) continue;
    
    const item = O[i];
    if (Array.isArray(item)) {
      result.push(...flattenSafe(item, depth - 1, seen));
    } else {
      result.push(item);
    }
  }
  
  return result;
}

// 测试循环引用
const a = [1];
a.push(a);
console.log(flattenSafe(a)); // [1]

四、实际应用场景

4.1 树形结构转一维数组

const categories = [
  {
    id: 1,
    name: '电子产品',
    children: [
      { id: 11, name: '手机', children: [{ id: 111, name: '苹果手机' }] },
      { id: 12, name: '电脑' }
    ]
  },
  { id: 2, name: '服装' }
];

// 将树形结构转换为一维数组
function flattenTree(tree) {
  return tree.reduce((prev, curr) => {
    prev.push(curr);
    if (curr.children) {
      prev.push(...flattenTree(curr.children));
    }
    return prev;
  }, []);
}

console.log(flattenTree(categories));
// [{id:1, name:'电子产品'}, {id:11, name:'手机'}, {id:111, name:'苹果手机'}, {id:12, name:'电脑'}, {id:2, name:'服装'}]

4.2 多维数组求和

function sumDeep(arr) {
  return flatten(arr, Infinity).reduce((a, b) => a + b, 0);
}

console.log(sumDeep([1, [2, [3, [4]]]])); // 10

4.3 数组深度去重

function uniqueDeep(arr) {
  return [...new Set(flatten(arr, Infinity))];
}

console.log(uniqueDeep([1, [2, [2, [3, 3]]]])); // [1, 2, 3]

五、性能对比

我们对不同实现方式进行了性能测试(测试环境:Node.js 20,100 万次调用):

实现方式 执行时间 相对性能
原生 flat 120ms 100%
最终递归版 180ms 67%
迭代版 250ms 48%
reduce + 递归 320ms 37%
concat 版 580ms 21%

结论:

  • 原生 flat() 性能最好,优先使用
  • 手写递归版性能接近原生,完全满足生产需求
  • 迭代版性能稍差,但不会栈溢出,适合处理极深嵌套的数组

六、总结

本文详细讲解了 JavaScript 数组扁平化的原理和实现,从原生方法的使用到生产级手写实现,覆盖了所有边界情况和性能优化点。

核心要点回顾:

  1. 原生 flat() 方法默认深度为 1,使用 Infinity 可以完全展开
  2. flat() 会自动忽略数组空位,但保留显式的 undefinednull
  3. flat() 是通用方法,支持任意类数组对象
  4. 手写实现时要注意 depth 参数的正确转换和稀疏数组的处理
  5. 现代 JavaScript 不再推荐使用 Symbol.species,直接返回普通数组即可

希望这篇文章能帮助你彻底掌握数组扁平化,写出更健壮、更高效的代码。如果你有任何问题或建议,欢迎在评论区留言讨论!

参考资料:

多标签页并发请求导致 Token 刷新失败?只有 15行代码就能解决 !

多标签页并发请求导致 Token 刷新失败?只有 15行代码就能解决 !

最近我们客服群里的告警反馈就炸了。

不少核心 SaaS 用户在抱怨:你们系统怎么回事?我开着几个标签页在后台对账,突然所有页面全部掉线,提示登录过期,害得我刚录入的数据全没了!

我立刻把负责用户中心模块的小伙子叫过来,一块排查后端日志🫡。

原因极其典型:用户在浏览器里开着 5 个我们的后台标签页,半小时后,Token 过期了。 5 个标签页在同一瞬间检测到了过期,同时向后端发起 refreshToken 请求。

而我们的后端为了安全,设计了 单次刷新令牌即失效(One-Time Use Token) 的安全机制。 当这 5 个并发请求几乎同时到达服务器时:

ChatGPT Image 2026年6月1日 16_10_10.png

请求 A 先到达,后端刷新成功,返回了新的双 Token,并将旧的 Token 拉黑; 请求 B、C、D、E 紧随其后,拿着已经被拉黑的旧 Token 去刷新,后端判定为凭证被盗用,直接执行了安全熔断,把该用户名下的所有 Session 全部强制踢下线

小伙子一脸委屈:老大,这纯粹是网络临界区冲突,前端发请求又没有跨页面同步机制,我怎么控制谁先发,谁后发?🤷‍♂️

大部分中初级开发在面对这个痛点时,脑子里的常规套路是👇: 利用 localStorage 配合 storage 监听事件,或者手写一个基于 SharedWorker 的中转广播,在多个 Tab 之间实现一套复杂的同步锁逻辑。

代码动辄写上百行,不仅难以调试,还要处理页面卡死、Localstorage 写入延迟、Worker 线程挂掉等一大堆兼容性地雷。

但在这个圈子混了快十年,我一向提倡的原则是:凡是能用一行原生 API 降维打击的,绝对不要在 JS 业务层去折腾复杂的轮子。

其实,现代浏览器早就为我们内置了一款低调、极其强大、却被 90% 前端忽略的冷门大杀器——Web Locks API(Web 锁 API)

接下来直接上真家伙,看看它是怎么用最纯粹的原生语法,优雅解决这个多标签页死结的👋。


先讲清楚,什么是 Web Locks API?

很多前端知道线程锁、进程锁,但极少有人知道浏览器端也有 页面级互斥锁

navigator.locksW3C 正式通过的标准 API(早在 2022 年就已被所有主流浏览器原生支持)。 它允许同源(Same-Origin)下的多个浏览器上下文(无论是多个 Tab 标签页,还是多个 Web Worker 线程),去异步申请一个互斥的共享资源锁

在锁被持有期间,其他任何标签页都无法获取同名的锁,必须老老实实排队。只有当持有锁的那个异步函数执行完毕(ResolveReject),浏览器才会自动释放锁,并把控制权交给下一个排队的 Tab。


只有 15 行代码解决多标签页并发刷新

有了它,我们怎么去重构 Token 刷新逻辑?

不需要写任何跨页面通信,不需要写任何 storage 监听。直接看处理流程👇:

ChatGPT Image 2026年6月1日 16_30_32.png

核心伪源码👇:

// 纯原生 Web Locks 优雅解决多标签页并发刷新
import { http } from '@/utils/request';

async function getValidToken() {
  const localToken = localStorage.getItem('access_token');
  
  // 检查如果内存/本地的 Token 依然有效,直接返回,不需要抢锁
  if (isTokenValid(localToken)) {
    return localToken;
  }

  // 核心 API:向浏览器申请一个名为 'token_refresh_lock' 的互斥锁
  return navigator.locks.request('token_refresh_lock', async (lock) => {
    // 抢到锁后,第一件事:再次检查最新本地 Token(防止前一个拿到锁的 Tab 已经刷新好了)
    const latestToken = localStorage.getItem('access_token');
    if (isTokenValid(latestToken)) {
      return latestToken; // 如果前一个页面已经刷好了,直接复用,免去多余的网络请求
    }

    try {
      // 只有抢到锁的标签页,才会真正向后端发起刷新请求
      const { accessToken } = await http.post('/auth/refresh', {
        refreshToken: localStorage.getItem('refresh_token')
      });
      
      localStorage.setItem('access_token', accessToken);
      return accessToken;
    } catch (err) {
      // 如果刷新失败(比如: RefreshToken 真的过期了),清除状态并抛出
      handleLogout();
      throw err;
    }
  }); // 异步函数结束,浏览器自动在底层释放锁,排队中的下一个 Tab 拿到锁后直接触发 isTokenValid 退出
}

你根本不需要知道别的标签页现在是个什么状态,你只需要把最核心的临界代码用 navigator.locks.request 包起来。

多标签页之间的并发冲突、时序排队,全部交由浏览器内核的 C++ 引擎 在底层调度,既不会阻塞主线程,又绝对安全可靠。


也要警惕锁死与超时灾难

如果文章写到这里就结束,那就是纯粹的 API 爽文 了 😁。

在真实的工程环境里,只要涉及多线程/多端锁,就必然面临两个无法逃避的问题:死锁(Deadlock)与意外挂起。

如果持有锁的那个标签页,在执行异步请求时由于网络极其缓慢,卡了整整 30 秒,难道其他 4 个标签页要跟着卡死、拒绝响应用户 30 秒吗?😖

又或者,持有锁的标签页突然发生了崩溃,锁没有被正确释放怎么办?(这个不用担心,浏览器在标签页关闭或崩溃时,会在底层强行安全回收它持有的锁)。

为了防范 网络卡死 导致的所有页面陷入无尽等待,我们必须利用 AbortSignal 给锁加上一个 超时自动断开 的防御机制:

ChatGPT Image 2026年6月1日 17_15_10.png

// 带超时控制的 Web 锁
async function acquireLockWithTimeout() {
  // 创建一个 5 秒超时的控制器
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 5000);

  try {
    return await navigator.locks.request(
      'token_refresh_lock', 
      { signal: controller.signal }, // 注入超时信号
      async (lock) => {
        clearTimeout(timeoutId); // 成功拿到锁,清除超时器
        
        if (lock === null) {
          // 如果设置了 ifAvailable: true 且拿不到锁,lock 会返回 null
          throw new Error('当前系统繁忙,锁获取失败');
        }
        
        return await doHeavyTokenRefresh();
      }
    );
  } catch (err) {
    if (err.name === 'AbortError') {
      console.warn('锁获取超时,强行解除等待状态');
    }
    throw err;
  }
}

利用 controller.signal 这一行配置,我们就优雅地完成了对锁机制的安全兜底。一旦网络超时,等待队列中的其他页面会瞬间被唤醒并解绑,绝不会造成全站卡死的连带事故。


最后

如果再次遇到多页面、多端并发的数据同步问题,别再本能地去 npm 里搜那些笨重的轮子,也别在业务层写一堆难维护的 localStorage 定时器。

学会浏览器原生的底盘能力。用最克制、最优雅的一行 navigator.locks,去彻底终结困扰团队多时的工程死结。

把技术用在刀刃上, 你们觉得呢?😁

如何创建蛛网地图|气泡事件+全球发布+关联组合图表开发示例

完整HTML代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>挪威夏季奥运会奖牌分布地图 - Highcharts</title>
    <!-- 引入 Highcharts 核心库 + 地图库 -->
    <script src="https://cdn.jsdelivr.net/npm/highcharts@10.3.3/highcharts.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/highcharts@10.3.3/modules/map.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/highcharts@10.3.3/modules/data.js"></script>

    <style>
        #container {
            width: 100%;
            height: 85vh;
            margin: 0 auto;
        }
    </style>
</head>
<body>

<div id="container"></div>

<script>
(async () => {

    // 加载世界地图拓扑数据
    const topology = await fetch(
        'https://code.highcharts.com/mapdata/custom/world.topo.json'
    ).then(response => response.json());

    // 奥运会城市数据:城市、国家、年份、纬度、经度、总奖牌、金牌、银牌、铜牌
    const data = [
        ['亚特兰大', '美国', '1996', 33.75, -84.38, 7, 2, 2, 3],
        ['悉尼', '澳大利亚', '2000', -33.87, 151.20, 10, 4, 3, 3],
        ['雅典', '希腊', '2004', 38, 23.72, 6, 5, 0, 1],
        ['北京', '中国', '2008', 39.92, 116.38, 9, 3, 5, 1],
        ['伦敦', '英国', '2012', 51.5, -0.12, 4, 2, 1, 1],
        ['里约热内卢', '巴西', '2016', -22.91, -43.20, 4, 0, 0, 4],
        ['东京', '日本', '2020', 35.69, 139.69, 8, 4, 2, 2]
    ];

    Highcharts.mapChart('container', {

        chart: {
            map: topology
        },

        // 关闭图例
        legend: {
            enabled: false
        },

        // 地图导航(缩放按钮)
        mapNavigation: {
            enabled: true,
            buttonOptions: {
                verticalAlign: 'bottom'
            }
        },

        // 地图自适应显示范围
        mapView: {
            fitToGeometry: {
                type: 'MultiPoint',
                coordinates: [
                    [-164, 54],
                    [-35, 84],
                    [179, -38],
                    [-68, -55]
                ]
            }
        },

        // 标题(中文)
        title: {
            text: '挪威在夏季奥运会获得奖牌分布(1996-2020)',
            align: 'left'
        },

        // 副标题
        subtitle: {
            text: '数据来源:维基百科',
            align: 'left'
        },

        // 鼠标悬浮提示框(汉化)
        tooltip: {
            headerFormat: '',
            pointFormat: '{point.city}({point.country} {point.year})<br/>' +
                '总奖牌数:{point.z}<br/>' +
                '<span style="color: #ffd700;">●</span> 金牌:{point.gold}<br/>' +
                '<span style="color: #c0c0c0;">●</span> 银牌:{point.silver}<br/>' +
                '<span style="color: #cd7f32;">●</span> 铜牌:{point.bronze}<br/>'
        },

        series: [
            {
                name: '世界地图',
                nullColor: '#fad3cf' // 地图底色
            },
            {
                name: '奥运会举办城市',
                type: 'mapbubble', // 气泡地图类型
                color: '#fe5f55',
                lineWidth: 1,
                // 数据字段对应关系
                keys: [
                    'city', 'country', 'year', 'lat', 'lon', 'z', 'gold', 'silver', 'bronze'
                ],
                data: data,
                minSize: '5%',   // 气泡最小尺寸
                maxSize: '12.5%',// 气泡最大尺寸
            }
        ]

    });

})();
</script>

</body>
</html>

1. 图表类型

这是一个 世界地图 + 气泡图(Map Bubble) 组合图表

  • 底层:世界地图
  • 上层:气泡点 → 代表奥运会举办城市

2. 气泡代表什么?

每个气泡 = 一届夏季奥运会

  • 气泡位置 = 举办城市坐标
  • 气泡大小 = 挪威获得的总奖牌数
  • 气泡越大 → 奖牌越多

3. 数据包含哪些信息?

每一行数据对应:

['城市','国家','年份',纬度,经度,总奖牌,金牌,银牌,铜牌]

4. 鼠标悬浮提示(Tooltip)

悬浮在气泡上会显示:

  • 城市、国家、年份
  • 总奖牌数
  • 金牌、银牌、铜牌数量

5. 图表功能

  • ✅ 支持 鼠标缩放地图
  • ✅ 支持 拖拽移动
  • ✅ 自动适配世界范围
  • ✅ 美观配色 + 中文汉化
  • ✅ 可直接本地运行

6. 适用场景

  • 地理数据可视化
  • 赛事 / 事件全球分布
  • 带数值大小的点位展示
  • 大屏数据展示

你直接复制这段代码保存为 .html

双击打开就能看到完整地图图表!

需要我再给你做 动态数据版、可切换年份、可筛选奖牌 的增强版吗?

three-instance-batch 开发笔记

为什么要做这个

写 Three.js 项目时被 InstancedMesh 折腾。改 transform 的时候需要重复 setMatrixAtneedsUpdate = true,删实例的时候需要手动标记尾部搬过来补坑。写三个场景就得写三套差不多的管理代码。

所以抽了这一层:上面像操作普通 Mesh 一样改 transform,下面自动追踪增量刷 GPU

逐个说

1. 怎么追踪变更

最初想过 Proxy 整包 Vector3,但 Three 内部大量直接 vec.x = 5,Proxy 会打断引擎自己的路径,而且每个实例多一层代理对象,内存翻倍。

最后用了最笨的办法——一个方法一个方法地换。

function wrapMethod(obj, method, onChange) {
  const orig = obj[method]
  obj[method] = function (...args) {
    const r = orig.apply(this, args)
    onChange()
    return r
  }
}

36 个方法分了四组——position/scale 7 个、rotation 7 个、quaternion 8 个、color 6 个——各自挂 onChange。构造一个 Instance 大约 35 次闭包调用,比 Proxy 方案多 1KB 左右的函数开销。

代价是属性赋值不管用。 position.x = 5 不触发任何东西。x/y/z 是普通数字属性,要想追踪得整成 getter/setter,会和 Three 的内部冲突。所以文档里说清楚,单轴改动用 setX()

2. 分组依据

InstancedMesh 一个 Mesh 只能绑一组 geometry + material。所以不同 geometry 铁定分开,但不同 material 什么时候能共用要考虑:

  • color 不同 → 可以共用,instanceColor 缓冲区搞定
  • map 不同 → 必须分,shader uniform 绑的纹理不一样
  • transparent 不同 → 必须分,影响渲染队列

最后筛了 13 个属性进签名。目前 MeshStandardMaterial + MeshPhongMaterial 常用组合没出过问题。目前的键的格式是:

geo.uuid | map=tex:xxx;transparent=false;... | 00
                                              ↑ castShadow receiveShadow

shadow 单独抠出来是因为运行时改 shadow 开关要换 Mesh——InstancedMesh 的 castShadow 是整个 Mesh 级别设的,不能逐实例控制。

改 shadow → key 变了 → 塞进 _needsGroupRebuild → 下一个 update 在旧组删掉、在新组重建。

3. 只刷新脏的数据

每一帧假设 5000 个实例里 50 个在动。全量刷 5000 行矩阵就是 5000 × 64B = 320KB 往 GPU 塞。

脏标记分两层:

Instance._localDirty / _worldDirty / _dirtyColor
    ↓ (通过 subscriber 推)
BatchGroup.dirtyIndices: Set<number>
BatchGroup.dirtyColors: Set<number>

update 时只遍历这两个 Set,哪行脏刷哪行。

颜色脏和矩阵脏分开记,因为动 transform 不代表变色了。以前试过统一一个 dirty flag,结果每帧多写 5KB 没变的颜色数据。

4. 父子层级的去重

parent.position 一动,子的 worldMatrix 全脏。这个是基于setter 自动往下传播 _worldDirty = true

但计算时:A → B → C 三层,C 计算 world 时递归进 B,B 又递归进 A。如果还有 D → B → C,A 被算两次。层级深了就是指数级重复。

let frameVersion = 0  // 每帧 ++

recomputeWorldMatrix() {
  if (this._frameVersion === frameVersion) return  // 本帧算过了
  this._frameVersion = frameVersion
  // ...
}

5. 删除怎么处理

数组中间掏个洞,我的做法是:把最后一个搬到空位,count--

idx=2,末位 lastIdx=5:
  [A B C D E F][A B F D E _]  count=65

F 的 indexMap 改 2,idx=2 推进 dirtyIndices。一轮 update 就同步到 GPU 了。

空出来的 lastIdx 推进 emptySlots 栈,下次 add 优先弹。这个栈有个小 bug——连续删多个时同一个索引会重复进栈——但正常使用场景下加删混合跑,很难堆起来。真要修的话弹栈前查个 Set 就行。

6. 扩容

InstancedMesh 容量是死的。超了就新 new 一个更大的,旧的数据拷过去,旧的 dispose。

over-allocation 默认 0.2,也就是说容量 16 → 扩容到 16 + ceil(4) = 20,不是 32。试过 2x,1000 实例浪费 1000 × 64B = 64KB 的 GPU Buffer 空槽。

7. Instance 跟 Batcher 的通信

Instance 不该知道 Batcher 的存在,反过来 Batcher 又要感知 Instance 变更。就一个接口:

interface BatcherSubscriber {
  markDirty(inst, type)   // matrix 还是 color
  markShadowChange(inst)  // 换组
  removeInstance(inst)    // 清理
}

Batcher 实现它,addInstance 时 inst._subscribe(this)。这样以后有其他"观察者"(比如统计面板想监听变更频率)不用改 Instance 的代码。

测试

56 条 vitest。写的时候反复纠结要不要 mock Three.js——最后决定不 mock,因为整个库的价值就在跟 InstancedMesh 的交互,mock 了等于没测。

最值钱的 test case:

  • 空槽复用:add 4 → remove 2 → add 2,断言 count 和 groupCount 不变,新实例在旧槽位上
  • swap-and-pop 索引正确:remove 非末位后,末位实例的 getMatrixAt 位置对
  • 扩容保留自定义材质:capacity=2,塞 3 个,检查 depthMaterial 还在
  • shadow 迁移:改 castShadow 后 update,groupCount 从 1 变 2,count 不变

TypeScript 进阶知识总结:从 extends、泛型到 infer,一篇打通 TS 类型系统

前言

TypeScript 的核心价值不是“给 JavaScript 加几个类型注释”,而是用类型系统提前描述数据结构、函数契约和业务状态。

这篇文章会从基础类型讲起,重点展开几个 TS 进阶高频点:

  • 索引签名 [key: string]: unknown
  • in 类型收窄
  • extends 的多种用法
  • value is Xxx 类型守卫
  • objectObjectunknownany 的区别
  • 联合类型和交叉类型
  • 函数重载
  • 泛型
  • infer
  • 常用工具类型源码实现和使用场景

1. TypeScript 到底解决什么问题?

TypeScript = JavaScript + 静态类型检查。

它最终还是会编译成 JavaScript,类型只存在于编译期。

function add(a: number, b: number): number {
  return a + b;
}

add(1, 2);
add("1", 2); // 报错

TS 的作用是:在代码运行前,提前发现类型错误。

2. 基础类型

常见基础类型:

let name: string = "Tom";
let age: number = 18;
let isAdmin: boolean = false;
let n: null = null;
let u: undefined = undefined;
let big: bigint = 100n;
let sym: symbol = Symbol("id");

数组:

const nums: number[] = [1, 2, 3];
const names: Array<string> = ["Tom", "Jerry"];

元组:

const user: [string, number] = ["Tom", 18];

对象:

const user: { id: string; name: string } = {
  id: "001",
  name: "Tom",
};

3. anyunknownnevervoid

3.1 any

any 表示任意类型,但会关闭类型检查。

let value: any = "hello";

value.toFixed(); // TS 不报错,但运行可能报错

3.2 unknown

unknown 也可以接收任意类型,但使用前必须判断。

let value: unknown = "hello";

if (typeof value === "string") {
  value.toUpperCase();
}

所以:

unknown = 类型安全版 any

3.3 void

void 常用于表示函数没有返回值。

function log(message: string): void {
  console.log(message);
}

3.4 never

never 表示永远不会出现的值。

function fail(message: string): never {
  throw new Error(message);
}

也常用于穷尽检查:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

4. 索引签名:[key: string]: any

你可能见过这样的写法:

type Obj = {
  [props: string]: any;
};

它表示:

这个对象可以有任意字符串 key,并且 value 是 any 类型。

props 只是名字,可以换成 key

type Obj = {
  [key: string]: any;
};

但是项目里更推荐:

type Obj = {
  [key: string]: unknown;
};

因为 any 会放弃类型检查,而 unknown 更安全。

const obj: Obj = {
  name: "Tom",
  age: 18,
};

const name = obj.name;

if (typeof name === "string") {
  name.toUpperCase();
}

注意,一旦写了索引签名,明确属性也要兼容它:

type User = {
  name: string;
  age: number;
  [key: string]: string | number;
};

5. in 的三种用法

5.1 判断属性是否存在

const user = {
  name: "Tom",
  age: 18,
};

console.log("name" in user); // true
console.log("email" in user); // false

5.2 联合类型收窄

type Cat = {
  meow: () => void;
};

type Dog = {
  bark: () => void;
};

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow();
  } else {
    animal.bark();
  }
}

"meow" in animal 会让 TS 知道当前分支里 animalCat

5.3 映射类型中遍历 key

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

这里的 in 表示遍历 keyof T 中的每一个 key。

6. extends:不只是继承

extends 是 TS 中非常重要的关键字。核心可以理解成:

A extends B

意思是:

A 必须满足 B。

6.1 interface 继承

interface Person {
  name: string;
}

interface Student extends Person {
  school: string;
}

Student 同时拥有 nameschool

const s: Student = {
  name: "Tom",
  school: "No.1 School",
};

6.2 class 继承

class Animal {
  move() {
    console.log("move");
  }
}

class Dog extends Animal {
  bark() {
    console.log("bark");
  }
}

6.3 泛型约束

function printName<T extends { name: string }>(obj: T) {
  console.log(obj.name);
}

意思是:

T 必须至少有 name: string

printName({ name: "Tom", age: 18 }); // OK
printName({ age: 18 }); // 报错

你也可以写得更具体:

function handle<T extends { id: string; name: string }>(item: T): T {
  return item;
}

这表示 T 至少要有 idname

const result = handle({
  id: "001",
  name: "Tom",
  age: 18,
});

result.age; // OK

泛型约束的价值是:

既限制结构,又保留传入对象的完整类型。

6.4 条件类型

type IsString<T> = T extends string ? true : false;

使用:

type A = IsString<string>; // true
type B = IsString<number>; // false

这里的 extends 更准确地说是:

T 是否可以赋值给 string。

例如:

type A = "hello" extends string ? true : false;
// true

type B = string extends "hello" ? true : false;
// false

7. T extends object

function fn<T extends object>(value: T) {
  return value;
}

T extends object 表示 T 必须是非原始类型。

可以:

fn({});
fn([]);
fn(() => {});
fn(new Date());

不可以:

fn("hello");
fn(123);
fn(true);

注意:数组和函数也属于 object

如果你想表达普通键值对象,可以考虑:

Record<string, unknown>

但它和普通对象也不是完全等价,实际项目里要看具体场景。

8. objectObject 的区别

8.1 object

object 表示非原始类型。

let a: object;

a = {};
a = [];
a = () => {};

a = "hello"; // 报错
a = 123; // 报错

8.2 Object

Object 范围更宽,几乎表示所有非 null、非 undefined 的值。

let b: Object;

b = {};
b = [];
b = "hello";
b = 123;
b = true;

所以真实项目里不建议用大写 Object 表示普通对象。

更推荐:

unknown // 任意类型,安全
object // 非原始类型
Record<string, unknown> // 普通键值对象

9. 不用 any / unknown,如何表示所有 JS 值?

可以用联合类型:

type Primitive =
  | string
  | number
  | boolean
  | bigint
  | symbol
  | null
  | undefined;

type AnyValue = Primitive | object;

因为 object 包含普通对象、数组、函数、Date、Map、Set 等非原始值。

如果你要表示 JSON 值,可以这样写:

type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

JSON 不包含:

undefined
function
symbol
bigint
Date
Map
Set

10. 类型守卫:value is Xxx

类型守卫用于告诉 TS:

如果这个函数返回 true,那么参数就是某个类型。

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

使用:

function handle(value: unknown) {
  if (isPlainObject(value)) {
    value.name; // OK,类型是 unknown
  }
}

属性值仍然是 unknown,所以要继续判断:

function handle(value: unknown) {
  if (isPlainObject(value) && typeof value.name === "string") {
    value.name.toUpperCase();
  }
}

类型守卫不必须和 unknown 配合,也可以用于联合类型:

function isString(value: string | number): value is string {
  return typeof value === "string";
}

11. 联合类型和交叉类型

11.1 联合类型 |

联合类型表示“或”。

type ID = string | number;

使用时需要收窄:

function printId(id: string | number) {
  if (typeof id === "string") {
    id.toUpperCase();
  } else {
    id.toFixed(2);
  }
}

适合表达多种可能:

type Status = "loading" | "success" | "error";

11.2 交叉类型 &

交叉类型表示“且”。

type User = { name: string } & { age: number };

const user: User = {
  name: "Tom",
  age: 18,
};

交叉类型常用于合并对象结构:

type Base = {
  id: string;
  createdAt: string;
};

type User = Base & {
  name: string;
};

注意基础类型交叉通常会得到 never

type A = string & number;
// never

对象属性冲突也会导致不可能类型:

type A = { id: string } & { id: number };
// id: never

12. 函数重载

函数重载用于表达:

同一个函数,不同参数类型或参数数量,对应不同返回类型。

function fn(x: string): string;
function fn(x: number): number;
function fn(x: string | number): string | number {
  return x;
}

前两行是重载签名,最后一行是实现签名。

调用时:

const a = fn("hello"); // string
const b = fn(123); // number

参数数量不同也可以重载:

function makeDate(timestamp: number): Date;
function makeDate(year: number, month: number, day: number): Date;

function makeDate(
  yearOrTimestamp: number,
  month?: number,
  day?: number
): Date {
  if (month !== undefined && day !== undefined) {
    return new Date(yearOrTimestamp, month, day);
  }

  return new Date(yearOrTimestamp);
}

不允许两个参数调用:

makeDate(2026, 6); // 报错

如果返回值不随参数类型变化,优先用联合类型。

如果输入和输出保持同一种类型关系,优先用泛型。

13. 泛型

泛型可以理解成:

把类型当成参数传进去。

function identity<T>(value: T): T {
  return value;
}

调用:

const a = identity("hello"); // string
const b = identity(123); // number

泛型的价值是:

既能复用逻辑,又能保留具体类型。

13.1 数组例子

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const a = first([1, 2, 3]); // number | undefined
const b = first(["a", "b"]); // string | undefined

13.2 多个泛型参数

function pair<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const result = pair("age", 18);
// [string, number]

13.3 泛型接口

type ApiResponse<T> = {
  code: number;
  message: string;
  data: T;
};

使用:

type User = {
  id: string;
  name: string;
};

const res: ApiResponse<User> = {
  code: 0,
  message: "ok",
  data: {
    id: "001",
    name: "Tom",
  },
};

13.4 泛型默认值

type ApiResponse<T = unknown> = {
  code: number;
  data: T;
};

14. 泛型和 keyof

这个模板非常重要:

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

使用:

const user = {
  id: 1,
  name: "Tom",
};

const id = getValue(user, "id"); // number
const name = getValue(user, "name"); // string

getValue(user, "age"); // 报错

这里同时用到了:

T
K extends keyof T
T[K]

它表达的是:

key 必须是 obj 的 key,返回值类型就是这个 key 对应的 value 类型。

15. infer

infer 用于条件类型中,作用是:

从一个类型结构中提取某一部分类型。

15.1 提取数组元素类型

type Item<T> = T extends Array<infer U> ? U : never;

使用:

type A = Item<string[]>; // string
type B = Item<number[]>; // number

15.2 提取 Promise 结果

type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
type A = UnwrapPromise<Promise<string>>;
// string

递归拆 Promise:

type DeepUnwrapPromise<T> = T extends Promise<infer R>
  ? DeepUnwrapPromise<R>
  : T;

15.3 提取函数返回值

type MyReturnType<T> =
  T extends (...args: any[]) => infer R ? R : never;

使用:

function getUser() {
  return {
    id: 1,
    name: "Tom",
  };
}

type User = MyReturnType<typeof getUser>;
// { id: number; name: string }

15.4 提取函数参数

type MyParameters<T> =
  T extends (...args: infer P) => any ? P : never;
function createUser(id: string, age: number) {}

type Params = MyParameters<typeof createUser>;
// [id: string, age: number]

15.5 提取对象字段

type GetData<T> = T extends { data: infer D } ? D : never;
type Response = {
  code: number;
  data: {
    id: string;
    name: string;
  };
};

type Data = GetData<Response>;
// { id: string; name: string }

16. TS 里的常用工具类型:源码实现 + 使用场景

TypeScript 内置了很多工具类型,它们本质上都是基于这些能力组合出来的:

  • 泛型
  • keyof
  • 映射类型 [K in keyof T]
  • 条件类型 T extends U ? X : Y
  • infer
  • 联合类型分发

下面逐个看常用工具类型。

16.1 Partial<T>

Partial<T> 会把对象类型的所有属性变成可选。

简化源码:

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

使用示例:

type User = {
  id: string;
  name: string;
  age: number;
};

type PartialUser = Partial<User>;

等价于:

type PartialUser = {
  id?: string;
  name?: string;
  age?: number;
};

使用场景:更新部分字段。

function updateUser(id: string, patch: Partial<User>) {
  // 只更新传入的字段
}

updateUser("001", {
  name: "Jerry",
});

16.2 Required<T>

Required<T> 会把对象类型的所有属性变成必选。

简化源码:

type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

这里的 -? 表示移除可选标记。

使用示例:

type User = {
  id?: string;
  name?: string;
};

type RequiredUser = Required<User>;

等价于:

type RequiredUser = {
  id: string;
  name: string;
};

使用场景:配置合并后,内部使用完整配置。

type Config = {
  host?: string;
  port?: number;
};

const defaultConfig: Required<Config> = {
  host: "localhost",
  port: 3000,
};

function createServer(config: Config) {
  const finalConfig: Required<Config> = {
    ...defaultConfig,
    ...config,
  };

  finalConfig.host;
  finalConfig.port;
}

16.3 Readonly<T>

Readonly<T> 会把对象属性变成只读。

简化源码:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

使用示例:

type User = {
  id: string;
  name: string;
};

type ReadonlyUser = Readonly<User>;

等价于:

type ReadonlyUser = {
  readonly id: string;
  readonly name: string;
};

使用场景:防止函数内部修改传入对象。

function printUser(user: Readonly<User>) {
  console.log(user.name);

  user.name = "Jerry"; // 报错
}

注意:Readonly<T> 默认是浅只读。

type User = {
  profile: {
    age: number;
  };
};

const user: Readonly<User> = {
  profile: {
    age: 18,
  },
};

user.profile.age = 20; // 可以,因为 profile 内部不是 readonly

16.4 Pick<T, K>

Pick<T, K> 从对象类型中挑选部分属性。

简化源码:

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

使用示例:

type User = {
  id: string;
  name: string;
  age: number;
  password: string;
};

type UserBaseInfo = Pick<User, "id" | "name">;

等价于:

type UserBaseInfo = {
  id: string;
  name: string;
};

使用场景:接口返回、组件 props、表格字段。

type UserCardProps = Pick<User, "id" | "name">;

function renderUserCard(user: UserCardProps) {
  return `${user.id} - ${user.name}`;
}

16.5 Omit<T, K>

Omit<T, K> 从对象类型中排除部分属性。

简化源码:

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

拆开看:

Exclude<keyof T, K>

先从所有 key 里排除 K,再用 Pick 取剩下的字段。

使用示例:

type User = {
  id: string;
  name: string;
  password: string;
};

type PublicUser = Omit<User, "password">;

等价于:

type PublicUser = {
  id: string;
  name: string;
};

使用场景:隐藏敏感字段。

function toPublicUser(user: User): Omit<User, "password"> {
  const { password, ...rest } = user;
  return rest;
}

也常用于创建参数:

type CreateUserDto = Omit<User, "id">;

16.6 Record<K, T>

Record<K, T> 用来创建一个 key/value 对象类型。

简化源码:

type MyRecord<K extends keyof any, T> = {
  [P in K]: T;
};

keyof any 等价于:

string | number | symbol

使用示例:

type Role = "admin" | "user" | "guest";

type RoleMap = Record<Role, string>;

等价于:

type RoleMap = {
  admin: string;
  user: string;
  guest: string;
};

使用场景:枚举映射、状态映射、字典对象。

type Status = "pending" | "success" | "error";

const statusText: Record<Status, string> = {
  pending: "处理中",
  success: "成功",
  error: "失败",
};

这样如果少写一个 key,TS 会报错:

const statusText: Record<Status, string> = {
  pending: "处理中",
  success: "成功",
  // error 缺失,报错
};

16.7 Exclude<T, U>

Exclude<T, U> 从联合类型 T 中排除可以赋值给 U 的类型。

简化源码:

type MyExclude<T, U> = T extends U ? never : T;

使用示例:

type Status = "pending" | "success" | "error";

type NonErrorStatus = Exclude<Status, "error">;

结果:

type NonErrorStatus = "pending" | "success";

使用场景:从联合类型里排除某些值。

type EventName = "click" | "hover" | "focus";

type MouseEventName = Exclude<EventName, "focus">;
// "click" | "hover"

理解重点:

type MyExclude<T, U> = T extends U ? never : T;

T 是联合类型时,条件类型会自动分发:

Exclude<"a" | "b" | "c", "a">

相当于:

("a" extends "a" ? never : "a")
| ("b" extends "a" ? never : "b")
| ("c" extends "a" ? never : "c")

结果:

"b" | "c"

16.8 Extract<T, U>

Extract<T, U> 从联合类型 T 中提取可以赋值给 U 的类型。

简化源码:

type MyExtract<T, U> = T extends U ? T : never;

使用示例:

type Status = "pending" | "success" | "error";

type SuccessStatus = Extract<Status, "success" | "done">;

结果:

type SuccessStatus = "success";

使用场景:从联合类型中取交集。

type FrontendEvent = "click" | "hover" | "focus";
type SupportedEvent = "click" | "focus";

type AvailableEvent = Extract<FrontendEvent, SupportedEvent>;
// "click" | "focus"

16.9 NonNullable<T>

NonNullable<T> 从类型中排除 nullundefined

简化源码:

type MyNonNullable<T> = T extends null | undefined ? never : T;

使用示例:

type Value = string | number | null | undefined;

type SafeValue = NonNullable<Value>;

结果:

type SafeValue = string | number;

使用场景:处理已经判空后的类型。

function assertValue<T>(value: T): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error("value is empty");
  }

  return value;
}

16.10 ReturnType<T>

ReturnType<T> 用来获取函数返回值类型。

简化源码:

type MyReturnType<T extends (...args: any[]) => any> =
  T extends (...args: any[]) => infer R ? R : never;

使用示例:

function getUser() {
  return {
    id: "001",
    name: "Tom",
  };
}

type User = ReturnType<typeof getUser>;

结果:

type User = {
  id: string;
  name: string;
};

使用场景:复用函数返回值类型,避免重复声明。

function createState() {
  return {
    count: 0,
    user: null as null | { id: string; name: string },
  };
}

type State = ReturnType<typeof createState>;

16.11 Parameters<T>

Parameters<T> 用来获取函数参数类型,结果是一个元组。

简化源码:

type MyParameters<T extends (...args: any[]) => any> =
  T extends (...args: infer P) => any ? P : never;

使用示例:

function createUser(id: string, age: number) {
  return { id, age };
}

type CreateUserParams = Parameters<typeof createUser>;

结果:

type CreateUserParams = [id: string, age: number];

使用场景:封装函数、转发参数。

function createUser(id: string, age: number) {
  return { id, age };
}

function wrapper(...args: Parameters<typeof createUser>) {
  return createUser(...args);
}

16.12 ConstructorParameters<T>

ConstructorParameters<T> 用来获取构造函数参数类型。

简化源码:

type MyConstructorParameters<T extends abstract new (...args: any[]) => any> =
  T extends abstract new (...args: infer P) => any ? P : never;

使用示例:

class User {
  constructor(public id: string, public name: string) {}
}

type UserConstructorParams = ConstructorParameters<typeof User>;

结果:

type UserConstructorParams = [id: string, name: string];

使用场景:工厂函数。

function createInstance<T extends abstract new (...args: any[]) => any>(
  Ctor: T,
  ...args: ConstructorParameters<T>
): InstanceType<T> {
  return new Ctor(...args);
}

如果遇到 abstract new 不好理解,可以先记:

new (...args: any[]) => any

表示构造函数类型。

16.13 InstanceType<T>

InstanceType<T> 用来获取构造函数创建出来的实例类型。

简化源码:

type MyInstanceType<T extends abstract new (...args: any[]) => any> =
  T extends abstract new (...args: any[]) => infer R ? R : never;

使用示例:

class User {
  id = "001";
  name = "Tom";
}

type UserInstance = InstanceType<typeof User>;

结果:

type UserInstance = User;

使用场景:根据 class 自动得到实例类型。

class Service {
  request() {}
}

type ServiceInstance = InstanceType<typeof Service>;

const service: ServiceInstance = new Service();

16.14 Awaited<T>

Awaited<T> 用来获取 Promise 最终 resolve 出来的类型。

真实源码更复杂,这里写一个简化版本:

type MyAwaited<T> = T extends Promise<infer R>
  ? MyAwaited<R>
  : T;

使用示例:

type A = Awaited<Promise<string>>;
// string

type B = Awaited<Promise<Promise<number>>>;
// number

使用场景:提取异步函数返回数据类型。

async function fetchUser() {
  return {
    id: "001",
    name: "Tom",
  };
}

type User = Awaited<ReturnType<typeof fetchUser>>;

拆开看:

ReturnType<typeof fetchUser>

得到:

Promise<{ id: string; name: string }>

再用:

Awaited<...>

得到:

{ id: string; name: string }

16.15 ReadonlyArray<T>

ReadonlyArray<T> 表示只读数组。

使用示例:

const nums: ReadonlyArray<number> = [1, 2, 3];

nums.push(4); // 报错
nums[0] = 10; // 报错

也可以写成:

const nums: readonly number[] = [1, 2, 3];

使用场景:函数不应该修改传入数组。

function sum(nums: readonly number[]) {
  return nums.reduce((total, item) => total + item, 0);
}

16.16 ThisParameterType<T>

ThisParameterType<T> 用来提取函数里的 this 参数类型。

简化源码:

type MyThisParameterType<T> =
  T extends (this: infer U, ...args: any[]) => any ? U : unknown;

使用示例:

function fn(this: { name: string }, age: number) {
  console.log(this.name, age);
}

type ThisTypeOfFn = ThisParameterType<typeof fn>;

结果:

type ThisTypeOfFn = {
  name: string;
};

使用场景:处理依赖 this 的老代码或库封装。

16.17 OmitThisParameter<T>

OmitThisParameter<T> 用来移除函数里的 this 参数。

function fn(this: { name: string }, age: number) {
  console.log(this.name, age);
}

type FnWithoutThis = OmitThisParameter<typeof fn>;

结果类似:

type FnWithoutThis = (age: number) => void;

使用场景:把依赖 this 的函数 bind 之后再使用。

const bound = fn.bind({ name: "Tom" });

const run: OmitThisParameter<typeof fn> = bound;

16.18 工具类型组合使用

工具类型真正强大的地方在于组合。

示例 1:提取异步函数返回数据。

async function getUser() {
  return {
    id: "001",
    name: "Tom",
  };
}

type User = Awaited<ReturnType<typeof getUser>>;

示例 2:创建接口入参类型。

type User = {
  id: string;
  name: string;
  password: string;
  createdAt: string;
};

type CreateUserDto = Omit<User, "id" | "createdAt">;

结果:

type CreateUserDto = {
  name: string;
  password: string;
};

示例 3:更新接口入参。

type UpdateUserDto = Partial<Omit<User, "id" | "createdAt">>;

结果:

type UpdateUserDto = {
  name?: string;
  password?: string;
};

示例 4:状态映射。

type Status = "pending" | "success" | "error";

const statusText: Record<Status, string> = {
  pending: "处理中",
  success: "成功",
  error: "失败",
};

示例 5:从联合类型中排除某些状态。

type Status = "pending" | "success" | "error" | "cancelled";

type ActiveStatus = Exclude<Status, "cancelled">;

16.19 小结

常用工具类型可以按用途分类记忆。

对象属性处理:

Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>

联合类型处理:

Exclude<T, U>
Extract<T, U>
NonNullable<T>

函数类型处理:

ReturnType<T>
Parameters<T>
ThisParameterType<T>
OmitThisParameter<T>

构造函数处理:

ConstructorParameters<T>
InstanceType<T>

异步类型处理:

Awaited<T>

它们背后的核心能力其实就几个:

keyof
in
extends
infer
映射类型
条件类型
联合类型分发

掌握这些底层能力后,工具类型就不是黑盒了。

17. 实战建议

写 TS 时可以记住这些习惯:

  • 不确定类型时,优先用 unknown,少用 any
  • 对外部数据,比如接口返回值,不要盲信类型。
  • 描述动态对象时,优先考虑 [key: string]: unknown
  • 对普通对象可以用 Record<string, unknown>,但要理解它不是所有对象。
  • 表达多种状态时,用联合类型。
  • 表达对象合并时,用交叉类型。
  • 参数和返回值有类型关系时,用泛型。
  • 不同参数组合对应不同返回类型时,用函数重载。
  • 从复杂类型里提取子类型时,用 infer
  • extends 不只表示继承,更常用于泛型约束和条件类型判断。

18. 总结

这篇文章里,我们围绕 TS 类型系统梳理了这些重点:

[key: string]: unknown

用于描述动态属性。

"name" in obj

用于属性判断和类型收窄。

T extends SomeType

用于泛型约束。

T extends U ? X : Y

用于条件类型判断。

value is SomeType

用于自定义类型守卫。

A | B

表示联合类型,满足其中一种。

A & B

表示交叉类型,同时满足多种。

function identity<T>(value: T): T

表示泛型函数。

T extends Array<infer U> ? U : never

表示从数组中提取元素类型。

TypeScript 的难点不在于语法多,而在于要理解这些语法背后的共同目标:

用类型系统描述真实业务里的数据关系,让错误尽量在编译期暴露出来。

当你能熟练使用 extendskeyof、泛型、类型守卫、infer 这些工具时,就已经进入 TS 类型系统的核心区域了。

dpkg Command in Linux: Install and Manage Debian Packages

When you install software on Ubuntu or Debian with apt , the package manager quietly calls dpkg in the background to do the actual work. dpkg is the low-level tool that unpacks and installs .deb files, maintains the package database, and handles removal and purging. Knowing how to use it directly is useful when you have a local .deb file that is not in a repository, when you need to query what files a package installed, or when you want to find which package owns a specific file on your system.

Syntax

txt
dpkg [OPTIONS] COMMAND [ARGUMENTS]

Install, remove, purge, and configure operations require sudo because they modify system directories and the package database. Query commands such as dpkg -l, dpkg -L, dpkg -S, and dpkg -s usually run without elevated privileges.

Install a Package

To install a local .deb file:

Terminal
sudo dpkg -i package.deb

dpkg unpacks the archive, runs the maintainer scripts, and registers the package in its database. If the package has unmet dependencies, the install fails with a dependency error. Fix it by running:

Terminal
sudo apt install -f

apt install -f (fix broken) resolves and installs any missing dependencies, then completes the pending dpkg installation.

For most local .deb installs, apt install ./package.deb is easier because it can resolve dependencies in the same step. Use dpkg -i when you specifically need the lower-level package operation.

Remove a Package

To uninstall a package while keeping its configuration files:

Terminal
sudo dpkg -r package-name

To uninstall a package and delete its configuration files at the same time (a full purge):

Terminal
sudo dpkg -P package-name

Use -P when you want a clean removal with no leftover config, or when you plan to reinstall with a fresh configuration.

List Installed Packages

To list every installed package with its version and status:

Terminal
dpkg -l

The output is wide. Each package line starts with three status columns: desired action, current package status, and error state. The normal installed state appears as ii, followed by the package name, version, architecture, and description:

output
Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name Version Architecture Description
+++-==============-=============-============-=================================
ii curl 8.5.0 amd64 command line tool for transferring data
ii nginx 1.26.0 amd64 small, powerful, scalable web/proxy server

The first i means the package is selected for installation, and the second i means it is installed and configured. Filter by name to narrow the output:

Terminal
dpkg -l 'nginx*'
output
ii nginx 1.26.0 amd64 small, powerful, scalable web/proxy server
ii nginx-common 1.26.0 all small, powerful, scalable web/proxy server (common files)

List Files Installed by a Package

To see every file a package placed on your system:

Terminal
dpkg -L nginx
output
/.
/etc
/etc/nginx
/etc/nginx/nginx.conf
/etc/nginx/sites-available
/etc/nginx/sites-enabled
/usr/sbin
/usr/sbin/nginx
...

This is useful when you need to find where a package installs its binary, config files, or documentation.

Find Which Package Owns a File

To identify which package installed a particular file:

Terminal
dpkg -S /usr/sbin/nginx
output
nginx: /usr/sbin/nginx

You can also search by pattern:

Terminal
dpkg -S '*/bin/curl'
output
curl: /usr/bin/curl

Show Package Details

To print the full metadata for an installed package including version, dependencies, and description:

Terminal
dpkg -s nginx
output
Package: nginx
Status: install ok installed
Priority: optional
Section: web
Installed-Size: 47
Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
Architecture: amd64
Version: 1.26.0-1ubuntu1
...

Inspect a .deb File Without Installing

To read the metadata of a .deb file before installing it:

Terminal
dpkg --info package.deb
output
 new Debian package, version 2.0.
size 1234567 bytes: control archive=2048 bytes.
...
Package: myapp
Version: 2.1.0
Architecture: amd64
...

To list the files the package would install without actually installing it:

Terminal
dpkg --contents package.deb

Extract a Package Without Installing

To unpack the contents of a .deb file into a directory for inspection:

Terminal
dpkg -x package.deb /tmp/extracted

The directory structure mirrors where files would be installed on the real system.

Export and Restore Package Selections

To export a list of all installed packages (useful for replicating a system):

Terminal
dpkg --get-selections > installed-packages.txt

To restore those selections on another machine:

Terminal
sudo dpkg --set-selections < installed-packages.txt
sudo apt-get dselect-upgrade

Reconfigure an Installed Package

Some packages run interactive setup at install time. To run that setup again:

Terminal
sudo dpkg-reconfigure package-name

This is commonly used to reconfigure tzdata for timezone changes, keyboard-configuration, or locales.

Fix Broken Package State

If a system update or manual operation leaves packages in a half-installed or unconfigured state, run:

Terminal
sudo dpkg --configure -a

This processes any pending package configuration steps. Follow it with sudo apt install -f if dependency issues remain.

Troubleshooting

dpkg reports dependency problems
dpkg installs the package file you give it, but it does not download missing dependencies. Run sudo apt install -f to install the required packages and finish the pending configuration.

A package operation was interrupted
If an update or install stopped before configuration finished, run sudo dpkg --configure -a. This resumes pending package configuration scripts.

dpkg cannot find a package
Use the installed package name, not the .deb filename. Run dpkg -l 'pattern*' to find matching installed packages, then use that package name with commands such as dpkg -L, dpkg -s, or sudo dpkg -r.

Quick Reference

For a printable quick reference, see the dpkg cheatsheet .

Command Description
dpkg -i pkg.deb Install a local .deb file
dpkg -r package Remove a package, keep config
dpkg -P package Purge a package and its config
dpkg -l List all installed packages
dpkg -l 'pattern*' Filter installed packages
dpkg -L package List files installed by a package
dpkg -S /path/to/file Find which package owns a file
dpkg -s package Show package status and details
dpkg --info pkg.deb Inspect a .deb file
dpkg --contents pkg.deb List files in a .deb
dpkg -x pkg.deb /dir Extract a .deb without installing
dpkg --get-selections Export installed package list
dpkg-reconfigure package Re-run package configuration
dpkg --configure -a Fix incomplete installations

Conclusion

dpkg is the foundation of package management on Debian-based systems. For everyday installs and updates from repositories, apt is the better tool because it resolves dependencies automatically. Reach for dpkg directly when you need to install a .deb file from disk, audit what a package installed, or diagnose a broken package state.

实现一个自己的 Agent cli

实现效果

先预览 Agent 实现的效果如下:

image.png

image.png GitHub:github.com/CCZX/lumen/

从 Chatbot 到 Code Agent

大模型最常见的产品形态是 Chatbot:用户输入一句话,模型返回一段回答。这个形态足够通用,也足够直观,但它天然更像“问答界面”。但是当我们想让模型真正参与软件开发时,仅有对话是不够的。写代码不是只生成一段文本,而是要读项目、理解约束、修改文件、运行命令、观察结果、修复错误,并在必要时继续迭代。

Code Agent 可以理解为把大模型放进一个受控的开发环境里,让它不只会“说”,还会“做”。它背后多了一套执行系统:上下文管理、权限控制、工具调用、可持续循环的任务调度逻辑。

image.png

Code Agent 的核心变化

Code Agent 的关键变化不是“换一个更会写代码的模型”,而是把模型放进一个可观察、可行动、可约束的循环里。

它的基本形态可以概括为:

用户目标
-> 构建上下文
-> 模型决策
-> 调用工具
-> 观察结果
-> 更新计划
-> 继续执行或交付结果

这和普通 Chatbot 最大的差异在于:模型每一步都可以根据真实环境反馈继续调整。比如它可以先搜索相关代码,再读取文件,接着修改文件内容,运行测试,看到报错后回到对应文件继续修复。

一个 Code Agent 需要什么

image.png

如果要实现一个自己的 Agent CLI,可以先把它拆成几层。

1. 对话层

对话层负责接收用户输入、展示模型输出,并维护消息历史。它看起来最像普通 Chatbot,但在 Agent CLI 里,对话层还需要支持流式输出、工具调用提示、权限确认、任务状态展示等能力。

这层要解决的问题包括:

  • 如何收集用户目标。
  • 如何把系统提示词、项目上下文和历史对话组织给模型。
  • 如何展示中间步骤,让用户知道 Agent 正在做什么。
  • 如何在需要高风险操作时暂停并请求确认。

2. 上下文层

模型不能一次性读完整个仓库,所以 Agent 必须主动选择上下文。上下文层的任务是帮助模型找到“此刻最相关的信息”。

常见上下文来源包括:

  • 当前工作目录。
  • 文件列表和目录结构。
  • 用户点名的文件或代码片段。
  • 项目说明文件,例如 README.mdAGENTS.md

好的上下文管理不是把所有内容都塞给模型,而是逐步检索、逐步阅读、逐步收敛。Code Agent 的体验好坏,很大程度上取决于它能不能在有限上下文窗口里持续找到关键证据。

3. 工具层

工具层是 Agent 和真实环境互动的入口。没有工具,模型只能生成文本;有了工具,模型才能执行动作。

一个最小可用的 Agent CLI 通常需要这些工具:

  • list_files:查看项目文件。
  • read_file:读取文件内容。
  • search:全文搜索。
  • apply_patch:以补丁方式修改文件。
  • run_command:运行测试、构建、格式化等命令。
  • get_diff:查看当前改动。

4. 规划与循环层

Agent 不是一次性生成答案,而是一个循环系统。它需要在每次观察后决定下一步:

  • 还缺上下文吗?
  • 是否已经定位问题?
  • 是否需要修改文件?

比如在我们让 Agent 修复一个问题时, 对于的交互时序为

image.png

可以看见 Agent 并不是一开始就把文件内容交给 LLM,而是由 LLM 自行判断是否还缺失相关上下文。

一个简单的循环可以写成这样:

while (taskNotDone) {
const context = collectRelevantContext();
const decision = model(messages, context, toolSpecs);

// 调用工具
if (decision.type === "tool_call") {
const result = runTool(decision.tool, decision.args);
messages.push(result);
continue;
}

if (decision.type === "final_answer") {
return decision.content;
}
}

真实实现会更复杂:需要限制最大循环次数,压缩历史消息,处理工具失败,检测重复动作,管理权限,以及在用户中途插话时重新对齐目标。但这个循环已经体现了 Agent 的本质:模型不是只回答,而是在“决策 -> 行动 -> 观察”的闭环里推进任务。

5. 安全与权限层

由于 Agent 能够自动修改你本地文件,所以安全层不是附属功能,而是基础设施。

至少需要考虑这些边界:

  • 文件边界:允许读写哪些目录。
  • 命令边界:哪些命令可以直接执行,哪些必须确认。
  • 网络边界:是否允许下载依赖、访问外部服务。

技术选型

由于 Agent 要同时做:流式 LLM 响应、Shell 执行、文件监听、用户输入、子 Agent 调度,属于 I/O 多路复用场景。所以 Node.js 的事件循环 + 非阻塞 I/O 非常适合用于开发 Agent

类型 技术 作用
语言 TypeScript 提供类型约束,适合构建复杂工程系统
CLI UI Ink 用 React 组件构建终端界面
LLM SDK OpenAI 接入 OpenAI 及兼容接口
参数校验 Zod 校验工具调用参数和配置
测试 Vitest 单元测试与集成测试

项目结构

src/
├── agent/ # Agent 核心
├── config/ # 配置读取与校验
├── store/ # Zustand vanilla store
├── ui/ # Ink UI
├── tools/ # 后续工具系统扩展
├── context/ # 后续上下文管理扩展
├── services/ # 后续服务层扩展
├── mcp/ # 后续 MCP 协议扩展
├── prompts/ # 后续提示词管理扩展
├── logging/ # 后续日志系统扩展
└── main.tsx # CLI 入口

实现阶段

mvp

src/
├── main.tsx
├── agent.ts
│   └── agent.ts
└── ui/
    └── App.tsx
    

首先实现最简单的 MVP 版本,让 Agent 能够成功调用 LLM

main.tsx:

#!/usr/bin/env node
import { render } from "ink";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { App } from "./ui/App.js";

async function main() {
const argv = await yargs(hideBin(process.argv))
.scriptName("agent-mini")
.usage("$0 [options]")
.option("api-key", {
type: "string",
description: "API key",
default: process.env.OPENAI_API_KEY,
})
.option("base-url", {
type: "string",
description: "OpenAI-compatible API base URL",
default: process.env.OPENAI_BASE_URL,
})
.option("model", {
type: "string",
description: "Model name",
default: process.env.OPENAI_MODEL ?? "gpt-4o-mini",
})
.help()
.parse();

if (!argv.apiKey) {
console.error("Error: API key is required.");
console.error("Set OPENAI_API_KEY or pass --api-key.");
process.exit(1);
}

render(
<App
apiKey={argv.apiKey}
baseURL={argv.baseUrl}
model={argv.model}
/>,
);
}

main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});

App.tsx

import { useMemo, useState } from "react";
import type { FC } from "react";
import { Box, Text } from "ink";
import Spinner from "ink-spinner";
import TextInput from "ink-text-input";
import { Agent } from "../agent/Agent.js";

interface AppProps {
apiKey: string;
baseURL?: string;
model?: string;
}

export const App: FC<AppProps> = ({ apiKey, baseURL, model }) => {
const [input, setInput] = useState("");
const [question, setQuestion] = useState("");
const [response, setResponse] = useState("");
const [isLoading, setIsLoading] = useState(false);

const agent = useMemo(
() => new Agent({ apiKey, baseURL, model }),
[apiKey, baseURL, model],
);

const handleSubmit = async (value: string) => {
const message = value.trim();

if (!message || isLoading) {
return;
}

setIsLoading(true);
setQuestion(message);
setResponse("");
setInput("");

try {
const result = await agent.chat(message);
setResponse(result || "(empty response)");
} catch (error) {
setResponse(`Error: ${(error as Error).message}`);
} finally {
setIsLoading(false);
}
};

return (
<Box flexDirection="column" padding={1}>
<Text bold color="cyan">
Agent Mini
</Text>

{question && (
<Box marginTop={1}>
<Text color="gray">You: {question}</Text>
</Box>
)}

<Box marginY={1}>
{isLoading ? (
<Box>
<Spinner type="dots" />
<Text> Thinking...</Text>
</Box>
) : (
response && <Text>{response}</Text>
)}
</Box>

<Box>
<Text color="green">{"> "}</Text>
<TextInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
placeholder="Ask me anything..."
/>
</Box>
</Box>
);
};

agent.ts

import OpenAI from "openai";

export interface AgentConfig {
apiKey: string;
baseURL?: string;
model?: string;
}

export class Agent {
private readonly client: OpenAI;
private readonly model: string;

constructor(config: AgentConfig) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
});
this.model = config.model ?? "gpt-4o-mini";
}
  
async chat(message: string): Promise<string> {
const response = await this.client.chat.completions.create({
model: this.model,
messages: [
{
role: "system",
content: "You are a helpful coding assistant.",
},
{
role: "user",
content: message,
},
],
});
return response.choices[0]?.message?.content ?? "";
}
}

package.json

{
"name": "agent-mini",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"agent-mini": "./dist/main.js"
},
"scripts": {
"dev": "tsx src/main.tsx",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/main.js"
},
"dependencies": {
"ink": "^6.4.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"openai": "^6.2.0",
"react": "^19.1.1",
"yargs": "^18.0.0"
},
"devDependencies": {
"@types/node": "^22.15.24",
"@types/react": "^19.1.12",
"@types/yargs": "^17.0.35",
"tsx": "^4.22.4",
"typescript": "^5.9.2"
}
}

在完成上述代码后,启动 Agent 之前我们还需要购买大模型服务,购买成功后新建 api key 就可以启动我们的 Agent 了

输入以下命令

OPENAI_API_KEY=your-key npm run dev -- --base-url your-model-base-url --model your-model-name

以购买 deepseek 为例,输入

OPENAI_API_KEY=your-key npm run dev -- --base-url https://api.deepseek.com --model deepseek-v4-pro

成功启动可以看到如下:

image.png

tools

src/tools/
├── builtin/
│   ├── file/
│   ├── search/
│   ├── shell/
│   └── web/
├── registry/
└── types/

工具层是 Agent 和真实环境互动的入口。没有工具,模型只能生成文本;有了工具,模型才能执行动作。

我们先实现最基本的 read 和 write 工具,让 Agent 能够读写我们的文件

read

在 src/tools/types/index.ts 内声明类型定义

import type { FunctionParameters } from 'openai/resources/shared';

export interface AgentTool {
  name: string;
  description: string;
  parameters: FunctionParameters;
  execute: (args: unknown) => Promise<string>;
}

在 src/tools/builtin/file/readFileTool.ts 实现读文件的 tool

import { constants } from 'node:fs';
import { access, open, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import type { AgentTool } from '../../types/index.js';

const DEFAULT_MAX_BYTES = 200_000;
const HARD_MAX_BYTES = 1_000_000;

const ReadFileInputSchema = z.object({
  path: z.string().min(1, 'path is required.'),
  max_bytes: z.number().int().positive().max(HARD_MAX_BYTES).optional(),
});

function isPathInside(parent: string, child: string): boolean {
  const relativePath = path.relative(parent, child);
  return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}

async function resolveWorkspacePath(inputPath: string): Promise<string> {
  const workspaceRoot = await realpath(process.cwd());
  const resolvedPath = path.resolve(workspaceRoot, inputPath);

  if (!isPathInside(workspaceRoot, resolvedPath)) {
    throw new Error(`Refusing to read outside workspace: ${inputPath}`);
  }

  return realpath(resolvedPath);
}

async function readFileContent(filePath: string, maxBytes: number): Promise<Buffer> {
  const fileHandle = await open(filePath, 'r');

  try {
    const buffer = Buffer.alloc(maxBytes);
    const { bytesRead } = await fileHandle.read(buffer, 0, maxBytes, 0);
    return buffer.subarray(0, bytesRead);
  } finally {
    await fileHandle.close();
  }
}

export const readFileTool: AgentTool = {
  name: 'read_file',
  description:
    'Read a UTF-8 text file from the current workspace. Use this before answering questions that require inspecting local source files.',
  parameters: {
    type: 'object',
    additionalProperties: false,
    properties: {
      path: {
        type: 'string',
        description:
          'Path to the file, relative to the current workspace. Absolute paths are only allowed when they still point inside the workspace.',
      },
      max_bytes: {
        type: 'integer',
        description: `Maximum bytes to read. Defaults to ${DEFAULT_MAX_BYTES}.`,
        minimum: 1,
        maximum: HARD_MAX_BYTES,
      },
    },
    required: ['path'],
  },
  async execute(args: unknown): Promise<string> {
    try {
      const input = ReadFileInputSchema.parse(args);
      const maxBytes = input.max_bytes ?? DEFAULT_MAX_BYTES;
      const filePath = await resolveWorkspacePath(input.path);

      if (!isPathInside(await realpath(process.cwd()), filePath)) {
        throw new Error(`Refusing to read outside workspace: ${input.path}`);
      }

      await access(filePath, constants.R_OK);

      const fileStat = await stat(filePath);
      if (!fileStat.isFile()) {
        throw new Error(`Not a file: ${input.path}`);
      }

      const content = await readFileContent(filePath, maxBytes);

      return JSON.stringify({
        path: path.relative(process.cwd(), filePath),
        bytes_read: content.length,
        truncated: fileStat.size > content.length,
        content: content.toString('utf8'),
      });
    } catch (error) {
      return JSON.stringify({
        error: (error as Error).message,
      });
    }
  },
};

在 src/tools/registry/index.ts 内获取 tool 的方法


import type { ChatCompletionTool } from 'openai/resources/chat/completions';
import { readFileTool } from '../builtin/file/index.js';
import type { AgentTool } from '../types/index.js';

const tools: AgentTool[] = [readFileTool];

export function getTools(): AgentTool[] {
  return tools;
}

export function getTool(name: string): AgentTool | undefined {
  return tools.find((tool) => tool.name === name);
}

export function getToolsAsChatCompletionTools(): ChatCompletionTool[] {
  return tools.map((tool) => ({
    type: 'function',
    function: {
      name: tool.name,
      description: tool.description,
      parameters: tool.parameters,
    },
  }));
}

在 Agent 内注册 tool

import OpenAI from 'openai';
import type {
  ChatCompletionMessage,
  ChatCompletionMessageParam,
  ChatCompletionMessageToolCall,
} from 'openai/resources/chat/completions';
import type { AgentConfig } from '../config/types.js';
import { getTool, getToolsAsChatCompletionTools } from '../tools/registry/index.js';

const MAX_TOOL_ROUNDS = 5;

type MessageWithReasoningContent = ChatCompletionMessage & {
  reasoning_content?: string | null;
};

export class Agent {
  private readonly client: OpenAI;
  private readonly model: string;

  constructor(config: AgentConfig) {
    if (!config.apiKey) {
      throw new Error('Agent config is missing apiKey.');
    }

    this.client = new OpenAI({
      apiKey: config.apiKey,
      baseURL: config.baseURL,
    });
    this.model = config.model;
  }

  async chat(message: string): Promise<string> {
    const messages: ChatCompletionMessageParam[] = [
      {
        role: 'system',
        content:
          'You are a helpful coding assistant. Use tools when you need to inspect local workspace files before answering.',
      },
      {
        role: 'user',
        content: message,
      },
    ];

    const tools = getToolsAsChatCompletionTools();

    for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
      const response = await this.client.chat.completions.create({
        model: this.model,
        messages,
        tools,
        tool_choice: 'auto',
      });

      const responseMessage = response.choices[0]?.message as
        | MessageWithReasoningContent
        | undefined;
      if (!responseMessage) {
        return '';
      }

      const toolCalls = responseMessage.tool_calls ?? [];
      if (toolCalls.length === 0) {
        return responseMessage.content ?? '';
      }

      messages.push(this.createAssistantToolCallMessage(responseMessage, toolCalls));

      for (const toolCall of toolCalls) {
        messages.push({
          role: 'tool',
          tool_call_id: toolCall.id,
          content: await this.executeToolCall(toolCall),
        });
      }
    }

    return 'Tool call limit reached before the model produced a final answer.';
  }

  private createAssistantToolCallMessage(
    message: MessageWithReasoningContent,
    toolCalls: ChatCompletionMessageToolCall[],
  ): ChatCompletionMessageParam {
    return {
      role: 'assistant',
      content: message.content ?? '',
      tool_calls: toolCalls,
      ...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
    } as ChatCompletionMessageParam;
  }

  private async executeToolCall(toolCall: ChatCompletionMessageToolCall): Promise<string> {
    if (toolCall.type !== 'function') {
      return JSON.stringify({
        error: `Unsupported tool call type: ${toolCall.type}`,
      });
    }

    const tool = getTool(toolCall.function.name);
    if (!tool) {
      return JSON.stringify({
        error: `Unknown tool: ${toolCall.function.name}`,
      });
    }

    try {
      const args = JSON.parse(toolCall.function.arguments || '{}') as unknown;
      return await tool.execute(args);
    } catch (error) {
      return JSON.stringify({
        error: `Invalid arguments for ${toolCall.function.name}: ${(error as Error).message}`,
      });
    }
  }
}

这样 Agent 就可以读取我们的本地文件了

write

参考:github.com/CCZX/lumen/…

最后

后续内容会持续更新

RAG vs 长上下文:企业场景完整决策框架,混合检索 +17% 召回率实战

RAG vs 长上下文:企业场景完整决策框架与技术实战

本文是「Claude 企业级工程实战手册」专栏第 13 篇。语义分块、混合检索 RRF、Cohere Reranker、幻觉检测——完整企业级 RAG 技术栈,附生产可用代码。


一、先做选择:RAG 还是长上下文?

场景 推荐方案 理由
单份合同/单个代码文件分析 长上下文 简单,无需 RAG 工程投入
内部知识库问答(<10 万文档) RAG + 重排序 成本可控,质量高
超大语料库(>100 万文档) RAG 必须 长上下文根本放不下
高精度法律/医疗查询 RAG + 长上下文混合 先检索,再深度分析
高频实时查询(>1000 次/天) RAG + Haiku 速度快,成本低
跨文档综合分析推理 长上下文 RAG 碎片化,无法整体推理

选型建议:用 20 个有代表性的查询,分别跑长上下文和 RAG,比较准确率和总成本。质量差距对业务足够重要 + 成本差距可接受 → 长上下文。否则 RAG 几乎总是更好的工程答案。


二、RAG 完整流水线

【文档入库阶段】
原始文档 → 解析清洗 → 语义分块 → 向量化 → 写入向量数据库

【查询阶段】
用户查询 → 查询扩展(生成变体) → 混合检索(向量 + BM25)
        → Reranker 重排序 → Top-5 上下文
        → Claude 生成(强制引用 + 幻觉检测)

三、语义分块(最容易被低估的环节)

固定大小分块(每 500 字切一刀)会在句子中间截断,破坏语义完整性。

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
from unstructured.partition.auto import partition

# 方案一:语义分块(推荐)
# 检测主题边界,不在句子中间切割
def semantic_chunking(text: str) -> list[str]:
    splitter = SemanticChunker(
        OpenAIEmbeddings(),
        breakpoint_threshold_type="percentile",
        breakpoint_threshold_amount=95  # 相似度低于 95 百分位时切割
    )
    return splitter.split_text(text)


# 方案二:文档感知分块(最佳,按文档结构切割)
def document_aware_chunking(file_path: str) -> list[dict]:
    """识别标题、段落等结构,沿自然边界切割"""
    elements = partition(filename=file_path)
    chunks = []
    current = {"content": "", "section": ""}

    for elem in elements:
        elem_type = type(elem).__name__
        if elem_type in ["Title", "Header"]:
            if current["content"]:
                chunks.append(current)
            current = {"content": str(elem), "section": str(elem)}
        else:
            current["content"] += f"\n{str(elem)}"

    if current["content"]:
        chunks.append(current)
    return chunks

对比数据:在企业知识库问答场景,语义分块比固定大小分块的召回率高约 12%,幻觉率降低约 8%。


四、混合检索(召回率 +17%)

单用向量检索会漏掉包含精确关键词的相关文档;单用 BM25 会漏掉语义相关但措辞不同的文档。混合检索 + 倒数排名融合(RRF)取两者之长。

from qdrant_client import QdrantClient
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

class HybridRetriever:
    def __init__(self, collection_name: str):
        self.vector_client = QdrantClient("localhost", port=6333)
        self.collection = collection_name
        self.encoder = SentenceTransformer("all-MiniLM-L6-v2")

    def search(
        self,
        query: str,
        corpus: list[str],
        top_k: int = 20,
        final_k: int = 5,
        alpha: float = 0.7  # 向量权重 70%,BM25 权重 30%
    ) -> list[dict]:

        # 向量检索
        query_vec = self.encoder.encode(query)
        dense_results = self.vector_client.search(
            collection_name=self.collection,
            query_vector=query_vec.tolist(),
            limit=top_k
        )

        # BM25 关键词检索
        tokenized = [doc.lower().split() for doc in corpus]
        bm25 = BM25Okapi(tokenized)
        scores = bm25.get_scores(query.lower().split())
        sparse_top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]

        # 倒数排名融合(RRF)
        rrf_scores: dict[str, float] = {}
        k = 60

        for rank, result in enumerate(dense_results):
            doc_id = str(result.id)
            rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + alpha * (1 / (k + rank + 1))

        for rank, idx in enumerate(sparse_top):
            doc_id = str(idx)
            rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + (1 - alpha) * (1 / (k + rank + 1))

        # 返回 Top-K
        top_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:final_k]
        return [{"id": i, "score": rrf_scores[i]} for i in top_ids]

五、查询扩展(提升覆盖率)

用 Haiku 把一个模糊查询扩展为多个精确查询变体,再分别检索取并集:

import anthropic

client = anthropic.Anthropic()

def expand_query(original_query: str) -> list[str]:
    """生成 3 个语义等价但措辞不同的查询变体"""
    resp = client.messages.create(
        model="claude-haiku-4-5-20251001",  # Haiku 省成本
        max_tokens=256,
        messages=[{
            "role": "user",
            "content": f"""原始查询:{original_query}

生成 3 个语义等价但措辞不同的查询变体,提高检索覆盖率。
每行一个,不要编号,不要解释。"""
        }]
    )
    variants = [v.strip() for v in resp.content[0].text.strip().split("\n") if v.strip()]
    return [original_query] + variants[:3]

六、Cohere Reranker(最关键的质量提升)

向量检索召回 20-100 个候选,Reranker 精选 Top-5 给 Claude。这一步对精确率的提升往往超过 20%。

import cohere

co = cohere.Client("YOUR_COHERE_API_KEY")

def rerank_results(query: str, candidates: list[dict], top_n: int = 5) -> list[dict]:
    """用交叉编码器模型重新评分,精选最相关的 Top-N"""
    documents = [c["content"] for c in candidates]

    response = co.rerank(
        query=query,
        documents=documents,
        model="rerank-v3.5",
        top_n=top_n
    )

    return [
        {
            **candidates[r.index],
            "relevance_score": r.relevance_score,
            "original_rank": r.index
        }
        for r in response.results
    ]

七、Claude 生成(强制引用 + 幻觉检测)

import re

def rag_generate(query: str, retrieved_chunks: list[dict]) -> dict:
    """生成回答,强制引用,检测幻觉"""
    # 组装上下文
    context = "\n\n---\n\n".join([
        f"[来源{i+1}] {chunk.get('source', '未知来源')}:\n{chunk['content']}"
        for i, chunk in enumerate(retrieved_chunks)
    ])

    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system="""你是一个严格基于文档的问答助手。

规则(不可违反):
1. 只使用 <context> 中提供的信息回答
2. 每个关键声明必须标注 [来源N] 引用
3. 如果来源文档中没有相关信息,明确说"根据提供的文档,无法回答此问题"
4. 不推断、不编造文档中没有的信息
5. 回答末尾给出置信度:高 / 中 / 低""",
        messages=[{
            "role": "user",
            "content": f"<context>\n{context}\n</context>\n\n{query}"
        }]
    )

    answer = response.content[0].text

    # 幻觉检测:验证引用编号是否超出范围
    citations = re.findall(r'\[来源(\d+)\]', answer)
    hallucination_issues = [
        f"引用[来源{n}]超出文档范围(共 {len(retrieved_chunks)} 个来源)"
        for n in citations if int(n) > len(retrieved_chunks)
    ]

    # 检测是否没有任何引用(可能是幻觉风险)
    if len(citations) == 0 and len(answer) > 100:
        hallucination_issues.append("回答较长但缺少引用标注,存在幻觉风险")

    return {
        "answer": answer,
        "sources": retrieved_chunks,
        "hallucination_detected": len(hallucination_issues) > 0,
        "issues": hallucination_issues,
        "citation_count": len(set(citations))
    }

八、长上下文的正确使用姿势

长上下文不是把所有文档一股脑放进去,有几个关键技巧:

def long_context_analysis(documents: list[dict], query: str) -> str:
    # 1. 最重要的文档放最前面(紧接系统提示)
    sorted_docs = sorted(documents, key=lambda d: d.get("relevance", 0), reverse=True)

    # 2. XML 标签结构化,帮助 Claude 定位
    docs_xml = "\n\n".join([
        f"<document id='{i+1}' title='{doc.get('title', f'文档{i+1}')}'>\n"
        f"{doc['content']}\n"
        f"</document>"
        for i, doc in enumerate(sorted_docs)
    ])

    # 3. 大量文档必须开 Prompt Cache,否则成本失控
    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=8096,
        system=[
            {
                "type": "text",
                "text": f"以下是需要分析的文档集合:\n\n{docs_xml}",
                "cache_control": {"type": "ephemeral"}  # 必须!节省大量成本
            }
        ],
        # 4. 指令放在用户消息末尾(最接近生成位置,注意力最强)
        messages=[{
            "role": "user",
            "content": f"{query}\n\n请跨文档综合分析,标注每个发现来自哪个文档(文档ID)。"
        }]
    )
    return response.content[0].text

长上下文的位置效应:Claude 对上下文开头和结尾的注意力最强,中间部分容易被稀释。重要文档放最前,核心指令放最后。


九、质量评估(RAGAS 框架)

from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset

def evaluate_rag_quality(
    questions: list[str],
    answers: list[str],
    contexts: list[list[str]],
    ground_truths: list[str]
) -> dict:
    dataset = Dataset.from_dict({
        "question": questions,
        "answer": answers,
        "contexts": contexts,
        "ground_truth": ground_truths
    })

    result = evaluate(dataset, metrics=[
        faithfulness,       # 忠实度(目标 > 0.85)
        answer_relevancy,   # 答案相关性(目标 > 0.80)
        context_precision,  # 检索精准度(目标 > 0.75)
        context_recall      # 检索召回率(目标 > 0.80)
    ])

    # 自动告警
    if result["faithfulness"] < 0.85:
        alert(f"⚠️ 幻觉率过高:{(1 - result['faithfulness']):.1%},需要排查")
    if result["context_recall"] < 0.80:
        alert(f"⚠️ 召回率不足:{result['context_recall']:.1%},建议优化分块策略")

    return dict(result)

十、完整端到端流程

def enterprise_rag_pipeline(query: str, knowledge_base_dir: str) -> dict:
    """企业级 RAG 完整流程"""
    retriever = HybridRetriever("enterprise_kb")

    # 1. 查询扩展
    expanded_queries = expand_query(query)

    # 2. 混合检索(对每个变体检索,取并集)
    all_candidates = []
    for q in expanded_queries:
        results = retriever.search(q, corpus, top_k=20, final_k=20)
        all_candidates.extend(results)

    # 去重
    seen = set()
    unique_candidates = [c for c in all_candidates if c["id"] not in seen and not seen.add(c["id"])]

    # 3. Reranker 精选 Top-5
    top_chunks = rerank_results(query, unique_candidates[:50], top_n=5)

    # 4. Claude 生成
    result = rag_generate(query, top_chunks)

    return result

下一篇:14. AI Agent 安全与合规治理

专栏首页:Claude 企业级工程实战手册


专栏导航 · Claude 企业级工程实战手册

⬅️ 上一篇:12. MCP 企业级集成全指南:从协议原理到 OAuth 2.1 安全配置四层体系 ➡️ 下一篇:14. AI Agent 安全全景:Promptware Kill Chain 与深度防御五层体系

本专栏共 14 篇,系统覆盖 Claude 模型选型 / Prompt 工程 / Claude Code 工作流 / API 高级用法 / MCP / RAG / AI 安全合规全链路。欢迎收藏:Claude 企业级工程实战手册

iptables Command in Linux: Manage Firewall Rules

When a packet arrives at a Linux machine, the kernel decides what to do with it based on a set of firewall rules. Those rules live in the kernel’s Netfilter framework, and for more than two decades the standard way to edit them from user space has been the iptables command.

iptables controls packet filtering, network address translation, and packet mangling. Higher-level front ends such as ufw and firewalld manage the same Netfilter firewall stack through simpler interfaces, although many modern systems use nftables underneath. Understanding the underlying command is still valuable, especially when you inherit a server that was set up by someone else. This guide walks through the concepts and the commands you need to read, edit, and persist firewall rules.

Tables and Chains

Before you write a rule, you need to know where it goes. iptables is organized into tables, and each table contains chains.

The three tables you will use most often are:

  • filter - the default table, used for allowing and blocking traffic
  • nat - used for network address translation, such as port forwarding and masquerading
  • mangle - used to alter packet headers, for example to set QoS marks

Each table has a set of built-in chains that correspond to moments in the life of a packet. In the filter table:

  • INPUT - packets destined for the local machine
  • OUTPUT - packets originating from the local machine
  • FORWARD - packets routed through the machine

A rule says: for packets that enter this chain and match these criteria, take this action. The action is called a target and is usually ACCEPT, DROP, REJECT, or the name of another chain.

iptables Syntax

The general form of the command is:

txt
iptables [-t TABLE] COMMAND CHAIN [MATCH] [-j TARGET]

If -t is omitted, iptables uses the filter table. Common commands include -A (append a rule), -I (insert), -D (delete), -L (list), -F (flush), and -P (set default policy).

All commands that change the firewall require root privileges. Run them with sudo or as root.

Warning
It is easy to lock yourself out of a remote server with a single wrong rule. Before you apply a restrictive ruleset over SSH, either test on a local machine first or use iptables-apply, which rolls back automatically if you lose access.

List Rules

To print every rule in the filter table, use the -L option:

Terminal
sudo iptables -L

The default output shows service names, resolves IP addresses, and hides packet and byte counters. For real work, add -n to keep numeric output and -v to show counters and interface information:

Terminal
sudo iptables -L -n -v
output
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
1234 98K ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
0 0 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:23
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination

To list a single chain, append its name:

Terminal
sudo iptables -L INPUT -n -v

To number the rules so you can reference them by index when deleting, add --line-numbers:

Terminal
sudo iptables -L INPUT -n -v --line-numbers

Add and Remove Rules

New rules are added to the end of a chain with -A (append) or at a specific position with -I (insert). The difference matters because iptables evaluates rules top to bottom and stops at the first match.

To allow incoming SSH connections:

Terminal
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT

To allow HTTP and HTTPS:

Terminal
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

To insert a rule as the first one in the chain, use -I CHAIN 1:

Terminal
sudo iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT

This is important when you are working over SSH. If a broad DROP rule already appears earlier in the chain, appending the accept rule after it would not help because the packet would be dropped first.

To delete a rule, either repeat the exact specification with -D:

Terminal
sudo iptables -D INPUT -p tcp --dport 23 -j DROP

Or delete by line number, which is easier when the rule has many options:

Terminal
sudo iptables -D INPUT 3

Allow and Block Specific IPs

To block all traffic from a single IP address:

Terminal
sudo iptables -A INPUT -s 203.0.113.10 -j DROP

To block a range using CIDR notation:

Terminal
sudo iptables -A INPUT -s 203.0.113.0/24 -j DROP

To allow SSH only from a trusted subnet:

Terminal
sudo iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 22 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j DROP

The first rule accepts SSH from the local subnet. The second drops SSH from everywhere else. Order matters: if you reversed the two lines, every SSH attempt would be dropped before the accept rule had a chance to match.

Allow Established Connections

Most firewall setups include a rule that accepts traffic belonging to an already established connection. This lets return traffic through without needing a matching rule for each outbound request:

Terminal
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

Put this rule near the top of the INPUT chain so it matches early. Without it, the default DROP policy breaks outbound connections that expect responses.

Set a Default Policy

Each built-in chain has a default policy that applies when no rule matches. The -P option changes it:

Terminal
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT

Switching INPUT to DROP is the foundation of a deny-by-default firewall: nothing gets in unless an explicit rule allows it. Before you flip the policy, make sure you have already added the rules that allow SSH, established connections, and anything else you need.

Flush Rules

To remove every rule from every chain in the current table:

Terminal
sudo iptables -F

To flush a specific chain only:

Terminal
sudo iptables -F INPUT

Flushing does not reset the default policies. If you have set INPUT to DROP, flushing will leave it at DROP with no rules, which blocks all inbound traffic. Reset the policy to ACCEPT first if that is not what you want:

Terminal
sudo iptables -P INPUT ACCEPT
sudo iptables -F

Save and Restore Rules

Rules added with iptables live in kernel memory only. They disappear on reboot unless you save them.

On Ubuntu, Debian, and Derivatives, the iptables-persistent package saves rules to /etc/iptables/rules.v4 and reloads them at boot:

Terminal
sudo apt install iptables-persistent

The installer asks whether to save the current rules. To update the saved copy later:

Terminal
sudo netfilter-persistent save

On Fedora, RHEL, and Derivatives, the equivalent service is iptables-services:

Terminal
sudo dnf install iptables-services
sudo systemctl enable --now iptables
sudo service iptables save

Independent of the distribution, you can dump and restore rules manually with iptables-save and iptables-restore:

Terminal
sudo iptables-save -f /etc/iptables/rules.v4
sudo iptables-restore /etc/iptables/rules.v4

This is also the recommended way to edit a large ruleset: save to a file, edit the file, then restore it atomically.

Troubleshooting

Rules disappear after a reboot
iptables rules are not persistent by default. Install iptables-persistent on Debian-based systems or iptables-services on RHEL-based ones, and save the ruleset.

SSH stops working after setting a DROP policy
You switched INPUT to DROP without an ACCEPT rule for port 22, or the ACCEPT rule is positioned after a more general DROP rule. Connect through the console, add the rule with -I INPUT 1, and save.

A rule looks correct but does not match
Check the order. iptables walks the chain top to bottom and stops at the first match, so an earlier accept or drop may be catching the packet first. Use iptables -L INPUT -n -v --line-numbers to inspect the order.

Changes are silently ignored
You may be editing the wrong table. A rule in filter does not affect NAT, and vice versa. Pass -t TABLE explicitly when you are not working in filter.

iptables: command not found
On some modern distributions, only nftables is installed by default. Install iptables with your package manager, or use nft directly.

Quick Reference

For a printable quick reference, see the iptables cheatsheet .

Action Command
List rules (verbose, numeric) iptables -L -n -v --line-numbers
Allow port iptables -A INPUT -p tcp --dport PORT -j ACCEPT
Block IP iptables -A INPUT -s IP -j DROP
Allow established connections iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
Insert rule at top iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT
Delete rule by number iptables -D INPUT N
Set default policy iptables -P INPUT DROP
Flush all rules iptables -F
Save rules iptables-save -f /etc/iptables/rules.v4
Restore rules iptables-restore /etc/iptables/rules.v4

FAQ

Is iptables still relevant in 2026?
It is still installed and widely used, but it is being replaced by nftables, which offers a cleaner syntax and better performance. On recent distributions, the iptables command is often a compatibility front end that writes nftables rules underneath.

Should I use iptables, ufw, or firewalld?
If you are managing rules by hand on Debian or Ubuntu, ufw is simpler and covers most cases. On Fedora and RHEL, firewalld is the default. Reach for raw iptables when you need fine-grained control that the front ends do not expose, or when you are troubleshooting an existing ruleset.

What is the difference between DROP and REJECT?
DROP silently discards the packet; the sender sees a timeout. REJECT sends an ICMP error back (or a TCP reset for TCP), so the sender gets immediate feedback. DROP is often preferred on public interfaces because it does not confirm that the port exists.

How do I block a country or a list of IPs?
For a handful of addresses, add one -s rule per entry. For larger lists, use the ipset tool to manage the addresses and reference the set from a single iptables rule.

Does iptables handle IPv6?
No. Use ip6tables for IPv6 rules. It has the same syntax and the same tables and chains, but operates on a separate rule set.

Conclusion

iptables is a low-level but reliable way to read and shape the Linux firewall. Once you have a working ruleset, save it with iptables-save and commit the file so the next person to touch the server has a clear starting point.

Deno 2.8 正式发布,再次超越 Bun,史上最大的次版本升级诞生!

👇 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

最近 Bun 效仿 Deno,要从 Zig 语言移植到 Rust “锈化“重写,源码 PR 已经合并了,正式官宣指日可待。

Deno 也不甘示弱,Deno 团队官宣 v2.8 正式发布,号称 Deno 进化史上最大的次版本升级,主要包括:

  • Node 兼容性远超 Bun,测试率超过七成
  • Deno CLI 新增命令,可以替换 pnpm install
  • 新增 JS Stage3 import defer 导入延迟提案
  • TS 更新到 v6 主版本,支持类型剥离

deno

👉 Node 兼容性超过 Bun

之前 Deno 2.7 针对 Node 官方测试的通过率约为 42%,勉强超过了 Bun 1.3.14 的 40.6%。

Deno 2.8 更进一步,几乎涵盖了所有 node: 模块,测试率飙升到 76.4%,大幅领先 Bun,Deno 和 Bun 的“Rust 竞赛“预计会愈演愈烈。

bun.png

👉 新增子命令

Deno CLI 新增了几个命令。

deno audit fix 能将漏洞模块升级到最新的补丁版,同时满足我们配置的主/次版本限制,任何需要升级主版本的模块都会单独列出,方便你决定升级与否。

deno bump-version 能更新 package.jsondeno.json 中的 version 字段。它也适用于 workspace 工作区模式,在根目录运行能将更新应用到每个模块。

image.png

还有其他几个命令,我把它们浓缩为下列表格:

命令 作用
deno ci 根据 lockfile 执行安装
deno pack 约等于 tsc + npm pack
deno transpile TSX 类型剥离,输出 JS
deno why 等价于 npm explain / pnpm why

👉 包管理变更

Deno CLI 不再要求 deno adddeno install 命令添加 npm: 前缀,默认将无前缀的包名视为 npm 模块。

image.png

注意,CLI 中的 JSR 注册源仍需要 jsr: 前缀,ESM 模块中的 import 语句也要求 npm: 前缀。

这样,deno install 能取代 npm installpnpm install 等命令,允许你使用 Deno 取代 npm 作为包管理器,但项目还是跑在 Node 上,既符合 Node 开发者的肌肉记忆,又提升了安装速度。

过去,monorepo 跨包共享依赖需要手动协调版本,当共享依赖更新时,每个模块的 package.json 必须同步更新。

Deno 2.8 采用 pnpm 的 catalog: 协议,这允许在 workspace 根目录中声明一个默认的 "catalog" 字段:

image.png

然后只需使用 catalog: 说明符,就能从任意工作区模块同步依赖版本:

image.png

此外,类似 pnpm 的模块隔离结构,Deno 默认的 node_modules 目录结构是隔离的,每个模块都有自己的符号链接解析树,因此它只能看到自己显式声明的依赖。

但一些旧工具仍然依赖 npm install 生成依赖提升的扁平目录结构,每个模块都位于 node_modules 顶层,并且可以 require() 它找到的任何依赖。

deno.json 新增了 nodeModulesLinker 字段,默认值是 "isolated"(隔离目录):

image.png

设置 "nodeModulesLinker": "hoisted",可以移植一个依赖 npm 扁平目录的现有 Node 项目。

还有,Deno 2.6 就新增了 min-release-age 最小发布时限配置,来拦截大多数供应链攻击。Deno 2.8 支持通过 .npmrc 配置:

image.png

👉 JS 新功能

Deno 支持 JS Stage3 的 import defer(延迟导入提案),模块能不运行其顶层代码加载,这样该模块只在首次访问其导出成员时才被执行。

举个栗子,模块先导入,但可以延迟执行:

image.png

这样,模块求值会延迟到访问导出成员的那个时间点。当模块求值成本高昂、但又不常使用时,import defer 新特性能缩短启动时间。

👉 TS 更新

TS 编译器更新到 v6.0.3 版本了,这是为了对齐 ts-go(TS 7.x) 的过渡版本,包括类型系统支持 ES2026 的最新功能等大量改动。

此外,deno check 默认包含 lib.node,不需要在 deno.json 中的 compilerOptions.lib 手动添加 "node" 了。

image.png

如上,Deno 自动支持 process / Buffer 等 Node 专属的全局变量和类型。lib.node 基于 @types/node 实现,Deno 会从 npm 拉取该模块,process.versions.node 匹配 Node 的主版本,目前是 v24.x

如果你希望使用其他版本的 @types/node,比如仍在维护的更低版本 Node 22,可以在 package.json 中将其安装为开发依赖:

image.png

然后在 deno.json 中让 Deno 导入对应版本的模块:

image.png

👉 开发体验

Deno 2.8 支持让 Chrome DevTools(开发者工具)检查网络流量:

  1. 运行程序时添加 --inspect-wait 等参数
  2. 在 Chromium 中打开 chrome://inspect
  3. 点击 Deno 目标上的 Inspect(检查)

image.png

开发者工具的“Network“网络选项卡会显示客户端请求和响应头等所有内容:

network

相同的事件也会通过 node:inspector 客户端和 VS Code 的 JavaScript 调试器等工具显示出来。

此外,Deno 2.8 上线了一个与 Node --cpu-prof 匹配的内置 CPU 分析器,当程序退出时,Deno 会将 V8 的 CPU 分析结果写入磁盘。

image.png

.cpuprofile 文件可以在 Chrome DevTools 中直接打开,也可以输出为另外两种格式:

  • --cpu-prof-flamegraph 会生成一个独立的交互式 SVG 图片,可以在浏览器中打开
  • --cpu-prof-md 会生成一份人类可读的 Markdown 报告,包含最热门的函数等详细信息

image.png

👇 重点总结

Deno 2.8 是 Deno 进化史上最大的次版本升级,主要包括:

  • Deno CLI 新增了若干命令,Node 兼容性远超 Bun
  • 新增 JS Stage3 的 import defer 延迟导入提案
  • 包管理器对齐 npm 行为,支持模块提升的扁平化目录
  • TS 更新到 v6 主版本,支持类型剥离和 Node 专属类型

除此之外,Deno 官方博客还展示了 Deno 2.8 的性能提升,Web API 新功能等,更多技术细节另请参阅官方博客。

以上就是今日《前端快讯》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

🙏 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

👇 参考文献:

React 19.x 的 lazy 与 Suspense

React.lazy

在构建大型 React 应用时,打包体积过大往往会影响首屏加载速度。React.lazy 正是为了解决这一问题而诞生的内置函数,它让你可以将组件动态导入(code splitting),并按需加载,从而显著提升应用性能。

为何使用 React.lazy?

  1. 减少初始包体积:应用的首屏可能不需要所有组件。通过代码分割,只加载当前路由或交互所需的组件。
  2. 提升首屏加载速度:更少的 JavaScript 意味着更快的解析和执行时间,改善用户体验。
  3. 优化缓存与带宽:用户可能只使用部分功能,懒加载未使用的代码可节省流量。
  4. 与 Suspense 天然集成:React.lazy 配合 Suspense 可以优雅地显示加载状态(如 Loading 动画)。

渲染阶段流程

React.lazy 组件加载完成的触发机制依赖于 Promise 的 resolve 回调 和 React 内部的 Suspense 重试(ping)机制

一、 初始化

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

二、 首次渲染

当 React 遇到这个懒加载对象时,会调用内部函数 lazyInitializer

  • 执行 _result()(即 import()),得到一个 Promise(thenable)。
  • 将 _status 更新为 Pending_result 指向该 Promise。
  • 抛出该 Promise 以触发最近的 <Suspense> 边界.

三、 suspense 捕获与监听

React 捕获抛出的 Promise,向上查找 Suspense 边界:

  • 调用 attachPingListener 为该 Promise 添加一个 then 回调(即 ping 函数)。
  • 该边界立即渲染 fallback UI。

四、 加载完成(Promise resolve)

当动态导入的模块成功加载后,Promise 被 resolve,模块对象作为结果返回。Promise 的回调执行:

  • 将 lazy 对象的 _status 更新为 Resolved_result 替换为模块对象。
  • 调用之前附加的 ping 回调(实际上是 pingSuspendedRoot)。

五、 触发重新渲染

pingSuspendedRoot 会标记对应根节点的优先级车道(pingedLanes),并调用 ensureRootIsScheduled,重新调度整个应用的渲染(或仅重试该 Suspense 边界)。

六、 二次渲染

React 再次执行该组件的渲染逻辑,此时 lazyInitializer 发现 _status === Resolved,直接返回 _result.default(真正的组件),从而正常完成渲染,替换 fallback。

注意事项

  1. 避免在渲染函数内动态调用 lazylazy 应在模块顶层定义,确保每次渲染都得到相同的引用。
  2. 重复导入优化:同一个 lazy 组件在多处使用时,内部会共享相同的 Promise,不会重复加载。
  3. 错误处理:懒加载可能因网络问题失败,建议结合错误边界(Error Boundary)捕获加载失败错误。
  4. 命名导出问题:默认导出是 lazy 的约定,非默认导出需手动转换。
  5. 避免在 Suspense 外部调用 lazy 组件:否则无法捕获挂起。

示例 懒加载组件

lazy 接收一个函数,该函数必须返回一个动态 import() 调用(返回 Promise,其 resolve 值为包含 React 组件的模块)。Suspense 用于包裹懒加载组件,并在等待期间渲染 fallback 内容。

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

懒加载组件开始到成功的 三个阶段

初始化 状态 -1

image.png

加载中 状态 0

image.png

加载完成 状态 1

image.png

import {  useState, } from "react";

const Card = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="card">
      <p>Count: {count}</p>
      <button onClick={() => setCount((val) => val + 1)}>Click me</button>
    </div>
  );
};

export default Card;

调用 React.lazy(() => import('./Component')) 会生成一个特殊的“懒加载对象”,内部包含 _status(初始为 Uninitialized)和 _result(存储加载器函数)。

image.png

image.png

beginWork

image.png

fiber.elementType

image.png

resolveLazy 解析 lazy 组件

image.png

lazy._init(lazy._payload) 执行初始化函数

image.png

回到 resolveLazy 解析 lazy 组件

全局变量 suspendedThenable 为 promise pending状态

image.png

 const SuspenseException: mixed = new Error(
  "Suspense Exception: This is not a real error! It's an implementation " +
    'detail of `use` to interrupt the current render. You must either ' +
    'rethrow it immediately, or move the `use` call outside of the ' +
    '`try/catch` block. Capturing without rethrowing will lead to ' +
    'unexpected behavior.\n\n' +
    'To handle async errors, wrap your component in an error boundary, or ' +
    "call the promise's `.catch` method and pass the result to `use`.",
);

handleThrow 负责处理渲染过程中抛出的各种异常(包括普通错误和 Suspense 挂起)

image.png

getSuspendedThenable

全局变量重置 为 null ,返回 promise pending

image.png

回到 handleThrow

image.png

renderRootSync

全局变量 workInProgressSuspendedReason 为 3, 代表 SuspendedOnImmediate 因任务立即挂起

image.png

找到边界

image.png

// 未挂起,正常渲染
const NotSuspended: SuspendedReason = 0;
// 渲染过程中抛出异常
const SuspendedOnError: SuspendedReason = 1;
// 等待异步数据
const SuspendedOnData: SuspendedReason = 2;
// 因立即任务挂起
const SuspendedOnImmediate: SuspendedReason = 3;
// 因实例挂起
const SuspendedOnInstance: SuspendedReason = 4;
// 因实例挂起但准备继续
const SuspendedOnInstanceAndReadyToContinue: SuspendedReason = 5;
// 因废弃的 Promise 挂起
const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 6;
// 挂起准备继续
const SuspendedAndReadyToContinue: SuspendedReason = 7;
// 因 hydration 挂起
const SuspendedOnHydration: SuspendedReason = 8;
// 因 action 挂起
const SuspendedOnAction: SuspendedReason = 9;

throwAndUnwindWorkLoop 处理渲染过程中的异常/挂起,展开栈并找到处理边界

image.png

throwException 处理渲染阶段的异常和 Suspense

image.png

attachPingListener 为 Suspense 边界的挂起 Promise(wakeable)添加“ping”监听器

image.png

渲染 fallback

再次进入 ,lazy组件还是加载中

image.png

懒加载组件加载完成

image.png

加载完毕是一个函数组件

image.png

示例 命名导出组件的懒加载

const InfoCard = lazy(() =>
  import("./Card").then((mod) => ({ default: mod.InfoCard })),
);
export const InfoCard = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="info-card">
      <p>Info Card</p>
      <button onClick={() => setCount((val) => val + 1)}>
        InfoCard-Click me
      </button>
      <p>InfoCard Count: {count}</p>
    </div>
  );
};

beginWork 阶段

fiber.tag = 16 , 代表 LazyComponent

case LazyComponent: {
  const elementType = workInProgress.elementType;
  return mountLazyComponent(
    current,
    workInProgress,
    elementType,
    renderLanes,
  );
}

image.png

completeWork 阶段

case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
  bubbleProperties(workInProgress);
  return null;

源码

const Uninitialized = -1; // 未初始化,未调用
const Pending = 0; // 加载中
const Resolved = 1; // 加载成功
const Rejected = 2; // 加载失败
function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {

  // 创建 payload 对象
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized, // 初始化,未调用
    _result: ctor, // 存储工厂函数
  };

  // 创建 lazyType 对象   
  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE, // 标识是 lazy 组件
    _payload: payload, // 存储 payload 对象,加载信息
    _init: lazyInitializer, // 初始化函数
  };

  return lazyType;
}
  • 未初始化状态(_status === Uninitialized),状态变为 Pending,执行 throw payload._result;,抛出 thenable。这正是 React Suspense 的触发点。
  • 成功回调:当模块加载成功时,将 payload._status 设置为 Resolvedpayload._result 设置为模块对象。
  • 失败回调:将 payload._status 设置为 Rejectedpayload._result 设置为错误对象。如果是 Rejected,抛出错误,由最近的错误边界(Error Boundary)捕获
function lazyInitializer<T>(payload: Payload<T>): T {
  // 未初始化处理
  if (payload._status === Uninitialized) {
    let resolveDebugValue: (void | T) => void = (null: any);
    let rejectDebugValue: mixed => void = (null: any);
    const ctor = payload._result; // 加载器函数 () => import("")
    const thenable = ctor(); // 加载器函数返回的 Thenable 对象

    // 监听 Promise 状态变化
    thenable.then(
      moduleObject => { // 加载成功
        // 正在加载、未初始化
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved; // 设置状态为加载成功
          resolved._result = moduleObject; // 设置结果为模块对象


          if (thenable.status === undefined) {
            const fulfilledThenable: FulfilledThenable<{default: T, ...}> =
              (thenable: any);
            fulfilledThenable.status = 'fulfilled'; // 设置状态为加载成功
            fulfilledThenable.value = moduleObject; // 设置值为模块对象
          }
        }
      },
      // 加载失败
      error => {
        if (
          (payload: Payload<T>)._status === Pending ||
          payload._status === Uninitialized
        ) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected; // 设置状态为加载失败
          rejected._result = error; // 设置结果为错误对象
 
          if (thenable.status === undefined) {
            const rejectedThenable: RejectedThenable<{default: T, ...}> =
              (thenable: any);
            rejectedThenable.status = 'rejected';
            rejectedThenable.reason = error;
          }
        }
      },
    );


    // 未初始化
    if (payload._status === Uninitialized) {
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  // 加载成功
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    return moduleObject.default; // 返回模块对象的默认导出
    
  } else {
   // 抛出 thenable。这正是 React Suspense 的触发点
    throw payload._result;
  }
}

Suspense

Suspense 是 React 内置的组件,用于包裹那些可能“挂起”(Suspend)的子组件。当子组件抛出 Promise(或 React 内部的 Suspense 异常)时,Suspense 会捕获并渲染 fallback 属性指定的占位内容,直到 Promise 解决后重新渲染子组件。

suspense 能够实现:

  • 并行等待多个资源。
  • 避免加载闪烁(快速加载时不显示 fallback)。
  • 与错误边界(Error Boundary)无缝集成。

注意事项

  1. 避免在 fallback 中再使用 Suspense
  2. Suspense 不能捕获错误 。它只处理 Promise 挂起,普通错误(如运行时错误)需要 Error Boundary。

beginWork

Suspense 组件会根据是否已捕获挂起(DidCapture 标记)或需要停留在 fallback 状态,决定本次渲染显示 fallback 还是 primary 内容:

  • 若需显示 fallback,则创建 fallback 子树并将 primary 子树包裹为隐藏的 Offscreen 组件以保留状态。
  • 否则正常渲染 primary 子树。
case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);

completeWork

Suspense 组件负责完成水合收尾(处理 SSR 脱水节点)、处理 DidCapture 标记以触发重新渲染 fallback、调度重试队列(为等待的 Promise 附加 ping 监听器),并标记因 fallback/primary 切换而产生的副作用(如添加 Visibility 或 Passive 标记),最后向上冒泡 childLanes

commit 阶段

Mutation 子阶段

  • 通过 Offscreen 组件的 Visibility 标记,对 primary 树执行 display: none(隐藏)或恢复显示。
  • 处理边界删除时的清理工作(解绑 ref、调用 componentWillUnmount)。
  • 清空已完成的重试队列。

Layout 子阶段

  • 执行 scheduleRetryEffect 中调度的重试回调:为 retryQueue 中的每个 Promise 附加 ping 监听器(pingSuspendedRoot)。
  • 允许子组件(primary 或 fallback)正常执行 useLayoutEffect 和 componentDidMount/Update

Passive 阶段(异步):

  • 执行 Offscreen 子树上因可见性变化而挂起的 useEffect 清理和回调。

示例 代码分割

import { lazy, Suspense, useState } from "react";

const Card = lazy(() => import("./Card"));
const SuspenseB = () => {
  const [num, setNum] = useState(0);
  return (
    <div className="suspense-b">
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>click</button>
      <Suspense fallback={<div className="suspense-b-fallback">Loading...</div>}>
        <Card />
      </Suspense>
    </div>
  );
};
export default SuspenseB;

beginWork updateSuspenseComponent

当前正在处理 workInprocess 是 suspense 组件,有属性pendingProps包含children 和 fallback,current为 null

image.png

image.png

mountSuspensePrimaryChildren

直接渲染 primary fiber

mountWorkInProgressOffscreenFiber 创建 Offscreen fiber

image.png

这里return,结束当前的beginWork

image.png

来到 beginWork updateOffscreenComponent

此时 workInProcess 为 Offscreen fiber,(之前 suspense要渲染的primary fiber),current 为 null

image.png

首次挂载创建 Offscreen 实例,用于存储 Offscreen 的可见性、待处理的标记、重试缓存及相关 transitions

image.png

image.png

reconcileChildren 子节点

createFiberFromTypeAndProps 创建 lazy fiber

return,又结束此次 beginWork

image.png

来到 beginWrok lazy fiber

image.png

在解析懒加载组件时,会 有微任务产生

进入 beginWork tag 为13 suspense

image.png

支持显示 fallback

挂载 primary fiber,mode 为隐藏状态

image.png

创建 fallback fiber,类型为 Fragment

image.png

关系

workInProcess.childprimary fiber

primary fibersiblingfallback fiber

image.png

image.png

image.png

beginWork 结束,再次进入beginWork 处理 fallback fiber

当 lazy 加载完成后,继续处理

示例 数据获取 use

import { use, useState, Suspense } from "react";

const fetchData = (async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  return response.json();
})();

const SuspenseC = () => {
  const result = use(fetchData);
  const [count, setCount] = useState(0);

  console.log("state-a-render-----");

  return (
    <div className="state-a">
      <h3>StateA</h3>
      <p>{result?.title}</p>
      <p>当前count: {count}</p>
      <button onClick={() => setCount(count + 1)}>SuspenseC -点击增加</button>
    </div>
  );
};

const App = () => {
  return (
    <Suspense fallback={<div className="suspense-c-fallback">Loading...</div>}>
      <SuspenseC />
    </Suspense>
  );
};
export default App;

最后

手写虚拟DOM后,我反问面试官:key为什么不能用index?

前言

虚拟DOM和diff算法是React面试的“进阶题”,一般不会让手写完整实现,但一旦遇到,就是区分“会用React”和“懂React”的分水岭。大部分前端能说出虚拟DOM的好处,但真要写一个mini版,很多人会卡在diff的key逻辑上。

今天我就还原那次面试:AI生成的虚拟DOM核心代码、我是如何解释diff的、以及为什么“key不能用index”这个问题能让我反客为主。最后附完整代码,你可以直接拿去跑,也可以用来准备面试。

一、AI生成的虚拟DOM核心代码

我在Cursor里输入:

用原生JavaScript实现一个简易虚拟DOM库,包含:

  • h(type, props, ...children) 创建虚拟节点
  • render(vnode) 将虚拟节点转为真实DOM
  • patch(oldVnode, newVnode) 对比并更新真实DOM,支持key属性,实现最小化更新

AI输出的核心结构如下(精简后):

// 创建虚拟节点
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}

// 渲染虚拟DOM到真实DOM
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let key in vnode.props) {
    el.setAttribute(key, vnode.props[key]);
  }
  vnode.children.forEach(child => el.appendChild(render(child)));
  return el;
}

// 简易diff(带key优化)
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  // 如果是文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) {
      parent.replaceChild(render(newVnode), oldVnode);
    }
    return;
  }
  // 不同类型,直接替换
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 相同类型,更新属性(省略细节)
  // 然后递归处理children,这里重点演示key的作用
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const keyedOld = new Map();
  // 将旧节点按key建立索引
  oldChildren.forEach((child, idx) => {
    if (child.props && child.props.key) keyedOld.set(child.props.key, { child, idx });
  });
  // 遍历新节点,复用key相同的节点
  newChildren.forEach((newChild, newIdx) => {
    if (newChild.props && newChild.props.key) {
      const matched = keyedOld.get(newChild.props.key);
      if (matched) {
        // 复用该DOM节点,递归更新子内容
        patch(matched.child, newChild, parent);
        // 移动位置(这里省略,示意核心)
        return;
      }
    }
    // 没有匹配,插入新节点
    parent.appendChild(render(newChild));
  });
}

二、我反问了面试官一个问题

等代码展示完,面试官还没开口,我说:“这个diff算法里用key来匹配节点。很多前端都用过key,但有一个经典误区——把数组索引当key用。您知道为什么这样会有问题吗?”

他来了兴趣:“你说说看。”

我解释:

  • diff算法通过key判断节点是否“相同”。如果用索引,比如列表顺序变了,索引0可能原来对应A,现在对应B,但key相同(都是0),React会认为这两个节点相同,不重新创建,只是更新内容。这样本应销毁A、创建B的场景,变成了复用A并修改内容。如果组件有复杂状态(比如动画、输入框焦点),就会出现状态错乱。
  • 更严重的是,在列表头部插入一个元素,所有后续节点的索引都变了,每个节点都会被“原地修改”,性能反而比不用key还差。
  • 正确做法是用数据中唯一稳定的标识(如id)作为key。

他点头:“这才是我想听到的答案。”

三、为什么面试官认可这种“反客为主”?

他后来告诉我:“你能自己生成正确的diff逻辑,还能主动抛出常见的误区,说明你不仅会写,还真的思考过生产中的坑。这种深度,比背代码有价值。”

所以这道题的关键不是完美写出所有diff逻辑,而是理解key的真实作用。AI帮你搭了骨架,你用自己的理解填充了灵魂。

四、完整可运行的迷你虚拟DOM代码

我把面试中使用的完整代码放在这里,你可以在浏览器控制台运行测试:

// 完整示例(带简版diff和key复用)
function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
  if (typeof vnode === 'string') return document.createTextNode(vnode);
  const el = document.createElement(vnode.type);
  for (let k in vnode.props) el.setAttribute(k, vnode.props[k]);
  vnode.children.forEach(c => el.appendChild(render(c)));
  return el;
}
function patch(oldVnode, newVnode, parent = oldVnode.parentNode) {
  if (oldVnode === newVnode) return;
  // 文本节点
  if (typeof oldVnode === 'string' || typeof newVnode === 'string') {
    if (oldVnode !== newVnode) parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  if (oldVnode.type !== newVnode.type) {
    parent.replaceChild(render(newVnode), oldVnode);
    return;
  }
  // 更新属性(略)
  // 处理children(简易版:只演示替换,不移动)
  const oldChildren = oldVnode.children;
  const newChildren = newVnode.children;
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLen; i++) {
    if (i < oldChildren.length && i < newChildren.length) {
      patch(oldChildren[i], newChildren[i], parent.childNodes[i]);
    } else if (i < newChildren.length) {
      parent.appendChild(render(newChildren[i]));
    } else {
      parent.removeChild(parent.childNodes[i]);
    }
  }
}

你可以用这段代码测试列表渲染,尝试改变顺序或插入头节点,观察不用key vs 用index vs 用id的区别。

五、写在最后

虚拟DOM和diff是React的根基,手写一遍能让你对性能优化有更深的体感。AI能帮你快速生成模板,但真正拉开差距的,是对“为什么key不能用index”这种问题的思考深度。

Redux 中间件作用(redux-thunk/redux-saga)

Redux 中间件(Middleware)本质上是:
dispatch(action) 到达 reducer 之前,对 action 做增强处理的一层机制。

它主要解决:

  • 异步请求
  • 日志打印
  • 权限校验
  • 接口调用
  • 延迟 dispatch
  • 副作用管理

一、Redux 默认的问题

Redux 原生规定:

store.dispatch({
  type: 'ADD'
})

dispatch 只能发送:

  • 普通对象 action

而且:

  • reducer 必须是纯函数
  • reducer 不能写异步

所以:

setTimeout()
axios()
fetch()

这些都不能直接写进 reducer。

这时候就需要:

中间件 Middleware


二、中间件执行流程

Redux 数据流:

dispatch(action)
   ↓
middleware
   ↓
reducer
   ↓
store 更新
   ↓
view 更新

多个中间件:

dispatch
  ↓
thunk
  ↓
logger
  ↓
saga
  ↓
reducer

三、redux-thunk

1. thunk 是什么

Redux Thunk

Thunk 是 Redux 最常用的异步中间件。

它允许:

dispatch(function)

而不是只能:

dispatch(object)

四、redux-thunk 核心思想

普通 Redux:

dispatch({
  type: 'GET_USER'
})

Thunk:

dispatch(async function(dispatch){
   const res = await axios.get('/user')

   dispatch({
      type:'SET_USER',
      payload: res.data
   })
})

也就是:

dispatch 一个函数

函数内部:

  • 可以写异步
  • 可以再次 dispatch
  • 可以拿到 store

五、thunk 工作原理

内部核心思想:

const thunk = store => next => action => {

   if(typeof action === 'function'){
      return action(store.dispatch, store.getState)
   }

   return next(action)
}

意思:

  • 如果 dispatch 的是函数

    • 就执行它
  • 如果是普通对象

    • 继续传给 reducer

六、thunk 使用流程

1. 安装

npm install redux-thunk

2. 注册 middleware

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

3. 编写异步 action

export const getUser = () => {

  return async (dispatch) => {

    const res = await axios.get('/api/user')

    dispatch({
      type:'SET_USER',
      payload:res.data
    })
  }
}

4. 页面调用

dispatch(getUser())

七、redux-thunk 优缺点

优点

简单易学

适合:

  • 小项目
  • 中型项目
  • 简单异步

缺点

大型项目容易:

  • 回调地狱
  • action 逻辑混乱
  • 难维护
  • 副作用分散

例如:

dispatch(async ()=>{
   await api1()
   await api2()
   await api3()
})

会越来越复杂。


八、redux-saga

Redux-Saga

Saga 是:

更强大的异步流程管理方案

核心思想:

把异步逻辑单独管理

类似:

  • 后台任务
  • 事件监听
  • 协程
  • generator

九、saga 最大特点

它使用:

Generator

例如:

function* getUserSaga() {

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

十、saga 工作流程

dispatch(action)
    ↓
saga监听
    ↓
执行异步任务
    ↓
put(action)
    ↓
reducer

十一、核心 API

takeEvery

监听每次 action

yield takeEvery('GET_USER', getUserSaga)

takeLatest

只保留最后一次请求

适合搜索:

yield takeLatest('SEARCH', searchSaga)

put

等于 dispatch

yield put({
  type:'SET_USER'
})

call

调用异步函数

yield call(api.getUser)

select

获取 store 数据

const state = yield select()

十二、saga 使用流程

1. 安装

npm install redux-saga

2. 创建 sagaMiddleware

import createSagaMiddleware from 'redux-saga'

const sagaMiddleware = createSagaMiddleware()

3. 注册

const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

4. 启动 saga

sagaMiddleware.run(rootSaga)

5. 编写 saga

function* getUserSaga(){

   const res = yield call(api.getUser)

   yield put({
      type:'SET_USER',
      payload:res
   })
}

function* rootSaga(){
   yield takeEvery('GET_USER', getUserSaga)
}

十三、thunk vs saga

对比 thunk saga
学习成本
异步方式 函数 Generator
复杂流程 一般
维护性
适合项目 小中型 大型
取消请求 不方便 容易
并发控制
副作用管理 分散 集中

十四、实际项目怎么选

小项目

直接:

  • Redux Toolkit
  • thunk

现在主流:

Redux Toolkit

因为 RTK 默认内置 thunk。


大型项目

复杂场景:

  • websocket
  • mqtt
  • 长连接
  • 多请求编排
  • 权限流
  • 工作流

适合:

  • saga

十五、你现在前端开发里更应该学什么

结合你现在 React + 平台开发经验:

建议优先级:

Redux Toolkit
    ↓
RTK Query
    ↓
redux-thunk
    ↓
redux-saga

因为现在很多公司:

  • 已经不用传统 redux
  • 更偏 RTK

十六、现代 Redux 已经变成这样

以前:

redux
redux-thunk
action
reducer
constants
types

现在:

Redux Toolkit
createSlice
createAsyncThunk
RTK Query

代码量减少很多。


十七、现代写法(推荐)

import { createAsyncThunk } from '@reduxjs/toolkit'

export const getUser = createAsyncThunk(
  'user/getUser',
  async ()=>{

     const res = await axios.get('/user')

     return res.data
  }
)

萌新小白基础理解篇之 this 关键字

前言

  早在我们前几篇文章中,就有出现过 this ,但是我们一直没有详细解释 this 是什么,this 可以出现在哪,this 的用法又是如何?那这篇文章我们一起来看看吧!

一、为什么要有this?

  this 是 js 中的一个关键字,它提供了一种更优雅的方式隐式的传递一个对象的引用,可以让代码更简洁易于复用,js 关键字是内置好的,拥有特殊语法含义的词,不能作为变量名,函数名,还有if,else,for等等关键字。我们来看一段代码感受一下。

 function identify(context) {
   return context.name.toUpperCase()  //.toUpperCase()  让小写全转化为大写 
 }

 function speek(context) {
   var greeting = 'hello, I am ' + identify(context)
   console.log(greeting);
 }

 var me = {
   name: 'tom'
 }

 speek(me)  

  当代码运行到14行时带来speek()函数的调用,把me作为实参传进去,此时运行speek()函数又带来了identify() 的调用,将me作为实参传进去,返回得到大写的 TOM ,console.log(greeting)得到 hello,I am TOM。

image.png

  如果用 this 我们可以怎么写

function identify() {
  return this.name.toUpperCase()
}

function speek() {
  var greeting = 'hello, I am ' + identify.call(this)
  console.log(greeting);
}

var me = {
  name: 'tom'
}

speek.call(me)

  我们可以看到,上述结果是相同的,函数 speek 和 identify 不再接收 context 参数。

  • 使用 this 关键字直接访问调用上下文中的属性(如 this.name)。
  • 调用时,通过 .call(me) 显式绑定 this 指向目标对象。下文会详细解释.call()用法
  • 逻辑链条变为:对象 → 绑定为 this → 函数内部直接通过 this 访问

  它提供了一种更优雅的方式隐式的传递一个对象的引用,可以让代码更简洁易于复用。

image.png

二、this 可以出现在哪?

  • 1.全局 (this === window
  • 2.函数体内

  理论上,this 可以出现在任何地方,如果出现在全局,那么 统一代指 的是window,所以我们主要区分函数体内的 this 代指的是哪个,this 用在不同的地方,代指的内容是不一样的。

this 可以出现在块级作用域但是毫无意义

三、 this的绑定规则

1.默认绑定 --- 当函数独立调用时,函数中的 this 指向 window
var a = 1   //  ===window:{a:1}  等于往window里面增加了 a为1 

function foo(){
    console.log(this.a)  // 1
}

function bar () {
    var a = 2
    foo() //独立调用
}

bar()

   this 如果出现在全局,那么它代指 window , 此时 this 出现在foo函数内,但是这个foo函数是被独立调用的,那么此时 this 依旧指向 windowconsole.log(this.a) 为 1,什么叫独立调用呢? 独立调用 = 函数名直接加括号执行,没有任何对象或上下文“牵着”它。

2.隐式绑定 --- 当一个函数被一个上下文对象所拥有并被该对象调用,那么函数中的 this 指向该对象
var a = 1   //  ===window:{a:1}  等于往window里面增加了 a为1 

function foo(){
    console.log(this.a)   //3
}

function bar () {
    var a = 2
    foo() //独立调用
}

bar()

var test = {
    a : 3,
    foo :foo  //引用函数
}

test.foo()  //隐式绑定

  我们可以看到 前面的 foo() 就是单独的函数名+括号的形式, 后面的为 test.foo() ,打个比方,就像你一个人逛街和你女朋友牵着你逛街的区别,你一个人逛街就叫独立调用,有女朋友牵着就不叫独立调用,此处我们称之为隐式绑定,而此时 this 指向的对象 就是 test ,所以此时 console.log(this.a) 为3

3.隐式丢失 --- 当一个函数被多层对象调用,函数的 this 指向最近的对象
function foo(){
    console.log(this.a)
}
var obj = {
    a:1, 
    foo : foo   //key :value  ,key 的名字可以随便取, 但 value 不可以随便
}
var oo = {
    a : 2,
    foo : obj
}

oo.foo.foo()  //this 指向 obj

  我们来捋一捋这个代码的逻辑,v8运行这段代码,运行到13行前,知道有一个 foo函数 ,有一个obj 对象,一个 oo 对象,当运行到13行时,有函数的调用,才开始读取它们的内容,那代码是从左往右执行,先读取 oo.foo ,那v8就要去oo里面找这个 foo 是什么,我们可以看到此时的 foo 值为 obj 对象,那就相当于 obj.foo(), 在去obj中 找 foo 是什么,此时 foo 的值 为foo 函数 ,然后() 开始foo函数的调用,所以相当于是 obj 调用了这个函数,此时 this 指向 obj ,也即当一个函数被多层对象调用,函数的 this 指向最近的对象。

4.显示绑定 --- 强行''掰弯'' this 指向一个对象 (三种方法)
  • fn.call(obj, x, y)

  • fn.apply(obj, [x,y])

  • fn.bind(obj, x, y)()

function foo(x,y){
    console.log(this.a, x+y)
}

var A = {
    a : 1
}

foo() //独立调用 指向 window
foo.call(A,1,2)  //this 指向A  传递参数 1,2
foo.apply(A,[2,3])  // this 指向A 传入参数2,3 
foo.bind(A,1,2)() //this 指向A,传入参数1,2 

.call( obj, x, y) : 让 this 强行指向 A,可以逐个传递参数 (较为零散的方式传递参数)

.apply( obj, [x,y] ) : 让 this 强行指向 A,以数组的模式逐个传递参数 (较为集中的方式传递参数)

.bind( obj, x, y)() : 让 this 强行指向 A,但是执行完后一定会返回一个函数出来,并且要把它触发掉,也是零散的传递参数,也可以 const bar 来接收 返回的函数 再调用触发,可以分开传参

const bar = foo.bind(obj,x,y)   const bar = foo.bind (obj,x)   const bar = foo.bind (obj) 
bar()                            bar(y)                        bar(x,y)
5.new 绑定 --- new 的原理会导致函数的 this 指向实例对象
function Person(){
    // var obj = {}      //1
    //Person.call(obj)   //2
    this.name = '杰哥'    //3   等同于  obj.name = '杰哥'
    // obj.__proto__ = Person.prototype    //4
    //return obj         //5
}

const p = new Person()  //此时的 p = obj
console.log(p)   // {name : 杰哥}

  我们在万物皆对象那篇文章中有讲到过 new 的工作原理,但当时并没有详细解释 this 所以表述其实并不准确,new 的具体工作原理应该是这样

  • 创建一个空对象 即 var obj = {}

  • 让函数体的 this 强行指向 实例对象 即 Person.call(obj)

  • 运行函数内的代码逻辑

  • 让对象的原型等于函数的原型 即 obj.proto = Person.prototype

  • 返回这个对象 即 return obj

四、箭头函数

  箭头函数没有 this 这个概念,写在箭头函数中的 this,也是它外层那个非箭头函数的

var bar = function(){     //函数表达式

}
bar()

var baz = (x,y) => {     //函数表达式
   
}

  如果不用到 this ,两种写法都是可以的,但如果用到 this 那我们需要注意一下了

function foo(){
    var fn = () =>{   //箭头函数没有 this 这个概念
        this.a = 2
    }
    fn()
}

var obj = {
    a : 1,
    bar:foo
}
obj.bar()
console.log(obj)

  由于箭头函数没有 this 这个概念,写在箭头函数中的 this,也是它外层那个非箭头函数的,所以此时 this 是 foo的 ,而foo是通过obj.bar()调用的,所以 foo 的 this 指向 obj 对象,console.log(obj) 得到 { a : 1, bar : foo }

image.png

箭头函数不可以被new调用 (new的第二步无法执行,用了就会报错)

(如有补充,请大佬指点)

3fd2900e2e696b2fa8e8cedf528d1195.jpg

在 React 里写动画又不跟渲染周期较劲:useRafFn、useRafState、useFps、useDevicePixelRatio、useUpdate

React 用一套时钟,浏览器用另一套。React 的协调器根据 state 更新、effect、调度器对"尽快"的理解来决定何时重新渲染组件。浏览器的合成器则按显示器能撑住的速度刷屏——大多数显示器是 60Hz,少数是 120Hz。两套时钟并不同步。state 更新会落在两次绘制之间被合并;庞大的渲染树可能整个错过一帧;setInterval(handler, 16) 一分钟下来会漂移几百毫秒,因为它根本不关心 GPU 在干嘛。

标准解法是 requestAnimationFrame。它在下一次绘制之前调用你的回调,附带一个高精度时间戳,并且在标签页隐藏时自动节流。它就是所有要看起来"丝滑"的东西该用的原语。但它在 React 里手工接线很繁琐:你需要一个 ref 存帧 ID、一个 effect 启动循环、一段清理函数在卸载时取消、一个 useLatest 让回调看到最新的 props,再加一个 ref 才能做暂停/恢复。每个动画组件都重写一遍这套脚手架,而大多数人第一次写都会漏掉某个清理。

ReactUse 把这套脚手架收进了五个共享同一底层循环的 hook。本文逐个走读——useRafFn 提供循环本身,useRafState 做随循环更新的 state,useFps 量化这个循环,useDevicePixelRatio 让你在循环里以正确分辨率绘制,useUpdate 应付那些"需要推一下 React 但又没 state 可改"的场景。合起来基本能覆盖你在专门的动画库之外要做的所有事。

一个组件里的 bug

一张跟随鼠标的浮卡:

function FloatingCard() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const move = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', move);
    return () => window.removeEventListener('mousemove', move);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
      }}
    >
      card
    </div>
  );
}

看上去没毛病。打开 devtools 性能面板,鼠标在屏幕上甩一遍。在一台快点的笔记本上,mousemove 每秒触发 120 到 500 次,看输入设备和 OS。每次都会调用 setPos,每次都触发一次重渲染调度,React 把它们合并到下一个 microtask。你在做屏幕能展示的两到八倍的协调工作,多出来的渲染全是纯开销——真正有意义的只是下一次绘制之前的最后一次。

useRafState 把这件事压缩成每帧一次,不管事件多快。原地替换,同样的 [state, setState] API,每次鼠标抖动少三次协调。本文剩下的 hook 都遵循同一个模式:保留 React 风格的 API,把 requestAnimationFrame 的管道藏起来。

1. useRafFn——带暂停/恢复的循环

useRafFn 是其他一切的基石。它接收一个回调,在每个 requestAnimationFrame tick 上调用,并把高精度时间戳传进去。返回 [stop, start, isActive],让你可以在标签页失焦、用户交互或任何其他信号上暂停循环:

import { useRef } from 'react';
import { useRafFn } from '@reactuses/core';

function StarField({ count = 200 }: { count?: number }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const starsRef = useRef(
    Array.from({ length: count }, () => ({
      x: Math.random(),
      y: Math.random(),
      z: Math.random() * 0.5 + 0.5,
    })),
  );

  const [stop, start, isActive] = useRafFn((time) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    const { width, height } = canvas;

    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, width, height);

    const t = time / 1000;
    for (const star of starsRef.current) {
      const x = ((star.x + t * 0.02 * star.z) % 1) * width;
      const y = star.y * height;
      ctx.fillStyle = `rgba(255, 255, 255, ${star.z})`;
      ctx.fillRect(x, y, 2, 2);
    }
  });

  return (
    <>
      <canvas ref={canvasRef} width={600} height={400} />
      <button onClick={() => (isActive() ? stop() : start())}>
        {isActive() ? '暂停' : '继续'}
      </button>
    </>
  );
}

这个 hook 有四个设计选择值得理解。回调在下一次绘制之前运行——这是 requestAnimationFrame 的语义——所以回调里做的任何 DOM 读取看到的都是即将绘制时的布局,不会额外触发强制回流。回调引用被 useLatest 包了一层,所以你可以闭包到新鲜的 props(count、作用域里任何东西)而不必重启循环。循环挂载时自动启动;第二个参数传 false 则从第一帧起就停在手动控制状态。清理注册在 effect 上,所以卸载时会取消挂起的帧——不会有野回调在死掉的组件上跑。

isActive 返回的是函数而不是布尔。在事件处理器里调用它总能拿到当前值;在渲染里调用只能看到渲染时的值。这种不对称容易踩。如果你要把激活标志用在 JSX 的 disabled={} 这种 prop 上,配合 useUpdatestop/start 调用方里手动 update()——上面示例没这么做是因为按钮文案下一次点击时本来就会重算。

useRafFn 真实场景下还有不少 canvas 之外的用法:任何要在两次事件之间追踪时间的活儿都用得到。一个要按 delta time 积分速度的物理模拟。一个 scrub bar 想紧跟媒体元素的 currentTime,而不是等那个粗糙的 timeupdate 事件(它按编解码器心情触发,不按你心情)。一个用弹簧拖尾跟随真实鼠标的自定义指针——useRafFn 读最新的目标位置,跑一步弹簧迭代,把结果写到 CSS 变量。这些都在替代那些会漂移、又会在后台标签里烧电池的 setInterval 模式。

2. useRafState——按帧合并的 useState

useRafState 是那张浮卡你真正会发布的版本:

import { useRafState } from '@reactuses/core';
import { useEventListener } from '@reactuses/core';

function FloatingCard() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener('mousemove', (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return (
    <div
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        transform: 'translate(-50%, -50%)',
        transition: 'transform 0.1s',
      }}
    >
      card
    </div>
  );
}

API 完全是 useState——同样的 setter 签名,同样支持 updater 函数——但写入会被 requestAnimationFrame 排队。同一帧内的五次 setPos 合并为一次 React 更新;React 更新每次绘制最多 flush 一次;DOM 更新的频率正好与屏幕刷新同步。mousemove 监听还是按 500Hz 触发,开销几乎等同于调一个空函数。协调成本掉到 60Hz,正好是屏幕能展示的。

几点要知道。这个 hook 给每个 state 槽位维护一个挂起的 requestAnimationFrame ID,所以同一帧内连续的 setter 是替换,不是排队——最后一个值赢。视觉 state 几乎总是想要这个语义:你不在乎中间的鼠标位置,只在乎绘制那一刻光标在哪。如果你真的在乎——比如你在采样传感器数据每个值都要——那就用普通 useState 并接受重渲染成本,或者写到 ref 里然后用 useRafFn tick 来 flush。

清理细节和 useRafFn 一样:挂起的帧在卸载时取消,所以快速点击-拖拽-卸载的连击不会冒出 setState on unmounted component 警告。内部实现是 useState + useRef(存帧 ID) + useUnmount 清理,总共大概二十行。你自己写得出来;这个 hook 只是省下了你每次都写一遍。

有个坑。因为 state 比事件慢一帧,调用 setter 立刻读 state 还是旧值:

setPos({ x: 100, y: 100 });
console.log(pos); // 还是 { x: 0, y: 0 } —— 更新还没跑

普通 useState 在同一次渲染周期内也是这样,但慢整整一帧这件事在拼命令式代码时容易让你意外。要回读这个值,旁边再放一个 ref 同步存。

3. useFps——量化你做出来的东西

useRafFnuseRafState 都在改善流畅度,但流畅度是一个可量化的指标,不是感觉。useFps 返回当前帧率(数字),通过统计底层 requestAnimationFrame 回调触发的频率算出来:

import { useFps } from '@reactuses/core';

function FpsOverlay() {
  const fps = useFps();
  const color = fps >= 55 ? 'green' : fps >= 30 ? 'orange' : 'red';

  return (
    <div
      style={{
        position: 'fixed',
        top: 8,
        right: 8,
        padding: '4px 8px',
        background: 'rgba(0,0,0,0.7)',
        color,
        fontFamily: 'monospace',
      }}
    >
      {fps} fps
    </div>
  );
}

丢进 dev build,你就有了平时要打开 Chrome rendering 面板才能看的 FPS 计数器。hook 接受一个 every 选项(默认 10),控制平均多少帧;小数字对卡顿响应快但抖动多,大数字读数更平滑但对突然掉帧反应慢。角落的常驻 overlay 用 10 很合适;如果你在调一段具体的卡顿过场动画,就用 1 或 2。

更有意思的用法是自适应渲染。读 FPS,掉到阈值以下就减少要做的事:

function ParticleSystem({ baseCount = 1000 }: { baseCount?: number }) {
  const fps = useFps({ every: 30 });
  const count =
    fps >= 55 ? baseCount : fps >= 40 ? baseCount / 2 : baseCount / 4;

  return <Particles count={count} />;
}

这正是 3A 游戏引擎在帧预算吃紧时的做法——降粒子数、调阴影分辨率、把流体模拟换成更粗的网格。对一个 React 应用来说,通常把动画背景的粒子数减半,或者干脆停掉一个非关键的 useRafFn 循环,就足够了。阈值数字凭口味;60Hz 显示器上 55 是一条合理的"我们基本还行"的线,因为平均值光被 GC 拽一下就能掉进 55 到 60 区间,没人会注意到。

关于 SSR:hook 在服务端返回 0,所以别把关键 UI 卡在"值非零"上。客户端第一次渲染在首个测量窗口结束前也是 0,下个 tick 才跳到真实值。如果你拿它做自适应渲染,第一个测量到达之前默认走"高保真"分支。

4. useDevicePixelRatio——以正确分辨率绘制

Canvas 元素有两套尺寸:CSS 尺寸决定它在页面上看起来多大;像素缓冲尺寸决定它看起来多精细。在 Retina 屏上设备像素比是 2,于是一个 CSS 尺寸 600px × 400px<canvas width="600" height="400"> 会显得糊——600×400 的像素缓冲被浏览器合成器拉伸到 1200×800 的物理像素上。修法是把缓冲设为 cssWidth × dprcssHeight × dpr,再把绘图上下文按 dpr 缩放,这样坐标还是按 CSS 单位写。

useDevicePixelRatio 响应式地追踪当前像素比——包括用户把窗口从 Retina 笔记本屏拖到外接 1x 显示器时:

import { useRef, useEffect } from 'react';
import { useDevicePixelRatio } from '@reactuses/core';

function CrispCanvas({ width, height, draw }: {
  width: number;
  height: number;
  draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const { pixelRatio } = useDevicePixelRatio();

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = width * pixelRatio;
    canvas.height = height * pixelRatio;
    const ctx = canvas.getContext('2d')!;
    ctx.scale(pixelRatio, pixelRatio);
    draw(ctx, width, height);
  }, [width, height, pixelRatio, draw]);

  return (
    <canvas
      ref={canvasRef}
      style={{ width, height }}
    />
  );
}

三行命令式 setup,但这三行恰好是几乎所有 React canvas 教程都写错的三行:把缓冲尺寸设为 css × dpr,再用内联 style 把 CSS 尺寸设回原始值,最后缩放上下文。这个 hook 让第三个依赖——像素比——变成响应式,所以把窗口从一个显示器拖到另一个会触发以新密度重绘。

内部用的是 matchMedia,针对当前像素比的 (resolution: <ratio>dppx) query。比率变化时 matchMedia 监听器触发,hook 重渲染,你的 effect 拿到新值再跑一次。监听器在挂载时加一次、卸载时移除——和本文所有 hook 一样的生命周期。

同样的模式适用于一切要画像素的东西:图像 canvas、WebGL 上下文、视频帧抽取。对 <img>srcset 选择也有意义,但浏览器会自动处理;只有你自己在做渲染时才需要这个 hook。SSR 返回 1,让服务端的布局计算保持合理,hydration 后第一次绘制时再更新到真实值。

5. useUpdate——一次无 state 的重渲染

本文最怪也是你最少用到的 hook。useUpdate 返回一个引用稳定的函数,调用时强制组件重渲染:

import { useRef } from 'react';
import { useUpdate, useRafFn } from '@reactuses/core';

function StopwatchDisplay() {
  const startRef = useRef(performance.now());
  const update = useUpdate();

  useRafFn(() => {
    update();
  });

  const elapsed = ((performance.now() - startRef.current) / 1000).toFixed(2);
  return <div>{elapsed}s</div>;
}

这个秒表每帧更新一次,并不把已用时间放到 React state 里。真相来源是 performance.now(),每次渲染重新读;useUpdate 的存在只是为了调度渲染。六行,没有 setState,没有对过期时间的闭包。你也可以用 useState((s) => s + 1) 做同样的事,但用 useUpdate 意图更清楚——"再渲一次这玩意",而不是"为了让它再渲一次而递增一个计数器"。

更实用的用法是和那些 React 不追踪其变化的命令式 API 互通。一个通过引用暴露当前相机位置的 WebGL 渲染器;一个 Three.js 场景图;一个你拿来当 state 用、但不想每次改都重建的 SetMap。改完之后调一下 update() 告诉 React 这个组件脏了:

function FavoritesList({ favorites }: { favorites: Set<string> }) {
  const update = useUpdate();

  return (
    <ul>
      {[...favorites].map((id) => (
        <li key={id}>
          {id}{' '}
          <button onClick={() => {
            favorites.delete(id);
            update();
          }}>
            remove
          </button>
        </li>
      ))}
    </ul>
  );
}

直接改 Set 再重渲,对大集合来说比 setFavorites(new Set([...favorites].filter(x => x !== id))) 快,还能让 Set 的引用在多次渲染间保持稳定,下游 memoize 的子组件就不用重算。它当然也是个一脚踏入坑里的好办法——React 的优化假设不可变,凡是靠引用变化检测更新的地方都会默默失灵。要刻意用、用要标注清楚、性能压不出问题就老老实实 useState

useUpdate 也常和 useTextSelection 这类与可变平台对象打交道的 hook 搭档(事件 hooks 那篇覆盖了这种情况)。如果底层对象在多次调用间是同一个引用,setState 是个空操作;useUpdate 就是绕路办法。

凑齐:60fps 弹簧拖尾指针

一次用上五个里的四个。一个用弹簧拖尾跟随真实鼠标的自定义指针,在 Retina 上以正确分辨率绘制,角落显示自己的 FPS,标签页隐藏时暂停:

import { useRef } from 'react';
import {
  useRafFn,
  useRafState,
  useFps,
  useDevicePixelRatio,
  useEventListener,
} from '@reactuses/core';

function SpringCursor() {
  const target = useRef({ x: 0, y: 0 });
  const [pos, setPos] = useRafState({ x: 0, y: 0 });
  const velocity = useRef({ x: 0, y: 0 });
  const fps = useFps();
  const { pixelRatio } = useDevicePixelRatio();

  useEventListener('mousemove', (e: MouseEvent) => {
    target.current = { x: e.clientX, y: e.clientY };
  });

  useRafFn(() => {
    const dx = target.current.x - pos.x;
    const dy = target.current.y - pos.y;
    const stiffness = 0.15;
    const damping = 0.7;
    velocity.current.x = velocity.current.x * damping + dx * stiffness;
    velocity.current.y = velocity.current.y * damping + dy * stiffness;
    setPos({
      x: pos.x + velocity.current.x,
      y: pos.y + velocity.current.y,
    });
  });

  useEventListener('visibilitychange', () => {
    if (document.hidden) velocity.current = { x: 0, y: 0 };
  });

  const size = 24;
  return (
    <>
      <div
        style={{
          position: 'fixed',
          left: pos.x,
          top: pos.y,
          width: size,
          height: size,
          marginLeft: -size / 2,
          marginTop: -size / 2,
          borderRadius: '50%',
          background: 'currentColor',
          pointerEvents: 'none',
          imageRendering: pixelRatio >= 2 ? 'auto' : 'pixelated',
        }}
      />
      <div style={{ position: 'fixed', top: 8, left: 8, fontFamily: 'monospace' }}>
        {fps} fps @ {pixelRatio}x
      </div>
    </>
  );
}

四个 hook 各干各的。useEventListener 以原生速率把鼠标坐标读到 ref——不触发 React 渲染。useRafFn 每帧跑一次弹簧积分,读最新目标位置、写当前弹簧位置。useRafState 把每帧的位置更新合并成一次渲染。useFps 反馈当前帧率。useDevicePixelRatio 影响 image-rendering 的选择(小细节,但正好是那种没人注意到、直到 1x 显示器上的用户来投诉的细节)。

朴素版本要么在每个 mousemove 上 setState(500Hz 渲染,烧电池),要么靠 setInterval(handler, 16)(漂移,并且在后台标签里继续跑),要么干脆不要弹簧、看上去很廉价。用这些 hook 之后,读取频率就是问题本身的频率——每帧一次,React 树永远不会以快于用户能看到的速度重渲染。

何时用哪个

你想
每个动画帧跑一个回调 useRafFn
每次绘制最多更新一次 state useRafState
测当前帧率 useFps
以显示器原生分辨率绘制 useDevicePixelRatio
改了 React 看不到的东西之后重新渲染 useUpdate

两条非规则。useRafFn 不是 setInterval 的替代——它按显示器刷新率跑,ProMotion 屏上是 120Hz,省电模式标签里是 30Hz。如果你要严格的"每秒 N 次"节拍,用 useInterval 然后接受视觉代价。还有 useUpdate 是逃生舱——一份代码库里反复用它超过一两次,背后的真问题往往是"我为了性能把 state 放到了 React 之外",正确的修法是修那个性能问题,而不是把逃生舱当常规。

安装

npm install @reactuses/core
# 或
pnpm add @reactuses/core
# 或
yarn add @reactuses/core

五个 hook 都是单独 tree-shake——引 useRafState 不会把 useDevicePixelRatio 拖进来。每个都带 TypeScript 类型,在客户端渲染应用和 SSR 框架(Next.js、Remix、Astro)里都能用;基于循环的 hook 在服务端是 no-op,useDevicePixelRatiouseFps 在 hydration 之前返回安全默认值(分别是 10)。

相关 hook

如果你想要的渲染循环 hook 不在这份名单里,三篇邻居博客可以一起看。ref 逃生舱 那篇讲 useLatest——它就是 useRafFn 内部用来让回调看到新鲜闭包又不重启循环的那个 trick——如果你想理解这些 hook 怎么实现而不只是怎么用,从这一篇开始。事件 hooksuseEventListeneruseThrottleFn,它们和 useRafFn 在输入驱动的动画上配合得很自然。滚动效果 那篇讲的是在这些原语之上更高一层的滚动联动动画 hook。

reactuse.com 浏览完整列表,或者直接打开上面任意一个 hook 读源码——它们大多不到 40 行,五个 hook 底下的循环原语都是同一个八行的 useRef + useEffect 模式,你大概率已经自己写过半打了。

深度解析 JS 中的 this 指向:从底层逻辑到实战规则

前言

在 JavaScript 的面试和日常开发中,this 绝对是一个绕不开的“大山”。很多初学者会被它忽左忽右的指向搞得晕头转向。今天我结合自己的学习笔记,把 this 的来龙去脉和绑定规则彻底理清楚。希望对同样在进阶路上的你有所帮助!

一、 为什么我们需要 this?

很多同学会问:既然我可以直接引用对象名,为什么还要用 this? 核心价值:隐式传递对象引用。 this 提供了一种更优雅的方式来传递引用,使得代码更简洁、易于复用。

function identify() {
    return this.name.toUpperCase();
}

var me = { name: "Kyle" };
var you = { name: "Reader" };

identify.call(me);  // KYLE
identify.call(you); // READER

如果不使用 this,你就需要显式地将对象作为参数传递,代码会变得冗余且难以维护。

二、 this 到底出现在哪?

在 JavaScript 中,this 主要出现在两个地方:

  1. 全局环境:在浏览器环境下,this 直接指向 window 对象。
  2. 函数体内:这是最复杂的地方,this 的指向不是在函数创建时决定的,而是在函数被调用时决定的

三、 五大绑定规则

掌握了下面这五条规则,你就掌握了 this 的“密码”:

1. 默认绑定

当函数被独立调用(不带任何修饰的函数调用)时,函数中的 this 指向全局对象 window。

function foo() {
    console.log(this); 
}
foo(); // window

2. 隐式绑定

当函数被一个上下文对象所拥有,并被该对象调用时,this 指向该对象。

var obj = {
    a: 2,
    foo: function() { console.log(this.a); }
};
obj.foo(); // 2

3. 隐式丢失(就近原则)

这是一个细节:当函数被多层对象嵌套调用时,this 指向离它最近的那个对象。

var obj2 = {
    a: 42,
    foo: function() { console.log(this.a); }
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42 (指向 obj2)

4. 显式绑定 (Explicit Binding)

显式绑定就像是给函数下达“死命令”,强制它在执行时将 this 指向我们指定的对象。

① call —— 逐个传参的“指挥官”

call 会立即执行函数。它的第一个参数是 this 的指向,后面的参数需要一个一个列出来。

function greet(skill, hobby) {
    console.log(`我是${this.name},我会${skill},喜欢${hobby}`);
}

const user = { name: "阿强" };

// 语法:fn.call(thisArg, arg1, arg2, ...)
greet.call(user, "JavaScript", "代码"); 
// 输出:我是阿强,我会JavaScript,喜欢代码

② apply —— 数组传参的“打包员”

apply 的功能和 call 完全一样,唯一的区别是:它接收参数的方式是数组。这在处理动态参数(如获取数组最大值)时非常有用。

const user = { name: "阿珍" };

// 语法:fn.apply(thisArg, [argsArray])
greet.apply(user, ["Python", "看书"]);
// 输出:我是阿珍,我会Python,喜欢看书

③ bind —— 延后执行的“契约书”

bind 不会立即执行函数,而是返回一个绑定了新 this 的新函数。你可以随时在需要的时候调用它。

const user = { name: "老王" };

// 语法:const newFn = fn.bind(thisArg, arg1, ...)
const bindGreet = greet.bind(user, "Vue", "钓鱼");

// 此时不会有输出,直到你手动调用它
bindGreet(); 
// 输出:我是老王,我会Vue,喜欢钓鱼

💡 快速对比表

为了方便记忆,我总结了一个对比表,大家可以直接保存:

方法 立即执行 传参方式 常用场景
call 参数列表 (arg1, arg2) 对象的属性继承、借用构造函数
apply 数组形式 ([args]) 与 arguments 配合、操作数组
bind 参数列表 (arg1, arg2) React/Vue 中的回调函数绑定、延迟执行

面试小贴士: 如果 call/apply/bind 的第一个参数传入了 null 或 undefined,那么在非严格模式下,this 会自动指向全局对象 window。

5. new 绑定

使用 new 关键字调用构造函数时,JS 内部会创建一个新对象,并把构造函数里的 this 绑定到这个新对象上。

function Person(name) {
    this.name = name;
}
var me = new Person("Jay");
console.log(me.name); // Jay

四、 特殊存在的箭头函数

箭头函数没有自己的 this! 这是它和普通函数最大的区别。箭头函数的 this 是在定义时捕获自外层(父级)非箭头函数的作用域。

注意: 箭头函数的 this 一旦确定,就无法通过 call/apply/bind 再次修改。

总结

  • 独立调用看 window。
  • 对象调用看对象。
  • 多层对象看最近。
  • call/apply/bind 看第一个参数。
  • new 看实例。
  • 箭头函数看它亲爹(外层作用域)。
❌