React 中的代数效应

1,802 阅读8分钟

代数效应

从概念角度来说, 代数效应是一种方法, 可以"隔离函数中的副作用, 从而让函数变为纯函数"。
这样我们在编写程序逻辑时就不用考虑具体的副作用的实现, 也就是"做什么"和"怎么做"是解耦的。

从实现机制角度来说, 代数效应可以看作是一种执行控制, 函数可以在某个需要执行副作用的地方暂停,保存并跳出当前执行栈, 沿调用栈向上找到这个副作用对应的处理函数(handlers), 处理函数执行完毕, 再从之前暂停的地方继续执行。
这有些类似JS中的try-catch, 只是try-catch之后是不可恢复的。

下面我们通过例子来获得一个对代数效应的具体印象。Dan Abramov 在这篇文章中展示了这样的一个例子:

function getName(user) {
  let name = user.name;
  if (name === null) {
    throw new Error('A girl has no name');
  }
  return name;
}

function makeFriends(user1, user2) {
  user1.friendNames.add(getName(user2));
  user2.friendNames.add(getName(user1));
}

const arya = { name: null };
const gendry = { name: 'Gendry' };
try {
  makeFriends(arya, gendry);
} catch (err) {
  console.log("Oops, that didn't work out: ", err);
}

我们把getName函数视作一个副作用, 在上面的代码里, getName抛错后被顶层的try-catch捕获后, JS引擎会退栈, 清除局部变量, 但如果有一种虚构的语法, 让引擎可以在捕获错误之后有机会从抛出错误的地方恢复执行, 那就是代数效应了:

// 依然是Dan的例子
function getName(user) {
  let name = user.name;
  if (name === null) {
    // 这里不用`throw`, 虚构了一个语法`perform`
    // 基本上和`throw`类似, `perform`会'抛出'一个副作用请求
    // throw new Error('A girl has no name');
    name = perform 'ask_name';
  	
    // 按照我们虚构的语法, 这里应该会恢复执行, 并且name这时应该是有值的
  }
  return name;
}

function makeFriends(user1, user2) {
  user1.friendNames.add(getName(user2));
  user2.friendNames.add(getName(user1));
}

const arya = { name: null };
const gendry = { name: 'Gendry' };
// try-catch 改为虚构的 try-handle
try {
  makeFriends(arya, gendry);
// } catch (err) {
} handle (effect) {
  // 这里就是上面抛出的副作用的处理器
  if (effect === 'ask_name') {
    // 虚构语法`resume`
    // 用`Arya Stark`作为name的值恢复执行
    // 这里也可以是一个异步IO请求获取name的值
    // 也可以是从浏览器缓存中读取的值
    // 这些都是'怎么做', 很自然的, 就实现了'怎么做'和'做什么'的隔离
    resume with 'Arya Stark';
  }
}

Generator(async/await)

JS中的 generatorasync/await都拥有暂停函数执行并恢复的能力, 是否可以用来在JS中实现隔离副作用,也就是代数效应呢?
实际上, redux-saga 就基于generator 实现了副作用分离, 但和上面虚构的那种理想情况不同, generator存在染色"的问题:
generator在暂停当前函数执行后, 控制权是传递到他的调用者中的(在其返回值上调用next()), 若当前的调用者想要在调用栈上向上递交控制权, 那么调用者自身也需要是generator函数。而在虚构的语法中, makeFriends函数并不需要知道getName内通过perform发起了一次副作用请求。
此外, 类似try-catch冒泡的机制, 副作用handler也不需要关注发起副作用请求的函数在调用栈上的层级。
下面我们用generator来改写上面的例子:

// 修改为generator函数
function* getName(user) {
  let name = user.name;
  if (name === null) {
      name = yield 'ask_name';
  }
  return name;
}
// 控制权在这里, 但我们不想在这一层处理, 提交控制权就需要"染色"
// makeFriends也修改为generator函数
function* makeFriends(user1, user2) {
  user1.friendNames.push(yield* getName(user2));
  user2.friendNames.push(yield* getName(user1));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };

// 在顶层获取generator函数控制权
let gen = makeFriends(arya, gendry);
let state = gen.next();
while(!state.done) {
    // 处理副作用
    if (state.value === 'ask_name') {
        // 同样, 这里也可能是异步IO/Mock数据/浏览器缓存读取...
        state = gen.next('Arya Stark');
    }
}

async/await来实现:

async function getName(user, effectHandler) {
  let name = user.name;
  if (name === null) {
      name = await effectHandler(user)
  }
  return name;
}

async function makeFriends(user1, user2) {
  user1.friendNames.push(await getName(user2, retrieveUserName));
  user2.friendNames.push(await getName(user1, retrieveUserName));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };

// 处理副作用
async function retrieveUserName(user) {
    return 'Arya Stark'
}

makeFriends(arya, gendry);

