提示
京东、淘宝等购物平台是以本地数据为主,当本地购物车数量变化(或设备初始化时)才会同步服务器购物车数据并进行数据合并。
一、使用 MobX 实现细粒度的数据更新监听
在 [MobX] 中实现细粒度的数据更新,核心在于理解其依赖追踪机制。 [Mobx] 会自动追踪组件或函数具体使用了哪些数据,从而只在相关数据变化时触发更新,避免了不必要的性能开销。
1. 在 React 组件中实现细粒度渲染
这是最常见的使用场景。通过 observer 高阶组件包裹 [React] 组件, [MobX] 会自动最终组件在渲染过程中读取了哪些可观察状态( observable )。只有这些被读取的状态发生变化时,组件才会重新渲染。
- 使用
observer(推荐) :observer会自动收集依赖,实现组件级别的细粒度更新import { observer } from 'mobx-react-lite';import { cartStore } from './store/cartStore';const Cart = observer(() => {// MobX 会追踪 cartStore.totalPrice 和 cartStore.cartItemsreturn (<div><h2>总价:¥{cartStore.totalPrice}</h2><ul>{cartStore.cartItems.map(item => (<li key={item.id}>{item.name}</li>))}</ul></div>);}); - 使用
useLocalObservable(用于组件内部状态) :对于仅在当前组件内使用的复杂状态,可以使用useLocalObservable创建本地可观察状态,同样享受细粒度更新的便利import { useLocalObservable, observer } from 'mobx-react-lite';export const Counter = observer(() => {const store = useLocalObservable(() => ({count: 0,increment() {this.count++;},}));return <button onClick={store.increment}> 计数:{store.count} </button>;});
2. 监听状态变化(副作用处理)
当需要在状态变化时执行一些副作用(如日志提交、发送网络请求等),可以使用 [MobX] 提供的反应函数。
zutorun(自动运行) :它会立即执行一次传入的函数,并自动追踪函数内部使用的所有的可观察状态。之后,只要任何一个依赖的状态发生变化,函数就立即执行import { autorun } from 'mobx';const dispose = autorun(() => {console.log('购物车商品数量:', cartStore.totalQuantity);});// 当 cartStore.totalQuantity 变化时,上面的日志会自动打印// 当组件卸载或不需要时,调用 dispose() 来停止监听reaction(精细化控制) :相比autorun,reaction允许将数据追踪和副作用执行分离开。它更适合处理数据变化后触发的特定逻辑import { reaction } from 'mobx';// 嗯,这不就是 `useEffect` 么 🤔const dispose = reaction(// 第一个函数:数据源,返回关心的数据() => cartStore.totalPrice,// 第二个函数:副作用,当数据变化时执行console.log(`总价从 ${olgPrice} 变更为 ${newPrice}`);// 可以在这里调用 API 更新后端价格);
3. 监听特定条件
when (一次性监听) :当只关心某个状态首次满足特定条件时,可以使用 when 。它在条件为真时执行一次副作用,然后自动停止监听
import { when } from 'mobx';
when(
// 条件函数
() => cartStore.cartItems.length > 0,
// 满足条件时执行的函数
() => {
console.log('购物车中有商品了!');
// 例如:显示一个“去结算”的提示
},
);
4. 调试和监控
在开发阶段,可以使用 spy 来监听 [MobX] 的所有活动,这对于理解数据流向和调试复杂问题非常有帮助。
import { spy } from 'mobx';
spy(event => {
if (event.type === 'action') {
console.log('执行了动作:', event.name);
} else if (event.type === 'reaction') {
console.log('触发了反应:', event.name);
}
});
二、乐观更新
将数据源分为三层:
-
- UI 层( MobX Store ) :负责即时渲染,不直接操作储存
-
- 本地持久化( LocalStorage ) :负责“断点续传”,刷新页面不会丢失数据
-
- 远程服务层( API ) :负责最终一致性,多端同步
1. 定义数据结构与状态
首先,需要在 Store 区分“本地临时状态”和“同步状态”。
src/stores/cartStore.ts
import { makeAutoObservable, runInAction } from 'mobx';
import { CartItem } from './types';
import { sleep } from 'a-js-tools';
// 模拟 API 调用
const cartAPI = {
syncCart: async (items: CartItem[]) => {
// 模拟网络延迟
await sleep(1000);
console.log('🚀 同步到服务器:', items);
return {
success: true,
};
},
getRemoteCart: async (): Promise<CartItem[]> => {
await sleep(500);
// 模拟服务器返回数据(可能包含在其它设备添加的商品)
return [
{ id: 99, name: '服务器商品', price: 99.8, quantity: 1, image: '☁️' },
];
},
};
class CartStore {
cartItems: CartItem[] = [];
isLoading = false;
lastSyncTime = 0;
constructor() {
makeAutoObservable(this);
this.loadLocalCart(); // 初始化先读取本地
this.syncWithServer(); // 尝试与服务器同步
}
// --- 1. 本地持久化逻辑 ---
// 从 LocalStorage 加载
loadLocalCart() {
try {
const localData = localStorage.getItem('shopping_cart');
if (localData) {
this.cartItems = JSON.parse(localData);
}
} catch (error) {
console.error('读取本地购物车失败', error);
}
}
// 保存到 localStorage (防抖处理可选)
saveLocalCart() {
localStorage.setIem('shopping_cart', JSON.Stringify(this.cartItems));
}
// --- 2. 核心业务逻辑(乐观更新)
addToCart = async (product: any) => {
// 1. 立即更新 UI (乐观更新)
const newItem: CartItem = { ...product, quantity: 1 };
const existingItem = this.cartItems.find(i => i.id === product.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
this.cartItems.push(newItem);
}
// 立即保证到本地(保证刷新不丢)
this.saveLocalCart();
// 3. 异步同步到服务器(不阻塞 UI)
try {
await cartApi.syncCart(this.cartItems);
this.lastSyncTime = Date.now();
} catch (error) {
console.error('同步服务器失败,数据已保存到本地');
// 亦可添加用户提示 “网络不佳,将稍后同步”
}
};
removeItem = async (id: number) => {
// 1. 乐观移除
this.cartItems = this.cartItems.filter(i => i.id !== id);
this.saveLocalCart();
// 2. 同步服务器
await cartAPI.syncCart(this.cartItems);
};
// --- 3. 服务器同步策略 ---
//策略:拉去服务器最新数据,合并本地未同步数据(这里简化为服务器为准,或简单的合并)
syncWithServer = async () => {
this.isLoading = true;
try {
const remoteItems = await cartApi.gitRemoteCart();
runInAction(() => {
// 简单合并策略:
// 实际项目需要根据 id 和 updateTime 进行复杂的冲突解决
// 或者,直接将本地数据传递给服务器(携带最后更新时间)让服务器处理再下发
if (remoteItems.length > 0) {
this.cartItems = remoteItems;
this.saveLocalCart();
}
});
} catch (error) {
console.error('同步失败', error);
} finally {
this.isLoading = false;
}
};
}
export const cartStore = new CartStore();
2. 冲突解决策略
当“本地数据”和“接口同步”发生冲突时(例如:用户在 A 设备添加了商品,在 B 设备删除了同一个商品),需要制定数据最后的规则。
| 策略 | 描述 | 使用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 本地优先 | 以当前设备的操作为准,覆盖服务器数据 | 单机应用、离线优先应用 | 响应速度快,用户无挫败感 | 多端同步容易丢失其他设备数据 |
| 服务器优先 | 每次炒作前先拉取最新数据,或直接以服务器返回数据为准 | 强一致性要求的金融类、库存类 | 数据绝对准确 | 依赖网络,弱网下体验差 |
| 时间戳合并 | 比较本地和服务器的 updateTime , 保留最新的 | 电商购物车(推荐) | 兼顾多端同步和本地操作 | 实现逻辑复杂,需处理“并发修改” |
伪代码
const mergeCarts = (local: CartItem[], remote: CartItem[]) => {
const map = new Map();
// 1. 先放入远程数据
remote.forEach(item => map.set(item.id, { ...item, source: 'remote' }));
// 2. 遍历本地数据
local.forEach(item => {
const remoteItem = map.get(item.id);
// 如果本地修改时间晚于服务器,覆盖服务器数据
if (!remoteItem || item.updateTime > remoteItem.updateTime) {
map.set(item.id, item);
}
});
return Array.from(map.values());
};
3. 跨标签页同步( SharedWorker )
如果浏览器开了两个标签页, localStorage 本身无法实时通知到另一个标签页更新。虽然 storage 时间可以监听,但在同标签页不触发。可以使用 sharedWorker ,这是一个独立的后台线程,所有的同源标签页共享。
- worker.js (独立文件)
- Store 中集成
worker.js
import type { CartItem } from '../stores/types';
interface SharedWorkerGlobalScope extends WindowOrWorkerGlobalScope {
onconnect: ((e: MessageEvent) => void) | null;
}
let cartData: CartItem[] = [];
// 储存所有页面的端口
const ports: MessagePort[] = [];
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
const port = e.ports[0];
ports.push(port);
// 新页面连接,发送当前数据
port.postMessage(cartData);
port.onmessage = function (e) {
cartData = e.data; // 更新共享状态
// 广播给所有其它的页面
ports.forEach(p => {
if (p !== port) {
p.postMessage(cartData);
}
});
};
port.start();
};
在 cartStore 构造函数中
class cartStore {
private sharedWorker: SharedWorker | undefined;
constructor() {
makeAutoObservable(this);
if (typeof SharedWorker !== 'undefined') {
const worker = new SharedWorker(
new URL('../workers/cart.work.ts', import.meta.url),
{
type: 'module', // 必须
credentials: 'same-origin',
},
);
worker.port.onmessage = e => {
// 收到其他标签页
runInAction(() => {
this.cartItems = e.data;
});
};
this.sharedWorker = sharedWorker;
worker.port.start();
}
}
// 修改数据时广播
addToCart(product) {
// ...其他逻辑...
this.synchronizeData(); // 同步数据
}
/** 同浏览器同步购物车数据 */
private synchronizeData() {
// 更新同浏览器页面逻辑
if (this.sharedWorker) {
if (this.sharedWorker.port.postMessage) {
// 将消息发送给同浏览器其他页面
this.sharedWorker.port.postMessage(
this.cartItems.reduce(
(cart: CartItem[], item) => [...cart, { ...item }],
[],
),
);
}
}
}
}
4. 设计购物车与同步黄金法则:
-
- UI 响应 :永远使用乐观更新,先改 UI,再发请求
-
- 数据兜底 :任何写操作必须同时写入
localStorage
- 数据兜底 :任何写操作必须同时写入
-
- 初始化 :先读
localStorage展示骨架屏,再异步拉取 API 合并数据
- 初始化 :先读
-
- 多端同步 :利用
SharedWorker解决同浏览器多标签页同步,利用 API 时间戳合并解决跨设备同步
- 多端同步 :利用