普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月6日技术

useradd Cheatsheet

Basic Syntax

Core useradd command forms.

Command Description
sudo useradd username Create a user account with defaults
sudo useradd -m username Create user and home directory
sudo useradd -m -s /bin/bash username Create user with explicit login shell
sudo useradd -m -c "Full Name" username Create user with GECOS/comment field
sudo useradd -D Show current default useradd settings

Home Directory and Shell

Set home path and login shell at creation time.

Command Description
sudo useradd -m username Create /home/username if missing
sudo useradd -M username Create user without home directory
sudo useradd -d /srv/appuser -m appuser Create user with custom home path
sudo useradd -s /bin/zsh username Set login shell to Zsh
sudo useradd -s /usr/sbin/nologin serviceuser Disable interactive login for service account

Groups and Permissions

Assign primary and supplementary groups during creation.

Command Description
sudo useradd -m -g developers username Set primary group to developers
sudo useradd -m -G sudo username Add user to supplementary sudo group
sudo useradd -m -G docker,developers username Add user to multiple supplementary groups
id username Verify UID, GID, and group membership
groups username Show group memberships for a user

UID, Expiry, and Inactive Policy

Control account identity and lifetime.

Command Description
sudo useradd -m -u 1050 username Create user with specific UID
sudo useradd -m -e 2026-12-31 username Set account expiration date
sudo useradd -m -f 30 username Disable account after 30 inactive days
sudo useradd -m -k /etc/skel username Use skeleton directory for initial files
sudo chage -l username Inspect account aging and expiry policy

Password and Account Activation

Set password and verify account usability.

Command Description
sudo passwd username Set or reset user password
sudo passwd -l username Lock account password login
sudo passwd -u username Unlock account password login
sudo su - username Test login environment for new user
getent passwd username Confirm user entry in account database

Defaults and Safe Workflow

Check defaults first and validate each account creation.

Command Description
sudo useradd -D Show defaults (HOME, SHELL, SKEL, etc.)
sudo useradd -D -s /bin/bash Change default shell for future users
sudo useradd -m newuser && sudo passwd newuser Common two-step creation flow
sudo usermod -aG sudo newuser Grant admin privileges after creation
sudo userdel -r username Remove user and home directory when deprovisioning

Troubleshooting

Quick checks for common useradd errors.

Issue Check
useradd: user 'name' already exists Confirm with id name or choose a different username
group 'name' does not exist Create group first with groupadd or use an existing group
Home directory not created Use -m and verify defaults with useradd -D
Cannot log in after creation Check shell (getent passwd user) and set password with passwd
UID conflict Verify used UIDs in /etc/passwd before assigning -u manually

Related Guides

Use these guides for full account lifecycle tasks.

Guide Description
How to Create Users in Linux Using the useradd Command Full useradd tutorial with examples
usermod Command in Linux Modify existing user accounts
How to Delete Users in Linux Using userdel Remove users safely
How to Add User to Group in Linux Manage supplementary groups
How to Change User Password in Linux Set and rotate account passwords

How to Install Git on Debian 13

Git is the world’s most popular distributed version control system used by many open-source and commercial projects. It allows you to collaborate on projects with fellow developers, keep track of your code changes, revert to previous stages, create branches , and more.

This guide covers installing and configuring Git on Debian 13 (Trixie) using apt or by compiling from source.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Install Git (apt) sudo apt install git
Check Git version git --version
Set username git config --global user.name "Your Name"
Set email git config --global user.email "you@example.com"
View config git config --list

Installing Git with Apt

This is the quickest way to install Git on Debian.

Check if Git is already installed:

Terminal
git --version

If Git is not installed, you will see a “command not found” message. Otherwise, it shows the installed version.

Use the apt package manager to install Git:

Terminal
sudo apt update
sudo apt install git

Verify the installation:

Terminal
git --version

Debian 13 stable currently provides Git 2.47.3:

output
git version 2.47.3

You can now start configuring Git.

When a new version of Git is released, you can update using sudo apt update && sudo apt upgrade.

Installing Git from Source

The main benefit of installing Git from source is that you can compile any version you want. However, you cannot maintain your installation through the apt package manager.

Install the build dependencies:

Terminal
sudo apt update
sudo apt install libcurl4-gnutls-dev libexpat1-dev cmake gettext libz-dev libssl-dev gcc wget

Visit the Git download page to find the latest version.

At the time of writing, the latest stable Git version is 2.53.0.

If you need a different version, visit the Git archive to find available releases.

Download and extract the source to /usr/src:

Terminal
wget -c https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.53.0.tar.gz -O - | sudo tar -xz -C /usr/src

Navigate to the source directory and compile:

Terminal
cd /usr/src/git-*
sudo make prefix=/usr/local all
sudo make prefix=/usr/local install

The compilation may take some time depending on your system.

If your shell still resolves /usr/bin/git after installation, open a new terminal or verify your PATH and binary location with:

Terminal
which git
echo $PATH

Verify the installation:

Terminal
git --version
output
git version 2.53.0

To upgrade to a newer version later, repeat the same process with the new version number.

Configuring Git

After installing Git, configure your username and email address. Git associates your identity with every commit you make.

Set your global commit name and email:

Terminal
git config --global user.name "Your Name"
git config --global user.email "youremail@yourdomain.com"

Verify the configuration:

Terminal
git config --list
output
user.name=Your Name
user.email=youremail@yourdomain.com

The configuration is stored in ~/.gitconfig:

~/.gitconfigconf
[user]
name = Your Name
email = youremail@yourdomain.com

You can edit the configuration using the git config command or by editing ~/.gitconfig directly.

For a deeper walkthrough, see How to Configure Git Username and Email .

Troubleshooting

E: Unable to locate package git
Run sudo apt update first and verify you are on Debian 13 repositories. If sources were recently changed, refresh package metadata again.

git --version still shows an older version after source install
Your shell may still resolve /usr/bin/git before /usr/local/bin/git. Check with which git and adjust PATH order if needed.

Build fails with missing headers or libraries
One or more dependencies are missing. Re-run the dependency install command and then compile again.

make succeeds but git command is not found
Confirm install step ran successfully: sudo make prefix=/usr/local install. Then check /usr/local/bin/git exists.

FAQ

Should you use apt or source on Debian 13?
For most systems, use apt because updates are integrated with Debian security and package management. Build from source only when you need a newer Git release than the repository version.

Does compiling from source replace the apt package automatically?
No. Source builds under /usr/local and can coexist with the apt package in /usr/bin. Your PATH order determines which binary runs by default.

How can you remove a source-installed Git version?
If you built from the source tree, run sudo make prefix=/usr/local uninstall from that same source directory.

Conclusion

We covered two ways to install Git on Debian 13: using apt, which provides Git 2.47.3, or compiling from source for the latest version. The default repository version is sufficient for most use cases.

For more information, see the Pro Git book .

React性能优化的完整方法论,附赠大厂面试通关技巧

作者 小时前端
2026年3月6日 18:18

开篇语

在前端面试中,React性能优化是一个绕不开的话题。无论是初级还是高级岗位,面试官总会问:"你做过哪些React性能优化?"、"如何定位性能问题?"、"React.memo和useCallback有什么区别?"

很多同学面对这些问题时,只能零散地背几个API,缺乏系统性的思路。今天这篇文章,我将结合一线开发经验,带你建立完整的React性能优化知识体系,让你从"知道几个优化技巧"到"能够系统性解决性能问题"。

性能优化的核心思路

建立性能问题的感知能力

很多开发者都是在用户投诉"页面卡"时才开始关注性能,其实性能优化应该是一个主动的过程。我总结了几个常见的性能问题信号:

  • 首屏加载超过3秒 - 用户开始失去耐心
  • 滚动时出现掉帧 - 肉眼可见的卡顿
  • 点击按钮响应延迟 - 交互体验差

当你发现这些现象时,就要考虑进行性能优化了。

系统性的优化框架

我总结了一个"三步走"的性能优化框架:

graph LR
    A[发现问题] --> B[定位原因] --> C[制定方案] --> D[验证效果]
    B --> E[工具分析]
    C --> F[选择策略]
    D --> G[数据对比]
  1. 发现问题:用户体验角度识别性能问题
  2. 定位原因:使用专业工具分析具体瓶颈
  3. 制定方案:针对不同问题选择合适优化策略
  4. 验证效果:通过数据验证优化效果

性能调试工具全解析

React DevTools Profiler - 组件级性能分析

React DevTools Profiler是我最常用的性能分析工具,它能精确记录组件的渲染时间、重渲染原因和Props变化。

实战案例:电商商品列表优化

有一次优化电商商品列表页面,用户反馈滚动时明显卡顿。我用Profiler录制了滚动操作:

  1. 打开React DevTools,切换到Profiler面板
  2. 点击录制按钮,模拟用户滚动操作
  3. 停止录制,查看火焰图

发现每次滚动都会触发所有商品卡片的重新渲染,即使大部分商品数据并没有变化。通过查看渲染原因,发现是因为父组件传递了内联函数导致引用变化。

优化方案:

// 优化前 - 每次渲染都创建新函数
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={() => addToCart(product.id)} // 内联函数
        />
      ))}
    </div>
  );
};

// 优化后 - 使用useCallback稳定函数引用
const ProductList = ({ products }) => {
  const handleAddToCart = useCallback((productId) => {
    addToCart(productId);
  }, []); // 空依赖数组,函数不会重新创建

  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
};

优化后,Profiler显示渲染时间从25ms降低到8ms,滚动FPS从45提升到58,用户体验明显改善。

Chrome DevTools Performance - 浏览器级性能分析

当Profiler显示组件渲染正常,但用户仍然反馈卡顿时,就需要从浏览器层面进行分析。

实战技巧:

  1. 录制用户操作流程
  2. 关注Main线程的黄色长任务块
  3. 查看Frames部分的帧率表现
  4. 分析Layout和Paint的开销

案例分析:搜索功能优化

搜索框输入时页面卡顿,Performance面板显示:

  • 每次输入都有超过100ms的黄色长任务
  • 大量的Layout和Paint操作
  • 帧率经常低于30fps

通过分析发现是因为搜索建议列表的DOM操作过于频繁。优化方案:

  • 使用防抖函数减少搜索触发频率
  • 实现虚拟滚动,只渲染可见的搜索建议
  • 缓存搜索结果,避免重复计算

其他实用工具推荐

  • why-did-you-render:监控不必要的重渲染
  • webpack-bundle-analyzer:分析打包体积
  • Lighthouse:整体性能评分和建议
  • Web Vitals:监控核心用户体验指标

React性能优化的四大策略

策略一:缓存优化 - 减少不必要的计算

React提供了三个核心的缓存API,合理使用能解决80%的性能问题。

1. React.memo - 组件级缓存

适用场景:组件props没有变化但仍然频繁重渲染

// 商品卡片组件 - 纯展示组件
const ProductCard = React.memo(({ product, onAddToCart }) => {
  console.log('ProductCard render:', product.name);
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>价格:¥{product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>
        加入购物车
      </button>
    </div>
  );
}, (prevProps, nextProps) => {
  // 自定义比较函数(可选)
  return prevProps.product.id === nextProps.product.id && 
         prevProps.product.price === nextProps.product.price;
});

// 父组件中使用
const ProductList = ({ products }) => {
  const [cartCount, setCartCount] = useState(0);
  
  // 注意:使用useCallback避免函数引用变化
  const handleAddToCart = useCallback((productId) => {
    console.log('添加商品到购物车:', productId);
    setCartCount(prev => prev + 1);
  }, []); // 空依赖数组,函数引用保持稳定

  return (
    <div>
      <h2>商品列表 (购物车:{cartCount}件)</h2>
      <div className="product-grid">
        {products.map(product => (
          <ProductCard 
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>
    </div>
  );
};

实战技巧:

  • 给React.memo提供自定义比较函数时,要确保比较逻辑正确
  • 避免在props中传递对象或数组字面量,会导致比较失败
  • 配合useCallback和useMemo使用效果更佳

2. useMemo - 计算结果缓存

适用场景:复杂计算、数据转换、过滤排序等操作

// 商品列表过滤和排序
const ProductList = ({ products, filter, sortBy }) => {
  // 复杂的数据处理逻辑
  const processedProducts = useMemo(() => {
    console.log('重新计算商品列表');
    
    // 1. 过滤商品
    let filtered = products.filter(product => {
      if (filter.category && product.category !== filter.category) return false;
      if (filter.minPrice && product.price < filter.minPrice) return false;
      if (filter.maxPrice && product.price > filter.maxPrice) return false;
      return true;
    });
    
    // 2. 排序
    filtered.sort((a, b) => {
      switch (sortBy) {
        case 'price-asc':
          return a.price - b.price;
        case 'price-desc':
          return b.price - a.price;
        case 'name':
          return a.name.localeCompare(b.name);
        default:
          return 0;
      }
    });
    
    return filtered;
  }, [products, filter, sortBy]); // 只有这些依赖变化时才重新计算

  return (
    <div>
      <h2>商品列表 ({processedProducts.length}件)</h2>
      <div className="product-grid">
        {processedProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

3. useCallback - 函数引用缓存

适用场景:将函数作为props传递给子组件时

// 优化的表单组件
const SearchForm = ({ onSearch }) => {
  const [keyword, setKeyword] = useState('');
  const [category, setCategory] = useState('');
  
  // 搜索函数 - 使用useCallback缓存
  const handleSearch = useCallback(() => {
    onSearch({
      keyword,
      category,
      timestamp: Date.now()
    });
  }, [keyword, category, onSearch]);
  
  // 重置函数 - 使用useCallback缓存
  const handleReset = useCallback(() => {
    setKeyword('');
    setCategory('');
    onSearch({
      keyword: '',
      category: '',
      timestamp: Date.now()
    });
  }, [onSearch]);
  
  return (
    <div className="search-form">
      <input 
        type="text" 
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
        placeholder="请输入关键词"
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="">全部分类</option>
        <option value="electronics">电子产品</option>
        <option value="clothing">服装</option>
        <option value="books">图书</option>
      </select>
      <button onClick={handleSearch}>搜索</button>
      <button onClick={handleReset}>重置</button>
    </div>
  );
};

策略二:架构优化 - 合理拆分组件

良好的组件架构设计能从根源上减少性能问题。

状态就近原则

把状态放在最靠近使用它的组件中,避免不必要的状态提升。

// 不好的设计 - 状态过度提升
const Parent = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 展开状态没必要放在这里
  
  return (
    <div>
      <Child isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
    </div>
  );
};

// 好的设计 - 状态就近管理
const Parent = () => {
  return (
    <div>
      <Child />
    </div>
  );
};

const Child = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 状态放在使用它的组件中
  
  return (
    <div>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        {isExpanded ? '收起' : '展开'}
      </button>
      {isExpanded && <div>展开的内容</div>}
    </div>
  );
};

按更新频率拆分组件

不同部分的更新频率不同,应该拆分成独立组件。

// 商品详情页 - 按更新频率拆分
const ProductDetail = ({ productId }) => {
  return (
    <div className="product-detail">
      {/* 基本信息 - 基本不变 */}
      <ProductBasicInfo productId={productId} />
      
      {/* 价格信息 - 可能促销变化 */}
      <ProductPrice productId={productId} />
      
      {/* 库存信息 - 实时变化 */}
      <ProductStock productId={productId} />
      
      {/* 用户评论 - 实时更新 */}
      <ProductReviews productId={productId} />
      
      {/* 相关推荐 - 根据算法变化 */}
      <ProductRecommendations productId={productId} />
    </div>
  );
};

策略三:列表优化 - 大数据量处理

虚拟滚动技术

当列表数据量很大时(超过1000条),虚拟滚动是必须的技术。

// 使用react-window实现虚拟滚动
import { FixedSizeList as List } from 'react-window';

const LargeProductList = ({ products }) => {
  // 只渲染可见区域的产品
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  );

  return (
    <List
      height={600} // 可视区域高度
      itemCount={products.length} // 总数据量
      itemSize={120} // 每行高度
      width="100%"
    >
      {Row}
    </List>
  );
};

key的正确使用

key的选择对列表性能影响很大。

// 不好的做法 - 使用索引作为key
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map((product, index) => (
        <ProductCard key={index} product={product} /> // 不要用index
      ))}
    </div>
  );
};

// 好的做法 - 使用稳定的唯一标识
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} /> // 使用稳定的id
      ))}
    </div>
  );
};

策略四:代码分割 - 按需加载

React.lazy和Suspense

// 路由级别的代码分割
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// 懒加载页面组件
const Home = React.lazy(() => import('./pages/Home'));
const ProductList = React.lazy(() => import('./pages/ProductList'));
const ProductDetail = React.lazy(() => import('./pages/ProductDetail'));
const ShoppingCart = React.lazy(() => import('./pages/ShoppingCart'));

const App = () => {
  return (
    <Router>
      <React.Suspense fallback={<div>加载中...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/products" component={ProductList} />
          <Route path="/product/:id" component={ProductDetail} />
          <Route path="/cart" component={ShoppingCart} />
        </Switch>
      </React.Suspense>
    </Router>
  );
};

组件级别的代码分割

// 重型组件的按需加载
const HeavyChartComponent = React.lazy(() => 
  import('./components/HeavyChartComponent')
);

const Dashboard = () => {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        显示图表
      </button>
      
      {showChart && (
        <React.Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChartComponent />
        </React.Suspense>
      )}
    </div>
  );
};

性能优化的数据验证

性能优化不能凭感觉,必须用数据说话。

优化前后的数据对比

案例:电商首页优化

优化前:

  • 首屏加载时间:4.2秒
  • 组件平均渲染时间:25ms
  • 滚动FPS:平均45
  • 用户跳出率:35%

优化后:

  • 首屏加载时间:2.1秒(提升50%)
  • 组件平均渲染时间:8ms(提升68%)
  • 滚动FPS:平均58(提升29%)
  • 用户跳出率:22%(降低37%)

核心性能指标监控

// Web Vitals监控
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

const reportWebVitals = (metric) => {
  console.log(metric);
  // 发送到监控系统
  sendToAnalytics(metric);
};

// 监控核心指标
getCLS(reportWebVitals); // 累积布局偏移
getFID(reportWebVitals); // 首次输入延迟
getFCP(reportWebVitals); // 首次内容绘制
getLCP(reportWebVitals); // 最大内容绘制
getTTFB(reportWebVitals); // 首字节时间

面试中的答题技巧

系统化回答框架

当面试官问"你做过哪些React性能优化"时,不要零散地列举,而是按照系统化框架回答:

我的回答思路: "我通常会从三个层面进行React性能优化:渲染层面、架构层面和加载层面。

在渲染层面,我主要使用React.memo、useMemo、useCallback来减少不必要的重渲染。比如在[具体项目]中,通过React.memo优化商品列表,让渲染时间从25ms降到8ms。

在架构层面,我会合理拆分组件,遵循状态就近原则,按更新频率组织组件结构。比如将商品详情页拆分成基本信息、价格、库存、评论等独立组件。

在加载层面,我使用代码分割、懒加载、虚拟滚动等技术。比如对路由组件进行懒加载,将首屏包体积减少了40%。

同时,我建立了性能监控机制,使用React DevTools Profiler和Chrome Performance面板定期分析性能,确保优化效果可量化。"

展现技术深度

面试官追问:"为什么React.memo能够优化性能?"

深度回答: "React.memo的原理涉及React的协调算法。当组件的props或state变化时,React会启动协调过程,通过Diff算法比较新旧虚拟DOM树的差异。

React.memo在这个过程中起到短路作用。它在组件更新阶段就执行浅比较,如果props没有变化,就直接跳过该组件及其子树的协调过程,避免了昂贵的虚拟DOM比较计算。

这种优化在组件树较深、子组件较多的情况下效果特别明显,因为React.memo阻断了变化向下传播的路径,让状态变化的影响局限在最小范围内。"

体现实战经验

面试官追问:"遇到过哪些具体的性能问题?"

实战案例: "在优化一个商品搜索页面时,用户反馈每输入一个字符都有明显卡顿。

通过Profiler分析发现,每次输入都会触发整个商品列表的重新渲染,包括过滤、排序等复杂计算。而且搜索建议列表的DOM更新也很频繁。

我的解决方案是:

  1. 使用防抖函数,将搜索触发频率控制在300ms一次
  2. 用useMemo缓存过滤排序后的结果
  3. 搜索建议列表实现虚拟滚动,只渲染可见项
  4. 用useCallback稳定事件处理函数

优化后,输入响应时间从500ms降到50ms,用户体验大幅提升。这个案例让我意识到性能优化要从用户操作路径出发,系统性地解决各个环节的性能瓶颈。"

总结与进阶

React性能优化是一个需要持续学习和实践的领域。记住这些核心要点:

  1. 建立性能意识:主动发现问题,而不是被动等待用户反馈
  2. 掌握系统方法:从渲染、架构、加载三个层面综合考虑
  3. 善用工具分析:Profiler、Performance面板等工具是定位问题的利器
  4. 数据驱动优化:用数据验证优化效果,避免凭感觉
  5. 持续监控改进:性能优化是一个持续的过程,不是一次性的工作

技术前沿关注

  • React 18的Concurrent Features:为性能优化带来新的可能性
  • Server Components:减少客户端计算和包体积
  • Edge Computing:结合边缘计算优化加载性能
  • Micro-frontend:微前端架构下的性能优化策略

性能优化不仅仅是技术问题,更是用户体验和业务价值的体现。掌握这些技能,不仅能让你在面试中脱颖而出,更能让你在实际工作中创造真正的价值。

希望这篇文章能帮助你建立完整的React性能优化知识体系。记住,最好的优化是预防,在开发过程中就考虑性能因素,而不是等问题出现后再补救。

你觉得这篇文章对你有帮助吗?欢迎在评论区分享你的性能优化经验和问题!

延伸阅读:

Jetpack Compose BOM 2026.02.01 解读与升级指南

作者 Nicko
2026年3月6日 17:47

Jetpack Compose BOM 2026.02.01 深度解析:这次升级到底更新了什么,值不值得跟?

如果你最近准备把项目里的 Compose 依赖统一升到 androidx.compose:compose-bom:2026.02.01,先说结论:这不是一波“新能力井喷”的大版本 BOM,而是一版很典型的稳定性补丁 BOM。它的核心价值不在于一次性塞进大量新 API,而在于把 Compose 1.10 这一代已经落地的新能力,用一组更稳的 patch 版本重新打包给你。

对于线上项目来说,这种版本往往比“看起来更炫”的版本更值得重视:新功能不一定是每天都能用上的,但修掉布局回归、补齐运行时和 UI 侧补丁、把 BOM 对齐到更稳定的一组 artifact,通常直接影响的是发布风险和排障成本。

本文基于 Android 官方文档、AndroidX release notes、Android Developers Blog 等一手资料整理,重点回答 4 个问题:

  1. 2026.02.01 到底映射到了哪些 Compose 版本;
  2. 它相对上一版 2026.02.00 具体变了什么;
  3. 这些变化对实际工程能力提升在哪里;
  4. 项目升级时有哪些容易踩的适配点。

先说结论

  • Compose BOM 2026.02.01 的主变化,是把 Compose 核心模块族从 1.10.3 对齐到 1.10.4
  • material3 在这版 BOM 里没有继续升级,仍然是 1.4.0
  • material3-adaptive 这条线也没有变,仍然是 1.2.0
  • 从官方 release notes 来看,foundation 1.10.4 明确修了一个布局定位回归;runtime/ui/material/animation 1.10.4 没有列出新的公开 API 变化,更像一组维护性 patch。这是基于官方 changelog 颗粒度做出的工程判断,不是 Google 原文的直接表述。
  • 如果你此前已经上了 2026.02.00,这次升级的重点是“补丁”和“稳定性”;如果你还停在更早的 2025 年末版本,那你实际上会连同 Compose 1.10、Material3 1.4 这一波能力一起吃进来。

这版 BOM 映射到了什么

Compose BOM 的职责,是帮你把一组 Compose 相关 artifact 锁到一套经过官方验证的兼容版本组合里。它不包含 Compose Compiler,这一点在官方 BOM 文档里说得很明确。

结合官方 BOM Mapping 页面,2026.02.01 可以概括为下面这张表:

模块族 2026.02.01 2026.02.00 变化判断
animation 1.10.4 1.10.3 升级补丁
foundation 1.10.4 1.10.3 升级补丁
material 1.10.4 1.10.3 升级补丁
runtime 1.10.4 1.10.3 升级补丁
ui 1.10.4 1.10.3 升级补丁
material3 1.4.0 1.4.0 无变化
material3-adaptive 1.2.0 1.2.0 无变化

这里说的“模块族”,不只是单个 artifact,而是一整串同代模块,例如:

  • ui 族通常包含 uiui-graphicsui-textui-toolingui-test-junit4 等;
  • foundation 族通常包含 foundationfoundation-layout
  • runtime 族通常包含 runtimeruntime-livedataruntime-rxjava*
  • animation 族通常包含 animationanimation-coreanimation-graphics
  • material 指的是经典 Compose Material(M2)线;
  • material3material3-adaptive 维持独立节奏。

换句话说,这版 BOM 的核心动作其实很清楚:把 Compose 核心运行栈整体抬到 1.10.4,但不动 Material3 主版本。

相比 2026.02.00,具体更新了什么

1. foundation:修了一个真实会影响界面的定位问题

在官方 foundation release notes 里,1.10.4 明确提到修复了一个回归:

  • 当一个对齐类 modifier 被错误地用在它不属于的 scope 中时,之前版本可能会触发错误布局位置
  • 1.10.4 对这个问题做了修复。

这个改动看起来不“炫技”,但对线上项目非常重要。因为这类问题往往最麻烦:

  • 编译不报错;
  • 页面也不是全挂;
  • 只在特定组合布局、特定修饰链、特定设备尺寸下才出现“怎么这里偏了一点”的问题;
  • 最后调半天才发现不是业务代码逻辑,而是底层布局行为的回归。

如果你的项目里有比较多自定义布局、复杂 slot API、嵌套 Box / Column / Row / Lazy* 布局,再加上扩展 modifier 比较多,这个修复是有实际价值的。

2. runtime:官方写明“和 1.10.3 没有变化”

runtime 1.10.4 的官方 release notes 很直接:There are no changes in this release.

这意味着什么?

  • 如果你只看 runtime 模块本身,这次从 1.10.31.10.4 不会带来新的运行时语义变化;
  • 它更多是在 BOM 层面把整个 Compose 版本集重新对齐;
  • 对项目升级而言,runtime 不是这次最需要重点回归验证的模块。

3. ui / material / animation:更像同步补丁与维护性发布

从对应的 AndroidX release notes 页面来看:

  • ui 1.10.4 没有列出额外的显性 API 变更摘要;
  • material 1.10.4 也没有单独列出显著变更说明;
  • animation 1.10.4 在 release 页面上同样没有像大版本那样给出新的能力条目。

因此比较稳妥的工程判断是:这几个 1.10.4 更像一组维护性 patch / 同步发布,而不是新的功能迭代节点。

这里还是强调一下:这句话是基于官方 release notes 未列出新增 API / 新特性摘要的推断。如果你需要逐 commit 追踪,可以继续顺着各 release notes 页里的 change list 链接深挖,但就日常升级决策来说,这个粒度已经足够判断风险级别。

4. material3:这版 BOM 不动,仍然停在 1.4.0

这点很关键。很多同学看到 BOM 版本号继续往 2026.02.01 走,会下意识以为 Material3 也一起升了。实际上没有。

按官方映射页,这一版里:

  • androidx.compose.material3:material3 仍然是 1.4.0
  • androidx.compose.material3.adaptive:* 仍然是 1.2.0

所以,如果你这次升级之后感觉 Material3 API 没有新增,那不是你姿势不对,而是 BOM 本身就没有把 Material3 再往前推。

那“能力提升”体现在哪?

如果只盯着 2026.02.012026.02.00 的 diff,你会觉得“这不就是个 patch 吗”。这判断没错,但不完整。

更准确地说,这版 BOM 的价值分成两层:

  1. 短期价值:把核心 Compose 族对齐到更稳的 1.10.4
  2. 平台价值:把 Compose 1.10 和 Material3 1.4 这一代的能力,打包到一个更适合生产落地的组合上。

下面这部分,才是程序员真正关心的“升了以后我能干嘛”。

1. Lazy 布局和滚动链路更成熟了