async/await还需要动态注入副作用的处理函数的wrap, 否则getName耦合副作用handler就失去了代数效应隔离副作用应有的灵活度。

React Suspense

先抛开上面说的代数效应, 接下来看看React.Suspense。

React 16.6 版本引入了Suspense, 这是一个标记组件, 类似Fragment, 没有实际渲染内容。
通过使用Suspense, 他的子组件在渲染阶段如果遇到未就绪的数据, React会暂停子组件的渲染, 转而渲染一个fallback组件(loading), 等到数据就绪后, React可以从之前暂停的位置继续子组件的渲染。

Suspense的机制可以简单描述为:

  1. 子组件在渲染阶段读取一个数据源
  2. 若数据未就绪, 子组件抛出一个异常, 异常携带一个thenable的值 (typeof value.then === 'function')
  3. React 调度器接住这个异常, 取出thenable值, 建立对这个值的监听, 在他resolve(或reject)后重新渲染

来看一个官方的例子

const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  // 数据未就绪时, 这里会抛出Promise, React在Promise Resolve后重新渲染
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

事实是, React Suspense的心智模型正是代数效应。

  1. 请求副作用 perform
    上文的例子中, resource.user.read(); 等价于我们虚构的语法中的 perform user, 发起了一个副作用请求, 当然实际还是通过throw抛异常的形式。
  2. 暂停当前函数执行
    React调度器接住了这个异常并暂停了对应组件的渲染, 等价于暂停了当前函数的执行。
  3. 恢复当前函数执行 Resume
    又因为异常值本身是一个Promise, React得以在Promise完成后重启渲染, 虽然这本质上不是函数的恢复执行, 但 "从 React 的角度来看,在 Promise 解决的时候重渲染组件树跟恢复执行没什么区别。只要你的编程模型假定幂等,就可以假装我们可以恢复执行!"

其实开始看到Suspense的实现时, 我觉得挺奇怪的, 抛出一个Promise? 不过相比自己写,Suspense确实能够解决问题, 我觉得最香的就是解决副作用Race Conditions的问题:

function ProfilePage({ user_id }) {  
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(user_id).then(u => setUser(u));  
  }, [id]);
  
  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <h1>{user.name}</h1>
  );
}

比如有样的组件, 参数user_id短时间内变个几次, 真实网络环境下就很容易出现当前fetch得到的user和当前的user_id是不匹配的:

  1. user_id 为 1
  2. 异步获取 user1
  3. user_id 为 2
  4. 异步获取 user2
  5. 异步获取 user2 完成, 当前user设置为user2
  6. 异步获取 user1 完成, 当前user设置为user1...

现在从代数效应这个角度再看Suspense的实现, 因为隔离了副作用, 此时组件内状态设置(setUser)是同步的, 就不需要考虑时序的问题了, 这样一下就很容易理解了!

Suggestion.gif

React Fiber

让我们再深入一点, 刚才还说JS内没有实现代数效应, 用generator模拟也会出现染色的问题, 那么React为何就能模拟出这样的效果呢?

JS引擎的执行机制是调用栈+事件循环, 从某个入口函数开始, 后入先出的执行每个进入调用栈的函数, 直到清空调用栈为止,这中间是不会暂停的。React 16之前渲染工作就是依赖JS引擎的调度, 因此当组件树规模大了后,组件间的递归调用会导致调用栈很深, 中间又无法暂停,UI就会卡顿,因此React 16引入了Fiber来解决这个问题。

Fiber架构下,一个组件对应一个Fiber结构, 每个组件渲染完成后就把执行权交还给React调度器,调度器检查当前帧剩余时间,如果时间没到就继续执行下一个Fiber渲染工作, 否则把执行权交还浏览器以防用户感受到卡顿(直接return即可),同时通过postMessage调度下一轮渲染(产生一个task)。

如果把组件渲染看作函数调用,那么组件对应的Fiber可以视作调用栈桢,每个组件走完渲染阶段即是函数调用完成, 但与调用栈不同, Fiber是链表,并且React一次只调度一个Fiber,所以他可以被打断或恢复。

正是基于这种底层模拟,React才拥有了实现类代数效应的能力, 并且这种执行机制几乎就是协程, 所以后续版本的并发渲染也好理解了。

React hook

React Hook 的心智模型也是代数效应(RFC: React Hooks by sebmarkbage · Pull Request #68 · reactjs/rfcs (github.com)), 下次再来看看他从代数效应角度去理解是什么样的吧。😁

参考资源

Algebraic Effects,以及它在React中的应用 - SegmentFault 思否
React Fiber架构如何从JS引擎手中“夺回”调度权 - SegmentFault 思否
通俗易懂的代数效应 — Overreacted
ReactFiber在并发模式下的运行机制 - 掘金 (juejin.cn)
Fiber Principles: Contributing To Fiber · Issue #7942 · facebook/react (github.com)
Suspense for Data Fetching (Experimental)