查看原文
其他

前 Meta 前端工程师:我又来骂 React 了!

作者是前Facebook/Meta前端工程师,也是近4000颗星react-native-webrtc的作者,现在Google用AngularDart开发前端。

本文摘自Andy Lee:React设计缺陷:缺失useDerivedState与useChildState?

没错又是我,我又来骂React了 ,没有啦,真理不辩不明,我攻击他人的“想法”,我也完全接受他人攻击我的“想法”(人身攻击的就别了,太low),什么面子什么大佬,对我来说没意义,唯一有意义的是享受思考。

至于我的想法是不是原创,我只能说我常常是完全靠自己思考出了一个新想法,才发现早有几千几万星的library甚至framework,没办法这世界太卷了,尤其前端工程更卷 ,但我还是挺欣喜,时常还是能原创想出一些从来没见过的思考。

背景

回到本文的主题,先说背景,

  1. 大家还记得在Class组件时代Controlled vs Uncontrolled组件的冲突么?
  2. 大家知道React 18在严格模式下useEffect会呼叫两次,导致社群炸锅么?
  3. Fetch-on-render vs Render-as-you-fetch的权衡,并且React推荐的Render-as-you-fetch非常难实作。
  4. 并没有一个官方Hook去对应derived state,甚至在Class组件使用getDerivedStateFromProps本来就是buggy的

直接讲结论,这些都是因为React原生没有支援useDerivedState这种概念的API,并且要最优雅的使useDerivedState可以运作,还是必须导入Subscribable可订阅的state(记得我之前写的React Turbo么?),但这次是相对薄薄一层。

范例

有defaultValue的Input组件

