阅读视图

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

firewalld Cheatsheet

Basic Commands

Start, stop, and reload the firewalld service.

Command Description
firewall-cmd --state Check if firewalld is running
sudo systemctl start firewalld Start the service
sudo systemctl stop firewalld Stop the service
sudo systemctl enable firewalld Enable at boot
sudo systemctl disable firewalld Disable at boot
sudo firewall-cmd --reload Reload rules without dropping connections
sudo firewall-cmd --complete-reload Full reload, resets all connections

Runtime vs Permanent

By default, firewall-cmd changes apply at runtime only and are lost on reload. Add --permanent to persist a rule, then reload to activate it.

Command Description
sudo firewall-cmd --add-service=http Allow HTTP (runtime only)
sudo firewall-cmd --add-service=http --permanent Allow HTTP (survives reload)
sudo firewall-cmd --reload Activate permanent rules
sudo firewall-cmd --runtime-to-permanent Save all runtime rules as permanent

Zones

Zones define trust levels for network connections. Each interface belongs to one zone.

Command Description
firewall-cmd --get-zones List all available zones
firewall-cmd --get-default-zone Show the default zone
sudo firewall-cmd --set-default-zone=public Set the default zone
firewall-cmd --get-active-zones Show active zones and their interfaces
firewall-cmd --zone=public --list-all List all settings for a zone
sudo firewall-cmd --zone=public --change-interface=eth0 Assign interface to zone (runtime)
sudo firewall-cmd --zone=public --add-interface=eth0 --permanent Assign interface permanently
sudo firewall-cmd --zone=public --remove-interface=eth0 Remove interface from zone

Services

Allow or block named services defined in /usr/lib/firewalld/services/.

Command Description
firewall-cmd --get-services List all predefined services
firewall-cmd --zone=public --list-services List services allowed in zone
firewall-cmd --info-service=http Show ports and protocols for a service
sudo firewall-cmd --zone=public --add-service=http --permanent Allow service permanently
sudo firewall-cmd --zone=public --remove-service=http --permanent Remove service

Ports

Open or close individual ports when no predefined service exists.

Command Description
firewall-cmd --zone=public --list-ports List open ports in zone
sudo firewall-cmd --zone=public --add-port=8080/tcp --permanent Open a TCP port
sudo firewall-cmd --zone=public --add-port=4000-4500/tcp --permanent Open a port range
sudo firewall-cmd --zone=public --remove-port=8080/tcp --permanent Close a port

Rich Rules

Rich rules allow fine-grained control over source, destination, port, and action.

Command Description
firewall-cmd --zone=public --list-rich-rules List rich rules in zone
sudo firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" accept' --permanent Allow traffic from subnet
sudo firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.10" reject' --permanent Reject traffic from IP
sudo firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="22" protocol="tcp" accept' --permanent Allow SSH from subnet
sudo firewall-cmd --zone=public --remove-rich-rule='rule family="ipv4" source address="203.0.113.10" reject' --permanent Remove a rich rule

Masquerade (NAT)

Masquerading lets machines on a private network reach the internet through the firewall host.

Command Description
firewall-cmd --zone=public --query-masquerade Check if masquerading is enabled
sudo firewall-cmd --zone=public --add-masquerade --permanent Enable masquerading
sudo firewall-cmd --zone=public --remove-masquerade --permanent Disable masquerading

Logging

Control which denied packets are logged to help with debugging.

Command Description
firewall-cmd --get-log-denied Show current log-denied setting
sudo firewall-cmd --set-log-denied=all Log all denied packets
sudo firewall-cmd --set-log-denied=unicast Log denied unicast only
sudo firewall-cmd --set-log-denied=off Disable denied-packet logging

Common Server Setup

Baseline rules for a web server using firewalld.

Command Description
sudo firewall-cmd --set-default-zone=public Set zone to public
sudo firewall-cmd --zone=public --add-service=ssh --permanent Keep SSH access
sudo firewall-cmd --zone=public --add-service=http --permanent Allow HTTP
sudo firewall-cmd --zone=public --add-service=https --permanent Allow HTTPS
sudo firewall-cmd --reload Activate all permanent rules
firewall-cmd --zone=public --list-all Verify active rules

How to Set Up a Firewall with UFW on Ubuntu 24.04

A firewall is a tool for monitoring and filtering incoming and outgoing network traffic. It works by defining a set of security rules that determine whether to allow or block specific traffic.

Ubuntu ships with a firewall configuration tool called UFW (Uncomplicated Firewall). It is a user-friendly front-end for managing iptables firewall rules. Its main goal is to make managing a firewall easier or, as the name says, uncomplicated.

This article describes how to use the UFW tool to configure and manage a firewall on Ubuntu 24.04. A properly configured firewall is one of the most important aspects of overall system security.

Prerequisites

Only root or users with sudo privileges can manage the system firewall. The best practice is to run administrative tasks as a sudo user.

Install UFW

UFW is part of the standard Ubuntu 24.04 installation and should be present on your system. If for some reason it is not installed, you can install the package by typing:

Terminal
sudo apt update
sudo apt install ufw

Check UFW Status

UFW is disabled by default. You can check the status of the UFW service with the following command:

Terminal
sudo ufw status verbose

The output will show that the firewall status is inactive:

output
Status: inactive

If UFW is activated, the output will look something like the following:

output
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

UFW Default Policies

The default behavior of the UFW firewall is to block all incoming and forwarding traffic and allow all outbound traffic. This means that anyone trying to access your server will not be able to connect unless you specifically open the port. Applications and services running on your server will be able to access the outside world.

The default policies are defined in the /etc/default/ufw file and can be changed either by manually modifying the file or with the sudo ufw default <policy> <chain> command.

Firewall policies are the foundation for building more complex and user-defined rules. Generally, the initial UFW default policies are a good starting point.

Application Profiles

An application profile is a text file in INI format that describes the service and contains firewall rules for the service. Application profiles are created in the /etc/ufw/applications.d directory during the installation of the package.

You can list all application profiles available on your server by typing:

Terminal
sudo ufw app list

Depending on the packages installed on your system, the output will look similar to the following:

output
Available applications:
Nginx Full
Nginx HTTP
Nginx HTTPS
OpenSSH

To find more information about a specific profile and included rules, use the following command:

Terminal
sudo ufw app info 'Nginx Full'

The output shows that the ‘Nginx Full’ profile opens ports 80 and 443.

output
Profile: Nginx Full
Title: Web Server (Nginx, HTTP + HTTPS)
Description: Small, but very powerful and efficient web server
Ports:
80,443/tcp

You can also create custom profiles for your applications.

Enabling UFW

If you are connecting to your Ubuntu server from a remote location, before enabling the UFW firewall you must explicitly allow incoming SSH connections. Otherwise, you will no longer be able to connect to the machine.

To configure your UFW firewall to allow incoming SSH connections, type the following command:

Terminal
sudo ufw allow ssh
output
Rules updated
Rules updated (v6)

If SSH is running on a non-standard port , you need to open that port.

For example, if your ssh daemon listens on port 7722, enter the following command to allow connections on that port:

Terminal
sudo ufw allow 7722/tcp

Now that the firewall is configured to allow incoming SSH connections, you can enable it by typing:

Terminal
sudo ufw enable
output
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup

You will be warned that enabling the firewall may disrupt existing ssh connections; type y and hit Enter.

Opening Ports

Depending on the applications that run on the system, you may also need to open other ports. The general syntax to open a port is as follows:

Terminal
ufw allow port_number/protocol

Below are a few ways to allow HTTP connections.

The first option is to use the service name. UFW checks the /etc/services file for the port and protocol of the specified service:

Terminal
sudo ufw allow http

You can also specify the port number and the protocol:

Terminal
sudo ufw allow 80/tcp

When no protocol is given, UFW creates rules for both tcp and udp.

Another option is to use the application profile; in this case, ‘Nginx HTTP’:

Terminal
sudo ufw allow 'Nginx HTTP'

UFW also supports another syntax for specifying the protocol using the proto keyword:

Terminal
sudo ufw allow proto tcp to any port 80

Port Ranges

UFW also allows you to open port ranges. The start and the end ports are separated by a colon (:), and you must specify the protocol, either tcp or udp.

For example, if you want to allow ports from 7100 to 7200 on both tcp and udp, run the following commands:

Terminal
sudo ufw allow 7100:7200/tcp
sudo ufw allow 7100:7200/udp

Specific IP Address and Port

To allow connections on all ports from a given source IP, use the from keyword followed by the source address.

Here is an example of allowlisting an IP address:

Terminal
sudo ufw allow from 64.63.62.61

If you want to allow the given IP address access only to a specific port, use the to any port keyword followed by the port number.

For example, to allow access on port 22 from a machine with IP address 64.63.62.61, enter:

Terminal
sudo ufw allow from 64.63.62.61 to any port 22

Subnets

The syntax for allowing connections to a subnet of IP addresses is the same as when using a single IP address. The only difference is that you need to specify the netmask.

Below is an example showing how to allow access for IP addresses ranging from 192.168.1.1 to 192.168.1.254 to port 3306 (MySQL):

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

Specific Network Interface

To allow connections on a particular network interface, use the in on keyword followed by the name of the network interface:

Terminal
sudo ufw allow in on eth2 to any port 3306

Denying Connections

The default policy for all incoming connections is set to deny, and if you have not changed it, UFW will block all incoming connections unless you specifically open the connection.

Writing deny rules is the same as writing allow rules; you only need to use the deny keyword instead of allow.

Say you opened ports 80 and 443, and your server is under attack from the 23.24.25.0/24 network. To deny all connections from that network, run:

Terminal
sudo ufw deny from 23.24.25.0/24

To deny access only to ports 80 and 443 from 23.24.25.0/24, use the following command:

Terminal
sudo ufw deny proto tcp from 23.24.25.0/24 to any port 80,443

Deleting UFW Rules

There are two ways to delete UFW rules: by rule number, and by specifying the actual rule.

Deleting rules by rule number is easier, especially when you are new to UFW. To delete a rule by number, first find the number of the rule you want to delete. To get a list of numbered rules, use the ufw status numbered command:

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

To delete rule number 3, the one that allows connections to port 8080, enter:

Terminal
sudo ufw delete 3

The second method is to delete a rule by specifying the actual rule. For example, if you added a rule to open port 8069 you can delete it with:

Terminal
sudo ufw delete allow 8069

Disabling UFW

If you want to stop UFW and deactivate all the rules, use:

Terminal
sudo ufw disable

To re-enable UFW and activate all rules, type:

Terminal
sudo ufw enable

Resetting UFW

Resetting UFW will disable it and delete all active rules. This is helpful if you want to revert all your changes and start fresh.

To reset UFW, run the following command:

Terminal
sudo ufw reset

IP Masquerading

IP Masquerading is a variant of NAT (network address translation) in the Linux kernel that translates network traffic by rewriting the source and destination IP addresses and ports. With IP Masquerading, you can allow one or more machines in a private network to communicate with the internet using one Linux machine that acts as a gateway.

Configuring IP Masquerading with UFW involves several steps.

First, you need to enable IP forwarding. To do that, open the /etc/ufw/sysctl.conf file:

Terminal
sudo nano /etc/ufw/sysctl.conf

Find and uncomment the line which reads net.ipv4.ip_forward=1:

/etc/ufw/sysctl.confini
net.ipv4.ip_forward=1

Next, you need to configure UFW to allow forwarded packets. Open the UFW configuration file:

Terminal
sudo nano /etc/default/ufw

Locate the DEFAULT_FORWARD_POLICY key, and change the value from DROP to ACCEPT:

/etc/default/ufwini
DEFAULT_FORWARD_POLICY="ACCEPT"

Now you need to set the default policy for the POSTROUTING chain in the nat table and the masquerade rule. To do so, open the /etc/ufw/before.rules file:

Terminal
sudo nano /etc/ufw/before.rules

Append the following lines:

/etc/ufw/before.rulesini
#NAT table rules
*nat
:POSTROUTING ACCEPT [0:0]

# Forward traffic through eth0 - Change to public network interface
-A POSTROUTING -s 10.8.0.0/16 -o eth0 -j MASQUERADE

# don't delete the 'COMMIT' line or these rules won't be processed
COMMIT

Replace eth0 in the -A POSTROUTING line with the name of your public network interface.

When you are done, save and close the file. Finally, reload the UFW rules:

Terminal
sudo ufw disable
sudo ufw enable

Troubleshooting

UFW blocks SSH after enabling it
If you enabled UFW without first allowing SSH, you will lose access to a remote server. To recover, you need console access (via your hosting provider’s web console) and run sudo ufw allow ssh followed by sudo ufw enable. Always allow SSH before enabling UFW on a remote machine.

Rules not active after ufw reset
After a reset, UFW is disabled and all rules are cleared. You need to re-add your rules and run sudo ufw enable to bring the firewall back up.

IPv6 rules not created
If ufw status shows rules only for IPv4, check that IPV6=yes is set in /etc/default/ufw, then run sudo ufw disable && sudo ufw enable to reload the configuration.

Application profile not found
If sudo ufw allow 'Nginx Full' returns an error, the profile may not be installed yet. Install the relevant package first (for example, nginx), then retry.

Conclusion

We have shown you how to install and configure a UFW firewall on your Ubuntu 24.04 server. For a full reference of UFW commands and options, see the UFW man page .

从零到一:我在Solana NFT铸造前端中搞定@solana/web3.js连接与交易

背景

上个月,团队决定开拓新链,启动了一个基于Solana的NFT铸造项目。作为团队里Web3前端经验相对丰富的,我自然被分配了搭建前端DApp的任务。我之前主要深耕以太坊和EVM兼容链,对ethers.jswagmi那一套滚瓜烂熟,心想换个链的SDK能有多难?结果,从熟悉的ethers.providers.Web3Provider切换到@solana/web3.jsConnection类,从MetaMask切换到Phantom钱包,这一路的“水土不服”让我踩的坑比预想的多得多。我的首要目标很简单:让用户能用Phantom钱包连接,并正确显示其SOL余额。

问题分析

一开始,我试图沿用EVM链的思维模式。在以太坊上,流程通常是:注入的window.ethereum -> new ethers.providers.Web3Provider() -> 获取账号和余额。我查了@solana/web3.js的文档,发现核心是Connection(连接节点)和PublicKey(地址)。我的初步思路是:

  1. 检测Phantom钱包(window.solana)。
  2. 连接钱包,获取公钥(PublicKey)。
  3. Connection查询该公钥的余额。

听起来很直接,但我马上遇到了第一个拦路虎:连接钱包后,余额始终为0。我确认了钱包里有SOL,RPC节点也换了好几个(devnet, mainnet-beta的公共节点)。排查后发现,问题出在两个地方:一是对Solana余额单位(lamports vs SOL)的转换不熟悉,二是没有正确处理钱包连接和状态变化的异步事件。这让我意识到,不能简单照搬EVM的模式,得从头理解Solana前端的交互逻辑。

核心实现

1. 环境搭建与钱包检测

首先,创建一个React + TypeScript项目,并安装核心依赖:

npm install @solana/web3.js @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets @solana/wallet-adapter-phantom

这里有个关键点:单纯用@solana/web3.js也能直接操作window.solana,但社区更推荐使用@solana/wallet-adapter-*这一套工具库。它提供了React上下文、钩子和一套标准的UI组件,能更好地管理钱包状态、支持多钱包,并处理了大量底层细节。我决定采用这个推荐方案,避免重复造轮子。

钱包检测和连接的核心逻辑,我们封装在自定义钩子或上下文中。但首先,要在应用根组件进行配置。

2. 配置钱包上下文与连接节点

App.tsx或主组件中,我们需要设置钱包适配器和提供连接。

// App.tsx
import React, { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';

// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';

function App() {
  // 配置网络。这里以开发网为例,上线需切主网
  const network = WalletAdapterNetwork.Devnet;
  // 使用Memoized,避免每次渲染都创建新的endpoint和wallets实例
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);
  
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      // 可以添加其他钱包适配器,如Solflare
    ],
    []
  );

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          {/* 你的应用组件 */}
          <MyWalletComponent />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

注意这个细节ConnectionProviderendpoint参数非常重要。公共节点可能有速率限制或不稳定,对于生产环境,强烈建议使用付费的RPC服务(如QuickNode, Helius)提供的专属节点URL,这能极大提升连接稳定性和查询速度。

3. 连接钱包与获取余额

接下来,在具体的组件MyWalletComponent中,我们使用适配器提供的钩子来操作钱包和获取数据。

// components/MyWalletComponent.tsx
import React, { useEffect, useState } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';

export const MyWalletComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  // 效果:当钱包连接状态或公钥变化时,获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (!connection || !publicKey) {
        setBalance(null);
        return;
      }
      setLoading(true);
      try {
        // 这里有个坑:getBalance返回的是lamports(1 SOL = 10^9 lamports)
        const lamportsBalance = await connection.getBalance(publicKey);
        // 转换为SOL单位
        const solBalance = lamportsBalance / LAMPORTS_PER_SOL;
        setBalance(solBalance);
      } catch (error) {
        console.error('获取余额失败:', error);
        setBalance(null);
      } finally {
        setLoading(false);
      }
    };

    fetchBalance();
    // 可以设置一个定时器来轮询余额,但对于实时性要求高的,建议用websocket订阅
  }, [connection, publicKey]); // 依赖项:连接对象和公钥

  return (
    <div>
      <WalletMultiButton />
      {connected && publicKey ? (
        <div>
          <p>钱包地址: {publicKey.toBase58()}</p>
          {loading ? (
            <p>查询余额中...</p>
          ) : (
            <p>余额: {balance !== null ? `${balance.toFixed(4)} SOL` : '--'}</p>
          )}
        </div>
      ) : (
        <p>请连接钱包</p>
      )}
    </div>
  );
};

这里有个大坑connection.getBalance(publicKey)返回的是number类型的lamports,而不是SOL。直接显示这个数字会让人误以为余额极小。必须除以LAMPORTS_PER_SOL(一个常量,值为1_000_000_000)来转换。这是我一开始显示余额为0的罪魁祸首之一(因为我的devnet账户余额是2 SOL,显示为2_000_000_000 lamports,我误以为是0)。

4. 构造并发送一笔简单的转账交易

显示余额之后,下一步自然是想让用户能操作。我们实现一个简单的SOL转账功能。

// 在MyWalletComponent中添加状态和函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
// ... 其他导入

export const MyWalletComponent: React.FC = () => {
  // ... 之前的 states 和 hooks
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');
  const [sending, setSending] = useState(false);

  const handleSendSol = async () => {
    if (!publicKey || !recipient || !amount) {
      alert('请填写完整信息');
      return;
    }
    const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
    if (isNaN(lamports) || lamports <= 0) {
      alert('请输入有效的金额');
      return;
    }

    setSending(true);
    try {
      // 1. 创建交易对象
      const transaction = new Transaction();
      
      // 2. 添加转账指令
      const transferInstruction = SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: new PublicKey(recipient),
        lamports,
      });
      transaction.add(transferInstruction);

      // 3. 获取最近的区块哈希(Recent Blockhash),这是Solana交易必需的
      const { blockhash } = await connection.getRecentBlockhash();
      transaction.recentBlockhash = blockhash;
      // 设置付费方(fee payer)
      transaction.feePayer = publicKey;

      // 4. 发送交易并等待确认
      // 注意:这里需要钱包适配器来签名,不能直接用sendAndConfirmTransaction
      // 我们先获取签名,然后发送
      const signature = await sendTransaction(transaction, connection);
      
      // 5. 等待确认(可选,对于快速反馈,可以只等“预确认”)
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`转账成功!交易哈希: ${signature}`);
      // 成功后刷新余额
      const newBalance = await connection.getBalance(publicKey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
      setRecipient('');
      setAmount('');
    } catch (error: any) {
      console.error('转账失败:', error);
      alert(`转账失败: ${error.message}`);
    } finally {
      setSending(false);
    }
  };

  // 注意:我们需要从useWallet钩子中解构出sendTransaction函数
  const { sendTransaction } = useWallet();

  return (
    <div>
      {/* ... 之前的连接和余额显示代码 */}
      {connected && (
        <div>
          <h3>转账SOL</h3>
          <input
            type="text"
            placeholder="接收方地址"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
          />
          <input
            type="number"
            step="any"
            placeholder="金额 (SOL)"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
          />
          <button onClick={handleSendSol} disabled={sending}>
            {sending ? '发送中...' : '发送'}
          </button>
        </div>
      )}
    </div>
  );
};

这里有个至关重要的区别:在EVM链,我们通常用signer.sendTransaction(tx)一步完成签名和发送。而在Solana,构造交易(Transaction)和签名/发送是分离的。我们先用@solana/web3.js构造一个包含指令(Instruction)和必要元数据(blockhash, feePayer)的交易对象,然后通过钱包适配器提供的sendTransaction方法,将交易对象交给钱包(如Phantom)去签名并发送到网络。这是Solana交易模型的一个核心特点。

完整代码

以下是一个整合后的、可直接运行的简化版App.tsx,展示了完整的连接、查余额、转账流程。

// App.tsx
import React, { useMemo, useState, useEffect } from 'react';
import { ConnectionProvider, WalletProvider, useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { WalletModalProvider, WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl, PublicKey, SystemProgram, Transaction, LAMPORTS_PER_SOL } from '@solana/web3.js';
import '@solana/wallet-adapter-react-ui/styles.css';

// 主应用包装器
function AppWrapper() {
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);
  const wallets = useMemo(() => [new PhantomWalletAdapter()], []);

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
            <h1>Solana Web3.js 入门实战</h1>
            <WalletDemo />
          </div>
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

// 主要演示组件
function WalletDemo() {
  const { connection } = useConnection();
  const { publicKey, connected, sendTransaction } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');
  const [sending, setSending] = useState(false);

  // 获取余额
  useEffect(() => {
    const updateBalance = async () => {
      if (!connection || !publicKey) {
        setBalance(null);
        return;
      }
      setLoading(true);
      try {
        const lamports = await connection.getBalance(publicKey);
        setBalance(lamports / LAMPORTS_PER_SOL);
      } catch (err) {
        console.error(err);
        setBalance(null);
      } finally {
        setLoading(false);
      }
    };
    updateBalance();
  }, [connection, publicKey]);

  // 处理转账
  const handleSend = async () => {
    if (!publicKey || !recipient || !amount || !sendTransaction) return;
    const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
    if (isNaN(lamports) || lamports <= 0) {
      alert('Invalid amount');
      return;
    }

    setSending(true);
    try {
      const transaction = new Transaction();
      transaction.add(
        SystemProgram.transfer({
          fromPubkey: publicKey,
          toPubkey: new PublicKey(recipient),
          lamports,
        })
      );

      const { blockhash } = await connection.getRecentBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      const signature = await sendTransaction(transaction, connection);
      console.log('Transaction signature:', signature);
      // 等待确认,可根据需求调整确认级别('processed', 'confirmed', 'finalized')
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`Sent ${amount} SOL to ${recipient}! Tx: ${signature}`);

      // 刷新余额
      const newLamports = await connection.getBalance(publicKey);
      setBalance(newLamports / LAMPORTS_PER_SOL);
      setRecipient('');
      setAmount('');
    } catch (error: any) {
      console.error('Send failed:', error);
      alert(`Send failed: ${error.message}`);
    } finally {
      setSending(false);
    }
  };

  return (
    <div>
      <div style={{ marginBottom: '20px' }}>
        <WalletMultiButton />
      </div>

      {connected && publicKey ? (
        <div>
          <p>
            <strong>Address:</strong> {publicKey.toBase58().slice(0, 8)}...
          </p>
          <p>
            <strong>Balance:</strong>{' '}
            {loading ? 'Loading...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
          </p>

          <div style={{ marginTop: '30px', borderTop: '1px solid #ccc', paddingTop: '20px' }}>
            <h3>Transfer SOL</h3>
            <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '400px' }}>
              <input
                type="text"
                placeholder="Recipient Public Key"
                value={recipient}
                onChange={(e) => setRecipient(e.target.value)}
                style={{ padding: '8px' }}
              />
              <input
                type="number"
                step="any"
                placeholder="Amount (SOL)"
                value={amount}
                onChange={(e) => setAmount(e.target.value)}
                style={{ padding: '8px' }}
              />
              <button onClick={handleSend} disabled={sending} style={{ padding: '10px' }}>
                {sending ? 'Sending...' : 'Send'}
              </button>
            </div>
            <p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
              <small>Use devnet SOL for testing. Get some from a faucet.</small>
            </p>
          </div>
        </div>
      ) : (
        <p>Connect your wallet to get started.</p>
      )}
    </div>
  );
}

export default AppWrapper;

踩坑记录

  1. 余额显示为0或极小值:这是最经典的坑。connection.getBalance()返回的是lamports,我没做转换就直接显示。解决方法:牢记 SOL = lamports / LAMPORTS_PER_SOL
  2. 交易发送失败:Missing recent blockhash:构造交易对象Transaction后,没有设置recentBlockhashfeePayer属性就直接发送。解决方法:必须在发送前调用connection.getRecentBlockhash()获取,并赋值给transaction.recentBlockhash,同时明确指定transaction.feePayer
  3. Phantom钱包弹窗连接后,状态没更新:直接监听window.solanaconnect事件,但React状态管理混乱。解决方法:使用@solana/wallet-adapter-react提供的useWallet钩子,它封装了状态管理,connectedpublicKey状态会自动更新。
  4. sendTransaction is not a function:我试图直接从@solana/web3.js导入sendAndConfirmTransaction并传入交易对象,但这需要私钥签名者。在浏览器前端,私钥由钱包保管。解决方法:使用从useWallet()钩子解构出来的sendTransaction方法,它将交易发送到钱包扩展进行签名。

小结

这一趟下来,我最大的收获是理解了Solana前端交互的“范式转换”:从EVM的Provider/Signer模型,转向Solana的Connection/Transaction/Wallet Adapter模型。核心在于明确职责分离:前端构造交易,钱包负责签名。掌握了连接、查余额、转账这三板斧,就算在Solana前端开发中站稳了脚跟。接下来,可以继续深挖如何与智能合约(Solana叫Program)交互,比如调用一个NFT铸造的指令,那又会涉及到不同的指令构造和账户(Account)管理,将是下一个有趣的挑战。

自定义 Hooks 实战(上):封装技巧与 useLocalStorage

引言

在 React 开发中,Hooks 已经成为状态管理和逻辑复用的核心工具。除了 React 内置的 Hooks,自定义 Hooks 让我们能够将组件逻辑提取到可重用的函数中,实现更好的代码组织和复用。

今天我们来深入探讨自定义 Hooks 的封装技巧,并通过一个实用的 useLocalStorage Hook 来演示如何构建高质量的自定义 Hooks。

自定义 Hooks 的核心原则

1. 命名规范

自定义 Hooks 必须以 use 开头,这是 React 的硬性要求,也是代码可读性的保障:

// ✅ 正确
const useLocalStorage = (key, initialValue) => { ... }
const useFetch = (url) => { ... }
const useDebounce = (value, delay) => { ... }

// ❌ 错误
const localStorageHook = (key, initialValue) => { ... }
const fetchData = (url) => { ... }

2. 单一职责

每个自定义 Hooks 应该只负责一件事,保持逻辑清晰:

// ✅ 好的设计:每个 Hook 职责单一
const { user } = useAuth();
const { data } = useFetch('/api/user');
const { theme } = useTheme();

// ❌ 避免:一个 Hook 做太多事
const useEverything = () => {
  // 认证 + 数据获取 + 主题管理...
}

3. 返回值设计

返回清晰的接口,优先使用对象解构:

// ✅ 清晰的返回值
const { value, setValue, removeValue } = useLocalStorage('key', 'default');

// ✅ 多个返回值时用数组
const [count, setCount] = useCounter(0);

实战:useLocalStorage Hook

基础实现

