React Split Components:一种全新的 React 函数组件写法,再不需要 Hooks

2021-11-08 15:08:58 +08:00
 nanxiaobei

1. 函数组件与 Hooks 的问题

1. Why 函数组件?

为什么 React 官方推崇函数组件? class 组件 "又不是不能用"。

因为函数组件更符合 React UI = f(state) 的哲学理念。

于是 Hooks 来了,给函数组件带来了 "内部变量" 与 "副作用",使其功能完备。同时也是 "逻辑共享" 解决方案。

2. 函数组件的问题

因为函数每次调用,都会把内部变量全都新建一遍,这在开发直觉上,总觉得有些不妥。

UI = f(state) 看起来像是纯函数,传入 state,返回 UI

就像 饭 = 电饭锅(米),但如果 电饭锅 每次煮饭都把 "电路系统" 全都新建一遍,这是反直觉的。

我们更希望 f 就是单纯的煮饭,其它功能是已经 "携带" 的,而不是每次都 "新建"。

3. Hooks 的问题

为解决变量新建问题,React 提供了 useStateuseCallbackuseMemouseRef 等。

state 得用 useState 包一下。传给子组件的复杂数据类型(函数、数组、对象),得用 useCallbackuseMemo 包一下(大计算量也得用 useMemo 包一下)。若需保留变量,得用 useRef 包一下。

而在 useEffectuseCallbackuseMemo 的实现里,必须有 deps 这个东西。

以上种种,都让 Hooks 写起来非常反直觉。我不就是用个变量、用个函数,怎么还得包一层?

不能像 Svelte 那样写代码吗?

2. 解决问题

1. 最符合直觉的 UI = f(state)

function Demo(state) {
  return <div>{state.count}</div>;
}

2. React 是这么工作的:

function Demo(props) {
  return <div>{props.count}</div>;
}

3. 若需组件 "携带" state 与函数,而不是每次新建,那就不能写在组件内:

let count = 0;
const onClick = () => {
  count += 1;
};

function Demo() {
  return <div onClick={onClick}>{count}</div>;
}

分开写破坏了一体性,不太好。有没有办法让组件既保有外部变量,又写在一个函数内?

4. 自然而然的,我们想到了闭包(注意内部返回的才是 React 组件):

function createDemo() {
  let count = 0;

  const onClick = () => {
    count += 1;
  };

  return function Demo() {
    return <div onClick={onClick}>{count}</div>;
  };
}

const Demo = createDemo();

简化写法:

function demo() {
  return () => <div />;
}

const Demo = demo();

此时,onClick 函数不需要用 useCallback 包装,因为它永远不会被新建。使用闭包模式,我们成功解除了对 useCallback 的依赖

写到这里,其实已经讲完了 ... 嗯?那这组件怎么用呢?!

3. 让能力完备

1. 解决 useState 与组件更新:

// 公共辅助函数
const useRender = () => {
  const [, setState] = useState(false);
  return useCallback(() => setState((s) => !s), []);
};

function demo() {
  let render;
  let count = 0;

  const onClick = () => {
    count += 1;
    render();
  };

  return () => {
    render = useRender();

    return (
      <>
        <h1>{count}</h1>
        <button onClick={onClick}>Click me</button>
      </>
    );
  };
}

const Demo = demo();

将组件内才有的 setState,被 "重新赋值" 给外部变量 render,供组件外使用。若需更新,手动调用 render() 即可(当然,函数命名随意比如 update,这里介绍的是设计模式,具体实现没什么约束)。

于是,我们成功解除了对 useState 的依赖

上面已经是个可用的组件了,在这里试试:codesandbox.io/s/react-split-components-1-ycw80

2. 解决 useMemouseRef,解决 props:

function demo() {
  let render;
  let props;

  const getPower = (x) => x * x;

  let count = 0;
  let power = getPower(count); // for useMemo
  const countRef = { current: null }; // for useRef

  const onClick = () => {
    // props 解构必须写在函数内,因为外部初始 props 值为 undefined
    const { setTheme } = props;
    setTheme();

    count += 1;
    power = getPower(count);
    render();
  };

  return (next) => {
    render = useRender();
    props = next;
    const { theme } = next;

    return (
      <>
        <h1>{theme}</h1>
        <h1 ref={countRef}>{count}</h1>
        <h1>{power}</h1>
        <button onClick={onClick}>Click me</button>
      </>
    );
  };
}

