普通视图

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

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 - 肘子的 Swift 周报 #131

作者 Fatbobman
2026年4月13日 22:00

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用

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

作者 竹林818
2026年4月13日 18:03

背景

上个月,我接手了一个多链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算法入门:图解“冒泡排序”,彻底搞懂双重循环的奥义

作者 心连欣
2026年4月13日 17:08

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 判断里)。


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

React19项目中 FormEdit / FormEditModal 组件封装设计说明

2026年4月13日 15:36

适用于 React + TypeScript + Arco Design Web 的配置化表单与弹窗表单方案

文档目标: 说明当前 FormEdit / FormEditModal / types / utils 方案的职责分层、设计原因、优点与风险、关键语法,以及实际使用方式。

1. 方案概览

当前方案是一套“配置驱动 + ref 驱动 + 弹窗承载 + 字段类型扩展”的通用表单基础设施。它的核心不是写死某个业务表单,而是通过一组统一的配置和类型,把新增、编辑、查看、弹窗提交这些高重复场景抽象出来。

• FormEdit:负责表单渲染、字段分发、初始值处理、对外暴露表单 API。

• FormEditModal:负责弹窗显示、确定/取消流程、是否关闭、是否重置。

• types:负责定义字段类型、配置结构、ref 方法、组件 props,是整个方案的契约层。

• utils:负责类名合并、初始值归一化、根据表单配置构建整表默认值。

2. 每个代码文件的详解

2.1 types.ts

types.ts 是整套方案最底层的协议文件。它的作用不是直接渲染 UI,而是约束“这套表单系统支持什么配置、暴露什么能力、字段有哪些类型”。

• 定义 EditState:统一新增、编辑、查看三种状态,避免业务层自行约定字符串。

• 定义 FormFieldType:从基础的 input / textarea / select 扩展到 switch / upload / cascader / datePicker / custom。

• 定义 FormItemConfig:让每个字段既包含业务属性,也包含布局和组件渲染属性。

• 定义 FormEditRef:把 validate、resetFields、setValues、getValues、setFieldValue 等能力统一收敛。

• 定义 FormEditProps:支持表单整体布局方向 direction、列数 columns、回填控制 syncKey、卡片/纯内容 layout 等。

import type { ReactNode, RefObject } from 'react';

export type EditState = 'add' | 'edit' | 'view';

export type FieldValue = string | number | boolean;

export type FormFieldType =
  | 'input'
  | 'textarea'
  | 'select'
  | 'radioGroup'
  | 'checkboxGroup'
  | 'switch'
  | 'upload'
  | 'cascader'
  | 'datePicker'
  | 'divider'
  | 'custom';

export interface OptionItem {
  key?: string | number;
  label: string;
  value: FieldValue;
  disabled?: boolean;
  children?: OptionItem[];
}

export interface RuleItem {
  required?: boolean;
  message?: string;
  trigger?: 'change' | 'blur' | Array<'change' | 'blur'>;
  validator?: (value: unknown, values: Record<string, unknown>) => void | string | Promise<void | string>;
}

export interface RenderFieldContext {
  value: unknown;
  formData: Record<string, unknown>;
  editState: EditState;
  disabled: boolean;
  setFieldValue: (key: string, value: unknown) => void;
  getFieldValue: (key: string) => unknown;
  setValues: (values: Record<string, unknown>) => void;
}

export interface FormItemConfig {
  key: string;
  title?: string;
  type: FormFieldType;

  hidden?: boolean;
  required?: boolean;
  disabled?: boolean;
  placeholder?: string;

  /**
   * columns=2 时可设置 2 跨整行
   */
  colSpan?: 1 | 2;

  /**
   * 外层栅格项 className
   */
  className?: string;

  /**
   * Form.Item 自身 className
   */
  formItemClassName?: string;

  rules?: RuleItem[];

  maxLength?: number;
  showWordLimit?: boolean;
  rows?: number;

  /**
   * select / radioGroup / checkboxGroup / cascader 共用
   */
  options?: OptionItem[];

  /**
   * select 多选
   */
  mode?: 'single' | 'multiple';

  /**
   * 选项展示 label-value
   */
  showKV?: boolean;

  /**
   * radio / checkbox 排列方向
   */
  direction?: 'horizontal' | 'vertical';

  /**
   * checkbox 最大可选数
   */
  max?: number;

  extra?: ReactNode;
  initialValue?: unknown;

  /**
   * 透传给具体字段组件的属性
   */
  fieldProps?: Record<string, unknown>;

  /**
   * switch 专用
   */
  checkedValue?: string | number | boolean;
  uncheckedValue?: string | number | boolean;

  /**
   * upload 专用
   */
  uploadAction?: string;
  limit?: number;

  /**
   * datePicker 专用
   */
  datePickerType?: 'date' | 'week' | 'month' | 'quarter' | 'year' | 'range';
  format?: string;

  /**
   * custom 专用
   */
  render?: (ctx: RenderFieldContext) => ReactNode;

  onChange?: (value: unknown, formData: Record<string, unknown>) => void;
}

export interface FormEditProps {
  modelValue?: Record<string, unknown>;
  formArr: FormItemConfig[];
  editState?: EditState;
  className?: string;

  title?: ReactNode;
  description?: ReactNode;
  width?: number | string;
  layout?: 'card' | 'plain';

  /**
   * 表单布局方向
   * horizontal:水平布局
   * vertical:垂直布局
   */
  direction?: 'horizontal' | 'vertical';

  /**
   * 默认 1 列
   * 字段多时可用 2 列
   */
  columns?: 1 | 2;

  /**
   * 用于控制何时重新回填表单
   * 推荐编辑态传入主键,例如 id
   */
  syncKey?: string | number | null | undefined;

  onValuesChange?: (changedValues: Record<string, unknown>, values: Record<string, unknown>) => void;
}

export interface FormEditRef {
  validate: () => Promise<boolean>;
  resetFields: () => void;
  setValues: (values: Record<string, unknown>) => void;
  getValues: () => Record<string, unknown>;
  setFieldValue: (key: string, value: unknown) => void;
  getFieldValue: (key: string) => unknown;
  clearErrors: () => void;
}

export interface FormEditModalProps {
  open?: boolean;
  title?: ReactNode;
  width?: string | number;
  className?: string;
  contentClassName?: string;

  showFooter?: boolean;
  useCustomFooter?: boolean;
  footer?: ReactNode;

  okText?: string;
  cancelText?: string;
  confirmLoading?: boolean;

  maskClosable?: boolean;
  escToClose?: boolean;
  destroyOnClose?: boolean;

  closeOnOk?: boolean;
  closeOnCancel?: boolean;

  formRef?: RefObject<FormEditRef | null>;
  validateBeforeOk?: boolean;
  resetAfterClose?: boolean;

  children?: ReactNode;

  onOpenChange?: (open: boolean) => void;
  onCancel?: () => void;
  onOk?: (values?: Record<string, unknown>) => void | Promise<void>;
}

export interface FormEditModalRef {
  open: () => void;
  close: () => void;
  toggle: () => void;
}

这一层的好处是:调用方和组件实现方共用同一份类型约束,后续扩展字段类型时,编译器会帮助你定位所有待补位置。

2.2 utils.ts

utils.ts 的核心是让表单初始值更稳定。通用表单最容易出问题的地方,不是“字段渲染不出来”,而是不同控件在新增态、编辑态、回填态下的默认值类型不一致。

• cx:合并 className,支持字符串、数组、对象条件写法。

• normalizeInitialValue:根据字段类型给出合理默认值,例如多选 select、upload、cascader、range 类型日期使用数组,switch 使用 uncheckedValue 或 false。

• buildInitialFormData:把单字段归一化提升为整张表单数据的构建函数。

• getVisibleFormItems:过滤 hidden 字段,避免隐藏字段仍参与渲染和初始值流程。

import type { FormItemConfig } from './types';

type ClassDictionary = Record<string, boolean | null | undefined>;
type ClassArray = ClassValue[];
type ClassValue = string | null | undefined | false | ClassDictionary | ClassArray;

export function cx(...args: ClassValue[]): string {
  const classes: string[] = [];

  const append = (value: ClassValue) => {
    if (!value) return;

    if (typeof value === 'string') {
      classes.push(value);
      return;
    }

    if (Array.isArray(value)) {
      value.forEach(append);
      return;
    }

    if (typeof value === 'object') {
      Object.keys(value).forEach((key) => {
        if (value[key]) {
          classes.push(key);
        }
      });
    }
  };

  args.forEach(append);
  return classes.join(' ');
}

export function normalizeInitialValue(item: FormItemConfig, value: unknown): unknown {
  if (value !== undefined) return value;
  if (item.initialValue !== undefined) return item.initialValue;

  if (item.type === 'checkboxGroup') return [];
  if (item.type === 'select' && item.mode === 'multiple') return [];
  if (item.type === 'upload') return [];
  if (item.type === 'cascader') return [];
  if (item.type === 'datePicker' && item.datePickerType === 'range') return [];
  if (item.type === 'switch') {
    return item.uncheckedValue ?? false;
  }

  return '';
}

export function buildInitialFormData(formArr: FormItemConfig[], modelValue: Record<string, unknown> = {}) {
  const nextData: Record<string, unknown> = {};

  formArr.forEach((item) => {
    if (item.type === 'divider') return;
    nextData[item.key] = normalizeInitialValue(item, modelValue[item.key]);
  });

  return nextData;
}

export function getVisibleFormItems(formArr: FormItemConfig[]) {
  return formArr.filter((item) => !item.hidden);
}

这层看似简单,但它决定了表单是否能稳定受控,是否会出现默认值错乱或组件警告。

2.3 formEdit.tsx

formEdit.tsx 是整套方案的核心。它接收字段配置 formArr,把配置转换成真正的 Arco Form 结构,并通过 ref 向父组件暴露命令式方法。

• 通过 Form.useForm 获取 Arco 表单实例。

• 通过 useMemo 构建 initialValues,避免每次 render 都重新生成。

• 通过 syncKey + lastPatchedKeyRef 控制何时重新回填表单,避免误覆盖用户输入。

• 通过 buildArcoRules 把业务规则适配成 Arco 规则。

• 通过 renderField 按 type 分发不同字段类型的渲染逻辑。

• 通过 direction 控制 Form 的整体布局方向,通过 columns + colSpan 控制字段网格布局。

import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import {
  Card,
  Cascader,
  Checkbox,
  DatePicker,
  Divider,
  Form,
  Input,
  Radio,
  Select,
  Switch,
  Typography,
  Upload,
} from '@arco-design/web-react';
import type { FormEditProps, FormEditRef, FormItemConfig } from './types';
import { buildInitialFormData, cx, getVisibleFormItems } from './utils';

const { Title, Paragraph, Text } = Typography;

type FormItemProps = React.ComponentProps<typeof Form.Item>;
type ArcoRules = NonNullable<FormItemProps['rules']>;
type ArcoRule = ArcoRules extends Array<infer T> ? T : never;