useLocalStorage 是最常用的自定义 Hooks 之一,它让本地存储变得简单优雅:

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // 读取初始值
  const readValue = () => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  };

  // 使用 lazy initialization
  const [storedValue, setStoredValue] = useState(readValue);

  // 监听其他标签页的变化
  useEffect(() => {
    const handleStorageChange = (event) => {
      if (event.key === key && event.newValue !== null) {
        try {
          setStoredValue(JSON.parse(event.newValue));
        } catch (error) {
          console.warn('Error parsing storage event:', error);
        }
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  // 返回包装的 setter
  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
        // 触发当前标签页的事件
        window.dispatchEvent(new Event('local-storage'));
      }
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };

  const removeValue = () => {
    try {
      setStoredValue(initialValue);
      if (typeof window !== 'undefined') {
        window.localStorage.removeItem(key);
        window.dispatchEvent(new Event('local-storage'));
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue, removeValue];
}

export default useLocalStorage;

使用示例

import useLocalStorage from './hooks/useLocalStorage';

function UserProfile() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [user, setUser] = useLocalStorage('user', null);

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  const logout = () => {
    setUser(null);
  };

  return (
    <div className={`app ${theme}`}>
      <button onClick={toggleTheme}>
        切换到{theme === 'light' ? '深色' : '浅色'}模式
      </button>
      
      {user ? (
        <div>
          <p>欢迎,{user.name}!</p>
          <button onClick={logout}>退出登录</button>
        </div>
      ) : (
        <button onClick={() => setUser({ name: '访客' })}>
          模拟登录
        </button>
      )}
    </div>
  );
}

进阶:添加类型安全(TypeScript 版本)

import { useState, useEffect, Dispatch, SetStateAction } from 'react';

type SetValue<T> = Dispatch<SetStateAction<T>>;

function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>, () => void] {
  const readValue = (): T => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  };

  const [storedValue, setStoredValue] = useState<T>(readValue);

  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key && event.newValue !== null) {
        try {
          setStoredValue(JSON.parse(event.newValue) as T);
        } catch (error) {
          console.warn('Error parsing storage event:', error);
        }
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  const setValue: SetValue<T> = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
        window.dispatchEvent(new Event('local-storage'));
      }
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };

  const removeValue = () => {
    try {
      setStoredValue(initialValue);
      if (typeof window !== 'undefined') {
        window.localStorage.removeItem(key);
        window.dispatchEvent(new Event('local-storage'));
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue, removeValue];
}

export default useLocalStorage;

封装技巧总结

  1. SSR 兼容:始终检查 window 是否存在
  2. 错误处理:用 try-catch 包裹 localStorage 操作
  3. 事件同步:监听 storage 事件实现多标签页同步
  4. 函数式更新:支持传入函数进行状态更新
  5. 类型安全:使用泛型提供完整的 TypeScript 支持

总结

自定义 Hooks 是 React 逻辑复用的强大工具。通过遵循命名规范、保持单一职责、设计清晰的返回值,我们可以构建出易于理解和维护的 Hooks。

useLocalStorage 作为一个经典案例,展示了如何处理浏览器 API、错误边界、跨标签页同步等实际问题。在下篇中,我们将继续探索 useFetchuseDebounceuseInterval 等更多实用的自定义 Hooks。

前端工程化基石:package.json 40+ 字段逐一拆解

每个前端项目的根目录下几乎都有一个 package.json,但你真的了解它的每个字段吗?本文将从基础字段高级配置,逐一拆解 package.json 中的所有字段,帮你彻底搞懂它。


一、必填字段

1.1 name — 包名

{
  "name": "@packageName/sdk"
}

规则:

  • 长度不超过 214 个字符
  • 不能以 ._ 开头
  • 不能包含大写字母
  • 不能包含 URL 不安全字符(如空格、~ 等)
  • 支持 scope(作用域),格式为 @scope/name,常用于组织级别的包管理,例如 @vue/cli@babel/core

作用:
name 是包的唯一标识符。当你执行 npm install xxx 时,xxx 就是这个字段的值。配合 version,它们共同构成了包的"身份证"。


1.2 version — 版本号

{
  "version": "1.6.7"
}

必须遵循 Semantic Versioning(语义化版本) 规范,格式为 MAJOR.MINOR.PATCH

含义 示例场景
MAJOR 不兼容的 API 变更 重构了核心 API
MINOR 向下兼容的功能新增 新增了一个工具函数
PATCH 向下兼容的问题修复 修复了一个边界 Bug

还支持预发布标签:1.0.0-alpha.11.0.0-beta.21.0.0-rc.1


二、描述信息字段

2.1 description — 包描述

{
  "description": "packageDescription"
}

简短描述包的功能,会展示在 npm search 的搜索结果中,也是 npm 官网搜索排序的权重因子之一。

2.2 keywords — 关键词

{
  "keywords": ["cloud", "sdk", "vue", "plugin", "micro-frontend"]
}

字符串数组,用于 npm 官网的搜索优化(SEO),帮助其他开发者更快找到你的包。

2.3 homepage — 项目主页

{
  "homepage": "https://github.com/user/project#readme"
}

项目官网或文档地址,会展示在 npm 包详情页的侧边栏。

2.4 bugs — Bug 反馈地址

{
  "bugs": {
    "url": "https://github.com/user/project/issues",
    "email": "bugs@example.com"
  }
}

也可以简写为字符串:"bugs": "https://github.com/user/project/issues"

2.5 license — 开源协议

{
  "license": "MIT"
}

常见协议:

协议 特点
MIT 极其宽松,几乎无限制
Apache-2.0 允许商用,需保留版权,提供专利许可
GPL-3.0 传染性协议,衍生作品也需开源
ISC 类似 MIT,更简洁
UNLICENSED 私有包,不允许他人使用

2.6 author — 作者

{
  "author": {
    "name": "张三",
    "email": "zhangsan@example.com",
    "url": "https://zhangsan.dev"
  }
}

也支持简写形式:"author": "张三 <zhangsan@example.com> (https://zhangsan.dev)"

2.7 contributors — 贡献者

{
  "contributors": [
    { "name": "李四", "email": "lisi@example.com" },
    "王五 <wangwu@example.com>"
  ]
}

格式同 author,是一个数组。

2.8 funding — 赞助信息

{
  "funding": {
    "type": "opencollective",
    "url": "https://opencollective.com/project"
  }
}

也支持数组形式,用于声明多个赞助渠道。执行 npm fund 可查看项目的赞助信息。


三、入口文件字段

这是 package.json 中最核心也最容易混淆的一组字段,直接决定了别人引用你的包时,加载的是哪个文件。

3.1 main — CommonJS 入口

{
  "main": "dist/cloud-sdk.umd.js"
}

作用:
Node.js 和旧版打包工具默认读取的入口。当执行 require('your-package') 时,实际加载的就是 main 指向的文件。

3.2 module — ESModule 入口

{
  "module": "dist/cloud-sdk.esm.js"
}

作用:
这不是 Node.js 官方字段,而是由打包工具(Webpack、Rollup、Vite)约定的。当打包工具发现 module 字段时,会优先使用它,因为 ESM 格式支持 Tree Shaking,能有效减小打包体积。

3.3 browser — 浏览器入口

{
  "browser": "dist/cloud-sdk.browser.js"
}

当包需要在浏览器中运行,且浏览器版本与 Node 版本实现不同时使用。打包工具在构建浏览器端代码时会优先读取此字段。

也支持对象形式,用于替换特定模块:

{
  "browser": {
    "./lib/server-utils.js": "./lib/browser-utils.js",
    "fs": false
  }
}

3.4 types / typings — TypeScript 类型入口

{
  "types": "dist/index.d.ts"
}

指定 TypeScript 类型声明文件的入口路径。typestypings 等价,推荐用 types

3.5 exports — 条件导出(重点!)

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/cloud-sdk.esm.js",
      "require": "./dist/cloud-sdk.umd.js"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.esm.js",
      "require": "./dist/utils.cjs.js"
    }
  }
}

这是 Node.js 12.11+ 引入的现代模块解析方案,是 mainmodulebrowser 的"终极替代方案"。

核心能力:

特性 说明
条件导出 根据环境(import / require / node / browser / default)返回不同文件
子路径导出 允许 import { foo } from 'pkg/utils' 形式的子路径引用
封装隔离 未在 exports 中声明的路径,外部无法访问,保护内部实现

条件匹配的优先级(从上到下):

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "node": {
        "import": "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "browser": "./dist/browser.js",
      "default": "./dist/index.js"
    }
  }
}

注意: types 条件必须放在最前面,否则 TypeScript 可能无法正确解析类型。

3.6 type — 模块系统声明

{
  "type": "module"
}
含义
"module" .js 文件默认作为 ESModule 处理
"commonjs"(默认值) .js 文件默认作为 CommonJS 处理

设置为 "module" 后:

  • .js → ESM
  • .cjs → CommonJS(强制)
  • .mjs → ESM(强制)

四、文件管控字段

4.1 files — 发布包含的文件

{
  "files": ["dist", "README.md", "LICENSE"]
}

白名单机制,指定 npm publish 时需要包含的文件和目录。类似 .gitignore 的反向操作。

始终包含的文件(无法排除):

  • package.json
  • README(任何大小写和扩展名)
  • LICENSE / LICENCE
  • CHANGELOG
  • main 字段指向的文件

始终排除的文件(无法包含):

  • .git
  • node_modules
  • .npmrc
  • package-lock.json

技巧: 也可以用 .npmignore 做黑名单控制,但 files 字段优先级更高,两者同时存在时以 files 为准。

4.2 directories — 项目目录结构

{
  "directories": {
    "lib": "src/lib",
    "bin": "bin",
    "man": "man",
    "doc": "docs",
    "example": "examples",
    "test": "test"
  }
}

声明项目的目录结构。实际使用较少,主要是一种语义化描述。


五、脚本与命令字段

5.1 scripts — NPM 脚本

{
  "scripts": {
    "dev": "vite build --watch",
    "build": "vite build",
    "lint": "eslint src",
    "lint:fix": "eslint src --fix",
    "format": "prettier --write src",
    "prepare": "husky install",
    "preinstall": "npx only-allow pnpm"
  }
}

通过 npm run <script-name> 执行。部分脚本名有特殊含义:

生命周期脚本:

脚本名 触发时机
preinstall 安装依赖之前执行
install 安装依赖时执行
postinstall 安装依赖之后执行
prepare npm install 之后、npm publish 之前执行
prepublishOnly 仅在 npm publish 之前执行
prepack 打 tarball 之前(npm pack / npm publish
postpack 打 tarball 之后

pre/post 钩子:

任何自定义脚本都可以加 pre / post 前缀:

{
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "vite build",
    "postbuild": "echo 构建完成"
  }
}

执行 npm run build 会依次执行:prebuildbuildpostbuild

注意: pnpm 和 yarn 现代版本默认不会自动执行 pre/post 钩子,需手动配置开启。

5.2 bin — 可执行文件

{
  "bin": {
    "create-uver": "./bin/create.js"
  }
}

当用户全局安装(npm install -g)或通过 npx 执行时,系统会创建软链接到 bin 指定的文件。

如果只有一个可执行文件,可以简写为:

{
  "name": "create-uver",
  "bin": "./bin/create.js"
}

此时命令名就是 name 字段的值。

5.3 man — 帮助手册

{
  "man": ["./man/doc.1", "./man/doc.2"]
}

指定 man 命令的文档文件路径,文件必须以数字结尾或以 .gz 压缩。


六、依赖管理字段

6.1 dependencies — 生产依赖

{
  "dependencies": {
    "lodash-es": "^4.17.21",
    "vue": "^3.4.0",
    "vue-router": "^4.5.0"
  }
}

项目运行时必须的依赖,npm install 默认安装,最终会被打包进产物中。

6.2 devDependencies — 开发依赖

{
  "devDependencies": {
    "typescript": "^5.3.3",
    "vite": "^6.3.5",
    "eslint": "^9.3.4",
    "prettier": "^3.2.5"
  }
}

仅开发阶段需要的依赖(构建工具、Linter、测试框架等)。其他项目安装你的包时不会安装 devDependencies

6.3 peerDependencies — 宿主依赖

{
  "peerDependencies": {
    "vue": "^3.0.0",
    "react": "^18.0.0"
  }
}

声明"我需要宿主环境提供这个依赖",而不是自己安装一份。最经典的场景是 UI 组件库 —— element-plus 声明 peerDependencies: { "vue": "^3.0.0" },因为它不应该自带一份 Vue。

npm 版本 行为
npm 3-6 仅发出警告
npm 7+ 自动安装 peerDependencies

6.4 peerDependenciesMeta — 宿主依赖元信息

{
  "peerDependencies": {
    "vue": "^3.0.0",
    "react": "^18.0.0"
  },
  "peerDependenciesMeta": {
    "react": {
      "optional": true
    }
  }
}

标记某个 peerDependency 为可选,未安装时不会报警告。

6.5 optionalDependencies — 可选依赖

{
  "optionalDependencies": {
    "fsevents": "^2.3.0"
  }
}

安装失败时不会导致整个 npm install 失败。典型场景:fsevents 仅在 macOS 下可用。

6.6 bundleDependencies / bundledDependencies — 捆绑依赖

{
  "bundleDependencies": ["lodash", "chalk"]
}

npm pack 时会将这些依赖打包进 tarball。适用于需要确保特定版本依赖的场景,或内网环境发布。

6.7 overrides(npm)/ resolutions(yarn)— 依赖覆盖

npm(overrides):

{
  "overrides": {
    "source-map": "^0.7.4"
  }
}

yarn(resolutions):

{
  "resolutions": {
    "source-map": "^0.7.4"
  }
}

pnpm(pnpm.overrides):

{
  "pnpm": {
    "overrides": {
      "source-map": "^0.7.4"
    }
  }
}

强制将依赖树中所有匹配的包替换为指定版本。常用于修复深层依赖的安全漏洞或兼容性问题。

版本号范围速查

符号 含义 示例 匹配范围
^ 兼容版本 ^1.2.3 >=1.2.3 <2.0.0
~ 近似版本 ~1.2.3 >=1.2.3 <1.3.0
>= 大于等于 >=1.2.3 >=1.2.3
* 任意版本 * 所有版本
无符号 精确版本 1.2.3 1.2.3
` ` ^1.0.0 || ^2.0.0 满足任一条件

七、发布配置字段

7.1 private — 私有包

{
  "private": true
}

设置为 true 后,npm publish 会直接拒绝发布。用于防止 monorepo 根目录或内部项目被意外发布到公共 npm。

7.2 publishConfig — 发布配置

{
  "publishConfig": {
    "registry": "http://jfrog.gdu-tech.com/artifactory/api/npm/gdu-npm-package/",
    "access": "public",
    "tag": "latest"
  }
}
字段 说明
registry 发布到指定 npm 仓库(私有源)
access "public""restricted",scope 包默认 restricted
tag 发布时的 dist-tag,默认 latest

7.3 repository — 仓库信息

{
  "repository": {
    "type": "git",
    "url": "https://github.com/user/project.git",
    "directory": "packages/cloud-sdk"
  }
}

directory 字段在 monorepo 中非常有用,指明包在仓库中的具体位置。

npm 官网会根据此字段在包详情页展示源码链接。


八、环境约束字段

8.1 engines — 运行环境要求

{
  "engines": {
    "node": ">=18.0.0",
    "pnpm": ">=9.15.0",
    "npm": ">=8.0.0"
  }
}

声明项目所需的 Node.js 和包管理器版本。默认仅作为建议,如需强制校验:

  • npm:.npmrc 中设置 engine-strict=true
  • yarn: 自动强制检查
  • pnpm: 自动强制检查

8.2 os — 操作系统限制

{
  "os": ["darwin", "linux", "!win32"]
}

限制包可运行的操作系统。! 前缀表示排除。

8.3 cpu — CPU 架构限制

{
  "cpu": ["x64", "arm64", "!ia32"]
}

限制包可运行的 CPU 架构。

8.4 packageManager — 指定包管理器

{
  "packageManager": "pnpm@9.15.0"
}

Node.js 16.9+ 引入的 Corepack 特性。声明项目使用的包管理器及精确版本,搭配 corepack enable,其他包管理器会被拦截。


九、Monorepo 相关字段

9.1 workspaces — 工作空间

npm/yarn:

{
  "workspaces": [
    "packages/*",
    "business/*"
  ]
}

pnpm 使用独立的 pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'business/*'

工作空间允许在一个仓库中管理多个包,共享 node_modules,实现包之间的互相引用。

9.2 pnpm — pnpm 专有配置

{
  "pnpm": {
    "overrides": {
      "source-map": "^0.7.4"
    },
    "peerDependencyRules": {
      "ignoreMissing": ["@babel/*"],
      "allowedVersions": {
        "vue": "3"
      }
    },
    "neverBuiltDependencies": ["fsevents"],
    "patchedDependencies": {
      "express@4.18.2": "patches/express@4.18.2.patch"
    }
  }
}

pnpm 的专属扩展配置项,功能非常丰富:

字段 说明
overrides 强制覆盖依赖版本
peerDependencyRules 控制 peerDep 检查行为
neverBuiltDependencies 跳过某些包的 postinstall 脚本
patchedDependencies 声明补丁文件,搭配 pnpm patch 使用

十、工具链配置字段

许多工具支持直接在 package.json 中配置,免去创建额外配置文件。

10.1 lint-staged

{
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yaml,yml}": ["prettier --write"]
  }
}

配合 husky 在 git commit 前对暂存文件执行 lint 和格式化。

10.2 browserslist

{
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

声明目标浏览器范围,影响 Babel、PostCSS Autoprefixer、SWC 等工具的编译输出。

10.3 sideEffects

{
  "sideEffects": false
}

告知打包工具(Webpack/Rollup/Vite)该包的所有模块都没有副作用,可以安全 Tree Shaking。

也可以指定有副作用的文件:

{
  "sideEffects": ["*.css", "*.scss", "./src/polyfill.js"]
}

这是优化打包体积最关键的字段之一。如果你的库设置了 "sideEffects": false,使用者只 import 了一个函数,打包工具就敢放心地把其余代码全部删掉。

10.4 config

{
  "config": {
    "port": "8080"
  }
}

可以在 npm scripts 中通过 npm_package_config_port 环境变量读取,用户可以用 npm config set project:port 3000 覆盖。

10.5 其他工具内联配置

以下工具都支持在 package.json 中直接配置:

工具 字段名 说明
ESLint(旧版) eslintConfig ESLint 配置
Prettier prettier 代码格式化配置
Babel babel 编译器配置
Jest jest 测试框架配置
Stylelint stylelint CSS Lint 配置
commitlint commitlint Commit 消息规范
unplugin-auto-import auto-import 自动导入配置

十一、不常见但有用的字段

11.1 flat — 扁平化依赖(yarn)

{
  "flat": true
}

强制 yarn 安装依赖时使用扁平结构,如果有版本冲突会提示用户选择。

11.2 preferGlobal — 建议全局安装(已废弃)

{
  "preferGlobal": true
}

npm 5+ 已废弃此字段,但部分老项目可能还在使用。

11.3 deprecated — 废弃提示

不是在 package.json 中设置的字段,而是通过 npm deprecate 命令发布:

npm deprecate my-package@"<2.0.0" "请升级到 2.x 版本"

安装时会显示黄色警告。


十二、字段优先级总结

入口文件解析优先级

不同工具对入口字段的解析优先级不同:

Node.js(>=12.11):

exports > main

Webpack 5:

exports > browser > module > main

Vite / Rollup:

exports > module > main

TypeScript:

exports["."]["types"] > types > typings > main(.d.ts)

一张图看清全貌

package.json
├── 📋 基本信息
│   ├── name            # 包名
│   ├── version         # 版本号
│   ├── description     # 描述
│   ├── keywords        # 关键词
│   ├── license         # 协议
│   ├── author          # 作者
│   └── contributors    # 贡献者
│
├── 📦 入口文件
│   ├── main            # CJS 入口
│   ├── module          # ESM 入口
│   ├── browser         # 浏览器入口
│   ├── types           # TS 类型入口
│   ├── exports         # 条件导出(现代方案)
│   └── type            # 模块系统声明
│
├── 📁 文件管控
│   ├── files           # 发布白名单
│   └── directories     # 目录结构声明
│
├── ⚙️ 脚本与命令
│   ├── scripts         # NPM 脚本
│   ├── bin             # 可执行文件
│   └── man             # 帮助手册
│
├── 📚 依赖管理
│   ├── dependencies          # 生产依赖
│   ├── devDependencies       # 开发依赖
│   ├── peerDependencies      # 宿主依赖
│   ├── peerDependenciesMeta  # 宿主依赖元信息
│   ├── optionalDependencies  # 可选依赖
│   ├── bundleDependencies    # 捆绑依赖
│   └── overrides/resolutions # 依赖覆盖
│
├── 🚀 发布配置
│   ├── private         # 私有标记
│   ├── publishConfig   # 发布配置
│   └── repository      # 仓库信息
│
├── 🔒 环境约束
│   ├── engines         # Node/npm 版本要求
│   ├── os              # 操作系统限制
│   ├── cpu             # CPU 架构限制
│   └── packageManager  # 包管理器声明
│
├── 🏗️ Monorepo
│   ├── workspaces      # 工作空间
│   └── pnpm            # pnpm 专有配置
│
└── 🔧 工具链配置
    ├── lint-staged     # 暂存文件 lint
    ├── browserslist    # 目标浏览器
    ├── sideEffects     # 副作用声明
    └── config          # 自定义配置

十三、最佳实践

1. 库开发的标准 package.json 模板

{
  "name": "@scope/my-lib",
  "version": "1.0.0",
  "description": "A modern library",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
    "lint": "eslint src",
    "test": "vitest"
  },
  "peerDependencies": {
    "vue": "^3.0.0"
  },
  "peerDependenciesMeta": {
    "vue": { "optional": true }
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "publishConfig": {
    "access": "public"
  },
  "license": "MIT"
}

2. Monorepo 根目录模板

{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@9.15.0",
  "engines": {
    "node": ">=18.0.0"
  },
  "scripts": {
    "dev": "pnpm --filter app dev",
    "build": "pnpm -r build",
    "lint": "eslint .",
    "prepare": "husky install"
  },
  "devDependencies": {
    "eslint": "^9.0.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "prettier": "^3.0.0",
    "typescript": "^5.0.0"
  },
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
  }
}

3. 常见误区

误区 正解
vite/webpack 放到 dependencies 构建工具应放在 devDependencies
不设置 files 字段 会把整个项目(含源码)都发布上去
exportstypes 条件放在后面 TypeScript 要求 types 必须在第一个
不设置 sideEffects 使用者无法有效 Tree Shaking
不设置 engines 用户在低版本 Node 上可能出现诡异问题
不设置 private: true monorepo 根目录可能被意外 npm publish

结语

package.json 看似简单,实则承载了包的身份信息、入口解析、依赖管理、构建配置、发布流程等方方面面。理解每一个字段的含义和使用场景,不仅能帮你写出更规范的 npm 包,还能在排查 "模块找不到"、"类型丢失"、"打包体积过大" 等问题时快速定位根因。

希望这篇文章能成为你的 package.json 随身手册,收藏备用!


如果觉得有帮助,别忘了点个赞 👍 收藏一下,后续还会更新更多前端工程化干货。

ES11(ES2020)新特性

发布时间:2020年6月 ES11 是一个重要版本,新增了空值合并、可选链、BigInt、动态导入等特性。


1. 可选链运算符(Optional Chaining)?.

安全地访问深层嵌套的属性,遇到 nullundefined 时短路返回 undefined,不报错。

基本用法

let user = {
  name: '张三',
  address: {
    city: '北京',
    street: '长安街'
  }
};

// 旧写法
let city = user && user.address && user.address.city;

// 新写法
let city = user?.address?.city;     // '北京'
let zip = user?.address?.zip;       // undefined(不会报错)
let phone = user?.contact?.phone;   // undefined

可选链调用方法

let obj = {
  method() { return 42; }
};

obj.method?.();     // 42
obj.otherMethod?.(); // undefined(不会报错)

可选链访问数组元素

let arr = null;
let item = arr?.[0];      // undefined
let item2 = arr?.[0]?.name; // undefined

注意事项

// 可选链不能用于赋值
obj?.name = '李四';   // SyntaxError

// 如果前面的值为 null/undefined,后面的表达式不会执行
let a = null;
let b = a?.foo.bar.baz();  // undefined,foo.bar.baz() 不会执行

实际应用

// 从 API 返回的数据中安全取值
let data = response?.data?.list?.[0]?.name;

// DOM 操作
let value = document.querySelector('#input')?.value;

// React/Vue 中
let userName = this.props?.user?.name ?? '匿名用户';

2. 空值合并运算符(Nullish Coalescing)??

只在值为 nullundefined 时使用默认值(|| 会在值为 0''false 时也触发)。

基本用法

// || 的问题:0、''、false 都会被当作假值
let count = 0;
console.log(count || 10);    // 10(错误!0 被当成假值)
console.log(count ?? 10);    // 0(正确!只有 null/undefined 才用默认值)

let name = '';
console.log(name || '匿名');  // '匿名'(错误!空字符串被覆盖)
console.log(name ?? '匿名');  // ''(正确!空字符串是有效值)

与 || 的对比

0 ?? 100;          // 0
0 || 100;          // 100

'' ?? 'default';   // ''
'' || 'default';   // 'default'

false ?? true;     // false
false || true;     // true

null ?? 'fallback';  // 'fallback'
null || 'fallback';  // 'fallback'

undefined ?? 'x';    // 'x'
undefined || 'x';    // 'x'

实际应用

// 设置默认值
let port = config.port ?? 3000;
let host = config.host ?? 'localhost';
let debug = config.debug ?? false;

// 与可选链组合使用
let name = user?.profile?.name ?? '匿名用户';
let count = list?.length ?? 0;

注意:不能与 || 和 && 混用

// 语法错误
null ?? 'default' || 'other';  // SyntaxError

// 需要加括号
(null ?? 'default') || 'other';  // 'default'

3. BigInt(大整数)

表示任意精度的整数,突破 Number.MAX_SAFE_INTEGER(2^53 - 1)的限制。

创建 BigInt

// 方式1:数字后加 n
let big1 = 9007199254740993n;

// 方式2:BigInt() 函数
let big2 = BigInt(9007199254740993);
let big3 = BigInt('9007199254740993');

解决精度问题

// Number 的精度限制
9007199254740992 === 9007199254740993;  // true(精度丢失!)

// BigInt 没有精度限制
9007199254740992n !== 9007199254740993n; // true

运算

let a = 12345678901234567890n;
let b = 98765432109876543210n;

a + b;   // 111111111011111111100n
a - b;   // -86419753208641975320n
a * b;   // 1219326311370217952237463801111263526900n
a / b;   // 0n(BigInt 除法向下取整)
a % b;   // 12345678901234567890n

// 比较运算
10n > 5;          // true
10n === 10;       // false(类型不同)
10n == 10;        // true(宽松相等)

注意事项

// BigInt 不能与 Number 混合运算
10n + 5;    // TypeError

// 需要先转换
Number(10n) + 5;  // 15
BigInt(5) + 10n;  // 15n

// 不能用 Math 方法
Math.max(1n, 2n);  // TypeError

// JSON 不支持 BigInt
JSON.stringify({ a: 1n });  // TypeError

4. Promise.allSettled()

等待所有 Promise 完成(无论成功或失败),返回每个 Promise 的结果:

基本用法

let p1 = Promise.resolve('成功1');
let p2 = Promise.reject('失败');
let p3 = Promise.resolve('成功2');

Promise.allSettled([p1, p2, p3]).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value);
    } else {
      console.log('失败:', result.reason);
    }
  });
});
// 成功: 成功1
// 失败: 失败
// 成功: 成功2

与 Promise.all 的区别

// Promise.all:任一失败就整体失败
Promise.all([p1, p2, p3])
  .then(res => console.log(res))
  .catch(err => console.log('有失败的:', err));
// 输出:有失败的: 失败

// Promise.allSettled:全部完成后才返回,包含每个结果
Promise.allSettled([p1, p2, p3])
  .then(res => console.log(res));
// [
//   { status: 'fulfilled', value: '成功1' },
//   { status: 'rejected', reason: '失败' },
//   { status: 'fulfilled', value: '成功2' }
// ]

