阅读视图

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

AI聊天界面的布局细节和打字跟随方法

AI 问答界面如何布局?

在豆包的AI问答聊天界面,为什么输入框总是会跟随在最底部?左边有导航栏,无论怎么缩小放大屏幕都会在当前问答展示界面的水平线中间?

难道是通过 position: fixed; 来实现的?但是它怎么能够解决第二个问题呢?先打开控制台看看。

在问答界面,输入框是一直被挤在最下方的,通过检查控制台会发现输入框好像会一直跟随在屏幕最下方?

image.png

但是随着控制台一直向上拉长,输入框又会被控制台覆盖?

image.png

说明根本不是通过固定定位来实现的效果。

下面来实现一下它的这种效果:这里展示的是最外层容器的布局。

<!-- 根容器 -->
<div class="chat">
    <!-- 展示容器 -->
    <div v-show='!isChat' class="chat-content">
    </div>
    <!-- 对话界面 -->
    <div v-show='isChat' class="chat-scroll-container">
    </div>
    <!-- 输入框 -->
    <div class="input-section">
    </div>
</div>

.chat {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.chat-container {
  width: 100%;
  max-width: 1000px;
  flex: 1;
  overflow-y: auto;
}
.chat-scroll-container {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  flex: 1;
  /* 隐藏滚动条但保留功能 */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE/Edge */
}
.input-section {
  width: 100%;
  height: 150px;
  flex-shrink: 0;
}

可以看到核心的实现,其实就是让输入框的兄弟容器将剩余空间全部占据,而 input-section 本身只需要不压缩自身的高度就可以,当屏幕缩小后,flex:1;能占据的空间变小,而输入框高度不变将一直在外层容器最下方,当空间展示不下时会出现的可视区域外。

flex-shrink

  • 父容器必须是弹性容器。
  • 默认表示子元素固定宽度,不被压缩。
  • 在父元素使用 flex-direction: column; 改变了弹性方向后,表示子元素固定高度,不被压缩。
  • 为 1 时,表示容器会适应父容器高度被压缩。

如何让视线跟随 AI 生成的内容

下方父容器为滚动容器,子元素为主要内容展示容器。以下介绍两种 AI 打字跟随的监听方法,控制跟随与用户操作停止跟随。

<div class="chat-scroll-container" ref="scrollContainerRef" @scroll="handleScroll">
    <div class="chat-messages" ref="chatMessagesRef">
    </div>
</div>

const chatMessagesRef = ref(null);
// 滚动容器引用
const scrollContainerRef = ref(null);
// 是否启用自动滚动跟随
const enableAutoScroll = ref(true);

// 上次滚动位置
let lastScrollTop = 0;

const handleScroll = throttle(() => {
  const el = scrollContainerRef.value;
  if (!el) return;
  const { scrollTop, scrollHeight, clientHeight } = el;
  // 判断当前是否已经在底部(留20px容差)
  const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20;
  // 如果用户在向上滚动超过阈值,取消自动跟随
  if (isAtBottom === false && scrollTop < lastScrollTop) {
    const upDistance = lastScrollTop - scrollTop;
    if (upDistance > 10) {
      enableAutoScroll.value = false;
    }
  }
  // 如果滚动到底部,重新开启自动跟随
  if (isAtBottom) {
    enableAutoScroll.value = true;
  }

  // 记录本次滚动位置
  lastScrollTop = scrollTop;
}, 100);

.chat-scroll-container{
    height: 1000px; 
    .chat-messages{
    // 高度由内容支撑
    }
 }

MutationObserver

监听容器的变化,包括高度、内容变化、DOM的操作等等。大多都抛弃该做法

  • 触发次数极多

  • 性能开销

  • 容易抖动、重复触发

  • 性能不如ResizeObserver

let observer = null;
onMounted(() => {
  initObserver();
});

// 初始化 MutationObserver
const initObserver = () => {
  if (!scrollContainerRef.value) return;
  // 断开旧的 observer
  if (observer) {
    observer.disconnect();
  }
  observer = new MutationObserver(() => {
    if (!enableAutoScroll.value) return;
    // 内容变化时,自动滚动到底部
    scrollToBottom();
  });
  observer.observe(scrollContainerRef.value, {
    childList: true,
    subtree: true,
    characterData: true,
  });
};
// 滚动操作
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

ResizeObserver

监听容器是否发生尺寸变化,而不是滚动容器。操作跟随需要操作滚动容器。

// 启用监听
onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value); // 监听高度变化容器
});
// 滚动操作,操作滚动容器
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑记录

背景

上个月,我接手了一个新的 NFT 铸造平台前端项目。项目要求很简单:用户点击一个“连接钱包”按钮,弹出 MetaMask 进行连接和授权,然后前端获取到用户的以太坊地址并显示出来。这听起来是 Web3 开发的“Hello World”,对吧?我心想,用老伙计 ethers.js 应该分分钟搞定。毕竟我之前在 DeFi 项目里用过很多次了。于是,我自信满满地开始敲代码,没想到接下来的几个小时,我几乎把 ethers.js 连接钱包的常见坑全踩了一遍。从 window.ethereumundefined 到账户切换监听失效,再到网络切换时的状态混乱,整个过程堪称一部小型历险记。

问题分析

我最开始的思路非常直接:在 React 组件的 useEffect 里,或者在一个按钮的点击事件中,直接调用 ethers.providers.Web3Provider 并传入 window.ethereum,然后调用 provider.send(“eth_requestAccounts”) 来请求账户。代码大概长这样:

const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.providers.Web3Provider(window.ethereum);
    const accounts = await provider.send(“eth_requestAccounts”, []);
    setAccount(accounts[0]);
  } else {
    alert(‘请安装 MetaMask!’);
  }
};

但一运行就出问题了。首先,开发服务器热更新时,有时会报错 window.ethereum is undefined。其次,连接成功后,我切换到 MetaMask 的另一个账户,前端页面上的地址并没有自动更新。最后,当用户在 MetaMask 里切换网络(比如从 Goerli 切到 Mainnet),我的应用完全感知不到,还显示着旧网络下的状态。

我意识到,我把问题想得太简单了。一个健壮的钱包连接模块,至少需要处理三件事:1. Provider 的可靠获取(处理未安装钱包、页面加载时机);2. 账户变化的监听;3. 网络变化的监听。而我最初的代码,只完成了最基础的“一次性连接”功能。

核心实现

第一步:安全地获取 Provider 并连接账户

首先,我们不能直接假设 window.ethereum 存在。用户可能没安装 MetaMask,或者我们的代码在服务器端渲染(SSR)时执行。所以,获取 Provider 的逻辑必须放在客户端生命周期内,并且做好错误处理。

这里有个坑: window.ethereum 的类型。在 TypeScript 中,直接访问会报错。我们需要扩展 Window 接口。同时,MetaMask 注入的 ethereum 对象有一个 request 方法,但 ethers.jsWeb3Provider 封装得很好,我们通常用 provider.sendprovider.getSigner

我的思路是:创建一个自定义 Hook,比如叫 useEthereumProvider,来安全地创建和管理 Provider 实例。

import { BrowserProvider, JsonRpcSigner } from ‘ethers’;
import { useEffect, useState } from ‘react’;

// 扩展 Window 接口以包含 ethereum
declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useEthereumProvider = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);

  useEffect(() => {
    // 确保在客户端环境下执行
    if (typeof window !== ‘undefined’ && window.ethereum) {
      // 注意:ethers v6 中,Web3Provider 已更名为 BrowserProvider
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);

      // 尝试获取已连接的账户
      ethersProvider.getSigner().then(s => setSigner(s)).catch(console.error);
    }
  }, []); // 空依赖数组,仅初始化一次

  return { provider, signer };
};

注意,我用了 ethers v6 的 BrowserProvider。如果你还在用 v5,请使用 ethers.providers.Web3Provider。这个 Hook 在组件挂载时安全地初始化 Provider。

第二步:实现连接钱包函数

有了 Provider,接下来实现具体的连接函数。这个函数需要处理用户点击“连接钱包”按钮的动作。

const [account, setAccount] = useState<string>(‘’);
const { provider } = useEthereumProvider();

const handleConnect = async () => {
  if (!provider) {
    alert(‘未检测到钱包Provider,请确认MetaMask已安装’);
    return;
  }

  try {
    // 请求账户访问权限。这里会弹出MetaMask授权窗口。
    const accounts = await provider.send(‘eth_requestAccounts’, []);
    if (accounts && accounts[0]) {
      setAccount(accounts[0]);
      // 获取 Signer 实例,用于后续签名交易
      const signer = await provider.getSigner();
      // 你可以将 signer 存储到状态或 context 中
    }
  } catch (error: any) {
    console.error(‘连接钱包失败:’, error);
    // 用户拒绝了请求
    if (error.code === 4001) {
      alert(‘您拒绝了连接请求。’);
    }
  }
};

注意这个细节: provider.send(‘eth_requestAccounts’, []) 是触发 MetaMask 弹出授权窗口的关键调用。它返回一个 Promise,用户授权后 resolve,拒绝后 reject 并带有错误码 4001

第三步:监听账户和网络变化

这是让应用“活”起来的关键。MetaMask 允许用户随时切换账户或网络,我们的前端需要实时响应。

window.ethereum 对象提供了 on 方法用于监听事件。主要监听两个事件:‘accountsChanged’‘chainChanged’

useEffect(() => {
  // 确保 ethereum 对象存在
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log(‘accountsChanged’, accounts);
    if (accounts.length === 0) {
      // 用户断开了连接,或者锁定了钱包
      setAccount(‘’);
      alert(‘请连接您的钱包。’);
    } else if (accounts[0] !== account) {
      // 切换到了新账户
      setAccount(accounts[0]);
      // 通常这里需要重新获取 Signer,因为账户变了
      if (provider) {
        provider.getSigner().then(newSigner => {
          // 更新 signer 状态
        });
      }
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // _chainId 是十六进制字符串,例如 ‘0x1’ (Mainnet)
    console.log(‘chainChanged’, _chainId);
    // 当网络切换时,MetaMask 建议页面重载
    // 但为了更好体验,我们可以不重载,而是更新应用内的网络状态,并重置相关数据
    window.location.reload(); // 简单粗暴但有效
    // 更优方案:更新 networkId 状态,并重新初始化合约实例等
  };

  // 添加监听
  window.ethereum.on(‘accountsChanged’, handleAccountsChanged);
  window.ethereum.on(‘chainChanged’, handleChainChanged);

  // 组件卸载时移除监听
  return () => {
    if (window.ethereum) {
      window.ethereum.removeListener(‘accountsChanged’, handleAccountsChanged);
      window.ethereum.removeListener(‘chainChanged’, handleChainChanged);
    }
  };
}, [account, provider]); // 依赖 account 和 provider

这里有个大坑: 关于 chainChanged 事件的处理。MetaMask 官方文档早期建议在 chainChanged 时刷新页面,因为许多 dApp 的状态(如合约实例)依赖于网络。虽然不刷新也可以,但你需要手动更新所有依赖网络的状态。为了简单可靠,我选择了刷新页面。在更复杂的应用中,你可能需要设计一个状态管理系统来优雅地处理网络切换。

第四步:获取当前网络信息

除了账户,我们通常还需要知道用户当前连接到了哪个网络。

const [chainId, setChainId] = useState<number | null>(null);
const { provider } = useEthereumProvider();

useEffect(() => {
  if (!provider) return;

  const fetchNetwork = async () => {
    try {
      const network = await provider.getNetwork();
      // network.chainId 是 BigInt 类型 (ethers v6)
      setChainId(Number(network.chainId));
    } catch (error) {
      console.error(‘获取网络信息失败:’, error);
    }
  };

  fetchNetwork();
  // 注意:provider.getNetwork() 可能不会随 chainChanged 自动更新。
  // 所以我们依赖上一步的 chainChanged 事件,触发重新获取或页面刷新。
}, [provider]);

完整代码

下面是一个整合了以上所有功能的、可直接运行的 React 组件示例。

// WalletConnector.tsx
import { BrowserProvider, JsonRpcSigner } from ‘ethers’;
import React, { useEffect, useState } from ‘react’;

declare global {
  interface Window {
    ethereum?: any;
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [account, setAccount] = useState<string>(‘’);
  const [chainId, setChainId] = useState<number | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  // 1. 初始化 Provider
  useEffect(() => {
    if (typeof window !== ‘undefined’ && window.ethereum) {
      const ethersProvider = new BrowserProvider(window.ethereum);
      setProvider(ethersProvider);
      // 尝试静默获取已连接的账户
      ethersProvider.getSigner()
        .then(s => {
          setSigner(s);
          s.getAddress().then(addr => setAccount(addr));
        })
        .catch(() => {/* 用户未连接,忽略错误 */});
    }
  }, []);

  // 2. 获取初始网络
  useEffect(() => {
    if (!provider) return;
    provider.getNetwork().then(network => {
      setChainId(Number(network.chainId));
    });
  }, [provider]);

  // 3. 设置事件监听
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log(‘账户变更:’, accounts);
      if (accounts.length === 0) {
        // 断开连接
        setAccount(‘’);
        setSigner(null);
        alert(‘钱包已断开。’);
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        // 更新 signer
        provider?.getSigner().then(s => setSigner(s));
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log(‘网络变更:’, _chainId);
      // 简单处理:刷新页面
      window.location.reload();
    };

    window.ethereum.on(‘accountsChanged’, handleAccountsChanged);
    window.ethereum.on(‘chainChanged’, handleChainChanged);

    return () => {
      if (window.ethereum) {
        window.ethereum.removeListener(‘accountsChanged’, handleAccountsChanged);
        window.ethereum.removeListener(‘chainChanged’, handleChainChanged);
      }
    };
  }, [account, provider]);

  // 4. 连接钱包函数
  const handleConnect = async () => {
    if (!provider) {
      alert(‘请安装 MetaMask 钱包扩展!’);
      return;
    }
    setLoading(true);
    try {
      const accounts = await provider.send(‘eth_requestAccounts’, []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      const currentSigner = await provider.getSigner();
      setSigner(currentSigner);
      // 获取并更新网络
      const network = await provider.getNetwork();
      setChainId(Number(network.chainId));
    } catch (error: any) {
      console.error(‘连接失败:’, error);
      if (error.code === 4001) {
        alert(‘连接请求被拒绝。’);
      }
    } finally {
      setLoading(false);
    }
  };

  // 5. 断开连接 (MetaMask 没有真正的“断开”,这里只是清除本地状态)
  const handleDisconnect = () => {
    setAccount(‘’);
    setSigner(null);
    alert(‘已断开本地连接。如需完全断开,请在 MetaMask 中操作。’);
  };

  return (
    <div style={{ padding:20px’, border:1px solid #ccc’, borderRadius:8px’ }}>
      <h3>钱包连接状态</h3>
      {!provider ? (
        <p>⚠️ 未检测到钱包Provider。请确保 MetaMask 已安装并启用。</p>
      ) : (
        <>
          <p>
            <strong>网络ID:</strong> {chainId ? `0x${chainId.toString(16)}` : ‘未知’}
          </p>
          <p>
            <strong>当前账户:</strong> {account ? `${account.substring(0, 6)}…${account.substring(account.length - 4)}` : ‘未连接’}
          </p>
          <div>
            {!account ? (
              <button onClick={handleConnect} disabled={loading}>
                {loading ? ‘连接中…’ : ‘连接 MetaMask’}
              </button>
            ) : (
              <div>
                <button onClick={handleDisconnect} style={{ marginLeft:10px’ }}>
                  断开连接
                </button>
              </div>
            )}
          </div>
          {signer && (
            <p style={{ marginTop:10px’, color:green’ }}>
              ✅ Signer 已就绪,可进行签名操作。
            </p>
          )}
        </>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum is undefined (Next.js/SSR 环境)

    • 现象: 在 Next.js 项目中,组件首次渲染或热更新时控制台报错。
    • 原因: 代码在服务端或构建时执行,window 对象不存在。
    • 解决: 所有访问 window.ethereum 的代码都必须包裹在 if (typeof window !== ‘undefined’) 条件判断中,或放在 useEffect、事件处理函数等客户端生命周期钩子中。
  2. 账户切换后页面不更新

    • 现象: 在 MetaMask 里切换了账户,但 dApp 页面上显示的地址还是旧的。
    • 原因: 没有监听 accountsChanged 事件。
    • 解决: 按照上文所述,正确添加 window.ethereum.on(‘accountsChanged’, callback) 监听,并在回调中更新 React 状态。注意: 当用户断开连接时,accounts 数组为空,需要处理这个情况。
  3. 网络切换后合约调用出错

    • 现象: 用户从 Goerli 切换到 Mainnet,dApp 仍尝试在旧网络的合约地址上调用,导致 RPC 错误。
    • 原因: 没有监听 chainChanged 事件,或监听后没有更新依赖网络的合约实例等状态。
    • 解决: 监听 chainChanged 事件。采用简单方案(刷新页面)或复杂方案(更新全局网络状态并重新初始化所有网络相关的对象,如 Provider、Signer、合约实例等)。
  4. ethers v5 与 v6 的 API 差异

    • 现象: 照着旧教程写代码,发现 Web3Provider 等类找不到。
    • 原因: 项目安装的是 ethers v6,其 API 有重大变更。
    • 解决: 查阅官方升级指南。关键变化:ethers.providers.Web3Provider 变为 ethers.BrowserProviderprovider.getSigner().getAddress() 返回 Promise;chainId 是 BigInt 类型。务必检查你使用的版本。

小结

通过这次实践,我深刻体会到 Web3 前端开发中“细节决定成败”。一个看似简单的钱包连接,需要考虑 Provider 的生命周期、用户交互的多种可能(授权、拒绝、切换)以及钱包状态的持续同步。最终稳定可用的代码,是初始化、请求授权、事件监听和状态管理四部分紧密协作的结果。如果你要在此基础上继续深挖,下一步可以考虑将钱包状态管理抽象为 Context 或使用状态管理库(如 Zustand、Jotai),以支持多组件共享,并集成更多钱包类型(如 WalletConnect)以实现更好的用户体验。

TailwindCSS 核心概念与实用技巧:从传统CSS到Utility-First迁移指南

引言:为什么越来越多人用Tailwind?

你是否还在为CSS命名发愁?

.container .header .button-primary 想破脑袋还是避免不了命名冲突。

传统CSS开发中,我们常常遇到这些痛点:

1. CSS文件越来越臃肿 项目迭代一段时间后,你会发现写了大量重复样式,却不敢删除旧代码,怕哪里出问题。最后CSS文件几千行,大部分都是无用代码。

2. 命名是永恒的难题 使用BEM命名规范?button button--primary button--large 虽然规范,但写起来冗长又繁琐。稍微复杂点的组件,命名就变成了玄学。

3. 样式和组件分离 写React/Vue组件时,JSX/模板里写了结构,还要跑到另一个CSS文件写样式,来回切换上下文,开发效率被打断。

4. 改样式要改多个文件 调整个间距颜色,要找到对应的CSS类,修改完还要回来检查,一不小心影响其他地方样式。

TailwindCSS 为什么能在近几年迅速流行?因为它从根本上解决了这些问题。

它把CSS带回到你的HTML中,用原子化的Utility类让你不用再写CSS,同时保持代码整洁可维护。

据统计,npm 下载量已经突破百万,Vue、React、Next.js 等主流框架都官方支持,越来越多团队开始全面采用。


什么是Utility-First?和传统CSS/BEM/CSS-in-JS的区别

先搞懂核心思想:Utility-First就是原子化CSS

简单说,Tailwind提供了大量功能单一的工具类,比如 text-center 代表文字居中,pt-4 代表上内边距1rem。

你不需要再写新的CSS,只需要在HTML中组合这些工具类就能构建出任何样式。

我们来对比一下不同方案:

传统CSS写法

<!-- HTML -->
<button class="btn btn-primary">点击我</button>
/* CSS */
.btn {
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-weight: 500;
}

.btn-primary {
  background-color: #3b82f6;
  color: white;
}

.btn-primary:hover {
  background-color: #2563eb;
}

BEM写法

<button class="button button--primary button--medium">点击我</button>
.button {
  font-family: system-ui;
  border: none;
  outline: none;
}

.button--primary {
  background-color: var(--color-primary);
  color: white;
}

.button--medium {
  padding: 8px 16px;
  font-size: 14px;
}

BEM解决了命名冲突问题,但还是需要不断写新的CSS,类名越来越长。

Tailwind Utility-First 写法

<button
  class="px-4 py-2 font-medium text-white bg-blue-500
             hover:bg-blue-600 rounded"
>
  点击我
</button>

不需要写任何CSS!所有样式都通过组合Utility类直接在HTML中完成。

CSS-in-JS 写法(对比参考)

// Styled Components 写法
const Button = styled.button`
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  font-weight: 500;
  background-color: #3b82f6;
  color: white;

  &:hover {
    background-color: #2563eb;
  }
`;

<Button>点击我</Button>;

CSS-in-JS把CSS放到JS里,解决了作用域问题,但运行时有开销,调试也相对麻烦。

Tailwind 则是纯CSS方案,构建时移除无用代码,最终产物体积很小,同时保留了CSS的原生优势。

一句话总结区别:

  • 传统CSS/BEM:语义化命名,一个类对应多个样式属性
  • Utility-First:功能单一,一个类只做一件事
  • CSS-in-JS:JS掌管样式,组件级作用域

Tailwind核心概念详解

1. 配置文件 tailwind.config.js

安装完Tailwind后,根目录会有一个 tailwind.config.js 配置文件。

这是Tailwind的神经中枢,你可以在这里自定义主题、断点、颜色、间距等等。

基础配置示例:

/** @type {import('tailwindcss').Config} */
module.exports = {
  // 扫描所有项目文件,找出用到的类,用于Tree Shaking
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,vue,html}"],
  theme: {
    // 扩展默认主题,不会覆盖
    extend: {
      // 自定义颜色
      colors: {
        primary: "#165DFF",
        secondary: "#6b7280",
      },
      // 自定义字体
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
      },
      // 自定义断点
      screens: {
        "3xl": "1920px",
      },
    },
  },
  // 第三方插件
  plugins: [],
};

如果你想完全覆盖默认主题,可以直接在 theme 里定义,不使用 extend

theme: {
  // 完全自定义颜色,会替换Tailwind默认颜色
  colors: {
    blue: {
      50: '#f0f9ff',
      100: '#e0f2fe',
      // ... 一直到 900
      600: '#2563eb',
    }
  }
}

对于中文开发者,建议在配置中加入中文字体优化:

theme: {
  extend: {
    fontFamily: {
      chinese: ['PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif'],
    },
  },
}

使用的时候直接:

<body class="font-chinese"></body>

2. @layer 分层机制

Tailwind 用 @layer 把样式分成三层:basecomponentsutilities

这个分层机制帮你正确排序CSS优先级,避免特异性冲突。

/* 在你的style.css中 */
@tailwind base;
@tailwind components;
@tailwind utilities;

我们分别解释:

@layer base - 基础样式层

用于重置浏览器默认样式,或者给HTML标签添加默认样式。

@layer base {
  h1 {
    @apply text-3xl font-bold mb-4;
  }
  h2 {
    @apply text-2xl font-semibold mb-3;
  }
  a {
    @apply text-blue-600 hover:underline;
  }
}

Base层优先级最低,后面的classes和utilities可以覆盖它。

@layer components - 组件层

用来提取可复用的组件样式,优先级高于base。

@layer components {
  .btn {
    @apply px-4 py-2 rounded font-medium;
  }
  .btn-primary {
    @apply bg-primary text-white hover:bg-primary/90;
  }
  .card {
    @apply bg-white rounded-lg shadow p-6;
  }
}

然后就可以在HTML中直接使用:

<button class="btn btn-primary">提交</button>
<div class="card">内容</div>

@layer utilities - 工具类层

优先级最高,如果你需要添加自定义工具类,放在这里。

@layer utilities {
  .text-shadow {
    text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  .content-auto {
    content-visibility: auto;
  }
}

放在 @layer utilities 里的自定义工具类,优先级比Tailwind自带的工具类还要高吗?不,它和Tailwind自带的utilities同级,后面写的会覆盖前面的。

记住这个优先级顺序:base < components < utilities,这样就不会出现奇怪的样式覆盖问题。

3. Purge / Tree-shaking 工作原理

Tailwind v3 默认就开启了Tree-shaking,它会扫描你所有的模板文件,只保留实际用到的Utility类。

工作流程:

  1. 你在 content 配置里指定了要扫描的文件路径
  2. 构建时,Tailwind 从这些文件中提取出所有用到的class名称
  3. 只生成这些class对应的CSS,没有用到的全部移除

举个例子,你的项目只用到了 px-4 py-2 bg-blue-500,那Tailwind就只会生成这几个类对应的CSS,其他所有没用到的padding、margin、颜色都不会出现在最终CSS文件中。

所以即使Tailwind默认包含了几千个Utility类,最终打包出来的CSS通常只有几KB到十几KB,比你自己写的CSS还小。

配置示例(v3标准写法):

// tailwind.config.js
module.exports = {
  content: [
    // 所有可能用到Tailwind类的文件都要写在这里
    "./index.html",
    "./src/**/*.{js,jsx,ts,tsx,vue,html}",
  ],
};

注意事项:如果你用了动态class拼接,需要用安全列表:

module.exports = {
  content: [...],
  safelist: [
    // 强制保留这些类,不会被摇掉
    'bg-red-500',
    'bg-green-500',
    'bg-yellow-500',
    // 或者用模式匹配
    {
      pattern: /bg-(red|green|yellow)-.+/,
    }
  ]
}

这在中文开发中很常见,比如后台配置返回不同状态的样式类,一定要记得加safelist,不然生产环境样式会丢。

4. 响应式断点系统

Tailwind的响应式设计非常简单,默认提供了五个断点:

断点 最小值 对应设备
sm 640px 手机横屏
md 768px 平板
lg 1024px 小桌面
xl 1280px 大桌面
2xl 1536px 超大桌面

使用方法非常简单:在类名前加上断点前缀就是了。

示例:移动端单列,平板双列,桌面三列

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <div>卡片1</div>
  <div>卡片2</div>
  <div>卡片3</div>
</div>

解释一下:

  • grid-cols-1:默认(小于640px)单列
  • md:grid-cols-2:宽度 ≥768px 变成双列
  • lg:grid-cols-3:宽度 ≥1024px 变成三列

更实际的导航栏示例:移动端汉堡菜单,桌面端全链接

<nav class="bg-white shadow fixed w-full">
  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
    <div class="flex justify-between h-16">
      <div class="flex">Logo</div>
      <!-- 桌面端菜单 -->
      <div class="hidden md:flex items-center space-x-4">
        <a href="#" class="text-gray-700 hover:text-blue-600">首页</a>
        <a href="#" class="text-gray-700 hover:text-blue-600">产品</a>
        <a href="#" class="text-gray-700 hover:text-blue-600">关于</a>
      </div>
      <!-- 移动端汉堡按钮 -->
      <div class="md:hidden flex items-center">
        <button>🍔</button>
      </div>
    </div>
  </div>
</nav>

hidden md:flex 的意思是:默认隐藏,大于等于md(768px)才显示,完美实现响应式切换。

自定义断点也很简单,在配置里加就行:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      screens: {
        xs: "480px", // 比sm更小的断点
        "3xl": "1920px", // 更大屏幕
      },
    },
  },
};

实用开发技巧

1. 提取组件(@apply) vs 保持纯utility

这是Tailwind开发中最常见的问题:什么时候该提取组件,什么时候直接堆Utility类?

两种方式都可以,我们来看具体例子。

直接保持纯Utility写法

<button
  class="px-4 py-2 text-sm font-medium text-white bg-blue-600
             hover:bg-blue-700 rounded-lg focus:outline-none
             focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
  提交
</button>

优点:所有样式都在这里,一目了然,不用跳去别的文件看。适合一次性、不重复使用的按钮。

使用 @apply 提取为可复用组件

@layer components {
  .btn-primary {
    @apply px-4 py-2 text-sm font-medium text-white bg-blue-600
           hover:bg-blue-700 rounded-lg focus:outline-none
           focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
  }
}

然后HTML就很简洁:

<button class="btn-primary">提交</button>
<button class="btn-primary">保存</button>

优点:复用方便,统一修改只改一处。适合项目中多处使用的组件。

在Vue/React组件中提取

这其实是更推荐的方式,因为你已经在使用组件化框架了,为什么不直接用组件呢?

React示例:

function Button({ children, ...props }) {
  return (
    <button
      className="px-4 py-2 text-sm font-medium text-white bg-blue-600
                 hover:bg-blue-700 rounded-lg focus:outline-none
                 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
      {...props}
    >
      {children}
    </button>
  );
}

// 使用
<Button>点击我</Button>;

Vue示例:

<template>
  <button
    class="px-4 py-2 text-sm font-medium text-white bg-blue-600
                hover:bg-blue-700 rounded-lg focus:outline-none
                focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    <slot />
  </button>
</template>

我的建议

  • 如果用组件化框架(React/Vue),优先用JSX/Vue组件提取,不要用@apply写到CSS里
  • 如果是纯HTML项目或者需要配合后端模板引擎,用@layer components提取
  • 不要过度提取,只提取真正会复用的组件,一次性的代码直接堆Utility就好

2. 暗色模式实现

Tailwind v3 内置暗色模式支持,开箱即用。

先在配置中开启:

// tailwind.config.js
module.exports = {
  darkMode: "class", // 或者 'media' 跟随系统
  // ...
};

darkMode: 'media' 会自动根据系统暗色切换,darkMode: 'class' 适合手动切换(用户点击按钮切换)。

使用方式:加上 dark: 前缀。

<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1>你好,世界</h1>
  <p>这是一段文字</p>
</div>

htmlbody 标签加上 dark class 后,暗色模式就激活了:

<html class="dark">
  <!-- 所有dark:前缀的样式都会生效 -->
</html>

实现手动切换的JS代码:

// 检查用户偏好
if (
  localStorage.theme === "dark" ||
  (!("theme" in localStorage) &&
    window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
  document.documentElement.classList.add("dark");
} else {
  document.documentElement.classList.remove("dark");
}

// 切换函数
function toggleDarkMode() {
  if (document.documentElement.classList.contains("dark")) {
    document.documentElement.classList.remove("dark");
    localStorage.theme = "light";
  } else {
    document.documentElement.classList.add("dark");
    localStorage.theme = "dark";
  }
}

卡片带暗色的完整示例:

<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
  <h3 class="text-gray-900 dark:text-white font-semibold">标题</h3>
  <p class="text-gray-600 dark:text-gray-300 mt-2">
    这是描述文字,在暗色模式下会变浅。
  </p>
  <button
    class="mt-4 px-4 py-2 bg-blue-600 dark:bg-blue-500
                 text-white rounded"
  >
    按钮
  </button>
</div>

3. Hover/Focus 等交互状态

Tailwind给所有交互状态都提供了变体前缀,直接用就行。

基础示例:

<!-- Hover -->
<button class="bg-blue-500 hover:bg-blue-600 text-white">Hover我变色</button>

<!-- Focus -->
<input
  class="border focus:outline-none focus:ring-2
             focus:ring-blue-500 border-gray-300 rounded px-3 py-2"
  placeholder="点击我看看"
/>

多个状态可以叠加:

<button
  class="bg-green-500 hover:bg-green-600
             focus:ring-2 focus:ring-green-500 focus:ring-offset-2
             active:bg-green-700
             disabled:opacity-50 disabled:cursor-not-allowed
             text-white px-4 py-2 rounded"
>
  按钮
</button>

常用状态变体列表:

  • hover: 鼠标悬停
  • focus: 获得焦点
  • active: 鼠标按下
  • disabled: 禁用状态
  • first: 第一个子元素
  • last: 最后一个子元素
  • odd: 奇数行
  • even: 偶数行
  • hover:dark: / dark:hover: 暗色模式下的hover

响应式和状态可以组合,顺序没关系:md:hover:bg-blue-500hover:md:bg-blue-500 效果一样。

4. group-hover 群组变体

很多时候我们希望鼠标悬停在父元素上,改变子元素的样式,这就需要 group-hover

使用分两步:

  1. 给父元素加上 group class
  2. 给子元素加上 group-hover: 前缀

卡片示例:鼠标悬停卡片时,让按钮背景变色。

<div class="group card border rounded-lg p-6 hover:shadow-lg">
  <h3 class="group-hover:text-blue-600">卡片标题</h3>
  <p>卡片内容...</p>
  <button
    class="bg-gray-200 group-hover:bg-blue-600
                 group-hover:text-white mt-4 px-4 py-2 rounded"
  >
    查看详情
  </button>
</div>

导航栏下拉菜单示例:

<div class="group relative inline-block">
  <button class="group-hover:text-blue-600">产品菜单 ▼</button>
  <div class="absolute hidden group-hover:block w-48 bg-white shadow">
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品1</a>
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品2</a>
    <a href="#" class="block px-4 py-2 hover:bg-gray-100">产品3</a>
  </div>
</div>

完美!不需要写任何JS,纯CSS实现悬停显示下拉菜单。

还有 group-focusgroup-active,用法一样,针对focus和active状态。

5. 任意值方括号语法

Tailwind v3 最香的功能就是任意值语法,用方括号 [] 直接写任意值。

什么时候用?当你需要一个Tailwind默认没提供的值,不用去改配置文件,直接写:

/* 自定义宽度 */
<div class="w-[310px]">
  /* 自定义定位 */
  <div class="top-[13px] left-[7px]">
    /* 自定义颜色 */
    <div class="bg-[#165DFF] text-[#fff]">
      /* 自定义字体大小 */
      <h1 class="text-[32px]">
        /* 自定义间距 */
        <div class="m-[14px] p-[8px]"></div>
      </h1>
    </div>
  </div>
</div>

组合响应式也没问题:

<div class="w-[300px] md:w-[500px] lg:w-[720px]"></div>

甚至可以写CSS自定义属性:

<div class="bg-[--primary-color]"></div>

这解决了什么问题?以前你想要一个特殊尺寸,必须去tailwind.config.js里扩展,现在直接方括号搞定,非常方便。

但是注意:不要滥用,能用上默认值就用默认值,比如 px-4 能满足就别写 px-[16px]。只有默认值满足不了的时候再用任意值语法。


常见迁移误区

误区一:过早提取组件

很多人从传统CSS转过来,习惯了一切都抽成组件,刚写了一个按钮就想着提取出来。

错误示例

项目才刚开始,按钮只用到一次,就急着提取:

@layer components {
  .header-button {
    @apply ... /* 只在头部用到一次 */;
  }
  .sidebar-item {
    @apply ... /* 只用到一次 */;
  }
}

问题:需求一变,这个组件就不用了,你白提了,而且还要维护CSS。

正确做法

重复出现第二次的时候再提取。

第一次写,直接堆Utility,第二次碰到一样的,复制过去,第三次还碰到,这时候你知道它真的需要复用,再提取也不迟。

误区二:混乱的class顺序

很多人写Tailwind,class顺序乱排,读起来非常费劲。

混乱示例

<button
  class="text-white hover:bg-blue-600 px-4 bg-blue-500 py-2 rounded"
></button>

顺序乱了,你很难快速读懂这个按钮有哪些样式。

推荐的排序思路

按这个顺序排列,可读性大大提高:

  1. 定位布局类:position, top/right/bottom/left, z-index, display, flex/grid, flex-wrap, justify-, items-, gap, w, h, m, p
  2. 边框阴影:border, rounded, shadow
  3. 背景文字颜色:bg, text
  4. 字体样式:font-, text-
  5. 交互状态:hover:, focus:, active:, disabled:, group-hover:
  6. 响应式变体:sm:, md:, lg:, xl:

整理之后

<button
  class="px-4 py-2 bg-blue-500 text-white rounded
             hover:bg-blue-600"
></button>

舒服多了对不对?

很多编辑器有Tailwind插件,可以自动排序,推荐开启。如果你用VSCode,安装 bradlc.vscode-tailwindcss 插件,开启 editor.codeActionsOnSave 自动排序。

误区三:不知道什么时候用自定义CSS

很多人转了Tailwind之后,觉得什么都能用Utility搞定,其实不是。

Tailwind不排斥自定义CSS,该用的时候就要用。

适合用自定义CSS的场景

场景一:复杂的媒体查询和关键帧动画

/* 自定义动画,这用Utility不好写,放在全局CSS就行 */
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fadeIn {
  animation: fadeIn 0.3s ease-out;
}

然后把它放在 @layer utilities 里,就能在HTML中用了:

@layer utilities {
  .animate-fadeIn {
    animation: fadeIn 0.3s ease-out;
  }
}

场景二:复杂伪元素

/* 比如清除浮动 */
@layer utilities {
  .clearfix::after {
    content: "";
    display: table;
    clear: both;
  }
}

场景三:你就是需要写原生CSS的时候

Tailwind只是工具,不是宗教,如果你觉得写原生CSS更清晰更简洁,那就直接写。

错误做法

用一堆方括号拼出一个复杂CSS,可读性极差:

<div class="[&:nth-child(2n+1)]:mr-0 [&>span]:absolute [...]"></div>

这种情况不如抽出来写自定义CSS。

误区四:覆盖Tailwind默认样式时优先级错了

如果你没有用 @layer,直接写在全局CSS,会出现优先级问题。

错误示例

/* 没有加@layer,这个样式会被Tailwind utilities覆盖 */
.btn-primary {
  background-color: red !important; /* 被迫加important */
}

正确做法

@layer components {
  /* components层优先级在utilities之前,不需要important */
  .btn-primary {
    background-color: red;
  }
}

记住:只要是自定义的Tailwind相关样式,都放到 @layer 里面,让Tailwind帮你处理优先级。


总结:给传统CSS开发者的迁移建议

从传统CSS转到Utility-First开发思维,需要一个适应过程,这里给大家几个实用建议:

1. 不要一开始就全量迁移

如果你有一个成熟的老项目,不用一下子全部改成Tailwind。可以配合着用,新组件用Tailwind写,旧组件慢慢迁移。

Tailwind和传统CSS可以和平共处。

2. 不要害怕HTML变"脏"

刚转过来会觉得一堆class写在HTML里很脏,这不符和"表现与结构分离"的思想啊?

这是思维转换中最关键的一步。其实,当你适应了Utility-First,你会发现这样反而更直观,不用来回跳文件找样式。

3. 优先用默认配置,少自定义

Tailwind默认的设计系统已经非常完善了,颜色、间距、断点都有了,能满足90%的场景。不要一开始就去推翻默认配置重写一遍。

默认值够用就用默认值,不够用了再用方括号或者配置扩展。

4. 善用编辑器插件提升开发效率

VSCode的Tailwind CSS IntelliSense 插件一定要装,自动补全class名称,提示颜色,非常好用。

5. 记住这个决策树

遇到问题不知道该怎么做,问自己:

  • 这个会复用吗?不会 → 直接堆Utility
  • 会复用吗?会 → 用React/Vue组件提取(如果用框架)
  • 框架也不好处理 → 用@layer components提取
  • Utility搞不定 → 写自定义CSS,放到@layer utilities

Tailwind不是银弹,但它确实解决了CSS开发中长期存在的很多问题。对于从传统CSS转过来的开发者,只要适应了Utility-First的思维,开发效率会提升很多。

开始动手试试吧,从一个小组件开始,慢慢你就会爱上这种开发方式。


图片大模型实践:可灵(Kling)文生图前后端实现

图片大模型实践:可灵(Kling)文生图前后端实现

本文讲图片模型里「可灵文生图」这一条链路:鉴权、代理、前端如何拼 URL、如何从异步任务结果里取出最终图片地址。语音或其它模型后续再单独开章节。

建议阅读顺序:先看下面「快速跑通」与「架构与数据流」,需要对照实现时再打开附录里的核心摘录或 GitHub 完整文件——不必在中间通读近千行粘贴代码。

可以先看下文本模型的文章,这篇是后续。

模型的使用,大差不差,去模型网站买额度,然后生成key,然后接口调用。


效果图

keling.gif

先去申请 可灵的 Key,可以的话充点小钱做实验。


一、快速跑通(三文件 + Git)

准备一个新目录,放入下面三个文件即可跑通可灵文生图(.env.local 勿提交到 Git)。

文件 作用
index-keling.html 前端单页:拼 URL、轮询、用 img 展示结果图
server.js 后端:读环境变量、签 JWT、转发 /kling/v1/...
.env.local(自建) 配置 ACCESS_KEY_IDACCESS_KEY_SECRET

克隆仓库:

git clone https://github.com/frontzhm/text-model.git
cd text-model

仓库主页: github.com/frontzhm/te…

.env.local 示例(与 server.js 同目录):

ACCESS_KEY_ID=你的AccessKey
ACCESS_KEY_SECRET=你的SecretKey
# 可选:KLING_API_ORIGIN=https://api-beijing.klingai.com

启动:

node server.js
# 另开终端,用静态服务打开页面(避免 file:// 下 ES Module 限制)
npx --yes serve .
# 浏览器访问 /index-keling.html,「代理」填 http://127.0.0.1:3000

二、为什么要有「后端」这一层?

可灵 API 与很多厂商一样,要求:

  1. 鉴权:用 AccessKey + SecretKey 按固定规则生成 JWT,放在 Authorization: Bearer <token> 里;
  2. HTTPS + 指定域名:国内新系统常用 https://api-beijing.klingai.com(与旧域名不同,用错域容易出现 401 / Auth failed);
  3. 浏览器限制:Secret 不能进前端;也不适合在页面里实现签名逻辑。

因此加一层 BFF:本仓库的 server.js 负责读 .env.local签发 JWT、把 /kling/v1/... 转发到可灵域名;浏览器只访问本地 http://127.0.0.1:3000


三、后端:server.js 里三件事

3.1 读环境变量

从项目根目录的 .env.local / .env 按行解析 KEY=value,例如:

  • ACCESS_KEY_ID / ACCESS_KEY_SECRET(或 KLING_* 别名)
  • 可选:KLING_API_ORIGIN(默认 https://api-beijing.klingai.com

3.2 生成 JWT(与官方 Python jwt.encode 一致)

  • Headeralg=HS256typ=JWT
  • Payloadiss = AccessKeyId,exp = now+1800s,nbf = now−5s
  • Signature:对 base64url(header).base64url(payload)HMAC-SHA256,再 Base64URL

使用 Node 内置 crypto.createHmac,无需 jsonwebtoken 包。

3.3 反向代理:路径「前缀剥离」+ 上游拼接

浏览器请求例如:http://127.0.0.1:3000/kling/v1/images/generations

  1. 剥前缀 /kling → 可灵 REST 路径 /v1/images/generations
  2. 拼上游KLING_API_ORIGIN + restPath + search
  3. 带上 Authorization: Bearer <刚签的 JWT> 转发 fetch,原样回写 status 与 body。

restPath 必须 /v1/ 开头且不含 ..,防止代理滥用。


四、前端:index-keling.html 在做什么?

技术栈:Vue 3(CDN ESM)。页面不存 AK/SK,只填代理根地址、Prompt、resolution / aspect_ratio 等。

4.1 创建任务(POST)

base = 代理根(去掉末尾 /),拼接提交地址:

endpoint = base + "/kling/v1/images/generations"

body 为 JSON payload(字段以官方文档为准),示例含 promptnegative_promptaspect_ratioresolution1k 一般比 2k 更省)。

响应里取 data.task_id

4.2 轮询(GET)——URL 拼接

resultUrl = endpoint + "/" + encodeURIComponent(task_id)

resultUrl 定时 GET,读 data.task_statussubmitted / processing 继续;failed 报错;否则解析 data.task_result.images[0].url

4.3 「图片拼接」指什么?(不是多图拼画布)

  • 接口 URLbase + 固定路径 + / + encodeURIComponent(id)
  • 展示:先把 imgUrl 设为 loading 图,成功后改为结果里的 HTTPS 图片 URL<img :src="imgUrl"> 由浏览器再去拉 CDN 图。

五、一次点击「Generate」的时序

sequenceDiagram
  participant B as 浏览器 index-keling.html
  participant S as server.js 代理
  participant K as api-beijing.klingai.com

  B->>S: POST /kling/v1/images/generations + JSON payload
  S->>S: 签发 JWT
  S->>K: POST /v1/images/generations + Bearer JWT
  K-->>S: 200 + task_id
  S-->>B: 透传 JSON

  loop 轮询
    B->>S: GET /kling/v1/images/generations/{task_id}
    S->>K: GET /v1/images/generations/{task_id} + Bearer JWT
    K-->>S: task_status + task_result...
    S-->>B: 透传 JSON
  end

  B->>B: imgUrl = task_result.images[0].url

六、省钱与排错

  • 分辨率payload.resolution1k 通常比 2k 更省(以官方计费为准)。
  • 401 / Auth failed:核对 北京域、AK/SK、重启 node server.js 后是否读到 .env.local
  • 422 / 字段错误:对照当前模型文档改 payload 字段名。

七、仓库文件对照

内容 文件
前端单页 index-keling.html
JWT + 代理 + DeepSeek 其它路由 server.js
环境说明 README.md

八、后续(语音等)

可按同一模板扩展:鉴权方式 → 是否需代理 → 前端拼 URL 还是拼流;语音若走流式或 WebSocket,「拼接」更多在 chunk 缓冲与解码,建议另开一篇写。


附录 A:核心代码摘录(与仓库一致)

完整可运行代码请以仓库为准;下面仅保留与可灵最相关的片段。

A.1 server.js:JWT + 代理(节选)

const KLING_API_ORIGIN = (
  process.env.KLING_API_ORIGIN || 'https://api-beijing.klingai.com'
).trim()
const KLING_PATH_PREFIX = '/kling'

function signKlingJwt(accessKeyId, accessKeySecret) {
  const now = Math.floor(Date.now() / 1000)
  const header = { alg: 'HS256', typ: 'JWT' }
  const payload = { iss: accessKeyId, exp: now + 1800, nbf: now - 5 }
  const h = toBase64Url(JSON.stringify(header))
  const p = toBase64Url(JSON.stringify(payload))
  const signingInput = `${h}.${p}`
  const sig = crypto
    .createHmac('sha256', accessKeySecret)
    .update(signingInput)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
  return `${signingInput}.${sig}`
}

// createServer 内:pathname 以 /kling 开头则 await proxyKlingRequest(...)
// proxyKlingRequest:restPath = pathname 去掉 /kling;拼 targetUrl;Bearer 调用 fetch(upstream)

toBase64UrlreadRequestBodyCORSloadDotEnv 及 DeepSeek 路由见仓库文件。)

A.2 index-keling.html:提交与轮询 URL(节选)

const endpoint = `${base}/kling/v1/images/generations`
const payload = {
  prompt: prompt.value.trim(),
  negative_prompt: negativeWords,
  aspect_ratio: aspectRatio.value,
  resolution: resolution.value
}
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload) })
const id = (await res.json()).data?.task_id
const resultUrl = `${endpoint}/${encodeURIComponent(id)}`
// 循环 fetch(resultUrl) 直到非 processing/submitted …

(Vue template<style>、localStorage 与错误处理见仓库完整 HTML。)


附录 B:完整源码一键打开(Raw)

便于整文件复制:


CSS mask 完全指南:从渐变裁切到弹幕遮挡

CSS 属性里,mask 大概是被低估最严重的那一个。很多人知道它能"遮住一些东西",但真正上手时又觉得无从下手。其实 mask 的语法和 background 几乎一模一样——如果你已经玩转了渐变背景,那 mask 对你来说就是换个属性名的事。

本文会从语法开始,一路讲到弹幕遮挡、转场动画这些实战场景。每个案例都附带可运行的代码。


1. mask 到底是什么?

一句话:mask 决定元素的哪些部分可见、哪些部分透明

它接受的值和 background 一样——渐变、图片、SVG 都行。工作原理也简单:

  • mask 中有颜色的区域(不管什么颜色),对应元素内容可见
  • mask 中透明的区域,对应元素内容不可见

来看最基础的例子:

.demo {
  background: url(photo.jpg);
  -webkit-mask: linear-gradient(90deg, transparent, #000);
  mask: linear-gradient(90deg, transparent, #000);
}

效果是图片从左侧完全透明,到右侧完全可见——一个从无到有的渐隐效果。

这里 #000 换成 redblue 或任何颜色,效果完全一样。mask 只关心透明度,不关心色相。


2. mask 语法详解

根据 MDN CSS mask 文档:

The mask shorthand CSS property hides an element (partially or fully) by masking or clipping the image at specific points. It is a shorthand for mask-image, mask-mode, mask-repeat, mask-position, mask-clip, mask-origin, mask-size, and mask-composite.

mask 是一个简写属性,包含以下子属性:

子属性 作用 对应的 background 属性
mask-image 遮罩图像(渐变/图片/SVG) background-image
mask-size 遮罩尺寸 background-size
mask-repeat 是否平铺 background-repeat
mask-position 遮罩定位 background-position
mask-origin 定位参考框 background-origin
mask-clip 裁切参考框 background-clip
mask-composite 多个遮罩的合成方式 无对应属性

看到没有?除了 mask-composite,其他属性和 background 完全对应。如果你已经熟悉了 background-sizebackground-position 这些属性,mask 的学习成本几乎为零。

兼容性前缀

目前(2026 年)在 Chrome、Edge 等 Blink 内核浏览器中,mask 仍需 -webkit- 前缀。实际写代码时建议这样写:

.el {
  -webkit-mask: linear-gradient(#000, transparent);
  mask: linear-gradient(#000, transparent);
}

或者直接在构建工具中配置 autoprefixer,让它帮你加前缀。


3. 基础用法:渐变遮罩裁切

3.1 案例:图片切角效果

多层线性渐变可以拼出切角图形,这个技巧在 background 上就能用。把同样的渐变写到 mask 里,就能把任意元素裁成切角造型——不管元素里面是图片、文字还是渐变背景。

.notch-image {
  width: 300px;
  height: 200px;
  background: url(https://picsum.photos/300/200) no-repeat center/cover;
  -webkit-mask:
    linear-gradient(135deg, transparent 15px, #fff 0) top left,
    linear-gradient(-135deg, transparent 15px, #fff 0) top right,
    linear-gradient(-45deg, transparent 15px, #fff 0) bottom right,
    linear-gradient(45deg, transparent 15px, #fff 0) bottom left;
  -webkit-mask-size: 50% 50%;
  -webkit-mask-repeat: no-repeat;
  mask:
    linear-gradient(135deg, transparent 15px, #fff 0) top left,
    linear-gradient(-135deg, transparent 15px, #fff 0) top right,
    linear-gradient(-45deg, transparent 15px, #fff 0) bottom right,
    linear-gradient(45deg, transparent 15px, #fff 0) bottom left;
  mask-size: 50% 50%;
  mask-repeat: no-repeat;
}

四个方向的渐变各占 50% 50%,拼在一起刚好覆盖整个元素。每个渐变在角落处用 transparent 挖掉一个三角形,组合起来就是四角切角。

这里的 #fff 0 用了渐变简写技巧:0 会被浏览器修正为前一个色标的位置 15px,形成硬边界。


3.2 案例:内切圆角按钮

普通的内切圆角用 radial-gradient 就能画出来。但问题在于:如果按钮背景是渐变色而不是纯色,直接用 background 画内切圆角基本无解——你没法让两层渐变"叠加"出一个圆角效果。

mask 能解决这个问题:把内切圆角的形状写成 mask,background 想用什么渐变都行

.inset-btn {
  padding: 16px 48px;
  font-size: 16px;
  color: #fff;
  border: none;
  background: linear-gradient(45deg, #2179f5, #e91e63);
  -webkit-mask:
    radial-gradient(
        circle at 100% 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right bottom,
    radial-gradient(circle at 0 0, transparent 0, transparent 12px, #fff 13px)
      left top,
    radial-gradient(
        circle at 100% 0,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right top,
    radial-gradient(
        circle at 0 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      left bottom;
  -webkit-mask-size: 50% 50%;
  -webkit-mask-repeat: no-repeat;
  mask:
    radial-gradient(
        circle at 100% 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right bottom,
    radial-gradient(circle at 0 0, transparent 0, transparent 12px, #fff 13px)
      left top,
    radial-gradient(
        circle at 100% 0,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right top,
    radial-gradient(
        circle at 0 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      left bottom;
  mask-size: 50% 50%;
  mask-repeat: no-repeat;
}

原理:四个 radial-gradient 分别处理四个角,每个径向渐变的圆心在对应角落,0~12px 的范围是透明的(挖出圆弧),13px 往外是白色(保留内容)。

改变 12px 的值可以调整圆弧大小。这种方案的好处是 background 完全自由——纯色、渐变、图片都没问题。


4. 进阶用法:渐变消失与融合

4.1 案例:横向滚动列表的渐变消失

在很多产品里都能看到这种效果:一个横向可滚动的列表,右侧内容渐渐消失,暗示用户"还有更多内容"。

不用 mask 的话你可能会想到覆盖一个半透明遮罩层。但这有个麻烦:遮罩层会挡住点击事件,还需要设置 pointer-events: none

用 mask 就一行代码:

.scroll-list {
  display: flex;
  overflow-x: auto;
  gap: 12px;
  -webkit-mask: linear-gradient(90deg, #000 70%, transparent);
  mask: linear-gradient(90deg, #000 70%, transparent);
}

linear-gradient(90deg, #000 70%, transparent) 的意思是:从左到右,前 70% 完全可见,剩下 30% 逐渐透明。就这么简单。

要注意一点:mask 作用于整个元素及其内容,包括文字、子元素、甚至滚动条。这正是 mask 和 "覆盖一层遮罩" 的本质区别——mask 是从元素自身出发做裁切,而不是在上面盖东西。


4.2 案例:两张图片融合

mask 做图片融合非常直观:两张图片叠在一起,上层图片加一个 mask,mask 的透明区域会露出下层图片。

.blend {
  position: relative;
  width: 400px;
  height: 300px;
  background: url(https://picsum.photos/400/300?random=1) no-repeat center/cover;
}

.blend::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/300?random=2) no-repeat center/cover;
  -webkit-mask: linear-gradient(45deg, #000 40%, transparent 60%);
  mask: linear-gradient(45deg, #000 40%, transparent 60%);
}

linear-gradient(45deg, #000 40%, transparent 60%) 中,40% 到 60% 这段是过渡区——两张图片在这里平滑融合。如果你把它改成 #000 50%, transparent 50%,那就是硬切割,没有过渡。

除了 linear-gradient 做线性方向的融合,radial-gradient 可以做径向区域的融合——在画面中某个位置开一个"窗口",露出下层的内容:

.radial-blend {
  position: relative;
  width: 520px;
  height: 320px;
  overflow: hidden;
}

.radial-blend .layer-cold {
  position: absolute;
  inset: 0;
  background: url(scene-cold.jpg) center / cover no-repeat;
}

.radial-blend .layer-warm {
  position: absolute;
  inset: 0;
  background: url(scene-warm.jpg) center / cover no-repeat;
  -webkit-mask: radial-gradient(circle at 35% 45%, #000 20%, transparent 60%);
  mask: radial-gradient(circle at 35% 45%, #000 20%, transparent 60%);
}

上层暖色调图片通过 radial-gradient 只在左侧偏上的位置可见,向外逐渐透明,露出底层冷色调图片。两张风格不同的照片在圆形过渡区自然融合。


5. mask-composite:组合遮罩

当一个元素有多个 mask 时,mask-composite 决定它们之间怎么合成。

根据 MDN mask-composite 文档:

The mask-composite CSS property represents a compositing operation used on the current mask layer with the mask layers below it.

标准语法支持四个关键字:

mask-composite: add; /* 叠加(默认)*/
mask-composite: subtract; /* 减去 */
mask-composite: intersect; /* 取交集 */
mask-composite: exclude; /* 排除重叠 */

但 WebKit 浏览器用的是另一套语法(-webkit-mask-composite),常用的值有:

-webkit-mask-composite: source-over; /* 对应 add */
-webkit-mask-composite: source-in; /* 对应 intersect */
-webkit-mask-composite: source-out; /* 只显示上层独有部分 */
-webkit-mask-composite: destination-out; /* 只显示下层独有部分 */
-webkit-mask-composite: xor; /* 对应 exclude */

案例:两个圆弧取交集

假设你想裁出一个"两个圆弧重叠"的形状:

.composite-demo {
  width: 300px;
  height: 200px;
  background: linear-gradient(135deg, #667eea, #764ba2);
  -webkit-mask:
    radial-gradient(circle at 100% 0, #000, #000 200px, transparent 200px),
    radial-gradient(circle at 0 0, #000, #000 200px, transparent 200px);
  -webkit-mask-composite: source-in;
  mask:
    radial-gradient(circle at 100% 0, #000, #000 200px, transparent 200px),
    radial-gradient(circle at 0 0, #000, #000 200px, transparent 200px);
  mask-composite: intersect;
}

如果不加 mask-composite,两个 mask 默认是 add(叠加),你看到的是两个圆弧的并集。加上 intersect(或 -webkit-mask-composite: source-in),就只保留两个圆弧重叠的部分

这个能力在做异形裁切时很有用:单个渐变很难画出的形状,可以通过多个简单渐变组合得到。


6. 高阶动画:mask 驱动的转场

mask 不只是静态裁切。通过动态改变 mask 的值,可以实现各种转场和切换效果。

6.1 渐变不能直接做动画——怎么办?

CSS 渐变本身不支持 transitionanimation。也就是说你写 transition: mask 0.3s 是没用的,linear-gradient 内部的参数变化不会有平滑过渡。

两种绕过方案:

  1. 逐帧动画:用 SASS 循环生成 0% 到 100% 共 101 帧的 @keyframes,每一帧写死 mask 的值
  2. CSS @property:注册一个自定义属性,让浏览器知道这个变量是 <percentage> 类型,这样它就能被动画插值

第一种方案的代码经过 SASS 编译后非常臃肿(101 帧)。推荐用第二种。

6.2 案例:conic-gradient 扇形转场(CSS @property 方案)

这是一个经典的转场效果:上层图片像扇形展开一样逐渐覆盖下层图片。hover 时触发动画。

@property --conic-p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: -10%;
}

.transition-box {
  position: relative;
  width: 400px;
  height: 400px;
  background: url(https://picsum.photos/400/400?random=5) no-repeat center/cover;
  cursor: pointer;
}

.transition-box::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/400?random=100) no-repeat
    center/cover;
  pointer-events: none;
  -webkit-mask: conic-gradient(
    #000 0,
    #000 var(--conic-p),
    transparent calc(var(--conic-p) + 10%),
    transparent
  );
  mask: conic-gradient(
    #000 0,
    #000 var(--conic-p),
    transparent calc(var(--conic-p) + 10%),
    transparent
  );
}

.transition-box:hover::before {
  animation: conicSweep 1.5s ease-in-out forwards;
}

@keyframes conicSweep {
  from {
    --conic-p: -10%;
  }
  to {
    --conic-p: 100%;
  }
}

这里有几个关键点:

  • @property --conic-p:注册之后,浏览器知道 --conic-p 是百分比类型,可以在动画中平滑插值。mask 里的 conic-gradient 会随着 --conic-p 从 -10% 变化到 100%,像时钟指针一样扫过整个圆。
  • pointer-events: none:伪元素覆盖在容器上层,如果不加这个属性,鼠标事件会被伪元素拦截,导致容器的 :hover 状态无法触发。
  • calc(var(--conic-p) + 10%) 多出的 10% 是过渡区,让边缘不那么生硬。如果你想要硬边界,把 +10% 去掉就行。

同样的思路,换成 linear-gradient 就是一个从左到右的滑动转场:

@property --slide-p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.slide-box {
  position: relative;
  width: 400px;
  height: 400px;
  background: url(https://picsum.photos/400/400?random=10) no-repeat
    center/cover;
  cursor: pointer;
}

.slide-box::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/400?random=200) no-repeat
    center/cover;
  pointer-events: none;
  -webkit-mask: linear-gradient(
    90deg,
    #000 var(--slide-p),
    transparent calc(var(--slide-p) + 5%)
  );
  mask: linear-gradient(
    90deg,
    #000 var(--slide-p),
    transparent calc(var(--slide-p) + 5%)
  );
}

.slide-box:hover::before {
  animation: slideReveal 1.2s ease-in-out forwards;
}

@keyframes slideReveal {
  from {
    --slide-p: 0%;
  }
  to {
    --slide-p: 100%;
  }
}

和扇形转场的原理完全一样,只是把 conic-gradient 换成了 linear-gradient--slide-p 从 0% 变化到 100%,实色区域从左往右推进,形成滑动揭示的效果。

如果你的目标浏览器不支持 @property(比如旧版 Firefox),也可以用 SASS 逐帧方案替代:

@keyframes maskSlide {
  @for $i from 0 through 100 {
    #{$i}% {
      mask: linear-gradient(
        90deg,
        #000 #{$i + '%'},
        transparent #{$i + 5 + '%'}
      );
    }
  }
}

编译后会生成 101 帧的 @keyframes,每一帧写死 mask 的值,代码量大但兼容性最好。


7. 实战:弹幕人物遮挡效果

在 BiliBili 或虎牙直播中,弹幕经过人物区域时会自动"绕道"——弹幕看起来在人物的后面。这个效果的实现原理就是 mask。

原理

  1. 视频画面和弹幕容器是两层叠加结构,弹幕在上层
  2. 后端通过图像识别算法,实时计算出人物的轮廓区域
  3. 生成一张 SVG/PNG 图片:人物轮廓区域是透明的,其他区域是白色/实色的
  4. 把这张图片设为弹幕容器的 mask-image
  5. 根据 mask 的工作原理——透明区域对应的弹幕内容不可见——弹幕就"消失"在人物背后了
  6. 随着视频播放,后端不断更新 mask 图片,实现实时遮挡

简化模拟

后端的实时图像识别我们没法在前端模拟,但原理可以用 radial-gradient 来演示:

.barrage-container {
  position: absolute;
  inset: 0;
  -webkit-mask: radial-gradient(
    circle at 100px 100px,
    transparent 60px,
    #fff 80px,
    #fff 100%
  );
  mask: radial-gradient(
    circle at 100px 100px,
    transparent 60px,
    #fff 80px,
    #fff 100%
  );
  animation: maskFollow 6s infinite alternate linear;
}

@keyframes maskFollow {
  to {
    -webkit-mask-position: 80vw 0;
    mask-position: 80vw 0;
  }
}

radial-gradient(100px, 100px) 位置挖了一个半径 60px 的圆形透明区域,60px 到 80px 是过渡,80px 以外完全可见。通过动画移动 mask-position,这个"挖洞"就会跟着移动。

真实场景中,这个 "挖洞" 的形状不是简单的圆形,而是从后端返回的人物轮廓 SVG。但 mask 的使用方式完全相同。

要搞清楚一点:mask 遮挡的是弹幕容器,不是人物。mask 的透明区域让弹幕不可见,从而"露出"弹幕下方的人物画面。


9. 兼容性

mask 属性的浏览器支持已经相当好了:

浏览器 支持情况
Chrome / Edge 支持(需 -webkit- 前缀)
Firefox 完全支持(无需前缀)
Safari 支持(需 -webkit- 前缀)
IE 不支持

如果你不需要兼容 IE,mask 可以放心用。前缀问题交给 autoprefixer 处理:

// postcss.config.js
module.exports = {
  plugins: [require('autoprefixer')],
};

mask-composite 的兼容性稍差一些,使用前建议在 Can I Use 上确认目标浏览器的支持情况。


10. 总结

核心原则

mask 中有颜色 → 内容可见透明 → 内容不可见。记住这一条就够了。

技巧速查表

技巧 实现方式 典型场景
渐变遮罩 mask: linear-gradient(...) 内容淡出、列表渐隐
切角/异形裁切 多重 linear-gradient + mask-size: 50% 50% 图片切角、优惠券造型
内切圆角 多重 radial-gradient 不规则按钮、卡片
图片融合 伪元素叠加 + mask 两图过渡、径向区域融合
组合遮罩 mask-composite: intersect 多 mask 取交集/差集
渐变动画转场 @property + conic-gradient 扇形展开、滑动切换
图表重绘 @property + conic-gradient + :hover 数据可视化 hover 效果
弹幕遮挡 radial-gradient / 实时图片 视频直播弹幕
雪碧图转场 mask: url(sprite.png) + steps() 精致页面转场

和 background 的关系

mask 的语法和 background 几乎一一对应——多层叠加、repeat、position、size 这些在 background 上能做的事,mask 上全能做。多出来的 mask-composite 让多个 mask 之间的布尔运算成为可能,这是 background 没有的能力。


延伸阅读

解锁对象遍历:当字符串遇上for...in循环

前言:

哈喽大家好,我是心连欣。在JavaScript的世界里,对象(Object)是我们组织数据的核心方式。从简单的用户信息到复杂的后端接口返回,对象无处不在。今天,我们通过一个实际的代码案例,深入探讨如何遍历对象,并特别关注一个容易被忽视的细节——当对象的属性值是字符串时,我们应该注意什么?

基础回顾:对象的创建与结构

首先,我们来看一个典型的用户信息对象。它包含了名字、年龄、性别和地址等属性。

let obj = {
      name:'xx',
      age:'21',
      gender:'女',
      address:'重庆',
    }

在这个对象中,nameagegender 和 address 是键(Key),而 'xx''21''女''重庆' 是对应的值(Value)。

细心的同学可能已经发现,这里的值全部被单引号包裹,这意味着它们在JavaScript中都是字符串类型。即使是 age: '21',它也不是数字21,而是字符"2"和"1"的组合。这一点在后续的数据处理中至关重要。

遍历神器:for...in循环

要读取对象中的每一个数据,最通用的方法是使用 for...in 循环。它的作用就像是把对象里的每一把“钥匙”都拿出来,然后开门看看里面装了什么。

 for(let k in obj){
      console.log(k);
      console.log(obj[k]);
    }

代码解析:

  1. let k in obj:循环开始时,变量 k 会依次接收对象的每一个属性名(字符串形式)。第一次循环 k 是 "name",第二次是 "age",以此类推。
  2. console.log(k) :直接输出属性名。
  3. console.log(obj[k]) :这一点真的非常非常非常重要!!!!!这是方括号表示法。因为 k 是一个变量,我们不能用点语法(obj.k 是错的),必须用 obj[k] 来动态获取对应的值。

注意:如果我们采用obj.k的语法来打印,就会出现以下错误:

image.png 导致出现undefined的提示。

深度解析:字符串遍历的陷阱与注意点

回到我们的核心问题:如果遍历出的内容是字符串,我们需要注意什么?

通过控制台输出,我们看到 age 的值是 '21'。在视觉上它和数字21没有区别,但在代码逻辑中,它们天差地别。

注意点一:类型检测

在遍历过程中,如果你不确定拿到的值是什么类型,务必使用 typeof 进行检测。

 for(let k in obj){
    let value = obj[k];
    if(typeof value === 'string'){
        console.log(`属性 ${k} 的值是字符串:${value}`);
    }
}

查看结果如下图:

image.png注意点二:隐式转换与计算错误

这是新手最容易踩的坑。假设我们想计算用户的年龄加5岁后的数值。

  • 错误写法:
// 假设 age 是字符串 '21' 
let nextYear = obj.age + 5;
console.log(nextYear); // 输出结果是 "215",而不是 26!
  • 原因:在JavaScript中,字符串与数字使用 + 号连接时,会执行字符串拼接操作,而不是数学加法。
  • 正确写法:

在进行计算前,必须将字符串转换为数字。可以使用 Number() 函数或一元加号 +

// 方法1:使用 Number() 
let nextYear1 = Number(obj.age) + 5;
// 方法2:使用一元加号(更简洁) 
let nextYear2 = +obj.age + 5; 
console.log(nextYear1); // 输出 26

注意点三:空字符串与逻辑判断

在遍历表单对象或用户输入时,字符串可能是空的 ""。在 if 判断中,空字符串会被视为 false(假值)。

let user = { name: 'xx', nickname: '' }; 
for(let k in user){ 
if(user[k])
{ console.log(`${k} 有值`); 
} else
{ console.log(`${k} 是空值`); // nickname 会触发这个分支 } }

总结

遍历对象不仅仅是打印出键和值,更重要的是理解数据的类型。

  1. 识别类型:时刻警惕被引号包裹的数字(如 '21'),它们本质是字符串。
  2. 动态取值:在 for...in 循环中,使用 obj[k] 来获取值。
  3. 类型转换:在进行数学运算之前,务必使用 Number() 或 + 将字符串转为数字,避免逻辑错误。

掌握了这些细节,你才能真正驾驭对象数据,写出健壮的代码!

使用纯canvas绘制一个掘金首页

使用纯 Canvas 绘制一个掘金首页

在前端开发中,我们习惯了使用 HTML 和 CSS 来构建用户界面。但你是否想过,如果完全抛弃 DOM 树,使用纯 Canvas 来绘制一个复杂的现代 Web 页面(比如稀土掘金的首页),会是怎样的体验?

react-canvas 这个项目中,我们进行了一次硬核的尝试:基于 Skia (CanvasKit) 和 Yoga 布局引擎,使用 React 自定义渲染器从零构建了掘金的首页。

🔗 在线体验地址react-canvas-design.vercel.app/#/juejin
💻 GitHub 仓库github.com/ouzhou/reac…

screenshot-20260414-161131.png

技术栈揭秘

要实现这个目标,我们不能使用标准的 react-dom。我们的底层基础设施包括:

  1. CanvasKit (Skia WebAssembly):作为底层的 2D 图形渲染引擎,负责绘制所有的矩形、文本、图像和 SVG 路径。
  2. Yoga Layout:Facebook 开源的跨平台 Flexbox 布局引擎。由于 Canvas 本身没有布局概念,我们通过 Yoga 来计算每个元素的坐标和尺寸。
  3. @react-canvas/react-v2:我们自己实现的 React 渲染器,将 React 组件树映射为底层的渲染节点。

核心实现思路

在纯 Canvas 的世界里,没有 <div><span><img>。一切都是自定义的节点。

1. 基础组件映射

我们将传统的 HTML 标签替换为了 react-canvas 提供的基础组件:

  • <div> -> <View>:作为基础的容器,支持 Flexbox 布局。
  • <span> / <p> -> <Text>:用于文本渲染,底层调用 Skia 的 Paragraph API。
  • <img> -> <Image>:用于渲染网络图片(如掘金的 Logo)。
  • <svg> -> <SvgPath>:用于渲染矢量图标。
  • 滚动区域 -> <ScrollView>:由于 Canvas 没有原生滚动条,我们需要自己处理滚动事件和视口裁剪。

2. 初始化画布与字体

Canvas 绘制中文需要显式加载字体文件,否则会出现乱码(豆腐块)。我们在最外层使用 CanvasProvider 初始化运行时,并加载了思源黑体:

import { CanvasProvider, Canvas, View, Text } from "@react-canvas/react-v2";
import localParagraphFontUrl from "../assets/NotoSansSC-Regular.otf?url";

<CanvasProvider initOptions={{ defaultParagraphFontUrl: localParagraphFontUrl }}>
  {({ isReady, runtime }) => (
    <Canvas
      width={vw}
      height={vh}
      paragraphFontProvider={runtime.paragraphFontProvider}
      defaultParagraphFontFamily={runtime.defaultParagraphFontFamily}
    >
      {/* 页面内容 */}
    </Canvas>
  )}
</CanvasProvider>

3. Flexbox 布局与样式

得益于 Yoga,我们可以像写 React Native 一样使用 Flexbox 布局。所有的样式都是内联的 JS 对象,而不是 CSS 类:

// 掘金顶部导航栏的布局示例
<View
  style={{
    width: vw,
    height: 60,
    backgroundColor: "#ffffff",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    paddingLeft: 24,
    paddingRight: 24,
  }}
>
  {/* Logo 和 导航项 */}
</View>

4. 交互状态 (Hover)

在 DOM 中,我们通常用 :hover 伪类来处理鼠标悬停状态。在 react-canvas 中,style 属性支持传入一个函数,接收当前的交互状态:

<View
  style={({ hovered }) => ({
    padding: 16,
    backgroundColor: hovered ? "#fafafa" : "#ffffff", // 悬停时改变背景色
    cursor: "pointer",
  })}
>
  <Text>文章标题</Text>
</View>

5. 绘制细节与踩坑:分割线

在传统的 CSS 中,我们可以轻松地写出 border-bottom: 1px solid #eee。但在我们目前的自定义渲染器中,单边边框的支持还在完善中。

为了在 Canvas 中画出完美的 1px 分割线,我们采用了绝对定位的 <View> 元素来模拟:

// 模拟 border-bottom
<View style={{ 
  position: "absolute", 
  bottom: 0, 
  left: 0, 
  right: 0, 
  height: 1, 
  backgroundColor: "#f1f1f1" 
}} />

最终效果

通过组合这些基础能力,我们成功地 1:1 还原了掘金首页的复杂布局,包括:

  • 固定的顶部导航栏(带搜索框和图标)
  • 左侧固定的分类导航侧边栏
  • 中间的文章信息流(包含标题、摘要、作者、时间、点赞数和封面图)
  • 右侧的签到卡片、排行榜和活动 Banner
  • 右下角的悬浮按钮

所有的渲染都在一个 <canvas> 标签内完成!

总结

使用纯 Canvas 绘制复杂的 Web UI 是一次非常有趣的探索。虽然它失去了 DOM 带来的无障碍性(A11y)、SEO 和原生的文本选中能力,但它带来了极致的渲染控制权和跨平台的一致性(同一套代码可以轻易移植到原生 App 甚至桌面端)。

这正是 Flutter、React Native Skia 等技术的核心魅力所在。通过 react-canvas,我们在 Web 端也体验到了这种“掌控每一个像素”的快感。

水厂水泵工作流程图canvas动画

水厂水泵工作流程图.jpg展示地址 http://jstopo.top

let state = {
    qpaoTime: 0,
    waterLen: 0,
    countNum: 0,
    timeR: 0
  };
  const starCanvas = document.getElementById('myCanvas');
  const drawLineFunc = (ctx, objs)=>{//绘制线路
      objs.forEach(item=>{
          ctx.save();
          ctx.beginPath();
          ctx.strokeStyle = item.color;
          ctx.lineWidth = item.isDash ? 5:item.lineWidth;
          ctx.lineCap = item.lineCap;
          // item.isDash ? ctx.setLineDash(item.lineDash||[]):ctx.setLineDash([]);
          item.lines.forEach((line,indx)=>{
              if(!item.isDash){
                  if(line.isMoveTo) ctx.moveTo(line.x,line.y);
                  else ctx.lineTo(line.x,line.y);
              }else if(item.isDash && !line.isMoveTo){
                  if( line.y == item.lines[indx-1].y){//不能是第一个点
                      if(line.x > item.lines[indx-1].x){//管道x向右
                          let c = state.countNum, total_c = line.x - item.lines[indx-1].x;
                          while(c <= total_c-4){
                              let ax = item.lines[indx-1].x+c;
                              ctx.moveTo(ax,line.y);
                              let ax2 = item.lines[indx-1].x+c+8;
                              ctx.lineTo(ax2, line.y);
                              c += 12;
                          }
                      }else{//管道x向左
                          let c = state.countNum, total_c = item.lines[indx-1].x - line.x;
                          while(c <= total_c-6){
                              let ax = item.lines[indx-1].x-c;
                              ctx.moveTo(ax,line.y);
                              let ax2 = item.lines[indx-1].x-c-8;
                              ctx.lineTo(ax2, line.y);
                              c += 12;
                          }
                      }
                      
                  }else if(line.x == item.lines[indx-1].x){
                      if(line.y > item.lines[indx-1].y){//管道y向下
                          let c = state.countNum, total_c = line.y - item.lines[indx-1].y;
                          while(c <= total_c-6){
                              let ay = item.lines[indx-1].y+c;
                              ctx.moveTo(line.x, ay);
                              let ay2 = item.lines[indx-1].y+c+8;
                              ctx.lineTo(line.x, ay2);
                              c += 12;
                          }
                      }else{//管道y向上
                          let c = state.countNum, total_c = item.lines[indx-1].y - line.y;
                          while(c <= total_c-4){
                              let ay = item.lines[indx-1].y-c;
                              ctx.moveTo(line.x, ay);
                              let ay2 = item.lines[indx-1].y-c-8;
                              ctx.lineTo(line.x, ay2);
                              c += 12;
                          }
                      }
                      
                  }
              }
          })
          ctx.stroke();
          ctx.restore();
      })
  }
  const drawInitRect = (ctx)=>{//绘制文字矩形
      ctx.save();
      ctx.beginPath();ctx.font = "normal 14px 微软雅黑";
      ctx.lineWidth = 2;ctx.fillStyle = "#333";
      ctx.rect(160, 32, 100, 80);
      ctx.fillText("闸门", 196, 130);
      ctx.moveTo(370+12, 18);
      ctx.arc(370, 38, 18, 0, Math.PI*2);
      ctx.rect(330, 57, 80, 26);
      ctx.fillText("5.5 m³/H", 416, 33);
      ctx.fillText("瞬时流量", 416, 50);
      ctx.fillText("7.5 GJ", 416, 96);
      ctx.fillText("瞬时热量", 416, 111);
  
      ctx.fillText("9.3 m³/H", 216, 370);
      ctx.fillText("瞬时流量", 216, 390);
  
      ctx.fillText("6.8 GJ", 216, 510);
      ctx.fillText("瞬时热量", 216, 530);
  
      ctx.fillText("自流井", 326, 770);
      ctx.fillText("水箱", 908, 750);
      ctx.fillText("水量 73.9%", 788, 690);
      ctx.moveTo(466, 56);
      ctx.lineTo(466, 85);
      ctx.lineTo(490, 85);
      ctx.lineTo(490, 105);
      ctx.lineTo(510, 105);
      ctx.lineTo(510, 85);
      ctx.lineTo(538, 85);
      ctx.lineTo(538, 56);
      ctx.lineTo(468, 56);
      ctx.moveTo(420, 401);
      ctx.rect(300, 572, 100, 180);
      ctx.moveTo(930-52, 606);
      ctx.arc(930, 606, 52, Math.PI, Math.PI*2);
      ctx.lineTo(982, 706);ctx.lineTo(1003, 706);ctx.lineTo(1003, 730);
      ctx.lineTo(860, 730);ctx.lineTo(860, 706);ctx.lineTo(878, 706);ctx.lineTo(878, 606);
      ctx.fill();
      ctx.restore();
      Animation_of_water_flowing_up(ctx);
  }
  const Animation_of_water_flowing_up = (ctx)=>{//水向上流动动画
    const grd = ctx.createLinearGradient(300, 752, 300, 752-state.waterLen);
    ctx.save();
    ctx.beginPath();
    grd.addColorStop(0.3,"#832392");
    grd.addColorStop(1,"#c636de");
    ctx.fillStyle = grd;ctx.strokeStyle = grd;
    //瀑布流动
    ctx.moveTo(400, 647);
    ctx.quadraticCurveTo(352, 678, 352-state.countNum, 752);
    ctx.lineTo(362-state.countNum, 749);
    ctx.quadraticCurveTo(366, 678, 400, 653);
    //水储存在箱子里
    ctx.moveTo(300, 752);
    ctx.lineTo(400, 752);
    ctx.lineTo(400, 751-state.waterLen);
    ctx.lineTo(300, 751-state.waterLen);
    ctx.lineTo(300, 752);
    //水箱 水量
    ctx.moveTo(975, 706);
    ctx.lineTo(885, 706);
    ctx.lineTo(885, 706-state.waterLen);
    ctx.lineTo(975, 706-state.waterLen);
    ctx.moveTo(975, 706);
    ctx.stroke();
    ctx.fill();
    ctx.restore();
    if(state.waterLen > 30){
        randomBubble(ctx,400,752,300,752-state.waterLen);
        randomBubble(ctx,955,706,895,706-state.waterLen);
    }
  }
  const randomBubble = (ctx,sx,sy,ex,ey)=>{//水里随机的气泡动画
      const width = sx - ex, height = sy - ey-16;
      ctx.save();
      ctx.beginPath();ctx.strokeStyle = "rgba(255,255,255,0.4)";
      ctx.arc(sx-Math.random()*width,sy-8-Math.floor(Math.random()*height),Math.floor(Math.random() * 8) + 1,0,Math.PI*2);
      ctx.fillStyle = "rgba(255,255,255,0.4)";
      ctx.fill();ctx.stroke();
      ctx.restore();
  }
  const requestAmatinat = (ctx,width,height)=>{
      const dotMove = ()=>{
          ctx.clearRect(0,0,width,height);
          drawLineFunc(ctx,[
              {
                  isDash:false,
                  lines:[
                    {x:1180,y:682,isMoveTo:true},
                    {x:1180,y:546},{x:1420,y:546},
                    {x:1420,y:546},{x:1420,y:160}
                  ],
                  color:'#333',lineWidth:16,lineCap:'round'
              },
              {
                isDash:true,
                lines:[
                  {x:1180,y:682,isMoveTo:true},
                  {x:1180,y:546},{x:1420,y:546},
                  {x:1420,y:546},
                  {x:1420,y:160}
                ],
                color:'#c636de',
                lineWidth:5,
                lineCap:'butt',
                lineDash:[12, 3]
              },
              {isDash:false,lines:[
                {x:500,y: 70,isMoveTo:true},
                {x:500,y:310},
                {x:width - 160,y:310},
                {x:100,y:70,isMoveTo:true},
                {x:width - 160,y:70},
                {x:100,y:70,isMoveTo:true},
                {x:width - 160,y:70},
                {x:320,y:221,isMoveTo:true},
                {x:320, y: 470},
                {x:1180,y: 470},
                {x:1180,y: 410},
                {x:width - 160, y:410}
              ],color:'#333',lineWidth:16,lineCap:'round'},
              {
                  isDash:true,
                  lines:[
                    {x:500,y:70,isMoveTo:true},
                    {x:500,y:310},
                    {x:width - 160,y:310},
                    {x:100,y:70,isMoveTo:true},
                    {x:width - 160,y:70},
                  ],
                  color:'#ff9800',
                  lineWidth:5,
                  lineCap:'butt',
                  lineDash:[12, 3]
              },
              {
                isDash:false,
                lines:[
                {x:100,y:220,isMoveTo:true},
                {x:width - 600,y:220},
                {x:width - 600,y:160},
                {x:width - 160,y:160},
                {x:100,y:550,isMoveTo:true},
                {x:830, y:550},{x:830,y:650},{x:880,y:650},
                {x:980,y:682,isMoveTo:true},
                {x:1536, y:682},
                {x:1536, y:410},
                {x:500,y:550,isMoveTo:true},
                {x:500,y:650},{x:401,y:650},
                {x:401,y:680,isMoveTo:true},
                {x:610,y:680},{x:610,y:750},
                {x:710,y:750},{x:710,y:790},
                ],
                color:'#333',lineWidth:16,lineCap:'round'
              },
              {
                isDash:true,
                lines:[
                    {x:100,y:220,isMoveTo:true},
                    {x:width - 600, y:220},
                    {x:width - 600, y:160},
                    {x:width - 160, y:160},
                    {x:320,y:221,isMoveTo:true},
                    {x:320, y:470},
                    {x:1180, y:470},
                    {x:1180, y:410},
                    {x:width - 160, y:410},
                    {x:100,y:550,isMoveTo:true},
                    {x:830, y:550},{x:830,y:650},{x:880,y:650},
                    {x:980,y:682,isMoveTo:true},
                    {x:1536, y:682},{x:1536, y:410},
                    {x:500,y:550,isMoveTo:true},
                    {x:500,y:650},{x:401,y:650},
                    {x:401,y:680,isMoveTo:true},
                    {x:610,y:680},{x:610,y:750},
                    {x:710,y:750},{x:710,y:830},
                  ],
                  color:'#c636de',
                  lineWidth:5,
                  lineCap:'butt',
                  lineDash:[12, 3]
              },
          ]);
          drawInitRect(ctx);
          state.countNum+=0.6;
          state.waterLen+=0.2;
          state.qpaoTime+=1;
          if(state.qpaoTime >= 100){
              state.qpaoTime = 0;
          }
          if(state.waterLen >= 110){
              state.waterLen = 110;
              cancelAnimationFrame(state.timeR);
          }
          if(state.countNum >= 12){
              state.countNum = 0;
              cancelAnimationFrame(state.timeR);
          }
          state.timeR = requestAnimationFrame(dotMove);
      }
      dotMove();
  }
  const drawChart = ()=>{
      if(!starCanvas) return;
      starCanvas.width = starCanvas.clientWidth;
      starCanvas.height = starCanvas.clientHeight;
  
      const ctx = starCanvas.getContext('2d');
      requestAmatinat(ctx,starCanvas.width,starCanvas.height);
  }
  window.onload = ()=>{
    drawChart();
  }

5 分钟用 Vite SSR 搭建一个全栈 React 应用

Vite 是 JavaScript 生态中最快的开发服务器。但用它做 SSR 一直意味着自己接 renderToPipeableStream、配置 client/server 构建、处理 hydration。

Pareto 是基于 Vite 7 的 React SSR 框架,帮你处理好这一切。文件路由、流式 SSR、loader、状态管理、62 KB 的客户端包——零配置。

5 分钟,从零到一个全栈 React 应用。

1. 创建项目(30 秒)

npx create-pareto@latest my-app
cd my-app
npm install
npm run dev

打开 http://localhost:3000。编辑 app/page.tsx,通过 Vite 的 HMR 即时热更新。

2. 理解项目结构(30 秒)

my-app/
  app/
    layout.tsx        # 根布局(header、nav、footer)
    page.tsx          # 首页 (/)
    head.tsx          # 根 <title> 和 meta 标签
    not-found.tsx     # 404 页面
    globals.css       # 全局样式
  pareto.config.ts    # 框架配置(可选)
  package.json
  tsconfig.json

app/ 下任何包含 page.tsx 的目录就是一个路由。嵌套目录创建嵌套路由。就这样。

3. 创建带服务端数据的页面(1 分钟)

/posts 创建新路由:

// app/posts/loader.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  // 只在服务端运行
  return {
    posts: [
      { id: 1, title: 'Hello World', body: '第一篇文章' },
      { id: 2, title: 'Vite SSR', body: '真的很快' },
    ],
  }
}
// app/posts/page.tsx
import { useLoaderData } from '@paretojs/core'

interface Post {
  id: number
  title: string
  body: string
}

export default function PostsPage() {
  const { posts } = useLoaderData<{ posts: Post[] }>()

  return (
    <div>
      <h1>文章列表</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}
// app/posts/head.tsx
export default function Head() {
  return (
    <>
      <title>文章 — My App</title>
      <meta name="description" content="所有博客文章" />
    </>
  )
}

访问 http://localhost:3000/posts。Loader 在服务端运行,HTML 是服务端渲染的,客户端 hydrate。查看源码——文章内容就在 HTML 里。

4. 为慢数据添加流式渲染(1 分钟)

真实应用需要查数据库、调 API。有些快,有些慢。用 defer() 流式传输慢数据,不阻塞页面:

// app/dashboard/loader.ts
import { defer } from '@paretojs/core'

async function getQuickStats() {
  return { users: 1_234, pageViews: 56_789 }
}

async function getSlowAnalytics() {
  // 模拟一个慢 API 调用
  await new Promise((r) => setTimeout(r, 2000))
  return { topPage: '/posts', bounceRate: 0.42 }
}

export async function loader() {
  const stats = await getQuickStats()  // 先解析快数据
  return defer({
    stats,                               // 已解析——包含在初始 HTML
    analytics: getSlowAnalytics(),       // Promise——后续流式传输
  })
}
// app/dashboard/page.tsx
import { useLoaderData, Await } from '@paretojs/core'

export default function DashboardPage() {
  const { stats, analytics } = useLoaderData()

  return (
    <div>
      <h1>仪表板</h1>
      <p>{stats.users} 用户 · {stats.pageViews} 页面浏览</p>

      <Await resolve={analytics} fallback={<p>加载分析数据...</p>}>
        {(data) => (
          <div>
            <p>热门页面:{data.topPage}</p>
            <p>跳出率:{(data.bounceRate * 100).toFixed(0)}%</p>
          </div>
        )}
      </Await>
    </div>
  )
}

访问 http://localhost:3000/dashboard。统计数据立即显示。分析数据 2 秒后流入。页面从不阻塞。

5. 添加客户端导航(30 秒)

<Link> 实现 SPA 风格的导航:

// app/layout.tsx
import type { PropsWithChildren } from 'react'
import { Link } from '@paretojs/core'

export default function RootLayout({ children }: PropsWithChildren) {
  return (
    <>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/posts">文章</Link>
        <Link to="/dashboard">仪表板</Link>
      </nav>
      <main>{children}</main>
    </>
  )
}

点击即时导航。Loader 数据通过 NDJSON 流式获取——延迟数据逐步流入,和初始 SSR 渲染行为一致。

6. 添加状态管理(30 秒)

Pareto 内置 defineStore(),集成 Immer——不需要额外依赖:

// app/stores/theme.ts
import { defineStore } from '@paretojs/core/store'

export const themeStore = defineStore((set) => ({
  mode: 'light' as 'light' | 'dark',
  toggle: () => set((d) => {
    d.mode = d.mode === 'light' ? 'dark' : 'light'
  }),
}))
// 在任何组件中使用
import { themeStore } from '../stores/theme'

function ThemeToggle() {
  const { mode, toggle } = themeStore.useStore()
  return <button onClick={toggle}>主题:{mode}</button>
}

状态在 SSR 期间自动序列化,客户端自动 hydrate。零样板代码。

7. 添加 API 端点(30 秒)

创建 route.ts 文件来定义 JSON API 端点:

// app/api/time/route.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  return { time: new Date().toISOString() }
}

GET http://localhost:3000/api/time 返回 {"time":"2026-04-03T..."}。标准 REST 端点,无需额外配置。

8. 构建和部署(1 分钟)

npm run build
npm run start

你的生产服务器是标准 Node.js 进程,跑 Express + Vite 优化后的构建产物。部署到任何地方:Docker、Fly.io、Railway、VPS、Kubernetes。

不需要特殊托管。不需要 serverless 运行时兼容。不锁定供应商。

你刚刚构建了什么

5 分钟内,你拥有了:

  • 文件路由 — 目录映射为路由
  • 服务端渲染 — 首次加载完整 HTML,利好 SEO
  • 流式 SSR — 慢数据不阻塞页面
  • 客户端导航 — SPA 体验 + NDJSON 流式传输
  • Head 管理 — 每个路由独立的 title 和 meta 标签
  • 状态管理 — Immer 驱动的 store,SSR hydration 全自动
  • API 端点 — JSON 路由和页面共存
  • TypeScript — 全链路类型安全
  • 62 KB 客户端包 — 比 Next.js 小 73%

全部基于 Vite 7——即时启动开发服务器、React Fast Refresh、原生 ESM。

为什么选 Vite 做 SSR?

Vite 的原生 ESM 开发服务器意味着开发时零打包。你的 100 个路由的应用启动速度和 1 个路由一样快。对比基于 Webpack 的框架,开发服务器启动时间随项目规模线性增长。

插件生态是另一个优势——PostCSS、Tailwind、MDX 以及数百个 Rollup/Vite 插件开箱即用,不需要框架包装层。

下一步

npx create-pareto@latest my-app
cd my-app && npm install && npm run dev

Pareto — 轻量级流式 React SSR 框架 | 文档

React 18+ 所有Hooks全解析(含实战示例,新手零踩坑)

React Hooks 是React 16.8+ 引入的核心特性,彻底替代了类组件,让函数组件拥有状态管理、副作用处理、性能优化等能力。本文整理了React 18+ 全部内置Hooks,按「基础必用、副作用、状态进阶、引用、上下文、性能优化、并发、工具类」分类,每个Hook都包含「核心作用、语法、实战示例、适用场景、注意事项」,覆盖日常开发99%的场景,新手可直接套用,老手可查漏补缺。

核心原则:所有Hooks仅能在「函数组件顶层」或「自定义Hook顶层」调用,不能在条件判断、循环、嵌套函数中调用(React通过调用顺序识别Hooks,避免逻辑混乱)。

一、基础必用Hooks(入门必备,100%高频)

这类Hooks是React开发的基础,几乎所有函数组件都会用到,必须熟练掌握。

1. useState —— 函数组件状态管理(核心)

核心作用

给函数组件添加响应式状态,返回「当前状态值 + 状态更新函数」,是最基础、最常用的Hook。

语法

// 基础用法:初始值直接传入
const [state, setState] = useState(initialState);

// 函数式初始值:仅首次渲染执行(适合昂贵计算,避免每次渲染重复计算)
const [state, setState] = useState(() => {
  return computeInitialValue(); // 昂贵计算逻辑
});

// 函数式更新:依赖上一次的状态值(避免闭包陷阱)
setState(prevState => newState);

实战示例

import { useState } from 'react';

// 示例1:简单计数器(基础状态)
function Counter() {
  // 声明count状态,初始值为0,setCount为更新函数
  const [count, setCount] = useState(0);
  
  // 函数式更新:依赖上一次的count值,避免闭包问题
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  
  return (
    
      当前计数:{count}
      <button onClick={increment}>+1<button onClick={-1
  );
}

// 示例2:对象状态(需注意更新方式)
function UserForm() {
  // 声明对象类型状态
  const [user, setUser] = useState({ name: '', age: 18 });
  
  // 对象更新:必须返回新对象(不能直接修改原对象,React无法检测变化)
  const updateName = (e) => {
    setUser(prev => ({ ...prev, name: e.target.value }));
  };
  
  return (
    <input value={姓名" />
      姓名:{user.name},年龄:{user.age}
  );
}

适用场景

简单响应式状态管理:计数器、开关(布尔值)、输入框值、弹窗显示/隐藏、简单数组/对象状态。

注意事项

  • 状态更新是「替换而非合并」:如果状态是对象/数组,必须返回新引用(使用扩展运算符...mapfilter等),不能直接修改原状态(如user.name = 'xxx'无效)。
  • 初始值仅首次渲染生效:即使初始值是函数,也只会在组件首次挂载时执行一次,后续渲染不会重复执行。
  • 避免闭包陷阱:如果更新状态依赖上一次的状态值(如计数器累加),必须使用「函数式更新」(prev => prev + 1),否则可能获取到旧状态。
  • 状态更新是异步的:React会批量处理状态更新,在同一个事件处理函数中多次调用setState,只会触发一次渲染(如setCount(1); setCount(2);最终只显示2)。

2. useEffect —— 副作用处理(核心)

核心作用

处理函数组件的「副作用」,替代类组件的componentDidMount(挂载)、componentDidUpdate(更新)、componentWillUnmount(卸载)三个生命周期钩子,统一管理副作用逻辑。

常见副作用:数据请求、DOM操作、定时器/计时器、事件监听、订阅(如WebSocket)等。

语法

// setup:副作用函数,执行副作用逻辑
// dependencies:依赖数组,控制setup函数的执行时机(可选)
useEffect(setup, dependencies?);

// setup函数可返回一个「清理函数」,用于组件卸载/依赖变化时清理副作用
useEffect(() => {
  // 副作用逻辑(如请求、定时器)
  return () => {
    // 清理逻辑(如取消请求、清除定时器、移除事件监听)
  };
}, [dependencies]);

执行时机(关键)

  • 不传递依赖数组(useEffect(setup)):每次组件渲染(挂载+更新)都会执行setup函数,慎用(性能较差)。
  • 传递空依赖数组(useEffect(setup, [])):仅在组件挂载时执行一次setup函数,卸载时执行清理函数(对应componentDidMount + componentWillUnmount)。
  • 传递依赖数组(useEffect(setup, [a, b])):组件挂载时执行一次,后续只有当依赖数组中的ab发生变化时,才会执行setup函数(对应componentDidMount + componentDidUpdate),卸载时执行清理函数。

实战示例

import { useState, useEffect } from 'react';

// 示例1:数据请求(挂载时请求,卸载时取消请求)
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 创建AbortController,用于取消请求
    const controller = new AbortController();
    const signal = controller.signal;

    // 异步请求函数
    const fetchUsers = async () => {
      try {
        const res = await fetch('/api/users', { signal });
        const data = await res.json();
        setUsers(data);
      } catch (err) {
        // 忽略取消请求导致的错误
        if (err.name !== 'AbortError') {
          console.error('请求失败:', err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();

    // 清理函数:组件卸载时取消请求
    return () => controller.abort();
  }, []); // 空依赖,仅挂载执行

  if (loading) return 加载中...;
  return (
    
      {users.map(user => (
        <div key={{user.name}
      ))}
  );
}

// 示例2:定时器(挂载时启动,卸载时清除)
function Timer() {
  const [time, setTime] = useState(0);

  useEffect(() => {
    // 启动定时器
    const timer = setInterval(() => {
      setTime(prev => prev + 1);
    }, 1000);

    // 清理函数:卸载时清除定时器(避免内存泄漏)
    return () => clearInterval(timer);
  }, []); // 空依赖,仅挂载执行

  return 当前时间:{time}秒;
}

适用场景

数据请求、DOM操作(如设置文档标题、操作DOM元素)、定时器/计时器、事件监听(如window.addEventListener)、订阅(如WebSocket连接)、清理无用资源(避免内存泄漏)。

注意事项

  • 清理函数的执行时机:组件卸载时、setup函数再次执行前(依赖变化时),必须清理可能导致内存泄漏的资源(定时器、事件监听、WebSocket、未完成的请求)。
  • 依赖数组必须完整:如果setup函数中使用了组件内的变量、状态、函数,必须将其加入依赖数组,否则可能获取到旧值(闭包陷阱);若依赖数组为空,setup函数中不能使用任何组件内的动态值。
  • 避免无限循环:如果setup函数中修改了依赖数组中的值,会导致依赖变化,再次触发setup函数,形成无限循环(如useEffect(() => setCount(prev => prev + 1), [count]))。
  • 副作用函数是异步的:不能直接给setup函数加async(会导致清理函数失效),若需执行异步逻辑,需在setup函数内部定义异步函数并调用。

二、副作用进阶Hooks(18+新增,替代useEffect特定场景)

React 18 新增了两个副作用Hook,专门解决useEffect的部分痛点,针对性更强,在特定场景下更推荐使用。

1. useLayoutEffect —— 同步DOM副作用

核心作用

useEffect功能类似,区别在于「执行时机」:useLayoutEffect在DOM更新后、浏览器绘制前同步执行,useEffect在浏览器绘制后异步执行。适合需要同步操作DOM、避免页面闪烁的场景。

语法

useLayoutEffect(setup, dependencies?);
// 语法与useEffect完全一致,仅执行时机不同

实战示例

import { useLayoutEffect, useState, useRef } from 'react';

// 示例:同步调整DOM位置,避免页面闪烁
function LayoutDemo() {
  const [width, setWidth] = useState(0);
  const ref = useRef(null);

  // useLayoutEffect:DOM更新后立即执行,同步获取DOM尺寸并调整
  useLayoutEffect(() => {
    if (ref.current) {
      // 同步获取DOM宽度
      setWidth(ref.current.offsetWidth);
      // 同步调整DOM位置(避免闪烁)
      ref.current.style.marginLeft = `${(window.innerWidth - width) / 2}px`;
    }
  }, [width]);

  return <div ref={0px', background: 'red' }} />;
}

适用场景

需要同步操作DOM、获取DOM尺寸/位置、调整DOM样式(避免页面闪烁)的场景,如弹窗居中、滚动位置调整、DOM尺寸监听。

注意事项

  • 执行时机:DOM更新后、浏览器绘制前,同步执行,会阻塞浏览器绘制,若逻辑过久会影响页面性能,慎用。
  • 与useEffect的区别:大部分场景用useEffect即可,只有需要同步操作DOM、避免闪烁时,才用useLayoutEffect
  • 服务器端渲染(SSR):useLayoutEffect在服务器端不会执行,若需兼容SSR,优先用useEffect,或在客户端判断后执行。

2. useInsertionEffect —— CSS-in-JS 专用副作用

核心作用

React 18 新增,专门用于 CSS-in-JS 库(如Styled Components、Emotion),在DOM更新前插入CSS样式,避免样式闪烁,是useLayoutEffect的补充。

语法

useInsertionEffect(setup, dependencies?);
// 语法与useEffect、useLayoutEffect一致,执行时机不同

执行时机

DOM更新前执行,早于useLayoutEffect,专门用于插入CSS样式,确保样式在DOM渲染前生效,避免样式闪烁。

适用场景

仅用于 CSS-in-JS 库的样式插入,普通开发场景几乎用不到,日常开发可忽略。

注意事项

  • 不能访问DOM:执行时机在DOM更新前,无法获取DOM元素(如ref.currentnull),仅能插入CSS样式。
  • 非CSS-in-JS不用:普通CSS、Sass/Less等场景,无需使用,用useEffectuseLayoutEffect即可。

三、状态进阶Hooks(复杂状态管理)

当状态逻辑复杂(如依赖多个状态、状态更新依赖其他状态)时,使用这类Hook,简化状态管理逻辑。

1. useReducer —— 复杂状态管理(替代useState)

核心作用

用于管理复杂状态(如状态是对象/数组、状态更新依赖多个因素、多个状态相互关联),通过「 reducer 函数」统一处理状态更新逻辑,类似Redux的核心思想,比useState更适合复杂场景。

语法

// reducer:状态更新函数,接收当前状态和动作,返回新状态
// initialState:初始状态
// init:可选,初始化函数,用于延迟初始化状态(适合昂贵计算)
const [state, dispatch] = useReducer(reducer, initialState, init?);

// reducer函数格式
function reducer(state, action) {
  switch (action.type) {
    case 'TYPE1':
      return newState1; // 根据action返回新状态
    case 'TYPE2':
      return newState2;
    default:
      return state; // 默认返回原状态,避免报错
  }
}

实战示例

import { useReducer, useState } from 'react';

// 1. 定义reducer函数:统一处理状态更新
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      // 添加待办事项,返回新数组(不能修改原数组)
      return [...state, { id: Date.now(), text: action.payload, done: false }];
    case 'TOGGLE_TODO':
      // 切换待办状态
      return state.map(todo => 
        todo.id === action.payload ? { ...todo, done: !todo.done } : todo
      );
    case 'DELETE_TODO':
      // 删除待办事项
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
}

// 2. 使用useReducer管理状态
function TodoList() {
  // 初始状态:空数组
  const [todos, dispatch] = useReducer(todoReducer, []);
  const [text, setText] = useState('');

  // 触发状态更新:通过dispatch发送action
  const addTodo = () => {
    if (!text.trim()) return;
    dispatch({ type: 'ADD_TODO', payload: text }); // payload:传递参数
    setText('');
  };

  return (
    <input value={e) => setText(e.target.value)} />
      <button onClick={}>添加待办
        {todos.map(todo => (
         <li 
            key={one ? 'line-through' : 'none' }}
            onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
          >
            {todo.text}<button onClick={ dispatch({ type: 'DELETE_TODO', payload: todo.id })}>删除
        ))}
      
  );
}

适用场景

复杂状态管理:待办列表、购物车、表单多字段状态、多个状态相互关联(如登录状态+用户信息+权限)。

注意事项

  • reducer必须是纯函数:不能修改原状态(必须返回新状态),不能包含副作用(如请求、DOM操作),仅处理状态更新逻辑。
  • action格式规范:推荐使用{ type: 'XXX', payload: 数据 }的格式,type用大写字符串,便于维护和调试。
  • 与useState的选择:简单状态用useState,复杂状态(多状态关联、多更新逻辑)用useReducer
  • 延迟初始化:如果初始状态需要昂贵计算,可使用第三个参数init函数,仅首次渲染执行一次。

四、引用Hook(获取DOM/保存持久值)

用于获取DOM元素、保存组件渲染周期内的持久值(不随渲染变化),核心是useRef,衍生出useImperativeHandle用于暴露组件内部方法。

1. useRef —— 获取DOM/保存持久值(高频)

核心作用

有两个核心用途:① 获取DOM元素或组件实例;② 保存组件渲染周期内的持久值(不随渲染重新初始化,修改不会触发组件重新渲染)。

语法

// initialValue:初始值,可选(默认undefined)
const ref = useRef(initialValue);

// 获取DOM:通过ref.current访问DOM元素
// 保存持久值:通过ref.current修改/访问,修改不会触发渲染

实战示例

import { useRef, useState, useEffect } from 'react';

// 示例1:获取DOM元素(如输入框聚焦)
function InputFocus() {
  // 创建ref,绑定到输入框
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载时,让输入框自动聚焦(访问ref.current获取DOM)
    inputRef.current?.focus();
  }, []);

  return <input ref={聚焦" />;
}

// 示例2:保存持久值(不触发渲染)
function TimerWithRef() {
  const [time, setTime] = useState(0);
  // 保存定时器ID,持久化存储,不随渲染变化
  const timerRef = useRef(null);

  useEffect(() => {
    // 启动定时器,将ID存入ref
    timerRef.current = setInterval(() => {
      setTime(prev => prev + 1);
    }, 1000);

    // 清理定时器:从ref中获取ID
    return () => clearInterval(timerRef.current);
  }, []);

  // 暂停定时器:修改ref.current,不会触发组件渲染
  const pause = () => {
    clearInterval(timerRef.current);
  };

  return (
    时间:{time}秒<button onClick={暂停 );
}

适用场景

获取DOM元素(输入框聚焦、获取DOM尺寸)、保存定时器ID、保存WebSocket连接、保存组件渲染周期内的临时值(不触发渲染)。

注意事项

  • ref.current修改不触发渲染:与useState不同,修改ref.current的值不会导致组件重新渲染,适合保存不需要触发渲染的临时值。
  • DOM ref的初始化时机:组件挂载完成后,ref.current才会指向DOM元素,挂载前为null,需用?.避免报错。
  • 函数组件不能直接获取实例:如果给函数组件绑定ref,需配合forwardRefuseImperativeHandle,才能暴露组件内部方法/属性。

2. useImperativeHandle —— 暴露组件内部方法(进阶)

核心作用

配合forwardRef使用,让父组件通过ref获取子组件的内部方法/属性,自定义暴露给父组件的内容,避免暴露整个子组件实例,提升封装性。

语法

// ref:父组件传递的ref
// createHandle:函数,返回暴露给父组件的方法/属性对象
// dependencies:依赖数组,依赖变化时重新生成暴露的内容
useImperativeHandle(ref, createHandle, dependencies?);

// 必须配合forwardRef包裹子组件,才能接收父组件传递的ref
const Child = forwardRef((props, ref) => {
  useImperativeHandle(ref, () => ({
    // 暴露给父组件的方法/属性
    method1: () => {},
    property1: ''
  }), []);
  return 子组件;
});

实战示例

import { useRef, forwardRef, useImperativeHandle } from 'react';

// 子组件:配合forwardRef和useImperativeHandle,暴露内部方法
const ChildInput = forwardRef((props, ref) => {
  const inputRef = useRef(null);

  // 暴露给父组件的方法:聚焦输入框、清空输入框
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => inputRef.current.value = ''
  }), []); // 空依赖,暴露的方法不随依赖变化

  return <input ref={" />;
});

// 父组件:通过ref调用子组件暴露的方法
function Parent() {
  const childRef = useRef(null);

  const handleFocus = () => {
    // 调用子组件暴露的focus方法
    childRef.current?.focus();
  };

  const handleClear = () => {
    // 调用子组件暴露的clear方法
    childRef.current?.clear();
  };

  return (
    <ChildInput ref={childRef} />
      <button onClick={聚焦输入框<button onClick={清空输入框
  );
}

适用场景

父组件需要调用子组件内部方法的场景:输入框聚焦/清空、弹窗显示/隐藏、子组件表单提交等。

注意事项

  • 必须配合forwardRef:子组件必须用forwardRef包裹,才能接收父组件传递的ref,否则无法使用useImperativeHandle
  • 避免过度暴露:仅暴露父组件需要的方法/属性,不要暴露整个子组件的内部状态,保持子组件的封装性。
  • 依赖数组:如果暴露的方法依赖子组件的状态/ props,必须将其加入依赖数组,否则可能获取到旧值。

五、上下文Hook(跨组件状态共享)

用于跨组件共享状态(无需逐层传递props),核心是useContext,配合createContext使用,替代类组件的Context API

1. useContext —— 跨组件状态共享(高频)

核心作用

接收一个Context对象(由createContext创建),获取Context中共享的状态和方法,实现跨组件(父子、祖孙、兄弟)状态共享,无需逐层传递props(解决props drilling问题)。

语法

// 1. 创建Context对象(可设置默认值,可选)
const MyContext = createContext(defaultValue);

// 2. 用Provider包裹组件树,提供共享状态
<MyContext.Provider value={共享的状态/方法}>
  子组件树
</MyContext.Provider>

// 3. 在子组件中使用useContext获取共享内容
const context = useContext(MyContext);

实战示例

import { createContext, useContext, useState } from 'react';

// 1. 创建Context(主题上下文,默认值为light)
const ThemeContext = createContext('light');

// 2. 顶层组件:提供共享状态(主题切换)
function App() {
  const [theme, setTheme] = useState('light');

  // 切换主题的方法
  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  // 用Provider包裹子组件,传递共享的theme和toggleTheme
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <div style={light' ? '#fff' : '#333',
        color: theme === 'light' ? '#333' : '#fff',
        minHeight: '100vh'
      }}>
        <Header />
        <Content />
      </ThemeContext.Provider>
  );
}

// 3. 子组件Header:使用useContext获取共享主题
function Header() {
  const { theme } = useContext(ThemeContext);
  return 当前主题:{theme};
}

// 4. 子组件Content:使用useContext获取共享方法,切换主题
function Content() {
  const { toggleTheme } = useContext(ThemeContext);
  return <button onClick={切换主题;
}

适用场景

跨组件状态共享:主题切换、用户登录状态、语言切换、全局配置(如接口基础地址)等。

注意事项

  • 必须有Provider包裹:如果子组件使用useContext,但没有被对应的Context.Provider包裹,会获取到Context的默认值(注意:默认值仅在没有Provider时生效,不是 fallback 值)。
  • 状态更新会触发所有使用该Context的组件重新渲染:如果共享状态频繁更新,可能导致性能问题,可配合useMemo优化。
  • Context嵌套:多个Context可以嵌套使用,子组件会获取最近的Provider提供的值。
  • TypeScript适配:创建Context时,建议指定类型,避免类型错误(如createContext<{ theme: string; toggleTheme: () => void } | undefined>(undefined))。

六、性能优化Hooks(避免不必要渲染)

React组件默认会在父组件渲染时重新渲染,这类Hook用于优化性能,避免不必要的渲染,提升组件运行效率。

1. useMemo —— 缓存计算结果(高频)

核心作用

缓存「昂贵计算的结果」,仅当依赖数组中的值发生变化时,才重新计算结果;否则直接返回缓存的结果,避免每次渲染都重复执行昂贵计算,提升性能。

语法

// compute:需要缓存结果的计算函数(返回计算结果)
// dependencies:依赖数组,仅当依赖变化时,重新执行compute
const memoizedValue = useMemo(compute, dependencies);

实战示例

import { useMemo, useState } from 'react';

// 示例:缓存昂贵计算结果(如数组排序、过滤)
function ExpensiveCompute() {
  const [list, setList] = useState([3, 1, 4, 1, 5, 9, 2, 6]);
  const [count, setCount] = useState(0);

  // 昂贵计算:数组排序(假设数组很大,排序耗时)
  // useMemo缓存排序结果,仅当list变化时重新排序
  const sortedList = useMemo(() => {
    console.log('重新排序'); // 仅list变化时打印
    return [...list].sort((a, b) => a - b);
  }, [list]); // 依赖list,count变化不重新计算

  return (
   排序后:{sortedList.join(',')}<button onClick={ setCount(prev => prev + 1)}>计数:{count}<button onClick={ setList([...list, Math.random()])}>添加元素
  );
}

适用场景

有昂贵计算的场景:数组排序、过滤、复杂数据处理、大量DOM节点渲染前的计算等。

注意事项

  • 不要过度使用:简单计算(如a + b)无需使用useMemo,缓存本身也有性能开销,仅用于昂贵计算。
  • 依赖数组必须完整:compute函数中使用的所有变量、状态、props,都必须加入依赖数组,否则可能返回缓存的旧结果。
  • 返回值是缓存的结果:useMemo返回的是计算结果,不是函数,与useCallback(缓存函数)区分开。

2. useCallback —— 缓存函数(高频)

核心作用

缓存函数引用,仅当依赖数组中的值发生变化时,才重新创建函数;否则直接返回缓存的函数引用,避免因函数引用变化导致子组件不必要的重新渲染(尤其配合React.memo使用)。

语法

// callback:需要缓存的函数
// dependencies:依赖数组,仅当依赖变化时,重新创建函数
const memoizedCallback = useCallback(callback, dependencies);

实战示例

import { useCallback, useState, memo } from 'react';

// 子组件:用memo包裹,避免不必要的渲染(仅props变化时渲染)
const Child = memo(({ onClick, name }) => {
  console.log('子组件渲染'); // 仅onClick或name变化时打印
  return <button onClick={{name};
});

// 父组件:用useCallback缓存函数,避免子组件频繁渲染
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('按钮');

  // 缓存onClick函数,仅当name变化时重新创建函数
  const handleClick = useCallback(() => {
    console.log('点击按钮:', name);
  }, [name]); // 依赖name,count变化不重新创建函数

  return (
    <Child onClick={handleClick} name={name} />
      <button onClick={ setCount(prev => prev + 1)}>计数:{count}<button onClick={ setName('新按钮')}>修改按钮名称
  );
}

适用场景

父组件向子组件传递函数,且子组件用React.memo包裹的场景:避免因父组件渲染导致函数引用变化,进而触发子组件不必要的渲染。

注意事项

  • 配合React.memo使用:useCallback本身不会阻止父组件渲染,需配合React.memo(包裹子组件),才能避免子组件不必要的渲染。
  • 依赖数组必须完整:缓存的函数中使用的所有变量、状态、props,都必须加入依赖数组,否则可能获取到旧值(闭包陷阱)。
  • 与useMemo的区别:useCallback缓存函数引用,useMemo缓存计算结果;useCallback(fn, deps) 等价于 useMemo(() => fn, deps)

3. useMemoizedFn —— 缓存函数(无依赖,18+新增)

核心作用

React 18 新增(需导入react-dom),用于缓存函数引用,无需依赖数组,函数内部能获取到最新的组件状态/ props,避免闭包陷阱,比useCallback更简洁。

语法

import { useMemoizedFn } from 'react-dom';

// callback:需要缓存的函数,无需依赖数组
const memoizedFn = useMemoizedFn(callback);

实战示例

import { useState, memo } from 'react';
import { useMemoizedFn } from 'react-dom';

const Child = memo(({ onClick }) => {
  console.log('子组件渲染');
  return <button onClick={点击;
});

function Parent() {
  const [count, setCount] = useState(0);

  // 无需依赖数组,函数内部能获取到最新的count
  const handleClick = useMemoizedFn(() => {
    console.log('当前计数:', count); // 始终获取最新count
  });

  return (
    <Child onClick={handleClick} />
     <button onClick={ setCount(prev => prev + 1)}>计数:{count}
  );
}

适用场景

需要缓存函数,且函数内部依赖组件状态/ props,不想手动维护依赖数组的场景(简化useCallback的使用)。

注意事项

  • 无需依赖数组:函数内部能自动获取最新的组件状态/ props,无需手动添加依赖,避免遗漏依赖导致的闭包陷阱。
  • 需导入react-dom:useMemoizedFn不是从react导入,而是从react-dom导入,注意导入路径。
  • React版本要求:仅支持React 18+,低版本React无法使用。

七、并发模式Hooks(React 18+ 新增,并发渲染)

React 18 引入并发渲染机制,这类Hook用于配合并发模式,控制组件的渲染优先级、中断/恢复渲染,提升用户体验。

1. useTransition —— 低优先级更新(高频)

核心作用

将某些状态更新标记为「低优先级」,让高优先级更新(如输入框输入、按钮点击)优先执行,避免低优先级更新(如大量数据渲染)阻塞UI,提升用户体验。

语法

// startTransition:函数,包裹低优先级更新逻辑
// isPending:布尔值,标记低优先级更新是否正在进行(可用于显示加载状态)
const [isPending, startTransition] = useTransition();

// 使用:将低优先级更新逻辑放入startTransition
startTransition(() => {
  setLowPriorityState(newValue);
});

实战示例

import { useTransition, useState } from 'react';

// 示例:输入框搜索(高优先级)+ 搜索结果渲染(低优先级)
function Search() {
  const [searchText, setSearchText] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  // 输入框变化:高优先级更新,优先执行,不阻塞输入
  const handleInputChange = (e) => {
    const value = e.target.value;
    setSearchText(value);

    // 搜索结果渲染:低优先级更新,在高优先级更新完成后执行
    startTransition(() => {
      // 模拟昂贵的搜索逻辑(如过滤大量数据)
      const filteredResults = Array(10000).fill(0).filter((_, index) => 
        index.toString().includes(value)
      );
      setResults(filteredResults);
    });
  };

  return (
    <input 
        type="text" 
        value={ 
        onChange={handleInputChange} 
        placeholder="搜索"
      />
      {/* 低优先级更新正在进行时,显示加载状态 */}
      {isPending && 加载中...}
      
        {results.map((item, index) => (
          <li key={{index}
        ))}
     
  );
}

适用场景

高优先级操作与低优先级操作并存的场景:输入框搜索、下拉框筛选、大量数据渲染、路由切换前的准备工作等。

注意事项

  • 低优先级更新可被中断:如果有更高优先级的更新(如输入框输入),低优先级更新会被中断,等高优先级更新完成后再恢复,避免阻塞UI。
  • 不能用于紧急更新:如表单提交、按钮点击后的状态更新(高优先级),不能用useTransition,否则会导致更新延迟。
  • isPending状态:用于显示低优先级更新的加载状态,提升用户体验,避免用户误以为页面卡死。

2. useDeferredValue —— 延迟更新值(进阶)

核心作用

useTransition类似,用于延迟更新某个值,将其标记为低优先级,优先保证高优先级更新的响应速度;区别在于useDeferredValue是“延迟值”,useTransition是“延迟更新逻辑”。

语法

// value:需要延迟的原始值
// options:可选配置,如timeoutMs(延迟时间)
const deferredValue = useDeferredValue(value, options?);

实战示例

import { useDeferredValue, useState } from 'react';

// 示例:输入框(高优先级)+ 延迟显示输入值(低优先级)
function DeferredDemo() {
  const [inputValue, setInputValue] = useState('');
  // 延迟更新inputValue,优先保证输入框响应
  const deferredInput = useDeferredValue(inputValue);

  return (
    <input 
        type="text" 
        value={        onChange={(e) => setInputValue(e.target.value)}
        placeholder="输入内容"
      />
      {/* 延迟显示输入值,避免输入时卡顿 */}
      延迟显示:{deferredInput}
  );
}

适用场景

需要延迟显示某个值,优先保证高优先级操作的场景:输入框实时显示、大量数据渲染时的延迟更新等,与useTransition互补。

注意事项

  • 与useTransition的区别:useDeferredValue是延迟“值”,useTransition是延迟“更新逻辑”;useDeferredValue更适合简单的“值延迟”场景,useTransition更适合复杂的“更新逻辑延迟”场景。
  • 延迟值是只读的:deferredValue是延迟后的只读值,不能直接修改,修改需通过原始值(如inputValue)。

八、工具类Hooks(辅助开发,低频但实用)

这类Hooks用于辅助开发,解决特定场景的问题,使用频率较低,但需要时能大幅简化代码。

1. useDebugValue —— 自定义Hook调试(调试用)

核心作用

用于自定义Hook中,在React开发者工具中显示自定义Hook的调试信息,方便调试,不影响组件功能。

语法

// label:调试信息(字符串或函数,函数返回调试信息)
useDebugValue(label);

// 示例:自定义Hook中使用
function useCustomHook() {
  // 自定义Hook逻辑
  useDebugValue('自定义Hook调试信息');
  return ...;
}

适用场景

开发自定义Hook时,用于调试,方便在React开发者工具中查看自定义Hook的状态和信息。

注意事项

  • 仅用于调试:生产环境中,useDebugValue会被自动移除,不会影响性能。
  • 适合复杂自定义Hook:简单自定义Hook无需使用,复杂自定义Hook(多状态、多逻辑)使用,提升调试效率。

2. useId —— 生成唯一ID(高频)

核心作用

生成跨渲染、跨组件的唯一ID,用于表单标签关联(labelfor属性与inputid属性)、 accessibility(无障碍)开发,避免ID重复。

语法

// 生成唯一ID
const id = useId();

// 生成带前缀的唯一ID(多个ID关联时使用)
const id = useId();
const inputId = `${id}-input`;
const labelId = `${id}-label`;

实战示例

import { useId } from 'react';

// 示例1:表单标签关联,生成唯一ID
function FormInput() {
  // 生成唯一ID
  const id = useId();

  return (

      {/* label

ufw Command in Linux: Uncomplicated Firewall Reference

If your server is open to the public network, it needs a firewall. On Ubuntu and Debian, the easiest way to set one up is with ufw. This tool sits on top of iptables (or nftables on newer systems) and replaces complex rules with simple, easy-to-read commands.

This guide walks through the ufw command, from enabling the firewall and setting default policies to writing rules for ports, services, and specific addresses.

ufw Syntax

The general form of the command is:

txt
sudo ufw [OPTIONS] COMMAND [ARGS]
  • OPTIONS - Flags such as --dry-run to preview what a rule would do.
  • COMMAND - The action, for example enable, allow, deny, delete, or status.
  • ARGS - The rule body, such as a port number, a service name, or a full rule specification.

ufw changes firewall rules, so almost every invocation needs sudo.

Checking the Firewall Status

Before you touch the rules, check whether the firewall is running and what is already in place:

Terminal
sudo ufw status

If ufw is inactive, you will see:

output
Status: inactive

This confirms that ufw is installed but not currently enforcing any firewall rules.

Once active, the same command lists the rules:

output
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
22/tcp (v6) ALLOW Anywhere (v6)
80/tcp (v6) ALLOW Anywhere (v6)

For a numbered view that is easier to reference when deleting rules, use:

Terminal
sudo ufw status numbered

And for a more detailed output that shows default policies and logging level:

Terminal
sudo ufw status verbose

Enabling and Disabling the Firewall

The first time you enable ufw, make sure you have already allowed SSH. Enabling the firewall over an SSH session without an allow ssh rule will lock you out of the server.

Allow SSH first:

Terminal
sudo ufw allow ssh

Then turn the firewall on:

Terminal
sudo ufw enable
output
Firewall is active and enabled on system startup

At this point, ufw starts enforcing the rules immediately and will come back automatically after a reboot.

To stop the firewall and clear it from the startup sequence:

Terminal
sudo ufw disable

A disabled ufw keeps the rules you defined. When you re-enable it, the same rules come back.

Default Policies

Default policies decide what happens to traffic that does not match any rule. The usual hardening is to deny incoming traffic and allow outgoing traffic:

Terminal
sudo ufw default deny incoming
sudo ufw default allow outgoing

After running these two commands, you only need to open the specific inbound ports your server should expose. Outbound traffic still flows freely, which is what most servers need.

You can also set the default for forwarded traffic, which matters if the host acts as a router or runs containers:

Terminal
sudo ufw default deny routed

Allowing Connections

The allow command is the one you will use most often. It accepts a service name, a port, a protocol, or a full rule.

Allow a Service by Name

ufw reads /etc/services and understands common service names:

Terminal
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https

Each command opens the matching port (22, 80, and 443) for both TCP and UDP where applicable.

Allow a Specific Port

To open a port directly, pass the port number:

Terminal
sudo ufw allow 8080

By default, this opens the port for both TCP and UDP. To limit it to one protocol, add /tcp or /udp:

Terminal
sudo ufw allow 8080/tcp
sudo ufw allow 53/udp

Allow a Port Range

Port ranges require an explicit protocol:

Terminal
sudo ufw allow 6000:6007/tcp
sudo ufw allow 6000:6007/udp

The colon separates the start and the end of the range, both inclusive.

Allow Traffic From a Specific Address

To accept connections from a single IP address, use from:

Terminal
sudo ufw allow from 203.0.113.25

To restrict that to a single service, add to any port:

Terminal
sudo ufw allow from 203.0.113.25 to any port 22

Allow Traffic From a Subnet

CIDR notation works the same way for whole networks:

Terminal
sudo ufw allow from 192.168.1.0/24 to any port 3306

This is a common pattern for databases: the port stays closed to the internet and is only reachable from your internal network.

Allow Traffic on a Specific Interface

To tie a rule to a network interface, add on INTERFACE:

Terminal
sudo ufw allow in on eth1 to any port 3306

The in keyword applies the rule to inbound traffic on eth1. Use out for outbound traffic.

Denying Connections

The deny command is the mirror of allow and takes the same arguments:

Terminal
sudo ufw deny 23
sudo ufw deny from 198.51.100.77

When the default incoming policy is already deny, you usually do not need to write explicit deny rules for closed ports. Explicit denies are useful when you want to block a specific address while keeping a port open to everyone else.

To log the denied packets instead of silently dropping them, use reject:

Terminal
sudo ufw reject 23

A reject sends an ICMP message back to the sender, while a deny drops the packet with no response.

Rate Limiting SSH

ufw can throttle repeated connections from the same IP, which is handy for SSH brute-force protection:

Terminal
sudo ufw limit ssh

The limit rule denies a connection if the source address attempts six or more connections in 30 seconds. This is a quick way to slow down password-guessing bots without installing a full intrusion-prevention tool.

Deleting Rules

There are two common ways to remove a rule. The first is to repeat the rule with delete in front:

Terminal
sudo ufw delete allow 8080/tcp

The second is to delete by rule number:

Terminal
sudo ufw status numbered
output
 To Action From
-- ------ ----
[ 1] 22/tcp ALLOW IN Anywhere
[ 2] 80/tcp ALLOW IN Anywhere
[ 3] 8080/tcp ALLOW IN Anywhere
Terminal
sudo ufw delete 3

ufw asks for confirmation before removing the rule. Keep in mind that after a deletion, the numbering shifts for the remaining rules, so always check the numbered status again before deleting another rule.

For a deeper walkthrough, see how to list and delete UFW firewall rules .

Application Profiles

Services that come with their own ufw profile can be referenced by name. List them with:

Terminal
sudo ufw app list
output
Available applications:
Nginx Full
Nginx HTTP
Nginx HTTPS
OpenSSH

To open the ports that a profile covers, pass its name to allow:

Terminal
sudo ufw allow 'Nginx Full'

Quote profile names that contain spaces. To see what ports a profile includes, use:

Terminal
sudo ufw app info 'Nginx Full'

Dry Run

Before you commit to a change, you can preview the iptables rules ufw would add with --dry-run:

Terminal
sudo ufw --dry-run allow 8080/tcp

No rule is created. The output shows the exact lines ufw would write, which is useful when you are writing rules over SSH and want to double-check them first.

Logging

ufw can log packets that match rules, which helps when troubleshooting. The log level can be adjusted:

Terminal
sudo ufw logging on
sudo ufw logging medium

Valid levels are off, low, medium, high, and full. ufw logs through the kernel log facility, so the exact destination depends on your system logging setup. On many Ubuntu systems with rsyslog, you will see entries in /var/log/ufw.log, and you can often inspect them with journalctl as well.

Reset the Firewall

To clear every rule and set ufw back to its fresh state, run:

Terminal
sudo ufw reset
Warning
ufw reset disables the firewall and removes every rule, including the one that allows SSH. If you are connected over SSH, add an allow rule for SSH and re-enable the firewall right after the reset, or you will lose access the moment you turn it back on.

IPv6 Support

On most modern systems, ufw can manage IPv6 rules as well as IPv4 rules. Check the setting in /etc/default/ufw:

/etc/default/ufwsh
IPV6=yes

When IPV6=yes, generic rules such as sudo ufw allow 22/tcp are created for both IPv4 and IPv6. Rules that include an explicit address stay specific to that address family. In the status output, the IPv6 rules appear with (v6) next to them.

Quick Reference

For a printable quick reference, see the ufw cheatsheet .

Task Command
Show status sudo ufw status verbose
Show numbered rules sudo ufw status numbered
Enable firewall sudo ufw enable
Disable firewall sudo ufw disable
Default deny incoming sudo ufw default deny incoming
Default allow outgoing sudo ufw default allow outgoing
Allow SSH sudo ufw allow ssh
Allow port sudo ufw allow 8080/tcp
Allow port range sudo ufw allow 6000:6007/tcp
Allow from IP sudo ufw allow from 203.0.113.25
Allow from subnet sudo ufw allow from 192.168.1.0/24
Rate-limit SSH sudo ufw limit ssh
Delete rule by number sudo ufw delete 3
List app profiles sudo ufw app list
Reset all rules sudo ufw reset

Troubleshooting

ufw: command not found
Install the ufw package with sudo apt install ufw on Ubuntu or Debian. On Fedora, RHEL, and derivatives, firewalld is the default and ufw is rarely used.

Locked out after enabling the firewall over SSH
The default deny policy blocked your SSH session. You will need console access to the server. Once in, run sudo ufw allow ssh and sudo ufw reload to restore access. The usual safeguard is to allow SSH before the first enable.

Rule added but traffic still blocked
Confirm the rule is in the right direction (inbound vs outbound) and on the right interface. Check with sudo ufw status verbose. If Docker is running, it writes its own iptables rules that can bypass ufw.

Changes do not take effect
After manual edits to /etc/ufw/ files, reload the firewall with sudo ufw reload. Commands issued through ufw apply immediately and do not need a reload.

FAQ

Is ufw the same as iptables?
No. ufw is a front end that generates iptables (or nftables) rules for you. The kernel still enforces the rules through its packet filter, but you do not need to edit the raw tables yourself.

Does ufw start automatically after reboot?
Yes, once you run sudo ufw enable, the service is registered with systemd and starts on boot. You can confirm with systemctl status ufw.

How do I allow a port for a single IP address?
Use the from and to any port form: sudo ufw allow from 203.0.113.25 to any port 22. This keeps the port closed to the rest of the internet and only opens it for that one address.

What is the difference between deny and reject?
deny drops the packet without any reply. reject sends an ICMP message back to the sender telling them the connection was refused. Reject is slightly friendlier to well-behaved clients but makes the server more visible to scans.

How do I undo everything and start over?
Run sudo ufw reset. It disables the firewall and removes every rule. Remember to re-add the SSH rule before re-enabling, especially on a remote server.

Conclusion

ufw keeps firewall management readable: allow what you need, deny what you do not, and lean on the default policies to cover the rest. If you want step-by-step instructions for setting it up on a new server, see how to set up a firewall with UFW on Ubuntu 24.04 .

马斯克版微信最大的看点,和微信无关


距离马斯克的「超级应用梦」落地,只剩最后三天。4 月 17 日,XChat 预计正式登陆苹果 App Store,全球同步开放下载。

这绝对不是一次普通的 App 上架。在马斯克那张疯狂且庞大的商业战略棋盘上,XChat 是他豪掷 440 亿美元买下 Twitter、将其暴力更名为 X 之后,又一枚核心、也最不容有失的落子。

他对这一天的期待,可以追溯到 2022 年。

收购 Twitter 之后,马斯克几乎在每个公开场合都会提到微信。他说:「在中国,你基本上是生活在微信里的,因为它对日常生活如此有用。如果我们能在 X 上实现这一点,哪怕只是接近,那都将是巨大的成功。」

马斯克痴迷的不是微信的聊天界面,是它作为数字生活操作系统的地位,支付、通讯、打车、外卖、水电费,全在一个 App 里。如果说收购 Twitter 是拿到了超级应用这场赌局的入场券,那么 XChat,就是他在牌桌上打出的第一张明牌。

顶着马斯克版微信的噱头,XChat 却活成了 Telegram 的模样?

从功能上看,XChat 主打的是隐私优先的独立聊天应用。

注册不需要手机号,直接用 X 账号登录,消息支持阅后即焚、撤回和编辑,群聊最多可容纳 481 人,文件传输上限高达 4GB,跨设备音视频通话全部内置,下载需要 iOS 26.0 或以上版本。

应用层面禁止截图和录屏,试图从源头堵住内容泄露的漏洞,这可能是一些科技圈老板最喜欢的功能,Grok AI 被直接嵌入聊天界面,可以在对话里随时调用,用于总结内容、实时翻译或规划行程。

XChat 的整体定位走的是干净、私密、少打扰的路线,界面剥离了 X 主应用里的信息流、广告和热搜,专门为私密对话留出空间。首发支持 46 种语言,包括简体中文和繁体中文。

带着马斯克极其鲜明的个人烙印,XChat 不仅在定位上大刀阔斧,其项目推进速度更是快得惊人,甚至透出几分激进与狂热。

去年 6 月,马斯克才在 X 上公开预告;到了 12 月,X 员工 Nikita Bier 就已经开始公开为其站台,惊叹团队「在短短三个月内完成加密私信迁移」,并顺脚踩了一下同行:「Facebook 花了三年时间才做到这一点。」

今年 3 月,iOS 版 TestFlight 测试名额开放,先是 1000 人,很快扩到 5000 人,名额在公告发出后短短两小时内被抢光但伴随高关注度而来的,是极其两极分化的口碑。

3 月就拿到 TestFlight 资格的用户 @Nicole_yang88 写道:「整体流畅度非常高,几乎没有卡顿感。界面走的是极简路线,层级清晰、配色克制,观感上确实有点接近 iMessage 的那种干净风格。」她还特别提到,与 X 主应用一键授权登录、账号数据无缝衔接,「完全没有切换应用的割裂感」。

但也有人完全不买账。

测试用户 @ohxiyu 发文:「打开一看,跟 X 私信像素级一样,那为什么要独立出来?私信、请求、骚扰全混在一起,跟现在的 DM 没区别。想找某个人聊天?没有联系人列表,只能翻聊天记录搜。」

更让人摸不着头脑的是私密模式的设计,对方开了阅后即焚,你这边完全没有提示,内容过一会儿就消失了。他说:「Telegram 好歹还弹个通知告诉你。连个菜单都没有。感觉就是把 DM 页面套了个壳扔出来了。」

甚至 XChat 还没正式开放下载,麻烦已经来了。

4 月 11 日预约开放当天,就有用户发出警告:App Store 里同期出现了一款俄语版 XChat,图标和名字与真品高度相似,下载后会要求用户提供信用卡信息和 ID 证明年龄。

▲ 右边才是正版,安全下载,目前唯一可信的路径是通过苹果海外版 App Store 官方搜索,认准开发商为 X Corp。🔗 https://apps.apple.com/us/app/xchat/id6760873038

博主 @Imlaomao 亲身中招:「不小心输入信用卡信息后,觉得不对,立刻把信用卡都注销了。」他虽然表示没有直接证据证明该 App 一定存在问题,但建议大家「安全第一,小心为好」。

一款把安全隐私刻在脑门上的应用,在发布首日就得靠用户自己去甄别李逵和李鬼。这个充满戏剧性的开局,很难说不是对 XChat 未来命运的一个隐喻。

所谓「比特币级加密」,只是文字游戏?

在 XChat 的所有宣传话术里,「比特币式加密(Bitcoin-style encryption)」无疑是最抓眼球的字眼。深谙流量密码的马斯克,用这个偏极客词汇,成功让无数人脑补出了一幅赛博朋克式的画面:聊天记录上链、去中心化存储。

理想很丰满,现实很骨感。

根据英伟达安全开发人员 Matthew Garrett 对 XChat 早期版本的技术分析,XChat 的消息加密层采用了 libsodium 的 box 加密方案。这套方案本身经过广泛审计,算得上扎实。但有一点马斯克没有说清楚:libsodium 的核心是 C 语言写的,X 调用的正是 C 语言版本,并非他对外宣称的「全新 Rust 架构」。

密钥管理方面,XChat 采用了开源协议 Juicebox——这套协议有独立白皮书,并非 X 自研。它的设计思路是:将你的私钥加密后分片,存储在 X 公司控制的多台服务器上。换新设备时,你输入一个 4 位数 PIN 码,系统从服务器检索分片、重组密钥,聊天记录全部恢复。

🔗 https://mjg59.dreamwidth.org/71646.html?403a723f\_page=0

问题在于,X 目前使用的三个后端域名均在 x.com 之下,推测均由 Twitter 直接控制。Juicebox 协议本身支持引入独立第三方后端以分散信任,但 Garrett 在分析时未发现 X 有这方面的实质部署。

更致命的一点在于,XChat 的协议缺乏「前向保密性(Forward Secrecy)」。这意味着,如果某一天你的静态密钥被攻破,无论是设备被盗、密钥被收缴,还是服务器端组装解密,你过去所有的聊天记录都会在瞬间全部可读。

Signal 的「Double Ratchet」算法可以确保即使一次通讯密钥泄露,历史记录依然安全。XChat 没有这个机制。

此外,通过查询苹果 App Store 官方披露的隐私标签,网友发现 XChat 保留了收集并与用户身份关联的数据权利,涵盖联系人信息、通讯内容、使用数据、诊断数据以及用户 ID。与此对照的是,Signal 仅收集注册必需的极少量联系人信息,且从不与个体身份关联。

更深的问题在于元数据。XChat 可能加密了你发送的文字和图片本身,但 X 平台在后台完整记录的是:你在和谁聊、聊的频率、最活跃的时间段、传输文件的大小。

在当代数据经济里,元数据的商业价值往往高于内容本身。这些行为轨迹可以反哺 X 主站的广告引擎,也是训练 Grok AI 的绝佳语料。简言之,聊天内容加密、行为数据裸奔,成了 XChat 最大的隐私悖论。

醉翁之意不在酒,马斯克的超级应用野心

理解 XChat 的野心,得先理解马斯克真正想做什么。他如此大费周章,想要的绝对不是一个仅仅用来聊天的工具,而是一个让用户把日常生活都装进去的「超级应用」,既是你和朋友说话的地方,也是你转账、买东西的地方。

按照这个逻辑,XChat 只是第一步。它要和即将上线的 X Money 支付系统深度绑定,让用户在发消息的同时就能完成跨国汇款和日常转账,把「社交+支付」的商业闭环彻底打通。

不过,障碍在于监管。

美国没有统一的联邦金融汇款牌照,必须在五十个州逐一申请。截至 2026 年初,X Payments LLC 已拿下超 40 个州及华盛顿特区的许可,但北美金融的心脏纽约州,依然对马斯克紧闭大门。

🔗 https://money-support.x.com/en/licenses

美国纽约州参议员 Brad Hoylman-Sigal 和众议员 Micah Lasher 曾联名向纽约金融服务局递交公开信,措辞严厉,要求拒绝向 X 发放牌照,理由是马斯克「行为严重缺乏品格与一般适合性」。

对于一个志在全美乃至全球的支付网络来说,丢掉纽约州,XChat 内的支付网络就无法覆盖全美最有消费力的人群,更何况,西方用户本就对「把所有鸡蛋放进一个篮子」这件事天然抵触,支付功能再打折扣,这个故事就更难讲下去了。

种种受挫的现实固然让人对「超级应用」的说辞产生怀疑,但只要看透他底层的逻辑,眼下的一切就变得合理起来。

抛开那些关于阅后即焚、加密隐私的极客噱头,目前关于 XChat 最具想象力的传闻,是它将如何与自家的 AI 大模型 Grok 融合。

虽然我们还没法实际上手验证,但如果顺着这个思路展开推演,你会发现,马斯克真正想颠覆的,根本不是聊天体验,而是人机交互的底层逻辑,也就是在 AI 时代做一个超级应用,那应该是什么样子?

微信的超级应用模式可以概括为「入口聚合」:一个 App 把出行、外卖、支付、社保、健康全部塞进来,用户在一个界面里跳转不同的服务。这个模式基本定义了过去十年中国互联网的产品范式。但它的底层逻辑始终是「你来找服务」。你知道你要打车,你点进滴滴的小程序;你知道你要付款,你打开微信支付。

只是,入口聚合,是 App 时代的超级应用答案。AI 调度,可能才是 AI 时代超级应用的版本答案。与其把一百个功能塞给用户,不如让一个 AI 替用户搞定一切。

当然,从目前的爆料信息来看,XChat 离这个愿景还差得远,没有丰富的服务生态做支撑,Grok 就算再聪明,也只能在聊天框里做做翻译和文字总结的苦力活。马斯克的答卷也许潦草、充满争议,但他已经开始交卷了。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

手机控制 AI 编程?Paseo 让你随时随地跑 Claude Code / Codex

原来 AI 编程工具不一定要坐在电脑前才能用……

先说一个让我有点意外的场景

上个月地铁上突然想到一个 bug,以前只能记在备忘录里等回家。那天试了一下 Paseo,掏出手机连上家里的服务器,用语音说了一句话,Claude Code 就跑起来了。到家的时候 PR 已经在等我了。

我花了点时间才接受这件事:AI 在跑,我在坐地铁。

这就是今天要聊的工具,Paseo

它是什么

官网:paseo.sh

一句话:Paseo 是一个统一的界面,让你同时管理 Claude Code、Codex、OpenCode 这几个 AI Agent,并且可以从手机、平板、任意设备远程控制它们。

架构很简单:

  • 你的电脑或服务器上跑一个 Paseo Daemon(后台进程)
  • 手机装 Paseo App
  • 两端通过加密通道连接
  • 手机发指令,电脑上的 Agent 跑,结果实时推回手机

完全免费,完全开源。代码不出你自己的机器。

image.png

它解决了什么问题

先说清楚这个,你才知道要不要继续看。

AI 编程工具只能在电脑前用。

Claude Code 很能干,但要打开终端才能用。出门、开会、躺着,想法有了但没法动。Paseo 解决的就是这个:算力留在服务器上,指令从手机发出去。

切换不同 Agent 的成本。

想用 Claude Code 跑一个,想试试 Codex 看看输出有没有区别,以前得开两个终端窗口分别管。Paseo 在一个界面里统一管,可以切换,可以对比,也可以让它们配合着干一件事(后面细说)。

语音输入基本不可用。

大多数语音转文字靠云端 API,你说的话要上传到别人服务器。在代码场景里这个感觉很别扭,尤其是还没开源的项目。Paseo 的语音识别完全跑在本地,数据不出局域网。

多 Agent 协作没有现成的编排工具。

写代码、审查、跑测试,如果想让三个 Agent 串联着干,以前得自己想办法。Paseo 有内置的 Skills 体系处理这个,后面会举例。

10 分钟装好

安装 Paseo

新手直接下桌面客户端,去 paseo.sh/download 下对应系统的包,自带 Daemon 管理,装完就能用。

服务器或者无界面环境用 CLI:

npm install -g @getpaseo/cli
paseo

# 如果需要重启镜像,使用:
paseo daemon restart

跑起来后终端会打一个二维码。

如果终端不小心关掉了,可以去软件中扫码

image.png

手机 App

App Store 或 Google Play 搜 Paseo 安装,扫二维码完成配对。

接上你的 AI Agent

Paseo 本身没有 AI 能力,它只是个壳。至少装一个 Agent:

  • Claude Code:npm install -g @anthropic-ai/claude-code
  • Codex:npm install -g @openai/codex
  • OpenCode:npm install -g opencode-ai

每个 Agent 的 API Key 自己配好,Paseo 不碰这些凭证。

配对成功后在手机 App 里选个 Agent 发条消息就能用了。

还有个体验真的惊艳到我了,就是可以查看文件内容或者文件diff。

test.gif

用起来真正有意思的地方

安全问题怎么解决的

"手机远程控制服务器"——这是大多数人第一个疑问。

连接走端到端加密中继,ECDH 密钥交换加 AES-256-GCM 加密。中继服务器只能看到 IP 和流量大小,内容它看不到,消息也伪造不了。你的代码、API Key、对话内容都在本地。

觉得还不够放心的话,直接走 Tailscale VPN 或自建隧道,完全绕过中继,局域网直连。这条路也是支持的。

语音功能

这是我没想到会做得这么认真的部分。

image.png

默认跑两个本地模型:语音识别用 parakeet-tdt-0.6b-v3-int8,语音合成用 kokoro-en-v0_19。Daemon 第一次启动会自动下载到 $PASEO_HOME/models/local-speech,之后离线运行,不走任何网络。

如果想要更准的识别,配置里换成 OpenAI Whisper API 改一行就好。

实际用起来什么感觉?坦白说,一开始以为是噱头。用了一周发现:描述性的需求适合语音("帮我把这个组件改成暗色模式"),涉及变量名和具体代码还是手打。两者混着用比较顺手。中文识别目前不算完美,英文更流畅一些。

多 Agent 协作的 Skills

这是 Paseo 上限比较高的地方,新手可以先跳过,等用顺手了再来看。

内置了五个编排 Skill:

Skill 干什么
/paseo-handoff 任务交接,把进度、已试方案、决策记录打包传给另一个 Agent
/paseo-loop 一个 Agent 执行,另一个验证,循环到成功为止
/paseo-orchestrator 你描述任务,它分配角色、启动多个 Agent 并行跑
/paseo-chat Agent 之间异步通信,支持持久化消息室和 @mentions
/paseo-committee Claude Opus + GPT 一起规划架构,只出方案不写代码

一个实际的用法:

# 功能需要先想清楚架构再动手
1. /paseo-committee → Claude Opus + GPT 讨论,出架构文档
2. /paseo-handoff  → 文档和上下文传给 Claude Code
3. Claude Code     → 开始实现
4. /paseo-loop     → 另一个 Agent 持续验证,直到测试通过

装 Skills:

npx skills add getpaseo/paseo

或者桌面应用 Settings → Integrations 里安装。

终端拆分,和项目管理

对,没错,它还有终端拆分的功能,和cmux一样(截图中左侧是项目分类)。

command+\ 水平拆分

command+shift+\ 垂直拆分

image.png

用了一个月,说点真实的

改变最大的不是某个具体功能,而是用 AI 工具的节奏变了。

以前用 Claude Code 是"坐下来,专门搞一段时间"的模式。有了 Paseo 之后变成了:等车的时候交代一件事,AI 在后台跑,我去干别的,回来看结果。异步的、碎片化的。这个改变比"AI 变聪明了"对日常效率的影响更直接。

不足的地方也有:文档目前比较薄,边缘配置要自己摸索;中文语音识别没英文流畅;Skills 这套多 Agent 协作的概念对新手来说需要时间理解,文档也没有足够多的例子帮你上手。

适合谁用:有服务器或闲置电脑、重度使用 Claude Code 或 Codex、对代码数据出不出本地比较在意的开发者。

不太适合谁:偶尔才用一下 AI 的,或者不习惯命令行的。这类用户直接用 Claude Code 就够了,Paseo 给他们增加的是配置复杂度,不是价值。

AI没我们想的那么聪明!复盘我的Vibe Coding翻车案例

我已经失去了编写代码的能力,变成只会写提示词的提示词工程师。但也没有完全丧失,我还会Review代码,偶尔手动改几行代码。

比如用Swift写UI,我在完全不复制粘贴的情况下,已经不会从0行代码写出一个页面,只会在AI生成的代码微调布局。

准确的说,不是丧失了能力,而是丧失了记忆!

回顾最近在开发ApiCatcher这款iOS端HTTPS抓包和调试工具的过程中,除了用AI开发核心功能遇到困难,其实在开发UI过程中也遇到一些困难。

今天分享我用AI开发ApiCatcher UI遇到的两个问题。解决这两个问题,我从Gemini 3.1 Pro模型,升级到Claude Opus 4.6模型,又从Claude Opus 4.6模型降到Gemini 3 Flash,最新只用Gemini 3 Flash就解决了问题。

第一个案例,我让AI帮我开发ApiCatcher的“AI对话生成脚本”功能,这里遇到的问题就是Markdown渲染。AI响应的消息里面包含代码。

关于UI,我的提示词大概是这样的:

页面做成聊天对话界面,底部是输入框和发送按钮,其余空间是显示聊天记录,用户只能发送文本消息,AI响应的消息是Markdown格式,AI响应的消息包含代码块,需要能高亮渲染代码块,代码不自动换行,而是代码块可以左右滚动。

这里的难点就是要支持代码块的高亮和代码块可以水平滚动。

无论是Gemini还是Claude,他们的思路都是先用开源的Swift语言编写的Markdown渲染开源库,怎么调都调不出令我满意的效果,渲染结果太丑了。在尝试完几个开源库后,Gemini和Claude会走向自己实现,写得非常复杂,然后实现的效果更加糟糕!

在无数次挫败后,我开始自己思考解决方案。我告诉Claude,SwiftUI的Markdown渲染开源库不好用,我们可以用Web技术栈,找JS开源库,然后用WebView来渲染Markdown消息。

这次Claude终于开窍了,但也没完全开窍。他把每个Markdown消息,都用一个WebView来渲染....

我再告诉他,为什么不是只用一个WebView来渲染所有消息呢?这时候Claude限制额度了......

我降回了Gemini 3 Flash。终于在几次调整后,Gemini做出了我想要的效果。

最终用到的技术栈:WebView、CodeMirror、Highlight.js。

第二个案例,JSON可折叠可搜索。

这次一上来就用Claude Sonnet 4.6模型,提示词:帮我实现JSON全屏预览页面,要求:JSON高亮显示,路径可以折叠和展开,可以搜索,搜索结果高亮,可以点击“下一个”按钮跳转到下一个搜索结果的位置。

Claude还是一样的思路,先找SwiftUI的开源库,但折腾半天卡在了搜索功能。还是一样折腾到耗尽Claude额度。

SwiftUI没有好用的开源库,那就换个思路,于是我又想到了WebView。最后选择WebView+CodeMirror+Highlight.js,加上CodeMirror的一个行号插件、搜索插件,用Gemini 3 Flash开发,完美实现!

在这两个案例中,无论是Claude还是Gemini,他们的思考方式都是先找SwiftUI可以使用的开源库,如果达不到目的,就改变策略,自己实现,但这种复杂的任务,他们根本没能力实现好。他们无法变通到通过WebView去乔接,从使用Swift技术栈,转变为使用Web开发技术栈。

我们只要告诉AI方法,他们就能实现,所以,目前AI的能力,其实受限于使用者的能力、认知。初级程序员+AI 无法超越 高级程序员+AI!

未来提示词会替代编程语言,程序员从写代码变成写提示词,但我们依然需要学习提升写提示词的能力。编程能力从来不是指代码写得多漂亮,而是对底层原理的认知深度,同样写提示词的能力也并非是指表达能力,同样是对底层原理的认知深度和广度。

以上面两个案例为例,我们需要认识到Swift原生开发并不是只能使用Swift,SwiftUI开发的原生应用也可以使用WebView来渲染网页,可以Swift生成网页代码传递给WebView渲染,并且可以生成JS代码让WebView执行。我们可以不懂怎么去写代码实现,只需要知道可以这样做,这极大降低了学习难度!

AI放大了我们个人的能力:从需要学会如何编码实现,了解具体的底层原理,到只需要知道可以这样实现。

但只是放大个人能力,AI遇强则强,遇弱则弱,我们依然需要保持学习。

16÷4 陷阱:一行代码让 SharedArrayBuffer 数据全部错位

16÷4 陷阱:一行代码让 SharedArrayBuffer 数据全部错位

主线程写进去的采样数据,Worklet 线程读出来全是乱码。

不是数据损坏。不是跨线程竞争。不是字节序。

你把 16 字节偏移当成了 16 个元素索引。

这个 bug 我在写 stw-sentinel 时踩的。processor.js 里 HEADER_SIZE = 16,TypedArray 构造器第三个参数是元素个数不是字节数——16 个 Int32 元素 = 64 字节,header 直接膨胀 4 倍,后面的数据全偏了 48 字节。SAB 没坏,Atomics 没报错,数据就是永远对不上。


陷阱解剖

SharedArrayBuffer 是一块裸内存。你在上面建视图,同一个偏移量,不同类型的索引含义完全不同:

// ❌ 我的 bug
const HEADER_SIZE = 16; // 16 字节

const header = new Int32Array(sab, 0, HEADER_SIZE); // 16 个 Int32 元素 = 64 字节!
const data = new Float32Array(sab, HEADER_SIZE * 4); // 偏移 64 字节,完全错位

// ✅ 修完
const HEADER_BYTES = 16;
const headerElements = HEADER_BYTES / 4; // 4 个 Int32 元素

const header = new Int32Array(sab, 0, headerElements); // 4 元素 = 16 字节
const data = new Float32Array(sab, HEADER_BYTES);      // 从第 16 字节开始

错误版本里,Int32Array(sab, 0, 16) 创建了 16 个 Int32 元素,占 64 字节。你的 header 本该占 16 字节,实际占了 64 字节。后面的数据区跟着偏移了 48 字节——不多不少,刚好 4 倍。

数据不会报错。 Int32Array 和 Float32Array 都能正常读写,Atomics 操作也不报异常。你的监控面板上看到的只是"数据对不上",没有任何 red flag 告诉你偏移算错了。

为什么 AudioWorklet 里这个坑最致命

非实时场景下,写错偏移顶多是初始化失败,加个 try-catch 就能定位。但 AudioWorklet 的 process() 回调每 128 帧跑一次(约 2.67ms),数据是流式消费的——错位就是错位,没有重传机制,没有校验和,数据流永远对不齐。

更毒的是:console.log 打出来全是 Int32 值,值本身没坏,只是写到了错误的内存位置。你盯着输出看半天,看不出任何异常。

前端开发者对"字节对齐"几乎没直觉。JavaScript 层面你碰不到字节,new ArrayBuffer(16) 对你来说就是"16 个槽位",很少去想这 16 个槽位的单位是什么。直到你用 SharedArrayBuffer 搭实时管道,字节和元素的分界线才会咬你一口。

超新星核爆.png

底层代码没有类型系统保护你。字节和元素搞混,编译器不报错,运行不崩溃,就是数据不对。这种 bug 最毒——你不一定发现得了。

在线验证:diffserv.xyz/lab, Worklet 心跳(~2.67ms),黄线是主线程帧间隔。两条线各跑各的,SAB 是唯一的桥。数据对齐了,就没有坑。

npm install stw-sentinel

GitHub: github.com/hlng2002/st…

从 DeepSeek 文本对话到流式输出

从 DeepSeek 文本对话到流式输出

本文把「非流式调用 → 浏览器里解析流式 → 用 Node 做 BFF → 前端改用 EventSource」串成一条主线

你将学到什么

  • 在浏览器里用 fetch 调用 DeepSeek 的 Chat Completions(与 OpenAI 兼容)。
  • 为什么要开 stream,以及流式响应在控制台里长什么样。
  • ReadableStream + TextDecoder + 行缓冲 解析 data: 开头的 SSE 分片。
  • 为什么 EventSource 很难直接对接「POST + Authorization」的大模型接口,以及如何用 零依赖 server.js 做中转。
  • 前端如何用 EventSource 消费自家 BFF 下发的 SSE,并顺带了解 SSE 的基本格式。

最后效果

deep_text.gif

懒得本地建立代码,也可以直接clone代码index-direct.htmlindex-stream.html能直接拖到浏览器,看效果。index.html拖入浏览器之前,需要首先node server.js,然后也能看到效果。哦,前提去申请一个deepseek的key。

准备工作

  1. 打开 DeepSeek 开放平台,按需充值并创建 API Key,妥善保存(不要写进公开仓库)。
  2. 下文示例里,直连 DeepSeek 的页面会把 Key 放在浏览器侧(仅适合本地学习);走代理后,Key 只放在服务端 .env.local

一、非流式:一次性拿到完整回复

复杂问题之前,先用「一问一答、整包返回」把链路跑通:向 https://api.deepseek.com/chat/completionsPOSTstream 关闭(或省略),再从 choices[0].message.content 取文本。

下面是一段最小 HTML(body 里放展示区域 + type="module" 脚本即可),新建文件,然后丢到浏览器就行!

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <body>
    <div id="reply"></div>
  
    <script type="module">
      const API_KEY = "sk-你自己的,没有的话去申请 https://platform.deepseek.com/usage";
      // DeepSeek 的「对话补全」接口地址(与 OpenAI Chat Completions 格式兼容)
      const endpoint = "https://api.deepseek.com/chat/completions";
  
      // HTTP 请求头:声明 JSON 正文,并用 Bearer Token 携带 API Key
      const headers = {
        "Content-Type": "application/json",
        Authorization: `Bearer ${API_KEY}`,
      };
  
      // 请求体:指定模型、对话消息列表;isStream: false 表示要一次性返回完整结果,而不是流式 SSE
      const payload = {
        // 模型类型
        model: "deepseek-chat",
        messages: [
          // role 字段是一个枚举字段,可选的值分别是 system、user 和 assistant,依次表示该条消息是系统消息(也就是我们一般俗称的提示词)、用户消息和 AI 应答消息
          { role: "system", content: "You are a helpful assistant." }, // 系统提示,约束助手行为
          { role: "user", content: "你好 Deepseek" }, // 用户本轮输入
        ],
        isStream: false,
      };
  
      // 向 DeepSeek 发起 POST,把 payload 序列化成 JSON 字符串作为 body
      const response = await fetch(endpoint, {
        method: "POST",
        headers: headers,
        body: JSON.stringify(payload),
      });
  
      // 把响应体解析为 JSON;接口成功时 choices[0].message.content 即助手回复正文
      const data = await response.json();
  
      // 把大模型返回的文本显示就行了
      document.getElementById("reply").textContent =
        data.choices[0].message.content;
    </script>
  </body>
</body>
</html>

二、为什么要流式:体感更好,协议长什么样

简单问题整包返回没问题;问题一长,用户会长时间盯着空白。把请求里的 stream 设为 true,模型就会边生成边吐字,前端边读边展示。

流式时,控制台里常见一行以 data: 开头,后面跟一段 JSON;结束标记一般是 data: [DONE](注意是 [DONE],大小写与官方一致)。下面是一条真实形态示例(单行 JSON,便于你对照日志):

data: {"id":"07b44fd1-5339-4ea5-a3e5-e62464fabe3d","object":"chat.completion.chunk","created":1776131664,"model":"deepseek-chat","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache_new_kvcache_20260410","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":972,"total_tokens":982,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":10}}

三、浏览器里用 fetch + ReadableStream 解析 SSE(Vue CDN 单页)

这一版页面做了几件事:

  • Vue 3(CDN ESM) 做一个最小界面:Key、问题、是否流式、提交按钮。
  • 流式时:response.body.getReader() + TextDecoder,按行切分;只处理以 data: 开头的行;能 JSON.parse 就读 choices[0].delta.content 做增量;解析失败就把半行塞回缓冲区,等下一段数据补齐。
  • 非流式:response.json() 一次取全量。

下面给出完整单页 HTML(可直接本地打开试用;Key 仅保存在本机 localStorage不要把带 Key 的页面部署到公网):

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DeepSeek 流式(Vue CDN 单页)</title>
  </head>
  <body>
    <div id="app"></div>

    <script type="module">
      import {
        createApp,
        ref,
      } from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";

      createApp({
        setup() {
          const apiKey = ref(
            typeof localStorage !== "undefined"
              ? localStorage.getItem("deepseek_api_key") || ""
              : "",
          );
          const question = ref("讲一个关于中国龙的故事");
          const content = ref("");
          const isStream = ref(true);
          const loading = ref(false);
          const error = ref("");

          function saveKey() {
            try {
              localStorage.setItem("deepseek_api_key", apiKey.value.trim());
            } catch (_) {}
          }

          async function update() {
            const key = apiKey.value.trim();
            if (!key) {
              error.value = "请填写 API Key(仅保存在本机 localStorage)";
              return;
            }
            if (!question.value.trim()) {
              error.value = "请输入问题";
              return;
            }

            error.value = "";
            loading.value = true;
            content.value = isStream.value ? "" : "思考中…";
            saveKey();

            const endpoint = "https://api.deepseek.com/chat/completions";
            const headers = {
              "Content-Type": "application/json",
              Authorization: "Bearer " + key,
            };

            try {
              const response = await fetch(endpoint, {
                method: "POST",
                headers,
                body: JSON.stringify({
                  model: "deepseek-chat",
                  messages: [{ role: "user", content: question.value.trim() }],
                  stream: isStream.value,
                }),
              });

              if (!response.ok) {
                const errText = await response.text();
                throw new Error(response.status + " " + errText.slice(0, 200));
              }

              if (isStream.value) {
                content.value = "";
                const reader = response.body?.getReader();
                if (!reader) {
                  throw new Error("响应不支持 ReadableisStream");
                }

                const decoder = new TextDecoder();
                let sseBuffer = "";

                while (true) {
                  const { value, done } = await reader.read();
                  if (done) break;

                  sseBuffer += decoder.decode(value, { isStream: true });
                  const parts = sseBuffer.split("\n");
                  sseBuffer = parts.pop() ?? "";

                  for (const rawLine of parts) {
                    const line = rawLine.trim();
                    if (!line || line.startsWith(":")) continue;
                    if (!line.startsWith("data:")) continue;
                    console.log(line);
                    const payload = line.slice(5).trim();
                    if (payload === "[DONE]") {
                      loading.value = false;
                      return;
                    }

                    try {
                      const data = JSON.parse(payload);
                      const delta = data?.choices?.[0]?.delta?.content;
                      if (delta) content.value += delta;
                    } catch {
                      sseBuffer = rawLine + "\n" + sseBuffer;
                    }
                  }
                }

                if (sseBuffer.trim()) {
                  const line = sseBuffer.trim();
                  if (line.startsWith("data:")) {
                    const payload = line.slice(5).trim();
                    if (payload && payload !== "[DONE]") {
                      try {
                        const data = JSON.parse(payload);
                        const delta = data?.choices?.[0]?.delta?.content;
                        if (delta) content.value += delta;
                      } catch (_) {}
                    }
                  }
                }
              } else {
                const data = await response.json();
                const text = data?.choices?.[0]?.message?.content;
                content.value = text ?? JSON.stringify(data);
              }
            } catch (e) {
              error.value = e instanceof Error ? e.message : String(e);
              if (!isStream.value) content.value = "";
            } finally {
              loading.value = false;
            }
          }

          return {
            apiKey,
            question,
            content,
            isStream,
            loading,
            error,
            update,
          };
        },
        template: `
          <div class="wrap">
            <h1>DeepSeek 对话(流式 / 非流式)</h1>
            <p class="hint">
              单文件演示:API Key 存于浏览器 localStorage。请勿把含 Key 的页面上传到公网。
            </p>
            <div class="row">
              <label for="k">Key</label>
              <input id="k" type="password" v-model="apiKey" placeholder="sk-…" autocomplete="off" />
            </div>
            <div class="row">
              <label for="q">问题</label>
              <input id="q" class="input-q" type="text" v-model="question" />
            </div>
            <div class="row">
              <label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
              <button type="button" :disabled="loading" @click="update">{{ loading ? '请求中…' : '提交' }}</button>
            </div>
            <p v-if="error" class="err">{{ error }}</p>
            <div class="output">{{ content || (loading && isStream ? '…' : '') }}</div>
          </div>
        `,
      }).mount("#app");
    </script>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #0f1419;
        color: #e6edf3;
        min-height: 100vh;
      }
      .wrap {
        max-width: 52rem;
        margin: 0 auto;
        padding: 1rem 1.25rem 2rem;
      }
      h1 {
        font-size: 1.1rem;
        font-weight: 600;
        margin: 0 0 0.75rem;
        color: #8b949e;
      }
      .row {
        display: flex;
        flex-wrap: wrap;
        gap: 0.5rem;
        align-items: center;
        margin-bottom: 0.75rem;
      }
      label {
        font-size: 0.85rem;
        color: #8b949e;
      }
      input[type="text"],
      input[type="password"] {
        flex: 1;
        min-width: 12rem;
        padding: 0.45rem 0.6rem;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #161b22;
        color: #e6edf3;
        font-size: 0.85rem;
      }
      input.input-q {
        width: 100%;
        min-width: 100%;
      }
      button {
        padding: 0.45rem 1rem;
        border-radius: 6px;
        border: 1px solid #388bfd;
        background: #21262d;
        color: #58a6ff;
        font-size: 0.85rem;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      .hint {
        font-size: 0.75rem;
        color: #6e7681;
        margin: 0 0 1rem;
        line-height: 1.45;
      }
      .output {
        margin-top: 0.75rem;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #30363d;
        background: #161b22;
        min-height: 12rem;
        white-space: pre-wrap;
        word-break: break-word;
        text-align: left;
        font-size: 0.9rem;
        line-height: 1.55;
      }
      .err {
        color: #f85149;
        margin-top: 0.5rem;
        font-size: 0.85rem;
        text-align: left;
      }
    </style>
  </body>
</html>

到这里可以记住一句话:流式开关在请求体里的 stream 字段;而浏览器侧「读流」的套路,基本就是 ReadableStream 读片 + 文本解码 + 行缓冲 + 解析 data: JSON


四、SSE 与「为什么不能在前端直接用 EventSource 调大模型」

DeepSeek 以及大量兼容 OpenAI 的平台,流式输出本质上是标准的 Server-Sent Events(SSE):文本协议、单向(服务端 → 浏览器)、比 WebSocket 轻。

EventSource 按规范只支持 GET,且不方便携带我们常用的 Authorization: Bearer ...;而大模型对话接口又通常是 POST + JSON body。所以:不是浏览器不能玩 SSE,而是不能「直接用 EventSource」去怼官方大模型域名

常见工程化解法是做一层 BFF(Backend For Frontend):由 Node 持有密钥,替浏览器去 POST 上游,再把上游 SSE 裁剪/转写成浏览器更好消费的 SSE(或 JSON 行)。


五、零 npm 的 Node 代理:server.js

这里用 Node 22+ 内置 http / fs / path / fetch,不引入 expressdotenv 等依赖,文件即服务。

  • 从项目根目录读取 .env.local.env(简单解析 KEY=value)。
  • GET /stream?question=...:上游 stream: true,把增量以 SSE 写回(示例里对纯文本 delta 做了 JSON.stringify,避免正文换行弄坏 SSE)。
  • GET /complete?question=...:上游 stream: false,返回 { "content": "..." },给「非流式」前端一条同源捷径。

在同级目录创建 .env.local,写入一行(示例):

VITE_DEEPSEEK_API_KEY=sk-你自己的

然后执行:

node server.js

完整服务端代码如下(文件名 server.js):

"use strict";

/**
 * 零依赖代理:Node 22+(内置 fetch)
 * 启动:node server.js
 * 环境变量:在项目根目录放置 .env.local 或 .env,写入
 *   VITE_DEEPSEEK_API_KEY=sk-...
 * 可选:PORT=3000、DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
 *
 * 调用示例:
 *   curl -N "http://localhost:3000/stream?question=你好"
 */

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const ROOT = __dirname;

function loadDotEnv() {
  for (const name of [".env.local", ".env"]) {
    const file = path.join(ROOT, name);
    if (!fs.existsSync(file)) continue;
    const text = fs.readFileSync(file, "utf8");
    for (const line of text.split(/\n/)) {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith("#")) continue;
      const eq = trimmed.indexOf("=");
      if (eq === -1) continue;
      const key = trimmed.slice(0, eq).trim();
      let val = trimmed.slice(eq + 1).trim();
      if (
        (val.startsWith('"') && val.endsWith('"')) ||
        (val.startsWith("'") && val.endsWith("'"))
      ) {
        val = val.slice(1, -1);
      }
      if (process.env[key] === undefined) process.env[key] = val;
    }
  }
}

loadDotEnv();

const PORT = Number(process.env.PORT) || 3000;
const API_KEY =
  process.env.VITE_DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || "";
const UPSTREAM =
  process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions";

if (!API_KEY) {
  console.error(
    "缺少 API Key:请在 .env.local 或 .env 中设置 VITE_DEEPSEEK_API_KEY(或 DEEPSEEK_API_KEY)",
  );
  process.exit(1);
}

const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

/**
 * 将上游 OpenAI 兼容 SSE 行解析为 delta 文本,并写给客户端
 * @param {import('node:http').ServerResponse} res
 * @param {ReadableStreamDefaultReader<Uint8Array>} reader
 */
async function pipeUpstreamSseToClient(res, reader) {
  const decoder = new TextDecoder();
  let carry = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    carry += decoder.decode(value, { stream: true });

    let nl;
    while ((nl = carry.indexOf("\n")) !== -1) {
      const rawLine = carry.slice(0, nl);
      carry = carry.slice(nl + 1);
      const line = rawLine.trim();
      if (!line || line.startsWith(":")) continue;
      if (!line.startsWith("data:")) continue;

      const payload = line.slice(5).trim();
      if (payload === "[DONE]") {
        res.write("event: end\n");
        res.write("data: [DONE]\n\n");
        return;
      }

      try {
        const data = JSON.parse(payload);
        const delta = data?.choices?.[0]?.delta?.content;
        if (delta) {
          // 用 JSON 包裹一段文本,避免 delta 内含换行破坏 SSE
          res.write(`data: ${JSON.stringify(delta)}\n\n`);
        }
      } catch {
        carry = `${rawLine}\n${carry}`;
        break;
      }
    }
  }

  res.write("event: end\n");
  res.write("data: [DONE]\n\n");
}

const server = http.createServer(async (req, res) => {
  const host = req.headers.host || `127.0.0.1:${PORT}`;
  let url;
  try {
    url = new URL(req.url || "/", `http://${host}`);
  } catch {
    res.writeHead(400, {
      "Content-Type": "text/plain; charset=utf-8",
      ...CORS,
    });
    res.end("bad url");
    return;
  }

  if (req.method === "OPTIONS") {
    res.writeHead(204, CORS);
    res.end();
    return;
  }

  if (req.method === "GET" && url.pathname === "/") {
    res.writeHead(200, {
      "Content-Type": "text/plain; charset=utf-8",
      ...CORS,
    });
    res.end(
      `DeepSeek 代理已就绪。\n\n流式:GET /stream?question=你的问题\n非流式:GET /complete?question=你的问题\n示例:http://localhost:${PORT}/stream?question=你好\n`,
    );
    return;
  }

  if (req.method === "GET" && url.pathname === "/complete") {
    const question = (url.searchParams.get("question") || "").trim();
    if (!question) {
      res.writeHead(400, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(JSON.stringify({ error: "缺少参数:question" }));
      return;
    }

    try {
      const upstream = await fetch(UPSTREAM, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: [{ role: "user", content: question }],
          stream: false,
        }),
      });

      const text = await upstream.text();
      if (!upstream.ok) {
        res.writeHead(upstream.status, {
          "Content-Type": "application/json; charset=utf-8",
          ...CORS,
        });
        res.end(
          JSON.stringify({ error: "upstream", body: text.slice(0, 800) }),
        );
        return;
      }

      let data;
      try {
        data = JSON.parse(text);
      } catch {
        res.writeHead(502, {
          "Content-Type": "application/json; charset=utf-8",
          ...CORS,
        });
        res.end(JSON.stringify({ error: "上游返回非 JSON" }));
        return;
      }

      const content = data?.choices?.[0]?.message?.content ?? "";
      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(JSON.stringify({ content }));
    } catch (e) {
      res.writeHead(500, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(
        JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
      );
    }
    return;
  }

  if (req.method === "GET" && url.pathname === "/stream") {
    const question = (url.searchParams.get("question") || "").trim();
    if (!question) {
      res.writeHead(400, {
        "Content-Type": "text/plain; charset=utf-8",
        ...CORS,
      });
      res.end("缺少参数:question");
      return;
    }

    res.writeHead(200, {
      ...CORS,
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    });

    const ac = new AbortController();
    const onClose = () => ac.abort();
    res.on("close", onClose);

    try {
      const upstream = await fetch(UPSTREAM, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: [{ role: "user", content: question }],
          stream: true,
        }),
        signal: ac.signal,
      });

      if (!upstream.ok || !upstream.body) {
        const t = await upstream.text().catch(() => "");
        res.write(
          `data: ${JSON.stringify({
            error: `upstream ${upstream.status}`,
            body: t.slice(0, 800),
          })}\n\n`,
        );
        return;
      }

      await pipeUpstreamSseToClient(res, upstream.body.getReader());
    } catch (e) {
      if (e?.name === "AbortError") {
        return;
      }
      console.error(e);
      res.write(
        `data: ${JSON.stringify({ error: e instanceof Error ? e.message : String(e) })}\n\n`,
      );
    } finally {
      res.off("close", onClose);
      if (!res.writableEnded) res.end();
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", ...CORS });
  res.end("not found");
});

server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
  console.log(
    `Stream: curl -N "http://localhost:${PORT}/stream?question=你好"`,
  );
  console.log(
    `Complete: curl "http://localhost:${PORT}/complete?question=你好"`,
  );
});

六、前端改用 EventSource:更轻的一层消费

当密钥已经只在服务端时,浏览器不再需要输入 Key。流式场景下,用 EventSource 连接自家代理,例如:

http://127.0.0.1:3000/stream?question=...question 请做 URL 编码)

下面是与当前仓库一致的 index.html 版本:流式走 EventSource,非流式走 GET /complete;并把代理根地址记在 localStorage 里,方便反复调试。

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DeepSeek 流式(Vue CDN 单页)</title>
  </head>
  <body>
    <div id="app"></div>

    <script type="module">
      import {
        createApp,
        ref,
        onUnmounted,
      } from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";

      createApp({
        setup() {
          /** 本地 node server.js 地址(与 server 监听端口一致) */
          const proxyBase = ref("http://127.0.0.1:3000");
          const question = ref("讲一个关于中国龙的故事");
          const content = ref("");
          const isStream = ref(true);
          const loading = ref(false);
          const error = ref("");

          /** @type {EventSource | null} */
          let eventSource = null;

          function closeEventSource() {
            if (eventSource) {
              eventSource.close();
              eventSource = null;
            }
          }

          onUnmounted(() => {
            closeEventSource();
          });

          function saveProxyBase() {
            try {
              localStorage.setItem(
                "deepseek_proxy_base",
                proxyBase.value.trim(),
              );
            } catch (_) {}
          }

          async function update() {
            if (!question.value.trim()) {
              error.value = "请输入问题";
              return;
            }

            const base = proxyBase.value.trim().replace(/\/$/, "");
            if (!base) {
              error.value = "请填写代理地址";
              return;
            }

            error.value = "";
            closeEventSource();
            saveProxyBase();

            const q = encodeURIComponent(question.value.trim());

            if (isStream.value) {
              loading.value = true;
              content.value = "";

              const url = `${base}/stream?question=${q}`;
              const es = new EventSource(url);
              eventSource = es;

              es.addEventListener("message", (e) => {
                if (e.data === "[DONE]") return;
                try {
                  const parsed = JSON.parse(e.data);
                  if (
                    parsed &&
                    typeof parsed === "object" &&
                    parsed !== null &&
                    "error" in parsed
                  ) {
                    error.value =
                      typeof parsed.error === "string"
                        ? parsed.error
                        : JSON.stringify(parsed.error);
                    closeEventSource();
                    loading.value = false;
                    return;
                  }
                  if (typeof parsed === "string") {
                    content.value += parsed;
                  }
                } catch {
                  error.value = "SSE 解析失败:" + e.data;
                  closeEventSource();
                  loading.value = false;
                }
              });

              es.addEventListener("end", () => {
                closeEventSource();
                loading.value = false;
              });

              es.onerror = () => {
                if (!error.value) {
                  error.value =
                    "EventSource 连接失败(请确认已运行 node server.js,且代理地址、端口正确)";
                }
                closeEventSource();
                loading.value = false;
              };

              return;
            }

            loading.value = true;
            content.value = "思考中…";
            try {
              const res = await fetch(`${base}/complete?question=${q}`);
              const data = await res.json().catch(() => ({}));
              if (!res.ok) {
                throw new Error(
                  typeof data.error === "string"
                    ? data.error
                    : res.status + " " + JSON.stringify(data).slice(0, 200),
                );
              }
              if (data && typeof data.content === "string") {
                content.value = data.content;
              } else {
                content.value = JSON.stringify(data);
              }
            } catch (e) {
              error.value = e instanceof Error ? e.message : String(e);
              content.value = "";
            } finally {
              loading.value = false;
            }
          }

          if (typeof localStorage !== "undefined") {
            const saved = localStorage.getItem("deepseek_proxy_base");
            if (saved) proxyBase.value = saved;
          }

          return {
            proxyBase,
            question,
            content,
            isStream,
            loading,
            error,
            update,
          };
        },
        template: `
          <div class="wrap">
            <h1>DeepSeek 对话(经本地 server.js)</h1>
            <p class="hint">
              先在本项目目录运行 <code>node server.js</code>(Key 写在服务端 .env.local)。流式走
              <code>GET /stream</code>(EventSource),非流式走 <code>GET /complete</code>。
            </p>
            <div class="row">
              <label for="proxy">代理</label>
              <input id="proxy" type="text" v-model="proxyBase" placeholder="http://127.0.0.1:3000" autocomplete="off" />
            </div>
            <div class="row">
              <label for="q">问题</label>
              <input id="q" class="input-q" type="text" v-model="question" />
            </div>
            <div class="row">
              <label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
              <button type="button" :disabled="loading" @click="update">{{ loading ? '请求中…' : '提交' }}</button>
            </div>
            <p v-if="error" class="err">{{ error }}</p>
            <div class="output">{{ content || (loading && isStream ? '…' : '') }}</div>
          </div>
        `,
      }).mount("#app");
    </script>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #0f1419;
        color: #e6edf3;
        min-height: 100vh;
      }
      .wrap {
        max-width: 52rem;
        margin: 0 auto;
        padding: 1rem 1.25rem 2rem;
      }
      h1 {
        font-size: 1.1rem;
        font-weight: 600;
        margin: 0 0 0.75rem;
        color: #8b949e;
      }
      .row {
        display: flex;
        flex-wrap: wrap;
        gap: 0.5rem;
        align-items: center;
        margin-bottom: 0.75rem;
      }
      label {
        font-size: 0.85rem;
        color: #8b949e;
      }
      input[type="text"],
      input[type="password"] {
        flex: 1;
        min-width: 12rem;
        padding: 0.45rem 0.6rem;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #161b22;
        color: #e6edf3;
        font-size: 0.85rem;
      }
      input.input-q {
        width: 100%;
        min-width: 100%;
      }
      button {
        padding: 0.45rem 1rem;
        border-radius: 6px;
        border: 1px solid #388bfd;
        background: #21262d;
        color: #58a6ff;
        font-size: 0.85rem;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      .hint {
        font-size: 0.75rem;
        color: #6e7681;
        margin: 0 0 1rem;
        line-height: 1.45;
      }
      .hint code {
        font-size: 0.85em;
        padding: 0.12em 0.4em;
        border-radius: 4px;
        background: #21262d;
        color: #79c0ff;
      }
      .output {
        margin-top: 0.75rem;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #30363d;
        background: #161b22;
        min-height: 12rem;
        white-space: pre-wrap;
        word-break: break-word;
        text-align: left;
        font-size: 0.9rem;
        line-height: 1.55;
      }
      .err {
        color: #f85149;
        margin-top: 0.5rem;
        font-size: 0.85rem;
        text-align: left;
      }
    </style>
  </body>
</html>

看效果:

  • node server.js启动服务
  • index.html直接在浏览器打开就行

七、和「手写 ReadableStream」相比:EventSource 在写什么

有了 BFF 之后,浏览器侧可以收敛成「连接 + 监听消息 + 结束关闭」的写法。下面是一段示意(注意:真实页面里 e.data 往往是 JSON 字符串化的片段,需要 JSON.parse 后再拼接,见上一节完整 index.html;这里保留原文写法不动):

const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.addEventListener("message", function(e: any) {
  content.value += e.data;
});
eventSource.addEventListener('end', () => {
  eventSource.close();
});

除了代码更短之外,EventSource 还自带自动重连语义(适合长连接场景;生产环境仍要结合幂等、去重与产品体验谨慎使用)。标准里也提到 Last-Event-ID 等能力,用于断线续传时减少重复流量(是否启用取决于你的 BFF 设计)。


八、附录:SSE 是什么、数据长什么样

SSE(Server-Sent Events):服务端主动向浏览器推送事件流,单向、基于 HTTP,通常比 WebSocket 更轻。

前端最小用法示例(与具体业务路径无关,仅演示 API):

// 建立 SSE 连接
const evtSource = new EventSource('/api/sse');

// 监听服务器发来的消息
evtSource.onmessage = (e) => {
  console.log('收到消息:', e.data);
};

// 监听错误
evtSource.onerror = (err) => {
  console.error('SSE 出错', err);
};

// 关闭连接
evtSource.onmessage = (e) => {
  if (e.data === 'done') {
    evtSource.close(); // 关闭 SSE 连接
    return;
  }
  console.log(e.data);
};

服务端写入时,需要满足 SSE 的基本形态:Content-Type: text/event-stream,消息以 data: 开头,并以 空行(\n\n 结束一条事件。下面用注释标了几种常见形态(注意:注释行只是说明,真实协议里注释行以 : 开头,这里保留原文示例不动):


data: 你好任意巴拉巴拉\n\n

# json串
data: {"name":"小明","age":20}\n\n

# 自定义结束 前端获取就行
data: done\n\n

# 发空行表示心跳
data: \n\n

Node 里设置响应头并周期性 write 的伪代码如下(仅帮助理解,不是可直接运行的完整服务):

// 伪代码
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

// 每隔 1 秒发一条
setInterval(() => {
  res.write(`data: ${new Date()}\n\n`);
}, 1000);

结束标记(例如 done)可以自定义,但团队内最好统一约定;本文 BFF 示例则使用 event: end + data: [DONE] 的组合来通知前端收尾。


小结

  • 直连fetch + stream + ReadableStream 解析 data: 行,灵活但代码多,且 Key 在浏览器。
  • BFF:Node 持有 Key,浏览器用 EventSource/fetch 访问同源或可控跨域接口,职责更清晰。
  • 安全:Key 进 .env.local.gitignore 忽略本地环境文件;页面不要上传公网。

祝调试顺利。

一文看懂:Vue3 watch 用 VuReact 转成 React 长啥样

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~在 Vue3 转 React 的过程中,watch 作为最常用的响应式监听 API,手动改写很容易丢失逻辑、写错依赖。

今天继续用 VuReact 工具,给大家带来 Vue3 watch → React 编译对照,全程一比一还原、保留所有行为与内链,看完直接上手迁移。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖追踪,完美对齐 Vue 响应式监听行为,不用手动处理 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watch → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watch 用法与核心行为

一、基础版:watch → useWatch

Vue 标准 watch 监听,支持 immediate、清理函数 onCleanup,VuReact 直接编译为 useWatch

Vue 源码

<script setup>
import { ref, watch } from 'vue';
const userId = ref(1);

watch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      userData.value = data;
    }
  },
  { immediate: true },
);
</script>

VuReact 编译后 React 代码

import { useVRef, useWatch } from '@vureact/runtime-core';
const userId = useVRef(1);

useWatch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      setUserData(data);
    }
  },
  { immediate: true },
);

核心要点

  • Vue watch() 直接编译为 useWatch
  • 完全保留:回调参数、immediateonCleanup 清理机制
  • 编译阶段自动分析依赖、深度追踪,无需手动管理依赖数组

二、深度监听 & 多源监听:对象/数组来源兼容

watch 监听对象内部属性、多源数组时,VuReact 同样支持 deep 与多源写法,行为完全对齐 Vue。

Vue 源码(深度监听 + 多源监听)

<script setup>
import { reactive, watch } from 'vue';
const state = reactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

// 深度监听对象内部
watch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

// 多源监听
watch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});
</script>

VuReact 编译后 React 代码

import { useReactive, useWatch } from '@vureact/runtime-core';
const state = useReactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

useWatch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

useWatch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});

对应关系

  • 监听函数写法、deep: true 深度监听完全保留
  • 多源数组监听直接兼容
  • 编译器自动做依赖分析,不用手动写 deps

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watch 相关规则:

  1. watchuseWatch
  2. 支持 immediate / deep / onCleanup 全部选项
  3. 支持单源、函数返回值、多源数组监听
  4. 依赖自动追踪,无需手动管理依赖数组
  5. 行为 1:1 对齐 Vue,迁移零逻辑损耗

相关资源

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

一文看懂:Vue3 watchEffect 用 VuReact 转成 React 长啥样

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~最近很多同学在做 Vue3 → React 技术栈迁移,被响应式 API 对齐、依赖手动管理搞得头大,尤其是 watchEffect 这种自动依赖收集的核心 API,在 React 里很容易漏写依赖。

今天就用 VuReact 这个编译工具,直接把 Vue3 watchEffect 的各种用法一比一翻译成标准可维护的 React 代码,全程对照、看完即用。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖数组,完美对齐 Vue 响应式行为,不用手动维护 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watchEffect → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watchEffect 用法与行为

一、基础版:watchEffect → useWatchEffect

Vue 最常用的基础 watchEffect,自动收集依赖、自动触发副作用。

Vue 源码

<script setup>
import { ref, watchEffect } from 'vue';
const count = ref(0);

watchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
});
</script>

VuReact 编译后 React 代码

import { useVRef, useWatchEffect } from '@vureact/runtime-core';
const count = useVRef(0);

useWatchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
}, [count.value]);

核心要点

  • Vue watchEffect() 直接编译为 useWatchEffect
  • 编译阶段自动分析依赖并生成精准依赖数组,无需手动管理
  • 完全模拟 Vue watchEffect 的自动依赖收集、清理机制、停止控制

二、带 flush 选项:post / sync 对齐渲染时机

Vue 中通过 flush: 'post' / flush: 'sync' 控制执行时机,VuReact 直接映射为专用 Hook,保持渲染时机一致。

Vue 源码(post + sync)

<script setup>
import { ref, watchEffect } from 'vue';
const width = ref(0);
const elRef = ref(null);

// DOM 更新后执行
watchEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  { flush: 'post' },
);

// 同步立即执行
watchEffect(
  () => {
    console.log(elRef.value);
  },
  { flush: 'sync' },
);
</script>

VuReact 编译后 React 代码

import { useVRef } from '@vureact/runtime-core';
import { useWatchPostEffect, useWatchSyncEffect } from '@vureact/runtime-core';

const width = useVRef(0);
const elRef = useVRef(null);

useWatchPostEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  [elRef.value, width.value, elRef.value.offsetWidth]
);

useWatchSyncEffect(
  () => {
    console.log(elRef.value);
  },
  [elRef.value]
);

对应关系

  • flush: 'post'useWatchPostEffect
  • flush: 'sync'useWatchSyncEffect
  • 执行时机、依赖追踪、副作用行为完全对齐 Vue
  • 依赖数组依旧自动生成,无需手动编写

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watchEffect 相关规则:

  1. watchEffectuseWatchEffect
  2. flush: 'post'useWatchPostEffect
  3. flush: 'sync'useWatchSyncEffect
  4. 依赖自动收集、deps 自动生成,不用手动维护
  5. 行为 1:1 对齐 Vue,迁移成本极低

相关资源

互动一下

你在 Vue 转 React 时,最头疼哪个 API? watch / computed / defineProps / defineEmits? 评论区留言,下期直接出对照编译手册

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

React 常用知识点整理

前言:本文总结React 常用知识点,给出简洁的说明和示例,方便记忆和速查


1. JSX 基础

  • JSX 中可使用 {} 嵌入 JS 表达式。
  • 渲染原生 HTML 片段使用 dangerouslySetInnerHTML
function App() {
  const rawHtmlData = {
    __html: "<span>富文本内容<i>斜体</i><b>加粗</b></span>",
  };

  return <div dangerouslySetInnerHTML={rawHtmlData} />;
}

2. 循环渲染(map + key

  • 列表渲染通常使用 map
  • key 必须稳定且唯一,优先使用后端 id
<ul>
  {list.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</ul>

3. 条件渲染

简单场景:&&、三元表达式

{/* 逻辑与 */}
{isLogin && <span>this is span</span>}

{/* 三元表达式 */}
{isLogin ? <span>jack</span> : <span>loading...</span>}

复杂场景:函数返回 JSX

可使用if语句,switch语句或策略模式,判断返回不同的JSX

function App() {
  const type = 1; // 0 | 1 | 3

  function getArticleJSX() {
    if (type === 0) return <div>无图模式模板</div>;
    if (type === 1) return <div>单图模式模板</div>;
    if (type === 3) return <div>三图模式模板</div>;
    return null;
  }

  return <>{getArticleJSX()}</>;
}

4. 事件绑定

  • 语法:on + 事件名 = {事件处理函数}(驼峰命名)。
  • 传参通常使用箭头函数。
  • 同时传事件对象和自定义参数时,手动透传 e
// 基础掉用,使用事件对象
function App() {
  const handleClick = (e) => {
    console.log("点击了按钮", e);
  };
  return (
   <div>
     <button onClick={handleClick}>点击</button>
   </div>
);
}

// 传递自定义参数
function App() {
  const handleClick = (name) => {
    console.log("点击了按钮", name);
  };
  return (
    <div>
      <button onClick={() => handleClick('zs')}>点击</button>
    </div>
  );
}

// 同时传递事件对象+自定义参数
function App() {
  const handleClick = (e, name) => {
    console.log("点击了按钮", e, name);
  };
  return (
    <div>
      <button onClick={(e) => handleClick(e, 'zs')}>点击</button>
    </div>
  );
}

5. 组件基础

  • 组件本质是首字母大写的函数(函数声明或箭头函数都可以)。
  • 组件内部包含状态、逻辑和 UI,使用时像标签一样书写。
function Welcome() {
  return <h1>Hello React</h1>;
}

6. CSS 样式

  • 行内样式:style={{ fontSize: "16px" }}
  • 类名:className="xxx"
  • 状态控制类名(条件拼接):
{tabs.map((item) => (
  <span
    key={item.type}
    className={`nav-item ${item.type === type ? "active" : ""}`}
    onClick={() => handleTabChange(item.type)}
  >
    {item.text}
  </span>
))}

7. useState 状态管理

  • const [state, setState] = useState(initialValue)
  • 初始值只在首次渲染生效,后续渲染不会重新初始化。
  • 状态是只读的:更新时用“替换”,不要直接修改原对象/原数组。
  • 依赖旧值更新时,优先函数式写法。
import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
    // setCount((preCount) => preCount +1);
  }
  return (
    <div>
    <button onClick={handleClick}>{count}</button>
    </div>
  );
}

对象更新示例:

const [form, setForm] = useState({ username: "zhangsan", password: "" });
setForm({ ...form, password: "123456" });

8. useEffect

useEffect(effect, deps) 常用于请求数据、订阅、定时器等副作用。

  • 不传依赖:每次渲染后都执行。
  • 传空数组 []:仅首次渲染后执行一次。
  • 传具体依赖 [a, b]:首次渲染 + 依赖变化时执行。
  • 清理函数用于取消订阅、清除定时器等:
useEffect(() => {
  const timer = setInterval(() => {}, 1000);
  return () => clearInterval(timer);
}, []);

9. useRef

  • 获取 DOM:ref={inputRef}inputRef.current.focus()
  • 存储不会触发重渲染的可变值(如定时器 id)
const inputRef = useRef(null);
inputRef.current?.focus();

10. 受控组件 vs 非受控组件

  • 受控组件:表单值由 React 状态控制(value + onChange),初始状态+更新事件函数。
  • 非受控组件:值由 DOM 自己维护,通常用 ref 获取当前值。
// 受控
function App(){
  const [value, setValue] = useState('')
  return (
    <input 
      type="text" 
      value={value} 
      onChange={e => setValue(e.target.value)}
    />
  )
}

// 非受控
function App(){
  const inputRef = useRef(null)
  const onChange = ()=>{
    console.log(inputRef.current.value)
  }
  return (
    <input 
      type="text" 
      ref={inputRef}
      onChange={onChange}
    />
    )
}

11. 组件通信

  • 父传子:props
  • 插槽能力:props.children
  • 子传父:父传函数给子,子调用并回传参数
  • 兄弟通信:状态提升(共享父组件中转)
  • 跨层通信:Context
  • 更复杂全局状态:Redux(或其他状态库)

12. useContext

  1. createContext 创建上下文对象
  2. 顶层用 Provider 提供 value
  3. 子孙组件用 useContext 消费数据
const ThemeContext = createContext("light");

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Page />
    </ThemeContext.Provider>
  );
}

13. Hooks 使用规则

  1. 只能在函数组件或自定义 Hook 中调用。
  2. 只能在组件顶层调用,不能写在 if/for/switch/普通函数 内。

14. 自定义 Hook

  • 命名必须以 use 开头。
  • 目的:复用“状态 + 副作用逻辑”。
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  return [value, toggle];
}

15. useReducer

适合复杂状态流转或多分支更新。

function reducer(state, action) {
  switch (action.type) {
    case "INC":
      return state + 1;
    case "DEC":
      return state - 1;
    case "SET":
      return action.payload;
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, 0);
dispatch({ type: "INC" });
dispatch({ type: "SET", payload: 100 });

16. useMemo(缓存值)

  • 在依赖不变时复用计算结果,减少重复计算。
  • 常用于缓存“昂贵计算结果”或“稳定引用(数组/对象)”。
const result = useMemo(() => heavyCalc(count1), [count1]);

const list = useMemo(() => [1, 2, 3], []);

17. React.memo(缓存组件)

  • props 未变化时跳过子组件重渲染。
  • React 会对 props 做浅比较(Object.is)。
const MemoComponent = memo(function SomeComponent(props) {
  return <div>{props.value}</div>;
});

18. useCallback(缓存函数)

  • 缓存函数引用,避免子组件因函数地址变化而无意义重渲染。
const changeHandler = useCallback((value) => {
  console.log(value);
}, []);

19. forwardRef

  • 作用:让父组件拿到子组件内部的 DOM/实例能力。
  • React 19 中 ref 可像普通 prop 一样传递到函数组件,但很多项目仍大量使用 forwardRef,兼容性更好。
import { forwardRef, useRef } from 'react'

const MyInput = forwardRef(function Input(props, ref) {
  return <input type="text" {...props} ref={ref} />
}, [])

function App() {
  const ref = useRef(null)
  const focusHandle = () => {
    ref.current.focus()
  }
  return (
    <div>
      <MyInput ref={ref} />
      <button onClick={focusHandle}>focus</button>
    </div>
  )
}

20. useImperativeHandle

  • 用于“自定义 ref 暴露内容”,而不是直接暴露整个 DOM。
import { forwardRef, useImperativeHandle, useRef } from 'react'

const MyInput = forwardRef(function Input(props, ref) {
  // 实现内部的聚焦逻辑
  const inputRef = useRef(null)
  const focus = () => inputRef.current.focus()

  // 暴露子组件内部的聚焦方法
  useImperativeHandle(ref, () => {
    return {
      focus,
    }
  })

  return <input {...props} ref={inputRef} type="text" />
})

function App() {
  const ref = useRef(null)

  const focusHandle = () => ref.current.focus()

  return (
    <div>
      <MyInput ref={ref} />
      <button onClick={focusHandle}>focus</button>
    </div>
  )
}

21. useLayoutEffect

  • useEffect:浏览器绘制后异步执行,不阻塞渲染。
  • useLayoutEffect:DOM 更新后、绘制前同步执行,会阻塞渲染。
  • 场景:需要在绘制前读取布局并立即修正(避免闪动)。

22. 路由懒加载:lazy + Suspense

import { lazy, Suspense } from "react";

const Home = lazy(() => import("@/pages/Home"));

function App() {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Home />
    </Suspense>
  );
}

高频易错点(建议重点记)

  • key 不要用随机值或 index(除非列表完全静态)。
  • 更新对象/数组状态时必须返回新引用。
  • useEffect 依赖项写全,避免闭包拿到旧值。
  • 性能优化优先级:先排查真实瓶颈,再使用 memo/useMemo/useCallback
  • dangerouslySetInnerHTML 只用于可信内容,避免 XSS 风险。
❌