function buildArcoRules(item: FormItemConfig, getValues: () => Record<string, unknown>): ArcoRule[] {
  const rules: ArcoRule[] = [];

  if (item.required) {
    rules.push({
      required: true,
      message: `${item.title || item.key}不能为空`,
    } as ArcoRule);
  }

  if (!item.rules?.length) {
    return rules;
  }

  item.rules.forEach((rule) => {
    if (rule.required) {
      rules.push({
        required: true,
        message: rule.message || `${item.title || item.key}不能为空`,
        trigger: rule.trigger,
      } as ArcoRule);
    }

    if (rule.validator) {
      rules.push({
        trigger: rule.trigger,
        validator: (value, callback) => {
          Promise.resolve(rule.validator?.(value, getValues()))
            .then((result) => {
              if (typeof result === 'string' && result) {
                callback(result);
                return;
              }

              callback();
            })
            .catch((error: unknown) => {
              if (error instanceof Error) {
                callback(error.message);
                return;
              }

              if (typeof error === 'string') {
                callback(error);
                return;
              }

              callback(rule.message || '校验失败');
            });
        },
      } as ArcoRule);
    }
  });

  return rules;
}

function getFieldColClass(columns: 1 | 2, item: FormItemConfig) {
  if (columns === 1) return 'col-span-1';
  return item.colSpan === 2 ? 'col-span-2' : 'col-span-1';
}

function renderLabel(item: FormItemConfig) {
  if (!item.title) return null;

  return (
    <span className="inline-flex items-center gap-1 whitespace-nowrap">
      {item.required ? <span className="leading-none text-red-500">*</span> : null}
      <span>{item.title}</span>
    </span>
  );
}

function FormEditInner(
  {
    modelValue = {},
    formArr,
    editState = 'add',
    className,
    title,
    description,
    width = 760,
    layout = 'card',
    direction = 'vertical',
    columns = 1,
    syncKey,
    onValuesChange,
  }: FormEditProps,
  ref: React.Ref<FormEditRef>,
) {
  const [form] = Form.useForm();
  const isView = editState === 'view';

  const visibleFormItems = useMemo(() => {
    return getVisibleFormItems(formArr);
  }, [formArr]);

  const initialValues = useMemo(() => {
    return buildInitialFormData(visibleFormItems, modelValue);
  }, [visibleFormItems, modelValue]);

  const currentPatchKey = syncKey ?? modelValue;
  const lastPatchedKeyRef = useRef(currentPatchKey);
  const mountedRef = useRef(false);

  useEffect(() => {
    if (!mountedRef.current) {
      mountedRef.current = true;
      form.setFieldsValue(initialValues);
      lastPatchedKeyRef.current = currentPatchKey;
      return;
    }

    if (lastPatchedKeyRef.current === currentPatchKey) return;

    lastPatchedKeyRef.current = currentPatchKey;
    form.resetFields();
    form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
  }, [currentPatchKey, form, initialValues, modelValue, visibleFormItems]);

  useImperativeHandle(
    ref,
    () => ({
      validate: async () => {
        try {
          await form.validate();
          return true;
        } catch {
          return false;
        }
      },
      resetFields: () => {
        form.resetFields();
        form.setFieldsValue(buildInitialFormData(visibleFormItems, modelValue));
      },
      setValues: (values) => {
        form.setFieldsValue(values);
      },
      getValues: () => {
        return form.getFieldsValue() as Record<string, unknown>;
      },
      setFieldValue: (key, value) => {
        form.setFieldValue(key, value);
      },
      getFieldValue: (key) => {
        return form.getFieldValue(key);
      },
      clearErrors: () => {
        const currentValues = form.getFieldsValue() as Record<string, unknown>;
        form.clearFields();
        form.setFieldsValue(currentValues);
      },
    }),
    [form, modelValue, visibleFormItems],
  );

  const getAllValues = () => {
    return form.getFieldsValue() as Record<string, unknown>;
  };

  const renderField = (item: FormItemConfig) => {
    const commonDisabled = isView || item.disabled;
    const commonValue = form.getFieldValue(item.key);

    const fieldContext = {
      value: commonValue,
      formData: getAllValues(),
      editState,
      disabled: !!commonDisabled,
      setFieldValue: (key: string, value: unknown) => form.setFieldValue(key, value),
      getFieldValue: (key: string) => form.getFieldValue(key),
      setValues: (values: Record<string, unknown>) => form.setFieldsValue(values),
    };

    switch (item.type) {
      case 'input':
        return (
          <Input
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请输入'}
            maxLength={item.maxLength}
            showWordLimit={item.showWordLimit}
            {...item.fieldProps}
          />
        );

      case 'textarea':
        return (
          <Input.TextArea
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请输入'}
            maxLength={item.maxLength}
            showWordLimit={item.showWordLimit}
            autoSize={{
              minRows: item.rows || 4,
              maxRows: Math.max((item.rows || 4) + 3, 6),
            }}
            {...item.fieldProps}
          />
        );

      case 'select':
        return (
          <Select
            allowClear
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择'}
            mode={item.mode === 'multiple' ? 'multiple' : undefined}
            maxTagCount={item.mode === 'multiple' ? 3 : undefined}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Select.Option key={opt.key ?? String(opt.value)} value={opt.value} disabled={opt.disabled}>
                {item.showKV ? `${opt.label}-${opt.value}` : opt.label}
              </Select.Option>
            ))}
          </Select>
        );

      case 'radioGroup':
        return (
          <Radio.Group
            direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
            disabled={commonDisabled}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Radio key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
                {opt.label}
              </Radio>
            ))}
          </Radio.Group>
        );

      case 'checkboxGroup':
        return (
          <Checkbox.Group
            direction={item.direction === 'vertical' ? 'vertical' : 'horizontal'}
            disabled={commonDisabled}
            max={item.max}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          >
            {(item.options || []).map((opt) => (
              <Checkbox key={opt.key ?? String(opt.value)} value={opt.value} disabled={commonDisabled || opt.disabled}>
                {opt.label}
              </Checkbox>
            ))}
          </Checkbox.Group>
        );

      case 'switch':
        return (
          <Switch
            disabled={commonDisabled}
            checked={commonValue === (item.checkedValue ?? true)}
            checkedValue={item.checkedValue ?? true}
            uncheckedValue={item.uncheckedValue ?? false}
            onChange={(value) => {
              form.setFieldValue(item.key, value);
              item.onChange?.(value, {
                ...getAllValues(),
                [item.key]: value,
              });
            }}
            {...item.fieldProps}
          />
        );

      case 'upload':
        return (
          <Upload
            disabled={commonDisabled}
            action={item.uploadAction}
            limit={item.limit}
            fileList={Array.isArray(commonValue) ? (commonValue as never[]) : []}
            onChange={(fileList) => {
              form.setFieldValue(item.key, fileList);
              item.onChange?.(fileList, {
                ...getAllValues(),
                [item.key]: fileList,
              });
            }}
            {...item.fieldProps}
          />
        );

      case 'cascader':
        return (
          <Cascader
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择'}
            options={item.options || []}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          />
        );

      case 'datePicker':
        if (item.datePickerType === 'range') {
          return (
            <DatePicker.RangePicker
              disabled={commonDisabled}
              placeholder={['开始日期', '结束日期']}
              format={item.format}
              onChange={(value) => {
                item.onChange?.(value, getAllValues());
              }}
              {...item.fieldProps}
            />
          );
        }

        return (
          <DatePicker
            disabled={commonDisabled}
            placeholder={item.placeholder || '请选择日期'}
            format={item.format}
            picker={item.datePickerType && item.datePickerType !== 'date' ? item.datePickerType : undefined}
            onChange={(value) => {
              item.onChange?.(value, getAllValues());
            }}
            {...item.fieldProps}
          />
        );

      case 'divider':
        return <Divider style={{ margin: '4px 0 12px' }} />;

      case 'custom':
        if (item.render) {
          return item.render(fieldContext);
        }
        return <Text type="secondary">custom 类型缺少 render 配置</Text>;

      default:
        return <Text type="secondary">暂不支持的表单类型:{item.type}</Text>;
    }
  };

  const formContent = (
    <div className={cx('w-full', className)}>
      {title || description ? (
        <div style={{ marginBottom: 24 }}>
          {title ? <Title heading={6}>{title}</Title> : null}
          {description ? (
            <Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
              {description}
            </Paragraph>
          ) : null}
        </div>
      ) : null}

      <Form
        form={form}
        layout={direction}
        initialValues={initialValues}
        autoComplete="off"
        onValuesChange={(changedValues, values) => {
          onValuesChange?.(changedValues as Record<string, unknown>, values as Record<string, unknown>);
        }}
      >
        <div className={cx('grid gap-x-5', columns === 2 ? 'grid-cols-2' : 'grid-cols-1')}>
          {visibleFormItems.map((item, index) => {
            if (item.type === 'divider') {
              return (
                <div key={`${item.key}-${index}`} className="col-span-full">
                  {renderField(item)}
                </div>
              );
            }

            return (
              <div key={`${item.key}-${index}`} className={cx(getFieldColClass(columns, item), item.className)}>
                <Form.Item
                  className={item.formItemClassName}
                  label={renderLabel(item)}
                  field={item.key}
                  rules={buildArcoRules(item, () => form.getFieldsValue() as Record<string, unknown>)}
                  requiredSymbol={false}
                  extra={item.extra}
                  triggerPropName={item.type === 'switch' ? 'checked' : 'value'}
                >
                  {renderField(item)}
                </Form.Item>
              </div>
            );
          })}
        </div>
      </Form>
    </div>
  );

  const containerStyle = {
    width: typeof width === 'number' ? `${width}px` : width,
    maxWidth: '100%',
    margin: '0 auto',
  };

  if (layout === 'plain') {
    return <div style={containerStyle}>{formContent}</div>;
  }

  return (
    <Card bordered style={{ borderRadius: 16 }}>
      <div style={containerStyle}>{formContent}</div>
    </Card>
  );
}

FormEditInner.displayName = 'FormEdit';

const FormEdit = forwardRef(FormEditInner);
FormEdit.displayName = 'FormEdit';

export default FormEdit;

当前版本里,renderField 已经覆盖 input、textarea、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、divider、custom 等类型,说明这套组件已经从“基础表单”提升为“可承载复杂业务表单”的组件。

2.4 formEditModal.tsx

formEditModal.tsx 负责承载表单弹窗。它把“打开 / 关闭、点击确定、点击取消、关闭后是否重置、提交前是否校验”等通用行为统一起来,让业务页面只关心 open 状态和 onOk 保存逻辑。

• 支持受控 / 非受控兼容的显示方式。

• 支持通过 formRef 在点击确定前自动 validate。

• 支持 closeOnOk / closeOnCancel 控制点击后是否关闭。

• 支持 resetAfterClose 控制关闭后是否恢复初始值。

• 支持自定义 footer,也支持内置确定 / 取消按钮。

这一层的价值在于:把弹窗提交流程标准化,避免每个页面重复写 validate -> getValues -> onOk -> close 这类模板代码。

import { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { Button, Modal, Space } from '@arco-design/web-react';
import type { FormEditModalProps, FormEditModalRef } from './types';

function FormEditModalInner(
  {
    open,
    title,
    width = 720,
    className,
    contentClassName,

    showFooter = true,
    useCustomFooter = false,
    footer,

    okText = '确定',
    cancelText = '取消',
    confirmLoading = false,

    maskClosable = false,
    escToClose = true,
    destroyOnClose = true,

    closeOnOk = true,
    closeOnCancel = true,

    formRef,
    validateBeforeOk = true,
    resetAfterClose = false,

    children,

    onOpenChange,
    onCancel,
    onOk,
  }: FormEditModalProps,
  ref: React.Ref<FormEditModalRef>,
) {
  const [innerOpen, setInnerOpen] = useState(false);

  const isControlled = open !== undefined;
  const visible = isControlled ? open : innerOpen;

  const setVisible = (next: boolean) => {
    if (!isControlled) {
      setInnerOpen(next);
    }
    onOpenChange?.(next);
  };

  const handleClose = () => {
    setVisible(false);
  };

  const handleAfterClose = () => {
    if (resetAfterClose) {
      formRef?.current?.resetFields();
    }
  };

  const handleCancel = () => {
    onCancel?.();

    if (closeOnCancel) {
      handleClose();
    }
  };

  const handleOk = async () => {
    if (validateBeforeOk && formRef?.current) {
      const passed = await formRef.current.validate();
      if (!passed) return;
    }

    const values = formRef?.current?.getValues();

    try {
      await onOk?.(values);

      if (closeOnOk) {
        handleClose();
      }
    } catch (error) {
      console.error('FormEditModal onOk 执行失败:', error);
    }
  };

  useImperativeHandle(
    ref,
    () => ({
      open: () => setVisible(true),
      close: () => setVisible(false),
      toggle: () => setVisible(!visible),
    }),
    [visible],
  );

  const defaultFooter = useMemo(() => {
    if (!showFooter) return null;

    if (useCustomFooter) {
      return footer;
    }

    return (
      <Space>
        <Button onClick={handleCancel}>{cancelText}</Button>
        <Button type="primary" loading={confirmLoading} onClick={handleOk}>
          {okText}
        </Button>
      </Space>
    );
  }, [cancelText, confirmLoading, footer, okText, showFooter, useCustomFooter, handleCancel, handleOk]);

  return (
    <Modal
      visible={visible}
      title={title}
      style={{ width }}
      className={className}
      unmountOnExit={destroyOnClose}
      maskClosable={maskClosable}
      escToExit={escToClose}
      onCancel={handleCancel}
      afterClose={handleAfterClose}
      footer={defaultFooter}
    >
      <div className={contentClassName}>{children}</div>
    </Modal>
  );
}

FormEditModalInner.displayName = 'FormEditModal';

const FormEditModal = forwardRef(FormEditModalInner);
FormEditModal.displayName = 'FormEditModal';

export default FormEditModal;

2.5 示例页面

import { useMemo, useRef, useState } from 'react';
import { Button, Input, Message, Space, Tag } from '@arco-design/web-react';
import { FormEdit, FormEditModal } from '@/components/FormEdit';
import type { FormEditRef, FormItemConfig } from '@/components/FormEdit';

export default function DemoModalForm() {
  const singleColFormRef = useRef<FormEditRef>(null);
  const doubleColFormRef = useRef<FormEditRef>(null);
  const customFormRef = useRef<FormEditRef>(null);

  const [singleOpen, setSingleOpen] = useState(false);
  const [doubleOpen, setDoubleOpen] = useState(false);
  const [customOpen, setCustomOpen] = useState(false);

  const [singleSubmitLoading, setSingleSubmitLoading] = useState(false);
  const [doubleSubmitLoading, setDoubleSubmitLoading] = useState(false);
  const [customSubmitLoading, setCustomSubmitLoading] = useState(false);

  // 一行一列:字段少,适合小弹窗
  const singleColumnFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'name',
        title: '名称',
        type: 'input',
        required: true,
        placeholder: '请输入名称',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        required: true,
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
        ],
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入说明',
        rows: 5,
        extra: '适合字段较少、弹窗较窄的场景',
      },
    ];
  }, []);

  // 一行两列:字段多,部分字段支持跨整行
  const doubleColumnFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'name',
        title: '名称',
        type: 'input',
        required: true,
        placeholder: '请输入名称',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
        ],
      },
      {
        key: 'type',
        title: '类型',
        type: 'radioGroup',
        options: [
          { label: '个人', value: 'personal' },
          { label: '企业', value: 'company' },
        ],
      },
      {
        key: 'tags',
        title: '标签',
        type: 'checkboxGroup',
        direction: 'horizontal',
        options: [
          { label: '热门', value: 'hot' },
          { label: '推荐', value: 'recommend' },
          { label: '最新', value: 'new' },
        ],
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入说明',
        rows: 5,
        colSpan: 2,
        extra: '这个字段比较长,所以在两列布局里跨整行显示',
      },
    ];
  }, []);

  // 一行 2 列,使用全部封装组件
  const customFormArr = useMemo<FormItemConfig[]>(() => {
    return [
      {
        key: 'projectName',
        title: '项目名称',
        type: 'input',
        required: true,
        placeholder: '请输入项目名称',
      },
      {
        key: 'status',
        title: '启用状态',
        type: 'switch',
        required: true,
        checkedValue: 1,
        uncheckedValue: 0,
        initialValue: 1,
        extra: '开启为 1,关闭为 0',
      },
      {
        key: 'city',
        title: '所属城市',
        type: 'select',
        placeholder: '请选择城市',
        required: true,
        options: [
          { label: '北京', value: 'beijing' },
          { label: '上海', value: 'shanghai' },
          { label: '深圳', value: 'shenzhen' },
          { label: '杭州', value: 'hangzhou' },
        ],
      },
      {
        key: 'identityType',
        title: '用户类型',
        type: 'radioGroup',
        required: true,
        options: [
          { label: '个人', value: 'personal' },
          { label: '企业', value: 'company' },
        ],
      },
      {
        key: 'tags',
        title: '标签',
        type: 'checkboxGroup',
        direction: 'horizontal',
        options: [
          { label: '热门', value: 'hot' },
          { label: '推荐', value: 'recommend' },
          { label: '最新', value: 'new' },
        ],
      },
      {
        key: 'region',
        title: '地区级联',
        type: 'cascader',
        required: true,
        placeholder: '请选择地区',
        options: [
          {
            label: '浙江省',
            value: 'zhejiang',
            children: [
              {
                label: '杭州市',
                value: 'hangzhou',
                children: [
                  { label: '西湖区', value: 'xihu' },
                  { label: '滨江区', value: 'binjiang' },
                ],
              },
              {
                label: '宁波市',
                value: 'ningbo',
                children: [{ label: '鄞州区', value: 'yinzhou' }],
              },
            ],
          },
          {
            label: '广东省',
            value: 'guangdong',
            children: [
              {
                label: '深圳市',
                value: 'shenzhen',
                children: [
                  { label: '南山区', value: 'nanshan' },
                  { label: '福田区', value: 'futian' },
                ],
              },
            ],
          },
        ],
      },
      {
        key: 'publishDate',
        title: '发布日期',
        type: 'datePicker',
        required: true,
        datePickerType: 'date',
        format: 'YYYY-MM-DD',
        placeholder: '请选择发布日期',
      },
      {
        key: 'timeRange',
        title: '时间范围',
        type: 'datePicker',
        required: true,
        datePickerType: 'range',
        format: 'YYYY-MM-DD',
      },
      {
        key: 'cover',
        title: '上传封面',
        type: 'upload',
        uploadAction: '/api/upload',
        limit: 1,
        fieldProps: {
          listType: 'picture-card',
          imagePreview: true,
        },
        extra: '示例,后续需要讲他封装为单独的组件,图片上传oss',
      },
      {
        key: 'customField',
        title: '自定义组件',
        type: 'custom',
        required: false,
        render: ({ value, setFieldValue, disabled }) => {
          return (
            <div className="flex flex-wrap items-center gap-2">
              <Tag
                checkable
                checked={value === 'A'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'A');
                }}
              >
                方案 A
              </Tag>
              <Tag
                checkable
                checked={value === 'B'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'B');
                }}
              >
                方案 B
              </Tag>
              <Tag
                checkable
                checked={value === 'C'}
                onClick={() => {
                  if (disabled) return;
                  setFieldValue('customField', 'C');
                }}
              >
                方案 C
              </Tag>

              <Input
                style={{ width: 220 }}
                placeholder="也可以输入自定义值"
                value={typeof value === 'string' ? value : ''}
                disabled={disabled}
                onChange={(nextValue) => {
                  setFieldValue('customField', nextValue);
                }}
              />
            </div>
          );
        },
        extra: '这里演示 custom 自定义渲染能力',
      },
      {
        key: 'desc',
        title: '说明',
        type: 'textarea',
        placeholder: '请输入详细说明',
        rows: 5,
        colSpan: 2,
        extra: '最下面一行跨整行显示,用于填写较长说明内容',
      },
    ];
  }, []);

  return (
    <div className="p-6">
      <Space size="large">
        <Button type="primary" onClick={() => setSingleOpen(true)}>
          打开一列布局弹窗
        </Button>

        <Button type="primary" status="success" onClick={() => setDoubleOpen(true)}>
          打开两列布局弹窗
        </Button>

        <Button type="primary" status="warning" onClick={() => setCustomOpen(true)}>
          打开有自定义的布局弹窗
        </Button>
      </Space>

      <FormEditModal
        open={singleOpen}
        title="新增信息(一列布局)"
        width={460}
        formRef={singleColFormRef}
        confirmLoading={singleSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setSingleOpen}
        onOk={async (values) => {
          setSingleSubmitLoading(true);
          try {
            console.log('一列布局提交数据:', values);
            Message.success('一列布局提交成功');
          } finally {
            setSingleSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={singleColFormRef}
          modelValue={{
            name: '',
            city: '',
            desc: '',
          }}
          formArr={singleColumnFormArr}
          layout="plain"
          direction="horizontal"
          columns={1}
          title="基础信息"
          description="当前示例为一行一列布局,适合字段较少或弹窗较窄的场景"
        />
      </FormEditModal>

      <FormEditModal
        open={doubleOpen}
        title="新增信息(两列布局)"
        width={720}
        formRef={doubleColFormRef}
        confirmLoading={doubleSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setDoubleOpen}
        onOk={async (values) => {
          setDoubleSubmitLoading(true);
          try {
            console.log('两列布局提交数据:', values);
            Message.success('两列布局提交成功');
          } finally {
            setDoubleSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={doubleColFormRef}
          modelValue={{
            name: '',
            city: '',
            type: 'personal',
            tags: [],
            desc: '',
          }}
          formArr={doubleColumnFormArr}
          layout="plain"
          direction="horizontal"
          columns={2}
          title="基础信息"
          description="当前示例为一行两列布局,长文本字段可通过 colSpan: 2 跨整行显示"
        />
      </FormEditModal>

      <FormEditModal
        open={customOpen}
        title="新增信息(两列 + 全组件示例)"
        width={920}
        formRef={customFormRef}
        confirmLoading={customSubmitLoading}
        closeOnOk
        closeOnCancel
        resetAfterClose
        onOpenChange={setCustomOpen}
        onOk={async (values) => {
          setCustomSubmitLoading(true);
          try {
            console.log('自定义两列布局提交数据:', values);
            Message.success('自定义布局提交成功');
          } finally {
            setCustomSubmitLoading(false);
          }
        }}
      >
        <FormEdit
          ref={customFormRef}
          modelValue={{
            projectName: '',
            status: 1,
            city: '',
            identityType: 'personal',
            tags: [],
            region: [],
            publishDate: '',
            timeRange: [],
            cover: [],
            customField: 'A',
            desc: '',
          }}
          formArr={customFormArr}
          layout="plain"
          columns={2}
          title="完整表单示例"
          description="当前示例为两列布局,演示了 input、select、radioGroup、checkboxGroup、switch、upload、cascader、datePicker、custom、textarea 等所有封装能力"
        />
      </FormEditModal>
    </div>
  );
}

3. 方案为什么这么设计,解决了什么问题

这套方案之所以采用“配置驱动 + ref 驱动 + 弹窗容器分离”的方式,是因为后台项目里的表单高度重复,但每个业务页又会有细小差异。如果每次都手写 Form.Item,不仅重复,而且难以统一。

• 解决重复开发问题:大部分新增/编辑/查看表单只需要配置 formArr,不需要从头写 UI。

• 解决行为不统一问题:校验、重置、关闭、查看态禁用、标题与说明展示都统一收口。

• 解决字段扩展问题:新增 switch、upload、cascader、datePicker、custom 后,可以覆盖更多真实业务场景。

• 解决回填控制问题:通过 syncKey 控制何时重新 patch 表单,避免编辑态误覆盖。

• 解决布局不灵活问题:通过 direction 和 columns 拆开“整体表单方向”和“字段网格布局”两个维度。

4. 方案的优点、改进点

4.1 优点

• 抽象清晰:类型、工具函数、表单、弹窗分层明确。

• 字段能力完整:覆盖了常见后台表单大部分控件。

• 配置统一:字段描述、布局控制、默认值、规则、扩展属性集中在一处。

• 对业务友好:父页面主要关心配置和 onOk 保存逻辑,不必处理表单底层细节。

• 可扩展性强:custom 字段为特殊业务组件提供了稳定扩展入口。

5. 关键语法解释

5.1 forwardRef

让函数组件可以接收 ref。当前 FormEdit 和 FormEditModal 都需要向父组件暴露方法,因此必须使用它。

5.2 useImperativeHandle

自定义 ref.current 上暴露的内容。这里暴露的是 validate、getValues、resetFields、open、close 等方法,而不是整个内部实例。

5.3 useMemo

缓存计算结果。当前主要用于 initialValues 和 visibleFormItems,减少重复计算并让依赖更明确。

5.4 useEffect

在数据变化时同步副作用。当前主要用于 modelValue / syncKey 变化时重新 patch 表单。

5.5 useRef

跨 render 持久保存引用。这里用于 mountedRef、lastPatchedKeyRef,也用于 formRef / modalRef。

5.6 联合类型

例如 direction?: 'horizontal' | 'vertical'。这种写法能把取值范围限制在有限集合内,减少误传。

6. 使用示例

6.1 基础单列表单

<FormEdit ref={formRef}
  formArr={singleColumnFormArr}
  layout="plain"
  direction="horizontal"
  columns={1}
/>

适用于字段较少、小弹窗场景。

6.2 两列表单 + 跨行 textarea

{
  key: 'desc',
  title: '说明',
  type: 'textarea',
  rows: 5,
  colSpan: 2,
}

适用于两列布局中某些长文本字段需要独占一行的场景。

6.3 switch / cascader / datePicker / upload

[
  { key: 'status', title: '状态', type: 'switch', checkedValue: 1, uncheckedValue: 0 },
  { key: 'region', title: '地区', type: 'cascader', options: regionOptions },
  { key: 'publishDate', title: '发布日期', type: 'datePicker', datePickerType: 'date' },
  { key: 'cover', title: '封面', type: 'upload', uploadAction: '/api/upload', limit: 1 },
]

6.4 custom 字段

{
  key: 'customField',
  title: '自定义组件',
  type: 'custom',
  render: ({ value, setFieldValue }) => (
    <Input
      value={typeof value === 'string' ? value : ''}
      onChange={(nextValue) => setFieldValue('customField', nextValue)}
    />
  ),
}

当内置字段类型不够用时,可以通过 custom 直接接入业务特有组件。

6.5 弹窗表单

<FormEditModal
  open={open}
  title="新增信息"
  formRef={formRef}
  onOpenChange={setOpen}
  onOk={async (values) => {
    console.log(values);
  }}
  <FormEdit
    ref={formRef}
    formArr={formArr}
    layout="plain"
    direction="horizontal"
    columns={2}
  />
</FormEditModal>

这是最推荐的项目用法:弹窗负责交互流程,FormEdit 负责表单本体。

7. 结论

当前这套 FormEdit 结构是一套面向项目复用的表单基础设施。它通过类型契约、配置化字段、布局解耦、规则适配、初始值归一化和弹窗容器分离,把后台系统中最常见的新增/编辑/查看型表单进行了有效抽象。

Vue3 转 React:组件透传 Attributes 与 useAttrs 使用详解|VuReact 实战

作者 Ruihong
2026年4月13日 13:08

在 Vue3 迁移 React、跨框架组件封装的场景里,透传 Attributes 是几乎必用、但极易踩坑的能力。Vue 的 $attrs / useAttrs 和 React 的 props 体系设计差异很大,而 VuReact 作为稳定的 Vue3 → React 编译工具,已经把这套逻辑做了完整对齐。

本文带你一次性搞懂:透传属性是什么、为什么必须用 useAttrs、TS 怎么写、转换后长什么样,直接复制就能用。


一、先搞懂:透传 Attributes 到底是什么?

1. Vue 官方定义

透传 attribute:传给组件,但没有被声明为 props / emits 的属性或事件监听器。 最常见:classstyleid、自定义属性、v-on 监听等。

Vue 默认会把它们自动继承到组件根节点,也可以用 $attrsuseAttrs() 手动控制。

2. React 里的等价逻辑

React 没有“透传”这个名词,但行为一致: 所有没在 Props 里定义的属性,都属于“透传属性”,全部挂在 props 上。

区别是:

  • Vue:运行时自动处理
  • React + TS:必须显式写类型,否则报错

3. VuReact 的核心适配规则

VuReact 把透传属性统一理解为: 无类型约束的运行时对象 + 已声明 Props 合并 = 最终组件属性

  • 组件无 Props → 自动生成:props: Record<string, unknown>
  • 组件有 Props → 自动交叉类型:Props & Record<string, unknown>

二、关键:必须从 $attrs 转向 useAttrs()

Vue 里有两种写法:

  • $attrs:运行时隐式变量 → VuReact 无法静态分析
  • useAttrs():显式 API → VuReact 完美支持、推荐唯一写法

1. Vue 中标准 useAttrs 写法(必背)

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

好处:

  • 编译器可静态识别
  • 支持 TS 类型注解
  • 符合 React“显式优于隐式”的习惯

2. VuReact 转换规则(一张表看懂)

Vue useAttrs 写法 React 转换结果
无类型 const attrs = props as Record<string, unknown>
类型断言 as Attrs const attrs = props as Attrs
变量带类型 attrs: Attrs const attrs = props as Attrs
搭配 defineProps Props & Record<string, unknown>

三、实战示例:从 Vue 到 React 完整对照

示例 1:基础用法(无 TS)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.title }}
  </div>
</template>

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

React 输出

import { memo } from 'react'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props as Record<string, unknown>

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.title}
    </div>
  )
})

export default Comp

示例 2:TS 类型增强(企业级推荐)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.customTitle }}
  </div>
</template>

<script setup lang="ts">
import { useAttrs } from 'vue'

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

const props = defineProps<{
  id: string
}>()

const attrs = useAttrs() as CustomAttrs
</script>

React 输出

import { memo } from 'react'

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

type ICompProps = { id: string }

const Comp = memo((props: ICompProps & Record<string, unknown>) => {
  const attrs = props as CustomAttrs

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.customTitle}
    </div>
  )
})

export default Comp

示例 3:动态属性 / 可选链(真实业务常用)

Vue 输入

<template>
  <div
    :class="[
      'base',
      attrs.class,
      attrs.xx?.class,
      attrs['custom-class']
    ]"
  >
    {{ attrs?.xxx?.content }}
  </div>
</template>

React 输出

import { memo } from 'react'
import { dir } from '@vureact/runtime-core'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props

  return (
    <div
      className={dir.cls([
        'base',
        attrs.class,
        attrs.xx?.class,
        attrs['custom-class']
      ])}
    >
      {attrs?.xxx?.content}
    </div>
  )
})

四、避坑指南(VuReact 必看)

  1. 必须用 useAttrs(),禁止用 $attrs 编译器无法分析运行时变量,会丢属性。

  2. TS 尽量写接口 有利于提示、重构、避免空值报错。

  3. class/style 自动适配 classclassName style → 自动适配 React.CSSProperties

  4. defineProps + useAttrs 会自动合并类型 不用手动改。

  5. JS 项目直接用 会被编译成 const attrs = props,完全兼容。


五、总结

VuReact 处理透传 Attributes 的核心思想只有一句话: 把 Vue 隐式的 $attrs 变成显式的 useAttrs,再映射到 React 的 props 体系。

  • 你只管按 Vue 官方写法写
  • 编译器自动转成标准 React TSX
  • 类型安全、生产可用、迁移成本极低

正在做 Vue3 → React 迁移的同学,这套透传方案可以直接进团队规范。


🔗 相关资源


推荐阅读

#Vue3 #React #Vue转React #VuReact #前端迁移 #useAttrs #组件封装 #TypeScript

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

2026年4月13日 11:47

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

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

其实这些前端异步痛点,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 年的前端工程师到底在卷什么?

作者 ErpanOmer
2026年4月13日 11:18

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模块化保姆级教程,彻底告别全局污染,新手也能秒上手

2026年4月13日 10:40

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

写代码时,变量、函数越写越多,不小心就全局污染,导致代码冲突报错;引入多个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. 直线上最多的点数:题解深度剖析

作者 Wect
2026年4月13日 10:36

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个数组方法保姆级拆解

2026年4月13日 10:34

前端人必看!数组是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,还能提升代码可读性——这篇文章整理了所有方法的实战代码和避坑点,建议收藏,面试前过一遍,开发时直接复制使用!

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

前端视频媒体带声音自动播放方案最佳实践和教程

作者 鹏多多
2026年4月13日 10:29

在前端开发过程中,经常会碰到这样的需求,自动播放视频,要求默认带声音。在浏览器环境下,视频媒体自动播放是可以的,默认静音的自动播放可以正常执行。浏览器不能自动播放的限制,仅针对带声音的自动播放。当网页无用户交互、媒体参与度不足时,带声音的自动播放会被浏览器拦截。

本文结合「用户交互触发」「媒体参与度优化」「跨域权限下放」三大核心场景,提供可落地的实现方案,附代码片段与关键细节说明,明确区分静音与带声音自动播放的实现差异。看完如有帮助,谢谢三连~

1. 核心前提:浏览器自动播放策略

浏览器对自动播放的限制核心的是「避免声音突然打扰用户」,具体规则如下:

  1. 「静音自动播放」:默认允许,无需用户交互、无需满足媒体参与度阈值,可直接实现;
  2. 「带声音自动播放」:受严格限制,需满足以下任一条件才可正常执行:
  3. 用户有强交互(如点击、Tab切换、滑动等),且交互触发在当前域名下;
  4. 媒体参与度(Media Engagement)达标(不同浏览器阈值不同,阈值达标后浏览器会放宽限制);
  5. 父元素(如顶级页面)已获得带声音播放权限,可下放给子iframe(同域名/配置跨域权限后)。

1.1. 什么是媒体参与度?

媒体参与度

媒体参与度(Media Engagement)是浏览器(以Chrome为首)内置的一种用户行为评分机制,核心是通过监测用户对当前域名的媒体交互行为,评估该网站的信任度,进而决定是否放宽带声音自动播放的限制,本质是浏览器给予域名的「信任积分」。

其判定依据主要包括:用户在当前域名播放过音频/视频、进行过点击/滑动/输入等交互操作、停留时间较长或浏览多个页面等;得分越高,浏览器对该域名的信任度越高,越容易允许带声音自动播放。