实际应用

// 批量请求,关心所有结果
async function fetchAll(urls) {
  let results = await Promise.allSettled(
    urls.map(url => fetch(url).then(r => r.json()))
  );
  
  let succeeded = results
    .filter(r => r.status === 'fulfilled')
    .map(r => r.value);
  
  let failed = results
    .filter(r => r.status === 'rejected')
    .map(r => r.reason);
  
  console.log(`成功: ${succeeded.length}, 失败: ${failed.length}`);
  return { succeeded, failed };
}

5. 动态导入 import()

运行时按需加载模块,返回 Promise:

基本用法

// 静态导入:编译时加载,必须写在顶部
import { module } from './module.js';

// 动态导入:运行时按需加载
let module = await import('./module.js');

按需加载

// 点击按钮时才加载
button.addEventListener('click', async () => {
  let { Chart } = await import('./chart.js');
  new Chart(canvas, config);
});

条件加载

async function loadPolyfill() {
  if (!window.Promise) {
    await import('promise-polyfill');
  }
}

路由懒加载

// React 路由懒加载
const Home = React.lazy(() => import('./Home'));
const About = React.lazy(() => import('./About'));

注意

  • 动态导入返回模块的命名空间对象
  • 可以在普通脚本中使用(不限于模块脚本)

6. globalThis

统一的全局对象,在不同环境下指向正确的全局对象:

// 不同环境的全局对象不同:
// 浏览器中:window
// Web Worker 中:self
// Node.js 中:global

// ES11 提供统一的 globalThis
console.log(globalThis);  // 浏览器中指向 window

实际应用

// 兼容写法(旧)
let globalObj = typeof window !== 'undefined' ? window
  : typeof global !== 'undefined' ? global
  : typeof self !== 'undefined' ? self : {};

// ES11 简化
let globalObj = globalThis;

7. String.prototype.matchAll()

返回字符串中所有匹配正则表达式的迭代器:

基本用法

let str = 'test1test2test3';
let matches = str.matchAll(/t(e)(st(\d?))/g);

for (let match of matches) {
  console.log(match);
}
// ['test1', 'e', 'st1', '1', ...]
// ['test2', 'e', 'st2', '2', ...]
// ['test3', 'e', 'st3', '3', ...]

与 match 的区别

// match 带 g 标志时,只返回匹配的字符串
'test1test2'.match(/t(e)st(\d)/g);
// ['test1', 'test2']

// matchAll 返回完整的匹配信息(包括捕获组)
[...'test1test2'.matchAll(/t(e)st(\d)/g)];
// [
//   ['test1', 'e', '1', ...],
//   ['test2', 'e', '2', ...]
// ]

注意

  • matchAll 要求正则必须有 g 标志
  • 返回的是迭代器,不是数组(可用 ...Array.from() 转换)

8. for...in 标准化枚举顺序

ES11 进一步明确了 for...in 遍历对象字符串键时的顺序:

  1. 整数索引形式的键(按数值升序)
  2. 其他字符串键(按创建顺序)

注意: for...in 不会遍历 Symbol

let obj = {};
obj[2] = 'b';
obj[0] = 'a';
obj[1] = 'c';
obj['name'] = '张三';
obj['age'] = 18;
obj[Symbol('id')] = 1;

for (let key in obj) {
  console.log(key);
}
// 输出顺序:'0', '1', '2', 'name', 'age'
// Symbol('id') 不会被 for...in 遍历到

总结

特性 说明 重要性
?. 可选链 安全访问深层属性 ⭐⭐⭐⭐⭐
?? 空值合并 更精确的默认值设置 ⭐⭐⭐⭐⭐
BigInt 任意精度大整数 ⭐⭐⭐⭐
Promise.allSettled() 获取所有 Promise 结果 ⭐⭐⭐⭐
import() 动态导入 按需加载模块 ⭐⭐⭐⭐
globalThis 统一全局对象 ⭐⭐⭐
String.matchAll() 获取所有正则匹配 ⭐⭐⭐
for...in 顺序 统一属性枚举顺序 ⭐⭐

WebMCP + WebSkills:企业级智能化页面操控方案,兼顾隐私安全与高效落地!

本文由云软件体验技术团队郑志超原创。

前言

🌟 情景再现:小明的“职场救赎”

这是小明入职这家大型电商平台公司的第一天。屁股还没坐热,老板就走过来丢下一个紧急任务:“小明,有个大客户叫王五,因为百亿补贴活动,我们需要给他补发一个 1000 元的价保申请单。你赶紧操作一下,客户等着呢。”

小明愣住了。作为刚入职不到两小时的新人,他甚至连后台系统的入口、各级菜单的功能都还没摸清,更别提复杂的价保审核流程和财务对账逻辑了。看着老板匆忙离去的背影,小明坐在工位上对着密密麻麻的业务后台菜单发呆,心里焦虑万分,又不敢在这时候去打扰忙碌的老板请教这种“基础操作”。

这时,坐在旁边的小红看出了小明的窘迫,笑着指了指屏幕右下角的图标:“别愁啦,咱们公司的管理后台集成了 WebMCP + WebSkills 智能系统。你直接跟它说话就行。”

小明半信半疑地打开助手,试着输入了一句:  “帮我给用户王五创建一个价保申请单,金额 1000 元,原因为百亿补贴。”

奇迹发生了!系统立刻自动定位到了用户管理模块,识别了王五的身份,并调取了相关的订单信息。几秒钟后,屏幕上直接弹出了一个预填好的申请单确认框,上面清晰地列出了所有申请细节,并提示:“已为您准备好价保申请单,请确认无误后点击‘提交’。”

小明屏住呼吸,轻轻一点确认按钮,任务圆满完成。

原本以为要折腾一上午的复杂业务,竟然在一句话之间就解决了。这个“神操作”不仅让小明保住了入职第一天的体面,更让他真实感受到了智能化应用带来的效率革命。

以下是模拟小明操作的视频演示(欢迎访问 在线演示地址 亲自体验):

外部1.gif

内容摘要:本文深度解析了 WebMCP + WebSkills 这套专为前端页面驱动设计的“组合拳”方案。通过解决现有自动化方案(无障碍适配、视觉模型)在安全性、成本及适配难度上的核心痛点,提供了 Vue、Angular 及 React 三大主流技术栈的工程级最佳实践,助力开发者在不改变现有业务系统的架构下,实现极简、高效、安全的 AI 驱动页面操作。同时,借助 WebAgent 远程遥控,用户只需手机扫码或输入识别码,即可通过移动端直接遥控桌面页面——这是 WebMCP 在交互体验上的重大突破。

1. 背景与痛点

1.1 场景引子:为什么页面自动化这么难?

做前端工程、AI业务接入的小伙伴,是不是都有过这样的崩溃时刻?想实现页面自动化操作,要么被各种方案的坑绊住脚,要么配置复杂到让人头大,好不容易跑通还面临安全隐患……别慌!这篇文档要介绍的“组合拳”——WebMCP+WebSkills,就是帮你在不大改现有系统的前提下,把页面操作做得又稳又安全。

2.11.JPG

1.2 业界主流方案与痛点

先吐个槽:业界现有方案,坑是真的多!

在 WebMCP 出现之前,咱们做页面操作自动化,主流就两种方案,但说句实在话,用起来都让人一言难尽,痛点直接拉满:

方案一:基于无障碍信息(如 chrome-devtools-mcp)

听着挺专业,但实际用起来全是“门槛”:首先得要求业务系统页面做好完善的无障碍信息适配,可现实里很多老项目、复杂业务页面,根本达不到这个要求;其次,业务逻辑一旦复杂,基于无障碍信息的操作就会出现各种不确定性,时而正常时而报错,排查起来比找 bug 还难;更麻烦的是,想用它还得额外装浏览器扩展插件,或者依赖 playwright 等工具,步骤繁琐,兼容性还参齐不齐。

方案二:基于视觉模型截图操作

这个方案看似不用适配页面,实则“费钱又费时间”:视觉模型运行起来特别消耗 token,长期用下来成本蹭蹭涨;而且执行速度慢得让人着急,复杂业务操作能卡到你怀疑人生;最关键的是,它根本扛不住复杂业务系统的考验,稍微多几个交互步骤就直接“罢工”。

共同致命伤:安全不可控

不管是无障碍信息方案,还是视觉模型方案,都存在一个核心隐患——安全性。两种方案都需要一定程度上获取页面敏感信息,且缺乏有效的安全管控机制,一不小心就可能造成数据泄露,给业务带来不可挽回的损失。

3.11.JPG

1.3 WebMCP + WebSkills 的定位

就在大家被这些痛点折磨得焦头烂额时,WebMCP+WebSkills 横空出世,直接精准戳中所有痛点,给前端页面操作自动化带来了新希望!

WebMCP 不是“替代者”,而是“最强补充”

很多小伙伴会误以为 WebMCP 是要取代业界现有的 MCP 协议,其实不然!WebMCP 是基于业界 MCP 协议打造的前端优化方案,核心定位是“补充和增强”——它保留了 MCP 协议的核心优势,同时针对前端页面操作的痛点做了针对性优化,让页面操作更简单、更高效、更安全。

WebSkills:让 AI 真的“懂你的业务”

而 WebSkills 则是 WebMCP 的“神助攻”,它能进一步增强 AI 对业务的理解能力,让页面操作自动化更智能,哪怕是复杂的业务场景,也能轻松应对,两者搭配使用,直接实现“1+1>2”的效果。

WebAgent 远程遥控:移动端直接操控桌面页面

WebMCP + WebSkills 还有一个杀手级亮点——远程遥控。通过 useWebAgentServer 将本地 MCP Server 桥接到远端 Agent 平台,用户扫描二维码或输入 6 位识别码,即可在手机上通过自然语言指令遥控桌面浏览器页面。真正实现"移动端说一句话,桌面页面帮你干活"。

4.11.JPG

2. 三大技术栈最佳实践总览

干货来袭:三大技术栈最佳实践,直接抄作业!

不管你是用 Vue、React 还是 Angular,WebMCP+WebSkills 都能完美适配,而且实现方式高度统一:核心是通过前端路由 + 页面工具(Page Tool Bridge)把业务页面和 MCP 工具打通,再通过 WebSkills 和 TinyRemoter 做“知识与对话入口”  。下面分别给出 Vue / Angular 的摘要示例,并附上工程级最佳实践链接。

2.1 Vue 工程最佳实践(摘要)

源码工程:packages/doc-ai
完整工程路径:packages/doc-ai
详细文档:docs/guide/vue-webmcp-best-practice.md

步骤 1:安装依赖

pnpm add @opentiny/next-sdk @opentiny/next-remoter

说明:这里直接引入 WebMCP 核心 SDK 与 TinyRemoter 组件包,为后续“页面工具 + 对话框 UI”打基础。

步骤 2:在 main.ts 中注册路由导航器

// src/main.ts
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
import { setNavigator } from '@opentiny/next-sdk'

const app = createApp(App)
app.use(router)
app.mount('#app')

// 告诉 SDK:需要跳转页面时统一走 router.push
setNavigator((route) => router.push(route))

中文小结:setNavigator 是 Page Tool Bridge 的前提,只需在入口调用一次,之后所有“与页面绑定的工具”在执行时都会通过这里完成路由跳转。

步骤 3:配置业务路由

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  historycreateWebHistory(),
  routes: [
    { path'/'component() => import('../views/home/index.vue') },
    { path'/product-list'component() => import('../views/product-list/index.vue') },
    { path'/price-protection'component() => import('../views/price-protection/index.vue') }
  ]
})

export default router

中文小结:后面在 registerTool 和 registerPageTool 里会引用这些 path,请保持一致,避免因为路由不对导致工具调用超时。

步骤 4:创建 MCP Server,并通过 withPageTools 绑定路由

// src/mcp-servers/index.ts
import {
  WebMcpServer,
  createMessageChannelPairTransport,
  withPageTools,
  registerNavigateTool
} from '@opentiny/next-sdk'
import registerProductGuideTools from './product-guide/tools'
import registerPriceProtectionTools from './price-protection/tools'

const rawServer = new WebMcpServer()
const [serverTransport, clientTransport] = createMessageChannelPairTransport()

export const server = withPageTools(rawServer)
export { clientTransport }

export const createMcpServer = async () => {
  registerNavigateTool(rawServer)
  registerProductGuideTools(server)
  registerPriceProtectionTools(server)
  await rawServer.connect(serverTransport)
}

中文小结:withPageTools 让工具可以和路由产生映射;registerNavigateTool 注册了一个通用的 navigate_to_page 工具,供大模型主动发起“先跳转再用页面工具”的链路。

步骤 5:注册与页面绑定的业务工具

// src/mcp-servers/product-guide/tools.ts
import { z } from '@opentiny/next-sdk'
import type { PageAwareServer } from '@opentiny/next-sdk'

const registerProductGuideTools = (server: PageAwareServer) => {
  server.registerTool(
    'product-guide',
    {
      title'产品指南',
      description'根据产品 ID 获取产品详细信息',
      inputSchema: {
        productId: z.string().describe('产品 ID')
      }
    },
    { route'/product-list' } // 工具执行时自动导航到该路由
  )
}

export default registerProductGuideTools

中文小结:第三个参数 { route: '/product-list' } 是关键,它告诉 SDK“这个工具需要在哪个页面内执行”,从而触发 Page Tool Bridge 的自动跳转与消息投递。

步骤 6:在页面内通过 registerPageTool 注册工具处理器

<!-- src/views/product-list/index.vue -->
<template>
  <div class="products-page">
    <div v-for="product in products" :key="product.id">{{ product.name }} - ¥{{ product.price }}</div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { registerPageTool } from '@opentiny/next-sdk'
import productsData from './products.json'

type Product = {
  id: number
  name: string
  price: number
  stock: number
  status'on' | 'off' | string
}

const products = ref<Product[]>(productsData as Product[])
let cleanupPageTool() => void

onMounted(() => {
  cleanupPageTool = registerPageTool({
    handlers: {
      'product-guide'async ({ productId }: { productId: string }) => {
        const product = products.value.find((p) => String(p.id) === productId)
        const text = product ? `产品信息:${JSON.stringify(product, null2)}` : `未找到产品 ID 为 ${productId} 的商品`
        return { content: [{ type'text', text }] }
      }
    }
  })
})

onUnmounted(() => cleanupPageTool?.())
</script>

中文小结:页面挂载时把 handler 注册进去,卸载时清理;handler 中可以直接访问 Vue 响应式数据,实现“AI 调工具 → 工具调页面逻辑”的完整闭环。

步骤 7:在 App.vue 中挂载 TinyRemoter + Skills,并接入远程遥控(可选)

<!-- src/App.vue -->
<template>
  <div class="app-container">
    <router-view />
    <TinyRemoter
      :show="true"
      :skills="skillMdModules"
      :mcpServers="mcpServers"
      :menuItems="menuItems"
      title="智能助手"
      :llmConfig="llmConfig"
    />
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { TinyRemoter } from '@opentiny/next-remoter'
import { createMcpServer, clientTransport } from './mcp-servers'
import { useWebAgentServer } from './mcp-servers/useWebAgentServer'

const llmConfig = {
  apiKey: import.meta.env.VITE_LLM_API_KEY || 'your-api-key-placeholder',
  baseURL: import.meta.env.VITE_LLM_BASE_URL || 'https://api.openai.com/v1',
  providerType: 'openai',
  model: 'gpt-4o',
  maxSteps: 10
}

const skillMdModules = import.meta.glob('./skills/**/*', {
  query: '?raw',
  import: 'default',
  eager: true
}) as Record<string, string>

const mcpServers = {
  'my-mcp-server': {
    type: 'local' as const,
    transport: clientTransport
  }
}

const menuItems = ref<any[]>([])

onMounted(async () => {
  // 本地 MCP 核心功能:失败直接抛出
  await createMcpServer()

  // 远程遥控增强功能:失败只打印警告,不影响本地对话
  try {
    const result = await useWebAgentServer()
    if (result?.sessionId) {
      const remoteUrl = `https://agent.opentiny.design/mcp?sessionId=${result.sessionId}`
      menuItems.value = [
        { action: 'remote-url', text: '遥控器链接', desc: remoteUrl, tip: remoteUrl, active: true, showCopyIcon: true },
        { action: 'remote-control', text: '识别码', desc: result.sessionId.slice(-6), know: true, showCopyIcon: true }
      ]
    }
  } catch (err) {
    console.warn('[WebAgent] 远程遥控初始化失败,本地功能不受影响:', err)
  }
})
</script>

中文小结:menuItems 在 WebAgent 连接成功后填充,TinyRemoter 会自动在悬浮菜单中显示"遥控器链接"和"识别码"。本地 MCP 与远程遥控必须分开 try/catch 处理,避免网络问题导致本地对话功能也一起失效。详细接入方式见 远程遥控亮点章节。

2.2 Angular 工程最佳实践(摘要)

源码工程:packages/doc-ai-angular
完整工程路径:packages/doc-ai-angular
详细文档:docs/guide/angular-webmcp-best-practice.md

Angular 与 Vue 最大的差异在于:TinyRemoter 是 Vue 组件,Angular 不能直接引入,需要通过 iframe + MessageChannel 与主应用通讯

整体架构:

  • • Angular 主应用:负责路由、业务页面、WebMCP Server、registerPageTool
  • • Vue Remoter 子应用(iframe 内):负责 TinyRemoter UI + Skills,使用 createMessageChannelClientTransport 连接主应用

步骤 1:在根组件中注册 setNavigator 并启动 MCP Server

// src/app/app.component.ts
import { ComponentOnInit, inject } from '@angular/core'
import { RouterRouterOutlet } from '@angular/router'
import { setNavigator } from '@opentiny/next-sdk'
import { createMcpServer } from '../mcp-servers'

@Component({
  selector'app-root',
  standalonetrue,
  imports: [RouterOutlet],
  templateUrl'./app.component.html',
  styleUrl'./app.component.scss'
})
export class AppComponent implements OnInit {
  private router = inject(Router)

  async ngOnInit(): Promise<void> {
    setNavigator(async (route) => {
      const navigated = await this.router.navigateByUrl(route)
      if (!navigated) {
        throw new Error(`页面跳转失败:导航至 "${route}" 被取消或拦截`)
      }
    })

    await createMcpServer()
  }
}

中文小结:和 Vue 版类似,这里统一封装“页面跳转策略”,同时在应用入口启动 MCP Server,确保后续 iframe 连接时已有可用的工具服务。

步骤 2:在根模板中通过 iframe 嵌入 Remoter

<!-- src/app/app.component.html -->
<div class="app-container">
  <div class="main-content">
    <router-outlet />
  </div>
  <aside class="remoter-sidebar">
    <iframe class="remoter-frame" src="/remoter.html" frameborder="0" allow="clipboard-write" title="AI 助手"></iframe>
  </aside>
</div>

中文小结:/remoter.html 会通过代理指向 Remoter 子应用入口(例如 Vite dev server 的 /remoter/),两端同源后即可使用 MessageChannel 互通。

步骤 3:在主窗口创建 MCP Server,并暴露 MessageChannel 服务端

// src/mcp-servers/index.ts
import {
  WebMcpServer,
  createMessageChannelServerTransport,
  withPageTools,
  registerNavigateTool
} from '@opentiny/next-sdk'
import registerProductGuideTools from './product-guide/tools'
import registerPriceProtectionTools from './price-protection/tools'

const rawServer = new WebMcpServer()
export const server = withPageTools(rawServer)

export const createMcpServer = async () => {
  registerNavigateTool(rawServer)
  registerProductGuideTools(server)
  registerPriceProtectionTools(server)

  const serverTransport = createMessageChannelServerTransport('local-mcp')
  await serverTransport.listen()
  await rawServer.connect(serverTransport)
}

中文小结:这里不再使用“同窗口内存对”的 createMessageChannelPairTransport,而是用 createMessageChannelServerTransport('local-mcp') 等待 iframe 侧主动连入。

步骤 4:在 Angular 页面中注册页面工具处理器

// src/app/pages/comprehensive/comprehensive.component.ts(节选)
import { ComponentOnInitOnDestroy } from '@angular/core'
import { registerPageTool } from '@opentiny/next-sdk'

@Component({
  /* 模板与样式省略 */
})
export class ComprehensiveComponent implements OnInitOnDestroy {
  productsProduct[] = productsData as Product[]
  private cleanupPageTool!: () => void

  ngOnInit(): void {
    this.cleanupPageTool = registerPageTool({
      handlers: {
        'product-guide'async ({ productId }: { productId: string }) => {
          const product = this.products.find((p) => String(p.id) === productId)
          const text = product
            ? `产品信息:${JSON.stringify(product, null2)}`
            : `未找到产品 ID 为 ${productId} 的商品`
          return { content: [{ type'text', text }] }
        }
      }
    })
  }

  ngOnDestroy(): void {
    this.cleanupPageTool?.()
  }
}

中文小结:写法和 Vue 版高度类似,只是生命周期钩子由 onMounted/onUnmounted 换成了 ngOnInit/ngOnDestroy,其余 Page Tool Bridge 行为完全一致。

步骤 5:在 Remoter 子应用中,通过 createMessageChannelClientTransport 连接主窗口

<!-- remoter/src/App.vue(节选) -->
<template>
  <tiny-remoter :skills="skillMdModules" :show="true" :fullscreen="true" :mcpServers="mcpServers" />
</template>

<script setup lang="ts">
import { TinyRemoter } from '@opentiny/next-remoter'
import { createMessageChannelClientTransport } from '@opentiny/next-sdk'

const skillMdModules = import.meta.glob('./skills/**/*', {
  query'?raw',
  import'default',
  eagertrue
}) as Record<string, string>

const clientTransport = createMessageChannelClientTransport('local-mcp'window.parent)

const mcpServers = {
  'local-mcp-server': {
    type'local',
    transport: clientTransport
  }
}
</script>

中文小结:endpoint 'local-mcp' 和主窗口必须一致,通过这一对 Transport,TinyRemoter 就可以把所有工具调用发送到 Angular 主应用,再由 Page Tool Bridge 转发到具体页面。

2.3 React 工程最佳实践(工程入口)

源码工程:packages/doc-ai-react
完整工程路径:packages/doc-ai-react

React 工程的整体架构与 Angular 工程高度一致,同样是:

  •  主应用(React SPA)  :直接对接 @opentiny/next-sdk,在浏览器中创建 WebMCP Server、注册业务工具,结合路由和 registerPageTool 在各业务页面内挂载页面工具处理器;
  •  Remoter 子应用(Vue)  :作为一个独立的前端子工程,通过 iframe 嵌入到 React 主应用中,内部渲染 TinyRemoter 组件并加载 WebSkills 文档;
  •  通信方式:主应用和 iframe 之间通过 MessageChannel 建立连接,主应用侧暴露服务端 Transport,Remoter 侧创建客户端 Transport,最终由 TinyRemoter 将对话中的工具调用透传到 React 主应用,再由 Page Tool Bridge 负责路由跳转和页面内业务逻辑执行。

简单理解:React 主应用负责“工具和页面”,Remoter 子应用负责“对话 UI 和技能文档”,两者通过 iframe + MessageChannel 打通,整体模式与 Angular 版本完全一致。示例工程可参考 packages/doc-ai-react,根据你的 React 路由和对话组件做适配即可。

2.4 远程遥控:跨设备遥控桌面的杀手级亮点 🎮

这是 WebMCP 区别于所有现有方案的独家能力:无需任何额外硬件或客户端,用手机扫一扫,就能用自然语言遥控桌面浏览器上的业务系统。

原理一句话

桌面浏览器(WebMCP Server)
    ↕ WebSocket 长连接
远端 Agent 平台(sessionId 路由)
    ↕
手机浏览器(遥控端 UI)
    ↓ 用户语音/文字指令
AI 解析意图 → 调用 MCP 工具 → 桌面页面执行 → 结果回显到手机

本地 MCP Server 通过 useWebAgentServer 向远端 Agent 平台注册,获得唯一 sessionId。手机端打开遥控页面并输入识别码(sessionId 后 6 位)或扫描二维码,即与桌面建立会话。

核心 API:useWebAgentServer

// src/mcp-servers/useWebAgentServer.ts
import { WebMcpServerWebMcpClient, createMessageChannelPairTransport, withPageTools } from '@opentiny/next-sdk'
import { registerAllTools } from './common'

const rawServer = new WebMcpServer()
const client = new WebMcpClient()
const [serverTransport, clientTransport] = createMessageChannelPairTransport()
export const server = withPageTools(rawServer)

const SESSION_ID_KEY = 'web-agent-session-id'

export const useWebAgentServer = async () => {
  registerAllTools(server)
  await rawServer.connect(serverTransport)
  await client.connect(clientTransport)

  const cachedSessionId = localStorage.getItem(SESSION_ID_KEY) ?? undefined
  const { sessionId } = await client.connect({
    sessionId: cachedSessionId,
    agenttrue,
    url'https://agent.opentiny.design/api/v1/webmcp-trial/mcp'
  })

  if (sessionId) localStorage.setItem(SESSION_ID_KEY, sessionId)
  return { sessionId }
}

三步快速接入

① 创建 useWebAgentServer.ts(如上)

② 在 onMounted 中分离调用(错误隔离是关键!)

onMounted(async () => {
  await createMcpServer() // 本地 MCP:失败直接抛出(核心功能)

  try {
    const result = await useWebAgentServer() // 远程遥控:失败只警告(增强功能)
    if (result?.sessionId) {
      const remoteUrl = `https://agent.opentiny.design/mcp?sessionId=${result.sessionId}`
      menuItems.value = [
        { action'remote-url'text'遥控器链接'desc: remoteUrl, tip: remoteUrl, activetrueshowCopyIcontrue },
        { action'remote-control'text'识别码'desc: result.sessionId.slice(-6), knowtrueshowCopyIcontrue }
      ]
    }
  } catch (err) {
    console.warn('[WebAgent] 远程遥控初始化失败,不影响本地功能:', err)
  }
})

⚠️ 为什么必须分开 try/catch?   若合并在同一 await 链,网络抖动导致 useWebAgentServer 失败时,整个 onMounted 会 reject,本地对话也随之失效。分开后,远程功能降级,本地始终可用。

③ 将 menuItems 传给 TinyRemoter

<TinyRemoter :menuItems="menuItems" :mcpServers="mcpServers" :skills="skillMdModules" />

⚠️ 关键细节:desc 必须存完整 URL

// ✅ 正确:desc 存带 sessionId 的完整链接
{ action: 'remote-url', desc: `${AGENT_ROOT}/mcp?sessionId=${result.sessionId}`, ... }

// ❌ 错误:desc 只存裸域名,复制后无法建立遥控会话
{ action: 'remote-url', desc: AGENT_ROOT, ... }

TinyRemoter 的复制按钮优先读取 desc 字段,若只是裸域名则复制内容缺少 sessionId,手机端无法建立遥控会话。

完整交互时序

① 桌面打开页面
    → createMcpServer():本地 MCP 启动完毕
    → useWebAgentServer():向 Agent 平台注册,获得 sessionId
    → TinyRemoter 菜单显示「遥控器链接」和「识别码」

② 用户扫码 / 复制链接 → 手机打开遥控端
    → 输入 6 位识别码(或链接自动携带 sessionId)
    → 与桌面建立 WebSocket 长连接(通过 Agent 平台路由)

③ 用户输入「帮我把库存里的 MacBook 下架」
    → AI 调用桌面的 MCP 工具 → Page Tool Bridge 自动跳转页面
    → 页面内处理器执行业务逻辑 → 结果返回给 AI → 回复用户

sessionId 持久化,刷新不丢会话

useWebAgentServer 内部将 sessionId 存入 localStorage(key:web-agent-session-id),刷新页面后自动复用,无需重新扫码。若 session 过期,Agent 平台会分配新 sessionId 并写回。

3. 总结

WebMCP + WebSkills + WebAgent 远程遥控,前端页面操作的"最优解"

对比业界现有方案,这套组合拳的优势一目了然:

能力亮点 说明
🚫 无需复杂工具 不用装浏览器插件,不用额外部署 playwright,轻量化接入
🔌 适配性更强 不要求业务页面做复杂无障碍适配,新老系统都能稳定运行
💰 高效又省钱 摆脱视觉模型的 token 消耗,执行速度快,长期成本低
🔒 安全可控 从底层保障数据安全,避免敏感信息泄露
🌐 多技术栈 Vue / React / Angular 全覆盖,实现方式统一
🎮 远程遥控(独家) 手机扫码 / 输入识别码,即可跨设备遥控桌面页面,零门槛移动端 AI 操控

远程遥控:最值得期待的杀手级亮点 🚀

远程遥控是 WebMCP 区别于所有现有方案的独家能力,也是当前最值得优先体验的功能:

  • 用户无需安装任何 App,打开手机浏览器,扫描二维码或输入 6 位识别码即可;
  • 在手机上用自然语言下达指令,AI 实时调用桌面页面注册的 MCP 工具;
  •  sessionId 自动持久化到 localStorage,刷新页面后无需重新扫码;
  • 本地对话与远程遥控完全解耦——即使远程初始化失败,本地 AI 对话功能照样可用。

未来,WebMCP + WebSkills + WebAgent 还会持续迭代优化,进一步简化接入流程、增强功能适配,覆盖更多复杂业务场景。

如果你也正在被页面操作自动化的痛点困扰,不妨直接去 GitHub 下载对应技术栈的最佳实践代码,跟着操作,分分钟解锁前端高效新姿势!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
参与 next-sdk 共建 → github.com/opentiny/ne… (欢迎star)
使用 next-sdk → opentiny.design/next-sdk
关于我们:opentiny.design/opentiny-de…

如果你有任何问题,欢迎在评论区留言交流!

ES9(ES2018)新特性

发布时间:2018年6月 ES9 主要完善了异步迭代、对象扩展、正则表达式等功能。


1. 异步迭代器(Async Iteration)

for await...of

用于遍历异步可迭代对象(异步生成器、异步的流等):

for await (const line of readLines(filePath)) {
  console.log(line);
}

异步生成器

async function* asyncGenerator() {
  let i = 0;
  while (i < 3) {
    await new Promise(res => setTimeout(res, 1000));
    yield i++;
  }
}

(async function() {
  for await (let val of asyncGenerator()) {
    console.log(val);  // 每秒输出 0, 1, 2
  }
})();

实际应用:分批获取数据

async function* fetchPages(urls) {
  for (let url of urls) {
    let res = await fetch(url);
    yield await res.json();
  }
}

(async function() {
  let pages = fetchPages(['/api/page1', '/api/page2', '/api/page3']);
  for await (let page of pages) {
    console.log(page);
  }
})();

与同步迭代器的区别

// 同步迭代器
obj[Symbol.iterator]    // 返回 { next() => { value, done } }

// 异步迭代器
obj[Symbol.asyncIterator]  // 返回 { next() => Promise({ value, done }) }

2. 对象展开运算符(Object Spread)

ES6 引入了数组展开运算符,ES9 将其扩展到对象:

展开合并对象

let defaults = { theme: 'light', lang: 'zh' };
let userPrefs = { theme: 'dark', fontSize: 14 };

// 合并对象(后面的覆盖前面的)
let settings = { ...defaults, ...userPrefs };
// { theme: 'dark', lang: 'zh', fontSize: 14 }

克隆对象(浅拷贝)

let original = { a: 1, b: { c: 2 } };
let clone = { ...original };
clone.a = 10;      // 不影响 original
clone.b.c = 20;    // 影响 original(浅拷贝)

覆盖部分属性

let user = { name: '张三', age: 18, city: '北京' };
let updatedUser = { ...user, age: 19 };
// { name: '张三', age: 19, city: '北京' }

添加新属性

let user = { name: '张三' };
let withId = { ...user, id: 1 };
// { name: '张三', id: 1 }

注意事项

  • 展开运算符只展开对象自身的可枚举属性
  • 原型链上的属性不会被展开
  • 值为 undefined 的属性仍会被包含
let obj = { a: undefined, b: null, c: 1 };
let copy = { ...obj };  // { a: undefined, b: null, c: 1 }

3. 对象剩余运算符(Object Rest)

解构对象时收集剩余属性:

基本用法

let { a, b, ...rest } = { a: 1, b: 2, c: 3, d: 4 };
console.log(a);    // 1
console.log(b);    // 2
console.log(rest); // { c: 3, d: 4 }

函数参数中使用

function updateUser({ id, ...changes }) {
  // id 单独取出,其余作为修改项
  console.log(`更新用户 ${id},修改内容:`, changes);
}

updateUser({ id: 1, name: '李四', age: 20 });
// 更新用户 1,修改内容: { name: '李四', age: 20 }

剔除某些属性

let { password, ...safeUser } = { name: '张三', age: 18, password: '123' };
console.log(safeUser);  // { name: '张三', age: 18 }

嵌套解构

let { a: { x, ...restA }, ...restObj } = { a: { x: 1, y: 2 }, b: 3 };
console.log(x);      // 1
console.log(restA);  // { y: 2 }
console.log(restObj); // { b: 3 }

注意

  • 剩余属性必须在最后
  • 剩余运算符得到的始终是普通对象
  • nullundefined 不能用展开/剩余运算符

4. Promise.prototype.finally()

无论 Promise 成功还是失败,都会执行的回调:

基本用法

fetchData()
  .then(data => console.log(data))
  .catch(err => console.error(err))
  .finally(() => {
    console.log('请求结束,隐藏loading');
    hideLoading();
  });

特点

  • finally 不接收参数,不知道 Promise 是成功还是失败
  • 返回的 Promise 会继承前面 Promise 的结果
Promise.resolve('ok')
  .finally(() => {
    console.log('清理资源');
    // 没有返回值或返回普通值,不影响最终结果
  })
  .then(res => console.log(res));  // 'ok'

对比 try...catch...finally

// Promise 方式
fetchData()
  .then(data => processData(data))
  .catch(err => handleError(err))
  .finally(() => cleanup());

// 等同于 try...catch...finally 的效果
async function handle() {
  try {
    let data = await fetchData();
    processData(data);
  } catch (err) {
    handleError(err);
  } finally {
    cleanup();
  }
}

实际应用

// 数据库连接
function queryDB() {
  let conn = connectDB();
  return conn.query('SELECT * FROM users')
    .finally(() => conn.close());  // 确保连接关闭
}

// 文件操作
function readConfig() {
  let file = openFile('config.json');
  return readFile(file)
    .finally(() => file.close());  // 确保文件关闭
}

5. 正则表达式扩展

5.1 命名捕获组(Named Capture Groups)

(?<name>...) 为捕获组命名:

// 旧写法:通过索引访问
let re = /(\d{4})-(\d{2})-(\d{2})/;
let match = '2023-12-25'.match(re);
console.log(match[1]);  // '2023'(年)
console.log(match[2]);  // '12'(月)
console.log(match[3]);  // '25'(日)

// 新写法:通过名称访问
let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
let match = '2023-12-25'.match(re);
console.log(match.groups.year);   // '2023'
console.log(match.groups.month);  // '12'
console.log(match.groups.day);    // '25'

解构使用命名捕获组

let { groups: { year, month, day } } =
  '2023-12-25'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);

在 replace 中使用

let re = /(?<firstName>\w+)\s(?<lastName>\w+)/;
let str = 'John Smith'.replace(re, '$<lastName>, $<firstName>');
console.log(str);  // 'Smith, John'

5.2 反向断言(Lookbehind Assertions)

在匹配位置的前面或后面添加条件判断:

// 正向先行断言(ES5 就有):后面必须跟某个模式
// (?=...)  后面跟...
// (?!...)  后面不跟...

// 反向先行断言(ES9 新增):前面必须跟某个模式
// (?<=...)  前面跟...
// (?<!...)  前面不跟...

// 匹配价格数字(前面有 $ 符号)
let str = '商品价格 $100,运费 $20';
let prices = str.match(/(?<=\$)\d+/g);
console.log(prices);  // ['100', '20']

// 匹配不以 $ 开头的数字
let nums = 'a123 $456 c789'.match(/(?<!\$)\d+/g);
console.log(nums);  // ['123', '789']

5.3 正则表达式 dotAll 模式

dotAll 标志 s,让 . 匹配包括换行符在内的所有字符:

// 默认情况下,. 不匹配换行符
/hello.world/.test('hello\nworld');   // false

// dotAll 模式下,. 匹配换行符
/hello.world/s.test('hello\nworld');  // true

5.4 正则表达式 Unicode 转义

在正则中可以使用 \p{...} 匹配 Unicode 字符类别:

// 匹配任何 Unicode 字母(包括中文等)
/\p{Letter}/u.test('你');    // true
/\p{Letter}/u.test('A');     // true
/\p{Letter}/u.test('1');     // false

// 匹配 Unicode 空白
/\p{White_Space}/u.test('\n');  // true

// 否定匹配
/\P{Letter}/u.test('1');     // true,非字母

总结

特性 说明 重要性
for await...of 异步迭代器 ⭐⭐⭐⭐
对象展开运算符 ... 对象合并、克隆 ⭐⭐⭐⭐
对象剩余运算符 ...rest 解构时收集剩余属性 ⭐⭐⭐⭐
Promise.finally() 无论成败都执行 ⭐⭐⭐⭐
命名捕获组 正则捕获组可命名 ⭐⭐⭐
反向断言 正则前面匹配条件 ⭐⭐⭐
dotAll 模式 . 匹配换行符 ⭐⭐
Unicode 转义 正则匹配 Unicode 类别 ⭐⭐

ES8(ES2017)新特性

发布时间:2017年6月 ES8 新增了异步编程的关键特性,同时完善了字符串、对象等基础能力。


1. async/await

ES8 最重要的特性,让异步代码看起来像同步代码。

基本语法

async function fetchData() {
  let res = await fetch('/api/data');
  let data = await res.json();
  return data;
}

工作原理

  • async 函数总是返回一个 Promise
  • await 只能在 async 函数内使用
  • await 暂停函数执行,等待 Promise 结果

对比回调地狱和 Promise 链

// 回调地狱
getData(function(a) {
  getMoreData(a, function(b) {
    getEvenMoreData(b, function(c) {
      console.log(c);
    });
  });
});

// Promise 链
getData()
  .then(a => getMoreData(a))
  .then(b => getEvenMoreData(b))
  .then(c => console.log(c))
  .catch(err => console.error(err));

// async/await,最清晰
async function getAll() {
  let a = await getData();
  let b = await getMoreData(a);
  let c = await getEvenMoreData(b);
  console.log(c);
}

错误处理

// try...catch 方式
async function fetchData() {
  try {
    let res = await fetch('/api/data');
    let data = await res.json();
    return data;
  } catch (err) {
    console.error('请求失败:', err);
  }
}

// 也可以用 .catch()
fetchData().catch(err => console.error(err));

并行执行多个异步操作

// 串行执行(慢)
async function serial() {
  let a = await fetch('/api/a');
  let b = await fetch('/api/b');
  return [a, b];
}

// 并行执行(快)
async function parallel() {
  let [a, b] = await Promise.all([
    fetch('/api/a'),
    fetch('/api/b')
  ]);
  return [a, b];
}

立即执行

(async function() {
  let data = await fetchData();
  console.log(data);
})();

注意事项

  • await 只能等待 Promise,非 Promise 值会自动包装
  • 在循环中慎用 await(会导致串行执行)
  • 顶层 await 需要 ES2022(模块环境下)

2. Object.values()

返回对象自身所有可枚举属性值的数组:

let obj = { a: 1, b: 2, c: 3 };
Object.values(obj);  // [1, 2, 3]

使用场景

// 获取所有属性值
let scores = { math: 90, english: 85, science: 92 };
let values = Object.values(scores);  // [90, 85, 92]

// 计算平均值
let avg = values.reduce((a, b) => a + b) / values.length;  // 89

// 检查是否有某个值
Object.values(obj).includes('target');

注意

  • 只返回自身的可枚举属性,不包括继承的
  • 属性顺序与 for...in 一致
  • 字符串对象也能用
Object.values('hello');  // ['h', 'e', 'l', 'l', 'o']

3. Object.entries()

返回对象自身可枚举属性的键值对数组:

let obj = { a: 1, b: 2, c: 3 };
Object.entries(obj);  // [['a', 1], ['b', 2], ['c', 3]]

配合 for...of 遍历

for (let [key, value] of Object.entries(obj)) {
  console.log(key, value);
}

将对象转为 Map

let map = new Map(Object.entries(obj));

使用场景

// 过滤对象属性
let filtered = Object.fromEntries(
  Object.entries(obj).filter(([k, v]) => v > 1)
);

// 映射对象值
let mapped = Object.fromEntries(
  Object.entries(obj).map(([k, v]) => [k, v * 2])
);

注意

  • Object.fromEntries() 是 ES10 才有的,ES8 只有 Object.entries()
  • 字符串对象也能用
Object.entries('ab');  // [['0', 'a'], ['1', 'b']]

4. String.prototype.padStart()

字符串头部补全:

'5'.padStart(2, '0');      // '05',补到2位
'5'.padStart(4, '0');      // '0005',补到4位
'abc'.padStart(5, 'xy');   // 'xyabc',从左补
'abc'.padStart(2);         // 'abc',超过长度不截断

语法

str.padStart(targetLength[, padString])

实际应用

// 日期格式化
let month = '5'.padStart(2, '0');   // '05'
let day = '9'.padStart(2, '0');     // '09'

// 序号格式化
'1'.padStart(3, '0');   // '001'
'42'.padStart(3, '0');  // '042'

// 卡号隐藏
'1234567890'.padStart(14, '*');  // '****1234567890'

5. String.prototype.padEnd()

字符串尾部补全:

'5'.padEnd(2, '0');      // '50'
'abc'.padEnd(5, '.');    // 'abc..'
'abc'.padEnd(5, 'xy');   // 'abcxy'
'abc'.padEnd(2);         // 'abc',超过长度不截断

实际应用

// 对齐输出
console.log('姓名'.padEnd(10, ' ') + '分数');
console.log('张三'.padEnd(10, ' ') + '90');
console.log('李四'.padEnd(10, ' ') + '85');

// 输出:
// 姓名       分数
// 张三       90
// 李四       85

注意

  • 如果补全字符串长度超过目标长度,会截断补全字符串
'abc'.padStart(6, '123456');  // '123abc',截断为'123'
'abc'.padEnd(6, '123456');    // 'abc123'

6. Object.getOwnPropertyDescriptors()

获取对象自身所有属性的描述符:

let obj = {
  name: '张三',
  get age() { return 18; }
};

Object.getOwnPropertyDescriptors(obj);
// {
//   name: { value: '张三', writable: true, enumerable: true, configurable: true },
//   age:  { get: [Function], set: undefined, enumerable: true, configurable: true }
// }

对比 getOwnPropertyDescriptor(单数)

// 单数:获取单个属性描述符(ES5)
Object.getOwnPropertyDescriptor(obj, 'name');

// 复数:获取所有属性描述符(ES8)
Object.getOwnPropertyDescriptors(obj);

实际用途:深拷贝 + 正确拷贝 getter/setter

// Object.assign 会丢失 getter/setter
let source = {
  get foo() { return 1; }
};
let copy = Object.assign({}, source);
// copy.foo = 1,变成普通值,不是 getter 了

// 正确的拷贝方式
let properCopy = Object.defineProperties(
  {},
  Object.getOwnPropertyDescriptors(source)
);
// properCopy.foo 是 getter,能正常工作

7. 函数参数末尾允许逗号

ES8 新增的是:函数定义的参数列表函数调用的参数列表也允许写尾随逗号。对象字面量和数组字面量更早以前就已经支持尾随逗号了。

// ES8 之前,对象和数组里就已经能写尾随逗号
let obj = {
  a: 1,
  b: 2,
};

let arr = [
  1,
  2,
];

// ES8 进一步允许函数参数列表写尾随逗号
function foo(
  a,
  b,
  c,
) {}

// 函数调用时也允许
foo(
  1,
  2,
  3,
);

好处

  • 添加新参数或重排参数时,git diff 更干净
  • 修改最后一项时不需要额外补逗号
  • 末尾逗号在语义上会被忽略,不影响执行

8. SharedArrayBuffer 和 Atomics

用于多线程编程。

SharedArrayBuffer

允许多个 Web Worker 共享同一块内存:

let sab = new SharedArrayBuffer(1024);  // 1KB 共享内存
let arr = new Int32Array(sab);
arr[0] = 42;

Atomics

提供原子操作,防止竞态条件:

let sab = new SharedArrayBuffer(4);
let arr = new Int32Array(sab);

// 在 Worker 中
Atomics.add(arr, 0, 5);    // 原子加5
Atomics.store(arr, 0, 10); // 原子写入
Atomics.load(arr, 0);      // 原子读取
Atomics.compareExchange(arr, 0, 10, 20); // 原子比较并交换
Atomics.wait(arr, 0, 10);  // 等待值变为10
Atomics.notify(arr, 0, 1); // 通知等待者

注意:由于安全原因(Spectre 漏洞),主流浏览器曾短暂禁用 SharedArrayBuffer,现在需要跨域隔离(Cross-Origin Isolation)才能使用。


总结

特性 说明 重要性
async/await 异步编程语法糖 ⭐⭐⭐⭐⭐ 最重要的特性
Object.values() 获取对象所有属性值 ⭐⭐⭐
Object.entries() 获取对象键值对数组 ⭐⭐⭐
String.padStart() 字符串头部补全 ⭐⭐⭐
String.padEnd() 字符串尾部补全 ⭐⭐⭐
Object.getOwnPropertyDescriptors() 获取所有属性描述符 ⭐⭐
函数参数末尾逗号 函数参数末尾允许逗号 ⭐⭐
SharedArrayBuffer/Atomics 共享内存和原子操作 ⭐(特殊场景)

AI编程 | 概念

AI 编程 | 概念


因为自己不太懂 AI 相关的一些概念,问了问 AI,看了些文章,然后整理成这篇笔记。本文主要讲 AI 相关的核心概念,以及从用户发出需求开始,背后究竟发生了哪些事。定位偏基础,适合前端 / 全栈工程师建立 AI 工程视角。如有错误,欢迎各位看官不吝指教!🙏🏻


一、先看历史:技术是怎么一步步走到今天的

理解现在,得先知道来路。

很多人第一次接触这些概念时,会以为"向量数据库催生了大模型"——其实顺序正好相反,是大模型的爆火让向量数据库找到了用武之地。把时间线梳理清楚,后面每个概念"为什么存在"就自然而然想明白了。

┌─────────────────────────────────────────────────────────────────────────────┐
                          AI 技术发展时间线                                   
├──────────┬──────────────────────────────────────────────────────────────────┤
  2017      Google 发布论文《Attention Is All You Need》                     
             Transformer 架构诞生,奠定现代 LLM 的基石                      
├──────────┼──────────────────────────────────────────────────────────────────┤
 2018~20    BERT(Google)、GPT-1/2/3(OpenAI)相继问世                     
             预训练 + 微调范式确立,涌现能力(Emergent Ability)首次出现    
├──────────┼──────────────────────────────────────────────────────────────────┤
 2022.11    ChatGPT 发布,LLM 进入大众视野                                  
             开发者开始大量接入 LLM API,工程化需求爆发                     
├──────────┼──────────────────────────────────────────────────────────────────┤
 2022~23    向量数据库(Pinecone / Milvus / Chroma)大规模普及              
            RAG 范式兴起,解决 LLM 知识过期 & 幻觉问题                      
            LangChain / LlamaIndex  Agent 框架相继出现                    
            Cursor 发布(Anysphere,2023),AI 编码工具元年                  
├──────────┼──────────────────────────────────────────────────────────────────┤
 2024.11    Anthropic 发布 MCP 协议(Model Context Protocol)               
             工具调用标准化,Agent 生态走向统一                             
├──────────┼──────────────────────────────────────────────────────────────────┤
  2025      Claude Code 发布(Anthropic,终端 CLI Agent)                   
             Agent 落地加速,多 Agent 协作成为新议题                        
└──────────┴──────────────────────────────────────────────────────────────────┘

用一句话把上面的链条串起来:

LLM 是地基 → RAG 给 LLM 装上长期记忆 → MCP 让工具调用有了统一标准 → Agent 把大脑、记忆和手脚整合成一个自主系统 → Skills / AGENTS.md 给 Agent 装上了项目规则手册


二、核心概念逐个拆解

2.1 LLM — 大语言模型

LLM(Large Language Model) 是基于 Transformer 架构、经过海量文本预训练的大规模语言模型,代表产品有 GPT-4、Claude、Gemini。

它的本质能力是预测下一个 token——通过对海量人类文本的学习,把语法、逻辑、常识都压缩进数百亿个神经网络参数(Weights)里。这些参数在训练完成后就固定下来,模型"出厂"后不会自动更新。

交互模式极其简单:

  ┌─────────────┐       ┌──────────────────┐       ┌─────────────┐
  │  Prompt In  │ ────► │   LLM 推理引擎   │ ────► │  Text Out   │
  │  (输入)   │       │  理解 · 推理 · 生成│       │  (输出)   │
  └─────────────┘       └──────────────────┘       └─────────────┘

把 LLM 理解成大脑是一个很贴切的比喻——它能理解你说的话、写代码、做逻辑推理。但光有大脑还不够,它不能主动翻文件、执行命令、访问网络,这些能力需要外部系统配合,这也是 RAG、MCP、Agent 存在的根本原因。

LLM 是如何理解自然语言的?

LLM "理解"语言并非真的像人类一样读懂含义,而是一套数学流程:将文字逐步转化为高维数值,再通过注意力机制捕获词语之间的关联。整个过程分为四步:

① 分词(Tokenization)
  ┌─────────────────────────────────────────────────┐
  │  原始文本:"今晚想吃点清淡的"                    │
  │      ↓ Tokenizer(分词器)                       │
  │  Token 序列:["今晚", "想", "吃", "点",          │
  │               "清", "淡", "的"]                  │
  │  每个 Token 映射到一个整数 ID                     │
  └─────────────────────────────────────────────────┘

   ② 向量化(Embedding)
  ┌─────────────────────────────────────────────────┐
  │  每个 Token ID → 高维浮点向量                    │
  │  "清淡" → [0.23, -0.81, 0.44, ...](768~4096 维)│
  │                                                 │
  │  注意,这里不是"找相似文档"(那是 RAG 的逻辑)      │
  │  向量只是为下一步注意力计算做数学准备             │
  └─────────────────────────────────────────────────┘

  ③ 注意力计算(Self-Attention)— Transformer 核心
  ┌─────────────────────────────────────────────────┐
  │  模型对每个 Token 计算它与其他所有 Token 的       │
  │  "相关程度"(注意力权重)                        │
  │                                                 │
  │  "清淡" 会把注意力集中到 "吃""今晚" 上       │
  │  → 理解这不是"性格清淡",而是"口味清淡"          │
  │                                                 │
  │  多头注意力(Multi-Head Attention):             │
  │  同时从多个维度捕获语义、句法、指代等不同关系     │
  └─────────────────────────────────────────────────┘

  ④ 自回归生成(Auto-regressive Generation)
  ┌─────────────────────────────────────────────────┐
  │  基于以上所有上下文,预测"下一个 Token"的概率分布 │
  │  → 采样 / 取最高概率 → 追加到序列 → 再次预测     │
  │  → 循环生成:"可以试试粥、蒸鱼或者豆腐汤……"      │
  │  → 直到生成 <EOS>(结束符)                      │
  └─────────────────────────────────────────────────┘

💡 LLM 本质上在做的事是:从海量文本中学习"哪些词在什么语境下应该出现在一起",然后在推理时根据上下文,给出统计上最合理的续写。它没有主观理解,但因为训练数据足够大,涌现出了很强的"仿理解"能力。

LLM 的固有局限:

缺陷 说明
知识截止(Knowledge Cutoff) 训练数据有时间截止点,对最新事件一无所知
幻觉(Hallucination) 可能生成看起来合理但实际错误的内容
领域知识不足 企业私有文档、内部代码库完全不了解
上下文窗口有限 单次推理能处理的文本量存在上限
推理成本高 按 token 计费,长对话成本显著

正是这些局限,推动了后续一系列工程技术的诞生。


2.2 Prompt — 提示词

Prompt 就是你发给模型的输入内容。它不只是一句话,而是模型"看到"的全部信息的总称——包括角色定义、背景上下文、任务指令和输出格式要求。

  ┌──────────────────────────────────────────────────────┐
  │                   一个完整的 Prompt                   │
  ├─────────────────────┬────────────────────────────────┤
  │   System Prompt     │  角色定义、能力边界、输出格式  │
  │   (系统提示词)     │  行为约束、禁止项              │
  ├─────────────────────┼────────────────────────────────┤
  │   History           │  历史对话记录(多轮上下文)     │
  │   (对话历史)       │                                │
  ├─────────────────────┼────────────────────────────────┤
  │   Context           │  RAG 检索到的相关文档片段      │
  │   (增强上下文)     │  当前项目代码 / 文件内容       │
  ├─────────────────────┼────────────────────────────────┤
  │   User Query        │  用户当前的问题 / 需求          │
  │   (用户输入)       │                                │
  └─────────────────────┴────────────────────────────────┘

模型给出的答案质量,很大程度上取决于 Prompt 的质量,这也是「提示词工程(Prompt Engineering)」这个方向存在的原因。同一个需求,描述得越清晰、上下文越充分,模型的输出就越准确。


2.3 Token 与 Context Window

Token

Token 是模型内部处理文本的最小单位,可以理解为"子词"。不同模型的 tokenizer 分词规则有差异,大致参考:

  • 英文单词:running ≈ 1 token
  • 中文汉字:1 个汉字 ≈ 1~2 token(视 tokenizer 而定)
  • 常见短语可能被合并为 1 个 token

💡 费用计算 = 输入 token 数 + 输出 token 数,发送的 Prompt 和模型返回的文本都算在内。可用 tiktoken(OpenAI)等工具库在本地预估消耗。

Context Window(上下文窗口)

上下文窗口是模型单次推理能"看到"的最大 token 数量,等同于它的工作记忆——不仅仅是当前这一条消息,而是所有塞进去的内容总和:

  ┌──────────────────────────────────────────────────────────────────────┐
  │                        Context Window                                │
  │                                                                      │
  │  System   │  对话   │  RAG 检索   │  工具返回  │  当前输入 │  输出   │
  │  Prompt   │  历史   │  上下文     │  结果      │           │  预留   │
  │                                                                      │
  │ ◄──────────────── 最大 Token 限制(不同模型不同)────────────────►  │
  └──────────────────────────────────────────────────────────────────────┘

主流模型上下文窗口参考(2026年年初):

模型类别 模型名称 上下文窗口 (Context Window) 备注
OpenAI GPT-5.2 (Garlic) 400K tokens 相比 GPT-4o 提升了 3 倍以上,输出长度大幅增至 128K
GPT-5 400K tokens 2026 标准旗舰版,强化了 Agent 执行能力
Anthropic Claude Opus 4.6 1M tokens (Beta) Opus 级模型首次支持百万级上下文,默认 GA 版本为 200K
Claude 4.6 Sonnet 1M tokens (Beta) 兼顾速度与长文本,支持上下文压缩 (Context Compaction)
Claude 4.5 Haiku 200K tokens 依然保持极高的性价比和响应速度
Google Gemini 3 Pro 1M - 2M tokens 视频与超大规模工程代码库处理的标杆
Gemini 3 Flash 1M tokens 在长文本检索(Needle In A Haystack)中表现极稳
国产模型 Kimi K2.5 (Reasoning) 2M - 10M tokens 月之暗面依然在长文本领域保持量级领先
DeepSeek-V3 128K - 256K tokens 极致性价比,在长文本内检索的精度极高
通义千问 Qwen 3.5 1M tokens 阿里云最新旗舰,对大规模代码库的对齐效果出色
豆包 (Doubao-Seed-Code) 256K tokens 字节跳动针对编程场景优化的长文本版本