function Input({ defaultValue }: { defaultValue: Signal<string> }) {
  const [value, setValue] = useDerivedState<string>(
    set => set(defaultValue()),
    [defaultValue]
  );
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

function Form() {
  const [email, setEmail, emailSignal] = useSignal("a123");
  return <form><Input defaultValue={emailSignal} /></form>
}

不需要编译的Fetch on setState

function Page({pageId}: { pageId: Signal<string> }) {
  const [result] = useDerivedState<Result>(
    async (set) => {
      const id = pageId();
      set({ loading: truedatanull, id });
      const data = await fetch("api/" + id);
      set((result) => (result.id !== id) ? result : { loading: false, data, id };
    },
    [pageId]
  );
  const {loading, data} = result;
  return <div>{loading ? "Loading..." : data }</div>;
}

function App() {
  const [pageId, setPageId, pageIdSignal] = useSignal("");
  return pageId ? <Page pageId={pageIdSignal} /> : <Home>;
}

首先以上例子使用useSignal和useDerivedState新的Hook都已实作可运作。

useSignal

const [state, setState, signal] = useSignal(123);

中的state, setState和useState完全一致、没有任何差别,而signal是一个可以被订阅的东西。

signal.subscribe(() => {});

setState发生时,signal.subscribe(callback)中的callback会同步呼叫,重点是,同步。

useDerivedState

const [derived, setDerived] = useDerivedState(
  (set) => {},
  [signal1, signal2]
);

useDerivedState不过就只是订阅signal.subscribe罢了,在signal被setState时,会同步呼叫callback,并带有一个set参数,方便修改这个derivedState。

回到以上两个例子,都是完全没bug的:

  1. defaultValue Input:无论是getDerivedStateFromProps,或在useEffect甚至render去setState,都是有bug的,尤其hook方法会造成double render。而useDerivedState不会造成double render。
  2. Page:Fetch on render的问题在于:fetch时机被延迟,以及会double render。而我这个实作严格来说是Fetch on setState,在pageId变化时,无论是fetch时机和render次数,都是跟Render-as-you-fetch同等性能。

Fetch waterfalls

defaultValue Input的問題已經用useDerivedState完美解決。

然而我的Fetch on setState在Page组件已经render过后,在切换到不同pageId时运作的很完美,然而在第一次render(比如從Home到Page),依然会有Fetch waterfalls的问题。

Waterfall的问题相当难解决,react-query、useSWR、Apollo其实都是存在Waterfall,社群中真正的解决方案,要嘛是需要编译(Relay),要嘛是非纯前端(Remix),Nextjs在第一页面后也都有Waterfall。

那要怎么不用编译工具也纯前端的解决Waterfall呢?这时真的要出大招了。

function Page({loading, data}) {
  return <div>{loading ? "Loading..." : data }</div>;
}

Page.useState = ({pageId}: { pageId: Signal<string> }) => {
  // 整段代码基本没变
  const [result] = useDerivedState<Result>(
    async (set) => {
      const id = pageId();
      if (!id) return// 加了
      set({ loading: truedatanull, id });
      const data = await fetch("api/" + id);
      set((result) => (result.id !== id) ? result : { loading: false, data, id };
    },
    [pageId]
  );
  const {loading, data} = result;
  return {loading, data};
}

function App() {
  const [pageId, setPageId, pageIdSignal] = useSignal("");
  const pageState = Page.useState({pageId})
  return pageId ? <Page {...pageState} /> : <Home>;
}

严格来说,这个做法不过是Lifting State Up 提升State(即Fully controlled组件)这个官方解法,然而我把一个子组件Page的state hooks提取成一个独立函数成为custom hook,然后到父组件App直接呼叫,再注入回去的做法,我认为是前所未见的,至少我没查到其他人这么做,希望我是世界上第一个 。

这个design pattern,姑且称为useChildState,更好的点在于,官方的Fully controlled是在父组件耦合(coupling)了子组件。而useChildState是解耦的(decoupling),App父组件完全不知道Page子组件有哪些states,App只是Page.useState,然后{...pageState},够解耦了吧?不管你Page.useState里要塞多少东西都没差,App完全不管,甚至还有在Page里nested更多子组件的玩法。

这时候还是需要感谢赞叹一下React Hook的发明,useChildState这个pattern,在Class组件是基本做不到的,而Hook却能够轻鬆优雅的把状态和渲染分开了!我说Hook和Dependency Injection的发明同等地位没有人反对吧。

总结

大致上整个解决方案就是如上所叙,主要是

  1. React必须要提供真正的useDerivedState,这个Hook大大有用处,为了要实现这个Hook,又必须要有Subscribable可订阅的state,如上所说useSignal(但我更希望是整合在目前的useState)
  2. useChildState的pattern设计模式,能分开state和render。因为render是滞后的,甚至在concurrent mode是可被打断的。分开state和render是关键,因为render是很耗性能的,一堆createElement以及vDOM diff,然而useState等纯value计算并不耗能。(如果React再改设计成纯value也耗能的话,我只能说React赶紧被扫进垃圾堆吧)

附录

对于derived state的官方解法如:
1. 用ref、
2. 上述的Fully controlled、
3. useEffect去sync state,我没有展开说,但我完全清楚其概念,但它们都有缺陷,useDerivedState才是真正解方。

对了,useDerivedState似乎Jotai是说有支援,但我用Jotai怎么写都写不出我期望的useDerivedState,所以还是自己稍微实作了。

State management

另外对于上述棘手问题,就连官方都会推诿给一个解决方案,即是用global state management library,然而这是非常不负责任的说法。

我就问,难道我为了控制一个input的default value,我也要把这个input的value存到redux的state tree里么?query result放入redux的话,那等同于所有组件的主要state都要放入redux,也就是组件tree和global state tree要一比一关联,这多反人性用过的都知道。

最重要的是global state破坏了组件的self-contained特性,render哪些UI或许还是解耦的,但state却耦合在global state。

Side effect

我这边稍微勘误一下,useEffect跑两次虽然让社群很不爽,但某种程度上来说,这是对的做法,因为useEffect其实是useSynchronizeImperativeStuff,就算useEffect不跑两次,side effect放在useEffect也不太好,会造成的问题即上述double render和fetch on render。

side effect到底该放在哪呢?这需要开另一篇文章讨论,不过其实有一个前提问题是TMD什么东西是被定义为side effect。

我先给个小结论,fetch data和reset default value压根就不是side effect,他们只不过就是一个set的动作罢了,只不过这个set的动作通常是比较遥远(在子组件里),所以父组件不好控制,所以大家试图用useEffect来trigger。这也就是为什么useDerivedState可以完美解决。

性能

最后关于useChildState的性能问题,它的性能是和官方提倡的Fully controlled一样“差”,没有比较差,但还是有点不爽,解决方案呢?React Turbo。

刚刚测了一下初始化1000个useState(),花了1ms,比如说有100个顶层Route,每个Route平均5层,每层级两个API calls,总共1000状态,用useChildState等于这1000个状态都直接堆到最上层的App组件,需要耗时1ms,还可以接受?

再说有100个顶层Route也会Code splitting吧?(遇性能不决就推给Code splitting),这1ms主要是在initial app load和转换url会消耗,一般使用者打字什么的,url不改变是没有关系。本来fetch data就要花个50ms以上,1ms没有影响的。(顶层组件要React.memo包)

作者:Andy Lee

https://www.zhihu.com/people/dikfiell


- EOF -

推荐阅读  点击标题可跳转

1、React:我们即将和后端 API 告别?

2、React 18 超全升级指南

3、React  Hooks 的原理,有的简单有的不简单


觉得本文对你有帮助?请分享给更多人

关注「大前端技术之路」加星标,提升前端技能

点赞和在看就是最大的支持❤️

继续滑动看下一个
大前端技术之路
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存