普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月26日掘金 前端

Three.js 场景完全入门指南:让你的 3D 场景不在乱成一团

作者 烛阴
2026年3月26日 10:15

场景图到底是什么?一句话说清楚

场景图 = 你 3D 世界里的「家族族谱」

你在 Three.js 里创建的每一个物体——立方体、球体、灯光、相机——它们不是孤立存在的,而是像家族成员一样,有爸爸、有儿子、有孙子,形成一个树状的层级结构。

这个结构,就叫场景图。

想象一下你在玩乐高:

  • 你先搭了一个车身(父节点)
  • 然后在车身上装了 4 个轮子(子节点)
  • 每个轮子上又装了轮毂装饰(孙节点)

当你拿起整个车身移动时,轮子和轮毂会自动跟着动。你不需要一个一个去移动它们。

这就是场景图的核心逻辑:父节点动,子节点自动跟着动。

02-concept-scenegraph.png

为什么需要场景图?

假设你要做一个太阳系模型:

  • 太阳在中心
  • 地球绕着太阳转
  • 月球绕着地球转

如果没有场景图,你得这么写:

// 每一帧都要手动计算位置
function animate() {
  // 地球绕太阳转
  earth.position.x = Math.cos(time) * 10;
  earth.position.z = Math.sin(time) * 10;

  // 月球绕地球转,还要加上地球的位置
  moon.position.x = earth.position.x + Math.cos(time * 2) * 2;
  moon.position.z = earth.position.z + Math.sin(time * 2) * 2;

  // 如果再加个火星、木星、土星...
  // 你的代码会变成一坨屎
}

有了场景图,你只需要:

// 把月球设为地球的子节点
earth.add(moon);

// 把地球设为太阳的子节点
sun.add(earth);

// 每一帧只需要旋转父节点
function animate() {
  sun.rotation.y += 0.01;  // 太阳自转
  earth.rotation.y += 0.02; // 地球自转,月球自动跟着转
}

场景图让你从「手动计算每个物体的绝对位置」,变成「只管理父子关系,让系统自动计算」。


场景图的三大核心规则

规则 1:每个物体都有自己的「局部坐标系」

这是最容易搞混的地方。

在 Three.js 里,每个物体的 positionrotationscale 都是相对于它的父节点的,不是相对于整个世界的。

举个例子:

const car = new THREE.Group(); // 汽车
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial); // 轮子

wheel.position.x = 2; // 轮子在汽车坐标系里,向右偏移 2 个单位
car.add(wheel);

car.position.x = 10; // 汽车在世界坐标系里,向右移动 10 个单位

此时,轮子在世界坐标系里的实际位置是 10 + 2 = 12

但你在代码里看到的 wheel.position.x 还是 2,因为它记录的是相对于父节点(汽车)的位置

这就像你在高铁上走动:

  • 你相对于车厢的位置是「第 5 排座位」(局部坐标)
  • 但你相对于地球的位置,是「第 5 排座位 + 高铁的位置」(世界坐标)

规则 2:父节点的变换会「传递」给所有子节点

这是场景图最强大的地方。

当你旋转、缩放、移动一个父节点时,它的所有子节点、孙节点、曾孙节点……都会跟着变。

const robot = new THREE.Group();
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
const leftArm = new THREE.Mesh(armGeometry, armMaterial);
const rightArm = new THREE.Mesh(armGeometry, armMaterial);

robot.add(body);
body.add(leftArm);
body.add(rightArm);

// 旋转机器人,整个机器人(包括身体和手臂)都会转
robot.rotation.y = Math.PI / 4;

// 旋转身体,手臂会跟着转,但机器人的腿不会动
body.rotation.x = Math.PI / 6;

这就像你转身:

  • 你的头、手、脚都会跟着转(子节点跟随父节点)
  • 但你手上拿的手机屏幕方向不会变(子节点保持自己的局部旋转)

规则 3:Scene 是所有节点的「根节点」

在 Three.js 里,Scene 就是那个最顶层的「祖宗节点」。

所有你想渲染出来的东西,都必须直接或间接地添加到 Scene 里。

const scene = new THREE.Scene();

// 方式 1:直接添加到场景
scene.add(cube);

// 方式 2:添加到某个组,再把组添加到场景
const group = new THREE.Group();
group.add(cube);
scene.add(group);

Scene 就像一个舞台:

  • 只有站在舞台上的演员(或演员团队)才能被观众(相机)看到
  • 你在后台准备的道具(没 add 到 scene 的物体),观众看不见

···

真实场景:用场景图管理一辆汽车

假设你要做一个可交互的汽车模型:

  • 汽车可以前进、后退、转弯
  • 4 个轮子要跟着车身动
  • 轮子转弯时要旋转
  • 车门可以单独打开

没有场景图的噩梦写法:

// 每次移动汽车,你要手动更新 5 个物体的位置
function moveCar(distance) {
  carBody.position.z += distance;
  wheel1.position.z += distance;
  wheel2.position.z += distance;
  wheel3.position.z += distance;
  wheel4.position.z += distance;
  door.position.z += distance;
}

// 转弯时,你要手动计算每个轮子的新位置
function turnCar(angle) {
  // 这里要写一堆三角函数...
  // 而且很容易算错
}

用场景图的优雅写法:

// 1. 创建层级结构
const car = new THREE.Group(); // 汽车根节点
const body = new THREE.Mesh(bodyGeometry, bodyMaterial); // 车身
const door = new THREE.Mesh(doorGeometry, doorMaterial); // 车门

const wheel1 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel2 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel3 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel4 = new THREE.Mesh(wheelGeometry, wheelMaterial);

// 2. 建立父子关系
car.add(body);
body.add(door); // 车门是车身的子节点
body.add(wheel1);
body.add(wheel2);
body.add(wheel3);
body.add(wheel4);

scene.add(car); // 整辆车添加到场景

// 3. 设置轮子的局部位置(相对于车身)
wheel1.position.set(-1, -0.5, 1.5);  // 左前轮
wheel2.position.set(1, -0.5, 1.5);   // 右前轮
wheel3.position.set(-1, -0.5, -1.5); // 左后轮
wheel4.position.set(1, -0.5, -1.5);  // 右后轮

// 4. 移动汽车,只需要操作根节点
function moveCar(distance) {
  car.position.z += distance; // 一行代码,整辆车都动了
}

// 5. 转弯,也只需要操作根节点
function turnCar(angle) {
  car.rotation.y += angle; // 一行代码,整辆车都转了
}

// 6. 打开车门,只操作车门节点
function openDoor() {
  door.rotation.y = Math.PI / 3; // 车门绕自己的轴旋转
}

// 7. 轮子转动,只操作轮子节点
function rotateWheels(speed) {
  wheel1.rotation.x += speed;
  wheel2.rotation.x += speed;
  wheel3.rotation.x += speed;
  wheel4.rotation.x += speed;
}

场景图让你的代码从「管理 100 个物体的绝对位置」,变成「管理 10 个父子关系」。

代码量少了 90%,bug 也少了 90%。

···

进阶技巧:Group 是你最好的朋友

Three.js 提供了一个专门用来组织场景图的工具:THREE.Group()

它就是一个「空节点」,自己不渲染任何东西,但可以作为其他物体的容器。

什么时候用 Group?

  1. 逻辑分组:把相关的物体放在一起

    const furniture = new THREE.Group();
    furniture.add(table);
    furniture.add(chair);
    furniture.add(lamp);
    
    // 一次性移动所有家具
    furniture.position.x = 5;
    
  2. 动画控制:需要整体旋转或移动时

    const solarSystem = new THREE.Group();
    solarSystem.add(sun);
    solarSystem.add(earth);
    solarSystem.add(mars);
    
    // 整个太阳系旋转
    solarSystem.rotation.y += 0.01;
    
  3. 坐标系转换:需要改变物体的旋转中心时

    // 默认情况下,物体绕自己的中心旋转
    // 如果你想让它绕另一个点旋转,可以用 Group
    
    const pivot = new THREE.Group();
    pivot.add(cube);
    cube.position.x = 5; // 立方体偏离 pivot 中心
    
    pivot.rotation.y += 0.01; // 立方体绕 pivot 中心旋转(公转)
    cube.rotation.y += 0.02;  // 立方体绕自己中心旋转(自转)
    

Group 就像乐高的底板:

  • 你可以在底板上搭建复杂的结构
  • 然后拿起整个底板移动,所有东西都跟着动
  • 底板本身不占空间,只是一个「组织工具」

···

常见坑点:为什么我的物体位置不对?

坑点 1:忘记父节点的变换会累积

const parent = new THREE.Group();
parent.scale.set(2, 2, 2); // 父节点放大 2 倍

const child = new THREE.Mesh(geometry, material);
child.scale.set(2, 2, 2); // 子节点也放大 2 倍
parent.add(child);

// 结果:子节点实际被放大了 2 × 2 = 4 倍!

解决方法:

  • 要么只在父节点设置缩放
  • 要么在子节点用 1 / parent.scale.x 来抵消

坑点 2:直接修改 world position 不生效

const child = new THREE.Mesh(geometry, material);
parent.add(child);

// ❌ 错误:直接设置世界坐标不会生效
child.position.set(10, 0, 0); // 这是局部坐标!

// ✅ 正确:如果要设置世界坐标,需要先转换
const worldPos = new THREE.Vector3(10, 0, 0);
child.parent.worldToLocal(worldPos);
child.position.copy(worldPos);

坑点 3:移除节点时忘记清理引用

// ❌ 错误:只从场景移除,但父子关系还在
scene.remove(child);

// ✅ 正确:从父节点移除
parent.remove(child);

// ✅ 更好:彻底清理
parent.remove(child);
child.geometry.dispose();
child.material.dispose();

核心代码与完整示例:      my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

从“后端验证”到“前端签名”:我在Web3项目中重构用户身份认证的实战记录

作者 竹林818
2026年3月26日 10:02

背景

上个月,我在参与一个Web3内容社区“CryptoPulse”的前端开发。这个社区允许用户发表关于项目的分析、评论,并有一个积分激励系统。一个最基础的需求是:用户必须登录后才能发帖和评论。

一开始,我们沿用了最熟悉的Web2方案:用户连接钱包后,前端将钱包地址发给后端,后端生成一个JWT(JSON Web Token)返回,前端将其存入localStorage或Cookie,后续每次请求都带上这个Token。这个方案跑起来没问题,但总感觉哪里不对劲。产品经理和社区用户都反馈:“我们明明是Web3应用,为什么登录流程和传统网站一样?还要依赖你们服务器的中心化认证?”

问题的核心矛盾在于:在Web3的世界里,身份(钱包地址)和授权(私钥签名)本应是用户自己掌控的。我们后端的JWT签发,本质上又成了一个中心化的“发证机构”。我们需要一种方式,让用户用自己钱包的签名能力,来证明“我就是这个地址的持有者”,并且这个证明能被我们的后端验证,同时整个过程不涉及私钥的传输。

问题分析

我的第一反应是:这不就是personal_sign吗?让用户对一段消息签名,后端用ecrecover验证签名和地址是否匹配。但具体到我们的“发帖”场景,需要签名的“消息”是什么?

最初的想法很简单:让用户对固定的消息,比如“Login to CryptoPulse”签名。但这立刻带来了安全问题:签名重用(Replay Attack)。如果攻击者截获了这个签名,他可以在任何时间、任何地点用它来冒充用户。这个签名必须是一次性的、与当前操作上下文绑定的。

那么,把签名和具体的表单数据绑定呢?比如,用户提交一篇包含titlecontent的帖子时,让用户对 title + content 的字符串签名。这解决了重用问题,但带来了新麻烦:

  1. 用户体验差:用户每次发帖、评论都要弹一次钱包签名,非常繁琐。
  2. 数据耦合过紧:如果用户签名后,在请求发送前网络波动导致内容丢失,或者他想稍作修改,整个签名就无效了,需要重签。
  3. 后端验证逻辑复杂:后端需要完整重构帖子数据来验证签名,任何字段顺序或格式的差异都会导致验证失败。

经过一番搜索和与后端同事的讨论,我们确定了方向:采用 “挑战-响应”(Challenge-Response) 模式,但需要优化。核心思路是:后端生成一个一次性、有时效性的随机字符串(Challenge),前端让用户钱包对其签名,然后将签名和用户地址一起送回后端验证。验证通过后,后端颁发一个短期有效的会话凭证。在凭证有效期内,用户进行发帖、评论等操作不再需要签名。

这样一来,签名的动作从“每次提交表单”前置到了“登录会话建立时”,平衡了安全性和用户体验。接下来,就是具体的实现和踩坑之旅了。

核心实现

第一步:设计后端API与前端状态管理

首先,我和后端同学约定好了两个关键接口:

  1. GET /api/auth/challenge:获取挑战码。请求参数为钱包地址address,后端返回一个结构如 { challenge: string, expiresAt: number } 的对象。后端会将该挑战码与该地址绑定,并设置一个短的过期时间(如5分钟)。
  2. POST /api/auth/verify:验证签名。请求体为 { address: string, signature: string, challenge: string }。验证成功后,后端在响应头设置HttpOnly的Session Cookie(或返回一个短期Token),并返回用户基本信息。

前端的状态管理,我选择用 wagmi + @tanstack/react-querywagmi 管理钱包连接和签名,react-query 管理异步的认证状态。

// hooks/useAuth.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAccount, useSignMessage } from 'wagmi';
import { apiClient } from '../lib/api'; // 封装好的axios实例

export const useAuth = () => {
  const { address, isConnected } = useAccount();
  const queryClient = useQueryClient();
  const { signMessageAsync } = useSignMessage();

  // 1. 获取挑战码
  const fetchChallenge = useQuery({
    queryKey: ['auth-challenge', address],
    queryFn: () => apiClient.get(`/auth/challenge?address=${address}`),
    enabled: !!address, // 只有连接钱包后才启用
    staleTime: 4 * 60 * 1000, // 挑战码4分钟内有效
  });

  // 2. 验证签名的Mutation
  const verifySignature = useMutation({
    mutationFn: async (params: { signature: string; challenge: string }) => {
      return apiClient.post('/auth/verify', {
        address,
        signature: params.signature,
        challenge: params.challenge,
      });
    },
    onSuccess: () => {
      // 验证成功,使所有用户相关查询失效,触发重新获取
      queryClient.invalidateQueries({ queryKey: ['user-profile'] });
    },
  });

  // 3. 封装登录动作
  const login = async () => {
    if (!fetchChallenge.data) {
      throw new Error('No challenge available');
    }
    const challenge = fetchChallenge.data.data.challenge;
    // 这里有个坑:一定要让用户知道他在签什么,消息格式要清晰
    const signature = await signMessageAsync({
      message: `CryptoPulse Login\n\nChallenge: ${challenge}`,
    });
    await verifySignature.mutateAsync({ signature, challenge });
  };

  return {
    isConnected,
    address,
    challenge: fetchChallenge.data?.data,
    login,
    isLoggingIn: verifySignature.isPending,
  };
};

第二步:实现签名与消息格式化

签名本身很简单,但消息的格式化是安全性和用户体验的关键。直接让用户签一串随机字符(挑战码)非常不友好,且容易被钓鱼。最佳实践是遵循 EIP-4361(Sign-In with Ethereum)规范,将消息格式化为人类可读的结构。

由于项目时间紧,我们先实现一个简化但清晰的版本:

// utils/signMessage.ts
export const formatLoginMessage = (challenge: string, address: string) => {
  const domain = window.location.host; // 当前域名
  const statement = 'Welcome to CryptoPulse. Click to sign in.';
  const uri = window.location.origin;
  const version = '1';
  const nonce = challenge; // 使用后端下发的挑战码作为nonce
  const issuedAt = new Date().toISOString();

  return `${statement}\n\n` +
         `URI: ${uri}\n` +
         `Version: ${version}\n` +
         `Chain ID: 1\n` +
         `Nonce: ${nonce}\n` +
         `Issued At: ${issuedAt}\n` +
         `Resources:\n` +
         `- https://${domain}`;
};

然后在登录函数中使用它:

const login = async () => {
  if (!challenge || !address) return;
  const message = formatLoginMessage(challenge, address);
  const signature = await signMessageAsync({ message });
  // ... 后续验证
};

这样,用户在MetaMask等钱包里看到的是一个结构清晰、包含我们域名和意图的请求,大大降低了被钓鱼的风险。

第三步:处理钱包连接与登录流程的联动

这里遇到了第一个流程上的坑。最初的逻辑是:用户点击“连接钱包” -> 连接成功 -> 自动触发fetchChallenge -> 自动弹出签名。这导致了糟糕的用户体验,用户连上钱包后还没看清页面,签名请求就弹出来了。

我们调整了流程,将“连接钱包”和“登录认证”解耦:

  1. “连接钱包”按钮只负责连接。
  2. 连接成功后,页面上显示一个独立的“登录/签名”按钮。
  3. 只有用户点击这个按钮,才去获取挑战码并触发签名。
// components/LoginButton.tsx
import { useAuth } from '../hooks/useAuth';

export const LoginButton = () => {
  const { isConnected, address, login, isLoggingIn, challenge } = useAuth();

  if (!isConnected) {
    return <button onClick={connectWallet}>Connect Wallet</button>;
  }

  // 连接后,显示登录按钮
  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <button
        onClick={login}
        disabled={isLoggingIn || !challenge}
      >
        {isLoggingIn ? 'Signing...' : 'Sign In to Post'}
      </button>
      {!challenge && <p>Preparing login...</p>}
    </div>
  );
};

第四步:会话管理与请求拦截

登录成功后,后端通过HttpOnly Cookie管理会话。前端需要知道当前的登录状态以更新UI。我们通过一个简单的 GET /api/auth/me 接口来获取当前用户信息。

// hooks/useUser.ts
export const useUser = () => {
  return useQuery({
    queryKey: ['user-profile'],
    queryFn: () => apiClient.get('/auth/me'),
    retry: false, // 401时不要重试
    staleTime: 5 * 60 * 1000, // 5分钟
  });
};

然后,在应用的根组件或布局组件中,我们可以根据 useUser 的返回状态来显示不同的UI(如显示用户名或显示登录按钮)。同时,需要在 apiClient(axios实例)中设置请求拦截器,自动处理401未授权错误,比如跳转到登录页或静默刷新Token(如果实现的是Token方案)。

完整代码示例

以下是一个简化但可运行的React组件示例,集成了上述核心逻辑:

// App.tsx
import { WagmiConfig, createConfig, mainnet } from 'wagmi';
import { createPublicClient, http } from 'viem';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginFlow } from './components/LoginFlow';

const queryClient = new QueryClient();
const config = createConfig({
  autoConnect: true,
  publicClient: createPublicClient({
    chain: mainnet,
    transport: http(),
  }),
});

function App() {
  return (
    <WagmiConfig config={config}>
      <QueryClientProvider client={queryClient}>
        <div className="App">
          <h1>CryptoPulse</h1>
          <LoginFlow />
        </div>
      </QueryClientProvider>
    </WagmiConfig>
  );
}

export default App;
// components/LoginFlow.tsx
import { useState } from 'react';
import { useAccount, useConnect, useDisconnect, useSignMessage } from 'wagmi';
import { injected } from 'wagmi/connectors';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, formatLoginMessage } from '../lib';

export const LoginFlow = () => {
  const { address, isConnected } = useAccount();
  const { connect } = useConnect();
  const { disconnect } = useDisconnect();
  const { signMessageAsync } = useSignMessage();
  const queryClient = useQueryClient();

  // 获取挑战码
  const { data: challengeData } = useQuery({
    queryKey: ['challenge', address],
    queryFn: () => apiClient.get(`/auth/challenge?address=${address}`),
    enabled: !!address,
  });

  // 验证签名
  const { mutateAsync: verifySig, isPending: isVerifying } = useMutation({
    mutationFn: (data: { signature: string; challenge: string }) =>
      apiClient.post('/auth/verify', { address, ...data }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
  });

  // 获取用户信息(代表登录状态)
  const { data: user } = useQuery({
    queryKey: ['me'],
    queryFn: () => apiClient.get('/auth/me'),
  });

  const handleLogin = async () => {
    if (!challengeData?.data?.challenge) return;
    const challenge = challengeData.data.challenge;
    const message = formatLoginMessage(challenge, address!);
    try {
      const signature = await signMessageAsync({ message });
      await verifySig({ signature, challenge });
    } catch (err) {
      console.error('Login failed:', err);
    }
  };

  if (user?.data) {
    return (
      <div>
        <p>Welcome, {user.data.username || address?.slice(0, 6)}!</p>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    );
  }

  if (isConnected) {
    return (
      <div>
        <p>Connected: {address}</p>
        <button onClick={handleLogin} disabled={isVerifying || !challengeData}>
          {isVerifying ? 'Signing In...' : 'Sign In'}
        </button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    );
  }

  return (
    <button onClick={() => connect({ connector: injected() })}>
      Connect Wallet
    </button>
  );
};
// lib/index.ts
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  withCredentials: true, // 重要!允许携带Cookie
});

export const formatLoginMessage = (challenge: string, address: string) => {
  return `Welcome to CryptoPulse.\n\n` +
         `Sign this message to authenticate.\n` +
         `Challenge: ${challenge}\n` +
         `Address: ${address}`;
};

踩坑记录

  1. “Sign message rejected” 用户拒绝签名:这是最常见的坑。最初我把获取挑战码和签名做成了自动连续操作,用户连接钱包后立刻弹窗,很多人下意识就拒绝了。解决方案:将连接和登录明确分离,给用户一个明确的“Sign In”按钮,并附上友好的解释文字,告知签名是安全的且不会消耗Gas。

  2. 跨域(CORS)与Cookie问题:前端在 localhost:3000,后端API在 localhost:8080。即使后端设置了CORS头 Access-Control-Allow-Origin: http://localhost:3000Access-Control-Allow-Credentials: true,前端axios请求如果不设置 withCredentials: true,浏览器也不会发送或接收Cookie。解决方案:确保前后端CORS配置正确,并在前端HTTP客户端中显式开启 withCredentials

  3. 消息编码与验证失败:在测试时,后端始终报告签名验证失败。排查后发现,wagmi/viemsignMessage 会对消息进行 EIP-191 标准的预处理(添加 \x19Ethereum Signed Message:\n 前缀和长度),而我的后端验证库(如ethers.jsverifyMessage)也期望同样的预处理。解决方案:确保前后端使用同一套消息预处理逻辑。大多数成熟的库(如ethers.verifyMessage, viemverifyMessage)都默认处理好了,关键在于前端签名和后端验证要使用兼容的库或相同的处理函数。

  4. 挑战码过期与重试:用户可能打开页面后很久才点击登录,此时挑战码已过期。最初的处理只是报错,体验不好。解决方案:在登录函数中捕获验证失败的错误,如果错误提示是“挑战码无效或过期”,则自动重新获取一次挑战码并让用户重签。但要注意避免无限循环,通常重试一次即可。

小结

这次重构让我深刻体会到,Web3前端开发不仅仅是调用智能合约,更重要的是设计出符合去中心化精神的用户流程。基于签名的身份认证,将信任的锚点从我们的服务器转移到了用户的钱包和区块链上,这才是真正的Web3原生体验。下一步,可以深入研究EIP-4361标准,实现更规范、兼容性更好的“以太坊登录”功能,并考虑如何将这套认证系统扩展到更多链上操作中。

搞懂 Cursor 后,我一行代码都不敲了《进阶篇》

作者 清汤饺子
2026年3月26日 10:01

Hi~大家好呀,我是清汤饺子。

上篇我们讲了 Cursor 的基础用法——怎么安装、怎么用 Agent、怎么写功能修 Bug。学会了这些,你已经能用 Cursor 正常干活了。

但你可能会有这种感觉:有时候 AI 好像"不太懂你"——它不知道你的编码习惯,不记得你的项目规范,每次都要重复解释很多东西。

好!那这篇文章就是来解决这个问题的。我们来聊聊怎么把 Cursor 调教成最懂你的搭档。

跟着这个系列学完,你会发现 Cursor 不只是个代码补全工具——它是你的 AI 开发团队。

你可以同时叫多个 Agent 帮你干活:一个读代码理解业务,一个写新功能,一个修 Bug,一个跑测试,一个写文档。你只需要告诉它们想做什么,然后等着验收结果就行。

这个系列一共三篇:

第一篇从零上手 Cursor

讲讲怎么安装、Agent 怎么用、怎么写功能、怎么修 Bug

第二篇(就是这篇):让 Cursor 更懂你

上下文引用、Rules、Skills、MCP 这些

第三篇团队协作与场景实战

怎么在团队里用好 Cursor


我踩过的坑

刚开始用 Cursor 我经常有这种感觉:

让 AI 帮你改个功能,它改完了你一看——完全不是你想的那样

后来我明白了,不是 AI 笨,是我没告诉它"看哪里"。

AI 输出的质量好坏,很大程度上取决于它"看到"了什么。你给它塞越多无关的信息,它的注意力就越分散,出来的结果就越水。

这篇文章,就是来讲讲怎么让 AI 更懂你。


一、上下文管理:让 AI 看到正确的代码

1.1 核心引用符号

Cursor 给了一套 @ 符号引用体系,让你精准控制送入模型的上下文。

说人话:就是告诉 AI "看这个文件"、"看这个文件夹"。

符号 作用
@文件名 把指定文件内容注入上下文
@文件夹名 注入整个目录的结构信息
@codebase 触发语义搜索,让 Agent 自己去找相关代码
@doc 引入已索引的第三方文档
@web 触发实时网络搜索
@git 引用 Git 历史、diff

1.2 两种搜索模式

用 @codebase 时,Cursor 会综合用两种搜索:

  • 精确搜索:知道函数名、变量名就直接搜,速度极快
  • 语义搜索:不知道具体叫什么,但能描述功能

1.3 使用建议

✅ 先精确,后宽泛:知道文件名就直接 @文件名

✅ 先探索,再改动:让 AI 先展示现有的相关实现

✅ 只引入必要的:塞太多无关文件会稀释 AI 的注意力

💡 心得:这是我踩过最大的坑!之前都是直接让 AI 干活,也不告诉它看哪里,结果它搜一堆不相关的代码,写出来的东西牛头不对马嘴。加上精准的 @ 引用后,效率直接翻倍。


二、Rules:给 AI 写项目规则

2.1 大模型没有记忆

不知道你们烦不烦,反正我是烦透了——每次开新会话都要跟 AI 解释一遍项目规范

"我们用 Tailwind 别用 styled-components" "API 统一放 src/api/ 目录" "组件名要用 PascalCase"

累不累啊。

Rules 解决的问题就是:把你的编码规范、架构决策,固化成 AI 的"持久记忆"

2.2 四种规则类型

类型 存储位置 适用范围
Project Rules .cursor/rules/ 当前项目,可提交到 Git
User Rules Cursor Settings 所有项目,个人偏好
Team Rules 团队 Dashboard 全团队,付费版
AGENTS.md 项目根目录 当前项目,纯 Markdown

2.3 怎么创建 Rules

两种方式:

  1. 在 Chat 中输入 /create-rule,描述你想要规则
  2. 通过 Settings:Cursor Settings > Rules > + Add Rule

2.4 最佳实践

✅ 每条规则保持在 500 行以内,超出就拆

✅ 规则要具体可执行,像清晰的内部文档

✅ 用 @文件 引用范例,而不是把代码贴进去

✅ 发现 Agent 反复犯同一个错时,就写一条规则

💡 心得:Rules 是我用了就回不去的功能。之前每次都要重复说的话,现在配置一次就行。而且配置完,AI 写出来的代码风格完全一致,代码审查都省心了。


三、Skills:封装可复用的 AI 技能

3.1 什么是 Skills

如果说 Rules 是给 AI 定"工作原则",那 Skills 就是给 AI 打包"专项能力"。

举个例子:你们团队每次发版都要走一套固定流程——跑测试、构建镜像、部署到测试环境、跑集成测试、部署到生产。

这些步骤每次都要手把手教 AI,累不累?

Skills 就是来解决这个问题的。 它把领域特定的知识和工作流打包成 Agent 可以调用的技能包。

3.2 特点

  • 可移植:兼容所有支持 Agent Skills 标准的 AI 工具
  • 渐进式加载:Agent 按需加载资源
  • 可版本控制:作为文件存在,可以 Git 管理

3.3 目录结构

.agents/skills/
└── deploy-app/
    ├── SKILL.md
    ├── scripts/
    └── references/

3.4 怎么调用 Skills

  • 自动触发:Agent 根据对话上下文判断
  • 手动调用:在 Chat 中输入 /技能名

3.5 Rules vs Skills 怎么选?

场景 用什么
每次对话都需要遵守的编码规范 Rules
需要执行脚本的复杂任务流程 Skills
可复用的跨项目专项能力 Skills

💡 心得:我是先从 Rules 开始的,后来发现某些流程反复出现,就封装成了 Skills。比如我们团队的"发版流程",现在喊一声 /deploy 就搞定,省老鼻子事儿了。


四、.cursorignore:控制 AI 的视野范围

4.1 为什么要用 .cursorignore

Cursor 打开项目时会自动索引代码库。

但有些文件你不希望 AI 触碰——凭据、密钥、超大生成文件。

.cursorignore 就是这道防火墙。

相当于告诉 AI:"这些文件你别看,别问,别碰。"

4.2 语法规则

和 .gitignore 语法完全一样:

# 屏蔽特定文件
config/secrets.json

# 屏蔽整个目录
private/vendor/

# 按扩展名屏蔽
*.key
*.pem

4.3 全局忽略规则

在 Cursor Settings 中可以设置全局忽略规则,对所有项目生效。

💡 心得:之前没注意,有次让 AI 帮我重构代码,它把 node_modules 也搜进去了——整个项目直接卡死。加上 .cursorignore 后,世界清静了。


五、MCP:扩展 Agent 的能力边界

5.1 我以前的困扰

默认情况下,Cursor Agent 可以读写代码,执行终端命令、搜索网页。

但我想让它帮我——

  • 看看 Figma 设计稿长什么样
  • 查一下数据库现在什么结构
  • 更新 Jira 的 Issue 状态

臣妾做不到啊!

5.2 MCP 是什么

MCP 打破了这个边界——让 Agent 连接到任意外部系统:数据库、设计工具、项目管理平台。

说人话:以前 AI 是个"聋子瞎子",只能看代码;现在它长了"耳朵眼睛",能自己去看设计稿、去查数据库、去更新 Jira。

MCP(Model Context Protocol)就是一个"连接协议"——相当于 AI 和外部工具之间的翻译官。

5.3 怎么安装 MCP Server

方式一:一键安装(推荐)

访问 Cursor Marketplace,点击 Server 的「Add to Cursor」按钮。

方式二:手动配置 mcp.json

在项目根目录创建 .cursor/mcp.json

⚠️ 密钥永远不要硬编码,使用环境变量传入!

5.4 常用 MCP 示例

  • Figma MCP:让 Agent 直接读取 Figma 设计文件
  • Linear MCP:Agent 可以直接读 Issue、更新状态
  • 数据库 MCP:让 Agent 查询数据库 schema

💡 心得:接上 Figma MCP 之后,我前端页面开发效率翻倍。之前要反复对比设计稿和代码,现在直接让 AI 看图,帮我调样式——简直不要太爽。


六、Agent 工具详解:终端、浏览器、搜索

6.1 Terminal 工具

Agent 不仅仅是个"写代码的工具",它有一套完整的行动能力。

你可以理解为:AI 不仅能帮你写代码,还能帮你跑代码。

Agent 可以直接在你的终端里执行 Shell 命令——运行测试、安装依赖、执行构建。

沙箱保护机制

默认情况下,终端命令运行在受限沙箱中,相当于有个安全带:

访问类型 默认策略
文件读取 允许整个文件系统
文件写入 只允许工作区目录
网络访问 默认阻止,可配置

可以在 Settings > Agents > Auto-Run 中配置:

  • Run in Sandbox:自动在沙箱运行(推荐)
  • Ask Every Time:每条命令都手动确认

6.2 Browser 工具

Agent 可以控制一个完整浏览器:截图、点击、填表单、读 console 日志。

说白了就是:AI 可以自己开浏览器操作网页。

核心能力:

  • Navigate:访问 URL
  • Click / Type:与按钮、表单交互
  • Screenshot:截图
  • Console Output:读 JS 错误

还内置了设计侧边栏,直接可视化调整元素。

💡 心得:Browser 工具是我用过最香的功能之一。之前调样式要在浏览器和编辑器之间来回切换,现在直接让 AI 帮我调,它自己打开浏览器看效果,不满意就改——我只需要最后验收就行。

6.3 Web Search 工具

当使用 @web 时,Agent 会触发网络搜索。

它不只是返回链接,而是读取页面内容后提取关键信息。

相当于 AI 帮你看网页、总结内容,而不是丢一堆链接让你自己去看。


七、Subagents:多代理协作完成复杂任务

7.1 什么时候用 Subagents

当任务足够复杂——需要大量代码探索、并行处理多个模块——单个 Agent 会遇到上下文窗口限制。

就像一个人同时做很多事会手忙脚乱,AI 也一样。

Subagents 是 Cursor 对这个问题的解答。

7.2 Subagent 机制

Subagent 本质上是父 Agent 可以委托任务的专属 AI 助手。

你可以理解为:派几个小助手出去干活,各有各的分工,最后给你汇总。

每个 Subagent:

  • 拥有独立上下文窗口
  • 接收父 Agent 传入的任务描述
  • 可以配置独立模型

7.3 两种运行模式

模式 行为 适用场景
Foreground 阻塞等待 需要依赖输出的顺序任务
Background 立即返回 长耗时任务、并行工作流

7.4 三个内置 Subagent

  • Explore:搜索和分析代码库
  • Bash:执行 Shell 命令
  • Browser:控制浏览器

7.5 最佳实践

✅ 每个 Subagent 职责单一

✅ description 字段决定自动委托效果

✅ 提交到 Git:让整个团队受益

✅ 从少量开始:先建 2-3 个针对性强的

💡 心得:大型重构的时候,Subagents 简直救命。之前要一个个文件手动处理,现在分工明确——一个读代码理解业务,一个写新功能,一个跑测试——几分钟就干完以前要一下午的活。


八、Cursor CLI:在命令行中使用 AI

8.1 CLI 是什么

以前你用 Cursor,是不是都得打开编辑器?

但有时候我就是想在终端里直接让 AI 干活,不想开图形界面——太慢了。

Cursor CLI 就是让你在终端里用 AI。

不用打开 VS Code,直接在命令行就能让 AI 帮你干活。

8.2 安装

curl https://cursor.com/install -fsS | bash

8.3 交互模式

# 启动交互会话
agent

# 带初始 Prompt
agent "把认证模块重构为 JWT 方式"

8.4 Headless 模式

在脚本或 CI 中用 -p / --print 参数:

# 只提建议
agent -p "这个代码库是做什么的?"

# 允许修改文件
agent -p --force "将这个文件重构为 ES6+ 语法"

⚠️ 注意:-p 模式下默认只读,加上 --force 才会真正写入文件。

💡 心得:CLI 我主要用在 CI 里。每次 PR 提交后自动跑一遍代码审查,省了一个同事的工作量——开玩笑的,至少省了他 30% 的时间。


小结

这篇文章覆盖了让 Cursor "更懂你"的完整体系:

模块 解决的问题
上下文管理 让 AI 看到正确的代码,不多不少
Rules 把团队规范固化为 AI 的持久记忆
Skills 将重复流程打包为可复用能力
.cursorignore 保护敏感文件
MCP 连接外部系统
Agent 工具 用终端、浏览器、搜索形成行动闭环
Subagents 拆解复杂任务,并行执行
CLI 把 AI 能力延伸到脚本和 CI/CD

这些功能不是孤立的——一个成熟的 工作流 可能是:

.cursorignore 保护敏感文件 → Rules 定义项目规范 → Skills 封装部署流程 → MCP 连接 Linear 和数据库 → Subagents 并行处理大型重构 → 最后 CLI 把 AI Review 集成进 PR 流水线。

从最需要的地方开始,逐步搭建属于你的 AI 协作工作流。


下一步

前两篇讲的都是个人使用。第三篇我们聊聊怎么在团队里用好 Cursor。

第三篇预告:团队协作与场景实战

  • GitHub / GitLab 集成
  • Cloud Agent:让 AI 在云端跑任务
  • 团队管理
  • 前端工作流实战
  • Python / 数据分析实战
  • 用 AI 写文档和测试

好了,这篇就先到这里。

觉得有帮助的话,点个赞收藏一下,后续更新也能第一时间看到~

有问题欢迎在评论区问我,咱们下篇见!

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

Docsify 文档缓存问题终极解决方案:拦截请求自动添加版本号

作者 Carsene
2026年3月26日 09:58

📋 问题背景

在使用 Docsify 搭建文档网站时,你是否遇到过这样的情况:明明已经更新了文档内容,但用户刷新页面后看到的仍然是旧内容

这是一个典型的浏览器缓存问题,困扰着许多 Docsify 用户。本文将深入分析问题原因,并提供一个终极解决方案。

🔍 问题分析

缓存问题的根源

  1. 浏览器缓存机制:浏览器会缓存静态资源(包括 .md 文件)以提高性能
  2. CDN 缓存:如果使用 CDN 加速,CDN 节点也会缓存文件
  3. 缓存失效机制:默认情况下,浏览器只有在资源过期或 URL 变化时才会重新请求

常见解决方案的局限性

方案 优点 缺点
手动清除缓存 简单直接 用户体验差,需要用户操作
Nginx 设置 no-cache 服务器端控制 需要服务器配置权限,影响所有资源
文件名加 hash 彻底解决 需要构建工具支持,增加部署复杂度
URL 加时间戳 简单有效 每次都重新加载,影响性能
只加版本号 版本管理清晰 同一版本内更新不生效

🎯 最佳实践:版本号 + 时间戳组合方案

核心思路

拦截 Docsify 的 MD 文件请求,自动添加版本号 + 时间戳参数

README.md → README.md?v=1.1.0_202603260852

设计理念

  • 版本号:用于大版本更新,保证版本管理清晰
  • 时间戳:用于同一版本内的频繁更新,确保缓存及时失效
  • 组合使用:兼顾版本管理和缓存控制的需求

技术实现

方案 A:内联代码(适合小型项目)

优点:简单直接,无需额外文件 缺点:版本信息分散,不便于自动化

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>文档标题</title>
  <!-- 其他 head 内容 -->
</head>
<body>
  <div id="app"></div>
  
  <!-- 版本控制和请求拦截 -->
  <script>
    // 版本控制配置
    const DOC_VERSION = '1.1.0';  // 版本号
    const DOC_TIMESTAMP = '202603260852';  // 时间戳
    const DOC_CACHE_KEY = DOC_VERSION + '_' + DOC_TIMESTAMP;
    
    // 重写 XMLHttpRequest
    const originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url) {
      let modifiedUrl = url;
      if (typeof url === 'string' && url.endsWith('.md') && !url.includes('v=')) {
        const separator = url.includes('?') ? '&' : '?';
        modifiedUrl = url + separator + 'v=' + DOC_CACHE_KEY;
      }
      // 使用 arguments 传递所有参数,避免参数丢失
      arguments[1] = modifiedUrl;
      return originalOpen.apply(this, arguments);
    };
    
    // 重写 fetch
    if (window.fetch) {
      const originalFetch = window.fetch;
      window.fetch = function(input, init) {
        let url = input;
        if (typeof input === 'string' && input.endsWith('.md') && !input.includes('v=')) {
          const separator = input.includes('?') ? '&' : '?';
          url = input + separator + 'v=' + DOC_CACHE_KEY;
        }
        return originalFetch.call(this, url, init);
      };
    }
  </script>
  
  <!-- Docsify 配置 -->
  <script>
    window.$docsify = {
      // 你的 docsify 配置
    }
  </script>
  
  <!-- 加载 Docsify -->
  <script src="//cdn.bootcdn.net/ajax/libs/docsify/4.13.1/docsify.min.js"></script>
</body>
</html>

方案 B:version.js 文件(推荐,适合中大型项目)

优点:集中管理,便于自动化,团队协作友好 缺点:需要处理自身缓存,多一个文件

步骤 1:创建 version.js 文件
// version.js
// 文档版本控制配置
const DOC_VERSION = '1.1.0';  // 版本号:发布新版本时更新
const DOC_TIMESTAMP = '202603260852';  // 时间戳:同一版本内更新时修改
const DOC_CACHE_KEY = DOC_VERSION + '_' + DOC_TIMESTAMP;

// 重写 XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
  let modifiedUrl = url;
  if (typeof url === 'string' && url.endsWith('.md') && !url.includes('v=')) {
    const separator = url.includes('?') ? '&' : '?';
    modifiedUrl = url + separator + 'v=' + DOC_CACHE_KEY;
  }
  arguments[1] = modifiedUrl;
  return originalOpen.apply(this, arguments);
};

// 重写 fetch
if (window.fetch) {
  const originalFetch = window.fetch;
  window.fetch = function(input, init) {
    let url = input;
    if (typeof input === 'string' && input.endsWith('.md') && !input.includes('v=')) {
      const separator = input.includes('?') ? '&' : '?';
      url = input + separator + 'v=' + DOC_CACHE_KEY;
    }
    return originalFetch.call(this, url, init);
  };
}
步骤 2:加载 version.js(解决自身缓存问题)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>文档标题</title>
  <!-- 其他 head 内容 -->
</head>
<body>
  <div id="app"></div>
  
  <!-- 加载版本配置和请求拦截 -->
  <script>
    // 动态创建 script 标签加载 version.js,添加时间戳防止缓存
    const script = document.createElement('script');
    script.src = 'version.js?v=' + Date.now();
    document.body.appendChild(script);
  </script>
  
  <!-- Docsify 配置 -->
  <script>
    window.$docsify = {
      // 你的 docsify 配置
    }
  </script>
  
  <!-- 加载 Docsify -->
  <script src="//cdn.bootcdn.net/ajax/libs/docsify/4.13.1/docsify.min.js"></script>
</body>
</html>

⚠️ 技术陷阱与解决方案

陷阱 1:页面空白,内容不显示

问题原因:重写 XMLHttpRequest 时参数丢失

错误代码

// ❌ 错误:显式声明参数,导致额外参数丢失
XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
  return originalOpen.call(this, method, modifiedUrl, async, user, password);
}

解决方案

// ✅ 正确:使用 arguments 传递所有参数
XMLHttpRequest.prototype.open = function(method, url) {
  let modifiedUrl = url;
  // ... 修改 url
  arguments[1] = modifiedUrl;  // 只修改 url 参数
  return originalOpen.apply(this, arguments);  // 传递完整参数列表
}

陷阱 2:只重写 fetch 无效

问题原因:Docsify 4.x 默认使用 XMLHttpRequest

解决方案:同时重写 XMLHttpRequest 和 fetch,确保兼容性

陷阱 3:version.js 自身缓存

问题原因:version.js 文件本身也会被浏览器缓存

解决方案:使用动态脚本加载,添加时间戳参数

script.src = 'version.js?v=' + Date.now();

陷阱 4:执行顺序错误

问题原因:在 Docsify 加载后才重写请求方法

解决方案:在 Docsify 加载前重写请求方法

📊 效果验证

使用前

请求:README.md
状态:200 OK (from disk cache)
结果:显示旧内容

使用后

请求:README.md?v=1.1.0_202603260852
状态:200 OK
结果:显示最新内容

🚀 实际应用指南

方案选择

项目规模 推荐方案 原因
小型项目 内联代码 简单直接,无需额外文件
中大型项目 version.js 集中管理,便于自动化

更新流程

场景 1:发布新版本

  1. 修改版本号(如 1.1.0 → 1.2.0)
  2. 重置时间戳

场景 2:同一版本内更新

  1. 保持版本号不变
  2. 更新时间戳(如 202603260852 → 202603261000)

自动化配置

创建 GitHub Actions 工作流,自动更新时间戳:

name: Update Doc Version

on:
  push:
    paths:
      - 'docs/**/*.md'
    branches:
      - main

jobs:
  update-version:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Update timestamp
        run: |
          TIMESTAMP=$(date +%Y%m%d%H%M)
          sed -i "s/const DOC_TIMESTAMP = '.*'/const DOC_TIMESTAMP = '$TIMESTAMP'/" docs/zh/version.js
          sed -i "s/const DOC_TIMESTAMP = '.*'/const DOC_TIMESTAMP = '$TIMESTAMP'/" docs/en/version.js
      
      - name: Commit changes
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"
          git add docs/zh/version.js docs/en/version.js
          git commit -m "chore: auto-update doc timestamp" || echo "No changes"
          git push

🎓 技术深度解析

1. XMLHttpRequest 重写原理

XMLHttpRequest.prototype.open 是一个原型方法,我们通过修改它来拦截所有 XHR 请求。使用 arguments 对象可以保留所有原始参数,避免参数丢失。

2. 为什么需要同时重写 fetch?

  • 兼容性:不同浏览器和 Docsify 版本可能使用不同的请求方式
  • 未来-proof:现代浏览器和未来版本的 Docsify 可能更多使用 fetch
  • 完整性:确保所有 MD 文件请求都被拦截

3. 版本号 + 时间戳的设计哲学

  • 版本号:提供语义化的版本管理,便于团队协作和问题追踪
  • 时间戳:提供细粒度的缓存控制,确保及时更新
  • 组合使用:在性能和更新及时性之间取得最佳平衡

4. 性能影响评估

  • 正面影响:同一版本内的重复访问会使用缓存,提高性能
  • 负面影响:版本更新时会重新加载所有 MD 文件
  • 总体评估:利大于弊,用户体验得到显著改善

📝 最佳实践总结

  1. 选择合适的方案:根据项目规模选择内联代码或 version.js
  2. 正确重写请求方法:使用 arguments 传递所有参数
  3. 同时支持两种请求方式:重写 XMLHttpRequest 和 fetch
  4. 处理 version.js 自身缓存:使用动态加载 + 时间戳
  5. 配置自动化更新:集成 GitHub Actions 自动更新时间戳
  6. 监控和测试:定期检查缓存效果,确保方案有效

🔧 代码优化建议

1. 错误处理

// 增强版:添加错误处理
XMLHttpRequest.prototype.open = function(method, url) {
  try {
    let modifiedUrl = url;
    if (typeof url === 'string' && url.endsWith('.md') && !url.includes('v=')) {
      const separator = url.includes('?') ? '&' : '?';
      modifiedUrl = url + separator + 'v=' + DOC_CACHE_KEY;
    }
    arguments[1] = modifiedUrl;
    return originalOpen.apply(this, arguments);
  } catch (error) {
    console.error('[AutoScan Docs] XHR rewrite error:', error);
    return originalOpen.apply(this, arguments);
  }
};

2. 配置灵活性

// 增强版:可配置的文件扩展名
const CACHE_CONFIG = {
  extensions: ['.md', '.markdown'],
  version: '1.1.0',
  timestamp: '202603260852'
};

const DOC_CACHE_KEY = CACHE_CONFIG.version + '_' + CACHE_CONFIG.timestamp;

// 检查文件扩展名
const shouldAddVersion = (url) => {
  return CACHE_CONFIG.extensions.some(ext => url.endsWith(ext));
};

🎯 最终效果

通过本文介绍的方案,你可以:

  • ✅ 彻底解决 Docsify 文档缓存问题
  • ✅ 确保用户始终看到最新的文档内容
  • ✅ 保持良好的性能和用户体验
  • ✅ 便于版本管理和团队协作
  • ✅ 支持自动化更新流程

无论是个人项目还是企业级应用,这个方案都能为你提供一个可靠、高效的 Docsify 文档缓存解决方案。


相关资源


希望本文对你解决 Docsify 文档缓存问题有所帮助!如果你有任何问题或建议,欢迎在评论区留言。🎉

一份合格的软件 VI 文字文档简单版

作者 LeonGao
2026年3月26日 09:57

0、先破后立:别把软件 VI 当“改个 Logo、换套颜色”,那只是皮;真正的 VI 是让产品长期“长得一致、说得一致、做得一致”。

很多团队写 VI,写着写着就变成“视觉资产打包”:给几个色值、放几张示例图,然后结束。结果是页面越做越多,风格越漂越远;新同事接手时靠猜,外包交付时靠运气。合格的软件 VI 文字文档,目标很直白:降低沟通成本,减少返工,让每一次新增页面和每一次改版都更可控
下面这份“简单版”,按工程口径写:交付、可控、复现、成本、安全,再加一个“品牌一致性”(因为 VI 不讲这个就失焦)。每段只讲能落地的内容。


1、交付:一份合格的 VI 文档,第一要件是“能直接拿去做”,而不是“看完感觉对”。

文档里要把交付件列死,别含糊。最少包含这些条目:

  1. 品牌一句话:产品是什么、给谁用、想让人记住什么气质(3 行以内)。
  2. 命名规则:产品名、模块名、功能名怎么写;中英文、大小写、空格、连字符统一口径。
  3. 基础视觉资产:Logo 规范(留白、最小尺寸、禁用示例)、主辅色、字体与字号层级、图标风格一句话定义。
  4. 核心组件口径:按钮、输入框、表单、弹窗、表格、通知、空状态、加载态、错误态——每个给“何时用 + 不要怎么用”。
  5. 文案基调:提示语、报错、确认弹窗的语气;能不能用感叹号,能不能用俚语,默认怎么称呼用户。
  6. 输出形式:PDF/在线文档链接/设计稿入口/组件库入口/更新日志入口,一次交齐。

写 VI 不怕短,怕“交付不完整”。只要能让别人照着做,就合格了一半。


2、可控:VI 不是审美表达,是约束系统;约束越清楚,风格越不跑偏。

可控的关键是“边界”。你要明确哪些能改,哪些不能动:

  • 固定项:Logo 使用、主色、字体体系、间距网格、圆角、阴影、图标线宽等,写成“禁止项 + 例外条件”。
  • 可变项:活动页允许更活泼?营销位能用渐变?插画能不能换风格?把弹性范围写清。
  • 优先级规则:冲突时听谁的——可用性优先还是品牌优先?无障碍优先还是视觉优先?给一句硬规则,别让团队现场吵架。
  • 审查口径:什么程度需要设计 review,什么程度产品经理就能放行;把门槛写出来。

一句话:可控的 VI,是把“我觉得”变成“按规则”。


3、复现:合格的 VI 文档要能“复刻同一种效果”,让新人和外包也能做出同款。

复现靠的是“步骤”和“示例”,而不是长篇概念。建议写三类最有用的东西:

  • 页面模板:后台列表页、详情页、表单页、移动端信息流页,各给一个骨架:栅格、边距、标题区、操作区、信息区怎么排。
  • 状态全家桶:成功/失败/警告/处理中/禁用/空/无权限/网络错误——每种状态的颜色、图标、文案、按钮策略写清。
  • Do / Don’t:每个关键点给 1–2 个正例和反例,反例越具体越省时间。

复现的判断标准很简单:把文档扔给一个没参与项目的人,他能不能做出和你们差不多的页面。


4、成本:VI 文档的价值不在“写得多”,在“减少重复劳动、减少返工”。

成本控制靠三件事:

  1. 组件优先:先规定组件,再谈页面。页面是组件的组合,组件稳定,页面就不会乱。
  2. 最小可用集:别一口气把所有场景写全,先把 80% 高频场景写到能用:按钮、表单、表格、弹窗、提示、空状态。其余用“扩展条款”挂着。
  3. 版本与变更机制:写清楚谁维护、多久更新一次、如何提改动、如何废弃旧规范。没有变更机制的 VI,半年就变成古董。

一句话:VI 写得越像“团队公共资产”,后期越省钱。


5、安全:软件 VI 也有安全线,尤其是“信息展示”和“误操作”两类雷。

很多人以为安全只在后端,其实界面一样能埋坑。VI 文档至少要写这些底线:

  • 敏感信息展示规则:手机号、身份证、邮箱、金额、定位、密钥类字段,默认如何脱敏;什么场景允许全量展示;截图风险怎么提示。
  • 权限与不可见:无权限是“隐藏入口”还是“展示但置灰”,不同产品不同策略,但必须统一。
  • 高危操作样式:删除、清空、解绑、转账、发布、回滚等,危险按钮颜色、二次确认文案、默认焦点放哪、是否需要输入校验(如输入“DELETE”)写清。
  • 错误信息口径:前台提示要对用户友好,后台日志要对排查友好;哪些信息不能在前台暴露(例如内部路径、表名、堆栈片段)。

一句话:VI 不是只管好看,也要管“不出事”。


6、品牌一致性:软件 VI 的终点是“用户认得出这是你”,不靠花哨,靠一致。

品牌一致性落到文字文档里,最有效的是把“气质”翻译成可执行规则:

  • 语气词典:哪些词常用、哪些词禁用;同义词选哪一个(例如“保存/提交/确认”统一用哪个)。
  • 信息层级:主标题怎么写,副标题怎么写,按钮文案用动词还是名词;长度上限建议。
  • 插画与动效:能不能用拟物、能不能用夸张表情、动效节奏偏快还是偏稳;动效使用场景边界写清。
  • 跨端一致:Web、iOS、Android、桌面端是否同一套组件口径;差异允许在哪些点出现(如导航样式、系统字体)。

一句话:一致性不是“都一样”,而是“变化有规则”。


快速测评清单(拿这张表,你就能自己验收 VI 文档合不合格)

  1. 交付完整度:是否明确列出交付件清单,并能一键找到入口(文档、设计稿、组件库、更新日志)。
  2. 可控性:是否写清固定项/可变项/例外条件/审查门槛,团队争议点能否按规则裁决。
  3. 复现能力:给新人 2 小时,他能否按文档做出列表页+表单页,并且样式接近现有产品。
  4. 返工概率:同一页面改三次后,是否还在改“风格不对”而不是改“需求变化”。
  5. 组件覆盖率:按钮/表单/表格/弹窗/通知/空状态/错误态是否齐全,缺口是否有扩展条款承接。
  6. 安全底线:脱敏、高危操作、权限态、错误提示是否有统一口径,并给出示例文案。
  7. 跨端一致:多端差异是否被允许且被记录,还是靠各端“自己理解”。
  8. 可维护性:是否有版本号、维护人、变更流程、废弃策略;能否追溯每次改动原因。
  9. 可读性:是否短句为主、规则清晰、示例够用;读者扫一遍能抓住要点。
  10. 落地结果:随机抽 5 个页面,视觉与文案是否能看出同一套体系,组件是否复用而非手搓。

重写一个「年久失修」的开源项目:把 jQuery + CoffeeScript 的 3D 户型图工具迁移到 TypeScript + Three.js r181

作者 Linncharm
2026年3月26日 09:56

起因

前段时间做项目需要一个「能在浏览器里跑的 3D 平面图编辑器」。调研了一圈, 发现市面上几乎没有现代化的开源方案。

倒是找到了一个叫 blueprint3d 的项目,功能设计得挺完整:2D 平面图编辑 + 3D 实时预览 + 家具拖拽摆放 + 墙面纹理。但打开一看——

  • 构建工具:Grunt + Bower
  • 语言:CoffeeScript
  • DOM 操作:jQuery
  • Three.js:使用的是早已废弃的 Geometry / Face3 API
  • 最近一次有效维护:好几年前

直接用是不可能的,改也很难改。于是决定从头重写一遍,顺手开源出来。

重写版地址:github.com/charmlinn/b…


整体架构

项目分两层:

blueprint3d-modern/
├── src/         # 核心库:纯 TypeScript ES Module,不依赖任何框架
└── app/         # Demo 应用:Next.js 15,消费核心库

这种分层的好处是核心库可以独立使用,不绑定 React 或任何框架。


核心库重写(src/)

1. 废弃 API 迁移

原版大量使用了 Three.js 的旧 API,迁移的主要工作量在这里。

Geometry → BufferGeometry

原版:

const geometry = new THREE.Geometry()
geometry.vertices.push(new THREE.Vector3(...))
geometry.faces.push(new THREE.Face3(0, 1, 2))

新版:

const geometry = new THREE.BufferGeometry()
const vertices = new Float32Array([...])
geometry.setAttribute('position', 
  new THREE.BufferAttribute(vertices, 3))

BufferGeometry 直接操作类型化数组,内存布局更紧凑,GPU 上传效率更高。 对于户型图这种需要频繁更新墙体几何体的场景,性能差异是可感知的。

贴图 UV 映射

墙面和地板的纹理 UV 在迁移后需要重新计算,原来基于 Face3 的 faceVertexUvs 全部改成了 BufferGeometry 的 uv attribute:

geometry.setAttribute('uv',
  new THREE.BufferAttribute(new Float32Array(uvs), 2))

2. 动画:tween.js → anime.js v4

原版用 tween.js 做相机切换动画。anime.js v4 的 API 更现代, 支持 Promise,方便配合 async/await 控制动画时序:

import { animate } from 'animejs'

await animate(camera.position, {
  x: target.x,
  y: target.y, 
  z: target.z,
  duration: 600,
  easing: 'easeInOutQuad'
})

3. Item 系统:Factory 模式加载 OBJ 模型

家具 Item 按放置位置分为几种子类型:

类型 说明
FloorItem 落地家具:床、沙发、桌子
WallItem 挂墙家具:挂画、电视
InWallItem 嵌墙物件:门、窗
CeilingItem 吸顶:灯具

加载流程:

// 每个家具有一个 metadata JSON
{
  "itemName": "Single Bed",
  "itemType": 1,        // 1 = FloorItem
  "modelUrl": "/models/bed.obj",
  "materialUrl": "/models/bed.mtl"
}

// Factory 根据 itemType 实例化对应子类
class Factory {
  static async loadItem(metadata: ItemMetadata, scene: THREE.Scene) {
    const obj = await loadOBJMTL(metadata.modelUrl, metadata.materialUrl)
    switch (metadata.itemType) {
      case ItemType.FloorItem: return new FloorItem(obj, scene)
      case ItemType.WallItem:  return new WallItem(obj, scene)
      // ...
    }
  }
}

Raycasting 选中逻辑也按子类型做了区分,WallItem 只响应打在墙面上的 射线,FloorItem 只响应打在地面上的,避免误选。


4. 2D 平面图编辑器

这部分是纯 Canvas 2D,不用 Three.js,独立于 3D 渲染器。

核心数据结构是 Corner-Wall 图:

class Floorplan {
  corners: Corner[]   // 墙角顶点
  walls: Wall[]       // 连接两个 Corner 的边
  rooms: Room[]       // 由 Wall 围合而成的面
}

Wall 从两个 Corner 派生出几何体,Room 用多边形填充算法从封闭的 Wall 环 中计算得出。每次 Corner 移动,相关的 Wall 和 Room 会自动重新计算。

吸附逻辑:

// 新 Corner 在一定距离内自动吸附到已有 Corner
const SNAP_DISTANCE = 20 // px
const nearest = findNearestCorner(mousePos, floorplan.corners)
if (distance(mousePos, nearest) < SNAP_DISTANCE) {
  return nearest.position
}

5. IndexedDB 持久化

原版完全没有存档功能。新版用浏览器原生 IndexedDB 实现, 无需任何后端,无需账号:

// 保存户型
const plan = await blueprintStorage.create({
  name: 'My Bedroom',
  roomType: 'bedroom',
  layoutData: blueprint.model.exportSerialized(), // JSON 序列化
  thumbnailBase64: canvas.toDataURL()             // canvas 截图作为缩略图
})

// 读取列表
const plans = await blueprintStorage.list()

// 加载
const plan = await blueprintStorage.get(id)
blueprint.model.loadSerialized(plan.layoutData)

Demo 应用(app/)

核心库搞定之后,Demo 应用主要是 UI 工程。

技术栈:

技术
框架 Next.js 15 App Router
UI Runtime React 19
样式 Tailwind CSS v4
组件 shadcn/ui + Radix UI
状态 Zustand
动画 Framer Motion
i18n next-intl(en / zh / tw)

踩坑记录

坑 1:Three.js 不能跑在服务端

App Router 下组件默认是 Server Component,Three.js 依赖 window / document,直接 import 会在 SSR 阶段报错。

解法:顶层组件用 dynamic 包裹:

const Blueprint3DApp = dynamic(
  () => import('./Blueprint3DAppBase'),
  { ssr: false }
)

坑 2:monorepo 下 Three.js ESM 路径解析错误

核心库在 src/,Demo 在 app/,两个目录各有自己的 node_modulesthree/examples/jsm 的 import 会解析到错误的版本,导致类型不匹配。

解法:在 next.config.js 里加 webpack alias 强制指向同一份:

config.resolve.alias['three/examples/jsm'] = 
  path.resolve(__dirname, '../node_modules/three/examples/jsm')

坑 3:Canvas ref 初始化时序

Three.js 需要拿到真实 DOM 的 canvas 元素才能初始化。 在 React 里必须在 useEffect 里做,不能在渲染阶段:

useEffect(() => {
  if (!canvasRef.current) return
  const blueprint = new Blueprint3d({
    threeContainer: canvasRef.current,
    floorplanContainer: floorplanRef.current,
    textureDir: '/textures/'
  })
  blueprintRef.current = blueprint
  return () => blueprint.destroy()
}, []) // 空依赖,只跑一次

还没做的

  • GLB / GLTF 模型支持(目前只有 OBJ/MTL)
  • 撤销 / 重做历史栈
  • 核心库发布到 npm
  • 墙体厚度独立配置
  • 多房间 / 多楼层

欢迎 PR,也欢迎在评论区讨论 Three.js 相关的问题。


相关链接

Vite 插件开发入门:从零写一个自动生成路由的插件

2026年3月26日 09:55

Vite 插件开发入门:从零写一个自动生成路由的插件

上周接手了一个中后台项目,200 多个页面,router/index.ts 写了 1800 行。每次新建页面都得手动往路由表里加一条记录,路径拼错了不报错,组件引用写错了要等构建阶段才能发现。整个团队每周至少因为路由配置出一次线上事故。

Nuxt 和 Next.js 都有基于文件系统的自动路由,Vite 生态里也有 vite-plugin-pages 这类方案。但我们项目的路由规则比较特殊:有权限前缀、有多 layout 嵌套、还有一套自定义的路由元信息约定。现成插件的扩展能力撑不住这些需求,硬改源码的成本比自己写还高。

这篇文章是那次从零开发插件的完整复盘,从最小插件结构讲到 HMR 支持和嵌套路由,踩的坑都会具体说明。写完这个插件之后,我对 Vite 的插件机制理解明显深了一截。

自动路由插件的核心思路

动手写代码之前,先把需求拆解清楚。这个插件要做三件事:扫描 src/pages/ 下的所有 .vue 文件,根据文件路径推导出路由配置,然后让业务代码能通过 import routes from 'virtual:auto-routes' 直接使用生成的路由表。

这三件事分别对应 Vite 插件的三个核心能力:文件监听代码生成虚拟模块

虚拟模块是怎么工作的

虚拟模块是 Vite 插件开发中最常用的模式。所谓"虚拟",指的是这个模块不存在于磁盘上,它的内容由插件在运行时动态生成。业务代码写 import routes from 'virtual:auto-routes',其实磁盘上根本没有这个文件——是插件在 resolveIdload 两个钩子里"凭空捏造"了它。

const VIRTUAL_MODULE_ID = 'virtual:auto-routes'
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID

export default function autoRoutes(): Plugin {
  return {
    name: 'vite-plugin-auto-routes',
    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
    },
    load(id) {
      if (id === RESOLVED_ID) {
        return `export default [{ path: '/', component: () => import('/src/pages/index.vue') }]`
      }
    }
  }
}

resolveId 负责"认领"模块 ID,load 负责返回模块内容,两者是固定搭配。那个 \0 前缀是 Rollup 的约定:以 \0 开头的模块 ID 不会被文件系统解析,其他插件看到这个前缀也会主动跳过,避免冲突。

我第一次写的时候忘了加 \0 前缀,结果在 vite-plugin-inspect 里死活看不到虚拟模块的输出,排查了大半个小时才在 Rollup 文档里翻到这条约定。

上面这个硬编码的例子只是为了演示虚拟模块的最小结构。接下来要做的事情才是插件的核心——扫描文件、生成路由表、处理嵌套关系。

扫描文件并转换为路由路径

第一步是用 fast-glob 扫描 src/pages/ 目录下所有 .vue 文件,然后把文件路径转换成路由 path。转换规则和 Nuxt 类似:index.vue 对应 /[id].vue 对应 /:id[...all].vue 对应 /:all(.*)*

import fg from 'fast-glob'
import path from 'path'

function scanPages(pagesDir: string) {
  const files = fg.sync('**/*.vue', {
    cwd: pagesDir,
    onlyFiles: true,
    ignore: ['**/components/**', '**/_*'],  // 排除组件目录和下划线前缀文件
  })

  return files.map(file => {
    // 统一为 posix 路径
    const filePath = file.replace(/\\/g, '/')
    // 去掉 .vue 后缀
    let routePath = filePath.replace(/\.vue$/, '')
    // index 文件映射为目录路径
    routePath = routePath.replace(/\/index$/, '') || '/'
    // [param] -> :param(动态路由)
    routePath = routePath.replace(/\[([^\]\.]+)\]/g, ':$1')
    // [...param] -> :param(.*)*(兜底路由)
    routePath = routePath.replace(/\[\.\.\.([^\]]+)\]/g, ':$1(.*)*')
    // 确保以 / 开头
    if (!routePath.startsWith('/')) routePath = '/' + routePath

    return {
      filePath: path.posix.join(pagesDir, filePath),
      routePath,
      rawFile: filePath,
    }
  })
}

举个具体例子,假设 src/pages/ 下有这些文件:

src/pages/
├── index.vue              → /
├── about.vue              → /about
├── users/
│   ├── index.vue          → /users
│   ├── [id].vue           → /users/:id
│   └── [id]/
│       └── settings.vue   → /users/:id/settings
└── [...404].vue           → /:404(.*)*

构建嵌套路由树

扫描得到的是一个扁平列表,但 Vue Router 需要的是树形结构——/users/:id/settings 应该嵌套在 /users/:id 下面,而 /users/:id 又嵌套在 /users 下(前提是 users/ 目录下有对应的 layout 文件)。

嵌套路由的判定规则是:如果一个路径存在同名目录,该目录下的文件就成为它的子路由。比如 users.vueusers/ 目录同时存在时,users/ 下的所有页面就是 users.vuechildren

interface RouteNode {
  path: string
  component?: string
  children: RouteNode[]
  meta?: Record<string, any>
}

function buildRouteTree(pages: ReturnType<typeof scanPages>): RouteNode[] {
  const root: RouteNode[] = []
  // 按路径深度排序,确保父路由先被处理
  const sorted = [...pages].sort((a, b) => {
    const depthA = a.routePath.split('/').length
    const depthB = b.routePath.split('/').length
    return depthA - depthB
  })

  // 用 Map 记录已注册的路由节点,key 是 routePath
  const nodeMap = new Map<string, RouteNode>()

  for (const page of sorted) {
    const node: RouteNode = {
      path: page.routePath,
      component: page.filePath,
      children: [],
    }

    // 查找父路由:逐级向上寻找同名 layout 文件
    const segments = page.routePath.split('/').filter(Boolean)
    let inserted = false

    if (segments.length > 1) {
      // 从最近的父级开始向上查找
      for (let i = segments.length - 1; i >= 1; i--) {
        const parentPath = '/' + segments.slice(0, i).join('/')
        const parentNode = nodeMap.get(parentPath)
        if (parentNode) {
          // 子路由的 path 只保留相对部分
          node.path = segments.slice(i).join('/')
          parentNode.children.push(node)
          inserted = true
          break
        }
      }
    }

    if (!inserted) {
      root.push(node)
    }
    nodeMap.set(page.routePath, node)
  }

  return root
}

这里有一个容易踩的坑:排序必须保证父路由先于子路由被处理,否则子路由找不到父节点,会被错误地挂到根级别。我最初用字母序排序,结果 users.vue 排在 users/index.vue 后面,整棵子树都散架了。

把路由树序列化为模块代码

拿到路由树之后,需要把它序列化成 JavaScript 代码字符串,作为虚拟模块的内容返回:

function generateRouteCode(routes: RouteNode[]): string {
  function serialize(node: RouteNode): string {
    const parts: string[] = []
    parts.push(`path: '${node.path}'`)
    if (node.component) {
      parts.push(`component: () => import('${node.component}')`)
    }
    if (node.meta && Object.keys(node.meta).length > 0) {
      parts.push(`meta: ${JSON.stringify(node.meta)}`)
    }
    if (node.children.length > 0) {
      parts.push(`children: [${node.children.map(serialize).join(',\n')}]`)
    }
    return `{ ${parts.join(', ')} }`
  }

  return `export default [${routes.map(serialize).join(',\n')}]`
}

HMR 支持:文件变化时自动更新路由

开发阶段最重要的体验就是新增或删除页面文件后路由自动更新,不需要手动重启 dev server。这需要用到 configureServerhandleHotUpdate 两个钩子。

export default function autoRoutes(options: { pagesDir?: string } = {}): Plugin {
  const pagesDir = options.pagesDir || 'src/pages'
  let rootDir: string

  // 缓存当前路由代码,用于判断是否真的有变化
  let currentRouteCode: string

  function regenerateRoutes() {
    const pages = scanPages(path.resolve(rootDir, pagesDir))
    const tree = buildRouteTree(pages)
    const sorted = sortRoutes(tree)  // 排序逻辑见下文
    return generateRouteCode(sorted)
  }

  return {
    name: 'vite-plugin-auto-routes',

    configResolved(config) {
      rootDir = config.root
    },

    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
    },

    load(id) {
      if (id === RESOLVED_ID) {
        currentRouteCode = regenerateRoutes()
        return currentRouteCode
      }
    },

    // 监听 pages 目录下的文件变化
    configureServer(server) {
      const pagesFullPath = path.resolve(rootDir, pagesDir)

      function handleFileChange(filePath: string) {
        if (!filePath.startsWith(pagesFullPath)) return
        if (!filePath.endsWith('.vue')) return

        const newCode = regenerateRoutes()
        // 只有路由表真正变化时才触发更新,避免无意义的刷新
        if (newCode === currentRouteCode) return
        currentRouteCode = newCode

        const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
        if (mod) {
          server.moduleGraph.invalidateModule(mod)
          server.ws.send({ type: 'full-reload' })
        }
      }

      server.watcher.on('add', handleFileChange)
      server.watcher.on('unlink', handleFileChange)
    },

    // .vue 文件内容变化时,检查 <route> 块是否有修改
    handleHotUpdate({ file, server }) {
      const pagesFullPath = path.resolve(rootDir, pagesDir)
      if (!file.startsWith(pagesFullPath) || !file.endsWith('.vue')) return

      const newCode = regenerateRoutes()
      if (newCode === currentRouteCode) return
      currentRouteCode = newCode

      const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
      if (mod) {
        server.moduleGraph.invalidateModule(mod)
        server.ws.send({ type: 'full-reload' })
      }
    },
  }
}

configureServer 里监听 addunlink 事件处理文件新增和删除;handleHotUpdate 处理文件内容修改(比如 <route> 块里的元信息变了)。两处都做了 newCode === currentRouteCode 的比对——这个判断很关键,没有它的话,任何 .vue 文件的修改都会触发路由模块更新,进而导致全页面 reload,HMR 的细粒度更新优势就全丢了。

解析权限前缀:文件名到路由元信息的映射

前面提到我们项目有一套权限路由命名约定:文件名前缀用 . 分隔,第一段是权限组标识。比如 admin.user-list.vue 表示这个页面属于 admin 权限组,路由路径是 /user-list,同时路由的 meta 里会自动注入 { auth: 'admin' }

这套约定让我们的权限路由完全由文件名驱动,不需要在每个页面里手写 meta,新人建页面时也不容易漏配权限。

function parseFileName(rawFile: string): { routeName: string; meta: Record<string, any> } {
  // rawFile 示例: 'admin.user-list.vue' 或 'dashboard/admin.stats.vue'
  const basename = rawFile.split('/').pop()!.replace(/\.vue$/, '')
  const segments = basename.split('.')

  // 已知的权限组前缀列表,可以从配置文件读取
  const knownAuthGroups = ['admin', 'editor', 'viewer', 'super']

  const meta: Record<string, any> = {}
  let routeName = basename

  if (segments.length > 1 && knownAuthGroups.includes(segments[0])) {
    meta.auth = segments[0]
    // 路由路径只取前缀之后的部分
    routeName = segments.slice(1).join('.')
  }

  return { routeName, meta }
}

实际效果:

文件名 路由路径 meta.auth
admin.user-list.vue /user-list admin
editor.article-edit.vue /article-edit editor
dashboard.vue /dashboard (无前缀,不注入)
admin.settings.vue /settings admin

然后在 scanPages 里调用这个函数,把解析出的 meta 附加到每条路由上:

// 在 scanPages 的 map 回调末尾
const { routeName, meta } = parseFileName(filePath)
return {
  filePath: path.posix.join(pagesDir, filePath),
  routePath: routeName.startsWith('/') ? routeName : '/' + routeName.replace(/\./g, '/'),
  rawFile: filePath,
  meta,
}

路由守卫那边只需要统一检查 to.meta.auth,不用关心权限信息从哪来。整条链路从"建文件"到"鉴权生效"完全自动化。

解析 <route> 自定义块

除了文件名约定,有些路由元信息确实更适合写在 .vue 文件里,比如页面标题、是否缓存、面包屑配置等。我们支持在 .vue 文件中使用 <route> 自定义块来声明这些信息:

<route>
{
  "title": "用户详情",
  "cache": true,
  "breadcrumb": ["用户管理", "用户详情"]
}
</route>

<template>
  <div>用户详情页</div>
</template>

在插件中提取 <route> 块的内容,需要读取 .vue 文件源码并解析:

import fs from 'fs'

function extractRouteBlock(filePath: string): Record<string, any> | null {
  const content = fs.readFileSync(filePath, 'utf-8')
  // 匹配 <route> 块,支持 <route lang="json"> 写法
  const match = content.match(/<route(?:\s[^>]*)?>([^]*?)<\/route>/)
  if (!match) return null

  const raw = match[1].trim()
  if (!raw) return null

  try {
    return JSON.parse(raw)
  } catch (e) {
    console.warn(`[auto-routes] Failed to parse <route> block in ${filePath}:`, e)
    return null
  }
}

然后在路由生成阶段合并两种来源的 meta——文件名前缀提供权限信息,<route> 块提供页面级配置,两者合并后写入路由的 meta 字段:

// 在 buildRouteTree 或 scanPages 中
const fileNameMeta = parseFileName(page.rawFile).meta
const routeBlockMeta = extractRouteBlock(page.filePath)
const mergedMeta = { ...fileNameMeta, ...routeBlockMeta }
// routeBlockMeta 的优先级更高,可以覆盖文件名前缀的约定

最终生成的路由对象类似:

{
  path: '/user-list',
  component: () => import('/src/pages/admin.user-list.vue'),
  meta: { auth: 'admin', title: '用户列表', cache: true }
}

踩坑记录

Windows 路径分隔符

fast-glob 返回的路径统一用 / 分隔,但 path.resolve 在 Windows 上会生成 \ 分隔的路径。如果生成的 import 语句里混入了反斜杠,Vite 直接无法解析模块,页面白屏。

解决方法是所有拼接出来的路径都过一遍 p.replace(/\\\\/g, '/')

路由排序影响匹配优先级

Vue Router 4 的匹配规则是先定义先匹配。如果 /:id 排在 /profile 前面,访问 /profile 时会命中 /:id,参数 id 的值变成字符串 "profile",页面渲染出完全错误的内容。

插件生成路由时的排序逻辑必须保证三个层级:静态路由最先,动态路由其次,兜底路由(包含 (.*) 的)排最后。同一层级内按字母序排列,确保结果稳定可预期。

function sortRoutes(routes: RouteNode[]): RouteNode[] {
  return routes
    .map(route => ({
      ...route,
      children: route.children.length > 0 ? sortRoutes(route.children) : [],
    }))
    .sort((a, b) => {
      const scoreA = getRouteScore(a.path)
      const scoreB = getRouteScore(b.path)
      if (scoreA !== scoreB) return scoreA - scoreB
      // 同级别按字母序,确保排序稳定
      return a.path.localeCompare(b.path)
    })
}

function getRouteScore(path: string): number {
  // 兜底路由排最后
  if (path.includes('(.*)')) return 2
  // 动态路由排中间
  if (path.includes(':')) return 1
  // 静态路由排最前
  return 0
}

实际遇到的一个坑:我们有 /users/profile/users/:id 两个路由,上线后发现所有用户的个人资料页都 404 了——因为最初的排序函数没有递归处理 children,只排了顶层路由,嵌套路由里的顺序完全随机。加上递归排序后问题解决。

开发环境和构建环境的行为差异

开发环境下,虚拟模块的 load 钩子在每次模块请求时都会调用,返回的路由表始终是最新的。构建时 load 只调用一次,结果会被缓存。

这个差异导致了一个隐蔽的 bug:我有一版实现会在 load 里生成一个 .routes.json 缓存文件用于调试,开发环境下每次 HMR 触发都会重写这个文件,文件变化又被 watcher 捕获,再次触发 HMR——形成无限循环,页面疯狂刷新停不下来。把调试文件的输出逻辑从 load 钩子里挪出来,改成手动调用,问题就消失了。

和现有方案的对比

维度 vite-plugin-pages unplugin-vue-router 自己写
路由元信息 <route> 块,YAML/JSON definePage <route> 块,JSON + 文件名前缀
类型安全 需要额外配置 开箱即用,类型自动推导 手动声明 .d.ts
自定义路由规则 有限,靠 extendRoute 回调 较灵活 完全自由
嵌套路由 支持 支持 需要自己实现
维护成本 社区维护 社区维护,迭代更活跃 团队自己维护
包体积影响 ~15KB ~25KB ~3KB(只有核心逻辑)

如果你的项目路由规则比较标准,unplugin-vue-router 是目前社区最推荐的选择,类型推导的开发体验确实好。我们自己写是因为有一套权限路由命名约定——页面文件名的前缀代表权限组(比如 admin.user-list.vue 属于 admin 权限组),这套规则在现有插件里没法直接表达。

落地效果

插件上线两周后做了一次回顾。

指标 改造前 改造后
路由配置文件行数 1800 行 15 行
新建页面耗时 ~3 分钟 ~30 秒
路由相关线上事故(周均) 1.2 次 0 次
路由配置 CR 耗时 每次 ~10 分钟 基本不需要

最直观的反馈来自团队里的新人同事——他入职第一天就按照文件命名规范新建了一个页面,路由自动生成、权限自动挂载,全程没有碰过 router/index.ts。之前的入职文档里有整整一页是在讲"如何正确添加路由配置",现在这页直接删了。

通用经验

从这个插件的开发过程里可以提炼出三个通用模式,覆盖了绝大多数 Vite 插件的使用场景。

虚拟模块模式适合往项目里注入运行时数据——路由表、环境变量、自动导入的模块清单都属于这一类。resolveId + load 是固定搭配,\0 前缀不能省。

代码转换模式transform 钩子,用于修改已有模块的源码,比如给组件自动注入 import 语句、为 JSX 添加编译提示。

开发服务器增强模式configureServer 钩子,适合需要添加自定义中间件或者 WebSocket 通信的场景,mock 服务和组件预览面板都是典型用例。

如果你想动手试试,建议从最简单的虚拟模块入手——写一个把 package.json 的版本号注入到运行时的小插件,十几行代码就能跑通,用来理解钩子的调用流程刚刚好。等虚拟模块的机制摸熟了,再往上叠文件监听和 HMR 支持。路由排序和嵌套路由的树构建放到最后处理,这两块的边界条件最多,一上来就啃容易卡住。

Expo开发App实战指南:从技术选型到架构设计

2026年3月26日 09:42

引言

在移动应用开发领域,跨平台开发框架一直是开发者关注的焦点。Expo作为React Native的官方推荐开发工具链,凭借其卓越的开发体验和丰富的生态系统,已经成为构建移动应用的首选方案之一。本文将从实战角度出发,详细介绍如何使用Expo开发一个完整的移动应用程序,涵盖技术选型、业务分析、架构设计和代码实现的完整流程。

Expo不仅仅是一个开发框架,更是一整套完善的移动应用开发生态系统。它提供了从项目创建、代码编写、调试测试到应用发布的完整工具链支持。开发者无需深入了解原生开发知识,就能够快速构建出功能丰富、体验优秀的移动应用程序。这种低门槛的特性使得Expo特别适合初创团队、独立开发者以及需要快速验证产品 idea 的项目。

在本文中,我们将以一个任务管理应用作为实战案例,带领读者深入了解Expo开发的各个方面。这个案例将涵盖用户认证、数据管理、离线存储、推送通知等核心功能模块,帮助读者建立起完整的Expo开发知识体系。通过这个实战案例,读者不仅能够掌握Expo的基本使用方法,更能够学习到如何运用最佳实践来构建生产级别的移动应用。

第一部分:技术选型与决策

1.1 为什么选择Expo

在选择移动应用开发技术栈时,我们需要综合考虑多个维度的因素。开发效率、维护成本、用户体验、性能表现、生态系统成熟度等都是需要权衡的重要指标。Expo正是为了解决传统React Native开发中的诸多痛点而诞生的,它通过提供统一的开发生态和丰富的内置功能,大大简化了移动应用开发的复杂度。

从开发效率角度来看,Expo提供了即时预览功能,开发者可以在编写代码的同时实时看到应用界面的变化。这种所见即所得的开发体验极大地提升了开发效率,让开发者能够快速迭代和优化产品。此外,Expo还提供了完善的TypeScript支持,使得代码的类型安全得到了保障,减少了运行时错误的发生。Expo的JavaScript/TypeScript开发体验与Web开发非常相似,这对于前端开发者来说大大降低了学习成本。

在维护成本方面,Expo的Over-The-Air更新功能允许开发者无需重新提交应用商店审核就能更新应用代码。这意味着我们可以快速修复线上Bug,发布新功能,极大地缩短了产品迭代周期。同时,Expo会自动处理React Native版本升级和原生依赖管理,让我们无需深入了解原生开发细节就能保持应用的先进性。这种低维护成本的特性对于资源有限的团队来说尤为重要。

从生态系统角度来看,Expo拥有丰富的官方和社区维护的库,涵盖了相机、文件系统、位置服务、传感器、推送通知等各个方面。这些库都经过了良好的测试和优化,开箱即用,大大减少了开发者在第三方库选型和集成上的工作量。Expo的生态系统还在不断壮大,社区活跃度高,遇到问题容易找到解决方案。

1.2 技术栈完整解析

在确定了使用Expo作为开发框架后,我们需要进一步选择与之配套的技术栈。每一个技术选择都应该服务于项目的具体需求,在功能、性能、开发效率之间找到最佳平衡点。以下是我们为实战项目选择的技术栈及其理由。

React Navigation是我们选择的导航解决方案,它是React Native生态中最成熟、使用最广泛的导航库。React Navigation提供了声明式的API设计,与React的组件化思想完美契合。它支持栈导航、标签页导航、抽屉导航等多种导航模式,能够满足各种复杂的应用导航需求。在Expo项目中使用React Navigation非常简单,Expo SDK已经内置了对其的优化支持,确保了良好的性能和兼容性。

在状态管理方面,我们选择使用Zustand作为主要的状态管理方案。Zustand是一个轻量级但功能强大的状态管理库,它采用了React Hooks的API设计,简洁直观,学习成本极低。相比Redux等传统状态管理方案,Zustand的代码量更少,模板代码几乎为零,同时支持中间件扩展,能够满足各种复杂的状态管理需求。Zustand还提供了出色的TypeScript支持,类型推断自然流畅。

对于数据持久化需求,我们选择使用AsyncStorage结合SQLite的方案。AsyncStorage适合存储简单的键值对数据,如用户偏好设置、认证令牌等。对于结构化程度较高的数据,如任务列表、用户信息等,我们使用expo-sqlite来实现SQLite数据库操作。SQLite作为一种嵌入式数据库,在移动设备上运行高效可靠,非常适合离线优先的应用场景。

对于HTTP请求和API交互,我们选择使用axios配合React Query。axios提供了简洁优雅的API设计和完善的错误处理机制,而React Query则为我们提供了强大的服务端状态管理能力,包括缓存、自动重试、分页等功能的开箱即用。这种组合让我们能够以声明式的方式处理数据获取和同步,大大简化了异步数据管理的复杂度。

1.3 开发工具链配置

完善的开发工具链配置是保证开发效率和代码质量的重要基础。在Expo项目中,我们需要配置开发服务器、TypeScript编译、代码检查、格式化工具等多个方面。合理配置这些工具能够让我们在开发过程中及时发现问题,保证代码风格的一致性,提升团队协作效率。

首先是开发服务器的配置。Expo提供了expo-cli作为命令行工具,我们可以配置开发服务器的相关参数,如端口号、加密设置、局域网访问等。对于团队协作场景,配置局域网访问非常重要,这样团队成员可以在同一网络下直接通过IP地址访问开发中的应用。开发服务器还支持热模块替换(HMR),能够在代码修改时快速更新应用界面而无需重新加载整个应用。

TypeScript配置是Expo项目的核心组成部分。我们需要在tsconfig.json中仔细配置编译选项,包括严格模式、路径别名、装饰器支持等。严格模式能够帮助我们在编译阶段发现更多潜在问题,提升代码质量。路径别名配置能够让我们使用更简洁的导入路径,如使用@/components代替相对路径的复杂引用。对于需要使用装饰器的库如MobX,我们需要确保experimentalDecorators和emitDecoratorMetadata选项正确配置。

ESLint和Prettier的配置同样不可或缺。ESLint负责代码质量检查,能够发现语法错误、潜在bug、不良编码实践等问题。Prettier则负责代码格式化,确保团队成员的代码风格保持一致。我们需要为这两个工具创建统一的配置文件,并将其集成到编辑器和持续集成流程中。建议使用eslint-config-airbnb或eslint-config-standard等成熟的配置作为基础,根据项目需求进行定制调整。

第二部分:业务分析与需求梳理

2.1 任务管理应用需求概述

为了更好地展示Expo开发的完整流程,我们将以一个任务管理应用作为实战案例。任务管理是一个经典的应用场景,几乎每个人都需要管理日常任务、待办事项的场景。这个应用将涵盖用户管理、任务管理、项目管理、标签分类等核心功能,能够充分展示Expo开发的各个方面。

从用户角度来看,一个实用的任务管理应用需要具备以下核心能力:创建、编辑、删除任务的基本操作能力;将任务分配到不同项目进行分类管理的能力;为任务设置截止日期、优先级、提醒时间的能力;使用标签对任务进行多维度分类的能力;支持子任务和任务依赖的能力;任务搜索和筛选的能力。这些功能看似简单,但要做好每一个细节都需要精心设计。

从技术角度来看,我们需要解决以下挑战:如何在没有网络的情况下正常使用应用;如何保证数据在多设备间的同步;如何处理大量数据的性能问题;如何优雅地管理复杂的应用状态;如何提供流畅的用户交互体验。这些技术挑战的解决方案将贯穿我们的整个开发过程,帮助我们深入理解Expo开发的最佳实践。

2.2 功能模块划分

在明确了整体需求后,我们需要将应用划分为若干个功能模块,每个模块负责相对独立的业务逻辑。这种模块化设计能够提升代码的可维护性和可测试性,便于团队协作和功能扩展。根据任务管理应用的特点,我们将其划分为以下主要模块。

用户认证模块负责用户的注册、登录、密码重置、权限验证等功能。虽然是一个相对独立的功能模块,但它与其他所有模块都有数据层面的关联。用户认证模块需要处理各种异常情况,如网络错误、账户不存在、密码错误、Token过期等,并给出友好的用户提示。在实现上,我们采用JWT进行身份认证,Token存储在安全的存储区域,并实现自动刷新和过期处理机制。

任务管理模块是应用的核心模块,负责任务的全生命周期管理。这包括任务的创建、编辑、删除、状态变更等基本操作,还包括任务的排序、筛选、批量操作等高级功能。任务管理模块需要与后端API进行数据同步,同时维护本地缓存以支持离线操作。我们采用乐观更新策略,在用户操作后立即更新UI,同时在后台同步数据到服务器,当同步失败时进行适当的错误处理和重试。

项目管理模块负责项目的创建、编辑、删除和成员管理。每个项目可以包含多个任务,项目的完成情况反映了整体进度。项目管理还需要处理项目权限问题,如哪些用户可以查看、编辑特定项目的内容。在界面上,项目通常以列表或看板的形式呈现,我们选择使用列表形式以保持界面简洁。

标签系统为任务提供了多维度的分类能力。一个任务可以同时属于多个标签,标签之间可以有关联关系。标签系统需要支持自定义颜色、图标等视觉元素,让用户能够直观地识别不同类型的任务。标签的增删改查操作需要实时同步到所有相关界面。

通知提醒模块负责向用户推送任务相关的提醒通知。这包括截止日期提醒、任务分配通知、每日任务摘要等。通知模块需要与系统的通知服务集成,处理通知权限请求,并提供通知偏好设置界面。用户应该能够选择接收哪些类型的通知,以及通知的触发时间。

2.3 数据模型设计

良好的数据模型是应用稳定性和可扩展性的基础。在设计数据模型时,我们需要考虑数据的完整性、一致性、查询效率等多个方面。以下是我们为任务管理应用设计的数据模型,包括主要实体及其关系。

用户实体是整个应用的基础,包含用户的基本信息和认证数据。用户表的主要字段包括:用户ID作为主键、用户名用于登录和显示、邮箱作为唯一标识和联系方式、密码哈希保证安全性、创建时间记录账户创建日期、最后登录时间用于活跃度分析、头像URL用于界面展示。用户的设置偏好可以存储在关联的设置表中,以JSON格式存储以支持灵活扩展。

任务实体是数据模型的核心,包含任务的所有属性和状态信息。任务表的主要字段包括:任务ID作为唯一标识、标题简要描述任务内容、详细描述存储任务的完整信息、所属项目ID建立与项目的关联、创建者ID记录任务创建人、负责人ID表示当前处理人、截止日期用于时间管理、优先级用数字表示重要程度、状态包括待处理、进行中、已完成等、创建时间和更新时间用于数据同步和排序。任务还可能包含子任务,通过父子任务ID关联实现。

项目实体用于组织和管理任务集合。项目表的主要字段包括:项目ID、项目名称、项目描述、项目图标或颜色、创建者ID、项目成员列表、创建时间和更新时间。项目成员关系通过关联表维护,支持不同的角色如创建者、管理员、成员等。

标签实体提供了灵活的任务分类能力。标签表的主要字段包括:标签ID、标签名称、标签颜色、标签图标、创建者ID。任务与标签的关系通过多对多关联表维护,允许一个任务有多个标签,一个标签包含多个任务。

提醒实体记录用户设置的任务提醒。提醒表的主要字段包括:提醒ID、关联的任务ID、提醒时间、提醒类型、是否已触发、是否已确认。

第三部分:架构设计与模式

3.1 应用架构概览

在移动应用开发中,良好的架构设计是确保应用长期可维护性的关键。我们采用分层架构结合功能模块组织的混合架构模式,既保证了代码的清晰结构,又便于功能扩展和维护。整体架构分为表现层、业务层、数据层和基础设施层四个主要层次。

表现层负责用户界面的呈现和交互处理。这一层采用React的组件化设计,将界面拆分为原子组件、分子组件和有机组件的层次结构。原子组件如按钮、输入框、图标等是最基本的UI元素,具有完整的样式和行为定义。分子组件由原子组件组合而成,如搜索框由输入框和图标按钮组成。有机组件则代表了完整的业务界面,如任务列表项、项目卡片等。表现层通过React Hooks与业务层通信,触发业务操作并响应状态变化。

业务层负责处理应用的业务逻辑和数据流转。这一层包含应用的核心业务服务,如认证服务、任务服务、项目服务等。每个服务类封装了特定业务领域的相关操作,提供清晰的服务接口。业务层还包含数据转换器,将API返回的原始数据转换为应用内部使用的数据模型,反之亦然。中间件机制用于在请求处理流程中注入日志、错误处理、性能监控等横切关注点。

数据层负责与各种数据源进行交互,包括远程API、本地数据库和缓存系统。数据层实现了仓储模式,为业务层提供统一的数据访问接口。每个仓储类对应一个数据实体,封装了该实体相关的所有CRUD操作。数据层实现了数据同步机制,处理离线操作的队列管理和冲突解决。对于频繁访问的数据,我们实现了多级缓存策略,包括内存缓存和持久化缓存。

基础设施层提供了应用运行所需的基础服务和工具函数。这包括日志记录、错误处理、网络请求、本地存储、推送通知等通用功能。基础设施层被其他所有层次依赖,是整个应用的技术基石。我们将基础设施层的服务实现为单例模式,确保全局唯一的实例和一致的配置。

3.2 目录结构设计

合理的目录结构是架构落地的具体体现。我们采用基于功能的目录组织方式,将相关代码集中管理,便于查找和维护。以下是项目的目录结构和各部分的职责说明。

src/
├── components/          # 可复用UI组件
│   ├── atoms/          # 原子组件
│   ├── molecules/       # 分子组件
│   └── organisms/      # 有机组件
├── screens/            # 页面组件
├── navigation/         # 导航配置
├── services/           # 业务服务层
├── repositories/       # 数据仓储层
├── stores/             # 状态管理
├── models/             # 数据模型
├── hooks/              # 自定义Hooks
├── utils/              # 工具函数
├── constants/          # 常量定义
├── types/              # TypeScript类型
├── api/                # API客户端
└── assets/             # 静态资源

components目录包含了应用中的可复用UI组件。我们采用原子设计方法论,将组件分为原子、分子和有机三个层级。atoms目录包含最基础的组件,如按钮、文本、图标等;molecules目录包含由原子组件组合而成的中等复杂度组件,如搜索栏、卡片头部等;organisms目录包含完整的业务组件,如任务卡片、项目列表项等。每个组件都包含组件文件、样式文件和测试文件,保持代码的完整性。

screens目录包含了应用的各个页面组件。每个页面通常对应导航系统中的一个路由,页面组件负责整合各种组件来构建完整的页面视图。页面组件应该保持轻薄,将复杂的业务逻辑委托给services层处理。

services目录包含了应用的业务服务类。每个服务类专注于一个业务领域,如AuthService处理认证相关逻辑,TaskService处理任务相关操作。服务类通过调用repositories层来访问数据,同时可以包含业务规则验证和数据转换逻辑。

repositories目录包含了数据访问层的实现。每个仓储类对应一个主要数据实体,封装了该实体相关的所有数据操作。仓储类负责与API客户端或本地数据库进行交互,对上层屏蔽数据存储的细节。

stores目录包含了Zustand状态管理相关的文件。我们将不同领域的状态分别管理,如authStore、taskStore、projectStore等。每个状态store包含状态定义、actions定义和计算属性。状态store不直接处理数据持久化,而是通过调用services来完成数据操作。

3.3 状态管理模式

状态管理是React应用开发中的核心话题,良好的状态管理能够让应用的行为更加可预测和可调试。在我们的Expo项目中,我们采用Zustand作为主要的状态管理方案,结合React Query进行服务端状态管理,形成了清晰的状态管理层次。

Zustand的使用非常简单直观。我们定义一个store时,首先声明store包含的状态,然后定义修改状态的actions。Zustand的actions可以直接修改状态,不像Redux那样需要返回新的状态副本,这使得代码更加简洁。对于需要异步操作的场景,Zustand支持在actions中执行异步代码,我们可以方便地调用services来完成数据操作。

import { create } from 'zustand';
import { Task, TaskStatus } from '@/types';
import { TaskService } from '@/services/TaskService';

interface TaskState {
  tasks: Task[];
  selectedTask: Task | null;
  isLoading: boolean;
  error: string | null;

  // Actions
  fetchTasks: (projectId?: string) => Promise<void>;
  createTask: (task: Partial<Task>) => Promise<Task>;
  updateTask: (id: string, updates: Partial<Task>) => Promise<void>;
  deleteTask: (id: string) => Promise<void>;
  selectTask: (task: Task | null) => void;
  clearError: () => void;
}

export const useTaskStore = create<TaskState>((set, get) => ({
  tasks: [],
  selectedTask: null,
  isLoading: false,
  error: null,

  fetchTasks: async (projectId?: string) => {
    set({ isLoading: true, error: null });
    try {
      const tasks = await TaskService.getTasks(projectId);
      set({ tasks, isLoading: false });
    } catch (error) {
      set({ error: (error as Error).message, isLoading: false });
    }
  },

  createTask: async (taskData: Partial<Task>) => {
    set({ isLoading: true, error: null });
    try {
      const newTask = await TaskService.createTask(taskData);
      set((state) => ({
        tasks: [...state.tasks, newTask],
        isLoading: false
      }));
      return newTask;
    } catch (error) {
      set({ error: (error as Error).message, isLoading: false });
      throw error;
    }
  },

  updateTask: async (id: string, updates: Partial<Task>) => {
    // 乐观更新:立即更新UI
    const originalTasks = get().tasks;
    set((state) => ({
      tasks: state.tasks.map((task) =>
        task.id === id ? { ...task, ...updates } : task
      )
    }));

    try {
      await TaskService.updateTask(id, updates);
    } catch (error) {
      // 回滚到原始状态
      set({ tasks: originalTasks, error: (error as Error).message });
      throw error;
    }
  },

  deleteTask: async (id: string) => {
    const originalTasks = get().tasks;
    set((state) => ({
      tasks: state.tasks.filter((task) => task.id !== id)
    }));

    try {
      await TaskService.deleteTask(id);
    } catch (error) {
      set({ tasks: originalTasks, error: (error as Error).message });
      throw error;
    }
  },

  selectTask: (task: Task | null) => {
    set({ selectedTask: task });
  },

  clearError: () => {
    set({ error: null });
  }
}));

对于需要与后端同步的数据,我们使用React Query来管理服务端状态。React Query提供了自动缓存、后台更新、乐观更新等功能,大大简化了服务端数据管理的复杂度。我们为每个API操作定义对应的query或mutation,React Query会自动处理缓存、更新和错误处理。

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TaskService } from '@/services/TaskService';
import { useTaskStore } from '@/stores/taskStore';
import { Task, CreateTaskDTO } from '@/types';

// 查询Hook
export function useTasks(projectId?: string) {
  const store = useTaskStore();

  return useQuery({
    queryKey: ['tasks', projectId],
    queryFn: () => TaskService.getTasks(projectId),
    initialData: store.tasks,
    onSuccess: (data) => {
      store.fetchTasks(projectId);
    }
  });
}

// 创建任务Mutation
export function useCreateTask() {
  const queryClient = useQueryClient();
  const store = useTaskStore();

  return useMutation({
    mutationFn: (newTask: CreateTaskDTO) => TaskService.createTask(newTask),
    onMutate: async (newTask) => {
      // 取消所有出站查询
      await queryClient.cancelQueries({ queryKey: ['tasks'] });

      // 保存旧数据用于回滚
      const previousTasks = queryClient.getQueryData(['tasks']);

      // 添加新任务到缓存
      queryClient.setQueryData(['tasks'], (old: Task[] | undefined) => [
        ...(old || []),
        { ...newTask, id: 'temp-id', status: 'pending' } as Task
      ]);

      return { previousTasks };
    },
    onError: (err, newTask, context) => {
      // 回滚到之前的数据
      queryClient.setQueryData(['tasks'], context?.previousTasks);
    },
    onSettled: () => {
      // 重新获取数据以确保一致性
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
      store.fetchTasks();
    }
  });
}

3.4 错误处理与边界情况

健壮的错误处理是应用可靠性的重要保障。在移动应用中,网络不稳定、服务器异常、用户操作失误等情况时有发生,我们需要优雅地处理这些情况,给用户良好的体验,同时收集有用的错误信息用于调试和监控。

我们建立了分层的错误处理机制。在基础设施层,我们定义了统一的错误类型和错误处理中间件。在业务层,每个服务方法都应该捕获和处理预期的错误,对于未预期的错误应该记录日志并抛出统一格式的错误。在表现层,我们通过错误边界组件和状态管理来处理错误状态,给用户友好的错误提示。

// 错误类型定义
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode?: number,
    public isOperational: boolean = true
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
}

// 预定义的业务错误
export const ErrorCodes = {
  NETWORK_ERROR: 'NETWORK_ERROR',
  UNAUTHORIZED: 'UNAUTHORIZED',
  NOT_FOUND: 'NOT_FOUND',
  VALIDATION_ERROR: 'VALIDATION_ERROR',
  SERVER_ERROR: 'SERVER_ERROR',
  OFFLINE_ERROR: 'OFFLINE_ERROR'
} as const;

// 错误工厂函数
export function createError(
  code: keyof typeof ErrorCodes,
  message: string,
  statusCode?: number
): AppError {
  return new AppError(message, ErrorCodes[code], statusCode);
}

// 网络错误处理
export async function handleNetworkError(error: unknown): Promise<never> {
  if (error instanceof AppError) {
    throw error;
  }

  if (isNetworkError(error)) {
    throw createError(
      'NETWORK_ERROR',
      '网络连接失败,请检查您的网络设置',
      0
    );
  }

  throw createError(
    'SERVER_ERROR',
    '服务器错误,请稍后再试',
    500
  );
}

// 错误边界组件
export class ErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback?: React.ComponentType<ErrorProps> },
  { hasError: boolean; error: Error | null }
> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // 记录错误日志
    logger.error('React Error Boundary caught an error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      const FallbackComponent = this.props.fallback || DefaultErrorFallback;
      return (
        <FallbackComponent
          error={this.state.error!}
          resetError={() => this.setState({ hasError: false, error: null })}
        />
      );
    }

    return this.props.children;
  }
}

对于离线操作的处理,我们采用队列管理的策略。当设备离线时,用户的所有数据操作会进入一个待同步队列,当网络恢复后自动同步。这种设计让应用在离线环境下也能正常使用,同时保证了数据的最终一致性。

// 离线操作队列
interface QueuedOperation {
  id: string;
  type: 'CREATE' | 'UPDATE' | 'DELETE';
  entity: string;
  entityId: string;
  payload: any;
  timestamp: number;
  retryCount: number;
}

class OfflineQueueManager {
  private queue: QueuedOperation[] = [];
  private isOnline: boolean = true;
  private syncInProgress: boolean = false;

  constructor() {
    // 监听网络状态变化
    Network.useNetworkState((state) => {
      this.isOnline = state.isConnected ?? false;
      if (this.isOnline && !this.syncInProgress) {
        this.processQueue();
      }
    });
  }

  async enqueue(operation: Omit<QueuedOperation, 'id' | 'timestamp' | 'retryCount'>) {
    const queuedOp: QueuedOperation = {
      ...operation,
      id: generateId(),
      timestamp: Date.now(),
      retryCount: 0
    };

    this.queue.push(queuedOp);
    await this.persistQueue();

    if (this.isOnline) {
      this.processQueue();
    }
  }

  private async processQueue() {
    if (this.syncInProgress || this.queue.length === 0) return;

    this.syncInProgress = true;

    while (this.queue.length > 0 && this.isOnline) {
      const operation = this.queue[0];

      try {
        await this.executeOperation(operation);
        this.queue.shift();
        await this.persistQueue();
      } catch (error) {
        operation.retryCount++;

        if (operation.retryCount >= MAX_RETRY_COUNT) {
          this.queue.shift();
          await this.notifySyncFailure(operation, error);
        } else {
          // 指数退避等待
          await this.delay(Math.pow(2, operation.retryCount) * 1000);
        }
      }
    }

    this.syncInProgress = false;
  }

  private async executeOperation(operation: QueuedOperation) {
    switch (operation.type) {
      case 'CREATE':
        return TaskService.createTask(operation.payload);
      case 'UPDATE':
        return TaskService.updateTask(operation.entityId, operation.payload);
      case 'DELETE':
        return TaskService.deleteTask(operation.entityId);
    }
  }

  private async persistQueue() {
    await AsyncStorage.setItem('offline_queue', JSON.stringify(this.queue));
  }

  private async loadQueue() {
    const data = await AsyncStorage.getItem('offline_queue');
    if (data) {
      this.queue = JSON.parse(data);
    }
  }
}

第四部分:核心代码实现

4.1 项目初始化与配置

项目初始化是Expo开发的起点,一个配置完善的初始项目能够为后续开发奠定良好基础。我们将详细讲解如何创建Expo项目、配置TypeScript、以及设置开发环境和工具链。

创建Expo项目非常简单,使用npx create-expo-app命令即可。这个命令会创建一个包含Expo SDK和必要依赖的新项目。我们建议选择TypeScript模板,这样可以获得完整的类型检查支持。如果创建的是JavaScript项目,可以使用npx tsc init命令初始化TypeScript配置。

# 创建新的Expo项目
npx create-expo-app@latest TaskMaster --template blank-typescript

# 进入项目目录
cd TaskMaster

# 安装核心依赖
npm install @react-navigation/native @react-navigation/native-stack @react-navigation/bottom-tabs
npm install react-native-screens react-native-safe-area-context
npm install zustand @tanstack/react-query
npm install axios
npm install expo-sqlite expo-notifications expo-device
npm install @react-native-async-storage/async-storage
npm install date-fns uuid
npm install expo-constants expo-linking

# 安装开发依赖
npm install -D @types/uuid eslint prettier eslint-config-universe

项目创建完成后,我们需要配置app.json来定义应用的元数据和配置。app.json是Expo项目的配置文件,包含了应用名称、图标、Splash屏幕、SDK版本等重要信息。我们可以根据需要配置不同的平台设置,如iOS的bundleIdentifier和Android的applicationId。

{
  "expo": {
    "name": "TaskMaster",
    "slug": "taskmaster",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "automatic",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "assetBundlePatterns": ["**/*"],
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "com.taskmaster.app",
      "infoPlist": {
        "NSCameraUsageDescription": "需要使用相机来扫描二维码",
        "NSPhotoLibraryUsageDescription": "需要访问相册来添加任务附件"
      }
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.taskmaster.app",
      "permissions": [
        "CAMERA",
        "READ_EXTERNAL_STORAGE",
        "WRITE_EXTERNAL_STORAGE",
        "RECEIVE_BOOT_COMPLETED",
        "VIBRATE",
        "INTERNET",
        "ACCESS_NETWORK_STATE"
      ]
    },
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#4A90D9"
        }
      ],
      "expo-sqlite"
    ],
    "extra": {
      "eas": {
        "projectId": "your-project-id"
      }
    }
  }
}

