阅读视图

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

RainbowKit 快速集成多链钱包连接:从“连不上”到丝滑切换的踩坑实录

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目需要从原先只支持以太坊主网,扩展到支持 Arbitrum、Polygon、Base 等七八条 EVM 链。老板给的要求很明确:用户体验要丝滑,钱包连接不能卡顿,链切换要直观,最好能快速上线。

我第一时间想到了 RainbowKit。社区里都说它“开箱即用”,封装了 wagmi 和一堆 UI 组件,能省不少事。但真当我动手把文档里的示例代码往项目里一粘,问题就接踵而至了。钱包是能弹出来了,但链列表不对,切换链后前端状态没更新,甚至有的链上交易会报莫名其妙的 RPC 错误。这篇文章,就是我填平这些坑的完整记录。

问题分析

我最开始的想法很简单:照着 RainbowKit 官方文档,安装 @rainbow-me/rainbowkitwagmiviem,然后配置一个 WagmiProviderRainbowKitProvider 把应用包起来不就完事了?代码大概长这样:

import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';

const config = getDefaultConfig({
  appName: 'My App',
  projectId: 'YOUR_PROJECT_ID', // 从 WalletConnect Cloud 拿的
  chains: [mainnet, polygon],
});

function App() {
  return (
    <WagmiProvider config={config}>
      <RainbowKitProvider>
        <MyComponent />
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

跑起来一看,钱包连接按钮是出来了,点开也能看到 MetaMask、Coinbase Wallet 等选项。但问题立刻出现了:

  1. 链列表不全:我配置了 [mainnet, polygon],但钱包切换网络的弹窗里,有时只显示主网,Polygon 不出现。
  2. 状态不同步:用户在 MetaMask 里手动切换了网络(比如从 Ethereum 切到 Polygon),但我应用里 useAccount() 钩子返回的 chain 信息有时还是旧的,导致后续的合约调用全跑到错误的链上。
  3. 自定义链配置麻烦:像 Base、Arbitrum 这些链,wagmi/chains 里虽然有,但它们的 RPC 节点有时不稳定,我需要换成项目自备的节点,这个配置过程比预想的要绕。

我意识到,“开箱即用”指的是基础功能,一旦涉及到生产环境的多链复杂场景,细节配置一个都不能少。下面我就分步骤拆解我是怎么解决这些问题的。

核心实现

第一步:正确配置多链与 RPC

这里有个大坑:RainbowKit/Wagmi 的链配置,并不仅仅是给组件提供一个列表那么简单。它涉及到钱包连接时向钱包(如 MetaMask)发起“建议”的网络列表,以及 wagmi 客户端内部用来读取链数据、发送交易的 RPC 连接。

我最初只用 wagmi/chains 里导出的链定义,但很快就遇到了公共 RPC 限速或不稳定导致交易失败的问题。解决方案是自定义 viemTransport,并为每条链指定更可靠的 RPC 端点。

// src/config/chains.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';

// 1. 定义项目需要的所有链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;

// 2. 为每条链配置 Transport (RPC 连接)
// 注意:生产环境建议将 RPC URL 放在环境变量中
const transports = {
  [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [arbitrum.id]: http('https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [base.id]: http('https://mainnet.base.org'), // 也可以使用公共节点
};

// 3. 创建 wagmi 配置
export const config = createConfig({
  chains: supportedChains as any, // 这里有个类型小坑,需要断言
  transports, // 关键!注入自定义的 RPC 传输层
  // ... 其他配置如连接器、SSR 等
});

// 4. 创建 RainbowKit 专用的配置(用于 UI 部分)
export const rainbowKitConfig = getDefaultConfig({
  appName: 'MyDeFiAggregator',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // 必须
  chains: supportedChains,
  transports, // 这里也要传一次,确保一致性
});

关键点transports 配置是性能和安全的关键。使用像 Alchemy、Infura 这样的专业节点服务,能显著提升交易发送和区块数据读取的可靠性。getDefaultConfig 内部其实也是调用了 createConfig,所以我们直接基于 createConfig 来构建,灵活性更高。

第二步:搞定 RainbowKitProvider 与主题

RainbowKit 的 UI 很棒,但默认主题可能和你的项目不搭。集成时,我建议一开始就处理好主题,避免后期再改一堆样式。

// src/providers/Web3Provider.tsx
import { RainbowKitProvider, darkTheme, lightTheme } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '@/config/chains';

const queryClient = new QueryClient();

export function Web3Provider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({
            accentColor: '#3B82F6', // 自定义主色
            accentColorForeground: 'white',
            borderRadius: 'medium',
            fontStack: 'system',
            overlayBlur: 'small',
          })}
          // 这个 locale 设置对中文用户很友好
          locale="en-US"
          // 可以在这里配置初始链影响连接时的默认网络
          initialChain={polygon}
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

注意这个细节WagmiProvider 需要 @tanstack/react-queryQueryClientProvider 作为上下文来管理请求状态。getDefaultConfig 帮我们隐式创建了 queryClient,但自己显式创建并传入能获得更多控制权,比如设置全局的请求重试、缓存时间等。

第三步:实现链感知的连接与切换

这是用户体验的核心。用户连接钱包后,我们需要清晰地展示当前连接的链,并提供一个便捷的切换方式。RainbowKit 提供了 ConnectButtonChain 组件,但直接使用可能不够。

我遇到的一个典型场景是:用户当前连接在 Polygon 上,但我们的某个功能只支持 Arbitrum。我们需要引导用户切换网络。

// src/components/ChainAwareConnectButton.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useSwitchChain } from 'wagmi';
import { supportedChains } from '@/config/chains';
import { useEffect } from 'react';

export function ChainAwareConnectButton({ requiredChainId }: { requiredChainId?: number }) {
  const { chain, isConnected } = useAccount();
  const { switchChain } = useSwitchChain();

  // 效果:当组件要求特定链,且用户已连接但链不对时,自动提示切换
  useEffect(() => {
    if (isConnected && requiredChainId && chain?.id !== requiredChainId) {
      // 这里可以触发一个自定义的模态框提示,而不是自动切换。
      // 自动切换体验很生硬,可能会被钱包拦截。
      console.warn(`请将网络切换至 ${supportedChains.find(c => c.id === requiredChainId)?.name}`);
    }
  }, [isConnected, chain, requiredChainId]);

  return (
    <ConnectButton.Custom>
      {({
        account,
        chain: connectedChain,
        openAccountModal,
        openChainModal,
        openConnectModal,
        authenticationStatus,
        mounted,
      }) => {
        const ready = mounted && authenticationStatus !== 'loading';
        const connected = ready && account && connectedChain;

        // 自定义按钮渲染逻辑
        return (
          <div
            {...(!ready && {
              'aria-hidden': true,
              'style': {
                opacity: 0,
                pointerEvents: 'none',
                userSelect: 'none',
              },
            })}
          >
            {(() => {
              if (!connected) {
                return (
                  <button onClick={openConnectModal} type="button">
                    连接钱包
                  </button>
                );
              }

              // 如果已连接,但链不符合要求,高亮显示链切换按钮
              const isOnWrongChain = requiredChainId && connectedChain.id !== requiredChainId;

              return (
                <div style={{ display: 'flex', gap: 12 }}>
                  {/* 链切换按钮 */}
                  <button
                    onClick={openChainModal}
                    type="button"
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      background: isOnWrongChain ? '#FEF3C7' : 'transparent', // 链不对时黄色背景提示
                      border: `1px solid ${isOnWrongChain ? '#F59E0B' : '#ccc'}`,
                      borderRadius: '8px',
                      padding: '4px 8px',
                    }}
                  >
                    {connectedChain.hasIcon && (
                      <div
                        style={{
                          background: connectedChain.iconBackground,
                          width: 20,
                          height: 20,
                          borderRadius: 999,
                          overflow: 'hidden',
                          marginRight: 4,
                        }}
                      >
                        {connectedChain.iconUrl && (
                          <img
                            alt={connectedChain.name ?? 'Chain icon'}
                            src={connectedChain.iconUrl}
                            style={{ width: 20, height: 20 }}
                          />
                        )}
                      </div>
                    )}
                    {connectedChain.name}
                  </button>

                  {/* 账户按钮 */}
                  <button onClick={openAccountModal} type="button">
                    {account.displayName}
                    {account.displayBalance ? ` (${account.displayBalance})` : ''}
                  </button>
                </div>
              );
            })()}
          </div>
        );
      }}
    </ConnectButton.Custom>
  );
}

这里有个坑useSwitchChain().switchChain 方法虽然存在,但在浏览器环境中,直接调用它来“强制”用户切换链,体验很差,而且 MetaMask 等钱包可能会阻止这种非用户触发的切换请求。最佳实践是只提供清晰的切换引导(比如高亮链按钮、文字提示),让用户自己点击 openChainModal 去操作。ConnectButton.Custom 给了我们极大的灵活性来实现这种定制 UI 和交互逻辑。

第四步:在应用各处安全地使用链状态

解决了连接和切换,最后一步是确保在需要链信息的任何地方(比如调用合约、查询余额),我们使用的 chainId 都是正确且最新的。

// src/hooks/useSafeChain.ts
import { useAccount, useChainId } from 'wagmi';
import { supportedChains } from '@/config/chains';

// 这个钩子确保返回的 chainId 一定是项目支持的,否则返回 undefined 或默认链
export function useSafeChain(requiredChainId?: number) {
  const { chain } = useAccount();
  const globalChainId = useChainId(); // wagmi v2 的新钩子,直接获取当前链ID

  // 优先级:参数指定 > 当前连接链 > undefined
  let targetChainId = requiredChainId || chain?.id || globalChainId;

  // 检查目标链是否在支持列表中
  const isSupported = supportedChains.some(c => c.id === targetChainId);

  if (!isSupported && targetChainId) {
    console.error(`链 ID ${targetChainId} 不在项目支持列表中。`);
    // 根据业务逻辑,可以在这里触发链切换,或者返回一个默认链(如主网)
    // return mainnet.id;
    return undefined;
  }

  return targetChainId;
}

// 在合约调用处使用
import { useReadContract } from 'wagmi';
import { useSafeChain } from '@/hooks/useSafeChain';
import { myContractAbi } from './abi';

export function MyComponent() {
  const safeChainId = useSafeChain(); // 获取当前安全的链ID

  const { data } = useReadContract({
    abi: myContractAbi,
    address: '0x...', // 注意:不同链上合约地址可能不同,这里需要根据 chainId 做映射
    functionName: 'balanceOf',
    args: ['0xUserAddress'],
    chainId: safeChainId, // 关键!将安全的 chainId 传入查询
    query: {
      enabled: !!safeChainId, // 只有链ID有效时才发起查询
    },
  });

  // ... 渲染逻辑
}

关键点:所有依赖于链的钩子(useReadContract, useWriteContract, useBalance 等),都应该显式地传入 chainId 参数。不要依赖 wagmi 的全局上下文自动推断,因为在复杂的多链交互中,尤其是在用户快速切换网络时,自动推断可能会滞后或出错。useSafeChain 这个自定义钩子相当于一个保险丝,确保后续操作基于一个经过验证的链环境。

完整代码

以下是一个简化但可运行的核心集成示例,基于 Next.js (App Router) 和 TypeScript。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { config } from '@/lib/wagmi-config';

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({ accentColor: '#0E76FD' })}
          locale="en-US"
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// lib/wagmi-config.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';

// 1. 定义支持的链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;
export type SupportedChainId = (typeof supportedChains)[number]['id'];

// 2. 配置 RPC Transports
const transports: Record<SupportedChainId, ReturnType<typeof http>> = {
  [mainnet.id]: http(process.env.NEXT_PUBLIC_ETHEREUM_RPC_URL),
  [polygon.id]: http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL),
  [arbitrum.id]: http(process.env.NEXT_PUBLIC_ARBITRUM_RPC_URL),
  [base.id]: http('https://mainnet.base.org'),
};

// 3. 创建 wagmi 配置对象
export const config = createConfig({
  chains: supportedChains as any,
  transports,
  connectors: [
    injected(), // 支持 MetaMask 等注入式钱包
    walletConnect({
      projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
    }),
  ],
  ssr: true, // 如果你用 Next.js 且需要 SSR,开启这个
});
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'My Web3 App',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/page.tsx
'use client';

import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useBalance } from 'wagmi';

export default function Home() {
  const { address, chain } = useAccount();
  const { data: balance } = useBalance({ address });

  return (
    <main style={{ padding: '2rem' }}>
      <h1>我的多链 DeFi 聚合器</h1>
      <div style={{ margin: '2rem 0' }}>
        <ConnectButton />
      </div>

      {address && (
        <div style={{ marginTop: '1rem', padding: '1rem', border: '1px solid #333' }}>
          <p>连接地址: {address}</p>
          <p>当前网络: {chain?.name} (ID: {chain?.id})</p>
          <p>余额: {balance?.formatted} {balance?.symbol}</p>
        </div>
      )}
    </main>
  );
}

环境变量 (.env.local):

NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=你的_WalletConnect_Cloud_项目ID
NEXT_PUBLIC_ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/your_key

踩坑记录

  1. Unsupported chain 错误:用户连接了钱包,但我的应用配置里没有他钱包当前所在的链(比如 BSC)。RainbowKit 的默认行为是“不支持”,用户会看到一个错误状态。解决方法:在 RainbowKitProvider 上设置 initialChain 为一个你支持的链(如主网),这样新用户连接时会先被引导到该链。对于已连接但链不对的用户,通过自定义 ConnectButton UI 强烈提示他们切换。

  2. WalletConnect 项目 ID 缺失:控制台报错 Invalid projectId解决方法:必须去 WalletConnect Cloud 创建一个项目,获取 projectId。这个 ID 是 WalletConnect 协议连接所必需的,不是可选项。

  3. Hydration 不匹配错误 (Next.js):在服务端渲染 (SSR) 时,服务端没有钱包连接状态,而客户端水合时状态可能不同,导致 React 报错。解决方法:确保 WagmiProviderRainbowKitProvider 只在客户端渲染。在 Next.js App Router 中,将包含这些 Provider 的文件标记为 'use client'。同时,在 createConfig 中设置 ssr: true,让 wagmi 适配 SSR 环境。

  4. 类型错误:chains 类型不匹配:在 createConfig 中直接传入 supportedChains 可能会遇到 TypeScript 类型过于宽泛的问题。解决方法:使用 as const 断言定义链数组,并在 createConfig 处使用 as any 进行临时断言,或者按照 wagmi 类型更精确地定义 Chain 数组。

小结

通过这一轮折腾,我最大的收获是:RainbowKit 确实是加速 Web3 前端开发的利器,但它不是“魔法”。生产级的多链集成,关键在于理解其底层依赖(wagmi, viem)的配置,特别是 TransportchainId 的精确管理。下一步,我可以继续深入优化自定义连接器、钱包连接后的权限验证(SIWE),以及更复杂的跨链状态管理。

JS算法入门:图解“冒泡排序”,彻底搞懂双重循环的奥义

1. 前言:为什么我们需要排序?

哈喽大家好,我是心连欣。在上一篇文章中,我们做了一个“成绩管理系统”,能算出最高分和平均分。但是,如果老师想看一个 “从高分到低分” 的排名表,我们的程序就傻眼了,因为数组里的数据是乱序的。

虽然 JavaScript 提供了现成的 array.sort() 方法,但作为一名有追求的程序员,我们必须知道:排序的底层到底是怎么实现的?

今天,我们就来攻克排序算法的“Hello World”——冒泡排序

2. 核心思想:像气泡一样“上浮”

冒泡排序的名字非常形象。想象一下水底的气泡,越轻的气泡(数值越小的元素)会慢慢浮到水面,而越重的气泡(数值越大的元素)会沉在水底。

但在我们的代码逻辑里(升序排列),我们通常反其道而行之:让最大的元素,像“重石头”一样,通过不断的交换,一步步“沉”到数组的末尾。

算法口诀:

  1. 两两比较:  从第一个开始,挨个比较相邻的两个数。
  2. 逆序交换:  如果前一个比后一个大,就交换它们。
  3. 一轮结束:  每一轮跑完,当前这一堆数里最大的那个,一定会被移到最右边。

3. 图解演示

假设我们要对 [5, 2, 9, 1] 进行升序排序:

第一轮(找出最大的9):

  • 比较 5 和 2:5 > 2,交换 → [2, 5, 9, 1]
  • 比较 5 和 9:5 < 9,不换 → [2, 5, 9, 1]
  • 比较 9 和 1:9 > 1,交换 → [2, 5, 1, 9]
  • 结果:  9 已经就位(沉底了)。

第二轮(找出剩下的最大的5):

  • 比较 2 和 5:不换 → [2, 5, 1, 9]
  • 比较 5 和 1:5 > 1,交换 → [2, 1, 5, 9]
  • 结果:  5 也就位了。(注意:这一轮不需要再比 9 了,因为它已经是最大的)。

4. 代码实现:双重循环的奥义

冒泡排序的核心在于两层循环

image.png 结果如下: image.png

5. 深度解析:为什么是 length - 1 - i

这是面试中最常考的细节,也是新手最容易晕的地方。

  • length - 1:因为我们是拿 arr[j] 和 arr[j+1] 比较。如果 j 走到了最后一个元素,j+1 就会越界(undefined)。所以内层循环必须比数组长度少 1。

  • - i:这是性能优化的关键。

    • 当 i=0(第一轮)结束时,数组最后 1 个元素已经是最大值了。
    • 当 i=1(第二轮)结束时,数组最后 2 个元素已经有序了。
    • 所以,内层循环不需要每次都从头跑到尾,每过一轮,就可以少跑一步。

6. 进阶优化:如何让它更聪明?

上面的代码有个小缺陷:如果数组本来就是有序的(比如 [1, 2, 3, 4]),它还是会傻傻地跑完所有循环。

我们可以加一个 “哨兵” (标志位)来优化它。

image.png

7. 总结

冒泡排序虽然在实际工程中(处理海量数据时)效率不如快速排序,但它教会了我们两个极其重要的编程思想:

  • 双重循环:  外层控制轮次,内层处理细节。这是处理二维数据、矩阵、复杂遍历的基础。
  • 交换算法:  利用临时变量 temp 交换数据,是链表操作、数组重排的基础。

课后思考:
目前的排序是从小到大(升序)。如果我想让成绩从高到低(降序)排列,只需要修改代码中的哪一行?(提示:就在 if 判断里)。


写算法就像搭积木,理解了冒泡排序,你就拿到了通往高级算法世界的第一把钥匙!加油!

23种Promise高效用法,彻底搞定前端异步痛点,新手也能秒上手

前端人必看!你是不是也被异步操作逼疯过😭

回调地狱嵌套一层又一层,代码乱得像“蜘蛛网”;并发请求太多导致接口拥堵,页面卡顿崩溃;异步请求超时、无法取消,报错无从排查;不知道怎么顺序/并行执行异步任务,越写越乱……

其实这些前端异步痛点,Promise早就给出了完美解决方案!作为JS异步编程的核心,Promise不仅能替代繁琐的回调函数,还能优雅处理并发、超时、重试等复杂场景,是前端工程师必备的核心技能,也是面试高频考点。

今天这篇文章,结合实战场景,把23种Promise高效用法逐一拆解,每一种都配「核心作用+完整可复制代码+通俗解读+实战场景」,从基础到进阶,小白能看懂,老手能查漏补缺,收藏这一篇,再也不用东拼西凑找资料,异步开发效率直接翻倍!

核心提醒:Promise的核心是“异步操作的状态管理”,三种状态(pending/fulfilled/rejected)一旦改变就无法逆转,掌握它的核心用法,能解决80%的前端异步问题,剩下20%靠这23种实战技巧搞定!

一、基础核心用法(3种)—— 入门必备,筑牢基础

这3种是Promise最基础、最常用的用法,是后续高级用法的前提,新手务必先掌握,熟练运用就能告别基础异步烦恼。

1. 使用async/await简化Promise(最常用)

核心作用:用同步代码的写法,实现异步操作,彻底告别回调地狱,代码逻辑更清晰、更易维护,是日常开发中最推荐的异步写法。

通俗解读:async关键字标记函数为异步函数,await关键字“等待”异步操作完成,只有await后面的异步操作结束,才会执行下一行代码,避免嵌套。

// 基础用法:处理单个异步请求
async function asyncFunction() {
  try {
    // 等待异步操作完成(比如接口请求)
    const result = await fetch('/api/data');
    const data = await result.json();
    // 异步操作成功后,执行后续逻辑
    console.log('接口请求成功:', data);
    return data;
  } catch (error) {
    // 捕获异步操作中的所有错误
    console.error('接口请求失败:', error);
  }
}

// 实战用法:结合业务场景(获取用户信息并渲染)
async function getUserInfo() {
  try {
    const response = await fetch('/api/user/info');
    const user = await response.json();
    // 渲染用户信息到页面
    document.querySelector('.user-name').textContent = user.name;
    document.querySelector('.user-age').textContent = user.age;
  } catch (err) {
    // 错误处理(比如提示用户获取失败)
    alert('获取用户信息失败,请重试!');
  }
}
// 调用异步函数
getUserInfo();

2. 使用Promises替代回调函数

核心作用:将传统的回调函数(回调地狱的根源),转化为Promise链式调用,代码更优雅,错误处理更统一。

通俗解读:很多老项目或原生API仍使用回调函数(比如setTimeout、fs.readFile),通过封装,将其转化为Promise,就能享受链式调用的便捷。

// 核心封装:将回调函数转为Promise
const callbackToPromise = (fn, ...args) => {
  return new Promise((resolve, reject) => {
    // 执行原回调函数,在回调中触发Promise的resolve/reject
    fn(...args, (error, result) => {
      if (error) {
        // 回调报错,触发reject
        reject(error);
      } else {
        // 回调成功,触发resolve
        resolve(result);
      }
    });
  });
};

// 实战用法:封装setTimeout(回调转Promise)
const delay = callbackToPromise(setTimeout, 1000);
// 链式调用,替代回调嵌套
delay.then(() => {
  console.log('1秒后执行');
  return callbackToPromise(setTimeout, 2000);
}).then(() => {
  console.log('再等2秒执行');
}).catch(err => {
  console.error('出错了:', err);
});

3. 使用Promise实现一个延时函数

核心作用:异步延时操作,常用于防抖、节流、模拟接口请求延迟等场景,比原生setTimeout更灵活,可结合await使用。

// 基础封装:异步延时函数
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

// 实战用法1:结合await使用(同步写法实现延时)
async function delayDemo() {
  console.log('开始执行');
  await delay(1500); // 延时1.5秒
  console.log('1.5秒后执行');
  await delay(2000); // 再延时2秒
  console.log('总共延时3.5秒后执行');
}

// 实战用法2:模拟接口请求延迟
async function mockFetch() {
  console.log('正在请求接口...');
  // 模拟接口延迟1秒
  await delay(1000);
  return { code: 200, data: '模拟接口返回数据', msg: 'success' };
}

mockFetch().then(res => console.log(res)).catch(err => console.error(err));

二、并发控制用法(4种)—— 解决多异步拥堵,提升性能

日常开发中,经常需要同时执行多个异步请求(比如批量获取数据),但并发过多会导致接口拥堵、页面卡顿,这4种用法能优雅控制并发,兼顾效率和性能。

4. 映射并发Promises并处理结果数组(Promise.all)

核心作用:并行执行多个Promise,等待所有Promise都成功(fulfilled)后,返回所有结果的数组;只要有一个失败(rejected),就立即触发catch,适合“所有异步操作都必须成功”的场景。

// 基础用法:并发请求多个接口
const fetchUrls = urls => {
  // 映射所有url为fetch Promise
  const fetchPromises = urls.map(url => 
    fetch(url).then(response => response.json())
  );
  // 等待所有Promise执行完成
  return Promise.all(fetchPromises);
};

// 实战用法:批量获取用户列表、商品列表、分类列表
const urls = [
  '/api/users',
  '/api/goods',
  '/api/categories'
];

fetchUrls(urls).then(results => {
  const [users, goods, categories] = results;
  console.log('用户列表:', users);
  console.log('商品列表:', goods);
  console.log('分类列表:', categories);
  // 所有数据获取完成后,渲染页面
}).catch(error => {
  console.error('某个接口请求失败:', error);
});

5. 并发控制(限制并发数量)

核心作用:Promise.all会同时执行所有Promise,当并发数量过多(比如几十上百个请求),会给服务器造成压力,通过此方法可限制同时执行的Promise数量,避免拥堵。

// 核心封装:并发控制函数
const concurrentPromises = (promises, limit) => {
  return new Promise((resolve, reject) => {
    let i = 0; // 当前执行到的Promise索引
    let result = []; // 存储所有Promise的结果
    // 执行单个Promise的函数
    const executor = () => {
      // 所有Promise都执行完成,返回结果
      if (i >= promises.length) {
        return resolve(result);
      }
      // 取出当前要执行的Promise
      const promise = promises[i++];
      // 执行Promise并处理结果
      Promise.resolve(promise)
        .then(value => {
          result.push(value);
          // 继续执行下一个Promise(递归调用)
          if (i < promises.length) {
            executor();
          } else {
            // 最后一个Promise执行完成,返回结果
            resolve(result);
          }
        })
        .catch(reject); // 任意一个Promise失败,直接触发reject
    };

    // 初始化,同时执行limit个Promise
    for (let j = 0; j < limit && j < promises.length; j++) {
      executor();
    }
  });
};

// 实战用法:限制最多同时执行3个请求
const allPromises = [
  fetch('/api/data1').then(res => res.json()),
  fetch('/api/data2').then(res => res.json()),
  fetch('/api/data3').then(res => res.json()),
  fetch('/api/data4').then(res => res.json()),
  fetch('/api/data5').then(res => res.json())
];

// 限制并发数量为3
concurrentPromises(allPromises, 3).then(results => {
  console.log('所有请求完成,结果:', results);
}).catch(err => {
  console.error('请求失败:', err);
});

6. 使用Promise.allSettled处理多个异步操作

核心作用:与Promise.all相反,无论每个Promise是成功还是失败,都会等待所有Promise执行完成,返回每个Promise的状态和结果,适合“不需要所有异步都成功”的场景(比如批量统计接口状态)。

// 基础用法:统计多个接口的执行状态
const promises = [
  fetch('/api/endpoint1'),
  fetch('/api/endpoint2'),
  fetch('/api/endpoint3') // 假设这个接口会失败
];

Promise.allSettled(promises).then(results => {
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      // 成功的Promise,处理结果
      console.log(`接口${index + 1}请求成功:`, result.value);
    } else {
      // 失败的Promise,处理错误
      console.error(`接口${index + 1}请求失败:`, result.reason);
    }
  });
});

// 实战用法:批量上传文件,统计成功/失败数量
async function batchUpload(files) {
  const uploadPromises = files.map(file => 
    fetch('/api/upload', {
      method: 'POST',
      body: file
    }).then(res => res.json())
  );

  const results = await Promise.allSettled(uploadPromises);
  const successCount = results.filter(r => r.status === 'fulfilled').length;
  const failCount = results.filter(r => r.status === 'rejected').length;
  console.log(`上传完成:成功${successCount}个,失败${failCount}个`);
}

7. 处理多个Promises的最快响应(Promise.race)

核心作用:并行执行多个Promise,只关心“最快完成”的那个Promise的结果(无论成功还是失败),适合“取最快响应”的场景(比如多节点接口容错)。

// 基础用法:获取多个接口中最快的响应
const promises = [
  fetch('/api/node1').then(res => res.json()), // 节点1,响应时间约500ms
  fetch('/api/node2').then(res => res.json()), // 节点2,响应时间约300ms
  fetch('/api/node3').then(res => res.json())  // 节点3,响应时间约800ms
];

Promise.race(promises)
  .then(value => {
    console.log('最快响应的接口数据:', value); // 会输出节点2的数据
    // 使用最快的响应数据渲染页面,提升用户体验
  })
  .catch(reason => {
    console.error('最早失败的接口:', reason); // 若最快的接口失败,直接触发
  });

// 实战用法:多CDN资源加载,取最快的那个
const loadResource = (urls) => {
  const loadPromises = urls.map(url => 
    new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(url);
      img.src = url;
    })
  );
  return Promise.race(loadPromises);
};

// 加载3个CDN的图片,取最快加载完成的
const imgUrls = [
  'https://cdn1.example.com/img.jpg',
  'https://cdn2.example.com/img.jpg',
  'https://cdn3.example.com/img.jpg'
];

loadResource(imgUrls).then(fastUrl => {
  console.log('最快加载的图片CDN:', fastUrl);
  document.querySelector('.img').src = fastUrl;
});

三、异常与状态处理用法(5种)—— 规避异步报错,提升代码健壮性

异步操作中,报错、超时、状态未知是常见问题,这5种用法能优雅处理这些异常,避免页面崩溃,让代码更健壮、更易维护。

8. Promise超时处理

核心作用:给Promise设置超时时间,若超过指定时间仍未完成(未resolve/reject),则自动reject,避免异步操作“卡死”,提升用户体验。

// 核心封装:带超时的Promise
const promiseWithTimeout = (promise, ms) =>
  Promise.race([
    promise, // 原异步操作
    // 超时定时器,超过ms毫秒后reject
    new Promise((resolve, reject) =>
      setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
    )
  ]);

// 实战用法:给接口请求设置3秒超时
async function fetchWithTimeout(url, ms = 3000) {
  try {
    const response = await promiseWithTimeout(fetch(url), ms);
    return await response.json();
  } catch (error) {
    if (error.message.includes('Timeout')) {
      // 处理超时逻辑(提示用户、重试等)
      alert('接口请求超时,请检查网络后重试!');
    } else {
      console.error('接口请求失败:', error);
    }
  }
}

// 调用:3秒内未响应则超时
fetchWithTimeout('/api/data', 3000);

9. Promise的取消

核心作用:JavaScript原生Promise无法直接取消,但通过封装,可模拟取消逻辑(比如用户切换页面、取消请求),避免无效异步操作浪费资源。

// 核心封装:可取消的Promise
const cancellablePromise = promise => {
  let isCanceled = false; // 取消标记

  // 包装原Promise,添加取消逻辑
  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      value => {
        // 若已取消,触发reject(携带取消标记)
        isCanceled ? reject({ isCanceled, value }) : resolve(value);
      },
      error => {
        // 若已取消,触发reject(携带取消标记)
        isCanceled ? reject({ isCanceled, error }) : reject(error);
      }
    );
  });

  // 返回Promise和取消方法
  return {
    promise: wrappedPromise,
    cancel() {
      isCanceled = true; // 标记为已取消
    }
  };
};

// 实战用法:用户取消接口请求
const { promise, cancel } = cancellablePromise(
  fetch('/api/largeData').then(res => res.json())
);

// 监听取消按钮点击
document.querySelector('.cancel-btn').addEventListener('click', () => {
  cancel(); // 取消Promise
  console.log('请求已取消');
});

// 处理Promise结果
promise.then(data => {
  console.log('请求成功:', data);
}).catch(err => {
  if (err.isCanceled) {
    // 处理取消逻辑(不提示错误)
  } else {
    console.error('请求失败:', err);
  }
});

10. 检测Promise状态

核心作用:原生Promise不允许直接查询状态(pending/fulfilled/rejected),通过此方法可获取Promise的当前状态,用于调试或特殊业务逻辑。

// 核心封装:检测Promise状态
const reflectPromise = promise =>
  promise.then(
    value => ({ status: 'fulfilled', value }), // 成功:返回状态和结果
    error => ({ status: 'rejected', error })   // 失败:返回状态和错误
  );

// 实战用法:检测多个Promise的状态
const promise1 = fetch('/api/data1').then(res => res.json());
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 1000));

// 检测两个Promise的状态
Promise.all([reflectPromise(promise1), reflectPromise(promise2)]).then(results => {
  results.forEach((result, index) => {
    console.log(`Promise${index + 1}状态:`, result.status);
    if (result.status === 'fulfilled') {
      console.log('结果:', result.value);
    } else {
      console.log('错误:', result.error);
    }
  });
});

11. 确保Promise只解决一次

核心作用:避免因代码逻辑问题(比如重复调用resolve),导致Promise被多次解决(fulfilled),引发后续逻辑异常。

// 核心封装:确保只resolve一次的Promise
const onceResolvedPromise = executor => {
  let isResolved = false; // 标记是否已resolve
  return new Promise((resolve, reject) => {
    executor(
      value => {
        // 只有未resolve过,才执行resolve
        if (!isResolved) {
          isResolved = true;
          resolve(value);
        }
      },
      reject // reject可多次调用,不影响
    );
  });
};

// 实战用法:避免重复提交(比如表单提交)
const submitForm = (formData) => {
  return onceResolvedPromise((resolve, reject) => {
    // 模拟表单提交接口
    fetch('/api/submit', {
      method: 'POST',
      body: formData
    }).then(res => res.json())
      .then(data => {
        resolve(data);
        // 模拟重复调用resolve(无效)
        resolve('重复resolve');
      })
      .catch(reject);
  });
};

// 调用:即使内部重复resolve,也只执行一次
submitForm({ name: '张三' }).then(data => {
  console.log('提交成功:', data); // 只输出一次
}).catch(err => {
  console.error('提交失败:', err);
});

12. 同时执行多个异步任务并处理中途的失败

核心作用:类似Promise.allSettled,但更灵活,可自定义失败处理逻辑,适合“允许部分异步失败,同时处理成功和失败结果”的场景。

// 结合reflectPromise,处理部分失败的场景
const promises = [
  fetch('/api/data1').then(res => res.json()), // 成功
  fetch('/api/data2').then(res => res.json()), // 失败
  fetch('/api/data3').then(res => res.json())  // 成功
];

// 先通过reflectPromise获取所有状态,再分别处理
Promise.all(promises.map(reflectPromise)).then(results => {
  // 处理成功的结果
  const successResults = results.filter(r => r.status === 'fulfilled').map(r => r.value);
  console.log('成功的结果:', successResults);

  // 处理失败的结果(比如记录日志、提示用户)
  const failResults = results.filter(r => r.status === 'rejected').map(r => r.error);
  failResults.forEach((err, index) => {
    console.error(`任务${index + 1}失败:`, err);
    // 可选:失败重试
    // retryPromise(() => fetch(`/api/data${index + 2}`).then(res => res.json()), 3, 1000);
  });
});

四、流程控制用法(6种)—— 优雅管理异步流程,避免混乱

异步流程混乱是前端异步开发的常见问题,比如顺序执行、条件执行、动态执行等,这6种用法能帮你精准控制异步流程,让代码逻辑更清晰。

13. 顺序执行Promise数组

核心作用:按顺序执行一组Promise,前一个异步操作完成后,再执行下一个,适合“依赖前一个结果”的场景(比如分步提交、依赖接口)。

// 核心封装:顺序执行Promise数组
const sequencePromises = promises =>
  promises.reduce(
    (prev, next) => prev.then(() => next()), // 前一个完成,执行下一个
    Promise.resolve() // 初始值:一个已resolve的Promise
  );

// 实战用法:分步提交表单(先验证、再提交、最后提示)
const step1 = () => new Promise(resolve => {
  console.log('步骤1:验证表单');
  setTimeout(() => resolve(), 1000);
});

const step2 = () => new Promise(resolve => {
  console.log('步骤2:提交表单');
  setTimeout(() => resolve(), 1500);
});

const step3 = () => new Promise(resolve => {
  console.log('步骤3:提示提交成功');
  setTimeout(() => resolve(), 500);
});

// 顺序执行3个步骤
sequencePromises([step1, step2, step3]).then(() => {
  console.log('所有步骤执行完成');
});

14. 基于条件的Promise链

核心作用:根据条件判断,决定是否执行下一个Promise,适合“分支异步逻辑”(比如根据用户权限,决定是否获取敏感数据)。

// 核心封装:条件Promise链
const conditionalPromise = (conditionFn, promise) => 
  conditionFn() ? promise : Promise.resolve(); // 条件不满足,返回已resolve的Promise

// 实战用法:根据用户权限,决定是否获取敏感数据
// 模拟判断用户是否有权限
const hasPermission = () => {
  const user = JSON.parse(localStorage.getItem('user'));
  return user?.role === 'admin'; // 管理员有权限
};

// 敏感数据接口(只有管理员能获取)
const fetchSensitiveData = () => 
  fetch('/api/sensitive').then(res => res.json());

// 条件执行:有权限则获取,无权限则跳过
conditionalPromise(hasPermission, fetchSensitiveData())
  .then(data => {
    if (data) {
      console.log('敏感数据:', data);
      // 渲染敏感数据
    } else {
      console.log('无权限获取敏感数据');
    }
  })
  .catch(err => console.error('获取失败:', err));

15. Promise的重试逻辑

核心作用:当Promise因暂时性错误(比如网络波动、接口临时不可用)失败时,自动重试指定次数,提升接口成功率。

// 核心封装:带重试的Promise
const retryPromise = (promiseFn, maxAttempts, interval) => {
  return new Promise((resolve, reject) => {
    // 递归重试函数
    const attempt = attemptNumber => {
      // 达到最大重试次数,触发reject
      if (attemptNumber === maxAttempts) {
        reject(new Error('Max attempts reached'));
        return;
      }
      // 执行Promise,失败则重试
      promiseFn().then(resolve).catch(() => {
        // 间隔interval毫秒后,重试下一次
        setTimeout(() => {
          attempt(attemptNumber + 1);
        }, interval);
      });
    };
    // 开始第一次尝试
    attempt(0);
  });
};

// 实战用法:接口请求失败后,重试3次,每次间隔1秒
const fetchData = () => fetch('/api/data').then(res => res.json());

retryPromise(fetchData, 3, 1000)
  .then(data => console.log('请求成功:', data))
  .catch(err => {
    console.error('重试3次仍失败:', err);
    alert('请求失败,请稍后再试!');
  });

16. Promise-pipeline(管道化异步操作)

核心作用:将多个异步操作串联成“管道”,前一个操作的结果作为后一个操作的输入,适合“多步骤异步处理”(比如数据获取→处理→保存)。

// 核心封装:Promise管道函数
const promisePipe = (...fns) => value => 
  fns.reduce((p, f) => p.then(f), Promise.resolve(value));

// 实战用法:数据处理管道(获取数据→格式化→保存)
// 步骤1:获取数据
const fetchData = () => fetch('/api/rawData').then(res => res.json());
// 步骤2:格式化数据
const formatData = (data) => {
  return new Promise(resolve => {
    const formatted = data.map(item => ({
      id: item.id,
      name: item.name,
      time: new Date(item.time).toLocaleString()
    }));
    resolve(formatted);
  });
};
// 步骤3:保存数据
const saveData = (data) => fetch('/api/save', {
  method: 'POST',
  body: JSON.stringify(data),
  headers: { 'Content-Type': 'application/json' }
}).then(res => res.json());

// 管道化执行:fetchData → formatData → saveData
const dataPipeline = promisePipe(fetchData, formatData, saveData);
dataPipeline().then(res => {
  console.log('数据处理完成:', res);
}).catch(err => {
  console.error('数据处理失败:', err);
});

17. 动态生成Promise链

核心作用:根据不同条件,动态生成异步操作链,适合“不确定异步步骤数量”的场景(比如根据用户选择,执行不同的异步操作)。

// 实战用法:根据用户选择的操作,动态生成Promise链
// 模拟用户选择的操作(可动态变化)
const userActions = [
  () => fetch('/api/action1').then(res => res.json()),
  () => fetch('/api/action2').then(res => res.json()),
  () => fetch('/api/action3').then(res => res.json())
];

// 动态生成Promise链
const promiseChain = userActions.reduce((chain, currentTask) => {
  return chain.then(currentTask); // 依次添加异步任务到链中
}, Promise.resolve()); // 初始值

// 执行动态生成的Promise链
promiseChain.then(results => {
  console.log('所有动态操作完成:', results);
}).catch(err => {
  console.error('动态操作失败:', err);
});

18. 连续获取不确定数量的数据页

核心作用:获取分页数据时,不知道总页数,通过递归方式,自动获取所有分页数据,直到没有下一页,适合“无限滚动”“批量导出”场景。

// 核心封装:递归获取所有分页数据
async function fetchPages(apiEndpoint, page = 1, allResults = []) {
  try {
    // 发起分页请求
    const response = await fetch(`${apiEndpoint}?page=${page}`);
    const data = await response.json();
    // 合并当前页数据
    const newResults = allResults.concat(data.results);
    // 判断是否有下一页,有则继续递归,无则返回所有数据
    if (data.nextPage) {
      return fetchPages(apiEndpoint, page + 1, newResults);
    } else {
      return newResults;
    }
  } catch (error) {
    console.error('获取分页数据失败:', error);
    throw error; // 抛出错误,让调用者处理
  }
}

// 实战用法:获取所有用户分页数据(不知道总页数)
fetchPages('/api/users')
  .then(allUsers => {
    console.log('所有用户数据:', allUsers);
    // 渲染所有用户数据
  })
  .catch(err => {
    alert('获取用户数据失败,请重试!');
  });

五、高级进阶用法(5种)—— 提升编码上限,面试加分项

这5种用法属于进阶技巧,在复杂项目中经常用到,掌握它们,能让你在异步开发中更游刃有余,也是前端面试的高频加分项。

19. 使用Generators管理异步流程

核心作用:将async/await与Generators配合,创建可控制的异步流程管理器,适合“复杂异步流程控制”(比如暂停、继续、中断异步操作)。

// 基础用法:Generator配合Promise管理流程
function* asyncGenerator() {
  console.log('开始执行异步流程');
  //  yield后面跟Promise,暂停等待Promise完成
  const result1 = yield fetch('/api/data1').then(res => res.json());
  console.log('第一个异步操作完成:', result1);
  
  // 第二个异步操作,依赖第一个的结果
  const result2 = yield fetch(`/api/data2?userId=${result1.id}`).then(res => res.json());
  console.log('第二个异步操作完成:', result2);
  
  return result2; // 返回最终结果
}

// 执行Generator函数
function runGenerator(generator) {
  const iterator = generator();
  const handle = (result) => {
    if (result.done) return result.value;
    // 处理yield返回的Promise
    result.value.then(res => {
      handle(iterator.next(res));
    }).catch(err => {
      iterator.throw(err);
    });
  };
  handle(iterator.next());
}

// 调用:执行异步流程
runGenerator(asyncGenerator);

20. 流式处理大型数据集

核心作用:处理大型数据集时,避免一次性加载所有数据导致内存过载,通过“流式处理”(分块处理),提升性能和用户体验。

// 实战用法:流式处理大型Excel数据(分块读取、分块处理)
async function processLargeDataSet(dataSet) {
  // dataSet:大型数据集(比如10万条数据),按块分割
  for (const dataChunk of dataSet) {
    // 分块处理数据(每个块单独异步处理)
    const processedChunk = await processData(dataChunk); // 处理单个块
    // 分块保存数据,避免一次性保存导致卡顿
    await saveProcessedData(processedChunk);
    console.log('处理完成一个数据块');
  }
  console.log('所有大型数据处理完成');
}

// 模拟分块处理函数
function processData(chunk) {
  return new Promise(resolve => {
    // 模拟数据处理(比如格式化、过滤)
    const processed = chunk.map(item => item * 2);
    setTimeout(() => resolve(processed), 500);
  });
}

// 模拟分块保存函数
function saveProcessedData(chunk) {
  return fetch('/api/saveChunk', {
    method: 'POST',
    body: JSON.stringify(chunk),
    headers: { 'Content-Type': 'application/json' }
  }).then(res => res.json());
}

// 模拟大型数据集(10个块,每个块1万条数据)
const largeDataSet = Array(10).fill(0).map(() => Array(10000).fill(0).map((_, i) => i));
// 流式处理
processLargeDataSet(largeDataSet);

21. 使用Promise实现简易的异步锁

核心作用:在多线程/多异步场景中,确保同一时间只有一个异步操作执行(比如防止重复提交、并发修改数据),避免数据冲突。

// 核心封装:简易异步锁
let lock = Promise.resolve(); // 初始锁:已解锁状态

// 获取锁
const acquireLock = () => {
  let release;
  // 等待锁释放的Promise
  const waitLock = new Promise(resolve => {
    release = resolve; // 释放锁的方法
  });
  // 尝试获取锁:当前锁释放后,返回释放锁的方法
  const tryAcquireLock = lock.then(() => release);
  // 更新锁状态:当前锁变为等待状态
  lock = waitLock;
  return tryAcquireLock;
};

// 实战用法:防止重复提交表单
async function submitForm(formData) {
  // 获取锁,获取成功才能执行提交
  const release = await acquireLock();
  try {
    console.log('开始提交表单');
    // 模拟表单提交(异步操作)
    const res = await fetch('/api/submit', {
      method: 'POST',
      body: formData
    }).then(res => res.json());
    console.log('表单提交成功:', res);
    return res;
  } catch (err) {
    console.error('表单提交失败:', err);
  } finally {
    // 无论成功失败,都释放锁
    release();
    console.log('锁已释放');
  }
}

// 模拟多次点击提交按钮(只会执行一次提交)
submitForm({ name: '张三' });
submitForm({ name: '张三' });
submitForm({ name: '张三' });

22. 组合多个Promise操作为一个函数

核心作用:将多个相关的Promise操作合并为一个函数,实现代码复用,减少冗余,提升代码可维护性。

// 实战用法:组合“获取数据+处理数据”为一个函数
// 处理数据的辅助函数
const processData = (data) => {
  return new Promise(resolve => {
    // 模拟数据处理(过滤、格式化)
    const processed = data.filter(item => item.status === 1).map(item => ({
      id: item.id,
      name: item.name,
      time: item.createTime
    }));
    resolve(processed);
  });
};

// 组合多个Promise操作
const fetchDataAndProcess = async url => {
  // 步骤1:获取数据
  const response = await fetch(url);
  const rawData = await response.json();
  // 步骤2:处理数据
  const processedData = await processData(rawData);
  // 返回处理后的结果
  return processedData;
};

// 复用函数:获取不同接口的数据并处理
fetchDataAndProcess('/api/users')
  .then(users => console.log('处理后的用户数据:', users))
  .catch(err => console.error('处理失败:', err));

fetchDataAndProcess('/api/goods')
  .then(goods => console.log('处理后的商品数据:', goods))
  .catch(err => console.error('处理失败:', err));

23. 处理可选的异步操作

核心作用:处理“可选的异步操作”,当条件满足时执行异步操作,不满足时返回默认值,避免多余的错误处理。

// 核心封装:处理可选异步操作
async function optionallyAsyncTask(condition, asyncOperation, fallbackValue) {
  if (condition) {
    // 条件满足,执行异步操作
    return await asyncOperation;
  } else {
    // 条件不满足,返回默认值
    return fallbackValue;
  }
}

// 实战用法:根据用户是否登录,决定是否获取用户个性化数据
const isLogin = () => !!localStorage.getItem('token'); // 判断用户是否登录
const fetchPersonalData = () => fetch('/api/personal').then(res => res.json()); // 个性化数据接口

// 调用:登录则获取个性化数据,未登录则返回默认数据
optionallyAsyncTask(isLogin(), fetchPersonalData(), {
  name: '游客',
  recommend: []
}).then(data => {
  console.log('用户数据:', data);
  // 渲染用户数据(无论是否登录,都有默认值,避免报错)
  document.querySelector('.user-info').textContent = JSON.stringify(data);
});

六、Promise高频避坑总结(新手必记,少踩雷)

很多新手学完Promise,一写代码就报错,不是语法错了,就是用法不对,整理了5个高频坑,记牢就能避开!

  • 坑1:忘记处理Promise的reject——Promise失败时,若未用catch捕获,会报未捕获错误,导致页面崩溃,务必给每个Promise添加catch处理。
  • 坑2:混淆Promise.all和Promise.allSettled——前者只要一个失败就整体失败,后者无论成败都会等待所有执行完成,按需选择。
  • 坑3:认为Promise可以直接取消——原生Promise无法取消,需通过封装(添加取消标记)模拟取消逻辑。
  • 坑4:await后面跟非Promise值——await只能等待Promise或async函数,若跟普通值,会直接返回该值,相当于同步执行,无意义。
  • 坑5:重复调用resolve/reject——Promise状态一旦改变就无法逆转,重复调用resolve/reject无效,还可能导致逻辑混乱,可用onceResolvedPromise避免。

七、面试高频考点(新手必记,轻松拿捏面试官)

Promise是前端面试高频考点,不用死记硬背,记住这5个核心问题,面试时直接套用即可:

  • Q1:Promise有哪三种状态?状态能否逆转? A:pending(等待中)、fulfilled(成功)、rejected(失败);状态一旦改变(pending→fulfilled或pending→rejected),就无法逆转。
  • Q2:Promise.all和Promise.race的区别? A:Promise.all等待所有Promise成功,一个失败则整体失败;Promise.race只取最快完成的Promise结果,无论成功还是失败。
  • Q3:async/await和Promise的关系? A:asyncawait是Promise的语法糖,async函数本质上返回一个Promise,await只能在async函数中使用,用于等待Promise完成。
  • Q4:如何实现Promise的取消? A:原生Promise无法直接取消,可通过添加取消标记(isCanceled),在Promise.then/catch中判断标记,实现模拟取消。
  • Q5:Promise的错误捕获有哪些方式? A:两种方式:一是给每个Promise添加.catch();二是在async/await中用try/catch捕获所有异步错误。

八、最后说几句掏心窝的话

Promise不难,核心就是“异步状态管理”,23种用法看似多,但不用死记硬背——日常开发中,高频使用的也就10种左右(async/await、Promise.all、超时处理、顺序执行等),剩下的可作为储备,用到时翻这篇文章即可。

它不是前端进阶的“加分项”,而是“必备项”——现在前端开发几乎所有异步操作都离不开Promise,不学真的会被淘汰。这篇文章整理了所有用法的实战代码、通俗解读和避坑细节,代码可直接复制练习,建议收藏起来,开发时遇到问题就翻一翻,慢慢就熟练了。

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从新手进阶成资深开发者💪

卷AI、卷算法、2026 年的前端工程师到底在卷什么?

1_jXusXvCfxECPU_Jh9S_E3w.jpg

最近是 2026 年的春招季,前几周密集面了大概快二十个前端。

翻开这批简历,我有一种极其魔幻的感觉:满屏都是 AI,满屏都是算法。

四五年前,大家简历上的高频词还是 精通 Vue3 响应式原理、熟练掌握 Webpack 性能调优

现在呢?十个候选人里,有九个写着熟练掌握 LLM 接入、深入理解 RAG(检索增强生成)、精通 Prompt 工程、参与过大模型 Agent 平台建设,剩下那个没写 AI 的,简历里赫然写着LeetCode 刷题 150+,精通动态规划与图论。

前端这个圈子,仿佛在一夜之间得了严重的技术焦虑并发症。大家都在拼命往简历里塞最高大上的词,生怕在 2026 年这个节点,因为不懂 AI 而被直接淘汰。

但现实是什么?

上周我淘汰了一个简历写得极其华丽、号称 主导过公司核心 AI 助手前端架构 的候选人。

我没问他大模型底层原理,也没让他手撕红黑树,我只问了他一个极其真实的业务场景: 在一个 AI 流式输出(Streaming)的对话场景里,如果大模型返回的是一个极其复杂的、带有代码块和多步工具调用(Tool Call)的 JSON 块。在流式传输还没结束、JSON 还是残缺状态的时候,你的前端是怎么保证 UI 不崩溃,并且能平滑渲染中间状态的?

他愣了半分钟,支支吾吾地说:我们用的是 Vercel AI SDK,它内部封装好了,直接拿 useChat 里的 messages 渲染就行……

😖😖😖...

我叹了口气,在面试评价上默默写下:只会调用 API,缺乏处理复杂工程能力。

这就是 2026 年前端圈最大的悲哀:大家都在卷 AI,但 90% 的人卷的只是如何发送一个带 API Key 的 HTTP 请求。


别把调用 API 包装成核心竞争力 🤷‍♂️

现在很多前端对懂 AI的理解极其肤浅。

以为在项目里接个 OpenAI 或者 Claude 的接口,搞个对话框,把输入框的字传过去,把返回的字用 Markdown 渲染出来,就叫AI 前端工程师了😖。

兄弟,那不叫 AI 开发,那叫表单提交。这种活儿,三年前刚培训班毕业的实习生也会干。

大模型时代,前端真正的难点根本不是发送请求,而是 应对大模型带来的复杂性。

以前我们写业务代码,接口返回的数据结构是确定的,是后端的 Swagger 定义好的。你只需要 if (res.code === 200) 然后按部就班地渲染。

但在 2026 年,大模型吐出来的东西是不可控的。 真实的高阶 AI 前端工程,每天要面对的是这些破事:

流式返回进行到一半,JSON 连个闭合的括号都没有,你的界面怎么解析?怎么渲染正在打字的生成式 UI?

一个 Agent 在后台疯狂调用工具(查天气、查数据库、画图),这个过程中产生的大量异步中间状态,如何在 React/Vue 中做防抖、状态合并和打断(Abort)?

大模型突然抽风,返回了完全不符合预期的组件协议,你的前端系统能不能做沙盒隔离,保证不引发整个页面的白屏崩溃?

这些问题,根本不是你背几个 Prompt 模板就能解决的。它考验的是你对数据流处理、AST(抽象语法树)解析、复杂状态机设计以及防御性编程的底层功底。

你卷了半天 Vercel AI SDK 的用法,一旦业务场景超出了 SDK 的默认配置,你立马就抓瞎了。


为什么面试官越来越爱考算法?

说完了 AI,再聊聊算法。这也是现在前端同行疯狂吐槽的点:我特么一个画页面的,凭什么让我手写动态规划?🤔

其实这是个很残酷的信号。

作为面试官,我跟你交个底:因为那些常规的、套路化的前端业务代码,现在 AI 真的能写了,而且写得比你快。

2026 年了,如果你只会写个增删改查的表格,只会封装个按钮组件,我在面试里连问你的兴趣都没有。既然基础的搬砖工作被 AI 大幅压缩了,那公司招人,过滤标准自然就要往上提。

考算法,本质上考的不是你对某道题的背诵能力,而是考你的复杂逻辑拆解能力和极限思维

特别是在做 AI 工具链的前端时:

  • 当你要在浏览器端用 WebAssembly 跑一个轻量级的向量数据库(Vector DB)进行本地 RAG 检索时,不懂数据结构你连原理都看不懂。
  • 当你要处理大模型返回的超大文档树,做精确的 DOM 节点比对和替换时,树的遍历算法就是你的基本功。

大家不是在卷算法,而是在抢夺那些AI 无法轻易替代的深水区岗位🤔。


没必要那么焦虑

前天面试结束,跟几个同组的技术老炮抽烟。大家感慨,其实这十年来,前端圈的焦虑从来没停过。

当年 jQueryReact 淘汰时,大家在卷;后来小程序大爆发时,大家也在卷;现在大模型来了,大家不过是换了个名词继续卷。

别被那种 AI 要干掉前端的鬼话吓倒了,也别为了迎合面试官去死记硬背什么 RAG 架构图。

潮水退去的时候,企业最终留下的,永远不是那个会背时髦名词的人,而是那个懂 HTTP 协议、懂浏览器底层、能在复杂的异步环境里把一个烂摊子稳稳托住的前端。

在这个越发喧嚣的 2026 年,少去追逐那些虚幻的词汇,多去打磨你手里的基本功吧🤷‍♂️

共勉🙌

加油加油加油.gif

ES6模块化保姆级教程,彻底告别全局污染,新手也能秒上手

前端人必看!你是不是也遇到过这些坑😭

写代码时,变量、函数越写越多,不小心就全局污染,导致代码冲突报错;引入多个JS文件,顺序乱了就崩;想复用一段代码,只能复制粘贴,后期维护堪比“拆炸弹”……

其实这些问题,ES6模块化早就帮我们解决了!它是浏览器端和服务器端通用的模块化规范,不用再额外学习AMD、CMD、CommonJS等复杂规范,新手入门零压力,学会它,代码整洁度、复用性直接翻倍,面试也能轻松加分!

今天这篇文章,结合实战代码,把ES6模块化拆解得明明白白,从核心概念到3种用法,从基础语法到避坑细节,小白能看懂,老手能查漏补缺,收藏这一篇,再也不用东拼西凑找资料!

核心提醒:ES6模块化的核心是“拆分代码、按需导入、按需导出”,每个JS文件都是独立模块,互不干扰,彻底解决全局污染和代码复用难题,是前端工程化的基础!

一、先搞懂:ES6模块化到底是什么?(新手必看)

在ES6出现之前,前端没有统一的模块化规范,开发者只能用AMD、CMD、CommonJS等规范,不同规范用法不同,学习成本高,还不通用——浏览器端用AMD,服务器端用CommonJS,切换起来很麻烦。

而ES6模块化的出现,直接统一了浏览器和服务器端的模块化标准,它的核心定义很简单,记住3点就够了:

  • 每个JS文件都是一个独立的模块,模块内部的变量、函数、类,默认都是私有的,不会污染全局作用域;
  • 想要使用其他模块的内容,用 import 关键字导入;
  • 想要把自己模块的内容共享给其他模块,用 export 关键字导出。

举个通俗的例子:ES6模块化就像一个个独立的“文件盒子”,每个盒子里装着自己的代码(变量、函数),盒子之间可以互相“借东西”(导入导出),但不会打乱彼此的内容,也不会影响外面的环境。

二、ES6模块化3种核心用法(实战为王,代码可直接复制)

ES6模块化主要有3种用法,覆盖所有开发场景,其中“默认导出/导入”和“按需导出/导入”最常用,一定要重点掌握,第三种“直接导入执行”按需了解即可。

1. 用法一:默认导出(export default)与默认导入(import)

核心作用:一个模块只能有一次默认导出,用于导出模块的“主要内容”(比如一个核心函数、一个对象),导入时可以自由命名,非常灵活。

✅ 默认导出语法(导出文件:比如 namedModule.js)

// 方式1:导出单个变量/函数/对象(推荐)
const user = {
  name: "前端小白",
  age: 22,
  skill: "ES6"
};
export default user; // 默认导出,每个模块只能写一次

// 方式2:直接导出(无需提前定义)
// export default {
//   name: "前端小白",
//   age: 22
// };

// 方式3:导出函数
// export default function sayHello() {
//   console.log("Hello ES6模块化!");
// }

✅ 默认导入语法(导入文件:比如 index.js)

// 语法:import 接收名称 from '模块标识符(文件路径)'
import userInfo from './namedModule.js'; // 接收名称可任意命名,合法即可

console.log(userInfo); 
// 输出:{name: "前端小白", age: 22, skill: "ES6"}

⚠️ 关键细节

每个模块中,只允许使用唯一的一次 export default,如果写多次,会直接报错;默认导入时,接收名称可以任意命名(比如把userInfo改成myUser),不影响使用。

2. 用法二:按需导出(export)与按需导入(import {})

核心作用:一个模块可以多次按需导出,用于导出模块的“多个零散内容”(比如多个变量、多个函数),导入时必须和导出的名称保持一致,也可以重命名,灵活性更高,是日常开发中最常用的方式。

✅ 按需导出语法(导出文件:比如 demandModule.js)

// 按需导出单个变量/函数(可多次导出)
export let s1 = 'aaa';
export let s2 = 'ccc';
export function say() {
  console.log("我是按需导出的函数");
}

// 也可以先定义,再批量按需导出(推荐,代码更整洁)
// let s1 = 'aaa';
// let s2 = 'ccc';
// function say() {
//   console.log("我是按需导出的函数");
// }
// export { s1, s2, say };

✅ 按需导入语法(导入文件:比如 index.js)

// 语法:import { 导出名称1, 导出名称2 } from '模块标识符'
// 基础用法:导入指定内容
import { s1, s2, say } from './demandModule.js';
console.log(s1); // 输出:aaa
console.log(s2); // 输出:ccc
say(); // 输出:我是按需导出的函数

// 进阶用法1:重命名(用as关键字,解决名称冲突)
import { s1, s2 as str2, say } from './demandModule.js';
console.log(str2); // 输出:ccc(s2重命名为str2)

// 进阶用法2:按需导入 + 默认导入(结合使用,最常用)
// 假设demandModule.js同时有默认导出和按需导出
import info, { s1, s2 as str2, say } from './demandModule.js';
console.log(info); // 输出:默认导出的内容(比如{ a: 20 })
console.log(s1); // 输出:aaa
console.log(str2); // 输出:ccc

⚠️ 关键细节

  • 每个模块中,可以使用多次按需导出,没有数量限制;
  • 按需导入的成员名称,必须和按需导出的名称完全一致,否则会报错;
  • 如果导入的名称和当前模块的变量冲突,可以用 as 关键字重命名;
  • 按需导入可以和默认导入一起使用,满足复杂场景需求。

3. 用法三:直接导入并执行模块中的代码

核心作用:不需要导入模块中的任何内容,只需要执行模块中的代码(比如模块中是一段初始化代码、打印日志、创建DOM等),语法非常简单。

✅ 语法示例

// 导出文件:initModule.js
console.log("模块代码执行了!");
// 比如一段初始化代码
function init() {
  console.log("初始化完成,页面可以正常使用~");
}
init(); // 模块内部直接执行

// 导入文件:index.js(直接导入,不接收任何内容)
import './initModule.js';
// 执行后会输出:模块代码执行了!  初始化完成,页面可以正常使用~

这种用法场景较少,常见于初始化配置、全局注册组件等场景,不需要复用模块内容,只需要执行模块中的代码即可。

三、ES6模块化必避坑(新手常犯错误,看完少踩雷)

很多新手学完模块化,一写代码就报错,不是语法错了,就是用法不对,整理了4个高频坑,记牢就能避开!

坑1:忘记给script标签加type="module"

在浏览器中直接运行模块化代码时,如果script标签没有加type="module",浏览器会把JS文件当作普通脚本解析,遇到import/export会直接报错(SyntaxError: Unexpected token 'export')。

<!-- ❌ 错误写法:会报错 -->
<!-- ✅ 正确写法:必须加type="module" -->

加了type="module"后,浏览器会按模块化规范解析文件,同时开启严格模式、私有作用域,支持import/export语法。

坑2:一个模块写多个export default

默认导出(export default)每个模块只能有一次,写多次会直接报错,若需要导出多个内容,优先用按需导出,或把多个内容包装成一个对象,再默认导出。

// ❌ 错误写法:多个export default
export default { a: 10 };
export default { b: 20 }; // 报错!

// ✅ 正确写法1:用按需导出
export { a: 10, b: 20 };

// ✅ 正确写法2:包装成一个对象默认导出
export default {
  a: 10,
  b: 20
};

坑3:按需导入时名称和导出名称不一致