const Demo = demo();

propsrender 一样以 "重新赋值" 传递出去。然后我们仔细想一下:通过闭包,useMemouseRef 其实已经不需要了。

useMemouseRef 是因为变量每次都新建,得包一下,而使用闭包,变量不会新建,且组件天然持有变量更新后的值,这一切都是 JS 的运行机制,自然而然。

useMemo 的类似 computed 的运算机制,改成手动触发即可。把 useMemo 的声明式写法改为 "手动调用" 的命令式写法,这更符合直觉(就像 class 组件时代一样)。

于是,我们成功解除了对 useMemouseRef 的依赖

上文代码,在这里试试:codesandbox.io/s/react-split-components-2-wl46b

3. 解决 useEffectuseLayoutEffect

const useRender = () => {
  // 省略其它代码...
  const [layoutUpdated, setLayoutUpdated] = useState();
  const [updated, setUpdated] = useState();

  useLayoutEffect(() => layoutUpdated?.(), [layoutUpdated]);
  useEffect(() => updated?.(), [updated]);

  return useCallback((onUpdated, isLayoutUpdate) => {
    // 省略其它代码...
    if (typeof onUpdated === 'function') {
      (isLayoutUpdate ? setLayoutUpdated : setUpdated)(() => onUpdated);
    }
  }, []);
};

function demo() {
  let render;
  let count = 0;

  const onClick = () => {
    count += 1;
    render(() => {
      console.log(count); // 将在 useEffect 中调用
    });
  };

  return () => {
    render = useRender();

    return (
      <>
        <h1>{count}</h1>
        <button onClick={onClick}>Click me</button>
      </>
    );
  };
}

const Demo = demo();

利用已有的 render 函数来实现 useEffect,这样更简洁(当然也可以另加函数)。

此时,render() 可以直接调用,也可以传入参数,render(onUpdated, isLayoutUpdate)isLayoutUpdate 决定 onUpdated 是在 useEffect 还是 useLayoutEffect 中调用。注意:理论上 render 可以调用多次,但 React 只触发一次更新,所以如果每次都传入 onUpdated,则只有最后一个生效。

于是,我们成功解除了对 useEffectuseLayoutEffect 的依赖

在这里试试:codesandbox.io/s/react-split-components-3-zw6tk

4. 解决 "useMount"

React 组件有个非常基础的需求,在 didMount 中发送接口请求。Hooks 将 didMount 和 didUpdate 统一为 useEffect 后,此需求就多了一个理解步骤,于是无数项目里自行实现了 "useMount"。

上文方案中,外部变量得在组件首次渲染后才赋值,这带来了一个问题:render 在首次 useEffect 之后才可用(所以特意将参数命名为 onUpdated),那 "useMount" 怎么实现呢?我们利用一下 useRender 的参数。

const useRender = (onMounted, isLayoutMount) => {
  // 省略其它代码...
  const layoutMountedRef = useRef(isLayoutMount && onMounted);
  const mountedRef = useRef(!isLayoutMount && onMounted);

  useLayoutEffect(() => layoutMountedRef.current?.(), []);
  useEffect(() => mountedRef.current?.(), []);

  // 省略其它代码...
};

function demo() {
  let render;
  let data;

  const onMounted = () => {
    request().then((res) => {
      data = res.data;
      render();
    });
  };

  return () => {
    render = useRender(onMounted);

    return (
      <>
        <h1>{JSON.stringify(data)}</h1>
      </>
    );
  };
}

const Demo = demo();

这样就行了,在这里试试:codesandbox.io/s/react-split-components-4-y8hn8

5. 其它 Hooks

目前为止,我们已经解决了 useStateuseEffectuseCallbackuseMemouseRefuseLayoutEffect,这些是日常开发中最常用的。官方 Hooks 里还剩下 4 个:useContextuseReduceruseImperativeHandleuseDebugValue,就不一一处理了。

简单来说:如果某个组件内才能拿到的变量,需要在组件外使用,就以重新赋值的方式传出去

在此设计模式下,任何已有需求都是可以被实现的,所谓 "功能完备"。

4. 隆重介绍 React Split Components (RiC)

就像 Higher-Order Components 一样,这种设计模式得有个命名。