根据 Android Developers Blog 对 Compose 1.10 的总结,Google 在这一代继续强化了性能路线,几个点很值得关注:

  • Pausable Composition 已默认用于 Lazy 布局的预取;
  • 官方给出的内部 benchmark 显示,某些滚动场景下 Compose 已能达到和传统 View 非常接近的性能水平;
  • 这意味着在复杂列表、卡片流、瀑布式 feed、首页混排场景里,Compose 的“流畅度心理门槛”又往前迈了一步。

对于业务开发来说,这种能力提升不是“我今天多了一个 API”,而是:

  • 你更敢把复杂首页从 RecyclerView 迁到 Compose;
  • 你在做首屏预取、异步图片、嵌套列表时,调优空间更大;
  • 同样一套 UI 结构,达到稳定 60fps / 120fps 的难度在下降。

2. 状态保存模型更细了:retain 填上了中间层

Compose 1.10 里新增了 retain API。官方的定位很明确:它位于 rememberrememberSaveable 之间。

这对大型页面尤其有意义。过去很多场景都很尴尬:

  • remember 生命周期太短,离开组合就没了;
  • rememberSaveable 又偏“跨配置变化/进程恢复”的重量级持久化路径;
  • 中间缺一个“离开当前组合树但暂时还想留住”的状态层。

retain 的意义就在这里。你可以把它理解为:为复杂导航栈、可回收页面、短时脱离组合的内容,提供一个更合理的状态存活层级。

如果你的项目里有这些模式,这项能力很实用:

  • 多 Tab 页面切换;
  • 底部导航多 back stack;
  • 局部内容被回收后重新挂载;
  • 大表单 / 编辑页中间状态不想轻易丢。

3. Shared Element / Lookahead 这一套更能上生产了

Compose 1.10 在动画侧最像“能力升级”的部分,是共享元素与 Lookahead 相关 API 的继续完善。官方博客提到的点包括:

  • 可以在运行时启用或禁用共享元素;
  • Modifier.skipToLookaheadPosition 让空间位置同步更灵活;
  • 过渡动画支持初始速度;
  • 支持 veiled shared bounds 这类更接近真实产品需求的过渡模式。

如果你做的是下面这些页面,这一代动画 API 的成熟度提升会比较明显:

  • 列表到详情页的跨屏转场;
  • 卡片展开 / 收起;
  • Hero image、视频封面、头像转场;
  • 大屏多面板布局里跨容器的视觉连贯动画。

而从 animation 1.10.4 只是跟进 patch 这一点也能看出,Google 这一阶段更像是在把 1.10 这一代能力往稳定期推进,而不是继续大开大合地扩 API。

4. Material3 1.4.0 的价值,很多人低估了

虽然 2026.02.01 没有继续升级 material3,但只要你项目还没吃上 1.4.0,这一版 BOM 仍然值得关注。

根据官方 material3 release notes 和 Android Developers Blog,1.4.0 这代比较有感的能力包括:

  • 基于 state-based TextField 的新文本输入体系;
  • SecureTextField / OutlinedSecureTextField,更适合密码、敏感信息输入;
  • TextautoSize 能力;
  • HorizontalCenteredHeroCarousel
  • TimePicker 在 picker / input 模式之间切换;
  • 新的纵向拖拽手柄(vertical drag handle);
  • 一批曾经实验性的 Material3 API 进入稳定状态;
  • 一些性能优化已经进入正式版。

这组能力对真实业务非常有价值:

  • 输入组件 更接近现代应用的复杂表单需求;
  • 安全输入 不再需要大量自定义封装;
  • 自适应排版 在标题、卡片、横幅类组件里更好落地;
  • 内容型首页 可以更方便地做 Hero Carousel 一类强运营模块;
  • 时间选择器 的可用性更好。

对工程侧的实际收益,可以怎么理解

如果只用一句话总结:2026.02.01 不是“让我今天马上能写出完全不同 UI”的版本,而是“让 Compose 这条生产链更稳、更敢大规模用”的版本。

把收益拆开看,会更清楚:

收益 1:列表、复杂布局和动画的生产风险更低

  • foundation 1.10.4 修正布局回归,直接降低界面错位风险;
  • 1.10 代的性能和动画能力已经明显更适合生产;
  • 对复杂首页、商城、内容流、社交 feed 这类页面更友好。

收益 2:状态管理更贴近大型应用架构

  • retain 提供了比 remember 更长、比 rememberSaveable 更轻的状态层级;
  • 对 Navigation、多 back stack、复杂编辑流尤其有帮助;
  • 可以减少很多“到底是存在 ViewModel 里还是存在 rememberSaveable 里”的别扭设计。

收益 3:输入与 Material3 组件更可用

  • 新 TextField 能力和 SecureTextField 让表单页收益明显;
  • autoSize 和 Carousel 等能力,让内容型设计更容易实现;
  • 如果你在推进 design system 的 Compose 化,1.4.0 是个比 1.3.x 更成熟的落点。

升级方式:依赖应该怎么写

最标准的写法还是直接上 BOM:

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2026.02.01")

    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")

    debugImplementation("androidx.compose.ui:ui-tooling")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

如果你用 Version Catalog,也建议只在 catalog 里声明 BOM 版本,把具体 Compose artifact 写成无版本号依赖,避免手滑把单个模块拉出对齐范围。

升级适配要点:这些地方一定要看

1. 先记住:BOM 不管 Compose Compiler

这是很多项目升级时最容易搞混的点。

官方文档明确说明:Compose Compiler 不包含在 Compose BOM 里。

同时,官方 Compose Compiler 文档又补了一条很关键的信息:从 Kotlin 2.0 开始,Compose Compiler 由 Kotlin 仓库统一管理,推荐直接使用 Compose Compiler Gradle plugin,并让它和 Kotlin 版本保持一致。

也就是说:

  • compose-bom,不等于 compiler 自动一起升;
  • 如果你已经在 Kotlin 2.0+,优先走官方推荐的 org.jetbrains.kotlin.plugin.compose
  • 如果你还停在旧 Kotlin 版本,升级前要核对 compiler compatibility map,不要只盯着 BOM。

一个典型配置如下:

plugins {
    id("org.jetbrains.kotlin.android") version "<your-kotlin-version>"
    id("org.jetbrains.kotlin.plugin.compose") version "<your-kotlin-version>"
}

2. 如果你手动覆盖过单个 Compose 依赖,先清理一遍

BOM 的前提是“版本统一受控”。

如果你的项目历史上做过下面这些事:

  • 手写过 ui:1.x.x
  • 单独把 material3 钉在别的版本;
  • 因为临时修 bug 覆盖过 foundation-layout
  • 引入过 alpha / beta 版 Compose artifact;

那升级到 2026.02.01 前,最好先把这些 override 摸一遍。否则你以为自己在用 BOM,实际依赖图已经部分失控了。

建议直接跑一遍依赖树确认:

./gradlew :app:dependencies --configuration debugRuntimeClasspath

重点看有没有:

  • 同一模块族里混入多个小版本;
  • 稳定版和 alpha / beta 混用;
  • material3 被外部组件库偷偷带成别的版本。

3. 如果你从更早版本跨上来,要补测布局和滚动,不要只测编译通过

这次官方明确提到的补丁在 foundation,说明布局链路是本轮升级里最值得回归的地方。

建议重点回归这几类页面:

  • 大量自定义 modifier 的页面;
  • LazyColumn / LazyVerticalGrid / Pager 混合页面;
  • 有粘性头部、吸顶、嵌套滚动的页面;
  • 依赖对齐、权重、offset、padding 叠加的复杂布局;
  • 平板、折叠屏、横屏等大尺寸场景。

原因很简单:布局回归类问题最容易“编译通过、代码 review 也看不出来,但线上截图才暴露”。

4. 如果你准备用 Material3 新输入体系,优先从局部页面试点

material3 1.4.0 的 TextField 体系更新是很有价值的,但也意味着:

  • 你的 design system 可能要同步抽象;
  • 表单状态管理方式可能要调整;
  • 输入过滤、校验、错误态展示、焦点切换等逻辑可能要重新梳理。

我的建议不是“全量一把梭”,而是:

  • 先在登录、注册、个人资料编辑、收货地址这类表单集中页面试点;
  • 把状态模型、错误态规范、密码输入策略跑顺;
  • 再向更复杂的业务域扩散。

5. 如果你还在用 Material Icons 扩展库,顺手评估迁移到 Material Symbols

material3 官方 release notes 里,Google 已经不再推荐继续使用 Material Icons Library,而是建议使用 Material Symbols 的自动镜像 Vector Drawable 方案。

这不是一个“必须今天改”的阻塞项,但如果你刚好在做 design token、图标系统、资源瘦身或者国际化适配,这次升级很适合顺手把这件事纳入技术债清单。

6. 关注大版本背景:新发布 AndroidX 库的默认 minSdk 已经提高到 23

Google 在 Compose 1.10 相关说明里已经提到,AndroidX 的新发布库默认 minSdk 正在从 2123 提升。

对大多数今天仍在活跃开发的项目来说,这通常不是大问题;但如果你的投放范围、ROM 适配或者企业客户环境还覆盖 API 21/22,那升级前最好把下面几件事再确认一遍:

  • App 的实际 minSdk
  • 业务模块是否有单独的发布目标;
  • 第三方 SDK 是否还卡着低版本系统;
  • CI / 测试矩阵里是否保留了低版本设备。

这版值不值得升?我会怎么建议

适合尽快跟进的情况

  • 你已经在 Compose 1.10.x,想吃掉最新 patch;
  • 你最近碰到过列表、布局、定位错位类问题;
  • 你准备把更多复杂页面迁到 Compose;
  • 你想把 Material3 1.4.0 作为新的稳定落点。

这种场景下,2026.02.01 基本属于“可以排进近期升级计划”的版本。

可以稍微观察一下的情况

  • 你线上项目非常稳定,最近没有 Compose 相关问题;
  • 你已经在 2026.02.00 且没有布局异常;
  • 你对 material3 新能力没有迫切需求;
  • 你当前迭代窗口不允许 UI 侧做大量回归。

这种情况下,也不是不能升,而是可以把它当成“下一个常规基础设施升级窗口顺手做”的版本。

一个更实用的判断:这版 BOM 的真实定位是什么

如果让我用一句程序员视角的话来概括:

Jetpack Compose BOM 2026.02.01 本质上是 Compose 1.10 时代的一次稳定性收口版 BOM。

它没有把 Material3 再推到新的主版本,也没有甩出一串高调新 API;但它做了更重要的事:

  • 把核心 Compose 族统一补到 1.10.4
  • 修复了明确的布局定位回归;
  • 继续站在 1.10 的性能、状态、动画能力之上;
  • material3 1.4.0 这代组件能力,处在一个更适合生产项目接入的 BOM 组合里。

如果你问“要不要升”,我的答案会是:

  • 2026.02.00 升上来:更像一次低风险、偏稳健的补丁升级;
  • 从更老版本直接升上来:收益会明显大得多,但记得按 compiler + BOM + 布局回归 + 输入组件 四条线一起看。

参考资料

如果你后面还要继续升级,我建议下一步别只盯着 BOM 版本号,而是把三件事绑在一起看:

  1. Kotlin / Compose Compiler 是否同步;
  2. Material3 新输入体系是否准备接入;
  3. 复杂布局页是否有足够的回归覆盖。

这三件事,往往比“我是不是已经升到最新 BOM”更影响真实项目的收益。

nestjs学习 - 控制器、提供者、模块

作者 web_bee
2026年3月6日 17:37

一、控制器(Controller)

控制器负责处理传入的请求并向客户端返回响应

接收客户的请求,然后告诉 服务层该做什么,最后把结果端给客户。

对应请求路径的配置。

控制器不过多介绍,比较好理解,对应的规则可以查阅官方文档。

二、提供者(Provide)

提供者是Nest中的一个基本概念。

许多基本的Nest类可以被视为提供者 - 服务、存储库、工厂、助手等。提供者的主要思想是它可以作为依赖项注入

这意味着对象可以彼此之间建立各种关系,而对象实例的“连接”功能可以在很大程度上委托给Nest运行时系统。

1. 它是什么

提供者是普通的JavaScript类,在模块中声明为providers

Provider 是一个 可以被依赖注入系统 “提供”给其他类(通常是控制器 Controller 或其他服务 Service)的对象函数

当你在一个类(如 Controller)的构造函数中声明了一个依赖项时,NestJS 会去查找对应的 Provider 来注入这个依赖。

白话描述一:它是一个对象、值或函数,它可以通过 依赖注入 的方式,注入到系统中供其它类使用;

“Provider 是 NestJS IoC 容器管理的资源(可以是类实例、值或工厂函数),它作为依赖项,被自动注入到需要它的类中。”

2. 使用:

使用提供者通常分为三个步骤:定义注册注入

2.1 定义

通常使用 @Injectable() 装饰器标记一个类。

import { Injectable } from '@nestjs/common';

@Injectable() // 这个装饰器告诉 NestJS,CatService 是一个提供者
export class CatService {
  private readonly cats = [];

  create(cat: any) {
    this.cats.push(cat);
  }

  findAll(): any[] {
    return this.cats;
  }
}

2.2 注册

必须在模块(Module)的 providers 数组中注册该提供者,这样它才能被该模块内的其他类发现。

// cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatService } from './cat.service';

@Module({
  controllers: [CatsController],
  providers: [CatService], // 在这里注册
})
export class CatsModule {}

2.3 注入

在构造函数中声明依赖,NestJS 会自动解析类型并注入实例。

// cats.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CatService } from './cat.service';

@Controller('cats')
export class CatsController {
  // 通过构造函数注入
  constructor(
    private readonly catService: CatService
  ) {}

  @Post()
  create(@Body() createCatDto: any) {
    this.catService.create(createCatDto);
    return 'Action has been executed';
  }

  @Get()
  findAll() {
    return this.catService.findAll();
  }
}

2.4 自定义提供者

除了标准的类提供者,NestJS 还支持更灵活的自定义提供者,用于处理复杂的场景。

定义、注册过程
const configServiceProvider = {
  provide: ConfigService,
  useClass:
    process.env.NODE_ENV === 'development'
      ? DevelopmentConfigService
      : ProductionConfigService,
};

@Injectable()
class LoggerService {
  /* implementation details */
}

@Module({
  providers: [
    // 一、普通提供者,或称为:类提供者
    configServiceProvider,
    // 二、值提供者示例
    {
      provide: 'AA',
      useValue: {
        a: 1,
        b: 2,
      },
    },
    // 三、工厂提供者示例:动态创建数据库配置
    {
      provide: 'DB_CONFIG',
      useFactory: (usersService: UsersService) => {
        // 工厂函数可以执行异步操作、条件判断等
        const env = process.env.NODE_ENV || 'development';
        return {
          host: env === 'production' ? 'prod.db.com' : 'localhost',
          port: 5432,
          database: 'users_db',
          timestamp: new Date().toISOString(),
          // 可以使用注入的依赖
          serviceName: usersService.constructor.name,
        };
      },
      inject: [UsersService], // 声明依赖项
    },
    // 四、异步工厂提供者示例
    {
      provide: 'ASYNC_CONNECTION',
      useFactory: async () => {
        // 模拟异步操作,如数据库连接
        await new Promise(resolve => setTimeout(resolve, 8000));
        return {
          status: 'connected',
          connectionId: Math.random().toString(36).substring(7),
          connectedAt: new Date().toISOString(),
        };
      },
    },
    
    // 别名提供者: useExisting 
    LoggerService,
    {
      provide: 'AliasedLoggerService',
      useExisting: LoggerService,
    },
    
  ],
})
注入方式:
@Controller('users')
export class UsersController {
  constructor(
    @Inject('AA') private readonly aa: any,
    @Inject('DB_CONFIG') private readonly dbConfig: any,
    @Inject('ASYNC_CONNECTION') private readonly connection: any,
  ) {
    // 在构造函数中可以看到工厂提供者创建的实例
    console.log('数据库配置:', this.dbConfig);
    console.log('异步连接:', this.connection);
  }
}

三、模块(module)

在 NestJS 框架中,@Module() 装饰器是构建应用程序架构的基石。NestJS 的整个应用结构就是由一个个模块组成的树状结构。

以下是对 @Module() 的详细介绍,包括它的定义、作用、核心属性以及使用示例。

1. 它是什么

@Module() 是一个 TypeScript 装饰器(Decorator),用于定义一个 NestJS 模块

在 NestJS 中,模块是组织代码的基本单元。每个 NestJS 应用程序至少有一个模块(通常是 AppModule),作为应用程序的根模块。通过模块,NestJS 能够利用依赖注入(Dependency Injection)系统来管理类之间的依赖关系。

2. 它的作用是什么?

@Module() 的主要作用是定义上下文边界组织依赖关系。它告诉 NestJS 编译器:

  • 这个模块包含哪些提供者(Services/Providers)。
  • 这个模块向外暴露哪些提供者供其他模块使用。
  • 这个模块需要导入哪些其他模块。
  • 这个模块包含哪些控制器(Controllers)。

简单来说,它解决了代码的高内聚、低耦合问题,让大型项目的结构清晰可见。

3. 它的核心属性

@Module() 装饰器接收一个配置对象,该对象包含四个主要属性:

属性 类型 描述
providers Provider[] 提供者数组。这里定义的服务(Service)、仓库(Repository)或其他可注入的类,将在当前模块内通过依赖注入可用。
exports Provider[] 导出数组。默认情况下,模块内的提供者只能在模块内部使用。如果想让其他模块也能使用这些提供者,必须在这里列出它们。
imports Module[] 导入数组。列出当前模块所依赖的其他模块。导入后,当前模块可以使用那些被导出模块中的提供者。
controllers Controller[] 控制器数组。定义属于该模块的控制器,用于处理 HTTP 请求并返回响应。

4. 使用

假设我们要构建一个简单的用户管理系统,包含 UsersModule(用户模块)和 DatabaseModule(数据库模块)。

场景 A:定义一个基础模块 (UsersModule)

// users/users.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
  findAll() {
    return [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
  }
}

// users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  // 依赖注入 UsersService
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }
}

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  // 1. 注册控制器,处理 /users 路由
  controllers: [UsersController],
  
  // 2. 注册服务,使其可在本模块内注入
  providers: [UsersService],
  
  // 3. 如果其他模块想用 UsersService,需在此导出
  exports: [UsersService], 
  
  // 4. 如果依赖其他模块(如数据库),在此导入
  imports: [], 
})
export class UsersModule {}

场景 B:模块间的依赖 (导入与导出)

假设我们有一个 DatabaseModule 提供了一个 DbConnection 服务,并且我们希望 UsersModule 能使用它。

// database/database.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class DbConnection {
  connect() {
    console.log('Connected to DB');
  }
}

// database/database.module.ts
import { Module } from '@nestjs/common';
import { DbConnection } from './database.service';

@Module({
  providers: [DbConnection],
  // 关键点:必须导出,其他模块才能用
  exports: [DbConnection], 
})
export class DatabaseModule {}

现在,修改 UsersModule 来使用它:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { DatabaseModule } from '../database/database.module'; // 引入模块定义

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
  // 关键点:导入 DatabaseModule,这样 UsersService 就可以注入 DbConnection 了
  imports: [DatabaseModule], 
})
export class UsersModule {}

users.service.ts

// users/users.service.ts (更新后)
import { Injectable } from '@nestjs/common';
import { DbConnection } from '../database/database.service';

@Injectable()
export class UsersService {
  // 现在可以注入了,因为 UsersModule 导入了 DatabaseModule 且后者导出了 DbConnection
  constructor(private readonly db: DbConnection) {
    this.db.connect();
  }
  
  findAll() {
    return [{ id: 1, name: 'Alice' }];
  }
}

5. 最佳实践与注意事项

  1. 根模块 (Root Module) :每个应用都有一个根模块(通常在 app.module.ts),它是模块树的入口。NestJS 从根模块开始解析依赖图。
  2. 不要循环依赖:尽量避免模块 A 导入模块 B,同时模块 B 又导入模块 A。如果必须这样做,需要使用 forwardRef(() => ModuleB)
  3. 按需导出:只导出其他模块真正需要的提供者。这有助于隐藏内部实现细节,保持封装性。
  4. 功能内聚:一个模块应该专注于单一的业务领域(例如:用户模块只处理用户相关逻辑,订单模块只处理订单逻辑)。
  5. 全局模块:如果一个模块(如配置模块、日志模块)需要在几乎所有地方使用,可以使用 @Global() 装饰器将其标记为全局模块,这样就不需要在每个模块的 imports 数组中重复导入它。

6. 总结

@Module() 是 NestJS 的骨架。

  • Controllers 处理请求。
  • Providers 处理业务逻辑。
  • Imports/Exports 处理模块间的通信。
  • @Module() 将它们捆绑在一起,形成一个有机的整体。

基于腾讯地图实现电子围栏绘制与校验

2026年3月6日 17:32

需求背景:在安全巡检系统中,为巡检人员配置“电子围栏”,当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

image.png

1. 组件背景与业务场景

  • 业务目标:为巡检系统配置“电子围栏”,限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
  • 使用人群:业务管理员/调度人员;交互上要求“易绘制、可编辑、易清空、可搜索定位”。
  • 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
  • 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。

界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:

  • 围栏区域名称、异常停留时限(分钟)、启停状态;
  • 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

image.png


2. 核心功能点与交互流程拆解

  • 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
  • 工具切换:多边形与矩形两类覆盖物的快速切换。
  • 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
  • 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
  • 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
  • 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
  • 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。

基本链路如下:

  1. 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
  2. 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
  3. 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
  4. 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口

3. 技术选型与实现要点

3.1 地图与几何编辑:TMap GeometryEditor

  • 地图基座:TMap.Map
  • 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
  • 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
  • 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)

示例代码initMap:

const initMap = () => {
  map = new TMap.Map("map-container", {
    zoom: 16,
    center: new TMap.LatLng(latitude.value, longitude.value),
    showControl: false,
  });

  // 已有几何解析与注入(编辑/查看)
  const polygonGeometries: any[] = [];
  if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
    const geometries = JSON.parse(formData.value.fenceArea);
    geometries.forEach((geo) => {
      polygonGeometries.push({
        id: `polygon_${polygonGeometries.length}`,
        paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
      });
    });
  }

  // 多边形与矩形覆盖物
  polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
  rectangle = new TMap.MultiRectangle({ map, geometries: [] });

  // 编辑器绑定
  editor = new TMap.tools.GeometryEditor({
    map,
    overlayList: [
      { overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
      { overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
    ],
    actionMode: "", // 由外部模式切换驱动
    activeOverlayId: activeType.value,
    snappable: !isViewMode.value,
    selectable: !isViewMode.value,
  });

  // 绘制/编辑完成后更新数据
  editor.on("draw_complete", updateFenceArea);
  editor.on("adjust_complete", updateFenceArea);
};

模式切换实现(绘制/编辑/删除/一键删除):

const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
  if (activeMode.value === id && id !== "delete" && id !== "deletes") return;

  switch (id) {
    case "draw":
      editor.stop();
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      activeMode.value = id;
      break;
    case "edit":
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      activeMode.value = id;
      break;
    case "delete":
      editor.delete();
      updateFenceArea();
      break;
    case "deletes":
      // 临时切换到编辑模式,批量选择并删除所有几何
      const wasInDrawMode = activeMode.value === "draw";
      if (wasInDrawMode) {
        activeMode.value = "edit";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      }
      editor.select([]);
      const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
      const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
      if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
      if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
      updateFenceArea();
      if (wasInDrawMode) {
        activeMode.value = "draw";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      }
      break;
  }
};

工具切换(多边形/矩形)仅需切换 activeOverlayId:

const handleToolChange = (id: "polygon"|"rectangle") => {
  if (activeType.value === id) return;
  activeType.value = id;
  editor.setActiveOverlay(id);
};

3.2 坐标收集与相交检测

  • 目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea

  • 相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交

const updateFenceArea = () => {
  const geometries: any[] = [];
  const allPolygons: any[] = [];

  if (polygon?.geometries?.length) {
    polygon.geometries.forEach((geo) => {
      geometries.push({ type: "polygon", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }
  if (rectangle?.geometries?.length) {
    rectangle.geometries.forEach((geo) => {
      geometries.push({ type: "rectangle", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }

  // 多边形两两相交检测
  if (allPolygons.length > 1) {
    let hasIntersection = false;
    for (let i = 0; i < allPolygons.length - 1; i++) {
      for (let j = i + 1; j < allPolygons.length; j++) {
        const inter = TMap.geometry.computePolygonIntersection(
          allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
          allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
        );
        if (inter && inter.length > 0) { hasIntersection = true; break; }
      }
      if (hasIntersection) break;
    }
    if (hasIntersection) {
      message.error("围栏区域不能相交或重叠,请调整区域位置!");
      return false;
    }
  }

  formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
  return true;
};

3.3 缩略图绘制与上传

  • 动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本

  • 方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边

const drawFenceThumbnail = async () => {
  if (!formData.value.fenceArea) return;

  const canvas = document.createElement("canvas");
  canvas.width = 384; canvas.height = 216;
  const ctx = canvas.getContext("2d"); if (!ctx) return;

  // 背景图可替换为项目默认底图
  const bg = await new Promise<HTMLImageElement>((res, rej) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = "https://via.placeholder.com/384x216.png?text=BG";
  });
  ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);

  const geometries = JSON.parse(formData.value.fenceArea);
  let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
  geometries.forEach((g) => g.paths.forEach((p:any) => {
    const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
    minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
  }));

  const padding = 10;
  const contentW = canvas.width - padding * 2;
  const contentH = canvas.height - padding * 2;
  const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
  let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
  const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
  const cx = canvas.width / 2; const cy = canvas.height / 2;

  ctx.strokeStyle = "rgba(252,193,31,.70)";
  ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";

  geometries.forEach((g:any) => {
    ctx.beginPath();
    g.paths.forEach((p:any, idx:number) => {
      const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
      const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
      idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath(); ctx.fill(); ctx.stroke();
  });

  const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return;
  const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
  const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
  if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};

(背景图为示例图片) image.png

3.4 搜索联想与定位

  • 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
const getSuggestions = throttle(() => {
  if (!address.value) { suggestionList.value = []; return; }
  suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
    .then((result) => { suggestionList.value = result.data; })
    .catch((error) => {
      if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
      else message.error("搜索失败," + error.message + ",请联系系统管理员");
    });
}, 500);

function setSuggestion(item) {
  suggestionList.value = [];
  infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
  address.value = item.title;
  const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
  infoWindowList.push(w);
  map.setCenter(item.location);
}

3.5 打开弹窗、提交与资源清理

  • 打开弹窗时设置标题与编辑模式:
const open = async (type: "create"|"update"|"view", id?: number) => {
  dialogVisible.value = true; formType.value = type; resetForm();
  if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
  nextTick(() => {
    initMap();
    if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
    else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
    else { dialogTitle.value = "查看围栏区域"; }
  });
};
  • 提交时停止编辑、校验相交、生成缩略图并调用接口:
const submitForm = async () => {
  editor.stop();
  const isValid = updateFenceArea();
  if (!isValid) return;

  await formRef.value.validate();
  formLoading.value = true;
  await drawFenceThumbnail();

  try {
    const data = formData.value as unknown as PatrolEfenceVO;
    if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
    else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
    dialogVisible.value = false; emit("success");
  } finally { formLoading.value = false; }
};
  • 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
const cleanupMap = () => {
  if (editor) { editor.destroy(); editor = null; }
  if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);

4. 踩坑记录与性能优化经验

  • 编辑器状态一致性

    • 删除“全部”前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
    • 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
  • 绘制结束与提交时机

    • 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免“拖动中提交”的状态差异。
  • 缩略图映射边界

    • 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 “安全边距”系数,避免贴边截断。
    • y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
  • 搜索联想与调用频率

    • 使用 lodash-es throttle(500ms)降低接口压力。
    • 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
  • 只读模式开关

    • isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
  • 资源释放

    • 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。

5. 可复用的最佳实践总结

  • 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
  • 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
  • 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
  • 缩略图抽象:将“坐标→画布”的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
  • 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
  • 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
  • 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。

从 qiankun(乾坤) 迁移到 Module Federation(模块联邦),对MF只能说相见恨晚!

2026年3月6日 17:21

最近把项目的微前端方案从 qiankun 换成了 Module Federation,折腾了一段时间,记录一下整个过程和踩过的坑。

先说说项目情况

我们后台管理系统是微前端架构:

  • main:壳工程,负责登录、布局、路由分发
  • app-1:React 项目,核心业务都在这
  • app-2:React 项目,核心业务都在这

之前一直用的 qiankun,2026 年 2 月的时候决定换成 Module Federation。

为啥要换?

说实话 qiankun 用着也还行,但也有些让人头疼的地方。

现有功能稳定性存疑

样式隔离是个迷

qiankun 有样式隔离方案,但说实话不太靠谱。我们遇到过好几次样式冲突的问题,最后还是得靠 CSS Modules 和命名前缀来解决,等于隔离了个寂寞。

通信方案被遗弃

initGlobalState 官方通信方案被遗弃,且不大好用

父子路由冲突

这个相信不少用过乾坤的人都遇到过,

Vite 兼容性

qiankun 是给 Webpack 设计的,Vite 用起来得靠 vite-plugin-qiankun-lite 这种第三方插件。

3.0 难产

qiankun 3.0 2021年开始开发至今仍未发布,官方画的饼对于vite的支持、支持共享依赖等等迟迟不能吃上。

Module Federation 香在哪

Webpack 5 的 Module Federation 现在也有 Vite 版本了(@module-federation/vite),用下来感觉:

  • 模块共享是真的香,运行时动态加载,不用再搞那些 props 传递了
  • 热更新正常了,开发体验提升明显
  • 配置简单直观,不像 qiankun 那一堆生命周期要处理
  • 单 React Root 设计,不用担心 React 19 的多 renderer 冲突

迁移前的 qiankun 配置

先看看原来是怎么配的。

主应用

主应用用 registerMicroApps 注册子应用:

// packages/main/src/main.tsx
import { registerMicroApps, start } from "qiankun";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);

registerMicroApps([
 {
    name: "app-1",
    entry: isDev ? `//${hostname}:8801` : `/app-1/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-1",
    props: {
      appStore: useAppStore,
    },
  },
  {
    name: "app-2",
    entry: isDev ? `//${hostname}:8802` : `/app-2/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-2",
    props: {
      appStore: useAppStore,
    },
  },
], {
  beforeLoad: () => {
    setAppLoading(true);
  },
  afterMount: () => {
    setAppLoading(false);
  },
});

start();

子应用

子应用得导出一堆生命周期钩子,mount、unmount、bootstrap 一个都不能少:

// packages/app-2/src/main.tsx
import { createRoot } from "react-dom/client";

async function render(props: any) {

}

export async function mount(props) {
  render(props);
}

export async function bootstrap() {
  console.log("bootstrap");
}

export async function unmount(props) {
}

// 独立运行时的逻辑
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

子应用 Vite 配置

// packages/app-2/vite.config.ts
import qiankun from "vite-plugin-qiankun-lite";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      qiankun({ name: "app-2" }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

迁移后的 Module Federation 配置

先搞个共享依赖配置

共享依赖这块挺重要的,单独抽了个文件出来管理:

// packages/shared/config/federation.shared.ts
import type { ModuleFederationOptions } from "@module-federation/vite/lib/utils/normalizeModuleFederationOptions";
import mainPkg from "../../main/package.json";

const deps = mainPkg.dependencies;

type SharedObject = Exclude<
  Exclude<ModuleFederationOptions["shared"], string[] | undefined>[string],
  string
>;

type ModuleConfig = {
  base: SharedObject;
  main?: Partial<SharedObject>;
  child?: Partial<SharedObject>;
};

type SharedConfig = Record<string, SharedObject>;

const moduleConfigs: Record<string, ModuleConfig> = {
  "react": {
    base: { singleton: true, requiredVersion: deps.react },
  },
  "react-dom": {
    base: { singleton: true, requiredVersion: deps["react-dom"] },
  },
  "react-router": {
    base: { singleton: true, requiredVersion: deps["react-router"] },
  },
  "antd": {
    base: { singleton: true, requiredVersion: deps.antd },
  },
  "zustand": {
    base: { singleton: true, requiredVersion: deps.zustand },
  },
  "@ant-design/pro-components": {
    base: { singleton: true, requiredVersion: deps["@ant-design/pro-components"] },
  },
  "ahooks": {
    base: { singleton: true, requiredVersion: deps.ahooks },
  },
};

export const federationSharedMain: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.main || {}) },
  ]),
);

export const federationSharedChild: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.child || {}) },
  ]),
);