按需导入的核心规则:导入名称必须和导出名称完全一致,除非用as关键字重命名,否则会提示“未定义”错误。

// 导出文件
export let s1 = 'aaa';

// ❌ 错误写法:导入名称不一致
import { s2 } from './demandModule.js'; // 报错:s2 is not defined

// ✅ 正确写法1:名称一致
import { s1 } from './demandModule.js';

// ✅ 正确写法2:用as重命名
import { s1 as str1 } from './demandModule.js';

坑4:导入变量后直接修改

ES6模块化的导入变量是只读的引用,不是值的拷贝,不能直接修改导入的变量,否则会报错;如果需要修改,可在导出模块中定义修改方法,再导入使用。

// 导出文件:counter.js
export let count = 0;
export function increment() {
  count++; // 导出模块内部修改变量
}

// 导入文件:index.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1(同步更新)

// ❌ 错误写法:直接修改导入的变量
// count = 5; // 报错:Assignment to constant variable.

四、ES6模块化实战场景(贴合真实开发,直接复用)

学会了语法,还要知道在实际开发中怎么用,以下3个高频场景,覆盖大部分前端开发需求,代码可直接复制使用。

场景1:封装工具函数(最常用)

把常用的工具函数(比如格式化时间、防抖节流)封装成一个模块,按需导入使用,避免重复写代码,方便维护。

// 工具模块:utils.js
// 按需导出多个工具函数
export function formatTime(time) {
  // 格式化时间:YYYY-MM-DD
  return new Date(time).toLocaleDateString().replace(///g, '-');
}

export function debounce(fn, delay) {
  // 防抖函数
  let timer = null;
  return function() {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, delay);
  };
}

// 导入使用:index.js
import { formatTime, debounce } from './utils.js';

// 使用格式化时间函数
console.log(formatTime(new Date())); // 输出:2026-04-13

// 使用防抖函数
const handleClick = debounce(() => {
  console.log("防抖触发");
}, 500);

场景2:拆分组件(前端工程化基础)

在Vue、React等框架中,模块化拆分组件是基础操作,每个组件是一个独立模块,导出组件,再在其他组件中导入使用。

// 组件模块:Button.js
// 模拟Vue组件导出
export default function Button(props) {
  return ``;
}

// 导入使用:App.js
import Button from './Button.js';

// 渲染按钮组件
document.body.innerHTML = Button({
  color: 'red',
  text: '点击我'
});

场景3:统一入口文件(优化导入路径)

当项目模块较多时,可创建一个入口文件(index.js),统一导出所有模块,其他文件只需导入入口文件,简化导入路径。

// 入口文件:index.js
// 统一导出其他模块
export { default as Button } from './components/Button.js';
export { formatTime, debounce } from './utils.js';
export { userInfo } from './user.js';

// 导入使用:app.js
// 只需导入入口文件,即可获取所有模块
import { Button, formatTime, userInfo } from './index.js';

五、面试高频考点(新手必记,轻松拿捏面试官)

ES6模块化是前端面试高频考点,不用死记硬背,记住这4个核心问题,面试时直接套用即可:

  • Q1:ES6模块化和CommonJS的区别? A:ES6模块化是浏览器和服务器端通用,用import/export;CommonJS主要用于服务器端(Node.js),用require/module.exports;ES6模块化是静态导入(编译时解析),CommonJS是动态导入(运行时解析)。
  • Q2:export default和export的区别? A:export default每个模块只能有一次,导出单个内容,导入时可任意命名;export可多次使用,导出多个内容,导入时名称必须一致(可重命名)。
  • Q3:为什么import/export在浏览器中会报错? A:因为script标签没有加type="module",浏览器会把JS文件当作普通脚本解析,不支持模块化语法。
  • Q4:ES6模块化的导入变量为什么不能直接修改? A:导入的变量是只读的引用(Live Bindings),不是值的拷贝,直接修改会违反模块化的封装原则,需在导出模块中定义修改方法。

六、最后说几句掏心窝的话

ES6模块化不难,核心就是“导入(import)”和“导出(export)”,记住3种用法和4个避坑点,就能轻松上手。它不是前端进阶的“加分项”,而是“必备项”——现在前端开发几乎全员使用模块化,不学真的会被淘汰。

这篇文章整理了模块化的核心语法、实战案例、避坑细节和面试考点,代码可直接复制练习,建议收藏起来,开发时遇到问题就翻一翻,慢慢就熟练了。

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从新手进阶成资深开发者💪

LeetCode 149. 直线上最多的点数:题解深度剖析

LeetCode 中等难度题目「149. 直线上最多的点数」,这道题核心考察对“直线斜率”的理解和哈希表的运用,看似简单但细节超多,一不小心就会踩坑。下面结合完整代码,一步步讲透解题逻辑,新手也能轻松看懂。

题目回顾

题目很直白:给你一个数组 points ,其中 points[i] = [xi, yi] 表示 X-Y 平面上的一个点。求最多有多少个点在同一条直线上。

举个例子:如果 points = [[1,1],[2,2],[3,3]],那么这三个点在同一条直线上,答案就是 3;如果 points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]],答案则是 4(有4个点共线)。

核心难点:如何表示“同一条直线”?如何避免重复计数?如何处理斜率的精度问题?

解题核心思路

直线的核心特征是「斜率」—— 同一平面内,两点确定一条直线,而斜率相同(且经过同一点)的点,必然在同一条直线上。

基于这个原理,我们可以用「固定一点,遍历其他点」的思路,具体步骤如下:

  1. 边界处理:如果点的数量 ≤ 2,直接返回点的数量(因为两点必然共线);

  2. 遍历每个点 points[i],将其作为「基准点」;

  3. 计算基准点与其他所有点 points[j](j > i)的斜率,用哈希表记录「斜率对应的点的数量」;

  4. 统计当前基准点对应的最大共线点数,更新全局最大值;

  5. 优化剪枝:如果当前全局最大值已经 ≥ 剩余未遍历的点的数量,或者超过总点数的一半,直接终止循环(无需继续计算,因为不可能出现更大值)。

关键细节:斜率的表示(避坑重点)

这道题最容易踩坑的地方,就是「斜率的表示」。直接用 dy/dx (即两点纵坐标差除以横坐标差)会有两个问题:

  • 精度问题:浮点数计算会有误差(比如 1/3 和 2/6 本是同一个斜率,但浮点数表示可能不同);

  • 特殊情况:垂直直线(dx=0,斜率不存在)、水平直线(dy=0,斜率为0),无法用常规除法表示。

解决方案:用「最简整数比」表示斜率,将 dy 和 dx 化简为互质的整数,再用一个唯一的key表示这个比值。

具体做法(对应代码中的gcd函数和key计算):

  1. 计算两点的横坐标差 dx = x_i - x_j,纵坐标差 dy = y_i - y_j;

  2. 特殊处理:

    • dx=0(垂直直线):令 dy=1(统一表示所有垂直直线的斜率);

    • dy=0(水平直线):令 dx=1(统一表示所有水平直线的斜率);

  3. 符号统一:如果 dy 为负,将 dx 和 dy 同时取反(保证斜率的符号一致,比如 2/-3 和 -2/3 是同一个斜率,统一为 2/3);

  4. 化简:用最大公约数(gcd)将 dx 和 dy 化简为互质的整数(比如 dx=4,dy=2,化简为 dx=2,dy=1);

  5. 生成key:将二维的 (dy, dx) 转化为一维key,避免哈希表的key冲突。代码中用「dy + dx * 20001」,因为题目中坐标的范围是 [-10^4, 10^4],dx的最大绝对值是 20000,乘以20001后,再加上dy(范围 [-20000, 20000]),可以保证每个 (dy, dx) 对应唯一的key。

完整代码+逐行解析

先贴完整代码(TypeScript版本),再逐行拆解核心逻辑:

function maxPoints(points: number[][]): number {
  const n = points.length;
  if (n <= 2) return n; // 边界处理:2个及以下点必共线
  let res = 0;

  // 最大公约数函数:用于化简dx和dy
  const gcd = (a: number, b: number): number => {
    return b != 0 ? gcd(b, a % b) : a;
  }

  // 遍历每个点作为基准点i
  for (let i = 0; i < n; i++) {
    // 剪枝:如果当前最大结果已经≥剩余点数量,或超过总点数的一半,无需继续
    if (res >= n - i || res > n / 2) {
      break;
    }
    const map = new Map(); // 记录当前基准点下,斜率对应的点的数量

    // 遍历所有在i之后的点j(避免重复计算,因为i和j与j和i的斜率相同)
    for (let j = i + 1; j < n; j++) {
      let dx = points[i][0] - points[j][0];
      let dy = points[i][1] - points[j][1];

      // 特殊处理:垂直/水平直线,统一斜率表示
      if (dx === 0) {
        dy = 1; // 垂直直线,斜率统一用(1,0)表示
      } else if (dy === 0) {
        dx = 1; // 水平直线,斜率统一用(0,1)表示
      } else {
        // 符号统一:dy为负时,dx和dy同时取反
        if (dy < 0) {
          dx = -dx;
          dy = -dy;
        }
        // 化简dx和dy为互质整数
        const gcdXY = gcd(Math.abs(dx), Math.abs(dy));
        dx /= gcdXY;
        dy /= gcdXY;
      }

      // 生成唯一key,存入哈希表
      const key = dy + dx * 20001;
      map.set(key, (map.get(key) || 0) + 1);
    }

    // 统计当前基准点下,最多的共线点数(map的值是“与基准点共线的点的数量”,需+1包含基准点本身)
    let maxn = 0;
    for (const num of map.values()) {
      maxn = Math.max(maxn, num + 1);
    }
    // 更新全局最大值
    res = Math.max(res, maxn);
  }
  return res;
};

逐行解析核心代码

  1. 边界处理:if (n <= 2) return n; —— 这是最基础的优化,因为1个点返回1,2个点返回2,都无需后续计算。

  2. gcd函数:求两个数的最大公约数,用于化简dx和dy。比如gcd(4,2)=2,gcd(3,5)=1,核心是递归实现“辗转相除法”。

  3. 外层循环(基准点遍历):for (let i = 0; i < n; i++),每个i作为基准点,后续只遍历j > i的点,避免重复计算(比如i=0、j=1和i=1、j=0是同一个斜率,无需重复统计)。

  4. 剪枝逻辑:if (res >= n - i || res > n / 2) break; —— 比如总共有5个点,当前res=3,剩余未遍历的点只有2个(n-i=5-3=2),不可能超过3,直接终止循环;另外,最多共线点数不可能超过总点数的一半(如果超过,早就在之前的基准点中统计到了),这一步能大幅提升效率。

  5. 哈希表map:key是斜率的唯一标识,value是“与基准点i共线且在i之后的点的数量”。

  6. 内层循环(计算斜率):for (let j = i + 1; j < n; j++),计算基准点i和点j的dx和dy,然后进行化简和符号统一,生成key存入map。

  7. 统计当前基准点的最大共线点数:num + 1 是因为map的value是“除基准点外的共线点数”,加上基准点本身才是总共线点数。

  8. 更新全局最大值res:每次遍历完一个基准点,就用当前的maxn更新res,最终res就是答案。

常见坑点&优化建议

坑点1:斜率精度问题

千万不要用 dy/dx 计算斜率(比如用浮点数存储),会出现精度误差。比如 dx=1、dy=3 和 dx=2、dy=6,斜率都是1/3,但浮点数表示可能有微小差异,导致哈希表认为是两个不同的斜率。

坑点2:符号不统一

比如 dx=2、dy=-3 和 dx=-2、dy=3,其实是同一个斜率,但如果不统一符号,会生成两个不同的key。所以代码中才会判断“如果dy<0,dx和dy同时取反”,保证斜率符号一致。

坑点3:重复计算

如果内层循环遍历所有j(j从0到n-1,j≠i),会导致i和j、j和i重复计算,浪费时间。所以内层循环只遍历j > i的点,既避免重复,又提升效率。

优化建议

剪枝逻辑一定要加!尤其是当n较大时(比如n=1000),剪枝能大幅减少循环次数,避免超时。另外,哈希表的key生成方式可以灵活调整,只要能保证“不同斜率对应不同key,相同斜率对应相同key”即可,代码中的「dy + dx * 20001」是结合题目坐标范围的最优选择。

测试用例验证

我们用两个典型测试用例验证代码:

  1. 测试用例1:points = [[1,1],[2,2],[3,3]]

    • i=0(基准点[1,1]),j=1:dx=-1,dy=-1 → 符号统一后dx=1,dy=1 → key=1+1*20001=20002,map={20002:1};

    • j=2:dx=-2,dy=-2 → 化简后dx=1,dy=1 → key=20002,map={20002:2};

    • maxn=2+1=3,res=3;后续循环剪枝,最终返回3。

  2. 测试用例2:points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]]

    • i=0(基准点[1,1]),遍历j=1~5,计算各个斜率,最终map中最大value为3(对应4个点共线),maxn=4,res=4;

    • 后续循环无法超过4,最终返回4。

总结

这道题的核心是「用最简整数比表示斜率」,避免精度和符号问题,再通过「固定基准点+哈希表计数」的思路,统计每个基准点对应的最大共线点数,最后结合剪枝优化提升效率。

整体难度中等,重点在于细节处理——斜率的化简、符号统一、key的生成,这些都是避坑的关键。理解之后会发现,这道题本质是“哈希表的应用+直线斜率的数学理解”,掌握后可以举一反三,应对类似的几何计数问题。

ES6 40个数组方法保姆级拆解

前端人必看!数组是JS开发中最常用的数据结构,没有之一✨

很多开发者写数组操作,还在死磕for循环写几十行冗余代码,要么用错方法导致bug频发,要么不知道哪个方法更高效——其实ES6+早已贴心提供了40个数组方法,覆盖遍历、筛选、修改、转换等所有场景,学会它们,编码效率直接翻倍,面试也能轻松拿捏面试官!

今天这篇文章,结合实战场景,把40个数组方法逐一拆解,每个方法都配「核心特点+可复制代码+避坑提示」,从基础到进阶,小白能看懂,老手能查漏补缺,收藏这一篇,再也不用东拼西凑找资料!

核心提醒:很多人觉得“不用学完40个”,但实际开发中,选对方法能少写50%代码!比如筛选数据用filter,批量转换用map,累加计算用reduce,找元素用find——精准匹配场景,才是高效开发的关键👇

一、基础遍历类(3个)—— 替代for循环,简洁不冗余

核心作用:遍历数组元素,执行指定操作,告别手动维护索引的麻烦,代码可读性拉满,是日常开发使用频率最高的一类方法。

1. Array.forEach() —— 基础遍历神器

核心特点:遍历数组,对每个元素执行回调函数,无返回值;第一个参数始终是当前元素,还可接收索引、原数组作为可选参数。

避坑提示:无法通过break中断遍历,若需中断,可改用for...of循环;回调函数自身可修改原数组,需谨慎使用。

// 基础用法:遍历并打印每个元素
const array = ['a', 'b', 'c'];
array.forEach((e) => console.log(e));
// 输出结果:a、b、c(依次打印)

// 进阶用法:获取元素+索引
array.forEach((e, index) => {
  console.log(`索引${index}${e}`);
});
// 输出结果:索引0:a、索引1:b、索引2:c

2. Array.keys() —— 遍历数组索引

核心特点:返回一个数组迭代器对象,包含数组所有元素的索引,可通过for...of循环遍历获取所有索引。

const array = ['a', 'b', 'c', 'd'];
const iterator = array.keys();
// 遍历所有索引
for (const key of iterator) {
  console.log(key);
}
// 输出结果:0、1、2、3

3. Array.values() —— 遍历数组值

核心特点:返回一个数组迭代器对象,包含数组所有元素的值,可通过for...of循环遍历获取所有元素,与forEach功能互补。

const array = ['a', 'b', 'c', 'd'];
const iterator = array.values();
// 遍历所有元素值
for (const key of iterator) {
  console.log(key);
}
// 输出结果:a、b、c、d

二、筛选查找类(7个)—— 精准定位元素,告别手动判断

核心作用:根据指定条件,快速查找、筛选数组中的元素或索引,替代繁琐的if-else判断,减少代码冗余,提升开发效率。

4. Array.map() —— 批量转换数组

核心特点:遍历数组,对每个元素执行回调函数,返回一个新数组(长度与原数组一致),不改变原数组,常用于数据格式转换。

避坑提示:若回调函数无返回值,新数组会充满undefined,务必确保每个元素都有返回值。

// 基础用法:将数组中每个元素乘以2
const array = [1, 4, 9, 16];
const map1 = array.map((x) => x * 2);
console.log(map1); // 输出:[2, 8, 18, 32]
console.log(array); // 输出:[1, 4, 9, 16](原数组未改变)

// 实战用法:提取接口数据中的指定字段
const users = [{name: '张三'}, {name: '李四'}, {name: '王五'}];
const userNames = users.map(user => user.name);
console.log(userNames); // 输出:['张三', '李四', '王五']

5. Array.filter() —— 筛选符合条件的元素

核心特点:遍历数组,返回一个新数组,包含所有满足回调函数条件的元素,不改变原数组,常用于数据筛选、去重预处理。

// 基础用法:筛选长度大于6的单词
const words = ['spray', 'elite', 'exuberant', 'destruction', 'present'];
const result = words.filter((word) => word.length > 6);
console.log(result); // 输出:['exuberant', 'destruction', 'present']

// 实战用法:筛选有库存的商品
const products = [
  { name: '手机', price: 5999, inStock: true },
  { name: '电脑', price: 8999, inStock: false },
  { name: '耳机', price: 799, inStock: true }
];
const inStockProducts = products.filter(product => product.inStock);
console.log(inStockProducts); // 输出:[{name: '手机', ...}, {name: '耳机', ...}]

6. Array.find() —— 查找首个符合条件的元素

核心特点:遍历数组,返回首个满足回调函数条件的元素(返回元素本身);若找不到,返回undefined,适合查找单个目标元素。

const array = [5, 12, 8, 130, 44];
// 查找第一个大于10的元素
console.log(array.find((e) => e > 10)); // 输出:12

// 实战用法:查找指定id的用户
const users = [{id: 1, name: '张三'}, {id: 2, name: '李四'}];
const targetUser = users.find(user => user.id === 2);
console.log(targetUser); // 输出:{id: 2, name: '李四'}

7. Array.findIndex() —— 查找首个符合条件的元素索引

核心特点:与find()功能类似,区别在于返回首个满足条件的元素索引;若找不到,返回-1,常用于需要获取元素位置的场景。

const array = [5, 12, 8, 130, 44];
// 查找第一个大于45的元素索引
console.log(array.findIndex((e) => e > 45)); // 输出:3(元素130的索引)

8. Array.indexOf() —— 查找指定元素的索引

核心特点:查找数组中第一个目标元素的索引,若找不到返回-1;可设置第二个参数,指定起始查询位置,适合精确查找已知元素。

const beasts = ['ant', 'bison', 'camel', 'duck', 'bison'];
console.log(beasts.indexOf('bison')); // 输出:1(第一个bison的索引)
console.log(beasts.indexOf('bison', 2)); // 输出:4(从索引2开始查找的第一个bison)
console.log(beasts.indexOf('giraffe')); // 输出:-1(找不到该元素)

9. Array.lastIndexOf() —— 从末尾查找指定元素

核心特点:与indexOf()相反,从数组末尾开始搜索,返回第一个目标元素的索引;若找不到返回-1,适合查找元素最后出现的位置。

const array = ['ant', 'bison', 'camel', 'duck', 'bison'];
console.log(array.lastIndexOf('bison')); // 输出:4(最后一个bison的索引)
console.log(array.lastIndexOf('camel')); // 输出:2(camel的索引)

10. Array.includes() —— 判断元素是否存在

核心特点:判断数组是否包含指定元素,返回true/false,语法简洁,替代传统的indexOf() !== -1,可读性更高。

避坑提示:与some()区别:includes()直接判断元素是否存在,some()判断是否有元素满足自定义条件。

const array = [1, 30, 39, 29, 10, 13];
console.log(array.includes(30)); // 输出:true
console.log(array.includes(100)); // 输出:false

三、数组修改类(8个)—— 增删改查,灵活操作数组

核心作用:直接修改数组(或返回修改后的结果),涵盖元素添加、删除、替换、排序等操作,满足日常数组修改的所有需求,注意区分“是否改变原数组”。

11. Array.push() —— 末尾添加元素

核心特点:往数组末尾添加一个或多个元素,改变原数组,返回添加后数组的新长度。

const array = ['ant', 'duck', 'bison'];
// 添加单个元素
const count = array.push('cows');
console.log(count); // 输出:4(添加后的数组长度)
console.log(array); // 输出:["ant", "duck", "bison", "cows"]

// 批量添加元素
array.push('cat', 'dog');
console.log(array); // 输出:["ant", "duck", "bison", "cows", "cat", "dog"]

12. Array.unshift() —— 开头添加元素

核心特点:往数组开头添加一个或多个元素,改变原数组,返回添加后数组的新长度。

const array = [1, 10, 13];
// 批量添加元素到开头
console.log(array.unshift(4, 5)); // 输出:5(添加后的数组长度)
console.log(array); // 输出:[4, 5, 1, 10, 13]

13. Array.pop() —— 删除末尾元素

核心特点:删除数组最后一个元素,改变原数组,返回被删除的元素;若数组为空,返回undefined。

const array = ['ant', 'duck', 'bison'];
console.log(array.pop()); // 输出:bison(被删除的元素)
console.log(array); // 输出:["ant", "duck"]
// 继续删除
array.pop();
console.log(array); // 输出:["ant"]

14. Array.shift() —— 删除开头元素

核心特点:删除数组第一个元素,改变原数组,返回被删除的元素;若数组为空,返回undefined。

const array = [1, 6, 10, 13];
const firstElement = array.shift();
console.log(firstElement); // 输出:1(被删除的元素)
console.log(array); // 输出:[6, 10, 13]

15. Array.splice() —— 万能修改方法

核心特点:可实现“添加、删除、替换”三种功能,改变原数组;返回被删除的元素组成的数组,若未删除元素则返回空数组。

语法:array.splice(起始索引, 删除个数, 要添加的元素)

const array = ['ant', 'bison', 'camel', 'duck', 'bison'];
// 1. 插入元素(不删除):在索引1处插入'Feb'
array.splice(1, 0, 'Feb');
console.log(array); // 输出:["ant", "Feb", "bison", "camel", "duck", "bison"]

// 2. 替换元素:在索引4处删除1个元素,插入'May'
array.splice(4, 1, 'May');
console.log(array); // 输出:["ant", "Feb", "bison", "camel", "May", "bison"]

// 3. 删除元素:在索引1处删除1个元素
array.splice(1, 1);
console.log(array); // 输出:["ant", "bison", "camel", "May", "bison"]

16. Array.sort() —— 数组排序

核心特点:对数组元素进行排序,改变原数组;默认按字符串Unicode码排序,数字排序需手动传入比较函数。

// 字符串排序(默认)
const array1 = ['ant', 'bison', 'camel', 'duck'];
array1.sort();
console.log(array1); // 输出:["ant", "bison", "camel", "duck"]

// 数字排序(需传入比较函数)
const array2 = [3, 1, 4, 1, 5, 9];
// 升序排序
array2.sort((a, b) => a - b);
console.log(array2); // 输出:[1, 1, 3, 4, 5, 9]
// 降序排序
array2.sort((a, b) => b - a);
console.log(array2); // 输出:[9, 5, 4, 3, 1, 1]

17. Array.reverse() —— 数组反转

核心特点:反转数组中元素的顺序,改变原数组,语法简单,常用于需要倒序排列的场景。

const array = ['ant', 'bison', 'camel', 'duck'];
array.reverse();
console.log(array); // 输出:["duck", "camel", "bison", "ant"]

18. Array.fill() —— 填充数组

核心特点:用指定内容填充数组,改变原数组;可指定填充的起始索引和结束索引(不包含结束索引),不指定则填充整个数组。

const array = [1, 6, 10, 13];
// 填充索引2-4(不包含4)为0
console.log(array.fill(0, 2, 4)); // 输出:[1, 6, 0, 0]
// 填充索引1及以后为5
console.log(array.fill(5, 1)); // 输出:[1, 5, 5, 5]
// 填充整个数组为6
console.log(array.fill(6)); // 输出:[6, 6, 6, 6]

四、数组转换类(7个)—— 格式转换,适配不同场景

核心作用:将数组转换为其他格式(字符串、迭代器、新数组等),或从其他格式转为数组,满足数据展示、传递等不同需求,大多不改变原数组。

19. Array.toString() —— 数组转字符串

核心特点:将数组转为字符串,元素用逗号分隔,不改变原数组;简单直接,但无法自定义分隔符。

const array = [1, 6, 'a', '1a'];
console.log(array.toString()); // 输出:'1,6,a,1a'

20. Array.join() —— 自定义分隔符转字符串

核心特点:将数组用指定符号连接成字符串,不改变原数组;省略分隔符则用逗号分隔,可自定义任意分隔符(空字符串、横线等)。

const array = ['ant', 'duck', 'bison'];
console.log(array.join()); // 输出:ant,duck,bison(默认逗号分隔)
console.log(array.join('')); // 输出:antduckbison(无分隔符)
console.log(array.join('-')); // 输出:ant-duck-bison(横线分隔)

21. Array.concat() —— 合并数组

核心特点:合并两个或多个数组,返回一个新数组,不改变原数组;可替代扩展运算符(...),语法更简洁。

const array1 = ['a', 'b', 'c'];
const array2 = ['d', 'e', 'f'];
const array3 = array1.concat(array2);
console.log(array3); // 输出:['a', 'b', 'c', 'd', 'e', 'f']
console.log(array1); // 输出:['a', 'b', 'c'](原数组未改变)

22. Array.slice() —— 截取数组

核心特点:截取数组中的指定片段,返回一个新数组,不改变原数组;可指定起始索引和结束索引(不包含结束索引),支持负数索引(从末尾开始计数)。

const array = ['ant', 'bison', 'camel', 'duck', 'bison'];
console.log(array.slice(2)); // 输出:["camel", "duck", "bison"](从索引2开始截取)
console.log(array.slice(2, 4)); // 输出:["camel", "duck"](截取索引2-4,不包含4)
console.log(array.slice(-2)); // 输出:["duck", "bison"](从倒数第二个开始截取)
console.log(array.slice()); // 输出:原数组(相当于浅拷贝)

23. Array.from() —— 类数组转数组

核心特点:将类数组(如字符串、DOM集合、JSON)转为真正的数组,返回新数组;最简单的应用是克隆数组,还可传入回调函数处理元素。

// 字符串转数组
console.log(Array.from('foo')); // 输出:["f", "o", "o"]

// 克隆数组并处理元素
console.log(Array.from([1, 2, 3], x => x + x)); // 输出:[2, 4, 6]

// JSON(类数组)转数组
const json = { '0': 'a', '1': 'b', '2': 'c', length: 3 };
console.log(Array.from(json)); // 输出:['a', 'b', 'c']

24. Array.of() —— 创建新数组

核心特点:创建一个新的Array实例,将传入的参数作为数组元素;与new Array()区别:Array.of(3)创建[3],new Array(3)创建长度为3的空数组。

console.log(Array.of('foo', 2, 'bar', true)); // 输出:["foo", 2, "bar", true]
console.log(Array.of()); // 输出:[](空数组)
console.log(Array.of(3)); // 输出:[3]
console.log(new Array(3)); // 输出:[empty × 3](空数组,长度为3)

25. Array.entries() —— 生成键值对迭代器

核心特点:返回一个新的数组迭代器对象,包含数组中每个索引的键/值对([索引, 元素]),可通过for...of循环遍历。

const array = ['a', 'b', 'c', 'd'];
const iterator = array.entries();
for (const key of iterator) {
  console.log(key[0], key[1]);
}
// 输出结果:0 "a"、1 "b"、2 "c"、3 "d"

五、高级操作类(10个)—— 复杂场景必备,提升编码上限

核心作用:应对更复杂的数组操作(累加、扁平化、批量处理等),是前端进阶的关键,学会这些,能轻松处理复杂数据逻辑,面试加分项!

26. Array.reduce() —— 万能累加器

核心特点:从左到右遍历数组,通过回调函数将数组元素“累积”为一个值(数字、对象、数组等),不改变原数组,功能最灵活,可替代sum、map+filter等组合用法。

语法:array.reduce((累加器, 当前值, 索引, 原数组) => {}, 初始值)

// 1. 基础用法:数组求和
const array = [1, 2, 3, 4];
const initialValue = 0;
const sumWithInitial = array.reduce(
  (accumulator, currentValue) => accumulator + currentValue,
  initialValue
);
console.log(sumWithInitial); // 输出:10

// 2. 进阶用法:数组转对象(按id分组)
const users = [{id: 1, name: '张三'}, {id: 2, name: '李四'}];
const userMap = users.reduce((acc, user) => {
  acc[user.id] = user;
  return acc;
}, {});
console.log(userMap); // 输出:{1: {id:1, name:'张三'}, 2: {id:2, name:'李四'}}

27. Array.reduceRight() —— 反向累加

核心特点:与reduce()功能一致,区别在于从数组末尾开始遍历累加,最终返回一个单个值,适合需要反向处理数组的场景。

const array = [2, 3, 4, 5, 6, 7, 8, 9];
const sumWithInitial = array.reduceRight((pv, cv) => {
  console.log(`当前值: ${cv}, 上一次累积值: ${pv}`);
  return 2 * cv;
});
console.log(sumWithInitial); // 输出:36(从9开始反向计算:2*9=18 → 2*8=16 → ...最终结果36)

28. Array.flat() —— 数组扁平化

核心特点:将嵌套数组(多维数组)展开,返回一个新数组,不改变原数组;默认展开1层,可传入数字指定展开层数(Infinity表示无限层)。

// 基础用法:展开1层嵌套数组
const array = [0, 1, 2, [3, 4]];
const result = array.flat();
console.log(result); // 输出:[0, 1, 2, 3, 4]

// 进阶用法:展开多层嵌套数组
const array2 = [0, 1, [2, [3, [4, 5]]]];
console.log(array2.flat(2)); // 输出:[0, 1, 2, 3, [4, 5]](展开2层)
console.log(array2.flat(Infinity)); // 输出:[0, 1, 2, 3, 4, 5](无限层展开)

29. Array.flatMap() —— 映射+扁平化

核心特点:先对数组每个元素执行map()操作,再对结果执行flat()操作(默认展开1层),返回一个新数组,不改变原数组,简化map+flat的组合写法。

const array = [2, 3, 4];
// 对每个元素映射,再扁平化
const result = array.flatMap(num => (num === 2 ? [2, 2] : 1));
console.log(result); // 输出:[2, 2, 1, 1]
// 等价于:array.map(...).flat(1)

30. Array.copyWithin() —— 数组内部拷贝

核心特点:从数组的指定位置拷贝元素,粘贴到数组的另一个指定位置,改变原数组;语法:array.copyWithin(粘贴位置, 拷贝起始位置, 拷贝结束位置)。

const array = ['a', 'b', 'c', 'd', 'e'];
// 从索引3拷贝1个元素(d),粘贴到索引0
console.log(array.copyWithin(0, 3, 4)); // 输出:["d", "b", "c", "d", "e"]
// 从索引3拷贝所有元素(d、e),粘贴到索引1
console.log(array.copyWithin(1, 3)); // 输出:["d", "d", "e", "d", "e"]

31. Array.at() —— 按索引获取元素

核心特点:根据指定索引获取数组中的元素,支持负数索引(从末尾开始计数),语法比array[index]更灵活,可避免负数索引返回undefined的问题。

const array = [5, 12, 8, 130, 44];
console.log(array.at(2)); // 输出:8(索引2对应的元素)
console.log(array.at(-1)); // 输出:44(倒数第一个元素)
console.log(array.at(-3)); // 输出:8(倒数第三个元素)

32. Array.findLast() —— 查找最后一个符合条件的元素

核心特点:与find()功能类似,区别在于从数组末尾开始查找,返回最后一个满足条件的元素;若找不到,返回undefined。

const array = [5, 12, 8, 130, 44];
// 查找最后一个大于45的元素
const found = array.findLast(el => el > 45);
console.log(found); // 输出:130

33. Array.fromAsync() —— 异步类数组转数组

核心特点:将异步类数组(如异步迭代器、Promise数组)转为数组,返回一个新的Promise实例,其履行值是转换后的新数组,适合异步场景。

// 示例:将异步迭代器转为数组
async function getArray() {
  const asyncIterator = (async function* () {
    yield 1;
    yield 2;
    yield 3;
  })();
  const array = await Array.fromAsync(asyncIterator);
  console.log(array); // 输出:[1, 2, 3]
}
getArray();

34. Array.with() —— 替换指定索引元素

核心特点:将数组中指定索引的元素替换为目标值,返回一个新数组,不改变原数组,语法简洁,替代splice()的替换功能(无需改变原数组)。

const arr = [1, 2, 3, 4, 5];
// 将索引2的元素替换为6
console.log(arr.with(2, 6)); // 输出:[1, 2, 6, 4, 5]
console.log(arr); // 输出:[1, 2, 3, 4, 5](原数组未改变)

35. Array.toSorted() —— 排序不改变原数组

核心特点:与sort()功能一致,对数组元素进行排序,但不改变原数组,返回排序后的新数组,避免sort()修改原数组的副作用。

const array = ['ant', 'bison', 'camel', 'duck'];
const sortedMonths = array.toSorted();
console.log(sortedMonths); // 输出:["ant", "bison", "camel", "duck"](排序后)
console.log(array); // 输出:['ant', 'bison', 'camel', 'duck'](原数组未改变)

36. Array.toLocaleString() —— 本地化字符串转换

核心特点:将数组转为本地化字符串,元素用逗号分隔,不改变原数组;与toString()区别:会根据当前地区的语言和格式规则转换(如数字、日期格式)。

const array = [1234, new Date(), 'hello'];
// 本地化转换(根据当前地区格式)
console.log(array.toLocaleString()); 
// 输出示例:1,234, 2026/4/13 10:30:00, hello

37. Array.isArray() —— 判断是否为数组

核心特点:判断一个值是否为数组,返回true/false;注意:若值是TypedArray实例(如Int16Array),始终返回false,是最可靠的数组判断方法。

console.log(Array.isArray([1, 3, 5])); // 输出:true
console.log(Array.isArray('[]')); // 输出:false(字符串不是数组)
console.log(Array.isArray(new Array(5))); // 输出:true(new Array创建的是数组)
console.log(Array.isArray(new Int16Array([15, 33]))); // 输出:false(TypedArray实例)

38. Array.valueOf() —— 返回数组本身

核心特点:返回数组本身,不做任何修改和转换,常用于确保变量是数组类型,避免类型转换错误。

const fruits = ['a', 'b', 'c', 'd'];
console.log(fruits.valueOf()); // 输出:["a", "b", "c", "d"](返回数组本身)

39. Array.some() —— 判断是否有元素满足条件

核心特点:遍历数组,只要有一个元素满足回调函数条件,就返回true;所有元素都不满足,返回false,常用于“判断是否存在符合条件的元素”。

const array = [1, 2, 3, 4, 5];
// 判断数组中是否有偶数
const even = (el) => el % 2 === 0;
console.log(array.some(even)); // 输出:true

40. Array.every() —— 判断所有元素是否满足条件

核心特点:与some()相反,遍历数组,所有元素都满足回调函数条件,才返回true;只要有一个元素不满足,返回false,常用于“校验所有元素是否符合规则”。

const array = [1, 30, 39, 29, 10, 13];
// 判断所有元素是否都小于20
console.log(array.every((item) => item < 20)); // 输出:false(30、39大于20)

二、面试+实战高频避坑总结(必记)

很多开发者用错数组方法,不是不会用,而是没分清“是否改变原数组”“适用场景”,整理了4个高频避坑点,记牢少踩bug!

  • 改变原数组的方法(10个):push、unshift、pop、shift、splice、sort、reverse、fill、copyWithin
  • 不改变原数组的方法(30个):除上述10个外,其余30个均不改变原数组,可放心使用
  • 高频混淆方法:map(返回等长新数组)vs filter(返回筛选后数组)、some(有一个满足即true)vs every(所有满足才true)、slice(不改变原数组)vs splice(改变原数组)
  • 高效组合用法:map+filter(先转换再筛选)、flatMap(映射+扁平化)、reduce(替代sum、数组转对象等)

三、最后说几句掏心窝的话

40个数组方法看似多,但不用死记硬背——日常开发中,高频使用的也就10个左右(forEach、map、filter、push、splice、slice、reduce、find、includes、sort),剩下的可作为储备,用到时翻这篇文章即可。

前端开发的核心是“高效编码”,选对数组方法,能帮你少写冗余代码、减少bug,还能提升代码可读性——这篇文章整理了所有方法的实战代码和避坑点,建议收藏,面试前过一遍,开发时直接复制使用!

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端干货,一起从新手进阶成资深开发者💪

救命!ES6入门到精通,前端小白也能秒上手

谁懂啊家人们!前端入门绕不开ES6,可网上的教程要么太晦涩,要么代码零散,新手看了直接劝退😭

其实ES6根本没那么难!它不是全新的语言,只是JavaScript的“升级补丁”——把ES5里繁琐的写法简化,新增了超多实用功能,学会它,写代码效率直接翻倍,面试也能轻松拿捏!

今天就结合实战代码,把ES6核心知识点拆解得明明白白,从基础到进阶,小白也能跟着敲、跟着会,收藏这一篇就够了,再也不用东拼西凑找资料!

一、先搞懂:ES和JS到底是什么关系?(新手必看)

很多小白刚入门就被“ES6”“JavaScript”搞懵,其实一句话就能说清:

ES 是 ECMAScript 的简写,是 JavaScript 的“核心语法标准”;而 JS 由 3 部分组成:ECMAScript(核心)+ DOM(文档对象模型)+ BOM(浏览器对象模型)。简单说,ES 就是 JS 的“灵魂”,学 JS 必学 ES!

以前我们学的大多是 ES5 语法,而 ES6 及以后的版本,做了大量优化,解决了 ES5 的很多痛点(比如变量提升、代码冗余),现在前端开发几乎全员用 ES6+ 写法,不学真的会被淘汰!

💡 开发工具推荐:VS Code(免费又强大),必装 2 个插件:

  • View in Browser:一键在浏览器中查看效果
  • JavaScript (ES6) code snippets:ES6 代码片段,一键生成,提升编码速度

二、ES6 核心知识点(实操为王,代码可直接复制)

这部分是重点!每个知识点都配了「代码示例+通俗解释」,敲一遍就懂,建议边看边练,记得更牢~

1. 变量声明:let/const 替代 var(彻底解决变量提升坑)

ES5 里我们用 var 声明变量,会有“变量提升”“重复声明”“全局污染”三个大坑,而 ES6 的 let 和 const 直接解决了这些问题,用法超简单!

✨ let 用法(声明局部变量)

// 1. 不允许未定义就使用(避免变量提升)
// console.log(k); // 报错:Uncaught ReferenceError: k is not defined

// 2. 不允许重复声明
let k = 10;
// let k = 101; // 报错:Uncaught SyntaxError: Identifier 'k' has already been declared

// 3. 块级作用域(只在当前代码块有效)
for (let j = 0; j < 5; j++) {
  console.log("循环里的j:" + j); // 正常输出 0-4
}
// console.log("循环外的j:" + j); // 报错:j is not defined

✨ const 用法(声明常量)

// 声明常量,指向的内存地址不能修改
const x = 2;
// x = 991; // 报错:Uncaught TypeError: Assignment to constant variable.

// 注意:如果常量是对象/数组,内部属性可以修改
const obj = { name: "jspang" };
obj.name = "技术胖"; // 正常生效,不报错

小技巧:能⽤ const 就⽤ const,需要修改的变量再⽤ let,避免全局污染!

2. 变量解构赋值:简化赋值,少写冗余代码

以前给多个变量赋值,要写多行代码,ES6 的解构赋值,一行就能搞定,还支持数组、对象、字符串解构,超实用!

✨ 数组解构

// ES5 写法
let a = 0; let b = 1; let c = 2;

// ES6 解构写法(简洁!)
let [a, b, c] = [1, 2, 3];
console.log(a); // 1
console.log(b); // 2
console.log(c); // 3

// 嵌套数组解构
let [a, [b, c], d] = [1, [2, 3], 4];
console.log(a); // 1,b:2,c:3,d:4

✨ 对象解构(最常用,重点记!)

// 核心:变量名必须和对象属性名一致
let { foo, bar } = { foo: 'JSPang', bar: '技术胖' };
console.log(foo + bar); // 输出:JSPang技术胖

// 圆括号用法(当变量已经声明时)
let foo;
({ foo } = { foo: 'JSPang' }); // 必须加圆括号,否则报错
console.log(foo); // JSPang

✨ 解构默认值(避免 undefined)

// 当解构的值不存在时,使用默认值
let [a, b = "JS"] = ['张三'];
console.log(a + b); // 张三JS

// 注意:undefined 和 null 的区别
let [a, b = "JSPang"] = ['技术胖', undefined]; // undefined 用默认值
console.log(a + b); // 技术胖JSPang

let [a, b = "JSPang"] = ['技术胖', null]; // null 不用默认值
console.log(a + b); // 技术胖null

3. 字符串扩展:新增方法,简化字符串操作

ES6 给字符串新增了 includes()、startsWith()、endsWith()、repeat() 等方法,替代了传统的 indexOf(),写法更简洁,语义更清晰!

let str = "https://www.baidu.com/";

// 1. includes():判断是否包含指定字符串(返回true/false)
console.log(str.includes("www")); // true
console.log(str.includes("yyy")); // false

// 2. startsWith():判断是否以指定字符串开头
console.log(str.startsWith("https")); // true
console.log(str.startsWith("baidu", 12)); // 从第12位开始,是否以baidu开头?true

// 3. endsWith():判断是否以指定字符串结尾
console.log(str.endsWith("com")); // false(原字符串结尾是/)
console.log(str.endsWith("www", 11)); // 前11位是否以www结尾?true

// 4. repeat():复制字符串
console.log('jspang|'.repeat(3)); // jspang|jspang|jspang|

4. 数组扩展:新增方法,搞定数组操作

ES6 给数组新增了 Array.from()、Array.of()、find()、filter()、map() 等方法,再也不用手动写循环,效率翻倍!

// 1. Array.from():将类数组(如JSON)转为真正的数组
let json = {
  '0': 'jspang',
  '1': '技术胖',
  '2': '大胖逼逼叨',
  length: 3
};
let arr = Array.from(json);
console.log(arr); // ['jspang', '技术胖', '大胖逼逼叨']

// 2. Array.of():将任意值转为数组
let arr1 = Array.of(3, 4, 5, 6);
console.log(arr1); // [3,4,5,6]

// 3. find():查找数组中第一个满足条件的元素
let arr2 = [1,2,3,4,5,6];
console.log(arr2.find(value => value > 5)); // 6

// 4. filter():过滤数组(返回满足条件的新数组)
let num = [1, 5, 5, 9];
let num1 = num.filter(x => x != 5); // 过滤掉5
console.log(num1); // [1,9]

// 5. map():映射数组(对每个元素做处理,返回新数组)
let arr3 = ['jspang','技术胖','前端教程'];
console.log(arr3.map(x => 'web')); // ['web', 'web', 'web']

5. 扩展运算符(...):万能简化神器

扩展运算符(...)是 ES6 最常用的语法之一,能拆分数组、对象,简化函数参数传递,解决数组浅拷贝问题,用法超灵活!

// 1. 简化函数参数
let add = (...c) => {
  let sum = 0;
  for (const num of c) {
    sum += num;
  }
  return sum;
};
let num = [1, 5, 5, 9];
console.log(add(...num)); // 20(相当于add(1,5,5,9))

// 2. 数组浅拷贝(避免修改新数组影响原数组)
let arr1 = ['www','jspang','com'];
let arr2 = [...arr1]; // 浅拷贝
arr2.push('shengHongYu');
console.log(arr1); // ['www','jspang','com'](原数组不变)
console.log(arr2); // ['www','jspang','com','shengHongYu']

6. 箭头函数:简化函数写法,告别this坑

ES6 的箭头函数,把 function 关键字简化成 =>,代码更简洁,还解决了传统函数中 this 指向混乱的问题,前端面试高频考点!

// ES5 函数写法
function fun1(x, y) {
  return x + y;
}

// ES6 箭头函数写法(简化!)
let fun1 = (x, y) => x + y; // 只有一句执行语句,可省略{}和return
console.log(fun1(2, 6)); // 8

// 带默认值的箭头函数
let fun3 = (x, y = 1) => x + y;
console.log(fun3(4)); // 5(y默认值为1)

// 注意:箭头函数没有自己的this,this指向外层作用域
const obj = {
  name: "技术胖",
  say: () => {
    console.log(this.name); // undefined(this指向window,不是obj)
  }
};
obj.say();

7. Set/WeakSet:数组去重神器

Set 是 ES6 新增的数据结构,和数组类似,但不允许有重复值,天生适合数组去重,还有 add()、delete()、has() 等方法,用法简单!

// 1. 声明Set(自动去重)
let setArr = new Set(['jspang','技术胖','web','jspang']);
console.log(setArr); // Set {"jspang", "技术胖", "web"}(重复值被自动过滤)

// 2. 常用方法
setArr.add('前端职场'); // 新增元素
setArr.delete('jspang'); // 删除元素
console.log(setArr.has('技术胖')); // true(判断是否存在)
setArr.clear(); // 清空Set

// 3. 数组去重(实战常用)
let arr = [1,2,2,3,3,3];
let newArr = [...new Set(arr)];
console.log(newArr); // [1,2,3]

8. Map:比对象更灵活的键值对

Map 和对象类似,都是键值对结构,但 Map 的键可以是任意类型(数字、数组、函数、对象),而对象的键只能是字符串/ Symbol,灵活性更高!

// 声明Map并添加键值对
const map = new Map();
let num = 123;
let arr = [1,2,3];
map.set(num, "数字键");
map.set(arr, "数组键");
map.set('name', "技术胖");

// 常用方法
console.log(map.get(num)); // 数字键(获取值)
console.log(map.has('name')); // true(判断键是否存在)
map.delete(arr); // 删除指定键值对
console.log(map.size); // 2(获取键值对数量)
map.clear(); // 清空Map

9. Promise:解决回调地狱,异步编程神器

以前写异步代码(如请求接口、定时器),会出现“回调嵌套回调”的情况,也就是回调地狱,代码混乱难维护,而 Promise 完美解决了这个问题!

// 实战案例:模拟异步操作(洗菜做饭→吃饭→收拾桌子)
let state = 1; // 1表示成功,0表示失败

// 第一步:洗菜做饭
function step1(resolve, reject) {
  console.log('1.开始-洗菜做饭');
  if (state == 1) {
    resolve('洗菜做饭--完成'); // 成功,执行then
  } else {
    reject('洗菜做饭--出错'); // 失败,执行catch
  }
}

// 第二步:吃饭
function step2(resolve, reject) {
  console.log('2.开始-坐下来吃饭');
  if (state == 1) {
    resolve('坐下来吃饭--完成');
  } else {
    reject('坐下来吃饭--出错');
  }
}

// 第三步:收拾桌子
function step3(resolve, reject) {
  console.log('3.开始-收拾桌子洗碗');
  if (state == 1) {
    resolve('收拾桌子洗碗--完成');
  } else {
    reject('收拾桌子洗碗--出错');
  }
}

// 链式调用,避免回调地狱
new Promise(step1)
  .then(val => {
    console.log(val);
    return new Promise(step2); // 执行下一步
  })
  .then(val => {
    console.log(val);
    return new Promise(step3);
  })
  .then(val => {
    console.log(val);
  })
  .catch(err => {
    console.log(err); // 捕获任意一步的错误
  });

10. Class:面向对象编程,简化构造函数

ES6 引入了 Class(类)的概念,简化了 ES5 中构造函数的写法,让面向对象编程更直观,还支持继承,适合大型项目开发!

// 声明类
class Coder {
  // 构造函数(初始化属性)
  constructor(a, b) {
    this.a = a;
    this.b = b;
  }

  // 类的方法
  name(val) {
    console.log(val);
    return val;
  }

  skill(val) {
    console.log(this.name('jspang') + ':' + 'Skill:' + val);
  }

  add() {
    return this.a + this.b;
  }
}

// 实例化类
let jspang = new Coder(1, 2);
jspang.name('jspang'); // 输出:jspang
jspang.skill('web'); // 输出:jspang:Skill:web
console.log(jspang.add()); // 3

// 类的继承(extends关键字)
class htmler extends Coder {}
let pang = new htmler;
pang.name('技术胖'); // 输出:技术胖(继承了Coder类的方法)

三、ES6 必背面试考点(小白必记)

学会以上知识点,日常开发足够用了,但如果要面试,这几个考点一定要记牢,避免踩坑!

  • let/const 和 var 的区别(变量提升、重复声明、块级作用域)
  • 箭头函数和普通函数的区别(this 指向、arguments、不能作为构造函数)
  • Promise 的三种状态(pending、fulfilled、rejected)及链式调用
  • Set 和 Array 的区别(去重、无索引)
  • Map 和对象的区别(键的类型、遍历方式)

四、最后说几句掏心窝的话

很多小白觉得 ES6 难,其实是因为一开始就啃复杂的概念,忽略了“实操”。ES6 的核心是“简化代码、提高效率”,所有知识点都围绕这个核心,只要多敲代码、多练案例,3-7 天就能掌握核心用法!

这篇文章整理了 ES6 最常用、最核心的知识点,代码可直接复制练习,建议收藏起来,遇到不会的就翻一翻,慢慢就熟练了~

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从入门到精通!💪

React 文件处理:上传、拖放区与对象 URL

任何稍有规模的应用最终都要处理文件。个人资料编辑页要传头像。笔记应用要附加图片。CSV 导入器要拖放区。相册要在客户端生成缩略图。而每一个这样的功能都要从零开始重做一遍——因为 React 里的文件处理同时涉及三套浏览器 API(<input type="file">、Drag and Drop API、URL.createObjectURL),再加上 React 本身的 ref 和 effect 机制——大多数开发者每次都从头把它们拼一遍。

本文将带你过一遍每个 React 应用迟早都会遇到的四个文件处理基本能力:一个不需要在 DOM 里渲染隐藏 <input> 的文件选择器、一个能接收拖入文件的拖放区、一个不会泄漏内存的对象 URL 助手,以及一个按需加载第三方库的脚本标签加载器。每一个我们都会先写出手动实现,让你看清底层在做什么,然后再换成 ReactUse 里专门的 Hook。最后我们会把四个 Hook 组合成一个完整的照片上传组件,集挑选、拖放、预览和按需加载图片库于一身。

1. 不用隐藏 input 也能选文件

手动实现

React 中传统的文件选择写法看起来人畜无害,但暗藏不少坑:

import { useRef, useState } from "react";

function ManualFilePicker() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<FileList | null>(null);

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept="image/*"
        style={{ display: "none" }}
        onChange={(e) => setFiles(e.target.files)}
      />
      <button onClick={() => inputRef.current?.click()}>
        选择图片
      </button>
      {files && <p>已选 {files.length} 个文件</p>}
    </div>
  );
}

它能跑,但只要你想用第二次,缝合的痕迹就藏不住了。隐藏的 <input> 仍然在你的渲染树里,你的样式重置必须考虑它的存在。重置选中状态需要写 inputRef.current.value = ""——这种命令式的副作用,React 的 lint 规则会跳出来警告你。要是你想在异步处理逻辑里 await 用户的选择(比如想在一个处理文件的 async handler 里),你还得自己造一个一次性的 promise。

而且你没法在同一个页面上重复使用同一个组件两次而不让 ref 互相打架。如果用户连续选择同一个文件,第二次 change 事件根本不会触发——这是历代 React 开发者都踩过的著名陷阱。

ReactUse 的方式:useFileDialog

useFileDialog 把整个 input 元素从渲染树里抬出去,交给你一个 [files, open, reset] 的元组:

import { useFileDialog } from "@reactuses/core";

function ImagePicker() {
  const [files, open, reset] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  return (
    <div>
      <button onClick={() => open()}>选择图片</button>
      <button onClick={reset} disabled={!files}>
        重置
      </button>
      {files && (
        <ul>
          {Array.from(files).map((file) => (
            <li key={file.name}>
              {file.name} —— {(file.size / 1024).toFixed(1)} KB
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

手动版本忽略的三件小事,但都很重要:

  1. 没有隐藏 DOM。input 在内存里创建,不在你的渲染树里。组件输出就是按钮本身。
  2. 每次调用都能传参。在 open() 上直接传选项,可以覆盖 Hook 级别的默认值。想让同一个选择器既能选文档又能选图片?调用时再传 accept 就行。
  3. 真正的重置reset() 同时清空 React state 和底层 input,所以同一个文件可以再选一次。

open() 函数还会返回一个 promise,resolve 时给你已选的文件。这让异步流程清爽得多:

const handleUpload = async () => {
  const picked = await open();
  if (!picked) return;
  await uploadAll(Array.from(picked));
};

你不再需要把逻辑切分到 onChange 和按钮的点击处理函数之间。选择器就是一个可以 await 的函数。

2. 拖放文件区

手动实现

拖放是那种"教程里看着简单,生产环境里裂得稀碎"的 API。最直白的版本:

function ManualDropZone({ onFiles }: { onFiles: (f: File[]) => void }) {
  const [over, setOver] = useState(false);

  return (
    <div
      onDragOver={(e) => {
        e.preventDefault();
        setOver(true);
      }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setOver(false);
        onFiles(Array.from(e.dataTransfer.files));
      }}
      style={{
        border: over ? "2px solid blue" : "2px dashed gray",
        padding: 40,
      }}
    >
      把文件拖到这里
    </div>
  );
}

这个版本看似没问题,直到用户拖到子元素上时一切都崩了。光标一踏进子元素,浏览器就在父元素上触发 dragleave,尽管从逻辑上看文件还在区域内。你的边框开始闪烁,over state 变成谎言。要正确修复它,你得用计数器跟踪 dragenterdragleave,每次离开就减一,只有当计数器归零时才认定文件"离开"了。还得记得在 dragover 上调 preventDefault——否则 drop 根本不会触发——并且记住 dataTransfer.filesFileList 而不是数组。

大多数生产环境里的拖放区都做错了。闪烁就是破绽。

ReactUse 的方式:useDropZone

useDropZone 替你跳完了这套计数器舞蹈:

import { useRef } from "react";
import { useDropZone } from "@reactuses/core";

function CsvDropZone() {
  const dropRef = useRef<HTMLDivElement>(null);
  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    const csvs = files.filter((f) => f.name.endsWith(".csv"));
    console.log("拖入的 CSV:", csvs);
  });

  return (
    <div
      ref={dropRef}
      style={{
        border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
        background: isOver ? "#eff6ff" : "transparent",
        padding: 60,
        borderRadius: 12,
        textAlign: "center",
        transition: "all 120ms ease",
      }}
    >
      <p style={{ margin: 0 }}>
        {isOver ? "松开以上传" : "把 CSV 文件拖到这里"}
      </p>
    </div>
  );
}

注意 API 本质上就是 (target, onDrop) => isOver。就这么简单。Hook 内部处理 dragenter/dragover/dragleave/drop,维护进入/离开计数器,让子元素不会破坏高亮,阻止浏览器默认的"在新标签页打开"行为,最后把一个 boolean 还给你来驱动样式。

回调收到的是 File[] | null——null 代表一次空拖放(没错,某些浏览器在用户拖入非文件内容时确实会触发)。你的处理函数可以一次判断后就干净地退出。

3. 用对象 URL 预览文件

手动实现

拿到 File 之后,你通常想把它展示给用户看。浏览器给了你 URL.createObjectURL(blob),可以把任何 blob 变成一个临时 URL,扔进 <img><video> 就能用。代价是:你创建的每一个 URL 都会占内存,必须记得用完调 URL.revokeObjectURL——否则就泄漏了。在 React 里,"用完"通常意味着"组件卸载或文件变化时",这正是 effect 存在的意义,也正是开发者最容易忘记的事情:

function ManualImagePreview({ file }: { file: File | null }) {
  const [url, setUrl] = useState<string>();

  useEffect(() => {
    if (!file) {
      setUrl(undefined);
      return;
    }
    const next = URL.createObjectURL(file);
    setUrl(next);
    return () => URL.revokeObjectURL(next);
  }, [file]);

  if (!url) return null;
  return <img src={url} alt={file?.name} />;
}

这是对的,但是那种"再不小心改一笔就漏的对"。清理函数和 createObjectURL 调用要永远成对存在。多加一个条件 return 或者忘了一个依赖,就会出现一个只有在长会话里才暴露的 bug。

ReactUse 的方式:useObjectUrl

useObjectUrl 是那段 effect 的单行版:

import { useObjectUrl } from "@reactuses/core";

function ImagePreview({ file }: { file: File }) {
  const url = useObjectUrl(file);
  if (!url) return null;
  return (
    <img
      src={url}
      alt={file.name}
      style={{ maxWidth: 200, borderRadius: 8 }}
    />
  );
}

Hook 接管了生命周期。当 file prop 变化时,它会回收旧 URL 并创建新 URL。组件卸载时,它会回收最后一个。你不可能忘记清理,因为你压根就没写过它。

4. 按需加载第三方脚本

手动实现

有时候你想处理的文件,对应的库太大或太冷门,不值得放进主包。图片裁剪库、PDF 解析器、OCR 引擎、视频转码器——它们都是几十 MB 的体积,对那些从不上传文件的用户来说一文不值。你只想在第一个文件到来之后才付出这个代价。

在 React 里手动加载脚本标签本身就是一道菜谱:

function loadScript(src: string): Promise<void> {
  return new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) {
      resolve();
      return;
    }
    const el = document.createElement("script");
    el.src = src;
    el.async = true;
    el.onload = () => resolve();
    el.onerror = () => reject(new Error(`加载失败 ${src}`));
    document.head.appendChild(el);
  });
}

function ManualImageProcessor() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    loadScript("https://cdn.example.com/heavy-image-lib.js")
      .then(() => setReady(true))
      .catch(console.error);
    // 没有清理 —— 一旦加载就保留
  }, []);

  return ready ? <Editor /> : <p>正在加载编辑器...</p>;
}

这覆盖了正常路径,但忽略了乱七八糟的情况:如果两个组件同时请求同一个脚本(竞态条件)怎么办?如果脚本加载失败你想重试怎么办?如果你想在组件消失时主动卸载它怎么办?

ReactUse 的方式:useScriptTag

useScriptTag 给你的就是你本来要写的那些原语,但边界情况都已经处理好:

import { useScriptTag } from "@reactuses/core";

function HeavyImageEditor() {
  const [, status, , unload] = useScriptTag(
    "https://cdn.example.com/image-editor.js",
    () => console.log("编辑器库已就绪"),
    { manual: false, async: true },
  );

  if (status === "loading") return <p>正在下载编辑器...</p>;
  if (status === "error") return <p>编辑器加载失败</p>;
  if (status !== "ready") return null;

  return <ImageEditorComponent onClose={unload} />;
}

四样白送的好处:

  1. 单例行为。同一个脚本 URL 被请求两次,Hook 会去重——没有竞态,没有重复加载。
  2. 状态机idle/loading/ready/error 让你在每一步都能渲染恰当的内容。
  3. 手动控制。设置 manual: true,脚本要等你显式调用返回的 load() 才会加载——非常适合"首次交互时再加载"的模式。
  4. 卸载。调用 unload() 可以把 script 标签从 document 里移除。如果你想在用户关闭编辑器后把那个庞大的库从内存里清掉,这就派上用场了。

全部组合:照片上传组件

现在我们把四个 Hook 组合成一个组件:一个允许用户挑选或拖入图片、即时预览、并在第一次需要时延迟加载一个假想的客户端图片缩放库的照片上传组件。

import { useRef, useState } from "react";
import {
  useFileDialog,
  useDropZone,
  useObjectUrl,
  useScriptTag,
} from "@reactuses/core";

interface QueuedImage {
  file: File;
  id: string;
}

function Thumbnail({ image }: { image: QueuedImage }) {
  const url = useObjectUrl(image.file);
  return (
    <figure
      style={{
        margin: 0,
        padding: 8,
        background: "#f8fafc",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {url && (
        <img
          src={url}
          alt={image.file.name}
          style={{
            width: 120,
            height: 120,
            objectFit: "cover",
            borderRadius: 4,
          }}
        />
      )}
      <figcaption
        style={{
          marginTop: 6,
          fontSize: 12,
          maxWidth: 120,
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
        }}
      >
        {image.file.name}
      </figcaption>
    </figure>
  );
}

function PhotoUploadWidget() {
  const [queue, setQueue] = useState<QueuedImage[]>([]);
  const [shouldLoadResizer, setShouldLoadResizer] = useState(false);
  const dropRef = useRef<HTMLDivElement>(null);

  const [, openPicker, resetPicker] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    addFiles(files);
  });

  const [, resizerStatus] = useScriptTag(
    "https://cdn.example.com/image-resize.js",
    () => console.log("缩放器已就绪"),
    { manual: !shouldLoadResizer },
  );

  const addFiles = (files: File[]) => {
    const newImages = files
      .filter((f) => f.type.startsWith("image/"))
      .map((file) => ({
        file,
        id: `${file.name}-${file.lastModified}-${Math.random()}`,
      }));
    setQueue((prev) => [...prev, ...newImages]);
    if (newImages.length > 0) setShouldLoadResizer(true);
  };

  const handlePick = async () => {
    const picked = await openPicker();
    if (picked) addFiles(Array.from(picked));
  };

  const clearAll = () => {
    setQueue([]);
    resetPicker();
  };

  return (
    <div style={{ maxWidth: 720, fontFamily: "system-ui, sans-serif" }}>
      <div
        ref={dropRef}
        style={{
          border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
          background: isOver ? "#eff6ff" : "#ffffff",
          padding: 48,
          borderRadius: 16,
          textAlign: "center",
          transition: "all 120ms ease",
        }}
      >
        <p style={{ marginTop: 0, fontSize: 18 }}>
          {isOver ? "松开即可上传" : "把照片拖到这里"}
        </p>
        <button
          onClick={handlePick}
          style={{
            padding: "8px 16px",
            borderRadius: 8,
            border: "1px solid #3b82f6",
            background: "#3b82f6",
            color: "white",
            cursor: "pointer",
          }}
        >
          或从设备中选择
        </button>
      </div>

      <div
        style={{
          marginTop: 16,
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <span style={{ fontSize: 14, color: "#64748b" }}>
          已排队 {queue.length} 张图片
          {shouldLoadResizer && ` —— 缩放器:${resizerStatus}`}
        </span>
        {queue.length > 0 && (
          <button
            onClick={clearAll}
            style={{
              padding: "6px 12px",
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              background: "white",
              cursor: "pointer",
            }}
          >
            全部清空
          </button>
        )}
      </div>

      {queue.length > 0 && (
        <div
          style={{
            marginTop: 16,
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
            gap: 12,
          }}
        >
          {queue.map((image) => (
            <Thumbnail key={image.id} image={image} />
          ))}
        </div>
      )}
    </div>
  );
}

四个 Hook,四个职责,互不重叠:

  • useFileDialog 负责"点击挑选"流程,并提供可 await 的 promise
  • useDropZone 处理拖放,并解决子元素引发的边框闪烁
  • useObjectUrl 为每个缩略图生成并回收预览 URL,绑定到组件生命周期
  • useScriptTag 只在第一张图片到来后延迟加载缩放库,并且整个会话只加载一次

组合很自然,因为每个 Hook 只做一件事。Hook 之间不共享 ref,effect 不会级联。你最终发布的组件大概 100 行,大部分是标签和样式,那些棘手的浏览器底层活计被藏在已经经过测试和 SSR 加固的 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useFileDialog —— 打开文件选择器,无需在 DOM 中渲染隐藏的 input
  • useDropZone —— 跟踪文件拖入元素的状态,正确处理子元素事件
  • useObjectUrl —— 为 File 和 Blob 创建并自动回收 URL
  • useScriptTag —— 动态加载外部脚本,带状态跟踪和卸载支持
  • useEventListener —— 声明式地附加事件监听器,可用于自定义上传进度事件
  • useSupported —— 响应式地检查浏览器是否支持某个 API

ReactUse 提供了 100+ 个 React Hook。全部探索 →

JS手撕:手写Koa中间件与Promise核心特性

在前端开发中,Koa框架的洋葱模型、Promise的各类静态方法以及异步流程控制,是每个开发者必须掌握的核心知识点。它们看似独立,实则底层逻辑高度关联——都是为了解决异步代码的可读性、可维护性问题。本文将从实战出发,手把手拆解核心代码,用“通俗解释+专业剖析”的方式,让你不仅能看懂手写代码,更能理解背后的设计思想。

一、手写Koa中间件调用(洋葱模型):理解“层层嵌套,反向回流”

用过Koa的同学都知道,它的中间件执行机制被称为“洋葱模型”——就像剥洋葱一样,中间件会从外到内依次执行,执行到最内层后,再从内到外反向执行。这种机制的核心价值的是:让中间件既能处理请求进入时的逻辑(如日志记录、权限校验),也能处理响应返回时的逻辑(如统一异常处理、响应格式化)。

1.1 核心代码实现(可直接运行)

function koa() {
  // 存放所有通过 app.use() 注册的中间件函数
  const middlewares = []
  const app = async (ctx) => {
    // 从第0个中间件开始执行调度
    await dispatch(0, ctx)
  }

  // 注册中间件的方法:将中间件存入数组
  app.use = (middleware) => {
    middlewares.push(middleware)
  }

  // 核心调度函数:递归执行中间件
  const dispatch = async (index, ctx) => {
    // 终止条件:所有中间件都执行完毕,直接返回
    if (index === middlewares.length) return

    // 获取当前索引对应的中间件
    const middleware = middlewares[index]
    // 执行中间件:第二个参数是next函数,调用next()即执行下一个中间件
    await middleware(ctx, () => dispatch(index + 1, ctx))
  }
  return app
}

// 1. 创建 app 实例
const app = koa()

// 2. 注册 3 个中间件(模拟真实开发中的分层逻辑)
app.use(async (ctx, next) => {
  console.log('【中间件 1 开始】—— 日志记录:请求进入')
  console.log('请求URL:', ctx.req.url)
  
  await next() // 放行,执行下一个中间件(核心:交出执行权)
  
  console.log('【中间件 1 结束】—— 日志记录:响应返回')
  console.log(6)
})

app.use(async (ctx, next) => {
  console.log('  【中间件 2 开始】—— 权限校验:通过')
  console.log(2)
  
  await next() // 放行,执行下一个中间件
  
  console.log(5)
  console.log('  【中间件 2 结束】—— 响应处理:添加响应头')
})

app.use(async (ctx, next) => {
  console.log('    【中间件 3 开始】—— 业务逻辑:处理请求')
  console.log(3)
  
  await next() // 没有更多中间件,直接返回(执行终止)
  
  console.log(4)
  console.log('    【中间件 3 结束】—— 业务逻辑:返回结果')
})

// 3. 模拟请求上下文(ctx:Koa的核心,封装请求和响应信息)
const ctx = {
  req: { url: '/' },
  res: {}
}

// 4. 启动执行
app(ctx).then(() => {
  console.log('\n所有中间件执行完毕!')
})

1.2 核心原理拆解(通俗+专业)

通俗理解

把每个中间件想象成一个“关卡”,请求要经过所有关卡才能到达最核心的业务逻辑(中间件3);处理完业务逻辑后,响应要再反向经过所有关卡,才能返回给客户端。比如:

请求进入 → 中间件1(记录日志)→ 中间件2(权限校验)→ 中间件3(处理业务)→ 中间件2(处理响应)→ 中间件1(记录响应日志)→ 响应返回

专业剖析

  • 中间件存储:用数组middlewares存储所有通过app.use()注册的中间件,保证执行顺序与注册顺序一致。

  • 调度函数dispatch:递归实现中间件的依次执行,index参数控制当前执行的中间件索引,当index等于中间件数组长度时,递归终止(最内层执行完毕)。

  • next函数:本质是dispatch(index+1, ctx)的封装,调用next()就相当于“交出执行权”,让下一个中间件执行;await next()则保证“下一个中间件执行完毕后,再继续执行当前中间件的后续逻辑”,这是洋葱模型反向回流的关键。

  • ctx上下文:统一封装请求(req)和响应(res)信息,所有中间件共享同一个ctx,实现数据传递(比如中间件1存储的用户信息,中间件3可以直接使用)。

1.3 执行结果与验证

运行上述代码,控制台输出如下(完美匹配洋葱模型):

【中间件 1 开始】—— 日志记录:请求进入
请求URL: /
2
  【中间件 2 开始】—— 权限校验:通过
3
    【中间件 3 开始】—— 业务逻辑:处理请求
4
    【中间件 3 结束】—— 业务逻辑:返回结果
5
  【中间件 2 结束】—— 响应处理:添加响应头
6
【中间件 1 结束】—— 日志记录:响应返回

所有中间件执行完毕!

二、手写简易co模块:自动执行Generator函数(告别手动.next())

在async/await出现之前,Generator函数是解决异步回调地狱的重要方案,但它有一个痛点:需要手动调用.next()方法才能逐步执行,非常繁琐。co模块的核心作用就是“自动执行Generator函数”,它会自动遍历Generator的迭代器,直到执行完毕。

核心逻辑:Generator函数中,yield后面通常跟Promise(异步操作),co模块会等待Promise完成,将结果传给Generator,再自动执行下一步,直到迭代结束。

2.1 核心代码实现(可直接运行)

// 手写co模块核心函数:自动执行带Promise的Generator
function run(generatorFunc) {
  // 1. 生成Generator迭代器(Generator函数执行后返回迭代器)
  let it = generatorFunc()

  // 2. 第一次启动Generator,获取第一个yield的结果(通常是Promise)
  let result = it.next()

  // 3. 用Promise包装自动执行流程,最终返回一个Promise(方便外部使用.then())
  return new Promise((resolve, reject) => {
    // 递归函数:自动执行下一个yield
    const next = function (result) {
      // 终止条件:Generator执行完毕(done为true),resolve最终返回值
      if (result.done) {
        resolve(result.value)
        return
      }

      // 核心:result.value是yield后面的Promise,等待它完成
      result.value
        .then((res) => {
          // Promise成功:将结果传给Generator(it.next(res)),并继续执行下一步
          let nextResult = it.next(res)
          next(nextResult)
        })
        .catch((err) => reject(err)) // 捕获异步错误,终止执行
    }

    // 启动自动执行流程
    next(result)
  })
}

// 模拟异步请求(真实开发中可能是接口请求、文件读取等)
function fetchData(data) {
  return new Promise(resolve => {
    setTimeout(() => resolve(data), 500) // 延迟500ms模拟异步
  })
}

// 定义一个Generator函数(包含多个异步操作)
function* gen() {
  console.log('开始执行Generator,发起第一个异步请求')
  
  let res1 = yield fetchData('数据1') // 第一个异步请求,等待完成后赋值给res1
  console.log('第一个请求结果:', res1)
  
  let res2 = yield fetchData('数据2') // 第二个异步请求,依赖第一个请求完成
  console.log('第二个请求结果:', res2)
  
  let res3 = yield fetchData('数据3') // 第三个异步请求,依赖第二个请求完成
  console.log('第三个请求结果:', res3)

  return '全部异步请求完成' // Generator最终返回值
}

// 自动执行Generator函数(无需手动调用.next())
run(gen).then(finalVal => {
  console.log('Generator执行完毕,最终返回:', finalVal)
})

2.2 核心原理拆解(通俗+专业)

通俗理解

把Generator函数想象成一个“异步任务清单”,co模块(这里的run函数)就是一个“自动执行者”:它会先拿出清单上的第一个任务(第一个yield),等待任务完成后,把结果记下来,再自动拿出下一个任务,直到所有任务都完成,最后把清单的最终结果返回给你。

专业剖析

  • Generator迭代器:Generator函数(function*)执行后会返回一个迭代器(it),迭代器的next()方法会返回一个对象{ value: ..., done: ... },value是yield后面的值(这里是Promise),done表示Generator是否执行完毕。

  • 自动迭代逻辑:next函数是核心,它接收上一个yield的执行结果,调用it.next(res)将结果传入Generator(赋值给res1、res2等),同时获取下一个yield的结果,递归执行自身,实现自动迭代。

  • Promise封装:run函数最终返回一个Promise,这样外部可以通过.then()获取Generator的最终返回值,也能通过.catch()捕获异步错误,符合异步编程的统一规范。

  • 异步依赖处理:由于每次yield的Promise完成后才会执行下一个yield,因此可以轻松实现异步操作的顺序执行(比如先获取数据1,再用数据1获取数据2)。

2.3 执行结果与验证

运行代码后,控制台每隔500ms输出一次结果,最终输出如下:

开始执行Generator,发起第一个异步请求
第一个请求结果: 数据1
第二个请求结果: 数据2
第三个请求结果: 数据3
Generator执行完毕,最终返回: 全部异步请求完成

三、异步串行/并行加法:理解异步流程控制的核心

异步加法看似简单,却能完美体现“串行”和“并行”两种异步流程控制的差异:

  • 串行:多个异步操作按顺序执行,前一个操作完成后,再执行下一个(适合有依赖关系的场景);

  • 并行:多个异步操作同时执行,无需等待前一个完成(适合无依赖关系的场景,能提升效率)。

我们先实现一个基础的异步加法函数,再基于它分别实现串行和并行求和。

3.1 基础准备:异步加法函数与Promise包装

// 1. 基础异步加法函数(基于回调函数,模拟真实异步场景)
// 接收 a, b 两个数字,callback 是回调函数(错误优先原则:第一个参数是错误,第二个是结果)
const asyncAdd = (a, b, callback) => {
  // 模拟异步操作(延迟 500ms,比如接口请求、计算密集型操作)
  setTimeout(() => {
    // 这里简化处理,不模拟错误,直接返回结果 a+b
    callback(null, a + b);
  }, 500);
};

// 2. 包装函数:将 callback 风格的异步方法,转成 Promise 风格
// 目的:方便在 async/await、Promise 链式调用中使用(更符合现代异步编程规范)
const promiseAdd = (a, b, index) => {
  console.log(`第 ${index} 次计算,参数 ${a}, ${b}`);
  return new Promise((resolve, reject) => {
    // 调用原来的 callback 异步加法
    asyncAdd(a, b, (err, res) => {
      if (err) {
        reject(err); // 出错时,抛出错误
      } else {
        resolve(res); // 成功时,返回计算结果
      }
    });
  });
};

3.2 方式一:异步串行求和(reduce实现)

核心逻辑:用数组的reduce方法,将前一次的计算结果(Promise)作为下一次计算的输入,实现“一步一步按顺序执行”。

// 串行求和:reduce + Promise 链式,实现异步累加
const add1 = (arr) => {
  // reduce参数说明:
  // acc:上一次的Promise结果(累加和),初始值为0
  // val:当前数组要加的数
  // index:当前索引(用于打印日志)
  return arr.reduce((acc, val, index) => {
    // Promise.resolve(acc):确保acc始终是Promise(兼容初始值0)
    return Promise.resolve(acc).then((value) => {
      // 等待上一步累加完成,再和当前值 val 相加
      return promiseAdd(value, val, index + 1); // index+1 是因为索引从0开始
    });
  }, 0); // 初始值 acc = 0(第一次计算:0 + arr[0])
};

// 执行串行求和:1+2+3+...+9,一步一步按顺序执行
add1([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步串行加法结果", sum)
);

3.3 方式二:异步并行求和(递归+Promise.all实现)

核心逻辑:采用“二叉树式”分组,将数组两两分组,每组同时执行加法(并行),再将每组的结果递归分组,直到得到最终总和。这种方式比“所有数字同时相加”更高效(避免过多并发任务)。

// 并行求和:递归 + Promise.all 实现并行归约求和(二叉树式计算)
async function parallelSum(arr) {
  // 递归终止条件:数组只剩一个数,直接返回(无需再计算)
  if (arr.length === 1) return arr[0];

  const tasks = []; // 存放所有并行执行的异步任务

  // 步长为2,将数组两两分组:[1,2] [3,4] [5,6] ... [9,0](奇数长度时,最后一个补0)
  for (let i = 0; i < arr.length; i += 2) {
    // arr[i+1] || 0:处理奇数长度数组(比如最后一个元素9,没有i+1,补0)
    tasks.push(promiseAdd(arr[i], arr[i + 1] || 0));
  }

  // Promise.all:并行执行所有任务,等待所有任务完成后,返回结果数组
  const results = await Promise.all(tasks);

  // 递归:将上一轮的计算结果,继续两两分组并行计算
  return parallelSum(results);
}

// 执行并行求和:速度比串行快(无需等待上一步完成)
parallelSum([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步并行加法结果", sum)
);

3.4 核心差异对比(通俗+专业)

对比维度 异步串行 异步并行
执行顺序 按顺序执行,前一个完成再执行下一个 所有任务同时执行,无顺序依赖
执行时间 总时间 = 所有任务时间之和(本例:9*500ms=4500ms) 总时间 = 最长任务时间 * 递归次数(本例:3*500ms=1500ms)
适用场景 任务有依赖(比如下一个任务需要上一个任务的结果) 任务无依赖(比如多个独立的接口请求、计算任务)
实现核心 Promise链式调用 + reduce Promise.all + 递归归约

3.5 执行结果与验证

串行求和会依次打印每次计算的参数,总耗时约4500ms;并行求和会同时打印多组计算参数,总耗时约1500ms,最终两者的求和结果均为45。

四、手写Promise核心静态方法:理解Promise的底层逻辑

Promise的静态方法(all、race、allSettled、any)是异步流程控制的常用工具,它们的底层逻辑都基于Promise的核心特性——状态不可逆(pending→fulfilled/rejected)。下面我们逐个手写实现,拆解它们的核心规则。

4.1 手写Promise.all:“全部成功才成功,一个失败就失败”

核心规则:接收一个Promise数组,只有所有Promise都成功(fulfilled),才返回所有结果的数组;只要有一个Promise失败(rejected),就立即返回该失败原因,终止执行。

// 手写实现 Promise.all 核心方法
function myPromiseAll(promiseArr) {
  // 返回一个新的 Promise(外部可以通过.then()/.catch()获取结果)
  return new Promise((resolve, reject) => {
    const len = promiseArr.length;    // 传入的 Promise 数组长度
    const result = [];                // 存放所有成功结果的数组(按原数组顺序)
    let count = 0;                    // 记录已经成功完成的任务数量

    // 边界处理:如果传入空数组,直接resolve空结果
    if (!len) {
      resolve(result);
      return; // 必须加return,防止后续代码继续执行
    };

    // 遍历所有promise(用entries()获取索引,保证结果顺序与输入一致)
    for (const [i, p] of promiseArr.entries()) {
      // Promise.resolve(p):包装非Promise值(比如普通数字、字符串),统一处理成Promise
      Promise.resolve(p).then(
        (value) => {
          // 成功:按原数组索引存入结果(确保顺序正确)
          result[i] = value;
          count++; // 成功数 +1

          // 所有任务都成功 → 调用resolve,返回结果数组
          if (count === len) {
            resolve(result);
          }
        },
        (reason) => {
          // 任何一个任务失败 → 立刻reject,终止所有任务(失败优先)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败'));

// 测试1:全部成功
myPromiseAll([p1, p2]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));
// 测试2:有一个失败
myPromiseAll([p1, p3]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));

4.2 手写Promise.race:“谁先完成,就返回谁”

核心规则:接收一个Promise数组,不管是成功还是失败,只要有一个Promise先完成(状态变为fulfilled或rejected),就立即返回该结果,其他任务继续执行,但结果会被忽略。

// 规则:谁最先完成(成功/失败),就返回谁
function myPromiseRace(promiseArr) {
  // 返回一个新的 Promise
  return new Promise((resolve, reject) => {
    // 遍历所有传入的promise
    for (const p of promiseArr) {
      // 统一包装成Promise(处理普通值)
      Promise.resolve(p).then(
        (value) => {
          // 任何一个成功 → 立刻resolve(状态不可逆,后续结果不会覆盖)
          resolve(value);
        },
        (reason) => {
          // 任何一个失败 → 立刻reject(状态不可逆,后续结果不会覆盖)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例:模拟快慢不同的Promise
const fastPromise = new Promise((resolve) => setTimeout(() => resolve('快的Promise'), 100));
const slowPromise = new Promise((resolve) => setTimeout(() => resolve('慢的Promise'), 1000));
const errorPromise = new Promise((_, reject) => setTimeout(() => reject('失败的Promise'), 500));

// 测试1:成功的Promise更快
myPromiseRace([fastPromise, slowPromise]).then(res => console.log('race结果:', res));
// 测试2:失败的Promise更快
myPromiseRace([errorPromise, fastPromise]).then(res => console.log('race结果:', res)).catch(err => console.log('race失败:', err));

4.3 手写Promise.allSettled:“无论成败,都返回所有结果”

核心规则:接收一个Promise数组,等待所有Promise都完成(无论成功还是失败),返回一个包含所有任务结果的数组,每个结果对象包含状态(fulfilled/rejected)和对应的值/原因,不会因为某个任务失败而终止。

// 规则:无论成功/失败,都返回所有结果,不会中断
Promise.allSettled = function (promiseArr) {
  return new Promise(function (resolve) {
    const len = promiseArr.length;  // 数组长度
    const result = [];              // 存放所有结果
    let count = 0;                  // 已完成的promise数量

    // 空数组直接返回空
    if (!len) {
      resolve(result);
      return; // 必须加return!
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // 成功:按标准格式存入(status为fulfilled,value为成功结果)
          result[i] = { status: "fulfilled", value };
          count++;
          if (count === len) { // 全部完成就resolve
            resolve(result);
          }
        },
        (reason) => {
          // 失败:按标准格式存入(status为rejected,reason为失败原因)
          result[i] = { status: "rejected", reason };
          count++;
          if (count === len) { // 失败也要计数,确保所有任务都完成
            resolve(result);
          }
        }
      );
    }
  });
};

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.reject(new Error('失败'));
Promise.allSettled([p1, p2]).then(res => {
  console.log('allSettled结果:', res);
  // 输出:[ {status: 'fulfilled', value: 1}, {status: 'rejected', reason: Error} ]
});

4.4 手写Promise.any:“只要有一个成功就成功,全部失败才失败”

核心规则:接收一个Promise数组,只要有一个Promise成功(fulfilled),就立即返回该成功结果;如果所有Promise都失败(rejected),则抛出一个AggregateError(包含所有失败原因)。

注意:与Promise.race的区别——any只关注成功,只有全部失败才会失败;race不管成功失败,谁先完成就返回谁。

// 规则:
// 1. 只要有一个成功,就返回这个成功结果
// 2. 全部失败 → 抛出 AggregateError 错误
function myPromiseAny(promiseArr) {
  return new Promise(function (resolve, reject) {
    const len = promiseArr.length;
    const errors = []; // 收集所有失败原因(全部失败时使用)
    let count = 0;

    // 空数组:标准规定返回 AggregateError
    if (len === 0) {
      return reject(new AggregateError([], "All promises were rejected"));
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // ✅ 任何一个成功 → 直接返回成功结果(状态不可逆)
          resolve(value);
        },
        (reason) => {
          // ❌ 失败:记录错误,计数+1
          errors[i] = reason;
          count++;

          // 全部都失败了 → 抛出 AggregateError(包含所有失败原因)
          if (count === len) {
            reject(new AggregateError(errors, "All promises were rejected"));
          }
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.reject(new Error('失败1'));
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败2'));

// 测试1:有一个成功
myPromiseAny([p1, p2, p3]).then(res => console.log('any成功:', res)); // 输出2
// 测试2:全部失败
myPromiseAny([p1, p3]).then(res => console.log('any成功:', res)).catch(err => {
  console.log('any失败:', err.message); // 输出"All promises were rejected"
  console.log('所有失败原因:', err.errors); // 输出[Error('失败1'), Error('失败2')]
});

五、Promise并发控制(带超时、重传、失败收集):实战级封装

在真实开发中,我们经常会遇到“大量异步任务需要并发执行,但不能无限制并发”(比如同时调用100个接口,会导致服务器压力过大),同时还需要处理“任务超时”“失败重试”“收集失败任务”等需求。下面我们封装一个实战级的Promise并发控制器,满足这些核心需求。

5.1 核心代码实现(可直接复用)

/**
 * Promise 并发控制器(带 并发限制 + 超时 + 自动重试)
 * @param {Array} tasks - 任务数组,每一项是 () => Promise 的函数(必须是函数,确保懒执行)
 * @param {Object} options - 配置参数(均有默认值)
 * @param {number} options.limit - 最大并发数,默认5
 * @param {number} options.timeout - 单个任务超时时间,默认3000ms
 * @param {number} options.maxRetries - 最大重试次数,默认3次
 * @returns {Promise} 最终返回【所有失败的任务列表】(方便后续重试或排查问题)
 */
function promiseConcurrencyControl(tasks, {
  limit = 5,
  timeout = 3000,
  maxRetries = 3
} = {}) {
  return new Promise((resolve) => {
    const results = [];          // 存储所有任务最终结果(成功/失败)
    const failedTasks = [];      // 存储【最终彻底失败】的任务(重试后仍失败)
    let taskIndex = 0;           // 下一个要执行的任务下标(控制任务顺序)
    let runningCount = 0;        // 当前正在运行的任务数量(控制并发数)

    // ==========================================
    // 核心函数:启动下一个任务(调度器)
    // 只要有任务未执行、且当前并发数未达上限,就持续启动任务
    // ==========================================
    function runNextTask() {
      // 终止条件:所有任务都执行完毕(taskIndex >= 任务总数),且没有正在运行的任务
      if (taskIndex >= tasks.length && runningCount === 0) {
        return resolve(failedTasks); // 返回最终失败的任务列表
      }

      // 循环启动任务:只要还有任务,且并发数未达上限
      while (taskIndex < tasks.length && runningCount < limit) {
        const currentIndex = taskIndex++; // 取当前任务下标(避免并发时下标混乱)
        const task = tasks[currentIndex]; // 取出当前任务(函数)
        runningCount++;                   // 正在运行的任务数 +1

        // 执行任务(带超时、重试逻辑)
        executeTaskWithRetry(task, currentIndex, 0);
      }
    }

    // ==========================================
    // 带【超时】和【自动重试】的任务执行器
    // @param task - 任务函数 () => Promise
    // @param index - 任务下标(用于定位任务)
    // @param retryCount - 当前已经重试的次数(初始为0)
    // ==========================================
    function executeTaskWithRetry(task, index, retryCount) {
      // 1. 创建超时Promise:超过指定时间未完成,直接reject(超时错误)
      const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`Task ${index} timed out after ${timeout}ms`));
        }, timeout);
      });

      // 2. 竞速:任务执行 和 超时监控 谁先完成
      Promise.race([
        task(),                  // 执行真实任务(懒执行,避免提前启动)
        timeoutPromise           // 超时监控
      ])
      .then(result => {
        // ======================
        // 任务执行成功
        // ======================
        results[index] = {
          success: true,
          result,
          retries: retryCount // 记录重试次数(0表示未重试)
        };
        runningCount--; // 正在运行的任务数 -1
        runNextTask();   // 启动下一个任务(维持并发数)
      })
      .catch(error => {
        // ======================
        // 任务失败 / 超时
        // ======================
        if (retryCount < maxRetries) {
          // 还有重试次数 → 立即重试,重试次数+1
          console.log(`Task ${index} 失败(原因:${error.message}),重试 ${retryCount + 1}/${maxRetries}`);
          executeTaskWithRetry(task, index, retryCount + 1);
        } else {
          // 重试次数用完 → 标记为彻底失败,存入失败列表
          const failureInfo = {
            taskIndex: index,       // 任务下标(方便定位)
            error: error.message,   // 失败原因
            retries: maxRetries     // 已重试次数
          };
          failedTasks.push(failureInfo);

          results[index] = {
            success: false,
            ...failureInfo
          };

          runningCount--;
          runNextTask(); // 继续启动下一个任务
        }
      });
    }

    // 启动并发控制(入口)
    runNextTask();
  });
}

// ------------------------------
// 测试工具函数(模拟真实场景中的异步任务)
// ------------------------------

/**
 * 创建测试任务(随机成功/失败,可模拟接口请求)
 * @param {number} id - 任务ID(用于区分)
 * @param {number} successProbability - 成功率(0~1,默认0.7)
 * @param {number} delay - 任务执行延迟(默认1000ms)
 */
function createTestTask(id, successProbability = 0.7, delay = 1000) {
  return () => new Promise((resolve, reject) => {
    setTimeout(() => {
      // 随机成功/失败(模拟接口请求的不确定性)
      if (Math.random() < successProbability) {
        resolve(`Task ${id} 成功`);
      } else {
        reject(new Error(`Task ${id} 执行失败`));
      }
    }, delay);
  });
}

// 生成 10 个测试任务(成功率70%,延迟800ms)
const testTasks = Array.from({ length: 10 }, (_, i) =>
  createTestTask(i + 1, 0.7, 800)
);

// 启动并发控制(配置:最大并发3个,超时1500ms,最多重试2次)
promiseConcurrencyControl(testTasks, {
  limit: 3,         // 最多同时运行3个任务
  timeout: 1500,    // 单个任务超过1.5秒超时
  maxRetries: 2     // 每个任务最多重试2次
})
.then(failedTasks => {
  console.log('\n=== 全部执行完成 ==');
  console.log('最终失败的任务:', failedTasks);
});

5.2 核心功能拆解(实战重点)

  • 并发限制:通过runningCount(当前运行任务数)和limit(最大并发数)控制,只有runningCount < limit时,才会启动新任务,避免并发过多导致的性能问题。

  • 任务调度:runNextTask函数作为调度器,循环启动任务,确保并发数维持在limit以内,同时处理任务执行完毕后的“补位”(启动下一个任务)。

  • 超时控制:通过Promise.race将任务执行与超时监控绑定,超过指定时间未完成的任务,直接视为失败,进入重试逻辑。

  • 自动重试:任务失败后,若重试次数未用完,立即重试,重试次数用完后,标记为彻底失败,存入失败列表。

  • 失败收集:最终返回所有彻底失败的任务列表,包含任务下标、失败原因和重试次数,方便后续排查问题或重新重试。

  • 懒执行:任务数组中的每一项是一个返回Promise的函数,而非直接执行的Promise,确保任务只有在被调度时才会启动,避免提前执行导致的并发混乱。

5.3 应用场景

该并发控制器可直接用于真实开发中的场景,比如:

  • 批量接口请求(比如批量获取用户信息、批量上传文件);

  • 批量处理异步任务(比如批量处理文件、批量发送消息);

  • 需要容错的异步场景(比如部分任务失败后,无需终止全部,只需收集失败任务后续处理)。

六、总结:核心知识点串联

本文讲解的所有内容,核心都是围绕“异步流程控制”展开:

  1. Koa洋葱模型:通过递归调度中间件,实现“请求进入→业务处理→响应返回”的分层逻辑,核心是next函数的执行权移交;

  2. co模块:自动迭代Generator函数,解决手动.next()的繁琐,本质是Promise与Generator的结合;

  3. 异步串/并行:串行适合有依赖的任务,并行适合无依赖的任务,核心是Promise链式调用与Promise.all的运用;

  4. Promise静态方法:all、race、allSettled、any,分别对应不同的异步场景,底层都是基于Promise的状态不可逆特性;

  5. 并发控制:在Promise基础上,增加并发限制、超时、重试等实战功能,解决大量异步任务的高效、稳定执行问题。

掌握这些知识点,不仅能看懂框架底层代码,更能在实际开发中灵活处理各类异步场景,写出更高效、更健壮的代码。

4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理

前言

我们从前面的文章中知道的所谓发布订阅模式的本质是不管代码结构如何变化,它的核心都是管理对象间的依赖关系,或者说是事件间的依赖关系,一方变化了,所有跟其建立依赖关系的依赖都将得到通知。同时发布者对象既可以是发布者也可以是订阅者,所以我们不能只从代码组织结构去分辨模式,而是从意图去分辨。

Vue2 的数据响应式的实现,在代码结构层面多少是看得出有经典发布订阅模式的架构影子,所以社区里也有人从发布订阅模式角度去分析过,但 Vue3 的数据响应式的实现从代码结构上来看跟所谓标准的发布订阅模式的代码架构差别是很大的。一般社区作者也不从发布订阅模式的角度去分析它的实现原理,那么今天就让我们从发布订阅模式的角度去理解 Vue3 的数据响应式原理吧。

发布订阅模式原理回顾

我们经过前面的学习,我们很容易通过发布订阅模式初步实现 Vue3 的 reactive API,代码如下:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && dep.addSub(activeEffect)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            dep.notify()
            return result
        }
    })
}

我们就可以进行以下测试了:

const proxy = reactive({ author: 'Cobyte' })

// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}

activeEffect = subscriber
subscriber()
activeEffect = null

// 修改
proxy.author = 'coboy'

根据上一篇 Vue2 的数据响应式原理的实践,我们可以做小小的优化:

class Dep {
  // 省略...
-  addSub(sub) {
+  addSub() {
    if (activeEffect) {
-        this.subs.push(sub)
+        this.subs.push(activeEffect)
    }
  }
  // 省略...
}
// 省略...
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            activeEffect && dep.addSub(activeEffect)
+            dep.addSub()
            return Reflect.get(target, key) 
        },
        // 省略... 
    })
}

我们上面 reactive 的实现,每个订阅者还不能进行跟每个对象的属性进行隔离的。什么意思呢?看以下测试代码:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
    console.log(`日期是:${proxy.date}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null

// 修改
proxy.author = 'coboy'

测试结果如下:

D01.png

我们可以看到最后修改 author 属性值的时候,两个订阅者函数都执行了。是因为我们在 getter 进行订阅的时候,把不同属性的订阅者都存储在同一个全局变量中了,而在 Vue2 中把每一个属性的消息代理都通过闭包进行了隔离,也就是每一个属性都拥有属于自己的消息代理,相当于每一个属性都是一个发布者。

而 Vue3 中的 Proxy API 很明显不能通过闭包来进行隔离每个属性的消息代理。那么我们根据前面的发布订阅模式的实践理解,还可以通过给消息代理对象通过添加 key 的方式来让订阅者只订阅自己感兴趣的内容。

那么相关代码修改如下:

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = []
+    this.subs = {}
  }
  // 添加订阅者
-  addSub() {
+  addSub(key) {
+    if (!this.subs[key]) {
+        this.subs[key] = []
+    }
    if (activeEffect) {
-    this.subs.push(sub)
+    this.subs[key].push(activeEffect)
    }
  }
  // 通知订阅者
-  notify() {
+  notify(key)
-    this.subs.forEach(sub => sub())
+    this.subs[key].forEach(sub => sub())
  }
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub()
+            dep.addSub(key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) { 
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify()
+            dep.notify(key)
            return result
        }
    })
}

我们经过上面的修改再进行测试,我们发现已经可以正确打印我们期待的结果了。

D02.png

我们上面实现的 reactive 函数还存在一个问题,我们现在可以通过 key 来把不同订阅者进行分类,但不同的对象中可能会存在相同的 key,例子如下:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const proxy2 = reactive({ author: 'Cobyte2' })
// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
    console.log(`我是:${proxy2.author}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null

// 修改
proxy.author = 'coboy'

测试结果如下:

D03.png

我们发现我们只修改了 proxy.author 的值,但订阅者2 subscriber2 也执行了,这不是我们期待的结果,所以我们还要迭代我们的功能。

我们既然可以添加 key 来让订阅者订阅自己喜欢的内容,那么是否还可以进行增加 key, 来区分不同的对象呢?我们把对象也当成一个 key,也就是在 getter 添加依赖的时候这样操作:dep.addSub(target, key, activeEffect),那么在 setter 的时候这样操作:dep.notify(target, key)。很明显我们可以通过 Map 来把一个对象作为一个 key。

所以我们对消息代理中心做以下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = {}
+    this.subs = new Map()
  }
  // 添加订阅者
-  addSub(key) {
+  addSub(target, key) {
+    let depsMap = this.subs.get(target)
+    if (!depsMap) {
+        depsMap = {}
+        this.subs.set(target, depsMap)
+    }
    
-    if (!this.subs[key]) {
-      this.subs[key] = []
-    }
+    if (!depsMap[key]) {
+        depsMap[key] = []
+    }
     if (activeEffect) {
-    this.subs[key].push(activeEffect)
+    depsMap[key].push(activeEffect)
     }
  }
  // 通知订阅者
-  notify(key) {
+  notify(target, key) {
-    this.subs[key].forEach(sub => sub())
+    const depsMap = this.subs.get(target)
+    if (!depsMap) return
+    const deps = depsMap[key] 
+    deps && deps.forEach(sub => sub())
  }
}

接着我们也去修改 reactive 中相关的地方:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub(key)
+            dep.addSub(target, key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify(key)
+            dep.notify(target, key)
            return result
        }
    })
}

我们重新测试,我们发现打印了如期的结果:

D04.png

桶的数据结构设计?

我们看到通过发布订阅模式去理解 Vue3 的数据响应式原理,理解起所谓依赖数据结构 ,是非常好理解的。我们通过由浅入深的地讲解所谓 数据结构的形成,它的形成是自然而然的形成的,而不是一开始就经过特别精心设计的,它没有那么的神秘,它是由最简单的功能一步步迭代形成的,是非常符合我们的日常开发规律的,因为我们日常的应用也是由最简单的功能开始慢慢迭代成非常复杂的功能。一开始所谓 ,只是一个 Array (可以简单理解为:[]) 的结构,后来我们通过增加 key 来区分不同的订阅者,这行为在发布订阅模式中就是通过 key 来让订阅者只订阅自己感兴趣的内容;增加 key 后, 的数据结构变为 Object -> Array (可以简单理解为:{ key: [] }),再后来我们继续增加响应式对象作为 key,来区分不同的属性,避免不同响应式数据中可能存在相同属性的情况。最后我们的 的数据结构变为 Map -> Object -> Array (可以简单理解为:{ target: { key: [] } })。

我们熟悉 Vue3 源码的同学会知道,所谓 的数据结构跟我们上面还是区别的,那其实都是性能优化迭代的结果,我们也可以继续迭代我们的功能。首先是我们的订阅者是通过 Array 的方式存储的,为了防止重复添加订阅者,我们需要在执行完订阅者函数之后把 activeEffect 变量设置为 null,同时也是为了确保只在副作用函数中读取响应式变量才进行依赖收集。我们可以把订阅者的存储方法改成 Set 的数据结构,因为 Set 具有自动去除重复的功能。

相关代码修改如下:

class Dep {
  // 省略...
  addSub(target, key, sub) {
    // 省略...
    if (!depsMap[key]) {
-        depsMap[key] = []
+        depsMap[key] = new Set()
    }
    if (activeEffect) {
-    depsMap[key].push(activeEffect)
+    depsMap[key].add(activeEffect)
    }
  }
  // 省略...
}

经过上面修改,我们的 结构变成了 Map -> Object -> Set。我们还可以继续优化,我们可以把中间的 Object 改成 Map,因为在频繁增删键值对和存储大量数据的场景下 Map 的性能要比 Ojbect 更好。

class Dep {
  // 省略...
  addSub(target, key, sub) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
-        depsMap = {}
        depsMap = new Map()
        this.subs.set(target, depsMap)
    }
+    let dep = depsMap.get(key)
-    if (!depsMap[key]) {
+    if (!dep) {
-        depsMap[key] = new Set()
+        dep = new Set()
+        depsMap.set(key, dep)
+    }
    if (activeEffect) {
-    depsMap[key].add(activeEffect)
+    dep.add(activeEffect)
    }
  }
  // 通知订阅者
  notify(target, key) {
    const depsMap = this.subs.get(target)
    if (!depsMap) return
-    const deps = depsMap[key] 
+    const deps = depsMap.get(key) 
    deps && deps.forEach(sub => sub())
  }
}

最后我们还可以继续优化的地方就是把存储订阅者的变量 this.subsMap 类型改成 WeakMap 类型。

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = new Map()
+    this.subs = new WeakMap()
  }
}

为什么不采用 WeakMap 而不采用 Map 呢?我们通过下面的一个例子来说明:

const map = new Map()
const weakMap = new WeakMap()

function test() {
    const mapObj = { test: 'mapObj' }
    const weakMapObj = { test: 'weakMapObj' }
    map.set(mapObj, true)
    weakMap.set(weakMapObj, true)
}

test()

console.log('map', map)
console.log('weakMap', weakMap)

我们从打印的结果中可以一目了然地看出两者的区别,WeakMap 对 key 是弱引用的,所谓弱引用就是一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候,垃圾回收器就会把该对象从内存移除。Map 则不会把 key 对象进行移除,这样就会容易导致内存溢出,就算不内存溢出,当数据大的时候,操作性能也会下降,所以 Vue3 源码中就采用了 WeakMap。

最后小结 Vue3 底层源码是使用 WeakMap 和 Map 来构建依赖关系图,具体来说是:

  • targetMap 是一个WeakMap,键是响应式对象(target),值是一个Map(depsMap)。
  • depsMap 的键是对象的属性(key),值是一个Dep(即一个Set),存储了所有依赖该属性的副作用函数。

订阅者中介的实现

我们通过前面的文章对发布订阅模式的学习,可以知道发布者可以抽离一些公共功能统一放到一个中介类中,也就是所谓的事件总线或者消息代理,而订阅者同样也可以进行中介化,从而实现订阅者的多态化。所谓多态就是当不同的对象去执行同一个方法时会产生出不同的状态。我们通过上一篇文章可以知道 Vue2 中所谓的 Watcher 类其实就是订阅者中介,在项目中不同的组件其实底层都是通过 Watcher 类来执行的,而所谓依赖收集,其中收集的是 Watcher,那些响应式数据发生变化后去通知的也是 Watcher,然后再通过 Watcher 去执行具体的组件渲染。

那么 Vue3 的数据响应式也是通过发布订阅模式实现的,那么很自然的也存在订阅者中介。在 Vue3 源码中 ReactiveEffect 类从发布订阅模式的角度理解就是订阅者中介的角色,所以从发布订阅模式的角度理解 Vue3 的数据响应式原理,就非常容易理解为什么要有一个 ReactiveEffect 类了,甚至不用去看具体的实现细节,我们都可以知道 ReactiveEffect 所实现的功能是什么了。

我们知道 Vue2 中的 Watcher 有一个 update 方法,就是在发布者去通知所有订阅者的时候,订阅者统一执行的方法就是 update,那么很明显 ReactiveEffect 也同样需要这样的一个方法,在 Vue3 源码中这个方法叫 run,同样初始化的时候需要接收一个函数作为参数也就是具体订阅者需要做的事情。

ReactiveEffect 的初步实现:

class ReactiveEffect {
    constructor(fn) {
        this._fn = fn
    }
    run () {
        // 根据 Vue2 的数据响应式原理,我们知道在执行具体订阅者函数之前需要把当前订阅者赋值给一个中间变量。
        activeEffect = this
        this._fn()
        // 确保只在副作用函数中读取响应式变量才进行依赖收集
        activeEffect = null
    }
}

然后我们进行测试:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const _effect = new ReactiveEffect(() => {
    console.log(`我是:${proxy.author}`)
})
_effect.run()
proxy.author = 'coboy' 

我们可以看到正确打印了结果:

D06.png

同时我们发现 ReactiveEffect 的订阅者函数参数初始化在外部手动执行的,而 Vue2 的 Watcher 中的订阅者函数初始化在 Watcher 内部实例化的时候自动执行的,这个只是设计上区别。

我们把上述实现订阅的过程进行封装一下,那么就是 effect API 了,代码如下:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
}

从发布订阅模式的角度来看本质上 Vue3 的数据响应式实现原理跟 Vue2 的数据响应式原理的实现是一脉相承的。

互为订阅者

我们通过前面文章的学习,我们知道在 Vue2 中会存在发布者中介类 Dep 和订阅者类 Watcher 互为订阅者的情况,场景就是可能会取消某一个副作用函数的中的响应式数据的追踪,比如组件卸载了,那么我们就需要停止组件的依赖追踪。在 Vue3 中自然也存在这种场景,那么也就说在 Vue3 中也存在互为订阅者的情况。但在 Vue3 中的情况又会跟 Vue2 不一样,Vue2 是订阅者 Watcher 类直接订阅发布者中介类 Dep,因为在 Vue2 中每一个 Dep 实例都和一个发布者关联,也就是和每一个属性或者对象进行关联。而在 Vue3 中因为是通过 Proxy API 实现的数据响应式,每一个 Dep 的实例并不对应着具体的属性,所以我们要找到对应具体的属性的记录的变量,其实就是对应 key 的记录变量。

我们再看看 Dep 中关于对应 key 部分的订阅者记录变量部分代码:

class Dep {
  // 省略...
  addSub(target, key) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
        depsMap = new Map()
        this.subs.set(target, depsMap)
    } 
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, deps)
    }
    if (activeEffect) {
      dep.add(activeEffect)
    }
  }
  // 省略...
}

我们可以看到对应每一个 key 的订阅者记录变量是 deps,所以我们只需要把对应的 deps 记录到 ReactiveEffect 中即可。

首先我们修改 ReactiveEffect 类,添加记录变量 deps

class ReactiveEffect {
+    // 记录哪些变量记录了该订阅者,在 Vue2 中则是记录哪些 Dep 记录了该 Watcher
+    deps = []
    // 省略...
}

接着我们在记录响应式数据对象的 key 的消息代理对象的地方把对应的 key 的消息代理对象添加到订阅者 ReactiveEffectdeps 变量中,代码如下:

class Dep {
  // 省略...
  addSub(target, key) {
    // 省略...
    if (activeEffect) {
      deps.add(activeEffect)
+      activeEffect.deps.push(deps)
    }
  }
  // 省略...
}

这样我们就完成了对应 key 的变量对 ReactiveEffect 的订阅,那么有订阅,也就有取消订阅。

取消订阅功能如下:

class ReactiveEffect {
    // 省略...
    // 取消订阅
+    stop () {
+      this.deps.forEach(dep => dep.delete(this))
+    }
}

接着我们再修改 effect API:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
+    return _effect
}

这样我们就可以进行以下测试了:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const _effect = effect(() => {
  console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
_effect.stop()
proxy.author = '掘金签约作者'

打印结果如下:

D06.png

我们看到取消依赖追踪后,我们再去修改响应式数据,我们之前设置的订阅者函数就不再执行了,也就是得不到通知了。

那么停止依赖追踪之后,我又想它继续进行依赖追踪呢?这样我们就需要把 ReactiveEffect 中的 run 方法也返回出来。

我们继续进行 effect API 的功能迭代,新的修改如下:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
+    const runner = _effect.run.bind(_effect)
+    runner.effect = _effect
+    return runner 
}

这样我们就可以在取消依赖追踪后,还可以在某个时机中又恢复依赖追踪了,测试代码如下:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const runner = effect(() => {
  console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
runner.effect.stop()
proxy.author = '掘金签约作者'
// 恢复依赖追踪
runner()
proxy.author = '恢复依赖追踪了'

我们可以看到如期打印了我们期待的结果:

D07.png

为什么 Vue3 的发布订阅模式不采用传统代码结构?

我们上面实现 Vue2 的数据响应式原理是很明显采用了发布订阅模式的,因为我们存在一个发布者中介类 Dep,这个代码结构跟传统教学中的发布订阅模式中的代码结构是很相似的。但实际上 Vue3 源码中是不存在发布者中介类的,也就是跟传统发布订阅模式的代码结构是不相同的,那么是否意味着 Vue3 并没有采用发布订阅模式呢?答案是否定的,正如我们前面文章中所说的那样,判断模式不能从代码结构上进行判断,而应该从代码意图。

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = new WeakMap()
  }
  // 添加订阅者
-  addSub(target, key) {
+  track(target, key) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
        depsMap = new Map()
        this.subs.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    if (activeEffect) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
  // 通知订阅者
-  notify(target, key) {
+  trigger(target, key) {
    const depsMap = this.subs.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(effect => effect.run())
  }
}

上面我们经过对方法名称的修改,我们的代码结构从命名上跟 Vue3 源码有些类似了,我们接着把 Dep 类也去掉:

  // 全局订阅者记录变量
  const targetMap = new WeakMap()
  // 添加订阅者
  function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    if (activeEffect) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
  // 通知订阅者
  function trigger(target, key){
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(effect => effect.run())
  }

接着我们也要把 reactive 中的相关代码也进行修改:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub(target, key)
+            track(target, key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify(target, key)
+            trigger(target, key)
            return result
        }
    })
}

我们可以看到经过上述修改之后,我们的代码结构跟 Vue3 源码是一模一样的了,但并不是说代码结构变了,模式也变了,上述代码结构依然是发布订阅模式。那么 Vue3 为什么要把依赖收集和依赖触发的函数进行分开呢?主要是因为分开之后依赖收集和依赖触发的函数就可以分别独立导出了,给其他功能 API 比如 ref、computed 使用了,代码可以达到最极致的抽象及复用。

确保只在副作用函数中读取响应式变量才进行依赖收集

不采用 Proxy API 实现数据响应式

因为 Proxy 无法提供对原始值的代理,所以我们需要对原始值的响应式进行特别处理,我们可以使用一层对象作为包裹,间接实现原始值的响应式方案。

当我们不通过 Proxy 实现代理的时候,除了使用 Vue2 中使用的 Object.defineProperty以外,我们还可以根据前面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显我们可以使用在发布订阅模式那篇中讲到的公众号的例子。

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '原始值内容',
    // 发布文章
    setArticle(value) {
        this.article = value
        // 更新文章的时候通知所有的订阅者
        this.notify()
    },
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(title));
    }
}

上述代码就是前面我们实现公众号讲解发布订阅模式的例子。在上述例子中,我们实现了在数据更新的时候触发依赖,也就是 setArticle 函数。那么我们再实现在数据读取的时候进行依赖收集即可,为了现在这个功能,我们把读取 article 属性值的行为也封装成一个函数。

代码如下:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
+    getArticle() {
+        return this.article
+    },
    // 省略...
}

这样我们就可以通过以下的方式获取文章内容了:

effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.getArticle()}`)
})
// 更改内容
weChatOfficialAccount.setArticle(520)

同时我们的发布者的通知函数也需要进行修改:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 省略...
    // 广播信息
-    notify(title) {
+    notify() {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
-        this.subscribers.forEach(fn => fn(title))
+        this.subscribers.forEach(dep => dep.run())
    }
}

那么我们就可以在 getArticle 函数中进行依赖收集了:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    getArticle() {
+        // 进行依赖收集,也就是进行订阅
+        if (activeEffect) this.addDep(activeEffect)
        return this.article
    },
    // 省略...
}

这样我们的测试结果如下:

D08.png

我们上述方式是通过一个典型的发布订阅模式来实现对一个对象的观察,当这个对象发生改变之后,所有依赖该对象的订阅者都将得到通知。

我们通过一个工厂函数上面的公众号对象进行进行封装,代码如下:

// ref 工厂函数
function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
        getArticle() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
        setArticle(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 添加订阅者
        addDep(fn) {
            // 把订阅者添加进记录列表
            this.subscribers.push(fn) 
        },
        // 广播信息
        notify() {
            // 发布信息时就是把记录列表中的订阅者全部通知一次
            this.subscribers.forEach(dep => dep.run());
        }
    }
}

我们可以看到经过上述的代码封装之后,我们实现了对原始值的响应式。那么接下来我们希望通过普通的方式获取和设置对象的值:

const weChatOfficialAccount = ref('初始值')
effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.article}`)
})
// 更改内容
weChatOfficialAccount.article = 520

通过前面的学习我们知道除了使用 Object.defineProperty 进行显式声明属性访问器之外,还可以通过字面量的方式,本质还是属性访问器

