如何不依赖外部库,用不可变数据类型降低竞态条件风险?
用原生JavaScript实现不可变数据,规避异步竞态条件
不可变数据类型能有效降低JavaScript异步代码里的竞态条件风险——这类问题大多源于全局可变状态。虽说函数式编程能规避可变状态,但JavaScript不是纯函数式语言,自带的Array、Map都是可变引用类型,复制这类结构成本不低,很容易把可变引用泄漏到全局作用域,进而引发各种不可预期的问题。
比如下面这个示例,多个异步操作同时读写同一个数组,就会跑出各种混乱的结果:
const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); async function pushStack(ys, x, ms) { await delay(ms) ys.push(x); return ys; }; async function popStack(ys, ms) { await delay(ms) const y = ys.pop(); return y; }; async function getLastStack(ys, ms) { await delay(ms) return ys.length - 1; }; async function readStack(ys, ms) { await delay(ms) return ys[await getLastStack(ys, Math.random()*10)]; } const xs = [1,2,3,4]; Promise.all([ pushStack(xs, 5, Math.random()*10), readStack(xs, Math.random()*10), popStack(xs, Math.random()*10) ]).then(([a,b,c]) => { console.log(JSON.stringify(a), b, c); }); /* 可能的输出包括: [1,2,3,5], 3, 4 [1,2,3,5], 5, 4 [1,2,3,4], 4, 5 [1,2,3,4], 5, 5 [1,2,3,4], 4, 4 */
下面是不依赖复杂外部库的解决办法:
1. 用原生操作实现不可变读写
核心思路就是绝不直接修改原数据,每次操作都返回新的副本:
数组操作改造
- 替换
push:用扩展运算符[...原数组, 新元素]或者原数组.concat(新元素)生成新数组 - 替换
pop:用原数组.slice(0, -1)拿到去掉最后一项的新数组,单独取最后一项用原数组[原数组.length - 1] - 替换
splice:结合slice和扩展运算符实现局部修改 - 读取操作直接基于原数组,不用动它
把上面的示例改成不可变版本:
const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); // 不可变push:返回新数组,原数组不动 async function pushStack(ys, x, ms) { await delay(ms) return [...ys, x]; }; // 不可变pop:返回新数组和弹出的元素 async function popStack(ys, ms) { await delay(ms) const popped = ys[ys.length - 1]; return { newStack: ys.slice(0, -1), popped }; }; async function getLastStack(ys, ms) { await delay(ms) return ys.length - 1; }; async function readStack(ys, ms) { await delay(ms) return ys[await getLastStack(ys, Math.random()*10)]; } const xs = [1,2,3,4]; Promise.all([ pushStack(xs, 5, Math.random()*10), readStack(xs, Math.random()*10), popStack(xs, Math.random()*10) ]).then(([newPushStack, readVal, {newPopStack, poppedVal}]) => { console.log(JSON.stringify(newPushStack), readVal, poppedVal); }); /* 现在输出会稳定符合预期: [1,2,3,4,5], 4, 4 */
对象操作改造
- 替换直接修改属性:用
Object.assign({}, 原对象, {要改的键: 新值})或者扩展运算符{...原对象, 要改的键: 新值}生成新对象 - 嵌套对象修改:逐层生成新副本,比如
{...原对象, 嵌套属性: {...原对象.嵌套属性, 要改的键: 新值}}
2. 用闭包封装可变状态,对外暴露安全接口
如果完全用副本的性能开销太大,可以把可变数据藏在闭包里,只对外提供返回新状态的方法,不让外部直接碰原数据:
function createImmutableStack(initialItems) { let stack = [...initialItems]; // 闭包里的可变状态,外部拿不到 return { async push(x, ms) { await delay(ms); stack = [...stack, x]; return [...stack]; // 只返回副本 }, async pop(ms) { await delay(ms); const popped = stack[stack.length - 1]; stack = stack.slice(0, -1); return { stack: [...stack], popped }; }, async getLast(ms) { await delay(ms); return stack.length - 1; }, async readLast(ms) { await delay(ms); const lastIdx = await this.getLast(Math.random()*10); return stack[lastIdx]; } } } // 使用示例 const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const stack = createImmutableStack([1,2,3,4]); Promise.all([ stack.push(5, Math.random()*10), stack.readLast(Math.random()*10), stack.pop(Math.random()*10) ]).then(([newPushStack, readVal, {stack: newPopStack, poppedVal}]) => { console.log(JSON.stringify(newPushStack), readVal, poppedVal); });
这种方式既利用了闭包内可变状态的性能优势,又保证外部没法直接修改状态,从根源上避免竞态问题。
3. 浅拷贝与深拷贝的选择
如果是简单数据结构(没有嵌套的引用类型),用浅拷贝(扩展运算符、Object.assign、slice)就够;如果是嵌套结构,需要深拷贝的话,可以用原生的JSON.parse(JSON.stringify(对象))(注意它处理不了函数、Symbol、循环引用),或者自己写个递归深拷贝函数:
function deepClone(obj) { if (obj === null || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map(item => deepClone(item)); const cloned = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { cloned[key] = deepClone(obj[key]); } } return cloned; }
内容的提问来源于stack exchange,提问作者user5536315
相关产品推荐
相关产品推荐