TypeScript配置是保障代码质量的重要工具。我们需要在tsconfig.json中仔细配置编译选项。以下是推荐的TypeScript配置,它启用了严格模式、路径别名和最新的ECMAScript特性支持。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "lib": ["ESNext"],
    "jsx": "react-native",
    "strict": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "noEmit": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@screens/*": ["src/screens/*"],
      "@services/*": ["src/services/*"],
      "@stores/*": ["src/stores/*"],
      "@hooks/*": ["src/hooks/*"],
      "@types/*": ["src/types/*"],
      "@utils/*": ["src/utils/*"],
      "@constants/*": ["src/constants/*"],
      "@api/*": ["src/api/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules", "dist", ".expo"]
}

为了让路径别名生效,我们需要在babel.config.js中配置babel-plugin-module-resolver插件。这个插件会在编译时将路径别名转换为实际的相对路径。

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      [
        'module-resolver',
        {
          root: ['./src'],
          alias: {
            '@': './src',
            '@components': './src/components',
            '@screens': './src/screens',
            '@services': './src/services',
            '@stores': './src/stores',
            '@hooks': './src/hooks',
            '@types': './src/types',
            '@utils': './src/utils',
            '@constants': './src/constants',
            '@api': './src/api'
          }
        }
      ]
    ]
  };
};

4.2 导航系统实现

导航系统是移动应用的核心骨架,决定了用户在应用中的浏览体验。React Navigation是React Native生态中最成熟的导航解决方案,它提供了声明式的API设计和丰富的导航模式支持。我们将为任务管理应用配置完整的导航系统。

首先,我们需要定义应用的路由类型,确保类型安全。TypeScript的强类型支持能够在编译时发现导航相关的错误,提升代码质量。

// navigation/types.ts
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';

// 认证流程
export type AuthStackParamList = {
  Welcome: undefined;
  Login: undefined;
  Register: undefined;
  ForgotPassword: undefined;
};

// 主应用底部标签
export type MainTabParamList = {
  Home: undefined;
  Projects: NavigatorScreenParams<ProjectStackParamList>;
  Tasks: NavigatorScreenParams<TaskStackParamList>;
  Profile: undefined;
};

// 项目模块
export type ProjectStackParamList = {
  ProjectList: undefined;
  ProjectDetail: { projectId: string };
  CreateProject: undefined;
  EditProject: { projectId: string };
};

// 任务模块
export type TaskStackParamList = {
  TaskList: undefined;
  TaskDetail: { taskId: string };
  CreateTask: { projectId?: string };
  EditTask: { taskId: string };
};

// 组合类型定义
export type RootStackParamList = {
  Auth: NavigatorScreenParams<AuthStackParamList>;
  Main: NavigatorScreenParams<MainTabParamList>;
};

// Screen Props类型
export type AuthScreenProps<T extends keyof AuthStackParamList> = NativeStackScreenProps<
  AuthStackParamList,
  T
>;

export type MainTabScreenProps<T extends keyof MainTabParamList> = CompositeScreenProps<
  BottomTabScreenProps<MainTabParamList, T>,
  RootStackScreenProps<keyof RootStackParamList>
>;

export type ProjectScreenProps<T extends keyof ProjectStackParamList> = CompositeScreenProps<
  NativeStackScreenProps<ProjectStackParamList, T>,
  RootStackScreenProps<keyof RootStackParamList>
>;

export type TaskScreenProps<T extends keyof TaskStackParamList> = CompositeScreenProps<
  NativeStackScreenProps<TaskStackParamList, T>,
  RootStackScreenProps<keyof RootStackParamList>
>;

// Navigation Prop类型(用于组件内部导航)
declare global {
  namespace ReactNavigation {
    interface RootParamList extends RootStackParamList {}
  }
}

接下来,我们创建导航容器的配置。导航容器是整个应用的导航状态管理者,它包装了整个应用,提供了统一的导航上下文。

// navigation/AppNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useAuth } from '@/hooks/useAuth';
import { Colors } from '@constants/theme';

// 导入各层导航器
import { AuthNavigator } from './AuthNavigator';
import { MainTabNavigator } from './MainTabNavigator';

// 导入类型
import { RootStackParamList } from './types';

// 创建导航器实例
const RootStack = createNativeStackNavigator<RootStackParamList>();

export function AppNavigator() {
  const { isAuthenticated, isLoading } = useAuth();

  if (isLoading) {
    // 显示启动画面或加载指示器
    return null;
  }

  return (
    <NavigationContainer>
      <RootStack.Navigator
        screenOptions={{
          headerShown: false,
          animation: 'fade'
        }}
      >
        {isAuthenticated ? (
          <RootStack.Screen name="Main" component={MainTabNavigator} />
        ) : (
          <RootStack.Screen name="Auth" component={AuthNavigator} />
        )}
      </RootStack.Navigator>
    </NavigationContainer>
  );
}

底部标签导航器是主应用的主要导航结构,它包含了应用的四个主要模块入口。我们为每个标签配置了图标和标题,使得导航更加直观。

// navigation/MainTabNavigator.tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Colors } from '@constants/theme';

// 导入屏幕组件
import { HomeScreen } from '@screens/HomeScreen';
import { ProjectListScreen } from '@screens/ProjectListScreen';
import { ProjectDetailScreen } from '@screens/ProjectDetailScreen';
import { CreateProjectScreen } from '@screens/CreateProjectScreen';
import { TaskListScreen } from '@screens/TaskListScreen';
import { TaskDetailScreen } from '@screens/TaskDetailScreen';
import { CreateTaskScreen } from '@screens/CreateTaskScreen';
import { ProfileScreen } from '@screens/ProfileScreen';

// 导入类型
import { MainTabParamList, ProjectStackParamList, TaskStackParamList } from './types';
import { TabBarIcon } from '@components/molecules/TabBarIcon';

const Tab = createBottomTabNavigator<MainTabParamList>();
const ProjectStack = createNativeStackNavigator<ProjectStackParamList>();
const TaskStack = createNativeStackNavigator<TaskStackParamList>();

// 项目堆栈导航器
function ProjectStackNavigator() {
  return (
    <ProjectStack.Navigator
      screenOptions={{
        headerStyle: { backgroundColor: Colors.surface },
        headerTintColor: Colors.text,
        headerTitleStyle: { fontWeight: '600' }
      }}
    >
      <ProjectStack.Screen
        name="ProjectList"
        component={ProjectListScreen}
        options={{ title: '项目' }}
      />
      <ProjectStack.Screen
        name="ProjectDetail"
        component={ProjectDetailScreen}
        options={{ title: '项目详情' }}
      />
      <ProjectStack.Screen
        name="CreateProject"
        component={CreateProjectScreen}
        options={{ title: '创建项目', presentation: 'modal' }}
      />
    </ProjectStack.Navigator>
  );
}

// 任务堆栈导航器
function TaskStackNavigator() {
  return (
    <TaskStack.Navigator
      screenOptions={{
        headerStyle: { backgroundColor: Colors.surface },
        headerTintColor: Colors.text,
        headerTitleStyle: { fontWeight: '600' }
      }}
    >
      <TaskStack.Screen
        name="TaskList"
        component={TaskListScreen}
        options={{ title: '所有任务' }}
      />
      <TaskStack.Screen
        name="TaskDetail"
        component={TaskDetailScreen}
        options={{ title: '任务详情' }}
      />
      <TaskStack.Screen
        name="CreateTask"
        component={CreateTaskScreen}
        options={{ title: '创建任务', presentation: 'modal' }}
      />
    </TaskStack.Navigator>
  );
}

// 主标签导航器
export function MainTabNavigator() {
  return (
    <Tab.Navigator
      screenOptions={{
        tabBarActiveTintColor: Colors.primary,
        tabBarInactiveTintColor: Colors.textSecondary,
        tabBarStyle: styles.tabBar,
        headerShown: false
      }}
    >
      <Tab.Screen
        name="Home"
        component={HomeScreen}
        options={{
          title: '首页',
          tabBarIcon: ({ color, size }) => (
            <TabBarIcon name="home" color={color} size={size} />
          )
        }}
      />
      <Tab.Screen
        name="Projects"
        component={ProjectStackNavigator}
        options={{
          title: '项目',
          tabBarIcon: ({ color, size }) => (
            <TabBarIcon name="folder" color={color} size={size} />
          )
        }}
      />
      <Tab.Screen
        name="Tasks"
        component={TaskStackNavigator}
        options={{
          title: '任务',
          tabBarIcon: ({ color, size }) => (
            <TabBarIcon name="checkbox" color={color} size={size} />
          )
        }}
      />
      <Tab.Screen
        name="Profile"
        component={ProfileScreen}
        options={{
          title: '我的',
          tabBarIcon: ({ color, size }) => (
            <TabBarIcon name="user" color={color} size={size} />
          )
        }}
      />
    </Tab.Navigator>
  );
}

const styles = StyleSheet.create({
  tabBar: {
    backgroundColor: Colors.surface,
    borderTopColor: Colors.border,
    borderTopWidth: StyleSheet.hairlineWidth,
    paddingTop: 8,
    paddingBottom: 8,
    height: 60
  }
});

4.3 核心组件实现

组件是React应用的构建块,良好的组件设计能够让代码更加可复用和可维护。我们采用原子设计方法论,将组件分为原子、分子和有机三个层级,每个层级都有明确的职责边界。

首先是原子组件的实现。原子组件是最基础的UI元素,它们不依赖其他组件,但必须有完整的样式和行为定义。以下是几个核心原子组件的实现。

// components/atoms/Button/Button.tsx
import React from 'react';
import {
  TouchableOpacity,
  Text,
  StyleSheet,
  ActivityIndicator,
  ViewStyle,
  TextStyle
} from 'react-native';
import { Colors } from '@constants/theme';
import { ButtonVariant, ButtonSize } from './Button.types';

interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  loading?: boolean;
  fullWidth?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
  style?: ViewStyle;
  textStyle?: TextStyle;
}

export function Button({
  title,
  onPress,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  loading = false,
  fullWidth = false,
  leftIcon,
  rightIcon,
  style,
  textStyle
}: ButtonProps) {
  const isDisabled = disabled || loading;

  return (
    <TouchableOpacity
      style={[
        styles.base,
        styles[variant],
        styles[size],
        fullWidth && styles.fullWidth,
        isDisabled && styles.disabled,
        style
      ]}
      onPress={onPress}
      disabled={isDisabled}
      activeOpacity={0.7}
    >
      {loading ? (
        <ActivityIndicator
          color={variant === 'primary' ? Colors.white : Colors.primary}
          size="small"
        />
      ) : (
        <>
          {leftIcon && <>{leftIcon}</>}
          <Text
            style={[
              styles.text,
              styles[`${variant}Text`],
              styles[`${size}Text`],
              textStyle
            ]}
          >
            {title}
          </Text>
          {rightIcon && <>{rightIcon}</>}
        </>
      )}
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  base: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: 12,
    gap: 8
  },
  primary: {
    backgroundColor: Colors.primary
  },
  secondary: {
    backgroundColor: Colors.background,
    borderWidth: 1,
    borderColor: Colors.border
  },
  outline: {
    backgroundColor: 'transparent',
    borderWidth: 1,
    borderColor: Colors.primary
  },
  ghost: {
    backgroundColor: 'transparent'
  },
  danger: {
    backgroundColor: Colors.error
  },
  small: {
    paddingVertical: 8,
    paddingHorizontal: 16,
    minHeight: 36
  },
  medium: {
    paddingVertical: 12,
    paddingHorizontal: 24,
    minHeight: 48
  },
  large: {
    paddingVertical: 16,
    paddingHorizontal: 32,
    minHeight: 56
  },
  fullWidth: {
    width: '100%'
  },
  disabled: {
    opacity: 0.5
  },
  text: {
    fontWeight: '600'
  },
  primaryText: {
    color: Colors.white
  },
  secondaryText: {
    color: Colors.text
  },
  outlineText: {
    color: Colors.primary
  },
  ghostText: {
    color: Colors.primary
  },
  dangerText: {
    color: Colors.white
  },
  smallText: {
    fontSize: 14
  },
  mediumText: {
    fontSize: 16
  },
  largeText: {
    fontSize: 18
  }
});
// components/atoms/Input/Input.tsx
import React, { forwardRef } from 'react';
import {
  TextInput,
  View,
  Text,
  StyleSheet,
  TextInputProps,
  ViewStyle
} from 'react-native';
import { Colors } from '@constants/theme';

interface InputProps extends TextInputProps {
  label?: string;
  error?: string;
  hint?: string;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
  containerStyle?: ViewStyle;
}

export const Input = forwardRef<TextInput, InputProps>(
  (
    {
      label,
      error,
      hint,
      leftIcon,
      rightIcon,
      containerStyle,
      style,
      ...props
    },
    ref
  ) => {
    const hasError = !!error;

    return (
      <View style={[styles.container, containerStyle]}>
        {label && <Text style={styles.label}>{label}</Text>}
        <View
          style={[
            styles.inputContainer,
            hasError && styles.inputError,
            props.editable === false && styles.inputDisabled
          ]}
        >
          {leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
          <TextInput
            ref={ref}
            style={[
              styles.input,
              leftIcon && styles.inputWithLeftIcon,
              rightIcon && styles.inputWithRightIcon,
              style
            ]}
            placeholderTextColor={Colors.placeholder}
            {...props}
          />
          {rightIcon && <View style={styles.iconRight}>{rightIcon}</View>}
        </View>
        {error && <Text style={styles.error}>{error}</Text>}
        {hint && !error && <Text style={styles.hint}>{hint}</Text>}
      </View>
    );
  }
);

Input.displayName = 'Input';

const styles = StyleSheet.create({
  container: {
    marginBottom: 16
  },
  label: {
    fontSize: 14,
    fontWeight: '500',
    color: Colors.text,
    marginBottom: 8
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: Colors.background,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: Colors.border
  },
  inputError: {
    borderColor: Colors.error
  },
  inputDisabled: {
    backgroundColor: Colors.disabled,
    opacity: 0.7
  },
  input: {
    flex: 1,
    paddingVertical: 14,
    paddingHorizontal: 16,
    fontSize: 16,
    color: Colors.text
  },
  inputWithLeftIcon: {
    paddingLeft: 8
  },
  inputWithRightIcon: {
    paddingRight: 8
  },
  iconLeft: {
    paddingLeft: 14
  },
  iconRight: {
    paddingRight: 14
  },
  error: {
    fontSize: 12,
    color: Colors.error,
    marginTop: 6,
    marginLeft: 4
  },
  hint: {
    fontSize: 12,
    color: Colors.textSecondary,
    marginTop: 6,
    marginLeft: 4
  }
});

接下来是分子组件的实现。分子组件由原子组件组合而成,代表了更完整的UI功能单元。以下是几个常用的分子组件。

// components/molecules/TaskCard/TaskCard.tsx
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Colors } from '@constants/theme';
import { Task, TaskPriority, TaskStatus } from '@/types';
import { PriorityBadge } from '@components/atoms/PriorityBadge';
import { StatusIndicator } from '@components/atoms/StatusIndicator';
import { format, isToday, isTomorrow, isPast, parseISO } from 'date-fns';
import { zhCN } from 'date-fns/locale';

interface TaskCardProps {
  task: Task;
  onPress: () => void;
  onLongPress?: () => void;
  showProject?: boolean;
  compact?: boolean;
}

export function TaskCard({
  task,
  onPress,
  onLongPress,
  showProject = false,
  compact = false
}: TaskCardProps) {
  const dueDate = task.dueDate ? parseISO(task.dueDate) : null;
  const isOverdue = dueDate && isPast(dueDate) && task.status !== 'completed';
  const isDueToday = dueDate && isToday(dueDate);
  const isDueTomorrow = dueDate && isTomorrow(dueDate);

  const formatDueDate = () => {
    if (!dueDate) return null;
    if (isDueToday) return '今天';
    if (isDueTomorrow) return '明天';
    return format(dueDate, 'M月d日', { locale: zhCN });
  };

  return (
    <TouchableOpacity
      style={[styles.container, compact && styles.containerCompact]}
      onPress={onPress}
      onLongPress={onLongPress}
      activeOpacity={0.7}
    >
      <View style={styles.header}>
        <StatusIndicator status={task.status} size="medium" />
        <View style={styles.titleContainer}>
          <Text
            style={[
              styles.title,
              task.status === 'completed' && styles.titleCompleted
            ]}
            numberOfLines={compact ? 1 : 2}
          >
            {task.title}
          </Text>
          {showProject && task.project && (
            <Text style={styles.projectName} numberOfLines={1}>
              {task.project.name}
            </Text>
          )}
        </View>
        <PriorityBadge priority={task.priority} />
      </View>

      {!compact && task.description && (
        <Text style={styles.description} numberOfLines={2}>
          {task.description}
        </Text>
      )}

      {dueDate && (
        <View style={styles.footer}>
          <Text
            style={[
              styles.dueDate,
              isOverdue && styles.dueDateOverdue,
              isDueToday && styles.dueDateToday
            ]}
          >
            {formatDueDate()}
          </Text>
          {task.subtasks && task.subtasks.length > 0 && (
            <Text style={styles.subtasks}>
              {task.subtasks.filter((st) => st.completed).length}/
              {task.subtasks.length} 子任务
            </Text>
          )}
        </View>
      )}

      {task.tags && task.tags.length > 0 && (
        <View style={styles.tags}>
          {task.tags.slice(0, 3).map((tag) => (
            <View
              key={tag.id}
              style={[styles.tag, { backgroundColor: tag.color + '20' }]}
            >
              <Text style={[styles.tagText, { color: tag.color }]}>
                {tag.name}
              </Text>
            </View>
          ))}
          {task.tags.length > 3 && (
            <Text style={styles.moreTags}>+{task.tags.length - 3}</Text>
          )}
        </View>
      )}
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: Colors.surface,
    borderRadius: 16,
    padding: 16,
    marginBottom: 12,
    shadowColor: Colors.black,
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.05,
    shadowRadius: 8,
    elevation: 2
  },
  containerCompact: {
    padding: 12
  },
  header: {
    flexDirection: 'row',
    alignItems: 'flex-start',
    gap: 12
  },
  titleContainer: {
    flex: 1
  },
  title: {
    fontSize: 16,
    fontWeight: '600',
    color: Colors.text,
    lineHeight: 22
  },
  titleCompleted: {
    textDecorationLine: 'line-through',
    color: Colors.textSecondary
  },
  projectName: {
    fontSize: 12,
    color: Colors.textSecondary,
    marginTop: 4
  },
  description: {
    fontSize: 14,
    color: Colors.textSecondary,
    marginTop: 8,
    lineHeight: 20
  },
  footer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginTop: 12,
    paddingTop: 12,
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: Colors.border
  },
  dueDate: {
    fontSize: 13,
    color: Colors.textSecondary
  },
  dueDateOverdue: {
    color: Colors.error
  },
  dueDateToday: {
    color: Colors.warning
  },
  subtasks: {
    fontSize: 13,
    color: Colors.textSecondary
  },
  tags: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    marginTop: 12,
    gap: 8
  },
  tag: {
    paddingHorizontal: 10,
    paddingVertical: 4,
    borderRadius: 6
  },
  tagText: {
    fontSize: 12,
    fontWeight: '500'
  },
  moreTags: {
    fontSize: 12,
    color: Colors.textSecondary,
    paddingVertical: 4
  }
});

4.4 页面组件实现

页面组件是应用的具体视图层,负责将数据和UI组件组装成完整的页面。每个页面组件都应该保持相对轻薄,将复杂的业务逻辑委托给services层和hooks。以下是几个核心页面组件的实现。

// screens/TaskListScreen/TaskListScreen.tsx
import React, { useCallback, useMemo } from 'react';
import {
  View,
  Text,
  StyleSheet,
  FlatList,
  RefreshControl,
  TouchableOpacity
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTaskStore } from '@stores/taskStore';
import { useAuthStore } from '@/stores/authStore';
import { Colors } from '@constants/theme';
import { TaskCard } from '@components/molecules/TaskCard';
import { EmptyState } from '@components/molecules/EmptyState';
import { FilterBar } from '@components/molecules/FilterBar';
import { TaskStackParamList, RootStackParamList } from '@/navigation/types';
import { Task, TaskFilter } from '@/types';
import { useFilterTasks } from '@/hooks/useFilterTasks';

type NavigationProp = NativeStackNavigationProp<TaskStackParamList & RootStackParamList>;

export function TaskListScreen() {
  const navigation = useNavigation<NavigationProp>();
  const { tasks, isLoading, fetchTasks, selectedTask, selectTask } = useTaskStore();
  const { user } = useAuthStore();
  const [filter, setFilter] = React.useState<TaskFilter>({
    status: 'all',
    priority: 'all',
    projectId: null,
    tagIds: []
  });

  // 应用筛选逻辑
  const filteredTasks = useFilterTasks(tasks, filter);

  // 按状态分组
  const groupedTasks = useMemo(() => {
    const overdue: Task[] = [];
    const today: Task[] = [];
    const upcoming: Task[] = [];
    const completed: Task[] = [];

    filteredTasks.forEach((task) => {
      if (task.status === 'completed') {
        completed.push(task);
      } else if (task.dueDate) {
        const dueDate = new Date(task.dueDate);
        const now = new Date();
        const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);

        if (dueDate < now) {
          overdue.push(task);
        } else if (dueDate <= todayEnd) {
          today.push(task);
        } else {
          upcoming.push(task);
        }
      } else {
        upcoming.push(task);
      }
    });

    return { overdue, today, upcoming, completed };
  }, [filteredTasks]);

  const handleRefresh = useCallback(async () => {
    await fetchTasks();
  }, [fetchTasks]);

  const handleTaskPress = useCallback(
    (task: Task) => {
      selectTask(task);
      navigation.navigate('TaskDetail', { taskId: task.id });
    },
    [navigation, selectTask]
  );

  const handleCreateTask = useCallback(() => {
    navigation.navigate('CreateTask', {});
  }, [navigation]);

  const renderSection = (title: string, taskList: Task[], empty?: string) => {
    if (taskList.length === 0) return null;

    return (
      <View style={styles.section}>
        <View style={styles.sectionHeader}>
          <Text style={styles.sectionTitle}>{title}</Text>
          <Text style={styles.sectionCount}>{taskList.length}</Text>
        </View>
        {taskList.map((task) => (
          <TaskCard
            key={task.id}
            task={task}
            onPress={() => handleTaskPress(task)}
            showProject
          />
        ))}
      </View>
    );
  };

  return (
    <View style={styles.container}>
      <FilterBar filter={filter} onFilterChange={setFilter} />

      <FlatList
        data={[]}
        renderItem={() => null}
        ListHeaderComponent={
          <>
            {renderSection('已逾期', groupedTasks.overdue)}
            {renderSection('今日待办', groupedTasks.today)}
            {renderSection('即将到期', groupedTasks.upcoming)}
            {renderSection('已完成', groupedTasks.completed)}
          </>
        }
        ListEmptyComponent={
          <EmptyState
            icon="clipboard"
            title="暂无任务"
            description="点击下方按钮创建第一个任务"
            action={{
              label: '创建任务',
              onPress: handleCreateTask
            }}
          />
        }
        refreshControl={
          <RefreshControl
            refreshing={isLoading}
            onRefresh={handleRefresh}
            tintColor={Colors.primary}
          />
        }
        contentContainerStyle={styles.listContent}
        showsVerticalScrollIndicator={false}
      />

      <TouchableOpacity style={styles.fab} onPress={handleCreateTask}>
        <Text style={styles.fabIcon}>+</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.background
  },
  listContent: {
    paddingHorizontal: 16,
    paddingBottom: 100
  },
  section: {
    marginBottom: 24
  },
  sectionHeader: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 12
  },
  sectionTitle: {
    fontSize: 16,
    fontWeight: '600',
    color: Colors.text
  },
  sectionCount: {
    fontSize: 14,
    color: Colors.textSecondary,
    marginLeft: 8,
    backgroundColor: Colors.surface,
    paddingHorizontal: 8,
    paddingVertical: 2,
    borderRadius: 10
  },
  fab: {
    position: 'absolute',
    right: 20,
    bottom: 20,
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: Colors.primary,
    alignItems: 'center',
    justifyContent: 'center',
    shadowColor: Colors.primary,
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.3,
    shadowRadius: 8,
    elevation: 8
  },
  fabIcon: {
    fontSize: 28,
    color: Colors.white,
    fontWeight: '300'
  }
});
// screens/CreateTaskScreen/CreateTaskScreen.tsx
import React, { useState, useCallback } from 'react';
import {
  View,
  Text,
  StyleSheet,
  ScrollView,
  KeyboardAvoidingView,
  Platform,
  Alert
} from 'react-native';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors } from '@constants/theme';
import { Input } from '@components/atoms/Input';
import { Button } from '@components/atoms/Button';
import { DatePicker } from '@components/molecules/DatePicker';
import { PrioritySelector } from '@components/molecules/PrioritySelector';
import { ProjectSelector } from '@/components/molecules/ProjectSelector';
import { TagSelector } from '@/components/molecules/TagSelector';
import { useTaskStore } from '@/stores/taskStore';
import { TaskStackParamList } from '@/navigation/types';
import { Task, CreateTaskDTO } from '@/types';

type RouteProps = RouteProp<TaskStackParamList, 'CreateTask'>;
type NavigationProp = NativeStackNavigationProp<TaskStackParamList>;

export function CreateTaskScreen() {
  const navigation = useNavigation<NavigationProp>();
  const route = useRoute<RouteProps>();
  const { createTask, isLoading } = useTaskStore();

  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [dueDate, setDueDate] = useState<Date | null>(null);
  const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
  const [projectId, setProjectId] = useState<string | null>(
    route.params?.projectId || null
  );
  const [selectedTags, setSelectedTags] = useState<string[]>([]);

  const [errors, setErrors] = useState<Record<string, string>>({});

  const validate = useCallback(() => {
    const newErrors: Record<string, string> = {};

    if (!title.trim()) {
      newErrors.title = '请输入任务标题';
    } else if (title.length > 200) {
      newErrors.title = '任务标题不能超过200个字符';
    }

    if (description.length > 2000) {
      newErrors.description = '任务描述不能超过2000个字符';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }, [title, description]);

  const handleSubmit = useCallback(async () => {
    if (!validate()) return;

    try {
      const taskData: CreateTaskDTO = {
        title: title.trim(),
        description: description.trim() || undefined,
        dueDate: dueDate?.toISOString(),
        priority,
        projectId,
        tagIds: selectedTags
      };

      await createTask(taskData);
      navigation.goBack();
    } catch (error) {
      Alert.alert('创建失败', '请稍后重试');
    }
  }, [title, description, dueDate, priority, projectId, selectedTags, validate, createTask, navigation]);

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
    >
      <ScrollView
        style={styles.scrollView}
        contentContainerStyle={styles.scrollContent}
        keyboardShouldPersistTaps="handled"
        showsVerticalScrollIndicator={false}
      >
        <Input
          label="任务标题"
          placeholder="请输入任务标题"
          value={title}
          onChangeText={setTitle}
          error={errors.title}
          maxLength={200}
          autoFocus
        />

        <Input
          label="任务描述"
          placeholder="详细描述任务内容..."
          value={description}
          onChangeText={setDescription}
          error={errors.description}
          multiline
          numberOfLines={4}
          maxLength={2000}
          textAlignVertical="top"
          style={styles.descriptionInput}
        />

        <View style={styles.field}>
          <Text style={styles.label}>截止日期</Text>
          <DatePicker
            value={dueDate}
            onChange={setDueDate}
            placeholder="选择截止日期"
            minDate={new Date()}
          />
        </View>

        <PrioritySelector value={priority} onChange={setPriority} />

        <View style={styles.field}>
          <Text style={styles.label}>所属项目</Text>
          <ProjectSelector
            value={projectId}
            onChange={setProjectId}
            placeholder="选择项目(可选)"
          />
        </View>

        <View style={styles.field}>
          <Text style={styles.label}>标签</Text>
          <TagSelector
            value={selectedTags}
            onChange={setSelectedTags}
            placeholder="选择标签(可选)"
          />
        </View>
      </ScrollView>

      <View style={styles.footer}>
        <Button
          title="取消"
          variant="secondary"
          onPress={() => navigation.goBack()}
          style={styles.cancelButton}
        />
        <Button
          title="创建任务"
          onPress={handleSubmit}
          loading={isLoading}
          style={styles.submitButton}
        />
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.background
  },
  scrollView: {
    flex: 1
  },
  scrollContent: {
    padding: 16,
    paddingBottom: 100
  },
  field: {
    marginBottom: 16
  },
  label: {
    fontSize: 14,
    fontWeight: '500',
    color: Colors.text,
    marginBottom: 8
  },
  descriptionInput: {
    minHeight: 120
  },
  footer: {
    flexDirection: 'row',
    padding: 16,
    paddingBottom: Platform.OS === 'ios' ? 34 : 16,
    backgroundColor: Colors.surface,
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: Colors.border,
    gap: 12
  },
  cancelButton: {
    flex: 1
  },
  submitButton: {
    flex: 2
  }
});

4.5 服务层实现

服务层封装了应用的业务逻辑,是连接数据层和表现层的桥梁。良好的服务层设计能够让业务逻辑得到复用,同时保持代码的清晰和可测试性。以下是核心服务类的实现。

// services/TaskService.ts
import { API_CLIENT } from '@/api/client';
import { Task, CreateTaskDTO, UpdateTaskDTO, TaskFilters } from '@/types';
import { handleApiError } from '@/utils/errorHandling';

class TaskService {
  private readonly baseUrl = '/api/v1/tasks';

  async getTasks(filters?: TaskFilters): Promise<Task[]> {
    try {
      const params = new URLSearchParams();

      if (filters?.projectId) {
        params.append('projectId', filters.projectId);
      }
      if (filters?.status && filters.status !== 'all') {
        params.append('status', filters.status);
      }
      if (filters?.priority && filters.priority !== 'all') {
        params.append('priority', filters.priority);
      }
      if (filters?.tagIds?.length) {
        params.append('tagIds', filters.tagIds.join(','));
      }
      if (filters?.assigneeId) {
        params.append('assigneeId', filters.assigneeId);
      }

      const queryString = params.toString();
      const url = queryString ? `${this.baseUrl}?${queryString}` : this.baseUrl;

      const response = await API_CLIENT.get<Task[]>(url);
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async getTaskById(id: string): Promise<Task> {
    try {
      const response = await API_CLIENT.get<Task>(`${this.baseUrl}/${id}`);
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async createTask(data: CreateTaskDTO): Promise<Task> {
    try {
      const response = await API_CLIENT.post<Task>(this.baseUrl, data);
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async updateTask(id: string, data: UpdateTaskDTO): Promise<Task> {
    try {
      const response = await API_CLIENT.patch<Task>(
        `${this.baseUrl}/${id}`,
        data
      );
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async deleteTask(id: string): Promise<void> {
    try {
      await API_CLIENT.delete(`${this.baseUrl}/${id}`);
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async toggleTaskStatus(id: string): Promise<Task> {
    try {
      const response = await API_CLIENT.post<Task>(
        `${this.baseUrl}/${id}/toggle-status`
      );
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async addSubtask(
    taskId: string,
    subtask: { title: string }
  ): Promise<Task> {
    try {
      const response = await API_CLIENT.post<Task>(
        `${this.baseUrl}/${taskId}/subtasks`,
        subtask
      );
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async toggleSubtask(
    taskId: string,
    subtaskId: string
  ): Promise<Task> {
    try {
      const response = await API_CLIENT.post<Task>(
        `${this.baseUrl}/${taskId}/subtasks/${subtaskId}/toggle`
      );
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async deleteSubtask(taskId: string, subtaskId: string): Promise<Task> {
    try {
      const response = await API_CLIENT.delete<Task>(
        `${this.baseUrl}/${taskId}/subtasks/${subtaskId}`
      );
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async bulkUpdateStatus(
    taskIds: string[],
    status: 'pending' | 'in_progress' | 'completed'
  ): Promise<Task[]> {
    try {
      const response = await API_CLIENT.patch<Task[]>('/api/v1/tasks/bulk', {
        taskIds,
        status
      });
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async bulkDelete(taskIds: string[]): Promise<void> {
    try {
      await API_CLIENT.post('/api/v1/tasks/bulk-delete', { taskIds });
    } catch (error) {
      throw handleApiError(error);
    }
  }

  async getTaskStatistics(): Promise<TaskStatistics> {
    try {
      const response = await API_CLIENT.get<TaskStatistics>(
        `${this.baseUrl}/statistics`
      );
      return response.data;
    } catch (error) {
      throw handleApiError(error);
    }
  }
}

export interface TaskStatistics {
  total: number;
  pending: number;
  inProgress: number;
  completed: number;
  overdue: number;
  byPriority: {
    high: number;
    medium: number;
    low: number;
  };
  byProject: {
    projectId: string;
    projectName: string;
    count: number;
  }[];
}

export const taskService = new TaskService();

4.6 API客户端配置

API客户端是应用与后端服务器通信的入口点,良好的API客户端设计能够统一处理认证、错误处理、日志记录等横切关注点。以下是我们为项目配置的API客户端实现。

// api/client.ts
import axios, {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  AxiosError
} from 'axios';
import * as SecureStore from 'expo-secure-store';
import { API_BASE_URL, API_TIMEOUT } from '@constants/config';
import { AuthTokenService } from '@/services/AuthTokenService';
import { handleApiError, ApiError } from '@/utils/errorHandling';
import { logger } from '@/utils/logger';

class ApiClient {
  private client: AxiosInstance;
  private isRefreshing: boolean = false;
  private refreshSubscribers: ((token: string) => void)[] = [];

  constructor() {
    this.client = axios.create({
      baseURL: API_BASE_URL,
      timeout: API_TIMEOUT,
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json'
      }
    });

    this.setupInterceptors();
  }

  private setupInterceptors() {
    // 请求拦截器:添加认证Token
    this.client.interceptors.request.use(
      async (config) => {
        const token = await AuthTokenService.getAccessToken();
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }

        // 添加请求ID用于追踪
        config.headers['X-Request-ID'] = this.generateRequestId();

        // 开发环境添加日志
        if (__DEV__) {
          logger.debug(`[API Request] ${config.method?.toUpperCase()} ${config.url}`);
        }

        return config;
      },
      (error) => {
        logger.error('[API Request Error]', error);
        return Promise.reject(error);
      }
    );

    // 响应拦截器:处理错误和Token刷新
    this.client.interceptors.response.use(
      (response) => {
        if (__DEV__) {
          logger.debug(`[API Response] ${response.status} ${response.config.url}`);
        }
        return response;
      },
      async (error: AxiosError) => {
        const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };

        // 处理401错误:尝试刷新Token
        if (error.response?.status === 401 && !originalRequest._retry) {
          if (this.isRefreshing) {
            // 等待Token刷新完成
            return new Promise((resolve) => {
              this.refreshSubscribers.push((token: string) => {
                if (originalRequest.headers) {
                  originalRequest.headers.Authorization = `Bearer ${token}`;
                }
                resolve(this.client(originalRequest));
              });
            });
          }

          originalRequest._retry = true;
          this.isRefreshing = true;

          try {
            const newToken = await this.refreshToken();
            this.refreshSubscribers.forEach((callback) => callback(newToken));
            this.refreshSubscribers = [];

            if (originalRequest.headers) {
              originalRequest.headers.Authorization = `Bearer ${newToken}`;
            }
            return this.client(originalRequest);
          } catch (refreshError) {
            // Token刷新失败,清除登录状态
            await AuthTokenService.clearTokens();
            // 可以在这里触发全局的登出事件
            return Promise.reject(refreshError);
          } finally {
            this.isRefreshing = false;
          }
        }

        // 其他错误转换为统一的ApiError
        return Promise.reject(handleApiError(error));
      }
    );
  }

  private async refreshToken(): Promise<string> {
    const refreshToken = await AuthTokenService.getRefreshToken();
    if (!refreshToken) {
      throw new Error('No refresh token available');
    }

    const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {
      refreshToken
    });

    const { accessToken, refreshToken: newRefreshToken } = response.data;

    await AuthTokenService.setTokens(accessToken, newRefreshToken);

    return accessToken;
  }

  private generateRequestId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  async get<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
    return this.client.get<T>(url, config);
  }

  async post<T>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): Promise<AxiosResponse<T>> {
    return this.client.post<T>(url, data, config);
  }

  async put<T>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): Promise<AxiosResponse<T>> {
    return this.client.put<T>(url, data, config);
  }

  async patch<T>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): Promise<AxiosResponse<T>> {
    return this.client.patch<T>(url, data, config);
  }

  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
    return this.client.delete<T>(url, config);
  }
}

export const API_CLIENT = new ApiClient();

第五部分:性能优化与最佳实践

5.1 性能优化策略

移动应用的性能直接影响用户体验,良好的性能优化能够让应用运行更加流畅,减少电量消耗。以下是我们总结的Expo应用性能优化策略和实践方法。

组件渲染优化是React应用性能优化的基础。React的虚拟DOM虽然已经做了很多优化,但在复杂应用中,不必要的组件重新渲染仍然会造成性能问题。我们使用React.memo来包装纯展示组件,避免在父组件状态变化时重新渲染没有变化的子组件。对于需要比较props的组件,我们提供自定义的比较函数来精确控制何时需要重新渲染。

// 使用React.memo进行组件优化
const TaskCard = React.memo(
  ({ task, onPress }: TaskCardProps) => {
    return (
      <TouchableOpacity onPress={() => onPress(task.id)}>
        <Text>{task.title}</Text>
      </TouchableOpacity>
    );
  },
  (prevProps, nextProps) => {
    // 自定义比较函数:当这些属性都没变时才跳过渲染
    return (
      prevProps.task.id === nextProps.task.id &&
      prevProps.task.title === nextProps.task.title &&
      prevProps.task.status === nextProps.task.status &&
      prevProps.onPress === nextProps.onPress
    );
  }
);

列表渲染优化对于展示大量数据的应用尤为重要。FlatList是React Native中用于高效渲染列表的组件,它只会渲染当前屏幕可见的元素,大大减少了内存占用和渲染时间。我们需要正确配置keyExtractor、getItemLayout等属性来实现最佳性能。

// 高性能FlatList配置
<FlatList
  data={tasks}
  keyExtractor={(item) => item.id}
  renderItem={({ item, index }) => (
    <TaskCard
      task={item}
      onPress={handleTaskPress}
      index={index}
    />
  )}
  // 使用getItemLayout提供固定高度的列表项
  getItemLayout={(data, index) => ({
    length: TASK_CARD_HEIGHT,
    offset: TASK_CARD_HEIGHT * index,
    index
  })}
  // 预加载附近区域的内容
  windowSize={5}
  // 最大一次性渲染的条目数
  maxToRenderPerBatch={10}
  // 批量更新之间的间隔
  updateCellsBatchingPeriod={50}
  // 移除不可见的元素
  removeClippedSubviews={true}
  // 初始渲染数量
  initialNumToRender={10}
  // 下拉刷新
  refreshing={isLoading}
  onRefresh={onRefresh}
  // 上拉加载更多
  onEndReached={loadMore}
  onEndReachedThreshold={0.5}
/>

图片优化是移动应用性能的重要组成部分。我们使用expo-image组件来实现图片的自动优化,包括缓存、格式转换、尺寸调整等。expo-image支持多种图片格式和加载策略,能够显著提升图片加载速度和用户体验。

// 使用expo-image进行图片优化
import { Image } from 'expo-image';

<Image
  source={{ uri: task.thumbnailUrl }}
  style={styles.thumbnail}
  // 内容模式
  contentFit="cover"
  // 过渡动画
  transition={200}
  // 占位图
  placeholder={{ blurhash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj' }}
  // 缓存策略
  cachePolicy="memory-disk"
/>

5.2 离线支持实现

离线支持是现代移动应用的重要特性,它允许用户在网络不稳定或无网络的情况下正常使用应用。我们采用本地数据库加同步队列的策略来实现完整的离线支持。

首先,我们需要配置SQLite数据库来存储离线数据。expo-sqlite提供了便捷的SQLite操作接口,我们创建数据库schema和仓储类来管理本地数据。

// repositories/LocalTaskRepository.ts
import * as SQLite from 'expo-sqlite';
import { Task, LocalTask } from '@/types';
import { generateId } from '@/utils/idGenerator';

class LocalTaskRepository {
  private db: SQLite.SQLiteDatabase | null = null;

  async initialize() {
    this.db = await SQLite.openDatabaseAsync('taskmaster.db');
    await this.createTables();
  }

  private async createTables() {
    if (!this.db) throw new Error('Database not initialized');

    await this.db.execAsync(`
      CREATE TABLE IF NOT EXISTS tasks (
        id TEXT PRIMARY KEY,
        server_id TEXT,
        title TEXT NOT NULL,
        description TEXT,
        status TEXT DEFAULT 'pending',
        priority TEXT DEFAULT 'medium',
        project_id TEXT,
        due_date TEXT,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        is_synced INTEGER DEFAULT 0,
        is_deleted INTEGER DEFAULT 0
      );

      CREATE TABLE IF NOT EXISTS task_tags (
        task_id TEXT,
        tag_id TEXT,
        PRIMARY KEY (task_id, tag_id)
      );

      CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
      CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
      CREATE INDEX IF NOT EXISTS idx_tasks_sync ON tasks(is_synced);
    `);
  }

  async saveTask(task: LocalTask): Promise<void> {
    if (!this.db) throw new Error('Database not initialized');

    await this.db.runAsync(
      `INSERT OR REPLACE INTO tasks
       (id, server_id, title, description, status, priority, project_id, due_date, created_at, updated_at, is_synced, is_deleted)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        task.id,
        task.serverId,
        task.title,
        task.description,
        task.status,
        task.priority,
        task.projectId,
        task.dueDate,
        task.createdAt,
        task.updatedAt,
        task.isSynced ? 1 : 0,
        task.isDeleted ? 1 : 0
      ]
    );
  }

  async getUnsyncedTasks(): Promise<LocalTask[]> {
    if (!this.db) throw new Error('Database not initialized');

    const rows = await this.db.getAllAsync<any>(
      'SELECT * FROM tasks WHERE is_synced = 0'
    );

    return rows.map(this.mapRowToTask);
  }

  async markAsSynced(localId: string, serverId: string): Promise<void> {
    if (!this.db) throw new Error('Database not initialized');

    await this.db.runAsync(
      'UPDATE tasks SET server_id = ?, is_synced = 1 WHERE id = ?',
      [serverId, localId]
    );
  }

  async getTasks(projectId?: string): Promise<LocalTask[]> {
    if (!this.db) throw new Error('Database not initialized');

    let query = 'SELECT * FROM tasks WHERE is_deleted = 0';
    const params: any[] = [];

    if (projectId) {
      query += ' AND project_id = ?';
      params.push(projectId);
    }

    query += ' ORDER BY created_at DESC';

    const rows = await this.db.getAllAsync<any>(query, params);
    return rows.map(this.mapRowToTask);
  }

  async deleteTask(id: string): Promise<void> {
    if (!this.db) throw new Error('Database not initialized');

    // 软删除
    await this.db.runAsync(
      'UPDATE tasks SET is_deleted = 1, is_synced = 0, updated_at = ? WHERE id = ?',
      [new Date().toISOString(), id]
    );
  }

  private mapRowToTask(row: any): LocalTask {
    return {
      id: row.id,
      serverId: row.server_id,
      title: row.title,
      description: row.description,
      status: row.status,
      priority: row.priority,
      projectId: row.project_id,
      dueDate: row.due_date,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
      isSynced: row.is_synced === 1,
      isDeleted: row.is_deleted === 1
    };
  }
}

export const localTaskRepository = new LocalTaskRepository();

5.3 推送通知集成

推送通知是移动应用与用户保持连接的重要渠道,它能够帮助我们及时向用户传达重要信息,提升应用的活跃度和用户粘性。Expo提供了expo-notifications库来简化推送通知的集成。

// services/NotificationService.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { Task, Reminder } from '@/types';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';

// 配置通知处理器
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
    shouldShowBanner: true,
    shouldShowList: true
  })
});

class NotificationService {
  private permissionGranted: boolean = false;

  async initialize(): Promise<boolean> {
    if (!Device.isDevice) {
      console.log('Push notifications require a physical device');
      return false;
    }

    const { status: existingStatus } = await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;

    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }

    this.permissionGranted = finalStatus === 'granted';

    if (this.permissionGranted && Platform.OS === 'android') {
      // 为Android设置默认通道
      await Notifications.setNotificationChannelAsync('default', {
        name: 'default',
        importance: Notifications.AndroidImportance.MAX,
        vibrationPattern: [0, 250, 250, 250],
        lightColor: '#4A90D9'
      });

      // 为任务提醒设置单独通道
      await Notifications.setNotificationChannelAsync('task-reminders', {
        name: '任务提醒',
        importance: Notifications.AndroidImportance.HIGH,
        vibrationPattern: [0, 250, 250, 250],
        sound: 'default'
      });
    }

    // 添加通知点击监听器
    this.setupNotificationListeners();

    return this.permissionGranted;
  }

  private setupNotificationListeners() {
    // 前台通知接收
    Notifications.addNotificationReceivedListener((notification) => {
      console.log('Notification received:', notification);
    });

    // 通知点击处理
    Notifications.addNotificationResponseReceivedListener((response) => {
      const data = response.notification.request.content.data;
      // 根据数据导航到相应页面
      if (data.taskId) {
        // 可以通过事件总线或其他方式通知导航
        console.log('User wants to view task:', data.taskId);
      }
    });
  }

  async scheduleTaskReminder(task: Task, reminderTime: Date): Promise<string> {
    if (!this.permissionGranted) {
      console.log('Notification permission not granted');
      return '';
    }

    const identifier = await Notifications.scheduleNotificationAsync({
      content: {
        title: '任务提醒',
        body: task.title,
        data: {
          taskId: task.id,
          projectId: task.projectId,
          type: 'task-reminder'
        },
        sound: 'default'
      },
      trigger: {
        date: reminderTime,
        type: Notifications.SchedulableTriggerInputTypes.DATE
      }
    });

    return identifier;
  }

  async scheduleDailySummary(hour: number = 9, minute: number = 0): Promise<string> {
    if (!this.permissionGranted) {
      return '';
    }

    const now = new Date();
    const scheduledDate = new Date();
    scheduledDate.setHours(hour, minute, 0, 0);

    if (scheduledDate <= now) {
      scheduledDate.setDate(scheduledDate.getDate() + 1);
    }

    return Notifications.scheduleNotificationAsync({
      content: {
        title: '每日任务概览',
        body: '查看今天的待办任务',
        data: { type: 'daily-summary' },
        sound: 'default'
      },
      trigger: {
        type: Notifications.SchedulableTriggerInputTypes.DATE,
        date: scheduledDate
      }
    });
  }

  async cancelNotification(identifier: string): Promise<void> {
    await Notifications.cancelScheduledNotificationAsync(identifier);
  }

  async cancelAllNotifications(): Promise<void> {
    await Notifications.cancelAllScheduledNotificationsAsync();
  }

  async getBadgeCount(): Promise<number> {
    return await Notifications.getBadgeCountAsync();
  }

  async setBadgeCount(count: number): Promise<void> {
    await Notifications.setBadgeCountAsync(count);
  }
}

export const notificationService = new NotificationService();

总结与展望

通过本文的实战讲解,我们完整地了解了使用Expo开发移动应用的完整流程。从技术选型开始,我们分析了为什么选择Expo以及配套的技术栈;在业务分析阶段,我们以任务管理应用为例进行了需求梳理和功能模块划分;架构设计部分我们详细讲解了分层架构、目录结构、状态管理模式;代码实现部分我们提供了核心组件、服务层、API客户端的详细实现;最后我们还探讨了性能优化和离线支持的最佳实践。

Expo作为React Native的官方推荐开发工具链,已经发展成为一个成熟稳定的移动应用开发平台。它不仅简化了开发流程,降低了入门门槛,还提供了丰富的生态系统和工具支持。随着Expo的持续迭代和社区的活跃发展,相信它会在跨平台移动应用开发领域发挥越来越重要的作用。

在实际开发中,我们还需要持续关注以下几个方面:一是保持对Expo SDK更新的跟进,及时迁移到新版本以获得更好的性能和新的功能;二是建立完善的测试体系,包括单元测试、集成测试和E2E测试,确保应用质量;三是重视可访问性设计,让应用能够服务于更广泛的用户群体;四是持续优化应用性能,关注内存占用、启动时间、交互响应等关键指标。

希望本文能够帮助读者建立起Expo开发的完整知识体系,并在实际项目中应用这些最佳实践,开发出优秀的移动应用产品。

状态管理与架构篇-异步状态管理:加载、空态、错误态统一处理

2026年3月26日 00:26

异步状态管理:加载、空态、错误态统一处理

系列:状态管理与架构篇 · 第 4 篇

列表页、详情页、表单提交,接口一多,页面里很容易出现同一种写法:isLoadingisEmptyerrorMessage 各管一摊,有的地方还要加个 hasLoaded 区分「真没数据」和「还没请求」。 Riverpod 里如果继续沿用这套,不是不能跑,而是状态组合一膨胀,UI 分支和单测都很难收尾

这篇只聊一件事:异步结果在 Riverpod 里怎么收敛成一种形状,让加载、空列表、业务错误、网络异常在同一套逻辑里过完,而不是每个页面重新发明轮子。


1. 问题背景

典型场景:首屏进页面要拉列表,下拉刷新、上拉分页、某个 Tab 切换再请求一次。 产品还会要求:首次加载要有占位,刷新失败要在当前列表上提示,空列表要给运营位或引导文案。

工程里常见现象:

  • FutureProvider 或手写的 StateNotifier 里,state 是普通 model,成功时塞数据,失败时要么 debugPrint 要么弹 Toast,页面上还是那个旧数据,状态里看不出这次请求是失败还是陈旧
  • 空态和错误态混用:接口返回 [] 和业务错误都走「列表为空」,用户没法区分是「真的没有」还是「挂了」。
  • 多个请求并行:refreshloadMore 同时改同一份 state,最后一笔写入赢了,早返回的请求把新数据覆盖掉

这些问题的根源不是 Riverpod 难用,而是没有把「异步的一次尝试」建模清楚


2. 原因分析

Flutter 官方在 AsyncValue 里已经把异步结果的形态说死了:loading / data / error,再配上 AsyncValue.guardtry/catch 收成统一类型。 很多项目没用起来,通常有两类原因。

一是历史包袱:StateNotifier<HomeState> 这类 state 一开始就按「成功后的界面」去设计,HomeState 里没有地方放「这次请求是否进行中」,于是一切补丁都往 Widget 或局部变量里塞。

二是把「列表数据」和「分页游标」绑死在同一个可变对象上(例如在 model 上直接 addAll、改 pageNum)。 可变结构 + 多入口更新 时,很难证明任意时刻 UI 和内存是一致的;测试也只能依赖跑真接口或整段 mock。

所以从原理上讲,要统一三态,关键是:

  • 页面上读的应该是 「当前这一轮异步结果的投影」,而不是「永远在变的业务对象草稿」。
  • 加载中是否要保留上一版成功数据(skeleton / stale-while-revalidate)要明确策略,不能每个页面即兴发挥。

3. 解决方案

Riverpod 2 里用 AsyncNotifier(或代码生成的 @riverpod class X extends _$X)管「单一数据源」比较合适:build() 负责冷启动加载,refreshAsyncValue.guard 包一层,内部照旧调 repository。

3.1 列表:AsyncValue + 保留旧数据

分页列表常见需求:刷新转圈时别把老列表闪没。 做法可以是 state 里同时持有 AsyncValue<List<Item>> 和「当前用于展示的列表」:loading 时用上一份 data,只有 error 时决定是清空还是保留(按产品来,但要写进 Notifier 注释里,避免半年后有人当 bug 改回去)。

更省事的一种是:用 AsyncValue 只承载最近一次完整请求的结果,分页增量放在不可变结构里,例如每次 loadMore 成功就 state = state.copyWith(items: [...old, ...new], page: nextPage)避免对同一个 List 实例原地 addAll,这样快照清晰,也方便对比测试。

3.2 空态 vs 错误态

  • AsyncDatadata.isEmpty:当作业务空态,展示空页面组件。
  • AsyncError:展示错误占位 + 重试。 不要让错误走 Toast 就结束,至少让 ref.watch 的订阅方还能画出一块错误 UI;Toast 可以额外加,不能替代 state。

若接口把「空」和「错」都落成 HTTP 200 + 空 body,只能在 repository 层做一次区分(例如业务 code 非 0 转成 throw),别在 Widget 里猜

3.3 并发:取消或忽略陈旧结果

refresh 连点或 Tab 快速切换时,只保留最后一次请求的结果。 可以用 CancelToken、也可以 int 自增 generation,resolve 回来时比对,不匹配就丢弃。 这一点和用不用 Riverpod 无关,但 Notifer 是唯一合适做这件事的地方,写在页面里一定会漏。


4. 关键代码

下面片段是示意:用生成式 Provider(riverpod_generator)时,类名、文件名按你们规范来即可,逻辑不变。

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'room_feed_notifier.g.dart';

@riverpod
class RoomFeed extends _$RoomFeed {
  @override
  Future<List<RoomSummary>> build() async {
    return _fetchPage(1);
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => _fetchPage(1));
  }

  Future<void> loadMore() async {
    final previous = state.valueOrNull ?? const <RoomSummary>[];
    final nextPage = (previous.length ~/ pageSize) + 1;
    state = await AsyncValue.guard(() async {
      final more = await _fetchPage(nextPage);
      if (more.isEmpty) return previous;
      return [...previous, ...more];
    });
  }

  Future<List<RoomSummary>> _fetchPage(int page) async {
    final repo = ref.read(roomRepositoryProvider);
    return repo.list(page: page, size: pageSize);
  }
}

页面侧尽量只保留一种写法:

final asyncRooms = ref.watch(roomFeedProvider);

return asyncRooms.when(
  loading: () => const RoomListSkeleton(),
  error: (e, st) => RoomErrorView(
    message: e.toString(),
    onRetry: () => ref.read(roomFeedProvider.notifier).refresh(),
  ),
  data: (list) => list.isEmpty
      ? const RoomEmptyView()
      : RoomListView(items: list),
);

如果希望和「上一版数据」共存,可以把 when 换成 whenOrNull 或自定义 extension,在 loading 分支里读 asyncRooms.valueOrPrevious,这属于小优化,先在团队里统一一种别混用即可


5. 效果验证

操作上可以直接看两件事:一是进列表页、断网、恢复网络点重试,同一套 when 分支是否都能走到且不需要手动 setState;二是在测试里对 Notifier 打桩 repository,refresh 抛异常时 state 是否为 AsyncError,Empty 时是否为 AsyncData([])。 能稳定断言这两类,后面加 Tab、加筛选不容易把边界改丢。


6. 可复用结论

  • 异步列表优先用 AsyncValue 表达一次请求的语义,空列表和异常不要混在同一个「长度为零」里糊弄过去。
  • 分页尽量不可变拼接,少在共用的 model 上原地改 pageNum、原地 addAll,并发场景会轻松很多。
  • 并发只认最后一次结果:generation 或取消写在 Notifier 里,别散落在 UI 事件里。
  • UI 只订阅一种 state 形状,loading / error / data(empty) 分支齐全,比一堆 bool 更经得起迭代。

状态管理与架构篇-ViewModel 如何写得可测试、可复用

2026年3月25日 23:57

ViewModel 如何写得可测试、可复用

系列:状态管理与架构篇(2/6)
建议标签:FlutterRiverpod单元测试架构

上一篇把 Riverpod 拆成 data / application / presentation。这一篇盯着 application 里那一层:习惯叫 ViewModel、Presenter 或 Notifier 都行,关键是:它能不能脱离界面单测、能不能在另一个页面原样接上


1. 问题背景:业务场景 + 现象

真实业务里,ViewModel 往往从「几个 ref.watch + 调接口」长成一坨:

  • 改一个校验规则,不敢动,因为只有真机点一遍才放心。
  • 列表页和弹窗里都要「选标签」,拷贝两份 Notifier,改一处漏一处。
  • 单测要么不写,要么起完整 App、mock 一整条网络链,维护成本比业务还大
  • PR 里经常出现:BuildContextGoRouterSnackBar 混在 Notifier 里。

目标很简单:ViewModel = 可替换依赖的协调层 + 可观察的状态;测试时只换依赖,不重写逻辑。


2. 原因分析:核心原理 + 排查过程

2.1 为什么难测

  1. 隐藏依赖:在方法里直接 Dio()GetIt()Firebase.*,测试无法注入替身。
  2. 副作用绑死在状态变更里:改 state 的同时写磁盘、埋点、弹 toast,断言 state 时副作用已经炸一片。
  3. BuildContext:单测没有 Widget 树,一引用就歇菜。
  4. 时间、随机数、设备信息:未抽象就写进分支,快照不稳定。

2.2 为什么难复用

  1. 把「页面专用字段」和「领域共用规则」塞在同一个 State,别处在意的不一样却共享一颗大对象。
  2. Notifier 里写死路由名、Dialog 业务文案,复用就等于复制粘贴再改字符串。
  3. 没有「用例层」或薄 Repository,两个入口各自拼同一套 if-else。

2.3 排查时可以问的三句话

  • 新建一个 ProviderContainer只给 ViewModel 和它的直接依赖,能跑通主流程吗?
  • WidgetRef 换成「外部传入的 Ref / 纯构造函数注入」,逻辑还要改吗?
  • 第二个页面如果只是「展示维度不同」,State 能否拆成共用内核 + 页面扩展

任一题答案是「不能 / 要改很多」,就有分层或拆分空间。


3. 解决方案:方案对比 + 最终选择

3.1 ViewModel 的职责边界(建议定死)

放在 ViewModel 里 不要放进来
组装多个 Repository、做校验与分支 BuildContext、具体路由 API、showDialog
暴露 State / AsyncValue、提供 refresh / submit 直接 Dio、静态 SDK 调用
把 domain 结果映射成「这一屏需要的字段」 UI 组件 import(Widget 只进 presentation)

导航、SnackBar、权限弹窗:由 UI 监听 state 或一次性事件再触发;ViewModel 最多发「该导航了、参数是啥」的不可变数据。

3.2 可测试:依赖注入方式对比

  • 全用具体类:最快,测起来要 mock 整个网络栈。
  • 接口 + Riverpod ProviderProviderContaineroverride 成 fake,单测只关心契约
  • 构造函数注入纯类:Notifier 变薄,重逻辑进 XxxController(纯 Dart),测 Controller 甚至不用 Riverpod。

最终选择:业务复杂就用「接口 + override」;核心算法抽到纯函数/纯类,测试优先写那儿

3.3 可复用:两种常用形态

  1. 参数化 Providerfamily / 带参数的 Provider,同一套 Notifier,不同 id、不同 mode
  2. 内核 State + 派生:共用 TagSelectionCore,列表页和弹窗各包一层「只多一个 scrollOffset 之类」的浅封装,避免两份 submit 逻辑。

4. 关键代码:最小必要代码片段

4.1 ViewModel 只依赖接口

abstract class ProfileRepository {
  Future<Profile> load(String userId);
}

@riverpod
class ProfileVm extends _$ProfileVm {
  @override
  Future<ProfileUi> build(String userId) async {
    final repo = ref.watch(profileRepositoryProvider);
    final p = await repo.load(userId);
    return ProfileUi(nickname: p.nickname, avatarUrl: p.avatarUrl);
  }
}

测试里 profileRepositoryProvider override 成返回固定 Profile,不断网。

4.2 单测:ProviderContainer + override

test('loads profile into ui state', () async {
  final container = ProviderContainer(overrides: [
    profileRepositoryProvider.overrideWithValue(_FakeRepo()),
  ]);
  addTearDown(container.dispose);

  final sub = container.listen(profileVmProvider('u1'), (_, __) {});
  await container.read(profileVmProvider('u1').future);

  final ui = container.read(profileVmProvider('u1')).value!;
  expect(ui.nickname, 'n1');
});

4.3 重逻辑下沉:可同时在多处复用

class OrderDraftValidator {
  String? shippingError(Address? a) {
    if (a == null) return '请填写地址';
    if (a.phone.length < 11) return '手机号不合法';
    return null;
  }
}

@riverpod
class OrderSubmitVm extends _$OrderSubmitVm {
  final _validator = OrderDraftValidator();

  Future<void> submit() async {
    final err = _validator.shippingError(state.shipTo);
    if (err != null) {
      state = state.copyWith(inlineError: err);
      return;
    }
    // ...
  }
}

OrderDraftValidator 的测试零 Riverpod,改规则只动一处。

4.4 导航副作用:用「意图」代替直接 context.go

sealed class ProfileEffect {}
class OpenEditProfile extends ProfileEffect {}

@riverpod
class ProfileScreenVm extends _$ProfileScreenVm {
  final _effects = StreamController<ProfileEffect>.broadcast();
  Stream<ProfileEffect> get effects => _effects.stream;

  void onEditTapped() => _effects.add(OpenEditProfile());
}

页面 initState / ref.listenManual 里监听 effects,再调用路由。ViewModel 不 import go_router


5. 效果验证:数据/截图/日志

flutter test,针对 ViewModel 的用例应能在毫秒级结束,且不拉起 MaterialApp
提交前看 CI:仅改动 OrderDraftValidator 时,只执行对应 test 文件,失败日志里应直接指出断言行,而不是 Widget 树超时。

你们在项目里可以固定两条习惯:

  • 每个新 ViewModel 至少一条「happy path」单测(override repo 即可)。
  • Code Review 看到 Notifier 里出现 BuildContextNavigatorScaffoldMessenger,直接打回。

6. 可复用结论:通用经验 + 避坑清单

经验

  • 能 override 的依赖才是好依赖;具体实现出现在 data + di,ViewModel 只看到接口。
  • 先测纯函数 / 校验器,再测 Notifier 的编排,投入产出最稳。
  • family + 小 State 比「一个大全局 Notifier + 几十字段」更适合复用。
  • 副作用出口收束:要么 UI 监听,要么单独的 Effect 流,不要散落在每个 setter 里。

避坑

  • Notifier 里 static 拉配置、拉账号 ——单测全局污染。
  • build()订阅永远不反弹的 Stream 又不 dispose ——泄漏和诡异重跑。
  • 为了省事 copy static 工具类进第二个 feature ——抽成 core 或 package。
  • 远端 DTO 原样塞进 State ——UI 和协议一变,两个页面一起爆。

下期预告

第 3 篇:Provider select 与局部刷新——列表和长表单里怎么收紧监听粒度、DevTools 里怎样确认重建是否收敛。

🚀 2026 前端生存指南:用 Vite + React 19.2 手搓一个“丝滑”到犯规的项目架构

作者 AI的主人
2026年3月25日 23:35

🚀 2026 前端生存指南:用 Vite + React 19.2 手搓一个“丝滑”到犯规的项目架构

摘要:还在为 Webpack 配置头秃?还在纠结 Vue 和 React 谁才是“正宫”?别争了,2026 年的今天,React 19.2 已经带着它的“自动优化编译器”杀疯了!本文将带你从零开始,用 Vite 极速启动,搭配 React Router 6+,手搓一套能扛住双 11 流量的现代化架构。准备好了吗?我们要让冷启动比你的咖啡冷却得还快!☕️


🎬 序幕:告别“等待”,拥抱“瞬间”

曾几何时,创建一个新项目是这样的:

  1. npm init (等待...)
  2. 安装 Webpack, Babel, Loader, Plugin... (等待 x 100)
  3. 配置 webpack.config.js (写错一行,报错一整天)
  4. 终于 npm start 了,然后看着进度条慢慢爬... (去上个厕所回来还没好)

现在,2026 年了,朋友! 我们只需要一条命令:

npm create vite@latest my-super-app -- --template react

嗖! 项目好了。 再嗖! npm run dev 服务器启动了。 再再嗖! 浏览器打开了。

这就是 Vite 的魔法。它不是脚手架,它是开发体验的革命者。利用原生 ESM (ES Modules),它实现了极致的冷启动。不需要打包整个应用,你需要哪个文件,它就即时编译哪个文件。就像点菜,吃多少炒多少,绝不浪费一毫秒。


🛠️ 第一关:依赖管理的“爱恨情仇”

package.json 的世界里,存在着两个平行宇宙:dependenciesdevDependencies。分不清楚?小心你的生产包体积爆炸!

📦 生产依赖 (dependencies)

这是你项目的灵魂。没有它们,你的应用跑不起来。

  • react (19.2.0): 2026 年的王者。现在的 React 不仅仅是 UI 库,它是响应式、组件化、数据绑定的集大成者。React 19.2 更是引入了稳定的 Compiler,自动帮你做 useMemouseCallback 的优化,你只管写代码,性能它来扛!
  • react-dom: 如果把 React 比作大脑(Core),那 react-dom 就是手脚。它负责把虚拟 DOM 真正渲染到浏览器的 DOM 树上。
    • 冷知识:Vue 3.5+ 其实也借鉴了 React 的很多思想,可以说 Vue = React(Core) + 更贴心的语法糖。但在生态广度上,React 依然是那个“第一的现代前端开发框架”。

🔧 开发依赖 (devDependencies)

这是你项目的工具箱。只在开发、测试、构建时使用,上线时不需要带走。

  • vite: 开发服务器和构建工具。
  • stylus/sass: 预处理器。你写代码时需要它编译 CSS,但浏览器只需要最终的 CSS 文件。
  • typescript/eslint: 代码检查员。

安装姿势要帅:

# 安装生产依赖
npm install react react-dom react-router-dom

# 安装开发依赖 (记得加 -D 或 --save-dev)
npm install -D vite stylus

💡 避坑指南:千万别把 vite 装进 dependencies!否则你的 node_modules 会像吃了激素一样膨胀,部署时间翻倍,运维小哥会想顺着网线过来打你。


🗺️ 第二关:路由——单页应用的“导航仪”

没有路由的 SPA (单页应用) 就像一个没有门的大房子,用户进来了就出不去,只能刷新页面(然后丢失所有状态,惨!)。

1. 请出大神:React Router DOM

npm install react-router-dom

在 2026 年,我们依然首选 react-router-dom v7+(或者兼容 React 19 的最新版本)。它完美支持 Suspense、Data API 和 类型安全。

2. 配置路由:搭建你的“立交桥”

别再写一堆 if (path === '/home') 了。让我们用声明式的方式配置路由。

// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import Home from './pages/Home'
import About from './pages/About'
import UserProfile from './pages/UserProfile'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />}>
          <Route index element={<Home />} /> {/* 首页 */}
          <Route path="about" element={<About />} /> {/* 关于页 */}
          <Route path="user/:id" element={<UserProfile />} /> {/* 动态路由:/user/123 */}
        </Route>
      </Routes>
    </BrowserRouter>
  </StrictMode>,
)

3. 导航:让用户“飞”起来

页面级组件之间如何跳转?用 <Link> 标签,它会阻止默认的页面刷新,实现无感跳转

// src/components/NavBar.jsx
import { Link, useNavigate } from 'react-router-dom';

export default function NavBar() {
  const navigate = useNavigate();

  const handleEmergencyJump = () => {
    // 编程式导航:适合在逻辑处理后跳转,比如登录成功
    navigate('/dashboard');
  };

  return (
    <nav>
      {/* 声明式导航:简单直接 */}
      <Link to="/">🏠 首页</Link>
      <Link to="/about">ℹ️ 关于我们</Link>
      
      <button onClick={handleEmergencyJump}>
        🚀 紧急前往控制台
      </button>
    </nav>
  );
}

🌟 React 19 新特性加持: 在 React 19 中,配合 useActionState 和 Forms 的新特性,你可以在表单提交后自动处理导航,甚至实现乐观更新(Optimistic Updates)。用户点击“保存”,界面瞬间更新,后台慢慢请求,失败了再回滚。这种“丝滑”感,让用户以为你的服务器就在他们电脑里!


🔄 第三关:生命周期——Dev -> Test -> Prod 的轮回

前端开发就是一场无尽的轮回:

  1. Dev (开发): npm run dev。Vite 开启 HMR (热模块替换)。你改一行代码,浏览器瞬间刷新,状态都不丢。这是创造的阶段。
  2. Test (测试): npm run test。Jest/Vitest 上场,确保你的组件不会在奇怪的地方崩溃。这是找茬的阶段。
  3. Production (上线): npm run build。Vite 使用 Rollup 进行生产打包,Tree-shaking 摇掉无用代码,压缩、混淆、哈希命名。这是交付的阶段。

循环往复,永无止境: Dev ➡️ Test ➡️ Prod ➡️ (发现 Bug) ➡️ Dev ...

在这个循环中,Vite 是你的加速器,React 19 是你的稳定器。

  • 开发时:ESM 极速加载。
  • 生产时:Rollup 极致优化。
  • 运行时:Compiler 自动优化渲染。

🎨 结语:架构之美,在于简单

看看我们现在的架构:

  • 构建工具:Vite (快如闪电)
  • 核心框架:React 19.2 (智能编译)
  • 路由管理:React Router (灵活导航)
  • 样式方案:Stylus (嵌套语法,优雅书写)

没有复杂的配置,没有沉重的包袱。我们只需要关注组件状态用户体验

最后的小幽默: 以前老板问:“为什么页面加载这么慢?” 你答:“Webpack 在打包...”

现在老板问:“为什么页面加载这么快?” 你答:“因为用了 Vite 和 React 19,而且我刚才喝咖啡的时间都被省下来了。”

老板:“那再做一个功能吧。” 你:“......” (这就是技术的代价 😂)

好了,别废话了,打开终端,npm create vite,开始你的 2026 前端之旅吧!🚀


🚀 手搓一个会“读心术”的邮件机器人:当 NestJS 遇上 LangChain,SMTP 不再冷冰冰

作者 AI的主人
2026年3月25日 23:10

🚀 手搓一个会“读心术”的邮件机器人:当 NestJS 遇上 LangChain,SMTP 不再冷冰冰

摘要:还在苦哈哈地写 nodemailer 的配置?还在为忘记用户邮箱而头秃?今天,我们不写死板的 CRUD,我们来“手搓”一个拥有大脑的邮件助手。它不仅能查用户信息,还能在你一声令下(甚至是一个模糊的暗示)自动发送邮件。准备好了吗?让我们把 SMTP 协议玩出花来!

🎭 序幕:为什么我们要“手搓”?

在这个 AI 泛滥的年代,如果你的后端服务还只会机械地接收 POST /send-email 然后返回 200 OK,那未免太无趣了。

想象一下这样的场景:

用户:“嘿,帮我把上周的报表发给张三,顺便告诉他老板心情不错。” 传统后端:“错误 400:请提供张三的邮箱地址、报表文件路径及具体文本内容。” 我们的 AI 后端:“收到!已查询到张三邮箱,报表已附,并加了一句‘老板今天心情大好,放心享用’。发送成功!”

这就是 Function Calling (工具调用) 的魅力。今天,我们就利用 NestJS 的优雅架构和 LangChain 的大脑,手搓两个核心 Tool:query_user (查户口) 和 send_mail (送信),让代码活起来。

🛠️ 第一关:搭建舞台 (NestJS + Mailer)

首先,我们需要一个靠谱的邮差。虽然题目里提到了 HTTP 和 Nginx,但发邮件这事儿,还得靠老派的 SMTP 协议(特别是 QQ 邮箱这种老牌服务商)。别被“HTTP 不能使用 SMTP”吓到,我们的 NestJS 应用是运行在 HTTP 服务器上的,但它内部可以通过 TCP 连接去呼叫 SMTP 服务器。

1. 依赖安装

正如江湖传言,NestJS 和 @nestjs-modules/mailer 是天生一对:

pnpm i @nestjs-modules/mailer nodemailer

2. 配置邮差 (AppModule)

我们在 AppModule 中注入配置。这里有个小坑:端口

  • 465: 隐式 SSL (QQ 邮箱常用)。
  • 587: 显式 TLS (StartTLS)。

看我们的代码,如何优雅地从 .env 读取秘密情报:

// app.module.ts 片段
MailerModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => ({
    transport: {
      host: configService.get<string>('MAILER_HOST'), // 比如 smtp.qq.com
      port: Number(configService.get<string>('MAILER_PORT')) || 465,
      secure: true, // 真加密,不玩虚的
      auth: {
        user: configService.get<string>('MAILER_USER'),
        pass: configService.get<string>('MAILER_PASS'), // 这里是授权码,不是登录密码哦!
      },
    },
    defaults: {
      from: `"AI 小秘书" <${configService.get<string>('MAILER_FROM')}>`,
    },
  }),
}),

💡 小贴士:如果你用的是 QQ 邮箱,记得去设置里开启 SMTP 服务,并获取那个长长的“授权码”。别把登录密码填进去,否则腾讯的安全中心会以为你被盗号了,直接把你封禁。

🧠 第二关:注入灵魂 (LangChain Tools)

光有邮差不够,我们得给 AI 装上“手”和“眼”。在 AiModule 中,我们通过 useFactory 动态创建两个强大的工具。

🔍 工具一:query_user (千里眼)

这个工具负责在不泄露密码的前提下,根据 ID 找到用户的邮箱。

// ai.module.ts 片段
{
  provide: QUERY_USER_SERVER,
  useFactory: (userServer: UserServer) => {
    const queryUserArgsSchema = z.object({
      userId: z.string().describe('用户ID,别搞错了,不然查不到人'),
    });
    return tool(
      async ({ userId }) => {
        const user = userServer.findOne(userId);
        if (!user) {
          // 幽默感时刻:不仅报错,还告诉你能查谁
          const availableIds = userServer.findAllUsers().map((u) => u.id).join(', ');
          return `用户 ${userId} 不存在。本系统只认识这些大佬: ${availableIds}`;
        }
        // 安全脱敏:密码绝对不能见光
        const { password: _p, ...safe } = user;
        return `用户 ${user.name} 的信息:${JSON.stringify(safe)}`;
      },
      {
        name: 'query_user',
        description: '根据用户ID查询用户信息(姓名、邮箱),不含密码。想发邮件前先用这个!',
        schema: queryUserArgsSchema,
      },
    );
  },
  inject: [UserServer],
},

📩 工具二:send_mail (飞毛腿)

这是重头戏。注意,我们允许传入 texthtml。AI 可以根据上下文决定是发纯文本还是富文本(比如带个表情符号 🎉)。

// ai.module.ts 片段
{
  provide: 'SEND_MAIL_TOOL',
  useFactory: (mailService: MailerService) => {
    const sendMailArgsSchema = z.object({
      to: z.string().describe('收件人邮箱,格式要对,不然退信很尴尬'),
      subject: z.string().describe('邮件主题,要吸引人'),
      text: z.string().optional().describe('邮件内容 纯文本版'),
      html: z.string().optional().describe('邮件内容 富文本版,可以加粗变色'),
    });
    return tool(
      async ({ to, subject, text, html }) => {
        await mailService.sendMail({
          to,
          subject,
          text: text ?? '',
          html: html ?? '',
        });
        return `邮件发送成功!已飞向 ${to} 🚀`;
      },
      {
        name: 'send_mail',
        description: '发送邮件:需提供收件人邮箱与主题。如果不知道邮箱,请先调用 query_user。',
        schema: sendMailArgsSchema,
      },
    );
  },
  inject: [MailerService],
},

⚡ 第三关:大脑回路 (AiService 流式处理)

有了工具,怎么让它们协同工作?这就轮到 AiService 登场了。我们使用 bindTools 将模型与工具绑定,并实现了一个流式处理的循环。

这个循环的逻辑非常性感:

  1. 用户提问 -> 发送给 LLM。
  2. LLM 思考 -> “嗯,用户想发邮件,但我没邮箱,我得先调用 query_user”。
  3. 返回 Tool Call -> 服务端捕获,执行 query_user
  4. 回填结果 -> 把查询到的邮箱告诉 LLM。
  5. LLM 再次思考 -> “好嘞,现在有邮箱了,调用 send_mail”。
  6. 执行发送 -> 邮件发出。
  7. 最终回答 -> 告诉用户“搞定啦”。
// ai.service.ts 核心逻辑
async *runChainStream(query: string): AsyncIterable<string> {
  const messages: BaseMessage[] = [
    new SystemMessage(`你是一个智能助手... 可在需要时调用工具 query_user、send_mail...`),
    new HumanMessage(query),
  ];

  while (true) {
    // 1. 让模型思考并可能产生工具调用
    const stream = await this.modelWithTools.stream(messages);
    let fullAIMessage: AIMessageChunk | null = null;
    
    // 流式输出文本给用户(如果是直接回答)
    for await (const chunk of stream as AsyncIterable<AIMessageChunk>) {
      fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;
      const hasToolCallChunk = !!fullAIMessage.tool_call_chunks?.length;
      
      // 如果没有工具调用,直接吐出文字
      if (!hasToolCallChunk && chunk.content) {
        yield chunk.content as string;
      }
    }

    if (!fullAIMessage) return;
    messages.push(fullAIMessage);

    // 2. 检查是否有工具需要执行
    const toolCalls = fullAIMessage.tool_calls ?? [];
    if (!toolCalls.length) return; // 没有工具调用,对话结束

    // 3. 执行工具并记录结果
    for (const toolCall of toolCalls) {
      const toolName = toolCall.name;
      let result;
      
      if (toolName === 'query_user') {
        result = await this.queryUserTool.invoke(toolCall.args);
      } else if (toolName === 'send_mail') {
        result = await this.sendMailTool.invoke(toolCall.args);
      }

      // 将工具执行结果作为“系统观察”喂回给模型
      messages.push(
        new ToolMessage({
          content: typeof result === 'string' ? result : String(result),
          name: toolName,
          tool_call_id: toolCall.id || '',
        }),
      );
    }
    // 循环继续,模型将根据工具结果进行下一步行动
  }
}

🌐 架构全景:当 Nginx 遇见 3000 端口

你可能注意到了需求里提到的 Nginx (80)Node (3000)

  • Nginx 是我们的门面担当,处理静态资源(ServeStaticModule 配置的 public 目录)、反向代理和 SSL 终结。
  • NestJS (3000) 是幕后黑手,专心处理业务逻辑、AI 推理和 SMTP 连接。
  • 数据库 (3306) 静静地在角落里存储用户数据,等着 query_user 来翻牌子。

这种分离让架构既稳健又灵活。即使 AI 发疯一直在调用工具,Nginx 依然能稳稳地 serving 你的 HTML 页面。

🎉 结语:从“手搓”到“自动驾驶”

通过这段代码,我们不仅仅是在发邮件。我们构建了一个基于意图的行动系统

  • 用户说:“给 ID 为 1001 的人发个问候。” -> AI 自动查库 -> 自动发送。
  • 用户说:“给 Alice 发个报表。” -> AI 发现不知道 Alice 是谁 -> 询问用户或报错(取决于你的 Prompt 工程)。

这就是现代后端开发的乐趣:不再是简单的 CRUD 搬运工,而是智能体的编排者。

下次当你看到 SMTP 408 或者连接超时时,别慌,那是你的 AI 正在努力穿越防火墙,只为把那句“老板心情不错”准时送到收件箱里。


👨‍💻 动手试试: 克隆代码,配置好你的 .env (别忘了 QQ 邮箱授权码),运行 pnpm start:dev,然后对着你的 API 说一句:“帮我查一下用户 1 并发封邮件测试一下”,见证奇迹的时刻!

(本文纯属技术分享,如有雷同,那是你也想到了这么好的架构。)

读懂大模型:一切 AI 应用的起点

作者 3Katrina
2026年3月25日 22:53

前言

在春招求职的过程中,我愈发清晰地感受到:当下技术就业市场的新蓝海,已然向大模型及相关 AI 领域倾斜。作为一名求职者,主动学习并掌握大模型相关的前沿知识,已然成为提升自身竞争力的关键。

在此之前,我零散地接触过各类 AI 技术知识,包括大模型基础、MCP、Function Call、Agent 等内容,但学习途径多局限于技术博客与 YouTube 视频,始终缺乏一套完整、体系化的学习路径与知识框架。

因此,我决定在接下来的时间里,对这些知识进行系统性梳理与深度学习,以此夯实技术基础,强化求职核心竞争力。而本篇文章,便是我系统学习的起点 —— 我将先从大模型的基础概念入手。毕竟,相较于如今遍地开花的 Agent 应用,乃至未来可期的 AGI,大模型(Large Models)才是这一切技术形态的最初源头与核心根基

什么是大模型?

很多人接触过各种各样的大模型,但是你能否准确的介绍出 ”什么是大模型”?

大模型通常是指训练数据庞大、参数规模巨大、能力强大的深度神经网络模型

这里的训练数据、参数规模庞大大家乍一听可能没什么概念,接下来给大家看一些量化的数据:通常,大模型的参数量在10亿以上,目前顶尖模型的参数规模已达到万亿级别。也就意味着:如果你的模型参数量没有达到十亿以上,不好意思,那你暂时还没资格被称为大模型

给大家举个例子 在下图中,可以看见GPT-3这样的模型参数量又1750亿,DeepSeek-V3这样的大型模型参数量有6710亿,而BERT(NLP(自然语言处理)领域里里程碑式的预训练语言模型,由 Google 在 2018 年提出)这种模型则只有3亿参数,处理一些小文本场景还好,处理一些复杂的上下文它的参数量不够,没有办法 image.png


为什么会出现大模型?

大模型的出现并非偶然,而是数据算力模型架构协同演进的结果。

1)数据够多:训练范式的改变使得训练数据规模获得了数量级上的跃迁

image.png

为什么现在的大模型数量级能够巨幅上升呢?

原因其实在于:

  • 传统监督学习高度依赖人工标注数据(对原始数据进行标记、分类、注释或结构化的过程,便于机器可识别和理解),获取成本高、规模受限,例如

    1. 分类标注:为整张图像分配类别标签(如"猫"、"狗",人工标注的)
    2. 命名实体识别:标注文本中的人名、地名、组织名等实体
    3. 情感分析:标注文本的情感倾向(正面、负面、中性)
    4. 语音转写:将语音内容转换为文本
  • 而大模型主要采用自监督学习范式(如“预测下一个token”),能够直接利用海量的未标注文本与多模态数据进行模型的训练,可用数据规模获得了数量级上的跃迁。

    1. 自监督学习,本质上属于无监督学习的一种特殊形式,但采用了监督学习的训练方式。核心思 想是利用数据本身的内在结构或属性,自动为无标签数据生成伪标签,然后像监督学习一样训 练模型,无需依赖人工标注。比如,掩码语言建模。
    2. 如Qwen3的预训练阶段使用了约36T个token(近似理解为词)的语料,这一数据规模远超 传统机器学习时代的训练数据总量

image.png

2)算力够强:GPU/TPU等并行计算设备性能发展与分布式训练成熟

image.png

深度学习训练本质是大规模矩阵运算,这类计算具有高度并行性,与GPU/TPU的硬件架构天然契合。

随着硬件性能的不断提升,单卡算力不断突破,目前英伟达最新一代的B200在FP16(半精度浮点数)条件下的峰值算力已达5PFLOPS(每秒约次浮点运算,)。

简单来说就是GPU的计算能力越来越强了,越来越能支持更大数据量的运算了

3)架构合理:Transformer架构的出现

Transformer架构支持并行计算,并且在模型规模、数据规模、训练步数(算力开销)提升时展现出稳 定的性能收益(即良好的“可扩展性”,如下图所示,图中的Test Loss表示损失函数的值,用于衡量模 型性能)。

image.png

相对于传统的相比 RNN/LSTM 只能逐步传递信息,Transformer的自注意力能直接计算任意两个位置的关联,解决了长文本语义建模难题。

综上,数据规模的跃迁、算力基础设施的发展,和Transformer架构优异的可扩展性,共同推动了模型 规模和性能的持续膨胀,迎来了“大模型时代”。

大模型的计量单位

在大语言模型(LLM)及更一般的大模型研究中,通常从参数规模、训练数据集规模和计算规模三个维 度来度量模型的规模。

1)参数规模(Parameters Scale)

什么是LLM的参数? 参数是指深度神经网络里面“神经元数量、层数、神经元权重、神经元偏移量、超参数”等数据的集合。

image.png

大模型参数规模通常以B为单位,B是Billion的缩写,即10亿,。如:7B模型的参数量为70亿。

2)训练数据集规模

LLM的训练是在文本语料上进行的,语料处理的第一步是分词为一系列token,所以通常用token的数 量衡量LLM训练数据集规模。
1B token =token =10亿token
1T token =B token =token =1万亿token

说明:token是什么
可能是一个英文单词,也可能是半个,三分之一个
可能是一个中文词,或者一个汉字

token是计算机理解人类语言的基础单位。大模型在开训前,需要先训练一个tokenizer模型。它能把所 有的文本,切成token。这里提供一个网站大家可以测试转化token的效果 platform.openai.com/tokenizer

大模型的四要素

大模型由四个要素构成:模型权重(参数)、推理代码、训练代码、训练数据集。

image.png

一、模型权重(参数)—— 模型的 “大脑 + 知识”

1. 它是什么?

  • 就是你之前看到图里的 w(权重)、b(偏置) 等所有数值
  • 是一堆巨大的数字文件(.bin / .pth / .safetensors)
  • 是模型训练完成后得到的最终成果

2. 它的作用

  • 存储模型学会的语言规律、知识、逻辑、常识
  • 决定模型回答得准不准、聪不聪明
  • 参数越多 = 脑容量越大(7B、13B、70B、175B 都是指它)

3. 类比

模型权重 = 一本书的全部内容你问问题 → 模型查这本书 → 给出答案

二、推理代码 —— 让模型 “说话 / 回答” 的程序

1. 它是什么?

  • 一套轻量代码(相对于训练代码来说)
  • 负责加载权重,然后接收你的问题,输出回答
  • 不学习、不更新、不训练,只负责 “使用模型”

2. 它的作用

  • 读入你的输入(prompt)
  • 调用模型权重做计算
  • 输出文本(回答、写作、翻译、代码)

3. 类比

推理代码 = 读书的人权重 = 书人拿着书,帮你查问题、念答案

4. 我们日常用的所有 AI 都靠它

ChatGPT、文心一言、Llama 聊天、通义千问 …… 后台跑的都是推理代码

三、训练代码 —— 教会模型知识的 “教学程序”

1. 它是什么?

  • 一套复杂、工程化的代码
  • 负责:初始化模型 → 读数据 → 计算损失 → 更新权重 → 保存模型
  • 只有训练模型时才会用到

2. 它的作用

  • 从 0 开始训练一个大模型
  • 不断调整权重 w、b,让模型越来越聪明
  • 支持多卡、分布式、大规模训练

3. 类比

训练代码 = 老师 + 教学方法训练数据集 = 课本老师用课本教学生 → 学生学会知识 → 得到最终的 “权重”

4. 只有大厂 / 研究机构会用

训练代码极复杂,普通人基本用不到。

四、训练数据集 —— 模型学习用的 “课本”

1. 它是什么?

  • 海量文本数据:网页、书籍、文章、百科、对话、代码等
  • 是纯文本内容,不是代码也不是模型

2. 它的作用

  • 让模型学习语言、逻辑、事实、常识
  • 数据决定模型上限

3. 常见规模

  • 小模型:1 亿~10 亿词
  • 大模型:1 万亿~10 万亿词

4. 类比

训练数据集 = 从小学到博士的全部教材

注意:训练代码和推理代码99%都是用Python写的

  • 框架全是 Python 接口(PyTorch、TensorFlow、JAX、MindSpore)
  • 写得快、调试快、生态无敌
  • 数据处理、分布式、日志、绘图全靠 Python

大模型的分类

以下是从其它地方得到的 大模型分类的图表:

分类标准 类别 示例
按照模态分类 大语言模型 Qwen3/DeepSeek-V3/GPT-5语言模块
多模态理解模型 Qwen3-VL/GPT-5/Gemini-3
多模态生成模型 Nano-Banana/Stable Diffusion/DALL·E
按照功能分类 生成式大模型 GPT-5/DeepSeek-V3/Qwen3
嵌入模型 BGE/E5/GTE
重排序模型 BGE-Reranker/ms-marco-MiniLM
分类模型 通常是经过微调的小尺寸模型

关于这一部分内容,这里先不详细展开介绍,后面作者有空了会专门更新一篇文章来介绍大模型的分类,并且客观地分析市场上主流的大模型的一些优缺点

理解模型能力来源

接下来我们来聊聊大模型训练阶段中的模型能力来源
所以在训练阶段中,我们到底做了那些事情,能够让大模型能够精准地回答我们的问题呢?
(注意:下面所探讨的是针对于大语言模型,多模态模型由于涉及多种模态输入,其训练目标,数据构成及优化策略差异较大,尚未形成一定的范式,故不在探讨范围内)

image.png

环节1:预训练

预训练是大语言模型的 “筑基阶段” ,也是整个训练流程中耗时最长、算力消耗最大、数据量需求最多的环节,相当于让模型从“零基础”变成“饱读诗书的学者”,奠定所有核心能力的基础,我们日常说的千亿、万亿参数模型,核心就是在这个阶段训练完成的。

核心前提:硬件与物料准备
这个环节需要用到我们之前聊过的训练代码、海量训练数据集,搭配大规模GPU集群开展工作。训练数据集以海量无标注文本为主,包含书籍、网页、百科、优质文章、代码库等,规模通常达到万亿tokens级别,是模型学习语言规律和知识的核心原料。

核心目标
让模型掌握通用语言规律、基础逻辑推理、海量事实知识,学会语法、语义、上下文关联,能够完成文本续写、通顺表达等基础任务,本质是让模型的参数(权重)记住海量知识和语言模式。

核心流程

  1. 模型初始化:基于Transformer架构搭建大模型骨架,所有参数(权重、偏置)随机初始化,此时模型完全没有任何知识和语言能力。
  2. 无监督学习:采用“下一个词预测”的核心训练目标,给模型输入一段文本,让它预测下一个最可能出现的词,不断循环这个过程。
  3. 参数迭代优化:训练代码通过反向传播算法,不断调整模型参数,缩小预测误差,经过数十亿甚至上万亿次迭代,让参数逐渐收敛。
  4. 产出基座模型:预训练结束后,得到基座模型(Base Model) ,也就是纯模型权重文件,这是大模型的核心本体。

基座模型拥有极强的语言生成能力、知识储备、逻辑理解能力,能读懂文本、续写内容、做简单推理,但它没有“服务意识”,不会按照人类的指令回答问题,更擅长文本续写,而非直接回应提问,比如你问“1+1等于几”,它可能会续写“1+1等于几,在数学中是基础运算问题”,而不是直接给出答案。

预训练决定了模型的能力上限,数据质量、数据规模、模型参数大小、训练时长,直接影响模型的知识广度、推理深度,后续环节无法突破这个上限,只能优化输出形式。

环节2:SFT(监督微调)

SFT全称是Supervised Fine-Tuning,即监督微调,是大模型的 “规矩养成阶段” ,相当于把饱读诗书但不懂人情世故的基座模型,教成“能听懂人话、按指令做事”的助手,让模型从“被动续写”转变为“主动响应指令”。

核心物料
基于预训练好的基座模型权重,搭配高质量有标注指令数据集,数据集由人工整理,格式为“指令+输入+标准答案”,比如“指令:解答数学题,输入:1+1=?,标准答案:2”,数据量远小于预训练,通常百万级到千万级tokens即可,训练代码也更轻量化。

核心目标
让模型学会理解人类指令、遵循指令完成任务,掌握问答、总结、翻译、写作等实用技能,输出内容贴合人类的使用习惯,不再是无意义的文本续写

核心流程
加载基座权重:不重新训练模型,只在预训练好的参数基础上做小幅度微调,避免破坏已学到的知识。
监督学习训练:用指令数据集训练模型,让模型学习“输入指令→输出标准回答”的映射关系,优化输出的准确性和规范性。
产出SFT模型:微调完成后,模型已经能基本听懂指令,回答问题也更贴合需求,具备了实用价值,但此时模型可能存在回答生硬、逻辑矛盾、甚至输出有害内容的问题。

训练成本远低于预训练,耗时短、算力需求小,是快速优化模型实用性的核心环节,但单纯的SFT无法解决模型输出偏见、有害信息、不符合人类价值观的问题,需要后续对齐环节优化。

环节3:RLHF/RLAIF(对齐优化)

RLHF(Reinforcement Learning from Human Feedback)通过人类反馈来优化模型输出,使其更符 合人类偏好和价值观;RLAIF(Reinforcement Learning from AI Feedback)是RLHF的扩展(“自动化版本”),使用AI模型代替人类进行反馈和打分。

核心目标
让模型 “更安全、更有用、更符合人类偏好”

这是大模型从 “能用” 到 “好用、可信” 的最后一步,也叫对齐(Alignment)

RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)

  • 步骤:人工对模型回答进行排序 → 训练奖励模型(RM)→ 使用 PPO 等强化学习算法优化模型,使其输出更符合人类偏好。
  • 作用:提升回答的有用性、逻辑性、诚实性,减少有害、偏见、胡说内容。

RLAIF(Reinforcement Learning from AI Feedback,基于 AI 反馈的强化学习)

  • 用更强的大模型替代人类进行标注和排序,大幅降低成本、提升效率、扩展规模。
  • 是当前主流大厂对齐的主流方案之一。
  • 产出结果:得到对齐后的对话模型(Chat Model) ,也就是我们日常使用的 ChatGPT、文心一言、Llama Chat 等产品背后的模型。
  • 关键意义:实现能力与价值观对齐,让模型安全、可控、真正服务于人。

小结

阶段 核心目标 解决问题
预训练 学会语言和知识(打基础) “模型能不能说话”
SFT(监督微调) 学会按指令回答(按标准做事) “模型听不听话”
RLHF / RLAIF(对齐优化) 学会人类偏好(按偏好做事) “回答好不好、对不对、安不安全”

1)只有预训练、没有SFT和对齐优化的AI,就像"一个只读过所有书但没上过学的天才儿童"。这个孩子 拥有海量知识,但完全不懂人情世故,聪明但危险。他会:

  • 口无遮拦:看到什么就说什么,不管是否礼貌或合适
  • 不懂分寸:可能说出伤害人的话,自己却浑然不知
  • 不会变通:只会机械地复述知识,不会根据场景调整回答
  • 举例:它可能在你问"如何减肥"时,给出"绝食三天"这种极端建议。

2)没有对齐的AI就像没受过教育的天才,虽然知识渊博,但可能:

  • 缺乏判断力:分不清什么该说、什么不该说,可能输出有害或不当内容
  • 容易"走极端":在回答敏感问题时,可能给出极端或不安全的建议
  • 缺乏价值观约束:没有经过人类价值观的校准,输出的内容可能违背伦理道德

大模型如何落地

前文我们详细拆解了大语言模型的能力来源,从预训练打下知识根基,到SFT让模型听懂指令,再到RLHF/RLAIF实现人类对齐,一步步把空白的参数模型打磨成具备实用能力的AI助手。但模型训练完成只是第一步,真正让大模型产生价值,核心在于落地应用

很多开发者和企业都会遇到这样的困惑:手里有训练好的模型权重,也懂基础原理,可到底该怎么把它用到实际业务里?是直接用开源模型二次开发,还是从零训练?部署后跑不起来、速度太慢、成本太高又该怎么解决?

接下来我们就从训练落地推理落地两个核心维度,系统讲讲大语言模型的完整落地流程,避开常见坑,适配不同业务场景的实际需求。

(注:依旧仅针对纯大语言模型展开,多模态模型落地不在本次讨论范围内)

一、大模型训练落地:不是从零开始,而是按需定制

绝大多数企业和个人开发者,完全不需要从零训练基座大模型。从零预训练需要万亿级token数据、数千张GPU卡,成本动辄上亿,只有头部科技厂商会承担这项工作。我们所说的训练落地,核心是基于开源基座模型,做轻量化定制训练,适配自身业务场景,也就是前文提到的SFT微调、对齐优化,以及针对性的领域预训练。

1. 训练落地的核心前提:选型与准备

(1)基座模型选型

优先选择成熟开源基座,比如Llama系列、Qwen(通义千问开源版)、Mistral、GLM等,根据业务需求选参数规模:

  • 轻量场景(边缘端、小型应用) :1B-7B参数模型,算力要求低,部署成本低,适配简单问答、规则类任务;
  • 通用场景(企业客服、内容生成) :7B-13B参数模型,平衡性能与成本,是中小团队主流选择;
  • 复杂场景(专业问答、长文本分析) :34B-70B参数模型,能力更强,需配套高算力资源。

(2)数据准备:高质量远大于大数量

训练落地的核心是数据,而非盲目堆量:

  • 领域预训练数据:行业专属文本(如医疗病历、法律条文、金融研报),用于扩充模型行业知识;
  • SFT微调数据:业务专属指令-回答对,格式规范、无错误、贴合真实用户提问场景,宁可少而精,不可多而杂;
  • 对齐数据:人工标注的偏好数据、安全合规数据,避免模型输出有害、违规内容。

(3)算力与工具选型

个人/小团队无需自建集群,直接用机器学习平台(如AutoDL、阿里云PAI、腾讯云TI-One):

  • 轻量化微调(SFT) :单张RTX 3090/4090/A10即可,搭配LoRA/QLoRA轻量化微调技术,大幅降低显存占用;
  • 全参数微调:需多卡GPU(如A100、V100),仅适合大模型深度定制;
  • 工具框架:Transformers、PEFT(轻量化微调)、Deepspeed(分布式训练)、Axolotl(一站式微调工具)。

2. 训练落地标准流程

  1. 基座模型加载:下载开源模型权重,在开发机完成环境配置与初步调试;
  2. 数据清洗与格式化:统一数据格式,剔除脏数据、重复数据、违规数据;
  3. 轻量化微调配置:设置LoRA/QLoRA参数,选择微调层(注意力层+全连接层为主);
  4. 提交训练任务:通过平台自定义任务提交训练代码,启动训练,监控损失值;
  5. 模型导出与验证:训练完成后导出微调权重,合并基座与微调权重,测试业务效果;
  6. 对齐优化(可选) :对效果不佳、合规性不足的模型,做RLHF/RLAIF优化,提升实用性。

二、大模型推理落地:让训练好的模型,真正用起来

如果说训练落地是“打磨模型”,那推理落地就是“让模型对外提供服务”。推理阶段不更新模型权重,只负责加载权重、接收用户请求、输出结果,核心诉求是快、稳、省、可用,也是普通开发者接触最多的落地环节。

1. 推理落地的核心目标

在满足业务响应速度(通常单轮回答延迟低于3秒)的前提下,尽可能降低算力成本,同时保证输出稳定、合规,适配高并发场景。

2. 推理落地前的关键准备

(1)模型量化:降低算力门槛的核心手段

训练好的模型权重精度高、体积大,直接推理显存不够用,必须做量化压缩:

  • FP16/BF16:半精度,基本不损失性能,适合中高端GPU;
  • INT8/INT4:量化精度,体积缩小4-8倍,显存占用大幅降低,小GPU也能跑,性能损失极小;
  • 常用工具:GGUF/GGML(本地推理)、AWQ、GPTQ(高性能量化)。

(2)推理方式选型

根据业务场景选推理部署方式,分为两大类:

① 本地/私有化推理

  • 适用场景:企业内部数据、敏感业务、隐私要求高的场景;
  • 工具:Ollama(极简本地推理)、vLLM、Text Generation Inference(TGI)、FastChat;
  • 优势:数据不外流,可控性强;劣势:需自备GPU算力。

② 云端推理服务

  • 适用场景:公开业务、高并发、无自建算力的团队;
  • 方式:云平台托管推理、API接口调用(如OpenAI API、开源模型托管API);
  • 优势:无需维护硬件,弹性扩缩容;劣势:数据需上传云端,有一定成本。

(3)推理代码与服务封装

推理代码核心逻辑很简单,全程用Python编写:

  1. 加载量化后的模型权重;
  2. 接收用户输入(Prompt),做文本预处理(分词、格式拼接);
  3. 调用模型前向计算,生成回答;
  4. 文本后处理(剔除冗余内容、格式优化);
  5. 封装成API接口(用FastAPI/Flask),对外提供服务,对接前端或业务系统。

3. 推理落地标准流程

  1. 模型优化:对训练好的权重做量化、剪枝,减小体积;
  2. 推理环境搭建:安装Transformers、Torch、推理引擎(vLLM/TGI);
  3. 本地调试:在开发机运行推理代码,测试单轮回答速度与效果;
  4. 服务封装:打包成API服务,支持并发请求、参数可调(温度、最大长度);
  5. 部署上线:部署到服务器/云平台,配置监控、日志;
  6. 压测与优化:测试并发能力,优化推理速度,解决延迟过高问题。

4. 推理落地核心优化技巧

  • 推理引擎加速:用vLLM、TGI替代原生推理,速度提升5-10倍,支持动态批处理;
  • 上下文窗口管理:根据业务控制输入长度,避免超长文本导致延迟飙升;
  • 缓存复用:对重复请求、历史对话做缓存,减少重复计算;
  • 动态扩缩容:云端部署根据并发量调整算力,避免资源浪费。

结尾

以上便是大模型的入门基础知识,相信通过本文的梳理,能为你带来一些启发,帮助你建立对大模型的初步认知。本篇内容就先到这里,后续我会持续更新,继续深入讲解大模型的演进历程、工程实践,以及大模型的各类核心能力与应用场景,敬请期待。

从0开始设计一个树和扁平数组的双向同步方案

作者 guojb824
2026年3月25日 22:53

从0开始设计一个树和扁平数组的双向同步方案

背景:在前端开发中,展示和操作大型树形结构(如十万级节点的文件树、组织架构图)时,传统递归渲染 DOM 会导致严重的性能瓶颈。为了结合虚拟滚动技术,我们需要将树“扁平化”为一维数组。本文将从0开始,推导并设计一个支持“逻辑树”与“视图扁平数组”实时、高效双向同步的方案。


1. 核心操作提炼

在开始设计数据结构之前,我们先明确业务需求。一个完备的树和数组双向同步方案,必须支持以下核心操作:

  1. 初始化构建:将原始树结构转换为扁平数组,并建立辅助索引。
  2. 树添加子节点:在指定节点的子节点列表末尾添加新节点,并同步到数组。
  3. 树添加兄弟节点:在指定节点之后添加一个兄弟节点,并同步到数组。
  4. 树删除节点:删除指定节点及其所有子孙节点,并同步到数组。
  5. 树移动节点:将某棵子树从一个父节点移动到另一个父节点下(拖拽操作),并同步到数组。
  6. 树修改节点属性:修改节点的非结构属性(如名称、展开状态等),并触发视图更新。

2. 数据结构设计

为了让上述操作在树和数组中都能高效执行,我们需要精心设计节点的数据结构,并引入辅助索引(空间换时间)。

2.1 节点结构 (TreeNode)

最基础的树节点通常只包含 idchildren

interface TreeNode {
  id: string | number;
  children: TreeNode[];
  // ... 其他业务数据属性
}

但在我们的双向同步方案中,为了实现高效的查找和回溯,仅有这两个属性是远远不够的。在接下来的算法设计中,我们会根据具体的操作场景,一步步引入并添加必要的辅助属性和辅助 Map 索引(空间换时间)。

2.2 两大数据载体

  1. Tree (Array):原始的逻辑树结构,用于维护业务层级关系。
  2. flatArray (Array):基于 DFS 遍历生成的扁平数组,直接绑定到 UI 虚拟滚动组件上用于渲染。

2.3 辅助索引的引入思路

仅仅依靠树和数组依然不够,如果每次操作都要去遍历寻找目标节点,性能会大打折扣。为了将查找复杂度降至 O(1)O(1),我们会在接下来的算法设计中,根据具体的操作场景,一步步引入并建立必要的辅助 Map 索引(空间换时间)。


3. 算法与逻辑构思

接下来,我们针对提炼的每一个操作,进行详细的算法与逻辑设计。

3.1 初始化构建

逻辑构思:首先需要将一棵树展开为一维数组。在遍历过程中,为了支撑后续的高效查找,我们自然地引入前两个辅助索引:

  1. treeNodeMap (Map<id, TreeNode>):将节点 ID 映射到节点对象的引用,保证后续任何操作都能 O(1)O(1) 定位节点。
  2. flatIndexMap (Map<id, index>):记录节点在 flatArray 中的数组下标,用于后续在数组中快速进行切片(Splice)操作。

引入树节点属性 subTreeSize: 为了能在扁平数组中快速确定一棵子树占据的切片范围,我们需要为每个树节点引入一个核心字段 subTreeSize(以该节点为根的子树的节点总数,包含自身)。在 DFS(深度优先遍历)生成的扁平数组中,一棵子树的所有节点是绝对连续的。subTreeSize 就是这段连续区间的长度。

算法设计:采用深度优先遍历 (DFS)

  • 时间复杂度O(N)O(N),其中 NN 为树中节点的总数。需要遍历每个节点一次。
  1. 遍历过程中,将节点引用存入 treeNodeMap
  2. 将节点 pushflatArray 中,并将此时的数组长度(减1)作为 index 存入 flatIndexMap
  3. 在 DFS 回溯阶段,自底向上累加子节点的 subTreeSize,最终得出每个节点的正确规模。
function initTreeFlat(tree: TreeFlatNode[]) {
  treeData.value = tree;
  flatArray.value = [];
  treeNodeMap.clear();
  flatIndexMap.clear();
  siblingIndexMap.clear();

  const traverse = (
    nodes: TreeFlatNode[],
    parentId: TreeNodeId | null | undefined = null,
  ) => {
    let currentLevelSize = 0;
    nodes.forEach((node, index) => {
      node.parentId = parentId;
      if (!node.children) node.children = [];

      treeNodeMap.set(node.id, node);
      siblingIndexMap.set(node.id, index);

      flatArray.value.push(node);
      flatIndexMap.set(node.id, flatArray.value.length - 1);

      let childrenSize = 0;
      if (node.children && node.children.length > 0) {
        childrenSize = traverse(node.children, node.id);
      }

      node.subTreeSize = 1 + childrenSize;
      currentLevelSize += node.subTreeSize;
    });
    return currentLevelSize;
  };

  if (treeData.value && treeData.value.length > 0) {
    traverse(treeData.value);
  }

  return flatArray.value;
}

3.2 树添加子节点(追加到末尾)

逻辑构思:逻辑树中,只需往 children 里 push;但在扁平数组中,新节点应该紧挨着该父节点整棵现有子树的末尾插入。 算法设计

  • 时间复杂度O(N)O(N),主要受限于 flatArraysplice 插入操作和后续所有节点在 flatIndexMap 中的更新遍历。
  1. 树更新:通过 treeNodeMap 找到父节点,往 children 追加新节点。更新新节点的 subTreeSize = 1。向上回溯更新所有祖先的 subTreeSize += 1
  2. 寻找数组插入点
    • 若父节点无子节点:插入点 = flatIndexMap.get(parentId) + 1
    • 若有子节点:找到最后一个子节点 prev。插入点 = flatIndexMap.get(prev.id) + prev.subTreeSize
  3. 数组更新:使用 flatArray.splice(插入点, 0, newNode) 插入。
  4. 索引更新:新节点记入 flatIndexMap。由于数组元素后移,遍历 flatArray 从插入点之后的所有节点,将其在 flatIndexMap 中的值 +1
// 辅助函数:向上更新祖先节点的 subTreeSize
const updateSubTreeSizeUpwards = (node: TreeFlatNode, delta: number) => {
  let current: TreeFlatNode | null | undefined = node;
  while (current) {
    if (current.subTreeSize !== undefined) {
      current.subTreeSize += delta;
    }
    if (current.parentId) {
      current = treeNodeMap.get(current.parentId);
    } else {
      current = null;
    }
  }
};

// 辅助函数:更新指定索引之后的 flatIndexMap
const updateFlatIndexMap = (startIndex: number) => {
  for (let i = startIndex; i < flatArray.value.length; i++) {
    const node = flatArray.value[i];
    flatIndexMap.set(node.id, i);
  }
};

// 辅助函数:扁平化并注册新节点及其子树
const flattenAndRegister = (
  node: TreeFlatNode,
  parentId: TreeNodeId | null | undefined,
  flatList: TreeFlatNode[],
) => {
  node.parentId = parentId;
  if (!node.children) node.children = [];

  flatList.push(node);
  treeNodeMap.set(node.id, node);

  let size = 1;
  if (node.children.length > 0) {
    node.children.forEach((child, index) => {
      siblingIndexMap.set(child.id, index);
      size += flattenAndRegister(child, node.id, flatList);
    });
  }
  node.subTreeSize = size;
  return size;
};

const addChildNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
  if (!currentNode.children) currentNode.children = [];

  // 1. 树更新:添加到父节点的 children
  currentNode.children.push(newNode);
  const siblingIndex = currentNode.children.length - 1;
  siblingIndexMap.set(newNode.id, siblingIndex);

  // 2. 准备新节点(及其可能包含的子树)的扁平数组
  const newFlatNodes: TreeFlatNode[] = [];
  flattenAndRegister(newNode, currentNode.id, newFlatNodes);

  // 3. 寻找数组插入点并更新数组
  // 插入点在 currentNode 现有的子树之后
  const insertIndex =
    (flatIndexMap.get(currentNode.id) as number) +
    (currentNode.subTreeSize as number);
  flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

  // 4. 索引更新与向上回溯
  updateFlatIndexMap(insertIndex);
  updateSubTreeSizeUpwards(currentNode, newNode.subTreeSize as number);

  triggerUpdate();
};

3.3 树添加兄弟节点

逻辑构思:与添加子节点类似,区别在于插入位置是紧跟在目标兄弟节点的子树之后。 在更新逻辑树时,我们需要把新节点插入到父节点的 children 数组的特定位置,这就需要知道当前节点的父节点,以及当前兄弟节点的索引。

引入树节点属性 parentId: 为了能够在操作时(如添加兄弟节点、删除节点)方便地向上找到父节点,我们需要在初始化阶段为每个树节点注入 parentId 属性(根节点的 parentId 可以为 null)。

引入新索引 siblingIndexMap: 为了避免每次去 children 数组里执行 O(N)O(N) 的查找来获取当前兄弟节点的索引,我们需要引入第三个辅助索引(在实际开发中,它同样需要在初始化时收集,并在其他增删操作中同步维护):3. siblingIndexMap (Map<id, index>):记录节点在父节点 children 数组中的位置。

算法设计

  • 时间复杂度O(N)O(N),同样受限于数组插入时的移位操作,以及后续节点在相关 Map 索引中的更新。
  1. 树更新:通过 parentId 找到父节点,通过 siblingIndexMap 瞬间找到当前节点在 children 中的位置,在其后插入新节点。向上回溯更新祖先 subTreeSize += 1
  2. 寻找数组插入点
    • 插入点 = flatIndexMap.get(当前节点.id) + 当前节点.subTreeSize
  3. 数组更新flatArray.splice(插入点, 0, newNode)
  4. 索引更新:后续节点 flatIndexMap+1,同时将新节点记入 siblingIndexMap,并更新插入位置之后所有兄弟节点的 siblingIndexMap(值 +1)。
const addSiblingNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
  const parentId = currentNode.parentId;
  let parentNode: TreeFlatNode | null | undefined = null;
  let childrenArray: TreeFlatNode[] | null = null;

  if (parentId) {
    parentNode = treeNodeMap.get(parentId);
    childrenArray = parentNode!.children!;
  } else {
    childrenArray = treeData.value;
  }

  // 1. Insert into children array
  const currentSiblingIndex = siblingIndexMap.get(currentNode.id) as number;
  const insertIndexInChildren = currentSiblingIndex + 1;
  childrenArray.splice(insertIndexInChildren, 0, newNode);

  // 2. Update sibling indices for subsequent siblings
  for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
    siblingIndexMap.set(childrenArray[i].id, i);
  }

  // 3. Prepare flat list
  const newFlatNodes: TreeFlatNode[] = [];
  flattenAndRegister(newNode, parentId, newFlatNodes);

  // 4. Insert into flatArray
  // Insert after currentNode's subtree
  const insertIndex =
    (flatIndexMap.get(currentNode.id) as number) +
    (currentNode.subTreeSize as number);
  flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

  // 5. Update maps and sizes
  updateFlatIndexMap(insertIndex);
  if (parentNode) {
    updateSubTreeSizeUpwards(parentNode, newNode.subTreeSize as number);
  }

  triggerUpdate();
};

3.4 树删除节点(批量删除策略)

逻辑构思:如果逐个删除子树节点,由于 Array.prototype.splice 的底层实现需要将删除位置之后的所有元素向前移动以填补空缺,每次删除一个元素都会产生 O(N)O(N) 的移位开销(NN 为数组总长度)。如果子树包含 MM 个节点,逐个删除会导致大量的重复移位操作,总时间复杂度将退化为 O(N×M)O(N \times M),性能极差。利用 DFS 连续性特性,我们直接在扁平数组中“切掉”这一整段,只需要进行一次 O(N)O(N) 的数组移位操作即可。 算法设计

  • 时间复杂度O(N)O(N),一次性 splice 删除了 MM 个节点,产生了 O(N)O(N) 的数组元素前移开销,以及遍历更新剩余节点索引的 O(N)O(N) 开销。
  1. 获取规模:待删除节点数 count = node.subTreeSize
  2. 数组更新:起点 startIndex = flatIndexMap.get(node.id)。直接执行 flatArray.splice(startIndex, count)
  3. 树与索引更新
    • treeNodeMap 移除这 count 个节点。
    • 从父节点的 children 中移除目标节点,更新后续兄弟节点的 siblingIndexMap(值 -1)。
    • 祖先节点的 subTreeSize -= count
    • 遍历数组剩余元素,更新后续节点的 flatIndexMap(值 -count)。
const deleteNode = (node: TreeFlatNode) => {
  const { id, subTreeSize, parentId } = node;
  const startIndex = flatIndexMap.get(id) as number;

  // 1. Remove from flatArray
  flatArray.value.splice(startIndex, subTreeSize as number);

  // 2. Remove from parent's children
  if (parentId) {
    const parent = treeNodeMap.get(parentId)!;
    const index = parent.children!.findIndex((c) => c.id === id);
    if (index > -1) {
      parent.children!.splice(index, 1);
      // Update sibling indices
      for (let i = index; i < parent.children!.length; i++) {
        siblingIndexMap.set(parent.children![i].id, i);
      }
    }
    updateSubTreeSizeUpwards(parent, -(subTreeSize as number));
  } else {
    // Root node
    if (treeData.value) {
      const index = treeData.value.findIndex((c) => c.id === id);
      if (index > -1) {
        treeData.value.splice(index, 1);
        // Update sibling indices
        for (let i = index; i < treeData.value.length; i++) {
          siblingIndexMap.set(treeData.value[i].id, i);
        }
      }
    }
  }

  // 3. Update flatIndexMap
  updateFlatIndexMap(startIndex);

  // 4. Cleanup maps
  // Ideally we should recursively delete from maps, but for now simple delete is okay
  // as long as we don't reuse IDs or query deleted nodes.
  treeNodeMap.delete(id);
  flatIndexMap.delete(id);
  siblingIndexMap.delete(id);

  triggerUpdate();
};

3.5 树移动节点 (Cut & Paste)

逻辑构思:移动本质上是“先删后加”,但为了保留对象的引用和内部状态(避免触发大量的 UI 卸载/重挂载),我们采用“剪切-粘贴”策略。 算法设计

  • 时间复杂度O(N)O(N),相当于执行了一次删除和一次添加,需要进行两次数组元素的移位操作和索引更新。
  1. 剪切 (Detach)
    • 规模 count = node.subTreeSize。起点 oldIndex = flatIndexMap.get(node.id)
    • 提取子树:subTreeNodes = flatArray.splice(oldIndex, count)
    • 更新原父链的 subTreeSize -= count,更新原位置后续节点的 flatIndexMap。清理原父节点的 children
  2. 粘贴 (Attach)
    • 按照“添加节点”的逻辑计算出新的插入点 newIndex
    • 整体插入:flatArray.splice(newIndex, 0, ...subTreeNodes)
    • 更新新父链的 subTreeSize += count,更新新位置后续节点的 flatIndexMap。更新新父节点的 children
const moveNode = (
  node: TreeFlatNode,
  targetNode: TreeFlatNode,
  placement: "before" | "after" | "inner",
) => {
  if (!node || !targetNode) return;

  let current: TreeFlatNode | null | undefined = targetNode;
  while (current) {
    if (current.id === node.id) {
      throw new Error("Cannot move a node into itself or its descendants");
    }
    if (current.parentId) {
      current = treeNodeMap.get(current.parentId);
    } else {
      current = null;
    }
  }

  const { id, subTreeSize, parentId: oldParentId } = node;
  const oldIndex = flatIndexMap.get(id) as number;

  // 1. Cut (Detach)
  const subTreeNodes = flatArray.value.splice(oldIndex, subTreeSize as number);

  if (oldParentId) {
    const oldParent = treeNodeMap.get(oldParentId)!;
    const childIndex = oldParent.children!.findIndex((c) => c.id === id);
    if (childIndex > -1) {
      oldParent.children!.splice(childIndex, 1);
      for (let i = childIndex; i < oldParent.children!.length; i++) {
        siblingIndexMap.set(oldParent.children![i].id, i);
      }
    }
    updateSubTreeSizeUpwards(oldParent, -(subTreeSize as number));
  } else {
    const childIndex = treeData.value.findIndex((c) => c.id === id);
    if (childIndex > -1) {
      treeData.value.splice(childIndex, 1);
      for (let i = childIndex; i < treeData.value.length; i++) {
        siblingIndexMap.set(treeData.value[i].id, i);
      }
    }
  }

  // 2. Paste (Attach)
  let newParentId: TreeNodeId | null | undefined = null;
  let newParent: TreeFlatNode | null | undefined = null;
  let insertIndexInChildren = 0;
  let newFlatIndex = 0;
  let childrenArray: TreeFlatNode[] | null = null;

  if (placement === "inner") {
    newParentId = targetNode.id;
    newParent = targetNode;
    if (!newParent.children) newParent.children = [];
    childrenArray = newParent.children;
    insertIndexInChildren = childrenArray.length;

    newFlatIndex =
      (flatIndexMap.get(targetNode.id) as number) +
      (targetNode.subTreeSize as number);
    if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
      newFlatIndex -= subTreeSize as number;
    }
  } else {
    newParentId = targetNode.parentId;
    if (newParentId) {
      newParent = treeNodeMap.get(newParentId);
      childrenArray = newParent!.children!;
    } else {
      childrenArray = treeData.value;
    }

    const targetSiblingIndex = siblingIndexMap.get(targetNode.id) as number;
    insertIndexInChildren =
      placement === "before" ? targetSiblingIndex : targetSiblingIndex + 1;

    if (placement === "before") {
      newFlatIndex = flatIndexMap.get(targetNode.id) as number;
    } else {
      newFlatIndex =
        (flatIndexMap.get(targetNode.id) as number) +
        (targetNode.subTreeSize as number);
    }

    if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
      newFlatIndex -= subTreeSize as number;
    }
  }

  childrenArray.splice(insertIndexInChildren, 0, node);
  for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
    siblingIndexMap.set(childrenArray[i].id, i);
  }

  flatArray.value.splice(newFlatIndex, 0, ...subTreeNodes);

  node.parentId = newParentId;

  if (newParent) {
    updateSubTreeSizeUpwards(newParent, subTreeSize as number);
  }

  updateFlatIndexMap(Math.min(oldIndex, newFlatIndex));

  triggerUpdate();
};

3.6 树修改节点属性

逻辑构思:仅修改非结构属性,不影响树形态。 算法设计:通过 treeNodeMapO(1)O(1) 复杂度拿到节点引用,直接修改。由于 flatArray 中保存的是同一个对象的引用,借助 Vue/React 的响应式机制,UI 会自动局部更新。

  • 时间复杂度O(1)O(1),通过 Map 瞬间定位,修改属性即完成操作,没有额外的遍历或数组移位开销。
const handleEdit = (data) => {
  data.label = "new value";
  emit("update:treeData", [...treeData.value]);
};

4. 虚拟滚动的实现与结合

完成了底层数据的双向同步后,我们在视图层引入虚拟滚动(Virtual Scrolling)以彻底解决 DOM 节点过多的性能问题。

4.1 结合 vue-virtual-scroll-list 实现虚拟滚动

在实际开发中,我们通常不需要手写虚拟滚动逻辑,可以直接借助成熟的第三方库(如 vue-virtual-scroll-list)来实现。

使用 vue-virtual-scroll-list 非常简单,我们只需要将维护好的扁平数组 flatArray 传递给组件即可:

<template>
  <virtual-list
    class="tree-virtual-list"
    :data-key="'id'"
    :data-sources="flatArray"
    :data-component="TreeNodeComponent"
    :estimate-size="30"
  />
</template>

<script setup>
import VirtualList from "vue-virtual-scroll-list";
import TreeNodeComponent from "./TreeNodeComponent.vue";
// ... 维护 flatArray 的逻辑
</script>

<style scoped>
.tree-virtual-list {
  height: 500px;
  overflow-y: auto;
}
</style>

4.2 结合双向同步方案的优势

  1. 直接驱动:上述设计的 flatArray 就是一个一维的响应式数组。每次发生增删改移操作后,flatArray 会实时发生变化(长度变化或元素变更)。
  2. 自动映射:虚拟滚动组件直接监听 flatArray 的长度变化,重新计算 totalHeight 和视图区间,开发者无需手动干预 DOM。
  3. 层级视觉还原:在渲染截取出来的节点时,可以结合节点在树中的层级关系动态计算内边距,在视觉上完美还原树的结构。
  4. 折叠/展开逻辑:树的折叠和展开,本质上就是对该节点的子树进行“批量删除”和“重新添加子节点”的操作。复用上述的 3.4 和 3.2 逻辑,配合虚拟滚动,即使展开包含几万个节点的目录,也只是一次数组 splice 操作,界面不会有任何卡顿。

完整代码

将数据结构和树的操作封装成组合式函数,便于复用。

useTreeFlat.ts源码:

import { ref, watch } from "vue";
import type {
  TreeFlatNode,
  TreeNodeMap,
  FlatIndexMap,
  SiblingIndexMap,
  TreeNodeId,
} from "./types";

export const useTreeFlat = (props: any, emit?: any) => {
  const flatArray = ref<TreeFlatNode[]>([]);
  const treeData = ref<TreeFlatNode[]>([]); // Store reference to the source tree array
  const treeNodeMap: TreeNodeMap = new Map();
  const flatIndexMap: FlatIndexMap = new Map();
  const siblingIndexMap: SiblingIndexMap = new Map();

  // Helper to update ancestor sizes
  const updateSubTreeSizeUpwards = (node: TreeFlatNode, delta: number) => {
    let current: TreeFlatNode | null | undefined = node;
    while (current) {
      if (current.subTreeSize !== undefined) {
        current.subTreeSize += delta;
      }
      if (current.parentId) {
        current = treeNodeMap.get(current.parentId);
      } else {
        current = null;
      }
    }
  };

  // Helper to update flatIndexMap for nodes after a certain index
  const updateFlatIndexMap = (startIndex: number) => {
    for (let i = startIndex; i < flatArray.value.length; i++) {
      const node = flatArray.value[i];
      flatIndexMap.set(node.id, i);
    }
  };

  // Recursive function to flatten a node and its children,
  // setting metadata and populating maps for NEW nodes.
  const flattenAndRegister = (
    node: TreeFlatNode,
    parentId: TreeNodeId | null | undefined,
    flatList: TreeFlatNode[],
  ) => {
    node.parentId = parentId;
    if (!node.children) node.children = [];

    flatList.push(node);
    treeNodeMap.set(node.id, node);
    // Note: flatIndexMap will be set after we insert into flatArray to be correct.

    let size = 1;
    if (node.children.length > 0) {
      node.children.forEach((child, index) => {
        siblingIndexMap.set(child.id, index);
        size += flattenAndRegister(child, node.id, flatList);
      });
    }
    node.subTreeSize = size;
    return size;
  };

  const initTreeFlat = (tree: TreeFlatNode[]) => {
    treeData.value = tree;
    flatArray.value = [];
    treeNodeMap.clear();
    flatIndexMap.clear();
    siblingIndexMap.clear();

    const traverse = (
      nodes: TreeFlatNode[],
      parentId: TreeNodeId | null | undefined = null,
    ) => {
      let currentLevelSize = 0;
      nodes.forEach((node, index) => {
        node.parentId = parentId;
        if (!node.children) node.children = [];

        treeNodeMap.set(node.id, node);
        siblingIndexMap.set(node.id, index);

        flatArray.value.push(node);
        flatIndexMap.set(node.id, flatArray.value.length - 1);

        let childrenSize = 0;
        if (node.children && node.children.length > 0) {
          childrenSize = traverse(node.children, node.id);
        }

        node.subTreeSize = 1 + childrenSize;
        currentLevelSize += node.subTreeSize;
      });
      return currentLevelSize;
    };

    if (treeData.value && treeData.value.length > 0) {
      traverse(treeData.value);
    }

    return flatArray.value;
  };

  // Watch props for changes
  if (props && props.treeData) {
    watch(
      () => props.treeData,
      (newVal) => {
        const newStr = JSON.stringify(newVal);
        const oldStr = JSON.stringify(treeData.value);
        if (newStr !== oldStr) {
          const newData = JSON.parse(newStr);
          initTreeFlat(newData);
        }
      },
      { immediate: true, deep: true },
    );
  }

  const triggerUpdate = () => {
    treeData.value = [...treeData.value];
    if (emit) {
      emit("update:treeData", treeData.value);
    }
  };

  const addChildNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
    if (!currentNode.children) currentNode.children = [];

    // 1. Add to parent's children
    currentNode.children.push(newNode);
    const siblingIndex = currentNode.children.length - 1;
    siblingIndexMap.set(newNode.id, siblingIndex);

    // 2. Prepare flat list for new node(s)
    const newFlatNodes: TreeFlatNode[] = [];
    flattenAndRegister(newNode, currentNode.id, newFlatNodes);

    // 3. Insert into flatArray
    // Insert after the last node of currentNode's EXISTING subtree.
    // currentNode.subTreeSize currently includes existing children (before this new one is fully accounted for in the loop? No, subTreeSize is property).
    // Wait, updateSubTreeSizeUpwards is called AFTER. So currentNode.subTreeSize is the OLD size.
    // So insertion point is flatIndexMap[currentNode.id] + currentNode.subTreeSize.
    const insertIndex =
      (flatIndexMap.get(currentNode.id) as number) +
      (currentNode.subTreeSize as number);
    flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

    // 4. Update maps and sizes
    updateFlatIndexMap(insertIndex);
    updateSubTreeSizeUpwards(currentNode, newNode.subTreeSize as number);

    triggerUpdate();
  };

  const addSiblingNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
    const parentId = currentNode.parentId;
    let parentNode: TreeFlatNode | null | undefined = null;
    let childrenArray: TreeFlatNode[] | null = null;

    if (parentId) {
      parentNode = treeNodeMap.get(parentId);
      childrenArray = parentNode!.children!;
    } else {
      childrenArray = treeData.value;
    }

    // 1. Insert into children array
    const currentSiblingIndex = siblingIndexMap.get(currentNode.id) as number;
    const insertIndexInChildren = currentSiblingIndex + 1;
    childrenArray.splice(insertIndexInChildren, 0, newNode);

    // 2. Update sibling indices for subsequent siblings
    for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
      siblingIndexMap.set(childrenArray[i].id, i);
    }

    // 3. Prepare flat list
    const newFlatNodes: TreeFlatNode[] = [];
    flattenAndRegister(newNode, parentId, newFlatNodes);

    // 4. Insert into flatArray
    // Insert after currentNode's subtree
    const insertIndex =
      (flatIndexMap.get(currentNode.id) as number) +
      (currentNode.subTreeSize as number);
    flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

    // 5. Update maps and sizes
    updateFlatIndexMap(insertIndex);
    if (parentNode) {
      updateSubTreeSizeUpwards(parentNode, newNode.subTreeSize as number);
    }

    triggerUpdate();
  };

  const deleteNode = (node: TreeFlatNode) => {
    const { id, subTreeSize, parentId } = node;
    const startIndex = flatIndexMap.get(id) as number;

    // 1. Remove from flatArray
    flatArray.value.splice(startIndex, subTreeSize as number);

    // 2. Remove from parent's children
    if (parentId) {
      const parent = treeNodeMap.get(parentId)!;
      const index = parent.children!.findIndex((c) => c.id === id);
      if (index > -1) {
        parent.children!.splice(index, 1);
        // Update sibling indices
        for (let i = index; i < parent.children!.length; i++) {
          siblingIndexMap.set(parent.children![i].id, i);
        }
      }
      updateSubTreeSizeUpwards(parent, -(subTreeSize as number));
    } else {
      // Root node
      if (treeData.value) {
        const index = treeData.value.findIndex((c) => c.id === id);
        if (index > -1) {
          treeData.value.splice(index, 1);
          // Update sibling indices
          for (let i = index; i < treeData.value.length; i++) {
            siblingIndexMap.set(treeData.value[i].id, i);
          }
        }
      }
    }

    // 3. Update flatIndexMap
    updateFlatIndexMap(startIndex);

    // 4. Cleanup maps
    // Ideally we should recursively delete from maps, but for now simple delete is okay
    // as long as we don't reuse IDs or query deleted nodes.
    // For completeness, we should probably clear entries for all descendants.
    // But since they are removed from flatArray and parent's children, they are effectively gone.
    treeNodeMap.delete(id);
    flatIndexMap.delete(id);
    siblingIndexMap.delete(id);

    triggerUpdate();
  };

  const moveNode = (
    node: TreeFlatNode,
    targetNode: TreeFlatNode,
    placement: "before" | "after" | "inner",
  ) => {
    if (!node || !targetNode) return;

    let current: TreeFlatNode | null | undefined = targetNode;
    while (current) {
      if (current.id === node.id) {
        throw new Error("Cannot move a node into itself or its descendants");
      }
      if (current.parentId) {
        current = treeNodeMap.get(current.parentId);
      } else {
        current = null;
      }
    }

    const { id, subTreeSize, parentId: oldParentId } = node;
    const oldIndex = flatIndexMap.get(id) as number;

    // 1. Cut (Detach)
    const subTreeNodes = flatArray.value.splice(
      oldIndex,
      subTreeSize as number,
    );

    if (oldParentId) {
      const oldParent = treeNodeMap.get(oldParentId)!;
      const childIndex = oldParent.children!.findIndex((c) => c.id === id);
      if (childIndex > -1) {
        oldParent.children!.splice(childIndex, 1);
        for (let i = childIndex; i < oldParent.children!.length; i++) {
          siblingIndexMap.set(oldParent.children![i].id, i);
        }
      }
      updateSubTreeSizeUpwards(oldParent, -(subTreeSize as number));
    } else {
      const childIndex = treeData.value.findIndex((c) => c.id === id);
      if (childIndex > -1) {
        treeData.value.splice(childIndex, 1);
        for (let i = childIndex; i < treeData.value.length; i++) {
          siblingIndexMap.set(treeData.value[i].id, i);
        }
      }
    }

    // 2. Paste (Attach)
    let newParentId: TreeNodeId | null | undefined = null;
    let newParent: TreeFlatNode | null | undefined = null;
    let insertIndexInChildren = 0;
    let newFlatIndex = 0;
    let childrenArray: TreeFlatNode[] | null = null;

    if (placement === "inner") {
      newParentId = targetNode.id;
      newParent = targetNode;
      if (!newParent.children) newParent.children = [];
      childrenArray = newParent.children;
      insertIndexInChildren = childrenArray.length;

      newFlatIndex =
        (flatIndexMap.get(targetNode.id) as number) +
        (targetNode.subTreeSize as number);
      if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
        newFlatIndex -= subTreeSize as number;
      }
    } else {
      newParentId = targetNode.parentId;
      if (newParentId) {
        newParent = treeNodeMap.get(newParentId);
        childrenArray = newParent!.children!;
      } else {
        childrenArray = treeData.value;
      }

      const targetSiblingIndex = siblingIndexMap.get(targetNode.id) as number;
      insertIndexInChildren =
        placement === "before" ? targetSiblingIndex : targetSiblingIndex + 1;

      if (placement === "before") {
        newFlatIndex = flatIndexMap.get(targetNode.id) as number;
      } else {
        newFlatIndex =
          (flatIndexMap.get(targetNode.id) as number) +
          (targetNode.subTreeSize as number);
      }

      if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
        newFlatIndex -= subTreeSize as number;
      }
    }

    childrenArray.splice(insertIndexInChildren, 0, node);
    for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
      siblingIndexMap.set(childrenArray[i].id, i);
    }

    flatArray.value.splice(newFlatIndex, 0, ...subTreeNodes);

    node.parentId = newParentId;

    if (newParent) {
      updateSubTreeSizeUpwards(newParent, subTreeSize as number);
    }

    updateFlatIndexMap(Math.min(oldIndex, newFlatIndex));

    triggerUpdate();
  };

  const moveUp = (node: TreeFlatNode) => {
    const parentId = node.parentId;
    const childrenArray = parentId
      ? treeNodeMap.get(parentId)!.children!
      : treeData.value;
    const siblingIndex = siblingIndexMap.get(node.id) as number;
    if (siblingIndex > 0) {
      const targetNode = childrenArray[siblingIndex - 1];
      moveNode(node, targetNode, "before");
    }
  };

  const moveDown = (node: TreeFlatNode) => {
    const parentId = node.parentId;
    const childrenArray = parentId
      ? treeNodeMap.get(parentId)!.children!
      : treeData.value;
    const siblingIndex = siblingIndexMap.get(node.id) as number;
    if (siblingIndex < childrenArray.length - 1) {
      const targetNode = childrenArray[siblingIndex + 1];
      moveNode(node, targetNode, "after");
    }
  };

  return {
    treeData,
    initTreeFlat,
    addChildNode,
    addSiblingNode,
    deleteNode,
    moveNode,
    moveUp,
    moveDown,
    flatArray,
  };
};

实现效果

image-5.png

结语

本方案通过引入 subTreeSize 字段并利用 DFS 的连续性原理,将复杂的树形结构拓扑变更,巧妙地降维成了简单的一维数组切片(Splice)操作。结合辅助 Map 索引换取时间,配合视图层的虚拟滚动,最终构建出了一个高性能、逻辑清晰的树与数组实时双向同步架构。

LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置:二分查找实战

作者 Wect
2026年3月25日 22:44

刷题路上,二分查找是绕不开的经典算法,而LeetCode 34题「在排序数组中查找元素的第一个和最后一个位置」,正是二分查找的进阶应用——它不仅要求我们找到目标值,更要精准定位其在非递减数组中的起始和结束位置,同时还要满足O(log n)的时间复杂度要求。今天就来拆解这道题,从题干分析到代码实现,再到细节坑点,一步步搞懂如何高效解决这道题。

一、题干解析:明确需求与约束

先再仔细读一遍题干,避免遗漏关键信息:

  • 输入:非递减顺序排列的整数数组nums,目标值target;

  • 输出:target在数组中的开始位置和结束位置,若不存在则返回[-1, -1];

  • 核心约束:必须设计时间复杂度为O(log n)的算法。

这里有两个关键点需要注意:

  1. 非递减数组:数组元素可能重复,且从左到右不递减(允许相等),这是二分查找的前提,也是我们定位边界的核心依据;

  2. O(log n)时间复杂度:直接遍历数组(O(n))会超时,因此必须用二分查找,且需要两次二分——分别找左边界(第一个等于target的位置)和右边界(最后一个等于target的位置)。

二、解题思路:两次二分查找,定位左右边界

既然数组是有序的,我们可以利用二分查找的特性,通过调整判断条件,分别找到target的左边界和右边界:

  1. 左边界:第一个大于等于target的位置(如果target存在,这个位置就是第一个target的索引;如果不存在,这个位置会大于数组长度或对应元素不等于target);

  2. 右边界:第一个大于target的位置,再减1(如果target存在,减1后就是最后一个target的索引;如果不存在,减1后会小于左边界)。

为了复用代码,我们可以设计一个通用的二分查找函数,通过一个布尔参数lower来控制查找左边界还是右边界:

  • 当lower为true时,查找左边界(第一个>=target的位置);

  • 当lower为false时,查找右边界的“临界位置”(第一个>target的位置)。

最后,通过判断左边界是否小于等于右边界、且边界对应的元素是否为target,来确定最终结果——如果满足,则返回[左边界, 右边界];否则返回[-1, -1]。

三、代码实现与逐行解析

先给出完整代码(TypeScript版本),再逐行拆解核心逻辑,确保每一步都能理解:

function searchRange(nums: number[], target: number): number[] {
  const n = nums.length;

  // 通用二分查找函数:lower控制查找左边界/右边界临界值
  const binarySearch = (target: number, lower: boolean) => {
    let left = 0, right = n - 1, ans = n; // ans初始化为n,应对target大于所有元素的情况
    while (left <= right) {
      const mid = Math.floor((left + right) / 2); // 中间位置,避免溢出
      // 关键判断:根据lower调整二分方向
      if (nums[mid] > target || (lower && nums[mid] >= target)) {
        right = mid - 1; // 目标在左半区,收缩右边界
        ans = mid; // 记录当前mid,可能是我们要找的边界
      } else {
        left = mid + 1; // 目标在右半区,收缩左边界
      }
    }
    return ans;
  }

  let res: number[] = [-1, -1]; // 初始化为不存在的情况
  const leftIdx = binarySearch(target, true); // 查找左边界
  const rightIdx = binarySearch(target, false) - 1; // 查找右边界并调整

  // 验证边界的合法性:左边界<=右边界,且边界元素等于target
  if (leftIdx <= rightIdx && rightIdx < nums.length && nums[leftIdx] === target && nums[rightIdx] === target) {
    res = [leftIdx, rightIdx];
  }

  return res;
};

核心代码逐行解析

1. 初始化与通用二分函数定义

const n = nums.length; —— 记录数组长度,避免多次调用nums.length,提升效率。

binarySearch函数:接收target和lower两个参数,返回对应边界的索引。这里ans初始化为n,是为了应对target大于数组中所有元素的情况(此时二分结束后,ans仍为n,后续rightIdx = n-1,会小于leftIdx,直接返回[-1,-1])。

2. 二分查找的核心判断逻辑

if (nums[mid] > target || (lower && nums[mid] >= target)):这是整个算法的灵魂,分两种情况理解:

  • 当lower为true(找左边界):只要nums[mid] >= target,就说明左边界可能在mid或mid左侧,因此收缩右边界(right = mid - 1),并记录当前mid为候选边界(ans = mid);

  • 当lower为false(找右边界临界值):只有nums[mid] > target时,才说明右边界临界值在mid或mid左侧,收缩右边界,否则继续向右查找。

else { left = mid + 1; }:当不满足上述条件时,说明目标在mid右侧,收缩左边界,继续查找。

3. 边界验证与结果返回

leftIdx = binarySearch(target, true):得到左边界(第一个>=target的位置);

rightIdx = binarySearch(target, false) - 1:得到第一个>target的位置,减1后就是最后一个<=target的位置(即右边界);

边界验证条件:leftIdx <= rightIdx(确保边界有效,不会出现左边界在右边界右侧的情况)、rightIdx < nums.length(避免数组越界)、nums[leftIdx] === target和nums[rightIdx] === target(确保找到的边界确实是target的位置,而非其他值的边界)。

四、关键坑点与注意事项

这道题看似简单,但很多人在二分查找的边界处理上容易出错,总结几个高频坑点:

  1. ans的初始值:必须设为n,而不是-1。如果target大于数组中所有元素,二分结束后left会超过right,ans仍为n,此时rightIdx = n-1,leftIdx = n,leftIdx > rightIdx,直接返回[-1,-1],避免出错;

  2. 二分循环条件:必须是left <= right,而不是left < right。如果用left < right,可能会错过最后一个符合条件的元素(比如数组中只有一个target时);

  3. 边界验证:不能只判断leftIdx <= rightIdx,还要验证nums[leftIdx]和nums[rightIdx]是否等于target。比如数组为[1,2,3,4],target为5,此时leftIdx = 4,rightIdx = 3,不满足leftIdx <= rightIdx;但如果target为0,leftIdx = 0,rightIdx = -1,也不满足;如果数组为[2,2],target为3,leftIdx = 2,rightIdx = 1,同样不满足;

  4. 整数溢出:mid的计算用Math.floor((left + right) / 2),在JavaScript/TypeScript中,整数范围足够大,不会出现溢出,但如果是其他语言(如Java),建议用left + Math.floor((right - left)/2),避免left+right溢出。

五、总结与拓展

这道题的核心是「二分查找的边界定位」,通过一次二分查找函数的复用,分别找到左、右边界,既满足了O(log n)的时间复杂度,又简化了代码逻辑。

拓展思考:

  • 如果数组是递减的,如何修改代码?只需调整二分查找的判断条件,将nums[mid] > target改为nums[mid] < target即可;

  • 如果题目要求找到target的出现次数,只需用右边界 - 左边界 + 1(若存在target),否则为0;

  • 二分查找的核心是「收缩边界」,只要明确“目标在左半区还是右半区”,就能灵活调整判断条件,解决各类边界查找问题。

v0.dev 支持 RSC 了!AI 生成全栈组件离我们还有多远?

2026年3月25日 21:37

如果你最近还在把 Vercel 的 v0.dev 当作一个单纯的“Tailwind 代码生成器”或者“UI 画板”,那你可能要重新审视这个工具了。

最近,v0.dev 迎来了一个低调但绝对称得上是里程碑式的更新:它开始支持生成 React Server Components (RSC)

这意味着什么?这意味着 AI 正在跨越那条隐形的红线——从纯粹的“前端视觉层”生成,正式将触角伸向了“服务端逻辑”。今天,我们就来聊聊这个变化为什么如此重要,以及距离我们真正实现“一句话生成全栈组件”,到底还有多远。


从“画皮”到“入骨”:RSC 给 v0 带来了什么?

在过去,当你对 v0 说“给我一个用户资料卡片”时,它会极其聪明地组合 Tailwind CSS 和 shadcn/ui,给你一个漂亮的界面。但里面的数据是死的:John Doe, johndoe@example.com。要想把它用到生产环境,你还得自己写数据获取逻辑、处理 Loading 状态。

但支持 RSC 之后,游戏规则改变了。

现在,v0 可以直接生成一个异步的 React 组件。它不仅仅知道怎么“画”出这个组件,它还知道怎么“喂”饱这个组件。AI 可以直接在组件顶部写出服务端的 Fetch 逻辑,甚至直接连接数据库(如果你提供了足够的上下文)。

以前的 v0 是前端切图仔,现在的 v0 是初级全栈工程师。

RSC 将数据获取和 UI 渲染收敛到了同一个文件中,这种心智模型不仅对人类开发者友好,对 LLM(大型语言模型)来说更是简直完美。AI 不再需要在多个文件之间跳转来维护状态,只需在单文件中顺水推舟地完成“请求 -> 处理 -> 渲染”的闭环。


距离真正的“AI 生成全栈”,我们还差几步?

既然 v0 已经迈出了服务端数据获取的第一步,那么离真正的“一句话生成完整业务线”(不仅能看,能读,还能写、能交互),我们还有多远?

客观看待,技术上已经非常接近,但要达到工程级的可靠性,还需要跨越以下三个关卡:

1. 从“读(RSC)”到“写(Server Actions)”

RSC 解决了“看”的问题(Read),但真正的全栈组件需要“动”(Write)。用户提交表单、点赞、删除一条记录,这些都需要通过状态变更来实现。

React 已经给出了答案:Server Actions

可以预见,v0 的下一步必然是深度集成 Server Actions。当你要求“生成一个带提交功能的登录表单”时,AI不仅能写出 UI,还能自动生成底层的 action.ts,处理数据验证(如 Zod)并模拟数据库写入。一旦这个闭环打通,AI 生成单文件全栈组件(Single-File Full-Stack Component)将成为常态。

2. 数据库与上下文感知 (Context Awareness)

现在的 AI 生成大多还是“盲人摸象”。它不知道你的数据库表结构长什么样。

要生成真正的全栈组件,AI 需要深度理解你的代码库上下文。它必须知道你的 Prisma Schema 或者 Drizzle Schema。未来的 v0(或 Cursor 等工具)必然会增加一种机制,让你可以轻易地将数据库结构作为上下文注入。

“根据我的 User 表,生成一个支持分页和关键字搜索的后台管理表格。” —— 这才是终极形态。

3. 鉴权、边界与安全性 (Security & Edge Cases)

这是目前 AI 最大的软肋。AI 为了让代码跑起来,往往会忽略安全性。

在全栈组件中,谁来保证这个请求是被授权的?谁来防止 SQL 注入或越权访问?如果 AI 自动生成的服务端逻辑没有正确包裹 requireAuth() 或进行权限校验,这将是灾难性的。在 AI 生成全栈代码普及之前,基于 AI 的自动化安全审计工具必须先成熟起来。


结语:产品工程师的黄金时代

v0 支持 RSC,只是一个微小的版本更新,但它是一个明确的信号:UI 和后端的边界正在被 AI 暴力的抹平。

我们正在从“手写每一行代码的工匠”,变成“拼装智能组件的架构师”。对于开发者来说,这意味着我们不再需要把时间浪费在无聊的增删改查和像素级对齐上,而是可以将全部精力投入到业务逻辑、用户体验和产品创新上。

AI 生成全栈组件离我们不远了,也许就在下一次的 Next.js Conf 上,我们就能见证它的完全体。

你,准备好了吗?

🐾 我是404星球的猫

💻✨ 探索前端无界,拥抱AI未来,我们下篇见~

👇 关注我,解锁技术交叉新视野

第十八讲 渲染原理与自定义绘制

作者 节点玩家
2026年3月25日 21:28

前言:

可以跳过。

一、定位

本讲是 Flutter 进阶开发的核心内容,主要解决以下问题:

  • 理解 Flutter 界面渲染的底层逻辑(Widget/Element/RenderObject 三棵树),告别"黑盒式"开发

  • 掌握 setState 刷新机制,避免无效重建和性能问题

  • 学会使用 CustomPaint 实现自定义绘制,突破内置组件的视觉限制

  • 掌握常用视觉特效(裁剪、变形、透明度、混合模式)的使用场景

  • 运用 RepaintBoundary 优化重绘性能,提升复杂界面流畅度

渲染三棵树:Widget(配置)→ Element(实例)→ RenderObject(渲染),setState 仅标记 Element 为 dirty 触发更新

自定义绘制:通过 CustomPaint + CustomPainter 实现任意图形,shouldRepaint 是性能优化关键

性能优化:RepaintBoundary 隔离重绘区域,合理拆分组件避免无效重建,视觉特效按需使用

三棵树关系与渲染流程

image.png

树类型 核心作用 关键特性
Widget 界面配置描述 轻量、不可变、可复用
Element 实例化桥梁 持有Widget和RenderObject引用,决定是否重建
RenderObject 渲染执行体 处理布局(Layout)、绘制(Paint)、HitTest

二、核心知识点

2.1 三棵树与setState刷新机制

核心原理
  • Widget是「配置模板」,Element 是「模板实例」,RenderObject是「渲染工人」
  • setState 本质是标记当前 Element 为 dirty,Flutter 引擎在下一帧会重新构建该 Element 对应的 Widget,并更新 RenderObject
  • 重建范围:默认会从调用 setState 的 Widget 开始,递归重建所有子 Widget
案例:setState 重建范围演示
import 'package:flutter/material.dart';

class ThreeTreesDemo extends StatefulWidget {
  const ThreeTreesDemo({super.key});

  @override
  State<ThreeTreesDemo> createState() => _ThreeTreesDemoState();
}

class _ThreeTreesDemoState extends State<ThreeTreesDemo> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    print("外层Widget重建"); // 每次setState都会打印
    return Scaffold(
      appBar: AppBar(title: const Text("三棵树与setState")),
      body: Column(
        children: [
          Text("计数:$_count"),
          // 用RepaintBoundary隔离+避免重建
          RepaintBoundary(
            child: _StaticWidget(), // 静态组件不会被重建
          ),
          ElevatedButton(
            onPressed: () => setState(() => _count++),
            child: const Text("点击增加"),
          )
        ],
      ),
    );
  }
}

// 抽离静态组件,避免被父级setState重建
class _StaticWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print("静态Widget重建"); // 只会打印一次
    return Container(
      width: 200,
      height: 200,
      color: Colors.blue,
      child: const Center(child: Text("静态内容")),
    );
  }
}

注意事项
  1. 避免在 build 方法中创建新对象(如 TextStyle、List),否则会触发不必要的重建
  2. 静态组件要抽离为独立 Widget,减少重建范围
  3. setState 是「标记更新」而非「立即刷新」,会在下一帧批量处理

2.2 CustomPaint 自定义绘制

核心属性
属性 作用
painter 自定义绘制逻辑(必须,继承 CustomPainter)
foregroundPainter 前景绘制(覆盖子组件)
size 绘制区域大小(默认子组件大小)
willChange 标记是否频繁变化,优化性能
案例:绘制自定义圆形进度条
import 'package:flutter/material.dart';
import 'dart:ui' as ui;

class CustomPaintDemo extends StatefulWidget {
  const CustomPaintDemo({super.key});

  @override
  State<CustomPaintDemo> createState() => _CustomPaintDemoState();
}

class _CustomPaintDemoState extends State<CustomPaintDemo> {
  double _progress = 0.5; // 50%进度

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("CustomPaint 自定义绘制")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 自定义绘制组件
            CustomPaint(
              size: const Size(200, 200),
              painter: ProgressPainter(progress: _progress),
              child: Center(
                child: Text(
                  "${(_progress * 100).toInt()}%",
                  style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                ),
              ),
            ),
            // 进度调节
            Slider(
              value: _progress,
              onChanged: (v) => setState(() => _progress = v),
            )
          ],
        ),
      ),
    );
  }
}

// 自定义绘制器
class ProgressPainter extends CustomPainter {
  final double progress;

  ProgressPainter({required this.progress});