考虑到它是把 "变量 + 逻辑" 与 "组件体" 分离的闭包写法,学习 React Server Components 命名格式,我将其命名为 React Split Components,可简称 RiC,小 i 在这里可以很好的表达 "分离" 的特点(主要是搜索后发现,RSC 、RPC 、RLC 、RTC 竟然全被占了,天啊,"split" 一共就 5 个字母)。

React Split Components 的特点:

1. 解除对 Hooks 的依赖,但不是指纯函数组件

通过闭包,天然无需 Hooks 包裹。这能让 React 开发者从 "函数组件的反直觉" 与 "Hooks 的繁琐" 中解放出来,写出类似 Svelte 的纯 JS 直觉代码。

毕竟闭包是 JS 的天然特性。

2. 仅在写法层面,无需 ESLint 支持

其实在设计 useEffect 实现的时候,我想到了一种利用现有代码的写法:将 useEffect(fn, deps) 变为 watch(deps, fn)。但如果这么写,watchdeps 就需要 ESLint 插件支持了(因为 Hooks deps 就需要插件支持,否则很容易出错)。

若无必要,勿增实体。我们要将实现尽可能自然、尽可能简化、尽可能符合直觉。

3. 类似高阶组件,是一种 "设计模式",非 API ,无需库支持

它不是 React 官方 API ,无需构建工具支持(比如 React Server Components 就需要)。

它无需第三方库支持(其实 useRender 可以封装为 npm 包,但考虑到每个人习惯不一、需求不一,所以尽可以自己来实现辅助函数,上文代码可作为参考)。

React Split Components 最终代码示例:codesandbox.io/s/react-split-components-final-9ftjx

5. Hello, RiC

React Split Components (RiC) 示例:

function demo() {
  let render;
  let count = 0;

  const onClick = () => {
    count += 1;
    render();
  };

  return () => {
    render = useRender();

    return (
      <>
        <h1>{count}</h1>
        <button onClick={onClick}>Click me</button>
      </>
    );
  };
}

const Demo = demo();

多么 Svelte ,多么直觉,多么性能自动最优化 bye bye Hooks 。

GitHub: github.com/nanxiaobei/react-split-components

4044 次点击
所在节点    React
42 条回复
ruanyu1
2021-11-08 21:29:57 +08:00
大.....聪明?
aaronlam
2021-11-08 23:51:22 +08:00
我怎么觉得有点脱裤子放屁的感觉?就单从重新赋值,把 render 传到外部就有点不是很优雅。。
hkbarton
2021-11-09 02:15:32 +08:00
```
const onClick = () => {
count += 1;
render();
};
```
这个就很蛋疼,如果想自己控制什么时候刷新界面,那其实不用 react 更好。
ericgui
2021-11-09 02:44:59 +08:00
.......

祝你好运
nanxiaobei
2021-11-09 03:16:00 +08:00
@hkbarton #23

React 难倒不是自己控制什么时候刷新界面的吗?比如

```
setData(res.data);
setLoading(false);
```
SmiteChow
2021-11-09 10:22:37 +08:00
完全没有理解 react 的设计哲学,react 的优点被当作了缺点,react 的官方特性被当作了 tricky skill.

然后自己实现了一套 tricky skill 来割裂 react 模式
theohateonion
2021-11-09 10:46:07 +08:00
@nanxiaobei 意思是每个地方需要使用内置 hook 的地方都必须手写 render ,显式的声明我要 render 了,非常的不 react
nanxiaobei
2021-11-09 11:03:38 +08:00
@theohateonion #27 setData 之类的函数难倒是「隐式」调用的吗? class 组件时代你是怎么开发的? this.setState() 是不是非常的不 React ?
nanxiaobei
2021-11-09 11:04:56 +08:00
@SmiteChow #26 React 不是宗教,你却急着烧香
SmiteChow
2021-11-09 11:07:47 +08:00
@nanxiaobei 实话实说,你水平也在,可以重新搞轮子,但没必要攻击 react 。
CodingNaux
2021-11-09 12:15:07 +08:00
mobx + react 他不香吗?
chnwillliu
2021-11-09 14:15:25 +08:00
@nanxiaobei React 的 setState 和你的 render 还是不一样的,setState 的理念是别直接赋值 state, 用 setState 更新,记住这点就好,当然同样的 set 完了马上 getState 的逻辑也要搞清。