查看 Chrome 浏览器媒体参与度:直接访问 chrome://media-engagement,可查看当前域名的参与度得分及阈值。其中,Score 为当前得分(0-100分),Threshold 为准入阈值(通常20-30分,因浏览器版本和设备而异),Engaged 为 true 时,说明得分达标,浏览器授予带声音自动播放权限。

2. 基础实现:静音自动播放

该方式无需交互,直接可用。

2.2. 实现逻辑

无需用户前置交互,直接创建媒体元素并设置 muted="true",即可实现静音自动播放; 若需切换为带声音播放,需通过用户交互触发(如点击按钮取消静音)。

2.3. 案例

<!-- 页面结构:静音自动播放 + 手动取消静音按钮 -->
<div class="media-container">
  <video 
    id="videoPlayer" 
    muted="true"  <!-- 关键设置静音实现自动播放 -->
    autoplay       <!-- 自动播放属性 -->
    loop           <!-- 可选:循环播放 -->
    style="width: 100%;"
  >
    <source src="your-video-url.mp4" type="video/mp4">
  </video>
  <button id="unmuteBtn" style="margin-top: 10px; padding: 8px 16px;">
    点击开启声音
  </button>
</div>

<script>
  const video = document.getElementById('videoPlayer');
  const unmuteBtn = document.getElementById('unmuteBtn');

  // 静音自动播放无需额外触发,浏览器默认允许
  console.log('静音自动播放已执行');

  // 用户交互触发:取消静音(带声音播放)
  unmuteBtn.addEventListener('click', async () => {
    try {
      // 取消静音并尝试带声音播放(需用户交互触发,否则会报错)
      video.muted = false;
      await video.play();
      console.log('带声音播放成功');
    } catch (error) {
      console.log('带声音播放失败(未满足权限条件):', error);
      // 失败后恢复静音,避免影响自动播放
      video.muted = true;
    }
  });
</script>

2.4. 关键细节

  • 只要设置 muted="true"autoplay 属性可直接生效,无需用户交互;
  • 带声音播放必须通过用户交互触发(如点击按钮),否则即使取消静音,play() 也会被拦截;
  • 建议搭配 try/catch 包裹带声音播放逻辑,避免报错影响页面正常运行。

3. 带声音自动播放

若需实现「无需用户每次交互,即可带声音自动播放」,核心是提升当前域名的媒体参与度:

  1. 设计引导页,让用户完成高频交互(如点击、滑动、按键);
  2. 引导页中播放静音媒体,持续积累媒体参与度;
  3. 参与度达标后,跳转至目标页面,此时浏览器会默认允许带声音自动播放。

这里的引导页,实际上可以是任意页面,设计得让用户无感,只要发生交互即可。

示例代码:

<!-- 引导页:用于提升媒体参与度,为带声音自动播放铺路 -->
<div class="guide-page" style="text-align: center; padding: 50px 0;">
  <h3>点击任意区域进入播放页</h3>
  <p id="guideTip" style="margin: 20px 0; color: #666;">当前媒体参与度:<span id="engagementScore">0</span>(达标即可带声音自动播放)</p>
</div>

<script>
  // 创建静音音频(用于积累参与度,不干扰用户)
  const engagementAudio = new Audio('silent-audio.mp3');
  engagementAudio.loop = true;
  engagementAudio.muted = true; // 静音播放,避免打扰

  // 监听用户交互,触发参与度提升
  document.querySelector('.guide-page').addEventListener('click', async () => {
    // 首次点击触发静音播放,开始积累参与度
    await engagementAudio.play();
    // 模拟参与度更新(实际可通过 chrome://media-engagement 查看真实值)
    updateEngagementScore();
    // 假设参与度达标(模拟值≥80),跳转至目标页面
    const currentScore = Number(document.getElementById('engagementScore').textContent);
    if (currentScore >= 80) {
      setTimeout(() => {
        window.location.href = 'autoplay-page.html'; // 目标带声音自动播放页面
      }, 1000);
    }
  });

  // 模拟媒体参与度积累
  function updateEngagementScore() {
    const scoreSpan = document.getElementById('engagementScore');
    let currentScore = Number(scoreSpan.textContent) + 20;
    scoreSpan.textContent = Math.min(currentScore, 100); // 参与度上限100
  }
</script>

3.1. 媒体参与度提升流程图

插图1.png

4. 跨域 iframe 自动播放

  • 静音自动播放:iframe 可直接实现,无需父页面权限;
  • 带声音自动播放:需父页面已获得带声音播放权限(通过用户交互/参与度达标),并将权限下放给 iframe(同域名/配置跨域权限)。

4.1. 案例

示例代码:

  • 父页面(同域名,已获带声音播放权限)
<!-- 父页面:通过用户交互获得带声音播放权限 -->
<button id="parentPlayBtn" style="padding: 8px 16px; margin: 20px 0;">点击开启带声音播放(授权)</button>
<iframe id="mediaIframe" src="iframe-page.html" width="800" height="450"></iframe>

<script>
  const parentPlayBtn = document.getElementById('parentPlayBtn');
  const iframe = document.getElementById('mediaIframe');
  let hasAudioPermission = false;

  // 父页面用户交互,获得带声音播放权限
  parentPlayBtn.addEventListener('click', async () => {
    const testAudio = new Audio('test-audio.mp3');
    try {
      await testAudio.play();
      testAudio.pause();
      hasAudioPermission = true;
      console.log('父页面已获得带声音播放权限');
      // 向iframe发送权限下放通知
      iframe.contentWindow.postMessage('autoplay-allowed', '*');
    } catch (error) {
      console.log('父页面带声音播放授权失败:', error);
    }
  });
</script>
  • iframe 页面
<!-- 子iframe:根据父页面权限,实现对应自动播放 -->
<video id="iframeVideo" muted="true" autoplay loop style="width: 100%;">
  <source src="iframe-video-url.mp4" type="video/mp4">
</video>

<script>
  const video = document.getElementById('iframeVideo');

  // 监听父页面权限通知,切换为带声音播放
  window.addEventListener('message', async (e) => {
    if (e.data === 'autoplay-allowed') {
      try {
        // 父页面已授权,尝试带声音播放
        video.muted = false;
        await video.play();
        console.log('iframe 带声音自动播放成功');
      } catch (error) {
        console.log('iframe 带声音播放失败:', error);
        video.muted = true; // 失败后恢复静音自动播放
      }
    }
  });
</script>

4.2. 关键细节

  • 跨域场景下,需在父页面响应头配置 Permissions-Policy: autoplay=(self "https://子域名.example.com"),允许权限下放;
  • 即使父页面授权,iframe 带声音播放仍建议用 try/catch 处理异常,避免权限失效导致播放失败;
  • 若父页面未授权,iframe 仍可正常实现静音自动播放,不影响基础体验。

5. 进阶方案:智能检测播放能力

封装音频/视频播放类,自动检测浏览器是否允许带声音播放:能播放则自动开启声音,不能则默认静音播放,无需手动判断,适配所有场景。

5.1. 案例

// 封装智能媒体播放类(适配音频/视频,自动区分静音/带声音)
class SmartMediaPlayer {
  constructor(mediaUrl, isVideo = false) {
    // 创建媒体元素(音频/视频)
    this.media = isVideo ? document.createElement('video') : document.createElement('audio');
    this.media.src = mediaUrl;
    this.media.loop = true; // 可选:循环播放
    this.canPlayWithAudio = false; // 标记是否可带声音播放
  }

  // 初始化检测:自动判断播放能力
  async init() {
    try {
      // 尝试带声音播放(无用户交互时,此处会报错)
      await this.media.play();
      this.canPlayWithAudio = true;
      console.log('可带声音自动播放');
    } catch (error) {
      // 带声音播放失败,切换为静音自动播放(默认允许)
      this.media.muted = true;
      await this.media.play();
      this.canPlayWithAudio = false;
      console.log('带声音播放受限,已切换为静音自动播放');
    }
  }

  // 手动切换声音(需用户交互触发)
  toggleAudio() {
    if (!this.canPlayWithAudio) return; // 未获得带声音权限,不执行
    this.media.muted = !this.media.muted;
  }

  // 播放/暂停控制
  togglePlay() {
    this.media.paused ? this.media.play() : this.media.pause();
  }
}

// 调用示例(音频)
(async () => {
  const audioPlayer = new SmartMediaPlayer('background-music.mp3');
  await audioPlayer.init();
  // 页面加载完成后自动播放(静音/带声音自动适配)
  audioPlayer.togglePlay();
})();

// 调用示例(视频)
(async () => {
  const videoPlayer = new SmartMediaPlayer('demo-video.mp4', true);
  await videoPlayer.init();
  // 追加到页面
  document.body.appendChild(videoPlayer.media);
})();

5.2. 智能播放能力检测流程图

插图2.png

6. 参考资料与注意事项

6.1. 官方参考

6.2. 开发注意事项

  • 静音自动播放虽无需交互,但建议搭配加载状态提示,避免用户误以为媒体未加载;
  • 带声音自动播放的核心是「用户交互」或「媒体参与度」,二者缺一不可,不可强行绕过浏览器限制;
  • 移动端浏览器对带声音自动播放的限制更严格,即使参与度达标,部分机型仍需用户交互触发;
  • 媒体文件建议压缩优化,避免加载延迟导致自动播放触发时机滞后,影响用户体验;
  • 可通过 chrome://media-engagement 调试当前域名的参与度,适配不同浏览器的阈值差异。

以上,在实际开发中,可根据业务需求(是否需要声音),组合使用以上方案,既满足自动播放需求,又符合浏览器权限策略,兼顾用户体验与开发合规性。

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

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

2026年4月13日 10:24

谁懂啊家人们!前端入门绕不开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 最常用、最核心的知识点,代码可直接复制练习,建议收藏起来,遇到不会的就翻一翻,慢慢就熟练了~

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

SDD 实战:用 Claude Code + OpenSpec,把 AI 编程变成“流水线”

作者 zhEng
2026年4月13日 10:03

一、什么是 OpenSpec?

OpenSpec 指的是一个规范驱动开发SDD(Spec-Driven Development) 的范式,为AI编码提供了“规格说明书”,把AICoding从“凭感觉写代码”提升到“按规格任务执行”的高度,告别“开盲盒”式的AI编程。

一句话定义:

在写任何一行代码之前,先定义一份“AI可执行的规格说明书(Spec)”

它的核心思想是:

  • 人类负责:定义规则(Spec)
  • AI 负责:执行规则(生成代码)

(1)什么是规范驱动开发(SDD)?

规范驱动开发(Spec-Driven Development, SDD)是一种软件开发方法论,其核心理念是:

  • 规范定义行为:系统的行为由规范(Specification)明确定义
  • 代码实现规范:代码是对规范的实现,而非规范的替代
  • 规范驱动变更:所有变更都从规范变更开始
  • 规范即文档:规范既是需求文档,也是设计文档

(2)和传统开发有什么不同?

方式 核心输入 AI行为
Prompt 编程 自然语言
OpenSpec 结构化规范 执行

从“描述需求” → “定义系统行为”

二、为什么需要 OpenSpec?

2.1 传统 AI 编程的核心问题

AI 编程助手(如 Claude / Copilot)存在几个致命缺陷:

  • ❌ 模糊输入 → 不稳定输出
  • ❌ 无法形成“系统级约束”
  • ❌ 无版本追踪(改了啥说不清)
  • ❌ 上下文一长就失控
  • ❌ 遗漏重要功能 & 添加了不必要的功能

2.2 OpenSpec 的解决方案

OpenSpec 通过“规范驱动”解决这些问题:

  • ✅ 明确共识:编码前锁定需求
  • ✅ 结构化管理:所有规范集中管理
  • ✅ 可审查:Spec 可读、可评审
  • ✅ 可执行:AI根据确定的需求生成代码
  • ✅ 可追踪:所有变更都有历史

❗ 其本质就是: AI 不再自由发挥,而是严格执行 Spec

三、快速开始 OpenSpec

3.1 环境准备

Node.js >= 20.19.0

全局安装

npm install -g @fission-ai/openspec@latest

验证是否安装成功:

openspec --version

image.png

3.2 初始化项目

cd openspec-demo
openspec init

image.png

初始化过程中会让你选择 AI 编程工具(推荐 Claude Code)。

image.png 完成后会生成核心目录:

image.png

openspec/
├── specs/        # 当前系统规范(源真相)
├── changes/      # 变更提案
└──── archive/      # 历史归档

四、OpenSpec 核心能力(Skills)

4.1 openspec-propose(发起变更)

  • 核心作用:发起一个变更提案

  • 做什么:

    • openspec/changes/ 下创建一个独立变更目录
    • 引导你编写变更说明(proposal.md) :为什么改、改什么、影响范围
    • 生成待完善的规格文档(spec),先和AI对齐需求,在写代码
  • 场景:

    • 新增功能
    • 重构模块
    • 修复重大问题前的需求对齐

4.2 openspec-explore(分析系统)

  • 核心作用:探索与分析当前规范与变更

  • 做什么:

    • 读取 openspec/specs/ 里的现有规范,帮你理解系统当前行为
    • 分析待处理的变更提案,评估影响范围、依赖关系
    • 辅助你细化方案、拆分任务,避免开发时偏离规范
  • 适用场景:

    • 开发前做技术调研、
    • 理解现有系统、
    • 评估变更风险

4.3 openspec-apply-change(生成代码)

  • 核心作用:将规范变更落地到代码实现

  • 做什么:

    • 读取指定变更提案的规范文档
    • 引导 Claude 按规范生成 / 修改代码,严格对齐 spec
    • 确保代码实现与规范完全一致,避免 “写的和想的不一样”
  • 适用场景:

    • 规范定稿后
    • 正式开发 / 迭代代码阶段

4.4 openspec-archive-change(归档)

  • 核心作用:归档已完成的变更,更新项目规范

  • 做什么:

    • 将已实现的变更规范合并到 openspec/specs/ (项目 “源真相”)
    • 把变更目录移动到 openspec/changes/archive/ 归档
    • 生成交付记录,让项目规范始终保持最新状态
  • 适用场景:代码开发完成、测试通过后,正式纳入项目规范

4.5 整体工作流程

  1. propose → 定义需求,定义规范
  2. explore → 分析影响
  3. apply-change → 按照规范生成代码
  4. archive → 更新规范,归档

这其实就是:把软件开发变成“规范驱动流水线”

五、实战:用 OpenSpec + Claude Code 对TodoList进行优化

这是上一篇文章使用Claude Code实现的TodoList

Claude Code 入门实战:从安装配置到真实项目落地

image.png

本次需求:

  1. 待办事项的列表改为使用Table展示,并且支持批量改变完成状态和删除功能;
  2. 在列表中增加创建时间和更新时间两个字段,展示格式为YYYY-MM-DD hh:mm:ss
  3. 现状:添加相同的待办事项可以添加成功;期望:不允许添加重复的待办事项,并给出存在重复的待办事项提示;
  4. 改为Table展示后再调整下页面的样式,待办事项清单的宽度以及背景颜色;

Step 1:通过 /openspec-propose调用openspec的skills

  • /openspec-propose提交需求后,系统自动在openspec/change目录下创建了本次需求的独立目录enhance-todo-list,这里的目录名称可以理解为就是本次的需求ID
  • 目录自动生成标准化需求文档,支持反复评审打磨,确保需求清晰,边界明确后再进入开发阶段,避免需求存在偏差

image.png

创建proposal.md提案文件

## Why

当前待办事项应用使用List组件展示,功能较为基础,不支持批量操作。同时,缺少对重复事项的校验机制,以及用户无法直观查看待办事项的创建和更新时间。这些限制降低了应用的用户体验和管理效率。

## What Changes

- **UI组件升级**: 将List组件替换为Table组件,支持更丰富的展示和交互
- **批量操作**: 新增批量改变完成状态和批量删除功能
- **时间字段增强**: 添加创建时间(createdAt)和更新时间(updatedAt)字段,格式化为 `YYYY-MM-DD hh:mm:ss`
- **重复校验**: 添加待办事项内容去重机制,防止重复添加

## Capabilities

### New Capabilities
- `batch-todo-operations`: 批量操作待办事项(批量完成/取消完成、批量删除)
- `todo-time-tracking`: 待办事项时间记录和展示(创建时间、更新时间)
- `todo-duplicate-validation`: 待办事项重复性校验

### Modified Capabilities
- `todo-crud`: 基础待办事项增删改查(添加重复校验到创建操作)

## Impact

- **代码变更**:
  - `src/components/TodoList.tsx`: 重构为Table组件,添加批量操作逻辑
  - `src/types/todo.ts`: 添加updatedAt字段
  - 新增依赖: `dayjs` 时间处理库

- **API变更**:
  - addTodo: 添加重复校验逻辑
  - 新增: batchToggleTodos、batchDeleteTodos 方法

- **用户体验**:
  - 提供更高效的批量操作能力
  - 更清晰的时间信息展示
  - 避免重复待办事项的创建

  • openspec/change/enhance-todo-list/specs 这个文件里面的内容可以理解为是本次需求的测试用例文件。
  • design.md & tasks.md是根据需求创建的设计文档和将需求拆解为一个个的Task文档
  • 这里就需要我们去确认这个task.md文档中拆解的task是否合理,是否可以满足我们的需求,在后续apply的时候会去执行文档中所有的task

image.png

Step 2: 自动化生成代码,上述文档确认完成后,执行指令: /openspec-apply-change 需求ID

系统将会自动按照tasks.md中的任务清单,逐个执行任务

image.png

完成需求后页面效果: image.png

查看实现的代码,整个过程中几乎没有“手写业务代码”,而是把精力放在“定义系统行为”上面。

这就是OpenSpec传统 AI 编程最大的不同。 image.png

Step 3: 执行 /openspec-archive-change 需求ID将本次的迭代的需求进行归档操作,方便后续追溯

  • 执行完本次的需求文件夹会被移动到openspec/changes/archive/日期+需求ID目录下

image.png

六、总结

OpenSpec 的意义,不只是一个工具,更像是一种开发范式的转变:

从“人写代码,AI辅助”
到“人定义系统,AI负责实现”

在这种模式下:

  • 代码不再是“源真相”,规范才是
  • AI 不再是“猜需求”,而是“执行规则”
  • 开发过程从“不断试错”,变成“按规范推进”

这背后,其实是软件工程的一次“回归”:

👉 回归到“用明确的约束定义系统行为”

当 AI 编码能力越来越强,真正拉开差距的,不再是“谁写代码更快”,而是:

谁能定义出更清晰、更严谨的系统规范

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

作者 AI划重点
2026年4月13日 09:55

任何稍有规模的应用最终都要处理文件。个人资料编辑页要传头像。笔记应用要附加图片。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核心特性

作者 Wect
2026年4月13日 09:16

在前端开发中,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基础上,增加并发限制、超时、重试等实战功能,解决大量异步任务的高效、稳定执行问题。

掌握这些知识点,不仅能看懂框架底层代码,更能在实际开发中灵活处理各类异步场景,写出更高效、更健壮的代码。

Flex + Grid 混合布局指南

作者 小霍同学
2026年4月13日 09:04

Flex + Grid 混合布局指南

在现代前端开发中,Flex 和 Grid 并不是非此即彼的选择,而是一对强大的组合拳。Flex 擅长一维排列(行或列),Grid 擅长二维布局(行与列同时控制)。混合使用它们,可以同时获得两种布局模型的优点:用 Grid 搭建页面的宏观骨架,用 Flexbox 处理组件内部的微观对齐。

Flex 与 Grid 的核心差异

维度 Flex Grid
维度 一维(行 列) 二维(行 列同时控制)
内容驱动 项目沿主轴分布,由内容撑开 轨道由容器定义,项目放置其中
对齐能力 主轴/交叉轴对齐,强大且灵活 单元格内对齐 + 整个网格对齐
典型用途 导航栏、列表、卡片内元素 整体页面布局、相册网格、表单

一句话总结:

  • 需要精确控制行和列的位置关系 → Grid
  • 需要沿着一个方向灵活排列,或处理未知数量的项目 → Flex

快速决策:何时用 Flex,何时用 Grid

