React 16 算的是 React 最大的一次变革,不仅底层实现基于 Fiber 重写,带来了更好的任务调度能力和性能表现,也为开发者提供了诸如 new Context、Suspense 、Hooks 等等强大的 API。

作为 React 16 新特性拥趸之一,笔者偶然读到 《When to use React Suspense vs React Hooks》 这篇文章,千万别被其标题所迷惑,它并不是介绍怎么用 Suspense 和 Hooks 的。

与笔者之前读过的 Hooks 或者 Suspense 的原理分析不同,该文另辟蹊径,讨论了如何通过 Monad>) 来实现 Suspense 和 Hooks。但是文章 “开门见山”,在没有铺垫的情况下,直接介绍了基于 Monad 的 Suspense 和 Hooks 实现,这就会让读者觉得十分唐突,读起来十分吃力。

本文基于这篇文章,以思考怎么在函数组件中处理异步任务为引,循序渐进的铺陈 Suspense 和 Hooks 的简单实现,最后引出 Monad,构建一个二者都适用的实现机制。借此,一方面希望读者更加熟悉 Suspense 和 Hooks 的设计来由和方式,一方面也让读者感受到统一数据类型及函数式编程对业务抽象的帮助。

由于笔者能力所限,本文不会对 Monad 和函数式编程做进一步的严肃介绍,感兴趣的读者可以阅读 参考资料 中的延伸阅读。

怎么让函数组件处理异步任务?

在 Hooks 推出之前,我们仅仅使用函数组件(Functional Component)编写纯展示组件,它是一个纯函数,给定函数的输入(Props),必然能得到一致的组件结构(DOM)。但如果我们想要打破函数纯度限制,比如我们非要在函数组件中完成一个远程数据拉取和展示,那该怎么做呢?

说的在具体一些,组件运行伊始,我们拉取数据,若数据尚未到达,则不渲染组件。自然而然地,首先我们会想到 async/await:

const App = async props => {
const total = await fetchTotal();
const users = await fetchUsers();

return (
<div>
<div>{`Total: ${total}`}</div>
<ul>
{users.map(user => (
<li>{user.name}</li>
))}
</ul>
</div>
);
};

ReactDOM.render(<App />, document.querySelector("#app"));

但运行这段代码,将会报错:

Objects are not valid as a React child (found: [object Promise])

这是因为 async 函数最终被转换为了 Promise 对象,而不是 React 组件。那我们当然会想到说,构建一个 转换器,来实现 async function 到 React 组件的转化:

const App = transformer(async props => {
const total = await fetchTotal();
const users = await fetchUsers();

return (
<div>
<div>{`Total: ${total}`}</div>
<ul>
{users.map(user => (
<li>{user.name}</li>
))}
</ul>
</div>
);
});

怎样控制业务流程的暂停与继续?

在实现这个转换器之前,我们先回顾下用 async/await 编写组件的目的:

  • 使用命令式编程编排异步任务。
  • 期望获得 await 提供的暂态能力。上例中,我们期望在 total 获得之后,再去拉取 users

那么不依赖于 async/await,还要实现运行逻辑的启停,我们可以在逻辑外层包裹一个 runner,顾名思义,它能够控制一段逻辑的运行,当遇到异步副作用时,让流程暂停,等待任务完成,当任务完成时,继续执行流程。

假定我们使用 Promise 创建了一个异步任务:

const fetch = () =>
new Promise(resolve => setTimeout(() => resolve("data"), 1000));

我们也知道,Promise 是使用 then 进行任务串联:

fetchTotal()
.then(total => [total, fetchUsers()])
.then(([total, users]) => {});

如果期望 runner 能让我们基于 Promise 构建的任务跳脱出 then 的链式调用,而使用 async/await 的命令式风格来控制流程:

const effect = run(() => {
const total = fetchTotal();
const users = fetchUsers();
});

runner

这能做到吗?首先,我们要思考,JavaScript 是否有能力 阻断后续程序流程 ?答案是有的,但要借助于异常与 try/catch,若流程中跑出了一个异常,那么逻辑就会进入 catch 块,而不会执行后续流程:

const task1 = () => throw "error";

try {
const ret1 = task1();
// task1 抛出异常,task2 不再执行
const ret2 = task2();
} catch (error) {}