而手动 render 带来的心智负担是,当逻辑复杂存在多层异步嵌套的时候或者更新 state 的逻辑在深层分支里的时候,你要时刻记得在恰当的时候手动调用 render ,最后指不定就成了不管事件回调里改没改 state, 末了都调用一次 render 吧。
theohateonion
2021-11-09 14:35:23 +08:00
@chnwillliu 不能同意更多, @nanxiaobei 想想多层组件嵌套的场景,一个缺乏经验的新手如何能够控制,选择一个合适的时机进行 render 的调用?
nanxiaobei
2021-11-09 15:41:39 +08:00
@SmiteChow #30 其实不知道哪句话让你产生了「攻击 react 」的感觉,首先就是 React 没那么神圣你为啥搞的这么虔诚,其次可以看看我的 GitHub ,做的项目基本都是 React 相关的 😉 https://github.com/nanxiaobei
nanxiaobei
2021-11-09 15:43:56 +08:00
@chnwillliu #32 为啥都是「 React 哲学」一个个哲学大师 ... 来给你看一个直接赋值的 React 开源项目 https://github.com/pmndrs/valtio
nanxiaobei
2021-11-09 15:47:36 +08:00
@chnwillliu #32
「辑复杂存在多层异步嵌套的时候或者更新 state 的逻辑在深层分支里的时候」,其实你知道自己在说什么吗?
React 同步任务里,调用无数次 setState 也只触发一次更新,React 18 会改为异步任务调无数次也只触发一次。
你调用 setData setList setUserData setImgUrl setLoading 的时候,关心「恰当的时候」了吗,不都是直接调用吗?怎么换个马甲就不认识了,真是奇怪。
chnwillliu
2021-11-09 18:27:07 +08:00
@nanxiaobei 你这个漏了,忘记调用 render 的话 UI 就不更新了,很容易出莫名其妙的 bug ,所以最后就成了到处调用 render ,比如 onClick 里套 setTimeout setTimeout 里再 xhr , xhr 回调里判断返回值,再更新 state , 最后你得记得调用你所谓的 render 函数。而 React 说的是你用我规定的方法更新 state ,更新 UI 的事我帮你干了你不用操心。


其实 Angular 的脏检查就是这个方法啦,Angular 会 hook 到所有可能的回调接口,你直接跟常规 js 一样改你的变量,Angular 帮你做脏检查 update UI , 是没有心智负担的。Svelte 的做法是编译插入代码以触发 UI update 逻辑。Vue 是 defineProperty / Proxy 做到你改 data 它帮你更新 UI.

而你的方案是需要使用者自己 call 一个方法来触发 UI 更新,漏了的结果就是 UI 和变量的值不同步,秉承多调用一次没什么大问题的想法,那结果就是每层异步逻辑都要记得调用这个更新方法,虽然有 async / await 会好些。

这就是我不明白的地方,你规避了 React 的缺点,但似乎又引入了更严重的问题。

个人鄙见哈。
nanxiaobei
2021-11-09 18:33:14 +08:00
@chnwillliu 嵌套一堆 setTimeout 那肯定写的不对的,这个 render 函数根本不是重点,完全可以 proxy 搞个数据监听出来,这都不是事
chnwillliu
2021-11-09 18:40:15 +08:00
@nanxiaobei 这么说吧, 在你的例子里如果是有个 if 条件判断才更新 count, 你的 render 调用是放 if 里吧?后来需求改了 加了 else ,else 里还有个 setTimeout 里面更新了 count, 是不是得记得 count 一赋值得记得调用 render ?那实际的业务场景可能更复杂,很多变量交织判断再赋值,你还跟踪什么时候要调用 render 吗?需求又突然改了,原本某个变量只是在逻辑里用了,后来在 UI 里也用到了,那还要捣回去检查这个变量的赋值操作后是否有 render 调用?那解决方案是什么?保底起见都调用一次 render ? onClick 里面 setTimeout 也得这么干不是?嵌套的异步都得记得调 render, 万一里面改了某个变量 UI 里用到了呢?
chnwillliu
2021-11-09 18:47:44 +08:00
@nanxiaobei 不不不,它就是重点的。现如今的几大框架解决的最基本问题就是,怎么更新数据,数据更新了怎么通知 UI 更新。这是他们最大的区别,其他的东西,你有我也可以有,一个库的事。但是数据到 UI 的更新逻辑,是几大框架最大的壁垒。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/813881

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX