阅读视图

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

大人工智能时代下前端界面全新开发模式的思考(三)

第三章:范式的跃迁——从组件驱动到意图驱动

工具的变革只是表象,更深层的变革发生在开发范式层面。前端开发正在经历从"组件驱动"到"意图驱动"的范式跃迁,这不仅是技术的变化,更是思维方式、能力模型和职业价值的根本性重构。

这一章我们将深入探讨这场范式转变的内涵、影响和实践路径。


3.1 代码范式的对比:两种世界观的碰撞

让我们通过具体的代码示例,来感受组件驱动和意图驱动这两种范式的根本差异。

3.1.1 场景:实现一个用户管理功能

需求描述

  • 展示用户列表
  • 支持搜索(按姓名或邮箱)
  • 支持按角色筛选
  • 支持分页
  • 支持行内编辑
  • 响应式布局
  • 加载状态和空状态处理

组件驱动模式(传统方式)

// UserManagement.tsx - 约150行代码
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { 
  Card, 
  CardHeader, 
  CardTitle, 
  CardContent,
  CardFooter 
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/use-toast';
import { Search, Edit2, Save, X } from 'lucide-react';
import { debounce } from 'lodash';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  status: 'active' | 'inactive';
  createdAt: string;
}

interface Filters {
  search: string;
  role: string;
  page: number;
  pageSize: number;
}

export function UserManagement() {
  // 状态管理
  const [filters, setFilters] = useState<Filters>({
    search: '',
    role: 'all',
    page: 1,
    pageSize: 10
  });
  
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editForm, setEditForm] = useState<Partial<User>>({});
  
  const queryClient = useQueryClient();
  
  // 数据获取
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', filters],
    queryFn: async () => {
      const params = new URLSearchParams();
      if (filters.search) params.append('search', filters.search);
      if (filters.role !== 'all') params.append('role', filters.role);
      params.append('page', String(filters.page));
      params.append('pageSize', String(filters.pageSize));
      
      const response = await fetch(`/api/users?${params}`);
      if (!response.ok) throw new Error('Failed to fetch users');
      return response.json();
    }
  });
  
  // 更新用户mutation
  const updateUser = useMutation({
    mutationFn: async (user: Partial<User> & { id: string }) => {
      const response = await fetch(`/api/users/${user.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(user)
      });
      if (!response.ok) throw new Error('Failed to update user');
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
      toast({ title: 'User updated successfully' });
      setEditingId(null);
    },
    onError: (error) => {
      toast({ 
        title: 'Failed to update user', 
        variant: 'destructive',
        description: error.message
      });
    }
  });
  
  // 防抖搜索
  const debouncedSearch = useMemo(
    () => debounce((value: string) => {
      setFilters(prev => ({ ...prev, search: value, page: 1 }));
    }, 300),
    []
  );
  
  // 事件处理
  const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    debouncedSearch(e.target.value);
  };
  
  const handleRoleChange = (value: string) => {
    setFilters(prev => ({ ...prev, role: value, page: 1 }));
  };
  
  const handlePageChange = (page: number) => {
    setFilters(prev => ({ ...prev, page }));
  };
  
  const handleEdit = (user: User) => {
    setEditingId(user.id);
    setEditForm(user);
  };
  
  const handleSave = () => {
    if (editingId && editForm) {
      updateUser.mutate({ id: editingId, ...editForm });
    }
  };
  
  const handleCancel = () => {
    setEditingId(null);
    setEditForm({});
  };
  
  // 计算分页
  const totalPages = Math.ceil((data?.total || 0) / filters.pageSize);
  
  if (error) {
    return (
      <Card className="w-full">
        <CardContent className="pt-6">
          <div className="text-center text-red-600">
            <p className="text-lg font-semibold">Error loading users</p>
            <p className="text-sm">{error.message}</p>
            <Button 
              onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}
              className="mt-4"
            >
              Retry
            </Button>
          </div>
        </CardContent>
      </Card>
    );
  }
  
  return (
    <Card className="w-full">
      <CardHeader>
        <CardTitle className="text-2xl font-bold">User Management</CardTitle>
      </CardHeader>
      
      <CardContent className="space-y-6">
        {/* 过滤器 */}
        <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
          <div className="relative w-full sm:w-64">
            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
            <Input
              placeholder="Search users..."
              onChange={handleSearchChange}
              className="pl-10"
            />
          </div>
          
          <Select value={filters.role} onValueChange={handleRoleChange}>
            <SelectTrigger className="w-full sm:w-40">
              <SelectValue placeholder="Filter by role" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All Roles</SelectItem>
              <SelectItem value="admin">Admin</SelectItem>
              <SelectItem value="editor">Editor</SelectItem>
              <SelectItem value="viewer">Viewer</SelectItem>
            </SelectContent>
          </Select>
        </div>
        
        {/* 表格 */}
        <div className="border rounded-lg overflow-hidden">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Name</TableHead>
                <TableHead>Email</TableHead>
                <TableHead>Role</TableHead>
                <TableHead>Status</TableHead>
                <TableHead className="text-right">Actions</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {isLoading ? (
                // 加载状态
                Array.from({ length: 5 }).map((_, i) => (
                  <TableRow key={i}>
                    <TableCell><Skeleton className="h-4 w-32" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-48" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-20" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-16" /></TableCell>
                    <TableCell><Skeleton className="h-8 w-20 ml-auto" /></TableCell>
                  </TableRow>
                ))
              ) : data?.users.length === 0 ? (
                // 空状态
                <TableRow>
                  <TableCell colSpan={5} className="text-center py-8 text-gray-500">
                    No users found
                  </TableCell>
                </TableRow>
              ) : (
                // 数据展示
                data?.users.map((user: User) => (
                  <TableRow key={user.id}>
                    <TableCell>
                      {editingId === user.id ? (
                        <Input
                          value={editForm.name || ''}
                          onChange={(e) => setEditForm(prev => ({ ...prev, name: e.target.value }))}
                          className="w-40"
                        />
                      ) : (
                        <span className="font-medium">{user.name}</span>
                      )}
                    </TableCell>
                    <TableCell>
                      {editingId === user.id ? (
                        <Input
                          value={editForm.email || ''}
                          onChange={(e) => setEditForm(prev => ({ ...prev, email: e.target.value }))}
                          className="w-56"
                        />
                      ) : (
                        user.email
                      )}
                    </TableCell>
                    <TableCell>
                      <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                        user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
                        user.role === 'editor' ? 'bg-blue-100 text-blue-800' :
                        'bg-gray-100 text-gray-800'
                      }`}>
                        {user.role}
                      </span>
                    </TableCell>
                    <TableCell>
                      <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                        user.status === 'active' 
                          ? 'bg-green-100 text-green-800' 
                          : 'bg-red-100 text-red-800'
                      }`}>
                        {user.status}
                      </span>
                    </TableCell>
                    <TableCell className="text-right">
                      {editingId === user.id ? (
                        <div className="flex justify-end gap-2">
                          <Button 
                            size="sm" 
                            onClick={handleSave}
                            disabled={updateUser.isPending}
                          >
                            <Save className="w-4 h-4 mr-1" />
                            Save
                          </Button>
                          <Button 
                            size="sm" 
                            variant="outline"
                            onClick={handleCancel}
                          >
                            <X className="w-4 h-4 mr-1" />
                            Cancel
                          </Button>
                        </div>
                      ) : (
                        <Button 
                          size="sm" 
                          variant="ghost"
                          onClick={() => handleEdit(user)}
                        >
                          <Edit2 className="w-4 h-4 mr-1" />
                          Edit
                        </Button>
                      )}
                    </TableCell>
                  </TableRow>
                ))
              )}
            </TableBody>
          </Table>
        </div>
        
        {/* 分页 */}
        {totalPages > 1 && (
          <Pagination>
            <PaginationContent>
              <PaginationItem>
                <PaginationPrevious 
                  onClick={() => handlePageChange(Math.max(1, filters.page - 1))}
                  className={filters.page === 1 ? 'pointer-events-none opacity-50' : ''}
                />
              </PaginationItem>
              
              {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
                <PaginationItem key={page}>
                  <PaginationLink
                    onClick={() => handlePageChange(page)}
                    isActive={page === filters.page}
                  >
                    {page}
                  </PaginationLink>
                </PaginationItem>
              ))}
              
              <PaginationItem>
                <PaginationNext 
                  onClick={() => handlePageChange(Math.min(totalPages, filters.page + 1))}
                  className={filters.page === totalPages ? 'pointer-events-none opacity-50' : ''}
                />
              </PaginationItem>
            </PaginationContent>
          </Pagination>
        )}
      </CardContent>
    </Card>
  );
}

传统方式的特点

  • 代码量大:约150行,还不算类型定义和样式
  • 关注点分散:需要同时处理UI、状态、数据获取、错误处理、加载状态
  • 依赖众多:需要熟悉React Query、UI组件库、Lodash等多个库
  • 调试复杂:状态流转复杂,bug定位困难
  • 但是:完全可控,每一行代码都理解其作用

意图驱动模式(AI生成)

提示词:
"创建一个用户管理表格组件,要求:
1. 从 /api/users 获取数据,使用React Query
2. 支持按姓名或邮箱搜索(防抖300ms)
3. 支持按角色筛选(admin/editor/viewer)
4. 分页功能,每页10条
5. 行内编辑功能,可修改姓名和邮箱
6. 加载状态显示骨架屏
7. 空状态提示
8. 错误处理,显示重试按钮
9. 使用Tailwind CSS和shadcn/ui组件
10. 响应式布局,移动端友好
11. 添加适当的类型定义"

→ AI生成完整实现(约150行,与手写相当)

意图驱动方式的特点

  • 代码量相当:AI生成的代码也是约150行
  • 关注点集中:开发者只需要关注"要什么",不需要关注"怎么实现"
  • 实现细节黑盒:搜索防抖、分页逻辑、状态管理都由AI处理
  • 快速迭代:需要修改时,修改提示词重新生成,而非修改代码
  • 但是:不完全理解实现细节,调试困难,可维护性存疑

3.1.2 关键差异分析

维度 组件驱动 意图驱动
关注点 如何组装组件、管理状态、处理副作用 需要实现什么功能、满足什么需求
代码所有权 精心编写、深度理解、长期维护 一次性使用、黑盒理解、按需重新生成
调试方式 阅读代码、理解逻辑、定位问题 与AI对话、重新生成、试错迭代
学习曲线 陡峭(需要掌握语法、框架、模式) 平缓(需要学会与AI沟通)
代码质量 依赖开发者水平,质量可控 依赖AI能力和Prompt质量,波动较大
维护成本 高(需要持续维护代码) 低(可以重新生成),但长期可能更高
创新性 高(完全自定义,可实现任何想法) 中(受限于AI的理解和能力)

3.1.3 范式转变的本质

这两种模式的差异,本质上是"控制"与"委托"的权衡:

  • 组件驱动:开发者完全控制实现细节,但需要投入大量时间和精力
  • 意图驱动:开发者委托AI处理实现细节,但需要接受一定的不可控性

这不是非此即彼的选择,而是一个连续谱。实际开发中,我们往往在两者之间找到平衡点:

高控制 ←─────────────────────────────→ 高委托
        组件驱动    混合模式    意图驱动
        (手动编写)  (AI辅助)   (AI主导)
        
适用场景:
- 核心功能 → 手动编写
- 工具函数 → AI生成+审查
- 样板代码 → AI生成
- 原型验证 → AI主导

3.2 架构层面的三大转变

从组件驱动到意图驱动的转变,不仅仅是编码方式的变化,更是架构层面的根本性重构。

3.2.1 从"声明式UI"到"生成式UI"

声明式UI(传统)

开发者声明UI应该是什么样,框架负责将其渲染到DOM。

// 声明式:我声明这个div应该是什么样
function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div className="p-4 bg-blue-500 text-white rounded hover:bg-blue-600">
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment
      </button>
    </div>
  );
}

开发者明确声明:

  • 这是一个div
  • padding是1rem(p-4)
  • 背景是蓝色(bg-blue-500)
  • 文字是白色(text-white)
  • 圆角(rounded)
  • 悬停时背景变深(hover:bg-blue-600)

生成式UI(AI驱动)

开发者描述意图,AI生成UI。

提示词:"创建一个计数器组件,蓝色主题,有悬停效果"

→ AI生成代码(可能不完全符合预期,需要迭代)

关键区别

维度 声明式UI 生成式UI
确定性 高(代码即UI) 低(AI可能生成不同结果)
可预测性 高(相同输入,相同输出) 中(相同提示词,可能不同结果)
控制精度 像素级控制 意图级控制
开发速度 慢(需要手动编写每一行) 快(AI批量生成)
调试难度 中(理解代码即可) 高(需要理解AI的"思维")

实践建议

生产环境中,建议采用"混合模式":

// 核心UI手动声明(确保精确控制)
function CoreLayout() {
  return (
    <div className="min-h-screen flex">
      <Sidebar />
      <main className="flex-1 p-6">
        <AIContent /> {/* AI生成的内容区域 */}
      </main>
    </div>
  );
}

// AI生成内容(非关键路径)
function AIContent() {
  const { content } = useAI({
    prompt: "根据当前页面上下文生成合适的内容"
  });
  
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

3.2.2 从"状态驱动"到"对话驱动"

状态驱动(传统)

前端架构的核心是状态管理。

数据流向:
用户操作 → Action → Dispatcher → Reducer → State → UI重新渲染

示例:
点击按钮 → dispatch({ type: 'INCREMENT' }) → 
Reducer处理 → State.count++ → UI显示新数值

React的useState、Redux、Vuex,都是围绕"状态"设计的。

对话驱动(AI应用)

状态依然存在,但不再是架构的核心。**对话历史(Conversation History)**成为新的状态载体。

// Vercel AI SDK的useChat管理的是消息历史
function ChatComponent() {
  const { messages, input, handleSubmit } = useChat();
  
  // messages就是新的"状态",它驱动UI的展示
  return (
    <div>
      {messages.map(m => (
        <Message key={m.id} role={m.role} content={m.content} />
      ))}
      
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
        />
        <button type="submit">发送</button>
      </form>
    </div>
  );
}

对话驱动的特点

  1. 上下文保持:AI记住之前的对话,可以基于上下文理解用户意图
  2. 多轮交互:不是一次性操作,而是通过多轮对话逐步完成任务
  3. 不确定性:同样的输入,可能因为上下文不同而产生不同输出
  4. 流式响应:AI的响应是流式的,UI需要支持渐进式更新

架构变化

传统应用架构:
用户操作 → 状态更新 → UI渲染

AI应用架构:
用户输入 → AI理解 → 生成响应 → 流式展示 → 用户反馈 → 下一轮...
            ↑_________↓
              上下文循环

3.2.3 从"静态组件"到"智能组件"

静态组件(传统)

给定相同的props,永远渲染相同的UI。

// 静态组件:纯函数,确定性输出
function Button({ children, onClick, variant }: ButtonProps) {
  return (
    <button 
      className={`btn btn-${variant}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// 相同输入,相同输出
<Button variant="primary">Click me</Button> // 总是渲染相同的按钮

智能组件(AI驱动)

组件具备AI能力,能根据上下文自动调整行为。

// 智能组件概念示例
function AdaptiveButton({ intent, context }) {
  // 组件理解上下文,自动调整行为
  const { variant, size, icon, label, confirmation } = useAI({
    prompt: `根据意图"${intent}"和上下文${JSON.stringify(context)},
             生成最合适的按钮配置`,
    constraints: {
      variants: ['primary', 'secondary', 'danger', 'ghost'],
      sizes: ['sm', 'md', 'lg', 'xl'],
      requireConfirmation: ['delete', 'irreversible']
    }
  });
  
  const handleClick = () => {
    if (confirmation) {
      showConfirmationDialog(confirmation.message, executeAction);
    } else {
      executeAction();
    }
  };
  
  return (
    <Button variant={variant} size={size} onClick={handleClick}>
      {icon && <Icon name={icon} />}
      {label}
    </Button>
  );
}

// 使用:组件自动根据场景调整
<AdaptiveButton 
  intent="删除用户账户"
  context={{ userRole: 'admin', targetUser: 'VIP客户', irreversible: true }}
/>
// AI理解这是危险且不可逆的操作
// 自动选择danger变体,添加确认对话框,显示警告信息

智能组件的特征

  1. 自适应:根据用户行为、设备环境、网络状况自动调整
  2. 自优化:根据使用数据自动优化性能(如自动代码分割、懒加载)
  3. 自解释:能够解释自己的行为,帮助用户理解和调试
  4. 个性化:根据用户偏好和历史行为提供个性化体验

3.3 Prompt工程的新角色

在AI驱动的前端开发中,Prompt Engineering(提示工程)扮演着越来越重要的角色。它不再是一个"技巧",而是一个核心技能。

3.3.1 Prompt即接口(Prompt as Interface)

在传统开发中,我们定义函数接口:

// 传统接口定义
interface CreateUserParams {
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

function createUser(params: CreateUserParams): Promise<User> {
  // 实现...
}

在AI驱动开发中,Prompt成为新的"接口":

// Prompt即接口
interface ComponentGenerationPrompt {
  role: "前端开发专家";
  task: {
    componentName: string;
    requirements: string[];     // 功能需求列表
    techStack: {
      framework: 'React' | 'Vue' | 'Angular';
      styling: 'Tailwind' | 'CSS Modules' | 'Styled';
      typescript: boolean;
    };
    designSpec: DesignTokens;   // 设计规范
    context: {
      existingHooks: string[];  // 已有Hooks
      uiLibrary: string;        // UI组件库
      conventions: string[];    // 代码规范
    };
  };
  output: {
    code: string;              // 完整组件代码
    tests: string;             // 测试用例
    examples: string;          // 使用示例
    docs: string;              // Props文档
  };
}

// 用这个"接口"生成组件
const prompt: ComponentGenerationPrompt = {
  role: "前端开发专家",
  task: {
    componentName: "UserProfileCard",
    requirements: [
      "展示用户头像、姓名、职位",
      "悬停显示更多详情",
      "支持点击跳转到用户详情页",
      "响应式布局"
    ],
    techStack: {
      framework: "React",
      styling: "Tailwind",
      typescript: true
    },
    designSpec: designSystem.tokens,
    context: {
      existingHooks: ["useUser", "useRouter"],
      uiLibrary: "shadcn/ui",
      conventions: ["使用函数组件", "Props类型使用interface"]
    }
  }
};

const component = await generateComponent(prompt);

3.3.2 Prompt资产化管理

随着Prompt越来越多,团队需要建立Prompt资产库:

prompts/
├── README.md                    # 使用指南
├── guidelines/
│   └── writing-effective-prompts.md  # Prompt编写规范
├── templates/
│   ├── code-generation/         # 代码生成模板
│   │   ├── react-component.md
│   │   ├── vue-component.md
│   │   ├── utility-function.md
│   │   ├── custom-hook.md
│   │   └── api-client.md
│   ├── code-review/             # 代码审查模板
│   │   ├── security-check.md
│   │   ├── performance-review.md
│   │   ├── accessibility-check.md
│   │   └── style-guide-check.md
│   ├── debugging/               # 调试排错模板
│   │   ├── error-analysis.md
│   │   ├── performance-debug.md
│   │   └── memory-leak-debug.md
│   ├── documentation/           # 文档生成模板
│   │   ├── component-docs.md
│   │   ├── api-docs.md
│   │   └── readme-generator.md
│   └── architecture/            # 架构设计模板
│       ├── system-design.md
│       ├── data-modeling.md
│       └── api-design.md
├── examples/                    # 示例Prompt
│   ├── good-examples/           # 优秀案例
│   └── bad-examples/            # 反面教材
└── snippets/                    # 可复用的Prompt片段
    ├── tech-stack-definitions.md
    ├── code-conventions.md
    └── design-system-tokens.md

Prompt模板示例

<!-- prompts/templates/code-generation/react-component.md -->

# React组件生成模板

## 角色
你是资深前端工程师,精通React、TypeScript和现代前端工程化。

## 任务
根据以下要求生成高质量的React组件代码。

## 输入
- 组件名称:{{componentName}}
- 功能需求:{{requirements}}
- 技术栈:{{techStack}}
- 设计规范:{{designSpec}}
- 上下文:{{context}}

## 输出要求
1. 使用函数组件和TypeScript
2. 完整的Props类型定义
3. 包含JSDoc注释
4. 处理加载状态和错误状态
5. 遵循{{techStack.conventions}}代码规范
6. 使用{{techStack.uiLibrary}}组件库
7. 可访问性支持(aria属性、键盘导航)

## 代码结构

import React from 'react';
// 导入语句

// Props类型定义
interface {{componentName}}Props {
  // ...
}

/**
 * {{componentName}}组件
 * @description {{description}}
 */
export function {{componentName}}(props: {{componentName}}Props) {
  // 实现代码
}

## 示例
{{examples}}

3.3.3 Prompt工程最佳实践

1. 结构化Prompt

好的Prompt应该结构清晰、信息完整:

❌ 不好的Prompt:
"写一个用户表单"

✅ 好的Prompt:
"创建一个用户注册表单组件

角色:前端开发专家
技术栈:React + TypeScript + Tailwind CSS + shadcn/ui

功能要求:
1. 表单字段:用户名(必填,3-20字符)、邮箱(必填,有效格式)、密码(必填,8+字符,包含大小写和数字)
2. 实时验证:失去焦点时验证,显示错误信息
3. 提交处理:调用/api/register,显示加载状态
4. 成功处理:清空表单,显示成功消息
5. 错误处理:显示服务器返回的错误信息

UI要求:
1. 使用Card布局,最大宽度480px,居中
2. 输入框使用shadcn/ui的Input组件
3. 错误信息使用红色文字,显示在输入框下方
4. 提交按钮显示加载Spinner

可访问性:
1. 所有输入框关联label
2. 错误信息使用aria-describedby关联
3. 支持键盘导航"

2. 渐进式细化策略

与AI协作的最佳实践是"渐进式细化":

Round 1: 生成骨架
"创建一个用户管理页面,包含表格和基本CRUD操作"
→ AI生成基础结构

Round 2: 添加功能
"在表格上方添加搜索框和筛选器,支持按姓名和角色筛选"
→ AI添加筛选功能

Round 3: 优化细节
"搜索框添加防抖处理,筛选器使用下拉菜单,表格添加分页"
→ AI优化交互细节

Round 4: 完善体验
"添加加载状态、空状态、错误处理,优化移动端显示"
→ AI完善用户体验

3. 示例驱动(Few-Shot Learning)

提供示例可以帮助AI理解预期输出:

"创建一个格式化日期函数,要求:
1. 输入:Date对象或时间戳
2. 输出:'YYYY年MM月DD日 HH:mm'格式
3. 处理无效输入

示例:
输入:new Date('2024-03-15 14:30:00')
输出:'2024031514:30'

输入:null
输出:'无效日期'

请实现这个函数:"

4. 约束和边界

明确指定约束条件,避免AI生成不符合要求的代码:

"实现一个节流函数,约束条件:
1. 使用TypeScript,完整类型定义
2. 支持leading和trailing选项
3. 使用requestAnimationFrame优化性能
4. 不要使用lodash或其他库
5. 包含单元测试"

3.4 新抽象层的出现:意图层(Intent Layer)

AI的引入,在前端架构中增加了一个新的抽象层。

3.4.1 传统架构 vs AI增强架构

传统前端架构

用户操作 → 事件处理 → 状态更新 → 组件重新渲染
    ↑________________________________↓
              循环

开发者直接控制每一个环节。

AI增强架构

用户意图 → AI理解 → 决策/生成 → 状态更新 → 组件重新渲染
    ↑________________________↓
           反馈循环

在"用户意图"和"实现代码"之间,增加了AI处理层。

3.4.2 意图层带来的变化

1. 更高的抽象级别

开发者描述意图,AI处理实现细节。

传统方式:
"我需要创建一个div,className是p-4 bg-blue-500..."

AI方式:
"创建一个蓝色卡片组件"

2. 更好的用户体验

AI可以根据上下文提供智能化建议。

// AI可以根据用户角色自动调整界面
function Dashboard() {
  const { user } = useAuth();
  
  // AI根据用户角色和历史行为,生成个性化的仪表板布局
  const { layout, widgets } = useAI({
    prompt: `为${user.role}生成个性化的仪表板布局`,
    context: {
      userRole: user.role,
      permissions: user.permissions,
      frequentlyUsed: user.metrics.frequentlyUsedFeatures,
      recentActivity: user.metrics.recentActivity
    }
  });
  
  return <AdaptiveLayout layout={layout} widgets={widgets} />;
}

3. 更大的不确定性

AI的输出不是完全确定的,需要处理各种边界情况。

function AIGeneratedComponent({ prompt }) {
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    generateCode(prompt)
      .then(code => {
        // 验证生成的代码
        if (!isValidCode(code)) {
          throw new Error('Generated code is invalid');
        }
        setResult(code);
      })
      .catch(err => {
        setError(err);
        // 记录错误,用于改进AI模型
        logError(prompt, err);
      })
      .finally(() => setLoading(false));
  }, [prompt]);
  
  if (loading) return <LoadingState />;
  if (error) return <ErrorState error={error} onRetry={() => window.location.reload()} />;
  
  return <RenderedComponent code={result} />;
}

3.4.3 意图层的边界和风险

何时使用意图层?

适合使用AI

  • 样板代码生成
  • 快速原型验证
  • 探索性开发
  • 文档生成
  • 测试用例生成

不适合使用AI

  • 核心算法实现
  • 安全敏感代码
  • 性能关键路径
  • 需要严格合规的代码
  • 创新性设计

风险控制

AI代码进入生产环境的门禁:

1. 自动检查层
   ├─ 语法检查(ESLint/TypeScript)
   ├─ 安全检查(SAST扫描)
   ├─ 性能检查(Bundle分析)
   └─ 可访问性检查(axe-core)

2. 人工审查层(必须)
   ├─ 逻辑正确性审查
   ├─ 安全漏洞审查
   ├─ 性能影响评估
   └─ 可维护性评估

3. 测试验证层
   ├─ 单元测试通过率>80%
   ├─ 集成测试通过
   ├─ 端到端测试通过
   └─ 视觉回归测试通过

4. 灰度发布层
   ├─ 5%流量验证
   ├─ 监控错误率
   ├─ 监控性能指标
   └─ 全量发布

3.5 小结:拥抱范式转变

从组件驱动到意图驱动的转变,是前端开发范式的一次重大跃迁。这不仅仅是工具的升级,更是思维方式的重构。

关键转变总结

维度 传统模式 新模式 应对策略
关注点 如何组装组件 如何描述意图 学习Prompt工程
代码所有权 精心维护 按需生成 建立质量门禁
调试方式 理解代码逻辑 与AI对话迭代 保留核心能力
技能重点 框架和API 需求拆解和沟通 培养软技能
架构思维 状态管理 意图管理和AI编排 学习AI架构

未来的前端工程师

将是一个混合角色

  • 50%的架构师:设计系统、把控质量、做出关键决策
  • 30%的Prompt工程师:与AI高效沟通,生成高质量代码
  • 20%的产品设计师:理解用户需求,创造优秀体验

这个转变不会一夜之间完成,而是一个渐进的过程。现在开始学习和适应,才能在未来保持竞争力。


下章预告

第四章《锋利的双刃剑——批判性审视AI生成代码》将深入探讨:

  • AI生成代码的可访问性危机及解决方案
  • 性能陷阱和技术债的累积模式
  • 安全漏洞的隐蔽性和防护措施
  • 工程师能力退化的风险及防范
  • 真实案例分析:过度依赖AI的教训

工具指南24-在线CSS Box Shadow生成器

打开任何一个现代 Web 应用,你几乎找不到一个不用 box-shadow 的页面。卡片悬浮、按钮点击反馈、模态框层级、导航栏分隔——阴影是建立视觉层次的基础手段。但 box-shadow 的参数组合极其复杂:水平偏移、垂直偏移、模糊半径、扩展半径、颜色、inset……一个自然的阴影通常需要 2-3 层叠加,每层 5 个参数,手写意味着 15 个数值的排列组合。在编辑器里盲调效率极低,每次修改都要切到浏览器确认效果。

可视化工具能把这个过程从"猜参数"变成"拖滑块"。这篇文章介绍一个在线 Box Shadow 生成器,同时深入讲解 CSS 阴影的技术细节,帮你理解每个参数的作用,写出性能更好的阴影代码。

工具介绍

CSS Box Shadow 生成器 提供了一个可视化的阴影编辑界面,核心功能包括:

  • 多层阴影叠加:添加多个阴影层,分别调整参数,实现复杂的光影效果
  • 实时预览:拖动滑块即刻看到效果变化,不用反复刷新浏览器
  • Inset 阴影:支持内阴影模式,用于凹陷效果
  • 一键复制:生成的 CSS 代码可直接粘贴到项目中

操作很直观:调整各个参数的滑块,观察预览区域的阴影变化,满意后复制 CSS 代码。支持同时编辑多层阴影,这是手写代码最难调试的部分。

box-shadow 语法详解

先搞清楚语法结构,才能真正理解工具里每个滑块的含义。

基本语法

box-shadow: [inset] <offset-x> <offset-y> [blur-radius] [spread-radius] [color];

五个参数各自的作用:

box-shadow: 
  /* offset-x: 水平偏移,正值向右,负值向左 */
  /* offset-y: 垂直偏移,正值向下,负值向上 */
  /* blur-radius: 模糊半径,值越大阴影越柔和,默认 0(硬边缘)*/
  /* spread-radius: 扩展半径,正值阴影扩大,负值收缩,默认 0 */
  /* color: 阴影颜色,通常用 rgba 控制透明度 */
  4px 8px 16px -2px rgba(0, 0, 0, 0.15);

参数对视觉效果的影响

每个参数单独调整时的效果:

/* 只有偏移,没有模糊 —— 硬阴影,像剪纸效果 */
box-shadow: 4px 4px 0 0 #000;

/* 加模糊,去偏移 —— 均匀发光效果 */
box-shadow: 0 0 20px 0 rgba(59, 130, 246, 0.5);

/* 负扩展 —— 阴影比元素小,只在底部可见 */
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.3);

/* inset —— 内阴影,凹陷效果 */
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1);

理解这些基础后,在工具里调参就不是盲目操作了,而是有目的地调整。

多层阴影

自然界中的阴影不是单层的。一个物体在光源下会产生多层不同浓度的阴影——靠近物体的部分浓且清晰,远离的部分淡且模糊。CSS 通过逗号分隔多层阴影来模拟这个效果:

/* 经典的双层自然阴影 */
.card {
  box-shadow: 
    0 1px 3px 0 rgba(0, 0, 0, 0.1),   /* 近处:小偏移,低模糊,较浓 */
    0 1px 2px -1px rgba(0, 0, 0, 0.1); /* 补充层:略有扩展收缩 */
}

/* 三层阴影:Google Material Design 风格 */
.elevated-card {
  box-shadow: 
    0 1px 2px 0 rgba(0, 0, 0, 0.05),   /* 底层:微弱的基础阴影 */
    0 4px 6px -1px rgba(0, 0, 0, 0.1),  /* 中层:主阴影 */
    0 10px 15px -3px rgba(0, 0, 0, 0.1); /* 远层:大范围的柔和阴影 */
}

多层阴影的调试是最需要可视化工具的场景。在生成器里逐层调整,比在代码里改数字再刷新高效得多。

实战设计模式

光知道语法不够,还得知道什么场景用什么阴影。下面是几个常见的设计模式和对应的 CSS 实现。

卡片悬浮效果

卡片组件几乎是 box-shadow 最高频的使用场景。好的卡片阴影需要在"存在感"和"不抢戏"之间找平衡:

/* 默认状态:轻柔的阴影,暗示可交互 */
.card {
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
  transition: box-shadow 0.2s ease;
}

/* hover 状态:阴影加深加大,暗示"抬起" */
.card:hover {
  box-shadow: 
    0 10px 15px -3px rgba(0, 0, 0, 0.1),
    0 4px 6px -4px rgba(0, 0, 0, 0.1);
}

关键技巧:hover 时增大 offset-yblur-radius,模拟元素离页面"抬起"的效果。配合 transition 让阴影变化有动画过渡。

按钮点击反馈

按钮的阴影变化可以传达"按下"的物理反馈:

.button {
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15);
  transition: all 0.15s ease;
}

.button:active {
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
  transform: translateY(1px);
}

按下时缩小阴影 + 向下位移,两者配合才有真实的按压感。只改阴影不改位置,效果会很奇怪。

聚焦环(Focus Ring)

用 box-shadow 替代 outline 做聚焦指示,可以跟随圆角且支持颜色自定义:

.input:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4);
}

/* 多层:内边框 + 外发光 */
.input:focus-visible {
  outline: none;
  box-shadow: 
    0 0 0 1px #3b82f6,              /* 内层:实色边框 */
    0 0 0 4px rgba(59, 130, 246, 0.2); /* 外层:柔和光晕 */
}

这里用了 spread-radius 配合零偏移零模糊,让阴影变成一个等宽的"边框"。这个技巧在 Tailwind CSS 的 ring 工具类中被广泛使用。

Neumorphism(新拟态)

新拟态设计依赖两层方向相反的阴影,模拟凸起或凹陷效果:

/* 凸起效果 */
.neumorphic {
  background: #e0e0e0;
  box-shadow: 
    6px 6px 12px #bebebe,   /* 右下深色阴影 */
    -6px -6px 12px #ffffff;  /* 左上亮色高光 */
}

/* 凹陷效果(inset)*/
.neumorphic-inset {
  background: #e0e0e0;
  box-shadow: 
    inset 6px 6px 12px #bebebe,
    inset -6px -6px 12px #ffffff;
}

新拟态对背景色有严格要求——必须是中性灰色系,否则两层阴影的对比度不够,效果会消失。在生成器里调试这类效果比手写快得多,因为你需要同时调整背景色和两层阴影的颜色来找到平衡点。

性能注意事项

box-shadow 不是"免费"的。浏览器渲染阴影需要额外计算,在某些场景下可能导致性能问题。

模糊半径与渲染成本

模糊半径越大,浏览器需要采样的像素范围越广,渲染成本越高。根据实际渲染测试的经验值:

  • blur-radius: 4px → 影响很小,几乎无性能开销
  • blur-radius: 20px → 中等开销,大量元素同时渲染时注意
  • blur-radius: 50px+ → 高开销,避免在动画中使用

动画阴影的正确姿势

直接动画 box-shadow 属性会触发重绘(repaint),在列表中对大量元素同时做阴影动画会造成卡顿。更好的做法是用伪元素 + opacity:

.card {
  position: relative;
}

/* 把"hover 后的阴影"放在伪元素上 */
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.card:hover::after {
  opacity: 1;
}

这样浏览器只需要改变 opacity(走合成层优化),不用每帧重新计算阴影的模糊像素,性能好很多。这是 CSS 动画优化的通用技巧:能用 opacity/transform 做的动画,就不要用其他属性

will-change 的使用

如果确实需要动画 box-shadow,可以提前告诉浏览器:

.card {
  will-change: box-shadow;
}

但不要滥用——will-change 会让浏览器提前分配 GPU 资源,对内存有额外开销。更好的做法是通过 JavaScript 在动画开始前动态添加这个属性,动画结束后移除,而不是一直写在 CSS 中。只在确认存在性能问题的元素上使用。

设计系统中的阴影规范

成熟的设计系统会定义一套标准化的阴影层级,而不是让每个组件自己写阴影值。

Tailwind CSS 的阴影层级

Tailwind 定义了 6 个层级的阴影,覆盖了绝大多数场景:

/* shadow-sm: 微弱阴影,表单输入框等 */
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);

/* shadow: 默认阴影,卡片等 */
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);

/* shadow-md: 中等阴影,下拉菜单等 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);

/* shadow-lg: 较深阴影,弹出层等 */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);

/* shadow-xl: 深阴影,模态框等 */
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);

/* shadow-2xl: 最深阴影,全屏浮层等 */
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);

注意规律:层级越高,offset-y 越大,blur-radius 越大,spread-radius 用负值控制阴影不要过度扩散。这套设计背后的逻辑是模拟"距离页面越远,阴影越大越柔和"。

自定义阴影 Token

如果你在做自己的设计系统,建议用 CSS 自定义属性管理阴影:

:root {
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}

.card { box-shadow: var(--shadow-sm); }
.dropdown { box-shadow: var(--shadow-md); }
.modal { box-shadow: var(--shadow-lg); }

用生成器调出满意的阴影效果后,把值存到 Token 里统一管理,项目中所有组件引用 Token 而不是写死数值。改阴影风格时只需要改 Token 定义,不用逐个组件修改。

暗色模式下的阴影处理

暗色模式是容易踩坑的地方。在浅色背景上好看的阴影,切到暗色背景可能完全看不见。

:root {
  --shadow-color: rgba(0, 0, 0, 0.1);
}

/* 暗色模式:加大阴影不透明度,或者换用更深的颜色 */
@media (prefers-color-scheme: dark) {
  :root {
    --shadow-color: rgba(0, 0, 0, 0.4);
  }
}

.card {
  box-shadow: 0 4px 6px -1px var(--shadow-color);
}

另一种做法是在暗色模式下用"发光"代替阴影,用浅色半透明值模拟光源效果:

@media (prefers-color-scheme: dark) {
  .card {
    box-shadow: 0 0 15px rgba(255, 255, 255, 0.05);
  }
}

这种处理在生成器里来回切换预览背景色就能快速对比效果。

常见问题

box-shadow 和 filter: drop-shadow 的区别

两者看起来效果类似,但有本质差异:

  • box-shadow 作用于元素的盒模型矩形,无论元素形状如何,阴影都是矩形(或跟随 border-radius 的圆角矩形)
  • filter: drop-shadow() 作用于元素的 alpha 通道轮廓,会跟随元素的实际形状(包括透明区域)
/* box-shadow:矩形阴影,不跟随 PNG 透明区域 */
img { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); }

/* drop-shadow:跟随图片实际形状 */
img { filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2)); }

对于普通的 div、按钮、卡片,两者效果基本一样。但对于不规则形状(SVG 图标、透明 PNG、clip-path 裁剪的元素),只有 drop-shadow 能产生正确的阴影。

阴影颜色的选择

新手常犯的错误是用纯黑色 #000 做阴影。自然界中的阴影不是纯黑的,它会带有环境色的倾向。更自然的做法:

/* 偏冷色的阴影(适合蓝色系界面)*/
box-shadow: 0 4px 12px rgba(0, 0, 40, 0.12);

/* 偏暖色的阴影(适合暖色系界面)*/
box-shadow: 0 4px 12px rgba(40, 20, 0, 0.1);

/* 彩色阴影(用元素自身颜色做阴影)*/
.blue-button {
  background: #3b82f6;
  box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
}

彩色阴影是近年来 UI 设计的趋势之一,能让按钮和卡片看起来更有"质感"。在生成器里把阴影颜色设成和元素背景相近的色调,透明度调到 30%-50%,就能得到不错的效果。

总结

box-shadow 参数多、层数多、场景多,是最适合用可视化工具辅助的 CSS 属性之一。用 在线 Box Shadow 生成器 快速调试出想要的效果,再把生成的代码整理成设计 Token 统一管理,是效率最高的工作流。

核心要点:

  • 自然阴影通常需要 2-3 层叠加,用工具逐层调整比手写高效
  • 动画阴影优先用伪元素 + opacity,避免直接动画 box-shadow
  • 建立阴影层级体系(xs/sm/md/lg),用 CSS 变量管理
  • 暗色模式需要单独处理阴影参数
  • 不规则形状用 filter: drop-shadow() 而非 box-shadow

本系列其他文章


原文发布于 陈广亮的技术博客,欢迎关注获取更多前端与 AI 开发内容。

智能体与工作流:从「想做一个应用」到「能跑通一条链」

智能体与工作流:从「想做一个应用」到「能跑通一条链」

这篇博客用睡前故事串起两件事:概念上分清智能体与工作流;操作上Coze 搭画布(大模型 / 循环 / 插件)、发布工作流,再 创建对话智能体 把链挂上去并用预览验收;最后对比 Coze 与 Node.js 自研。配图已上传 OSS(与本地 assets/agent-workflow/*.png 同源文件名,便于你用 img 脚本覆盖更新)。

本文结构(按需跳读)

部分 内容
概念与需求 智能体、工作流、为何不能一次调模型、串行链、mermaid
设计四步 只在脑子里/文档里「生成」草图,不涉及 Coze 点击路径
实践一 Coze 工作流画布:节点类型、从「开始」到「结束」的配置与截图
实践二 发布工作流创建智能体 → 编排里 挂工作流 → 预览与 发布智能体(截图)
收尾 Coze 优缺点、与自研关系、全文小结

智能体是什么:不止「和大模型聊一句」

智能体(Agent)在经典定义里,是能感知环境做决策再行动的系统。落到今天的大模型应用上,可以把它理解成:

以模型为「大脑」之一,再叠上检索、工具调用、业务规则、多模态输出等能力,按固定或可变策略运转,最终对用户给出一个完整结果单元(而不只是一段即时回复)的那一层产品形态。

用户输入、系统提示词、中间调用的搜索与 TTS 等,都是「环境」与「指导信息」;智能体要做的,是在这些约束下把多步事情办完。


工作流是什么:把能力排成一条(或多条)流水线

智能体里真正承担业务骨架的,是 工作流(Workflow):把「分析意图 → 查资料 → 写稿 → 润色 → 念出来」这类步骤,变成可执行、可观测、可迭代的节点图。

  • 串行工作流:上一步输出是下一步输入,适合故事生成这类主线清晰的任务。
  • 并行与分支:实际业务里常有「同时查多个源」「某步失败则降级」等,图会变复杂;入门阶段先把一条串行链画清楚,价值最大。

一句话:智能体是「产品视角」的说法,工作流是「工程视角」的实现方式。


用「6~8 岁睡前故事」理解:为什么不能只调一次文本模型

假设产品需求是:

  1. 用户给一个故事主题;尽量讲经典民间故事,没有经典则围绕主题创作
  2. 内容与语言要符合 6~8 岁认知,不「超龄」。
  3. 最后用亲切的语音把故事念出来。

若只做 chat.completions 一次调用,模型既可能胡编典故,又无法引用可靠原文,更没有声音。因此这个应用本质上需要多能力组合:

能力 作用
搜索 / 检索 找到故事原文或参考资料(常与 RAG / 检索增强生成 一起讨论)
写作与润色 在检索结果上写草稿,再按儿童口吻改写
语音合成(TTS) 把定稿文本变成可播放音频

这些能力不会自动长在一起,要靠你在产品里编排顺序、约定每步输入输出。这就是工作流要解决的问题。


核心工作流长什么样(串行示例)

把上面的需求压成一条链,可以是:

输入主题 → 生成检索 query → 搜索并整理材料 → 撰写草稿 → 语言与风格润色 → 语音合成 → 输出(文本 + 音频)

用流程图表示更直观:

flowchart LR
  A[用户主题] --> B[生成搜索 query]
  B --> C[搜索 / 整理]
  C --> D[写草稿]
  D --> E[儿童向润色]
  E --> F[TTS]
  F --> G[文本 + 语音]

实现顺序上的建议:先在纸上或文档里画出这条链,标清每一步的输入输出数据结构;再决定用 Coze 拖拽,还是用代码(例如 Node.js)写「调度器」。顺序对了,换工具只是换壳。


设计阶段的四步清单(还不打开 Coze 也能做)

你可以把下面四步当作任意业务的模板,先在文档或白板完成;它们回答的是「做什么」,而不是「在 Coze 里点哪个菜单」:

  1. 定义成功态:用户最终拿到什么?(一段 JSON、一篇带出处的文章、一条语音……)
  2. 拆能力:需要模型、搜索、数据库、支付、TTS 中的哪几项?哪些可以合并成一步?
  3. 定依赖与顺序:哪一步必须等上一步结束?哪一步可以并行?失败时是否重试或降级?
  4. 选承载:原型期用 Coze 等低代码快速验证;上线前再评估是否迁到 自研编排(数据隐私、细粒度调试、成本结构)。

做到这里,你已经「生成」了智能体的设计稿。接下来两节是落地:先在 Coze 里把 工作流画布 跑通,再 发布挂到智能体


实践一:在 Coze 里搭「睡前故事」工作流(画布)

扣子 Coze 提供了工作流编排:用节点把大模型、循环、插件等连成图,适合快速验证「这条链跑不跑得通」。下面配图来自同一套「睡前故事」示例画布,界面以你当前 Coze 版本为准;若菜单文案略有差异,对照节点职责即可。

节点类型与添加路径(和本文截图一致)

在 Coze 工作流画布上点 「添加节点」 时,可按下面方式选类型(不同版本菜单层级可能微调,核心是节点类型要对):

画布上的职责 添加节点时的选择 说明
开始 / 结束 无需添加 新建工作流后画布默认自带;只需配置入参、出参。
生成 query、撰写草稿、润色 大模型 三处都是「大模型」节点,分别改节点标题与提示词、输入输出即可。
搜索并整理内容(外层) 循环 先加循环节点,再在循环体内部加搜索用的插件节点。
循环体内的搜索 插件 → 必应搜索 每次迭代用当前 query 调必应,把结果汇总给后续大模型。
语音合成 插件 → 搜索文本转语音 将润色后的正文交给插件生成音频(插件名以控制台为准)。

下文按数据从左到右的顺序讲配置要点;你在菜单里选对的节点类型,就和「一步步实现」对上了。

画布总览:一条从主题到「文本 + 语音」的链

整体从左到右大致是:开始(默认)→ 生成 query(大模型)→ 搜索并整理内容(循环,循环体内必应搜索)→ 撰写草稿(大模型)→ 润色(大模型)→ 语音合成(插件:搜索文本转语音)→ 结束(默认)。多模态输出在「结束」节点里一次性返回给上层 Bot 或 API。

Coze 工作流画布总览:睡前故事智能体

1. 新建工作流

进入 Coze 控制台 → 工作空间资源库 → 新建 工作流,例如命名为 bedtime_story,描述写清「给 6~8 岁孩子讲睡前故事」。进入画布后,「开始」与「结束」是默认节点,不必在「添加节点」里再选一次;后面所有节点都是从「开始」往后串、最后收进「结束」。

「开始」节点:声明工作流对外的入参。示例里只暴露一个字符串 input(故事主题),后续大模型节点通过模板变量 {{input}} 引用。

开始节点:配置入参 input

2. 第一个大模型节点:从主题到「检索 query」

添加节点 → 大模型,将节点标题改为「生成 query」(名称可自定)。用于:根据用户输入分析意图,并输出一组搜索用 query(后续由循环消费)。

系统提示词可围绕「目标 + 分析方法 + 任务」来写,例如(节选思路):

  • 若主题是常见民间故事名,则生成便于检索原文的 query;
  • 否则结合文化背景生成能搜到参考资料的 query;
  • 明确输出格式要求(如字符串数组)。

用户提示词里使用 Coze 的模板变量,把「开始」节点的输入接进来,例如:

{{input}}

双花括号中的名字需与开始节点里定义的输入字段名一致(默认常为 input)。

输出变量建议配置两个(示例命名):

输出名 类型 含义
querys 字符串数组 多条检索 query
intent 字符串 对用户意图的简短概括

这里 intent 未必被后续节点消费,但让模型多输出一个「对自己有用」的字段,往往能起到链式思考(chain-of-thought)外显的效果,有助于提高 querys 质量——这是很多工作流里的小技巧。

联调小技巧:开发时可以把该节点输出直接连到 结束 节点,在结束节点里配置要暴露的变量,先验证「query 生成」是否稳定,再往下接搜索与写作。

下图可见:模型选用「豆包·2.0·pro」等;输入绑定「开始 → input」;输出解析为 JSON 字段(如 intentquerys 数组),供下一节点消费。

生成 query 节点:系统提示词、用户侧 {{input}}、JSON 输出 intent / querys

3. 循环 + 必应搜索:对多条 query 逐个检索

因为 querys 是数组,在「生成 query」后面 添加节点 → 循环;外层循环节点标题可写成「搜索并整理内容」一类,便于读图。

  • 循环类型:选「使用数组循环」;循环数组绑定上一大模型节点的 querys
  • 循环体内部:再点 添加节点 → 插件 → 必应搜索(或你工作区里可用的等价联网搜索插件)。每次迭代把当前元素映射为搜索的 querycount 控制条数;输出里的 data 等字段供循环汇总。
  • 输出映射:界面上常有经验顺序——先在循环体里把「必应搜索」节点接好、跑通,再回来配置循环节点对外的输出数组(否则没有可引用的中间结果)。

循环节点:数组绑定「生成 query → querys」,输出汇总检索结果

循环体内插件「必应搜索」:query 来自循环、count 控制返回条数

若不需要循环内的临时中间变量,可在 Coze 里按界面提示精简变量,避免图越来越乱。

4. 撰写草稿 → 润色(大模型)→ 语音合成(插件)→ 结束(默认)

在循环之后,把整理后的检索结果交给两个连续的大模型节点做「写稿 + 润色」,最后用插件出音。

撰写草稿添加节点 → 大模型。输入侧接入「搜索并整理内容」汇总后的材料;系统提示词约束「6~8 岁、经典尽量忠于原文」等;用户提示词用模板 参考资料:{{input}} 把变量喂进模型;输出 output(及可选 reasoning_content)供下一步使用。

撰写草稿节点:大模型,输入检索整理结果

润色:同样 添加节点 → 大模型。输入接 撰写草稿 → output;系统提示词切换为「温柔大姐姐给妹妹讲睡前故事」等人设;用户侧 故事材料:{{input}};输出仍为字符串 output

润色节点:大模型,承接草稿 output

语音合成添加节点 → 插件 → 搜索文本转语音(若控制台插件名称有细微差别,以实际列表为准)。将正文字段绑定 润色 → output;并按插件面板填写音色、cluster(如 volcano_tts)、app_id / app_token 等;输出里常见 link 指向生成音频 URL。

语音合成节点:插件「搜索文本转语音」,文本来自润色 output

结束:使用画布默认的「结束」节点即可;在配置里选 「返回变量」:例如 text 映射润色后的正文,audio 映射语音合成返回的 link(或平台等价字段),这样上层一次拿到「可读文本 + 可播音频」。

结束节点:返回变量 text(润色)与 audio(语音 link)

每一段的输入输出变量名要与前后节点对齐;逻辑顺序应与上文「核心工作流」示意图一致——你在 Coze 里是在「画图实现」同一张设计稿。


实践二:发布工作流,并挂到「对话智能体」

画布上的 工作流 解决「一条链怎么跑」;智能体(Bot) 解决「用户怎么对话触发这条链」。建议顺序:试运行并发布工作流 → 在资源库 创建智能体编排 → 技能 → 工作流 里添加已发布的工作流 → 预览与调试 验证触发与入参 → 发布智能体

试运行与发布工作流

bedtime_story(或你的工作流名)编辑页里先 试运行,确认「开始 → … → 结束」整条链无报错后,使用平台提供的 发布(或「上线」类)能力,把工作流变为 已发布 状态。只有发布后,智能体侧「添加工作流」列表里才容易稳定搜到它(具体按钮名称以 Coze 当前版本为准)。

在资源库创建智能体

进入 工作空间 → 资源库,右上角 「+ 创建」,选择 「创建智能体」(适用于对话式智能体)。

资源库中创建智能体入口

填写名片并确认

在弹窗里选 标准创建(或你需要的创建方式),填写 智能体名称功能介绍工作空间图标 等。示例中与睡前故事一致:儿童睡前故事 / 给 6-8 岁儿童讲睡前故事 等。点 确认 进入编排页。

创建智能体:名称、介绍、空间与图标

编排里挂载工作流技能

打开 编排,在 技能 区域找到 工作流 一行,点击右侧 「+」(提示为 添加工作流)。在列表中选中已发布的 bedtime_story,点 添加,把它挂到当前智能体上。这样用户发一句自然语言时,智能体才会按策略去 调用 你刚编排好的那条链。

编排页:技能 → 工作流 → 添加工作流

添加工作流弹窗:选择已发布的 bedtime_story

预览调试并发布智能体

右侧 预览与调试 里直接输入用户会说的主题(例如 「狼来了」)。若编排正确,应能看到 正在调用 bedtime_story 一类状态,并走完整条工作流(含你结束的 text / audio 等返回)。确认满意后,再在平台里 发布智能体,对外分享或接入渠道。

预览与调试:用户输入触发 bedtime_story 工作流

与「实践一」的关系:工作流 = 可复用的业务链;智能体 = 对话壳 + 默认模型 + 挂载的一条或多条工作流。先发布链,再把链挂到 Bot 上,用预览验证「用户一句话 → 工作流入参 input」是否对齐。


Coze 工作流的优缺点:适合当「哪一级」

优点

  • :复杂分支、流式输出、插件生态都能较快搭出可演示版本。
  • 省成本:适合创业者、团队做原型验证与需求对齐。
  • 可视化:非纯研发也能参与讨论「第几步该干什么」。

局限

  • 平台绑定:流程与数据多在 Coze 侧,对强隐私、强合规、专有部署的场景要慎重。
  • 节点内部偏黑盒:要做极致的数据结构优化、细粒度耗时分析时,不如代码透明。

因此常见节奏是:Coze 验证工作流是否合理 → 定型后用 Node.js(或其它后端) 把同一条 DAG 写成可维护的服务(与本仓库 server.js 编排多厂商 API 的思路一致)。理解「工作流原理」之后,换承载并不神秘。


小结:从草图到可对话的一条龙

  1. 先定义成功态与边界(对应上文「设计四步」前两条):用户拿什么结果、年龄与体裁等约束。
  2. 再画工作流(设计四步后两条 + mermaid):拆能力、定顺序、选承载。
  3. 实践一:Coze 画布:「大模型 → 循环 + 必应 → 大模型 ×2 → 搜索文本转语音」,开始/结束用默认节点。
  4. 实践二:上架:发布工作流 → 创建智能体 → 编排 → 技能 → 工作流 挂载 → 预览 → 发布智能体。
  5. 再决定要不要自研:原型通过后,用 Node.js 等复刻同一条 DAG(见本仓库 server.js 一类编排)。

智能体不是「多调几次模型」的代名词,而是**「多步能力 + 清晰编排」**的产物;设计稿 → 工作流画布 → 对话壳挂载 走完,就是从想法到可演示产品的完整一程。

React 19 正式发布:这一次,表单和服务器组件终于"原生"了

React 19 正式发布:这一次,表单和服务器组件终于"原生"了

2024 年 12 月,React 19 正式登陆 npm。这是一次真正意义上的全栈升级——不仅有给 UI 开发者的新 Hook 和表单能力,还有面向框架作者的 React Server Components 稳定支持。

本文不堆砌升级指南,只聚焦一个核心问题:React 19 到底带来了什么值得你花时间学的新特性?


一、Actions:表单提交终于不用自己写 pending 了

React 19 最核心的改动是引入了 Actions 这个概念。

它的背景很真实:用户提交一个表单 → 发 API 请求 → 等待响应 → 处理错误或跳转。过去这一切都需要开发者手动写 isPendingsetErrorsetIsPending 三件套。

// React 18:手动管理 pending 和错误
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);  // 手动设置 pending
    const error = await updateName(name);
    setIsPending(false); // 手动重置
    if (error) { setError(error); return; }
    redirect("/path");
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

Actions 的做法:把异步逻辑包进 startTransition,React 自动帮你处理 pending 状态。

// React 19:Actions 自动处理 pending + 错误
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const error = await updateName(name);
      if (error) { setError(error); return; }
      redirect("/path");
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

isPending 会自动跟随 transition 的状态变化,不需要手动 setIsPending


二、useActionState:连错误处理都包装好了

useActionState 是 Actions 的进一步封装,把最常见的"提交 → 等待 → 结果"模式再简化一层:

const [error, submitAction, isPending] = useActionState(
  async (previousState, formData) => {
    const error = await updateName(formData.get("name"));
    if (error) return error;  // 返回 error
    redirect("/path");
    return null;              // 成功返回 null
  },
  null,  // 初始状态
);

用法非常直觉:submitAction 就是你要调用的函数,error 是上次调用的结果,isPending 是自动管理的加载状态。


三、useOptimistic:乐观更新终于有官方方案了

"乐观更新"指的是:用户操作后立即显示预期结果,不用等服务器返回。比如点赞、发帖、修改用户名,都适合用这个模式。

function ChangeName({ currentName, onUpdateName }) {
  // 乐观 name:立即渲染,请求完成前显示用户预期的值
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);

  const submitAction = async (formData) => {
    const newName = formData.get("name");
    setOptimisticName(newName);  // 立即更新 UI
    const updatedName = await updateName(newName);
    onUpdateName(updatedName);   // 服务器返回后自动切回真实值
  };

  return (
    <form action={submitAction}>
      <p>你的名字是:{optimisticName}</p>
      <input type="text" name="name" disabled={currentName !== optimisticName} />
    </form>
  );
}

如果请求失败超时,React 会自动回滚到 currentName,不需要手动处理。


四、<form> Actions:表单提交进入"声明式"时代

React 19 的 react-dom 新增了对 <form> 的原生支持,可以直接传一个函数给 action

function App() {
  const submitAction = async (formData) => {
    "use server";  // 这个函数会在服务器端执行
    await saveName(formData.get("name"));
  };

  return (
    <form action={submitAction}>
      <input type="text" name="name" />
      <button type="submit">提交</button>
    </form>
  );
}

提交成功后,React 会自动重置表单。如果需要手动重置,调用 requestFormReset() API 即可。


五、use:比 useContext 更灵活的资源读取方式

React 19 引入了全新 API use,它可以有条件地读取 Promise 和 Context:

读取 Promise(配合 Suspense)

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);  // 挂起直到 resolved
  return comments.map(c => <p key={c.id}>{c.text}</p>);
}

function Page({ commentsPromise }) {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

读取 Context(支持 early return)

useContext 的痛点是不能在条件语句后调用use 解决了这个问题:

import { use } from 'react';
import ThemeContext from './ThemeContext';

function Heading({ children }) {
  if (children == null) return null;  // early return 了?
  const theme = use(ThemeContext);     // 照样能用 use
  return <h1 style={{ color: theme.color }}>{children}</h1>;
}

注意:use 不支持在 render 内部直接创建 Promise(会导致哨兵错误),需要从 Suspense 友好的框架或库获取已缓存的 Promise。


六、React Server Components:稳定支持,生产可用

React 19 包含了完整的 React Server Components(RSC) 能力,意味着:

  • 组件可以在服务器端运行一次(CI 构建时或每次请求时)
  • 服务器组件有零 bundle 体积(不会打到客户端 JS 里)
  • 可以直接在组件里 await 数据获取
// Server Component(服务器组件,默认支持)
async function ArticleList() {
  const articles = await db.query('SELECT * FROM articles');
  return (
    <ul>
      {articles.map(a => <li key={a.id}>{a.title}</li>)}
    </ul>
  );
}

配合 Server Actions(用 "use server" 标记的异步函数),可以做到从 Client Component 调用服务器端逻辑:

// 客户端调用服务器端函数,全程类型安全
async function updateName(name: string) {
  "use server";
  await db.update({ name });
}

七、其他值得注意的改进

改进 说明
ref 作为 prop 新版函数组件可以直接接收 ref 作为 prop,不再需要 forwardRef,未来会废弃 forwardRef
<Context> 作为 provider <Context.Provider> 可以直接写成 <Context value={...}>,旧的写法未来废弃
ref 回调支持清理函数 ref 回调可以返回清理函数,元素从 DOM 移除时自动调用
Hydration 错误更详细 报错信息直接显示服务器和客户端的 diff,不用再猜哪行出了问题
新的静态生成 API prerender / prerenderToNodeStream 改进静态站点生成,SSR 性能更好

八、React 19.2(2025年10月)更新速览

React 19.2 在 19 基础上新增了两个实验性功能:

  • Activity:类似 React 的"活动状态"追踪,适合实时协作类应用
  • React Performance Tracks:新的性能监控 API,帮助开发者精确分析组件渲染性能

这两个功能目前仍处于实验阶段,正式稳定支持尚需时间。


总结:React 19 解决了什么问题

React 19 的核心改进可以归结为两条线:

客户端:让表单、乐观更新、pending 状态这些常见模式开箱即用,不再需要 Copy/Paste 一堆样板代码。

全栈:Server Components + Server Actions 让前后端边界更清晰,同一个函数可以运行在服务器,返回结果给客户端,全程类型安全。

如果你的团队正在用 Next.js 14+ 或其他支持 RSC 的框架,React 19 的价值会非常明显。如果是纯客户端 React App,Actions + useOptimistic 也足以让表单开发体验提升一个档次。


参考来源

  • React 官方博客:react.dev/blog(2024/12/05)
  • React 19.2 Release Notes(2025/10/01)
  • React Conf 2025 Recap(2025/10/16)

【monorepo架构】前端 pnpm workspace详解

公众号:AI小揭秘

前端 pnpm workspace 架构详解

一篇帮你搞懂 pnpm workspace 的实战向教程,从「为啥要用」到「怎么配」全给你捋清楚;每个知识点都会讲清是什么、为什么、怎么用、注意啥,方便你系统学习、随时查阅、直接落地。


一、先聊聊:我们到底遇到了啥问题?

做前端久了,多包、monorepo、组件库联调这些事一多,就会踩到一堆具体又磨人的坑。下面把这些痛点拆开说:具体表现 → 典型场景 → 对你有啥影响。搞清楚这些,后面再看 pnpm workspace 解决啥就一目了然。

1.1 node_modules 膨胀,磁盘和时间都遭殃

具体表现:用 npm 搞 monorepo 时,根目录一个 node_modules,每个子包再来一个;或者多个独立项目各自一份。每个 node_modules 里,npm 会做扁平化:把子依赖提升到顶层,同一份包可能在不同项目的 node_modules 里各存一份,重复拷贝

典型场景:比如你有一个 monorepo,里面 5 个 app、3 个共享库,都用 React、lodash、一堆 Babel/Webpack 相关包。单项目 node_modules 可能就 400~600MB,monorepo 里再乘上包数量、加上提升带来的重复,轻松破 2GB。npm install 第一次全量装要几分钟,以后每次 npm ci 或清缓存重装,体感也很慢。

影响:占磁盘、拉代码慢、CI 缓存大、流水线耗时增加;本机多开几个项目,node_modules 动不动几十 GB。

1.2 依赖版本乱成一锅粥:幽灵依赖与冲突

幽灵依赖的定义:某个包没有在你自己的 package.jsondependencies / devDependencies 里声明,你却能在代码里 importrequire 到它。常见原因就是 npm 的扁平化:你装了 A,A 依赖 B,B 被提升到了项目根 node_modules,于是你的代码「意外」地能直接用 B。

典型场景:你习惯性 import _ from 'lodash',但从没在 package.json 里加过 lodash,因为它是某个依赖的子依赖,被提升上来了。后来你升级了那个依赖,人家不再依赖 lodash,或者换了版本,你这边没改一行业务代码就报错:找不到 lodash。更坑的是「本地能跑、CI 挂」:本地可能还有别的路径残留或缓存,CI 干净安装就炸。同理,删了某个你以为没用的依赖,结果别的地方一直隐式用着,一删就挂。

版本冲突:A 包要 React 18,B 包要 React 17,扁平化之后只能满足一边,另一边可能用了「不对」的版本,运行时才暴露问题,调试成本很高。

1.3 本地包联调贼麻烦:npm link 的坑

典型场景:你维护一个业务组件库,要在另一个前端项目里联调。通常做法是 npm link:在组件库目录 npm link,在业务项目里 npm link your-components。但经常会遇到:

  • 双实例问题:React、Vue 等对「单实例」有要求,link 过去可能出现两个版本,引发诡异 bug。
  • bin 路径:某些 CLI 或工具通过 node_modules/.bin 找可执行文件,link 后路径解析不对,跑不起来。
  • 不同 Node 版本 / 环境: link 的是「当时本机」的构建结果,换机器、换 Node、改点配置,行为可能不一致。

总之,改一下组件库就要反复 link、unlink、重装,体验很差,也容易忘步骤导致联调结果不可靠。

1.4 CI 又慢又占空间

典型场景:每次 CI 全量 npm install,没有跨项目或跨 job 的 store 复用;缓存 key 设计不当(例如只按 package.json 不按 lockfile),导致缓存命中率低,每次都几乎全量装。加上前面说的 node_modules 巨大,流水线耗时长、占用空间大,体验和成本都不好。


上面这些,本质都可以归为两类问题:一是多包怎么组织、怎么一起开发、怎么发布(项目结构 + 工作流);二是依赖怎么存、怎么解析、怎么隔离(存储与解析策略)。pnpm 的 workspace 就是在这两方面同时发力的方案之一:多包管理 + 更合理的依赖存储与解析。下面先把你可能最关心的——pnpm 底层是怎么干的——讲清楚,再回头看 workspace 具体解决了啥。


二、pnpm 底层原理:为啥能省空间、装得快、依赖还干净?

很多人只记住结论:「pnpm 省磁盘、快、没幽灵依赖」,但不知道它到底咋做到的。这一节把存储模型node_modules 结构说透,你后面看配置、看优缺点都会更有数。

2.1 全局 store:content-addressable + 硬链接

pnpm 有一个全局 store,所有安装过的包都会先放进这里,再通过硬链接挂到各个项目的 node_modules 里。

  • 存哪儿

    • Linux:默认 ~/.local/share/pnpm/store
    • macOS:默认 ~/Library/pnpm/store
    • Windows:默认 %LOCALAPPDATA%\pnpm\store(即 C:\Users\<你>\AppData\Local\pnpm\store
      若设置了 $XDG_DATA_HOME,Linux/macOS 会改用 $XDG_DATA_HOME/pnpm/store。可通过 .npmrcstore-dir 覆盖,例如 store-dir=D:\pnpm-store
  • content-addressable(按内容寻址)
    包在 store 里按内容哈希存,同一版本、同一份包只存一份。不同项目、不同 monorepo 子包,只要依赖的版本相同,都用这一份,去重、跨项目复用

  • 硬链接
    硬链接可以理解为「同一份文件的多个路径入口」,改一处全体生效,但不额外占磁盘。pnpm 从 store 把包硬链接到项目里的 node_modules/.pnpm/...,所以看起来每个项目都有一份,实际磁盘只存 store 里那一份。
    复制的区别:不占多余空间。和符号链接的区别:符号链接是「指向另一个路径」的小文件,硬链接是文件系统层面的多路径同一 inode,更省空间、也更稳定(删掉一个链接不会影响 store 里的那份,只要还有别的链接在)。

结果:同 monorepo、同样依赖,用 pnpm 时磁盘占用往往只有 npm 的一半左右(常见 benchmark 结论),二次安装时大量命中 store,pnpm install 明显更快。

2.2 node_modules 的真实结构:非扁平 + 严格依赖

npm 会把依赖扁平化提升到顶层,所以你能「意外」用到子依赖;pnpm 不这么做,结构是非扁平的。

目录结构示意(精简版):

  • 项目根目录的 node_modules/

    • 只放你直接声明的依赖(dependencies / devDependencies 里的包)。
    • 这些「包名」多数是符号链接,指向 node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>
  • node_modules/.pnpm/

    • 里面才是实际内容(或链到 store)。
    • 每个 package@version 一个目录,且每个包有自己的 node_modules,里面只装它自己的依赖
    • 子依赖不会提升到项目根 node_modules,所以你没法在业务代码里 require('某个未声明的子依赖')

严格依赖就是这样实现的:
只有package.json 里显式声明的包,才会出现在你项目的 node_modules 顶层(或子包自己的 node_modules 里)。未声明的包根本不在你可访问的路径下,require / import 会直接报错,从根上杜绝幽灵依赖

有些老旧工具会假设「所有依赖都在根 node_modules 扁平展开」,在 pnpm 默认结构下会找不到包。这时可以用 public-hoist-patternnode-linker=hoisted有限提升,相当于在「兼容旧工具」和「严格依赖」之间做权衡;提升多了,幽灵依赖风险又回来了,所以能窄就窄。

2.3 workspace 包怎么被链接进来?

当你在 package.json 里写 "@my/ui": "workspace:*" 时,pnpm 会:

  1. pnpm-workspace.yaml 定义的目录里找到对应包(如 packages/ui);
  2. 该包所在目录(源码目录)链接到 node_modules 里对应位置,不拷贝、不先打包

所以,你改 packages/ui 的源码,消费方(例如 apps/web立即可见,不用 npm link,也没有双实例、路径错乱那些破事。这就是 workspace 协议 带来的「本地包即源码」的联调体验。

2.4 和 npm / Yarn 的存储对比(简要)

  • npm:扁平化 + 每项目各自拷贝,多项目多份;易幽灵依赖;安装速度、磁盘占用都一般。
  • Yarn:经典模式类似 npm;Plug'n'Play 可选,但生态兼容性要看工具。
  • pnpm:全局 store + 硬链接 + 非扁平 node_modules,省空间、安装快、默认严格依赖。

差异主要在存储与解析策略,而不是「有没有 workspace」这个概念。


三、pnpm workspace 解决了什么问题?(深化版)

有了第二节的原理打底,这里直接说 workspace 在「多包管理」场景下,具体帮你解决了啥;每个点都往「能用、能查」上靠。

3.1 磁盘与安装

  • store + 硬链接:全 workspace 共享同一 store,同版本依赖只存一份;子包、apps 装依赖都是链过去,磁盘占用明显低于 npm 同规模 monorepo(约一半量级的说法很常见)。
  • workspace 包不占 store:像 @my/utils@my/ui 这种本地包,pnpm 只做链接到源码目录,不往 store 里塞,也不拷贝,改完即生效。
  • 安装速度pnpm install 在 monorepo 里通常比 npm install 快不少,尤其二次安装、CI 命中 store 时。

3.2 依赖隔离与一致性

  • 幽灵依赖
    pnpm 默认严格依赖,未声明就不能用。你刻意避免隐式依赖,配合 code review,能从根本上消灭「删了某依赖突然挂」「本地有 CI 没有」这类问题。
    若必须兼容旧工具,再考虑 public-hoist-pattern 有限提升,并清楚这会带来隐性依赖风险。

  • 版本统一

    • 单一 lockfile:整个 workspace 只有一个 pnpm-lock.yaml 在根目录,所有子包、所有环境的依赖解析都以它为准,版本全仓库一致,复现性高。
    • catalog(pnpm 9+):在 pnpm-workspace.yaml 里定义 catalog,给常用依赖约定版本(如 react: ^18.3.1),子包用 catalog: 引用,升级时只改一处,避免各包各自为政。
    • overrides:根 package.json 里可配 pnpm.overrides,强制某依赖在全 workspace 解析成指定版本,适合解决传递依赖冲突、安全修复等。

3.3 多包协作与发布

  • 统一装依赖、统一跑脚本:根目录一次 pnpm install,所有 workspace 包依赖都装好;用 pnpm -r run buildpnpm --filter ... 批量或定向跑脚本,配合根 package.jsonscripts,协作流程清晰。
  • 按需发布pnpm publish -r 可递归发布,结合 --filter 只发布改动的包;配合 changesets 做 version + changelog + publish,适合多包独立发版。
  • 权限与发包:可以按包名、按目录做 access 控制,和现有 npm registry 权限模型配合使用。

四、pnpm workspace 架构长什么样?

4.1 目录树与职责

下面是一个常见的 pnpm workspace 根目录结构,以及各部分的职责。

项目根目录
├── pnpm-workspace.yaml    # 声明哪些目录是 workspace 包(唯一、仅根目录)
├── package.json           # 根包:公共 devDependencies、批量脚本、overrides 等
├── pnpm-lock.yaml         # 全 workspace 唯一 lockfile,所有人、CI 共用一个
├── .npmrc                 # 可选:store-dir、node-linker、hoist 等
├── packages/
│   ├── ui/                # 如:组件库
│   ├── utils/             # 公共工具
│   ├── config-eslint/     # 共享 ESLint 配置
│   └── ...
└── apps/
    ├── web/               # 前端应用
    ├── docs/              # 文档站
    └── ...
  • package.json

    • 全仓库共用的 devDependencies(如 TypeScript、ESLint、Vitest、Prettier)。
    • 定义 scripts,用 pnpm -r--filter 批量或定向执行子包的 build、dev、test。
    • 根包通常 "private": true,不发布;可加 packageManagerpnpm.overrides 等。
  • pnpm-workspace.yaml

    • 唯一,只能放在根目录。
    • 通过 packages 数组声明哪些目录算 workspace 包(如 packages/*apps/*),只有这些才能被 workspace:* 引用。
    • pnpm 官方推荐用这个文件,而不是 package.jsonworkspaces 字段。
  • pnpm-lock.yaml

    • 全 workspace 共用一个,在根目录。
    • 锁死所有依赖(含 workspace 包解析结果),保证任意环境 pnpm install 结果一致。
  • packages/*

    • 一般放可复用库:组件库、工具库、配置包等。
    • 各自有 package.json,通过 workspace:* 相互依赖或被 apps/* 依赖。
  • apps/*

    • 一般放应用:前端项目、文档站、Demo 等。
    • 依赖 packages/* 时用 workspace:*,改库即生效。

有的项目还会加 tools/* 放脚本、CLI 等,本质上一样:在 pnpm-workspace.yaml 里写上对应 glob 即可。

4.2 命名与布局约定

  • packages:可复用、可能发布到 npm 的库;apps:入口应用、不发布或只发构建产物。
  • 何时拆 apps?当你明确有「多个应用 + 共享 packages」时,拆开更清晰;只有一两个 app 时,全放 packages 也没问题,按团队习惯来。
  • 依赖方向
    • 子包互相依赖、app 依赖子包,一律用 workspace:*
    • 禁止循环依赖(A 依赖 B,B 又依赖 A),否则安装、构建都会出问题。
    • 根包通常作为业务依赖,只提供脚本和公共 devDependencies。

4.3 workspace 包的解析与匹配机制

靠啥匹配?
pnpm 解析 workspace:* 时,只看 package.json 里的 name,和目录名、路径都无关。你写 "@my/ui": "workspace:*",pnpm 就会在 pnpm-workspace.yaml 声明的那堆目录里,找 name@my/ui 的包;找到就把该包所在目录链进 node_modules,找不到就直接报错,不会悄悄去 npm 装一个。

具体流程

  1. pnpm-workspace.yaml,收集所有匹配 packages 的目录(如 packages/*apps/*);
  2. 逐个读这些目录下的 package.json,拿到 name,建成一张 「name → 目录」 的映射;
  3. 解析依赖时,遇到 workspace:*workspace:^ 等,用依赖里的包名去这张表里查;
  4. 查到了 → 用该包所在目录做链接目标,链到当前包的 node_modules 里;
  5. 查不到 → 报错(例如 ERR_PNPM_NO_MATCHING_PACKAGE),安装中止。

所以:包名必须和依赖里写的一模一样packages/uiname 要是 @my/ui,别的地方才能 "@my/ui": "workspace:*";写成 @my/components 就匹配不上。

几种写法

  • workspace:*:匹配 workspace 里同名包的任意版本,并链到源码目录;开发联调最常用。
  • workspace:^workspace:~:按 semver 匹配 workspace 内版本;发布时会被替换成具体版本号(如 1.0.0),发布出去的 package.json 里不会还带着 workspace:
  • workspace:../packages/utils(相对路径):明确指向某个目录,不靠 name 匹配;适合临时调试或路径敏感的布局。

别名
可以用 "别名": "workspace:真实包名@*" 把 workspace 包挂到另一个名字下,例如 "react": "workspace:my-react@*"。发布时同样会替换成普通依赖形式。

找不到会怎样?
只会报错,不会回退到 npm 装。这样你才能确定:用的一定是本地的 workspace 包,没有误用远端的。

4.4 依赖图与构建顺序

workspace 里包和包之间的依赖关系,会形成一张有向图:谁依赖谁,一目了然。pnpm 跑 pnpm -r run build 这类递归命令时,默认按这张图的拓扑顺序执行:先跑被依赖的,再跑依赖别人的,避免「还没 build 完就被别人 require」的坑。

拓扑顺序是啥?
简单说:若 A 依赖 B,则一定执行 B 的 build执行 A 的 build。例如 utilsuiweb,顺序就是 utilsuiweb。同一层之间(比如多个 app 互不依赖)谁先谁后不保证,但层级不会乱。

默认行为

  • pnpm -r run build(以及 pnpm -r run <script>):按依赖图拓扑排序,再依次执行;没有 -r 时则只跑当前包。
  • pnpm -r --parallel run build不管顺序,所有包并行跑;跑 devtest 时常用 --parallel,但 build 一般要保证顺序,所以慎用 --parallel

怎么知道谁依赖谁?

  • 看各包 package.jsondependencies / devDependencies 里对 workspace 包、普通包的引用;
  • pnpm why <pkg> 看某包被谁依赖;pnpm list -r 看全 workspace 的依赖树(注意 list 默认不按拓扑序,按字母序);
  • 有些团队会接 Turborepo、Nx 等,用它们画依赖图、跑拓扑并行 build(同一层并行,层与层之间仍按依赖顺序)。

循环依赖
若出现 A → B → C → A,依赖图成环,拓扑排序搞不定,pnpm 会报错;安装、-r 执行都可能挂。所以必须保证 workspace 内无环,设计时就要避免「包互相依赖」。

4.5 安装与打包:workspace 如何工作

安装(pnpm install
根目录执行 pnpm install 时,大致会做这几步:

  1. 读 workspace 定义:解析 pnpm-workspace.yaml,得到所有 workspace 包目录(如 packages/*apps/*)。
  2. 收集包信息:逐个读这些目录下的 package.json,建 name → 目录 映射,并算出整棵依赖树(含对 npm 包的依赖)。
  3. 解析 workspace:*:遇到 workspace:* 等,按 4.3 的规则匹配到本地包目录,从 registry 拉包。
  4. 链接 workspace 包:把匹配到的本地包目录链到各包的 node_modules 里(符号链接或 junction),不拷贝、不往 store 塞;改源码立即生效。
  5. 装外部依赖:对 npm 上的包,按平时那套来:store + 硬链接,装到 node_modules/.pnpm 等位置。
  6. 写 lockfile:把所有依赖(含 workspace:* 的解析结果)写入根目录的 pnpm-lock.yaml

所以:workspace 包只做链接,不占 store;占磁盘、耗时的主要是外部依赖,而它们仍走 store 复用。

打包 / 构建(pnpm -r run build
构建改依赖安装方式,只是按依赖图顺序跑各包的 build 脚本:

  1. 算依赖图:根据各包 package.json 的依赖关系,得到有向图。
  2. 拓扑排序:排出「被依赖的在前、依赖别人的在后」的顺序(pnpm 内部用类似 graph-sequencer 的方式处理)。
  3. 依次执行:按该顺序对每个 workspace 包执行 pnpm run build(或你配的其它 script)。
  4. 若某包没有 build 脚本,pnpm 会报错或跳过该包,视配置而定。

因此:先装依赖,再构建;装依赖保证 node_modules 里 workspace 包、npm 包都就位,构建则按依赖顺序生成各包产物。
若用 --parallel,pnpm 会忽略拓扑顺序,所有包一起跑;适合 devtest 等不严格要求「被依赖的先跑」的场景,但 build 一般别开 --parallel,否则可能用到尚未 build 的依赖。

和 Turborepo / Nx 的关系
pnpm 只负责依赖安装 + 按拓扑序跑 script缓存、增量构建、远程缓存等,可交给 Turborepo、Nx。通常做法是:pnpm 管 install 和 workspace 链接,Turbo/Nx 管 build / test 的调度与缓存,两者一起用没问题。


五、优缺点一览(够直白版)+ 逐条详解

5.1 优点总览

说明
省磁盘、安装快 全局 store + 硬链接,避免重复存包;workspace 包用链接,不复制。
依赖干净 严格依赖,无幽灵依赖;lockfile 唯一,版本一致。
本地联调友好 workspace:* 直接链到源码,改即生效,无需 npm link
monorepo 友好 内建 workspace 支持,-r--filter 过滤、并行跑脚本很方便。
易于做权限与发布 配合 pnpm publish -r、changesets 做按包发布、权限控制。

详细说明

  • 省磁盘、安装快:原理即第二节的 store + 硬链接;workspace 包不进 store,只做链接。典型收益是 monorepo 磁盘占用和 pnpm install 耗时明显下降。
  • 依赖干净:严格依赖 + 单一 lockfile,少很多「删了某包就挂」「本地有 CI 没有」的玄学问题;注意若用了 public-hoist-pattern 等,要控制范围,否则又引入隐性依赖。
  • 本地联调:改 packages/ui 立刻在 apps/web 里生效,无需 link;注意跑 dev 的终端要在根目录或对应 app 目录,且已执行过根目录的 pnpm install
  • monorepo 友好pnpm -r--filter 能力足,再配合 Turborepo/Nx 做任务编排、缓存,体验更好。
  • 发布:按包发布、changesets 管理版本与 changelog,和现有 registry 流程兼容。

5.2 缺点 / 注意点总览

说明
和 npm 不完全兼容 部分工具假设「所有依赖扁平在根 node_modules」,可能报错,需适配。
学习与迁移成本 团队要搞懂 workspace、workspace:*pnpm-workspace.yaml--filter 等。
部分旧工具兼容性 极端老旧的构建/调试工具对 pnpm 的 node_modules 结构可能不友好。
需统一包管理 全 repo 必须用 pnpm,不能混用 npm/yarn,否则 lockfile、链接会乱。

详细说明

  • 和 npm 不完全兼容
    有些 Webpack 插件、老版 Babel、个别 CLI 会直接去根 node_modules 找包,pnpm 默认非扁平就可能找不到。处理办法:

    • node-linker=hoisted.npmrc)切回类 npm 扁平结构,会牺牲严格依赖;
    • 或只用 public-hoist-pattern 把有问题的包提升上来,尽量窄配。
  • 学习与迁移成本
    团队至少要会:workspace 概念、pnpm-workspace.yamlworkspace:* 协议、根目录 pnpm install--filter-r 的用法。可以抽半小时过一遍本文 + 官方文档,再在试点项目跑一遍。

  • 旧工具兼容性
    建议先小范围试点,遇到具体工具再查 pnpm 兼容性 或社区 issue;大多数现代前端工具已支持。

  • 统一包管理
    全仓库只用 pnpm,禁止 npm install / yarn。用 packageManager 锁版本,CI 里 corepack enable && pnpm install,避免有人用错包管理器导致 lockfile 或链接关系错乱。

适合:中大型前端项目、组件库 + 多应用、多包复用的 monorepo。
不大适合:单应用、没有多包复用需求的小项目;用 pnpm 单仓也能受益,但 workspace 收益有限。


六、应用场景(什么时候上 workspace?)

下面按场景拆:谁用、解决啥问题、推荐结构、关键配置、日常工作流。你对照自己项目,能直接套用或微调。

6.1 UI 组件库 + 多个业务项目

场景:你们有一个业务组件库,要同时支撑 2~3 个前端项目;组件库频繁迭代,需要在各项目里即时验证,而不是先发 npm 再装。

推荐结构

packages/
  ui/           # 组件库
apps/
  web-admin/
  web-h5/
  web-docs/     # 组件文档

web-adminweb-h5web-docs 都依赖 @my/ui,用 workspace:*

关键配置

  • pnpm-workspace.yamlpackages: ['packages/*', 'apps/*']
  • 各 app 的 package.json"@my/ui": "workspace:*"
  • scripts:如 "dev:docs": "pnpm --filter web-docs run dev""build:ui": "pnpm --filter @my/ui run build"

工作流
packages/ui → 在 apps/web-docs 或任意 app 里直接看效果;要发版时用 changesets 给 @my/ui 打 version、写 changelog、publish,各 app 再决定何时把 workspace:* 换成固定版本(若你们发 npm 的话)。

6.2 多应用 + 公共 utils / config

场景:多条产品线、多个前端应用,共享 utilsapi-clienteslint-config 等,希望统一版本、统一升级

推荐结构

packages/
  utils/
  api-client/
  config-eslint/
apps/
  app-a/
  app-b/

apps 按需依赖 @my/utils@my/api-clientconfig-eslint 被各 app 的 devDependencies 引用。

关键配置

  • pnpm-workspace.yaml:同上。
  • 各包用 workspace:* 互引;根 package.json 可放公共 devDependencies,或用 catalog 统一 React、TypeScript 等版本。
  • 根脚本:"build": "pnpm -r --filter './apps/*' run build",只构建 apps。

工作流
公共逻辑在 packages/* 改,各 app 自动用到;发版用 changesets 按包发布,各 app 通过 workspace:* 或固定版本消费。

6.3 文档站 + 组件库

场景:组件库配套一个文档站(如 VitePress、Docusaurus),文档站要直接引用源码里的组件做 Demo,而不是已发布的 npm 包。

推荐结构

packages/
  ui/
apps/
  docs/

docs 依赖 @my/uiworkspace:*

关键配置

  • 同上,packages + appsdocs"@my/ui": "workspace:*"
  • 文档站构建配置里保证能解析 packages/ui 的源码(通常 workspace 链接后没问题)。

工作流
改组件 → 跑 docs 的 dev,文档里实时看效果;发版时先发 @my/ui,再更新文档站里对版本的说明(若文档站自己也要发)。

6.4 全栈 monorepo(前后端同仓)

场景:前端 + Node 服务同仓,共享类型、常量或少量 utils,用同一套依赖管理。

推荐结构

packages/
  types/
  shared-utils/
apps/
  web/
  api/          # Node 服务

apiweb 都依赖 @my/types@my/shared-utilsworkspace:*

关键配置

  • pnpm-workspace.yaml 包含 packages/*apps/*
  • package.jsonscripts 里分别 --filter web--filter api 跑 dev/build。

工作流
typesshared-utils,前后端同时生效;各自部署时只构建对应 app,公共逻辑通过 workspace 链进去。


只要你存在「多个包 + 互相依赖 + 要一起开发」的需求,workspace 就很值得上;上面四种可以组合,比如「组件库 + 多应用 + 文档站」一起做。


七、详细教程:从零搭一个 pnpm workspace

下面按步骤做一遍,每步会写操作、预期结果、常见报错与排查。路径、包名和上文保持一致,你照抄就能跑通。

7.1 环境准备

  • 安装 pnpm

    npm install -g pnpm
    

    或用 Corepack(Node 16.9+):

    corepack enable
    corepack prepare pnpm@latest --activate
    

    建议用 pnpm 8.x 或 9.x,Node 18+ 更省心。

  • 校验

    pnpm -v
    node -v
    

    看到版本号即成功。

7.2 初始化根项目

mkdir my-workspace && cd my-workspace
pnpm init

会生成根目录 package.json。编辑成类似:

{
  "name": "my-workspace",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@9.0.0"
}
  • private: true:根包不会被 pnpm publish 发出去,避免误发。
  • packageManager:锁死 pnpm 版本,配合 corepack enable 使用;可选但推荐。

7.3 配置 pnpm-workspace.yaml

项目根目录新建 pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'apps/*'
  • packages/*packages/ 下每个子目录(如 packages/uipackages/utils)都算一个 workspace 包。
  • apps/*:同理。
  • 只有被列出来的目录才会被 pnpm 当成 workspace 成员,才能被 workspace:* 引用。

预期:保存后暂无输出;之后 pnpm install 时 pnpm 会扫描这些目录。

7.4 创建子包目录并初始化

mkdir -p packages/ui packages/utils apps/web

然后逐个初始化(Windows 用户可用 PowerShell,mkdir -p 若不可用就分步 mkdir):

cd packages/utils && pnpm init && cd ../..
cd packages/ui   && pnpm init && cd ../..
cd apps/web      && pnpm init && cd ../..

Windows:若 mkdir -p 报错,可改为 mkdir packages\uimkdir packages\utilsmkdir apps\web 等分步创建;cd ../.. 在 PowerShell 中同样适用。)

每个子包会多一个 package.json。接下来改包名、入口、exports

packages/utils/package.json

{
  "name": "@my/utils",
  "version": "0.0.1",
  "main": "index.js",
  "exports": {
    ".": "./index.js"
  }
}

packages/ui/package.json

{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "index.js",
  "exports": {
    ".": "./index.js"
  }
}

apps/web/package.json

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "echo \"dev placeholder\"",
    "build": "echo \"build placeholder\""
  }
}
  • exports:现代 Node 和打包器都认,用来明确入口,避免多余文件被引用;对 ESM、TS 等更友好。
  • webdev/build 先占位,后面验证完 workspace 再换成真实命令。

7.5 用 workspace:* 做包间依赖

packages/ui/package.json 里加依赖 @my/utils

{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "index.js",
  "exports": { ".": "./index.js" },
  "dependencies": {
    "@my/utils": "workspace:*"
  }
}

apps/web/package.json 里加依赖 @my/ui

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "echo \"dev placeholder\"",
    "build": "echo \"build placeholder\""
  },
  "dependencies": {
    "@my/ui": "workspace:*"
  }
}

workspace:* 表示「用当前 workspace 里的同名包,追踪源码」;装完依赖后会链接到对应包目录,改代码即时生效。

7.6 根目录执行 pnpm install

务必在根目录执行(若不在根目录,先 cd 到项目根):

pnpm install

预期

  • 根目录出现 node_modules/pnpm-lock.yaml
  • packages/uiapps/webnode_modules 里会有 @my/utils@my/ui 的链接;
  • lockfile 里能看到对 workspace: 的解析,例如:
packages:
  '@my/utils@workspace:*':
    resolution: { directory: packages/utils, type: directory }
  '@my/ui@workspace:*':
    resolution: { directory: packages/ui, type: directory }

(省略其他字段;实际 lockfile 还有 nameversion 等。)

若报 ERR_PNPM_NO_MATCHING_PACKAGE:检查 pnpm-workspace.yamlpackages 是否包含对应目录,以及子包 name 是否和依赖里写的一致。

7.7 根 package.json 里加批量脚本

根目录 package.json 增加:

{
  "name": "my-workspace",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "dev": "pnpm -r --parallel run dev",
    "build": "pnpm -r run build",
    "build:web": "pnpm --filter web run build"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}
  • pnpm -r:递归在所有 workspace 包里执行同名 script。
  • pnpm -r --parallel:并行跑,适合 dev
  • pnpm --filter web run build:只对 web 包执行 build

Windows:若使用 PowerShell,scripts 里的双引号、&& 等和 Unix 略有差异,一般上述写法没问题;若遇解析错误,可改为 node 跑一小段脚本封装命令。

7.8 验证 workspace 链路

  • packages/utils/index.js 写:
module.exports = { add: (a, b) => a + b };
  • packages/ui/index.js 写:
const { add } = require('@my/utils');
module.exports = { add, hello: 'from ui' };
  • apps/web 里加个临时脚本验证。给 apps/web/package.jsonscripts 增加一行 "run:check",例如:
"scripts": {
  "dev": "echo \"dev placeholder\"",
  "build": "echo \"build placeholder\"",
  "run:check": "node -e \"const x=require('@my/ui'); console.log(x.add(1,2), x.hello)\""
}

保存后,在根目录执行:

pnpm --filter web run run:check

预期输出3 'from ui'
Cannot find module '@my/ui'

  • 确认在根目录执行过 pnpm install
  • 确认 apps/webdependencies 里有 "@my/ui": "workspace:*"
  • 看看 apps/web/node_modules/@my 下是否有 ui 的链接。

ENOENT 等路径类错误:

  • 检查 packages/utilspackages/ui 是否有 index.js,以及 package.jsonmain / exports 是否指向它。

验证通过后,可以把 webdev / build 换成真实命令(如 Vite、Next 等),继续开发。


八、配置说明(可查阅手册)

这一节把 pnpm workspace 相关配置 拆开讲:每项是啥、怎么配、适用场景、注意点。方便你以后查。

8.1 pnpm-workspace.yaml

  • 唯一性:整个仓库只放一个在根目录;pnpm 只认根目录这份。
  • packages
    • 字符串数组,每个元素是一个 glob 或具体路径。
    • 例:'packages/*''apps/*''tools/*',或 'packages/ui''packages/utils'
    • 只有匹配到的目录且其中包含 package.json,才会被当作 workspace 包。
  • 排除:部分版本支持 ! 排除,如 !'packages/legacy/*',以你用的 pnpm 文档为准。
  • package.jsonworkspaces:pnpm 官方推荐用 pnpm-workspace.yaml 定义 workspace,不用 workspaces 字段;若同时存在,以 pnpm-workspace.yaml 为准。

示例

packages:
  - 'packages/*'
  - 'apps/*'
  - 'tools/*'

8.2 根目录 package.json

  • private: true:根包不发布,避免误 pnpm publish
  • packageManager:如 "pnpm@9.0.0",锁包管理器 + 版本;需 corepack enable
  • scripts:结合 pnpm -r--filter 做批量或定向执行(见 8.6)。
  • pnpm.overrides:强制某依赖在全 workspace 解析成指定版本。
    {
      "pnpm": {
        "overrides": {
          "lodash": "4.17.21"
        }
      }
    }
    
    装依赖时 pnpm 会按 overrides 解析,并反映在 lockfile;适合修安全漏洞、解决传递依赖冲突。
  • catalog(pnpm 9+):在 pnpm-workspace.yaml 里定义(不是 package.json),子包用 catalog: 引用;见下方示例。

catalog 示例pnpm-workspace.yaml):

packages:
  - 'packages/*'
  - 'apps/*'

catalog:
  react: ^18.3.1
  react-dom: ^18.3.1

子包 package.json

{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

升级时只改 catalog 即可,所有用 catalog: 的包一起变。

8.3 workspace: 协议

  • workspace:*:用当前 workspace 里同名包任意版本,并链接到源码目录。开发联调默认用这个。
  • workspace:^workspace:~:按 semver 匹配 workspace 内版本;发布时 pnpm 会把它替换成实际版本号(如 1.0.0),所以发布到 npm 的包不会还带着 workspace:
  • 锁文件里的表现
    '@my/ui@workspace:*':
      resolution: { directory: packages/ui, type: directory }
    
    表示解析为本地 packages/ui 目录。

日常开发 workspace:* 就够用;若你们有严格的 semver 约束再考虑 ^ / ~

8.4 pnpm-lock.yaml

  • 唯一:整份 workspace 共用一个 lockfile,放在根目录。
  • 内容:锁住所有依赖(含 workspace 解析结果)的版本、完整性校验等。
  • 维护:用 pnpm installpnpm add 等变更依赖,不要手改
  • CI:务必把 pnpm-lock.yaml 纳入 git;CI 里 pnpm install --frozen-lockfile 可保证和 lockfile 完全一致,复现构建。

8.5 .npmrc(项目级)

放在项目根目录,只影响当前仓库。

常见项:

配置项 含义 示例
store-dir 全局 store 路径 store-dir=D:\pnpm-store
node-linker 链接方式 isolated(默认)/ hoisted
hoisted 已废弃,用 node-linker
public-hoist-pattern 哪些包提升到根 node_modules public-hoist-pattern[]=*eslint*
shamefully-hoist 全部提升,类似 npm true,易幽灵依赖,慎用
auto-install-peers 自动装 peerDependencies true
strict-peer-dependencies peer 未满足时报错 true
  • node-linker=hoisted:切回类 npm 扁平结构;兼容性好,但失去严格依赖。
  • public-hoist-pattern:只把匹配的包提升,例如 ESLint、Prettier 等工具常见需求;能窄就窄,减少幽灵依赖。
  • resolution-mode:依赖解析策略(如 lowest-direct);lockfile-include-tty 等可按需查文档。

示例(只提升部分工具):

public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

8.6 --filter 完整语法

--filter 用来限定要对哪些 workspace 包执行命令,常与 pnpm -rpnpm add 等一起用。

写法 含义 示例
--filter <pkg> 指定包(按 name 或路径) pnpm --filter web run build
--filter <pkg>... pkg 以及依赖了 pkg 的所有包(dependents) pnpm -r --filter '@my/ui...' run build
--filter ...<pkg> pkg 以及 pkg 依赖的所有包(dependencies) pnpm -r --filter '...web' run build
--filter ...^<pkg> 依赖了 pkg 的包,不含 pkg 自身 pnpm -r --filter '...^@my/ui' run test
@scope/* 通配,所有 @scope 下包 pnpm -r --filter '@my/*' run build

示例

# 只给 web 装 lodash
pnpm add lodash --filter web

# 只给名字匹配 @my/* 的包跑 build
pnpm -r --filter '@my/*' run build

# 只给依赖了 @my/ui 的包跑 test(不含 @my/ui 自身,例如 web、docs)
pnpm -r --filter '...^@my/ui' run test

# 只给 web 及其依赖的 workspace 包跑 build(含 web 自身)
pnpm -r --filter '...web' run build

多 filter 可组合,例如 --filter '@my/ui...' --filter web 表示满足任一条件的包。仅要「依赖了某包」的包且排除该包本身时,用 ...^<pkg>

8.7 依赖提升(hoisting)

  • 默认:pnpm 不提升,依赖装在各自包的 node_modules.pnpm 下,严格隔离。
  • public-hoist-pattern:把匹配的包额外提升到根 node_modules,方便某些工具查找;提升范围越大,幽灵依赖风险越高。
  • shamefully-hoist:几乎全部提升,和 npm 类似;不推荐,除非你只是临时兼容旧工具。

对比

  • 不提升:根 node_modules 只有直接依赖,子依赖在 .pnpm 里,严格。
  • 提升后:根 node_modules 会出现被提升的包,未声明也可能被引用,所以要想清楚再开。

8.8 只用 pnpm / 锁包管理

  • 全仓库统一用 pnpm,禁止 npmyarn,否则 lockfile 和链接会乱。
  • package.jsonpackageManager,如 "pnpm@9.0.0"
  • 启用 Corepackcorepack enable;CI 里先 corepack enablepnpm install,保证版本一致。

九、和 npm / Yarn workspace 的简单对比

能力 npm workspaces Yarn workspace pnpm workspace
磁盘占用 高,多份拷贝 一般 低,store+硬链接
安装速度 一般 较快
node_modules 结构 扁平 扁平或 PnP 非扁平,.pnpm
幽灵依赖 易出现 默认严格,无
lockfile 格式 package-lock.json yarn.lock pnpm-lock.yaml
workspace 协议 workspace:* workspace:* workspace:*
配置方式 package.json workspaces package.json workspaces pnpm-workspace.yaml
filter/scripts 无内置 filter 有 workspaces 脚本 -r--filter
CI 缓存友好度 一般 较好 好(store 可复用)

何时选 pnpm workspace

  • 你打算认真搞 monorepo、多包复用,且关注磁盘、安装速度、依赖干净。
  • 愿意统一用 pnpm,并接受一点学习与迁移成本。

何时继续用 npm / Yarn

  • 现有 npm/Yarn 脚本、CI 已经很成熟,团队不想动。
  • 单仓库、包很少,workspace 收益有限,用 pnpm 单仓也不错,不必非上 workspace。

pnpm 的差异主要来自存储与解析策略,而不是「有没有 workspace」本身。


十、进阶与延伸

10.1 发版:按包发布 + changesets

  • pnpm publish -r:递归发布所有 未 private 的 workspace 包;可加 --filter 只发改动的,例如先 pnpm -r --filter '@my/ui...' run buildpnpm publish -r --filter '@my/ui'
  • changesets
    • changeset 管理 version bumpchangelog
    • 流程大致:改代码 → pnpm changeset 选包、选版本类型、写 changelog → pnpm changeset version 更新版本号 → pnpm publish -r 发布。
      这样多包独立发版、可追溯,很常见。

10.2 任务编排:Turborepo / Nx

  • package.jsonbuilddev 等可以交给 TurboNx 跑:他们按依赖图做拓扑排序,只跑该跑的,且能做远程/本地缓存,加速 CI 和本地构建。
  • pnpm workspace 只负责依赖安装与链接;Turborepo/Nx 负责任务调度,两者配合良好。

10.3 参考


十一、小结与 FAQ

11.1 小结

  • 问题:多包重复安装、幽灵依赖、本地联调麻烦、CI 又慢又占空间 → 本质是多包管理 + 依赖存储/解析没做好;pnpm workspace 针对这两点设计。
  • 原理:全局 store + 硬链接省空间、提速;非扁平 node_modules + 严格依赖防幽灵依赖;workspace 包链到源码,改即生效。
  • 架构:根 pnpm-workspace.yaml + 根 package.json + 唯一 pnpm-lock.yaml + packages/* / apps/*;子包用 workspace:* 互引,禁止循环依赖。
  • 配置:弄清 pnpm-workspace.yaml、根 package.jsonworkspace: 协议、.npmrc 常用项、--filter 用法即可上手。
  • 建议:按第七节亲手搭一遍,再在一个小项目里拆一个 utils 包用 workspace:* 引用,跑几天 dev/build,体感会很明显;后续再接 changesets、Turborepo 等。

11.2 FAQ

Q:子包的依赖装到根还是装到各自包?
A:各自 package.json 里声明,各自装;pnpm 会把实体放在 store、在对应包的 node_modules/.pnpm 下链接。根 package.json 只放全仓库共用的 devDependencies(如 TS、ESLint)和脚本。

Q:workspace:* 发布到 npm 前要改吗?
A:不用pnpm publish 时会把 workspace:* 等替换成实际版本号再发布,发布出去的 package.json 里是普通版本范围。

Q:Windows 下路径或脚本有问题怎么办?
A:

  • 路径尽量别带中文、空格;store-dir 等用正斜杠或系统可识别的形式。
  • 若在 PowerShell 里 scripts 报错,可试着用 node 写一个小脚本封装 pnpm -r / --filter 等命令,再在 scripts 里调该脚本。
  • 全局 pnpm、Node 建议用官方安装包或 nvm-windows,避免权限、路径异常。

如果你有具体的目录结构或 package.json 想优化,可以贴出来,按你现在的项目一步步改也行。

【Node】操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

公众号:AI小揭秘

Node.js 操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

你以为 fs.readFile 是让 Node 帮你「拿一下文件」?不,其实是:你下单 → 前台记单 → 后厨线程池做菜 → 做好了再叫你。这篇文章带你看看这份「外卖」是怎么从磁盘端到你手里的。


一、先别急着写代码:为什么你要关心「底层」?

很多人学 Node.js 的 fs 模块,会背两句口诀就收工:「用异步别用同步」「大文件用 Stream」
背完发现:

  • 为什么我 readFile 读个 10GB 的日志直接 OOM?
  • 为什么说 Node 是单线程,但一堆文件操作时还是会「卡」?
  • fs.promisesfs.readFile 回调版,底层是不是同一套?

(补充一句:文件 I/O 本身是等磁盘,不会占满 CPU;你觉得「卡」多半是线程池被占满,新任务在排队。)

要回答这些,就得知道:你的 JS 代码 → Node 的 C++ 绑定 → libuv → 操作系统 → 磁盘,这条链上每一环在干什么。
知道之后,你选 API、调参数、排查性能问题,都会心里有数——而不是靠「玄学调参」。

所以这篇东西的目标很简单:用尽量人话 + 一点幽默,把 Node.js 操作磁盘文件的底层原理讲清楚,顺便带上能跑的示例。


二、从你敲下 fs.readFile 开始:调用链长什么样?

你写的可能是:

const fs = require('fs');
fs.readFile('/tmp/hello.txt', (err, data) => {
  if (err) throw err;
  console.log(data.toString());
});

在底层,大概发生了这些事(简化版):

  1. JavaScript 层
    fs.readFile 是 Node 内置模块 fs 上的方法,实现里会做路径解析、编码处理、以及「把回调塞进某个流程里」。

  2. C++ 绑定层(Node 的 node_file.cc 等)
    JS 调用的其实是 C++ 里封装好的函数。这里会:

    • 把路径、回调、选项等转成 C++ 能用的东西;
    • 调 libuv 的 API,发起「异步文件读请求」。
  3. libuv 层
    libuv 是 Node 用来抽象「异步 I/O」的 C 库,跨平台(Windows / Linux / macOS 都靠它)。
    文件 I/O,它一般不会用 epoll/kqueue 这种「纯事件」机制,而是:
    把实际读文件的工作丢进「线程池」,在池里某条线程里做阻塞式的 read。
    所以:你以为的单线程,只是 JS 执行单线程;文件读写是在别的线程里阻塞地干的。

  4. 操作系统 → 磁盘
    线程池里的线程调的就是 OS 的 read(或类似)系统调用,由内核去和磁盘驱动、块设备打交道,把数据从磁盘读到内核缓冲区,再拷到用户态(Node 的 Buffer)。

  5. 回到 JS
    读完后,libuv 在某个时机(下一次事件循环的 I/O 阶段)把结果和你的回调塞回主线程,于是你的 (err, data) => { ... } 被调到了,data 就是那个 Buffer。

一句话:fs.readFile = 你在 JS 里下单 → Node 通过 libuv 把「读文件」这个任务派给线程池 → 线程池里的线程阻塞地读磁盘 → 读完再通过事件循环把结果回传给 JS。
所以「Node 单线程」指的是 JS 只在一个线程跑,磁盘 I/O 并不在主线程上阻塞,而是在线程池里。


三、事件循环与 libuv:谁在真正「干活」?

Node 的事件循环(event loop)是由 libuv 实现的。
和文件相关的部分可以粗分为:

  • Poll 阶段:等 I/O(网络、部分原生异步 API 等)。
  • 线程池完成回调:文件 I/O 在池里做完后,会在合适的阶段把「完成」事件插回事件循环,从而执行你传的 callback 或 resolve Promise。

所以:

  • 主线程(跑 JS 的那条):只负责执行你的 JS、跑定时器、处理已完成 I/O 的回调,不直接去读磁盘
  • 真正摸磁盘的:是 libuv 的线程池里那几条 worker 线程(默认 4 个,可配 UV_THREADPOOL_SIZE)。

这就是为什么:

  • 你写 fs.readFileSync 时,主线程会阻塞(因为同步 API 就是在主线程上直接调系统调用读文件);
  • fs.readFile 不会阻塞主线程,因为读是在线程池里做的。

四、线程池:别被「单线程」三个字骗了

默认情况下,libuv 的线程池大小是 4(和你的 CPU 核数无关,就是个固定值)。
所以:

  • 同时发 10 个 fs.readFile,只有 4 个在「真·读磁盘」,剩下 6 个在排队。
  • 线程池既管文件 I/O,也管部分 crypto、部分 DNS 等,所以文件多的时候你会感觉「怎么慢下来了」——因为池子被占满了。

可以通过环境变量把池子调大(建议不超过 CPU 数太多,否则上下文切换会变多):

# 例如把线程池改成 8
set UV_THREADPOOL_SIZE=8   # Windows
export UV_THREADPOOL_SIZE=8  # Linux/macOS
// 你可以自己试:同时读多个文件,看完成顺序
const fs = require('fs');
const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt'];

files.forEach((f, i) => {
  fs.readFile(f, () => console.log(`第 ${i + 1} 个完成: ${f}`));
});
// 前 4 个往往先完成(线程池只有 4),第 5 个要等池里有空位

五、Buffer:内存里那块「黑板」

Buffer 是 Node 里表示「一块二进制数据」的类型,本质是 V8 外的一块连续内存(不经过 V8 堆的 GC,由 Node 自己管理)。
文件读进来、网络收来的裸字节,在 JS 里最常见的就是用 Buffer 拿着。

  • fs.readFiledata 就是 Buffer。
  • data.toString() 是把这块内存按指定编码(默认 UTF-8)解码成字符串。
  • 大文件一次性 readFile,就是一次性在内存里开一块和文件一样大的 Buffer——所以 10GB 文件会直接 OOM,和「底层」没关系,就是设计如此。

所以:大文件不要用 readFile,用 Stream 或 read(fd, buffer, offset, length, position) 分段读。

const fs = require('fs');

// 小文件没问题(记得先判断 err,否则文件不存在时 buf 为 undefined)
fs.readFile('small.txt', (err, buf) => {
  if (err) return console.error(err);
  console.log(Buffer.isBuffer(buf)); // true
  console.log(buf.length);            // 字节数
});

// 大文件:别这么干,用 createReadStream
// fs.readFile('huge.log', ...);  // 可能 OOM

六、文件描述符:操作系统给你的「取餐号」

文件描述符(file descriptor, fd) 是操作系统里「打开的文件」的整数句柄。
open 一个文件,内核给你一个 fd(比如 3、4、5),后续 read/write 都用这个数字来指代「哪个打开的文件」。

Node 里:

  • fs.open(path, flags, callback) 会得到 (err, fd)
  • fs.read(fd, buffer, offset, length, position, callback) 表示:从 fd 对应的文件里,从 position 开始,读 length 字节,放进 bufferoffset 位置,读完再回调。
  • 用完后要 fs.close(fd),否则会占用内核资源(可打开 fd 数量有限制)。

用 fd + read 可以自己实现「分段读大文件」:

const fs = require('fs');

function readInChunks(filePath, chunkSize = 64 * 1024) {
  const buffer = Buffer.alloc(chunkSize);
  let position = 0;

  fs.open(filePath, 'r', (err, fd) => {
    if (err) return console.error(err);
    function readNext() {
      fs.read(fd, buffer, 0, chunkSize, position, (err, bytesRead) => {
        if (err) return fs.close(fd, () => console.error(err));
        if (bytesRead === 0) return fs.close(fd, () => console.log('读完了'));
        console.log(`读到 ${bytesRead} 字节,position=${position}`);
        position += bytesRead;
        readNext();
      });
    }
    readNext();
  });
}

readInChunks('./some-big-file.log');

这里就是「底层」用法:自己控 Buffer、position、每次读多少,不依赖 readFile 一次性装进内存。


七、Stream:别一口吞,一口一口吃

Stream(流) 是「一块一块处理数据」的抽象:不要求一次性把整个文件读进内存,而是读一块、处理一块、再读下一块。

  • fs.createReadStream(path) 会打开文件,并返回一个 Readable 流
  • 底层一般也是用 fd + 多次 read,每次读满一块 Buffer(默认 64KB,可配),通过 data 事件或 read() 推给你。
  • 流内部有 highWaterMark:内部缓冲超过这个值就暂停从底层拉数据,避免内存爆掉。

所以:大文件用 ReadStream + 管道或逐 chunk 处理,就不会 OOM。

const fs = require('fs');

// 大文件拷贝:流式,内存占用稳定
function copyBigFile(src, dest) {
  const readStream = fs.createReadStream(src, { highWaterMark: 64 * 1024 });
  const writeStream = fs.createWriteStream(dest, { highWaterMark: 64 * 1024 });
  readStream.pipe(writeStream);
  writeStream.on('finish', () => console.log('拷贝完成'));
}

// 边读边处理:例如数行数
let lines = 0;
fs.createReadStream('huge.log')
  .on('data', (chunk) => {
    for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) lines++;
  })
  .on('end', () => console.log('总行数:', lines));

八、同步 vs 异步:什么时候该用谁?

方式 谁在干活 阻塞主线程? 适用场景
fs.readFile 线程池 小文件、配置等
fs.readFileSync 主线程 启动时读配置、脚本
createReadStream 线程池 + 事件 大文件、日志
fs.read(fd, ...) 线程池 需要精细控制位置/块

原则:

  • 能异步就异步,避免阻塞事件循环。
  • 只有在「进程刚启动、必须立刻拿到结果才能往下跑」的场景,才考虑用 Sync(例如读一个 config.json 再启动服务)。

九、新特性与最新知识点(Promise、FileHandle、io_uring)

1. fs.promises 与 async/await

Node 内置了基于 Promise 的 fs API,不用自己包一层:

const fs = require('fs').promises;

async function main() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    console.log(config);
  } catch (e) {
    console.error(e);
  }
}
main();

底层和回调版是同一套:都是走 libuv 线程池,只是把 callback 换成了 Promise 的 resolve/reject。

2. FileHandle:长期持有 fd 的「句柄」

fs.promises.open() 返回的是 FileHandle,可以多次读/写再关闭,适合「同一个文件反复读」:

const fsp = require('fs').promises;

async function readHeadAndTail(path, headBytes = 100, tailBytes = 100) {
  const handle = await fsp.open(path, 'r');
  const stat = await handle.stat();
  const head = Buffer.alloc(headBytes);
  const tail = Buffer.alloc(tailBytes);
  await handle.read(head, 0, headBytes, 0);
  if (stat.size > tailBytes) {
    await handle.read(tail, 0, tailBytes, stat.size - tailBytes);
  }
  await handle.close();
  return { head: head.toString(), tail: tail.toString() };
}

3. Linux 上的 io_uring(了解即可)

从 libuv 1.45 起,Linux 上部分文件 I/O 曾尝试用 io_uring 做更高性能的异步磁盘 I/O;后来默认又改回线程池。若要用 io_uring,需要在创建 event loop 时显式开启(如 UV_LOOP_USE_IO_URING_SQPOLL)。
对写业务代码的我们来说:知道「文件 I/O 主要走线程池」就够了,除非你在做极致性能调优。


十、综合示例:一个「带流式读 + 行解析」的日志处理器

下面这段把「底层」和「实用」串起来:用 ReadStream 读大日志,按行切分、逐行处理(不会把整个文件载入内存)。

const fs = require('fs');
const readline = require('readline');

async function processLargeLog(filePath, onLine) {
  const stream = fs.createReadStream(filePath, {
    highWaterMark: 256 * 1024, // 256KB 一块
  });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
  for await (const line of rl) {
    await onLine(line); // 你可以在这里做解析、写库、发 MQ 等
  }
}

// 使用示例:只打印包含 "ERROR" 的行
processLargeLog('./app.log', async (line) => {
  if (line.includes('ERROR')) console.log(line);
}).then(() => console.log('处理完毕'));

这里用到的就是:fs 的 ReadStream(底层 fd + 分块 read)+ readline 按行消费,既不会 OOM,又符合「流式」的思维方式。


十一、小结:一张「外卖流程图」收尾

  • 你调 fs.readFile / createReadStream 等 → Node fs 模块(JS)
  • C++ 绑定libuv
  • libuv 把文件 I/O 丢给 线程池(默认 4 个 worker)
  • 线程池里 阻塞式 read内核 → 磁盘
  • 读到的数据放进 Buffer,完成后通过 事件循环 把回调/Promise 推回 主线程
  • 若是 Stream,则是多次「读一块 → 推一块」,由 highWaterMark 等控制背压

记住这几件事:

  1. 单线程指的是 JS,文件 I/O 在 libuv 线程池里。
  2. 大文件用 Stream 或 fd + read,别用 readFile 一把梭。
  3. Buffer 是那块「装字节」的内存;fd 是操作系统给你的「取餐号」。
  4. 新代码优先用 fs.promisesFileHandle,逻辑更清晰;底层和回调版一致。

如果你愿意再往深挖,可以看:

这样,下次有人问「Node 读文件到底是同步还是异步」「为什么我读大文件会崩」,你就能从事件循环讲到线程池、从 Buffer 讲到 Stream,顺便用「外卖下单 → 后厨线程池 → 取餐号 fd」的比喻把对方讲懂。
祝写 Node 少踩坑,磁盘 I/O 稳如狗。

别再手搓 Skill 了,用这个工具 5 分钟搞定

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


说实话,第一次看到 "Skill" 这个词,我也有点懵。是不是又要写很多代码?是不是只有程序员能玩?

后来自己上手做了几个才发现——完全不是那么回事。Skill 更像是:把一件你本来就会做的事,写成一套能反复执行、不会乱跑的步骤

就说做饭吧。第一次做某道菜,你得一边看菜谱一边试探;做多了流程就固定了——先备料,再处理,最后出锅。这时候让别人帮你做,你大概会说"就按这个步骤来,别自己发挥"。Skill 干的就是这事,只是对象从「人」变成了「模型」——让大模型也照着这个步骤来,别自己发挥。

一个 Skill 到底长啥样?

先记住一句话:一个 Skill,本质上就是一个文件夹。

最简单的结构:

my-skill/
├── SKILL.md        # 说明:什么时候用、输入输出是什么
├── scripts/        # 执行:真正跑的逻辑
└── references/     # 参考:示例输入输出

SKILL.md 是最核心的——告诉模型"这件事干嘛用的、什么时候用、输出长啥样"。

你可以把它当成一份「使用说明书 + 注意事项」。

Skill 能解决什么问题?

举个例子,之前我开放了一个去水印下载鸭工具,同时写了份接口文档。但说实话,调用接口的步骤是固定的——传参数、发请求、解析返回。就这几步,每次都要重复。

调用步骤:

  1. 传参:token、url
  2. 发请求:
curl -X GET "https://nologo.code24.top/api/open/parse?url=https%3A%2F%2Fv.douyin.com%2Fxxxxx" \
  -H "Authorization: your-token"
  1. 解析返回数据

就这三步,每次都要重复一遍,挺烦的。 图片1.png

写成 Skill 之后,这些步骤直接固化下来,AI 碰到要调去水印接口的场景,自己就跑完了,不用你再手动复制粘贴。

其中 token、url 怎么获取,直接在 SKILL.md 里写清楚,或者丢个文档链接就行。

image.png

这个去水印解析的 Skill 已经开源了:github.com/CatsAndMice…,名字叫 nologo-open-api

效果测试成功:

image 1.png

但说实话,手搓还是有点麻烦

概念不难理解,但真要自己从头写一个 Skill,还是得折腾——要想触发条件,要写 SKILL.md,要调试……

有没有更省事的方法?

有。用 skill-creator。

skill-creator 是什么?

Skill-Creator是一款专为开发者设计的Skill创建向导,旨在简化开发流程。其便捷性和实用性已得到广泛验证,在SkillHub上的下载量已突破7.5万。

地址:www.skillhub.cn/skills/skil…

image 2.png

说到 SkillHub,这事儿还有点意思:

安装 Skill-Creator 特别简单——直接跟它聊天就行,它会一步步指导 AI 帮你创建技能

举个例子:

你:"用 skill-creator 帮我创建一个提取小红书链接的 Skill"

它:"好的,我来帮你创建。先告诉我:这个 Skill 要处理什么类型的链接?"

你:"抖音、小红书都行,主要提取无水印的视频地址"

它:"明白了。输出格式要不要加上 metadata?我给你两个选项……"

就这么一来一回,你描述需求,它帮你搞定剩下的——写 SKILL.md、搭目录结构、甚至调试代码。

说白了就是:你出想法,它帮你落地。

总结

Skill 的核心是把一件事的"固定流程"写成可反复执行的步骤,让模型按规程稳定产出,避免每次都从零复制粘贴、手动操作。像调用去水印接口这类标准化流程,尤其适合沉淀成 Skill 直接复用;而如果你不想从触发条件、SKILL.md 到目录结构都自己手搓,skill-creator 这种对话式向导能把创建与落地成本降到最低。

【pnpm 】pnpm 执行 xxx 的 底层原理

公众号:AI小揭秘。

pnpm install / pnpm run dev / pnpm run build 底层原理

讲清楚 pnpm ipnpm run devpnpm run build 在底层做了什么:执行步骤、数据流、以及它们如何与 lockfile、store、node_modulespackage.json scripts 配合。附流程图方便对照。


一、总览:三条命令分别干啥

命令 缩写 主要职责
pnpm install pnpm i 解析依赖 → 拉包/复用 store → 算目录结构 → 链接到 node_modules
pnpm run dev 执行 package.jsondev 脚本(及 predev / postdev),通常跑开发服务器
pnpm run build 执行 build 脚本(及 prebuild / postbuild),通常做生产构建

pnpm run 的底层逻辑对 devbuild完全一致,只是脚本名不同;差异来自你在 scripts 里写的具体命令(如 vitenext build)。


二、pnpm installpnpm i)底层原理

2.1 核心目标

  • 根据 package.json(及 workspace 的 pnpm-workspace.yaml)确定要装哪些包、哪些版本。
  • pnpm-lock.yaml 锁定解析结果,保证可复现。
  • 把包实体放进全局 store,再通过硬链接 + 符号链接挂到项目的 node_modules,避免重复拷贝。

2.2 整体执行流程(高层)

flowchart TD
    Start[pnpm install] --> ReadLock{存在 pnpm-lock.yaml?}
    ReadLock -->|否| Resolve[依赖解析,读 package.json 等]
    ReadLock -->|是| ParseLock[解析 lockfile]
    Resolve --> Fetch[从 registry 拉元数据与 tarball]
    Fetch --> BuildTree[构建依赖树]
    BuildTree --> WriteLock[写入/更新 pnpm-lock.yaml]
    ParseLock --> LockOK[锁内容与 package.json 一致?]
    LockOK -->|否且非 frozen| Resolve
    LockOK -->|是或 frozen 通过| CalcStruct[计算 node_modules 目录结构]
    WriteLock --> CalcStruct
    CalcStruct --> Store[包入 store 或复用已有]
    Store --> Link[硬链接到 .pnpm, 符号链接到 node_modules]
    Link --> Done[安装完成]

2.3 分阶段说明

阶段一:Lockfile 与依赖解析
  1. 读 lockfile

    • 若存在 pnpm-lock.yaml,先解析;得到「包名 → 解析后版本、integrity、resolved」等映射。
  2. package.json 对齐

    • 对比 package.json(及 workspace 子包)的 dependenciesdevDependencies 与 lockfile。
    • --frozen-lockfile:若不一致直接失败,不改 lockfile、不写 node_modules
    • 未 frozen:若不一致则重新解析,再更新 lockfile。
  3. 依赖解析(无 lockfile 或需要更新时)

    • registry(默认 npm)拉取元数据,按 semver 解析版本;workspace 内 workspace:* 等解析为本地包。
    • 递归处理传递依赖,得到整棵依赖树
    • 若有 overridescatalog 等,在此阶段应用。
  4. 写回 lockfile

    • 将解析结果写回 pnpm-lock.yaml--lockfile-only 时只做这一步,不进行后续链接)。
阶段二:目录结构计算
  1. 计算 node_modules 布局
    • 确定哪些包放在 node_modules(直接依赖)、哪些只在 .pnpm 下、以及 符号链接 的指向。
    • 满足 非扁平、严格依赖:未声明的包不会出现在项目可访问路径下。
阶段三:Store 与链接
  1. Store 存取

    • 实体存到 全局 store(默认 ~/.local/share/pnpm/store 等,可 store-dir 配置)。
    • 内容寻址:同版本、同 integrity 只存一份;缺少则从 registry 下载 tarball 写入 store。
  2. 硬链接到 .pnpm

    • node_modules/.pnpm 下按 package@version 建目录,包内文件以硬链接从 store 链出。
    • 每个 package@version 有自己的 node_modules,里面只放它自己的依赖的符号链接。
  3. 符号链接到「使用方」的 node_modules

    • node_modules:项目直接依赖的包,符号链接到 .pnpm/<pkg>@<version>/node_modules/<pkg>
    • workspace 包workspace:* 解析出的本地包,链接到源码目录,不占 store。

2.4 pnpm install 流程简图(按阶段)

flowchart LR
    subgraph Phase1 [阶段一 解析]
        A1[读 package.json] --> A2[读/解析 lockfile]
        A2 --> A3{一致?}
        A3 -->|否| A4[解析 + 拉 registry]
        A4 --> A5[写 lockfile]
        A3 -->|是| A5
    end

    subgraph Phase2 [阶段二 结构]
        B1[算依赖树] --> B2[算 node_modules 布局]
    end

    subgraph Phase3 [阶段三 存储与链接]
        C1[store 取/存包] --> C2[硬链接到 .pnpm]
        C2 --> C3[符号链接到 node_modules]
    end

    Phase1 --> Phase2 --> Phase3

2.5 小结

  • pnpm i = 解析(含 lockfile)→ 算结构 → store + 硬链接 + 符号链接。
  • Workspace 下会多一步:解析 pnpm-workspace.yaml、处理 workspace:*,再统一算布局、链接。

三、node_modules 目录结构与执行相关文件

本节把 pnpm install 完成后 node_modules 里有哪些目录和文件、pnpm run dev / pnpm run build 执行时又会用到其中哪些,按「目录 → 文件 → 执行逻辑」列清楚,并详细列出与执行相关的文件清单

3.1 node_modules 顶层目录一览

以单包项目、依赖了 vitelodash 为例,项目根目录下的 node_modules 大致长这样:

<项目根>/node_modules/
├── .bin/                    # 可执行命令的入口(见 3.3)
│   ├── vite                 # Unix 下执行 vite 时实际跑的文件
│   ├── vite.cmd             # Windows CMD
│   ├── vite.ps1             # Windows PowerShell
│   ├── tsc
│   ├── tsc.cmd
│   └── ...
├── .modules.yaml            # pnpm 元数据(store 路径、layout 版本等)
├── .pnpm/                   # 所有包实体所在处(硬链接到 store,见 3.2)
├── vite                     # 符号链接 → .pnpm/vite@x.x.x/node_modules/vite
├── lodash                   # 符号链接 → .pnpm/lodash@x.x.x/node_modules/lodash
└── ...
  • 直接依赖(如 vitelodash):在顶层以包名出现,实际是符号链接,指向 .pnpm/<包名>@<版本>/node_modules/<包名>
  • .bin:下面是对应各包 bin 字段的可执行入口(脚本或符号链接),pnpm run dev / pnpm run build 时 PATH 里会带上这个目录。
  • .modules.yaml:pnpm 自己用的元数据,记录 store 路径、layout 版本等,run 不读它,install 会写。

3.1.1 与「执行」相关的 node_modules 内文件详细清单

下表按路径列出 pnpm run dev / pnpm run build 执行链路中会读、会执行的 node_modules 内文件与目录;「执行逻辑」一列说明该文件在运行时的作用。

路径 类型 谁创建 执行时作用 / 执行逻辑
node_modules/.bin/ 目录 pnpm install 被加入 PATH 前面;shell 解析 vitetsc 等命令时在此目录查找可执行文件。
node_modules/.bin/vite 文件(脚本或符号链接) pnpm install(根据 vite 的 bin 字段) Unix/macOS:被 shell 执行。若为脚本,首行 shebang 调 node,正文调包内入口;若为符号链接,指向 .pnpm/vite@x.x.x/node_modules/vite/dist/node/cli.js 等,由 node 执行。
node_modules/.bin/vite.cmd 文件(批处理) pnpm install Windows CMD:执行时用 node "%~dp0\..\vite\dist\node\cli.js" 等形式调包内入口(%~dp0 为 .cmd 所在目录)。
node_modules/.bin/vite.ps1 文件(PowerShell) pnpm install Windows PowerShell:脚本内用 node $PSScriptRoot\..\vite\dist\node\cli.js 等调包内入口。
node_modules/.bin/tsc 文件 pnpm install(typescript 的 bin) 同上逻辑,最终执行 node .../typescript/bin/tsc 或 tsc.js。
node_modules/.bin/tsc.cmd / .ps1 文件 pnpm install Windows 下执行 tsc 时命中的 wrapper。
node_modules/.pnpm/ 目录 pnpm install 存所有包实体;run 不直接遍历,而是通过 .bin 里的 wrapper 间接执行到其下某包的 bin 入口文件
node_modules/.pnpm/vite@5.4.0/node_modules/vite/ 目录(硬链接到 store) pnpm install 包本体;.bin/vite 的 wrapper 最终会 node 这个目录下 package.json#bin 指定的入口(如 dist/node/cli.js)。
node_modules/.pnpm/vite@5.4.0/node_modules/vite/package.json 文件 包自带 定义 bin 入口路径;pnpm 安装时据此在 .bin 下生成 wrapper;运行时由 wrapper 或 node 间接读到入口路径。
node_modules/.pnpm/vite@5.4.0/node_modules/vite/dist/node/cli.js 文件 包自带 vite 的 CLI 入口;.bin/vite(或 .cmd/.ps1)最终执行 node .../cli.js,即此文件。
node_modules/.pnpm/typescript@x.x.x/node_modules/typescript/bin/tsctsc.js 文件 包自带 tsc 命令的真实入口;.bin/tsc 最终执行此文件。
node_modules/vite 符号链接 pnpm install 指向 .pnpm/vite@x.x.x/node_modules/viterun 时若脚本里用 node 的 require/import 解析 vite,会走到此链接再到 .pnpm 下包本体。
node_modules/.modules.yaml 文件 pnpm install 记录 store 路径、layout 版本;仅 install 使用run 不读。

目录小结

  • 执行 dev/build 直接用到package.json(scripts)、node_modules/.bin/*(wrapper)、.pnpm/<包>@<版本>/node_modules/<包>/bin 入口文件
  • 间接用到:顶层 node_modules/<包名> 符号链接(Node 解析 require('vite') 等时)、.pnpm 下各依赖的 node_modules(运行时模块解析)。

3.2 .pnpm 目录结构(包实体与依赖链)

.pnpm 里才是「包的真实内容」所在位置(内容来自 store 的硬链接)。每个 package@version 一个目录,且每个包有自己的 node_modules,只放自己声明的依赖的符号链接。

示例(项目依赖 vitevite 又依赖 esbuild 等):

node_modules/.pnpm/
├── vite@5.4.0
│   └── node_modules/
│       ├── vite          # 指向 store 的硬链接(包本体)
│       ├── esbuild       # 符号链接 → ../../esbuild@x.x.x/node_modules/esbuild
│       ├── rollup        # 符号链接 → ...
│       └── ...
├── esbuild@0.19.x
│   └── node_modules/
│       └── esbuild       # 指向 store
├── lodash@4.17.21
│   └── node_modules/
│       └── lodash
└── ...
  • <包名>@<版本>/node_modules/<包名>:包本体(目录或硬链接到 store 的目录)。
  • <包名>@<版本>/node_modules/<依赖名>:该包的依赖,以符号链接指到 ../../<依赖名>@<版本>/node_modules/<依赖名>
  • 根目录的 node_modules/vite:符号链接到 .pnpm/vite@5.4.0/node_modules/vite,所以你在代码里 import 'vite' 时,Node 解析到的就是 .pnpm 里这一份。

执行逻辑

  • pnpm install 只写 storenode_modules(含 .pnpm 与顶层符号链接、.bin);不执行任何业务脚本。
  • pnpm run dev / pnpm run build 不会去「遍历 .pnpm」;它们只是执行 package.json 里配置的命令,命令里若写 vite,就会通过 PATH 找到 node_modules/.bin/vite,再由该文件间接执行到 .pnpm 里对应包的入口

3.3 .bin 目录:有哪些文件、怎么被执行

.bin 下的文件来自各依赖包 package.jsonbin 字段。pnpm 在 install 阶段会为每个 bin 项在 node_modules/.bin 下生成可执行入口,名字即 bin 的 key(如 vitetsc)。

平台 / 类型 文件名示例 说明
Unix / Linux / macOS vitetsc(无后缀) 一般为脚本(shebang 调用 node)或符号链接到包内 bin 文件。
Windows CMD vite.cmdtsc.cmd 批处理,内部通常用 node "%~dp0\..\vite\dist\cli.js" 等形式调包内入口。
Windows PowerShell vite.ps1tsc.ps1 PowerShell 脚本,同样会去调包内对应 js。

.bin 下典型文件内容示例(执行逻辑)

  • Unix:node_modules/.bin/vite(脚本形式时)
    内容通常类似:

    #!/usr/bin/env node
    require('../vite/dist/node/cli.js')
    

    或直接为符号链接,指向 .pnpm/vite@5.4.0/node_modules/vite/dist/node/cli.js。执行时:shell 调起该文件 → 若为脚本则 #!/usr/bin/env node 导致用 node 执行本文件,进而 require 包内 cli.js;若为符号链接则 node 执行链接目标(即 cli.js)。

  • Windows CMD:node_modules/.bin/vite.cmd
    内容通常类似:

    @echo off
    node "%~dp0..\vite\dist\node\cli.js" %*
    

    执行逻辑%~dp0 为当前 .cmd 所在目录(即 node_modules/.bin),..\vite 为顶层符号链接 vite(在 pnpm 下会解析到 .pnpm 里对应包),最终用 node 执行 cli.js%* 把命令行参数原样传给 cli.js。

  • Windows PowerShell:node_modules/.bin/vite.ps1
    逻辑类似,用 $PSScriptRoot 定位到 .bin,再 node 执行上一级 vite 下的入口 js。

执行逻辑(以 pnpm run dev 且 scripts.dev 为 vite 为例)

  1. pnpm 在当前包package.json 里读到 scripts.dev = "vite"
  2. pnpm 把 <包目录>/node_modules/.bin(及 workspace 根同路径)加到 PATH 前面,再在子 shell 里执行 vite
  3. 系统在 PATH 里找到第一个名为 vite 的可执行文件:
    • Unix:即 node_modules/.bin/vite(无后缀),可能是脚本或符号链接;
    • Windows CMD:会找 vite.cmd;PowerShell 可能用 vite.ps1
  4. 执行该文件:
    • 若是脚本,内容通常类似 #!/usr/bin/env node + 调 node <包内入口>,或直接 node path/to/vite/dist/node/cli.js
    • 若是符号链接,会指向 .pnpm/vite@x.x.x/node_modules/vite 下的 bin 入口(如 dist/node/cli.js),再由 node 执行该 js。
  5. 最终实际运行的是 node + .pnpm/vite@x.x.x/node_modules/vite 里声明的 bin 入口文件

因此:执行链路 = package.json#scripts.dev → shell 执行 vite → PATH 解析到 node_modules/.bin/vite(或 .cmd/.ps1)→ 该文件内部执行 node + .pnpm 里 vite 的 bin 入口

执行时文件与目录关系(示意)

flowchart LR
    A[package.json] -->|读 scripts.dev/build| B[命令字符串 vite 等]
    B --> C[PATH 含 node_modules/.bin]
    C --> D[node_modules/.bin/vite]
    D --> E[.pnpm/vite@x.x.x/node_modules/vite 下 bin 入口]
    E --> F[node 执行 cli.js]

3.4 执行 dev / build 时涉及的文件与目录(按顺序)

步骤 类型 路径 / 文件 作用
1 <包目录>/package.json 确定当前包、查 scripts.dev / scripts.build 等。
2 scripts.predev / scripts.dev / scripts.build 得到要执行的命令字符串(如 vitevite build)。
3 环境 <包目录>/node_modules/.bin<workspace根>/node_modules/.bin 被 pnpm 追加到 PATH 前面。
4 执行 node_modules/.bin/vite(或 vite.cmd / vite.ps1 shell 解析 vite 时命中的可执行文件。
5 执行 node_modules/.pnpm/vite@x.x.x/node_modules/vite/dist/node/cli.js(以 vite 为例) .bin 里的 wrapper 最终用 node 执行的真实入口
6 该包及其依赖下的 package.jsonnode_modules/... Node / Vite 等运行时按模块解析规则继续读,与 pnpm 无直接关系。

3.4.1 按「是否参与执行」区分的 node_modules 目录一览

下面用一棵更完整的目录树,标出执行 dev/build 时直接参与的目录/文件(✅)与仅 install 使用、run 不读的(○):

<项目根>/node_modules/
├── .bin/                          ✅ run 时 PATH 包含此目录,执行 vite/tsc 等命中此处
│   ├── vite                       ✅ Unix 下执行 vite 时运行
│   ├── vite.cmd                   ✅ Windows CMD 下执行 vite 时运行
│   ├── vite.ps1                   ✅ Windows PowerShell 下执行 vite 时运行
│   ├── tsc / tsc.cmd / tsc.ps1    ✅ 同上,tsc 命令
│   └── ...
├── .modules.yaml                  ○ 仅 pnpm install 读写,run 不读
├── .pnpm/                         ✅ run 时通过 .bin wrapper 间接执行到其下包的 bin 入口
│   ├── vite@5.4.0/
│   │   └── node_modules/
│   │       ├── vite/              ✅ 包本体,.bin/vite 最终 node 其下 bin 入口
│   │       │   ├── package.json   ✅ 定义 bin 入口路径
│   │       │   └── dist/node/cli.js  ✅ vite 命令的真实执行文件
│   │       ├── esbuild            ○ run 时由 vite 等按 require 解析
│   │       └── ...
│   ├── typescript@5.x.x/
│   │   └── node_modules/
│   │       └── typescript/bin/tsc ✅ tsc 命令的真实执行文件
│   └── ...
├── vite                           ✅ 符号链接;Node require('vite') 等会解析到此
├── lodash                         ✅ 同上
└── ...

执行链路小结
scripts.dev 字符串(如 vite)→ shell 在 PATH 里找到 node_modules/.bin/vite(或 .cmd/.ps1)→ 该文件内部执行 node + .pnpm 下 vite 的 bin 入口(如 cli.js)→ 之后由 Vite/Node 按模块解析规则读 .pnpm 下各依赖,与 pnpm 无直接关系。

目录小结

  • install 生成并维护:node_modules/node_modules/.pnpm/node_modules/.bin/node_modules/.modules.yaml,以及顶层包名符号链接。
  • run 直接用到的是:package.json(读 scripts)、node_modules/.bin/*(执行入口),间接用到 .pnpm 里对应包的 bin 入口文件。

3.5 小结

  • node_modules 顶层:.bin(可执行入口)、.pnpm(包实体与依赖链)、.modules.yaml(pnpm 元数据)、以及直接依赖的符号链接。
  • .pnpm:按 包名@版本 存包本体(硬链接到 store),每个包有自己的 node_modules,里面是该包依赖的符号链接。
  • .bin:由 install 根据各包 bin 生成;run 时 PATH 包含 .bin,执行 vite 等会先走到 .bin 再转到 .pnpm 里对应包的入口文件。
  • 执行 dev/build:先读 package.json 的 scripts,再在子 shell 里执行命令字符串,通过 .bin 找到并执行对应包的 bin 入口。

四、pnpm run(含 dev / build)底层原理

4.1 核心目标

  • 当前包package.jsonscripts 里找到对应脚本(如 devbuild)。
  • pre / 本体 / post 顺序执行(若有)。
  • 执行时把 node_modules/.bin(及 workspace 根 node_modules/.bin)加入 PATH,以便直接跑本地安装的 CLI。

4.2 整体执行流程

flowchart TD
    Start[pnpm run scriptName] --> ResolvePkg[解析当前包,读 package.json]
    ResolvePkg --> FindScript{scripts.scriptName 存在?}
    FindScript -->|否| Err[报错 Missing script]
    FindScript -->|是| Pre{存在 pre scriptName?}
    Pre -->|是| RunPre[执行 pre scriptName]
    Pre -->|否| RunMain
    RunPre --> RunMain[执行 scriptName]
    RunMain --> Post{存在 post scriptName?}
    Post -->|是| RunPost[执行 post scriptName]
    Post -->|否| Done[结束]
    RunPost --> Done

4.3 分步骤说明

1. 确定「当前包」与 script
  • 当前目录 若不是 workspace 根,pnpm 会向上找 包含 package.json 的目录,当作当前包
  • Workspace:若在子包目录执行 pnpm run dev,则用该子包package.json;在根目录则用根包的。
2. 查找 script
  • package.jsonscripts 里找 scriptName(如 devbuild)。
  • 没有则报 Missing script: "dev" 等错误。
3. Pre / 本体 / Post 顺序
  • 若存在 prescriptName,先执行 pnpm run prescriptName(递归,同样有 pre/post)。
  • 再执行 scriptName 对应的命令。
  • 若存在 postscriptName,再执行 pnpm run postscriptName
  • 例如 pnpm run build → 有 prebuild 则先 prebuild,再 build,再 postbuild(若有)。
4. 准备执行环境(PATH 等)
  • <包目录>/node_modules/.bin 加入 PATH 前端。
  • Workspace:还会把 <workspace 根>/node_modules/.bin 加入 PATH,因此根目录装的 CLI(如 vitetsc)在子包里也能直接调用。
5. 执行命令
  • 子 shell 中执行 scripts[scriptName] 里的字符串(如 vitenext dev)。
  • 通常通过 nodenode_modules/.bin 下对应平台的 wrapper(如 vitevite.jsvite.cmd),再 node vite.js;具体由 npm lifecycles / run-script 等底层处理。

PATH 与 .bin 的关系
pnpm 先把 node_modules/.bin(及 workspace 根同路径)塞进 PATH 前面,再启子进程跑脚本。因此脚本里写的 vitetsc 等会解析到 node_modules/.bin 里的 wrapper,而 .bin 里的文件是 pnpm install 阶段根据各包 bin 字段创建的符号链接或脚本。流程关系如下:

flowchart LR
    subgraph Install [pnpm install]
        I1[解析依赖] --> I2[链接包到 node_modules]
        I2 --> I3[根据 bin 字段生成 .bin 下可执行文件]
    end

    subgraph Run [pnpm run dev 或 build]
        R1[查找 scripts.dev 或 scripts.build] --> R2[PATH 前追加 node_modules/.bin]
        R2 --> R3[子 shell 执行脚本命令]
        R3 --> R4[解析 vite 等到 .bin 对应 wrapper]
    end

    I3 -.->|install 完成后 .bin 就绪| R4

4.4 pnpm run 流程简图(环境 + 生命周期)

flowchart TD
    subgraph Env [环境准备]
        E1[确定当前包] --> E2[找 scripts.scriptName]
        E2 --> E3[PATH += node_modules/.bin]
        E3 --> E4[Workspace 时 PATH += 根 node_modules/.bin]
    end

    subgraph Lifecycle [生命周期]
        L1[pre scriptName] --> L2[scriptName]
        L2 --> L3[post scriptName]
    end

    Env --> Lifecycle
    Lifecycle --> Spawn[在子 shell 中执行命令]
    Spawn --> Exit[退出码决定 pnpm run 成功/失败]

4.5 devbuild 在「run」层面的区别

  • 执行机制完全相同:都是 pnpm run <script>,只是 <script> 名字不同。
  • 差异来自你在 scripts 里写的命令,例如:
    • dev:常为 vitenext devwebpack serve长期进程
    • build:常为 vite buildnext buildtsc一次性构建
  • Pre/post:若你配置了 predev / postdevprebuild / postbuild,会按顺序跑;没配则只跑本体。

4.6 小结

  • pnpm run dev / pnpm run build = 找 script → 可能 pre → 本体 → 可能 post;执行前把 node_modules/.bin 等加入 PATH,在子 shell 中跑对应命令。
  • node_modules/.bin 里的可执行文件由 依赖包bin 字段生成,pnpm 在 install 阶段已经链好;run 只负责 查 script、改 PATH、调起这些 bin

五、三者之间的关系

flowchart LR
    subgraph Install [pnpm install]
        I1[解析依赖] --> I2[store 与链接]
        I2 --> I3[node_modules 就绪]
        I3 --> I4[.bin 可执行文件就绪]
    end

    subgraph Run [pnpm run dev / build]
        R1[读 scripts] --> R2[PATH += .bin]
        R2 --> R3[pre / 本体 / post]
        R3 --> R4[执行 vite / next 等]
    end

    I4 --> R1
  • pnpm install 准备好 node_modulesnode_modules/.binpnpm run dev / pnpm run build 才能正确找到 vitenext 等命令。
  • 未安装就 run,通常会报 找不到命令Cannot find module

六、常用 flag 与行为

6.1 pnpm install

Flag 作用
--frozen-lockfile 不更新 lockfile;若与 package.json 不一致则失败
--lockfile-only 只更新 pnpm-lock.yaml,不写 node_modules
--prefer-offline 尽量用 store,缺的再拉
--offline 只用 store,不访问 registry

6.2 pnpm run

Flag 作用
--silent 少打日志
--prefix <path> 以指定目录为包根(找 package.json

dev / build 本身没有专属 flag;传参会透传给脚本,例如:

pnpm run build -- --mode production

-- 后面的 --mode production 会交给 scripts.build 对应的命令。


七、参考

用 codex AI 更新了下之前写的浏览器云书签标签页扩展

之前的文章

juejin.cn/post/749309…

开源地址

github.com/wumingluren…

image.png

image.png

image.png

最近重新做了 ui 还支持了换肤功能,复刻了一份 Omni 功能。

主要是玩玩 codex 的各种 skill。

之前还有配套的导航站,还没有升级 ui ,下一步就准备升级一下。

下面内容由 AI 生成。

这半个月,我们把无名云书签往前推了一大步

这段时间,无名云书签做了几次很关键的更新。

如果用一句话来概括,就是我们不再只满足于“把书签存起来”,而是开始认真把它做成一个真正顺手、能每天打开就用的浏览器工具。

最近,这个项目主要往前走了四步:重做了新标签页、做出了 Omni 命令面板的第一个可用版本、统一了开发工具链,也顺手解决了一个很影响体验的样式污染问题。

新标签页,不想再只是一个“列表页”

最先动刀的是新标签页。

以前的新标签页更偏功能导向,能用,但谈不上舒服。打开之后,你能看到推荐书签、能搜内容,但整体更像一个功能页,而不是一个你愿意长期停留的导航页。

这次改版,我们把它从“一个能看书签的页面”,往“一个真正能承接日常访问入口的首页”推进了一步。

新的版本里,页面结构被重新梳理了。搜索、推荐内容、反馈状态都被放进了更统一的视觉层级里,信息密度更高,但阅读负担反而更低。空状态、骨架屏、错误提示这些以前容易被忽略的细节,这次也都补上了。你在加载、搜索、无结果这些场景下,终于能明确知道系统现在在做什么。

另一个比较明显的变化,是换肤。

这次新标签页内置了 5 套主题风格,支持直接切换,并且会保存你的选择。我们希望它不是一个“只能用默认样式”的工具页,而是一个你可以按自己的习惯留在浏览器里的空间。无论你喜欢偏清爽、偏冷静,还是更适合夜间使用的风格,现在都有了一个更自然的落点。

对项目内部来说,这次改版也不只是改了外观。像 BookmarkItemThemeSwitcheruseNewTabThemethemes 这些模块被单独拆出来之后,后面不管是继续补主题、加模块,还是调整布局,都会轻松很多。

Omni 命令面板,终于有了第一个能打的版本

如果说新标签页解决的是“打开浏览器之后”的体验,那 Omni 命令面板解决的,就是“在任何页面里,怎么更快完成操作”。

最近,我们把 Omni 命令面板的 MVP 做出来了。

现在你可以直接通过 Cmd/Ctrl + Shift + K 呼出这个面板,不需要先切到某个固定页面,也不用绕到设置页或者侧边栏里找入口。它更像一个悬浮在浏览器里的统一操作台。

这个版本最核心的能力,是把原本分散的东西聚合到了一起。

你可以在同一个输入框里同时搜:

  • 飞书云书签
  • 当前浏览器标签页
  • 浏览器书签
  • 浏览历史
  • 常用快捷动作
  • 最近使用记录

这件事听起来简单,但它实际改变的是操作路径。以前你可能需要先想“我要去哪里找这个东西”,现在是先输入,再从结果里选。这个心智负担小很多,尤其在书签变多、标签页变多之后,差别会特别明显。

为了让搜索更顺手,这次也加了命令前缀能力。比如你想只查标签页,可以直接输入 /tabs;只查云书签,可以用 /feishu;只看历史记录、动作、浏览器书签,也都可以快速切换。它不是一个复杂的命令系统,但已经足够把“全局搜索”变成“可控搜索”。

除了搜,Omni 现在也能直接做事。

这个版本已经支持一组比较高频的快捷动作,比如打开新标签页、打开扩展设置、打开侧边栏、把当前页面存到飞书、存到浏览器书签、关闭当前标签页、关闭其他标签页、关闭右侧标签页、固定标签页、静音标签页,以及把当前输入直接交给默认搜索引擎。

为了避免误操作,危险动作还加了二次确认。设置页里也补了对应的 Omni 配置项,可以控制开关、搜索来源、每组显示结果数量,以及是否保留危险动作确认。

这一步对无名云书签来说挺重要。因为从这里开始,它不只是一个“存书签”的扩展,而是在往“浏览器里的个人信息入口”走。

修掉一个很小,但很烦的问题

最近,我们修了一个看起来不大、但实际很影响观感的问题:内容脚本的样式会污染宿主页面。

这个问题的本质是,扩展注入页面时使用的样式里,有一些通用类名,比如 .hidden.flex 这类工具类。如果它们被直接挂到宿主页面环境里,就有可能影响原网站自己的样式表现。

这类问题往往不容易第一时间被发现,因为它不一定是“页面直接坏掉”,很多时候只是某些站点会变得怪怪的。但一旦用户碰到,体感会很差,而且锅最后还是会落到扩展头上。

这次的处理方式,是不再通过 manifest 直接把内容脚本样式注入宿主页面,而是改成在运行时加载到 Shadow DOM 里。这样扩展自己的界面还能保留原来的样式能力,但不会继续往外泄漏。

同时,这次也补了一条测试,专门保证这个行为不会再被后续改动带回来。

这种改动不一定会出现在截图里,也不一定会成为“新功能”被感知到,但它会直接决定扩展是不是一个足够克制、足够可靠的工具。

顺手把开发链路也理顺了

除了功能本身,这轮还有一件更偏工程化的事情一起做了:项目里的命令和文档,统一切到了 pnpm

这包括 README 里的安装、开发、构建、打包说明,也包括项目脚本本身的调用方式。package-lock.json 被移除,pnpm-lock.yaml 成为了当前唯一的锁文件。

这个变化对普通使用者几乎没有感知,但对后续维护很有帮助。至少从现在开始,这个项目在“文档怎么写”和“实际怎么跑”这件事上是一致的,新同学接手时也不会一上来就踩到工具链不统一的问题。

这轮更新之后,无名云书签更像什么了

如果说以前的无名云书签,核心价值是“把书签放进飞书里”,那这轮更新之后,它开始更像一个围绕书签展开的浏览器工作台。

你可以在新标签页里更舒服地浏览和进入内容,也可以在任何网页里直接拉起命令面板,搜索、跳转、保存、整理。当这些入口被串起来之后,书签管理就不再只是“存档”,而更接近“随时可用的个人知识入口”。

这也是接下来这个项目更值得继续做下去的地方。

后面应该还会继续补 Omni 的能力、磨新标签页的细节,也把一些现在已经能用但还不够顺的部分继续打磨下去。至少从这半个月来看,无名云书签已经不再停留在“能用”的阶段,而是在慢慢往“好用、愿意一直用”靠近。

我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库

我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库

把它的设计语言完整提炼出来,做成了一个可以直接 npm install 的微信小程序组件库。

效果截图

Snipaste_2026-04-15_16-25-37.jpg

Snipaste_2026-04-15_16-27-47.jpg

Snipaste_2026-04-15_16-27-15.jpg

Snipaste_2026-04-15_16-26-37.jpg

Snipaste_2026-04-15_16-26-20.jpg

Snipaste_2026-04-15_16-26-00.jpg

克制的双色系统(蓝+橙),无阴影的卡片层次,菜单页那个从 + 按钮展开到数量步进器的丝滑交互,会员卡页面方案选择器的信息架构……这些细节放在一起,构成了一套非常完整且高辨识度的设计语言。

项目叫 LKCN UI,22 个组件,纯原生微信小程序自定义组件,零依赖,原生 / Taro / uni-app 项目都能直接用。

GitHub: https://github.com/user/lkcn-ui

色彩系统

瑞幸全局只用 两个强调色

色值 用途 使用场景
#1A6EFF Brand Blue 交互元素 TabBar 激活态、按钮、加购圆钮、链接
#FF6B35 Accent Orange 促销与价格 价格数字、CTA 按钮、Badge、优惠券

辅助色包括会员金 #C8A26E、即享绿 #2B7D5B、咖啡棕 #3D2D1F

一个重要发现:瑞幸的卡片没有阴影。整个 App 的层次感完全靠圆角 + 间距 + 背景色差来实现,这使得渲染性能非常好,也让整体视觉特别干净。

字体体系

价格是瑞幸 UI 最有辨识度的元素。它把价格拆成了三段不同大小的文字:

¥(小号加粗) 9(大号加粗) .9(小号加粗)  ¥32(小号灰色删除线)

这种「符号小、整数大、小数小」的层次处理让价格数字极具视觉冲击力,同时原价的删除线灰色处理制造了强烈的价差感知。我在 lkcn-price 组件里完整还原了这个效果。

间距与圆角

间距体系是标准的 8px 递增:4 / 8 / 12 / 16 / 24 / 32(rpx 翻倍)。

圆角有 5 级:4px(标签)→ 8px(按钮、输入框)→ 12px(卡片)→ 20px(弹窗)→ 999px(胶囊)。

所有 Token 都通过 CSS 变量注入,覆盖变量即可全局换肤:

page {
  --lkcn-blue: #1A6EFF;
  --lkcn-orange: #FF6B35;
  --lkcn-radius-md: 24rpx;
  /* ... 60+ 个变量 */
}

22 个组件一览

全部组件从瑞幸小程序的真实页面中提取,不是凭空设计的:

基础组件: Button(6 种类型 × 3 尺寸)、Tag(4 类型 × 4 颜色)、Price(整数/小数自动拆分)、Badge、Avatar

布局容器: Card、Grid(3/4/5 列自适应)、Swiper(胶囊形指示点)、CouponScroll、PromoCard

导航: TabBar(safe-area 适配)、Tabs(滑动下划线)、SegmentControl、SearchBar、CategorySidebar、LocationBar

业务组件: ProductCard(菜单列表项)、Stepper(折叠→展开态)、LevelCard(会员等级)、MembershipPlan(订阅方案选择)、NoticeBar、FloatingButton

1. Stepper:瑞幸的加购交互

瑞幸菜单页的加购交互是我见过最优雅的——数量为 0 时只显示一个蓝色 + 圆钮,点击后展开为 [-] [数字] [+] 三段式控件。

<!-- 使用方式 -->
<lkcn-stepper value="{{count}}" bind:change="onChange" />

组件内部的关键判断:

<!-- value <= min 时只显示 + 按钮 -->
<view wx:if="{{value <= min}}" class="lkcn-stepper__add lkcn-stepper__add--solo">
  <text class="lkcn-stepper__icon">+</text>
</view>
<!-- 否则展开完整控件 -->
<view wx:else class="lkcn-stepper__controls">
  <!-- [-] [count] [+] -->
</view>

加购按钮的 scale(0.88) + cubic-bezier(0.34, 1.56, 0.64, 1) 弹性回弹动画让点击手感特别好。

2. Price:三段式价格渲染

<lkcn-price value="9.9" original="32" prefix="预估到手" />

组件自动将 9.9 拆分为整数 9 和小数 .9,分别用不同字号渲染,currency symbol ¥ 用小号加粗。这种处理在电商类小程序里非常实用,直接拿去用就行。

3. CategorySidebar:菜单页左侧导航

这个组件还原了菜单页左侧的完整细节——激活态的白色背景、左侧橙色指示条、分类标签(新品产地季苦瓜轻体),以及新品小红点。

<lkcn-category-sidebar
  categories="{{categories}}"
  active="{{catActive}}"
  height="100vh"
  bind:change="onCatChange"
/>

数据结构支持纯文字和对象两种格式:

categories: [
  '人气Top',                           // 纯文字
  { text: '周边NEW', tag: '周边NEW', tagColor: 'blue' },  // 带标签
  { text: '果C美式', tag: '苦瓜轻体', tagColor: 'green', dot: true },
]

4. MembershipPlan:会员方案选择器

会员卡页面底部那个方案选择 + 订阅 CTA + 协议勾选的完整流程,一个组件搞定:

<lkcn-membership-plan
  plans="{{plans}}"
  active="{{planActive}}"
  agreement="开通会员代表接受"
  agreement-links="{{[{text:'《服务协议》'}, {text:'《续费说明》'}]}}"
  bind:subscribe="onSubscribe"
/>

为什么选原生而不是 Taro / uni-app

这是我在开发前做的一个关键决策。核心理由就一个——受众最大化

原生微信小程序自定义组件能被所有技术栈引入:

原生组件 → 原生项目 ✅、uni-app 项目 ✅、Taro 项目 ✅
uni-app 组件 → 只有 uni-app 能用 ❌
Taro 组件 → 只有 Taro 能用 ❌

uni-app 引入原生组件只需要放到 wxcomponents/ 目录,在 pages.json 注册即可。Taro 也类似。写一份代码三个生态都能吃到,这是 Vant Weapp 走过的路。

快速上手

npm install lkcn-ui

在微信开发者工具中构建 npm,然后注册组件:

{
  "usingComponents": {
    "lkcn-button": "lkcn-ui/button/index",
    "lkcn-price": "lkcn-ui/price/index",
    "lkcn-product-card": "lkcn-ui/product-card/index"
  }
}

直接使用:

<lkcn-button type="primary" round>立即下单</lkcn-button>

<lkcn-product-card
  image="/images/coconut-latte.png"
  title="生椰拿铁(首创)"
  tags="{{['全球销量第一', 'IIAC金奖']}}"
  price="9.9"
  original-price="32"
  bind:add="onAddToCart"
/>

也可以不用 npm,直接把 packages/ 下需要的组件目录复制到你的项目里。

换肤

所有视觉变量都通过 CSS 变量控制,覆盖即可适配你自己的品牌:

page {
  --lkcn-blue: #7C3AED;    /* 换成你的品牌紫 */
  --lkcn-orange: #F59E0B;  /* 换成你的品牌黄 */
  --lkcn-radius-md: 32rpx; /* 更大的圆角 */
}

不需要改任何组件源码,Design Token 体系的优势就在这里。

项目数据

  • 22 个组件,全部完成
  • 143 个源文件
  • 0 外部依赖
  • 每个组件 4 件套(wxml / wxss / js / json)
  • 60+ Design Token CSS 变量
  • 11 个可交互 demo 页面
  • 包体积 < 90KB(未压缩)

后续计划

  • 组件 TypeScript .d.ts 类型声明
  • VitePress 文档站
  • 暗色模式适配
  • GitHub Actions CI 自动发布

如果你也觉得有用,欢迎 Star:

GitHub: https://github.com/user/lkcn-ui

单例模式渐进式学习指南

单例模式渐进式学习指南

面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。


目录

  1. 什么是单例模式?
  2. 为什么前端里需要单例?
  3. 先从最小例子理解“唯一实例”
  4. 单例模式的标准结构
  5. 前端中常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点与缺点
  8. 使用单例时的常见误区
  9. 面试中怎么回答单例模式
  10. 练习题与思考题
  11. 学习总结

一、什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式

它的核心目标只有一句话:

保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。

你可以把它理解成:

  • 系统里这个对象只能创建一次
  • 后面再获取时,拿到的都是同一个对象
  • 大家共用它,而不是每次都 new 一个新的

生活类比

可以把单例想象成:

  • 浏览器里的 window
  • 页面中的全局配置中心
  • 整个项目里唯一的消息提示组件管理器
  • 唯一的缓存中心

这些东西通常不需要来一个人就建一个新的,否则系统会乱套。

单例的两个关键词

关键词 含义
唯一实例 无论调用多少次,都只有一个对象
全局访问 任何需要它的地方都能拿到同一个对象

二、为什么前端里需要单例?

很多初学者会有个疑问:

前端不就是写页面吗?为什么还要学设计模式?

其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。

常见需求

  • 全局只有一个登录弹窗
  • 全局只有一个消息通知容器
  • 全局只有一个请求管理器
  • 全局只有一个事件总线实例
  • 全局只有一个缓存对象
  • 全局只有一个状态管理容器入口

如果每次使用都重新创建:

  • 会造成资源浪费
  • 会引发状态不一致
  • 会让调试复杂度上升
  • 甚至会出现界面重复渲染、重复请求等问题

一个典型问题

比如你写一个全局弹窗:

function createModal() {
  return {
    show() {
      console.log('弹窗打开')
    },
  }
}

const modal1 = createModal()
const modal2 = createModal()

console.log(modal1 === modal2) // false

这里 modal1modal2 不是同一个对象。

这意味着:

  • 你可能创建了多个弹窗实例
  • 每个实例的状态互不相通
  • 页面上可能冒出多个重复弹窗

这时候,单例模式就登场了。


三、先从最小例子理解“唯一实例”

普通写法:每次都创建新对象

function createUserStore() {
  return {
    name: 'frontend-store',
  }
}

const store1 = createUserStore()
const store2 = createUserStore()

console.log(store1 === store2) // false

单例写法:始终返回同一个对象

function createSingleUserStore() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        name: 'frontend-store',
      }
    }

    return instance
  }
}

const getStore = createSingleUserStore()

const store1 = getStore()
const store2 = getStore()

console.log(store1 === store2) // true

这段代码发生了什么?

核心在这里:

let instance = null

它会把第一次创建出来的对象缓存起来。

后续再调用时:

  • 如果 instance 不存在,就创建
  • 如果 instance 已存在,就直接返回

于是无论调用多少次,拿到的都是同一个对象。

一句话:单例不是“不让你调用”,而是“让你重复调用时仍然拿到同一个实例”。


四、单例模式的标准结构

虽然前端里未必真的写“类”,但你最好知道它的标准思想。

结构拆解

一个典型的单例通常包含 3 个部分:

  1. 私有实例缓存:记录是否已经创建过对象
  2. 创建逻辑:第一次使用时创建对象
  3. 访问入口:外部通过统一方法获取实例

用类的方式理解

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance
    }

    this.data = '唯一实例'
    Singleton.instance = this
  }
}

const s1 = new Singleton()
const s2 = new Singleton()

console.log(s1 === s2) // true

更推荐前端中理解成“模块级唯一对象”

在现代前端中,很多单例并不是通过 class 写出来的,而是通过 模块缓存机制 自然形成的。

// config.js
const config = {
  apiBaseUrl: 'https://api.example.com',
  timeout: 5000,
}

export default config
// a.js
import config from './config.js'

// b.js
import config from './config.js'

因为 ES Module 会缓存模块实例,所以多个文件导入同一个模块时,通常拿到的是同一份模块对象。

这也是前端里最常见、最自然的“单例感”来源。


五、前端中常见的单例场景

这一部分最重要,因为真正写业务时,你不是为了“背定义”而用单例,而是为了解决全局唯一资源管理问题

1. 全局消息提示(Message / Toast)

很多 UI 库里的全局提示本质就是单例。

class Message {
  constructor() {
    this.queue = []
  }

  show(text) {
    this.queue.push(text)
    console.log('消息:', text)
  }
}

let messageInstance = null

export function getMessageInstance() {
  if (!messageInstance) {
    messageInstance = new Message()
  }

  return messageInstance
}

使用时:

const message1 = getMessageInstance()
const message2 = getMessageInstance()

message1.show('保存成功')
console.log(message1 === message2) // true

2. 全局弹窗管理器

如果每点击一次按钮都创建一个弹窗管理器,页面就可能出现多个重复节点。

单例的好处是:

  • 整个应用只维护一个弹窗容器
  • 状态统一管理
  • DOM 节点不会重复创建

3. 请求管理器 / API 客户端

比如你封装了一个请求实例:

class RequestService {
  constructor(baseURL) {
    this.baseURL = baseURL
  }

  get(url) {
    console.log(`GET: ${this.baseURL}${url}`)
  }
}

let requestInstance = null

export function getRequestService() {
  if (!requestInstance) {
    requestInstance = new RequestService('/api')
  }

  return requestInstance
}

这样做可以统一:

  • baseURL
  • 请求拦截器
  • token 注入
  • 错误处理策略

4. 缓存中心

const cache = {
  data: new Map(),
  set(key, value) {
    this.data.set(key, value)
  },
  get(key) {
    return this.data.get(key)
  },
}

export default cache

这本质上也是一个单例对象。

5. 状态共享对象

某些轻量项目不用 Pinia / Redux,也会自己写一个全局 store。

const store = {
  state: {
    userInfo: null,
  },
  setUser(user) {
    this.state.userInfo = user
  },
}

export default store

所有页面共享同一份 store,这就是一种模块单例。


六、几种常见实现方式

方式一:闭包实现单例(最适合入门)

function createSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        id: Date.now(),
      }
    }
    return instance
  }
}

const getInstance = createSingleton()
const obj1 = getInstance()
const obj2 = getInstance()

console.log(obj1 === obj2) // true
优点
  • 容易理解
  • 不依赖 class
  • 很适合讲清“缓存实例”的本质
缺点
  • 如果逻辑复杂,代码可维护性一般

方式二:类 + 静态属性

class LoginDialog {
  static instance = null

  constructor() {
    if (LoginDialog.instance) {
      return LoginDialog.instance
    }

    this.visible = false
    LoginDialog.instance = this
  }

  open() {
    this.visible = true
    console.log('登录弹窗打开')
  }
}

const dialog1 = new LoginDialog()
const dialog2 = new LoginDialog()

console.log(dialog1 === dialog2) // true
优点
  • 结构清晰
  • 更贴近传统设计模式写法
  • 适合面试表达
缺点
  • 对前端业务代码来说有时略显“重”

方式三:模块单例(现代前端最常见)

// auth-store.js
const authStore = {
  token: '',
  setToken(token) {
    this.token = token
  },
}

export default authStore
import authStore from './auth-store.js'
为什么它是单例?

因为模块只会初始化一次,后续导入拿到的是同一个模块实例引用。

优点
  • 写法最自然
  • 非常适合工程化项目
  • 不需要显式写 getInstance
缺点
  • 初学者可能“用了单例却没意识到自己在用单例”

方式四:惰性单例(Lazy Singleton)

惰性单例指的是:

不在一开始就创建实例,而是在第一次真正需要时才创建。

function getModal() {
  if (!getModal.instance) {
    getModal.instance = {
      createdAt: Date.now(),
      show() {
        console.log('显示 modal')
      },
    }
  }

  return getModal.instance
}

这种方式很常见,因为很多全局对象并不一定在页面加载时就需要。

惰性单例的意义

  • 减少初始加载开销
  • 按需创建资源
  • 更适合弹窗、通知、复杂组件容器

七、单例模式的优点与缺点

任何设计模式都不是“银弹”,单例也一样。

优点

1. 节省资源

只创建一次对象,避免重复初始化。

2. 统一状态管理

所有地方访问的都是同一份实例,状态天然一致。

3. 便于全局协调

适合处理:

  • 全局配置
  • 全局弹窗
  • 全局缓存
  • 全局事件中心
4. 减少重复代码

不必每次都手动创建和管理相同对象。

缺点

1. 全局状态过多会让系统变复杂

一旦所有东西都做成单例,项目就会慢慢变成“全局变量乐园”。这可不是什么嘉年华。

2. 测试不友好

单例在测试中容易产生状态污染。

比如:

  • 上一个测试改了实例状态
  • 下一个测试拿到的还是同一个实例
  • 测试之间互相影响
3. 模块耦合增强

很多模块都依赖某个全局单例时,重构会变困难。

4. 容易被滥用

不是“全局都能访问”就该用单例,只有确实应该全局唯一时才适合。


八、使用单例时的常见误区

误区 1:把普通工具函数也做成单例

比如一个纯函数工具库:

function formatDate(date) {
  return String(date)
}

这种函数没有状态,不需要单例。

没有状态、没有初始化成本、没有唯一资源约束的对象,通常没必要单例化。

误区 2:把“全局可访问”误认为“必须单例”

全局可访问 ≠ 必须只有一个实例。

比如:

  • 表单校验器可能每个表单都应该有独立实例
  • 图表对象可能每个图表容器都应该各自创建

误区 3:忽略实例重置能力

在测试或热更新环境中,有些单例需要支持重置,否则状态会残留。

let instance = null

export function getInstance() {
  if (!instance) {
    instance = { count: 0 }
  }
  return instance
}

export function resetInstance() {
  instance = null
}

误区 4:把单例当作“解决一切共享问题”的万能方案

如果共享状态越来越复杂,应该考虑:

  • 状态管理库(Pinia / Redux / Zustand)
  • 依赖注入
  • 组合式函数(composables)
  • 上下文容器

单例是工具,不是宗教。


九、面试中怎么回答单例模式

如果面试官问:

你怎么理解单例模式?前端中有哪些应用?

你可以这样回答:

标准回答模板

单例模式是一种创建型设计模式,核心是保证某个对象在系统中只有一个实例,并提供统一的访问入口。

在前端开发中,它常用于管理全局唯一资源,比如:

  • 全局弹窗
  • 消息提示组件
  • 请求实例
  • 缓存对象
  • 全局配置对象

实现方式通常有:

  • 闭包缓存实例
  • 类的静态属性保存实例
  • ES Module 天然单例

它的优点是节省资源、统一状态;缺点是容易带来全局耦合、测试困难,因此要谨慎使用,避免滥用。

如果面试官继续追问:ES Module 算不算单例?

你可以回答:

在工程实践里,很多模块导出的对象会因为模块缓存机制而表现出单例特征,所以它是一种非常常见的“模块级单例”实现方式。

如果继续追问:单例和全局变量有什么区别?

你可以回答:

  • 全局变量只是“所有地方都能访问”
  • 单例模式强调“唯一实例 + 可控访问入口 + 创建时机管理”

所以单例比裸露的全局变量更有结构,也更便于维护。


十、练习题与思考题

练习 1:实现一个单例缓存对象

要求:

  • 只能创建一个缓存实例
  • 提供 setget 方法

你可以自己先暂停 5 分钟写一下,再参考下面思路:

function createCacheSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        data: new Map(),
        set(key, value) {
          this.data.set(key, value)
        },
        get(key) {
          return this.data.get(key)
        },
      }
    }

    return instance
  }
}

练习 2:实现一个全局登录弹窗管理器

要求:

  • 整个应用中只能有一个登录弹窗实例
  • 支持 open()close()

练习 3:思考哪些场景不适合单例

请判断以下对象是否适合单例,并说明理由:

  • 每个页面一个轮播图实例
  • 全局埋点上报管理器
  • 每个表格一个筛选器对象
  • 全局请求客户端
  • 每个图表一个图表实例

参考答案方向

对象 是否适合单例 原因
每个页面一个轮播图实例 不适合 每个轮播图通常是独立的
全局埋点上报管理器 适合 全局统一上报规则和缓存队列
每个表格一个筛选器对象 不适合 每个表格状态独立
全局请求客户端 适合 请求配置、拦截器应统一
每个图表一个图表实例 不适合 每个容器对应独立实例

十一、学习总结

你应该记住的 4 句话

  1. 单例模式的核心是:一个实例、全局访问。
  2. 前端中凡是“全局唯一资源”,都值得考虑单例。
  3. 现代前端里最常见的单例形式,其实是模块单例。
  4. 不要滥用单例,能局部化的状态就不要硬塞成全局。

一张速记表

问题 结论
单例模式是什么? 保证对象只有一个实例
适合什么场景? 全局配置、消息提示、请求实例、缓存中心
常见实现方式? 闭包、类静态属性、ES Module
最大风险是什么? 全局耦合、状态污染、测试困难
判断标准是什么? 这个对象是否真的应该全局唯一

git cherry-pick Command: Apply Commits from Another Branch

Sometimes the change you need is already written, just on the wrong branch. A hotfix may land on main when it also needs to go to a maintenance branch, or a useful commit may be buried in a feature branch that you do not want to merge wholesale. In that situation, git cherry-pick lets you copy the effect of a specific commit onto your current branch.

This guide explains how git cherry-pick works, how to apply one or more commits safely, and how to handle the conflicts that can appear along the way.

Syntax

The general syntax for git cherry-pick is:

txt
git cherry-pick [OPTIONS] COMMIT...
  • OPTIONS - Flags that change how Git applies the commit.
  • COMMIT - One or more commit hashes, branch references, or commit ranges.

git cherry-pick replays the changes introduced by the selected commit on top of your current branch. Git creates a new commit, so the result has a different commit hash even when the file changes are the same.

Cherry-Picking a Single Commit

Start by finding the commit you want to copy. This example lists the recent commits on a feature branch:

Terminal
git log --oneline feature/auth
output
a3f1c92 Fix null pointer in auth handler
d8b22e1 Add login form validation
7c4e003 Refactor session logic

The output gives you the abbreviated commit hashes. In this case, a3f1c92 is the fix we want to move.

Switch to the target branch before running git cherry-pick:

Terminal
git switch main
git cherry-pick a3f1c92
output
[main 9b2d4f1] Fix null pointer in auth handler
Date: Tue Apr 14 10:42:00 2026 +0200
1 file changed, 2 insertions(+)

Git applies the change from a3f1c92 to main and creates a new commit, 9b2d4f1. The subject line is the same, but the commit hash is different because the parent commit is different.

Cherry-Picking Multiple Commits

If you need more than one non-consecutive commit, pass each hash in the order you want Git to apply them:

Terminal
git cherry-pick a3f1c92 d8b22e1

Git creates a separate new commit for each one. This works well when you need a few targeted fixes but do not want the rest of the source branch.

For a range of consecutive commits, use the range notation:

Terminal
git cherry-pick a3f1c92^..7c4e003

This tells Git to include a3f1c92 and every commit after it up to 7c4e003. If you omit the caret, the starting commit itself is excluded:

Terminal
git cherry-pick a3f1c92..7c4e003

That form applies every commit after a3f1c92 through 7c4e003.

Applying Changes Without Committing

Sometimes you want the changes from a commit, but not an automatic commit for each one. Use --no-commit (or -n) to apply the changes to your working tree and staging area without creating the commit yet:

Terminal
git cherry-pick --no-commit a3f1c92

This is useful when you want to combine several small fixes into one commit on the target branch, or when you need to edit the files before committing.

After reviewing the result, create the commit yourself:

Terminal
git status
git commit -m "Backport auth null-check fix"

This gives you more control over the final commit message and lets you group related backports together.

Recording Where the Commit Came From

For maintenance branches and backports, it is often helpful to keep a reference to the original commit. Use -x to append the source commit hash to the new commit message:

Terminal
git cherry-pick -x a3f1c92

Git adds a line like this to the new commit message:

output
(cherry picked from commit a3f1c92...)

That extra line makes future audits easier, especially when you need to prove that a fix on a release branch came from a reviewed change on another branch.

Cherry-Picking a Merge Commit

Cherry-picking a regular commit is straightforward, but merge commits need one extra option. Git must know which parent to treat as the main line:

Terminal
git cherry-pick -m 1 MERGE_COMMIT_HASH
  • -m 1 - Use the first parent as the base, which is usually the branch that received the merge.
  • -m 2 - Use the second parent instead.

If you are not sure which parent is which, inspect the history first:

Terminal
git log --oneline --graph

Cherry-picking merge commits is more advanced and easier to get wrong. If the goal is to bring over an entire merged feature, a normal merge is often clearer than cherry-picking the merge commit itself.

Resolving Conflicts

If the target branch has changed in the same area of code, Git may stop and ask you to resolve a conflict:

output
error: could not apply a3f1c92... Fix null pointer in auth handler
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".

Open the conflicted file and look for the conflict markers:

output
<<<<<<< HEAD
return session.getUser();
=======
if (session == null) return null;
return session.getUser();
>>>>>>> a3f1c92 (Fix null pointer in auth handler)

Edit the file to keep the final version you want, then stage it and continue:

Terminal
git add src/auth.js
git cherry-pick --continue

If you decide the commit is not worth applying after all, abort the operation:

Terminal
git cherry-pick --abort

git cherry-pick --abort puts the branch back where it was before the cherry-pick started.

A Safe Backport Workflow

When you cherry-pick onto a release or maintenance branch, slow down and make the source obvious. A simple workflow looks like this:

Terminal
git switch release/1.4
git pull --ff-only
git cherry-pick -x a3f1c92
git status

The important part is the sequence. Start from the branch that needs the fix, make sure it is up to date, cherry-pick with -x, then review and test the branch before pushing it. This avoids the common mistake of copying a fix into an outdated branch and shipping an untested backport.

If the picked commit depends on earlier refactors or new APIs that are not present on the target branch, stop there. In that case, either copy the prerequisite commits too or recreate the fix manually.

When to Use git cherry-pick

git cherry-pick is a good fit when you need a precise change without the rest of the branch:

  • Backporting a bug fix from main to a release branch
  • Recovering one useful commit from an abandoned feature branch
  • Moving a small fix that was committed on the wrong branch
  • Pulling a reviewed change into a hotfix branch without merging unrelated work

Avoid it when the target branch needs the full context of the source branch. If the commit depends on earlier commits, shared refactors, or schema changes, a merge or rebase is usually the cleaner option.

Troubleshooting

error: could not apply ... during cherry-pick
The target branch has conflicting changes. Resolve the files Git marks as conflicted, stage them with git add, then run git cherry-pick --continue.

Cherry-pick created duplicate-looking history
That is normal. Cherry-pick copies the effect of a commit, not the original object. The new commit has a different hash because it has a different parent.

The picked commit does not build on the target branch
The commit likely depends on earlier work that is missing from the target branch. Inspect the source branch history with git log and either cherry-pick the prerequisites too or reimplement the change manually.

You picked the wrong commit
If the cherry-pick already completed, use git revert on the new commit. If the operation is still in progress, use git cherry-pick --abort.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Pick one commit git cherry-pick COMMIT
Pick several specific commits git cherry-pick C1 C2 C3
Pick a consecutive range including the first commit git cherry-pick A^..B
Apply changes without committing git cherry-pick --no-commit COMMIT
Record the source commit in the message git cherry-pick -x COMMIT
Continue after resolving a conflict git cherry-pick --continue
Skip the current commit in a sequence git cherry-pick --skip
Abort the operation git cherry-pick --abort
Pick a merge commit git cherry-pick -m 1 MERGE_COMMIT

FAQ

What is the difference between git cherry-pick and git merge?
git cherry-pick copies the effect of selected commits onto your current branch. git merge joins two branch histories and brings over all commits that are missing from the target branch.

Does cherry-pick change the original commit?
No. The source commit stays exactly where it is. Git creates a new commit on your current branch with the same file changes.

Should I use -x every time?
Not always, but it is a good habit for backports and maintenance branches. It gives you a clear link back to the original commit.

Can I cherry-pick commits from another remote branch?
Yes. Fetch the remote branch first, then cherry-pick the commit hash you want. You can inspect the incoming history with git log or review the file changes with git diff before applying anything.

Conclusion

git cherry-pick is the tool to reach for when one commit matters more than the branch it came from. Use it for targeted fixes, keep -x in mind for backports, and fall back to merge or rebase when the change depends on broader branch context.

基于 Element Plus 的企业级主题定制方案:SCSS 变量覆盖 + Vite 全局注入实战

前言

前端项目中,UI 组件库的主题定制是一个常见但又容易做"脏"的需求。常见的做法是在组件上疯狂加 !important 覆盖样式——短期有效,长期维护噩梦。

本文基于实际项目(某 SaaS 系统)中的主题定制实践,分享一套规范、可维护、可扩展的 Element Plus 主题定制方案。核心涉及:

  • Vite additionalData 实现 SCSS 全局注入
  • Element Plus SCSS 变量覆盖 API
  • 按钮状态系统设计
  • CSS 变量双层架构
  • 多组件覆盖与渐进式演进

一、问题背景

1.1 为什么需要定制主题?

该SaaS 系统,有以下特点:

  • 多品牌:需要同时支持(蓝色系)和(红色系)两套皮肤
  • 多组件:大量使用 Element Plus 的 Button、Checkbox、Radio、Select、DatePicker 等组件
  • 快速迭代:需要频繁调整主题色,不能每次都改源码

1.2 常见方案的弊端

方案 弊端
直接覆盖 .el-button CSS 类 样式分散、优先级混乱、升级组件库后失效
每个页面单独写样式文件 大量重复、无法复用、难以维护
修改 Element Plus 源码 升级即丢失、不利于长期维护
CSS !important 强行覆盖 优先级战争、样式冲突、维护噩梦

正确的思路:利用 Element Plus 提供的 SCSS 变量覆盖机制,在编译层面定制主题。


二、技术方案:总体架构

2.1 文件结构

src/assets/style/
├── elementPlus/
│   ├── index.scss          # CSS 变量层(:root 定义)
│   ├── theme.scss          # SCSS 变量覆盖层(编译时)
│   ├── button/
│   │   └── button.scss     # 按钮专项覆盖
│   ├── checkbox/
│   │   └── checkbox.scss
│   ├── date/
│   │   └── date.scss
│   └── select/
│       └── select.scss
└── common.less             # 全局通用样式(含 .theBtn 等)

2.2 两层变量架构

┌─────────────────────────────────────────────────────┐
│  Layer 1:SCSS 变量(编译时)                        │
│  theme.scss @forward 'element-plus/theme-chalk/...'  │
│  覆盖 Element Plus 内部的 $colors / $button / $checkbox │
│  ↓ 生成 CSS Custom Properties(--el-color-primary 等) │
├─────────────────────────────────────────────────────┤
│  Layer 2:CSS 变量(运行时)                          │
│  index.scss :root { --xx-button-text-color: ... }   │
│  覆盖 Element Plus 组件未覆盖到的自定义变量            │
│  ↓ 被 button.scss / common.less 等直接引用            │
├─────────────────────────────────────────────────────┤
│  Layer 3:组件专项覆盖                                │
│  button.scss / checkbox.scss 等                      │
│  处理组件内部特殊的、变量系统覆盖不到的状态            │
└─────────────────────────────────────────────────────┘

三、Vite 全局注入:additionalData

3.1 核心配置

这是整个方案的根基。在 vite.config.js 中配置:

// vite.config.js
export default defineConfig(({ mode }) => {
  return {
    // ... 其他配置
    css: {
      devSourcemap: true,  // 开发时保留 sourcemap 方便调试
      preprocessorOptions: {
        scss: {
          additionalData: `
            @use "@/assets/style/elementPlus/theme.scss" as *;
            @use "@/assets/style/elementPlus/index.scss" as *;
          `,
        },
      },
    },
  }
})

3.2 工作原理

additionalData 的作用是:在编译每个 SCSS 文件时,自动将指定内容 prepend 到文件头部

等效于在项目的每一个 SCSS 文件首行都自动插入了这两行 import:

@use "@/assets/style/elementPlus/theme.scss" as *;
@use "@/assets/style/elementPlus/index.scss" as *;

好处

  1. 零侵入:业务组件无需手动 import 主题文件
  2. 强一致性:所有文件引用同一套变量,不存在版本不一致
  3. 编译时展开:变量在编译时展开,运行时零开销

3.3 main.js 中的入口处理

同时在 main.js 中移除 Element Plus 默认全量 CSS,替换为按需覆盖:

import ElementPlus from 'element-plus'
- import 'element-plus/dist/index.css'  // 全量默认样式,移除
+ import '@/assets/style/index.scss'      // 替换为按需覆盖

效果:不加载 Element Plus 几十 KB 的默认 CSS,通过 SCSS 变量按需生成样式,减小产物提及。


四、Element Plus SCSS 变量覆盖

4.1 核心文件 theme.scss

// 覆盖 element-plus/theme-chalk/src/common/var.scss 中的变量
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary':   ('base': #409eff),   // 主色
    'success':   ('base': #4CAF50),   // 成功绿
    'warning':   ('base': #D87214),   // 警告橙
    'error':     ('base': #f56c6c),   // 错误红
    'info':      ('base': #909399),   // 信息灰
  ),
  $font-family: (
    (''): "'PingFangSC-Regular, PingFang SC', Helvetica, 'PingFang SC', 'Hiragino Sans GB', Arial, sans-serif"
  ),
  $checkbox: (
    (
      'border-radius': '4px',
      'checked-text-color': #409eff,
      'checked-input-border-color': #409eff,
      'checked-bg-color': #409eff,
      'checked-icon-color': #fff,
      'input-border-color-hover': #409eff,
    )
  ),
  $radio-checked: (
    (
      'icon-color': #409eff,
      'text-color': #409eff,
    )
  ),
  $select: (
    (
      'input-focus-border-color': #2C2836,
    )
  ),
  $input: (
    (
      'focus-border-color': #2C2836,
    )
  ),
  $pagination: (
    (
      'button-bg-color': #fff,
    )
  ),
  $button: ((
    'hover-text-color': #409eff,
    'hover-link-text-color': #409eff,
  )),
);

@use "element-plus/theme-chalk/src/index.scss" as *;

4.2 原理讲解

这段代码的核心是 SCSS 模块系统

@forward 'element-plus/theme-chalk/src/common/var.scss' with (...)
  • @forward:转发 Element Plus 的变量声明文件,但允许在转发时用 with (...) 覆盖其中的默认值
  • with (...) 块中定义的值,会替换 Element Plus 内部的 SCSS 变量(编译时生效)
  • 最终这些变量被 Element Plus 的 SCSS 源码使用,生成对应的 CSS Custom Properties(--el-color-primary 等)

不需要修改 Element Plus 一行源码,通过变量覆盖即可定制主题。


五、按钮状态系统设计

5.1 五态全覆盖

按钮是系统中使用最频繁的组件,五种状态都需要精细控制:

// 默认主题色按钮
.el-button--default {
  color: #fff;
  background-color: #409eff;
  border-color: #409eff;
}

// hover:变浅一度
.el-button.is-link:not(.is-disabled):hover,
.el-button.is-link:not(.is-disabled):focus,
.el-button.is-link:not(.is-disabled):active {
  color: var(--xx-button-text-color-light);
}

// active:按压反馈
.el-button:active {
  outline: 0;
}

// text 按钮 hover:浅色降权
.el-button--text:not(.is-disabled):hover,
.el-button--text:not(.is-disabled):active,
.el-button--text:not(.is-disabled):focus {
  color: var(--xx-button-text-color-light);
}

// disabled:透明度统一降权
.el-button--primary.is-plain.is-disabled,
.el-button--primary.is-plain.is-disabled:hover,
.el-button--primary.is-plain.is-disabled:focus,
.el-button--primary.is-plain.is-disabled:active {
  color: var(--xx-text-color-disabled);  // rgba(44,40,54,0.44)
}

5.2 按钮状态体系总结

状态 色值策略 视觉语义
default --el-color-primary 品牌主色,视觉最强
hover --xx-button-text-color-light 浅一度降权,提示可交互
active outline: 0 消除 Focus 环,按压反馈
disabled rgba(44,40,54,0.44) 透明度降权,禁止交互
link 按钮 hover/active/focus 三态全写 link 类型样式特殊,单独覆盖

5.3 禁用态统一规范

// ❌ 常见错误:每个地方单独写 disabled 样式
.el-button.is-disabled { color: #ccc; }
.el-link.is-disabled { color: #ccc; }

// ✅ 规范做法:统一变量
--xx-text-color-disabled: rgba(44,40,54,0.44);

// ✅ 统一引用
.el-button.is-disabled { color: var(--xx-text-color-disabled); }
.el-link.is-disabled { color: var(--xx-text-color-disabled); }

六、CSS 变量双层架构

6.1 index.scss 中的 :root 定义

@use './checkbox/checkbox.scss' as *;
@use './button/button.scss' as *;
@use './date/date.scss' as *;
@use './select/select.scss' as *;

:root {
  --xx-color-select-primary: #409eff;          // 默认蓝色
  --xx-color-red: #df3419;                      // 主题红
  --xx-color-red-light: #fdf3f1;                // 浅红背景

  --xx-button-text-color: var(--el-color-primary);    // 引用 Element Plus 变量
  --xx-button-text-color-light: var(--el-color-primary);

  --xx-text-color-disabled: rgba(44,40,54,0.44);      // 禁用灰
}

6.2 双层变量的引用关系

SCSS 变量(编译时)
  └─→ theme.scss 中定义
       └─→ 覆盖 Element Plus $colors / $button 等
            └─→ 生成 CSS Custom Properties
                 └─→ --el-color-primary

CSS 变量(运行时)
  └─→ index.scss :root 中定义
       ├─→ --xx-button-text-color: var(--el-color-primary)  ← 引用 SCSS 变量展开后的值
       └─→ --xx-color-red: #df3419                          ← 独立定义

业务组件
  └─→ color: var(--xx-button-text-color)  ← 统一引用入口

6.3 消除硬编码

在迭代过程中,原有代码中大量存在硬编码色值:

// common.less - 修改前
.theBtn {
  color: #2E63FD;  // 硬编码蓝色
}

// 修改后
.theBtn {
  color: var(--el-color-info);  // 引用 CSS 变量,随主题切换
}

统一使用 CSS 变量后,切换主题只需修改 theme.scss 中的 SCSS 变量值,所有引用处自动更新。


七、多组件覆盖清单

组件 覆盖点 覆盖方式
Button hover/active/text/link/disabled 五态 专项 SCSS 文件
Checkbox 圆角、选中色、hover border 专项 SCSS 文件
Radio 选中图标色、文字色 theme.scss $radio-checked
Select focus 边框色 theme.scss $select
DatePicker 今日日期文字色 date.scss
Pagination 分页按钮背景色 theme.scss $pagination

八、多主题切换思路

基于 additionalData 的架构,切换主题色只需要在 vite.config.js 中切换 additionalData 的引用文件:

// vite.config.js
const themeFile = env.VITE_THEME === 'red' 
  ? '@/assets/style/elementPlus/theme-red.scss' 
  : '@/assets/style/elementPlus/theme-blue.scss';

css: {
  preprocessorOptions: {
    scss: {
      additionalData: `
        @use "${themeFile}" as *;
        @use "@/assets/style/elementPlus/index.scss" as *;
      `,
    },
  },
}

只需准备两套 theme-xxx.scss 变量文件,即可实现一键换肤,无需改动业务代码。


九、总结

本文分享的主题定制方案有以下核心要点:

  1. @forward with (...):利用 Element Plus 官方 SCSS 变量覆盖 API,编译时定制主题,不改源码
  2. additionalData:Vite 全局注入机制,确保每个 SCSS 文件零侵入地引用主题变量
  3. 两层变量架构:SCSS 变量(编译时)生成 Element Plus CSS 变量,CSS 变量(:root)作为业务覆盖层
  4. 组件专项覆盖:变量系统覆盖不到的特殊状态,通过专项 SCSS 文件覆盖
  5. 消除硬编码:统一使用 CSS 变量,主题切换零改动

这套方案已在生产环境验证,适用于需要多品牌/多主题切换的企业级 Element Plus 项目。


小程序 web-view 内嵌 H5 的会话管理:Token 失效跳转登录的完整方案

前言

在小程序开发中,<web-view> 组件是承载 H5 页面的重要载体。但小程序和 H5 有各自独立的 Storage,token 并不互通。当 H5 页面的 token 失效时,如何让用户顺利跳转到小程序原生登录页,并在登录成功后原路返回?

本文将以实际项目为例,分享一套完整的跨端会话管理方案,涵盖:

  • Token 失效时从 H5 跳转小程序登录页
  • 登录成功后携带参数原路返回
  • 登录页返回按钮的智能控制

一、问题背景

1.1 会话隔离问题

H5 页面通过小程序 <web-view> 内嵌运行时,存在一个典型的跨端会话隔离问题:

存储 作用域 特点
小程序 Storage 小程序进程 独立存储,生命周期跟随小程序
H5 Storage web-view 进程 独立存储,与小程序不互通

核心矛盾:H5 页面 token 失效时,无法通过 H5 页面直接跳转到小程序原生登录页(wx.miniProgram API 在 web-view 中访问受限),用户被"困在" web-view 里。

1.2 期望的用户旅程

H5 活动页 → token 失效 → Toast 提示 → 跳转小程序登录页 
    → 登录成功 → 携带参数返回 H5 页面 → 正常使用

二、Token 失效跳转小程序登录

2.1 核心方法

// pages/webH5View/index.vue
noValidTokenGoMiniProgramLogin() {
  // 1. Toast 提示用户即将跳转
  uni.showToast({
    title: '登录过期,即将进入登录页面',
    icon: 'none',
    duration: 2 * 1000
  })

  // 2. 判断是否在小程序运行环境
  if (typeof window.__wxjs_environment !== 'undefined' 
      && window.__wxjs_environment === 'miniprogram') {
    setTimeout(() => {
      // 3. 通过 JSSDK 跳转小程序原生登录页
      this.$wx.miniProgram.redirectTo({
        url: `/pages/login/login?isShare=1&back=1&page=${encodeURIComponent(
          '/pages/webH5View/index'
        )}&h5Src=${encodeURIComponent(
          'https://testh5/#/index?isMiniprogram=1'
        )}`
      })
    }, 2000)
  }
}

2.2 关键技术点解析

环境检测

window.__wxjs_environment === 'miniprogram'

这是微信 JSSDK 注入的环境变量,不同于 navigator.userAgent,在 web-view 内是唯一可靠的判断方式。

延迟跳转

setTimeout(() => { ... }, 2000)

给 Toast 足够的展示时间,避免用户感到突兀。

携带 H5 原始地址

h5Src=${encodeURIComponent('H5完整地址')}

h5Src 参数记录 H5 访问地址,登录完成后原路返回。

redirectTo vs navigateTo

方法 行为 适用场景
navigateTo 保留当前页面,推入新页面 普通跳转
redirectTo 关闭当前页面,替换为新页面 登录场景,避免返回键回到失效页面

三、登录成功后的回跳链路

3.1 登录页参数接收

// pages/login/login.vue — onLoad
onLoad(option) {
  this.activityH5 = option.activityH5
  this.goId = option.id
  option.page ? this.goPage = option.page : null
  option.h5Src ? this.h5Src = option.h5Src : null  // 接收 H5 地址

  
  // 处理分享页登录场景
  if (option.isShare && option.isShare === '1') {
    this.isShare = option.isShare
    this.toHideBackButton = true
    // 已登录则跳首页
    if (this.$store.getters.userInfo && this.$store.getters.userInfo.token) {
      uni.switchTab({ url: '/pages/index/index' })
    }
  } else {
    // 根据路由栈长度控制返回按钮
    const pages = getCurrentPages()
    this.toHideBackButton = (pages.length > 1) ? false : true
  }
}

3.2 登录成功回跳逻辑

// pages/login/login.vue — 登录成功后处理
if (this.h5Src) {
  // H5 场景:登录完成 → 打开 web-view 并带入 H5 地址
  uni.redirectTo({
    url: `${decodeURIComponent(this.goPage)}?webViewUrl=${this.h5Src}`
  })
} else {
  // 普通场景:直接跳转目标页面
  uni.redirectTo({ url: decodeURIComponent(this.goPage) })
}

四、WebView 加载时的 Token 校验

4.1 完整的 onLoad 流程

// pages/webH5View/index.vue — onLoad
onLoad(option) {
  this.option = option
  this.isShare = option.isShare === '1' ? '1' : '0'

  if (this.userInfo.token) {
    // 有 token → 先校验有效性
    this.isLoginFunction()
  } else {
    // 无 token → 直接打开 H5(H5 侧会触发登录流程)
    this.isLogin = false
    this.handleUrl(this.option)
  }
}

// 是否登陆判断(调用小程序侧接口校验 token)
async isLoginFunction() {
  try {
    const res = await checkMiniProgramLoginFlag()
    this.isLogin = !!res   // token 有效则复用
  } catch (e) {
    this.isLogin = false  // token 失效触发登录
  }
  this.handleUrl(this.option)
}

4.2 URL 参数拼接策略

handleUrl(option) {
  let url = option.webViewUrl 
    ? decodeURIComponent(option.webViewUrl) 
    : decodeURIComponent(option.url || '')
  
  // H5 访问时 token 取空字符串(因为小程序 token 不在 H5 域下)
  let token = this.isLogin ? this.userInfo.token : ''
  
  let params = [
    'token=' + encodeURIComponent(token || ''),
    'userId=' + (this.userInfo.userId || '')
  ]
  
  let baseUrl = url + (url.indexOf('?') !== -1 ? '&' : '?') + params.join('&')
  
  // 标记来源为小程序,供 H5 页面识别并触发登录流程
  if (baseUrl.indexOf('isMiniprogram') === -1) {
    baseUrl = baseUrl + '&isMiniprogram=1'
  }
  
  this.hrefUrl = baseUrl
}

五、登录页返回按钮智能控制

5.1 问题场景

登录页作为入口页面,如果按返回键:

场景 行为 结果
有历史页面 正常返回上一页 ✅ 正常
无历史页面(直接打开小程序) 返回到空页面 ❌ 体验差

5.2 解决方案

// pages/login/login.vue — onLoad
if (option.isShare && option.isShare === '1') {
  // 分享页进入:强制隐藏返回按钮
  this.toHideBackButton = true
} else {
  // 正常进入:根据路由栈长度智能判断
  const pages = getCurrentPages()
  this.toHideBackButton = (pages.length > 1) ? false : true
}

5.3 导航栏组件

<!-- topnavigation 接收 isfx prop 控制返回按钮显隐 -->
<topnavigation :isfx="toHideBackButton"></topnavigation>

5.4 逻辑总结

场景 pages.length toHideBackButton 返回按钮
直接打开小程序 → 登录页 1(只有当前页) true 隐藏
从其他页面跳转 → 登录页 ≥ 2 false 显示

六、完整流程图

flowchart TD
    A[用户在小程序内打开 H5 活动页] --> B{web-view 加载 H5 URL}
    B --> C{H5 检测 token}
    C -->|有效| D[H5 正常加载]
    C -->|失效| E[调用 noValidTokenGoMiniProgramLogin]
    E --> F{判断环境}
    F -->|小程序环境| G[2秒后 wx.miniProgram.redirectTo]
    F -->|非小程序| H[普通 H5 登录流程]
    G --> I[跳转小程序登录页<br/>携带 isShare/back/h5Src 参数]
    I --> J{登录页 onLoad}
    J --> K{isShare=1?}
    K -->|是| L[强制隐藏返回按钮]
    K -->|否| M[根据路由栈长度判断]
    M --> N{pages.length > 1?}
    N -->|是| O[显示返回按钮]
    N -->|否| P[隐藏返回按钮]
    L --> Q[用户完成登录]
    O --> Q
    P --> Q
    Q --> R{有 h5Src?}
    R -->|是| S[打开 web-view<br/>携带 webViewUrl=h5Src]
    R -->|否| T[普通跳转目标页面]
    S --> U[web-view 重新 onLoad]
    U --> V[checkMiniProgramLoginFlag 校验 token]
    V -->|有效| W[token 拼接进 URL]
    V -->|失效| E
    W --> D

七、踩坑记录

7.1 navigateTo 会导致返回键回到失效页面

错误做法

wx.miniProgram.navigateTo({
  url: '/pages/login/login?back=1'
})

正确做法

wx.miniProgram.redirectTo({
  url: '/pages/login/login?back=1&h5Src=...'
})

7.2 环境判断不能依赖 UA

错误做法

navigator.userAgent.includes('miniProgram')  // 不可靠

正确做法

window.__wxjs_environment === 'miniprogram'  // JSSDK 注入,可靠

7.3 忘记 encodeURIComponent

错误做法

h5Src=https://example.com/#/page?activityId=123&isMiniprogram=1
// URL 中的 ? 和 & 会与小程序路由参数冲突

正确做法

h5Src=${encodeURIComponent('https://example.com/#/page?activityId=123&isMiniprogram=1')}

八、总结

本文分享了一套小程序 <web-view> 内嵌 H5 的完整会话管理方案,核心要点:

  1. 环境检测:使用 window.__wxjs_environment 判断运行环境
  2. 跨端跳转:通过 JSSDK 的 wx.miniProgram.redirectTo 实现 H5 → 小程序原生页面跳转
  3. 参数透传h5Src 参数记录原始 H5 地址,登录成功后原路返回
  4. 智能 UI:根据路由栈长度控制返回按钮显隐,提升用户体验

这套方案已在生产环境验证,希望能帮助到有类似需求的开发者。


参考资料


Next.js精通SEO第一章(引言)

SEO介绍

SEO(Search Engine Optimization),即搜索引擎优化,是一种通过优化网站结构和内容,提高网站在搜索引擎中的排名,从而吸引更多流量和用户的策略。

tips: SEO是一个长期优化过程(一般优化1-3个月才能看到效果),无需急于求成。

黑帽SEO

黑帽SEO是指通过不正当的手段,如关键词堆砌、隐藏文本、欺诈性链接等,来提高网站在搜索引擎中的排名。这种做法虽然可以在短期内获得较好的效果,但长期来看会对网站造成严重的负面影响,甚至可能导致网站被搜索引擎惩罚。

例如我们在Google搜索笔记本,我们找排名第一的网站

image.png 然后进去网站之后

鼠标右键->查看网页源代码,发现他用了非常多的关键词堆砌(笔记本),这就是黑帽SEO(豆包说的,不是我说的😏)。

image.png

白帽SEO

白帽SEO就是通过正当技术手段,例如优化TDK,优化网站结构,优化robots.txt,优化sitemap.xml,优化JSON-LD,优化Open Graph,优化Web Vitals等,来提高网站在搜索引擎中的排名。

SEO实践

  1. 理解搜索引擎的工作原理
  2. robots.txt 和 sitemap.xml 的配置
  3. TDK优化 + HTML语义化标签
  4. JSON-LD
  5. Open Graph
  6. Web Vitals
  7. SEO工具的使用

Google搜索引擎

Google 搜索是一款全自动搜索引擎,会使用名为“网页抓取工具”的软件定期探索网络,找出可添加到 Google 索引中的网页。实际上,Google 搜索结果中收录的大多数网页都不是手动提交的,而是网页抓取工具在探索网络时找到并自动添加的。

Google搜索引擎原理

Google 搜索的工作流程分为 3 个阶段:

  1. 抓取:Google 会使用名为“抓取工具”的自动程序从互联网上发现各类网页,并下载其中的文本、图片和视频。
  2. 索引编制:Google 会分析网页上的文本、图片和视频文件,并将信息存储在大型数据库 Google 索引中。
  3. 呈现搜索结果:当用户在 Google 中搜索时,Google 会返回与用户查询相关的信息。

所以答案就是:抓取->索引编制->呈现搜索结果

抓取

谷歌会使用(Googlebot)去抓取网页,Googlebot也被称为(抓取工具、漫游器或“蜘蛛”程序),他会通过算法来决定哪些网页需要抓取,并且确保不会过快抓取,以免对网站造成负担。

那么它是怎么抓取的呢?

  1. 通过链接抓取例如你的网站有a标签,那么Googlebot会通过a标签的href属性来抓取网页。<a href="https://www.xxxxxx.com">xxxxx</a>

  2. robots.txt(告诉爬虫机器人哪些页面可以抓取,哪些页面不能抓取,后面会详细讲)

  3. 站点地图 sitemap.xml(列出网站中的网页、文件、视频等 URL,方便爬虫发现和抓取这些资源

  4. 如果网站未收录,可以通过Google Search Console提交网站。

image.png

  1. RSS订阅,例如你的网站有RSS订阅,那么Googlebot会通过RSS订阅来抓取网页。

  2. 重定向,谷歌机器人也会根据你301/302重定向来抓取网页。

  3. JavaScript,现代谷歌浏览器已经可以识别JavaScript代码中动态生成的链接,也会被收录。

索引编制

什么是索引编制?

  1. 索引编制是把抓取到的内容匹配成用户查询的形式,插入到索引数据库中。用户搜索时,Google 是在索引数据库中进行匹配和排序的,并不是实时抓取全网的,所以你修改的网页一般要(2-3周)才会被同步

  2. 被抓取 ≠ 被索引如果你在代码中编写了noindex,则该页面不会加入索引数据库中。

<meta name="robots" content="noindex">
  1. 索引信号 索引信号是指Googlebot分析网页的内容,例如TDKHTML语义化标签JSON-LDOpen GraphWeb Vitalsalt属性,分析这些内容和网站质量,用于进行评估提升排名。

  2. 注意事项 如果你的网站有以下情况,则会被降低排名:伪装真实内容 滥用门页 滥用过期域名 被黑内容 滥用隐藏文字和链接 关键字堆砌 垃圾链接 机器生成的流量 恶意软件和恶意行为 误导性功能 滥用规模化内容 滥用网站声誉 内容贫乏的联属营销 用户生成的垃圾内容

原文链接:developers.google.com/search/docs…

呈现搜索结果

谷歌官方承诺:Google 不会通过收取费用来提高网页排名,网页排名是程序化地完成的(靠的是你对SEO的实力)

image.png

  1. 排名的考量(相关性-内容与搜搜意图的匹配)(权威性-域名权重,外链质量)(用户体验-加载速度SEO友好)
  2. 收录,在被抓如到索引之后,通常是2-3周才会被收录,排名需要一段时间的积累权重,一般是2-3个月。
  3. 结果,搜索的结果会全方面考量,用户的语言,设备,历史记录,SEO优化的是整体,而不是固定某个位置。

RN 的新模块系统 Turbo module

本文所有代码如果没有特别标注的话,默认用的都是 v0.76.0 的 RN 代码

TurboModule 是什么

要讲清楚这个问题,我们要先从 bridge 架构的 Native module 系统开始说起,旧架构的 Native module 系统的调用流程大概是这样的:

JS code
  │
  │   UIManager.dispatchViewManagerCommand(tag, commandID, params)
  ▼
+----------------------+
| JS UIManager proxy   |
| (generated function) |
+----------------------+
  │
  │   enqueueNativeCall(moduleID, methodID, args)
  ▼
+----------------------+
|   BatchedBridge      |
|   call queue         |
+----------------------+
  │
  │   batch + flush
  ▼
+----------------------+
|    Bridge payload    |
| [moduleIDs]          |
| [methodIDs]          |
| [params]             |
+----------------------+
  │
  │   pass to native
  ▼
+----------------------+
|  Native bridge side  |
+----------------------+
  │
  ├─ lookup module by moduleID
  ├─ lookup method by methodID
  └─ convert args
  ▼
+----------------------+
|  Native UIManager    |
+----------------------+
  │
  ├─ resolve reactTag
  ├─ resolve command
  └─ dispatch
  ▼
+----------------------+
| ViewManager / View   |
+----------------------+
  │
  ▼
Actual native UI effect

首先 JS 在调用的时候需要知道有哪些模块,以及对应模块的方法,这些资讯是由宿主侧在初始化阶段注入的

当 JS 调用 Native module 时,JS 还有一层代理(proxy)层将模块方法的调用统一转换成 enqueueNativeCall 方法的调用

当这个方法进入 JS 侧 bridge 的时候,会被打包、序列化后传送给宿主侧的 bridge 由这边的 bridge 进行反序列化、解析后调用对应的原生模块方法;如果这个原生模块的方法存在回调,还需要把这个流程反过来一次,然后找到对应的 callID(JS 调用原生模块方法的编号)拿到对应的 callback 执行

这种调用模式会有什么问题呢?我们用一个表格来看看:

旧 Native Module 的架构问题 问题描述 TurboModule 怎么修复
以 Bridge 为中心 模块系统依赖跨桥消息模型,能力受限(强制异步、对高频/小细粒度调度不友好等等) 改为 JSI binding 为中心,使得模块可以像 JS runtime 中的本地对象能力被调用
接口契约松散 原生模块缺乏单一数据源约束,JS/Android/IOS 各自实现,三者依靠文档、约定、测试保持一致,长期容易造成平台不一致重构风险高团队协作成本上升等问题 用 spec 来约束类型 + Codegen 根据类型约束生成 C++/Android/IOS 侧脚手架文件强化契约,建立单一数据源
生命周期治理弱 旧原生模块系统虽然也支持懒加载,但它的生命周期依赖 bridge 上下文驱动,生命周期管理边界也十分分散 TurboModuleManager 统一按需创建和管理
支持两层缓存:模块实例、JS 方法属性按需加载
UI 模块和普通原生能力混杂 旧 Native Module 既承载普通能力模块,也承载像 UIManager 这类 UI 基础设施,导致 “能力模块” 和 “渲染/视图系统” 职责混杂,抽象层次不清晰 TurboModule 负责普通原生能力;Fabric 负责 UI/视图系统,从架构上完成职责拆分

基于这些区别,现在可以回答本章节标题的问题了:TurboModule system 是 RN 新架构中面向非 UI 原生能力的模块系统,它以 JS runtime 为中心,替代框架中以 bridge 为中心的 Native Module system;并通过更清晰的模块边界、按需创建和更强的接口契约支持 RN 架构长期演进

设计方案

新旧模块兼容方案

在进入架构设计前,我们需要先了解一些背景知识:

新的 TurboModule 不仅重写了整个 NativeModule 系统,还更改了之前的调用方式

在 Bridge 时代,NativeModule 的调用是这样子的:

import { NativeModules } from 'react-native';

NativeModules.Vibration.vibrate(500);

但是在 TurboModule 中,调用方式变成了:

import { TurboModuleRegistry } from 'react-native';

const NativeVibration = TurboModuleRegistry.getEnforcing('Vibration')
NativeVibration.vibrate(500)

个人猜测改变调用方式的动机有两个:

  1. 相比于之前分散的模块,新的方法按模块名从 TurboModule 系统取,更适合“按名称查找”和“按需解析”的新语义,且加强了原生模块之间的内聚性
  2. 考虑到原生模块的迁移成本,通过 TurboModuleRegistry 能更好的适配旧的原生模块调用方式,使得旧模块也能在新架构上继续使用

特别是第二点,RN 为了让新架构也能兼容旧写法的 Native module,设计了如下的兼容流程:

RN_legacy_vs_turboModule

说明一下:

  1. 当我们在新架构调用了 Vibration.vibrate(500) 方法后,会进入到对应的 spec 文件中
  2. spec 文件就是 typescript 编写的文件,它声明了原生模块的类型,并且会暴露一个 TurboModuleRegistry.getEnforcing('Vibration') 方法的返回值
  3. getEnforcing 方法中,会判断当前的模块是否为 TurboModule
  4. 【接步骤 3】如果是,则通过原生平台的 TurboModuleProvider 模块去查找对应实现(对应图中最右边的橘色正方形)
  5. 【接步骤 3】如果不是,则要先判断是否要启用兼容逻辑(判断依据是 useLegacyNativeModuleInterop 是否为 true,只有当前 RN 在 bridge 模式或者原生平台提供了 LegacyModuleProvider 才会返回 true)
  6. 【接步骤 5】如果不启用则直接返回 null,因为没有找到对应原生模块
  7. 【接步骤 5】如果启用了,就判断原生平台是否提供 LegacyModuleProvider,如果有则走 LegacyModuleProvider 的逻辑
  8. 【接步骤 7】如果没有就回退到 bridge 的 enqueueNativeCall 解决方案(对应到我们开篇的流程图)

至于 TurboModuleProviderLegacyModuleProvider 分别做了什么,以及 RN 是如何管理模块的生命周期,我们可以来看看下个章节的架构设计~

调用链路

由于 LegacyModuleProviderTurboModuleProvider 基本类似,区别在于 LegacyModuleProvider 需要兼容之前基于 Bridge 的原生模块,所以我们重点看一下新的 TurboModule 调用链路:

turboModule flow

TurboModule 的调用链路中总共有两个主要角色:Js runtime 以及 C++ 中的 TurboModuleBinding

看过本专栏 JSI 文章的读者应该知道,在新架构中的 XXXbinding 一般就是负责把各种能力挂到 global 对象上

整个调用链路分为两个部分:初始化阶段、调用阶段

在 RN 应用初始化过程中,会调用 TurboModuleBinding::install 方法,这个方法会做两件事:

  1. 往 global 挂 __turboModuleProxy 属性,这是一个 C++ 侧的方法,也是 JS 调用 turboModule 的入口
  2. 如果当前是 bridgeless 模式(也就是使用 JSI),会往 global 挂 nativeModuleProxy 属性,这也是一个 C++ 侧的方法,负责兼容老架构的原生模块

至此,初始化阶段就完成了,接下来进入调用阶段

调用的发起点是 JS 侧(对应图中左侧 JS runtime),总共分为两步:

  1. 获得 TurboModule 对应 JS 对象的引用(对应到图中左侧的 jsRepresentation,为了方便理解我们把它赋值给了 NativeVibration 这个更加语义化的变量)
  2. 从该引用取得方法然后调用

首先是第一步获取 TurboModule 对应 JS 对象的引用,我们可以通过 global.__turboModuleProxy 取得,这是一个可以通过 JSI 调用的 C++ 方法,最后会调用 TurboModuleBinding::getModule 方法,该方法会做四件事:

  1. 创建一个 JS 对象 jsRepresentation,用来存放后续所需 TurboModule 的实例
  2. 去宿主平台的 TurboModuleManager(IOSAndroid)取得对应 TurboModule 的实例
  3. 把 TurboModule 的实例放到 jsRepresentation.__proto__ 中,这是 TurboModule 系统实现模块按照方法/属性级别缓存的主要设计
  4. 最后,我们把 jsRepresentation 返回给 JS runtime,让 JS 持有这个对象的引用以便后续直接调用其中的方法

以上的步骤只有第一次的时候需要,后续由于 JS 已经持有了 jsRepresentation 的引用,所以 JS 直接通过这个对象访问所需方法即可~

源码解析

在这个小节,我们来看看上个小节的初始化以及调用阶段的具体代码实现

首先是初始化阶段,我们先来看看 TurboModuleBinding::install 是怎么实现的:

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleBinding.cpp

// 这个方法会在应用初始化的时候被宿主平台各自实现的 TurboModuleManager 调用
// 目的是为了在 global 对象上挂载原生模块调用所需方法
void TurboModuleBinding::install(
    jsi::Runtime &runtime, TurboModuleProviderFunctionType &&moduleProvider,
    TurboModuleProviderFunctionType &&legacyModuleProvider,
    std::shared_ptr<LongLivedObjectCollection> longLivedObjectCollection) {
  // 挂载 __turboModuleProxy
  runtime.global().setProperty(
      runtime, "__turboModuleProxy",
    // 这是一个 C++ 方法
      jsi::Function::createFromHostFunction(
          runtime, jsi::PropNameID::forAscii(runtime, "__turboModuleProxy"), 1,
        // 传入了一个 lymbda,这个 lymbda 会在 JS 访问 global.__turboModuleProxy 时被调用
          [binding = TurboModuleBinding(runtime, std::move(moduleProvider),
                                        longLivedObjectCollection)](
              jsi::Runtime &rt, const jsi::Value &thisVal,
              const jsi::Value *args, size_t count) {
            // 检查参数的代码
            if (count < 1) {
              throw std::invalid_argument(
                  "__turboModuleProxy must be called with at least 1 argument");
            }
            std::string moduleName = args[0].getString(rt).utf8(rt);
            // 真正做事的方法
            return binding.getModule(rt, moduleName);
          }));

  // 因为 0.76.0 版本还需要兼容之前的 bridge 模式,所以会用 RN$Bridgeless 来标识现在用的是 JSI 架构的代码
  if (runtime.global().hasProperty(runtime, "RN$Bridgeless")) {
    bool rnTurboInterop = legacyModuleProvider != nullptr;
    auto turboModuleBinding =
        legacyModuleProvider ? std::make_unique<TurboModuleBinding>(
                                   runtime, std::move(legacyModuleProvider),
                                   longLivedObjectCollection)
                             : nullptr;
    auto nativeModuleProxy = std::make_shared<BridgelessNativeModuleProxy>(
        std::move(turboModuleBinding));
    defineReadOnlyGlobal(runtime, "RN$TurboInterop",
                         jsi::Value(rnTurboInterop));
    // 主要代码,目的是挂载 nativeModuleProxy 到 global 对象上
    defineReadOnlyGlobal(
        runtime, "nativeModuleProxy",
        jsi::Object::createFromHostObject(runtime, nativeModuleProxy));
  }
}

接下来我们来看看 getModule 做了什么:

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleBinding.cpp

// 获得具体模块实现的方法,目标是最后返回一个带着模块实现的 JS 对象 jsRepresentation
jsi::Value TurboModuleBinding::getModule(jsi::Runtime &runtime,
                                         const std::string &moduleName) const {
  std::shared_ptr<TurboModule> module;
  {
    SystraceSection s("TurboModuleBinding::moduleProvider", "module",
                      moduleName);
// 调用双端实现的 TurboModuleProvider 来获取对应的模块实例
    module = moduleProvider_(moduleName);
  }
  if (module) {
    // 这里是第一层缓存
    // 如果这不是第一次获取该模块实例,我们可以从模块中找到上一次返回的 jsRepresentation 
    // 这里直接返回即可,无需再创建一次 jsRepresentation 对象
    auto &weakJsRepresentation = module->jsRepresentation_;
    if (weakJsRepresentation) {
      auto jsRepresentation = weakJsRepresentation->lock(runtime);
      if (!jsRepresentation.isUndefined()) {
        return jsRepresentation;
      }
    }

    // 如果是第一次获取该模块实例,我们就需要先创建一个空的 jsRepresentation 对象
    jsi::Object jsRepresentation(runtime);
    weakJsRepresentation =
        std::make_unique<jsi::WeakObject>(runtime, jsRepresentation);

    // ⚠️ 核心代码
    // jsRepresentation 解决的是 “实例属性的缓存问题”
    // 具体做法是:
    // 1. 第一次创建并返回的 jsRepresentation 是一个空对象,但是我们用原型链将其与模块实例关联起来
    // 2. 如果我们第一次调用了该模块的方法,会因为当前对象为空而去原型链找,于是就进入了模块原型的 get 方法
    // 3. 模块的 get 方法被触发会把对应的属性缓存在 jsRepresentation 的属性中
    // 4. 这样一来,后面如果我们再次调用同样的方法,就可以直接在 jsRepresentation 的属性中查找到,不需要再进行原型链查找了
    // 具体的实现等我们聊到 TurboModuleProvider 的实现会再进行分析
    auto hostObject =
        jsi::Object::createFromHostObject(runtime, std::move(module));
    jsRepresentation.setProperty(runtime, "__proto__", std::move(hostObject));

    // 把刚刚创建的对象返回给 JS
    return jsRepresentation;
  } else {
    // 如果找不到对应的模块,直接返回 null
    return jsi::Value::null();
  }
}

接下来,我们来看看神秘的 TurboModuleProvider 都做了什么~

TurboModuleProvider 是由平台自行实现,所以会有两套实现,我们先来看看 IOS 的实现:

// in packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

// 这个方法是 IOS 侧把 TurboModule 系统安装进 JS runtime 的入口
// 在这里 IOS 提供了 turboModuleProvider 以及 legacyModuleProvider 并在最后调用了上述 TurboModuleBinding::install 方法
- (void)installJSBindings:(facebook::jsi::Runtime &)runtime
{
  // 创建 turboModuleProvider,可以看到这是一个 lymbda
  // 当 getModule 需要查找模块的时候就会调用这个 lymbda
  // 留意这里的返回值是一个 TurboModule,这个跟后续的 legacyModuleProvider 是一致的
  // 也就是说,新旧原生模块的差异在这里被抹平了
  auto turboModuleProvider = [self,
                              runtime = &runtime](const std::string &name) -> std::shared_ptr<react::TurboModule> {
    auto moduleName = name.c_str();
// 性能埋点
    TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);
    // 判断当前模块是否初始化了,如果没有的话也要埋个性能埋点
    // 这里只记录 objc 实现的原生模块,因为 objc 原生模块的初始化过程比较复杂,这个我们后面会聊
    auto moduleWasNotInitialized = ![self moduleIsInitialized:moduleName];
    if (moduleWasNotInitialized) {
      [self->_bridge.performanceLogger markStartForTag:RCTPLTurboModuleSetup];
    }

    // 关键代码,真正获取模块的逻辑
    // 如果该模块已经初始化了会直接返回实例,否则会执行初始化流程
    auto turboModule = [self provideTurboModule:moduleName runtime:runtime];

    // 初始化性能埋点
    if (moduleWasNotInitialized && [self moduleIsInitialized:moduleName]) {
      [self->_bridge.performanceLogger markStopForTag:RCTPLTurboModuleSetup];
    }

    // 埋点,记录是否获取成功
    if (turboModule) {
      TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    } else {
      TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
    }
    // 返回一个模块实例
    return turboModule;
  };

  // 一个开关,如果在 bridgeless 模式下默认是开启的
  // 用来开启兼容旧模块的开关,在开启的情况下才会提供 legacyModuleProvider
  if (RCTTurboModuleInteropEnabled()) {
    // 这里的代码跟上面 turboModuleProvider 基本一致,区别只在于获取模块的方法变了
    // 这里变成了 provideLegacyModule
    auto legacyModuleProvider = [self](const std::string &name) -> std::shared_ptr<react::TurboModule> {
      auto moduleName = name.c_str();
      
      TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

      // 关键代码
      auto turboModule = [self provideLegacyModule:moduleName];

      if (turboModule) {
        TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
      } else {
        TurboModulePerfLogger::moduleJSRequireEndingFail(moduleName);
      }
      return turboModule;
    };

    // 调用带有 legacyModuleProvide 的 TurboModuleBinding::install
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider), std::move(legacyModuleProvider));
  } else {
    // 调用只有 turboModuleProvider 的 TurboModuleBinding::install
    TurboModuleBinding::install(runtime, std::move(turboModuleProvider));
  }
}

接下来我们就要进入 provideTurboModule 的具体实现(由于 provideLegacyModule 方法逻辑类似,这里就不额外说明了)

不过在看代码之前,我们需要先了解一些背景知识:TurboModule 内部实现到底有几种

  1. 纯 C++ 实现的 TurboModule:最 “纯” 的新架构模块,这也是最接近新架构设计的原生模块

  2. 全局导出的 C++ TurboModule:这个跟第一类其实一样,唯一的区别在于它俩的查找方式不同,上一个是通过 delegate 查找、它是靠 register 查找

  3. Objc 平台模块:这一类泛指所有基于 objc 实现的平台模块,细分下去可以分为三类:

    1. ObjC 实现的 TurboModule:这个指的是由 Objc 实现且实现了 getTurboModule 方法的模块
    2. legacy ObjC NativeModule:这个由 provideLegacyModule 负责处理
    3. RCTCxxModule:这个是在旧架构中使用 C++ 实现的模块,因为在旧架构中 C++ 模块需要 Objc 来中转,所以它在之前也被当成了 Objc 的模块

而 provideTurboModule 查找的顺序也是依据上面的顺序来的,下面我们看看具体代码:

// in packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm

/**
 * provideTurboModule 方法接受一个模块名作为参数,然后查找并返回返回对应的 TurboModule 模块实例
 * 查找流程如下:
 * 1. 查 _turboModuleCache
 * 2. 查 delegate 提供的 pure C++ TurboModule
 * 3. 查 global exported C++ TurboModule map
 * 4. 再查 ObjC module
**/
- (std::shared_ptr<TurboModule>)provideTurboModule:(const char *)moduleName runtime:(jsi::Runtime *)runtime
{
  /**
   * 第一步:如果这个模块已经创建过有缓存了,直接返回缓存
   * _turboModuleCache 是一个 unordered_map,保存 “模块名” 到 “模块实例指针” 的映射
   */
  auto turboModuleLookup = _turboModuleCache.find(moduleName);
  if (turboModuleLookup != _turboModuleCache.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

  /**
   * 第二步:检查纯 C++ 模块(C++ 模块拥有最高优先级)
   */
  if ([_delegate respondsToSelector:@selector(getTurboModule:jsInvoker:)]) {
    int32_t moduleId = getUniqueId();
    TurboModulePerfLogger::moduleCreateStart(moduleName, moduleId);
    // 往 delegate 上找模块的实例
    auto turboModule = [_delegate getTurboModule:moduleName jsInvoker:_jsInvoker];
    if (turboModule != nullptr) {
      // 如果找到了就保存到缓存中
      _turboModuleCache.insert({moduleName, turboModule});
      TurboModulePerfLogger::moduleCreateEnd(moduleName, moduleId);
      // 然后返回实例
      return turboModule;
    }

    TurboModulePerfLogger::moduleCreateFail(moduleName, moduleId);
  }

  /**
   * 第三步:检查全局导出的 C++ 模块
   */
  auto &cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(moduleName);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(_jsInvoker);
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  /**
   * 第四步:找平台相关的模块,在 IOS 中就是 objc 模块
   */
  // 只有当 TurboModuleInterop 关闭或者当前模块是 TurboModule 的时候才会调用 _provideObjCModule 方法查找
  // legacyModule 不在这里处理,而是直接交给了 legacyModuleProvider
  // _provideObjCModule 会找到对应的模块,并且返回模块实例
  id<RCTBridgeModule> module =
      !RCTTurboModuleInteropEnabled() || [self _isTurboModule:moduleName] ? [self _provideObjCModule:moduleName] : nil;

  TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

  // 如果没有找到直接返回空指针
  if (!module) {
    return nullptr;
  }

  // 从模块实例找到对应的类
  Class moduleClass = [module class];

  // 找到模块需要的 queue
  dispatch_queue_t methodQueue = (dispatch_queue_t)objc_getAssociatedObject(module, &kAssociatedMethodQueueKey);
  if (methodQueue == nil) {
    RCTLogError(@"TurboModule \"%@\" was not associated with a method queue.", moduleClass);
  }

  // 根据 queue 创建 nativeMethodCallInvoker
  std::shared_ptr<NativeMethodCallInvoker> nativeMethodCallInvoker =
      std::make_shared<ModuleNativeMethodCallInvoker>(methodQueue);

  // 在 bridgeless 模式下没有 bridge,所以忽略
  // 这个方法主要是把 TurboModule 的 native method invoker 交给 RCTCxxBridge 包装一下,让 bridge 能感知 TurboModule 的异步 native 调用,从而维持 onBatchComplete 等旧 bridge 行为
  if ([_bridge respondsToSelector:@selector(decorateNativeMethodCallInvoker:)]) {
    nativeMethodCallInvoker = [_bridge decorateNativeMethodCallInvoker:nativeMethodCallInvoker];
  }

  // 处理 RCTCxxModule
  if ([moduleClass isSubclassOfClass:RCTCxxModule.class]) {
    // 直接用一个 TurboCxxModule 类包起来完事
    auto turboModule = std::make_shared<TurboCxxModule>([((RCTCxxModule *)module) createModule], _jsInvoker);
    // 还是一样放入 cache 中
    _turboModuleCache.insert({moduleName, turboModule});
    return turboModule;
  }

  // 最后我们来处理有 getTurboModul 方法的 objc 模块
  if ([module respondsToSelector:@selector(getTurboModule:)]) {
    ObjCTurboModule::InitParams params = {
        .moduleName = moduleName,
        .instance = module,
        .jsInvoker = _jsInvoker,
        .nativeMethodCallInvoker = nativeMethodCallInvoker,
        .isSyncModule = methodQueue == RCTJSThread,
        .shouldVoidMethodsExecuteSync = (bool)RCTTurboModuleSyncVoidMethodsEnabled(),
    };

    auto turboModule = [(id<RCTTurboModule>)module getTurboModule:params];
    if (turboModule == nullptr) {
      RCTLogError(@"TurboModule \"%@\"'s getTurboModule: method returned nil.", moduleClass);
    }
    _turboModuleCache.insert({moduleName, turboModule});

    if ([module respondsToSelector:@selector(installJSIBindingsWithRuntime:)]) {
      [(id<RCTTurboModuleWithJSIBindings>)module installJSIBindingsWithRuntime:*runtime];
    }
    return turboModule;
  }

  return nullptr;
}

总结一下,IOS 这块代码主要就是根据不同的模块类型一一处理,并且最后统一包裹成 TurboModule 返回

其中 _turboModuleCache 是一个关键的缓存机制,它保证了模块最多只会初始化一次

RN 团队在注释中有写了一个 TODO 想要把模块的生命周期管理下放到由模块自己管理(就是让 _turboModuleCache 不要像现在长时间保存实例缓存),但是我看了下到目前最新的 0.85 版本这个 TODO 还在

接下来我们来看看 Android 的 TurboModuleProvider 做了啥吧~

// in packages/react-native/ReactAndroid/src/main/jni/react/turbomodule/ReactCommon/TurboModuleManager.cpp

void TurboModuleManager::installJSIBindings(
    jni::alias_ref<jhybridobject> javaPart,
    bool shouldCreateLegacyModules) {
  auto cxxPart = javaPart->cthis();
  if (cxxPart == nullptr || !cxxPart->jsCallInvoker_) {
    return; 
  }

  // 从 runtimeExecutor 拿到 js runtime
  cxxPart->runtimeExecutor_([cxxPart,
                             javaPart = jni::make_global(javaPart),
                             shouldCreateLegacyModules](jsi::Runtime& runtime) {
    // 跟 IOS 一样,也是在这里调用了 install 方法
    TurboModuleBinding::install(
        runtime,
      // TurboModuleProvider
        cxxPart->createTurboModuleProvider(javaPart, &runtime),
        shouldCreateLegacyModules
      // LegacyModuleProvider
            ? cxxPart->createLegacyModuleProvider(javaPart)
            : nullptr);
  });
}

接下来我们看看具体实现:

// in packages/react-native/ReactAndroid/src/main/jni/react/turbomodule/ReactCommon/TurboModuleManager.cpp

TurboModuleProviderFunctionType TurboModuleManager::createTurboModuleProvider(
    jni::alias_ref<jhybridobject> javaPart,
    jsi::Runtime* runtime) {
  return [runtime, weakJavaPart = jni::make_weak(javaPart)](
             const std::string& name) -> std::shared_ptr<TurboModule> {
    auto javaPart = weakJavaPart.lockLocal();
    if (!javaPart) {
      return nullptr;
    }

    auto cxxPart = javaPart->cthis();
    if (cxxPart == nullptr) {
      return nullptr;
    }
// 具体实现在这~下面有代码~
    return cxxPart->getTurboModule(javaPart, name, *runtime);
  };
}

// 这个方法对应 IOS 的 provideTurboModule 方法
std::shared_ptr<TurboModule> TurboModuleManager::getTurboModule(
    jni::alias_ref<jhybridobject> javaPart,
    const std::string& name,
    jsi::Runtime& runtime) {
  const char* moduleName = name.c_str();
  TurboModulePerfLogger::moduleJSRequireBeginningStart(moduleName);

  /**
   * 第一步:如果这个模块已经创建过有缓存了,直接返回缓存
   */
  auto turboModuleLookup = turboModuleCache_.find(name);
  if (turboModuleLookup != turboModuleCache_.end()) {
    TurboModulePerfLogger::moduleJSRequireBeginningCacheHit(moduleName);
    TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);
    return turboModuleLookup->second;
  }

  TurboModulePerfLogger::moduleJSRequireBeginningEnd(moduleName);

  /**
   * 第二步:检查纯 C++ 模块(C++ 模块拥有最高优先级)
   */
  auto cxxDelegate = delegate_->cthis();
  auto cxxModule = cxxDelegate->getTurboModule(name, jsCallInvoker_);
  if (cxxModule) {
    turboModuleCache_.insert({name, cxxModule});
    return cxxModule;
  }

  /**
   * 第三步:检查全局导出的 C++ 模块
   */
  auto& cxxTurboModuleMapProvider = globalExportedCxxTurboModuleMap();
  auto it = cxxTurboModuleMapProvider.find(name);
  if (it != cxxTurboModuleMapProvider.end()) {
    auto turboModule = it->second(jsCallInvoker_);
    turboModuleCache_.insert({name, turboModule});
    return turboModule;
  }

  /**
   * 第四步:找平台相关的模块,在 Android 中就是 java 模块
   */
  static auto getTurboJavaModule =
      javaPart->getClass()
          ->getMethod<jni::alias_ref<JTurboModule>(const std::string&)>(
              "getTurboJavaModule");
  auto moduleInstance = getTurboJavaModule(javaPart.get(), name);
  if (moduleInstance) {
    TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);
    JavaTurboModule::InitParams params = {
        .moduleName = name,
        .instance = moduleInstance,
        .jsInvoker = jsCallInvoker_,
        .nativeMethodCallInvoker = nativeMethodCallInvoker_};

    auto turboModule = cxxDelegate->getTurboModule(name, params);
    if (moduleInstance->isInstanceOf(
            JTurboModuleWithJSIBindings::javaClassStatic())) {
      static auto getBindingsInstaller =
          JTurboModuleWithJSIBindings::javaClassStatic()
              ->getMethod<BindingsInstallerHolder::javaobject()>(
                  "getBindingsInstaller");
      auto installer = getBindingsInstaller(moduleInstance);
      if (installer) {
        installer->cthis()->installBindings(runtime);
      }
    }

    turboModuleCache_.insert({name, turboModule});
    TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    return turboModule;
  }

  // 处理旧架构中使用 C++ 实现的模块(对应 IOS 的 RCTCxxModule)
  static auto getTurboLegacyCxxModule =
      javaPart->getClass()
          ->getMethod<jni::alias_ref<CxxModuleWrapper::javaobject>(
              const std::string&)>("getTurboLegacyCxxModule");
  auto legacyCxxModule = getTurboLegacyCxxModule(javaPart.get(), name);
  if (legacyCxxModule) {
    TurboModulePerfLogger::moduleJSRequireEndingStart(moduleName);

    auto turboModule = std::make_shared<react::TurboCxxModule>(
        legacyCxxModule->cthis()->getModule(), jsCallInvoker_);
    turboModuleCache_.insert({name, turboModule});

    TurboModulePerfLogger::moduleJSRequireEndingEnd(moduleName);
    return turboModule;
  }

  return nullptr;
}

可以看到 Android 的实现跟 IOS 基本没区别

在这个小节的最后,我们来聊一下 TurboModule 的两层缓存是怎么实现的:

  1. 第一层缓存是在宿主的 turboModuleCache_ 中这里存放了所有被初始化了的原生模块实例
  2. 第二层缓存是在 C++ 的 jsRepresentation,这是一个 JS 对象,每个对象对应到一个模块实例,模块实例通过挂在它的原型链上使得其可以访问模块的方法

访问模块的核心方法在 TurboModule.h

// in packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModule.h

// 在 TurboModuleBinding::getModule 方法中,我们知道它返回了 jsRepresentation 并且把对应模块实例挂上了它的原型链
// 如果在 JS 侧访问某个模块方法但是在 jsRepresentation 中找不到时,会试图往原型链上找,于是就会命中这里的 get 方法
facebook::jsi::Value get(
      facebook::jsi::Runtime& runtime,
      const facebook::jsi::PropNameID& propName) override {
    {
      // 在当前 TurboModule 实例上找到对应的属性
      auto prop = create(runtime, propName);
      
      // 对于访问过的实例,我们把它放进 jsRepresentation 的属性中,这样下次访问就不用在往原型链上走了
      if (jsRepresentation_ && !prop.isUndefined()) {
        jsRepresentation_->lock(runtime).asObject(runtime).setProperty(
            runtime, propName, prop);
      }
      return prop;
    }
  }

可以看到,这一个小小的 get 方法就完成了对模块属性级别的缓存

Codegen

在了解了 TurboModule 的设计方案以及调用链路后,我们接下来要补齐 TurboModule 的最后一块拼图:Codegen

根据 官网博客 的描述,codegen 是一个可选的工具,所以我们首先需要知道的是它的优势是什么,以及什么时候推荐使用

Codegen 的优势

Codegen 的优势可以一句话总结:它用一份强类型 JS/TS spec 生成 Android、iOS、C++ 原生层所需的接口与胶水代码,从而减少样板代码维护成本,并降低 JS 与 Native 之间类型不一致跨语言调用出错的风险

怎么理解这句话呢?

假设我们想要实现一个原生模块叫 NativeNotifier 它的职责是调用原生的能力,生成一个系统弹窗出来(示例见下图)

因为这个模块调用了 原生的能力 所以我决定用 objc 来实现,下面来讲一下如果不使用 codegen 的话,要怎么做:

第一步,先创建 JS wrapper:

// NativeNotifier.ts
import {TurboModuleRegistry} from 'react-native';

type NativeNotifierType = {
  show(message: string): void;
};

export default TurboModuleRegistry.getEnforcing<NativeNotifierType>(
  'NativeNotifier',
);

第二步,创建 IOS header:

// NativeNotifier.h
#import <React/RCTBridgeModule.h>

// 用 RCTBridgeModule 是为了让 RN 能发现这个 ObjC module,并读取 RCT_EXPORT_MODULE / RCT_EXPORT_METHOD 产生的 metadata
@interface NativeNotifier : NSObject <RCTBridgeModule>
@end

第三步,创建 iOS 实现:

// NativeNotifier.mm
#import "NativeNotifier.h"
#import <React/RCTUtils.h>
#import <ReactCommon/RCTInteropTurboModule.h>
#import <ReactCommon/RCTTurboModule.h>
#import <UIKit/UIKit.h>

using namespace facebook::react;
@interface NativeNotifier () <RCTTurboModule>
@end

@implementation NativeNotifier

// 暴露模块与方法名
RCT_EXPORT_MODULE(NativeNotifier)
RCT_EXPORT_METHOD(show:(NSString *)message)
{
  // 具体逻辑实现,这里省略
}

- (std::shared_ptr<TurboModule>)getTurboModule:
    (const ObjCTurboModule::InitParams &)params
{
  // 把这个 ObjC module 包装成 TurboModule
  return std::make_shared<ObjCInteropTurboModule>(params);
}

@end

第四步,为了让 xcode 编译 NativeNotifier.mm,我们需要把它加入到 Xcode target 中

第五步,在 App 中调用它:

import NativeNotifier from './NativeNotifier';

export default function App() {

  return (
    <SafeAreaView style={styles.container}>
      <Pressable
        style={({ pressed }) => [
          styles.button,
          pressed && styles.buttonPressed,
        ]}
        // 调用这个模块方法
        onPress={() => NativeNotifier.show('Hello from native iOS code')}>
        <Text style={styles.buttonText}>Show native message</Text>
      </Pressable>
    </SafeAreaView>
  );
}

如果我们纯手写的话,我们不仅要先知道 TurboModuleRegistry.getEnforcing 的用法,还需要知道 RCTBridgeModuleRCTTurboModuleObjCInteropTurboModule 的用法与区别,最关键的是,这个流程我们需要在 Android 再来一次

这还是初次开发这种简单模块的情况,如果后续需要迭代修改,或者是模块的复杂度上去了,维护成本巨大,双端的一致性也是个大问题

Codegen 是怎么解决的呢?

第一步,在项目根目录创建 spec 目录以及 JS wrapper

// specs/NativeNotifier.ts

// 这个文件既是 JS 调用入口,也是 Codegen 读取的 spec
// 文件目录名字可以通过在 package.json 自定义,但约定俗成使用 specs
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

// 很重要的类型声明,Codegen 会通过这个类型生成胶水代码
export interface Spec extends TurboModule {
  show(message: string): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeNotifier');

第二步,去 package.json 配置相关信息,具体配置参考 官方博客

{
  "codegenConfig": {
    "name": "DemoSpec",  // SpecName
    "type": "modules",  // types
    "jsSrcsDir": "specs",  // source_dir
    "android": {
      "javaPackageName": "com.demo"  // java.package.name
    },
}

第三步,创建 iOS 实现:

// ios/Demo/NativeNotifier.h
#import <Foundation/Foundation.h>

@interface NativeNotifier : NSObject
@end
// ios/Demo/NativeNotifier.mm

#import "NativeNotifier.h"
#import <React/RCTUtils.h>
#import <DemoSpec/DemoSpec.h>
#import <UIKit/UIKit.h>

@interface NativeNotifier () <NativeNotifierSpec>
@end

@implementation NativeNotifier

RCT_EXPORT_MODULE()

- (void)show:(NSString *)message
{
  // 具体逻辑实现,这里省略
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
  return std::make_shared<facebook::react::NativeNotifierSpecJSI>(params);
}

@end

第四步,把 NativeNotifier.mm 加入到 Xcode target 中

第五步,实现 App.tsx 中的调用逻辑

完成了这五步后,在配置正确的情况下,IOS/Android build 流程会触发 Codegen,自动生成双端所需的接口和胶水代码;但具体的原生模块实现,例如 IOS 的 NativeNotifier.mm、Android 的 NativeNotifierModule.kt,仍然需要开发者自己实现并注册

细心的读者会发现,即使用了 Codegen,我们仍然需要:

  1. 创建 spec
  2. 配置 package.json
  3. 实现 iOS native 逻辑
  4. 加入 Xcode target
  5. 在 JS 中调用

也就是说,Codegen 并不是让创建 native module 的步骤变少,它真正减少的是隐藏在这些步骤背后的胶水代码:方法表、类型转换、JSI adapter、Android abstract spec、JNI glue,以及双端 API 签名同步的成本

简单画个比较表:

对比点 不使用 Codegen 使用 Codegen
JS/native API 来源 JS wrapper 和 native method 各写一份(总共三份) JS/TS spec 是唯一 API 来源(Single source of truth)
类型检查 主要靠人工保证 Codegen 根据 spec 生成 native 类型约束
iOS glue code 依赖 ObjCInteropTurboModule 运行时解析 RCT_EXPORT_METHOD metadata 生成 NativeNotifierSpec /NativeNotifierSpecJSI
Android glue code 需要手动写 module/package,签名一致性靠人工 生成 abstract Spec 和 JNI 胶水代码
方法名/参数数量错误 运行时更容易暴露 编译期或生成阶段更容易暴露
后续修改 API JS、iOS、Android 多处人工同步 先改 spec,再让生成代码约束 native 实现
适用场景 迁移旧 ObjC bridge module、快速兼容 新架构下新模块,更适合长期维护

总结

本文从 TurboModule 的设计开始,讲到了 Codegen 的优势以及为什么要使用 Codegen

诚然 Codegen 可以降低很多开发 TurboModule 的隐形成本,但作为开发者了解 TurboModule 以及背后的 JSI 才是主要的根基

毕竟只有掌握了底层的机制,才能往上搭出像 Nitro moduleExpo Modules API 这样的上层库/工具

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

使用 react-canvas 制作一个 Figma 工具:从画布到编辑器

如果你想做一个类似 Figma 的设计工具,第一反应往往是:

  • 要有高性能画布渲染
  • 要有可组合的 UI 结构
  • 要有事件命中、选中框、拖拽缩放、文本编辑
  • 还要能接入 AI(生成、改图、改布局)

我这次在 apps/open-canvas-lab 里给出的思路是:
react-canvas 作为渲染与交互底座,逐步搭一个“Figma 工具内核”。

lab.png

项目地址


为什么选 react-canvas

react-canvas 这套能力很适合做设计工具,因为它天然覆盖了编辑器最核心的三层:

  1. 场景渲染层:CanvasKit + 场景树,支持复杂布局、文本、图片、矢量
  2. 交互命中层:pick buffer + pointer 事件分发,支持精确命中
  3. 运行时层:场景节点可增删改查,可做撤销重做、选择态同步

相比“直接裸写 Canvas 2D”,这个方案的关键优势是:
你不是在拼命堆 imperative 绘图代码,而是在维护一套可演进的场景模型。


一个可落地的 Figma 工具架构

建议把应用拆成 4 个子系统:

1) Scene(文档模型)

  • 以节点树表达 Frame / Group / Text / Image / Path
  • 每个节点有 transform、style、约束信息
  • 变更统一走 command(方便 undo/redo)

2) Renderer(渲染与命中)

  • 主渲染:react-canvas 场景渲染
  • 命中:pick buffer 解析到 nodeId
  • 选中态叠加:控制框、锚点、参考线

3) Interaction(编辑器手势)

  • pointer down/move/up 组合成 drag / resize / rotate
  • 框选、吸附、对齐辅助线
  • 多选与分组操作

4) Tooling(工具链)

  • 左侧图层树、右侧属性面板
  • 顶部工具栏(选择、文本、矩形、钢笔)
  • 快捷键系统(复制、粘贴、对齐、撤销重做)

在 open-canvas-lab 的实现路线(推荐)

如果你要从 0 到 1 做出可用 MVP,可以按这个顺序:

  1. 文档与选中

    • 建立 node schema
    • 点选节点、高亮边框
  2. 变换编辑

    • 拖拽移动
    • 8 点缩放
    • 基础旋转
  3. 文本与图片

    • 文本节点样式编辑(字体、字号、行高)
    • 图片节点 object-fit / 裁剪
  4. 编辑器体验

    • 框选、多选、组合
    • 对齐吸附与辅助线
    • 撤销重做 + 操作历史
  5. 协作与 AI(进阶)

    • JSON 文档持久化
    • CRDT 协同(多人编辑)
    • AI 生成组件/版式并写回场景树

AI 能力该怎么落地(重点)

很多编辑器把 AI 做成“聊天框 + 一键生成图”,但真正可用的 AI 设计工具,关键是:
AI 输出必须是结构化编辑指令,而不是一段不可控文本。

ai.png 建议把 AI 能力拆成 3 层:

1) 意图层(Prompt / Plan)

  • 输入:自然语言需求(如“生成一个电商详情页首屏”)
  • 输出:任务计划(页面结构、组件清单、风格约束)
  • 形态:可审阅的中间 plan(用户可确认/修改)

这一层不要直接改场景,先做“可解释计划”,能显著降低误生成成本。

2) 工具层(Structured Tools)

给模型的不是“任意写 JSON”,而是明确工具集合,例如:

  • create_frame({ parentId, x, y, width, height, name })
  • create_text({ parentId, text, style })
  • create_image({ parentId, src, fit })
  • update_style({ nodeId, patch })
  • align_nodes({ nodeIds, mode })

模型只负责“调用工具”,具体执行由编辑器 runtime 保证合法性。
这样能把 AI 变成“受约束的自动化操作员”。

3) 执行层(Command Pipeline)

工具调用最终都转换为 command:

  • command[] -> validate -> apply -> layout -> render
  • 全量写入 undo/redo 栈
  • 每一步都可回滚、可重放

这保证了 AI 操作和手动操作使用同一条数据通路,不会出现“双系统分叉”。

推荐的 AI 能力清单

open-canvas-lab 里,建议优先做这 6 类能力:

  1. 从描述生成线框

    • 输入“做一个登录页”,输出基础布局骨架(frame + text + button)
  2. 风格迁移

    • 对选区做“科技蓝 / 极简黑白 / 品牌色系”重绘(仅改 style,不改结构)
  3. 批量排版

    • 统一间距、字号层级、栅格对齐
  4. 组件重写

    • 例如把“普通卡片”一键转成“带封面 + 标签 + CTA”卡片
  5. 文案智能填充

    • 生成标题、副标题、按钮文案,并支持语气风格切换
  6. 设计审查(AI Review)

    • 检查对齐、对比度、可读性、间距一致性,输出可执行修复建议

一个最小 AI 执行链路(MVP)

可以先实现下面这个闭环:

  1. 用户输入需求
  2. 模型输出 tool calls
  3. 前端校验参数(schema)
  4. 转成 command 执行
  5. 在画布高亮本次改动节点
  6. 用户可 accept / undo / retry

这个 MVP 的价值是:
你不需要先做很强的模型能力,就能把“AI 可控编辑”体验跑通。

AI 接入时最容易踩的坑

坑 1:让模型直接返回整份文档 JSON

问题:diff 巨大、不可控、很难回滚。
建议:必须改为“增量工具调用 + command 化执行”。

坑 2:AI 操作绕开编辑器状态机

问题:会破坏选中态、历史栈、约束关系。
建议:AI 与用户操作走同一 command pipeline。

坑 3:没有失败兜底

问题:工具半执行状态下文档损坏。
建议:每批 AI 操作做事务边界(失败整体回滚)。

坑 4:可解释性不足

问题:用户不知道 AI 改了什么。
建议:展示“本次修改节点清单 + 属性 diff”。


JSON 设计(简版)

为了让 AI、编辑器、存储三方都能稳定协作,建议把 JSON 拆成两层:

  1. document schema:描述页面与节点树(可持久化)
  2. command schema:描述一次编辑动作(可回放、可撤销)

1) document schema 示例

{
  "version": "1.0",
  "meta": { "name": "Landing Page", "updatedAt": 1776259200000 },
  "rootId": "frame_root",
  "nodes": {
    "frame_root": {
      "id": "frame_root",
      "type": "frame",
      "name": "Page",
      "children": ["title_1", "btn_1"],
      "layout": { "x": 0, "y": 0, "width": 1440, "height": 900 },
      "style": { "backgroundColor": "#ffffff" }
    },
    "title_1": {
      "id": "title_1",
      "type": "text",
      "text": "Build with react-canvas",
      "layout": { "x": 120, "y": 160, "width": 600, "height": 72 },
      "style": { "fontSize": 56, "fontWeight": 700, "color": "#111827" }
    },
    "btn_1": {
      "id": "btn_1",
      "type": "frame",
      "name": "CTA",
      "children": [],
      "layout": { "x": 120, "y": 280, "width": 168, "height": 48 },
      "style": { "borderRadius": 12, "backgroundColor": "#2563eb" }
    }
  }
}

2) command schema 示例

{
  "id": "cmd_20260415_001",
  "type": "update_style",
  "payload": {
    "nodeId": "btn_1",
    "patch": { "backgroundColor": "#1d4ed8", "borderRadius": 14 }
  },
  "meta": { "source": "ai", "traceId": "run_xxx" }
}

这套拆分的好处是:

  • 文档 JSON 负责“当前状态”
  • command JSON 负责“如何到达这个状态”
  • AI 输出 command,比直接覆盖整份 document 更安全

关键实现细节(踩坑重点)

坐标系统一

编辑器里至少有 3 套坐标:

  • 视口(client)
  • 画布(stage)
  • 节点局部(local)

一定要优先统一坐标映射,否则拖拽、选框和命中会经常“看起来差几像素但很难查”。

命中与视觉分离

不要用“可见像素”直接做命中判断。
正确姿势是用独立 pick 语义层(nodeId 编码),可维护性和稳定性会高很多。

编辑器状态尽量事件化

把“鼠标按下后进入哪种模式”建成有限状态机(FSM),比 scattered boolean 更稳,后续加钢笔、裁剪工具也不容易崩。


一个简单但重要的结论

做 Figma 工具真正困难的不是“画出来”,而是:

  • 模型是否可持续演进
  • 交互是否可组合
  • 渲染/命中/状态是否解耦

react-canvas 的价值在于,它已经把底层最难啃的部分(渲染与交互基础设施)提前搭好。
你可以把主要精力放在“产品能力”和“编辑体验”上。


结语

如果你正在基于 apps/open-canvas-lab 做编辑器方向的实验,这个方向是可行的:
先做一个“可编辑画板 MVP”,再逐步补齐 Figma 级能力,而不是一上来追求完整复刻。

「性能优化」虚拟列表极致优化实战:从原理到源码,打造丝滑滚动体验

前言

大家好,我是elk。

上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List)

什么是虚拟列表?

虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。

核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。

为什么需要虚拟列表?

在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:

  1. DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
  2. 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。

适用业务场景

  • 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
  • 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
  • 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。

核心原理

  • 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
  • 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
  • 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
  • DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销

核心基础概念

  • 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
  • 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
  • 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
  • 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
  • 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)

核心知识点

主要是涉及到事件监听以及基础数据的计算和更新

基础知识点

滚动事件监听

通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算

避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能

尺寸计算

  • 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
  • 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
  • 固定高度:无需计算,自行设置的高度「itemHeight」
  • 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中

索引计算

起始索引「startIndex」

固定高度

index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」

startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」

动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」

结束索引「endIndex」

index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」

endIndex = Math.min( list.length, index )

偏移量计算

固定高度

    top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」

动态高度

top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」

进阶知识点

在基础知识点上进行的优化措施,提升列表性能,优化用户体验

缓冲机制

当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染

  • 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
  • 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲

动态高度缓存与更新

在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:

  • 先进行预估高度的渲染,渲染后通过nextTick获取真实高度
  • 将真实高度写入缓存,并重新计算前缀和
  • 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存

滚动事件节流

在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame

  • 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑

二分查找优化索引定位

在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。

整体代码 —— 组件封装(Vue 3 + TypeScript)

以下是一个支持 动态高度缓冲区高度缓存二分查找 的完整虚拟列表组件。

<template>
  <div
    @scroll="handleScroll"
    ref="containerRef"
    :style="{ height: `${height}px` }"
    class="w-full position-relative top-0 left-0 overflow-auto"
  >
    <!-- 空状态 -->
    <div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
      <slot name="empty" />
    </div>
    <!-- 占位撑高容器 -->
    <template v-else>
      <div
        :style="{ height: `${containerHeight}px` }"
        class="w-full position-absolute top-0 left-0"
      ></div>
      <!-- 可视化容器 -->
      <div
        :style="{ transform: `translateY(${offset}px)` }"
        class="w-full position-absolute top-0 left-0"
      >
        <div
          v-for="(item, index) in visibleList"
          :key="item.id || index"
          ref="itemRef"
          :style="{ height: `${itemHeight}px` }"
          class="w-full flex items-center justify-center"
        >
          <slot name="default" :item="item" :index="index + startIndex" />
        </div>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'

interface ListItem {
  id: number | string
  name: string
}

interface PropsParams {
  // 列表数据
  data: ListItem[]
  // 容器高度
  height: number
  // 项高度-预估高度
  itemHeight: number
  // 缓冲区数量
  bufferCount: number
}
const props: PropsParams = defineProps({
  data: {
    type: Array as PropType<ListItem[]>,
    default: () => [],
    required: true,
  },
  height: {
    type: Number,
    default: 250,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  bufferCount: {
    type: Number,
    default: 5,
  },
})

// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)

// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])

// 可视化容器-开始索引
const startIndex = computed(() => {
  const index = getStartIndex(scrollTop.value)
  return Math.max(0, index - props.bufferCount)
})

// 可视化容器-结束索引
const endIndex = computed(() => {
  const index = startIndex.value + visibleCount.value + props.bufferCount * 2
  return Math.min(props.data.length, index)
})

// 撑开容器-高度
const containerHeight = computed(() => {
  return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})

// 可视化容器-渲染列表
const visibleList = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

// 偏移量-计算
const offset = computed(() => {
  return prefixSumCache.value[startIndex.value]
})

/**
 * @description: 二分法-计算初始索引
 * @return {*}
 */
const getStartIndex = (scrollTop: number) => {
  let left = 0
  let right = prefixSumCache.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (prefixSumCache.value[mid] === scrollTop) return mid
    if (prefixSumCache.value[mid] > scrollTop) {
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  return left
}

/**
 * @description: 初始化高度
 * @return {*}
 */
const initHeight = () => {
  try {
    // 初始化项高度缓存集合
    itemHeightCache.value = props.data.map(() => props.itemHeight)
    // 初始化前缀和缓存集合
    initPrefixSum()
  } catch (error) {
    console.error('初始化高度失败:', error)
  }
}

/**
 * @description: 初始化|修改 前缀和缓存集合
 * @return {*}
 */
const initPrefixSum = (index: number = 0) => {
  try {
    prefixSumCache.value = []
    let sum = 0
    // 计算前缀和缓存集合,从索引开始计算,直到列表结束
    itemHeightCache.value.forEach((item, i) => {
      if (i >= index) {
        prefixSumCache.value.push(sum)
        sum += item
      }
    })
  } catch (error) {
    console.error('初始化前缀和缓存集合失败:', error)
  }
}

/**
 * @description: 修改项的真实高度-当高度发生变化时才更新
 * @return {*}
 */
const updateItemHeight = async () => {
  try {
    await nextTick()
    const visibleItems = itemRef.value
    if (visibleItems.length === 0) return
    let hasHeightChanged = false
    visibleItems.forEach((el, index) => {
      if (el) {
        const itemIndex = index + startIndex.value
        const itemHeight = el.clientHeight
        // const itemHeight = el.getBoundingClientRect().height
        // 只有高度变化的时候才更新缓存
        if (itemHeight !== itemHeightCache.value[itemIndex]) {
          itemHeightCache.value[itemIndex] = itemHeight
          hasHeightChanged = true
        }
        if (hasHeightChanged) {
          initPrefixSum(itemIndex)
        }
      }
    })
  } catch (error) {
    console.error('更新项目高度失败:', error)
  }
}

/**
 * @description: 处理滚动事件
 * @return {*}
 */
let ticking = false
const handleScroll = () => {
  console.log('🚀 ~ handleScroll ~ containerRef: 触发了滚动事件')
  if (!ticking) {
    requestAnimationFrame(() => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value?.scrollTop || 0
        updateItemHeight()
      }
      ticking = false
    })
    ticking = true
  }
}

// 监听数据变化-更新项高度
watchEffect(() => {
  if (props.data.length > 0) {
    initHeight()
    updateItemHeight()
  }
})

// 初始化-更新项高度
onMounted(() => {
  initHeight()
  updateItemHeight()
})
</script>

<style lang="css" scoped></style>

常见问题 & 最佳实践

Q1:为什么我的虚拟列表在快速滚动时还是会白屏?

  • 缓冲区太小:适当增加 bufferCount(比如从 2 提升到 5)。
  • 动态高度更新不及时:确保在 nextTick 后获取真实高度,并重新计算前缀和。
  • 未使用 requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。

Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?

推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0prefixSum[i] 表示前 i 项的总高度。这样:

  • 第 i 项的偏移量 = prefixSum[i]
  • 总高度 = prefixSum[n]
  • 查找 scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。

Q3:如何支持列表项内容动态变化(比如展开/收起)?

  • 监听内容变化,调用 updateRealHeights 重新测量受影响的项。
  • 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。

Q4:除了 transform 偏移,还有别的方案吗?

也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY

总结

虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。

优化永无止境,如果你还想更进一步,可以探索:

  • 使用 ResizeObserver 监听每一项的尺寸变化,自动更新高度缓存。
  • 结合 IntersectionObserver 实现可视区外图片懒加载。
  • 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的“无限滚动”。

希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~

❌