Pinia 深度使用实战
Vue 3 官方状态管理方案:从 Setup Store 到插件体系、持久化、权限联动与中后台工程化落地。
目录
- [导读:Pinia 在架构中的位置](#导读:Pinia 在架构中的位置)
- [Setup Store vs Option Store](#Setup Store vs Option Store)
- [TypeScript 类型设计](#TypeScript 类型设计)
- [状态分层:什么该进 Pinia](#状态分层:什么该进 Pinia)
- 模块化目录与命名约定
- [Store 组合与跨 Store 协作](#Store 组合与跨 Store 协作)
- [补丁、重置与订阅 API](#补丁、重置与订阅 API)
- 持久化:手写与插件
- [插件体系:日志、重置、多 Tab 同步](#插件体系:日志、重置、多 Tab 同步)
- 性能:细粒度订阅与反模式
- [与 Router、HTTP、权限联动](#与 Router、HTTP、权限联动)
- 中后台实战场景
- [SSR / Nuxt 注意事项](#SSR / Nuxt 注意事项)
- [测试 Pinia Store](#测试 Pinia Store)
- 踩坑清单与最佳实践
一、导读:Pinia 在架构中的位置
1.1 与 Vuex、Composables 的关系
| 方案 | 适用场景 | 特点 |
|---|---|---|
组件内 ref / computed |
仅本组件使用的 UI 状态 | 最简单,无共享成本 |
| Composables | 可复用逻辑、轻量共享 | 无 DevTools 时间旅行、无统一持久化入口 |
| Pinia | 跨路由/跨模块的全局业务状态 | 类型友好、模块化、插件、DevTools |
| 服务端状态库(TanStack Query 等) | 接口缓存、失效、重试 | 与 Pinia 互补,勿把 API 缓存全塞进 Store |
前置阅读 :
23-Vue 高阶技巧实战.md第八章已有 Pinia 入门片段;本文在其基础上做体系化深化。
1.2 Pinia 核心心智模型
text
defineStore(id, setup | options)
│
├─ state → 响应式数据(ref / reactive)
├─ getters → 派生状态(computed)
├─ actions → 同步/异步修改(函数)
└─ 插件 → $subscribe / $onAction / 持久化 / 日志
学习目标:能设计可维护的 Store 分层;掌握 Setup Store + TypeScript;用插件解决持久化与审计;避免「全量解构导致失去响应式」等常见坑。
二、Setup Store vs Option Store
2.1 Setup Store(推荐)
Setup Store 写法与 Composition API 一致,灵活度最高,适合复杂异步与组合逻辑。
typescript
// stores/user.ts
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import type { UserInfo, LoginForm } from '@/types/user';
import * as userApi from '@/api/user';
export const useUserStore = defineStore('user', () => {
const token = ref('');
const userInfo = ref<UserInfo | null>(null);
const loading = ref(false);
const isLoggedIn = computed(() => !!token.value);
const displayName = computed(
() => userInfo.value?.nickname ?? userInfo.value?.username ?? '访客'
);
async function login(form: LoginForm) {
loading.value = true;
try {
const { token: t } = await userApi.login(form);
token.value = t;
await fetchProfile();
} finally {
loading.value = false;
}
}
async function fetchProfile() {
if (!token.value) return;
userInfo.value = await userApi.getProfile();
}
function $reset() {
token.value = '';
userInfo.value = null;
loading.value = false;
}
return {
token,
userInfo,
loading,
isLoggedIn,
displayName,
login,
fetchProfile,
$reset
};
});
2.2 Option Store
Option Store 类似 Vuex,适合从 Vuex 迁移或团队更熟悉「对象式」写法。
typescript
// stores/dict.ts
import { defineStore } from 'pinia';
import { fetchDictBatch } from '@/api/dict';
interface DictState {
cache: Record<string, Record<string, string>>;
loadingKeys: Set<string>;
}
export const useDictStore = defineStore('dict', {
state: (): DictState => ({
cache: {},
loadingKeys: new Set()
}),
getters: {
label: (state) => (type: string, value: string) => {
return state.cache[type]?.[value] ?? value;
},
isLoaded: (state) => (type: string) => type in state.cache
},
actions: {
async load(types: string[]) {
const missing = types.filter((t) => !this.isLoaded(t));
if (!missing.length) return;
missing.forEach((t) => this.loadingKeys.add(t));
try {
const batch = await fetchDictBatch(missing);
this.cache = { ...this.cache, ...batch };
} finally {
missing.forEach((t) => this.loadingKeys.delete(t));
}
}
}
});
2.3 选型建议
| 维度 | Setup Store | Option Store |
|---|---|---|
| 与 Composables 复用 | 直接 import 组合 |
需在 actions 里调用 |
$reset |
需手写 $reset 函数 |
内置(基于初始 state) |
| 复杂泛型 | 更自然 | 略繁琐 |
| 团队习惯 | Vue 3 新项目首选 | 迁移 Vuex 时可用 |
三、TypeScript 类型设计
3.1 导出 Store 类型供组件与测试使用
typescript
// stores/order.ts
export const useOrderStore = defineStore('order', () => {
const list = ref<Order[]>([]);
const filters = ref<OrderFilters>({ status: 'all', keyword: '' });
// ...
return { list, filters /* ... */ };
});
// 从 Store 实例推导类型(无需手写 interface 重复维护)
export type OrderStore = ReturnType<typeof useOrderStore>;
3.2 在组件中保持类型安全
vue
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useOrderStore } from '@/stores/order';
const orderStore = useOrderStore();
const { list, filters } = storeToRefs(orderStore);
// ✅ 直接调用 action,不丢类型
function onSearch() {
orderStore.fetchList();
}
</script>
3.3 为插件扩展 Store 自定义属性
typescript
// types/pinia.d.ts
import 'pinia';
declare module 'pinia' {
export interface PiniaCustomProperties {
$api: typeof import('@/api');
$router: import('vue-router').Router;
}
}
// main.ts --- 注入到每个 store
import { createPinia } from 'pinia';
import router from './router';
import api from './api';
const pinia = createPinia();
pinia.use(({ store }) => {
store.$api = api;
store.$router = router;
});
四、状态分层:什么该进 Pinia
4.1 推荐放入 Pinia 的状态
- 登录态、用户信息、权限码、动态路由元数据
- 全局 UI:侧边栏折叠、主题、语言、多标签页(Tabs)
- 跨页面共享的业务草稿、购物车式选中集合
- 字典/枚举缓存(读多写少)
4.2 不建议放入 Pinia 的状态
typescript
// ❌ 反模式:把接口列表缓存当唯一数据源,却手写 loading/error/分页
const list = ref([]);
const loading = ref(false);
async function fetchList() { /* 与组件、其他页面重复 */ }
// ✅ 更优:服务端状态交给 Query,Pinia 只保留「筛选条件」等 UI 意图
// composables/useOrderListQuery.ts + store 仅存 filters
4.3 分层示意
text
┌─────────────────────────────────────────┐
│ 组件 / 页面 │
├─────────────────────────────────────────┤
│ Composables(封装 Query、表单逻辑) │
├─────────────────────────────────────────┤
│ Pinia(全局业务态、权限、Tabs、字典) │
├─────────────────────────────────────────┤
│ API 层 / TanStack Query(请求与缓存) │
└─────────────────────────────────────────┘
五、模块化目录与命名约定
5.1 推荐目录结构(中后台)
text
src/stores/
├── index.ts # 可选:统一导出
├── plugins/
│ ├── persist.ts
│ ├── logger.ts
│ └── tabSync.ts
├── modules/
│ ├── user.ts
│ ├── permission.ts
│ ├── app.ts # 布局、主题
│ ├── tabs.ts # 多标签
│ └── dict.ts
└── composables/
└── useAppInit.ts # 聚合初始化,不是 store
5.2 命名约定
| 项目 | 约定 |
|---|---|
| Store id | 短且唯一:'user'、'permission' |
| 导出函数 | useXxxStore(与 React 习惯对齐,便于搜索) |
| 文件 | 单 Store 单文件;超大 Store 按 feature 拆分 |
5.3 按功能域拆分,避免「上帝 Store」
typescript
// ❌ useAppStore 里塞 2000 行:用户 + 菜单 + 字典 + 通知
// ✅ 各司其职,在 composable 里组合
// composables/useAppBootstrap.ts
export async function bootstrapApp() {
const user = useUserStore();
const permission = usePermissionStore();
const dict = useDictStore();
if (user.token) {
await Promise.all([
user.fetchProfile(),
permission.loadMenus(),
dict.load(['orderStatus', 'payType'])
]);
}
}
六、Store 组合与跨 Store 协作
6.1 在 action 内使用其他 Store
typescript
// stores/cart.ts
import { useUserStore } from './user';
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);
async function checkout() {
const user = useUserStore();
if (!user.isLoggedIn) {
user.$router?.push('/login');
return;
}
await api.checkout({ items: items.value, userId: user.userInfo!.id });
items.value = [];
}
return { items, checkout };
});
注意 :避免在 Store 初始化顶层 循环引用(A 引 B、B 引 A)。若必须双向依赖,将共享逻辑抽到
utils或第三个 Store。
6.2 Facade:对外暴露统一入口
typescript
// composables/useAppStore.ts --- 注意:这是 composable,不是 defineStore
import { useUserStore } from '@/stores/user';
import { usePermissionStore } from '@/stores/permission';
export function useAppFacade() {
const user = useUserStore();
const permission = usePermissionStore();
const can = (code: string) => {
if (!user.isLoggedIn) return false;
return permission.codes.includes(code);
};
async function logout() {
await api.logout();
user.$reset();
permission.$reset();
}
return { user, permission, can, logout };
}
七、补丁、重置与订阅 API
7.1 $patch:批量更新
typescript
const orderStore = useOrderStore();
// 对象式 patch
orderStore.$patch({
filters: { status: 'paid', keyword: '' },
page: 1
});
// 函数式 patch(依赖旧状态)
orderStore.$patch((state) => {
state.list.push(newOrder);
});
7.2 $reset 与 Option Store
Option Store 自带 $reset()。Setup Store 须在 return 中显式实现 $reset(见 2.1)。
7.3 $subscribe:监听状态变化
typescript
// 在 store 定义外(如 main.ts 或插件内)
const userStore = useUserStore();
const stop = userStore.$subscribe(
(mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
// mutation.storeId, mutation.events (pinia 2.1+ 批量变更)
console.log('user changed', mutation.type, state.token);
},
{ detached: true } // 组件卸载后仍监听(全局副作用时用)
);
// 不再需要时
stop();
7.4 $onAction:监听 action 生命周期
typescript
userStore.$onAction(({ name, args, after, onError }) => {
const start = performance.now();
after(() => {
console.log(`${name} took ${performance.now() - start}ms`);
});
onError((err) => {
reportError({ store: 'user', action: name, err });
});
});
八、持久化:手写与插件
8.1 手写持久化(细粒度控制)
typescript
// stores/user.ts 片段
import { watch } from 'vue';
const TOKEN_KEY = 'access_token';
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '');
watch(
token,
(val) => {
if (val) localStorage.setItem(TOKEN_KEY, val);
else localStorage.removeItem(TOKEN_KEY);
},
{ flush: 'post' }
);
return { token /* ... */ };
});
8.2 通用持久化插件
typescript
// stores/plugins/persist.ts
import type { PiniaPluginContext } from 'pinia';
import { watch, toRef } from 'vue';
type PersistOptions = {
key?: string;
paths?: string[];
storage?: Storage;
};
export function persistPlugin(options: PersistOptions = {}) {
const storage = options.storage ?? localStorage;
return ({ store }: PiniaPluginContext) => {
const key = options.key ?? store.$id;
const saved = storage.getItem(key);
if (saved) {
try {
store.$patch(JSON.parse(saved));
} catch {
storage.removeItem(key);
}
}
store.$subscribe(
() => {
const state = options.paths
? options.paths.reduce<Record<string, unknown>>((acc, path) => {
acc[path] = (store as any)[path];
return acc;
}, {})
: store.$state;
storage.setItem(key, JSON.stringify(state));
},
{ detached: true }
);
};
}
// main.ts
pinia.use(
persistPlugin({
key: 'app-settings',
paths: ['theme', 'sidebarCollapsed'],
storage: localStorage
})
);
8.3 使用 pinia-plugin-persistedstate(生产推荐)
bash
npm i pinia-plugin-persistedstate
typescript
// main.ts
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// stores/app.ts
export const useAppStore = defineStore('app', () => {
const theme = ref<'light' | 'dark'>('light');
return { theme };
}, {
persist: {
key: 'app',
paths: ['theme'],
storage: localStorage
}
});
安全提示 :勿将 refresh token、敏感权限明文长期放在
localStorage;敏感态优先httpOnlyCookie + 内存 Store。
九、插件体系:日志、重置、多 Tab 同步
9.1 开发环境 Action 日志插件
typescript
// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia';
export function createLoggerPlugin(enabled = import.meta.env.DEV) {
return ({ store }: PiniaPluginContext) => {
if (!enabled) return;
store.$onAction(({ name, args, after, onError }) => {
const tag = `[pinia:${store.$id}/${name}]`;
console.groupCollapsed(tag, args);
after((result) => {
console.log('→', result);
console.groupEnd();
});
onError((err) => {
console.error(err);
console.groupEnd();
});
});
};
}
9.2 多 Tab 登录态同步
typescript
// stores/plugins/tabSync.ts
export function tabSyncPlugin(keys: string[] = ['token']) {
return ({ store }: PiniaPluginContext) => {
window.addEventListener('storage', (e) => {
if (!e.key?.startsWith('pinia-')) return;
if (e.newValue) {
try {
const partial = JSON.parse(e.newValue);
store.$patch(partial);
} catch { /* ignore */ }
}
});
store.$subscribe(() => {
const payload = keys.reduce<Record<string, unknown>>((o, k) => {
o[k] = (store as any)[k];
return o;
}, {});
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(payload));
});
};
}
十、性能:细粒度订阅与反模式
10.1 使用 storeToRefs 保持响应式
typescript
// ❌ 解构丢失响应式
const { list, loading } = useOrderStore();
// ✅
import { storeToRefs } from 'pinia';
const orderStore = useOrderStore();
const { list, loading } = storeToRefs(orderStore);
const { fetchList } = orderStore; // action 可直接解构
10.2 在组件中按字段订阅(Pinia + Vue 自动追踪)
vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user';
import { computed } from 'vue';
const userStore = useUserStore();
// 仅当 permissions 变化时重新计算
const canEdit = computed(() => userStore.permissions.includes('order:edit'));
</script>
10.3 大列表:Store 存 ID,详情用 Map
typescript
export const useOrderStore = defineStore('order', () => {
const ids = ref<string[]>([]);
const entities = ref<Record<string, Order>>({});
const list = computed(() => ids.value.map((id) => entities.value[id]));
function upsertMany(orders: Order[]) {
orders.forEach((o) => {
entities.value[o.id] = o;
if (!ids.value.includes(o.id)) ids.value.push(o.id);
});
}
return { ids, entities, list, upsertMany };
});
10.4 反模式速查
| 反模式 | 后果 | 改法 |
|---|---|---|
| Store 存 DOM 引用 | 内存泄漏、难测试 | 放组件 ref |
| 在 getter 里发请求 | 重复请求、难缓存 | 放 action + Query |
处处 watch(store.$state) |
性能差 | 精确 $subscribe 或 computed |
| 复制一份 state 到组件 | 双源真相 | storeToRefs |
十一、与 Router、HTTP、权限联动
11.1 路由守卫中初始化 Store
typescript
// router/index.ts
import { useUserStore } from '@/stores/user';
import { usePermissionStore } from '@/stores/permission';
router.beforeEach(async (to, from, next) => {
const user = useUserStore();
const permission = usePermissionStore();
if (to.meta.public) return next();
if (!user.token) {
return next({ path: '/login', query: { redirect: to.fullPath } });
}
if (!permission.routesLoaded) {
try {
const routes = await permission.buildRoutes();
routes.forEach((r) => router.addRoute(r));
permission.routesLoaded = true;
return next({ ...to, replace: true });
} catch {
user.logout();
return next('/login');
}
}
if (to.meta.permission && !permission.has(to.meta.permission as string)) {
return next('/403');
}
next();
});
11.2 Axios 拦截器读写 Store
typescript
// api/http.ts
import axios from 'axios';
import { useUserStore } from '@/stores/user';
const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE });
http.interceptors.request.use((config) => {
const user = useUserStore();
if (user.token) {
config.headers.Authorization = `Bearer ${user.token}`;
}
return config;
});
http.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 401) {
const user = useUserStore();
user.$reset();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
11.3 指令 v-permission 与 Store 配合
typescript
// directives/permission.ts
import type { Directive } from 'vue';
import { usePermissionStore } from '@/stores/permission';
export const vPermission: Directive<HTMLElement, string | string[]> = {
mounted(el, binding) {
const permission = usePermissionStore();
const need = Array.isArray(binding.value) ? binding.value : [binding.value];
if (!need.some((code) => permission.has(code))) {
el.parentNode?.removeChild(el);
}
}
};
十二、中后台实战场景
12.1 多标签页(Tabs)Store
typescript
// stores/tabs.ts
import { defineStore } from 'pinia';
export interface TabItem {
path: string;
title: string;
name: string;
affix?: boolean;
}
export const useTabsStore = defineStore('tabs', () => {
const visited = ref<TabItem[]>([]);
const cachedNames = ref<string[]>([]);
function addTab(tab: TabItem) {
if (!visited.value.some((t) => t.path === tab.path)) {
visited.value.push(tab);
}
if (tab.name && !cachedNames.value.includes(tab.name)) {
cachedNames.value.push(tab.name);
}
}
function closeTab(path: string) {
const idx = visited.value.findIndex((t) => t.path === path);
if (idx === -1) return;
const tab = visited.value[idx];
if (tab.affix) return;
visited.value.splice(idx, 1);
cachedNames.value = cachedNames.value.filter((n) => n !== tab.name);
}
function closeOthers(path: string) {
visited.value = visited.value.filter((t) => t.affix || t.path === path);
cachedNames.value = visited.value.map((t) => t.name);
}
return { visited, cachedNames, addTab, closeTab, closeOthers };
});
配合 <keep-alive :include="tabsStore.cachedNames"> 与路由 name 一致。
12.2 列表页筛选状态跨路由保留
typescript
// stores/listFilters.ts
export const useListFiltersStore = defineStore('listFilters', () => {
const filtersByRoute = ref<Record<string, Record<string, unknown>>>({});
function getFilters(routeName: string) {
return filtersByRoute.value[routeName] ?? {};
}
function setFilters(routeName: string, partial: Record<string, unknown>) {
filtersByRoute.value[routeName] = {
...getFilters(routeName),
...partial
};
}
function clear(routeName: string) {
delete filtersByRoute.value[routeName];
}
return { filtersByRoute, getFilters, setFilters, clear };
}, {
persist: { paths: ['filtersByRoute'] }
});
12.3 全局 Loading / 请求计数
typescript
// stores/loading.ts
export const useLoadingStore = defineStore('loading', () => {
const count = ref(0);
const isLoading = computed(() => count.value > 0);
function start() {
count.value++;
}
function end() {
count.value = Math.max(0, count.value - 1);
}
return { count, isLoading, start, end };
});
// http 拦截器
http.interceptors.request.use((config) => {
if (!config.hideLoading) useLoadingStore().start();
return config;
});
http.interceptors.response.use(
(res) => {
if (!res.config.hideLoading) useLoadingStore().end();
return res;
},
(err) => {
if (!err.config?.hideLoading) useLoadingStore().end();
return Promise.reject(err);
}
);
十三、SSR / Nuxt 注意事项
13.1 每请求创建 Pinia 实例
typescript
// entry-server.ts / Nuxt 自动处理
import { createPinia } from 'pinia';
export async function render(url: string) {
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
// 预取数据
const user = useUserStore(pinia);
await user.fetchProfile();
const html = await renderToString(app);
const state = JSON.stringify(pinia.state.value);
return { html, state };
}
13.2 避免在模块顶层访问 window / localStorage
typescript
// ❌ 模块加载时就执行
const token = ref(localStorage.getItem('token') ?? '');
// ✅ 在 action 或 onMounted / 客户端插件中读取
function hydrateFromStorage() {
if (import.meta.server) return;
token.value = localStorage.getItem(TOKEN_KEY) ?? '';
}
13.3 Nuxt 3:@pinia/nuxt
typescript
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
storesDirs: ['./stores/**']
}
});
十四、测试 Pinia Store
14.1 单元测试 Setup Store
typescript
// stores/__tests__/user.spec.ts
import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { useUserStore } from '../user';
import * as userApi from '@/api/user';
vi.mock('@/api/user');
describe('useUserStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('login sets token and userInfo', async () => {
vi.mocked(userApi.login).mockResolvedValue({ token: 't1' });
vi.mocked(userApi.getProfile).mockResolvedValue({ id: '1', username: 'test' });
const store = useUserStore();
await store.login({ username: 'a', password: 'b' });
expect(store.token).toBe('t1');
expect(store.userInfo?.username).toBe('test');
expect(store.isLoggedIn).toBe(true);
});
it('$reset clears state', async () => {
const store = useUserStore();
store.token = 'x';
store.$reset();
expect(store.token).toBe('');
});
});
14.2 测试组件时 stub Store
typescript
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import OrderPage from '../OrderPage.vue';
it('shows empty when no orders', () => {
const wrapper = mount(OrderPage, {
global: {
plugins: [
createTestingPinia({
initialState: {
order: { list: [], loading: false }
},
stubActions: false
})
]
}
});
expect(wrapper.text()).toContain('暂无数据');
});
十五、踩坑清单与最佳实践
15.1 常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 模板不更新 | 解构了 store 的 state | 使用 storeToRefs |
| 刷新后权限丢失 | 只持久化了 token 未拉菜单 | 路由守卫里 buildRoutes |
| HMR 后 action 重复执行 | 热更新边界 | 开发环境接受或全页刷新 |
| 两个 tab 登录态不一致 | 未监听 storage |
使用 tabSync 插件 |
Option Store 中 this 类型报错 |
TS 严格模式 | 为 Store 声明 DefineStoreOptions 或改 Setup Store |
15.2 最佳实践清单
- 一域一 Store,用 composable 组合,避免上帝对象
- Setup Store 为主 ,显式实现
$reset - 服务端数据优先 Query/SWR,Pinia 存「意图」与「会话」
- 持久化白名单,不持久化敏感字段与大列表
- 路由、HTTP、权限三处共用同一套 user/permission Store
- 插件统一处理日志、持久化、Tab 同步
- 为 Store 写测试,覆盖 login/logout、权限、$reset
15.3 与系列其他文章的衔接
| 文章 | 衔接点 |
|---|---|
| 12 前端路由与 SPA | 动态路由、addRoute、History 回退 |
| 10 前端安全 | Token 存储、XSS 与 localStorage |
| 11 性能与可观测性 | 避免 Store 过大导致无效更新 |
| 23 Vue 高阶技巧 | Composables 与 Pinia 分工 |
| 27 微前端 | 子应用是否共享 Pinia 实例需单独设计 |
十六、总结
Pinia 的价值不在于「把所有数据都放进去」,而在于为跨模块、跨路由、需持久化与可观测 的业务状态提供统一容器。掌握 Setup Store、类型推导、storeToRefs、插件与持久化之后,再配合 Router/HTTP/权限与中后台 Tabs、字典等场景,即可在真实项目中稳定落地。