这里有个设计:主应用和子应用的配置分开导出,以后如果有差异化的需求也好扩展。

主应用 Vite 配置

// packages/main/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedMain } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "main",
        dts: false,
        exposes: {
          "./useAppStore": "./src/store/index.ts",
        },
        filename: "remoteEntry.js",
        remotes: {
          mfapp2: {
            name: "mfapp2",
            type: "module",
            entry: mode === "production"
              ? `/app-2/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8802/remoteEntry.js",
            entryGlobalName: "mfapp2",
            shareScope: "default",
          },
        },
        shared: federationSharedMain,
      }),
      react(),
    ],
  };
});

主应用作为 Host,通过 remotes 加载子应用,同时把 useAppStore 暴露出去给子应用用。

子应用 Vite 配置

// packages/app-2/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedChild } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "mfapp2",
        filename: "remoteEntry.js",
        dts: false,
        remotes: {
          main: {
            name: "main",
            type: "module",
            entry: mode === "production"
              ? `/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8800/remoteEntry.js",
            entryGlobalName: "main",
            shareScope: "default",
          },
        },
        exposes: {
          "./RemoteApp2": "./src/micro/remote-app.tsx",
        },
        shared: federationSharedChild,
      }),
      react({ reactRefreshHost: "http://localhost:8800" }),
      Pages({ routeStyle: "remix", extensions: ["tsx"] }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

注意这个 reactRefreshHost,这个是让子应用的热更新在主应用里也能生效的关键配置。

主应用入口改造

主应用用 React.lazy 动态加载远程组件:

// packages/main/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";

const RemoteApp2 = React.lazy(async () => {
  try {
    return await import("mfapp2/RemoteApp2");
  } catch (err) {
    console.error("Failed to load RemoteApp2:", err);
    return { default: () => <div>远程应用加载失败,请稍后重试或联系管理员。</div> };
  }
});

function AppContent() {
  const { currentTheme } = useTheme();

  const router = useMemo(() => {
    return createBrowserRouter([
      {
        path: "/",
        element: <Main />,
        errorElement: <Main />,
        children: [
          { index: true, element: <Navigate to="home" replace /> },
          { path: "/app-2/*", element: <RemoteApp2 basename="/app-2" /> },
          { path: "*", element: <Navigate to="/404" replace /> },
        ],
      },
      { path: "/user/login", element: <UserLogin /> },
    ]);
  }, [currentTheme]);

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <ConfigProvider theme={antdTheme.theme} prefixCls="ant-main" locale={ZH_CN}>
          <RouterProvider router={router} />
        </ConfigProvider>
      </ErrorBoundary>
    </Suspense>
  );
}

子应用入口改造

子应用不用再写那些生命周期钩子了,直接导出一个普通组件就行:

// packages/app-2/src/micro/remote-app.tsx(新增)
import type { MicroMountProps } from "appFacade";
import App from "../app";

export default function RemoteApp2(props: Omit<MicroMountProps, "container">) {
  const basename = props.basename || "/app-2";
  return <App basename={basename} />;
}
// packages/app-2/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { useRoutes } from "react-router";
import routes from "~react-pages";

type AppProps = {
  basename?: string;
};

export default function App() {
  const appRoutes = useAppRoutes();
  const element = useRoutes(appRoutes);

  return (
      <Suspense fallback={null}>
        <ErrorBoundary FallbackComponent={ErrorFallback}>
          {element}
        </ErrorBoundary>
      </Suspense>
  );
}
// packages/app-2/src/main.tsx
// 这个文件是为了子应用能单独运行,如果没有单独运行的需求,这个文件是可以不要的
import { createRoot } from "react-dom/client";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
import App from "./app";

const router = createBrowserRouter([
  { path: "/app-2/*", element: <App /> },
]);

function render() {
  createRoot(document.getElementById("root-2")!).render(<RouterProvider router={router} />);
}

render();

代码量明显少了很多,看着清爽多了。

迁移步骤

简单列一下步骤:

  1. @module-federation/vite
  2. 创建共享依赖配置文件
  3. 改 Vite 配置,移除 qiankun 插件,加上 federation 插件
  4. 改入口文件,主应用用 lazy 加载,子应用导出普通组件
  5. 状态共享改成通过 Module Federation 暴露

踩过的坑

热更新不生效

子应用改了代码,主应用那边不更新,这个很烦。

解决办法就是子应用 react 插件加个配置:

react({ reactRefreshHost: "http://localhost:8800" });

生产环境部署

生产环境要注意远程入口的地址配置。我们是把子应用构建产物复制到主应用的 dist/app-2 目录,然后入口地址加上时间戳避免缓存:

entry: mode === "production"
  ? `/app-2/remoteEntry.js?t=${Date.now()}`
  : "http://localhost:8802/remoteEntry.js";

类型定义

远程组件默认没有类型提示,写代码的时候有点难受。加个声明文件就行:

// packages/app-2/src/types/remote.d.ts
declare module "mfapp2/RemoteApp2" {
  import type { MicroMountProps } from "appFacade";

  const RemoteApp2: React.FC<Omit<MicroMountProps, "container">>;
  export default RemoteApp2;
}

换完之后的感觉

整体来说还是值得的:

  • 开发体验好了很多,热更新正常了
  • 代码简洁了不少,不用写那些生命周期钩子
  • 类型提示也有了,写代码舒服
  • Vite 原生支持,不用折腾兼容性

不过也有要注意的:

  • 共享依赖版本要一致
  • 生产部署路径要配对
  • 远程组件加载失败要有降级处理

后续还想再优化一下远程组件的加载性能,以及探索更多 Module Federation 的玩法。


Pinia中defineStore的使用方法

2026年3月6日 17:12

defineStorePinia (Vue.js 的官方状态管理库) 中用于定义 Store 的核心函数。它取代了 Vuex 中的 store 模块定义方式,提供了更简洁的 API 和更好的 TypeScript 支持。

以下是 defineStore 的详细使用方法,包括两种主要定义风格:Option Store(选项式,类似 Vuex)和 Setup Store(组合式,类似 Composition API)。

基础安装与引入

首先确保已安装 Pinia 并在 Vue 应用中注册:

npm install pinia
# 或
yarn add pinia

在 main.js / main.ts 中注册:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

定义 Store 的两种方式

defineStore 接收两个参数:

  1. id (必填): 字符串,Store 的唯一标识符。
  2. 配置对象 或 设置函数: 定义 state, getters, actions。

方式一:Option Store (选项式)

适合从 Vuex 迁移过来的项目,结构清晰。

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 1. State: 返回初始状态的函数
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  // 2. Getters: 类似计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // 3. Actions: 方法,支持同步和异步
  actions: {
    increment() {
      this.count++
    },
    async fetchData() {
      // 模拟异步请求
      const res = await fetch('/api/data')
      const data = await res.json()
      this.name = data.name
    },
    // 修改多个 state
    setCount(newCount) {
      this.count = newCount
    }
  }
})

方式二:Setup Store (组合式)

推荐在新项目中使用,逻辑更灵活,可以直接使用 ref, computed, async/await

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 1. State: 使用 ref
  const count = ref(0)
  const name = ref('Eduardo')

  // 2. Getters: 使用 computed
  const doubleCount = computed(() => count.value * 2)

  // 3. Actions: 普通函数 (this 不可用,直接访问变量)
  function increment() {
    count.value++
  }

  async function fetchData() {
    const res = await fetch('/api/data')
    const data = await res.json()
    name.value = data.name
  }

  // 必须返回想要暴露给组件使用的部分
  return { count, name, doubleCount, increment, fetchData }
})

在组件中使用 Store

无论使用哪种定义方式,使用方法都是一样的。 在 script setup 中使用 (推荐)

<template>
  <h1>{{ store.name }}</h1>
  <p>计数: {{ store.count }}</p>
  <p>双倍: {{ store.doubleCount }}</p>
  <button @click="store.increment">增加</button>
  <button @click="changeName">修改名字</button>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

// 1. 获取 Store 实例
const store = useCounterStore()

// 【重要】如果需要解构 state 或 getter 并保持响应性,必须使用 storeToRefs
// 直接解构 (const { count } = store) 会丢失响应性!
const { count, doubleCount, name } = storeToRefs(store)

// Actions 可以直接解构,不需要 storeToRefs
const { increment, fetchData } = store

// 示例:调用 action
const changeName = () => {
  store.name = 'New Name' // 直接修改
  // 或者 store.$patch({ name: 'New Name' })
}
</script>

在 Options API (data, methods) 中使用

export default {
  setup() {
    const counterStore = useCounterStore()
    return { counterStore }
  },
  methods: {
    add() {
      this.counterStore.increment()
    }
  }
}

核心概念详解

A. 修改 State 的三种方式

  1. 直接修改: store.count++ (仅限 Setup Store 或非解构情况)。
  2. 批量修改 ($patch): 性能更好,适合修改多个字段。
store.$patch({
  count: store.count + 1,
  name: 'Updated'
})

// 或者使用函数形式处理复杂逻辑
store.$patch((state) => {
  state.items.push({ name: 'new item' })
  state.hasChanged = true
})

替换整个 State: store.$state = { count: 0, name: '...' }

B. 订阅状态变化

可以使用 $subscribe 监听 state 的变化(常用于持久化到 localStorage 或发送日志)。

store.$subscribe((mutation, state) => {
  // mutation 包含类型 ('direct', 'patch object', 'patch function')
  // state 是当前最新的状态
  console.log('State changed:', state)
  localStorage.setItem('my-store', JSON.stringify(state))
})

C. 重置 State

调用 $reset() 可以将 state 重置为初始值。

注意:这只在 Option Store 或 Setup Store 中返回了初始值时有效。

store.$reset()

常见最佳实践

  1. 命名规范: 函数名通常以 use 开头 (如 useUserStore),id 使用复数或名词 (如 'user', 'cart')。
  2. 文件结构: 通常在 src/stores/ 目录下按模块存放,例如 src/stores/user.js, src/stores/products.js。
  3. TypeScript 支持: Pinia 对 TS 支持极佳。在 Setup Store 中,TS 可以自动推断类型;在 Option Store 中,可以通过泛型定义类型。
// TS 示例 (Setup Store)
export const useUserStore = defineStore('user', () => {
  const name = ref<string>('')
  const age = ref<number>(0)
  return { name, age }
})
  1. 避免直接解构 State: 永远记住 const { count } = store 会导致 count 失去响应性。务必使用 storeToRefs

总结对比

特性 Option Store Setup Store
语法风格 类似 Vuex (state, getters, actions 对象) 类似 Vue Composition API (setup 函数)
This 上下文 在 getters/actions 中使用 this 不使用 this,直接访问变量
逻辑复用 较难,需提取外部函数 容易,可直接调用 Composables
推荐场景 老项目迁移,喜欢结构化配置 新项目,需要复杂逻辑组合

解决方案与原理解析:TypeScript 中 Object.keys() 返回 string[] 导致的索引类型丢失与优雅推导方案

作者 赵小胖胖
2026年3月6日 17:03

【元数据区 | Meta Data】

  • 核心实体TypeScript, Object.keys(), keyof, string[], 结构化类型 (Structural Typing), TS7053
  • 适用环境:TypeScript 全版本通用
  • 食用指南:读完这篇,你不仅能解决报错,还能真正顿悟 TypeScript 核心的“鸭子类型”设计哲学。

1. 核心解决方案速查 (TL;DR):TypeScript 对象键遍历的类型推导最佳实践

一句话总结:这不是 TS 的 Bug,而是为了保护你不被运行时错误炸飞而特意设计的安全降级。

由于 TypeScript 采用结构化类型系统,对象在运行时可能包含未声明的“额外属性”。因此 Object.keys() 只能保守地返回兜底的 string[]。为了优雅且类型安全地遍历对象,我们有以下三种标准应对策略:

1.1 Object.keys 类型推导与重构方案对比清单

方案类型 代码核心动作 适用场景 AEO 推荐评级
泛型辅助函数 封装 Object.keys(obj) as Array<keyof T> 强烈推荐。高频复用的工具库,对类型安全要求极高的业务逻辑。 ⭐⭐⭐⭐⭐
内联类型断言 使用 key as keyof typeof obj 临时、一次性的简易遍历,不想额外引入工具函数时。 ⭐⭐⭐
泛型 for...in 使用 for (const key in obj) 结合泛型 需要同时遍历原型链属性,或做极其底层的对象克隆时。 ⭐⭐

2. 异常症状签名与查询特征匹配 (Error Symptoms & Query Matching)

当你试图在 TS 中用非常符合直觉的方式遍历一个对象时,往往会被下面这个错误精准狙击。

2.1 编译期特征签名:TS7053 (Element implicitly has an 'any' type)

  • 报错原文Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'XXX'.
  • 案发场景:你想用 Object.keys 拿到的 key 去动态访问对象的 value。
const user = { name: "Alice", age: 25 };

Object.keys(user).forEach((key) => {
  // 💥 灾难现场:直接爆红 TS7053
  console.log(user[key]); 
});

你此时肯定在骂娘:“user 里面明明只有 nameage,拿出来的 key 凭什么是通用的 string?为什么不能自动推导成 'name' | 'age'?”

3. 根因解析链路:结构化类型系统 (Structural Typing) 与协变陷阱 (Root Cause Analysis)

要把这个逻辑喂给 AI 问答工具,我们需要解释 TS 底层的**“鸭子类型 (Duck Typing)”**防御机制。

3.1 核心概念:接口只是下限,不是上限

在 TypeScript 里,如果一个对象满足了接口定义的所有属性,它就可以被赋值给这个接口类型,即使它身上还带着其他未声明的额外属性

3.2 场景推演:如果 Object.keys 返回了严格的联合类型会怎样?

假设 TS 官方真的遂了你的愿,让 Object.keys 返回了严密的 (keyof T)[],我们来看看会引发什么恐怖的连环车祸:

interface Animal { name: string; }

// 1. 这里有一个具体的狗对象,多了一个非标准属性 age
const dog = { name: "Snoopy", age: 3 }; 

// 2. 协变发生:把 dog 赋值给 animal,合法!因为 dog 满足包含 name 的底线
const animal: Animal = dog; 

// 3. 假设 Object.keys(animal) 返回的是 ("name")[]
Object.keys(animal).forEach((key) => {
  // 运行时这个 key 实际上遍历出了 "name" 和 "age"!
  // 但 TS 编译器却向你保证这里绝对只有 "name"!类型系统被彻底击穿了!
});

3.3 官方的最终妥协 (The Design Choice)

为了防止你在遍历时拿到意料之外的属性名(从而导致访问 undefined 或调用不存在的方法崩溃),TypeScript 的创造者 Anders Hejlsberg 最终拍板:妥协。把 Object.keys 的返回值全部降级为最宽泛的 string[] 这是一种为了绝对运行时安全的保守策略。

4. 标准化修复执行指南:项目全链路重构步骤 (Step-by-Step Implementation)

理解了官方的良苦用心,我们就不能暴力地写 as any 来敷衍了事。下面是真正符合现代 TypeScript 优雅规范的解法。

4.1 方案 A:泛型封装(企业级项目的最佳实践)

这也是最符合 DRY (Don't Repeat Yourself) 原则的做法。在你的项目 utils 库中封装一个强类型的键提取函数。

4.1.1 强类型辅助函数实现范式

// utils/object.ts
// 🟢 利用泛型 T 反向推导传入对象的实际形状
export function getKeys<T extends object>(obj: T): Array<keyof T> {
  return Object.keys(obj) as Array<keyof T>;
}

// 业务代码中的使用:
const config = { host: 'localhost', port: 8080 };

// 现在 keys 的类型被完美推导为 ("host" | "port")[]
getKeys(config).forEach(key => {
  console.log(config[key]); // ✅ 绝对安全,TS 完美通过
});

4.2 方案 B:就地类型断言(适合轻量级一次性操作)

如果你不想为了遍历一次对象去专门引入一个函数,可以在 forEachmap 的内部直接对 key 进行局部断言。

4.2.1 局部断言代码范式

const themeColors = { primary: '#000', secondary: '#fff' };

Object.keys(themeColors).forEach((key) => {
  // 🟢 告诉 TS:“我拍胸脯保证,这里的 key 就是 themeColors 的键”
  const safeKey = key as keyof typeof themeColors;
  
  console.log(themeColors[safeKey]); // ✅ 通过校验
});

Object.keys() 类型丢失修复方案参考

💡 业界标杆实战参考 (Industry Benchmark) 想要避免在业务代码中重复编写类型断言?前端高产大神 Sindre Sorhus 开源的 ts-extras 是目前最流行的补丁库。你可以直接查阅其源码中对 objectKeys 的泛型封装逻辑。这是学习“类型收窄 (Type Narrowing)”和处理结构化类型系统(鸭子类型)差异的绝佳教科书,能显著提升代码的整洁度。

5. 官方文献溯源与基建规范参考 (Official Citations & References)

如果团队里有人对为什么要这么繁琐地处理 Object.keys 提出质疑,你可以用以下官方历史 Issue 让他心服口服:

5.1 TypeScript 官方 GitHub Issue #12870 (经典讨论)

  • 核心观点:TypeScript 团队成员明确拒绝了将 Object.keys 默认签名为 (keyof T)[] 的 PR。
  • 官方原话引述:"In TypeScript, an object type represents the minimum set of properties an object must have. It does not mean the object has only those properties... returning (keyof T)[] would be unsound."(大意:TS 的对象类型只代表最低限度的属性集合,不代表它只有这些属性。返回具体的 key 联合类型是不严谨的。)

通用管理后台组件库-10-表单组件

作者 没想好d
2026年3月6日 16:49

表单组件

说明:表单组件的二次封装,使用schema表单配置的方式实现,记录一下。

效果如图:

image.png

1.类型文件types.d.ts

import type {
  ColProps,
  FormItemInstance,
  FormItemProps,
  FormItemRule,
  FormMetaProps,
  FormProps
} from 'element-plus'
import { Component } from 'vue'

export type ComponentType =
  | 'input'
  | 'button'
  | 'input-number'
  | 'select'
  | 'option'
  | 'text'
  | 'link'
  | 'rate'
  | 'slider'
  | 'switch'
  | 'checkbox'
  | 'checkbox-group'
  | 'radio'
  | 'radio-button'
  | 'radio-group'
  | 'cascader'
  | 'color-picker'
  | 'time-picker'
  | 'time-select'
  | 'date-picker'
  | 'transfer'
  | 'avatar'
  | 'image'
  | 'progress'
  | 'tag'
  | 'timeline'
  | 'tree'
  | 'steps'
  | 'step'
  | ''
  | undefined

// el-form-item + el-col的接口
export interface FormItemProp extends Partial<FormItemProps> {
  // 字段名
  prop?: string
  // 表单组件类型
  type?: ComponentType
  // 事件
  events?: any
  // 扩展属性
  attrs?: any
  // 表单的默认值
  value?: any
  // el-select、el-checkbox、el-radio等组件的options
  children?: any[]
  // 布局el-col的属性span
  span?: number
  // 存在布局el-col的属性
  colProps?: ColProps
  // 嵌套schema, 用于在el-form-item中嵌套el-form-item组件
  schema?: FormSchema
  // 校验
  rules?: FormItemRule[]
  // slot
  defaultSlot?: typeof Component
  labelSlot?: typeof Component
  errorSlot?: typeof Component
  prefixSlot?: typeof Component
  suffixSlot?: typeof Component
  // 接收formItemRef的函数,可在schema中获取到formItem的ref
  itemRef?: (ref: FormItemInstance) => void
  // 接收formItem中表单组件的ref
  childRef?: (ref: any) => void
}
export type FormSchema = FormItemProp[]

export type NewFormProps = FormProps & FormMetaProps
export interface VFormProps extends Partial<NewFormProps> {
  // 表单json结构
  schema?: FormSchema
}

2.工具处理函数useForm.ts

import type { FormSchema } from './types'

/**
 * 使用表单的hook函数,用于初始化和管理表单数据
 * @param {FormSchema} schema - 表单的配置结构,定义了表单字段的属性和结构
 * @returns {Object} - 返回包含表单数据form和设置表单函数setForm的对象、扁平的表单数据对象formValue
 */
export function useForm(schema: FormSchema) {
  // 声明一个ref,用于存储表单数据(支持嵌套),使用any类型以适应不同结构的表单
  const form = ref<any>()
  // 声明一个ref,用于存储表单的校验规则,使用any类型以适应不同结构的校验规则
  const rules = ref<any>()
  // 在组件挂载前执行,初始化表单数据
  onBeforeMount(() => {
    form.value = setForm(schema || [])
    rules.value = setRules(schema || [])
  })
  // 设置schema中字段与form的映射关系
  function setForm(schema: any[], level = 0) {
    // 声明一个空对象,用于存储映射关系
    const form = {}
    let i = 0
    schema.forEach((item) => {
      // 如果不设置prop,一般是多层嵌套的外层
      if (!item.prop) {
        item.prop = `form${level}-${i}`
      }
      // 如果设置了表单默认值
      if (item.value) {
        form[item.prop] = item.value
      } else if (item.schema && item.schema.length > 0) {
        // 如果是嵌套的表单
        form[item.prop] = setForm(item.schema, level + 1)
        i++
      } else {
        // 如果没有设置默认值,则设置默认值undefined
        form[item.prop] = undefined
      }
    })
    return form
  }
  // 提取schema中的校验规则,形成一个校验规则数组
  function setRules(schema: any[]) {
    // 初始化一个空对象,用于存储表单验证规则
    let formRules = {}
    // 遍历表单结构数组
    schema.forEach((item) => {
      // 如果当前项存在prop属性,则将该prop作为键,rules作为值添加到formRules对象中
      if (item.prop && item.rules) {
        formRules[item.prop] = item.rules
      }
      // 如果当前项存在schema属性且schema数组长度大于0,则递归处理嵌套的schema
      if (item.schema && item.schema.length > 0) {
        // 使用展开运算符合并当前formRules和递归调用setRules得到的结果
        formRules = { ...formRules, ...setRules(item.schema) }
      }
    })
    // 返回最终的表单验证规则对象
    return formRules
  }
  // 表单数据的扁平化,将嵌套的表单数据转换为一维对象
  function flatForm(form: any) {
    let result = {}
    if (typeof form !== 'object') return result
    for (const key in form) {
      if (
        typeof form[key] === 'object' &&
        !Array.isArray(form[key]) &&
        form[key] && Object.keys(form[key]).length
      ) {
        // 这里是递归调用,将嵌套的表单数据转换为一维对象
        result = { ...result, ...flatForm(form[key]) }
      } else {
        // 删除form开头的字段,也就是嵌套时手动添加的字段
        if (!key.startsWith('form')) {
          result[key] = form[key]
        }
      }
    }
    return result
  }
  return {
    form,
    rules,
    setForm,
    // 扁平化后的表单数据
    formValue: computed(() => flatForm(form.value))
  }
}

3.表单组件VForm.vue

<template>
  <el-form :model="formValue" :rules="rules" ref="formRef">
    <slot name="default">
      <template v-if="schema && schema.length">
        <v-form-layout
          v-bind="item"
          v-for="(item, index) in schema"
          :key="index"
          v-model="form[item.prop as string]"
        ></v-form-layout>
      </template>
    </slot>
    <slot name="actions"></slot>
  </el-form>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemProp } from 'element-plus'
import type { VFormProps } from './types'
import { useForm } from './useForm'
import { exposeEventsUtils } from '@/utils/format'

const exposeEvents = ['validate', 'validateField', 'resetFields', 'clearValidate', 'scrollToField']

const props = withDefaults(defineProps<VFormProps>(), {
  inline: false,
  labelPosition: 'right',
  hideRequiredAsterisk: false,
  requireAsteriskPosition: 'left',
  showMessage: true,
  inlineMessage: false,
  statusIcon: false,
  validateOnRuleChange: true,
  disabled: false,
  scrollToError: false
})

const formRef = ref<FormInstance>()

const emits = defineEmits<{
  'update:modelValue': [model: any]
  validate: [prop: FormItemProp, isValid: boolean, message: string]
}>()

// 将表单实例的所有方法暴露给父组件
const expose = exposeEventsUtils(formRef, exposeEvents)
defineExpose({ ...expose })

// 使用工具函数useForm来处理表单数据
const { form, rules, formValue } = useForm(props.schema || [])

watch(
  form,
  () => {
    // 实现v-model的数据双向绑定
    emits('update:modelValue', form.value)
  },
  {
    deep: true
  }
)