当对话内容超出窗口上限时,Agent 通常会采用两种策略兜底:

  1. 滑动窗口(Sliding Window):丢弃最早的对话轮次,只保留最近 N 条
  2. 自动摘要(Auto Summary):把早期对话压缩成摘要,保留语义而非原文

2.4 Vector Embeddings — 向量嵌入

向量嵌入(Vector Embeddings) 是把文本、图片等非结构化内容,转换为一组高维数值数组的过程。这个数组就是"向量",由 Embedding 模型生成,维度一旦确定就固定不变。

为什么需要向量?因为计算机无法直接比较两段文字的"语义相似度",但可以计算两个向量的距离(余弦相似度 / 欧氏距离)。Embedding 的核心意义就是:把语义相似的内容,映射到向量空间中相近的位置

  文本空间(无法直接比较语义)        向量空间(可以度量距离)
  ┌──────────────────────┐           ┌────────────────────────────────┐
  │  "今天天气真好"       │           │  [0.82, -0.31, 0.56, ...]  ◄──┼─ 距离近
  │  "阳光明媚的一天"     │  Embed ►  │  [0.79, -0.28, 0.61, ...]  ◄──┼─ 语义相似
  │  "股票大跌了"         │           │  [-0.44, 0.91, -0.20, ...] ◄──┼─ 距离远
  └──────────────────────┘           └────────────────────────────────┘

💡 向量的每一个维度本身没有具体含义,是神经网络在训练过程中自动学习到的抽象语义特征,维度数由 Embedding 模型决定(固定不变,如 text-embedding-3-small 输出 1536 维)。


2.5 Vector Database — 向量数据库

向量数据库(如 Milvus、Pinecone、Qdrant,本地可用 Chroma)是专门为高效相似度检索而设计的存储层,支持 ANN(近似最近邻)算法,能在毫秒级别从海量向量中找到最相似的 K 个结果。

它的存储结构很简洁,核心就三个字段:唯一 ID、向量数组、原始内容或元数据。

// 基础结构(以 Qdrant 字段命名为例)
{
  "id": "doc_12_chunk_3",
  "vector": [0.012, -0.88, 0.34, ...],   // Embedding 模型生成的高维向量
  "payload": {
    "text": "LangChain 是一个 LLM 应用框架...",
    "source": "langchain.pdf",
    "chunk_index": 3,
    "created_at": 1710000000
  }
}

💡 存储策略:小文本直接放在 payload 里;大文本存关系型数据库,向量库只存 ID 引用,避免单条记录过大影响检索性能。

向量数据库能存哪些东西:

类别 示例
文档知识库 技术文档、产品手册、法律条文
对话历史 用户历史对话的语义摘要
企业内部知识 内部 Wiki、会议记录、邮件摘要
代码库 函数、类的代码块及注释
网页内容 爬取并切块后的网页段落
用户画像 用户偏好、行为模式的向量表示
多模态内容 图片 / 视频的文本描述向量

数据库膨胀的管理策略:

随着时间推移,向量数量会持续增长,常用三种策略控制规模:

  1. 滑动窗口(Sliding Window):只保留最近 N 天的数据,到期删除
  2. 重要性降权(Importance Decay):旧向量降低检索权重而非直接删除,保留但弱化影响
  3. 分层存储(Tiered Storage):近 7 天热存储、7~30 天温存储、30 天以上归档,按访问频率分层

此外,随着数据量增大,可通过分片、倒排索引、聚类等技术解决性能扩展问题——这不是简单的二维坐标检索,而是有一整套工程体系支撑的。


2.6 RAG — 检索增强生成

RAG(Retrieval-Augmented Generation,检索增强生成) 是一种独立的技术范式,可以单独使用(比如企业知识库问答系统),也常作为 Agent 的检索模块集成。它的核心思路是:先检索,再生成——在调用 LLM 推理之前,先从外部知识库拉取相关内容,一起塞进 Prompt,让模型"有据可查"地回答,而不是靠训练时的记忆凭空生成。

RAG 分为两个阶段,时序上完全分离:

  ━━━━━━━━━━━━━━━━━━━━━━  阶段一:知识库构建(离线,一次性)  ━━━━━━━━━━━━━━━━━━━━━━

  原始数据源           文档解析            Chunk 切分
  ┌──────────┐        ┌──────────┐        ┌──────────┐
  │ PDF / MD │        │  文字    │        │ 段落切块 │
  │ 代码/Wiki │ ─────► │  提取    │ ─────► │(~512 tok)│
  └──────────┘        └──────────┘        └────┬─────┘
                                               │
                                     Embedding 模型向量化
                                               │
                                               ▼
                                        ┌──────────┐
                                        │ Vector DB│  ← 写入,离线完成
                                        └──────────┘

  ━━━━━━━━━━━━━━━━━━━━━━  阶段二:知识查询(在线,每次请求)  ━━━━━━━━━━━━━━━━━━━━━━

  用户提问             临时向量化(只读)   相似度检索           精排
  ┌──────────┐        ┌──────────┐        ┌──────────┐        ┌──────────┐
  │"如何使用  │        │ Embedding│        │  top-k   │        │Reranker  │
  │ Vue 3    │ ─────► │(不写入  │ ─────► │  相关文档│ ─────► │ 精排打分 │
  │  响应式?"│        │  DB)    │        │          │        └────┬─────┘
  └──────────┘        └──────────┘        └──────────┘             │
                                                           ┌────────▼───────┐
                                                           │  拼入 Prompt   │
                                                           │  送给 LLM 生成 │
                                                           └────────────────┘

知识库由谁来建,什么时候建?

离线构建阶段与大模型训练完全解耦——模型只提供理解和生成能力,知识库由使用方自己负责。开发者或企业在拿到模型 API 之后,根据自身业务需求,随时可以构建或更新知识库。

两个例子:

Cursor(编程场景) 开发者打开一个项目后,Cursor 会在本地实时扫描代码文件,自动完成切片、向量化,并存入本地临时索引。目的是让模型"读懂"你当前的项目上下文——当你问"这个函数在哪里被调用"时,模型能基于你的真实代码回答,而不是凭空猜测。这里知识库的内容就是你自己的项目,每个用户的库都不一样,构建过程对用户无感,自动发生

企业内部问答(非编程场景) 某保险公司想让员工能用自然语言查询内部理赔规则。IT 团队将数百份 Word 版本的操作手册、产品条款导入系统,完成切片和向量化后存入向量数据库。此后员工提问"轻症赔付比例是多少",系统先从库里检索相关条款,再交给模型组织成自然语言回答。知识库可以随着文件更新而重新构建,无需重新训练模型。

💡 在线查询阶段,用户问题会被临时向量化用于检索,这个向量不会写入数据库,只是一次性的检索 key。写入操作仅发生在离线的构建阶段。

Top-K 文档是什么?

在相似度检索这一步,系统会把用户问题的向量与知识库中所有文档 Chunk 的向量逐一比较相似度(余弦相似度等),然后按相似度从高到低排序,取出得分最高的前 K 个文档片段,这就是 Top-K 文档。

向量数据库中有 10000Chunk (当询问 "番茄炒蛋怎么做才好吃")
─────────────────────────────────────────────────────
 相似度排名 │ Chunk 内容摘要             │ 相似度得分
─────────────────────────────────────────────────────
 #1        │ 番茄炒蛋的家常做法         │ 0.94
 #2        │ 番茄的挑选与去皮技巧       │ 0.91
 #3        │ 鸡蛋嫩滑的火候控制方法     │ 0.87
 ···       │ ···                        │ ···
 #10000    │ 红酒醒酒时间与温度指南     │ 0.03
─────────────────────────────────────────────────────
Top-KK=3)→ 取 #1 #2 #3,拼入 Prompt 交给 LLM

K 值是一个可调参数,通常取 3~10。K 越大,检索到的上下文越丰富,但也会消耗更多 Context Window 空间并引入噪音;K 越小,精准度高但可能遗漏关键信息。以菜谱场景为例,K=3 只取番茄炒蛋最核心的三条内容,K=8 则可能额外纳入"鸡蛋的营养成分"或"番茄的品种介绍"——相关但未必有用。

实际工程中通常还会在 Top-K 后接一个 Reranker(精排模型),对这 K 个候选片段做二次评分重排,进一步提升最终送入 LLM 的文档质量。

在菜谱问答场景下,RAG 的"检索"就是:接收到你的问题后,扫描整个菜谱知识库,找到相关的食材处理、烹饪步骤和火候技巧,作为上下文一起发给模型——这是模型能给出"符合你口味和实际情况"的具体建议,而不是泛泛而谈的根本原因。


2.7 MCP — 模型上下文协议

MCP(Model Context Protocol,模型上下文协议) 是 Anthropic 于 2024 年 11 月发布的开放协议,本质是定义了一套标准:工具如何向模型声明自己的能力,模型如何通过统一接口调用这些工具。

没有 MCP 之前,每个编辑器都要自己实现一套"读文件、执行命令、调 API"的逻辑,彼此不通用。有了 MCP,就像有了 USB 接口标准——任何工具只要实现了 MCP Server,任何支持 MCP 的 Agent 就能直接调用,无需重复开发。

  MCP = 工具 / 系统  与  LLM  之间的统一通信标准
MCP 三层架构
  ┌───────────────────────────────────────────────────────────────────┐
  │                          Host(主机)                              │
  │           运行环境 Cursor / VS Code / Claude Code                  │
  │           · Agent 的载体,用户交互的 UI 界面                        │
  │           · 协调和管理多个 Client 实例                              │
  └───────────────────────────┬───────────────────────────────────────┘
                              │ 管理(1 Host : N Client)
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
  ┌──────────────────┐ ┌──────────────────┐  ...
  │  Client A        │ │  Client B        │
  │  大模型的"网卡"   │ │  大模型的"网卡"   │
  │  · 转发 tool_call│ │  · 转发 tool_call│
  │  · 回传执行结果  │ │  · 回传执行结果  │
  └────────┬─────────┘ └────────┬─────────┘
           │ 1:1 连接             │ 1:1 连接
           ▼                     ▼
  ┌──────────────────┐  ┌──────────────────┐
  │  Server A        │  │  Server B        │
  │  文件系统插件    │  │  GitHub 插件     │
  │  read/write_file │  │  clone/push/PR   │
  └──────────────────┘  └──────────────────┘

Host 负责整个生命周期

三层结构里,Host 不只是"UI 界面",它还是整个 MCP 运行环境的管理者。用户打开 Cursor 时,Host 会读取配置文件,把需要的 Server 作为子进程在后台拉起来,同时创建对应的 Client 实例与之建立连接——这一切对用户无感。用户关闭编辑器时,Host 也负责断开连接、回收所有 Server 进程。Agent 本身不管这些,它只管"通过 Client 要结果"。

Server 有两种形态:一种是 Host 启动的本地子进程(Stdio 通信),生命周期完全跟着 Host;另一种是远程独立部署的服务(HTTP/SSE 通信),Host 只负责连接,不负责启动和关闭。

如果某个工具因环境原因无法使用——比如系统缺少依赖、API Key 未配置——规范的 Server 会在启动阶段做环境检测,不满足条件的工具直接不注册进列表,Client 拿到的 tools/list 就不会包含它。若工具已暴露但执行时才报错,Server 会返回标准错误,由 Host 或 Agent 决定下一步:重试、换工具或提示用户。

你可以开发自己的server服务,但是需要满足MCP相应的规定,因为MCP是业界普遍采用的通信协议。现在各类场景基本都有对应的server了(比如filesystem,GitHub / GitLab, Puppeteer),开发者和大厂也在持续的贡献新的server

通信协议(JSON-RPC 格式):

{
  "method": "tools/call",
  "params": {
    "name": "write_file",
    "arguments": { "path": "src/app.js", "content": "..." }
  }
}

MCP 支持两种传输机制:

传输方式 适用场景 特点
Stdio(标准输入输出) 本地进程通信 性能最优,无网络开销,通常 1:1 服务单个 Client
HTTP / SSE(流式) 远程工具调用 支持多 Client 并发连接,适合云端部署的 MCP Server

MCP Server 支持的工具能力(举例):

文件系统   read_file(path)  /  write_file(path, content)  /  list_dir(path)
终端执行   run_shell(cmd)        ← 必须在安全隔离环境下运行
Git 操作   clone / commit / push / create_pr
HTTP 请求  http_get(url)  /  headless 浏览器(处理动态页面)
向量检索   vector_search(query_embedding, top_k)
数据库     直接连接 DB 驱动执行 SQL
CI/CD      调用云 provider SDK(aws cli、gcloud 等)
代码执行   code_executor(code, language)

双向工作流(Sampling): MCP 还支持"反向请求"——Server 可以发起对 LLM 的调用请求,实现更复杂的递归 Agent 行为:

  正向(工具调用): Host ──► Client ──► Server(执行工具)
  反向(Sampling): Server ──► Client ──► Host ──► LLM(发起 LLM 推理)

实现 MCP Server 常用的工具库:

  • 系统操作subprocess / child_process(执行 Shell),os / pathlib(文件系统)
  • HTTPrequests / httpx(Python),axios / fetch(JavaScript)
  • 云服务boto3(AWS)、google-cloud-*(GCP)、paramiko(SSH)
  • 浏览器自动化:Playwright、Puppeteer、Selenium

2.8 Guardrails — 护栏

高速公路的护栏是为了防止车辆偏离轨道。AI 里的护栏,是为了防止模型的输入和输出偏离安全、合规、准确的边界。它既是概念,也是工程上的独立模块,通常集成在 Agent 或平台层中。

Guardrails 覆盖整条链路,从用户输入到最终输出都有介入:

  用户输入
      │
      ▼
  ┌─────────────────────────────────────────────────────┐
  │  输入层(pre-prompt)                                │
  │  敏感词过滤 · 注入攻击检测 · 格式校验                │
  └──────────────────────┬──────────────────────────────┘
                         │
                         ▼
  ┌─────────────────────────────────────────────────────┐
  │  工具声明层                                          │
  │  白名单约束可调用工具 · 参数类型校验                  │
  └──────────────────────┬──────────────────────────────┘
                         │
                         ▼
  ┌─────────────────────────────────────────────────────┐
  │  模型交互层(runtime)                               │
  │  tool_call 权限校验 · 高危命令二次确认               │
  └──────────────────────┬──────────────────────────────┘
                         │
                         ▼
  ┌─────────────────────────────────────────────────────┐
  │  后处理层(post-generation)                         │
  │  规则匹配 · 合规检查 · 敏感数据过滤                  │
  └──────────────────────┬──────────────────────────────┘
                         │
                         ▼
  ┌─────────────────────────────────────────────────────┐
  │  审计层                                              │
  │  记录所有调用日志,支持人工审计与回滚                 │
  └─────────────────────────────────────────────────────┘

按作用分类(参考 McKinsey 框架):

类型 作用
适当性(Appropriateness) 检测内容是否有毒、有害、带偏见,拦截不当内容
幻觉(Hallucination) 确保生成内容不含事实错误或误导性信息
合规性(Regulatory-compliance) 验证内容是否符合行业监管要求
对齐(Alignment) 确保输出不偏离用户的原始意图
验证(Validation) 检查内容是否满足特定标准,可触发修正循环

四个核心组件:

  • Checker(检查器):扫描 AI 生成内容,检测错误并标记问题
  • Corrector(校正器):Checker 发现问题后,对输出进行润色和修正
  • Rail(轨道):管理 Checker 与 Corrector 的交互循环,未达标则反复触发修正
  • Guard(守卫):统筹协调以上三者,汇总结果,输出最终内容

2.9 AI Agent — 智能体

前面提到,LLM 本身只能"问答",它不会主动做事。AI Agent 就是在 LLM 之上构建的自主系统,赋予了模型"主动推进任务"的能力。

  AI Agent = LLM(大脑)
           + Memory(记忆)         ← Context Window(短期)+ Vector DB(长期)
           + Tool Layer / MCP(手脚)← 文件读写、终端、网络、Git...
           + Planning & Execution  ← 拆解目标、循环执行、自我纠错

一个典型的例子:输入"帮我实现用户登录逻辑"

  用户输入:"帮我实现用户登录逻辑"
        │
        ▼
  ① 感知 & 理解:读取项目结构,了解技术栈和现有代码
        │
        ▼
  ② 规划:拆解任务步骤 → 选择需要调用的工具
        │
        ▼
  ③ 执行工具调用:
     read_file("src/router/index.ts")     → 了解现有路由结构
     read_file("src/types/user.ts")       → 了解用户类型定义
     write_file("src/api/auth.ts", ...)   → 生成认证 API 层
     write_file("src/views/Login.vue", .) → 生成登录页面
        │
        ▼
  ④ 观察 & 反馈:
     run_shell("npm run build")           → 验证编译
     → 发现类型错误 → 自动修复 → 再次验证
        │
        ▼
  ⑤ 任务完成 or 继续下一轮循环

这个 "感知 → 规划 → 执行 → 观察 → 反馈" 的循环,叫做 ReAct(Reasoning + Acting)框架,是 Agent 行动的基本模式,可能循环多轮直到任务完成或触发终止条件。

Agent 内部模块结构:

  ┌────────────────────────────────────────────────────────────────────┐
  │                         Agent Runtime                              │
  │                                                                    │
  │  ┌───────────────┐  ┌───────────────┐  ┌──────────────────────┐   │
  │  │    Planner    │  │    Router     │  │       Memory         │   │
  │  │               │  │               │  │                      │   │
  │  │ · 分解任务    │  │ · 直接回答?  │  │ · short-term         │   │
  │  │ · 制定步骤    │  │ · 需要 RAG?  │  │   (Context Window)   │   │
  │  │ · 选择工具    │  │ · 需要工具?  │  │ · long-term          │   │
  │  └───────────────┘  └───────────────┘  │   (Vector DB / 文件) │   │
  │                                         └──────────────────────┘   │
  │  ┌─────────────────────────────────────────────────────────────┐   │
  │  │                     Tool Manager                            │   │
  │  │  · 维护可用工具列表(tool schema)                           │   │
  │  │  · 通过 MCP Client 发起工具调用,接收执行结果                │   │
  │  └─────────────────────────────────────────────────────────────┘   │
  │  ┌─────────────────────────────────────────────────────────────┐   │
  │  │                    Prompt Builder                           │   │
  │  │  System Prompt + 对话历史 + RAG 文档 + 工具结果 + 用户输入  │   │
  │  └─────────────────────────────────────────────────────────────┘   │
  └────────────────────────────────────────────────────────────────────┘

Agent 目前已具备多模态能力,能查看图片内容、读取代码截图。音频、视频等模态的支持也在持续推进中。


2.10 Skills — 技能规范文件

Skills 是一种「技能能力包」,让 AI 编程助手理解某个技术、规范、工具或最佳实践的正确使用方式,并在必要时直接执行相关工具。它的工作方式是按需加载——Claude 判断当前任务与哪些 Skill 相关,再动态载入对应内容,避免无效占用上下文。

一个 Skill 本质上是一个文件夹,由三个核心部分构成:

  • 指令(Instructions):核心文件 SKILL.md,包含 YAML frontmatter(定义名称、描述及触发条件)和 Markdown 正文(编码标准、最佳实践、API 正确用法及需规避的坑)
  • 脚本(Scripts):可直接运行的自动化脚本,让 Claude 不只是「读规则」,还能「跑工具」——比如检测依赖版本、执行格式化、生成模板文件等
  • 资源(Resources)references/ 目录下的参考文档、API 手册、示例代码,作为 Claude 的上下文补充知识

说人话就是:当我写 Vue 项目时,Claude 会参考我给的 Vue 规则集(API、写法推荐、最佳实践)来给方案,而不是靠训练时学到的旧知识;遇到需要自动化的任务,它还可以直接调用 Skill 内置的脚本来执行。

Agent Skills 是由 Anthropic 主导推动的开放标准,规范发布在 agentskills.io,这意味着按此标准创建的 Skill 不局限于 Claude,支持该标准的其他 AI 平台和工具同样可以复用。Anthropic 官方在 anthropics/skills 仓库中维护了一批示例和生产级 Skill 实现,涵盖文档创建、数据分析、企业工作流等场景,可直接取用或作为自定义 Skill 的参考。社区开源项目 antfu/skills(由 Vue 核心团队成员 Anthony Fu 发起)则将规则文件打包成 npm 包,方便前端开发者复用现成规范,无需从头搭建。

Skills 支持三种来源:

  • Anthropic 官方 Skill:由 Anthropic 维护,在 Claude 中自动触发,无需手动安装(如 Word、Excel、PDF 文档能力)
  • 自定义 Skill:由个人或团队编写,用于封装特定业务规范、数据分析流程或个人工作习惯
  • 合作伙伴 Skill:来自 Notion、Figma、Atlassian 等第三方,与对应 MCP 连接器配合使用,实现集成工作流

创建一个最简 Skill 只需一个带 frontmatter 的 SKILL.md 文件:

---
name: vue-best-practices
description: 当项目使用 Vue 3 时加载,提供 Composition API、script setup 及响应式 API 的最佳实践
---

# Vue 3 最佳实践

## 组件写法
- 优先使用 `<script setup>` 语法
- 使用 `defineProps``defineEmits` 替代选项式写法

## 响应式
- 对象/数组用 `reactive()`,基础类型用 `ref()`
- 避免直接解构 reactive 对象,使用 `toRefs()`

frontmatter 中只有两个必填字段:name(唯一标识符)和 description(同时作为 Claude 判断是否激活该 Skill 的依据)。对于需要执行能力的 Skill,可在文件夹中额外附加脚本文件。

antfu/skills 为例,安装使用方式如下:

# 安装 vue、vite、vue-best-practices 三个 skill
npx add-skill antfu/skills --skill vue --skill vite --skill vue-best-practices

安装时会交互式询问你要支持哪些 Agent,根据选择生成对应目录:

◆  Which agents do you want to install to?
│
│  ── Universal (.agents/skills) ── always included ──────────────
│    • Amp  • Codex  • Cursor  • Gemini CLI
│    • GitHub Copilot  • Kimi Code CLI  • OpenCode
│
│  ── Additional agents ─────────────────────────
│ ❯ ● Claude Code (.claude/skills)
│   ○ Cline (.cline/skills)
│   ○ Continue (.continue/skills)
│  ↓ 24 more
└

安装后生成的目录结构:

.agents/skills/              ← 通用目录,Cursor / Copilot / Codex / Gemini CLI 等都读这里
├── vue/
│   ├── references/          ← 资源:API 手册、示例代码
│   │   ├── core-new-apis.md
│   │   ├── advanced-patterns.md
│   │   └── script-setup-macros.md
│   ├── scripts/             ← 脚本:可运行的自动化工具
│   │   └── check-version.sh
│   └── SKILL.md             ← 指令:规范约束 + 触发条件
└── vite/
    ├── references/
    │   └── core-features.md
    └── SKILL.md

.claude/skills/              ← Claude Code 专用目录(部分 Agent 有独立目录)

可以把 Skill 理解为:给 Agent 的工作手册 + 工具箱——既告诉它「在这个项目里该怎么写代码」,也给它可以直接调用的工具完成自动化任务,还会在恰当时机自动激活,而不是始终占用上下文。


2.11 其他词汇

Vibe Coding(氛围编程) 一种以自然语言为主导的编程方式:开发者用日常语言描述想要实现的功能,由 AI 自动生成对应代码,开发者的核心工作从"写代码"转变为"审查代码、纠正方向"。它不要求你逐行手写,而是更像在给 AI 当"产品经理"——你负责提需求和把关,AI 负责实现。名字里的"氛围",指的是这种模糊但有方向感的协作状态。

Multimodal(多模态) 指 AI 模型能同时理解和处理多种类型信息的能力,包括文字、图片、音频、视频等。传统模型往往只能处理一种类型(如纯文字),而多模态模型可以做到"看图说话"、"听录音总结"等跨类型任务。GPT-4o、Claude 3、Gemini 等主流模型均已支持图文混合输入。

Fine-tuning(微调) 大模型经过海量数据的预训练后,已具备通用能力,但在特定领域(如医疗、法律、客服)可能表现不够精准。微调就是在这个基础上,用该领域的专属数据对模型进行"二次训练",让它在特定场景下表现得更专业、更符合需求。可以类比为:通用大学毕业生入职后接受的岗位专项培训。

Distillation / Knowledge Distillation(知识蒸馏) 一种模型压缩技术。核心思路是:用一个能力强但体积大的"教师模型"来指导训练一个更小、更轻量的"学生模型",让小模型尽量学到大模型的推理能力,同时大幅降低计算成本和部署门槛。结果是:小模型的体积可能只有大模型的几十分之一,但在很多任务上仍能达到接近的效果。DeepSeek-R1 就采用了这一技术。

三、项目中的上下文配置文件

这些文件的共同目的只有一个:为 Agent / 模型提供额外上下文、规范和快速索引,减少模型反复推断、猜测项目规则的成本。

  ┌────────────────────────────────────────────────────────────────┐
  │                  项目配置文件 · 作用层级                        │
  ├────────────────────┬───────────────────────────────────────────┤
  │  llms.txt          │  三方库级别:告知 AI 哪里找最新文档        │
  ├────────────────────┼───────────────────────────────────────────┤
  │  AGENTS.md         │  项目级别:构建命令、规范、约定             │
  ├────────────────────┼───────────────────────────────────────────┤
  │  CLAUDE.md         │  工具级别:特定 Agent 的偏好配置           │
  │  .cursorrules      │                                           │
  ├────────────────────┼───────────────────────────────────────────┤
  │  .agents/skills/   │  技术栈级别:框架 API 规范和最佳实践       │
  └────────────────────┴───────────────────────────────────────────┘

AGENTS.md

专门给 AI 编码 Agent 看的项目说明文件,类似"AI 专用 README",聚焦机器执行的细节:构建命令、测试流程、代码风格规范、commit message 格式等。Agent 启动时会优先读取,避免反复询问或靠训练知识猜测项目约定。

可分层嵌套使用——在 monorepo 的子包目录里也可以放 AGENTS.md,Agent 会优先使用距当前编辑文件最近的那一个。

llms.txt / llms-full.txt

为大语言模型优化的依赖库文档索引文件,以简洁 Markdown 格式提供结构化清单,告知 AI 哪里能找到该库的 API 文档、最佳实践和核心架构说明。

当你的项目用到某个库时,AI 会读取该库的 llms.txt,确保使用的是最新 API 而非训练数据里的旧版本。(还记得 Tailwind CSS 因流量经济考虑拒绝添加 llms.txt 的 PR 吗?😂)

CLAUDE.md / Copilot Instructions

针对特定 Agent 的默认上下文和指令集,用于持久化用户的偏好配置——比如"所有组件必须用 <script setup> 语法"、"禁止使用 any 类型"等。

.cursorrules / .skills

工具特有的规则文件,在特定环境下约束 Agent 的自动行为。例如 .cursorrules 可强制要求"使用 TypeScript 严格模式"、"禁止直接操作 DOM"、"组件必须放在 src/components 目录下"。


四、Agent 完整交互流程

掌握了前面所有概念后,来看一次完整的 Agent 执行过程究竟是什么样的。

前提:向量数据库已完成离线知识库构建;Agent 启动时已将 AGENTS.md 内容注入 System Prompt;MCP Client 已初始化并与各 MCP Server 建立连接。


上下文优先级(从高到低)

Agent 在任何时候都会综合多个信息源做判断,优先级依次是:

  1. 当前对话窗口——最新、最权威的信息
  2. AGENTS.md / 系统提示词——启动时就注入好的角色定义和规则
  3. RAG 检索结果——按需从文档库里临时捞出来的相关内容
  4. 向量数据库里的长期记忆——历史对话的语义摘要
  5. 模型本身的训练知识——静态的,不会实时更新

主流程


① 用户发出需求 → 输入护栏

用户说了一句话(比如"帮我给登录接口加上 rate limiting"),这句话不会直接进大模型,而是先过输入护栏这道关:

  • 检查有没有恶意指令(Prompt Injection)或敏感词
  • 校验格式是否合法、Token 长度有没有超限

❌ 如果不合规:直接拦截,返回提示,终止流程,并异步写入审计日志。
✅ 如果合规:继续往下走。


② 读取记忆

输入合规后,系统会去记忆服务(一个独立的后台服务,不是 Agent 自己去取)里读三类记忆:

  • 短期记忆:这次会话聊了什么
  • 长期记忆:用户的偏好、项目上下文(历史积累的)
  • 工作记忆:上一轮工具调用返回了什么结果

这些记忆后面会一起打包进 Prompt。


③ 语义路由——判断这个任务该怎么处理

拿到用户的输入后,一个轻量的语义路由模块(不是主 LLM,用小模型或规则引擎,省成本)会分析这个请求是什么类型的任务,然后分流:

任务类型 走哪条路
💬 简单问答/推理 直接用最小 Prompt + 思维链(CoT)调模型
📄 文档/知识查询 走上下文组装 → 触发 RAG 检索
🔍 代码审查 先做 AST/lint 静态分析,注入 .skills 规范,再调模型
⚙️ 复杂/需要操作的任务 走上下文组装 → 可能触发 RAG → 进入 ReAct 循环

④ 上下文组装——把所有材料拼成一个完整的 Prompt

把以下内容打包在一起,准备喂给大模型:

  • 系统提示词(角色定义、行为边界、AGENTS.md 规则)
  • 刚才读取的记忆
  • 用户偏好 & .skills 规范
  • 可用工具的描述清单(Tool Schema,告诉模型"你能用哪些工具、每个工具需要什么参数")
  • 当前环境上下文

⑤ 判断是否需要 RAG 检索

上下文组装完后,判断这个任务需不需要去文档库里查资料:

不需要 → 直接给大模型推理。

需要 RAG

  1. 把用户的问题临时转成向量(注意:这个向量不会存到数据库,只是临时用)
  2. 拿这个向量去 Vector DB 里做相似度检索,找出最相关的 Top-K 段落(Chunk)
  3. 可选:用 Reranker 精排模型对这些结果重新排序,挑最相关的
  4. 把检索到的内容合并注入 Prompt,同时标注来源引用
  5. 然后再给大模型推理

📌 RAG 的文档是怎么进 Vector DB 的?
Vector DB 有两条写入路径:

  • 离线索引:外部文档(PDF / Wiki / 代码库等)经"文档加载 → 切分成小 Chunk(带重叠,避免上下文断裂)→ 向量化 → 存入 Vector DB"流水线预处理,与实时请求无关,提前跑好。
  • 在线写回:每轮对话结束后(第 ⑪ 步),对话摘要同样会被向量化写入 Vector DB,作为长期记忆。这条路径是实时触发的,下次对话的 RAG 检索就可能命中它。

⑥ LLM 推理(ReAct 循环的入口)——整条链路最核心的节点

大模型拿到完整的 Prompt(包含系统提示、记忆、RAG 结果、用户输入、工具清单),开始推理:

  • 理解需求,做思维链推理,制定执行计划
  • 输出两种结果之一:
    • 直接输出文本(不需要用工具)→ 进入输出护栏
    • 输出 tool_call(声明要调用某个工具,带上参数)→ 进入工具权限校验

⑦ 工具权限校验

模型说"我要调用这个工具"之后,不会直接就跑,先过权限检查:

  • 这个工具当前用户有没有权限用?
  • 传入的参数合不合规?

❌ 无权限 / 参数违规:拒绝执行,把错误信息返回给模型,让它重新想。
✅ 通过:判断这次要不要并行调多个工具,然后去执行。


⑧ MCP 工具执行

通过 MCP Client 把工具调用指令路由到对应的 MCP Server,真正在宿主环境里执行各类操作。以下是主要工具类型:

🗂️ 文件 & 本地环境
  • 文件读写:读取源码、配置文件,写入生成内容,支持指定路径和编码
  • Shell 命令执行:运行脚本、编译构建、执行测试,获取 stdout/stderr
  • Git 操作:查看 diff、提交记录、分支信息,辅助代码审查
🌐 网络 & 第三方 API
  • GitHub / GitLab API:创建 PR、查看 Issue、触发 CI/CD 流水线
  • 第三方 HTTP 服务:调用业务 API、消息推送(Slack、钉钉)、支付 / 短信等接口
🕷️ 网页数据爬取

当需要从公开网页获取实时信息时(如竞品分析、文档抓取、价格监控),Agent 会调用爬取工具:

  • 轻量抓取(HTTP Fetch):直接发 HTTP 请求拿 HTML,适合静态页面;速度快,无需浏览器环境
  • 无头浏览器(Headless Browser):启动 Puppeteer / Playwright,适合 JS 渲染的 SPA 页面;可模拟点击、滚动、等待异步加载
  • 内容解析:拿到原始 HTML 后,用 CSS Selector 或 XPath 提取正文、表格、链接等结构化字段,过滤掉导航栏/广告等噪声
  • 反爬处理:必要时设置请求头(User-Agent、Referer)、控制请求频率、处理 Cookie / Session,避免被封禁
  • 结果回传:将解析后的结构化文本或 JSON 追加至上下文,供模型进一步分析

⚠️ 爬取工具的输出通常是"原始"内容,模型后续还需要做信息提炼,不会直接把整个页面塞进 Prompt(Token 成本过高)。

🗄️ 数据库查询

数据库查询分两种场景,底层机制完全不同:

1. 关系型数据库(SQL)

适用于结构化数据查询,例如查订单、查用户信息、做报表统计。

  • 工具收到指令后,生成 SQL 语句(可由 LLM 辅助生成 Text-to-SQL)
  • 通过数据库连接池(pg / mysql2 / prisma 等)执行查询
  • 返回结构化的行列数据(JSON 或 CSV 格式)
  • 注意事项:
    • 写操作(INSERT / UPDATE / DELETE)需要严格权限控制,通常设为只读账户
    • 复杂查询应加超时限制,防止慢查询阻塞
    • 返回结果过大时需截断,只取前 N 行喂给模型
典型数据库 适用场景
PostgreSQL / MySQL 业务数据、用量统计、日志查询
SQLite 本地轻量存储、开发调试
ClickHouse 大规模分析型查询、时序数据

2. 向量数据库(Vector DB / RAG 检索)

适用于语义相似度查询,例如"找出和这段描述最相关的文档片段",这就是第 ⑤ 步 RAG 流程里用到的那个数据库。

  • 查询时先把问题文本转成向量(Embedding)
  • 用 ANN(近似最近邻)算法在向量库中检索语义最相近的 Top-K 条目
  • 返回的不是精确匹配的行,而是"语义上最接近"的文本块
  • 可选接 Reranker 进一步精排,提升相关性
典型实现 特点
Pinecone 全托管,开箱即用
Weaviate 支持混合检索(关键词 + 向量)
Qdrant 高性能,支持过滤条件
pgvector 在 PostgreSQL 上扩展,无需额外服务
Chroma 轻量,适合本地开发

📌 两种数据库的核心区别:关系型数据库回答"精确是什么",向量数据库回答"语义上最像什么"。实际项目中二者经常配合使用——先用向量库找出相关文档,再用 SQL 查询文档对应的结构化元数据。

多个工具可以并行分发,异步等待全部结果返回。


⑨ 结果回传——ReAct 的"观察"步骤

工具执行完了,把结果(stdout 输出、文件内容、API 响应、报错信息等)收集起来:

  • 有错误:触发错误处理(重试 / 降级 / 换一个工具),然后继续
  • 正常:把工具结果追加到 Prompt 的上下文里,同时触发工作记忆更新

然后判断:

  • 任务完成了:进入输出护栏,准备返回结果
  • 还没完成:把新上下文重新喂给大模型,继续推理(回到第 ⑥ 步,形成 tool_call → 执行 → 观察 → 回传 → 继续推理 的闭环)
  • ⚠️ 循环次数超上限:强制终止,返回已完成的部分

⚠️ 循环次数超上限(Max Steps 熔断):这是框架层的硬性保护机制,防止以下情况导致无限运行:工具死循环调用、持续报错重试、模型推理自我矛盾、输出护栏反复拦截、任务缺乏明确终止条件等。触发后强制中断,返回当前已完成的部分内容,并告知用户任务未完整执行。


⑩ 输出护栏

模型最终的输出在返回给用户前,还要过一道检测:

  • 合规性校验(有无违规内容)
  • PII 过滤(有没有暴露隐私信息)
  • Hallucination 检测(有没有明显的幻觉)
  • 有害内容过滤

检测不通过:触发修复器(Corrector),尝试修正 / 重写 / 截断内容,修复后重新检测。如果修复次数超限,就降级返回一个兜底响应,引导用户重新描述需求。
检测通过:进入记忆写回。


⑪ 记忆写回

本次对话结束后,把有价值的信息异步写回记忆服务

  • 对话摘要 → 长期记忆
  • 工具执行结果 → 工作记忆
  • 用户行为信号 → 偏好更新

这样下次对话时,Agent 就能"记得"这次发生了什么。


⑫ 审计日志(audit log)

整条链路全程有审计层在旁路异步记录(完全不阻塞主流程):

  • 完整的调用链路和输入输出快照
  • Token 用量、延迟、安全事件
  • 用途:合规审查、故障排查、用量统计、安全溯源

审计日志数据库主要记录的就是每次执行过程,存储过程由审计层监听并记录,而非agent发起。


一图胜千言:

请添加图片描述


五、主流工具横向对比

这些工具都内置了 Agent,但各有侧重:

工具 类型 核心特点
Cursor IDE(基于 VS Code) Anysphere 公司,2023 年发布。理解完整项目上下文,Agent 模式可自主完成多步任务
Windsurf IDE(基于 VS Code) Codeium 公司出品。内置 Cascade Agent,实时感知编辑器状态(光标位置、控制台信息)
GitHub Copilot 插件 最早的 AI 编程助手,主要做代码补全建议,支持 VS Code、JetBrains 等主流编辑器
Claude Code 终端 CLI Anthropic,2025 年发布。纯终端 Agent,深度感知未打开的文件,完整支持 MCP 和 Skills

社区开源项目:

项目 定位
OpenCode 开源终端 Agent,类 Claude Code,model-agnostic,支持多种 LLM 后端
OpenHands 开源"全栈 AI 工程师",内置 Docker 沙盒隔离,面向团队 / 企业的编码 Agent 平台
OpenClaw 面向个人的全能自动化助理,支持通过聊天 / 短信 / WhatsApp 触发日常任务自动化

六、生态全景总结

读完全文,把各个部分的分工再整理一遍:

  ┌────────────────────────────────────────────────────────────────────┐
  │                      AI 工程生态 · 各层分工                         │
  ├─────────────────┬──────────────────────────────────────────────────┤
  │  LLM            │  Reasoning — 理解语言、逻辑推理、生成内容          │
  ├─────────────────┼──────────────────────────────────────────────────┤
  │  RAG             │  Knowledge — 外挂知识库,突破知识截止和幻觉限制   │
  ├─────────────────┼──────────────────────────────────────────────────┤
  │  MCP             │  Tool Interface — 标准化工具调用,扩展执行能力    │
  ├─────────────────┼──────────────────────────────────────────────────┤
  │  Agent           │  Planning + Execution — 自主拆解任务并推进完成   │
  ├─────────────────┼──────────────────────────────────────────────────┤
  │  Skills /        │  Context + Rules — 注入项目规范,约束生成行为    │
  │  AGENTS.md       │                                                  │
  ├─────────────────┼──────────────────────────────────────────────────┤
  │  Guardrails      │  Safety — 输入输出双向防护,保障安全与合规        │
  └─────────────────┴──────────────────────────────────────────────────┘

它们之间的依赖关系,形成一个完整的闭环:

                         LLM(推理引擎)
                              │
             ┌────────────────┼────────────────┐
             ▼                ▼                ▼
          RAG              MCP            Guardrails
       检索知识           执行工具          安全防护
      (Vector DB)      (Tool Server)
             │                │
             └────────────────┘
                      │
                      ▼
                   Agent
              协调以上一切,主动推进任务
                      │
             ┌────────┴────────┐
             ▼                 ▼
          Skills          AGENTS.md
         框架规范           项目约定

结语

AI 工程的本质,是把 LLM 的推理能力,通过一系列工程组件放大成自主行动能力

我们正在经历的不只是工具的更新——更像是一次开发范式的迁移:从人写每一行代码,到人描述意图、AI 执行实现,再到 AI 自主规划、人审查结果。

随着 Agent 越来越成熟,机器人(不只是人形机器人,还有工业机械臂、农业辅助器械)越来越智能,AI 在人类重复性工作中的参与度也会越来越高。带来的是效率还是新的复杂性?可能两者都有。但这大概率是接下来的方向。


参考

学习笔记--vue3 watchEffect监听的各种姿势用法和总结

watchEffect 监听不同数据源

watchEffect 会自动追踪在其回调函数中使用的所有响应式依赖,无需显式指定数据源。

1. 监听单个 ref

import { ref, watchEffect } from 'vue'

const count = ref(0)

// 自动追踪 count
watchEffect(() => {
  console.log('count 值:', count.value)
  // 当 count 变化时自动执行
})

// 修改值会触发
count.value++ // 输出: count 值: 1

2. 监听多个 ref

import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('John')
const age = ref(18)

// 自动追踪所有使用的 ref
watchEffect(() => {
  console.log(`姓名: ${name.value}, 年龄: ${age.value}, 计数: ${count.value}`)
  // 当 name、age 或 count 任何一个变化时都会执行
})

// 任何修改都会触发
count.value++  // 触发
name.value = 'Jane'  // 触发
age.value = 20  // 触发

3. 监听单个 reactive

import { reactive, watchEffect } from 'vue'

const state = reactive({
  count: 0,
  name: 'John'
})

// 方式1:直接使用整个对象(会深度追踪所有属性)
watchEffect(() => {
  console.log('state 整体:', state)
  // 当 state 的任何属性变化时都会触发
})

// 方式2:只追踪特定属性(性能更好)
watchEffect(() => {
  console.log('count 值:', state.count)
  // 只有当 state.count 变化时才触发
})

// 修改会触发
state.count++  // 触发方式1和方式2
state.name = 'Jane'  // 只触发方式1

4. 监听多个 reactive

import { reactive, watchEffect } from 'vue'

const user = reactive({
  name: 'John',
  age: 18
})

const settings = reactive({
  theme: 'dark',
  language: 'zh'
})

// 自动追踪所有使用的 reactive 属性
watchEffect(() => {
  console.log(`用户: ${user.name}, ${user.age}岁`)
  console.log(`设置: ${settings.theme}主题, ${settings.language}语言`)
  // 当 user.name、user.age、settings.theme、settings.language 任一变化时触发
})

// 修改会触发
user.name = 'Jane'  // 触发
settings.theme = 'light'  // 触发

5. 混合监听 ref 和 reactive

import { ref, reactive, watchEffect } from 'vue'

const count = ref(0)
const user = reactive({
  name: 'John',
  info: {
    age: 18,
    city: 'Beijing'
  }
})

// 自动追踪所有使用的响应式数据
watchEffect(() => {
  console.log(`计数: ${count.value}`)
  console.log(`用户: ${user.name}`)
  console.log(`年龄: ${user.info.age}`)
  console.log(`城市: ${user.info.city}`)
  // 依赖:count.value、user.name、user.info.age、user.info.city
})

// 任何依赖变化都会触发
count.value++  // 触发
user.name = 'Jane'  // 触发
user.info.age = 20  // 触发
user.info.city = 'Shanghai'  // 触发

6. 监听嵌套 reactive 对象

import { reactive, watchEffect } from 'vue'

const state = reactive({
  user: {
    profile: {
      name: 'John',
      address: {
        city: 'Beijing',
        street: 'Main St'
      }
    }
  }
})

// 深度追踪:会自动追踪所有访问的嵌套属性
watchEffect(() => {
  console.log('城市:', state.user.profile.address.city)
  console.log('街道:', state.user.profile.address.street)
  // 只追踪 city 和 street 的变化
})

// 修改嵌套属性会触发
state.user.profile.address.city = 'Shanghai'  // 触发
state.user.profile.address.street = 'Nanjing Rd'  // 触发

// 修改未追踪的属性不会触发
state.user.profile.name = 'Jane'  // 不会触发(未在回调中使用)

7. watchEffect 的清理和停止

import { ref, watchEffect } from 'vue'

const count = ref(0)

// watchEffect 返回停止函数
const stop = watchEffect((onCleanup) => {
  console.log('count:', count.value)
  
  // 清理函数:在重新运行前或停止时执行
  onCleanup(() => {
    console.log('清理副作用')
    // 用于取消请求、清除定时器等
  })
})

// 停止监听
stop()

8. 异步 watchEffect

import { ref, watchEffect } from 'vue'

const id = ref(1)
const data = ref(null)

watchEffect(async (onCleanup) => {
  let cancelled = false
  
  onCleanup(() => {
    cancelled = true
  })
  
  // 模拟异步请求
  const response = await fetch(`/api/data/${id.value}`)
  if (!cancelled) {
    data.value = await response.json()
  }
})

9. 控制执行时机

import { ref, watchEffect } from 'vue'

const count = ref(0)

// flush: 'pre' (默认) - 组件更新前执行
watchEffect(() => {
  console.log('pre:', count.value)
}, {
  flush: 'pre'
})

// flush: 'post' - 组件更新后执行
watchEffect(() => {
  console.log('post:', count.value)
}, {
  flush: 'post'
})

// flush: 'sync' - 同步执行
watchEffect(() => {
  console.log('sync:', count.value)
}, {
  flush: 'sync'
})

10. watchEffect vs watch 对比

import { ref, reactive, watch, watchEffect } from 'vue'

const count = ref(0)
const state = reactive({ name: 'John', age: 18 })

// watch: 显式指定数据源
watch(count, (newVal, oldVal) => {
  console.log('watch - count:', newVal, oldVal)
})

watch(
  [() => state.name, () => state.age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log('watch - name/age:', newName, newAge)
  }
)

// watchEffect: 自动追踪依赖
watchEffect(() => {
  console.log('watchEffect - count:', count.value)
  console.log('watchEffect - name/age:', state.name, state.age)
  // 自动追踪 count.value、state.name、state.age
})

// 执行时机
// watch: 懒执行,只有数据变化时才执行
// watchEffect: 立即执行一次,然后依赖变化时执行

实际应用示例

import { ref, reactive, watchEffect } from 'vue'

// 用户搜索示例
const searchKeyword = ref('')
const filters = reactive({
  category: 'all',
  sortBy: 'date',
  priceRange: [0, 1000]
})
const results = ref([])

// 自动搜索:任何搜索条件变化时自动执行
watchEffect(async () => {
  console.log('搜索条件变化,重新获取数据')
  
  // 构建查询参数
  const params = {
    keyword: searchKeyword.value,
    category: filters.category,
    sortBy: filters.sortBy,
    minPrice: filters.priceRange[0],
    maxPrice: filters.priceRange[1]
  }
  
  // 模拟 API 请求
  const response = await fetch(`/api/search?${new URLSearchParams(params)}`)
  results.value = await response.json()
})

// 任何条件变化都会触发搜索
searchKeyword.value = 'vue'  // 触发搜索
filters.category = 'books'   // 触发搜索
filters.sortBy = 'rating'    // 触发搜索
filters.priceRange = [0, 500] // 触发搜索

总结对比

特性 watch watchEffect
数据源 显式指定 自动追踪依赖
执行时机 懒执行(首次不执行) 立即执行
旧值获取 ✅ 可以获取 ❌ 无法获取
监听多个 需要数组 自动收集
嵌套对象 需要 deep 选项 自动深度追踪(访问到的属性)
性能优化 更精确控制 自动优化

最佳实践

  1. 使用 watchEffect 当

    • 不需要获取旧值
    • 依赖关系简单且自动
    • 需要立即执行副作用
  2. 使用 watch 当

    • 需要获取旧值
    • 需要精确控制监听的数据源
    • 需要懒执行(首次不执行)
  3. 性能优化

    // ❌ 避免:访问过多属性导致频繁执行
    watchEffect(() => {
      console.log(state)  // 任何属性变化都触发
    })
    
    // ✅ 推荐:只访问需要的属性
    watchEffect(() => {
      console.log(state.name, state.age)  // 只有这些属性变化才触发
    })
    

# 学习笔记--vue3 watch监听的各种姿势用法和总结

学习笔记--vue3 watch监听的各种姿势用法和总结

在 Vue 3 中,watch 监听不同数据源的方式有所不同

1. 监听单个 ref

import { ref, watch } from 'vue'

const count = ref(0)

// 直接传入 ref
watch(count, (newVal, oldVal) => {
  console.log('count 变化:', newVal, oldVal)
})

// 或者使用 getter 函数
watch(() => count.value, (newVal, oldVal) => {
  console.log('count 变化:', newVal, oldVal)
})

2. 监听多个 ref

import { ref, watch } from 'vue'

const count = ref(0)
const name = ref('John')

// 方式1:使用数组
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
  console.log('count 变化:', newCount, oldCount)
  console.log('name 变化:', newName, oldName)
})

// 方式2:使用 getter 数组
watch(
  [() => count.value, () => name.value],
  ([newCount, newName], [oldCount, oldName]) => {
    console.log('多个数据变化')
  }
)

3. 监听单个 reactive

import { reactive, watch } from 'vue'

const state = reactive({
  count: 0,
  name: 'John'
})

// ❌ 错误:直接传入 reactive 对象无法监听到内部属性的变化
watch(state, (newVal, oldVal) => {
  console.log('不会触发') // 深度监听时才会触发
})

// ✅ 正确:使用 getter 函数监听特定属性
watch(
  () => state.count,
  (newVal, oldVal) => {
    console.log('count 变化:', newVal, oldVal)
  }
)

// ✅ 深度监听整个 reactive 对象
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('state 任何属性变化都会触发')
  },
  { deep: true }
)

4. 监听多个 reactive 数据

import { reactive, watch } from 'vue'

const state1 = reactive({ count: 0 })
const state2 = reactive({ name: 'John' })

// 方式1:使用 getter 数组
watch(
  [() => state1.count, () => state2.name],
  ([newCount, newName], [oldCount, oldName]) => {
    console.log('数据变化')
  }
)

// 方式2:深度监听整个 reactive 对象(不推荐)
watch(
  [() => state1, () => state2],
  ([newState1, newState2], [oldState1, oldState2]) => {
    // 注意:oldState1 和 newState1 指向同一个对象
    console.log('状态变化')
  },
  { deep: true }
)

5. 混合监听 ref 和 reactive

import { ref, reactive, watch } from 'vue'

const count = ref(0)
const state = reactive({ name: 'John', age: 18 })

watch(
  [count, () => state.name, () => state.age],
  ([newCount, newName, newAge], [oldCount, oldName, oldAge]) => {
    console.log('混合数据变化')
  }
)

6. 监听响应式对象的属性

import { reactive, watch } from 'vue'

const user = reactive({
  info: {
    name: 'John',
    address: {
      city: 'Beijing'
    }
  }
})

// 监听嵌套属性
watch(
  () => user.info.address.city,
  (newVal, oldVal) => {
    console.log('城市变化:', newVal, oldVal)
  }
)

// 深度监听整个对象
watch(
  () => user,
  (newVal, oldVal) => {
    console.log('user 任何变化')
  },
  { deep: true }
)

总结对比

数据源 监听方式 注意事项
单个 ref watch(count, callback) 直接传入即可
多个 ref watch([ref1, ref2], callback) 使用数组形式
单个 reactive 属性 watch(() => state.prop, callback) 必须使用 getter
多个 reactive 属性 watch([() => state.prop1, () => state.prop2], callback) 使用 getter 数组
整个 reactive watch(() => state, callback, { deep: true }) 必须深度监听

最佳实践建议

  1. 优先使用 getter 函数,特别是监听 reactive 对象的属性
  2. 避免深度监听大型对象,可能会影响性能
  3. 注意旧值的引用问题:对于 reactive 对象,旧值可能与新值相同(因为引用未变)

最新版vue3+TypeScript开发入门到实战教程之路由详解

1、概述

网站是有许多单页面组成,页面并非孤立,而是可以相互跳转。以下是官网给的定义: Vue Router 是 Vue.js 官方的路由管理器,用于构建单页面应用(SPA)。它的核心价值在于:在不刷新页面的情况下,根据 URL 的变化动态渲染不同的组件,实现流畅的页面切换体验。 假设网站有四个页面,主页,a、b、c,网站可以从主页分别跳转到a、b、c是三个页面。也可跳回主页。这些跳转信息,称作路由信息,管理路由信息完成跳转称作路由器。路由四大要素:

  • 路由管理器,统一管理路由
  • 路由信息,记录组件与路由的对应关系
  • 跳转标签与跳转方法,用于跳转指定路由
  • 路由跳转后,指定组件显示位置

2、 基本路由导航实例

  • 创建主页,主页含有标题、导航、路由跳转子页面显示位置
  • 创建三个子页面,Fish、Cat、Bird
  • 创建路由器,挂载路由器
  • 创建路由信息

2.1创建路由器、路由信息、挂载路由器

2.1.1创建路由器、路由信息

const routes = [
  { path: '/fish', component: Fish },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }  // 动态路由
]
const router = createRouter(
  {
    history: createWebHistory(),
    routes: routes
  }
)

路由信息routes,注意routerroutes区别。routes包含path与component。

  • path是路径,浏览器地址,url如:http://localhost:5173/bird,访问bird页面
  • component组件,路径path对应的组件 路由器的创建,包含路由信息与history。history有两种模式:
  • createWebHistory。传统模式,url美观,seo友好
  • createWebHashHistory 。hash模式,url地址含有#,不美观,兼容性好

2.1.2挂载路由器

挂载路由器,要在创建vue实例后,挂载路由。vue实例是在main.ts中创建。

const app = createApp(App)
app.use(router)
app.mount('#app')

2.2路由基本切换效果

在这里插入图片描述

首先打开页面,框内为空。分别点击按钮,跳转到响应页面,内容出现在边框内,注意url地址变化。

2.2.1 目录文件结构

在这里插入图片描述

2.2.2 main.ts源码

import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router/index'
const app = createApp(App)
app.use(router)
app.mount('#app')

2.2.1 router/index.ts源码

import { createRouter,createWebHistory } from "vue-router";
import Fish from "@/view/Fish.vue";
import Cat from "@/view/Cat.vue";
import Bird from "@/view/Bird.vue";
console.log(createRouter);
const routes = [
  { path: '/fish', component: Fish },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }  // 动态路由
]
const router = createRouter(
  {
    history: createWebHistory(),
    routes: routes
  }
)
export default router;

2.2.1 App源码

<template>
  <div class="app">
    <router-link to="/fish">跳转到鱼</router-link>
    <router-link to="/cat">跳转到猫</router-link>
    <router-link to="/bird">跳转到鸟</router-link>
    <div class="content">
    <router-view></router-view>
    </div>
  </div>
</template>
<script setup lang="ts">
</script>

2.2.1 Fish、cat、Bird源码

Fish

<template>
  <div>
    <h1>会游泳的鲫鱼</h1>
  </div>
</template>
<script setup lang="ts">
</script>

Cat

<template>
  <div>
    <h1>爱吃老鼠的猫</h1>
  </div>
</template>
<script setup lang="ts">
</script>

Bird

<template>
  <div>
    <h1>翱翔天空的小鸟</h1>
  </div>
</template>
<script setup lang="ts">
</script>

2.3路由的两个注意点

  • 路由组件,如Fish、Cat等,应存放在pages或者views文件夹内,而非components文件夹内
  • 点击导航按钮,路由的切换,是旧页面组件的销毁,新页面组件创建的过程。

3、路由的工作模式

路由的工作模式有两种,在创建路由时,必须给定模式 -history -hash history是传统模式,优点是URL更加美观,更接近传统网站URL。缺点是后期项目上线,后台服务器需配合处理路径问题,否则报404错误。一般用history较多,如b站。 hash兼容性更好,不需要服务器后台处理路径问题。缺点是url带有#,不美观,且SEO优化方面差,后端项目常用。 以下是hash实例,与history不同之处在创建路由时,用createWebHashHistory 函数指定hash模式: router/index.ts代码

