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

4010 次点击
所在节点    React
42 条回复
ayang23
2021-11-08 15:19:53 +08:00
如果我一个页面上要用到两个 Demo 组件,内部变量怎么处理?需要用下面语句来先生成两个组件吗:
const Demo1 = demo();
const Demo2 = demo();

<><Demo1/><Demo2/></>
nanxiaobei
2021-11-08 15:28:06 +08:00
@ayang23 #1 是的,理论上是这样的~
Cbdy
2021-11-08 17:07:24 +08:00
我以前也思考过这个设计模式,推荐一个和这个设计模式很配的库:htm

function App() {
return html`<${Demo()}/>`
}




然而并不会有人真正这样用
L1shen
2021-11-08 17:13:41 +08:00
那为什么不用 solidjs 呢
makelove
2021-11-08 17:30:21 +08:00
顶楼上,我最近把一个项目用 solidjs 重写了一次,API 几乎差不多,只不过运行组件结构是静态的,再也不象 react 这么反人类,心情舒服很多,有点回到以前 jquery 经典时代的感觉

当然,solidjs 其实一看并没有一开始认为的那个快上手,要真正理解 solid 精髓还是要个几天的,这方面还是 react 概念上比较简单一点,不过概念简单不代表就是好用。不过也可能是我是从 react 转过去的,没写过同样是反应式的 vue ,从 vue 转过去可能更容易上手。
chnwillliu
2021-11-08 18:44:30 +08:00
这样每次 render 都要手动 forceUpdate ,state 完全不归 React 管,那为什么还要基于 React 魔改?而且还是用 hooks 魔改。你用 class component 魔改起来也简单一点啊,毕竟 forceUpdate onMounted 都直接给你了,不用你绕弯用 useState useEffect 模拟。
nanxiaobei
2021-11-08 18:53:01 +08:00
@chnwillliu #6 不不,这不是 forceUpdate ,这跟 const [count, setCount] = useState(0) 后调用 setCount 是一模一样的,都是触发 re-render 而已。
ruxuan1306
2021-11-08 18:54:16 +08:00
对 React 了解不多,但好像 React 的设计哲学就是纯函数、不可变、无状态。

楼主这种写法感觉有点那种 java 程序员写 javascript 时每个文件 class 起手一样,让人感到不地道。
nanxiaobei
2021-11-08 18:55:25 +08:00
@chnwillliu #6 这也不是魔改 React ,这一些都是原生 JS ,没有任何魔法,就像你不能说是 JS 魔改了 React 。
nanxiaobei
2021-11-08 18:57:14 +08:00
@ruxuan1306 #8 内个 ... 我是 5 年 React 开发经验,开发过好几个 React 相关库( https://github.com/nanxiaobei ),不会任何 Java 😉
chnwillliu
2021-11-08 19:08:01 +08:00
const createRiC = (factory)=>{
return class extends React.Component {
constructor (props) {
super(props);
this.__render = factory ({
update: this.forceUpdate.bind(this)
})
}

render() {
return this.__render();
}
}
}

const Demo = createRiC(({update})=>{
let count = 0;
const onClick = () => {
count++;
update();
};

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

魔改的话,这样也能跑,不必用 hooks 改呀
chnwillliu
2021-11-08 19:14:33 +08:00
@nanxiaobei 你这就是 forceUpdate 呀,只不过 hooks 版本没有 forceUpdate 要借用 useState 返回函数的 update.
chnwillliu
2021-11-08 19:19:09 +08:00
不过也对,函数式组件本来就是会重新跑一边,哪怕你就 setState 更新了其中一个 state. 我的点是,既然都这么用 React 了,那就不要 hooks 了,原来的 class component 兴许改造起来更顺手。
nanxiaobei
2021-11-08 19:20:36 +08:00
@chnwillliu 1. 不是 forceUpdate ,2. 不是 class 组件没有 this ,3. 如果你认为跟 React 官方写法不一样就是「魔改」,那确实是「魔改」,其实 mobx 也是魔改不是吗,高阶组件不也是魔改
momocraft
2021-11-08 19:25:23 +08:00
"2. 解决 useMemo 、useRef ,解决 props:" 中创建的 Demo 的所有 instance 共享一份状态?
nanxiaobei
2021-11-08 19:28:49 +08:00
@momocraft #15 是的,有这个问题。这个 1 楼提到了,已更新 codesandbox 示例代码,v2ex 没法修改文章,可以看这里 https://zhuanlan.zhihu.com/p/430796962
chnwillliu
2021-11-08 19:31:11 +08:00
@nanxiaobei https://reactjs.org/docs/react-component.html#forceupdate 我说的是这个 forceUpdate ,不是 DOM 的 forceUpdate 哈。class component 的 forceUpdate 其实也只是跳过 shouldComponentUpdate ,所以你的 useRender 本质上就是让 React 知道 View 更新了。你用 class component 来做的话,都不需要什么 useRender 。
nanxiaobei
2021-11-08 20:15:22 +08:00
@chnwillliu #17 其实 class 组件是已经被 React 淘汰的东西,当然主要是思考用函数的方式来解决
XCFOX
2021-11-08 20:18:48 +08:00
推荐 https://github.com/pmndrs/valtio ,使用 proxy 跟踪状态的变化来更新组件,非常符合直觉,状态与视图天生分离,省去了组件间通讯的各种麻烦。
当然如果允许的话最好是直接用 Vue3 或者 Svelte 。
momomirage
2021-11-08 21:00:41 +08:00
为了绕开 react 自己管理 render 要想这么多 何必还要用 react 呢

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

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

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

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

© 2021 V2EX