try-catch 可以阻断后续代码的逻辑,这帮助我们借道实现了 暂停当前逻辑 ,但我们还要解决:

当进入暂态的任务完成后,被阻断的逻辑能继续执行。

要回到我们的暂停位置,执行后续流程,其实就要:

  1. 重新执行逻辑,回到暂停发生位置,此时要不再 throw error 进入 暂态,而是直接获得任务结果
  2. 继续后续流程

因此,我们需要借助于缓存来缓存任务结果,决定再次执行任务时,是否进入暂态:

// 标识当前缓存位置
let pos = 0;
// 缓存任务运行结果
const cache = [];

const task1 = () => {
if (cache[pos]) {
return cache[pos++];
} else {
throw new Promise(resolve => setTimeout(() => resolve("task1"), 1000));
}
};

const task2 = () => {
if (cache[pos]) {
return cache[pos++];
} else {
throw new Promise(resolve => setTimeout(() => resolve("task2"), 1000));
}
};

const main = () => {
const step = () => {
pos = 0;
try {
const ret1 = task1();
const ret2 = task2();
console.log(ret1, ret2);
} catch (task) {
task.then(value => {
cache[pos++] = value;
// 重启流程
return step();
});
}
};

return step();
};

上例中,重复代码还较多,也并不通用,只能执行 task1 与 task2。我们可以设计一个 runner,负责执行基于 Promise 编排的业务流;另外也针对异步任务做一个 wrapper,控制任务是否应当进入暂态:

// 当前执行的位置
let pos = 0;

// 任务执行结果缓存
let cache = [];

export const wrapTask = task => {
if (cache[pos]) {
return cache[pos++];
}
throw task;
};

const runner = process => {
cache = [];
return step();

function step() {
// 每一步都从头执行
pos = 0;
try {
const ret = process();
return ret;
} catch (task) {
return task.then(value => {
cache[pos] = value;
return step();
});
}
}
};

现在,我们就能使用命令式风格编排基于 Promise 的业务逻辑了,下面的代码中,只有当 task1 和 task2 都完成了,才会执行 console.log,打印结果:

const task1 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task1"), 1000)));

const task2 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task2"), 1000)));

const main = () => {
const ret1 = task1();
const ret2 = task2();
console.log("result is", re1, ret2);
};

run(main); // result is task1 task2

这段代码的执行流程就为:

  • 从头执行业务,执行 task1,发现 cache 中没有 task1 的结果,throw task1
  • task1 resolved, 从头执行业务
  • task1 in cache, 返回 cache 中的结果赋值给 ret,任务游标 + 1,开始后续任务
  • task2 执行,发现 cache 中没有 task2 的结果, throw task2
  • task2 resolved, 从头执行业务
  • task1 in cahce, 返回 cache 中的结果赋值给 ret1,任务游标 + 1,开始后续任务
  • task2 in cahce, 返回 cache 中的结果赋值给 ret2
  • 业务执行完成,退出

OK,我们总结下基于 try/catch 与 Promise 实现暂态的原理:

  • 在 try block 中通过 throw promise 进入暂态
  • 在 catch block 中,通过 promise.then 更新缓存,重启流程,以进行下一步

实现 React Suspense

React 16 推出的 Suspense 除了做 Code-Splitting,还能实现异步组件。借助于上面实现的流程控制,我们也可以实现 React Suspense。首先,我们创建了一个 withAsync 来包裹函数组件:

const withAsync = func => {
class AysncComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
component: null
};
run(() => {
this.setState({
component: func(props)
});
});
}

render() {
return this.state.component;
}
}

return AysncComponent;
};

我们看到,在异步创建后,我们启动了一个 runner,来获得每一次函数组件的运行结果。现在,我们就能通过写出含有异步副作用(基于 Promise)的函数组件了:

const task1 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task1"), 1000)));

const task2 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task2"), 1000)));

const App = withAsync(() => {
const ret1 = task1();
const ret2 = task2();

return (
<ul>
<li>{ret1}</li>
<li>{ret2}</li>
</ul>
);
});

这个 withAsync 不就是我们第一节说要实现的转换器吗。

上面的例子你可以在 CodeSandbox 上查看。

竞态优化

假如我们写了多个函数组件,就像下面这样:

const task1 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task1"), 1000)));

const task2 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task2"), 2000)));

const task3 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task3"), 4000)));

const task4 = () =>
wrapTask(new Promise(resolve => setTimeout(() => resolve("task4"), 5000)));

const App1 = withAsync(() => {
const ret1 = task1();
const ret2 = task2();

return (
<ul>
<li>{ret1}</li>
<li>{ret2}</li>
</ul>
);
});

const App2 = withAsync(() => {
const ret3 = task3();
const ret4 = task4();

return (
<ul>
<li>{ret3}</li>
<li>{ret4}</li>
</ul>
);
});

ReactDOM.render(
<div>
<App1 />
<p>-------------</p>
<App2 />
</div>,
document.querySelector("#app")
);

预期的输出你希望是:

task1
task2
-------------
task3
task4

事与愿违,实际的输出可能是:

task1
task2
-------------
task1
task2

我们来分析下执行流程:

  • 从头执行 <App1 />,执行 task1,发现 cache 中没有 task1 的结果,throw task1
  • 从头执行 <App2 />,执行 task3,发现 cache 中没有 task3 的结果,throw task3
  • task 1 resolved,刷新缓存 ['task1'],从头执行 <App1 />
  • 从缓存中获得 'task1' 赋值给 ret1,pos + 1,throw task2
  • task 2 resolved,刷新缓存为 ['task1', 'task2'],从头执行 <App1 />
  • 赋值 ret1ret2,并输出 task1task2,此时 pos === 2
  • task 3 resovled,刷新缓存为 ['task1', 'task2', 'task3'],从头执行 <App2 />
  • 因为 pos 被重置为 0,所以取出 task1 赋值给 ret3
  • 同理,ret4 将被赋值为 task2。最终,你看到了不符合预期的输出

这个错误的例子你可以访问 这里 查看:

分析至此,你不难看出问题所在,在原始的设计中,我们并没有考虑 竞态处理 ,共享变量 poscache 在各个任务间没有得到保护。

为此,我们考虑:

  • 为每个流程设置其私有的 poscache 上下文
  • 每次运行流程时,绑定当前上下文到当前流程的上下文
  • 每次执行完流程时,释放对外部上下文的绑定
// 当前的流程上下文
let context = {};

export const wrapTask = task => {
// 每次运行 task,如果 task 已经被缓存,则返回
// 并更新
if (context.cache[context.pos]) {
return context.cache[context.pos++];
}
throw task;
};

export default process => {
// 为每个流程设置自己的上下文
const ctx = {
cache: [],
pos: 0
};

return step();

function step() {
// 每次流程执行,重置当前步骤到开头
ctx.pos = 0;
// 缓存当前上下文,执行完成后恢复
const cachedContext = context;
// 将上下文绑定到当前的流程的上下文
context = ctx;

try {
return process();
} catch (task) {
// 记录任务进入暂态时的位置,便于后面知道刷新哪个位置的缓存
const pos = ctx.pos;
return task.then(value => {
// 缓存当前执行结果
ctx.cache[pos] = value;
// 重启流程
return step();
});
} finally {
// 每次执行完,释放对上下文的掌控
context = cachedContext;
}
}
};

基于新的实现,上例中的两个流程 <App1 /><App2 />,每次运行时,都使用各自的上下文来刷新和读取缓存。如果对这个实现不太看得懂,读者不妨自己手写下新的执行流程就一目了然了。

这个新的实现和用例,你可以访问 这里 查看

Hook: useState

上面,我们实现了 函数式异步组件 ,我们再来看看 useState 这个 Hook,先回顾下它的用法:

const Counter = props => {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
};

可以看到,useState 为函数式组件带了状态治理的能力。假定我们要自己实现 useState hook,你会怎么思考?最简单地,可以这么思考:

每次 setState 后,重新运行函数,获得最新状态,并用新的状态渲染组件。

是不是又回到了我们实现 Suspense 是要面对的问题: 反复执行流程,刷新当前结果 。那么我们不妨试试用同样的手段来实现 useState hook:

const useState = initial =>
wrapTask(
Promise.resolve([
initial,
function setState(newState) {
// 如何重启流程?
}
])
);

const process = () => {
const [count, setCount] = useState(0);
const [count2, setCount2] = useState(0);

console.log(`${count} - ${count2}`);

if (count < 10) {
setCount(count + 1);
setCount(count + 2);
}
};

run(process());

但是遗憾的是,在 setState 函数中,我们没有办法重启流程。这是因为:基于 Promise 封装的任务,重启流程的时机是 任务完成(即 Promise 被 resolved),一个 异步 过程,而 useState 的重启时机,是我们调用了 setState 函数之后,这是一个 同步 过程。因此,我们不能使用上面的 runner 来实现 useState Hook。

那该怎么做呢?考虑到我们此时需要调度的任务是一个同步过程,即每次它执行完成后,就要执行下一步动作,那么任务就可以定义为:

type Task = (callback: (value: any) => void) => void;

即任务接受一个 callback 作为参数,当任务完成时,callback 会被调用,去重启流程。

const run = process => {
// 每次运行 task,如果 task 已经被缓存,则返回
// 并更新
if (context.cache[context.pos]) {
return context.cache[context.pos++];
}
throw task;
};

export default process => {
// ...
function step() {
// ...

try {
return process();
} catch (task) {
// 记录任务进入暂态时的位置,便于后面知道刷新哪个位置的缓存
const pos = ctx.pos;
return task(value => {
// 缓存当前执行结果
ctx.cache[pos] = value;
// 重启流程
return step();
});
} finally {
// 每次执行完,释放对上下文的掌控
context = cachedContext;
}
}
};

useState 就可以改造为:

const useState = initial =>
// 此时我们 wrap 的 task 就为接收一个 callback 作为参数的函数
wrapTask(callback =>
callback([
initial,
function setState(newState) {
callback([newState, setState]);
}
])
);

这部分完整实现和例子你可以访问 这里 查看。

了不起的 Monad

读者可能看到了,上面我们对 React Suspense 及 useState hook 的实现,逻辑和代码都是非常类似的。无论我们使用 Promise 还是 callback,目的都是在于 重启流程,以调度后续任务 ,他们只是在 catch 块中重启流程的方式不同,以及任务类型的不同。

如果我们能将 Promise 和 callback 任务 包裹(wrap)或者说提升(lift)到同一数据类型 ,就能泛化出通用的 runner 了,比如像下面这样:

task.next(value => {
cache[pos] = value;
return step();
});

在函数式编程中,Monad>) 便能完成这个任务。关于 Monad 在这里就不做更深入的探讨了,本文介绍的是使用 Monad 去在 JavaScript 解决实际问题,而非深入浅出 Haskell 或者范畴论(笔者也远远不具备这个能力)。

现在,对 Monad,你只需要知道:

对不同类型的值包裹成为 monadic value,以满足使用相同数据结构(类型)进行流程编排

通常,Monad 需要具备下面两个方法:

  • of(value): 能将任意数据类型的 value 包裹成一个 monadic value
  • chain(monad, f): 接受一个 monadic value,该方法能被 Monad 包裹的值,并通过 f 将其转换为另一个 monadic value。

chain 在语义上表述了 monadic value 之间的转换串联,由于其内部能够解除值的包裹,有展开铺平的意味,因此也会被写作 flatMap

现在,为了将 Promise 和 callback 提升为 Monad,就需要实现 ofchain 方法,即确定如何将它们提升为 Monad,又如何串联它们形成的任务:

const createRunner = ({ of, chain }) => process => {
// 在 runner 实现内部,我们不再关心流程是 Promise 还是 callback,
// 它们都能够视作 monad 进行处理

function step() {
// ...
try {
return of(process());
} catch (task) {
// 记录任务进入暂态时的位置,便于后面知道刷新哪个位置的缓存
const pos = ctx.pos;
return chain(task, value => {
// 缓存当前执行结果
ctx.cache[pos] = value;
// 重启流程
return step();
});
} finally {
// 每次执行完,释放对上下文的掌控
context = cachedContext;
}
}
};

const promiseRunner = createRunner({
of: v => Promise.resolve(v),
chain: (m, f) => m.then(f)
});

const callbackRunner = createRunner({
of: v => callback => callback(v),
chain: (m, f) => callback => m(value => f(value)(callback))
});

关于 callback 的 chain 方法的推导,可以看这篇文章:https://itnext.io/promises-continuation-monad-in-javascript-f2d70ceb24a4

现在,通过 CodeSandbox 上的例子,你可以看到,我们的 Suspense 及 useState 都是基于同一 runner 进行实现。

参考资料