1. Why 函数组件?
为什么 React 官方推崇函数组件? class 组件 "又不是不能用"。
因为函数组件更符合 React UI = f(state)
的哲学理念。
于是 Hooks 来了,给函数组件带来了 "内部变量" 与 "副作用",使其功能完备。同时也是 "逻辑共享" 解决方案。
2. 函数组件的问题
因为函数每次调用,都会把内部变量全都新建一遍,这在开发直觉上,总觉得有些不妥。
UI = f(state)
看起来像是纯函数,传入 state
,返回 UI
。
就像 饭 = 电饭锅(米)
,但如果 电饭锅
每次煮饭都把 "电路系统" 全都新建一遍,这是反直觉的。
我们更希望 f
就是单纯的煮饭,其它功能是已经 "携带" 的,而不是每次都 "新建"。
3. Hooks 的问题
为解决变量新建问题,React 提供了 useState
、useCallback
、useMemo
、useRef
等。
state 得用 useState
包一下。传给子组件的复杂数据类型(函数、数组、对象),得用 useCallback
、useMemo
包一下(大计算量也得用 useMemo
包一下)。若需保留变量,得用 useRef
包一下。
而在 useEffect
与 useCallback
、useMemo
的实现里,必须有 deps
这个东西。
以上种种,都让 Hooks 写起来非常反直觉。我不就是用个变量、用个函数,怎么还得包一层?
不能像 Svelte 那样写代码吗?
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
的依赖。
写到这里,其实已经讲完了 ... 嗯?那这组件怎么用呢?!
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. 解决 useMemo
、useRef
,解决 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();
props
与 render
一样以 "重新赋值" 传递出去。然后我们仔细想一下:通过闭包,useMemo
与 useRef
其实已经不需要了。
useMemo
和 useRef
是因为变量每次都新建,得包一下,而使用闭包,变量不会新建,且组件天然持有变量更新后的值,这一切都是 JS 的运行机制,自然而然。
而 useMemo
的类似 computed 的运算机制,改成手动触发即可。把 useMemo
的声明式写法改为 "手动调用" 的命令式写法,这更符合直觉(就像 class 组件时代一样)。
于是,我们成功解除了对 useMemo
、useRef
的依赖。
上文代码,在这里试试:codesandbox.io/s/react-split-components-2-wl46b
3. 解决 useEffect
与 useLayoutEffect
:
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
,则只有最后一个生效。
于是,我们成功解除了对 useEffect
、useLayoutEffect
的依赖。
在这里试试: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
目前为止,我们已经解决了 useState
、useEffect
、useCallback
、useMemo
、useRef
、useLayoutEffect
,这些是日常开发中最常用的。官方 Hooks 里还剩下 4 个:useContext
、useReducer
、useImperativeHandle
、useDebugValue
,就不一一处理了。
简单来说:如果某个组件内才能拿到的变量,需要在组件外使用,就以重新赋值的方式传出去。
在此设计模式下,任何已有需求都是可以被实现的,所谓 "功能完备"。
就像 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)
。但如果这么写,watch
的 deps
就需要 ESLint 插件支持了(因为 Hooks deps
就需要插件支持,否则很容易出错)。
若无必要,勿增实体。我们要将实现尽可能自然、尽可能简化、尽可能符合直觉。
3. 类似高阶组件,是一种 "设计模式",非 API ,无需库支持
它不是 React 官方 API ,无需构建工具支持(比如 React Server Components 就需要)。
它无需第三方库支持(其实 useRender
可以封装为 npm 包,但考虑到每个人习惯不一、需求不一,所以尽可以自己来实现辅助函数,上文代码可作为参考)。
React Split Components 最终代码示例:codesandbox.io/s/react-split-components-final-9ftjx
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 。
感谢 @ayang23 #1 提示,
对此问题,我想到了一个解决方案,文中所有 codesandbox 示例都更新了。
解决闭包的数据共享问题,动态生成每个组件实例自己的闭包数据:
const create = (fn) => (props) => {
const [ins] = useState(() => fn());
return ins(props);
};
function demo() {
return () => <div />;
}
const Demo = create(demo);
1
ayang23 2021-11-08 15:19:53 +08:00
如果我一个页面上要用到两个 Demo 组件,内部变量怎么处理?需要用下面语句来先生成两个组件吗:
const Demo1 = demo(); const Demo2 = demo(); <><Demo1/><Demo2/></> |
2
nanxiaobei OP @ayang23 #1 是的,理论上是这样的~
|
3
Cbdy 2021-11-08 17:07:24 +08:00 via Android
我以前也思考过这个设计模式,推荐一个和这个设计模式很配的库:htm
function App() { return html`<${Demo()}/>` } 然而并不会有人真正这样用 |
4
L1shen 2021-11-08 17:13:41 +08:00
那为什么不用 solidjs 呢
|
5
makelove 2021-11-08 17:30:21 +08:00
顶楼上,我最近把一个项目用 solidjs 重写了一次,API 几乎差不多,只不过运行组件结构是静态的,再也不象 react 这么反人类,心情舒服很多,有点回到以前 jquery 经典时代的感觉
当然,solidjs 其实一看并没有一开始认为的那个快上手,要真正理解 solid 精髓还是要个几天的,这方面还是 react 概念上比较简单一点,不过概念简单不代表就是好用。不过也可能是我是从 react 转过去的,没写过同样是反应式的 vue ,从 vue 转过去可能更容易上手。 |
6
chnwillliu 2021-11-08 18:44:30 +08:00 via Android 1
这样每次 render 都要手动 forceUpdate ,state 完全不归 React 管,那为什么还要基于 React 魔改?而且还是用 hooks 魔改。你用 class component 魔改起来也简单一点啊,毕竟 forceUpdate onMounted 都直接给你了,不用你绕弯用 useState useEffect 模拟。
|
7
nanxiaobei OP @chnwillliu #6 不不,这不是 forceUpdate ,这跟 const [count, setCount] = useState(0) 后调用 setCount 是一模一样的,都是触发 re-render 而已。
|
8
ruxuan1306 2021-11-08 18:54:16 +08:00
对 React 了解不多,但好像 React 的设计哲学就是纯函数、不可变、无状态。
楼主这种写法感觉有点那种 java 程序员写 javascript 时每个文件 class 起手一样,让人感到不地道。 |
9
nanxiaobei OP @chnwillliu #6 这也不是魔改 React ,这一些都是原生 JS ,没有任何魔法,就像你不能说是 JS 魔改了 React 。
|
10
nanxiaobei OP @ruxuan1306 #8 内个 ... 我是 5 年 React 开发经验,开发过好几个 React 相关库( https://github.com/nanxiaobei ),不会任何 Java 😉
|
11
chnwillliu 2021-11-08 19:08:01 +08:00 via Android
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 改呀 |
12
chnwillliu 2021-11-08 19:14:33 +08:00 via Android
@nanxiaobei 你这就是 forceUpdate 呀,只不过 hooks 版本没有 forceUpdate 要借用 useState 返回函数的 update.
|
13
chnwillliu 2021-11-08 19:19:09 +08:00 via Android
不过也对,函数式组件本来就是会重新跑一边,哪怕你就 setState 更新了其中一个 state. 我的点是,既然都这么用 React 了,那就不要 hooks 了,原来的 class component 兴许改造起来更顺手。
|
14
nanxiaobei OP @chnwillliu 1. 不是 forceUpdate ,2. 不是 class 组件没有 this ,3. 如果你认为跟 React 官方写法不一样就是「魔改」,那确实是「魔改」,其实 mobx 也是魔改不是吗,高阶组件不也是魔改
|
15
momocraft 2021-11-08 19:25:23 +08:00
"2. 解决 useMemo 、useRef ,解决 props:" 中创建的 Demo 的所有 instance 共享一份状态?
|
16
nanxiaobei OP @momocraft #15 是的,有这个问题。这个 1 楼提到了,已更新 codesandbox 示例代码,v2ex 没法修改文章,可以看这里 https://zhuanlan.zhihu.com/p/430796962
|
17
chnwillliu 2021-11-08 19:31:11 +08:00 via Android
@nanxiaobei https://reactjs.org/docs/react-component.html#forceupdate 我说的是这个 forceUpdate ,不是 DOM 的 forceUpdate 哈。class component 的 forceUpdate 其实也只是跳过 shouldComponentUpdate ,所以你的 useRender 本质上就是让 React 知道 View 更新了。你用 class component 来做的话,都不需要什么 useRender 。
|
18
nanxiaobei OP @chnwillliu #17 其实 class 组件是已经被 React 淘汰的东西,当然主要是思考用函数的方式来解决
|
19
XCFOX 2021-11-08 20:18:48 +08:00
推荐 https://github.com/pmndrs/valtio ,使用 proxy 跟踪状态的变化来更新组件,非常符合直觉,状态与视图天生分离,省去了组件间通讯的各种麻烦。
当然如果允许的话最好是直接用 Vue3 或者 Svelte 。 |
20
momomirage 2021-11-08 21:00:41 +08:00 6
为了绕开 react 自己管理 render 要想这么多 何必还要用 react 呢
|
21
ruanyu1 2021-11-08 21:29:57 +08:00
大.....聪明?
|
22
aaronlam 2021-11-08 23:51:22 +08:00
我怎么觉得有点脱裤子放屁的感觉?就单从重新赋值,把 render 传到外部就有点不是很优雅。。
|
23
hkbarton 2021-11-09 02:15:32 +08:00
```
const onClick = () => { count += 1; render(); }; ``` 这个就很蛋疼,如果想自己控制什么时候刷新界面,那其实不用 react 更好。 |
24
ericgui 2021-11-09 02:44:59 +08:00
.......
祝你好运 |
25
nanxiaobei OP |
26
SmiteChow 2021-11-09 10:22:37 +08:00
完全没有理解 react 的设计哲学,react 的优点被当作了缺点,react 的官方特性被当作了 tricky skill.
然后自己实现了一套 tricky skill 来割裂 react 模式 |
27
theohateonion 2021-11-09 10:46:07 +08:00
@nanxiaobei 意思是每个地方需要使用内置 hook 的地方都必须手写 render ,显式的声明我要 render 了,非常的不 react
|
28
nanxiaobei OP @theohateonion #27 setData 之类的函数难倒是「隐式」调用的吗? class 组件时代你是怎么开发的? this.setState() 是不是非常的不 React ?
|
29
nanxiaobei OP @SmiteChow #26 React 不是宗教,你却急着烧香
|
30
SmiteChow 2021-11-09 11:07:47 +08:00 1
@nanxiaobei 实话实说,你水平也在,可以重新搞轮子,但没必要攻击 react 。
|
31
CodingNaux 2021-11-09 12:15:07 +08:00
mobx + react 他不香吗?
|
32
chnwillliu 2021-11-09 14:15:25 +08:00 via Android 1
@nanxiaobei React 的 setState 和你的 render 还是不一样的,setState 的理念是别直接赋值 state, 用 setState 更新,记住这点就好,当然同样的 set 完了马上 getState 的逻辑也要搞清。
而手动 render 带来的心智负担是,当逻辑复杂存在多层异步嵌套的时候或者更新 state 的逻辑在深层分支里的时候,你要时刻记得在恰当的时候手动调用 render ,最后指不定就成了不管事件回调里改没改 state, 末了都调用一次 render 吧。 |
33
theohateonion 2021-11-09 14:35:23 +08:00
@chnwillliu 不能同意更多, @nanxiaobei 想想多层组件嵌套的场景,一个缺乏经验的新手如何能够控制,选择一个合适的时机进行 render 的调用?
|
34
nanxiaobei OP @SmiteChow #30 其实不知道哪句话让你产生了「攻击 react 」的感觉,首先就是 React 没那么神圣你为啥搞的这么虔诚,其次可以看看我的 GitHub ,做的项目基本都是 React 相关的 😉 https://github.com/nanxiaobei
|
35
nanxiaobei OP @chnwillliu #32 为啥都是「 React 哲学」一个个哲学大师 ... 来给你看一个直接赋值的 React 开源项目 https://github.com/pmndrs/valtio
|
36
nanxiaobei OP @chnwillliu #32
「辑复杂存在多层异步嵌套的时候或者更新 state 的逻辑在深层分支里的时候」,其实你知道自己在说什么吗? React 同步任务里,调用无数次 setState 也只触发一次更新,React 18 会改为异步任务调无数次也只触发一次。 你调用 setData setList setUserData setImgUrl setLoading 的时候,关心「恰当的时候」了吗,不都是直接调用吗?怎么换个马甲就不认识了,真是奇怪。 |
37
chnwillliu 2021-11-09 18:27:07 +08:00 via Android
@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 的缺点,但似乎又引入了更严重的问题。 个人鄙见哈。 |
38
nanxiaobei OP @chnwillliu 嵌套一堆 setTimeout 那肯定写的不对的,这个 render 函数根本不是重点,完全可以 proxy 搞个数据监听出来,这都不是事
|
39
chnwillliu 2021-11-09 18:40:15 +08:00 via Android
@nanxiaobei 这么说吧, 在你的例子里如果是有个 if 条件判断才更新 count, 你的 render 调用是放 if 里吧?后来需求改了 加了 else ,else 里还有个 setTimeout 里面更新了 count, 是不是得记得 count 一赋值得记得调用 render ?那实际的业务场景可能更复杂,很多变量交织判断再赋值,你还跟踪什么时候要调用 render 吗?需求又突然改了,原本某个变量只是在逻辑里用了,后来在 UI 里也用到了,那还要捣回去检查这个变量的赋值操作后是否有 render 调用?那解决方案是什么?保底起见都调用一次 render ? onClick 里面 setTimeout 也得这么干不是?嵌套的异步都得记得调 render, 万一里面改了某个变量 UI 里用到了呢?
|
40
chnwillliu 2021-11-09 18:47:44 +08:00 via Android
@nanxiaobei 不不不,它就是重点的。现如今的几大框架解决的最基本问题就是,怎么更新数据,数据更新了怎么通知 UI 更新。这是他们最大的区别,其他的东西,你有我也可以有,一个库的事。但是数据到 UI 的更新逻辑,是几大框架最大的壁垒。
|
41
jjwjiang 2021-12-22 15:30:41 +08:00
到 useState 前的替换尚觉得合理
到了 useEffect 以及以后之后我觉得已经比原来还复杂了 |
42
yusifeng 2022-01-14 09:35:23 +08:00
都这样的话,那用 proxy 包一下 count ,这样就可以不直接调用 render() 了,我觉得楼主的写法这像是在一个框架里找另外一个框架的感觉,还有一点我担心的就是,render() 和直接调用 setState() 有没有区别,在 diff 算法的时候会不会有去别啊(我没看过源码,所以不知道~),总之很高兴看到楼主的写法, 因为如果是我的话,根本不会有这方面的思考,感谢!!
|