import { createRouter,createWebHashHistory } from "vue-router";
import Fish from "@/view/Fish.vue";
import Cat from "@/view/Cat.vue";
import Bird from "@/view/Bird.vue";
console.log(createRouter);
const routes = [
  { path: '/fish', component: Fish },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }  // 动态路由
]
const router = createRouter(
  {
    history: createWebHashHistory(),
    routes: routes
  }
)
export default router;

运行效果: 在这里插入图片描述 注意路径带有#

4、路由跳转To的三种用法与路由命名

router-link有三种用法,以跳转为例Fish,重新配置Fish组件路由信息,给Fish路由命名为yu。 如下:{ name:'yu',path: '/fish', component: Fish }。 router-link有三种方式可以跳转到Fish组件

    <router-link :to="{name:'yu'}">跳转到鱼</router-link>
    <router-link :to="{path:'fish'}"">跳转到鱼</router-link>
    <router-link :to="/fish">跳转到鱼</router-link>

三种跳转方式各有利弊,常用第二种方式,便于路由传参。

不用点击也能预览图片:Element UI ImageViewer 命令式调用方案

前言

做前端开发这么多年,图片预览功能几乎是每个项目都要用到的。Element UI 虽然提供了 el-image 组件,点击图片就能预览,但实际项目中总有一些场景,不是用户点击图片触发预览,而是需要代码来控制。比如表单提交后自动预览上传的图片,或者列表项点击后预览相关图片。

最近我在项目中就遇到了这个问题,查了一下 Element UI 的文档,发现 ImageViewer 组件虽然功能强大,但只能通过 el-image 组件间接使用,没有提供直接的 API 来调用。于是我就封装了一个 previewImages 工具函数,实现了命令式调用 ImageViewer 的功能。今天就来分享一下这个方案,希望对大家有所帮助。

问题背景

Element UI 图片预览的缺点

Element UI 的 el-image 组件虽然好用,但在实际项目中也存在一些局限性:

  1. 必须通过点击触发:只能通过用户点击图片来触发预览,无法通过代码控制。
  2. 依赖 DOM 结构:需要在模板中声明 el-image 组件,无法在运行时动态创建。
  3. 灵活性不足:无法根据需要随时显示或隐藏预览,必须与图片元素绑定。
  4. 功能受限:只能预览与 el-image 组件关联的图片,无法预览任意图片列表。

传统实现方式的局限性

传统的图片预览实现方式主要有以下几种,各有各的问题:

  1. 使用 el-image 组件:只能通过用户点击触发,无法满足代码控制的需求。
  2. 自定义预览组件:需要自己实现预览逻辑,工作量大,且功能可能不如 Element UI 的 ImageViewer 完善。
  3. 使用第三方库:增加了项目依赖,可能与现有技术栈不兼容,学习成本高。

这些方式都无法很好地满足在特定逻辑后自动触发图片预览的需求,比如表单提交后预览上传的图片,或者列表项点击后预览相关图片。

解决方案

为什么要封装 previewImages 工具

基于以上问题,我封装了 previewImages 工具,主要原因有:

  1. 解决代码触发预览的问题:实现了通过代码控制图片预览的功能,满足各种场景的需求。
  2. 复用 Element UI 的 ImageViewer:充分利用 Element UI 现有的组件,避免重复造轮子。
  3. 简化使用方式:提供了简洁的 API,使用起来非常方便。
  4. 提高代码可维护性:将图片预览逻辑封装成工具函数,便于在项目中复用和维护。

previewImages 工具的实现原理

previewImages 工具通过命令式的方式调用 Element UI 的 ImageViewer 组件,实现了代码触发的图片预览功能。它的核心思想是:

  1. 动态创建 ImageViewer 组件实例
  2. 将组件挂载到 DOM 中
  3. 提供关闭预览的方法

核心代码解析

让我们来看一下 previewImages 工具的核心代码:

import Vue from 'vue'
import ImageViewer from 'element-ui/packages/image/src/image-viewer.vue'

/**
 * 命令式图片预览
 * methods: previewImages({urlList:[url1,url2...]})
 * @param {Object} props - 选项参数
 * @param {Array} props.urlList - 图片地址列表
 */

function previewImages(props) {
  const { urlList } = props
  if (!Array.isArray(urlList) || urlList.length === 0) {
    console.error(
      '[Vue Element Error][ImageViewer] urlList should be a non-empty array'
    )
    return
  }
  buildComponentInBody(ImageViewer, props)
}

function buildComponentInBody(component, props) {
  let disposer = null
  const onClose = () => disposer && disposer()
  const mountComponent = (component) => {
    const Component = Vue.extend(component)
    const instance = new Component({
      propsData: { ...props, onClose },
    }).$mount()
    document.body.appendChild(instance.$el)
    return () => {
      instance.$destroy()
    }
  }

  disposer = mountComponent(component)
}

export { previewImages, buildComponentInBody }

这段代码的实现原理非常简洁:

  1. 参数验证:检查 urlList 是否为非空数组,确保预览功能能够正常工作。
  2. 组件创建:使用 Vue.extend 创建组件构造函数,然后实例化组件并传入 propsonClose 方法。
  3. 挂载组件:将组件实例挂载到 DOM 中,使其显示出来。
  4. 资源清理:提供 disposer 函数,用于在预览关闭时销毁组件实例,避免内存泄漏。

使用方法

使用 previewImages 工具非常简单,只需要导入并调用即可:

1. 导入工具

import { previewImages } from '@/components/DialogPicker/previewImages'

2. 调用预览

// 预览单张图片
previewImages({ urlList: ['https://example.com/image1.jpg'] })

// 预览多张图片
previewImages({
  urlList: [
    'https://example.com/image1.jpg',
    'https://example.com/image2.jpg',
    'https://example.com/image3.jpg'
  ]
})

// 带初始索引的预览
previewImages({
  urlList: [
    'https://example.com/image1.jpg',
    'https://example.com/image2.jpg',
    'https://example.com/image3.jpg'
  ],
  initialIndex: 1 // 从第二张图片开始预览
})

实际应用场景

previewImages 工具在以下场景中特别有用:

1. 表单提交后预览

当用户提交表单后,我们可以自动预览上传的图片:

// 表单提交处理
async function handleSubmit() {
  try {
    // 提交表单
    const response = await submitForm(this.form)
    
    // 预览上传的图片
    if (response.data.images && response.data.images.length > 0) {
      previewImages({ urlList: response.data.images })
    }
    
    // 显示成功消息
    this.$message.success('表单提交成功')
  } catch (error) {
    this.$message.error('表单提交失败')
  }
}

2. 列表项点击预览

当用户点击列表项时,预览相关的图片:

// 列表项点击处理
handleItemClick(item) {
  // 预览与该项相关的图片
  if (item.images && item.images.length > 0) {
    previewImages({ urlList: item.images })
  }
}

3. 条件触发预览

在特定条件下触发图片预览:

// 条件触发预览
checkAndPreview() {
  // 检查条件
  if (this.condition) {
    // 预览图片
    previewImages({ urlList: this.imageList })
  }
}

扩展和优化

我们可以对 previewImages 工具进行一些扩展和优化,使其更加灵活和强大:

1. 支持更多配置选项

我们可以扩展 previewImages 函数,支持更多 ImageViewer 组件的配置选项:

function previewImages(props) {
  const { urlList, ...restProps } = props
  if (!Array.isArray(urlList) || urlList.length === 0) {
    console.error(
      '[Vue Element Error][ImageViewer] urlList should be a non-empty array'
    )
    return
  }
  buildComponentInBody(ImageViewer, { ...restProps, urlList })
}

这样,我们就可以传递更多配置选项,如 initialIndexzIndex 等:

previewImages({
  urlList: ['https://example.com/image1.jpg', 'https://example.com/image2.jpg'],
  initialIndex: 0,
  zIndex: 9999
})

2. 添加动画效果

我们可以为预览组件添加动画效果,提升用户体验:

function buildComponentInBody(component, props) {
  let disposer = null
  const onClose = () => {
    // 添加关闭动画
    const instance = document.querySelector('.el-image-viewer')
    if (instance) {
      instance.style.transition = 'opacity 0.3s'
      instance.style.opacity = '0'
      setTimeout(() => {
        disposer && disposer()
      }, 300)
    } else {
      disposer && disposer()
    }
  }
  const mountComponent = (component) => {
    const Component = Vue.extend(component)
    const instance = new Component({
      propsData: { ...props, onClose },
    }).$mount()
    // 添加打开动画
    instance.$el.style.opacity = '0'
    document.body.appendChild(instance.$el)
    setTimeout(() => {
      instance.$el.style.transition = 'opacity 0.3s'
      instance.$el.style.opacity = '1'
    }, 10)
    return () => {
      instance.$destroy()
    }
  }

  disposer = mountComponent(component)
}

3. 支持键盘导航

我们可以添加键盘导航支持,使用户可以通过键盘控制图片预览:

function buildComponentInBody(component, props) {
  let disposer = null
  const onClose = () => {
    // 移除键盘事件监听器
    document.removeEventListener('keydown', handleKeydown)
    disposer && disposer()
  }
  
  // 键盘事件处理
  const handleKeydown = (e) => {
    switch (e.key) {
      case 'Escape':
        onClose()
        break
      case 'ArrowLeft':
        // 切换到上一张图片
        const viewer = document.querySelector('.el-image-viewer__btn.el-image-viewer__btn--prev')
        viewer && viewer.click()
        break
      case 'ArrowRight':
        // 切换到下一张图片
        const nextViewer = document.querySelector('.el-image-viewer__btn.el-image-viewer__btn--next')
        nextViewer && nextViewer.click()
        break
    }
  }
  
  const mountComponent = (component) => {
    const Component = Vue.extend(component)
    const instance = new Component({
      propsData: { ...props, onClose },
    }).$mount()
    document.body.appendChild(instance.$el)
    // 添加键盘事件监听器
    document.addEventListener('keydown', handleKeydown)
    return () => {
      instance.$destroy()
    }
  }

  disposer = mountComponent(component)
}

技术要点

  1. 命令式组件调用:通过 Vue.extend$mount 实现组件的命令式调用,而不是通过模板声明式使用。

  2. 动态组件挂载:将组件实例动态挂载到 DOM 中,实现按需显示。

  3. 资源管理:提供 disposer 函数,确保组件在不需要时能够被正确销毁,避免内存泄漏。

  4. 参数验证:对输入参数进行验证,确保函数能够正常工作,提高代码的健壮性。

  5. 事件处理:通过 onClose 回调函数处理预览关闭事件,实现组件的生命周期管理。

注意事项

  1. 依赖要求:该工具依赖 Vue 和 Element UI 的 ImageViewer 组件,确保项目中已经安装了这些依赖。

  2. 图片地址:确保 urlList 中的图片地址是可访问的,否则可能会导致预览失败。

  3. 性能考虑:对于大量图片的预览,可能会影响性能,建议限制预览图片的数量。

  4. 浏览器兼容性:该工具使用了 Vue 的 API 和 Element UI 的组件,确保在目标浏览器中能够正常工作。

可直接运行的 Demo

为了让大家更好地理解和使用 previewImages 工具,我准备了一个可直接运行的 HTML demo。你只需要将以下代码保存为 index.html 文件,然后在浏览器中打开即可看到效果。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Element UI ImageViewer 命令式调用示例</title>
  <!-- 引入 Element UI CSS -->
  <link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
  <!-- 引入 Vue -->
  <script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
  <!-- 引入 Element UI JS -->
  <script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
</head>
<body>
  <div id="app">
    <div style="margin: 20px;">
      <h2>Element UI ImageViewer 命令式调用示例</h2>
      <div style="margin: 20px 0;">
        <el-button type="primary" @click="previewSingleImage">预览单张图片</el-button>
        <el-button type="success" @click="previewMultipleImages">预览多张图片</el-button>
        <el-button type="warning" @click="previewWithInitialIndex">带初始索引的预览</el-button>
      </div>
      <div style="margin: 20px 0;">
        <el-input v-model="customImageUrl" placeholder="输入图片地址" style="width: 400px;"></el-input>
        <el-button type="info" @click="previewCustomImage" style="margin-left: 10px;">预览自定义图片</el-button>
      </div>
    </div>
  </div>

  <script>
    // 复制 previewImages 工具代码
    function previewImages(props) {
      const { urlList } = props
      if (!Array.isArray(urlList) || urlList.length === 0) {
        console.error(
          '[Vue Element Error][ImageViewer] urlList should be a non-empty array'
        )
        return
      }
      buildComponentInBody(ElementUI.ImageViewer, props)
    }

    function buildComponentInBody(component, props) {
      let disposer = null
      const onClose = () => disposer && disposer()
      const mountComponent = (component) => {
        const Component = Vue.extend(component)
        const instance = new Component({
          propsData: { ...props, onClose },
        }).$mount()
        document.body.appendChild(instance.$el)
        return () => {
          instance.$destroy()
        }
      }

      disposer = mountComponent(component)
    }

    // 示例图片地址
    const imageUrls = [
      'https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg',
      'https://cube.elemecdn.com/9/28/3d939848980f98040e7488856320jpeg.jpeg',
      'https://cube.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg',
      'https://cube.elemecdn.com/5/93/396259950760c7ec4f6880e94446jpeg.jpeg'
    ]

    new Vue({
      el: '#app',
      data() {
        return {
          customImageUrl: 'https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg'
        }
      },
      methods: {
        // 预览单张图片
        previewSingleImage() {
          previewImages({ urlList: [imageUrls[0]] })
        },
        // 预览多张图片
        previewMultipleImages() {
          previewImages({ urlList: imageUrls })
        },
        // 带初始索引的预览
        previewWithInitialIndex() {
          previewImages({ 
            urlList: imageUrls,
            initialIndex: 2 // 从第三张图片开始预览
          })
        },
        // 预览自定义图片
        previewCustomImage() {
          if (this.customImageUrl) {
            previewImages({ urlList: [this.customImageUrl] })
          } else {
            this.$message.warning('请输入图片地址')
          }
        }
      }
    })
  </script>
</body>
</html>

这个 demo 包含了以下功能:

  1. 预览单张图片:点击按钮预览第一张示例图片。
  2. 预览多张图片:点击按钮预览所有示例图片。
  3. 带初始索引的预览:点击按钮从第三张图片开始预览。
  4. 预览自定义图片:输入图片地址,然后点击按钮预览。

你可以直接在浏览器中打开这个文件,点击各个按钮查看效果。

总结

previewImages 工具是一个非常实用的图片预览解决方案,它通过命令式的方式调用 Element UI 的 ImageViewer 组件,实现了代码触发的图片预览功能。这个工具不仅使用简单,而且灵活强大,可以满足各种场景下的图片预览需求。

通过封装 previewImages 工具,我们解决了 Element UI 图片预览的局限性,实现了更加灵活的图片预览功能。同时,我们也学习了如何通过 Vue.extend$mount 实现组件的命令式调用,这是一个非常实用的前端开发技巧。

希望这篇文章对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言。

你删过 lock 文件吗?聊聊包管理器迁移中 90% 的人会踩的坑

"删掉 node_modulespackage-lock.json,重新 npm install 一下。"

这句话你一定听过,甚至自己也说过。遇到依赖安装报错,删 lock 重装是最常见的"万能解法"。大部分时候确实管用——但它管用的原因和你想的不一样,而且在某些场景下,这个操作的代价比你预期的要大得多。

最近越来越多的项目开始从 npm 迁移到 pnpm。迁移本身不复杂,但很多人的做法是直接删掉 package-lock.json,然后 pnpm install。对于小项目,这通常没问题。但如果你的项目有几百个依赖、跑在生产环境、团队多人协作——这样做可能会引入一些很难排查的问题。

这篇文章聊的就是这个:lock 文件到底在锁什么,删掉它意味着什么,以及迁移包管理器时怎么做才是安全的。

lock 文件在锁什么

package.json 里的版本号不是精确版本,而是一个范围:

{
  "dependencies": {
    "react": "^18.3.1",
    "axios": "~1.7.0"
  }
}

^18.3.1 允许安装 18.3.118.x.x 之间的任何版本,~1.7.0 允许 1.7.01.7.x。也就是说,同一份 package.json,今天装和三个月后装,拿到的依赖版本可能完全不同。

而 lock 文件记录的是某一次 install 之后所有依赖的精确版本——不光是你在 package.json 里写的那几个,还包括它们背后的几十上百个传递依赖。

一句话总结:package.json 描述意图,lock 文件记录事实。

有了 lock 文件,团队成员用 npm ci(或 pnpm install --frozen-lockfile)安装时,拿到的依赖版本和你本地测试通过的完全一致。CI 构建、生产部署,都是同一份版本快照。

semver 是个"君子协议"——很多包不遵守

你可能会想:用 ^ 锁定大版本,minor 和 patch 升级不是应该向下兼容吗?

理论上是。但现实中,不少知名包在 patch 或 minor 版本里引入过 breaking change:

  • TypeScript 明确声明不遵守 semver。它的 minor 版本(比如 5.35.4)经常改变类型推断行为,一次升级可能导致几十个编译错误。
  • esbuild 长期处于 0.x 阶段,按 semver 规范 0.x 的任何变更都可能是 breaking,但很多打包工具用 ^0.21.0 这样的范围引用它。
  • PostCSS 的 minor 升级曾导致部分插件不兼容,表现为构建时样式输出错误——构建不报错,但页面样式不对,排查成本很高。

这就是为什么 lock 文件是生产环境的最后一道防线:你本地测试通过的版本组合,lock 文件帮你锁住了。删掉它重新安装,等于放弃了这个保障。

删 lock 重装,到底丢了什么

回到开头的问题:删掉 lock 文件再重装,你丢掉了两样东西。

第一,版本锁定。 所有依赖会按 package.json 的范围重新解析,取当前最新的可用版本。如果某个传递依赖在这段时间发了一个有问题的 patch,你就会拿到它。

第二,git 历史。 lock 文件的每次变更都有 git 记录。当你需要用 git bisect 排查"代码没改但线上表现变了"的问题时,lock 文件的 diff 是最关键的线索。删掉重建意味着这条追溯链断了。

对于一个依赖不到 50 个的小项目,这两个问题都不大——验证成本低,出了问题也容易定位。但对于依赖几百个、有完整 CI/CD 流水线的生产项目,这两个代价都不可接受。

迁移到 pnpm:三种策略,选错会出事

既然越来越多团队在迁移到 pnpm,那怎么迁才是安全的?根据项目规模,有三种策略。

策略 A:直接删 lock 重装

rm -rf node_modules package-lock.json
pnpm install

所有版本重新解析,传递依赖不可控。适合依赖少、刚起步的新项目。

策略 B:pnpm import 无损导入

pnpm import            # 从 package-lock.json 导入精确版本
rm package-lock.json   # 导入成功后删除旧 lock
pnpm install           # 安装依赖

pnpm import 会读取现有的 package-lock.json(也支持 yarn.lock),生成一个版本完全一致的 pnpm-lock.yaml。所有依赖——包括传递依赖——的精确版本都会被保留,零版本漂移。

这是大多数项目应该选择的方式。

策略 C:渐进式迁移

对于生产环境有高可用要求的项目,在策略 B 的基础上增加一个完整的验证周期:

git checkout -b chore/migrate-to-pnpm

pnpm import
rm package-lock.json
pnpm install

# 跑完所有测试
pnpm test
pnpm build
pnpm e2e

# staging 环境验证后再合入 main

怎么选

简单判断:项目依赖超过 50 个,或者跑在生产环境——用策略 B。如果还有高可用要求——用策略 C。只有刚起步的小项目才适合策略 A。

迁移后最常遇到的问题:phantom dependencies

从 npm 切到 pnpm 后,最常见的报错不是版本问题,而是 Module not found

这是因为 npm 的 flat node_modules 会把所有包平铺在根目录,你的代码可以 import 任何已安装的包,哪怕你没在 package.json 里声明。pnpm 的 symlink 结构不允许这样做。

// package.json 里没有声明 "ms"
// 但 "debug" 依赖了 "ms",npm 会把它平铺
import ms from 'ms'  // npm: 正常  |  pnpm: Module not found

修复方式很直接:把实际用到的包显式加到 package.json 里。

pnpm build 2>&1 | grep "Module not found"
pnpm add ms  # 逐个添加缺失的依赖

大项目可能需要修几十个,但这是一次性的工作,修完之后项目的依赖关系会清晰很多。

迁移后别忘了更新 CI

很多人本地迁完就提交了,CI 里还是 npm ci——然后 CI 就挂了。

GitHub Actions 的改动并不大,核心是加一个 pnpm/action-setup 步骤:

# 迁移前
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'
  - run: npm ci
  - run: npm run build

# 迁移后
steps:
  - uses: actions/checkout@v4
  - uses: pnpm/action-setup@v4
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'pnpm'
  - run: pnpm install --frozen-lockfile
  - run: pnpm build

另外建议在 package.json 里加上 packageManager 字段:

{
  "packageManager": "pnpm@10.29.2"
}

pnpm/action-setup@v4 会读取这个字段自动安装对应版本,Corepack 也会据此约束团队成员使用正确的包管理器。

lock 文件的 Git 管理:几条铁律

最后聊几个关于 lock 文件日常管理的要点。

lock 文件必须提交到 Git。 这一点怎么强调都不过分。不提交 lock 文件,团队成员的依赖版本可能各不相同,CI 构建不可复现,出了问题无法回滚到已知良好的状态。把 lock 文件加到 .gitignore 里是一个常见但严重的错误。

lock 文件冲突不要手动解。 多人开发时 lock 文件冲突是家常便饭。正确做法是接受一方的版本,然后重新生成:

git checkout --theirs pnpm-lock.yaml
pnpm install
git add pnpm-lock.yaml
git commit

pnpm install 会根据 package.json 重新解析 lock 文件,同时尽量保留已有的版本锁定。比手动合并几千行 YAML 安全得多。

CI 里永远用 --frozen-lockfile pnpm install --frozen-lockfile 等价于 npm ci,严格按 lock 文件安装。如果 lock 文件和 package.json 不一致就直接报错,而不是悄悄更新 lock 文件。

迁移 Checklist

最后附一个可以直接用的清单:

  • 确认项目能通过 build(最好有测试覆盖)
  • pnpm import 从现有 lock 文件导入
  • 删除旧 lock 文件
  • pnpm install 安装依赖
  • 修复 phantom dependency 报错
  • package.json 添加 "packageManager": "pnpm@x.x.x"
  • 更新 CI workflow
  • 全量测试 + 构建验证
  • 通知团队成员

以上就是关于 lock 文件和包管理器迁移的完整分析。核心观点只有一个:小项目随便迁,大项目用 pnpm import,别直接删 lock 文件。

你们团队在迁移包管理器或者管理 lock 文件的时候踩过什么坑?欢迎在评论区聊聊。

Fixed 定位的失效问题

通常情况下position: fixed元素相对于视口定位,但是某些情况下,比如祖先元素设置了transformfilterperspectivewill-change: transform的时候,子元素的固定定位会失效,不在相对于视口定位,而是相对于该祖先元素定位,约等于绝对定位。

比如:

<div style="position: fixed; top: 50vh; right: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>

<div style="transform: translate(0, 0); padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcoral"></div>
</div>
<div style="filter: blur(0); padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcyan"></div>
</div>
<div style="perspective: 0; padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgoldenrodyellow"></div>
</div>
<div style="will-change: transform; padding-top: 25vh">
  <div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgray"></div>
</div>

第一个元素定位正常,但后面的元素定位异常,这是因为这些元素的父元素因为特定的 CSS 属性被放在新的图层之中。

一般情况下,当我们发现了固定定位异常时,排查祖先元素是否含有上述的 CSS 属性即可。但有一种情况,虽然在浏览器的 CSS 面板中看不到上述属性,但元素依然处于不同的图层中。这就是当元素被执行过animate且执行了上述 CSS 属性的动画。

比如:

<div id="moving">
  <div style="position: fixed; top: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>
</div>

<script>
  let moving = window.document.getElementById('moving');
  moving.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(0, 50vh)' }], { duration: 1000, fill: 'forwards' });
</script>

如果运行上面的代码,可以看到固定定位的元素在跟随父元素移动,同时此时看到浏览器的 CSS 面板中父元素并没有 transform 相关属性。

不得不说,好坑啊。

React Hooks 闭包陷阱:高级场景与深度思考

前言

闭包陷阱不只是"定时器读不到最新值"那么简单。

在实际工程中,你会遇到:

  • 类组件转函数式后的隐性 bug
  • 自定义 Hook 里的闭包泄露
  • Concurrent Mode 下的闭包过期问题
  • 状态机场景下的闭包与 reducer 的相爱相杀
  • memo/useCallback 优化反而引发的新问题
  • 内存泄漏与闭包的深层关系

场景一:类组件转函数式后,ref 里的闭包成了定时炸弹

问题

你有一个类组件,习惯用 this 解决问题:

// 类组件写法
class SearchPanel extends React.Component {
  state = { keyword: '' };

  handleSearch = () => {
    // 这里直接用 this.state.keyword,永远是最新的
    api.search(this.state.keyword);
  };

  render() {
    return <input onChange={e => this.setState({ keyword: e.target.value })} />;
  }
}

改成函数式后,你可能这样写:

// ❌ 常见错误写法
function SearchPanel() {
  const [keyword, setKeyword] = useState('');

  const handleSearch = () => {
    // 等等,这里怎么获取 keyword?
    // 很多人会想到用一个 ref 存着
  };

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>搜索</button>
    </div>
  );
}

然后你用 ref 来"绕过"闭包问题:

// ❌ 潜在问题
function SearchPanel() {
  const [keyword, setKeyword] = useState('');
  const keywordRef = useRef(keyword);

  // 同步 ref
  useEffect(() => {
    keywordRef.current = keyword;
  }, [keyword]);

  const handleSearch = () => {
    // 用 ref 获取值
    api.search(keywordRef.current); // ⚠️ 这里看起来没问题
  };

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>搜索</button>
    </div>
  );
}

问题在哪?

如果用户快速点击搜索按钮(比点击一次还快),在 useEffect 还没执行之前,ref 里还是旧值。

进阶视角

类组件的 this.state 本质上是一个"永远指向最新值"的 mutable 对象。函数式的 useState 是 immutable 的,每次渲染都是新值。

正确的函数式写法:

// ✅ 正确:不要绕过 React 的响应式系统
function SearchPanel() {
  const [keyword, setKeyword] = useState('');

  // 直接把当前值传进去,不要通过 ref 间接获取
  const handleSearch = () => {
    api.search(keyword); // ✅ 这里就是最新的 keyword
  };

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>搜索</button>
    </div>
  );
}

架构思考:

类组件 函数式组件
this.state 是 mutable,引用永远最新 useState 是 immutable,每次渲染是新值
闭包不是问题,因为用的是 this 闭包是问题,因为捕获的是旧值
解决方案:忘了它,用响应式数据 解决方案:让函数组件在正确的时机重新创建

场景二:自定义 Hook 里的闭包泄露——你封装的 Hook 可能正在泄露内存

问题

你封装了一个 useInterval Hook:

// ❌ 有问题的 useInterval 实现
function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay !== null) {
      const id = setInterval(() => {
        savedCallback.current(); // 这里调用的是最新的 callback
      }, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

看起来没问题?好,我们来用一下:

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

  useInterval(() => {
    console.log('count:', count); // ⚠️ 这里永远打印 0
  }, 1000);

  return <div>{count}</div>;
}

这不就是场景一的问题吗?

但更严重的问题在后面:

如果 callback 每次渲染都变化(比如用了一些依赖),savedCallback.current 会不断更新,但旧的 callback 形成的闭包可能被某些地方持有,导致内存无法释放。

源码级分析

// 模拟问题场景
function useDataFetcher(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetch(url)
      .then(res => res.json())
      .then(result => {
        if (!cancelled) {
          setData(result); // ⚠️ 这个闭包捕获了 url
        }
      });

    return () => {
      cancelled = true; // 这里的逻辑其实有漏洞
    };
  }, [url]); // url 变化 → 新的 effect → 新的闭包

  return data;
}