优先使用 Grid 的场景

  • 整体页面结构(header, main, sidebar, footer)
  • 复杂的二维网格(如仪表盘、作品集、图库)
  • 需要显式控制重叠区域(grid-template-areas
  • 轨道尺寸需要基于比例(fr)和内容(minmax())配合

优先使用 Flexbox 的场景

  • 导航栏、工具栏、按钮组
  • 列表项内部(头像 + 文本 + 操作按钮)
  • 居中对齐(特别是垂直居中)需求强烈
  • 项目数量不固定,需要自动换行(flex-wrap
  • 顺序重排(order)的轻量需求

混合策略:宏观 Grid + 微观 Flex

这是最常用的模式:用 Grid 划分大区域,用 Flexbox 安排每个区域内部的内容

┌─────────────────────────────────────┐
│  Header (Grid 区域)                  │
│  └─ 内部用 Flex 排列 Logo + 导航菜单   │
├───────────┬─────────────────────────┤
│ Sidebar   │ Main (Grid 区域)         │
│ (Grid)    │ └─ 卡片列表用 Grid        │
│ └─ 内部用 │   每个卡片内部用 Flex      │
│   Flex 列 │   排列头像/标题/按钮       │
│   表导航  │                          │
├───────────┴─────────────────────────┤
│  Footer (Grid 区域)                  │
│  └─ 内部用 Flex 排列版权与链接         │
└─────────────────────────────────────┘

混合布局的四种实用模式

模式一:Grid 容器内使用 Flex 项目

场景:Grid 定义了宏观区域(如页眉、侧边栏),每个区域内需要水平或垂直排列元素。

<div class="page">
  <header class="header"> ... </header>
  <aside class="sidebar"> ... </aside>
  <main class="content"> ... </main>
  <footer class="footer"> ... </footer>
</div>
.page {
  display: grid;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 250px 1fr;
  gap: 20px;
}

.header {
  grid-area: header;
  display: flex;           /* 内部使用 Flexbox */
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
}

.sidebar {
  grid-area: sidebar;
  display: flex;
  flex-direction: column;  /* 垂直排列导航项 */
  gap: 0.5rem;
}

.footer {
  grid-area: footer;
  display: flex;
  justify-content: center;
  gap: 2rem;
}

模式二:Flex 容器内使用 Grid 项目

场景:外层是 Flex 排列的卡片容器,但每个卡片内部是二维网格布局(如图片区、标题区、描述区)。

<div class="card-list">
  <div class="card">
    <div class="card-image">...</div>
    <div class="card-title">...</div>
    <div class="card-desc">...</div>
    <div class="card-footer">...</div>
  </div>
  <!-- 更多卡片 -->
</div>
.card-list {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
}

.card {
  flex: 280px;              /* 简写,等价于 flex: 1 1 280px */
  display: grid;            /* 卡片内部使用 Grid */
  grid-template-rows: auto auto 1fr auto;
  gap: 12px;
  background: #f5f5f5;
  padding: 1rem;
}

.card-image { grid-row: 1; }
.card-title { grid-row: 2; }
.card-desc { grid-row: 3; }
.card-footer { 
  grid-row: 4;
  display: flex;            /* 甚至可以再嵌套 Flex */
  justify-content: flex-end;
}

模式三:响应式切换布局模式(Grid ↔ Flex)

场景:在宽屏时使用 Grid 展示多列,在窄屏时改为 Flex 垂直堆叠。

.container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}

@media (max-width: 768px) {
  .container {
    display: flex;
    flex-direction: column;
    gap: 20px;
  }
  /* 关键:Flex 子项默认不拉伸宽度,需显式设置 */
  .container > * {
    width: 100%;
  }
}

注意:从 Grid 切换到 Flex 时,Grid 子项默认会拉伸填满列宽,而 Flex 子项默认按内容宽度显示。添加 width: 100% 可使其填满父容器。

模式四:利用 Flex 对齐能力简化 Grid 内部对齐

Grid 虽然提供了 place-items / place-self,但对于单行/单列的简单排列,Flex 往往更简洁。

需求:在 Grid 单元格内让一个按钮水平居中且底部对齐。

.grid-cell {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
}

相比之下,如果只用 Grid 属性,需要写 align-self: end; justify-self: center,且无法轻松实现垂直方向上的多个元素排列(如按钮上方还有文本)。Flexbox 更自然。

最佳实践与注意事项

推荐做法

  1. 从宏观到微观:先用 Grid 设计整体页面结构,再用 Flexbox 处理局部细节。
  2. 利用 gap 统一间距:Grid 和 Flex 都支持 gap,尽量使用而非 margin 来避免外边距折叠问题。
  3. 响应式优先使用 auto-fill / auto-fit:在 Grid 中结合 minmax() 创建弹性网格,减少媒体查询。
  4. 避免过度嵌套:能用一个 Grid 实现二维布局,就不要在外面再套一层 Flex。
  5. 善用 flex: 1 填充剩余空间:在 Grid 单元格内,如果需要某个 Flex 子项占满剩余高度,使用 flex: 1

常见陷阱及解决方案

  • Grid 子项作为 Flex 容器时,min-width: auto 可能导致内容溢出 解决方案:给该 Flex 容器设置 min-width: 0overflow: hidden,允许内容收缩到比默认最小宽度更小。

    .grid-item {
      display: flex;
      min-width: 0;   /* 防止长单词或图片撑开父级 */
    }
    
  • flex 属性在 Grid 项目上无效 Grid 项目的尺寸由 grid-template-columns/rows 决定,设置 flex: 1 不会影响其在网格中的大小。如果希望 Grid 项目内部有弹性填充,请在该项目内部使用 Flex 容器。

  • gap 在 Grid 与 Flex 中的行为差异

    • Grid:gap 在轨道之间添加间隔,间隔尺寸先于 fr 分配扣除,剩余空间再按比例分配。
    • Flex:gap 仅在项目之间添加间隔,不影响主轴对齐(如 space-between 会忽略最后一个 gap)。
  • Flex 换行与 Grid 混用的理解 Grid 容器内的项目默认不会自动换行(除非使用 repeat(auto-fill, ...))。如需类似 Flex 的换行效果,请使用 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))

  • align-self / justify-self 在 Flex 项目中无效 这些属性是 Grid 项目的专用属性,不要错误地应用于 Flex 子项。

兼容性提示

特性 Chrome Firefox Safari Edge IE
Flexbox (无前缀) 29+ 28+ 9+ 12+ 11(部分)
Grid (无前缀) 57+ 52+ 10.1+ 16+ 不支持
gap in Flexbox 84+ 63+ 14.1+ 84+ 不支持
subgrid 117+ 71+ 16+ 117+ 不支持

现状说明gap 在 Flexbox 中的支持现已普及(主流浏览器最新版本均支持),仅需注意 Safari < 14.1 或 iOS < 14.1 的旧设备。subgrid 已得到现代浏览器良好支持,可用于复杂嵌套网格。

对于需要兼容 IE 的项目

  • 不要使用 Grid(或使用降级方案,如 @supports 检测)
  • 完全依赖 Flexbox 配合 float 后备
  • 避免使用 Flexbox 的 gap,改用 margin

推荐使用 Autoprefixer 处理旧版浏览器前缀,并在 Grid 布局中提供简单的 Flex 后备:

.container {
  display: flex;           /* 后备 */
  flex-direction: column;
}