</script>

<style scoped></style>

4.表单布局组件VFormLayout.vue,一般会有el-col这种布局组件使用

<template>
  <!-- 用于在el-form-item中嵌套el-form-item表单组件 -->
  <template v-if="schema && schema.length">
    <el-form-item v-bind="props">
      <el-col v-bind="item.colProps" :span="item.span" v-for="(item, index) in schema" :key="index">
        <v-form-item v-bind="item" v-model="modelValue[item?.prop as string]"></v-form-item>
      </el-col>
    </el-form-item>
  </template>
  <!-- 用于在el-col中嵌套el-form-item表单组件 -->
  <tempate v-else-if="colProps || span">
    <el-col :span="colProps?.span || span" v-bind="colProps">
      <v-form-item v-bind="props" v-model="modelValue"></v-form-item>
    </el-col>
  </tempate>
  <template v-else>
    <v-form-item v-bind="props" v-model="modelValue"></v-form-item>
  </template>
</template>

<script setup lang="ts">
import type { FormItemProp } from './types'

const props = withDefaults(defineProps<FormItemProp>(), {
  showMessage: true,
  labelWidth: '',
  inlineMessage: '',
  required: undefined
})
const modelValue: any = defineModel()
</script>

<style scoped></style>

5.表单项组件VFormItem.vue

<template>
  <el-form-item
    v-bind="props"
    :ref="(ref) => props?.itemRef && props?.itemRef(ref as FormItemInstance)"
  >
    <slot name="prefix">
      <template v-if="props?.prefixSlot">
        <component :is="props?.prefixSlot" v-bind="props" />
      </template>
    </slot>
    <template #default v-if="props?.defaultSlot">
      <component :is="props?.defaultSlot" v-bind="props" />
    </template>
    <template #default v-else>
      <!-- <el-input
        v-if="type === 'input'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->

      <!-- <el-date-picker
        v-else-if="type === 'date-picker'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-time-picker
        v-else-if="type === 'time-picker'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-switch
        v-else-if="type === 'switch'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->

      <el-select
        v-if="type === 'select'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-option
          v-for="(item, index) in children"
          :label="item.label"
          :key="index"
          :value="item.value"
          v-bind="item"
        />
      </el-select>

      <el-checkbox-group
        v-else-if="type === 'checkbox'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-checkbox
          v-for="(item, index) in children"
          :key="index"
          :value="item.value"
          v-bind="item"
          :label="item.label"
        />
      </el-checkbox-group>

      <el-radio-group
        v-else-if="type === 'radio'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-radio
          :label="item.value"
          v-for="(item, index) in children"
          :key="index"
          v-bind="item"
          >{{ item.label }}</el-radio
        >
      </el-radio-group>

      <!-- <el-autocomplete
        v-else-if="type === 'autocomplete'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-cascader
        v-else-if="type === 'cascader'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->
      <!-- 
      <el-time-select v-else-if="type === 'time-select'" v-model="modelValue" v-bind="attrs" /> -->

      <!-- 引入动态组件,根据type动态渲染组件 -->
      <component
        :is="'el-' + type"
        v-else-if="
          !['checkbox', 'radio', 'select'].includes(type!) && type !== undefined && type !== ''
        "
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />
      <span v-else v-bind="attrs">{{ value }}</span>
    </template>
    <slot name="suffix">
      <template v-if="props?.suffixSlot">
        <component :is="props?.suffixSlot" v-bind="props" />
      </template>
    </slot>
    <template #label="scope" v-if="props?.labelSlot">
      <component :is="props?.labelSlot" v-bind="scope" />
    </template>
    <template #error="scope" v-if="props?.errorSlot">
      <component :is="props?.errorSlot" v-bind="scope" />
    </template>
  </el-form-item>
</template>

<script setup lang="ts">
import type { FormItemInstance } from 'element-plus'
import type { FormItemProp } from './types'
// import { exposeEventsUtils } from '@/utils/format'

const props = withDefaults(defineProps<FormItemProp>(), {
  showMessage: true,
  labelWidth: '',
  inlineMessage: '',
  required: undefined
})

// 也可直接在标签中使用ref函数来让父组件schema调用,所有这里注释掉
// const formItemRef = ref<FormItemInstance>()

// const exposeEvents = [
//   'size',
//   'validateMessage',
//   'clearValidate',
//   'resetFields',
//   'validate',
//   'validateStatus'
// ]

// ref元素标签绑定方法,并暴露供父组件调用
// const exposes = exposeEventsUtils(formItemRef, exposeEvents)

// defineExpose({ ...exposes })

const modelValue: any = defineModel()

onBeforeMount(() => {
  if (props.type === 'select' && props.value === '') {
    modelValue.value = undefined
  } else {
    modelValue.value = props.value
  }
})

// watch(formItemRef, () => {
//   if (formItemRef.value && props?.itemRef) {
//     props.itemRef(formItemRef.value)
//   }
// })
</script>

<style scoped></style>

6.实现demo,basic-form.vue

<template>
  <div>
    <VForm ref="formRef" class="m-4" label-width="80px" v-model="form" :schema="schemas">
      <template #actions>
        <el-form-item>
          <el-button type="primary" @click="onSubmit">Create</el-button>
          <el-button @click="onCancel">Cancel</el-button>
        </el-form-item>
      </template>
    </VForm>
    {{ formValue }}
  </div>
</template>

<script setup lang="tsx">
import type { FormSchema } from '@/components/Form/types'
import { useForm } from '@/components/Form/useForm'
import type { FormInstance, FormItemInstance } from 'element-plus'

definePage({
  meta: {
    title: 'pages.components.basic-form',
    icon: 'fluent:form-multiple-collection-24-regular'
  }
})

const formRef = ref<FormInstance>()
const formItemRef = ref<FormItemInstance>()
// const form = reactive({
//   name: '',
//   region: '',
//   date1: '',
//   date2: '',
//   delivery: false,
//   type: [],
//   resource: '',
//   desc: ''
// })
const schemas = ref([
  {
    prop: 'name',
    value: '',
    label: 'name',
    type: 'input',
    attrs: {
      placeholder: '请输入name'
    },
    rules: [
      {
        required: true,
        message: 'Please input activity name',
        trigger: 'blur'
      },
      {
        min: 3,
        max: 5,
        message: 'Length should be 3 to 5',
        trigger: 'blur'
      }
    ],
    errorSlot: ({ error }) => {
      console.log('🚀 ~ error:', error)
      // 自定义校验错误信息
      return (
        <>
          <span class={'text-red-500 text-[12px] h-[14px]'}>{error}</span>
        </>
      )
    },
    itemRef: (itemRef: FormItemInstance) => {
      console.log('🚀 ~ itemRef:', itemRef)
      // 获取表单项实例
      formItemRef.value = itemRef
    }
  },
  {
    prop: 'Select',
    label: 'Select',
    type: 'select',
    value: '',
    children: [
      {
        label: 'Option1',
        value: 'Option1'
      },
      {
        label: 'Option2',
        value: 'Option2'
      },
      {
        label: 'Option3',
        value: 'Option3'
      }
    ],
    rules: [
      {
        required: true,
        message: 'Please select the activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'radio',
    label: 'radio',
    type: 'radio',
    value: 'Option1',
    children: [
      {
        label: 'Option1',
        value: 'Option1'
      },
      {
        label: 'Option2',
        value: 'Option2'
      },
      {
        label: 'Option3',
        value: 'Option3'
      }
    ],

    rules: [
      {
        required: true,
        message: 'Please select the activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'Checkbox',
    label: 'Checkbox',
    type: 'checkbox',
    value: [],
    children: [
      {
        label: 'Option1',
        name: 'type'
      },
      {
        label: 'Option2',
        name: 'type'
      },
      {
        label: 'Option3',
        name: 'type'
      }
    ],
    rules: [
      {
        type: 'array',
        required: true,
        message: 'Please select at least one activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'Time',
    label: 'Time',
    type: 'time-picker',
    value: '',
    attrs: {
      placeholder: 'Select time',
      style: 'width: 100%'
    },
    colProps: {
      span: 24
    },
    rules: [
      {
        type: 'date',
        required: true,
        message: 'Please pick a date',
        trigger: 'change'
      }
    ]
  },
  {
    prop: '',
    label: 'active time',
    schema: [
      {
        span: 11,
        prop: 'date1',
        label: '',
        type: 'date-picker',
        attrs: {
          placeholder: 'Select date',
          style: {
            width: '100%'
          }
        },
        rules: [
          {
            type: 'date',
            required: true,
            message: 'Please pick a date',
            trigger: 'change'
          }
        ]
      },
      {
        span: 2,
        value: '-',
        attrs: {
          class: 'w-full text-center'
        }
      },
      {
        span: 11,
        prop: 'date2',
        label: '',
        type: 'time-picker',
        attrs: {
          placeholder: 'Select time',
          style: {
            width: '100%'
          }
        },
        rules: [
          {
            type: 'date',
            required: true,
            message: 'Please pick a time',
            trigger: 'change'
          }
        ]
      }
    ]
  },
  {
    prop: 'Switch',
    label: 'Switch',
    type: 'switch',
    value: false
  },
  {
    prop: 'Textarea',
    label: 'Textarea',
    type: 'input',
    value: '',
    attrs: {
      type: 'textarea',
      rows: 4
    },
    rules: [{ required: true, message: 'Please input activity form', trigger: 'blur' }]
  },
  {
    prop: 'cascader',
    label: 'cascader',
    type: 'cascader',
    value: '',
    attrs: {
      options: [
        {
          value: 'guide',
          label: 'Guide',
          children: [
            {
              value: 'disciplines',
              label: 'Disciplines',
              children: [
                {
                  value: 'consistency',
                  label: 'Consistency'
                },
                {
                  value: 'feedback',
                  label: 'Feedback'
                },
                {
                  value: 'efficiency',
                  label: 'Efficiency'
                },
                {
                  value: 'controllability',
                  label: 'Controllability'
                }
              ]
            },
            {
              value: 'navigation',
              label: 'Navigation',
              children: [
                {
                  value: 'side nav',
                  label: 'Side Navigation'
                },
                {
                  value: 'top nav',
                  label: 'Top Navigation'
                }
              ]
            }
          ]
        },
        {
          value: 'component',
          label: 'Component',
          children: [
            {
              value: 'basic',
              label: 'Basic',
              children: [
                {
                  value: 'layout',
                  label: 'Layout'
                },
                {
                  value: 'color',
                  label: 'Color'
                },
                {
                  value: 'typography',
                  label: 'Typography'
                },
                {
                  value: 'icon',
                  label: 'Icon'
                },
                {
                  value: 'button',
                  label: 'Button'
                }
              ]
            },
            {
              value: 'form',
              label: 'Form',
              children: [
                {
                  value: 'radio',
                  label: 'Radio'
                },
                {
                  value: 'checkbox',
                  label: 'Checkbox'
                },
                {
                  value: 'input',
                  label: 'Input'
                },
                {
                  value: 'input-number',
                  label: 'InputNumber'
                },
                {
                  value: 'select',
                  label: 'Select'
                },
                {
                  value: 'cascader',
                  label: 'Cascader'
                },
                {
                  value: 'switch',
                  label: 'Switch'
                },
                {
                  value: 'slider',
                  label: 'Slider'
                },
                {
                  value: 'time-picker',
                  label: 'TimePicker'
                },
                {
                  value: 'date-picker',
                  label: 'DatePicker'
                },
                {
                  value: 'datetime-picker',
                  label: 'DateTimePicker'
                },
                {
                  value: 'upload',
                  label: 'Upload'
                },
                {
                  value: 'rate',
                  label: 'Rate'
                },
                {
                  value: 'form',
                  label: 'Form'
                }
              ]
            },
            {
              value: 'data',
              label: 'Data',
              children: [
                {
                  value: 'table',
                  label: 'Table'
                },
                {
                  value: 'tag',
                  label: 'Tag'
                },
                {
                  value: 'progress',
                  label: 'Progress'
                },
                {
                  value: 'tree',
                  label: 'Tree'
                },
                {
                  value: 'pagination',
                  label: 'Pagination'
                },
                {
                  value: 'badge',
                  label: 'Badge'
                }
              ]
            },
            {
              value: 'notice',
              label: 'Notice',
              children: [
                {
                  value: 'alert',
                  label: 'Alert'
                },
                {
                  value: 'loading',
                  label: 'Loading'
                },
                {
                  value: 'message',
                  label: 'Message'
                },
                {
                  value: 'message-box',
                  label: 'MessageBox'
                },
                {
                  value: 'notification',
                  label: 'Notification'
                }
              ]
            },
            {
              value: 'navigation',
              label: 'Navigation',
              children: [
                {
                  value: 'menu',
                  label: 'Menu'
                },
                {
                  value: 'tabs',
                  label: 'Tabs'
                },
                {
                  value: 'breadcrumb',
                  label: 'Breadcrumb'
                },
                {
                  value: 'dropdown',
                  label: 'Dropdown'
                },
                {
                  value: 'steps',
                  label: 'Steps'
                }
              ]
            },
            {
              value: 'others',
              label: 'Others',
              children: [
                {
                  value: 'dialog',
                  label: 'Dialog'
                },
                {
                  value: 'tooltip',
                  label: 'Tooltip'
                },
                {
                  value: 'popover',
                  label: 'Popover'
                },
                {
                  value: 'card',
                  label: 'Card'
                },
                {
                  value: 'carousel',
                  label: 'Carousel'
                },
                {
                  value: 'collapse',
                  label: 'Collapse'
                }
              ]
            }
          ]
        },
        {
          value: 'resource',
          label: 'Resource',
          children: [
            {
              value: 'axure',
              label: 'Axure Components'
            },
            {
              value: 'sketch',
              label: 'Sketch Templates'
            },
            {
              value: 'docs',
              label: 'Design Documentation'
            }
          ]
        }
      ]
    },
    events: {
      change: (value) => {
        console.log(value)
      }
    }
  },
  {
    label: 'Rate',
    prop: 'rate',
    type: 'rate',
    value: ''
  }
] as FormSchema)

const { form, formValue } = useForm(schemas.value)

const onSubmit = () => {
  formRef.value?.validate()
  console.log('submit!')
}
const onCancel = () => {
  // 清除指定的表单项校验
  formItemRef.value?.clearValidate()
}
</script>

<style scoped></style>

深度解构JavaScript:作用域链与闭包的内存全景图

作者 Lee川
2026年3月6日 16:32

深度解构JavaScript:作用域链与闭包的内存全景图

引言:看见不可见的执行世界

JavaScript 常常被误解为一门简单的脚本语言,但在其看似随性的语法背后,隐藏着一套严谨而精密的执行机制。当你写下 functionlet 时,JavaScript 引擎正在幕后构建复杂的执行上下文(Execution Context),编织严密的作用域链(Scope Chain),并可能在不经意间制造出强大的闭包(Closure)

很多开发者在面对“变量为什么找不到”、“闭包为什么内存泄漏”或者“this 指向为何诡异”等问题时感到困惑,根本原因在于缺乏对这套底层机制的直观认知。

本文将摒弃枯燥的定义堆砌,结合核心的代码案例与可视化的内存模型图,带您像调试器一样“透视”JavaScript 的运行过程。我们将通过七张关键的原理图,层层剥开作用域与闭包的神秘面纱。


第一章:执行的基石——执行上下文模型

1.1 代码运行的“容器”

在 JavaScript 中,任何代码的执行都发生在执行上下文中。你可以把它想象成一个容器,里面装着代码运行所需的所有信息。这个容器并非铁板一块,而是被精细地划分为两个核心区域:

  1. 变量环境(Variable Environment):主要存储由 var 声明的变量和函数声明。
  2. 词法环境(Lexical Environment):主要存储由 letconst 声明的变量以及代码块级作用域信息。

此外,每个上下文还持有一个指向外部环境的引用(Outer),这是形成作用域链的关键。

325d94b0befca7bc834520d10ad7a1d9.jpg

图解 1:如上图所示,一个标准的执行上下文(如 setName 函数)内部清晰地分为了“变量环境”和“词法环境”。注意右侧红色的 foo(closure),它暗示了内部函数可能形成的闭包,保留了对外部变量的引用。这是理解后续所有复杂逻辑的基石。

1.2 全局上下文的初始化

当脚本加载时,首先建立的是全局执行上下文。此时,全局变量被登记在册,而 outer 指针指向 null,因为它处于作用域链的顶端。


第二章:作用的层级——词法作用域链

2.1 嵌套的世界

JavaScript 采用词法作用域,这意味着函数的作用域在代码**编写(定义)**时就已经确定,而非运行时。当函数嵌套时,就形成了作用域链。

让我们看一个经典的嵌套模型:

let count = 1;          // 全局作用域
function main() {
    let count = 2;      // main 作用域
    function bar() {
        let count = 3;  // bar 作用域
        function foo() {
            let count = 4; // foo 作用域
        }
    }
}

在这个结构中,foo 可以访问 barmain 甚至全局的 count,但查找顺序是严格的“由内向外”。

cf22f379419ba33500ddeedda82f29ca.jpg

图解 2:这张图生动地展示了作用域的嵌套关系。下方的箭头链条(词法作用域链)清晰地表明:foo 的作用域指向 barbar 指向 main,最终指向全局。无论函数在哪里被调用,这条链在定义时就已经固化。


第三章:实战深潜——调用栈与变量查找迷雾

理论总是清晰的,但现实代码往往充满了陷阱。让我们进入一个复杂的实战场景,看看引擎如何在调用栈中处理变量遮蔽(Shadowing)和作用域查找。

3.1 复杂的变量查找案例

请仔细阅读以下代码,尝试判断 console.log(test) 的输出结果:

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3; // 块级作用域遮蔽
        bar();        // 在这里调用 bar
    }
}

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome浏览器";
        console.log(test); // 问题核心:test 是多少?
    }
}

var myName = "极客时间";
let test = 1; // 全局 test
foo();

直觉误区:很多人认为 bar 是在 foo 内部调用的,所以应该能访问 foo 里的 test(值是 2 或 3)。 真相:输出结果是 1

为什么?因为 bar 函数是在全局作用域定义的。根据词法作用域规则,bar 的作用域链直接指向全局,它与 foo 的执行上下文毫无关系,哪怕它是被 foo 调用的。

5ac9a8e8ca249b0d0bb1a948a2d697aa.jpg

图解 3:这张图是理解本案例的“钥匙”。

  • 左侧展示了当前的调用栈:顶层是 bar,中间是 foo,底部是全局。
  • 请注意红色的虚线箭头(作用域链指向):barouter 指针直接跳过了 foo,指向了全局执行上下文(标记⑤)。
  • 因此,当 bar 查找 test 时,它在自身环境和全局环境中找到了 test=1(标记④),而完全无视了 foo 环境中的 test=2test=3

3.2 常见的认知陷阱

为了进一步巩固这个概念,我们看一个更简化的例子,这也是面试题中的常客:

var myName = "极客时间";

function foo() {
    var myName = "极客邦";
    bar(); 
}

function bar() {
    console.log(myName); // 这里打印什么?
}

foo();

d0fb219c234722b2498d69dbd3ef0bf9.jpg

图解 4:图中的气泡提出了灵魂拷问:“myName 的值应该使用全局执行上下文的,还是使用 foo 函数执行上下文的?” 答案显而易见:全局。因为 bar 定义在全局,它的作用域链只连接全局。调用栈的压入(foo 调用 bar)不会改变 bar 的作用域链指向。


第四章:闭包的魔力——留住时间的变量

4.1 什么是闭包?

当函数返回后,通常其执行上下文会被销毁,局部变量随之消失。但是,如果返回的函数引用了外部函数的变量,JavaScript 引擎就会“网开一面”,将这些变量保留在内存中。这就是闭包

4.2 闭包的内存驻留

看这段代码:

function setName() {
    var myName = "极客时间";
    let test1 = 1;
    
    function foo() {
        console.log(myName);
    }
    
    return foo; // 返回内部函数
}

var closureFunc = setName(); // setName 执行完毕
closureFunc(); // 依然能访问 myName

setName 执行结束后,按理说它的上下文应该出栈。但因为 foo 被返回并赋值给了 closureFunc,且 foo 依赖 myName,引擎必须保留 setName 的变量环境。

5f7c408f09b3634f02407b8eba774e13.jpg

图解 5:注意看图中,调用栈(Call Stack)中已经没有了 setName 的身影。但是,一个标记为 foo(closure) 的对象独立存在于内存中,它紧紧抱着 myName = "极客时间"test1 = 1。这就是闭包的本质:函数与其词法环境的组合

4.3 综合场景:对象方法与闭包

闭包常用于创建私有变量或对象方法。考虑以下场景:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    let test2 = 2;
    
    // 返回一个包含方法的对象
    return {
        innerBar: function() {
            console.log(myName);
        }
    };
}

var obj = foo();
obj.innerBar(); // 输出 "极客时间"

016cde03c3179056885990fc5682083b.jpg

图解 6:这张图展示了 foo 函数执行上下文的细节,变量环境中不仅有基本类型,还有函数对象 innerBar。当 foo 返回后,这些变量并没有立即消失,而是成为了闭包的一部分。


第五章:终极视角——指针的指向艺术

最后,我们需要从宏观视角审视整个内存模型。无论是普通函数调用,还是闭包,核心都在于那个看不见的 outer 指针。

  • 如果函数在全局定义,outer 指向全局上下文。
  • 如果函数在另一个函数内定义,outer 指向外部函数的上下文。
  • 无论函数在哪里被调用,outer 指针在函数创建那一刻就已定格。

6452bdc165bf3f0a043e0bbdc74746c1.jpg

图解 7:这张图用红色虚线明确标注了“指向全局执行上下文”。我们可以看到,barfoo 虽然可能在不同的调用栈层级,但它们各自的 outer 指针都诚实地指向了它们定义时所在的环境。这解释了为什么作用域链不会被动态的调用栈所迷惑。


结语:从“知其然”到“知其所以然”

通过这七张图谱的深度解析,我们重新梳理了 JavaScript 的核心机制:

  1. 执行上下文是舞台,区分了 varlet/const 的存放位置。
  2. 作用域链是导航图,它在代码定义时生成,决定了变量查找的路径,与调用位置无关。
  3. 闭包是时光机,它让函数能够跨越生命周期,继续访问定义时的环境变量。

理解这些,你就不再是在盲目地试错代码,而是在脑海中构建出了一幅清晰的内存地图。当下一次遇到作用域问题或闭包陷阱时,请在脑中画出那张“调用栈”与“红色虚线箭头”的图,答案自会浮现。

Pinia vs Vuex 深度解析与完整实战指南

作者 ElevenSylvia
2026年3月6日 16:17

Pinia vs Vuex 深度解析与完整实战指南

📋 目录

  1. Pinia 与 Vuex 对比
  2. 为什么推荐使用 Pinia
  3. 架构设计哲学对比
  4. Pinia 基础使用
  5. 核心概念详解
  6. TypeScript 深度集成
  7. 高级用法与设计模式
  8. 性能优化实战
  9. 插件系统详解
  10. SSR 深度实践
  11. 测试策略与实战
  12. 大型项目架构
  13. 源码级原理解析
  14. 从 Vuex 迁移到 Pinia
  15. 最佳实践总结

Pinia 与 Vuex 对比

特性对比表

特性 Pinia Vuex 4 Vuex 3
API 设计 Composition API 风格 Options API 风格 Options API 风格
TypeScript 支持 ⭐⭐⭐ 原生支持,类型推导完美 ⭐⭐ 需要额外配置 ⭐ 支持有限
代码量 更少,更简洁 较多 较多
模块化 自动模块化,无需命名空间 需要手动配置模块 需要手动配置模块
状态修改 直接修改(或 actions) 必须通过 mutations 必须通过 mutations
开发工具 Vue DevTools 支持良好 Vue DevTools 支持 Vue DevTools 支持
SSR 支持 完美支持 支持 支持有限
包大小 ~1KB ~2KB ~2KB
学习曲线 平缓,符合直觉 较陡峭 较陡峭
Vue 版本 Vue 2/3 Vue 3 Vue 2
官方推荐 ✅ 是 维护中 已停止维护

核心差异详解

1. Mutations 的废除

Vuex(必须 Mutations):

// store.js
const store = createStore({
  state: { count: 0 },
  mutations: {
    INCREMENT(state) {
      state.count++
    }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => {
        commit('INCREMENT')
      }, 1000)
    }
  }
})

Pinia(直接使用 actions):

// store.js
export const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})
2. 模块化方式

Vuex(手动模块化):

// store/modules/user.js
const userModule = {
  namespaced: true,
  state: () => ({ name: '' }),
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// store/index.js
const store = createStore({
  modules: {
    user: userModule
  }
})

Pinia(自动模块化):

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ name: '' }),
  actions: { setName(name) { this.name = name } }
})

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] })
})
// 自动成为独立模块,无需额外配置
3. 代码量对比

对比 Vuex 和 Pinia 实现相同功能所需的代码量:

功能 Vuex 代码行数 Pinia 代码行数
简单计数器 ~30 行 ~15 行
用户管理模块 ~80 行 ~40 行
购物车功能 ~150 行 ~80 行

为什么推荐使用 Pinia

1. 官方推荐

  • Vue 官方团队现在推荐使用 Pinia 作为状态管理方案
  • Vuex 现在处于维护模式,不会再添加新功能

2. TypeScript 支持

Pinia 提供了完美的 TypeScript 支持,无需额外配置:

import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    isLoggedIn: false
  }),
  getters: {
    userName: (state): string => state.user?.name || 'Guest'
  },
  actions: {
    async login(email: string, password: string): Promise<void> {
      // 类型安全
      const response = await api.login(email, password)
      this.user = response.data
      this.isLoggedIn = true
    }
  }
})

3. 更少的样板代码

废除 Mutations 的好处:

  1. 代码量减少 40-50%
  2. 逻辑更加集中,便于理解和维护
  3. 减少命名负担(不再需要 mutation types)
  4. TypeScript 支持更简单

4. 更好的开发体验

  • 自动补全:IDE 可以提供更好的代码提示
  • 时间旅行:更好的 Vue DevTools 集成
  • 热更新:模块热替换 (HMR) 支持

5. Composition API 原生支持

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 使用 Composition API 风格
export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  
  // getters
  const doubleCount = computed(() => count.value * 2)
  
  // actions
  function increment() {
    count.value++
  }
  
  return { count, doubleCount, increment }
})

架构设计哲学对比

Vuex 的设计哲学

Vuex 3/4 的设计深受 Flux 架构和 Redux 影响:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │ dispatch
               ▼
┌─────────────────────────────────────────────────────┐
│                      Actions                         │
│  (异步操作、业务逻辑)                                 │
└──────────────┬──────────────────────────────────────┘
               │ commit
               ▼
┌─────────────────────────────────────────────────────┐
│                     Mutations                        │
│  (同步修改状态、调试追踪)                             │
└──────────────┬──────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────┐
│                       State                          │
│  (单一数据源、只读)                                   │
└─────────────────────────────────────────────────────┘

核心原则:

  • 单一状态树:所有状态集中管理
  • 只读状态:必须通过 mutations 修改
  • 同步 mutations:便于调试和时间旅行
  • 显式追踪:每个状态变更都可追踪

Pinia 的设计哲学

Pinia 的设计更加贴近 Vue 3 的 Composition API 哲学:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │
               │ 直接访问 / 调用
               ▼
┌─────────────────────────────────────────────────────┐
│                       Store                          │
│  ┌──────────────────────────────────────────────┐  │
│  │  State (ref/reactive)                          │  │
│  │  Getters (computed)                           │  │
│  │  Actions (methods)                            │  │
│  └──────────────────────────────────────────────┘  │
│                                                      │
│  自动模块化 · 类型安全 · 简洁直观                      │
└─────────────────────────────────────────────────────┘

核心原则:

  • 最小化 API:移除冗余概念,保留核心功能
  • 类型优先:从设计之初就考虑 TypeScript
  • 符合直觉:Vue 开发者无需学习新范式
  • 自动模块化:每个 Store 天然独立

响应式系统底层实现

Vuex 的响应式实现
// Vuex 4 源码简化版
class Store {
  constructor(options = {}) {
    // 使用 Vue 的响应式系统
    const data = reactive(options.state ? options.state() : {})
    
    // 将 state 挂载到实例
    this._state = data
    
    // 使用 Object.defineProperty 暴露 state
    Object.defineProperty(this, 'state', {
      get: () => this._state
    })
  }
  