修改如下:

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
-        getArticle() {
+        get article() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
-        setArticle(value) {
+        set article(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 省略...
}

经过上述修改之后,我们就可以通过属性访问器像普通方式那样访问和设置对象的属性值了。

那么为了跟 Vue3 的 ref API 设计一致,我们把 article 属性改成 value

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
-        get article() {
+        get value() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
-        set article(value) {
+        set value(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 省略...
}

那么改了之后我们的 ref 就跟 Vue3 的一样用法了:

const weChatOfficialAccount = ref('初始值')
effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.value}`)
})
// 更改内容
weChatOfficialAccount.value = 520

接着我们对依赖收集函数 track 和依赖触发函数 trigger 进行修改让我们的代码尽可能地复用。修改如下:

// 添加订阅者
function track(target, key) {
    // 省略...
    if (activeEffect) {
-        dep.add(activeEffect)
-        activeEffect.deps.push(dep)
+        trackEffect(dep)
    }
}
+ function trackEffect(dep) {
+     dep.add(activeEffect)
+     activeEffect.deps.push(dep)
+ }
// 通知订阅者
function trigger(target, key) {
    // 省略...
-    deps && deps.forEach(effect => effect.run())
+    triggerEffect(deps)
}
+ function triggerEffect(deps) {
+     if(deps) {
+         deps.forEach(effect => effect.run());
+     }
}

接着我们进行重构 ref 函数:

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
-        subscribers: [],
+        dep: new Set()
        // 文章内容
        _value: value,
        get value() {
-            if (activeEffect) this.addDep(activeEffect)
+            if (activeEffect) trackEffect(this.dep)
            return this._value
        },
        // 发布文章
        set value(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
-            this.notify()
+            triggerEffect(this.dep)
        },
-        // 添加订阅者
-        addDep(fn) {
-            // 把订阅者添加进记录列表
-            this.subscribers.push(fn) 
-        },
-        // 广播信息
-        notify() {
-            // 发布信息时就是把记录列表中的订阅者全部通知一次
-            this.subscribers.forEach(dep => dep.run());
-        }
-    }
}

我们可以看到经过重构之后,我们的 ref 函数就变得比较整洁了,我们 ref 中的部分发布订阅的功能就和前面 reative 的发布订阅已经实现的功能代码进行了复用。

我们通过前面文章的学习,我们知道 Vue3 的 ref 底层是通过 OOP 的方式进行实现的,但本质还是跟我们上面一样的,那么我们也通过 OOP 的方式实现一遍吧。

实现代码如下:

class RefImpl {
    _value
    dep = new Set()
    constructor(value) {
        // 如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式
        this._value = isObject(value) ? reactive(value) : value
    }
    get value() {
       // 存在依赖就把依赖收集到依赖存储中心
       if (activeEffect) trackEffect(this.dep)
       return this._value 
    }
    set value(val) {
        this._value = val
        // 更新文章的时候通知所有的订阅者
        triggerEffect(this.dep)
    }
}

function ref(value) {
    return new RefImpl(value)
}

最终我们的测试结果还是一样的,这里唯一值得注意的是,如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式。

API 的设计技巧及知识的串联

我们上文中实现的数据响应式代码中,有一个函数的名称叫:observe,还有一个类叫:Observer,在 Vue2 源码中也是这么起名的。那么为什么要这么起名称呢?这么起名称有什么特殊的含义吗?

我们上面这个所谓数据响应式的原理,其实是在观察数据的变化,跟我们在 web 开发中观察 DOM 对象的变化的行为是很像的,甚至可以说本质是一样的。

MutationObserver 与 Vue2 数据响应式的联系

我们如果要观察一个 DOM 对象发生改变了就进行某些操作的话,可以通过 MutationObserver API来实现。例子如下:

// 获取 DOM 对象
const targetNode = document.querySelector('#some-id');

// 观察者回调函数
const subscriber = (mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((addedNode) => {
        console.log(`添加了子元素:${addedNode.nodeName}`);
        // 执行相应的处理逻辑
      });
      mutation.removedNodes.forEach((removedNode) => {
        console.log(`移除了子元素:${removedNode.nodeName}`);
        // 执行相应的处理逻辑
      });
    }
  });
} 

// 创建一个观察器实例并传入回调函数,当观察到变动时便执行回调函数
const observer = new MutationObserver(subscriber);
// 配置需要观察的选项
const config = {
  childList: true, // 观察子元素是否发生变化
};
// 观察 DOM 对象是否发生变化
observer.observe(targetNode, config);

我们从上面的代码可以看出 MutationObserver 所做的事情,跟我们 Vue2 中对响应式数据的监听是一样的。DOM 对象就是我们 Vue2 中的响应式数据,当它发生变化之后就会去触发回调函数执行,相当于 Vu2 中的响应式数据发生改变后会触发 Watcher 一样。所以 MutationObserver 本质也是一个发布订阅模式,但它使用方式跟我们所谓传统的发布订阅模式是不一样的,但正如我们前面说的理解一种模式不应该从代码组织结构去进行分辨,而是意图。

所以我们从 Vue2 的数据响应式实现原理,就可以联系到 MutationObserver,然后联系它们的相同点,从而加深我们对知识的理解。当然尤雨溪当初给 Vue2 对一个对象实现数据响应式的处理函数和类命名为 observeObserver,是否参考了 MutationObserver 的 API 命名规则我们无从考证,但它们的工作方式值得我们联系,从而加深我们的知识理解。

总结

本文从发布订阅模式的核心思想出发,深入剖析了 Vue3 响应式系统的设计本质。发布订阅模式的关键在于管理对象间的依赖关系——一方变化时,所有依赖方都能得到通知,而非拘泥于特定的代码结构。Vue3 虽然不再像 Vue2 那样拥有显式的 Dep 类,但其底层依然遵循这一模式。

通过逐步迭代,我们自然形成了 Vue3 中著名的“桶”数据结构:最初用一个数组存储订阅者,然后按属性 key 分类,再按响应式对象 target 隔离,最终演变为 WeakMap(target) → Map(key) → Set(effect) 的依赖图。这种结构并非凭空设计,而是功能迭代的自然产物,体现了发布订阅模式在 Proxy 场景下的灵活应用。

Vue3 中的 ReactiveEffect 类扮演了订阅者中介的角色,类似于 Vue2 的 Watcher,负责管理具体副作用函数的执行与依赖追踪。通过 effect 函数封装,我们可以轻松创建响应式副作用,并借助 stop 机制实现取消订阅,这体现了订阅者与发布者之间“互为订阅”的关系。

值得注意的是,Vue3 将依赖收集(track)和依赖触发(trigger)拆分为独立函数,而非保留传统的 Dep 类结构。这一设计变化并非模式的改变,而是为了提升代码复用性,让 refcomputed 等 API 也能共享同一套响应式核心。

此外,对原始值的响应式实现(ref)同样基于发布订阅模式——通过属性访问器(getter/setter)在读取时收集依赖,在修改时触发更新。当 ref 包裹对象时,内部会回退到 reactive 处理,保证了逻辑的一致性。

最后,从 API 命名(如 observe / Observer)到与浏览器原生 MutationObserver 的类比,都能看出响应式系统与观察者模式之间的深刻联系。理解这些设计背后的模式思想,远比记忆具体代码实现更有价值。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

Hello 算法:贪心的世界

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

一轮轮的简单选择中,时刻追求自身成长的最大可能,逐步导向最佳答案。

本篇话题展开之前,先看个日常很常见的问题:零钱兑换

你去超市购物,给收银员100元,而你购买的商品只需要2元,他要怎样给你找钱?

很简单,会100内加减的都能轻易搞定,你会根据还剩余的找零额度,从可选择的币种中选择面值最大的,直至达到数额为止。

其实你会发现,“零钱”只是一种比较具象的表达,把“找零”这件事进一步抽象,可用于解决很多领域的问题。

它,就是“贪心算法”。

贪心算法

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。

贪心算法简洁且高效,在许多实际问题中有着广泛的应用。

除了找零钱,它还可用于解决“分数背包问题”:

给一个背包,有容量限制,另有N件物品,每件物品都有它的重量和价值,每件物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算

求:在限定背包容量下,背包中物品的最大价值。

这个问题的解答策略和“找零”类似:最大化背包内物品总价值,本质上是最大化单位重量下的物品价值

  1. 将物品按照单位价值从高到低进行排序。
  2. 遍历所有物品,每轮贪心地选择单位价值最高的物品。
  3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。

注意:这里说的是“分数背包”,不是“0-1背包”。

代码实现

以“找零”为例,贪心算法的核心实现:

/* 零钱兑换:贪心 */
function coinChangeGreedy(coins, amt) {
    // 假设 coins 数组有序
    let i = coins.length - 1;
    let count = 0;
    // 循环进行贪心选择,直到无剩余金额
    while (amt > 0) {
        // 找到小于且最接近剩余金额的硬币
        while (i > 0 && coins[i] > amt) {
            i--;
        }
        // 选择 coins[i]
        amt -= coins[i];
        count++;
    }
    // 若未找到可行方案,则返回 -1
    return amt === 0 ? count : -1;
}

示意图如下:

微信图片_20260413001704_150.jpg

优点与局限

贪心算法的优点是操作直接、实现简单,通常效率也很高。

但不是所有分步解决的问题都适合使用贪心,什么样的问题适合呢?

主要关注两个性质。

  • 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
  • 最优子结构:原问题的最优解包含子问题的最优解。

关键词:最优解

还是找零问题,贪心能找到最优解的前提是,有足够的币值种类可选,如果没有,像下面这样:

给定币值:[1,20,50],目标值是 60,使用贪心策略,它会找到 50 + 1*10,总数是11,但实际更优的做法是 20 * 3,只需要3就可以。

所以可以理解为,贪心适合的场景是“想要多少(比如10),就有多少”,而不是妥协退而求其次。

其他应用

除了上面介绍的两种,贪心的适用场景还包括但不限于:

  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

小结

贪心是必掌握的经典算法之一,实现也不难,重点是吃透它的使用场景。

下一篇,将是本系列的终篇,让我们一起期待会是什么。

更多好文第一时间接收,可关注公众号:“前端说书匠”

《前端周刊》尤大开源 Vite+ 全家桶,前端工业革命启动;尤大爆料 Void 云服务新产品,Vite 进军全栈开发;ECMA 源码映射规范......

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周全球 Web 开发圈的主要情报如下:

  • 🎉 尤大出席 Vue 大会,发表了关于 Vue & Vite 的重要讲话
  • ✨ Vue 生态“文艺复兴“,“蒸汽模式“公测,Pinia Colada 新品首发
  • ✅ TC39 工作组推进 ECMA 源码映射规范
  • 👍 Vite 生态“工业革命“,Vite+ 全家桶免费开源
  • 🙏 尤大爆料 Void 一键部署云平台,Vite 进军全栈开发

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 Vue 生态文艺复兴

近日,Vue 之父 & Vite 之父 & VoidZero CEO 尤雨溪出席了 Vue 阿姆斯特丹大会和 D2 技术大会,发表了重要讲话。

cover.png

本期我们就来回顾一下这位“前端之神“已公开的关于 Vite & Vue 生态的最新情报和未来规划。

Vue 3.6 Beta

Vue 是 GitHub 第二 UI 框架,也是唯一一个同时支持 SFC(单文件组件)/ JSX、集成 Signals(细粒度响应性)的渐进式框架。

Vue 目前发布了 v3.6-beta(公测版),主要包括:

  • 移植 alien-signals 重构 @vue/reactivity
  • 新增可选的 Vapor Mode 编译模式,这是一种专属于 <script setup> SFC 的无虚拟 DOM “蒸汽模式“
  • Vapor Mode 是 Vue 当前 API 的子集,所以部分功能受限,比如不支持 <Suspense /> 组件和 Options API

尤大最初在 2022 透露了 Vapor Mode(蒸汽模式),去年 Vue 从 Alpha 顺利晋升到 Beta,今年有望正式发布。但考虑到尤大还需要兼顾 Vite+ 等海量开源项目,暂不确定,敬请期待!

Vue Router 5.0

Vue Router 是 Vue 生态官方的客户端路由库,年初发布了 v5.0 主版本,主要包括:

  • 不再依赖 unplugin-vue-router,将其集成为 vue-router/unplugin,支持基于文件的路由
  • IIFE 构建不再包含 @vue/devtools-api,该模块升级到 v8.0 之后,不再提供 IIFE 格式
  • v5.0 是一个过渡版本,v6.0 将只支持纯 ESM 模块

Pinia Colada v1 首发

Pinia 近期没有主/次版本更新,但上线了新的产品 Pinia Colada。

pinia-colada.png

Pinia Colada 是基于 Pinia 的 Vue 专属异步状态管理库,第一个主版本正式发布,优点在于:

  • 支持缓存、去重、SWR(过期重验证)等高级功能,不用我们自己定义相关复杂逻辑
  • 无需手写 isLoading 等属性,内部封装后暴露这些接口
  • 消除数据请求的大量重复模板代码,更符合人体工学

colada-demo.png

Nuxt 4.4

Vue 生态的第一全栈元框架 Nuxt 发布了 v4.4 次版本,主要包括:

  • 新增 createUseFetch() 等工厂函数,支持组合拦截器定义高级实例,比如创建带有默认选项的 useApiFetch() 来替换 useFetch()
  • 新增 useAnnouncer() 组合函数和 <NuxtAnnouncer /> 组件,适用于页面内容动态变化、但焦点不变的场景,比如表单提交
  • 更棒的 import 保护,现在会显示建议和完整的追踪信息
  • 构建性能分析报告,显示构建阶段或打包插件的持续时间等数据,轻松诊断性能瓶颈

profiler.png

🎉 Vite 生态工业革命

而 Vite 生态,尤大所在的 VoidZero 团队掀起了一场前端“工业革命“。

上周我们提到了 Rust 驱动的第一个 Vite 稳定版 Vite 8 正式发布,替换 Rollup + esbuild,采用 Rolldown + Oxc,性能爆表。

此外,基于 Oxc 编译器的格式化神器 Oxfmt 发布了 beta(公测版),JavaScript 跟 TypeScript 的格式化功能 100% 兼容 Prettier,但性能比快了 30 倍。

本期补充更多 Vite 生态的进展,包括 Oxlint 和 Vitest。

Oxlint JS 插件 Alpha

ESLint 的 Rust 移植版 Oxlint 也有新进展,它的 JS 插件进入 Alpha 阶段,目前 100% 通过 ESLint 内置规则的官方测试套件。

oxc-test.png

具体而言,Oxlint 采用 Rust 重写了 650+ 多条代码质检规则,涵盖了 ESLint 的大部分规则。即使没有使用 Rust 重写的规则,Oxlint 也提供了 oxlint-plugin-eslint 插件来无缝迁移,使得 Oxlint 100% 兼容 ESLint 的所有内置规则。

性能方面,Oxlint 团队把 Node 源码库的 ESLint 替换掉,进行测评跑分,性能暴涨近 5 倍。

Oxlint JS 插件的成熟意味着目前 JS 生态现存的 ESLint 插件,比如非官方的社区插件,也能无缝迁移到 Oxlint 项目。这样用户无需重写插件,同时部分受益于 Rust 的原生性能。

Vitest 4.1

Vite 生态衍生的 Vitest 是 GitHub 前十的测试框架,近期也发布了 4.1 次版本,主要包括:

  • 采用新鲜出炉的 Vite 8
  • 测试标签分组,按标签设置或筛选测试,借鉴 pytest 筛选标签的自定义语法
  • 开发体验优化,比如自定义 UI 窗口配置,Playwright 追踪视图改进,自动生成 GitHub Actions Job 摘要
  • Vitest 的 VS Code 扩展现在支持 Deno,import 语句后会显示模块加载时间

vscode.png

🛜 官方情报

ECMA-426 源码映射格式规范

Source Map(源码映射)是一种特殊的 JSON 文件,用于在我们编写的源码和运行时代码之间进行映射。

举个栗子,我们在开发时可能编写一些强大的方言,比如 Sass 或 TypeScript,再把它们转换成 HTML、CSS 或 JavaScript,这样浏览器才能正常执行。

source-map.gif

问题在于,当我们使用 devtools(开发者工具) debug 时,我们希望直接定位到源码,而不是编译或压缩后人类不可读的代码。

这时就要用到 Source Map 了,这个 JSON 文件中保留了源码和运行时代码之间的映射关系,比如哪一行、哪一列等等。

过去,Source Map 并未被标准化,大家通过一份谷歌文档约定实现,但一些功能始终无法协调。

为此,彭博社成立了 TC39-TG4(源码映射工作组),制定了 ECMA-426(源码映射格式规范)。

ecma.png

近年来,它们标准化了更多功能,Scopes 和 Range Mappings 也即将上线 devtools!

React 文档更新

React 文档更新了 <ViewTransition /> 组件结合 <Activity /> 组件章节,如果你想让组件在保持状态的同时实现进场或出场动画,可以使用 <Activity /> 组件。

react-doc.png

Svelte 最佳实践

Svelte 文档更新了“最佳实践“章节,帮助大家编写快速健壮的 Svelte 应用,可以将 svelte-core-bestpractices 投喂给 AI 代理作为 Skills 使用。

svelte-doc.png

🛠️ 工具推荐

Vite+ 全家桶开源

随着 Vite 生态的各个工具逐渐成熟,尤大创立的 Void Zero 公司也官宣:Vite+ 进入 Alpha 阶段!

Vite+ 是将 Vite 生态所有开发工具叠加在一起的一体化工具链,由 Vite Task 任务运行器驱动,提供了 vp install / vp dev 等命令。

具体而言,Vite+ 把 Vite 生态的所有流行的开源软件 —— Vite、Vitest、Oxlint、Oxfmt、Rolldown 和 tsdown 都添加到一个全家桶,用于开发、测试、代码质检、格式化和构建生产环境项目。

此外,Vite+ 还支持管理 pnpm / Bun 等包管理器,甚至能管理 Node 的版本。同时,我们配置的 lint 或格式化规则,比如 eslint.config.js.prettierrc 等配置文件,可以整合到单一的 vite.config.js 中。

vite-plus.gif

之前,Vite+ 原本要求对企业用户付费授权,现在尤大直接开源,完全免费。不管是公司还是独立开发者,都能纵享丝滑了,感谢 Void Zero 的慷慨!

随着 Vite+ 官宣 Alpha 版本,很多项目开始试用,Vite+ 目前已经集成到 Vue 源码的相关分支,还有 Vue CLI create-vue,进一步投入到生产环境测评。

Void Cloud 全栈开发

尤大在 Vue 大会演讲的最后,致敬乔布斯经典的“One More Thing“环节放大招,透露了新产品 Void Cloud。

VOID 是 Vite+ / Optimized(优化)/ Isomorphic(同构)/ Deployment(部署)这几个设计理念的首字母缩写,这是一款 Vite 专属的云服务插件,也是一个也云服务平台,或者全栈元框架。

Void 内置了强大的后端 SDK(软件开发工具),包括数据库、键值存储、对象存储、AI 推理、身份认证等后端应用常见功能,可以按需采用。

void.gif

由于 Void 基于 Vite 生态,因此所有前端框架/元框架天然支持,比如 React、Vue、Nuxt 等,且支持静态站点生成或服务端渲染等不同渲染方式。

Void Cloud 旨在让 Vite 用户能够一键部署,直接上线全栈应用,标志着 Vite 生态将进军全栈开发领域。

Antdv Next 组件库

我一般不推荐组件库,因为 GitHub 有大量成熟的组件库供大家白嫖,容易选择困难。但最近新出了一个 Vue 3 的组件库,它就是 Antdv Next!

Antdv Next 是一套开箱即用的高质量 Vue3 企业级组件库,基于阿里系的蚂蚁设计系统构建。

阿里系之前 Ant Design Vue 是比较流行的组件库,虽然其源码仓库还有提交,但我发现 2024 之后就没有再发布新版本了,目测不会推出新功能了。Ant Design Vue 支持 Vue 2 和 Vue 3,而 Antdv Next 只服务于 Vue 3。

我粗略看了一下,Antdv Next 采用了现代化的技术栈,比如 Vite / Vitest / pnpm 等,可以集成 AI、UnoCSS、Tailwind CSS 等。

Vue 初学者最不习惯的应该是 Antd 系列的组件源码都是基于 TSX 来实现,而不是常见的 SFC,但这只影响开源贡献,不会影响我们以 SFC 的方式使用。

由于组件实现采用了 TSX,Antdv Next 的自定义主题相应地也采用了 CSS-in-JS,其主题是目前我个人比较喜欢的亮点之一。

antdv-next.gif

总之,Antdv Next v1.0 已经正式官宣,值得继续关注,欢迎大家去 GitHub star 支持一波~

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

重排、重绘与合成——浏览器渲染性能的底层逻辑

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧——明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。


一、先厘清一个容易混淆的概念

在进入正题前,必须先把这两件事分开:React re-render浏览器重排/重绘

它们经常被放在一起讨论,但其实是两个不同层的事情:

筛选条件变化
    ↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
    ↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制

React re-render 可能触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。


二、浏览器渲染流程回顾

在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:

DOM + CSSOM
    ↓
Render Tree(渲染树)
    ↓
Layout(重排)   ← 计算每个元素的位置和大小
    ↓
Paint(重绘)    ← 填充颜色、边框、阴影……
    ↓
Composite(合成)← 合并图层,输出到屏幕

重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。


三、重排(Reflow):最贵的一步

什么是重排?

当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息——这个过程叫重排,也叫 Reflow。

典型触发场景:

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';

// 改变元素显示状态
element.style.display = 'none';   // 从文档流移除,触发重排
element.style.display = 'block';  // 重新加入文档流,触发重排

// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);

// 窗口大小变化
window.addEventListener('resize', handler);

为什么代价大?

重排的代价在于连锁反应。HTML 元素的布局是相互影响的——一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响……

浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。


四、重绘(Repaint):比重排轻,但不是没有代价

什么是重绘?

当元素的外观发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素——这叫重绘,也叫 Repaint。

典型触发场景:

// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';

// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';

// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';

重绘不需要重新计算布局,只需要重新"上色"——所以比重排轻得多,但仍然有开销,不是免费的。


五、关键规律:三者的包含关系

重排 ⊃ 重绘 ⊃ 合成

重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)

开销排序:重排 > 重绘 > 合成


六、合成层与 transform 为什么快

这是整篇文章最关键的部分。

三种操作的完整流程对比

操作 触发流程 性能
width / height / top / left 重排 → 重绘 → 合成 最差
color / background-color 重绘 → 合成 中等
transform / opacity 只合成 最好

transform 的工作原理

当浏览器发现一个元素使用了 transformopacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。

普通元素动画(left/top):
    修改样式
        ↓
    重新 Layout(计算位置)    ← CPU,影响其他元素
        ↓
    重新 Paint(绘制像素)     ← CPU,绘制整个区域
        ↓
    Composite(合成)          ← GPU

transform/opacity 动画:
    修改样式
        ↓
    Composite(合成)          ← GPU 直接处理
    (跳过 Layout 和 Paint)

关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素——只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。

实际代码对比

/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 0.3s ease;
}
.box-bad:hover {
  left: 200px; /* 每一帧都触发重排 */
}

/* 只触发合成,动画丝滑 */
.box-good {
  position: absolute;
  transform: translateX(0);
  transition: transform 0.3s ease;
}
.box-good:hover {
  transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}

视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform

主动触发合成层提升

除了 transformopacity,还可以通过 will-change 提示浏览器提前创建合成层:

/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
  will-change: transform;
}
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

will-change 不是越多越好——每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。


七、实际开发陷阱:循环里交替读写 DOM

这是一个在真实项目里很容易踩的坑,也是面试的高频考题。

为什么读取布局属性会触发强制重排?

当你读取 offsetHeightclientWidthgetBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值

如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。

问题代码:循环内交替读写

// 每次循环都触发一次强制重排——100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');

for (let i = 0; i < boxes.length; i++) {
  const height = boxes[i].offsetHeight;        // 读:强制触发重排,获取准确值
  boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
  // 下一次循环读 offsetHeight,又强制清算上面的标记
}

浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理——每次读取都迫使浏览器立即清算之前积累的修改。

修复:先批量读,再批量写

// 先读完所有值,再批量写——只触发 1 次重排
const boxes = document.querySelectorAll('.box');

// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);

// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
  box.style.height = heights[i] + 10 + 'px';
});

本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。

如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:

// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);

requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.height = heights[i] + 10 + 'px';
  });
});

八、浏览器完整渲染流程总图

把前面所有内容串起来,完整看一遍:

URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
                                    ↓
                              解析 HTML → DOM 树
                              解析 CSS  → CSSOM 树
                                    ↓
                              Render Tree(去掉不可见节点)
                                    ↓
┌───────────────────────────────────────────────────────────┐
│                    浏览器渲染流水线                         │
│                                                           │
│  Layout(重排)                                            │
│  触发条件:width/height/top/left/margin/display 等改变      │
│       ↓                                                   │
│  Paint(重绘)                                             │
│  触发条件:color/background/shadow/visibility 等改变        │
│       ↓                                                   │
│  Composite(合成)                                         │
│  所有操作最终都到这一步                                       │
│                                                           │
│  ✦ transform / opacity                                    │
│    → 元素提升为独立合成层,GPU 直接处理                        │
│    → 跳过 Layout 和 Paint,直达 Composite                   │
└───────────────────────────────────────────────────────────┘
                                    ↓
                               屏幕显示 🎉

延伸思考

梳理完这些,还有几个问题没完全搞清楚:

  1. 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
  2. React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
  3. CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部——这个机制是怎么运作的?

🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
  2. 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
  3. transform 快的原因:浏览器把 transform/opacity 的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top 每帧都触发重排,transform 每帧只做合成——这是动画性能差异的根源。
  4. 强制同步重排陷阱:读取 offsetHeightgetBoundingClientRect() 等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。
  5. will-change 的正确用法:提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用 will-change: auto 释放。

参考资料

手撕发布订阅与观察者模式:从原理到实践

前言

在JavaScript异步编程和组件通信中,发布订阅模式和观察者模式是两种至关重要的设计模式。

它们都能实现对象间的一对多依赖关系,但实现方式截然不同。

本文将通过两道手撕面试题代码,深入剖析这两种模式的核心原理、实现方式,以及它们之间的本质区别。

一、题目 FED19 发布订阅模式

描述

请补全JavaScript代码,完成"EventEmitter"类实现发布订阅模式。 注意:

  1. 同一名称事件可能有多个不同的执行函数
  2. 通过"on"函数添加事件
  3. 通过"emit"函数触发事件
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二 、发布订阅模式

发布/订阅模式的核心思想,是实现应用中那些彼此不相干的模块之间的轻松通信。

这种模式在 jQuery 插件生态和各类前端架构设计书籍中常有深入探讨,但需要说明的是,它并非 JavaScript 语言规范的一部分,所以在 MDN 等官方文档中并不会有直接的介绍。

原理

发布-订阅模式定义了一种一对多的依赖关系,当发布者(Publisher)对象的状态发生改变时,所有依赖它的订阅者(Subscriber)对象都会得到通知。它像一个“信息中介”,将消息的发送者和接收者彻底解耦,两者不需要知道对方的存在,只需要知道共同的“频道名称”。

它的工作原理可以拆解为以下几个角色:

  • 发布者 (Publisher):负责在特定“频道”上发送消息或事件,不关心谁会接收。
  • 订阅者 (Subscriber):负责订阅感兴趣的“频道”,并在频道有消息时执行相应的回调函数。
  • 事件调度中心 (Event Bus / PubSub):这是模式的核心,负责维护所有“频道”和订阅者的关系。它提供订阅(on / subscribe)、发布(emit / publish)、取消订阅(off / unsubscribe)等核心方法。

下面是一个极简的 JavaScript 实现:

// 创建一个事件中心 (Event Bus)
const eventHub = {
    // 用于存储事件和对应的回调函数
    topics: {},
    
    // 订阅方法
    subscribe: function(topic, listener) {
        if (!this.topics[topic]) this.topics[topic] = [];
        this.topics[topic].push(listener);
        
        // 返回一个可以用于取消订阅的函数
        return () => {
            const index = this.topics[topic].indexOf(listener);
            if (index !== -1) this.topics[topic].splice(index, 1);
        };
    },
    
    // 发布方法
    publish: function(topic, data) {
        if (!this.topics[topic]) return;
        this.topics[topic].forEach(listener => {
            listener(data);
        });
    }
};

// --- 使用示例 ---
// 模块A:订阅 'user-login' 事件
const unsubscribe = eventHub.subscribe('user-login', (userInfo) => {
    console.log(`模块A收到通知,用户 ${userInfo.name} 已登录。`);
});

// 模块B:发布 'user-login' 事件
eventHub.publish('user-login', { name: '张三' }); 
// 输出: 模块A收到通知,用户 张三 已登录。

// 当不再需要时,可以取消订阅
// unsubscribe(); 

经典实现

其实我觉得这个思想类似于浏览器的 addEventListener

浏览器 API 中的 window 对象上的事件机制,是发布-订阅模式的一种经典实现

DOM 事件系统(包括 window 上的事件)就是浏览器原生实现的、基于发布-订阅模式的事件架构

DOM 事件系统如何实现发布-订阅

让我们把浏览器的事件模型和标准的发布-订阅模式做个映射:

模式角色 DOM 事件系统中的对应实现 说明
事件调度中心 windowdocumentElement 等 DOM 节点 每个 DOM 节点都内置了事件管理能力
订阅 (Subscribe) addEventListener('eventName', callback) 订阅特定事件类型
发布 (Publish) 用户交互或代码触发:dispatchEvent(event)、点击等 触发事件,执行所有订阅的回调
取消订阅 (Unsubscribe) removeEventListener('eventName', callback) 移除事件监听,避免内存泄漏
事件通道 事件类型字符串,如 'click''resize''message' 类似发布-订阅中的"topic"

window 就是典型的事件总线

// ========== window 作为事件调度中心 ==========

// 1. 订阅 (Subscribe):监听一个自定义事件
window.addEventListener('user-logged-in', (event) => {
    console.log(`收到通知,用户 ${event.detail.name} 登录了`);
    // 可以触发任何行为
});

// 2. 发布 (Publish):在任意地方触发事件
function login() {
    // ... 登录逻辑 ...
    const customEvent = new CustomEvent('user-logged-in', {
        detail: { id: 1, name: '张三' }
    });
    window.dispatchEvent(customEvent);
}

// 3. 取消订阅 (Unsubscribe)
const handler = (event) => { console.log('只会执行一次'); };
window.addEventListener('once-event', handler);
// 不再需要时移除
window.removeEventListener('once-event', handler);

理解 window 事件是发布-订阅模式,对掌握浏览器 API 和设计模式有双重价值:

  1. 解释了很多原生 API 的行为

    • window.addEventListener('resize', handler) — 订阅窗口大小变化事件
    • window.addEventListener('online', handler) — 订阅网络状态变化
    • window.addEventListener('message', handler) — 订阅跨窗口消息(iframe 通信)
    • 这些都遵循同样的"先订阅、后触发、最后取消订阅"模式。
  2. 揭示了事件委托的原理: 由于事件会冒泡,在 windowdocument 上订阅一个事件,可以接收到任何子元素触发的事件。这正是利用了"一个调度中心可以接收所有发布"的特性。

    // 事件委托:在 window 上订阅,捕获所有点击
    window.addEventListener('click', (event) => {
        if (event.target.matches('.btn-delete')) {
            console.log('删除按钮被点击');
        }
    });
    

应用场景

理解原理后,更重要的是知道它在哪些场景下能真正派上用场。

  • 跨组件通信:在大型前端应用中,用于解决没有直接关系的组件(如兄弟组件、跨层级组件)之间的通信问题,可以避免通过父组件层层传递回调函数的麻烦。
  • 异步编程:在处理AJAX请求、图片加载、脚本加载等异步操作时,可以用发布-订阅模式来管理成功、失败、完成等不同状态下的回调,让代码更清晰。
  • 模块解耦:将一个复杂系统中的不同功能模块(如购物车、用户中心、商品展示)通过事件中心进行通信,可以显著降低模块间的直接依赖,使得各个模块可以独立开发、测试和维护。
  • MV 框架的底层实现*:Vue.js 中组件间的 $on / $emit 方法,本质上就是基于发布-订阅模式的实现。

注意事项

在使用这种模式时,有几个“坑”需要特别注意:

  • 内存泄漏:当一个组件或对象被销毁时,一定要记得调用 unsubscribeoff 方法,将它之前订阅的事件从事件中心移除。否则,事件中心的回调函数依然持有对已销毁对象的引用,导致其无法被垃圾回收,从而造成内存泄漏。
  • 过度使用:虽然模式好用,但过度使用会使应用中的数据流变得非常隐蔽和难以追踪。当一个事件的触发会引发一连串不可见的连锁反应时,代码的调试和维护会变得异常困难。对于简单的父子组件通信,直接传递 props 或调用方法仍是更清晰的选择。
  • 事件命名冲突:在大型项目中,事件名称容易重复,引发非预期的行为。建议使用一套清晰的命名规范,如 模块名:动作名(例如 user:login, cart:add)。

三、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                constructor (){
                    this.events = {};
                }

                on(eventName , callback ){
                    if (!this.events[eventName]){
                        this.events[eventName] = [] ;
                    }
                    this.events[eventName].push(callback);
                }

                emit(eventName , ...args){
                    const callbacks = this.events[eventName];
                    if (callbacks && callbacks.length){
                        callbacks.forEach(callback => {
                            callback(...args);
                        });
                    }
                }
                
            }
        </script>
    </body>
</html>

根据题目要求,我们需要实现一个 EventEmitter 类,支持:

  1. 同一名称事件可以有多个不同的执行函数
  2. on 方法添加事件监听
  3. emit 方法触发事件
class EventEmitter {
    constructor() {
        // 存储事件及其对应的回调函数列表
        this.events = {};
    }
    
    // 添加事件监听
    on(eventName, callback) {
        // 如果该事件还没有对应的回调数组,则初始化一个空数组
        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }
        // 将回调函数添加到数组中
        this.events[eventName].push(callback);
    }
    
    // 触发事件
    emit(eventName, ...args) {
        // 获取该事件对应的回调函数列表
        const callbacks = this.events[eventName];
        // 如果存在回调函数,则依次执行
        if (callbacks && callbacks.length) {
            callbacks.forEach(callback => {
                callback(...args);
            });
        }
    }
}

使用示例

const emitter = new EventEmitter();

// 添加多个监听同一个事件
emitter.on('click', () => console.log('clicked 1'));
emitter.on('click', (msg) => console.log('clicked 2:', msg));
emitter.on('click', (msg) => console.log('clicked 3:', msg));

// 触发事件
emitter.emit('click', 'hello');
// 输出:
// clicked 1
// clicked 2: hello
// clicked 3: hello

代码说明

  1. constructor:初始化一个空对象 events 用于存储事件名和对应的回调函数数组

  2. on(eventName, callback)

    • 检查 events 对象中是否已存在该事件名的回调数组
    • 如果不存在,创建空数组
    • 将回调函数添加到数组中
  3. emit(eventName, ...args)

    • 获取该事件对应的回调函数数组
    • 如果存在,遍历数组并依次执行每个回调函数
    • 使用扩展运算符 ...args 将传入的参数传递给每个回调函数

这个实现满足题目的所有要求:支持同一事件的多个回调函数,通过 on 添加,通过 emit 触发。

四、题目 FED20 观察者模式

描述

请补全JavaScript代码,完成"Observer"、"Observerd"类实现观察者模式。要求如下:

  1. 被观察者构造函数需要包含"name"属性和"state"属性且"state"初始值为"走路"

  2. 被观察者创建"setObserver"函数用于保存观察者们

  3. 被观察者创建"setState"函数用于设置该观察者"state"并且通知所有观察者

  4. 观察者创建"update"函数用于被观察者进行消息通知,该函数需要打印(console.log)数据,数据格式为:小明正在走路。其中"小明"为被观察者的"name"属性,"走路"为被观察者的"state"属性

注意:"Observer"为观察者,"Observerd"为被观察者

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            // 补全代码
            class Observerd {
                
            }

            class Observer {
                
            }
        </script>
    </body>
</html>

五、观察者模式

在 JavaScript 中,经常会遇到一个问题:你需要一种方法来响应特定事件,并利用这些事件提供的数据来更新页面的某些部分。

例如,用户输入后,你需要将其应用到一个或多个组件中。这会导致代码中出现大量的推送和拉取操作,以保持所有内容的同步。

观察者模式正是在这种情况下发挥作用——它支持元素之间的一对多数据绑定。

这种单向数据绑定可以由事件驱动。借助这种模式,您可以构建可重用的代码,以满足您的特定需求。

核心概念

  • 被观察者(Observable):维护一组观察者,状态变化时自动通知它们
  • 观察者(Observer):订阅被观察者,当被通知时执行相应逻辑

被观察者的三个核心部分

EventObserver
│ 
├── subscribe: adds new observable events
│ 
├── unsubscribe: removes observable events
|
└── broadcast: executes all events with bound data
部分 作用
observers 数组,存储所有观察者
subscribe() 添加观察者
unsubscribe() 移除观察者
notify(data) 通知所有观察者

基础实现(ES6 Class)

class Observable {
  constructor() {
    this.observers = [];
  }
  subscribe(func) {
    this.observers.push(func);
  }
  unsubscribe(func) {
    this.observers = this.observers.filter(observer => observer !== func);
  }
  notify(data) {
    this.observers.forEach(observer => observer(data));
  }
}

观察者模式的实际应用

例如博客字数统计演示

创建一个博客文章输入框,系统自动统计字数。用户每次按键输入,都通过观察者模式触发同步更新。

  1. 观察者模式追踪文本区域的变化
  2. 字数统计实时显示在输入框下方
  3. 箭头函数实现单行事件绑定
  4. 广播事件驱动变更给所有订阅者

字数统计函数

const getWordCount = (text) => text ? text.trim().split(/\s+/).length : 0;

单元测试示例

// 准备
const blogPost = 'This is a blog \n\n  post with a word count.     ';

// 执行
const count = getWordCount(blogPost);

// 验证
assert.strictEqual(count, 9);

注:该函数能处理多种边界情况,包括换行、多个空格等。

DOM 集成步骤

  1. HTML 结构
<textarea id="blogPost" placeholder="Enter your blog post..." class="blogPost">
</textarea>
  1. JavaScript 实现
// 创建字数显示元素
const wordCountElement = document.createElement('p');
wordCountElement.className = 'wordCount';
wordCountElement.innerHTML = 'Word Count: <strong id="blogWordCount">0</strong>';
document.body.appendChild(wordCountElement);

// 创建观察者实例
const blogObserver = new EventObserver();

// 订阅更新
blogObserver.subscribe((text) => {
  const blogCount = document.getElementById('blogWordCount');
  blogCount.textContent = getWordCount(text);
});

// 绑定事件
const blogPost = document.getElementById('blogPost');
blogPost.addEventListener('keyup', () => blogObserver.broadcast(blogPost.value));

扩展:RxJS

RxJS 是一个库,它通过使用 observable 序列来编写异步和基于事件的程序。它提供了一个核心类型 Observable,附属类型 (Observer、 Schedulers、 Subjects) 和受 [Array#extras] 启发的操作符 (map、filter、reduce、every, 等等),这些数组操作符可以把异步事件作为集合来处理。

可以把 RxJS 当做是用来处理事件的 Lodash

ReactiveX 结合了 观察者模式迭代器模式使用集合的函数式编程,以满足以一种理想方式来管理事件序列所需要的一切。

RxJS 中用来解决异步事件管理的的基本概念是:

  • Observable (可观察对象): 表示一个概念,这个概念是一个可调用的未来值或事件的集合。
  • Observer (观察者): 一个回调函数的集合,它知道如何去监听由 Observable 提供的值。
  • Subscription (订阅): 表示 Observable 的执行,主要用于取消 Observable 的执行。
  • Operators (操作符): 采用函数式编程风格的纯函数 (pure function),使用像 mapfilterconcatflatMap 等这样的操作符来处理集合。
  • Subject (主体): 相当于 EventEmitter,并且是将值或事件多路推送给多个 Observer 的唯一方式。
  • Schedulers (调度器): 用来控制并发并且是中央集权的调度员,允许我们在发生计算时进行协调,例如 setTimeoutrequestAnimationFrame 或其他。

结合观察者模式 + 迭代器模式 + 函数式编程

示例:监听鼠标拖拽事件

merge(
  fromEvent(document, "mousedown").pipe(mapTo(false)),
  fromEvent(document, "mousemove").pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, "mouseup")))
.subscribe(isDragging => console.log(isDragging));

优点

  • 关注点分离
  • 符合单一职责原则
  • 观察者和被观察者低耦合

缺点

  • 观察者过多时,通知所有观察者可能带来性能问题

六、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class Observerd {
                constructor(name){
                    this.name = name ;
                    this.state = '走路' ;
                    this.observers = [];
                }

                setObserver(observer){
                    this.observers.push (observer);
                }

                setState(state){
                    this.state = state ;
                    this.notifyAllObservers();
                }     

                notifyAllObservers(){
                    this.observers.forEach(observer => {
                        observer.update(this);
                    });
                }
            }

            class Observer {
                update(observed){
                    console.log(`${observed.name}正在${observed.state}`);
                }
            }
        </script>
    </body>
</html>

根据题目要求,需要实现观察者模式。

以下是补全的代码:

class Observerd {
    constructor(name) {
        this.name = name;
        this.state = "走路";
        this.observers = [];
    }
    
    setObserver(observer) {
        this.observers.push(observer);
    }
    
    setState(state) {
        this.state = state;
        this.notifyAllObservers();
    }
    
    notifyAllObservers() {
        this.observers.forEach(observer => {
            observer.update(this);
        });
    }
}

class Observer {
    update(observed) {
        console.log(`${observed.name}正在${observed.state}`);
    }
}

代码说明:

  1. Observerd(被观察者)类

    • constructor(name):构造函数接收name参数,初始化name属性、state属性(初始值为"走路")和observers数组(存储观察者)
    • setObserver(observer):添加观察者到observers数组
    • setState(state):更新state属性,并通知所有观察者
    • notifyAllObservers():遍历所有观察者,调用每个观察者的update方法,传入当前被观察者实例
  2. Observer(观察者)类

    • update(observed):接收被观察者对象,按照指定格式打印日志(如:"小明正在走路")

使用示例:

const observed = new Observerd("小明");
const observer = new Observer();

observed.setObserver(observer);
observed.setState("跑步"); // 控制台输出:小明正在跑步

七、总结

观察者模式与发布者-订阅者模式有何不同?

虽然两种模式都涉及一对多依赖关系,但关键区别在于主体(或发布者)与其观察者(或订阅者)之间的通信方式。

  • 在观察者模式中,主体直接通知其观察者。

  • 在发布-订阅模式中,发布者将通知发送到中介(或通道),然后由中介将通知推送给订阅者。

这种额外的抽象层使得通知过程更加灵活和可定制。

原文:

How does the Observer Pattern differ from the Publisher-Subscriber Pattern?

While both patterns involve one-to-many dependencies, the key difference lies in how the subject (or publisher) communicates with its observers (or subscribers). In the Observer Pattern, the subject directly notifies its observers. In the Publisher-Subscriber Pattern, the publisher sends notifications to a mediator (or channel), which then pushes the notifications to the subscribers. This extra level of abstraction allows for greater flexibility and customization of the notification process.

发布订阅模式 观察者模式
有没有中间人 有(事件中心) 没有(直接通知)
双方知不知道对方存在 不知道(通过事件名交流) 知道(被观察者存着观察者列表)
生活类比 微信群:发消息的人不知道谁在看 你订阅了某人的微博:他更新了主动推给你

说实话,这两题面试手撕代码题实际上背负了很多抽象概念,单独的内容也都可以抽出来好好讲讲,难度并不低。

对于初学者建议按这个顺序来:

  1. 先熟悉上面的代码(应付面试)
  2. 然后自己手敲 3 遍(不要复制粘贴)
  3. 再去看本文的"应用场景"部分(这时候才有共鸣)
  4. 最后再去理解 RxJS、优缺点这些进阶内容

我也是初次深入学习一下这些概念,信息量大得有点懵。

但是回头看看我实际写过的项目代码,很多已经用到了这些思想,只是当时没有注意到这个模式。不妨现在好好回头去整理整理。

限于个人写作,文中若有疏漏,还请不吝赐教。

参考文档

发布订阅模式 vs 观察者模式:它们真的是一回事吗?本文深入解析发布订阅与观察者模式的核心差异:发布订阅通过事件中心实现 - 掘金

Node.js EventEmitter | 菜鸟教程

events 事件触发器 | Node.js v24 文档

观察者模式 - JavaScript 设计模式

JavaScript Design Patterns: The Observer Pattern — SitePoint

TypeScript学习系列(二):高级类型篇

前言

在上一篇文章juejin.cn/post/762508… 中,介绍了 TypeScript 系统中的基础类型及其用法,本篇我们将进击 TypeScript 中一些高级类型,学完本篇,就能对 TypeScript 系统中的各大类型有个比较全面的理解了。

泛型

泛型可以理解为类型参数类型变量,在定义类型别名、接口、类、函数参数时都会用到。在上一篇文章中我们已经介绍过了,下边来看与它相关联的一些场景。

extends 约束

extends 关键字主要用于泛型约束中,例如:

type IsNumber<T> = T extends number ? T : never

IsNumber 类型接收泛型 T 作为类型参数,随后使用 extends 关键字

  • T extends number:表示 T 能赋值于 numberextends 表示 赋值于
  • T extends number ? T : never:这里运用条件类型(类似 js 的三元表达式),意思是,T 如果能赋值于 number 类型,那么返回结果就是 T 类型,反之就返回 never 忽略类型。

那上边这个例子就很好理解,主要使用 extends 关键字来约束泛型在满足 number 类型时再返回它。

内置工具约束

在了解了泛型extends 和基本的条件类型,我们可以来看 TypeScript 提供的一些内置工具,主要用于约束泛型,比如 Partial<T>Required<T>,都是工具名称 + <T> 的组合,专为约束泛型 T 而生。

Partial

Partial 允许我们将传入泛型中的所有属性变为可选的属性,例如:

type Person = {
  name: string,
  age: number
}

type MyPartialProperties = Partial<Person>

image.png

所有属性名旁边都加上了 ? 符号,表示可选属性。

Required

Required 将传入类型中的所有属性变为必传的属性,例如:

type Person = {
  name?: string,
  age: number
}

type MyRequiredProperties = Required<Person> // Person 上的所有属性都是必须的,? 会去掉

image.png

可以看到原本可选 name 属性旁边的 ? 被去掉了,变成了必须项。

ReadOnly

ReadOnly 可以将类型上的属性指定为只读的:

type Person = {
  name?: string,
  age: number
}

type MyReadOnlyProperties = Readonly<Person> // Person 上的所有属性前边会加上 readonly 表示只读属性

image.png

所有属性前边都加上了 readonly 描述符,表示属性是只读的。

Pick

Pick 可以从类型中筛选出某个属性:

type Person = {
  name: string,
  age: number
}

type MyPickProperties = Pick<Person, "name"> // 仅筛选出 Person 中为 name 的那个属性

image.png

从泛型 Person 中取出了 name 属性作为新类型,如上图 MyPickProperties 只剩下 name 属性。

Omit

可以从对象类型中排除掉不需要的属性,支持传联合类型用于同时排除多个属性:

type Person = {
  name: string,
  age: number,
  sex: string
}

type MyOmitProperties1 = Omit<Person, "name"> // 排除掉 Person 类型中的 name 属性
type MyOmitProperties2 = Omit<Person, "name" | "age"> // 排除掉 Person 类型中的 name 和 age 属性

image.png

将泛型 Person 中的 name 属性排除掉了。

image.png

通过联合类型将泛型 Person 中的 nameage 属性一块排除掉。

Record

可以创建一个新的对象类型,这个新对象类型是由某个指定类型中的属性组成的,同时可以指定新类型上的属性类型:

type Example = 'name' | 'age' | 'sex'

type MyRecord = Record<Example, string> // 新类型中属性由 Example 中组成,同时新类型上属性类型指定为 string

image.png

Example 类型中的所有属性组成的新类型 MyRecord,并且指定新类型中属性的类型为 string

ReturnType

ReturnType 用于提取函数类型的返回值类型,而可以不用手动指定函数的返回值类型:

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

type GetFuncReturnType = ReturnType<typeof testExample> // number

image.png

提取 testExample 函数的返回值类型于新类型 GetFuncReturnType 中。

Extract

Extract 用于从两个类型中取出相互兼容的部分,其实就是取交集:

type A = string | number
type B = boolean | number

type ExtractAB = Extract<A, B> // number(相交的部分就是 number)

image.png

类型 A 和类型 B 共有的部分就是 number,自然取到的交集就是 number,作为 ExtractAB 的新类型。

Exclude

Exclude 主要用于从联合类型中排除掉不需要的属性:

type Example = string | number | boolean
type ExcludeString = Exclude<Example, string> // ExcludeString 剩下 number | boolean

image.png

Example 类型中排除掉 string 类型,剩下的 numberboolean 就作为 ExcludeString 的类型。

type Example1 = "dog" | "cat"
type Example2 = "cat"
type ExcludeCat = Exclude<Example1, Example2>

image.png

上边这个例子可以看出,Exclude 会排除掉 Example1 中和 Example2 相同那一部分,Example1 中和 Example2 相同的那部分是 cat,故排除掉 Example1 中的 cat

我们可以发现,ExtractExclude 的操作刚好相反,Extract 是取两个泛型相交的部分,而 Exclude 是从第一个泛型中排除掉和第二个泛型相同(相交)的那部分。Extract 是取,而 Exclude 是排,两者都是取交集

NonNullable

NonNullable 用于排除类型中为 nullundefined 的部分,返回一个新类型:

type Example = number | undefined | null
type NotNullAndUndefined = NonNullable<Example> // 排除类型 C 中为 null 和 undefined 的部分

image.png

NonNullable 可以排除 Example 类型中的 nullundefined

条件类型

条件类型我们在开篇泛型那里就见过了,ts 中的条件类型类似于 js 中的三元运算符,一般配合 extends 一起使用,如:T extends U ? U : never。下面我们来看下分布式条件类型

分布式条件类型

当条件类型作用于泛型类型参数时,如果该类型是联合类型(注意是联合类型),则条件会分布到每一个联合成员上,分别计算,再将结果合并成一个新的联合类型,我们来举例看下:

type Animal = "dog" | "cat"
type AnimalOrFruit = "dog" | "apple" | "cat" | "banana"

type TogetherExample<T> = T extends Animal ? T : never

type OnlyAnimal = TogetherExample<AnimalOrFruit>

image.png

  • T extends AnimalT 能否赋值于 Animal 类型(Animal 类型中包含 dogcat,也就是 T 是否为 dogcat)。
  • T extends Animal ? T : never:接收的泛型 T 如果能赋值于 Animaldogcat),那么就取这个 T,反之就用 never 来忽略类型。
  • TogetherExample<AnimalOrFruit>AnimalOrFruit 类型中为 dogcat 的就正常收集到新类型 OnlyAnimal 中,其他的 applebanana 由于不能赋值于 Animal,被使用 never 类型忽略了。故最终新类型 OnlyAnimal 中仅包含 dogcat

从上边例子也能看出来,所谓分布式条件类型,就是当接收的泛型参数为联合类型时,会将条件作用于每个类型中。

infer

infer 关键字的作用是延时推导,它会在类型未推导时进行占位,等到真正推导出来后,它能返回准确的类型:

type Example<T> = T extends (...args: any) => infer R ? R : never

type ExampleFunc = (a: number, b: number) => number

type TestGetFuncReturnExample = Example<ExampleFunc> // number
  • T extends (...args: any) => infer R ? R : never:判断 T 是否为一个函数类型
  • (...args: any) => infer Rargs 为函数入参,infer R 为返回值类型的占位操作

整个意思就是,T 是函数类型的话,能推导出它的返回值类型 R;反之,就返回 never。代入上边例子,ExampleFunc 是一个函数类型,先使用 infer R 占位返回值类型,等到真正推导出函数的返回值类型为 number 时,它能准确返回类型。

映射类型

映射类型可以基于现有的类型来修改某个属性或通过排除属性来生成新的类型,修改属性包括把属性映射为只读可选、属性名添加前缀等操作。排除属性主要就是将不符合条件的属性映射为 never。我们逐个举例来看下:

  • 映射为只读属性
interface Person {
    name: string,
    age: number,
}

type MyReadOnlyProperties<T> = {
    readonly [P in keyof T]: T[P] // 通过 readonly 关键字将属性映射为只读
}

type ReadOnlyPerson = MyReadOnlyProperties<Person>

image.png

  • 映射为可选属性
interface Person {
    name: string,
    age: number,
}

type MyPartialProperties<T> = {
    [P in keyof T]?: T[P] // 通过 ? 符号将属性映射为可选
}

type PartialPerson = MyPartialProperties<Person>

image.png

  • 为属性添加前缀,可以结合 as 来完成:
interface Person {
    name: string,
    age: number,
}

type MyPrefixProperties<T> = {
  [P in keyof T as `prefix_${string & P}`]: T[P]
}

type PrefixProperties = MyPrefixProperties<Person>

image.png

1、首先使用 P in keyof T 将枚举出 T 类型中的每个属性

2、使用 as 来将每个属性重命名为 prefix_${string & P},即加上 prefix_ 这个前缀,如 name 就会变成 prefix_name,这里的 string & P 作用就是确保 P 类型是一个字符串

3、T[P]:属性的值就正常映射为 P 属性在 T 上的原有类型

  • 仅映射特定类型来生成新的类型
interface Person { 
    name: string,
    age: number,
}

type GenerateNewTypeWithString<T> = {
    [P in keyof T as T[P] extends string ? P : never]: T[P] // 仅映射出类型为 string 的属性
}

type NewTypeString = GenerateNewTypeWithString<Person> // 仅包含 string 类型的属性,即 name

image.png

索引类型

索引类型可以通过属性名直接访问某个属性的具体类型,主要使用中括号 [],例如:

interface Person {
    name: string
    age: number
}
type PersonOfAge = Person["age"]

image.png

如上访问 Person 上的 age 属性,其类型就是 number

还可以获取类型中所有属性的联合类型,如下:

interface Person {
    name: string
    age: number
}

type PersonOfValue = Person[keyof Person]

image.png

使用 keyof 遍历出 Person 上所有的属性,相当于:

  • Person["name"] | Person["age"]
  • 那结果就是 string | age

类型守卫

类型守卫可以根据条件来细化变量的具体类型,从而使代码在运行时更加安全和可维护,主要通过几种方式来实现,包括 typeofinstanceofin自定义类型函数,我们逐个来看看。

type A = string | number | boolean

function logInfo(a: A): void {
    if(typeof a === 'string') {
        // 当 a 为 string 时执行某些操作...
        console.log(`variable ${a} is a string`)
    }
}
  • typeof

image.png

logInfo 函数接收形参 a,用类型 A 约束,类型 A 是个联合类型,也就是说形参 a 的类型可能是 stringnumberboolean 中的一个,在函数体内通过 typeof 来判断 astring 类型,那么在该条件分支内,ts 就能确定 a 的类型为 string 了,这也很好的避免由于类型不确定导致的意外操作。

  • instanceof
class Person {
    speak() {
        console.log('people can speak')
    }
}

class Animal {
    fly() {
        console.log('some animals can fly')
    }
}

const p1 = new Person()
const a1 = new Animal()

function personOrAnimal(a: Person | Animal) {
    if(a instanceof Person) {
        a.speak()
    } else if(a instanceof Animal) {
        a.fly()
    }
}

personOrAnimal(a1) // people can speak

使用 instanceof 能够检查变量是否属于某个类的实例,这样在对应条件分支内 ts 编译器就能确定该实例所属类,从而能给予我们该实例上能调用的方法和属性的提示,这也很好保证了运行时的准确性。

  • in
interface Person {
    write(): void
}
interface Animal {
    eat(): void
    fly(): void
}

class A implements Animal {
    eat() {
        console.log('animal can eat')
    }
    fly() {
        console.log('animal can fly')
    }
}

function getInfo(a: Person | Animal) {
    if('write' in a) {
        a.write()
    } else if('fly' in a) {
        a.fly()
        a.eat()
    }
}

上边 getInfo 函数接收一个实例,通过 in 来判断属性是否存在于实例上,如果存在就能直接使用,而且该属性所属类上的其他属性也能直接访问,比如判断 a 上如果存在 fly 属性。那么 fly 属性所属类上的 eat 也能访问了。

  • 自定义类型
function isString(str: any) {
    return typeof str === 'string'
}

function getInfo(a: any) {
    if(isString(a)) {
        console.log('the operation of string a')
    }
}

通过使用自定义的 isString 函数来判断某个变量是否为特定的类型,满足就能在条件分支内对该类型变量进行一些操作。

类是面向对象的核心概念,它主要封装了对象的状态和行为,也就对应着属性和方法,ts 为类提供了类型检查功能。为此我们可以在类中为属性或方法定义类型,如下例子:

class Animal {
    name: string
    constructor(name: string) {
        this.name = name
    }
    say(): void {
        console.log('动物发出声音')
    }
}

继承

在 ts 中实现类的继承和 js 中是一致的,如下示例:

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

const d1 = new Dog("哈士奇")
console.log(d1.name) // 哈士奇

super

super 主要用于调用父类的构造函数,将子类构造函数接收的参数传给父类构造函数,由父类构造函数来做初始化,这样也省去了在子类构造函数中重复声明初始化的操作。

class Dog extends Animal {
    constructor(name: string) {
        super(name) // 调用父类构造函数初始化
        // this.name = name // 相当于
        // this.xxx = xxx // 更多参数
    }
    say(): void { // 重写父类方法
        console.log("wang wang~")
    }
}

修饰符

  • public:可在任何地方访问
  • private:仅可在类内部访问,子类也不允许访问
  • protected:仅可在类内部、子类中访问

下边我们举例来理解这三个修饰符:

  • 实例访问:仅能访问公有属性(public)
class Person {
    public name: string // 公共属性
    private age: number // 私有属性
    protected sex: string // 保护属性

    constructor(name: string, age: number, sex: string) {
        this.name = name
        this.age = age
        this.sex = sex
    }
    getPersonAge(): number { 
        return this.age // 私有属性,仅在当前类能访问
    }
}

const p1 = new Person("man", 11, 'male')
console.log(p1.name) // man 
console.log(p1.age) // 编译报错,age 是类私有属性
console.log(p1.sex) // 编译报错,sex 是类的保护属性,仅可子类中访问
  • 子类访问:可以访问公有属性(public)和保护属性(protected)
class Student extends Person {
    constructor(name: string, age: number, sex: string) {
        super(name, age, sex)
    }

    getStudentName(): string {
        return this.name // 正常
    }

    getStudentSex(): string {
        return this.sex // 正常
    }

    getStudentAge(): number {
        return this.age // 编译报错,age 是私有属性,仅能声明类自身访问
    }
}

抽象类与接口

抽象类就是使用 abstract 修饰的类,抽象类中可以定义抽象、也可以定义具体方法,继承抽象类的子类必须实现抽象类中定义的抽象方法,同时也可以重写抽象类中的具体方法,我们看下边例子就知道了:

abstract class Person {
    abstract myHobby(): void // 抽象方法

    walk(): void {
        console.log('people walking')
    }
}

class Student extends Person {
    myHobby(): void { // 子类必须实现父类的抽象方法
        console.log('music')
    }
    walk(): void { // 重写父类的方法
        console.log('student walking')
    }
}

const s1 = new Student()
s1.myHobby() // music
s1.walk() // student walking

再来看看接口: 定义一组规范,不提供具体的实现,仅包含函数的签名

interface Play {
    games(): void // 函数签名
}

由实现类来完成函数体:

class Student extends Person implements Play {
    // 实现接口中的函数签名
    games() {
        console.log('lol')
    }

    myHobby(): void {
        console.log('music')
    }
    walk(): void {
        console.log('student walking')
    }
}
const s1 = new Student()
s1.games() // lol

为此,我们可以得出抽象类接口的区别:

相同点:都用于定义行为规范,抽象类的抽象方法和接口中的函数签名都必须在子类中实现。

不同点:抽象类中可以包含具体方法的实现,而接口仅含函数签名或属性签名,不包含具体方法的实现。抽象类不能直接实例化,只能作为基类通过子类来继承;而接口可以被类实现,一个类可以同时实现多个接口,使用 implements 关键字。

总结

本篇文章主要围绕 TypeScript 中的高级类型展开介绍,从泛型开始,扩展来看它的一些场景,包括泛型约束、ts 中的内置工具类型。然后就是常用的条件类型、映射类型、索引类型、类型守卫,以及我们常用的类,最后对比了抽象类与接口的区别。

我是 luckyCover,我正在持续更新 TypeScript 学习系列的文章,欢迎大家一起讨论学习呀~

手撕 V8:我是如何用 2.67ms 的心跳活捉 700ms 冻结幽灵的

手撕 V8:我是如何用 2.67ms 的心跳活捉 700ms 冻结幽灵的

最近在搞一个高性能 Web 应用,被一个"幽灵"困扰了很久:页面总会无征兆地出现瞬间掉帧。

大家都知道这是 V8 的 Stop-The-World (STW) 在搞鬼,但在浏览器里,监控 STW 是个悖论——如果主线程被冻结了,你用来监控的主线程代码(比如 rAF 或 performance.now)本身也是冻结的。

你没法在自己心脏停跳的时候记录停跳时长。

为了抓到这个幽灵,我折腾出了一个叫 stw-sentinel 的小工具,思路挺"偏门"的,发出来给各位老哥 Review 下。

核心思路:找一个"编外"保镖

既然主线程不可信,我就把目光投向了 AudioWorklet

  • 优先级极高:它跑在音频回调线程上,受操作系统音频驱动调度,优先级甚至高于浏览器的渲染线程。
  • 物理隔离:哪怕 V8 的主线程正在进行大规模的垃圾回收(Major GC),只要 CPU 还没爆表,AudioWorklet 依然能稳定跳动。

技术栈:无锁通信

监控者(AudioWorklet)和被监控者(Main Thread)之间必须保持绝对的高效:

  • SharedArrayBuffer (SAB) :两边共享一块物理内存。
  • Atomics:主线程每隔一段时间去 SAB 里打个卡,AudioWorklet 负责高频检查。如果打卡中断,幽灵就现身了。

踩过的深坑

中间最想吐槽的是底层寻址。在处理 SAB 偏移量时,我被一个 16 bytes ÷ 4 = 4 elements 的寻址偏移搞掉了半个通宵。

在 SharedArrayBuffer 中,内存是连续的字节流。当你用 Int32Array 操作时,索引是按 4 字节步进的:

Index = ByteOffset / 4

所以 16 字节的 Header 对应的索引就是 4。这个 16 → 4 的转换,就是高级语言开发者和内存地址之间的"最后一公里"。JS 层的索引步长和 C 层的字节偏移在这里撞车了,这种底层 Bug 真的只能靠硬啃。

战果

在我的测试 Lab 里,我成功捕获到了一次长达 684.5ms 的 V8 冻结,而此时我的 Sentinel 心跳依然稳定在 2.67ms(48kHz/128 frames)。

这种"降维打击"的观测感非常爽。

如果你在本地跑不起来,先别急着提 Issue。检查下你的 Response Headers。在这个 Spectre 漏洞后的时代,没有 Cross-Origin-Opener-Policy: same-origin,你连 SharedArrayBuffer 的边都摸不到。这是属于硬核开发者的"入场券"。


  • 源码 & 文档: GitHub - stw-sentinel

  • 在线演示(Lab): diffserv.xyz/lab

  • 一行命令体验: npx stw-sentinel

    如果你也对 V8 性能、线程隔离或者 SharedArrayBuffer 感兴趣,欢迎来 GitHub 提个 Issue 或者点个 Star。

重排、重绘、合成:浏览器渲染的“三兄弟”,你惹不起也躲不过

你给一个元素悄悄改了宽度,结果整个页面都抖了一下?你加了个动画,电脑风扇开始狂转?今天我们来认识浏览器渲染里的“三兄弟”——重排、重绘、合成。弄懂它们,你就能写出流畅60帧的页面,告别卡顿。

前言

想象一下,你家客厅要重新装修。你只是换了个抱枕(重绘),很轻松。但如果你要把墙拆了(重排),那得搬家具、砸墙、重新粉刷,累得半死。如果只是把电视画面换个图层(合成),连工人都不要,遥控器一按就行。

浏览器的渲染也是这个道理。理解这三种操作的成本,就能写出性能飞起的页面。

一、先复习:渲染流水线

之前我们讲过,浏览器把HTML/CSS变成屏幕上的像素,要经过:DOM树 + CSSOM树 → 渲染树 → 布局(计算位置大小)→ 绘制(填充像素)→ 合成(合并图层)。

其中:

  • 重排(Reflow):重新计算布局(位置、大小)。成本最高。
  • 重绘(Repaint):重新绘制像素(颜色、背景、阴影等)。成本中等。
  • 合成(Composite):重新合并图层。成本极低(走GPU)。

二、重排:动到筋骨,全员遭殃

什么操作会触发重排?

  • 改变元素的几何属性widthheightmarginpaddingbordertopleft……
  • 改变DOM结构:增删元素、改变内容(文字变了导致高度变化)。
  • 读取某些布局属性offsetTopscrollTopclientWidthgetComputedStyle()。因为浏览器需要返回最新值,不得不强制重排。
  • 改变窗口大小(resize事件)。
  • 激活伪类(如:hover导致样式变化影响布局)。

重排的代价:浏览器要重新计算整个或部分渲染树,然后重新布局、绘制、合成。就像你拆了一面墙,整个房子都得重新量尺寸。

三、重绘:只换皮肤,不动骨架

什么操作会触发重绘但不触发重排?

  • 改变颜色colorbackground-colorborder-colorbox-shadow等。
  • 改变可见性visibility(但display: none会触发重排)。
  • 改变背景图outline等。

重绘的代价:不需要重新布局,但还是要重新绘制像素,比重排轻,但也不是免费。

四、合成:GPU加速的“超车道”

合成是成本最低的环节,因为它不涉及布局和绘制,只把已有的图层合并。能触发合成的属性有:

  • transform(平移、旋转、缩放)
  • opacity
  • filter

当你用transform: translateZ(0)will-change: transform时,浏览器会把这个元素提升到单独的合成层,后续动画只由GPU处理,完全不触发重排和重绘。这就是为什么动画推荐用transform而不是left

/* 差:触发重排 */
.box {
  transition: left 0.3s;
  left: 0;
}
.box:hover {
  left: 100px;
}

/* 好:只触发合成 */
.box {
  transition: transform 0.3s;
  transform: translateX(0);
}
.box:hover {
  transform: translateX(100px);
}

五、如何减少重排和重绘?

1. 批量修改样式

不要挨个改属性,用class一次改完:

// 差
element.style.width = '100px';
element.style.height = '100px';
element.style.margin = '10px';

// 好
element.classList.add('new-size');

2. 让元素脱离文档流再操作

比如要插入多个li,可以先隐藏(display: none),改完再显示,只触发两次重排。

const ul = document.getElementById('list');
ul.style.display = 'none';
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  ul.appendChild(li);
}
ul.style.display = 'block';

3. 使用文档片段(DocumentFragment)

const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  fragment.appendChild(li);
}
ul.appendChild(fragment); // 只触发一次重排

4. 读写分离

不要交替读取和修改布局属性,否则会触发多次重排。

// 差
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = boxes[i].offsetWidth + 'px'; // 读后立即写
}

// 好:先读后写
const widths = [];
for (let i = 0; i < boxes.length; i++) {
  widths.push(boxes[i].offsetWidth);
}
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = widths[i] + 'px';
}

5. 使用transformopacity做动画

永远不要用lefttopwidthmargin做动画,改用transform

6. 固定元素位置

position: fixedabsolute的元素,其重排影响范围较小(只在自己层内)。

7. 避免使用table布局

一个小改动可能触发整个table的重排。

六、实战:一个性能优化的例子

假设你要做一个跟随鼠标移动的小光点(类似鼠标特效)。错误做法:每帧改变top/left,触发重排。正确做法:用transform

// 差:每移动1px就重排一次
dot.style.left = x + 'px';
dot.style.top = y + 'px';

// 好:只触发合成
dot.style.transform = `translate(${x}px, ${y}px)`;

七、怎么分析页面重排/重绘?

Chrome DevTools → Performance 面板,录制一段操作,查看“Layout Shift”、“Paint”等标记。红色紫色区域越少越好。

八、总结:三兄弟的“饭量”

  • 重排:吃满汉全席,最贵。动几何、DOM结构。
  • 重绘:吃快餐,中等。动颜色、背景。
  • 合成:喝矿泉水,几乎免费。动transform、opacity。

优化口诀:能用transform别用left,能用class别改style,读写分离,批量操作。

如果你觉得今天的“三兄弟”够形象,点个赞让更多人看到。明天我们将聊聊JavaScript引擎与内存管理,看看V8是怎么给代码“打扫卫生”的。我们明天见!

深入理解 JS 中的栈与堆:从内存模型到数据结构,再谈内存泄漏

深入理解 JS 中的栈与堆:从内存模型到数据结构,再谈内存泄漏

在 JavaScript 编程中,“栈”(Stack)和 “堆”(Heap)是两个高频出现的核心概念 —— 它们既贯穿于 JS 的内存管理逻辑,也是计算机科学中经典的数据结构。理清栈与堆的本质区别,不仅能帮助我们理解 JS 代码的执行机制,更能从根源上规避内存泄漏这类影响前端性能的关键问题。

一、JS 内存模型中的栈与堆:内存管理的核心

JS 引擎在运行代码时,会将内存划分为栈和堆两个区域,二者各司其职,共同支撑代码的执行,核心差异体现在存储内容管理方式上。

1. 栈:轻量高效的 “自动管理区”

栈是 JS 引擎为快速执行设计的内存区域,具备 “连续内存、后进先出(LIFO)” 的特性,由系统自动分配和释放,无需垃圾回收(GC)介入,访问速度极快。

  • 存储内容:栈主要存储执行上下文(比如函数调用时的执行环境)、基本数据类型(Number、String、Boolean、Undefined、Null、Symbol、BigInt),以及引用类型的 “引用地址”。
  • 管理逻辑:函数调用时,JS 引擎会为函数创建执行上下文并压入栈中;函数执行结束后,该执行上下文会被立即弹出栈,对应的内存自动释放。这种 “压栈 - 弹栈” 的操作仅需移动内存指针,效率极高。

2. 堆:灵活的 “动态数据区”

堆是用于存储复杂数据的内存区域,内存分配不连续,可能产生内存碎片,访问速度慢于栈,其生命周期由垃圾回收机制(GC)管理(而非系统自动释放)。

  • 存储内容:堆中存放所有引用类型的真实数据,包括对象、数组、函数等。
  • 管理逻辑:当我们创建一个对象(如let obj = {a: 1})时,变量obj会被存入栈中,而对象{a: 1}的真实数据会被存入堆中,栈里的obj实际保存的是指向堆中该对象的引用地址。GC 会通过 “标记清除”(标记可达对象,回收未标记对象)或 “引用计数”(为对象计数,引用数为 0 则回收)的方式,清理堆中无引用的无用数据。

3. 栈与堆的关联:闭包的特殊场景

闭包是栈与堆交互的典型案例 —— 本该在函数执行结束后随栈释放的局部数据(比如函数内的变量),因被内部函数引用,会被转移到堆中持续存在,直到引用关系消失。例如:

function outer() {
    let obj = { a: 1 }; // 本该随outer执行结束释放
    return function inner() {
        console.log(obj); // 闭包引用导致obj被存入堆
    }
}
let fn = outer(); // inner保留对obj的引用,obj无法被回收

二、数据结构视角下的栈与堆:结构与用途的差异

除了内存管理,栈和堆也是两种核心数据结构,其设计逻辑和用途与内存模型中的栈堆截然不同。

1. 栈:线性有序的 “容器”

作为数据结构的栈是线性结构,严格遵循 “后进先出(LIFO)” 原则 —— 只能在栈顶进行元素的插入(压栈)和删除(弹栈)操作,无法随机访问中间元素。这种特性使其适用于函数调用栈、表达式求值、括号匹配等场景。

2. 堆:树状无序的 “优先级结构”

作为数据结构的堆是非线性的树状结构(通常为完全二叉树),无 “后进先出” 的规则,核心特点是 “堆顶元素为最值”:

  • 大顶堆:堆顶元素是整个堆的最大值;
  • 小顶堆:堆顶元素是整个堆的最小值。堆的核心用途是排序(如堆排序)、优先队列实现等,与内存模型中 “存储引用类型” 的堆无直接关联,仅为同名不同概念。

三、内存泄漏:本该释放的内存 “被滞留”

理解了栈与堆的内存管理逻辑,就能更清晰地认识内存泄漏问题 —— 这是前端性能优化的核心痛点,其本质和成因都与 “引用关系” 密切相关。

1. 内存泄漏的本质

内存泄漏的核心本质是:本该被垃圾回收机制回收的内存,因被意外保留了可达引用,导致无法释放,内存占用持续增长。最终会引发页面卡顿、响应缓慢,甚至浏览器崩溃。

判断一个对象是否可被回收,核心标准是 “可达性”:只要对象能被 JS 的根对象(如 window)通过引用链访问到,就会被判定为 “有用”,不会被 GC 回收;反之则会被清理。

2. 内存泄漏的常见成因

在 JS 中,内存泄漏的诱因多与 “不当保留引用” 有关,常见场景包括:

  • 定时器 / 延时器未清理:组件卸载后,定时器未取消,其内部函数会持续引用外部变量,导致变量无法被回收。例如 React 组件中未清除的setInterval,会让组件内的状态始终处于可达状态。
  • 事件监听未移除:给 DOM 元素绑定事件后,未在组件卸载或元素销毁时移除监听,监听函数会持续引用相关变量,造成内存泄漏。
  • DOM 引用未释放:即使移除了 DOM 节点(如document.body.removeChild(el)),若仍保留对该节点的变量引用(el未置为null),该节点仍可达,无法被 GC 回收。
  • 全局变量滥用:意外声明的全局变量(如未加let/const的变量)会挂载到window上,成为根对象的可达引用,永远无法被回收;刻意创建的全局变量若未手动置空,也会长期占用内存。
  • 闭包导致的泄漏:不合理的闭包会让栈中本该释放的局部变量被持久化到堆中,若闭包长期存在(如赋值给全局变量),则变量会一直无法回收。
  • Map/Set 使用不当:普通Map/Set对键 / 值的强引用关系,会导致即使对象本身被置为nullobj = null),只要Map/Set仍引用该对象,就无法被回收;而WeakMap/WeakSet的弱引用特性可避免此问题(对象被回收时,对应的键值对会自动清除)。
  • 订阅 / 发布模式未取消订阅:若未取消对事件总线、状态管理库的订阅,订阅函数会持续引用相关数据,造成泄漏。
  • Promise/Promise 链未结束:Promise 处于pending/rejected未处理状态,或请求发起后组件已卸载但响应未中止(如未使用AbortController终止 API 请求),会导致相关引用无法释放。

总结

栈与堆在 JS 中承担着不同的角色:内存模型中,栈是高效的自动管理区,堆是动态的引用类型存储区;数据结构中,栈是线性 LIFO 结构,堆是树状最值结构。而内存泄漏的本质,是打破了堆内存的 “可达性” 规则 —— 本该被 GC 清理的对象,因意外的引用关系始终处于可达状态。

理清栈、堆的内存逻辑,规避不当的引用行为(如及时清理定时器、移除监听、使用弱引用容器等),是写出高性能、低内存泄漏风险 JS 代码的核心前提。

❌