@supports (display: grid) {
  .container {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}

4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理

作者 Cobyte
2026年4月13日 09:00

前言

我们从前面的文章中知道的所谓发布订阅模式的本质是不管代码结构如何变化,它的核心都是管理对象间的依赖关系,或者说是事件间的依赖关系,一方变化了,所有跟其建立依赖关系的依赖都将得到通知。同时发布者对象既可以是发布者也可以是订阅者,所以我们不能只从代码组织结构去分辨模式,而是从意图去分辨。

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 应用开发。

如何实现RN应用的离线功能、数据缓存策略?

作者 光影少年
2026年4月12日 20:29

React Native(RN) 里实现“离线可用 + 数据缓存”,本质是两件事:

① 本地持久化数据(缓存)
② 网络恢复后的同步策略(离线→在线)

我给你按“能直接落地”的思路讲,不整虚的👇


🧩 一、离线能力的整体架构

一个靠谱的 RN 离线方案一般是这样:

接口请求 → 缓存层 → UI展示
            ↓
        本地存储(数据库/缓存)
            ↓
        同步机制(网络恢复)

核心点就3个:

  1. 缓存数据
  2. 判断网络状态
  3. 同步数据(冲突处理)

📦 二、本地存储方案选型(很关键)

1️⃣ 轻量缓存(推荐入门)

👉 AsyncStorage

适合:

  • token
  • 用户信息
  • 小量接口缓存
import AsyncStorage from '@react-native-async-storage/async-storage'

// 存
await AsyncStorage.setItem('user', JSON.stringify(user))

// 取
const user = JSON.parse(await AsyncStorage.getItem('user'))

👉 ❗缺点:

  • 不适合大数据
  • 没查询能力

2️⃣ 高性能KV缓存(推荐)

👉 react-native-mmkv

优势:

  • C++实现,性能非常高
  • 支持同步读取(比 AsyncStorage 快很多)
import { MMKV } from 'react-native-mmkv'

const storage = new MMKV()

storage.set('name', 'zhang')
const name = storage.getString('name')

👉 适合:

  • 高频读写
  • 状态缓存(Redux持久化)

3️⃣ 本地数据库(复杂业务必选)

👉 realm

  • 离线能力强
  • 支持对象存储
  • 自动同步(可选)

👉 SQLite

  • 更底层
  • 灵活但要自己写SQL

👉 适合:

  • 列表数据
  • 离线表单
  • 聊天记录

🌐 三、网络状态监听(离线核心)

👉 用:@react-native-community/netinfo

import NetInfo from "@react-native-community/netinfo";

NetInfo.addEventListener(state => {
  console.log("是否联网:", state.isConnected);
});

👉 用途:

  • 判断是否走缓存
  • 触发“数据同步”

🔁 四、数据缓存策略(重点)

🧠 策略1:Cache First(优先缓存)

👉 打开页面先读缓存,再请求接口更新

const cache = await getCache()

if (cache) {
  setData(cache)
}

fetchApi().then(res => {
  setData(res)
  saveCache(res)
})

👉 优点:

  • 秒开
  • 离线可用

🧠 策略2:Network First(优先网络)

try {
  const res = await fetchApi()
  saveCache(res)
  return res
} catch {
  return getCache()
}

👉 优点:

  • 数据新
    👉 缺点:
  • 离线体验一般

🧠 策略3:Stale-While-Revalidate(推荐)

👉 最适合RN

先用缓存  后台更新  UI刷新

👉 类似 Web 的 SWR


📝 五、离线写入(重点难点🔥)

比如:

👉 用户离线提交表单 / 点赞 / 操作

你要做:

1️⃣ 本地先存操作(队列)

const queue = [
  { type: 'CREATE', data: {...}, status: 'pending' }
]

2️⃣ 网络恢复后同步

if (isOnline) {
  queue.forEach(async item => {
    await api(item)
    markDone(item)
  })
}

3️⃣ 冲突处理(很关键)

常见策略:

  • 时间戳优先(last-write-wins)
  • 版本号控制
  • 服务端合并

⚡ 六、工程级方案(推荐你用)

👉 状态管理 + 持久化

  • Redux + redux-persist
  • Zustand + persist

👉 Zustand示例:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useStore = create(persist(
  (set) => ({
    user: null,
    setUser: (user) => set({ user })
  }),
  { name: 'app-storage' }
))

👉 请求缓存库(高级)

  • react-query(TanStack Query)
  • SWR(也有RN版本)

👉 react-query支持:

  • 自动缓存
  • 重试
  • 离线恢复

🚀 七、一个完整落地方案(推荐你这样做)

结合你现在前端背景,我给你一个“企业级方案”👇

RN App
├── Zustand(状态管理)
├── MMKV(本地缓存)
├── React Query(接口缓存)
├── NetInfo(网络检测)
└── Sync Queue(离线同步)

🧠 总结(最重要)

👉 RN离线能力 =

缓存(读) + 队列(写) + 同步(网络恢复)

🎯 给你的进阶建议(很关键)

你现在是前端出身,可以重点搞这个方向:

👉 “离线优先应用(Offline-first App)”

做一个项目练手:

  • 离线Todo App(带同步)
  • 离线表单系统(类似工单)
  • IoT设备数据缓存(结合你Go也能玩)

Recent lld/ELF performance improvements

作者 MaskRay
2026年4月12日 15:00

Since the LLVM 22 branch was cut, I've landed patches thatparallelize more link phases and cut task-runtime overhead. This postcompares current main against lld 22.1, mold, and wild.

Headline: a Release+Asserts clang --gc-sections link is1.37x as fast as lld 22.1; Chromium debug with --gdb-indexis 1.07x as fast. mold and wild are still ahead — the last sectionexplains why.

Benchmark

lld-0201 is main at 2026-02-01 (6a1803929817);lld-load is main plus the new[ELF] Parallelize input file loading. mold andwild run with --no-fork so the wall-clocknumbers include the linker process itself.

Three reproduce tarballs, --threads=8,hyperfine -w 1 -r 10, pinned to CPU cores withnumactl -C.

Workload lld-0201 lld-load mold wild
clang-23 Release+Asserts, --gc-sections 1.255 s 917.8 ms 552.6 ms 367.2 ms
clang-23 Debug (no --gdb-index) 4.582 s 4.306 s 2.464 s 1.565 s
clang-23 Debug (--gdb-index) 6.291 s 5.915 s 4.001 s N/A
Chromium Debug (no --gdb-index) 6.140 s 5.904 s 2.665 s 2.010 s
Chromium Debug (--gdb-index) 7.857 s 7.322 s 3.786 s N/A

Note that llvm/lib/Support/Parallel.cpp design keeps themain thread idle during parallelFor, so--threads=N really utilizes N+1 threads.

wild does not yet implement --gdb-index — it silentlywarns and skips, producing an output about 477 MB smaller on Chromium.For fair 4-way comparisons I also strip --gdb-index fromthe response file; the no --gdb-index rows above use thatsetup.

A few observations before diving in:

  • The --gdb-index surcharge on the Chromium link is+1.42 s for lld (5.90 s → 7.32 s) versus+1.12 s for mold (2.67 s → 3.79 s). This is currently oneof the biggest remaining gaps.
  • Excluding --gdb-index, mold is 1.66x–2.22x as fast andwild 2.5x–2.94x as fast on this machine. There is plenty of roomleft.
  • clang-23 Release+Asserts --gc-sections (workload 1) hascollapsed from 1.255 s to 918 ms, a 1.37x speedup over 10 weeks. Most ofthat came from the parallel --gc-sections mark, parallelinput loading, and the task-runtime cleanup below — each contributing amultiplicative factor.

macOS (Apple M4) notes

The same clang-23 Release+Asserts link, --threads=8, onan Apple M4 (macOS 15, system allocator for all four linkers):

Linker Wall User Sys (User+Sys)/Wall
lld-0201 324.4 ± 1.5 ms 502.1 ms 171.7 ms 2.08x
lld-load 221.5 ± 1.8 ms 476.5 ms 368.8 ms 3.82x
mold 201.2 ± 1.7 ms 875.1 ms 220.5 ms 5.44x
wild 107.1 ± 0.5 ms 456.8 ms 284.6 ms 6.92x

Parallelize--gc-sections mark

Garbage collection had been a single-threaded BFS overInputSection graph. On a Release+Asserts clang link,markLive was ~315 ms of the 1562 ms wall time (20%).

6f9646a598f2adds markParallel, a level-synchronized BFS. Each BFS levelis processed with parallelFor; newly discovered sectionsland in per-thread queues, which are merged before the next level. Theparallel path activates when!TrackWhyLive && partitions.size() == 1.Implementation details that turned out to matter:

  • Depth-limited inline recursion (depth < 3) beforepushing to the next-level queue. Shallow reference chains stay hot incache and avoid queue overhead.
  • Optimistic "load then compare-exchange" section-flag dedup insteadof atomic fetch-or. The vast majority of sections are visited once, sothe load almost always wins.

On the Release+Asserts clang link, markLive dropped from315 ms to 82 ms at --threads=8 (from 199 ms to 50 ms at--threads=16); total wall time 1.16x–1.18x.

Two prerequisite cleanups were needed for correctness:

  • 6a874161621emoved Symbol::used into the existingstd::atomic<uint16_t> flags. The bitfield waspreviously racing with other mark threads.
  • 2118499a898bdecoupled SharedFile::isNeeded from the mark walk.--as-needed used to flip isNeeded insideresolveReloc, which would have required coordinated writesacross threads; it is now a post-GC scan of global symbols.

Parallelize input fileloading

Historically, LinkerDriver::createFiles walked thecommand line and called addFile serially.addFile maps the file (MemoryBuffer::getFile),sniffs the magic, and constructs an ObjFile,SharedFile, BitcodeFile, orArchiveFile. For thin archives it also materializes eachmember. On workloads with hundreds of archives and thousands of objects,this serial walk dominates the early part of the link.

The pending patch will rewrite addFile to record aLoadJob for each non-script input together with a snapshotof the driver's state machine (inWholeArchive,inLib, asNeeded, withLOption,groupId). After createFiles finishes,loadFiles fans the jobs out to worker threads. Linkerscripts stay on the main thread because INPUT() andGROUP() recursively call back intoaddFile.

A few subtleties made this harder than it sounds:

  • BitcodeFile and fatLTO construction callctx.saver / ctx.uniqueSaver, both of which arenon-thread-safe StringSaver /UniqueStringSaver. I serialized those constructors behind amutex; pure-ELF links hit it zero times.
  • Thin-archive member buffers used to be appended toctx.memoryBuffers directly. To keep the outputdeterministic across --threads values, each job nowaccumulates into a per-job SmallVector which is merged intoctx.memoryBuffers in command-line order.
  • InputFile::groupId used to be assigned inside theInputFile constructor from a global counter. With parallelconstruction the assignment race would have been unobservable but stillugly; b6c8cba516dahoists ++nextGroupId into the serial driver loop and storesthe value into each file after construction.

The output is byte-identical to the old lld and deterministic across--threads values, which I verified with diffacross --threads={1,2,4,8} on Chromium.

A --time-trace breakdown is useful to set expectations.On Chromium, the serial portion of createFiles accounts foronly ~81 ms of the 5.9 s wall, and loadFiles (after thispatch) runs in ~103 ms in parallel. Serial readFile/mmap isnot the bottleneck. What moves the needle is overlapping the per-fileconstructor work — magic sniffing, archive member materialization,bitcode initialization — with everything else that now kicks off on themain thread while workers chew through the job list.

Extending parallelrelocation scanning

Relocation scanning has been parallel since LLVM 17, but three caseshad opted out via bool serial:

  1. -z nocombreloc, because .rela.dyn mergedrelative and non-relative relocations and needed deterministicordering.
  2. MIPS, because MipsGotSection is mutated duringscanning.
  3. PPC64, because ctx.ppc64noTocRelax (aDenseSet of (Symbol*, offset) pairs) waswritten without a lock.

076226f378dfand dc4df5da886eseparate relative and non-relative dynamic relocations unconditionallyand always build .rela.dyn withcombreloc=true; the only remaining effect of-z nocombreloc is suppressing DT_RELACOUNT. 2f7bd4fa9723then protects ctx.ppc64noTocRelax with the already-existingctx.relocMutex, which is only taken on rare slow paths.After these changes, only MIPS still runs scanning serially.

Faster getSectionPiece

Merge sections (SHF_MERGE) split their input into"pieces". Every reference into a merge section needs to map an offset toa piece. The old implementation was always a binary search inMergeInputSection::pieces, called fromMarkLive, includeInSymtab, andgetRelocTargetVA.

42cc45477727changes this in two ways:

  1. For non-string fixed-size merge sections,getSectionPiece uses offset / entsizedirectly.
  2. For non-section Defined symbols pointing into mergesections, the piece index is pre-resolved duringsplitSections and packed into Defined::valueas ((pieceIdx + 1) << 32) | intraPieceOffset.

The binary search is now limited to references via section symbols(addend-based), which is common on AArch64 but rare on x86-64 where theassembler emits local labels for .L references intomergeable strings. The clang-relassert link with--gc-sections is 1.05x as fast.

Optimizingthe underlying llvm/lib/Support/Parallel.cpp

All of the wins above rely onllvm/lib/Support/Parallel.cpp, the tiny work-stealing-ishtask runtime shared by lld, dsymutil, and a handful of debug-info tools.Four changes in that file mattered:

  • c7b5f7c635e2— parallelFor used to pre-split work into up toMaxTasksPerGroup (1024) tasks and spawn each through theexecutor's mutex + condvar. It now spawns only ThreadCountworkers; each grabs the next chunk via an atomic fetch_add.On a clang-14 link (--threads=8), futex calls dropped from~31K to ~1.4K (glibc release+asserts); wall time 927 ms → 879 ms. Thisis the reason the parallel mark and parallel scan numbers are worthquoting at all — on the old runtime, spawn overhead was a real fractionof the work being parallelized.
  • 9085f74018a4— TaskGroup::spawn() replaced the mutex-basedLatch::inc() with an atomic fetch_add andpasses the Latch& through Executor::add()so the worker calls dec() directly. Eliminates onestd::function construction per spawn.
  • 5b1be759295c— removed the Executor abstract base class.ThreadPoolExecutor was always the only implementation;add() and getThreadCount() are now directcalls instead of virtual dispatches.
  • 8daaa26efdda— enables nested parallel TaskGroup via work-stealing.Historically, nested groups ran serially to avoid deadlock (the threadthat was supposed to run a nested task might be blocked in the outergroup's sync()). Worker threads now actively execute tasksfrom the queue while waiting, instead of just blocking. Root-levelgroups on the main thread keep the efficient blockingLatch::sync(), so the common non-nested case pays nothing.In lld this lets SyntheticSection::writeTo calls withinternal parallelism (GdbIndexSection,MergeNoTailSection) parallelize automatically when calledfrom inside OutputSection::writeTo, instead of degeneratingto serial execution on a worker thread — which was the exact situationD131247 had worked aroundby threading a root TaskGroup all the way down.

Small wins worth mentioning

  • 036b755daedbparallelizes demoteAndCopyLocalSymbols. Each file collectslocal Symbol* pointers in a per-file vector viaparallelFor, which are merged into the symbol tableserially. Linking clang-14 (--no-gc-sections) with its 208K.symtab entries is 1.04x as fast.

Where lld still loses time

To locate the gap I ran lld --time-trace,mold --perf, and wild --time on the Chromium--gdb-index link (--threads=8). Grouped intocomparable phases:

Phase lld mold
Parse input files 2778 ms 1034 ms
Scan relocations 233 ms 103 ms
Assign / finalize layout 750 ms ~150 ms
Symtab + synthetic finalize 570 ms ~80 ms
Write sections (copy chunks) 533 ms 558 ms
Create gdb index 1317 ms 911 ms
Wall 6742 ms 3428 ms

That leaves four meaningful gaps, in order of absolute impact:

Parse input files: 2.78 s vs 1.03 s, ~52% of the totalgap. Same ratio on clang-debug (2.49 s vs 1.09 s). The phase isalready parallel; the gap is pure constant factor in the per-objectparse path (reading section headers, interning strings, splittingCIEs/FDEs, resolving symbols into the global table). wild is even moreextreme here — its whole "Load inputs into symbol DB" is ~255 ms onChromium, which is where most of its overall advantage comes from.

Assign / finalize / symtab finalize: ~1.3 s vs ~0.23s. finalizeAddressDependentContent,assignAddresses, finalizeSynthetic,Add symbols to symtabs, and Finalize .eh_frametogether cost ~1.3 s on Chromium. mold's equivalents(compute_section_sizes, compute_symtab_size,create_output_sections, set_osec_offsets)total ~230 ms. .symtab alone is ~127 ms lld vs ~27 ms moldon clang-debug; I have a local branch that turnsSymbolTableBaseSection::finalizeContents into aprefix-sum-driven parallel fill and replaces thestable_partition + MapVector shuffle withper-file lateLocals buffers. 1640 ELF tests pass; notposted yet.

Create gdb index: +1.32 s lld vs +0.91 s mold onChromium. Varies by workload — on clang-debug the two are within 200 ms(1.73 s vs 1.54 s). The work is embarrassingly parallel per input, butlld funnels a lot of string interning through a singleDenseMap (sharded, but still); mold uses a lock-freeConcurrentMap sized by HyperLogLog.

Scan relocations: 233 ms vs 103 ms. Small absolutebut a clean 2.3x ratio. Target-specific scanning (theAdd target-specific relocation scanning for … series fromlast year) already removed much of the dispatch overhead; what remainsis per-relocation work in the x86-64 path.

Interestingly, writing section content is not a gap.lld spends 533 ms in Write sections vs mold's 558 ms incopy_chunks vs wild's 574 ms inWrite data to file — all within noise of each other. Theearlier assumption that .debug_* section writes were a lldweakness didn't survive measurement; the --gdb-indexsurcharge really lives in index construction, not the write.

wild is worth calling out separately: its user time is comparable tolld's but its system time is roughly half, and its parse phase is 4-8xfaster than either of the C++ linkers. mold is at the other extreme —the highest user time on every workload, bought back by aggressiveparallelism.

❌
❌