问题:url 快速变化时(比如搜索框输入),旧请求的回调虽然检查了 cancelled,但闭包本身还在内存中。如果这个闭包捕获了大数据(比如列表数据),就有内存泄漏风险。

高级视角:正确的 useInterval 实现

// ✅ 正确的 useInterval(借鉴 ahooks)
import { useEffect, useRef, useCallback } from 'react';

function useInterval(callback, delay) {
  const callbackRef = useRef(callback);

  // 每次 callback 变化,同步更新 ref
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // 定时器执行时,永远读 ref 里的最新函数
  useEffect(() => {
    if (delay === null || delay === undefined) {
      return;
    }

    const tick = () => callbackRef.current();

    const id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]); // 注意:这里不依赖 callback,只依赖 delay
}

但真正的架构问题是:

你的自定义 Hook 使用者,可能根本不知道内部有闭包陷阱。他们传入的 callback 如果依赖了外部变量,问题就会隐藏在这里。

最佳实践:

// ✅ 在自定义 Hook 里用 useLatest 统一处理
function useLatest(value) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

function useInterval(callback, delay) {
  const callbackRef = useLatest(callback);

  useEffect(() => {
    if (delay == null) return;

    const tick = () => callbackRef.current();
    const id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

场景三:useReducer 里的闭包——状态机场景的特殊情况

问题

你可能觉得用了 useReducer 就不用管闭包了:

// ❌ 仍然有闭包问题
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  useEffect(() => {
    const timer = setInterval(() => {
      // ❌ 这里还是闭包陷阱!
      dispatch({ type: 'INCREMENT' });
      // 等等,dispatch 需要读旧状态吗?
      // 让我们看看
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div>{state.count}</div>;
}

实际上这个例子可以跑,因为 dispatch 的工作方式不同。

源码级解析:dispatch 为什么特殊?

// ReactFiberHooks.js
function updateReducer(reducer, initialArg, init) {
  const hook = updateWorkInProgressHook();
  const queue = hook.memoizedQueue;
  const pending = queue.pending;

  // 关键:dispatch 不依赖任何外部变量
  // 它的行为是"把 action 放入队列",不是"立即执行"
  // 所以 dispatch 本身不会过期

  if (pending !== null) {
    // ...
  }

  const newState = hook.memoizedState;
  return [newState, dispatch];
}

所以:

操作 是否受闭包影响
setCount(n) ❌ 不受(但 n 可能是旧值)
setCount(c => c + 1) ✅ 不受,函数式更新
dispatch({ type: 'INCREMENT' }) ✅ 不受,dispatch 只是发指令

真正的问题:reducer 里的闭包

// ❌ 问题在 reducer 内部
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT_BY':
      // 这里需要访问外部的某个"配置"
      return { count: state.count + action.amount };
    default:
      return state;
  }
}

function Counter({ defaultAmount = 1 }) { // ⚠️ props 变化
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  // 这里的 defaultAmount 变化时,reducer 不会自动更新
  // 你需要确保 action 携带足够的信息
  const handleIncrement = () => {
    dispatch({ type: 'INCREMENT_BY', amount: defaultAmount });
  };

  return <button onClick={handleIncrement}>{state.count}</button>;
}

高级视角:

useReducer 并不是闭包的银弹。它的优势是把"如何计算新状态"和"何时触发计算"分开,但如果你在 reducer 外部依赖了某些值,闭包问题依然存在。


场景四:memo 与 useCallback——优化反而引发的新问题

问题

用了 memo + useCallback 做性能优化,结果闭包问题更严重了:

// ❌ 过度优化的陷阱
const Child = memo(function Child({ onClick, data }) {
  console.log('Child 渲染了');
  return <button onClick={() => onClick(data.id)}>{data.label}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [list, setList] = useState([{ id: 1, label: 'A' }]);

  // ❌ 用 useCallback 包裹,但依赖了 list
  const handleClick = useCallback((id) => {
    console.log('点击了', id, list); // ⚠️ 这里永远是旧 list
  }, [list]); // list 变化 → handleClick 重建 → Child 重新渲染

  return (
    <div>
      <Child onClick={handleClick} data={list[0]} />
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
    </div>
  );
}

useCallback 是想避免子组件重渲染,结果因为依赖了 listlist 每次变化 handleClick 都会重建,子组件还是重渲染了。

什么时候真正需要 useCallback?

// ✅ 正确的用法:传给子组件的回调
function Parent() {
  const [count, setCount] = useState(0);

  // 只有当这个函数要传给 memo 过的子组件时,才用 useCallback
  const handleClick = useCallback(() => {
    console.log(count); // 如果需要读 count,加依赖
  }, [count]);

  return (
    <div>
      <MemoChild onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>count</button>
    </div>
  );
}

// ✅ 另一种思路:用 useRef 存最新值,不让子组件依赖变化
function Parent() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const handleClick = useCallback(() => {
    console.log(countRef.current); // ✅ 不依赖变化,函数永远不重建
  }, []); // 空依赖,永远是同一个函数

  return (
    <div>
      <MemoChild onClick={handleClick} />
      <button onClick={() => setCount(c => c + 1)}>count</button>
    </div>
  );
}

架构决策:

场景 推荐方案
回调需要读最新 state 加依赖,或用 ref
回调只需要"触发动作" useCallback + 空依赖
子组件是 memo 的 优先确保 props 不变
性能问题根源不在这里 先用 React DevTools Profiler 定位

场景五:Concurrent Mode 下的闭包过期——时间切片带来的新问题

问题

React 18 开启了 Concurrent Mode,同一个组件可能同时存在多个版本的渲染。这让闭包问题更复杂了:

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    // 发起搜索请求
    const controller = new AbortController();

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => {
        // ⚠️ 关键问题:这里拿到的 query 是哪个版本的?
        setResults(data);
      });

    return () => controller.abort();
  }, [query]);

  return <div>{results.map(r => <li key={r.id}>{r.title}</li>)}</div>;
}

在 Concurrent Mode 下,可能发生这种情况:

  1. 用户输入 "a",React 开始渲染 "a" 的搜索结果
  2. 用户快速输入 "ab",React 中断 "a" 的渲染,开始渲染 "ab"
  3. "ab" 的请求先返回,设置 results = ["ab 结果"]
  4. "a" 的请求后返回,设置 results = ["a 结果"]

结果:用户看到了"ab"的搜索框,却显示着"a"的结果。

源码级分析:React 18 的 thenable 机制

// ReactFiberCommitWork.js
function commitEffect() {
  // ...
  if (thenableState !== null) {
    // 异步更新可能会被 "插队"
    // 这里的状态更新不是线性的
  }
}

如何应对 Concurrent Mode 的闭包?

// ✅ 方案一:使用 AbortController 取消旧请求
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
      // ✅ 再次检查 query 是否还是当前值
      setQuery(current => {
        if (current !== query) return current; // 如果已经变了,忽略这次更新
        return current;
      });
      setResults(data);
    });

  return () => controller.abort();
}, [query]);

// ✅ 方案二:使用 useDeferredValue(React 18)
function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  // 用 deferredQuery 做渲染,用 query 做请求
  // 渲染可以是"过期"的,但数据请求是最新的
}

// ✅ 方案三:使用 useSyncExternalStore( React 18 官方方案)
import { useSyncExternalStore } from 'react';

// 自己管理订阅,确保读取到的是"已提交的"值
function useSearchQuery(query) {
  const snapshot = useSyncExternalStore(
    subscribe,
    getServerSnapshot,
    getClientSnapshot(query)
  );
  return snapshot;
}

场景六:异步函数在 useEffect 里的闭包——最常见的内存泄漏

问题

这是一个经典但容易被忽视的问题:

// ❌ 内存泄漏的典型案例
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let isMounted = true;

    fetchUser(userId).then(user => {
      if (isMounted) {
        setUser(user); // ⚠️ 如果组件已卸载,这里仍然会执行
      }
    });

    return () => {
      isMounted = false; // 这是一个闭包,但它不是过期闭包的锅
    };
  }, [userId]);

  if (!user) return <Loading />;

  return <div>{user.name}</div>;
}

等等,这个例子其实是正确的写法(加了 isMounted 标记)。

真正的问题在下面:

// ❌ 真正的内存泄漏
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const subscription = userService.subscribe(userId, (newUser) => {
      setUser(newUser); // ⚠️ 组件卸载时没有取消订阅!
    });

    return () => {
      // ❌ 忘记取消订阅
      // subscription.unsubscribe();
    };
  }, [userId]);

  return <div>{user?.name}</div>;
}

闭包与内存泄漏的关系

问题类型 闭包的角色 解决方案
过期闭包读旧值 闭包捕获旧变量 用 ref / 函数式更新
异步完成后 setState 组件已卸载 用 isMounted 或 AbortController
事件订阅未清理 闭包持有组件引用 useEffect 返回清理函数
定时器未清理 闭包持有组件引用 clearInterval / clearTimeout

一个更隐蔽的例子:

// ❌ 定时器 + 闭包 = 内存泄漏
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds(s => s + 1); // ✅ 函数式更新,没问题
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{seconds}</div>;
}

// 但如果这样写:
function TimerWithBug() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // ❌ 没有返回清理函数
    const timer = setInterval(() => {
      setSeconds(seconds + 1); // 读的是闭包里的 seconds,永远是 0
    }, 1000);
    // 组件卸载时 timer 还在运行 → 内存泄漏
  }, []); // 依赖数组为空,effect 不重新执行,所以也不会修复

  return <div>{seconds}</div>;
}

场景七:Server Components 下的闭包差异—— React 19 的新挑战

⚠️ React 19 / Next.js App Router 场景

问题

Server Components (RSC) 和 Client Components 的闭包行为完全不同:

// ❌ Server Component(默认)
async function Profile({ userId }) {
  const user = await fetchUser(userId); // ✅ 直接 await,不需要 useEffect

  // 这个函数组件在服务端渲染,不会创建闭包
  // 因为它只执行一次,返回 JSX
  return <div>{user.name}</div>;
}

// ✅ Client Component
'use client';
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

架构差异:

特性 Server Components Client Components
闭包问题 无(只渲染一次) 有(每次渲染可能创建闭包)
数据获取 直接 async/await useEffect + 依赖数组
状态管理 无状态 useState/useReducer
包体积 不打包到客户端 打包到客户端

如何设计?

原则:尽量把不需要交互的组件写成 Server Components。

// ✅ 正确的分层
// ProfilePage.tsx (Server Component - 默认)
import Profile from './Profile';

export default async function ProfilePage({ params }) {
  // 服务端获取数据
  const user = await fetchUser(params.userId);

  // 只把需要交互的部分交给客户端
  return (
    <main>
      <h1>{user.name}</h1>
      <Profile initialUser={user} />
    </main>
  );
}

// Profile.tsx ('use client')
'use client';
function Profile({ initialUser }) {
  const [user, setUser] = useState(initialUser); // 用 initialUser 初始化

  // 只有这里的交互逻辑才需要处理闭包
  return <EditableUser user={user} onSave={setUser} />;
}

场景八:微前端场景下的闭包——qiankun / single-spa 下的特殊问题

问题

在微前端架构中,主应用和子应用各自有独立的 React 实例。闭包问题可能跨应用传播:

// 主应用
function MainApp() {
  const [user, setUser] = useState(null);

  // 传递给子应用的回调
  const handleUserUpdate = useCallback((newUser) => {
    setUser(newUser);
  }, []);

  return (
    <div>
      <MicroApp
        name="user-profile"
        onUserUpdate={handleUserUpdate}
      />
    </div>
  );
}

// 子应用(独立 React 实例)
function UserProfile({ onUserUpdate }) {
  const [user, setUser] = useState({ name: 'Tom' });

  useEffect(() => {
    // ⚠️ onUserUpdate 是从主应用传过来的
    // 它的闭包是在主应用的渲染周期里创建的
    // 子应用的状态变化,可能触发主应用的更新
    onUserUpdate(user);
  }, [user, onUserUpdate]);

  return <div>{user.name}</div>;
}

微前端下的闭包治理

// ✅ 方案:使用事件总线或状态管理,不直接传回调
// eventBus.js
import mitt from 'mitt';
export const bus = mitt();

// 主应用
function MainApp() {
  useEffect(() => {
    bus.on('user-update', (user) => {
      setUser(user);
    });
    return () => bus.off('user-update');
  }, []);

  return <MicroApp name="user-profile" />;
}

// 子应用
function UserProfile() {
  const [user, setUser] = useState({ name: 'Tom' });

  useEffect(() => {
    bus.emit('user-update', user);
  }, [user]);

  return <div>{user.name}</div>;
}

为什么这样更好:

  1. 解耦:子应用不需要知道谁在监听
  2. 最新值:事件触发时读取的是当前值,不存在闭包捕获旧值
  3. 可清理:在 useEffect 返回的函数里可以取消监听

总结:闭包问题的本质与架构思考

闭包问题的本质

JavaScript 闭包 = 函数 + 作用域链
React 函数式组件 = 每次渲染 = 新的函数 + 新的作用域

两者结合 = 每次渲染创建新闭包,可能捕获旧值

高级视角的解决思路

层级 策略 工具
代码规范 exhaustive-deps 强制检查 eslint-plugin-react-hooks
组件设计 避免深层传递 callbacks Context / 状态管理
抽象封装 自定义 Hook 统一处理 useLatest / useInterval
架构分层 Server vs Client 分离 RSC / 'use client'
运行时 Concurrent Mode 适配 useDeferredValue / useSyncExternalStore
微前端 跨应用通信用事件总线 mitt / postMessage

最后一句

闭包不是 bug,是 JavaScript 的核心特性。React 用函数式范式重新定义了 UI,闭包问题只是这条路上的"学费"。

欢迎关注公众号程序员蜡笔熊,欢迎点赞转发,有什么意见或指正欢迎评论区评论。

《前端周刊》React 败北,虾皇登基,OpenClaw 勇夺 GitHub 第一开源软件

今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:

  • 🦞 OpenClaw 赶超 React,加冕 GitHub 第一软件
  • 💰 Linux 基金会成立 React 基金会,华为加盟
  • 🔄 Cloudflare 入驻 B 站,尝试把 Next 移植到 Vite
  • 🛠️ 尤大推出 Vite+,Oxfmt 性能吊打 Prettier

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

每周热搜

OpenClaw 勇夺 GitHub 第一

GitHub 星榜

如图,目前 GitHub 第一仓库是 build-your-own-x,一个聚合了各种资源的懒人包项目。

而涨星最快的仓库是 996.ICU,它其实一场中国社畜抗议 996 过劳文化的运动。

ICU996.png

但以上两者都不算真正的开源软件。

在此之前,GitHub stars 超过 20 万的开源软件有且仅有 3 个:React、Linux 和 Vue。

但两周前,没有 996 的 Open Claw 打破了 996.ICU 的不败神话,赶超了 Linux,一周后 stars 再度反超 React,标志着开源软件的“四皇“正式诞生。

big4-oss.png

更恐怖的是,React 在 2013 年首发,耗时 13 年才积累了 20 万 stars;而 Open Claw 赶超 React 竟然只用了 1/3 年,百日封神。

Star History Chart

目前,OpenClaw 是唯一一个狂砍 GitHub 三大记录的开源软件:

  • 🚀 GitHub 涨星最快的仓库
  • ⭐ GitHub stars 第一的开源软件
  • 👍 GitHub 第一个、也是唯一一个 stars 超过 30 万 的开源软件

恭喜虾皇,AI 的惊喜还在到处涌现!!!

官方情报

React 基金会成立

Linux 基金会官宣 React 基金会成立。

这个独立基金会拥有 Meta、微软、华为等 8 位创始成员,将接管 React、React Native、JSX 等项目。

CLoudflare 将 Next 移植到 Vite

上周,一位 Cloudflare AI 程序员只耗费 $1,100 美元的 token,就将 React 第一全栈框架 Next 移植到 Vite 生态,这个项目就是 vinext。

Cloudflare 认为,Next 的痛点在于它基于 Turbopack 构建,在 Serverless 平台部署存在阻力。

虽然这可以通过 OpenNext “曲线救国“,但更好的方案是基于 GitHub 第一 Web 构建工具 Vite 来驱动,因为 Vite Environment API 可以在任何平台运行。

目前,vinext 处于实验状态,README 说明了若干设计权衡和限制,Next 社区还反馈了安全漏洞。但 vinext 投入成本极低,再次证明了 AIGC 惊人的生产力和无限可能。

此外,Cloudflare 还重做了 Turnstile 人机验证部件。

有趣的是,目前地球总人口大约 80 亿,而这个部件每天就被点击了约 77 亿次,堪称地球上曝光量最大、交互最多的网页小部件了。

所以,你可能也已经在 ChatGPT、Youtube 等各种网站点击过这个人机验证控件了。

最后,Cloudflare 官宣入驻 B 站,感兴趣的粉丝可以一键三连。

bili.png

Angular SRR 漏洞

@angular/ssr 模块发现了 2 个安全漏洞:

  • 首先是一个 Critical 致命漏洞:SSRF 漏洞(服务端请求伪造),存在 HTTP header(请求头)注入风险
  • 还有一个 Moderate 中等漏洞:开放重定向漏洞,存在通过 X-Forwarded-Prefix 发动攻击的风险

Angular 团队建议你尽快将 SSR(服务端渲染)应用更新到最新补丁版。对于无法及时更新的项目,Angular 也提供了一些变通方案。

Prisma 再进化

Prisma 是 GitHub 第一 Node.js ORM(对象关系映射),它能跟后端数据库的 Restful 或 GraphQL 等 API 完美搭配使用。

Prisma 团队正用 TypeScript 重写下一代的 Prisma Next,它是 Prisma 8 的雏形。

目前,Prisma 7 暂定不支持 MongoDB 等非 SOL 数据库,但 Prisma Next 会支持。

此外,它还支持直接写 TS 来替代 schema.prisma 这种 Prisma 专属的声明式 schema 语言。

还有,Prisma Next 会把“查询地狱“重整为更优雅可读的链式调用。

版本更新

这一节我们有精选一些 GitHub stars 过万的仓库或流行的 npm 模块,共享它们近期主/次版本的更新内容,略有删改。

Nuxt UI v4.5

Nuxt UI 是一个全面的 Vue 组件库,最近发布了 4.5 版本。

这个次版本更新主要包括:

  • 新增 <Theme /> 组件,它可以一次性覆盖所有子组件主题
  • 得益于 Tailwind 4.2,它新增了 4 种中性色
  • useToast() 现在会自动去重,在重复通知时显示脉冲动画

toast.gif

Shiki v4.0

Shiki 是 GitHub 第一 Textmate 语法高亮库,能实现 VS Code 的高亮样式,由 Antfu 大神维护。

Shiki 发布了 4.0 主版本,主要包括:

  • 它要求 Node 版本至少 20,因为 Node 18 去年停止维护了
  • 删除一些拼写错误的废弃 API
// 删除之前过去式的方法名
createdBundledHighlighter();

// 重构为一般现在时的方法名
createBundledHighlighter();

Better Auth v1.5

Better Auth 是 GitHub 前十的认证与授权库,兼容 React 跟 Next,也支持 Vue 和 Nuxt 等流行框架。

Better Auth 发布了 1.5 次版本,是迄今为止最大的一次更新,主要包括:

  • @better-auth/electron 支持 Electron 桌面应用认证,它能处理完整的 OAuth 流程
  • MCP 插件现在自带一个远程认证客户端。如果你的 MCP 服务器与 Better Auth 实例分开,你可以验证 token,无需重复认证逻辑
  • 新插件 @better-auth/oauth-provider 将 Better Auth 实例变成一个兼容 OIDC 的 OAuth 2.1 授权服务器,将取代 OIDC Provider 插件,MCP 插件也将迁移到这个新插件
  • 新的 CLI 取代了原来的 @better-auth/cli
# npx auth 取代旧的 CLI
npx auth init

工具推荐

Oxfmt beta(公测)

Oxfmt 是一款 Rust 驱动、兼容 Prettier 的代码格式化神器。

它是 Vite Rolldown 版底层 Oxc 编译器生态的产品之一,也是最近尤大推出的 Vite+ 工具链的一部分,旨在用 Rust 重写的 Prettier 打败 Prettier。

Oxfmt 进入 beta(公测)阶段,我们刚刚提到的 GitHub 第一软件 OpenClaw 已经在用 Oxfmt 了,亮点主要有:

  • 内置 JS 的 import 导入语句排序(强迫症晚期狂喜)
  • 内置 Tailwind CSS class 类名自动排序,不需要 prettier-plugin-tailwindcss 插件
  • 默认启用 package.json 属性字段自动排序
  • 支持海量 IDE,包括 VS Code、WebStorm、Cursor、Neovim 等
  • JS / TS 一致性测试 100% 兼容 Prettier,少数不一致性正在和 Prettier 团队合作,但性能堪称降维打击

在性能跑分中,Oxfmt 比 Biome 快 3 倍,比 Prettier 快 30 倍。

oxfmt-rank.gif

特别鸣谢

以上就是本期《Web 周刊》的全部内容了,感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

已经关注我的粉丝们,我们下期再见啦,掰掰~~

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

鳌虾 AoCode:重新定义 AI 编程助手的下一代可视化工具

前言

在 AI 代码生成工具层出不穷的今天,程序员面临着一个核心问题:如何更高效、更精准地让 AI 理解我们的需求?传统的 AI 对话模式需要我们反复描述项目背景、手动关联各种文档和技能规范,这种模式不仅效率低下,还容易因为信息不完整导致生成结果与预期相差甚远。

鳌虾(AoCode) 正是为解决这些痛点而生。它通过可视化拖拽的方式,让开发者无需手敲冗长的 Prompt,即可自动生成高质量的 AI 编程指令。更重要的是,它能与项目中的技能文件(skills)无缝结合,让 AI 始终在统一的规范下生成代码,从根本上减少"幻觉"的产生。

GitHubgithub.com/zy1992829/a…


一、工具使用:零门槛上手,三步生成 AI 指令

1.1 组件拖拽,所见即所得

image.png

鳌虾提供了一个直观的可视化页面设计器。左侧是丰富的组件库,右侧是线框图骨架画布。开发者只需从左侧拖拽组件到画布中,即可快速搭建页面结构。

支持的组件包括:

  • 页面布局:单列、双列、左侧定宽、右侧定宽等多种布局容器
  • 基础组件:搜索栏、数据表格、表单区域、可编辑表格、详情区块
  • 自定义模块:支持纯文本自定义模块

每个组件都可以单独配置其属性和关联的业务字段,满足不同的业务需求。

1.2 智能读取项目技能文件

鳌虾支持自动扫描并读取项目中的技能文件。它会按照优先级自动探测以下目录:

.trae/skills  >  .trae/rules  >  .cursor/rules  >  .windsurf/rules  >  .aocode/rules  >  docs/rules

读取逻辑采用三态模式

  • 状态一:未找到任何技能文件 → 输出"您没有任何技能约束"
  • 状态二:找到文件但文件中没有 <rules>[CODE_RULES_START] 标签 → 静默处理,不输出任何内容
  • 状态三:找到文件且文件包含标签内容 → 自动提取并注入到 AI 指令中

这种设计确保了 AI 指令的精简性——只传递必要的信息,避免噪声干扰。

1.3 页面级技能分配

在鳌虾中,每个页面都可以独立绑定不同的技能文件。比如:

  • index.vue(列表页)绑定 page.md
  • edit.vue(编辑页)绑定 edit.md
  • look.vue(详情页)绑定 look.md

这样,不同类型的页面会自动带上各自的规范约束,生成结果更加精准。

1.4 一键生成 Clipboard 指令

image.png

配置完成后,点击**"生成 AI 指令"**按钮,鳌虾会自动生成一份结构化的指令文本,包含:

  • 功能目录和路径信息
  • 页面模块及布局顺序
  • 绑定的技能规范内容
  • API 基础路径

生成后直接复制到剪贴板,粘贴到 AI 对话窗口即可。


二、工具对比:鳌虾 vs 传统 AI 编程

对比维度 传统 AI 编程 鳌虾 AoCode
Prompt 输入 每次都要手敲完整描述 可视化配置,一键生成
技能规范传递 手动复制粘贴或反复提及 自动读取并注入
多页面一致性 每个页面都要重复描述项目背景 页面级技能分配,一劳永逸
信息完整性 容易遗漏关键约束条件 结构化输出,确保信息无遗漏
技能文件管理 依赖开发者自觉遵守 系统层面强制关联
学习成本 需要学习 Prompt 编写技巧 无需任何 Prompt 经验

2.1 传统模式的痛点

传统 AI 编程中,开发者常常面临这样的困境:

  1. 重复劳动:每次对话都要重新描述项目结构、技术栈、规范要求
  2. 信息不对称:AI 无法主动了解项目规范,容易产生"幻觉"
  3. 一致性差:不同对话生成的代码风格不统一,集成困难
  4. 维护成本高:项目规范变更后,需要手动更新所有历史 Prompt

2.2 鳌虾的解决方案

  1. 零 Prompt 编写:通过可视化配置替代手写文本,降低使用门槛
  2. 技能即规范:将项目规范写入技能文件(skills),AI 随时可读
  3. 上下文共享:一次配置,多页面复用,确保输出一致性
  4. 版本可控:技能文件可纳入版本管理,规范变更有迹可循

三、快速上手:下载与安装

3.1 环境要求

  • Node.js:>= 16.0.0
  • npm:>= 8.0.0

3.2 安装步骤

使用 npm 全局安装:

npm install -g aoxia-ui-generator

# 验证安装
aocode --version

安装完成后,在任意项目目录下运行即可启动鳌虾:

aocode

服务启动后会自动打开浏览器访问 http://localhost:3000/,即可开始使用。

3.3 项目初始化

首次使用时,建议在项目根目录下创建 .trae/skills 文件夹,并放置你的技能规范文件:

my-project/
├── .trae/
│   └── skills/
│       ├── page.md      # 列表页规范
│       ├── edit.md      # 编辑页规范
│       └── look.md      # 详情页规范
└── src/
    └── views/
        └── ...

鳌虾会自动扫描并读取这些文件,让你在页面配置时自由绑定。

image.png

image.png


四、未来展望:AI 编程的下一个十年

4.1 从"工具"到"助手"的进化

当前的 AI 编程工具大多停留在"响应指令"的层面。鳌虾的愿景是成为主动协作的助手——它不仅被动响应开发者的配置,还会主动建议最优的页面结构、规范的代码组织方式。

4.2 技能生态的构建

未来,鳌虾计划构建一个开放的技能市场(Skills Market)

  • 开发者可以发布自己编写的技能文件
  • 项目可订阅行业最佳实践技能
  • 支持技能的版本管理和更新通知

4.3 多模态融合

未来的 AI 编程将不局限于文本。鳌虾计划引入:

  • 设计稿导入:直接解析 Figma、Sketch 等设计文件
  • API 文档解析:自动理解接口定义并生成对应页面
  • 代码审查集成:生成后自动检查是否符合规范

4.4 对标 OpenClaw,走向国际

鳌虾的愿景不止于国内市场。它以 OpenClaw(开源龙虾)为对标目标,致力于成为全球开发者喜爱的 AI 编程工具。开源、生态、国际化的道路,将是鳌虾下一阶段的核心方向。


结语

AI 编程的时代已经到来,但"幻觉"问题始终困扰着开发者。鳌虾通过可视化配置 + 技能文件 + 智能注入的创新模式,让 AI 始终在规范的框架内生成代码,从根本上减少了不确定性。

这不是一个简单的 Prompt 生成器,而是一套完整的AI 编程工作流解决方案。它让开发者从繁琐的文本工作中解放出来,专注于真正的业务逻辑。

当别人还在手敲 Prompt 的时候,你已经在用鳌虾生成代码了。


鳌虾 AoCode,下一代 AI 编程助手,让代码生成更精准、更高效、更可控。


❌