React Suspense, React Hooks 与 FP

React Suspense, React Hooks 与 FP

九月 19, 2019

最近看了 一篇很有意思的文章,讲述了 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 中 ApplicativeMonad 的定义

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 实例作为第一个参数。并分别使用 ofchain 函数替代了 Promise.resolvethen 函数。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 以后会让这一段代码重新运行呢?因为在 schedulecatch 代码块中,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,以后再看吧 _(:з」∠)_

参考文献

  1. V. Akimov, “When to use React Suspense vs React Hooks”, Medium, 2019.
  2. B. Milewski, Category Theory for Programmers. ImageWrap, 2018.