  @override
  void paint(Canvas canvas, Size size) {
    // 1. 配置画笔
    final Paint bgPaint = Paint()
      ..color = Colors.grey[300]!
      ..style = PaintingStyle.stroke
      ..strokeWidth = 10;

    final Paint progressPaint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 10
      ..strokeCap = StrokeCap.round; // 圆角端点

    // 2. 绘制背景圆
    Offset center = Offset(size.width / 2, size.height / 2);
    double radius = size.width / 2 - 5;
    canvas.drawCircle(center, radius, bgPaint);

    // 3. 绘制进度弧
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -ui.pi / 2, // 起始角度(12点钟方向)
      2 * ui.pi * progress, // 扫过角度
      false, // 是否闭合
      progressPaint,
    );
  }

  // 关键:判断是否需要重绘(优化性能)
  @override
  bool shouldRepaint(covariant ProgressPainter oldDelegate) {
    return oldDelegate.progress != progress; // 只有进度变化时才重绘
  }
}

注意事项
  1. shouldRepaint 必须实现,返回 false 可避免无效重绘
  2. 绘制复杂图形时,尽量使用 Path 而非多次绘制基础图形
  3. 避免在 paint 方法中创建新对象(如 Paint),可提前初始化

2.3 常用视觉特效

核心属性与案例
特效 核心属性 案例代码
Clip(裁剪) ClipRect/ClipRRect/ClipPath ClipRRect(borderRadius: BorderRadius.circular(20), child: Image.asset("img.png"))
Transform(变形) transform(Matrix4)、alignment Transform.rotate(angle: pi/4, child: Container(width: 100, height: 100, color: Colors.red))
Opacity(透明度) opacity(0-1) Opacity(opacity: 0.5, child: Text("半透明文字"))
BlendMode(混合模式) blendMode(如BlendMode.srcOver) ColorFiltered(colorFilter: ColorFilter.mode(Colors.red, BlendMode.overlay), child: Image.asset("img.png"))
综合案例:特效组合使用
import 'package:flutter/material.dart';
import 'dart:math' as math;

class EffectsDemo extends StatelessWidget {
  const EffectsDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("视觉特效组合")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 裁剪 + 变形 + 透明度 + 混合模式
            Opacity(
              opacity: 0.8,
              child: Transform(
                transform: Matrix4.rotationZ(math.pi / 12) // 旋转15度
                  ..scale(0.9), // 缩放0.9倍
                alignment: Alignment.center,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(30),
                  child: ColorFiltered(
                    colorFilter: ColorFilter.mode(
                      Colors.pink.withOpacity(0.5),
                      BlendMode.softLight, // 柔光混合
                    ),
                    child: Container(
                      width: 200,
                      height: 200,
                      color: Colors.blue,
                      child: const Center(
                        child: Text(
                          "特效组合",
                          style: TextStyle(fontSize: 24, color: Colors.white),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

注意事项
  1. Transform 是「视觉变形」,不影响布局大小,可能导致点击区域偏移
  2. Opacity 值为0时,组件仍会参与布局,可结合 Offstage 隐藏
  3. BlendMode 会增加绘制开销,复杂界面需配合 RepaintBoundary 使用

2.4 RepaintBoundary 重绘隔离

核心作用
  • 将组件包裹在 RepaintBoundary 中,可使该组件的重绘独立于父组件
  • 避免一个小组件变化导致整个页面重绘,提升性能
  • 可通过 Flutter DevTools 的「Paint Profiler」查看重绘区域
案例:重绘隔离优化
import 'package:flutter/material.dart';

class RepaintBoundaryDemo extends StatefulWidget {
  const RepaintBoundaryDemo({super.key});

  @override
  State<RepaintBoundaryDemo> createState() => _RepaintBoundaryDemoState();
}

class _RepaintBoundaryDemoState extends State<RepaintBoundaryDemo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat(reverse: true);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("RepaintBoundary 重绘隔离")),
      body: Column(
        children: [
          // 无隔离:动画会导致整个Column重绘
          const Text("无RepaintBoundary(整个区域重绘)"),
          AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              return Container(
                width: 100 + _controller.value * 100,
                height: 100,
                color: Colors.red,
              );
            },
          ),
          const SizedBox(height: 20),
          // 有隔离:仅动画区域重绘
          const Text("有RepaintBoundary(仅动画区域重绘)"),
          RepaintBoundary( // 关键:重绘隔离
            child: AnimatedBuilder(
              animation: _controller,
              builder: (context, child) {
                return Container(
                  width: 100 + _controller.value * 100,
                  height: 100,
                  color: Colors.green,
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

注意事项
  1. 不要滥用 RepaintBoundary,每个隔离区域会增加内存开销
  2. 动画、频繁刷新的组件优先使用 RepaintBoundary
  3. 可通过 Flutter DevTools 的「Performance」面板查看重绘情况

三、全章节技术综合应用案例:自定义动态仪表盘

功能说明

整合「三棵树原理+setState优化+CustomPaint绘制+视觉特效+RepaintBoundary」,实现一个带动态效果的仪表盘:

  • 自定义绘制仪表盘刻度、指针
  • 指针随滑块动态旋转(Transform)
  • 刻度值半透明显示(Opacity)
  • 仪表盘圆角裁剪(ClipRRect)
  • 重绘隔离优化性能(RepaintBoundary)
  • 避免无效重建(优化setState范围)

完整代码

import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:math' as math;

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter 渲染原理综合案例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const DashboardDemo(),
    );
  }
}

// 仪表盘主页面(优化setState范围)
class DashboardDemo extends StatefulWidget {
  const DashboardDemo({super.key});

  @override
  State<DashboardDemo> createState() => _DashboardDemoState();
}

class _DashboardDemoState extends State<DashboardDemo> {
  double _value = 50; // 0-100的仪表盘数值

  @override
  Widget build(BuildContext context) {
    // 仅外层build,静态内容不重复构建
    return Scaffold(
      appBar: AppBar(title: const Text("自定义动态仪表盘")),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          // 重绘隔离:仅仪表盘区域重绘
          RepaintBoundary(
            child: _DashboardView(value: _value),
          ),
          const SizedBox(height: 40),
          // 数值调节滑块
          SizedBox(
            width: 300,
            child: Slider(
              min: 0,
              max: 100,
              value: _value,
              onChanged: (v) => setState(() => _value = v),
              activeColor: Colors.orange,
            ),
          ),
          Text(
            "当前值:${_value.toStringAsFixed(0)}",
            style: const TextStyle(fontSize: 18),
          )
        ],
      ),
    );
  }
}

// 仪表盘视图(抽离为独立组件,减少重建)
class _DashboardView extends StatelessWidget {
  final double value;

  const _DashboardView({required this.value});

  @override
  Widget build(BuildContext context) {
    // 计算指针旋转角度(0-100对应-120°到120°)
    double angle = (value / 100) * 240 - 120;
    angle = angle * math.pi / 180; // 转弧度

    return ClipRRect(
      // 圆角裁剪
      borderRadius: BorderRadius.circular(20),
      child: Container(
        width: 300,
        height: 300,
        color: Colors.grey[100],
        child: Stack(
          alignment: Alignment.center,
          children: [
            // 1. 自定义绘制仪表盘刻度
            CustomPaint(
              size: const Size(300, 300),
              painter: DashboardPainter(),
            ),
            // 2. 指针(变形+透明度)
            Opacity(
              opacity: 0.9,
              child: Transform.rotate(
                angle: angle,
                child: Container(
                  width: 120,
                  height: 8,
                  decoration: BoxDecoration(
                    color: Colors.red,
                    borderRadius: BorderRadius.circular(4),
                    boxShadow: const [
                      BoxShadow(color: Colors.redAccent, blurRadius: 5)
                    ],
                  ),
                ),
              ),
            ),
            // 3. 中心圆点(混合模式)
            ColorFiltered(
              colorFilter: ColorFilter.mode(
                Colors.orange,
                BlendMode.overlay,
              ),
              child: Container(
                width: 20,
                height: 20,
                decoration: const BoxDecoration(
                  color: Colors.white,
                  shape: BoxShape.circle,
                  boxShadow: [BoxShadow(blurRadius: 3)],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// 仪表盘刻度绘制器
class DashboardPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 移动画布原点到中心
    canvas.translate(size.width / 2, size.height / 2);
    // 反向Y轴(Flutter默认Y轴向下,调整为向上)
    canvas.scale(1, -1);

    // 1. 绘制外圆
    final outerPaint = Paint()
      ..color = Colors.blue[200]!
      ..style = PaintingStyle.stroke
      ..strokeWidth = 4;
    canvas.drawCircle(Offset.zero, size.width / 2 - 20, outerPaint);

    // 2. 绘制刻度(240°范围,每10°一个刻度)
    final gradPaint = Paint()..color = Colors.blue;
    for (int i = 0; i <= 24; i++) {
      double angle = (-120 + i * 10) * math.pi / 180;
      double r1 = size.width / 2 - 20;
      double r2 = r1 - (i % 6 == 0 ? 20 : 10); // 每6个刻度长一点

      // 绘制刻度线
      canvas.drawLine(
        Offset(r1 * math.cos(angle), r1 * math.sin(angle)),
        Offset(r2 * math.cos(angle), r2 * math.sin(angle)),
        gradPaint..strokeWidth = i % 6 == 0 ? 3 : 1,
      );

      // 绘制刻度值(每60°一个)
      if (i % 6 == 0) {
        double textAngle = angle;
        double textR = r1 - 30;
        // 恢复Y轴方向绘制文字
        canvas.save();
        canvas.scale(1, -1);
        TextPainter(
          text: TextSpan(
            text: "${i * 100 / 24}",
            style: const TextStyle(color: Colors.black, fontSize: 14),
          ),
          textDirection: TextDirection.ltr,
        )
          ..layout()
          ..paint(
            canvas,
            Offset(
              textR * math.cos(textAngle) - 10,
              textR * math.sin(textAngle) - 8,
            ),
          );
        canvas.restore();
      }
    }
  }

  @override
  bool shouldRepaint(covariant DashboardPainter oldDelegate) => false;
}

效果说明

  1. 滑块拖动时,仪表盘指针实时旋转,数值同步更新
  2. 自定义绘制的刻度清晰,刻度值自动计算
  3. 通过 RepaintBoundary 隔离,仅仪表盘区域重绘,性能最优
  4. 结合了裁剪、变形、透明度、混合模式等所有视觉特效
  5. 优化了 setState 重建范围,仅必要部分刷新

开发建议

  1. 复杂界面先分析渲染流程,再动手编码
  2. 自定义绘制优先复用 Paint/Path 对象,减少内存开销
  3. 所有动画/频繁刷新组件都应考虑 RepaintBoundary 隔离
  4. 利用 Flutter DevTools 分析重绘和重建,定位性能瓶颈

第十六讲 状态管理基础

作者 节点玩家
2026年3月25日 21:10

前言:

状态管理是一个重点,可以看看。之前也有很多案例用过了已经。

一、定位

本讲聚焦 Flutter 中最基础也最核心的状态管理能力

  1. setState 核心:用于单个 StatefulWidget 的局部状态更新,仅触发当前 Widget 的 build 方法,适合管理组件内部状态(如按钮点击、表单输入)。
  2. InheritedWidget 核心:Flutter 原生的跨组件状态共享方案,通过「状态缓存+依赖收集」实现精准重建,适合共享跨层级 Widget 需要访问的状态(如用户信息、主题配置)。
  3. 组合使用原则:用 setState 管理局部临时状态,用 InheritedWidget 封装共享状态,通过「不可变状态+copyWith」实现状态安全更新,兼顾性能与可维护性。

1.1 setState 局部刷新原理

image.png

1.2 InheritedWidget 状态共享原理

image.png

1.3 整体状态管理基础架构

image.png

二、核心知识点详解

2.1 setState 局部刷新

2.1.1 核心概念

setState 是 StatefulWidget 中最基础的状态更新方法,本质是通过标记当前 Element 为脏状态,触发该 Widget 的 build 方法重新执行,实现局部 UI 刷新。

2.1.2 核心属性/方法说明
名称 类型 作用
setState(VoidCallback fn) 方法 接收一个无返回值的回调函数,在回调中更新状态,触发 build 重建
mounted 布尔属性 标记当前 State 是否挂载到 Widget 树中,更新状态前建议检查(避免空指针)
2.1.3 基础案例
import 'package:flutter/material.dart';

// setState 基础使用案例
class SetStateDemo extends StatefulWidget {
  const SetStateDemo({super.key});

  @override
  State<SetStateDemo> createState() => _SetStateDemoState();
}

class _SetStateDemoState extends State<SetStateDemo> {
  // 局部状态:计数器
  int _counter = 0;

  // 状态更新方法
  void _incrementCounter() {
    // 核心:调用setState更新状态
    setState(() {
      _counter++; // 仅更新此状态,触发build重建
    });
  }

  @override
  Widget build(BuildContext context) {
    print("SetStateDemo 执行build"); // 验证仅局部刷新
    return Scaffold(
      appBar: AppBar(title: const Text("setState 局部刷新")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('你点击的次数:'),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

2.1.4 注意事项
  1. 仅局部刷新setState 只会触发当前 State 对应的 Widget 的 build 方法,不会刷新整个页面(性能优化点)

  2. 避免同步耗时操作:不要在 setState 回调中执行网络请求、大量计算等耗时操作(会阻塞 UI 线程)

  3. 状态更新必须在回调内:直接修改 _counter 而不调用 setState,或在回调外修改状态,都不会触发 UI 刷新

  4. 避免不必要的重建:如果状态变化不需要刷新 UI,不要调用 setState

  5. 检查 mounted:异步操作后更新状态时,务必检查 if (mounted),避免 Widget 已销毁仍更新状态:

    1.  // 延后两秒挂载
       void _asyncUpdate() async {
         await Future.delayed(const Duration(seconds: 2));
         if (mounted) { // 关键:检查是否仍挂载
           setState(() {
             _counter++;
           });
         }
       }
      

2.2 InheritedWidget 跨组件状态共享

2.2.1 核心概念

InheritedWidget 是 Flutter 提供的跨组件状态共享核心组件,允许上层 Widget 将状态数据向下传递,下层任意 Widget 可通过 BuildContext 高效获取,且当状态变化时,仅通知依赖该状态的子 Widget 重建。

2.2.2 核心属性/方法说明
名称 类型 作用
updateShouldNotify 方法(返回bool) 对比新旧 Widget 的状态,决定是否通知依赖组件重建
dependOnInheritedWidgetOfExactType<T>() 方法 通过 BuildContext 获取指定类型的 InheritedWidget,并建立依赖关系
getElementForInheritedWidgetOfExactType<T>() 方法 获取 InheritedWidget 对应的 Element,不建立依赖关系(状态变化不触发重建)
2.2.3 基础案例
import 'package:flutter/material.dart';

// 步骤1:定义共享状态模型
class UserInfo {
  final String name;
  final int age;

  UserInfo({required this.name, required this.age});

  // 不可变设计:状态更新时创建新对象
  UserInfo copyWith({String? name, int? age}) {
    return UserInfo(
      name: name ?? this.name,
      age: age ?? this.age,
    );
  }
}

// 步骤2:自定义InheritedWidget
class UserInheritedWidget extends InheritedWidget {
  // 共享状态
  final UserInfo userInfo;
  // 状态更新方法(供外部调用)
  final Function(UserInfo) updateUserInfo;

  const UserInheritedWidget({
    super.key,
    required this.userInfo,
    required this.updateUserInfo,
    required super.child,
  });

  // 步骤3:提供便捷的获取方法(简化子组件调用)
  static UserInheritedWidget of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<UserInheritedWidget>();
    assert(result != null, 'No UserInheritedWidget found in context');
    return result!;
  }

  // 步骤4:判断是否需要通知依赖组件重建
  @override
  bool updateShouldNotify(UserInheritedWidget oldWidget) {
    // 状态变化时返回true,触发依赖组件重建
    return userInfo.name != oldWidget.userInfo.name || userInfo.age != oldWidget.userInfo.age;
  }
}

// 步骤5:使用共享状态的子组件1
class UserNameWidget extends StatelessWidget {
  const UserNameWidget({super.key});

  @override
  Widget build(BuildContext context) {
    print("UserNameWidget 执行build");
    // 获取共享状态
    final userInfo = UserInheritedWidget.of(context).userInfo;
    return Text(
      '用户名:${userInfo.name}',
      style: const TextStyle(fontSize: 20),
    );
  }
}

// 步骤6:使用共享状态的子组件2
class UserAgeWidget extends StatelessWidget {
  const UserAgeWidget({super.key});

  @override
  Widget build(BuildContext context) {
    print("UserAgeWidget 执行build");
    // 获取共享状态
    final userInfo = UserInheritedWidget.of(context).userInfo;
    return Text(
      '年龄:${userInfo.age}',
      style: const TextStyle(fontSize: 20),
    );
  }
}

// 步骤7:顶层容器组件(管理状态+包裹InheritedWidget)
class InheritedWidgetDemo extends StatefulWidget {
  const InheritedWidgetDemo({super.key});

  @override
  State<InheritedWidgetDemo> createState() => _InheritedWidgetDemoState();
}

class _InheritedWidgetDemoState extends State<InheritedWidgetDemo> {
  late UserInfo _userInfo;

  @override
  void initState() {
    super.initState();
    _userInfo = UserInfo(name: "张三", age: 20);
  }

  // 更新共享状态
  void _updateUser() {
    setState(() {
      _userInfo = _userInfo.copyWith(
        name: "李四",
        age: _userInfo.age + 1,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    print("InheritedWidgetDemo 执行build");
    return Scaffold(
      appBar: AppBar(title: const Text("InheritedWidget 状态共享")),
      body: UserInheritedWidget( // 包裹需要共享状态的Widget树
        userInfo: _userInfo,
        updateUserInfo: (newUserInfo) {
          setState(() {
            _userInfo = newUserInfo;
          });
        },
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: const [
              UserNameWidget(), // 子组件1:获取用户名
              SizedBox(height: 20),
              UserAgeWidget(), // 子组件2:获取年龄
            ],
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _updateUser,
        child: const Icon(Icons.refresh),
      ),
    );
  }
}

2.2.4 注意事项
  1. 不可变状态设计:InheritedWidget 本身是不可变的,状态更新需创建新的 InheritedWidget 实例(通过 setState 实现)
  2. 精准重建:只有调用 dependOnInheritedWidgetOfExactType 的子组件会被通知重建,其他组件不受影响
  3. 避免过度共享:不要把所有状态都放在一个 InheritedWidget 中,按功能拆分(避免不必要的重建)
  4. 上下文范围:子组件必须在 InheritedWidget 的子树中,才能通过 context 获取到共享状态
  5. 依赖关系:如果不需要状态变化时重建,可使用 getElementForInheritedWidgetOfExactType 替代(无依赖)

三、综合应用案例

3.1 需求说明

实现一个「用户信息管理」页面:

  • 顶层通过 InheritedWidget 共享用户信息(姓名、年龄、积分)
  • 局部通过 setState 更新积分(局部刷新)
  • 跨组件更新用户姓名和年龄(共享状态刷新)
  • 不同层级的子组件都能访问并响应状态变化

3.2 完整代码

import 'package:flutter/material.dart';

// 1. 定义共享状态模型
class UserState {
  final String name;
  final int age;
  final int score;

  UserState({
    required this.name,
    required this.age,
    required this.score,
  });

  // 不可变更新:创建新对象
  UserState copyWith({
    String? name,
    int? age,
    int? score,
  }) {
    return UserState(
      name: name ?? this.name,
      age: age ?? this.age,
      score: score ?? this.score,
    );
  }
}

// 2. 自定义InheritedWidget
class UserStateProvider extends InheritedWidget {
  final UserState userState;
  final Function(UserState) updateUserState;

  const UserStateProvider({
    super.key,
    required this.userState,
    required this.updateUserState,
    required super.child,
  });

  // 便捷获取方法
  static UserStateProvider of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<UserStateProvider>();
    assert(result != null, 'No UserStateProvider found in context');
    return result!;
  }

  // 判断是否需要通知依赖组件
  @override
  bool updateShouldNotify(UserStateProvider oldWidget) {
    return userState.name != oldWidget.userState.name ||
        userState.age != oldWidget.userState.age ||
        userState.score != oldWidget.userState.score;
  }
}

// 3. 局部状态组件:积分更新(仅刷新自身)
class ScoreCounter extends StatefulWidget {
  const ScoreCounter({super.key});

  @override
  State<ScoreCounter> createState() => _ScoreCounterState();
}

class _ScoreCounterState extends State<ScoreCounter> {
  void _addScore() {
    // 获取共享状态
    final provider = UserStateProvider.of(context);
    // 局部更新:仅更新积分(setState + 共享状态更新)
    setState(() {
      provider.updateUserState(
        provider.userState.copyWith(
          score: provider.userState.score + 10,
        ),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    print("ScoreCounter 执行build");
    final score = UserStateProvider.of(context).userState.score;
    return Column(
      children: [
        const Text(
          '当前积分',
          style: TextStyle(fontSize: 16, color: Colors.grey),
        ),
        Text(
          '$score',
          style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
        ),
        ElevatedButton(
          onPressed: _addScore,
          child: const Text('增加10积分'),
        ),
      ],
    );
  }
}

// 4. 跨层级子组件:用户信息展示
class UserInfoDisplay extends StatelessWidget {
  const UserInfoDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    print("UserInfoDisplay 执行build");
    final userState = UserStateProvider.of(context).userState;
    return Card(
      margin: const EdgeInsets.all(20),
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('姓名:${userState.name}', style: const TextStyle(fontSize: 18)),
            Text('年龄:${userState.age}', style: const TextStyle(fontSize: 18)),
            Text('积分:${userState.score}', style: const TextStyle(fontSize: 18)),
          ],
        ),
      ),
    );
  }
}

// 5. 状态更新组件:修改姓名和年龄
class UserEditWidget extends StatelessWidget {
  const UserEditWidget({super.key});

  @override
  Widget build(BuildContext context) {
    print("UserEditWidget 执行build");
    final provider = UserStateProvider.of(context);
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: [
        ElevatedButton(
          onPressed: () {
            provider.updateUserState(
              provider.userState.copyWith(name: "新姓名-${DateTime.now().second}"),
            );
          },
          child: const Text('修改姓名'),
        ),
        ElevatedButton(
          onPressed: () {
            provider.updateUserState(
              provider.userState.copyWith(age: provider.userState.age + 1),
            );
          },
          child: const Text('年龄+1'),
        ),
      ],
    );
  }
}

// 6. 顶层容器:整合所有组件
class StateManagementCombinedDemo extends StatefulWidget {
  const StateManagementCombinedDemo({super.key});

  @override
  State<StateManagementCombinedDemo> createState() => _StateManagementCombinedDemoState();
}

class _StateManagementCombinedDemoState extends State<StateManagementCombinedDemo> {
  late UserState _userState;

  @override
  void initState() {
    super.initState();
    // 初始化状态
    _userState = UserState(name: "初始姓名", age: 20, score: 0);
  }

  // 更新共享状态的核心方法
  void _updateUserState(UserState newState) {
    setState(() {
      _userState = newState;
    });
  }

  @override
  Widget build(BuildContext context) {
    print("StateManagementCombinedDemo 执行build");
    return Scaffold(
      appBar: AppBar(
        title: const Text("状态管理综合案例"),
        centerTitle: true,
      ),
      body: UserStateProvider( // 包裹整个子树,提供共享状态
        userState: _userState,
        updateUserState: _updateUserState,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const [
            UserInfoDisplay(), // 展示状态
            UserEditWidget(),  // 修改姓名/年龄
            SizedBox(height: 30),
            ScoreCounter(),    // 局部更新积分
          ],
        ),
      ),
    );
  }
}

// 7. 入口函数
void main() {
  runApp(const MaterialApp(
    home: StateManagementCombinedDemo(),
    debugShowCheckedModeBanner: false,
  ));
}

3.3 运行效果说明

  1. 初始页面显示:姓名=初始姓名、年龄=20、积分=0
  2. 点击「增加10积分」:仅 ScoreCounter 组件重建(局部刷新),积分数值增加
  3. 点击「修改姓名」:UserInfoDisplayUserEditWidget 重建(依赖共享状态),姓名更新为带秒数的新值
  4. 点击「年龄+1」:依赖组件重建,年龄数值+1
  5. 控制台打印的 build 日志可验证:只有状态变化相关的组件才会重建,实现高效刷新

【前端搞全栈】我的环境变量管理最佳实践

作者 MorphixAI
2026年3月25日 20:52

.env 文件满天飞,到一个命令统一管理 —— 我在 MorphixAI 开发中踩过的坑和最终方案


背景

先交代一下上下文。

我在做一个叫 MorphixAI 的项目 —— 一个 AI 驱动的个人工作台。它的核心思路是把你散落在 GitHub、Jira、Notion、邮件、日历等平台的工作数据聚合起来,让 AI 帮你理解上下文、管理任务、执行操作。

这个项目的技术栈比较多样:

子项目 技术栈 用途
morphicai-api Express + TypeScript 后端 API
morphicai-web Next.js 15 + React 19 Web 前端
morphicai-app-shell Vite + Ionic + Capacitor 跨平台 App Shell
morphicai-native React Native + Expo iOS/Android 客户端
openclaw-morphixai Node.js MCP Server(开源)
morphixai-code Node.js CLI 工具(开源)

6 个子项目,3 种前端框架,部署在 Zeabur 上。项目推进节奏比较快,如果在环境变量这种「基础设施」问题上反复踩坑,那真的太浪费时间了。

这篇文章想分享的就是:在这种多项目架构下,我是怎么一步步理顺环境变量管理的,以及最终沉淀出的方案和工具。

morphix-blog-01-architecture.png


阶段一:.env 文件管理

最早的做法和大多数项目一样 —— 每个项目根目录放一个 .env 文件,里面写满各种密钥和配置。

SUPABASE_URL=https://xxx.supabase.co
SUPABASE_KEY=eyJhbGciOi...
OPENAI_API_KEY=sk-...

.env 加到 .gitignore 里,然后写一个 .env.example 提交到 git。

morphix-blog-02-env-scattered.png 项目早期是够用的。但随着子项目越来越多,问题集中暴露了:

多项目重复配置。比如 SUPABASE_URLSUPABASE_KEY,6 个项目都要用,值完全一样。但每个项目各自维护一份 .env,改一个值得跑到 6 个目录里挨个改,漏一个就是线上问题。

密钥安全无法保障。项目里有 OpenAI API Key、Supabase Service Key 这些直接关联费用的密钥。OpenAI 的 key 是实打实按 token 计费的,泄露了就是真金白银的损失。.env 文件虽然加了 .gitignore,但它就是一个明文文件,躺在本地磁盘上。如果项目需要和其他开发者协作,你没办法做到精细的权限控制。


阶段二:引入 Infisical

意识到 .env 文件管理不住之后,我们引入了 Infisical。

Infisical 是什么

Infisical 是一个开源的密钥管理平台,你可以理解为专门给开发者设计的密钥保险箱 —— 一个地方存所有密钥,所有环境、所有项目从这里统一拉取。

核心能力:

能力 说明
多环境管理 dev / staging / prod 各一套,互不干扰
项目 + Folder 隔离 一个项目下可以按 /ai/frontend 等路径分组
两种认证方式 本地用 CLI 登录(infisical login),CI/Docker 用 Machine Identity
SDK 集成 Node.js / Python / Go SDK,代码里直接拉取
CLI 工具 infisical run -- npm start 一行搞定注入
权限控制 按人、按角色、按环境控制访问

市面上做密钥管理的工具不少,比如 HashiCorp Vault。但 Vault 是企业级方案,部署和维护成本都高。Infisical 卡在一个很好的位置 —— 比 .env 文件规范,比 Vault 轻量,有开源版可以自部署,也有云服务直接用。

我们怎么用的

本地开发通过 Infisical CLI 拉取密钥:

infisical login          # 一次性登录
infisical run --env=dev --path=/ai -- next dev   # 拉取密钥并启动

生产部署走 GitHub Actions。在 CI 构建阶段,先通过 Infisical CLI 动态拉取密钥,生成 .env 文件,再执行 Docker 构建:

# GitHub Actions 构建流程
steps:
  - name:  Infisical 拉取密钥
    run: infisical export --env=prod --path=/ai --format=dotenv > .env
  - name: 构建 Docker 镜像
    run: docker build .

这解决了两个核心问题:密钥有了统一的来源(single source of truth),以及密钥的访问可以通过权限控制来管理。

关于协作安全,这里说一下实际情况。Infisical 的权限控制是管理层面的 —— 你可以控制谁能在 Infisical 管理界面上看到哪些密钥。但只要项目跑起来了,环境变量已经注入到 process.env 里,技术上是可以读取的。所以 Infisical 的价值不是「绝对防泄露」,而是降低密钥暴露面 —— 密钥不再以明文文件的形式存在,不需要在聊天工具里传来传去,访问权限可以集中管控和审计,需要的时候随时收回。

还有什么不够顺畅

  • .env 文件构建到 Docker 镜像中有安全隐患。CI 里先 infisical export 生成 .env,再 docker build,密钥就被烘焙进了镜像。任何能拉到这个镜像的人都能看到里面的密钥
  • Docker 镜像里装 Infisical CLI 麻烦。Alpine 镜像装 CLI 有二进制依赖问题,镜像体积也会增大
  • 本地覆盖不方便infisical run 注入远程密钥后,想把某个 URL 临时指向 localhost 调试,没有优雅的覆盖方式

阶段三:迁移到 Zeabur,催生 morphix-env

转折点是把部署从 GitHub Actions 迁移到了 Zeabur。

Zeabur 是一个国内团队做的 PaaS 部署平台,类似 Vercel / Railway。它提供了一个很方便的功能 —— 自动识别项目中的 Dockerfile,从 GitHub 仓库拉代码直接构建和部署。不需要自己写 CI 流程,推代码就自动部署。

但这也意味着,我们没有办法在 Docker 构建之前插入额外的步骤了。之前在 GitHub Actions 里「先 infisical export 拉密钥生成 .env,再 docker build」的方式,在 Zeabur 上行不通 —— 它直接构建你的 Dockerfile,没有地方执行预处理脚本。

而且回过头想,之前的方式其实也有问题:先拉取密钥生成 .env 文件,再构建到 Docker 镜像里,这本身就不安全。密钥被烘焙进了镜像,任何能拉到镜像的人都能看到。

这里需要区分两类环境变量:

  • 前端公开变量(如 SUPABASE_URLSUPABASE_ANON_KEY)—— 这些本来就会出现在浏览器端的 JS bundle 里,编译进产物没有安全问题
  • 服务端密钥(如 OPENAI_API_KEYSUPABASE_SERVICE_KEY)—— 这些绝对不能固化到镜像里,只应该在运行时使用

所以我们真正需要的是:

  1. 不依赖特定的 CI/CD 平台 —— 不管是 GitHub Actions 还是 Zeabur,都能用
  2. 密钥按需动态拉取 —— 构建时需要就在构建时拉,运行时需要就在运行时拉,但不提前生成 .env 文件、不固化到镜像里
  3. 不需要在 Docker 镜像里装 Infisical CLI —— 用轻量的 Node.js SDK 就行
  4. 本地开发能方便地覆盖 —— .env.local 优先

于是就有了 morphix-env。


morphix-env:最终方案

核心设计

morphix-env run -- next dev

这一行命令背后做了五件事:

1. 读取配置文件 mx-env.config.json
2. 从 Infisical 按需拉取密钥(自动选择 SDK 或 CLI)
3. 如果配置了 envPrefix,自动给变量加前缀
4. 加载 .env.local 覆盖(本地开发自定义)
5. 启动子命令,继承完整的 process.env

不管是 npm run dev(本地开发)、npm run build(Docker 构建阶段)、还是 npm start(生产运行),都走同一个命令。密钥在命令执行的那一刻从 Infisical 拉取,不需要提前准备任何文件。

morphix-blog-03-morphix-env-flow.png

设计决策一:变量优先级

┌──────────────────────────────────────────┐
│  .env.local                   ← 最高优先  │
│  开发者的本地覆盖,永远优先               │
├──────────────────────────────────────────┤
│  Infisical secrets             ← 中优先   │
│  远程拉取,不覆盖已有值                   │
├──────────────────────────────────────────┤
│  process.env                   ← 最低优先  │
│  Docker ENV、CI 变量、shell exports       │
└──────────────────────────────────────────┘

这意味着:

  • 远程密钥管理是底座,保证所有项目用同一套配置
  • 本地想改个 API 地址调试?改 .env.local 就行,不影响远程配置
  • Docker/CI 中已有的 process.env 作为最后兜底

设计决策二:自动识别认证方式

有 INFISICAL_CLIENT_ID 环境变量?
  → 用 SDK(Machine Identity)—— Docker / 部署平台场景
没有?
  → 本地装了 infisical CLI?
    → 用 CLI(用户登录态)—— 本地开发
  → 也没有?
    → 跳过 Infisical,只用本地文件

开发者不需要关心当前是用 SDK 还是 CLI —— 工具自动判断。本地开发跑一次 infisical login,之后 pnpm dev 就自动拉取。Docker 里设几个环境变量就行,不需要装 CLI 二进制。

设计决策三:envPrefix

Infisical 里存的是通用的变量名(如 SUPABASE_URL),但不同前端框架要求不同前缀。在配置中声明 envPrefix,拉取时自动转换:

{
  "infisical": {
    "paths": ["/frontend"],
    "envPrefix": "VITE_"
  }
}

Infisical 里只维护一份变量,不同项目按需配置前缀:

项目 envPrefix SUPABASE_URL 变为
morphicai-api 不配置 SUPABASE_URL(原样)
morphicai-web NEXT_PUBLIC_ NEXT_PUBLIC_SUPABASE_URL
morphicai-app-shell VITE_ VITE_SUPABASE_URL

实际使用

配置文件

// mx-env.config.json(提交到 git,不含密钥)
{
  "infisical": {
    "paths": ["/frontend"],
    "envPrefix": "VITE_"
  },
  "envFiles": [".env.local"]
}

package.json

{
  "scripts": {
    "dev": "morphix-env run --env dev -- vite",
    "build": "morphix-env run -- vite build",
    "start": "morphix-env run -- node server/index.js"
  }
}

Dockerfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install

COPY . .
RUN npm run build

# 运行阶段
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
RUN npm ci --omit=dev
CMD ["npm", "start"]

有一点需要说明:morphix-env 要连接 Infisical 拉取密钥,本身还是需要几个认证信息。这几个变量需要在部署平台(如 Zeabur)上配置为环境变量:

变量 说明
INFISICAL_CLIENT_ID Machine Identity 的 ID
INFISICAL_CLIENT_SECRET Machine Identity 的密钥
INFISICAL_PROJECT_ID Infisical 项目 ID
DEPLOY_ENV 环境标识(dev / prod)

这些是「拉取密钥的钥匙」,数量很少且固定,只需要在平台上配一次,永久生效。剩下的几十上百个业务密钥全部从 Infisical 动态拉取,不需要在部署平台上逐个配置。后续新增环境变量,只需要去 Infisical 管理平台上加一条,所有项目下次启动时自动生效,不需要改任何代码或部署配置。

注意 morphix-env 必须在 dependencies(不是 devDependencies),因为 start 脚本在运行阶段也需要它。


适用场景

什么时候你该考虑类似的方案?

  • 项目有 2 个以上环境(dev/staging/prod)
  • 项目中有关联费用的密钥(OpenAI Key、云服务 Key 等),需要管控访问
  • 在用 Docker 部署PaaS 平台(环境变量传递链路变长)
  • 多个子项目共享同一批密钥
  • 前后端项目需要不同的变量前缀

如果以上中了 3 个,值得花半天时间理一理。


总结

环境变量管理不是什么高深的技术问题,但它确实是一个「不解决就一直烦你」的工程问题。

我的经验是:

  1. 密钥必须有一个 single source of truth —— 我们选了 Infisical,你也可以选其他方案,关键是「一处修改,处处生效」
  2. 本地开发必须能覆盖 —— 远程配置是底座,但开发者需要灵活性
  3. 密钥按需拉取,而不是提前生成文件 —— 减少中间环节,降低泄露面
  4. 工具能跑在所有环境 —— 本地、CI、Docker、PaaS,一套配置搞定

morphix-env 就是按这些原则写的,目前在 MorphixAI 的 6 个子项目中都在用。核心代码 300 行左右,但确实帮我省了不少时间。

开源在 npm 上:

npm install morphix-env

GitHub: github.com/Morphicai/m…

如果你也在多项目架构下被环境变量折磨过,欢迎试试。有问题可以直接提 issue。


如果觉得有帮助,点个赞或者收藏一下。后续我会继续分享 MorphixAI 开发过程中的工程实践,包括多端 SDK 通信、AI Agent 架构设计等内容。

❌
❌