跳到主要内容

提示

京东、淘宝等购物平台是以本地数据为主,当本地购物车数量变化(或设备初始化时)才会同步服务器购物车数据并进行数据合并。

一、使用 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.cartItems
    return (
    <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 (精细化控制) :相比 autorunreaction 允许将数据追踪副作用执行分离开。它更适合处理数据变化后触发的特定逻辑
    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);
}
});

二、乐观更新

将数据源分为三层:

    1. UI 层( MobX Store ) :负责即时渲染,不直接操作储存
    1. 本地持久化( LocalStorage ) :负责“断点续传”,刷新页面不会丢失数据
    1. 远程服务层( 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
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();
};

4. 设计购物车与同步黄金法则:

    1. UI 响应 :永远使用乐观更新,先改 UI,再发请求
    1. 数据兜底 :任何写操作必须同时写入 localStorage
    1. 初始化 :先读 localStorage 展示骨架屏,再异步拉取 API 合并数据
    1. 多端同步 :利用 SharedWorker 解决同浏览器多标签页同步,利用 API 时间戳合并解决跨设备同步