React Suspense, React Hooks 与 FP
最近看了 一篇很有意思的文章,讲述了 Suspense, Hook 与 Monad, Applicative 之间的关系。里面的信息量有点大,拿出来整理一下。
实现 Suspense
首先考虑实现这样一个函数,同步地从 Promise 中取出里面的值。
function getValue<T>(x: Promise<T>): T {}
我们知道,JavaScript 中是无法用常规方法非阻塞地从 Promise 中取出值的。因此这里增加一个函数 schedule
用来调度 getValue
函数。当 Promise 没有被 resolve 时中断操作让出执行权,并在 resolve 之后重新调用获得结果。
schedule(() => {
const value = getValue(delay('value'));
console.log(value); // 这里可以正确输出
});
function delay<T>(x: T): Promise<T>;
这里利用 throw,大概写成了这个形式
const ctx = {
promise: null as Promise<T>,
fullfilled: false,
value: null as T
};
/**
* 如果已经计算完毕,直接返回结果
* 否则抛出异常让 schedule 捕获
*/
export const getValue = (x: Promise<T>) => {
if (ctx.fullfilled) {
return ctx.value;
}
ctx.promise = x;
throw new Error('not evaluated');
};
export const schedule = (thunk: () => T) => {
return step();
function step(value?: T) {
try {
// 执行计算,如果已经计算完毕则正常返回
return thunk();
} catch (e) {
// 如果没有计算完毕会捕获异常
// 使用 then 安排 resolve 之后重新调用
return ctx.promise.then((value) => {
ctx.fullfilled = true;
ctx.value = value;
return step(value);
});
}
}
};
schedule(() => {
const value = getValue(delay("value"));
console.log(value);
});
使用变量 ctx
保存执行时的状态,调用 getValue
时,如果发现 Promise 没有被 resolve,则抛出异常,让外层的 schedule
捕获。schedule
使用 then
函数让 Promise 被 resolve 的时候再次调用 getValue
函数,此时已经获取到了内部的值,直接返回即可。这个例子还十分粗糙,没有考虑多次调用 getValue
的情况,每次只会返回第一个执行的结果。稍加改进以后可以变成:
const token = Symbol("schedule interrupt");
let context: Context;
/**
* 如果已经计算完毕,直接返回结果
* 否则抛出异常让 schedule 捕获
*/
export const getValue = (x: Promise<T>) => {
// 如果当前的 promise 已经计算过了,直接返回
// 并移动 pos 的位置计算下一个
if (context.pos < context.trace.length) {
return context.trace[context.pos++];
}
context.promise = x;
throw token;
};
export const schedule = (thunk: () => T) => {
const ctx: Context = {
promise: null as Promise<T>,
trace: [] as T[], // trace 用于记录已经计算完毕的值
pos: 0, // pos 表示当前计算的 promise 是第几个
};
return step();
function step(value?: T): Promise<T> {
const savedContext = context;
// 初始化为 0,每次调用的时候都从第 0 个开始计算
ctx.pos = 0;
try {
context = ctx;
// 执行计算,如果全部 promise 都已经计算完毕则正常返回
// 加上一层 resolve 使得返回的类型一致
return Promise.resolve(thunk());
} catch (e) {
// 不是 schedule interrupt 的话就是真正的错误
// 重新抛出
if (e !== token) {
throw e;
}
// 如果没有计算完毕会捕获异常
// 第 i 个 promise 没有计算完毕时
// pos 的值会停在 i
// 使用 then 在计算完毕后将值记录在 trace[pos] 的位置上
// 然后再次尝试调用 thunk
// 直到所有的值都被计算完毕
const { pos } = ctx;
return ctx.promise.then((value) => {
ctx.trace.length = pos;
ctx.trace[pos] = value;
ctx.pos = pos + 1;
return step(value);
});
} finally {
context = savedContext;
}
}
};
schedule(() => {
const value = getValue(delay("value"));
const value2 = getValue(delay("value2"));
console.log(value, value2);
});
schedule(() => {
const value = getValue(delay("value3"));
console.log(value);
});
这里增加了 trace
来保存所有计算完毕的值,并且用 pos
表示当前正在计算的是第几个 Promise。并且为了支持多个 schedule
同时调用,将每次运行时的 ctx
保存下来,计算完毕时还原到上一层的 ctx
。至此我们已经实现了一个简易的 Suspense。
Applicative 与 Monad
把 FP 的概念引入 Suspense 之前,先回顾一下 Haskell 中 Applicative
和 Monad
的定义
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
class Applicative m => Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
它们应当是一个 Functor(范畴论意义中为 Haskell Type 范畴下的 endofunctor)。此外,Applicative 需要定义 pure
函数实现源类型到 Applicative 类型的转化。以及 <*>
函数用于 Applicative 类型函数的应用。Monad 在 Applicative 的基础上,还需要实现 >>=
函数用于连接函数的调用。
JavaScript 中的 Monad
通过观察发现,我们可以从上面的代码中提取出一个 Monad 类型:
-- 混合了 JavaScript 的伪代码
instance Monad PromiseM where
return = a -> Promise<a>
Promise<a> >>= f = Promise<a>.then(f)
实现中我们使用 of
表示 Monad 的 return
函数,用 chain
表示 Monad 的 >>=
函数。
const PromiseM: Monad = {
of<T>(x: T) {
return Promise.resolve(x);
},
chain<T1, T2>(x: Promise<T1>, f: (x: T1) => Promise<T2>) {
return x.then(f);
},
};
(ts 里面没有高阶泛型,哭了)
现在利用这个 Monad 重新整理上面的代码
export const schedule = (m: Monad) => (thunk: () => T) => {
const ctx: Context = {
effect: null as Monad_<T>,
trace: [] as T[], // trace 用于记录已经计算完毕的值
pos: 0, // pos 表示当前计算的 promise 是第几个
};
return step();
function step(value?: T): Monad_<T> {
const savedContext = context;
// 初始化为 0,每次调用的时候都从第 0 个开始计算
ctx.pos = 0;
try {
context = ctx;
// 执行计算,如果全部 promise 都已经计算完毕则正常返回
return m.of(thunk());
} catch (e) {
// 如果没有计算完毕会捕获异常
// 第 i 个 promise 没有计算完毕时
// pos 的值会停在 i
// 使用 then 在计算完毕后将值记录在 trace[pos] 的位置上
// 然后再次尝试调用 thunk
// 直到所有的值都被计算完毕
const { pos } = ctx;
return m.chain(ctx.effect, (value: T) => {
ctx.trace.length = pos;
ctx.trace[pos] = value;
ctx.pos = pos + 1;
return step(value);
});
} finally {
context = savedContext;
}
}
};
schedule(PromiseM)(() => {
const value = getValue(delay("value"));
const value2 = getValue(delay("value2"));
console.log(value, value2);
});
这里将 schedule
函数改造为更高阶的函数,传入了一个 Monad 实例作为第一个参数。并分别使用 of
和 chain
函数替代了 Promise.resolve
和 then
函数。context
中的 promise
属性也被替换成了更加通用的 effect
属性。现在 schedule
函数可以支持其它种类的 Monad 了(啪啪啪啪啪)。
useState
接下来利用上面的 schedule
函数实现一个 useState
的 Hook。再来看一个 Monad 实例
const ContinuationM: Monad = {
of<T>(value: T) {
return (cont: (v: T) => T) => cont(value);
},
chain<T>(arg: Monad_<T>, next: (x: T) => Monad_<T>) {
return (cont: (v: T) => T) => arg(value => next(value)(cont));
}
};
这个被称为 Continuation Monad。它与 Promise 有些类似,将值存储在闭包中返回一个函数,在调用的时候才能获取到这个值。首先将初始值包装为一个 Continuation Monad 值,与 React 的 useState
相同,数组的第一个参数是值,第二个是设置值的函数。
function Cont(x: string): Monad_<T> {
return cont => cont([x, function next(value) { cont([value, next]); }]);
}
function useState<T>(initial: T) {
return getValue(Cont(initial))!;
}
schedule(ContinuationM)(() => {
const [x, setX] = useState('1');
console.log(x);
if (x !== '2') {
setX('2');
}
})(() => {});
// 1
// 2
最后需要注意的是,函数已经被处理成了更高阶的函数,所以最后需要手动调用 (() => {})
才能让它真正开始运行。为什么执行 setX
以后会让这一段代码重新运行呢?因为在 schedule
的 catch
代码块中,m.chain
的第二个参数里面包含了 step
函数,根据 Continuation Monad 的性质,在执行 setX
之后也会触发 step
函数的运行,此时会先更新 trace
中保存的值,然后执行 thunk
函数。最后将这个 useState
用到 Function Component 里面也是水到渠成了,这里借助 Class Component 的 state 处理组件的更新:
function makeComponent(schedule: (fn: () => void) => void) {
return <T>(fc: React.FC<T>) =>
class Wrapper extends React.PureComponent<T> {
state: {
control: React.ReactNode;
};
mounted: boolean;
constructor(props) {
super(props);
this.state = { control: null };
// state 发生变化时,重新计算 ReactNode,通过 state 更新
schedule(() => {
const control = fc(props);
if (this.mounted) {
this.setState({ control });
} else {
this.state = { control };
}
});
}
componentDidMount() {
this.mounted = true;
}
render() {
return this.state.control;
}
};
}
const Component = makeComponent(
// 把之前的 schedule 代码移到这里,在 class component 中调用
thunk => schedule(ContinuationM)(thunk)(() => {})
);
// 高阶组件,实际上嵌套在 class component 里面
const App = Component(() => {
const [value, setValue] = useState('');
return (
<div className="App">
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
</div>
);
});
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
这段代码在有多个 useState
的时候会出现问题。
const [x, setX] = useState(0);
const [y, setY] = useState(1);
setY(2);
setX(1);
console.log(x, y);
// 1 1
因为在 schedule
函数中,ctx.trace.length = pos
清除了当前位置之后记录的数据,所以导致在设置 x
值的时候 y
被清除了。怎么解决这个问题呢?文章后面提到了一个库 effectfuljs,以后再看吧 _(:з」∠)_
。
参考文献
- V. Akimov, “When to use React Suspense vs React Hooks”, Medium, 2019.
- B. Milewski, Category Theory for Programmers. ImageWrap, 2018.