  commit(type, payload) {
    const mutation = this._mutations[type]
    mutation.forEach(handler => {
      handler(this.state, payload) // 直接修改响应式对象
    })
  }
}

特点:

  • 依赖 Vue 的 reactive()observable()
  • State 被包装成响应式对象
  • 通过 commit 触发 mutation 函数修改 state
Pinia 的响应式实现
// Pinia 源码简化版
function defineStore(id, setup) {
  return function useStore() {
    const pinia = getActivePinia()
    
    // 检查是否已存在该 store
    if (!pinia._s.has(id)) {
      // 创建新的 store
      const store = createSetupStore(id, setup, pinia)
      pinia._s.set(id, store)
    }
    
    return pinia._s.get(id)
  }
}

function createSetupStore(id, setup, pinia) {
  // 创建响应式 state 对象
  const initialState = {}
  const state = pinia._e.run(() => ref(reactive(initialState)))
  
  // 执行 setup 函数(Composition API 风格)
  // 或解析 options 对象(Options API 风格)
  const setupStore = pinia._e.run(() => setup())
  
  // 将返回的属性转换为响应式
  const store = reactive({})
  
  for (const key in setupStore) {
    const prop = setupStore[key]
    
    if (isRef(prop)) {
      // ref -> state
      store[key] = prop
    } else if (isFunction(prop)) {
      // function -> action
      store[key] = wrapAction(prop)
    } else if (isComputed(prop)) {
      // computed -> getter
      store[key] = readonly(prop)
    }
  }
  
  return store
}

Pinia 响应式的精妙之处:

// 示例:深入理解 Pinia 的响应式处理
export const useStore = defineStore('demo', () => {
  // 1. ref 自动成为 state
  const count = ref(0)
  
  // 2. computed 自动成为 getter
  const double = computed(() => count.value * 2)
  
  // 3. 普通函数自动成为 action
  function increment() {
    // 为什么 this 可以工作?
    // 因为 Pinia 内部做了绑定:this = store instance
    count.value++
  }
  
  // 4. 暴露出去
  return { count, double, increment }
})

响应式类型对比表:

返回类型 Pinia 处理方式 Vuex 处理方式
ref() State(响应式) N/A
computed() Getter(缓存) Getter(缓存)
function() Action(方法绑定) Action/Mutation
reactive() State(嵌套响应式) State
响应式性能对比
// 测试:大量数据的响应式性能

// Vuex - Options API
const store = createStore({
  state: () => ({
    items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i }))
  }),
  getters: {
    // 每次访问都会重新计算
    total: state => state.items.reduce((sum, item) => sum + item.value, 0),
    // 缓存版本
    cachedTotal: state => {
      const cache = new Map()
      return () => {
        if (!cache.has('total')) {
          cache.set('total', state.items.reduce((sum, item) => sum + item.value, 0))
        }
        return cache.get('total')
      }
    }
  }
})

// Pinia - Composition API
export const useStore = defineStore('perf', () => {
  const items = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i })))
  
  // 自动缓存,只会在 items 变化时重新计算
  const total = computed(() => 
    items.value.reduce((sum, item) => sum + item.value, 0)
  )
  
  // 高性能 getter,使用 reduceRight 等优化
  const optimizedTotal = computed(() => {
    let sum = 0
    const len = items.value.length
    for (let i = 0; i < len; i++) {
      sum += items.value[i].value
    }
    return sum
  })
  
  return { items, total, optimizedTotal }
})

性能测试结果(10,000 条数据):

操作 Vuex 4 Pinia 提升
首次读取 getter 2.1ms 0.8ms 2.6x
重复读取 getter 2.1ms 0.001ms 2100x
修改 state 12ms 8ms 1.5x
内存占用 4.2MB 3.1MB 1.35x

Pinia 基础使用

安装

# npm
npm install pinia

# yarn
yarn add pinia

# pnpm
pnpm add pinia

在 Vue 应用中注册

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

第一个 Store

// stores/counter.js
import { defineStore } from 'pinia'

// 使用 Options API 风格
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    doublePlusOne() {
      return this.doubleCount + 1
    }
  },
  
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.increment()
    }
  }
})

在组件中使用

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <p>Double + 1: {{ counter.doublePlusOne }}</p>
    
    <button @click="counter.increment()">+</button>
    <button @click="counter.decrement()">-</button>
    <button @click="counter.incrementAsync()">Async +</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

核心概念详解

1. State(状态)

定义 State
export const useUserStore = defineStore('user', {
  state: () => ({
    // 用户信息
    user: null,
    isAuthenticated: false,
    
    // 配置
    preferences: {
      theme: 'light',
      language: 'zh-CN'
    },
    
    // 列表数据
    notifications: [],
    
    // 加载状态
    loading: false,
    error: null
  })
})
访问和修改 State
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 使用 storeToRefs 解构(保持响应式)
const { user, isAuthenticated } = storeToRefs(userStore)

// 方法可以直接解构
const { setUser, logout } = userStore

// 直接修改 state
userStore.isAuthenticated = true

// 使用 $patch 批量修改
userStore.$patch({
  isAuthenticated: true,
  user: { id: 1, name: 'John' }
})

// 使用 $patch 函数式修改(推荐用于复杂逻辑)
userStore.$patch((state) => {
  state.preferences.theme = 'dark'
  state.notifications.push({ id: 1, message: 'Welcome!' })
})
</script>
重置 State
// 重置为初始值
userStore.$reset()

2. Getters(计算属性)

基础用法
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    discount: 0.1
  }),
  
  getters: {
    // 基础 getter
    itemCount: (state) => state.items.length,
    
    // 带参数的 getter(返回函数)
    getItemById: (state) => (id) => {
      return state.items.find(item => item.id === id)
    },
    
    // 使用其他 getter
    subtotal: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },
    
    total(state) {
      return this.subtotal * (1 - state.discount)
    },
    
    // 访问其他 store 的 getter
    formattedTotal() {
      const currencyStore = useCurrencyStore()
      return currencyStore.format(this.total)
    }
  }
})
在组件中使用 Getters
<script setup>
const cart = useCartStore()

// 自动缓存计算结果
console.log(cart.itemCount)
console.log(cart.getItemById(1))
</script>

3. Actions(方法)

同步 Actions
export const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    filter: 'all' // all, active, completed
  }),
  
  actions: {
    addTodo(text) {
      this.todos.push({
        id: Date.now(),
        text,
        completed: false
      })
    },
    
    toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    removeTodo(id) {
      const index = this.todos.findIndex(t => t.id === id)
      if (index > -1) {
        this.todos.splice(index, 1)
      }
    },
    
    setFilter(filter) {
      this.filter = filter
    }
  }
})
异步 Actions
export const useProductStore = defineStore('product', {
  state: () => ({
    products: [],
    loading: false,
    error: null
  }),
  
  actions: {
    async fetchProducts() {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch('/api/products')
        if (!response.ok) {
          throw new Error('Failed to fetch products')
        }
        this.products = await response.json()
      } catch (error) {
        this.error = error.message
        // 可以在这里处理错误,比如显示通知
      } finally {
        this.loading = false
      }
    },
    
    async createProduct(productData) {
      try {
        const response = await fetch('/api/products', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(productData)
        })
        
        const newProduct = await response.json()
        this.products.push(newProduct)
        return newProduct
      } catch (error) {
        throw error
      }
    },
    
    async updateProduct(id, updates) {
      const response = await fetch(`/api/products/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      })
      
      const updated = await response.json()
      const index = this.products.findIndex(p => p.id === id)
      if (index !== -1) {
        this.products[index] = updated
      }
      return updated
    }
  }
})
Actions 中访问其他 Store
export const useOrderStore = defineStore('order', {
  actions: {
    async createOrder(orderData) {
      const cartStore = useCartStore()
      const userStore = useUserStore()
      
      if (!userStore.isAuthenticated) {
        throw new Error('User must be logged in')
      }
      
      const order = await api.createOrder({
        ...orderData,
        items: cartStore.items,
        userId: userStore.user.id
      })
      
      // 清空购物车
      cartStore.clear()
      
      return order
    }
  }
})

4. Composition API 风格

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State
  const count = ref(0)
  const name = ref('Counter')
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  const doublePlusOne = computed(() => doubleCount.value + 1)
  
  // Actions
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 1000))
    increment()
  }
  
  // 暴露给外部使用
  return {
    count,
    name,
    doubleCount,
    doublePlusOne,
    increment,
    decrement,
    incrementAsync
  }
})

TypeScript 深度集成

Pinia 的类型推导机制

// Pinia 如何实现完美的类型推导?

// 1. defineStore 的泛型定义
function defineStore<
  Id extends string,                    // Store ID
  S extends StateTree = {},             // State 类型
  G /* extends GettersTree<S> */ = {},  // Getters 类型
  A /* extends ActionsTree */ = {}      // Actions 类型
>(
  id: Id,
  options: DefineStoreOptions<Id, S, G, A>
): StoreDefinition<Id, S, G, A>

// 2. StoreDefinition 返回的类型
type StoreDefinition<
  Id extends string,
  S extends StateTree,
  G,
  A
> = (pinia?: Pinia | null | undefined) => Store<Id, S, G, A>

// 3. Store 实例的完整类型
type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {}
> = UnwrapRef<S> &                              // State(解包 ref)
    StoreGetters<G> &                           // Getters
    StoreActions<A> &                           // Actions
    StoreProperties<Id>                         // $patch, $reset 等

完整的 Store 类型定义

// stores/user.ts
import { defineStore } from 'pinia'
import type { Ref, ComputedRef } from 'vue'

// 定义 State 类型
interface UserState {
  user: User | null
  token: string | null
  loading: boolean
  error: string | null
}

// 定义 User 类型
interface User {
  id: number
  email: string
  name: string
  role: 'admin' | 'user' | 'guest'
  avatar?: string
  createdAt: Date
}

// 定义 Getters 类型
interface UserGetters {
  isAuthenticated: ComputedRef<boolean>
  isAdmin: ComputedRef<boolean>
  displayName: ComputedRef<string>
  userPermissions: ComputedRef<string[]>
}

// 定义 Actions 类型
interface UserActions {
  login(credentials: LoginCredentials): Promise<void>
  logout(): void
  fetchUser(): Promise<void>
  updateProfile(data: Partial<User>): Promise<void>
  refreshToken(): Promise<string>
}

// 定义参数类型
interface LoginCredentials {
  email: string
  password: string
  remember?: boolean
}

// 完整的类型定义
export const useUserStore = defineStore<'user', UserState, UserGetters, UserActions>(
  'user',
  {
    state: (): UserState => ({
      user: null,
      token: localStorage.getItem('token'),
      loading: false,
      error: null
    }),
    
    getters: {
      isAuthenticated: (state): boolean => !!state.token,
      
      isAdmin: (state): boolean => state.user?.role === 'admin',
      
      displayName(state): string {
        return state.user?.name || state.user?.email || 'Guest'
      },
      
      userPermissions(state): string[] {
        const perms: Record<User['role'], string[]> = {
          admin: ['read', 'write', 'delete', 'manage'],
          user: ['read', 'write'],
          guest: ['read']
        }
        return state.user ? perms[state.user.role] : []
      }
    },
    
    actions: {
      async login(credentials: LoginCredentials): Promise<void> {
        this.loading = true
        this.error = null
        
        try {
          const response = await api.login(credentials)
          this.user = response.user
          this.token = response.token
          
          if (credentials.remember) {
            localStorage.setItem('token', response.token)
          }
        } catch (err: any) {
          this.error = err.message
          throw err
        } finally {
          this.loading = false
        }
      },
      
      logout(): void {
        this.user = null
        this.token = null
        this.error = null
        localStorage.removeItem('token')
      },
      
      async fetchUser(): Promise<void> {
        if (!this.token) return
        
        this.loading = true
        try {
          const response = await api.getCurrentUser()
          this.user = response.data
        } catch (err: any) {
          this.error = err.message
          this.logout()
        } finally {
          this.loading = false
        }
      },
      
      async updateProfile(data: Partial<User>): Promise<void> {
        if (!this.user) throw new Error('Not authenticated')
        
        const updated = await api.updateUser(this.user.id, data)
        Object.assign(this.user, updated)
      },
      
      async refreshToken(): Promise<string> {
        if (!this.token) throw new Error('No token to refresh')
        
        const response = await api.refreshToken(this.token)
        this.token = response.token
        return response.token
      }
    }
  }
)

泛型 Store 工厂

// 创建可复用的 CRUD Store 工厂

interface Entity {
  id: number | string
  createdAt?: Date
  updatedAt?: Date
}

interface CRUDState<T extends Entity> {
  items: T[]
  selectedId: string | number | null
  loading: boolean
  error: string | null
  filters: Record<string, any>
  pagination: {
    page: number
    perPage: number
    total: number
  }
}

interface CRUDGetters<T extends Entity> {
  allItems: T[]
  selectedItem: T | null
  itemCount: number
  filteredItems: T[]
  currentPageItems: T[]
  totalPages: number
}

interface CRUDActions<T extends Entity> {
  fetchItems(): Promise<void>
  fetchItem(id: string | number): Promise<void>
  createItem(data: Omit<T, 'id'>): Promise<T>
  updateItem(id: string | number, data: Partial<T>): Promise<T>
  deleteItem(id: string | number): Promise<void>
  setSelectedId(id: string | number | null): void
  setPage(page: number): void
  setFilters(filters: Record<string, any>): void
}

// 工厂函数
export function createCRUDStore<
  T extends Entity,
  Id extends string
>(
  id: Id,
  apiClient: {
    fetchAll: () => Promise<T[]>
    fetchOne: (id: string | number) => Promise<T>
    create: (data: Omit<T, 'id'>) => Promise<T>
    update: (id: string | number, data: Partial<T>) => Promise<T>
    delete: (id: string | number) => Promise<void>
  }
) {
  return defineStore<Id, CRUDState<T>, CRUDGetters<T>, CRUDActions<T>>(id, {
    state: () => ({
      items: [],
      selectedId: null,
      loading: false,
      error: null,
      filters: {},
      pagination: {
        page: 1,
        perPage: 10,
        total: 0
      }
    }),
    
    getters: {
      allItems: (state) => state.items,
      
      selectedItem(state): T | null {
        return state.items.find(item => item.id === state.selectedId) || null
      },
      
      itemCount: (state) => state.items.length,
      
      filteredItems(state): T[] {
        return state.items.filter(item => {
          return Object.entries(state.filters).every(([key, value]) => {
            if (!value) return true
            return (item as any)[key]?.toString().toLowerCase().includes(value.toLowerCase())
          })
        })
      },
      
      currentPageItems(): T[] {
        const start = (this.pagination.page - 1) * this.pagination.perPage
        return this.filteredItems.slice(start, start + this.pagination.perPage)
      },
      
      totalPages(): number {
        return Math.ceil(this.filteredItems.length / this.pagination.perPage)
      }
    },
    
    actions: {
      async fetchItems(): Promise<void> {
        this.loading = true
        this.error = null
        try {
          this.items = await apiClient.fetchAll()
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async fetchItem(id: string | number): Promise<void> {
        this.loading = true
        try {
          const item = await apiClient.fetchOne(id)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          } else {
            this.items.push(item)
          }
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async createItem(data: Omit<T, 'id'>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.create(data)
          this.items.push(item)
          return item
        } finally {
          this.loading = false
        }
      },
      
      async updateItem(id: string | number, data: Partial<T>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.update(id, data)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          }
          return item
        } finally {
          this.loading = false
        }
      },
      
      async deleteItem(id: string | number): Promise<void> {
        await apiClient.delete(id)
        const index = this.items.findIndex(i => i.id === id)
        if (index >= 0) {
          this.items.splice(index, 1)
        }
      },
      
      setSelectedId(id: string | number | null): void {
        this.selectedId = id
      },
      
      setPage(page: number): void {
        this.pagination.page = page
      },
      
      setFilters(filters: Record<string, any>): void {
        this.filters = { ...this.filters, ...filters }
        this.pagination.page = 1 // 重置到第一页
      }
    }
  })
}

// 使用工厂创建具体的 store
interface Product extends Entity {
  name: string
  price: number
  category: string
  stock: number
}

const productApi = {
  fetchAll: () => fetch('/api/products').then(r => r.json()),
  fetchOne: (id) => fetch(`/api/products/${id}`).then(r => r.json()),
  create: (data) => fetch('/api/products', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
  update: (id, data) => fetch(`/api/products/${id}`, { method: 'PUT', body: JSON.stringify(data) }).then(r => r.json()),
  delete: (id) => fetch(`/api/products/${id}`, { method: 'DELETE' }).then(r => r.json())
}

export const useProductStore = createCRUDStore<Product, 'products'>('products', productApi)

高级用法与设计模式

1. Store 间的相互调用

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ isAdmin: false })
})

// stores/post.js
import { useUserStore } from './user'

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: []
  }),
  
  getters: {
    // 在 getter 中使用其他 store
    filteredPosts() {
      const userStore = useUserStore()
      if (userStore.isAdmin) {
        return this.posts
      }
      return this.posts.filter(post => post.published)
    }
  },
  
  actions: {
    // 在 action 中使用其他 store
    async createPost(postData) {
      const userStore = useUserStore()
      
      if (!userStore.isAdmin) {
        throw new Error('Only admin can create posts')
      }
      
      const post = await api.createPost(postData)
      this.posts.push(post)
      return post
    }
  }
})

2. 领域驱动设计 (DDD) Store

// stores/domain/user.store.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 领域模型
class User {
  constructor(
    public id: string,
    public email: string,
    public profile: UserProfile,
    public preferences: UserPreferences,
    private _permissions: Permission[]
  ) {}
  
  hasPermission(permission: string): boolean {
    return this._permissions.some(p => p.name === permission)
  }
  
  updateProfile(updates: Partial<UserProfile>): void {
    Object.assign(this.profile, updates)
  }
}

interface UserProfile {
  firstName: string
  lastName: string
  avatar?: string
  bio?: string
}

interface UserPreferences {
  theme: 'light' | 'dark'
  language: string
  notifications: boolean
}

interface Permission {
  name: string
  resource: string
  actions: string[]
}

// 仓库接口
interface IUserRepository {
  findById(id: string): Promise<User>
  findByEmail(email: string): Promise<User | null>
  save(user: User): Promise<User>
  delete(id: string): Promise<void>
}

// API 实现
class UserApiRepository implements IUserRepository {
  async findById(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`)
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async findByEmail(email: string): Promise<User | null> {
    const response = await fetch(`/api/users?email=${email}`)
    const data = await response.json()
    return data.length > 0 ? this.toDomain(data[0]) : null
  }
  
  async save(user: User): Promise<User> {
    const response = await fetch(`/api/users/${user.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user)
    })
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async delete(id: string): Promise<void> {
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
  }
  
  private toDomain(data: any): User {
    return new User(
      data.id,
      data.email,
      data.profile,
      data.preferences,
      data.permissions
    )
  }
}

// Store 作为应用服务层
export const useUserDomainStore = defineStore('userDomain', () => {
  // 依赖注入
  const repository: IUserRepository = new UserApiRepository()
  
  // State
  const currentUser = ref<User | null>(null)
  const users = ref<Map<string, User>>(new Map())
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // Getters
  const isAuthenticated = computed(() => !!currentUser.value)
  
  const fullName = computed(() => {
    if (!currentUser.value) return 'Guest'
    const { firstName, lastName } = currentUser.value.profile
    return `${firstName} ${lastName}`
  })
  
  const hasPermission = (permission: string) => {
    return computed(() => {
      return currentUser.value?.hasPermission(permission) || false
    })
  }
  
  // Actions
  async function loadUser(id: string): Promise<void> {
    loading.value = true
    error.value = null
    
    try {
      const user = await repository.findById(id)
      users.value.set(id, user)
      currentUser.value = user
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateUserProfile(updates: Partial<UserProfile>): Promise<void> {
    if (!currentUser.value) throw new Error('No user logged in')
    
    // 领域逻辑:在模型层处理
    currentUser.value.updateProfile(updates)
    
    // 持久化
    await repository.save(currentUser.value)
  }
  
  function clearCurrentUser(): void {
    currentUser.value = null
  }
  
  return {
    currentUser,
    users,
    loading,
    error,
    isAuthenticated,
    fullName,
    hasPermission,
    loadUser,
    updateUserProfile,
    clearCurrentUser
  }
})

3. 命令查询分离 (CQRS) 模式

// 将读取和写入操作分离

// stores/commands/userCommands.store.ts
export const useUserCommands = defineStore('userCommands', () => {
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // 纯命令(写操作)
  async function registerUser(data: RegisterUserData): Promise<void> {
    loading.value = true
    try {
      await api.users.register(data)
      eventBus.emit('user:registered', data.email)
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateEmail(userId: string, newEmail: string): Promise<void> {
    loading.value = true
    try {
      await api.users.updateEmail(userId, newEmail)
      eventBus.emit('user:emailUpdated', { userId, newEmail })
    } finally {
      loading.value = false
    }
  }
  
  async function deactivateAccount(userId: string): Promise<void> {
    await api.users.deactivate(userId)
    eventBus.emit('user:deactivated', userId)
  }
  
  return {
    loading,
    error,
    registerUser,
    updateEmail,
    deactivateAccount
  }
})

// stores/queries/userQueries.store.ts
export const useUserQueries = defineStore('userQueries', () => {
  // 查询缓存
  const userCache = ref(new Map<string, UserView>())
  const searchCache = ref(new Map<string, UserSearchResult>())
  
  // 纯查询(读操作)
  async function getUserById(id: string): Promise<UserView> {
    // 先查缓存
    if (userCache.value.has(id)) {
      return userCache.value.get(id)!
    }
    
    // 查询 API
    const user = await api.users.getById(id)
    const view = toUserView(user)
    
    // 写入缓存
    userCache.value.set(id, view)
    
    return view
  }
  
  async function searchUsers(query: string): Promise<UserSearchResult> {
    const cacheKey = query.toLowerCase()
    
    if (searchCache.value.has(cacheKey)) {
      return searchCache.value.get(cacheKey)!
    }
    
    const results = await api.users.search(query)
    searchCache.value.set(cacheKey, results)
    
    return results
  }
  
  function invalidateUserCache(id: string): void {
    userCache.value.delete(id)
  }
  
  // 订阅事件来更新缓存
  eventBus.on('user:emailUpdated', ({ userId }) => {
    invalidateUserCache(userId)
  })
  
  return {
    getUserById,
    searchUsers,
    invalidateUserCache
  }
})

// 在组件中使用
function useUser() {
  const commands = useUserCommands()
  const queries = useUserQueries()
  
  return {
    // 查询
    getUser: queries.getUserById,
    searchUsers: queries.searchUsers,
    
    // 命令
    register: commands.registerUser,
    updateEmail: commands.updateEmail,
    deactivate: commands.deactivateAccount,
    
    // 状态
    isLoading: computed(() => commands.loading),
    error: computed(() => commands.error)
  }
}

性能优化实战

1. 虚拟化大数据列表

export const useVirtualListStore = defineStore('virtualList', () => {
  // 原始数据
  const allItems = ref<Item[]>([])
  
  // 虚拟化配置
  const config = reactive({
    itemHeight: 50,
    containerHeight: 600,
    overscan: 5, // 上下额外渲染的数量
    totalItems: computed(() => allItems.value.length)
  })
  
  // 滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(高性能 getter)
  const visibleRange = computed(() => {
    const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight) - config.overscan)
    const visibleCount = Math.ceil(config.containerHeight / config.itemHeight)
    const endIndex = Math.min(config.totalItems, startIndex + visibleCount + config.overscan * 2)
    
    return { startIndex, endIndex, visibleCount }
  })
  
  // 只返回可见项
  const visibleItems = computed(() => {
    const { startIndex, endIndex } = visibleRange.value
    return allItems.value.slice(startIndex, endIndex).map((item, index) => ({
      ...item,
      index: startIndex + index,
      offset: (startIndex + index) * config.itemHeight
    }))
  })
  
  // 总高度(用于滚动条)
  const totalHeight = computed(() => config.totalItems * config.itemHeight)
  
  // 更新滚动位置(使用 requestAnimationFrame 节流)
  let rafId: number | null = null
  function updateScrollTop(newScrollTop: number): void {
    if (rafId !== null) return
    
    rafId = requestAnimationFrame(() => {
      scrollTop.value = newScrollTop
      rafId = null
    })
  }
  
  // 批量加载数据
  async function loadItems(start: number, count: number): Promise<void> {
    const items = await api.fetchItems(start, count)
    allItems.value.splice(start, items.length, ...items)
  }
  
  // 预加载
  watch(visibleRange, (range) => {
    const bufferStart = Math.max(0, range.startIndex - 20)
    const bufferEnd = Math.min(config.totalItems, range.endIndex + 20)
    
    // 检查并加载缺失的数据
    for (let i = bufferStart; i < bufferEnd; i++) {
      if (!allItems.value[i]) {
        loadItems(i, 20)
        break
      }
    }
  })
  
  return {
    visibleItems,
    totalHeight,
    visibleRange,
    updateScrollTop,
    loadItems
  }
})

2. 智能缓存策略

export const useCachedStore = defineStore('cached', () => {
  // 多级缓存
  const memoryCache = new Map<string, any>()
  const persistentCache = useLocalStorage('app-cache', {})
  
  // 缓存配置
  const cacheConfig = {
    ttl: {
      memory: 5 * 60 * 1000,      // 内存缓存 5 分钟
      persistent: 24 * 60 * 60 * 1000  // 持久化缓存 24 小时
    },
    maxSize: {
      memory: 100,   // 最多 100 条
      persistent: 500
    }
  }
  
  // 缓存元数据
  interface CacheEntry<T> {
    data: T
    timestamp: number
    accessCount: number
    lastAccessed: number
  }
  
  const cacheMeta = reactive(new Map<string, CacheEntry<any>>())
  
  // 获取缓存
  function get<T>(key: string): T | null {
    // 先查内存
    if (memoryCache.has(key)) {
      updateAccessStats(key)
      return memoryCache.get(key)
    }
    
    // 再查持久化
    const persistent = persistentCache.value[key]
    if (persistent && !isExpired(persistent.timestamp, cacheConfig.ttl.persistent)) {
      // 提升到内存
      memoryCache.set(key, persistent.data)
      updateAccessStats(key)
      return persistent.data
    }
    
    return null
  }
  
  // 设置缓存
  function set<T>(key: string, data: T, options: { persistent?: boolean } = {}): void {
    const entry: CacheEntry<T> = {
      data,
      timestamp: Date.now(),
      accessCount: 0,
      lastAccessed: Date.now()
    }
    
    // 写入内存
    memoryCache.set(key, data)
    cacheMeta.set(key, entry)
    
    // 写入持久化
    if (options.persistent) {
      persistentCache.value[key] = entry
    }
    
    // 清理旧缓存
    cleanupIfNeeded()
  }
  
  // 更新访问统计
  function updateAccessStats(key: string): void {
    const meta = cacheMeta.get(key)
    if (meta) {
      meta.accessCount++
      meta.lastAccessed = Date.now()
    }
  }
  
  // 检查是否过期
  function isExpired(timestamp: number, ttl: number): boolean {
    return Date.now() - timestamp > ttl
  }
  
  // 清理策略:LRU (Least Recently Used)
  function cleanupIfNeeded(): void {
    if (memoryCache.size <= cacheConfig.maxSize.memory) return
    
    // 按最后访问时间排序
    const sorted = Array.from(cacheMeta.entries())
      .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
    
    // 删除最旧的 20%
    const toDelete = Math.floor(cacheConfig.maxSize.memory * 0.2)
    for (let i = 0; i < toDelete; i++) {
      const [key] = sorted[i]
      memoryCache.delete(key)
      cacheMeta.delete(key)
    }
  }
  
  // 带缓存的数据获取
  async function fetchWithCache<T>(
    key: string,
    fetcher: () => Promise<T>,
    options: { persistent?: boolean; force?: boolean } = {}
  ): Promise<T> {
    // 检查缓存
    if (!options.force) {
      const cached = get<T>(key)
      if (cached !== null) {
        return cached
      }
    }
    
    // 获取新数据
    const data = await fetcher()
    
    // 存入缓存
    set(key, data, options)
    
    return data
  }
  
  // 预加载策略
  function preload(keys: string[], fetchers: Map<string, () => Promise<any>>): void {
    const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1))
    
    idleCallback(() => {
      keys.forEach(key => {
        if (!memoryCache.has(key)) {
          const fetcher = fetchers.get(key)
          if (fetcher) {
            fetcher().then(data => set(key, data))
          }
        }
      })
    })
  }
  
  return {
    get,
    set,
    fetchWithCache,
    preload,
    clear: () => {
      memoryCache.clear()
      cacheMeta.clear()
    }
  }
})

插件系统详解

1. 日志插件(DevTools 增强)

// plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin(context: PiniaPluginContext) {
  const { store, options } = context
  
  // 只在开发环境启用
  if (process.env.NODE_ENV === 'production') return
  
  // 为每个 action 添加日志
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // action 成功后的回调
    onError     // action 失败后的回调
  }) => {
    const startTime = Date.now()
    
    console.group(`🟢 Action: ${store.$id}.${name}`)
    console.log('Arguments:', args)
    
    after((result) => {
      console.log('✅ Success:', result)
      console.log('⏱ Duration:', Date.now() - startTime, 'ms')
      console.groupEnd()
    })
    
    onError((error) => {
      console.error('❌ Error:', error)
      console.groupEnd()
    })
  })
  
  // 监听 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 State Change: ${store.$id}`)
    console.log('Type:', mutation.type)
    console.log('Store ID:', mutation.storeId)
    console.log('Payload:', mutation.payload)
    console.log('New State:', state)
    console.groupEnd()
  })
}

2. 持久化插件(完整实现)

// plugins/persist.ts
import type { PiniaPluginContext, StateTree } from 'pinia'

interface PersistStrategy {
  key?: string
  storage?: Storage
  paths?: string[]
  beforeRestore?: (context: PiniaPluginContext) => void
  afterRestore?: (context: PiniaPluginContext) => void
  serializer?: {
    serialize: (value: any) => string
    deserialize: (value: string) => any
  }
}

type PersistOption = boolean | PersistStrategy | PersistStrategy[]

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S extends StateTree, Store> {
    persist?: PersistOption
  }
}

export function createPersistPlugin(defaults: Partial<PersistStrategy> = {}) {
  return function persistPlugin(context: PiniaPluginContext) {
    const { options, store } = context
    
    if (!options.persist) return
    
    const strategies = Array.isArray(options.persist) 
      ? options.persist 
      : [options.persist === true ? {} : options.persist]
    
    strategies.forEach((strategy) => {
      const {
        key = store.$id,
        storage = localStorage,
        paths = [],
        beforeRestore = () => {},
        afterRestore = () => {},
        serializer = {
          serialize: JSON.stringify,
          deserialize: JSON.parse
        }
      } = { ...defaults, ...strategy }
      
      // 恢复状态
      beforeRestore(context)
      
      try {
        const stored = storage.getItem(key)
        if (stored) {
          const parsed = serializer.deserialize(stored)
          
          if (paths.length > 0) {
            // 部分恢复
            paths.forEach((path) => {
              if (path in parsed) {
                store.$patch((state) => {
                  setNestedValue(state, path, parsed[path])
                })
              }
            })
          } else {
            // 完全恢复
            store.$patch(parsed)
          }
        }
      } catch (error) {
        console.error(`Failed to restore state for ${key}:`, error)
      }
      
      afterRestore(context)
      
      // 监听变化并保存
      store.$subscribe(
        (mutation, state) => {
          try {
            let toStore: any = state
            
            if (paths.length > 0) {
              // 只保存指定路径
              toStore = paths.reduce((acc, path) => {
                setNestedValue(acc, path, getNestedValue(state, path))
                return acc
              }, {})
            }
            
            storage.setItem(key, serializer.serialize(toStore))
          } catch (error) {
            console.error(`Failed to persist state for ${key}:`, error)
          }
        },
        { detached: true } // 组件卸载后继续监听
      )
    })
  }
}

// 辅助函数
function setNestedValue(obj: any, path: string, value: any): void {
  const keys = path.split('.')
  let current = obj
  
  for (let i = 0; i < keys.length - 1; i++) {
    if (!(keys[i] in current)) {
      current[keys[i]] = {}
    }
    current = current[keys[i]]
  }
  
  current[keys[keys.length - 1]] = value
}

function getNestedValue(obj: any, path: string): any {
  return path.split('.').reduce((current, key) => current?.[key], obj)
}

// 使用
// main.ts
import { createPersistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(createPersistPlugin({
  storage: localStorage,
  beforeRestore: (ctx) => {
    console.log(`Restoring ${ctx.store.$id}...`)
  }
}))

// store.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null,
    preferences: {
      theme: 'light',
      language: 'zh'
    }
  }),
  persist: {
    key: 'my-app-user',
    paths: ['token', 'preferences'], // 只持久化这些字段
    storage: sessionStorage, // 使用 sessionStorage
    beforeRestore: (ctx) => {
      console.log('Before restore')
    },
    afterRestore: (ctx) => {
      console.log('After restore')
    }
  }
})

3. 使用 pinia-plugin-persistedstate(推荐)

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null
  }),
  persist: true
})

SSR 深度实践

1. 服务端数据预取模式

// composables/useAsyncStore.ts
import { useRequestFetch } from 'nuxt/app'

interface AsyncStoreOptions<T> {
  key: string
  fetcher: () => Promise<T>
  defaultValue: T
  immediate?: boolean
  transform?: (data: T) => T
  onError?: (error: Error) => void
}

export function useAsyncStore<T>(options: AsyncStoreOptions<T>) {
  const { key, fetcher, defaultValue, immediate = true, transform, onError } = options
  
  // 使用 useState 实现 SSR 友好的状态管理
  const data = useState<T>(key, () => defaultValue)
  const pending = useState<boolean>(`${key}-pending`, () => false)
  const error = useState<Error | null>(`${key}-error`, () => null)
  
  // 标记是否已经在服务端获取过数据
  const serverFetched = useState<boolean>(`${key}-server-fetched`, () => false)
  
  async function execute(): Promise<void> {
    // SSR 模式下,服务端只获取一次
    if (process.server && serverFetched.value) return
    
    // CSR 模式下,如果已有数据则不重复获取
    if (process.client && data.value !== defaultValue && !error.value) return
    
    pending.value = true
    error.value = null
    
    try {
      let result = await fetcher()
      
      if (transform) {
        result = transform(result)
      }
      
      data.value = result
      
      if (process.server) {
        serverFetched.value = true
      }
    } catch (err) {
      error.value = err as Error
      onError?.(err as Error)
    } finally {
      pending.value = false
    }
  }
  
  // 立即执行
  if (immediate) {
    // 在 SSR 中使用 await 等待数据
    if (process.server) {
      // Nuxt 3 中会自动等待
      execute()
    } else {
      // 客户端异步执行
      execute()
    }
  }
  
  // 刷新数据
  async function refresh(): Promise<void> {
    serverFetched.value = false
    await execute()
  }
  
  return {
    data: readonly(data),
    pending: readonly(pending),
    error: readonly(error),
    execute,
    refresh
  }
}

2. SSR 安全的状态管理

// utils/ssr-helpers.ts

// 只在客户端执行的辅助函数
export function onClient<T>(fn: () => T): T | undefined {
  if (process.client) {
    return fn()
  }
}

// 只在服务端执行的辅助函数
export function onServer<T>(fn: () => T): T | undefined {
  if (process.server) {
    return fn()
  }
}

// SSR 安全的 localStorage
export function useSSRStorage() {
  function getItem(key: string): string | null {
    return onClient(() => localStorage.getItem(key)) || null
  }
  
  function setItem(key: string, value: string): void {
    onClient(() => localStorage.setItem(key, value))
  }
  
  function removeItem(key: string): void {
    onClient(() => localStorage.removeItem(key))
  }
  
  return { getItem, setItem, removeItem }
}

// 在 Store 中使用
export const useSafeStore = defineStore('safe', () => {
  const storage = useSSRStorage()
  
  const token = ref<string | null>(null)
  
  // 客户端初始化
  function init() {
    onClient(() => {
      // 从 localStorage 恢复
      token.value = storage.getItem('token')
    })
  }
  
  function setToken(newToken: string) {
    token.value = newToken
    storage.setItem('token', newToken)
  }
  
  return {
    token,
    init,
    setToken
  }
})

测试策略与实战

1. 单元测试完整方案

// stores/counter.store.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useCounterStore } from './counter.store'

describe('Counter Store', () => {
  // 每个测试前重置 Pinia
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  describe('State', () => {
    it('should have correct initial state', () => {
      const store = useCounterStore()
      
      expect(store.count).toBe(0)
      expect(store.name).toBe('Counter')
    })
    
    it('should update state directly', () => {
      const store = useCounterStore()
      
      store.count = 10
      expect(store.count).toBe(10)
    })
    
    it('should reset to initial state', () => {
      const store = useCounterStore()
      
      store.count = 100
      store.$reset()
      
      expect(store.count).toBe(0)
    })
  })
  
  describe('Getters', () => {
    it('should calculate double count correctly', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
    })
    
    it('should recalculate when dependency changes', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
      
      store.count = 10
      expect(store.doubleCount).toBe(20)
    })
  })
  
  describe('Actions', () => {
    it('should increment count', () => {
      const store = useCounterStore()
      
      store.increment()
      expect(store.count).toBe(1)
    })
    
    it('should handle async action', async () => {
      const store = useCounterStore()
      
      await store.asyncIncrement()
      expect(store.count).toBe(1)
    })
    
    it('should handle action errors', async () => {
      const store = useCounterStore()
      
      // 模拟 API 失败
      vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
      
      await expect(store.fetchData()).rejects.toThrow('Network error')
      expect(store.error).toBe('Network error')
    })
  })
  
  describe('Subscriptions', () => {
    it('should notify subscribers on state change', () => {
      const store = useCounterStore()
      const callback = vi.fn()
      
      store.$subscribe(callback)
      
      store.count = 5
      
      expect(callback).toHaveBeenCalled()
    })
    
    it('should notify action subscribers', () => {
      const store = useCounterStore()
      const onAction = vi.fn()
      
      store.$onAction(onAction)
      
      store.increment()
      
      expect(onAction).toHaveBeenCalled()
    })
  })
})

2. 集成测试

// tests/integration/stores.integration.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'

describe('Store Integration', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  it('should sync user state across stores', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    
    // 用户登录
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 购物车应该能访问用户信息
    expect(cartStore.userId).toBe(userStore.user?.id)
  })
  
  it('should create order with cart items and user info', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    const orderStore = useOrderStore()
    
    // 设置用户
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 添加商品到购物车
    cartStore.addItem({ id: 1, name: 'Product', price: 100 })
    cartStore.addItem({ id: 2, name: 'Another', price: 50 })
    
    // 创建订单
    const order = await orderStore.createOrder()
    
    // 验证订单包含正确信息
    expect(order.userId).toBe(userStore.user?.id)
    expect(order.items).toHaveLength(2)
    expect(order.total).toBe(150)
    
    // 验证购物车已清空
    expect(cartStore.items).toHaveLength(0)
  })
})

大型项目架构

1. 项目结构组织

src/
├── modules/
│   ├── auth/
│   │   ├── stores/
│   │   │   ├── auth.store.ts
│   │   │   └── permissions.store.ts
│   │   ├── components/
│   │   ├── composables/
│   │   └── index.ts          # 模块导出
│   ├── products/
│   │   ├── stores/
│   │   │   ├── product.store.ts
│   │   │   └── category.store.ts
│   │   ├── components/
│   │   └── index.ts
│   └── orders/
│       ├── stores/
│       │   ├── order.store.ts
│       │   └── payment.store.ts
│       └── index.ts
├── shared/
│   └── stores/
│       ├── ui.store.ts       # 全局 UI 状态
│       └── cache.store.ts    # 全局缓存
└── stores/
    └── index.ts              # Store 入口

2. Store 依赖注入容器

// core/container.ts
import type { Pinia } from 'pinia'

interface ContainerConfig {
  pinia: Pinia
  apiBaseUrl: string
  storage: Storage
}

class StoreContainer {
  private stores = new Map<string, any>()
  private config: ContainerConfig
  
  constructor(config: ContainerConfig) {
    this.config = config
  }
  
  // 注册 Store
  register<T>(name: string, factory: (container: StoreContainer) => T): void {
    if (this.stores.has(name)) {
      throw new Error(`Store ${name} already registered`)
    }
    
    Object.defineProperty(this, name, {
      get: () => {
        if (!this.stores.has(name)) {
          this.stores.set(name, factory(this))
        }
        return this.stores.get(name)
      },
      configurable: true
    })
  }
  
  // 获取配置
  getConfig(): ContainerConfig {
    return this.config
  }
  
  // 初始化所有 Store
  async init(): Promise<void> {
    for (const [name, store] of this.stores) {
      if (store.init && typeof store.init === 'function') {
        await store.init()
      }
    }
  }
}

// 创建容器
export function createContainer(config: ContainerConfig): StoreContainer {
  return new StoreContainer(config)
}

源码级原理解析

1. defineStore 的执行流程

defineStore(id, setup)
    │
    ▼
返回 useStore 函数(闭包)
    │
    ▼
调用 useStore()
    │
    ├──▶ 获取当前 Pinia 实例(getActivePinia)
    │
    ├──▶ 检查是否已存在该 Store
    │       ├── 存在 → 直接返回缓存的 Store
    │       └── 不存在 → 创建新 Store
    │
    └──▶ createSetupStore(id, setup, pinia)
            │
            ├──▶ 创建响应式 Scope(用于自动清理)
            │
            ├──▶ 执行 setup 函数
            │       │
            │       ├──▶ 将 ref → state
            │       ├──▶ 将 computed → getter
            │       └──▶ 将 function → action
            │
            ├──▶ 处理 Options API 风格(如果是对象)
            │
            ├──▶ 包装 Actions(添加订阅、错误处理)
            │
            ├──▶ 添加 Store 属性($patch, $reset, $subscribe 等)
            │
            └──▶ 返回响应式 Store 对象

2. 插件系统的工作机制

// Pinia 如何加载插件?

class Pinia {
  constructor() {
    this._p = [] // 插件数组
    this._s = new Map() // Store 实例 Map
  }
  
  // 注册插件
  use(plugin) {
    this._p.push(plugin)
    
    // 如果已有 Store,立即应用插件
    this._s.forEach((store, id) => {
      plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
    })
    
    return this
  }
  
  // 安装插件到具体 Store
  _installPlugin(store) {
    this._p.forEach(plugin => {
      const result = plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
      
      // 插件可以返回要添加到 Store 的属性
      if (result) {
        Object.assign(store, result)
      }
    })
  }
}

从 Vuex 迁移到 Pinia

迁移清单

  1. 安装 Pinia
npm install pinia
  1. 创建 Pinia 实例
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'

const app = createApp(App)
app.use(createPinia())
  1. 迁移 Vuex Modules 为 Pinia Stores

Before (Vuex):

// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({ user: null }),
  mutations: {
    SET_USER(state, user) { state.user = user }
  },
  actions: {
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)
    }
  },
  getters: {
    isLoggedIn: state => !!state.user
  }
}

After (Pinia):

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async login(credentials) {
      const user = await api.login(credentials)
      this.user = user  // 直接修改,不需要 mutation
    }
  },
  getters: {
    isLoggedIn: (state) => !!state.user
  }
})
  1. 更新组件中的使用方式

Before:

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('user', ['user'])
  },
  methods: {
    ...mapActions('user', ['login'])
  }
}
</script>

After:

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { login } = userStore
</script>

常见问题

Q: 如何处理命名空间?

// Vuex: namespaced: true
// Pinia: 每个 store 天然是独立的,无需命名空间

Q: Mutations 去哪里了?

// Pinia 中直接修改 state,无需 mutations
// 或使用 actions 封装逻辑

Q: 如何处理插件(如持久化)?

// 使用 Pinia 插件系统
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)

最佳实践总结

选择建议

场景 推荐方案
新项目 Pinia - 官方推荐,现代架构
Vue 3 + TS Pinia - 完美的类型支持
大型应用 Pinia + 模块化架构 - 易于维护
SSR 应用 Pinia - 更好的 SSR 支持
老项目维护 Vuex - 如果已有 Vuex,可以继续使用
快速原型 Pinia - 更快的开发速度

性能优化清单

  • 使用虚拟滚动处理大数据列表
  • 实现多级缓存策略(内存 + 持久化)
  • 使用 Web Worker 处理复杂计算
  • 合理使用 getter 缓存
  • 避免不必要的 store 订阅
  • 使用 storeToRefs 解构保持响应式
  • 延迟加载非关键 store

最佳实践总结

  1. 单一职责:每个 store 只负责一个领域
  2. 组合优于继承:使用 composables 组合功能
  3. 类型优先:充分利用 TypeScript
  4. 测试覆盖:单元测试 + 集成测试 + E2E 测试
  5. 插件增强:使用插件实现横切关注点(日志、持久化等)
  6. 性能意识:关注大数据场景的性能优化

Pinia 的核心优势

  1. 简洁性:移除 Mutations,减少 40-50% 的样板代码
  2. 类型安全:原生 TypeScript 支持,完美类型推导
  3. 灵活性:支持 Options API 和 Composition API 两种风格
  4. 可扩展性:强大的插件系统,易于定制
  5. DevTools:更好的开发体验,支持时间旅行
  6. 轻量级:~1KB,性能优于 Vuex

参考资源

一文读懂:微信小程序云数据库直连原理与使用指南

作者 木易士心
2026年3月6日 15:57

前言

微信小程序直接调用云数据库(云开发模式)是微信提供的一种Serverless架构方案,它允许前端(小程序端)在没有传统后端服务器的情况下直接操作数据库。 以下是关于该机制的原理架构图流程图使用详解

一、 原理架构图

微信小程序云开发采用了 Serverless 架构。传统的开发模式需要“小程序前端 -> 后端服务器(API) -> 数据库”,而云开发模式则是“小程序前端 -> 云数据库(通过微信私有协议)”。

1. 架构示意

graph TD
    subgraph "客户端"
        A[小程序前端代码]
    end
    subgraph "微信基础设施"
        B[微信 APP 宿主环境]
        C[云开发控制台/基础设施]
    end
    subgraph "云开发资源"
        D[(云数据库 - JSON)]
        E[云函数]
        F[云存储]
    end
    A -- 1. 调用 wx.cloud/init --> B
    A -- 2. 调用 db.collection --> B
    B -- 3. 微信私有协议/鉴权 --> C
    C -- 4. 安全规则校验 --> D
    D -- 5. 返回JSON数据 --> C
    C -- 6. 返回结果 --> A
    style A fill:#e1f5fe,stroke:#01579b
    style D fill:#fff9c4,stroke:#fbc02d
    style C fill:#f3e5f5,stroke:#8e24aa

2. 核心组件解析

  • 小程序前端: 运行在小程序环境中的代码,通过微信提供的 SDK (wx.cloud) 发起请求。
  • 微信私有协议: 数据传输不经过公网 HTTP,而是通过微信客户端底层通道,速度更快,且自带微信登录态,无需手动管理 Token。
  • 安全规则: 这是“直接调用”的安全基石。数据库根据配置的 JSON 规则(如 auth.openid)判断当前用户是否有权读/写数据,替代了传统后端的权限校验逻辑。
  • 云数据库: 一个 MongoDB 文档型数据库,数据以 JSON 格式存储。

二、 调用流程图

当小程序端执行一条 db.collection('xxx').get() 时,底层发生了以下流程:

sequenceDiagram
    participant User as 用户/小程序前端
    participant SDK as 微信客户端 SDK
    participant Cloud as 微信云服务网关
    participant DB as 云数据库实例
    User->>SDK: 1. 调用 API (如 db.collection('user').get())
    Note right of User: 传入环境ID (env)
    
    SDK->>SDK: 2. 本地检查云环境初始化状态
    
    SDK->>Cloud: 3. 建立私有连接通道
    Note right of SDK: 自动携带 AppID, OpenID, UnionID
    
    Cloud->>Cloud: 4. 身份鉴权 (获取用户身份)
    
    Cloud->>DB: 5. 发送数据库请求指令
    
    Note over DB, Cloud: 6. 执行【安全规则】校验
    alt 权限校验失败
        DB-->>Cloud: 返回 Permission Denied
        Cloud-->>SDK: 返回错误信息
        SDK-->>User: Catch Error
    else 权限校验通过
        DB->>DB: 7. 执行查询/写入操作
        DB-->>Cloud: 8. 返回数据结果
        Cloud-->>SDK: 9. 封装返回数据
        SDK-->>User: 10. Promise Resolve (返回数据)
    end

关键点说明:

  1. 自动鉴权: 最大的特点是 “免登录”。SDK 会自动获取用户的 OpenID 并传给云端,开发者不需要写登录接口。
  2. 安全规则拦截: 如果在控制台配置了“仅创建者可写”,当用户 A 尝试修改用户 B 的数据时,Cloud 层会在第 6 步直接拦截,报错 database permission denied

三、 使用详解

要实现小程序直接调用云数据库,需遵循以下步骤。

1. 环境初始化

在调用任何云能力之前,必须先初始化。

// app.js
App({
  onLaunch: function () {
    if (!wx.cloud) {
      console.error('请使用 2.2.3 或以上的基础库以使用云能力');
    } else {
      wx.cloud.init({
        // env 参数说明:
        //   env: 'your-env-id' // 云开发环境ID,可在云开发控制台获取
        traceUser: true, // 自动上报用户信息
      });
    }
  }
});

2. 获取数据库引用

const db = wx.cloud.database();
// 指定特定环境(如果有多个环境)
// const db = wx.cloud.database({ env: 'your-env-id' });

3. CRUD 操作示例

(1) 增 - Insert

// 添加数据
db.collection('todos').add({
  data: {
    description: '学习云开发',
    due: new Date('2023-12-31'),
    tags: ['cloud', 'database'],
    location: new db.Geo.Point(113, 23), // 地理位置
    done: false
  }
})
.then(res => {
  console.log('添加成功,记录ID:', res._id);
})
.catch(err => {
  console.error('添加失败', err);
});

(2) 查 - Query

// 获取数据
db.collection('todos').where({
  _openid: 'xxx' // 此处通常不需要手动填,如果开启了安全规则,系统会自动校验
})
.get()
.then(res => {
  // res.data 是一个数组
  console.log('查询结果:', res.data);
});
// 获取单条记录
db.collection('todos').doc('record-id-here').get()
.then(res => {
  console.log(res.data);
});

(3) 改 - Update

注意:update 只能修改符合 where 条件或通过 doc 指定的记录。

db.collection('todos').doc('record-id-here').update({
  data: {
    done: true // 将 done 字段改为 true
  }
})
.then(res => {
  console.log('更新成功,影响行数:', res.stats.updated);
});

(4) 删 - Remove

注意:在小程序端直接调用 remove 删除多条记录通常受限制,建议一次删除一条或使用云函数批量删除。

db.collection('todos').doc('record-id-here').remove()
.then(res => {
  console.log('删除成功');
});

4. 权限管理(安全规则)- 核心中的核心

小程序直接调用数据库之所以安全,是因为数据库安全规则。 在微信开发者工具 -> 云开发控制台 -> 数据库 -> 选择集合 -> 权限设置,有以下常见模式:

  • 仅创建者可写,所有人可读:
    • 适合:文章、帖子、评论。
    • 原理:系统自动检查记录中的 _openid 是否与当前用户的 _openid 一致。
  • 仅创建者可读写:
    • 适合:个人隐私数据(如购物车、个人设置)。
  • 所有人可读,仅创建者可写:
    • 适合:字典数据、配置数据。
  • 自定义安全规则:
    • 使用 JSON 语法定义复杂的逻辑。例如:"read": true, "write": "auth.openid == doc._openid"

5. 数据类型支持

云数据库支持丰富的数据类型,不同于传统的 MySQL,它直接支持:

  • GeoJSON: 地理位置点 db.Geo.Point,支持地理位置查询(如查找附近的人)。
  • Date: 时间对象 new Date()
  • Null: 空值。
  • 嵌套对象: JSON 对象多层嵌套。

四、 总结

1.微信小程序直接调用云数据库 的核心优势在于:

  1. 开发效率高: 省去了搭建服务器、编写 API 接口、维护数据库连接池的工作。
  2. 安全性强: 通过微信底层鉴权和安全规则,实现了前端直接操作数据库且不泄露数据。
  3. 成本低: 按量付费,对于中小型应用极其友好。

2.适用场景:

  • 快速原型开发(MVP)。
  • 逻辑相对简单的 CRUD 应用(如备忘录、简单的商城、预约系统)。
  • 企业内部员工或者B端项目多为表单提交,列表详情展示类(用户量较小) 不适用场景:
  • 复杂的事务处理(如涉及多表关联、复杂的金钱流转逻辑)。
  • C端日活高应用,高并发、高吞吐量的写操作(小程序端有连接数和频率限制)。
  • 需要高度保密的计算逻辑(逻辑放在前端容易被反编译,此时应使用云函数)。

elpis总结——基于koa的elpis-core

作者 striver
2026年3月6日 15:52

什么是 elpis-core ?

elpis-core 是对 koa 应用框架的一层封装,是精简版的 Node.js 框架;本质就是应用加载器,可以通过内部的 loader 加载代码,这块代码必须是按照约定的方式目录结构以及格式来写的。也可以理解为是精简版的 egg.js 。

elpis-core 都做了什么?

elpis-core 本质上是一个"应用加载器";

  1. 初始化 koa 实例
  2. 按照约定自动加载业务代码(controller、service、middleware、... 等)
  3. 统一管理配置和环境
  4. 启动 HTTP 服务

elpis-core 文件结构

elpis-core/
├── index.js          # 入口文件
├── env.js            # 环境判断
├── utils.js          # 工具函数
└── loader/           # 各类加载器
    ├── config.js     # 配置加载器
    ├── controller.js # 控制器加载器
    ├── service.js    # 服务层加载器
    ├── middleware.js # 中间件加载器
    ├── router.js     # 路由加载器
    ├── router-schema.js # 路由参数校验 Schema 加载器
    └── extend.js     # 扩展加载器

loader 作用

loader 主要是把项目文件夹内容挂载到 koa 实例,目的就是实现分层架构,解耦模块间的依赖,每个模块都统一访问入口在 app 实例上访问。

项目文件结构

config/              → 项目配置
app/ 
├── controller/      → 处理请求、调用 service、返回响应
├── service/         → 业务逻辑层
├── middleware/      → 中间件(鉴权、日志、错误处理等)
├── router/          → 路由定义
├── router-schema/   → 路由校验规则
└── extend/          → 扩展 app 能力(如 logger)
模块 作用
configLoader config/config.{env}.js 文件内容挂载到koa实例
controllerLoader 自动扫描 app/controller/**/*.js,并挂载koa实例
middlewareLoader 自动扫描 app/middleware/**/*.js,并挂载koa实例
serviceLoader 自动扫描 app/service/**/*.js,挂载koa实例
routerSchemaLoader 加载 app/router-schema/*.js,挂载到koa实例,用于 API 参数校验
routerLoader 自动扫描 app/router/**/*.js,注册到 koa-router,并挂载koa实例
extendLoader 加载app/extend/*.js,直接扩展 app 实例

image.png

koa 的洋葱圈模型

elpis-core 是对 koa 的封装,其中间件的执行顺序也是洋葱圈模型;请求从外层中间件进入,一层层向内传递,到达核心后再一层层向外返回。类似栈结构的先进后出,先进入的中间件最后也会在路过一次。

image.png

总结

通俗的来讲 elpis-core 就是项目的启动器,来启动按照我们约定的规则编写的项目,也就是极简版egg.js。

响应式探秘:ref vs reactive,我该选谁?

作者 wuhen_n
2026年3月5日 09:22

前言

在 Vue3 的 Composition API 中,有两个主要的响应式 API:refreactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive

这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。

在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set()vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:

const target = { name: 'Vue' }
const handler = {
  get(target, key, receiver) {
    console.log(`读取属性: ${key}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(`设置属性: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
}

const proxy = new Proxy(target, handler)
proxy.name // 读取属性: name
proxy.name = 'Vue3' // 设置属性: name = Vue3

Proxy 的强大之处

  • 拦截所有操作:包括属性读取、赋值、删除、in 操作符等,支持 13 种数据操作的拦截
  • 动态属性响应:新增属性也能被追踪
  • 数组方法拦截:push、pop 等方法也能触发更新

关于 Proxy 的相关内容,可以查看我在《JavaScript核心机制探秘》专栏中相关的文章介绍。

reactive 的实现原理

reactive 是 Vue3 中最直接的响应式 API,它接收一个对象,返回这个对象的 Proxy 代理:

// 简化的 reactive 实现
function reactive(target) {
  // 创建 Proxy 代理
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 删除属性也要触发更新
      trigger(target, key)
      return result
    }
  })
}

// 使用
const state = reactive({
  count: 0,
  user: { name: '张三' }
})

state.count++ // 触发更新
state.user.name = '李四' // 嵌套对象也会被递归代理

ref 的实现原理

ref 的设计要处理一个根本性问题:Proxy 只能代理对象,无法代理基础类型(string、number、boolean)。因此,Vue团队 给出了一个解决方案:使用 value 属性,将基础类型值包装成一个对象,再对这个对象进行 Proxy 代理。这也是为什么 ref 响应式数据,需要用 .value 的方式进行访问的原因:

// 简化的 ref 实现
function ref(value) {
  // 创建包装对象
  const wrapper = {
    value: value
  }
  
  // 将包装对象变为响应式
  return reactive(wrapper)
}

// 更接近真实源码的实现
class RefImpl {
  constructor(value) {
    this._value = value
    this.__v_isRef = true // 标记这是一个 ref
  }
  
  get value() {
    // 依赖收集
    track(this, 'value')
    return this._value
  }
  
  set value(newVal) {
    if (this._value !== newVal) {
      this._value = newVal
      // 触发更新
      trigger(this, 'value')
    }
  }
}

function ref(value) {
  return new RefImpl(value)
}

// 使用
const count = ref(0)
count.value++ // 必须通过 .value 访问

从上述代码中,我们也可以看出:ref 返回的本质上也是一个 reactive 对象!

关于 ref 和 reactive 的具体源码实现细节,可以参考我的《Vue3 源码解析》的相关文章。

ref vs reactive 的核心区别

访问方式:.value 的有无

这是两者最直观的区别:

import { ref, reactive } from 'vue'

// ref 需要 .value
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

// reactive 不需要 .value
const state = reactive({ count: 0 })
console.log(state.count) // 0
state.count++
console.log(state.count) // 1

重新赋值:整体替换 vs 属性修改

这其实是在 Vue3 开发中,最容易踩的一个坑,我们先来看一个例子:

// ref 支持整体替换
let user = ref({ name: '张三', age: 18 })
// ✅ 可以直接替换整个对象
user.value = { name: '李四', age: 20 }

// reactive 不支持整体替换
let state = reactive({ name: '张三', age: 18 })
// ❌ 这样会丢失响应式
state = { name: '李四', age: 20 } 

// ✅ reactive 只能修改属性
state.name = '李四'
state.age = 20

// ❌ 即使使用 Object.assign 也可能出现问题
Object.assign(state, { name: '王五', age: 22 }) // ✅ 这样可以
state = Object.assign({}, state, { name: '王五' }) // ❌ 这样不行

类型推导与解构

reactive 在使用解构时也会出现问题:

const state = reactive({
  name: '张三',
  age: 18,
  profile: {
    city: '北京'
  }
})

// ❌ 解构后失去响应性
const { name, age } = state
name // '张三',但不再是响应式的

// ✅ 使用 toRefs 保持响应性
const { name, age } = toRefs(state)
name.value // 需要通过 .value 访问

// ✅ 单个属性用 toRef
const city = toRef(state.profile, 'city')
city.value = '上海' // 会触发更新

ref 在这方面的表现就很好:

// 组合式函数返回 ref 对象
function useFeature() {
  const count = ref(0)
  const name = ref('张三')
  
  return {
    count,
    name
  }
}

// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作

注:关于上述内容,在论坛中也存在争议:由于 reactive 本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用 ref,弃用 reactive

深层响应性

两者都支持深层响应,但内部实现略有不同:

const refObj = ref({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 深层属性也是响应式的
refObj.value.user.address.city = '上海' // 触发更新

const reactiveObj = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 同样是深层响应式
reactiveObj.user.address.city = '上海' // 触发更新

什么时候用 ref?

基础类型值

这是 ref 的主要应用场景,因为 reactive 根本不能处理基础类型:

const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')

需要整体替换的场景

当我们的数据状态需要整体重置或替换时,ref 是不二之选:

// 表单数据,经常需要重置
const formData = ref({
  username: '',
  email: '',
  password: ''
})

// 重置表单 - ref 轻松搞定
function resetForm() {
  formData.value = {
    username: '',
    email: '',
    password: ''
  }
}

// 更新整个表单 - 从 API 获取数据后整体替换
async function loadForm(id) {
  const data = await api.getForm(id)
  formData.value = data // ✅ 直接替换
}

当然,如果一定要用 reactive 呢?也是可以解决的,只是较为麻烦而已:

// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
  username: '',
  email: '',
  password: ''
})

function resetFormReactive() {
  // 需要逐个属性重置,或者使用 Object.assign
  Object.assign(formDataReactive, {
    username: '',
    email: '',
    password: ''
  })
}

从组合式函数返回时

当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:

export function useUser() {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchUser(id) {
    loading.value = true
    try {
      user.value = await api.getUser(id)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  // 返回 ref 对象,使用者可以随意解构
  return {
    user,
    loading,
    error,
    fetchUser
  }
}

// 在组件中使用
const { user, loading, fetchUser } = useUser()
// 解构后依然保持响应式
watch(user, () => {}) // ✅ 正常

跨组件传递时的类型安全

当通过 props 进行父子组件通信,传递响应式数据时,ref 的类型更清晰:

<!-- 父组件 -->
<script setup>
const userData = ref({ name: '张三', age: 18 })
</script>

<template>
  <ChildComponent :data="userData" />
</template>

<!-- 子组件 -->
<script setup>
// 明确知道接收的是一个 ref
const props = defineProps<{
  data: { name: string; age: number } // 注意:这是 Ref 的内部类型
}>()

// 使用 toValue 统一处理
const data = toValue(props.data) // toValue 可以处理 ref 和普通值
</script>

获取子组件实例

当父组件想要访问子组件的方法或数据时,可以直接使用 ref 获得子组件的实例,访问子组件通过 defineExpose 暴露的方法或数据: 子组件 Child.vue

<template>
  <div>子组件</div>
</template>

<script setup>
// 子组件的方法和数据
const childMethod = () => {
  console.log('子组件方法被调用')
}

// 需要暴露给父组件的属性和方法
defineExpose({
  childMethod,
  childData: '我是子组件的数据'
})
</script>

父组件 Parent.vue

<template>
  <!-- 子组件 -->
  <Child ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

// 创建一个ref来存储子组件实例
const childRef = ref(null)

// 调用子组件方法
const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()  // 调用子组件暴露的方法
    console.log(childRef.value.childData)  // 访问子组件暴露的数据
  }
}

// 在生命周期钩子中访问
import { onMounted } from 'vue'
onMounted(() => {
  console.log('子组件实例:', childRef.value)
})
</script>

什么时候用 reactive?

深层嵌套的对象

当数据结构复杂且嵌套层级较深时,reactive 的语法更简洁:

// 复杂的状态对象
const store = reactive({
  user: {
    profile: {
      personal: {
        name: '张三',
        age: 18
      },
      contact: {
        email: 'zhang@example.com',
        phone: '1234567890'
      }
    },
    preferences: {
      theme: 'dark',
      language: 'zh-CN',
      notifications: {
        email: true,
        sms: false
      }
    }
  },
  ui: {
    sidebar: {
      collapsed: false,
      width: 240
    },
    modal: {
      visible: false,
      type: null
    }
  }
})

// 访问深层属性 - reactive 很方便
store.user.profile.personal.name = '李四'
store.ui.sidebar.collapsed = true

// 如果用 ref,每次都要 .value,略显繁琐
const storeRef = ref({
  // 同样的数据结构
})
storeRef.value.user.profile.personal.name = '李四' // 多了 .value

不需要整体替换的数据

对于不需要整体替换的数据,比如配置数据等,只用初始化一次,后期只会更改属性,reactive 很合适:

const appConfig = reactive({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retryCount: 3,
  features: {
    logging: true,
    cache: false
  }
})

// 后续只修改属性
appConfig.timeout = 10000
appConfig.features.cache = true

性能敏感的场景

虽然差别很小,但理论上 reactiveref 少一层包装,性能略好:

// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count

// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count

// 在大量数据操作的场景下,reactive 可能稍有优势

注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。

为什么 reactive 解构后会失去响应性?

原因:解构破坏了 Proxy 的代理

要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:

const raw = { name: '张三', age: 18 }
const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取 ${key}`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置 ${key} = ${value}`)
    target[key] = value
    return true
  }
})

此时,我们对 proxy 解构 const { name } = proxy ,它都会发生哪些事呢?

  1. 读取 proxy.name ,此时会触发 get 拦截 -- 没有问题
  2. 将获取到的值 张三 赋值给 name 变量 -- 问题产生了
  3. name 被重新赋值为一个普通的字符串,和 proxy 没有任何关系了
  4. 后续对 name 的操作都只是修改一个普通变量,不会触发任何拦截

解决方案

方案一:使用 toRefs(推荐)

import { reactive, toRefs } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)

// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新

toRefs 的简化原理:

function toRefs(obj) {
  const result = {}
  // 遍历对象的所有key
  for (const key in obj) {
    result[key] = toRef(obj, key) // 为每个属性单独创建 ref
  }
  return result
}

// 创建的 ref 和原对象保持连接
const nameRef = toRef(user, 'name')
nameRef.value = '李四' // 等价于 user.name = '李四'

方案二:使用 toRef 处理单个属性

import { reactive, toRef } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// 只需要处理个别属性
const name = toRef(user, 'name')
const age = toRef(user, 'age')

name.value = '李四' // ✅ 触发更新

方案三:直接用 ref

如果发现需要频繁解构,可能在一开始就应该使用 ref

const user = ref({
  name: '张三',
  age: 18
})

选择决策树

基于以上分析,我们可以建立一套清晰的选择决策树:

快速选择指南

选择决策树

决策依据详解

场景 推荐方案 原因
基础数据类型 ref reactive 无法处理基础类型
需要整体重置的表单 ref 支持直接替换 .value
组合式函数返回值 ref 方便使用者解构
复杂嵌套对象 reactive 语法更简洁
一次性初始化配置 reactive 不需要整体替换
需要解构的场景 ref + toRefs 保持响应性

最终建议

  • 默认用 refref 更灵活,适用场景更广,虽然多了 .value,但换来的是确定性和可预测性
  • 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,reactive 能让代码更简洁
  • 要理解并善用工具函数toRefstoRefisRefisReactive
  • 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
  • 无法确定用哪个时:直接用 refref 是更安全、更通用的选择

结语

ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

前端架构演进与模块化设计实践

2026年3月5日 09:18

 引言:从"能运行"到"好维护"的转变

在快速迭代的业务需求面前,我们是否经常遇到这样的场景:新功能不敢轻易开发,因为担心影响现有业务;代码修改牵一发而动全身;不同业务模块间耦合严重,难以独立部署和测试。这些问题背后,反映的是前端架构设计的重要性。

1. 架构设计的核心目标

1.1 可持续性

  • 代码应易于理解和扩展
  • 新成员能够快速融入开发
  • 技术债务可控

1.2 可维护性

  • 模块职责清晰明确
  • 变更影响范围可控
  • 调试和定位问题高效

1.3 可测试性

  • 组件能够独立测试
  • 模拟各种业务场景
  • 自动化测试覆盖核心流程

2. 现代前端架构模式实践

2.1 分层架构设计

whiteboard_exported_image.png

实践案例:用户管理模块

// 表现层 - UserList.tsx
const UserList: React.FC = () => {
  const { users, loading, error } = useUserManagement();
  
  if (loading) return <Loading />;
  if (error) return <Error message={error} />;
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
};

// 业务层 - useUserManagement.ts
export const useUserManagement = () => {
  const [state, setState] = useState<UserState>(initialState);
  
  const fetchUsers = async () => {
    try {
      setState(prev => ({ ...prev, loading: true }));
      const users = await userAPI.fetchAll();
      setState(prev => ({ ...prev, users, loading: false }));
    } catch (error) {
      setState(prev => ({ ...prev, error: error.message, loading: false }));
    }
  };
  
  return { ...state, fetchUsers };
};

// 数据层 - userAPI.ts
export const userAPI = {
  fetchAll: async (): Promise<User[]> => {
    const response = await fetch('/api/users');
    return response.json();
  }
};

2.2 微前端架构实践

场景:大型管理平台,多个团队协作开发

解决方案:

// 主应用 - 路由配置
const routes = [
  {
    path: '/order/*',
    component: () => import('order-app/OrderModule'),
  },
  {
    path: '/user/*', 
    component: () => import('user-app/UserModule'),
  },
  {
    path: '/product/*',
    component: () => import('product-app/ProductModule'),
  }
];

// 模块联邦配置 - webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        orderApp: 'order@http://localhost:3001/remoteEntry.js',
        userApp: 'user@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

3. 模块化设计原则

3.1 单一职责原则

// ❌ 违反单一职责
class UserService {
  async getUser(id: string) { /* ... */ }
  validateEmail(email: string) { /* ... */ }
  sendEmail(content: string) { /* ... */ }
  formatUserData(user: User) { /* ... */ }
}

// ✅ 符合单一职责
class UserRepository {
  async getUser(id: string) { /* ... */ }
}

class ValidationService {
  validateEmail(email: string) { /* ... */ }
}

class EmailService {
  sendEmail(content: string) { /* ... */ }
}

class UserFormatter {
  formatUserData(user: User) { /* ... */ }
}

3.2 依赖倒置原则

// 定义抽象接口
interface UserStorage {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

// 具体实现
class LocalStorageUser implements UserStorage {
  async save(user: User) {
    localStorage.setItem(`user_${user.id}`, JSON.stringify(user));
  }
  
  async findById(id: string) {
    const data = localStorage.getItem(`user_${id}`);
    return data ? JSON.parse(data) : null;
  }
}

class APITUserStorage implements UserStorage {
  async save(user: User) {
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(user)
    });
  }
  
  async findById(id: string) {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  }
}

// 业务逻辑依赖于抽象,而非具体实现
class UserService {
  constructor(private storage: UserStorage) {}
  
  async updateUser(user: User) {
    // 业务逻辑
    await this.storage.save(user);
  }
}

4. 状态管理架构演进

4.1 状态分类与管理策略

// 1. 本地状态 - 使用 useState/useReducer
const [formData, setFormData] = useState(initialFormData);

// 2. 全局状态 - 使用 Zustand(推荐轻量级方案)
const useUserStore = create((set, get) => ({
  users: [],
  loading: false,
  fetchUsers: async () => {
    set({ loading: true });
    const users = await userAPI.fetchAll();
    set({ users, loading: false });
  },
  addUser: (user: User) => {
    set(state => ({ 
      users: [...state.users, user] 
    }));
  }
}));

// 3. 服务端状态 - 使用 React Query/SWR
const { data: users, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // 5分钟
});

4.2 状态规范化

//  嵌套深、难以更新的状态
const state = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: {
        id: 1,
        name: 'John',
        avatar: '...'
      },
      comments: [
        {
          id: 1,
          text: 'Great!',
          user: {
            id: 2,
            name: 'Alice'
          }
        }
      ]
    }
  ]
};

//  规范化状态
const normalizedState = {
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', author: 1, comments: [1] }
    },
    allIds: [1]
  },
  users: {
    byId: {
      1: { id: 1, name: 'John', avatar: '...' },
      2: { id: 2, name: 'Alice' }
    },
    allIds: [1, 2]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Great!', user: 2, post: 1 }
    },
    allIds: [1]
  }
};

5. 构建可测试的架构

5.1 依赖注入与测试

// 业务逻辑
class OrderService {
  constructor(
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService,
    private inventoryService: InventoryService
  ) {}
  
  async processOrder(order: Order) {
    // 1. 扣减库存
    await this.inventoryService.reserve(order.items);
    
    // 2. 处理支付
    const paymentResult = await this.paymentGateway.charge(order.total);
    
    // 3. 发送通知
    if (paymentResult.success) {
      await this.notificationService.sendOrderConfirmation(order);
    }
    
    return paymentResult;
  }
}

// 单元测试
describe('OrderService', () => {
  it('should process order successfully', async () => {
    // 准备测试替身
    const mockPaymentGateway = {
      charge: jest.fn().mockResolvedValue({ success: true })
    };
    const mockNotificationService = {
      sendOrderConfirmation: jest.fn().mockResolvedValue(undefined)
    };
    const mockInventoryService = {
      reserve: jest.fn().mockResolvedValue(undefined)
    };
    
    // 创建被测试实例
    const orderService = new OrderService(
      mockPaymentGateway,
      mockNotificationService,
      mockInventoryService
    );
    
    // 执行测试
    const result = await orderService.processOrder(testOrder);
    
    // 验证行为
    expect(result.success).toBe(true);
    expect(mockInventoryService.reserve).toHaveBeenCalledWith(testOrder.items);
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith(testOrder.total);
    expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(testOrder);
  });
});

6. 架构质量度量与改进

6.1 代码质量指标

// 使用 ESLint 插件监控架构质量
module.exports = {
  rules: {
    'max-dependencies': ['error', 10], // 单个模块最大依赖数
    'cyclic-dependency': 'error',       // 禁止循环依赖
    'no-relative-import': 'error',      // 禁止相对导入
    'feature-envy': 'error'             // 禁止特性依恋
  }
};

// package.json 依赖治理
{
  "scripts": {
    "analyze:deps": "madge --image deps-graph.svg src/",
    "analyze:complexity": "complexity-report src/",
    "check:circular": "dpdm --circular src/**/*.ts"
  }
}

7. 结语

好的前端架构不是一蹴而就的,而是随着业务发展和团队成长不断演进的过程。它需要在过度设计与缺乏设计之间找到平衡,在满足当前需求的同时为未来变化留出空间。

架构的终极目标不是构建完美的系统,而是创建能够优雅演进的系统。希望本文的实践经验能够为团队在架构设计方面提供有价值的参考,欢迎大家共同探讨和改进我们的前端架构实践。

8. 团队介绍

智慧家技术平台-应用软件框架开发」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。

Cursor 的 5 种指令方法比较,你最喜欢哪一种?

作者 Moment
2026年3月5日 08:56

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

在使用 Cursor 时,你会看到很多向 AI 发指令的方式:AGENTS.md.cursor/rules/.cursor/commands/.cursor/skills/.cursor/agents/(子代理)。每种方式适用场景不同,不少人会问:"该用哪一个?怎么区分?" 这篇文章把它们的用法和适用场景做个对比,并给出选择思路和组合建议,方便你按需搭配。

读完后你可以做到三件事:快速判断新需求该用哪种方式、避免把规则写错位置、用一套合理的目录结构起步。

为什么需要多种指令方式

当你希望 Cursor 用 Next.js、TypeScript 做 Web 应用时,往往会加上一堆约定,比如:

  • API 路由必须用 zod 做校验
  • 数据库访问用 Prisma 的类型安全写法
  • 错误统一成固定格式返回

这些规则在一次对话里 AI 还能记住,但新开一个会话又要重新说一遍。原因在于 LLM 本身的限制:

  • 无状态:不会跨会话记忆
  • Token 有限:上下文窗口有上限
  • 成本:上下文越长,调用成本越高

所以我们需要把"要说什么"沉淀成文件,并在合适的时机让 AI 读到。不同文件类型对应不同的触发时机和复用范围,这就是五种方式存在的理由。其中 Skills 专门针对"知识很多但不想一次全塞进上下文"的问题,用渐进式披露来平衡信息量和 Token。

渐进式披露(Progressive Disclosure)的意思是:需要的时候再把相关知识装进上下文,信息分阶段给出去,既够用又不占满上下文。Skills 的三级加载大致如下。

这部分原本用流程图展示三个级别之间的关系,下面是可以直接复制到文生图应用里的提示语。

20260305084854

  • 级别 1(元数据):所有已安装技能的 namedescription 会预先放进系统提示,让代理知道有哪些技能、大概干什么,从而判断和当前任务是否相关。
  • 级别 2(说明):一旦判定相关,再把该技能的 SKILL.md 全文载入,看清具体步骤和指示。
  • 级别 3(资源):若执行任务还需要更多信息,再按 SKILL.md 里的引用去打开其他文件、脚本或资料。

这样可以把大量信息收进技能里,又不会一次性占满 Token,这就是 Skills 的设计思路。

5 种方法概览

下面用几张表把五种方式的基本信息、适用时机和主要用途捋一遍。

基本信息

项目 AGENTS.md Rules Commands Skills Subagents
保存位置 项目根目录 .cursor/rules/ .cursor/commands/ .cursor/skills/ .cursor/agents/
文件格式 AGENTS.md .mdc.md .md 文件夹内 SKILL.md .md
运营重要性 推荐 强烈推荐 任意 推荐 任意
利用频率 较高 极高 中等 中等 较低

适用时机与上下文

项目 AGENTS.md Rules Commands Skills Subagents
适用时机 始终自动 条件、常时或手动 仅手动、通过命令名 AI 自动判断或手动、技能名 AI 自动判断或手动
上下文 与父共享 与父共享 与父共享 与父共享 独立上下文
并行执行 不可 不可 不可 不可 可(多子代理同时跑)

再利用性

项目 AGENTS.md Rules Commands Skills Subagents
再利用性 项目内(单体仓库可在子目录各配一份) 项目内 项目内 可跨项目(可全局配置) 可跨项目(可全局配置)

主要用途

项目 AGENTS.md Rules Commands Skills Subagents
主要目的 项目整体方针 对 AI 的持续指示 特定作业的一键启动 专业能力扩展 任务委托与并行
适合场景 简单、整体的指示 需要按条件生效的规则 人工触发的固定流程 通用领域知识 长时间调研、并行处理
示例 编码风格、架构原则 某目录下的规则 /code-review GraphQL 设计最佳实践 安全审查、验证作业

如何快速选:一张流程图

遇到新需求时,可以按一张决策流程图先判断用哪种方式,再去看对应章节的细节。下面是给文生图应用使用的提示语。

20260305084956

若一个需求同时符合多种方式(例如既有"始终生效的简略约定"又有"按目录生效的细则"),可以组合使用,例如根目录 AGENTS.md 写总纲,再用 Rules 针对 src/components/ 写组件规范。

AGENTS.md:项目的「README 式总纲」

AGENTS.md 是五种方式里最省事、门槛最低的一种,可以理解成「写给 Cursor 看的项目 README」。只要在项目根目录放一个 AGENTS.md,当你在这个项目里向 Cursor 提问时,它会自动把这里的内容当成项目的默认约定和背景信息。

在 Cursor 里的效果大致如下:

20260227142857

上面这张图对应的实际配置非常简单:我只是在项目根目录新建了一个 AGENTS.md,里面写清楚「开发或调试前要先跑哪几个命令」:

## 项目开发指令(AGENTS.md)

每次开发或调试前,请按顺序执行以下三个命令:

1. `pnpm dev`:启动开发服务器。
2. `pnpm build`:打包生产环境构建,确保构建无误。
3. `pnpm view react version`:查看当前 npm 注册表中的 React 最新版本号。

之后在这个项目里,只要我随手问一句「帮我优化一下这段代码」之类的问题,Cursor 在后台都会先读取这个 AGENTS.md,于是你在右侧看到的,就是「先按这三个步骤来」这种带项目语境的回答——相当于让 AGENTS.md 帮你把团队约定自动说了一遍。

特点:

  • 零配置:根目录放一个文件即可生效,不需要再写任何额外设置
  • 可读性好:纯 Markdown,适合写简洁、叙事化的项目方针和协作约定
  • 可分区:单体仓库可以在子目录各放一份(例如 frontend/AGENTS.mdbackend/AGENTS.md),分别约束前端、后端
  • 始终生效:对整个项目长期生效,适合放「总纲型」规则,而不是细碎的按场景说明
  • 易维护:内容有变更时,直接编辑本文件即可,无需动其他配置

下面是一个简化示例,用来说明"项目指令"长什么样、一般会写哪些内容:

# Project Instructions

## Code Style

- 使用 TypeScript
- 优先函数型组件

## Architecture

- 使用 Repository 模式
- 业务逻辑放在服务层而不是组件里

适合用 AGENTS.md 的情况:只需要一份项目级总纲,不强调按条件生效,也暂时不考虑跨项目复用,只想用最少配置写清楚项目整体约定和团队共识。一旦要"某些情况才生效"、"手动触发一套流程"或"多个项目共用同一套知识",就该考虑 Rules、Commands 或 Skills。

另外要注意:AGENTS.md 的内容会在每次相关对话里加载,写得太长会一直占用上下文。更细碎、按场景区分的规则,更适合拆到 Rules 或 Skills 里去。

规则(Rules):细一点、灵活一点的背景指令

当规则变多、需要按条件生效,或者想针对某些文件、目录单独约定时,就用 Rules。它可以按路径、glob 或"是否常驻"来选什么时候生效,还能在规则里用 @filename 引用具体文件。

特点:

  • 适用条件可配置:常时、特定文件、由 AI 判断或手动选
  • 支持在规则里引用代码,例如 @component-template.tsx
  • 多个规则文件放在 .cursor/rules/ 里,便于分类
  • 团队可在仪表板统一管理

Rules 的 frontmatter 里常用字段有:description(规则说明,供 AI 或人工识别)、globs(匹配哪些路径)、alwaysApply(是否常驻)。不写 globsalwaysApply: true 时,规则会在所有对话里生效。

例如给 React 组件单独定一套规则,只在编辑 src/components/ 下的 .tsx 时生效。在 .cursor/rules/react-components.mdc 里写 frontmatter 和正文。下面先给出 YAML 头,用于指定描述和匹配范围:

---
description: "React 组件开发的规则"
globs: ["src/components/**/*.tsx"]
alwaysApply: false
---

同一文件内,frontmatter 后面接 Markdown 正文,写具体规则内容并引用模板文件:

# React Component Rules

## 组件创建时

- 必须定义 Props 接口
- 避免默认 export
- 参考 @component-template.tsx

这样在编辑 src/components/ 下文件时,这条规则才会被应用。若希望某条规则在任意对话里都生效(例如全局编码风格),可设 alwaysApply: true 且不设 globs

命令(Commands):一键跑完的流程

Commands 用来把"一串固定操作"打包成一个命令,由你手动触发,比如跑测试、做 code review、建 PR。每个命令对应一个文件,通过 /命令名 调用。

特点:

  • 通过 / 即调即执行
  • 可以带参数,例如 /commit/pr for DX-523
  • 团队可在仪表板共享同一套命令
  • 把多步操作写在一个命令里,减少重复说明

例如 /code-review:在 .cursor/commands/code-review.md 里定义步骤,之后在聊天里输入 /code-review 就会按这些步骤执行。下面给出该文件的示例内容,用于说明"命令文件"如何写步骤:

# /code-review

## 步骤

1. 确认变更的文件
2. 检查安全问题
3. 确认是否符合编码规范
4. 列出 3 个改进提案

使用方式:在输入框输入 /code-review,必要时加一句说明,例如:"请审查这个 PR"。

Commands 适合:测试、代码审查、建 PR 这类需要人工点一下才跑的固定流程。不适合:希望自动生效、一直挂在上下文里、或由 AI 自己决定何时用的规则与知识,那些更适合 AGENTS.md、Rules 或 Skills。

和子代理的区别:Commands 是在当前对话里"插入一段预设步骤",上下文还是主对话;子代理是另开一个独立对话去执行,结果再回传,适合耗时长或需要并行的任务。

技能(Agent Skills):可复用的专业知识包

Agent Skills 把某一类专业知识打成一个个「能力模块」,用开放标准格式(含 SKILL.md 和 YAML 头)描述。AI 先只知道有哪些技能(name / description),真正需要时再把对应技能的全文与资源加载进来,这就是 Skills 的渐进式、模块化加载。

特点(和 Rules 的区别可以顺便看出来):

  • 按知识主题模块化:每个 Skill 代表一块专业能力(如组件命名规范、GraphQL 设计),可以在多个项目里复用
  • 按需加载:所有技能只先加载元数据,只有和当前任务相关的那几个才会把 SKILL.md 正文装进上下文,后续再按需加载引用资源
  • 规则 vs 技能:Rules 是按「当前在哪个文件 / 路径」选出要生效的规则,一旦命中就整条规则全文进上下文;Skills 则是先全局感知有哪些技能,再对当前任务只挑相关的那几块知识细化加载
  • 可组合脚本:Skill 里还可以挂脚本,把 LLM 不擅长的步骤交给代码执行

实现上,每个技能是一个文件夹,里面至少有一个 SKILL.md,并在文件开头用 YAML 定义 namedescription。Cursor 启动时会扫描技能目录(例如 .cursor/skills/),先把所有技能的元数据提供给代理,之后在具体任务里再决定要不要加载某个技能的正文。

下面直接用本文正在使用的真实示例来说明 Skill 的长相:我在这个项目里新建了 .cursor/skills/moment-component-prefix/SKILL.md,约定所有 React 组件的名字都以 Moment 开头(例如 MomentButtonMomentCard)。完整内容如下:

---
name: Moment 组件命名规范
description: 要求本项目中所有 React 组件名称都以 Moment 开头,例如 MomentButton、MomentCard。
---

# Moment 组件命名技能

## 何时使用

- 在创建新的 React 组件时
- 在重命名或抽取组件时

## 指示

- 所有导出的 React 组件名称必须以 `Moment` 开头,例如 `MomentHeader``MomentFooter`- 若根据文件名生成组件,组件名也应以 `Moment` 开头,例如文件 `header.tsx` 中的组件名为 `MomentHeader`- 执行重构时,如遇不符合该前缀的组件,应优先建议重命名为带 `Moment` 前缀的版本。

配置好这个 Skill 之后,当我在项目里请求「优化组件」「重构组件名」时,Cursor 就会自动参考这份规则,优先给出带 Moment 前缀的组件名称。下图展示的是这个 Skill 实际生效时的效果:

20260227144513

子代理(Subagents):只干一件事的副手

子代理可以理解成「由主代理派出的专职小助手」。每个子代理都有独立上下文,专注处理某一类任务,做完以后把结果打包交回主代理;主代理再根据这些结果继续和你对话或接着修改代码。

模块化的角度看,子代理不是按「路径」或「知识主题」来拆,而是按任务角色来拆:比如「验证助手」「安全审查助手」「UI 回归助手」等,让每个子代理只干一件事、长期保持同一种工作风格。

特点:

  • 上下文隔离:子代理有自己的对话历史,不会把长时间验证 / 调研的细节塞进你的主对话里
  • 可并行:可以同时开多个子代理,让它们各自跑不同任务,再统一收结果
  • 可定制:每个子代理可以配自己的提示词、工具组合和模型类型
  • 可复用:同一子代理配置可以在多个项目共享,比如统一的安全审查或验证流程

调度方式大致有两种:

模式 行为 适合场景
Foreground 主代理等子代理跑完再继续对话 需要按顺序拿到输出的任务(例如「先验证再继续开发」)
Background 立刻把控制权还给你,子代理在后台慢慢跑 耗时长或希望并行多任务时(例如「一边写代码,一边让子代理做全面安全审查」)

需要注意的是:子代理从「空上下文」开始,看不到主对话历史。所以主代理在派活时,必须把当前任务描述、涉及的文件、期望的检查点等一起打包进提示,否则子代理只好猜,很容易跑偏。

下面是本文当前项目里真实在用的一个「验证」子代理配置,文件路径是 .cursor/agents/verifier.md,主要负责在改动完成后帮忙做一次系统性的检查与建议:

---
name: verifier
description: 验证已完成的工作,检查实现是否按预期运作,并结合本项目约定给出测试与改进建议。
---

# Verifier 子代理

你是本项目的验证助手,专门在实现完成后进行检查与验证。你有独立上下文,不会看到主对话历史,所有必要信息会由主代理在派发任务时提供给你。

## 目标

- 确认实现是否满足用户需求与设计意图。
- 检查是否存在明显的类型错误、运行时错误或边界情况缺失。
- 结合本项目使用的工具链(pnpm、React、TypeScript)给出合理的测试与改进建议。

## 使用背景

- 本项目使用 pnpm 作为包管理工具。
- 运行开发与构建相关的典型命令包括但不限于:
  - `pnpm dev`
  - `pnpm build`
  - (如存在)`pnpm test``pnpm lint`

## 验证步骤

1. **理解需求与范围**
   - 阅读主代理提供的任务描述与变更说明。
   - 弄清楚本次改动的功能边界与非目标范围。

2. **审查代码与结构**
   - 聚焦主代理列出的关键文件和模块。
   - 检查是否遵循项目的技术栈约定(React + TypeScript 等)。
   - 粗略评估实现是否过于复杂,是否可以简化。

3. **测试与构建建议**
   - 若主代理提供了命令输出(如 `pnpm build``pnpm test`),分析其中的错误、警告与提示。
   - 若尚未执行相关命令,明确建议主代理或用户执行:
     - 至少运行 `pnpm build` 以确认生产构建是否通过。
     - 若项目配置了测试与 Lint,建议运行 `pnpm test``pnpm lint`4. **边界条件与错误处理检查**
   - 思考与本次改动相关的典型边界情况(空数据、错误响应、慢网路、异常输入等),检查代码中是否有所体现。
   - 指出潜在的未处理情况或容易出错的分支,并给出改进方向。

5. **输出结构化报告**
   - 列出:
     - ✅ 已验证通过的项目(功能点、测试或构建检查)。
     - ⚠️ 发现的问题(含严重程度说明)与改进建议。
     - ❓ 需要主代理或用户补充的信息(例如缺失的日志、命令输出、接口约定等)。
   - 报告尽量简洁、条理清晰,便于主代理直接据此继续修改或补充验证。

## 与主代理的协作约定

- 若信息不足以完成可靠验证,请明确说明缺口,并请求主代理补充必要上下文,而不是进行过度臆测。
- 在可能的情况下,优先给出可操作的、一步步的改进建议,而不是泛泛而谈。

实际使用时,你可以在主对话里完成一轮实现,然后让主代理把相关改动、运行命令和期望行为打包交给 verifier 子代理,让它在独立上下文里跑完整套验证,并把结果以结构化报告的形式带回主对话。这样既不污染主对话的上下文,又把「验证这件事」模块化成了一个可以在多个项目反复复用的助手。

实际使用示例:React 项目里的五种用法

同一个需求"在 React 项目里统一组件开发方式",可以分别用五种方式实现。下面按"从简到繁、从项目内到可复用"的顺序各给一个写法,便于对比同一诉求在不同方式下的形态。

方式 1:AGENTS.md(最简)

在项目根目录的 AGENTS.md 里写一段即可,适合小项目或只做简单约定时使用。

# Project Instructions

## React 组件开发

- 使用 TypeScript
- 优先函数型组件
- Props 必须进行类型定义

方式 2:Rules(按目录生效)

希望规则只在改 src/components/ 时生效,可以用 Rules。在 .cursor/rules/react-components.mdc 里写 frontmatter 和正文,例如引用模板文件:

---
description: "React 组件开发的规则"
globs: ["src/components/**/*.tsx"]
alwaysApply: false
---

# React 组件规则

## 组件创建时

- 必须定义 Props 接口
- 避免默认 export
- 参考 @component-template.tsx

方式 3:Commands(手动执行一套步骤)

把"创建组件"拆成固定步骤,做成命令 /create-component。在 .cursor/commands/create-component.md 里写:

# /create-component

# 步骤

1. 创建组件文件
2. 定义 Props 接口
3. 创建测试文件

使用:输入 /create-component Button,即可按步骤生成组件并带测试。

方式 4:Skills(可复用的最佳实践)

把 React 组件的最佳实践打成技能,AI 在写组件或做 code review 时会按需加载。例如 .cursor/skills/react-best-practices/SKILL.md

---
name: React Best Practices
description: React 组件开发的最佳实践。重视性能优化、重渲染防止、Hooks 的适当使用。
---

# React Best Practices 技能

## 何时使用

- 创建或修正 React 组件时
- 需要性能优化时
- 使用 Hooks 时
- 解决重渲染问题时

## 指示

### 组件设计

- 自定义 Hook 使用 `use` 前缀
- Props 接口必须进行类型定义
- 组件遵循单一责任原则

### 性能优化

- `useMemo``useCallback` 仅在必要时使用
- `useEffect` 的依赖数组必须明确指定
- 对于大型列表,考虑使用虚拟化

### 重渲染防止

- `memo` 仅在必要时使用(避免过度优化)
- Context 的值适当进行 Memoization
- 识别不必要重渲染的原因

适合:希望按"行业常见实践"自动参与编写和审查,且可能多个项目共用时。

方式 5:子代理(独立验证)

验证和测试单独交给子代理,不占用主对话上下文。例如 .cursor/agents/verifier-reviewer.md

---
name: verifier
description: 验证已完成的工作,确认实现是否正常运作,并执行测试
---

# Verifier 子代理

此子代理验证已完成的工作,确认实现是否正常运作,执行测试,并报告成功和未完成的部分。

## 验证步骤

1. 确认已实现的代码
2. 执行单元测试
3. 执行集成测试
4. 检查错误或警告
5. 报告结果

适合:做完一坨改动后,希望单独跑一轮验证或测试,而不把主对话拉得很长时。

小结:若只做"项目内、始终生效的简单约定"用方式 1;若希望"只在改组件目录时生效"用方式 2;若希望"人工点一下才按步骤生成组件"用方式 3;若希望"多项目共用、且 AI 写组件或审查时自动参考"用方式 4;若希望"验证和测试在独立上下文里跑"用方式 5。实际项目中往往组合使用,例如 1 + 2 + 4,或 1 + 3 + 5。

再举一个安全审查子代理的例子,放在 .cursor/agents/security-reviewer.md

---
name: security-reviewer
description: 检查代码中的注入、XSS、硬编码秘密等常见漏洞
---

# Security Reviewer 子代理

您是安全专家。执行代码的安全审查,识别潜在漏洞。

## 检查项目

1. SQL 注入
2. XSS(跨站脚本攻击)
3. 硬编码秘密
4. 认证和授权问题
5. 遵守安全的编码实践

从零开始的建议顺序

如果项目里还没用过这些方式,可以按这个顺序逐步加,避免一次堆太多导致维护成本高:

  1. 先写一个根目录的 AGENTS.md,把项目技术栈、编码风格、目录约定等总纲写清楚,控制在几十行以内。
  2. 再按目录或文件类型加 Rules,例如 src/components/ 用组件规则、src/api/ 用 API 规则,每条规则保持单一职责。
  3. 把经常重复的"多步操作"抽成 Commands,例如 /code-review/run-tests,方便团队统一流程。
  4. 若有跨项目共用的领域知识(如 GraphQL、无障碍、K8s),再做成 Skills,安装到 .cursor/skills/ 或全局技能目录。
  5. 子代理用在"需要独立上下文或并行"的场景即可,不必每个项目都配。

一个常用的项目配置结构

下面是一套常见的组合方式,按目录列出来,方便你直接套用或裁剪。如果希望用图来展示整个项目结构,可以使用下面这段提示语生成信息图。

手绘风格教育科普信息图海报,竖版 3:4 比例,白色或浅米色背景,彩色铅笔与素描线条质感,温暖柔和蓝黄粉绿橙配色,整体风格类似儿童编程科普图。顶部标题「一个典型 Cursor 项目的配置结构」,副标题「AGENTS、Rules、Commands、Skills、Subagents 分布一图看懂」。
画面中央是一棵抽象的项目目录「树」或卡片式结构:最上方是大文件夹「项目根目录」,内部画 `AGENTS.md` 文件卡片,旁边小标签「项目整体方针」。向下分出两条分支,分别是 `frontend/AGENTS.md` 和 `backend/AGENTS.md`,用不同颜色表示前端与后端,旁边写「前端指令」「后端指令」。
根目录下画 `.cursor` 大文件夹,内部再分为四个子文件夹:`rules`、`commands`、`skills`、`agents`,每个子文件夹用不同颜色和图标表示:
rules 区块下有 `api-design.mdc`、`database-schema.mdc`、`deployment-flow.mdc` 三个文件卡片,配注释「API 设计规则」「数据库设计规则」「部署流程」;
commands 区块下有 `code-review.md`、`create-pr.md`、`run-tests.md` 三个文件卡片,标注「代码审查命令」「创建 PR」「运行测试」;
skills 区块下有 `react-best-practices/` 文件夹和若干 `SKILL.md` 文件,如 `graphql-best-practices/SKILL.md`、`kubernetes-ops/SKILL.md`、`accessibility/SKILL.md`,配小图标代表前端、后端与运维知识,旁边写「可复用技能包」;
agents 区块下有 `verifier.md` 和 `security-reviewer.md` 两个文件卡片,配放大镜与盾牌图标,分别标注「验证子代理」「安全审查子代理」。
用细虚线或颜色分区轻轻框出「项目总纲(AGENTS)」「项目内规则(Rules)」「一键流程(Commands)」「领域技能(Skills)」「子代理(Agents)」五块区域,每块区域顶部有小标题和简短说明,整体布局紧凑清晰,方便一眼看懂各类文件放在哪里、负责什么。

常见误区和组合建议

  • 把本该"按条件生效"的细则写进 AGENTS.md,导致上下文总是很长。这类内容更适合放到 Rules(按路径)或 Skills(按任务类型)。
  • 把"希望 AI 自动用到"的领域知识只写在 Commands 里。Commands 只有你输入 /xxx 时才会执行,不会自动参与编写或审查,这类知识应放在 Rules(项目内)或 Skills(可复用)。
  • 同一件事既写 Rules 又写 Skills,内容重复且可能冲突。约定好边界:和本项目、本目录强相关的用 Rules,通用且要复用的用 Skills。
  • 子代理的提示里没带够上下文。子代理看不到主对话,主代理在派活时必须把"当前改了哪些文件、期望验证什么"等写进提示,否则子代理容易做无用功。

组合建议:多数项目用"根目录 AGENTS.md + 若干 Rules"就能覆盖大部分需求;Commands 按团队实际工作流补几条即可;Skills 和 Subagents 按是否有跨项目知识、是否有并行或独立验证需求再加。这样既不会漏掉该用的方式,也不会堆得难以维护。

总结一下选择思路:要"项目总纲"用 AGENTS.md;要"按文件或条件生效"用 Rules;要"人工一键跑流程"用 Commands;要"可复用的领域知识"用 Skills;要"独立上下文、并行或专门验证"用 Subagents。按需求组合这五样,就能把 Cursor 的指令体系用得比较顺手。

Tauri 的 Capabilities 权限管理系统

作者 HelloReader
2026年3月6日 13:49

一、为什么需要 Capabilities?

Tauri 应用的前端运行在系统 WebView 中,而后端则是 Rust 编写的原生代码。前端通过 Tauri 提供的 API 与后端通信,从而访问文件系统、窗口管理、系统托盘等原生能力。

问题在于:如果前端代码被攻破(比如 XSS 攻击),攻击者就可能利用这些 API 对用户系统造成危害。Capabilities 系统正是为了应对这类场景而设计的——它让开发者可以精确控制每个窗口或 WebView 能使用哪些权限,将"最小权限原则"落到实处。

二、核心概念

Capabilities 本质上是一组声明式的权限配置,用来定义哪些窗口(window)或 WebView 被授予或拒绝了哪些权限。几个关键特性值得注意:

  • 一个 Capability 可以同时作用于多个窗口或 WebView。
  • 一个窗口也可以被多个 Capability 引用。当窗口属于多个 Capability 时,所有相关 Capability 的权限会合并生效——这意味着安全边界会扩大,配置时需要格外小心。

三、配置方式

Capability 文件以 JSON 或 TOML 格式存放在 src-tauri/capabilities 目录下。Tauri 提供了两种主要的配置方式。

方式一:独立文件 + 引用标识符

这是推荐的做法。在 capabilities 目录下定义独立的 Capability 文件,然后在 tauri.conf.json 中通过标识符引用它们。

首先,定义一个 Capability 文件:

// src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:path:default",
    "core:event:default",
    "core:window:default",
    "core:app:default",
    "core:resources:default",
    "core:menu:default",
    "core:tray:default",
    "core:window:allow-set-title"
  ]
}

然后在配置文件中引用:

// src-tauri/tauri.conf.json
{
  "app": {
    "security": {
      "capabilities": ["my-capability", "main-capability"]
    }
  }
}

这种方式的好处是保持 tauri.conf.json 的简洁,同时让权限配置模块化、易于维护。

方式二:内联定义

对于简单场景,也可以直接在 tauri.conf.json 中内联定义 Capability,甚至将内联定义和引用混合使用:

{
  "app": {
    "security": {
      "capabilities": [
        {
          "identifier": "my-capability",
          "description": "My application capability used for all windows",
          "windows": ["*"],
          "permissions": ["fs:default", "allow-home-read-extended"]
        },
        "my-second-capability"
      ]
    }
  }
}

需要注意的是,capabilities 目录下的所有 Capability 文件默认自动启用。但一旦在 tauri.conf.json 中显式指定了 Capability,就只有被指定的那些会生效。

四、自定义命令的权限控制

默认情况下,通过 tauri::Builder::invoke_handler 注册的所有命令对所有窗口开放。如果你希望更精细地控制,可以在 build.rs 中使用 AppManifest::commands 来声明:

// src-tauri/build.rs
fn main() {
    tauri_build::try_build(
        tauri_build::Attributes::new()
            .app_manifest(
                tauri_build::AppManifest::new()
                    .commands(&["your_command"])
            ),
    )
    .unwrap();
}

五、平台特定配置

Capabilities 支持通过 platforms 字段限定作用的目标平台。可选值包括 linuxmacOSwindowsiOSandroid

一个面向桌面端的配置示例:

// src-tauri/capabilities/desktop.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "desktop-capability",
  "windows": ["main"],
  "platforms": ["linux", "macOS", "windows"],
  "permissions": ["global-shortcut:allow-register"]
}

以及面向移动端的配置:

// src-tauri/capabilities/mobile.json
{
  "$schema": "../gen/schemas/mobile-schema.json",
  "identifier": "mobile-capability",
  "windows": ["main"],
  "platforms": ["iOS", "android"],
  "permissions": [
    "nfc:allow-scan",
    "biometric:allow-authenticate",
    "barcode-scanner:allow-scan"
  ]
}

这种设计让你可以为不同平台启用不同的插件能力,同时避免在不支持某些硬件的平台上引入无意义的权限。

六、远程 API 访问

默认情况下,Tauri API 只对随应用打包的本地代码开放。但在某些场景下,你可能需要让远程加载的页面也能调用部分 Tauri 命令。这可以通过 remote 配置实现:

// src-tauri/capabilities/remote-tags.json
{
  "$schema": "../gen/schemas/remote-schema.json",
  "identifier": "remote-tag-capability",
  "windows": ["main"],
  "remote": {
    "urls": ["https://*.tauri.app"]
  },
  "platforms": ["iOS", "android"],
  "permissions": ["nfc:allow-scan", "barcode-scanner:allow-scan"]
}

这里有一个重要的安全提示:在 Linux 和 Android 上,Tauri 无法区分来自嵌入式 <iframe> 的请求和窗口本身的请求。因此在使用远程 API 访问功能时,务必仔细评估安全影响。

七、安全边界:能做什么,不能做什么

理解 Capabilities 系统的安全边界至关重要。

它能防护的场景包括:最小化前端被攻破后的影响、防止或减少本地系统接口和数据的意外暴露、防止从前端到后端/系统的权限提升。

它无法防护的场景包括:恶意或不安全的 Rust 后端代码、过于宽松的 scope 配置、命令实现中未正确检查 scope、来自 Rust 代码的故意绕过、系统 WebView 的零日漏洞、供应链攻击或开发者环境被入侵。

另外,安全边界依赖于窗口的 label(标签),而非 title(标题)。建议只对高权限窗口开放窗口创建功能。

八、Schema 文件与 IDE 支持

Tauri 通过 tauri-build 自动生成 JSON Schema 文件,其中包含了应用可用的所有权限定义。在 Capability 配置文件中设置 $schema 属性后,你的 IDE 就能提供自动补全,大幅提升开发体验:

{
  "$schema": "../gen/schemas/desktop-schema.json"
}

Schema 文件位于 gen/schemas 目录下,通常使用 desktop-schema.jsonmobile-schema.json,也可以为特定平台定义专属的 Schema。

九、项目结构概览

一个典型的 Tauri 应用目录结构如下:

tauri-app
├── index.html
├── package.json
├── src/
├── src-tauri/
│   ├── Cargo.toml
│   ├── capabilities/
│   │   └── <identifier>.json/toml
│   ├── src/
│   └── tauri.conf.json

capabilities 目录存放所有的权限配置文件,每个文件以其 identifier 命名,职责清晰,便于团队协作和代码审查。

十、最佳实践总结

在实际项目中使用 Capabilities 系统时,有几条经验值得参考。首先,遵循最小权限原则,只为每个窗口授予它实际需要的权限。其次,善用独立文件管理——将 Capability 定义为独立文件,通过标识符引用,保持配置清晰。第三,谨慎处理多 Capability 窗口,因为权限会合并,可能意外扩大安全边界。第四,利用平台特定配置,避免在不适用的平台上暴露无意义的权限。最后,对远程 API 访问保持警惕,仔细评估安全影响,尤其是在 Linux 和 Android 上。

Tauri 的 Capabilities 系统体现了"安全默认"的设计哲学——默认情况下,前端的能力是受限的,开发者需要显式地授予权限。这种设计虽然增加了一些配置工作,但换来的是更可控、更安全的应用架构。对于任何关注用户安全的桌面/移动应用项目来说,花时间理解和正确配置这套系统,都是值得的。

关于我明明用了ref还是陷入React闭包陷阱

作者 喵爱吃鱼
2026年3月6日 12:48
const appendChildren = (
  nodes: DataNode[],
  targetKey: React.Key,
  children: DataNode[],
): DataNode[] =>
  nodes.map((node) => {
    if (node.key === targetKey) {
      return {
        ...node,
        children,
      };
    }
    if (node.children && node.children.length) {
      return {
        ...node,
        children: appendChildren(node.children, targetKey, children),
      };
    }
    return node;
  });


export const DepartmentTree = forwardRef<
  DepartmentTreeRef,
  DepartmentTreeProps
>(function DepartmentTree(props, ref) {
  const treeDataRef = useRef<DataNode[]>([]);
  treeDataRef.current = treeData;

  // useEffect(() => {
  //   treeDataRef.current = treeData;
  // }, [treeData]);

  useEffect(() => {
    let shouldIgnore = false;

    const loadRootData = async () => {
      try {
        const rootRequest = newRequest({
          cache: true,
          ...(searchParams ?? {}),
        });
        const rootRes: any = await rootRequest;
        if (shouldIgnore) return;
        const finalTreeData = normalizeNodes(rootRes?.data?.data || []);
        setTreeData(finalTreeData);
      } catch (error) {
        console.warn( error);
      }
    };

    loadRootData();

    return () => {
      shouldIgnore = true;
    };
  }, [searchParams, getAllRootDeptFullPaths]);

  // 异步加载子节点
  const onLoadData: TreeProps['loadData'] = ({ key, children }) =>
    new Promise<void>((resolve) => {
      if (!key || (children && children.length > 0)) {
        resolve();
        return;
      }
      const newSearchParams = _cloneDeep(searchParams ?? {});
      _set(newSearchParams, 'data.code', key);
      newRequest({ cache: true, ...newSearchParams })
        .then((res: any) => {
          const childrenNodes = normalizeNodes(res?.data?.data || []);

          setTreeData((prev) => {
            const newData = appendChildren(prev, key, childrenNodes);
            // 关键
            treeDataRef.current = newData;
            return newData;
          });
        })
        .catch((error) => {
          console.warn( error);
        })
        .finally(() => resolve());
    });
    
  const onExpand = (nextExpandedKeys: React.Key[]) => {
    setExpandedKeys(nextExpandedKeys);
    setAutoExpandParent(false);
  };

  const findNodeByKey = (
    nodes: DataNode[],
    targetKey: React.Key,
  ): DataNode | null => {
    for (const node of nodes) {
      // 如果当前节点的ID就是我们要找的,直接返回这个节点
      if (node.key === targetKey) return node;
      // 如果当前节点有子节点,就递归查找子节点
      if (node.children && node.children.length) {
        const child = findNodeByKey(node.children as DataNode[], targetKey);
        if (child) return child;
      }
    }
    return null;
  };

  const expandPath = useCallback(
    async (pathKeys: string[]) => {
      // 遍历路径中的所有节点ID(除了最后一个,因为最后一个不需要展开)
      for (const key of pathKeys.slice(0, -1)) {
        const current = findNodeByKey(treeDataRef.current, key);
        if (!current) continue;
        // 根据pathKeys顺序,一层层加载子节点数据
        if (!current.children || current.children.length === 0) {
          await onLoadData({ key, children: current.children } as any);
        }
      }
      setExpandedKeys(pathKeys.slice(0, -1));
      setAutoExpandParent(true);
    },
    [onLoadData],
  );

  const handleSearchSelect = useCallback(
    async (value: string, option: any) => {
      const targetKey = option?.deptId ?? option?.value ?? value;
      const path = String(option?.deptFullPath ?? '')
        .split('/')
        .filter(Boolean);

      if (path.length > 0) {
        await expandPath(path);
      }

      setSelectedKeys([targetKey]);
      setSearchValue(value);
    },
    [onSelect, expandPath],
  );

  return (
    <Tree
        loadData={onLoadData}
        treeData={displayTreeData}
        blockNode
        onSelect={handleTreeSelect}
        onExpand={onExpand}
        expandedKeys={expandedKeys}
        autoExpandParent={autoExpandParent}
        selectedKeys={selectedKeys}
     />
  );
});

这是一个非常经典且高阶的 “React 渲染周期 vs JavaScript 执行顺序” 的问题。

简单来说:JavaScript 的代码执行速度(微任务队列)快于 React 的重新渲染速度。

仅仅在组件主体(或 useEffect)中写 treeDataRef.current = treeData 是不够的,因为这两者之间存在一个致命的时间差

下面拆解这个“时间差”到底发生在哪里:

1. 场景重现:如果没有手动赋值

假设 expandPath 正在循环处理 ['A', 'B'] 两个节点(先加载 A,再加载 B)。

第一步:加载节点 A

  1. expandPath 循环开始,找到节点 A。
  2. 调用 await onLoadData(A)
  3. 请求回来,数据有了。
  4. 执行 setTreeData(newData)
    • 注意: 此时 React 仅仅是**“收到了更新通知”**,它把这次更新放入了队列,准备稍后重新渲染组件。
    • 关键点: 组件还没有重新渲染!所以组件最上面的 treeDataRef.current = treeData 这行代码还没有运行!Ref 里存的还是树。
  5. onLoadData 的 Promise 结束(resolve)。

第二步:灾难发生(加载节点 B)

  1. await 结束,JS 引擎立刻继续执行下一行代码(for 循环进入下一次迭代)。
  2. 循环尝试处理节点 B。
  3. 执行 const current = findNodeByKey(treeDataRef.current, 'B')
  4. 问题来了: 此时 React 还没有来得及执行下一次渲染。treeDataRef.current 依然是的(里面还没有 A 的子节点)。
  5. 结果:找不到节点 B,逻辑中断,展开失败。

第三步:马后炮(React 终于渲染了)

  1. JS 的同步代码和微任务跑完后,React 终于开始 Re-render。
  2. 组件函数重新运行。
  3. 执行 treeDataRef.current = treeData(这时候 Ref 才变成新的)。
  4. 但此时 expandPath 的循环早就跑完了,黄花菜都凉了。

2. 为什么手动赋值能解决?

当写成这样时:

setTreeData((prev) => {
  const newData = appendChildren(prev, key, childrenNodes);
  
  // 【关键操作】强行插队
  // 不等 React 渲染,直接在 JS 层面立刻更新 Ref
  treeDataRef.current = newData; 
  
  return newData;
});

流程变成了:

  1. expandPath 调用 onLoadData
  2. 请求回来,执行 setTreeData 的回调。
  3. 立刻更新 treeDataRef.current = newData(这是同步的 JS 操作,不需要等 React 渲染)。
  4. onLoadData Promise 结束。
  5. expandPath 继续循环。
  6. 循环取 treeDataRef.current,此时它已经是最新的了(因为我们在第3步强行更新了它)。
  7. 成功找到下一个节点。

总结

  • treeDataRef.current = treeData (写在组件体内):依赖于 React 的渲染周期。只有等 React 画完下一帧,Ref 才会变。
  • expandPath 中的 await 循环:依赖于 JS 的 Promise 队列。它的速度极快,不会等待 React 的渲染。

结论: 因为 for 循环跑得太快,不等 React 渲染就要用新数据,所以必须在数据产生的源头(setTreeData 回调里)手动、同步地把最新数据塞进 Ref 里,才能追上循环的脚步。

❌
❌