亲爱的 React.js,
我们已经共度了将近十年的时光. 携手走过了很长的一段路. 但是事情逐渐开始失控,我们需要谈谈了。
我知道这很尴尬,没有人会想要进行这种谈话。所以我将会把我想要表达的内容写在这首歌里。
噢baby, 你曾是我的唯一
我不是第一次爱上JS。在你之前,我曾经与jQuery、Backbone.js、Angular.js都有过交集。我知道我可以从与 JavaScript 框架的关系中得到什么:更好的 UI、更高的生产力和更流畅的开发人员体验——但也有不断改变我对代码的认知来匹配框架的思维方式所带来的挫败感。
当我遇到你时。 我刚刚与 Angular.js结束了一段关系。 我被watch 和 digest搞得精疲力竭, 更不用说scope了。我一直在寻找那个不会令我痛苦的JS框架。
我与你一见钟情。 你的单向数据绑定 是如此的令人耳目一新。我在数据同步和性能方面遇到的问题对你来说根本不存在。你是纯 JavaScript,而不是使用字符串对 HTML 元素进行拙劣模仿。你拥有“声明性组件”,它太漂亮了,每个人都在盯着你看。
与你相处并不容易。我不得不努力改善我的编程习惯来适应你,但这是值得的! 最初,我与你相处得十分愉快,所以我到处向别人安利你。
Form的新英雄
当我想让你处理Form时,事情的走向逐渐奇怪了起来。使用vanilla JS很难处理Forms和输入,但是使用React时甚至更困难。
起初,开发者不得不在 受控输入 和 非受控输入之间做出选择。 在极端情况下,两者都有缺点和错误。但为什么我必须首先做出选择?两种形式策略太多了。
被“推荐”的受控组件太冗长了。这是一个加法表单所需要的代码:
import React, { useState } from 'react';
export default () => {
const [a, setA] = useState(1);
const [b, setB] = useState(2);
function handleChangeA(event) {
setA(+event.target.value);
}
function handleChangeB(event) {
setB(+event.target.value);
}
return (
<div>
<input type="number" value={a} onChange={handleChangeA} />
<input type="number" value={b} onChange={handleChangeB} />
<p>
{a} + {b} = {a + b}
</p>
</div>
);
};
如果只有两种方法,我会很高兴。但由于构建具有默认值、验证、依赖输入和错误消息的真实表单需要大量代码,我不得不使用第三方表单框架。他们都有某些方面的不足。
- Redux-form : 当我们使用Redux时,Redux-form看起来像是一个自然而然的选择,但后来他的开发者放弃了它。
- React-final-form: React-final-form充满了未修复的bug, 它的开发者也放弃了它。
- Formik: Formik被广泛使用,但是它太重了...对于大型表单来说速度较慢,并且功能有限。
- React-hook-form: 最终我们决定使用React-hook-form。它速度很快,但有隐藏的Bug,并且文档结构像迷宫。
在使用 React 构建表单多年之后,我仍然难以通过清晰的代码来提供强大的用户体验。当我看到Svelte如何处理表单时,我不禁觉得我被错误的抽象理念所束缚。看看这个加法表单:
<script>
let a = 1;
let b = 2;
</script>
<input type="number" bind:value={a}>
<input type="number" bind:value={b}>
<p>{a} + {b} = {a + b}</p>
你对Context太敏感了
在我们认识后不久,你就像我介绍了你的宠物Redux。没有它,你哪也去不了。我起初并不介意,因为它很可爱。但是随后我意识到这个世界在围着它转。它使得构建框架更为困难-其它开发者不能很轻松地调整应用与现有的reducers的关系。
你注意到了这点,但是你决定使用你自己的useContext来摆脱它。但是useContext缺少 Redux 的一个关键特性:对部分上下文变化做出反应的能力。这两者在性能方面并不相等:
// Redux
const name = useSelector(state => state.user.name);
// React context
const { name } = useContext(UserContext);
在第一个例子汇总,组件只有在user.name变化时才会重新渲染。而在第二个例子中,任何user中的变化都会导致组件重新渲染。这很重要,以至于我们必须拆分上下文以避免不必要的重新渲染。
// this is crazy but we can't do otherwise
export const CoreAdminContext = props => {
const {
authProvider,
basename,
dataProvider,
i18nProvider,
store,
children,
history,
queryClient,
} = props;
return (
<AuthContext.Provider value={authProvider}>
<DataProviderContext.Provider value={dataProvider}>
<StoreContextProvider value={store}>
<QueryClientProvider client={queryClient}>
<AdminRouter history={history} basename={basename}>
<I18nContextProvider value={i18nProvider}>
<NotificationContextProvider>
<ResourceDefinitionContextProvider>
{children}
</ResourceDefinitionContextProvider>
</NotificationContextProvider>
</I18nContextProvider>
</AdminRouter>
</QueryClientProvider>
</StoreContextProvider>
</DataProviderContext.Provider>
</AuthContext.Provider>
);
};
大多数时候,当我遇到你的性能问题时,是因为上下文很大,我别无选择,只能拆分它。
我不想使用useMemoor useCallback。无用的重新渲染是你的问题,不是我的。但是你强迫我这样做。看看我应该如何构建一个简单的表单输入来让它快速:
// from https://react-hook-form.com/advanced-usage/#FormProviderPerformance
const NestedInput = memo(
({ register, formState: { isDirty } }) => (
<div>
<input {...register('test')} />
{isDirty && <p>This field is dirty</p>}
</div>
),
(prevProps, nextProps) =>
prevProps.formState.isDirty === nextProps.formState.isDirty,
);
export const NestedInputContainer = ({ children }) => {
const methods = useFormContext();
return <NestedInput {...methods} />;
};
已经10年了,你还是有这个缺陷。提供一个useContextSelector有多难?
你知道这一点。但是您正在别的地方忙活,即使它可能是你最重要的性能瓶颈。
这些东西我一个也不想要
你和我说为了我好,我不应该直接操作DOM节点。我从来没觉得直接操作DOM是不好的行为,但是为了不打扰你,我再也没这样干过。现在我按你说的,乖乖使用refs。
但是ref像个病毒一样到处传播。大部分情况下,当组件使用 ref 时,它会将其传递给子组件。如果第二个组件是 React 组件,它必须将 ref 转发给另一个组件,依此类推,直到树中的一个组件最终呈现 HTML 元素。所以代码库最终会到处转发 refs,降低了过程中的易读性。
转发ref可以像这样简单:
const MyComponent = props => <div ref={props.ref}>Hello, {props.name}!</div>;
但是你觉得这样不够有逼格,所以你发明了这种react.forwardRef可憎的东西:
const MyComponent = React.forwardRef((props, ref) => (
<div ref={ref}>Hello, {props.name}!</div>
));
你可能会问,为啥这么难? 因为 使用TypeScript时,你无法用forwardRef构造泛型组件.
// 我该怎么转发这个?
const MyComponent = <T>(props: <ComponentProps<T>) => (
<div ref={/* pass ref here */}>Hello, {props.name}!</div>
);
此外,你已经确定 refs 不仅是 DOM 节点,在函数组件中它们等同this。或者,换句话说,ref是“不触发重新渲染的state”。以我的经验,每次我不得不使用这样的 ref,都是因为你——因为你的useEffectAPI 太奇怪了。换句话说,refs是您创建的问题的解决方案。
蝴蝶效应一样的useEffect(The Butterfly (use) Effect)
说到useEffect,我有一个个人问题。。我认识到这是一项优雅的创新,它在一个 API 中涵盖了挂载、卸载和更新事件。但这该算作进步么?
// 使用生命函数
class MyComponent {
componentWillUnmount: () => {
// do something
};
}
// 使用 useEffect
const MyComponent = () => {
useEffect(() => {
return () => {
// do something
};
}, []);
};
你看, 这孤独的一行代表了我对useEffect的忧伤:
}, []);
我在我的代码中看到了如此神秘的一套符号,这都是因为useEffect。 另外,你强迫我跟踪依赖关系,就像在这段代码中一样:
// change page if there is no data
useEffect(() => {
if (
query.page <= 0 ||
(!isFetching && query.page > 1 && data?.length === 0)
) {
// Query for a page that doesn't exist, set page to 1
queryModifiers.setPage(1);
return;
}
if (total == null) {
return;
}
const totalPages = Math.ceil(total / query.perPage) || 1;
if (!isFetching && query.page > totalPages) {
// Query for a page out of bounds, set page to the last existing page
// It occurs when deleting the last element of the last page
queryModifiers.setPage(totalPages);
}
}, [isFetching, query.page, query.perPage, data, queryModifiers, total]);
看到最后一行了吗?我必须确保我添加了所有反应变量在依赖数组中。而且我认为引用计数是所有带有垃圾收集器的语言的原生特性。但是不,我必须自己对依赖项进行微操作,因为你不知道该怎么做。
很多时候,这些依赖项之一是我创建的函数。因为你不区分变量和函数,所以我必须使用useCallback来让你不重新渲染。同样的结果,同样的最终加密签名:
const handleClick = useCallback(
async event => {
event.persist();
const type =
typeof rowClick === 'function'
? await rowClick(id, resource, record)
: rowClick;
if (type === false || type == null) {
return;
}
if (['edit', 'show'].includes(type)) {
navigate(createPath({ resource, id, type }));
return;
}
if (type === 'expand') {
handleToggleExpand(event);
return;
}
if (type === 'toggleSelection') {
handleToggleSelection(event);
return;
}
navigate(type);
},
[
// oh god, please no
rowClick,
id,
resource,
record,
navigate,
createPath,
handleToggleExpand,
handleToggleSelection,
],
);
因为我必须管理这个依赖地狱,一个带有一些事件处理程序和生命周期回调函数的简单组件会最终会变成屎山代码。所有这一切都是因为你认为一个组件可以执行任意次数。
如果我想要写一个计数器,在用户每次点击按钮时增加1秒,我必须这样做:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count => count + 1);
}, [setCount]);
useEffect(() => {
const id = setInterval(() => {
setCount(count => count + 1);
}, 1000);
return () => clearInterval(id);
}, [setCount]);
useEffect(() => {
console.log('The count is now', count);
}, [count]);
return <button onClick={handleClick}>Click Me</button>;
}
如果你知道如何追踪依赖,我可以这样简写。
function Counter() {
const [count, setCount] = createSignal(0);
const handleClick = () => setCount(count() + 1);
const timer = setInterval(() => setCount(count() + 1), 1000);
onCleanup(() => clearInterval(timer));
createEffect(() => {
console.log('The count is now', count());
});
return <button onClick={handleClick}>Click Me</button>;
}
对了,这是 Solid.js 。
最后,明智地使用useEffect需要阅读53 页的论文。我必须说,这是一个了不起的文档。但是如果一个库需要我翻几十页才能正确使用它,这不就是它设计得不好的标志吗?
方向错了
因为我们讨论了抽象泄漏,就是 useEffect, 你尝试改进它。你向我介绍 useEvent, useInsertionEffect, useDeferredValue, useSyncWithExternalStore和其他噱头。
它们确实让你看起来很漂亮:
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true, // How to get the value on the server
);
}
但对我来说,这就像是给猪涂口红。如果反应式effects更容易使用,您就不需要所有这些其Hooks了。
换句话说:除了越来越多地扩展核心 API 之外,您没有其他解决方案。对于像我这样必须维护庞大代码库的人来说,这种持续的 API 膨胀是一场噩梦。看到你每天做的得越来越多,好像在不断提醒你要隐藏什么。
严格的规则
你的Hooks是个好主意,但它们是有代价的。这个成本就是 Rules of Hooks。它们不容易记住,也不容易付诸实践。但是他们强迫我花时间在不需要它的代码上。
例如,我有一个可以由终端用户拖动的“检查器”组件。用户可以隐藏检查器。隐藏时,检查器组件不呈现任何内容。所以我很想“早点退出”,避免白白注册事件监听器。
const Inspector = ({ isVisible }) => {
if (!isVisible) {
// leave early
return null;
}
useEffect(() => {
// Register event listeners
return () => {
// Unregister event listeners
};
}, []);
return <div>...</div>;
};
但是不,这违反了Hooks的规则,因为useEffect钩子可能会或可能不会根据props执行。相反,我必须为所有effects添加一个条件,以便它们在isVisibleprops为false时提前离开:
const Inspector = ({ isVisible }) => {
useEffect(() => {
if (!isVisible) {
return;
}
// Register event listeners
return () => {
// Unregister event listeners
};
}, [isVisible]);
if (!isVisible) {
// leave not so early
return null;
}
return <div>...</div>;
};
因此,所有effects在其依赖项中都包含着isVisible,并且可能会过于频繁地运行(这会损害性能)。我知道我应该创建一个中间组件,如果isVisible等于false的时候,它什么都不渲染。但为什么?这只是钩子规则妨碍我的一个例子——还有很多其他的。一个后果是我的 React 代码库的大部分代码都用于满足 Hooks 规则。
这些Hooks的规则是为了实现细节的结果,但不一定非得那样。
你已经走的太远了
你从 2013 年就开始工作了,而且你已经强调尽可能长时间地保持向后兼容性。我对此表示感谢 - 这就是我能够与您一起构建庞大代码库的原因之一。但是这种向后兼容性是有代价的:在文档和社区资源中大部分是过时的,最坏的情况是具有误导性。
例如,当我在 StackOverflow 上搜索“React 鼠标位置”时,第一个结果提供了这个解决方案,它在一个世纪前就已经过时了:
class ContextMenu extends React.Component {
state = {
visible: false,
};
render() {
return (
<canvas
ref="canvas"
className="DrawReflect"
onMouseDown={this.startDrawing}
/>
);
}
startDrawing(e) {
console.log(
e.clientX - e.target.offsetLeft,
e.clientY - e.target.offsetTop,
);
}
drawPen(cursorX, cursorY) {
// Just for showing drawing information in a label
this.context.updateDrawInfo({
cursorX: cursorX,
cursorY: cursorY,
drawingNow: true,
});
// Draw something
const canvas = this.refs.canvas;
const canvasContext = canvas.getContext('2d');
canvasContext.beginPath();
canvasContext.arc(
cursorX,
cursorY /* start position */,
1 /* radius */,
0 /* start angle */,
2 * Math.PI /* end angle */,
);
canvasContext.stroke();
}
}
当我为特定的 React 功能寻找 npm 包时,我大多会发现具有过时语法的废弃npm包。以react-draggable为例。它是使用 React 实现拖放的事实标准。它有许多未解决的问题,开发活动少。也许是因为它仍然是基于class组件的——当代码库太旧时,很难吸引贡献者。
至于您的官方文档,他们仍然建议使用componentDidMountandcomponentWillUnmount而不是useEffect. 在过去的两年里,核心团队一直在开发一个名为Beta docs的新版本。他们还没有准备好迎接黄金时段。
总而言之,向hooks的漫长迁移还没有结束,它在社区中产生了明显的碎片化。新开发人员努力在 React 生态系统中找到自己的位置,而老开发人员则努力跟上最新的发展。
一些家庭琐事
起初,你父亲的 Facebook 看起来超级酷。Facebook 想要“让人们更紧密地联系在一起”——算上我吧!每当我拜访你的父母时,我都会结识新朋友。
但后来事情变得一团糟。你的父母参加了一个人群操纵计划。他们发明了“假新闻”的概念。他们开始在未经他们同意的情况下保存每个人的文件。拜访你的父母变得很可怕——以至于几年前我已经删除了自己的 Facebook 帐户。
我知道——你不能让孩子为父母的行为负责。但你仍然和他们住在一起。他们资助你的发展。他们是您最大的用户。你依赖他们。如果有一天,他们因为他们的行为而跌倒,你会和他们一起跌倒。
其他主要的 JS 框架已经能够摆脱父辈的束缚。他们变得独立并加入了一个名为The OpenJS Foundation 的基金会。Node.js、Electron、webpack、lodash、eslint 甚至 Jest 现在都由公司和个人集体资助。既然他们可以,你也可以。但你没有。你被父母困住了。为什么?
这些不是我的问题,而是你的
你和我的人生目标相同:帮助开发者构建更好的 UI。我正在开发React-admin。所以我理解你的挑战,以及你必须做出的权衡。你的工作不是一件容易的事,你可能正在解决大量我甚至不知道的问题。
但我发现自己不断地试图隐藏你的缺点。当我谈论你的时候,我从来没有提到过上面的问题 —— 我只是假装我们是一对很恩爱的夫妻,什么都没有发生。在react-admin中,我介绍了一些 API,可以消除直接与您打交道的麻烦。当人们抱怨 react-admin 时,我会尽力解决他们的问题 —— 但大多数时候,他们是对你有意见。作为一名框架开发人员,我也在第一线,我比其他人先解决所有问题。
我看过其他框架。他们有自己的缺陷——Svelte 不是 JavaScript,SolidJS 有一些令人讨厌的陷阱,比如:
// this works in SolidJS
const BlueText = props => <span style="color: blue">{props.text}</span>;
// this doesn't work in SolidJS
const BlueText = ({ text }) => <span style="color: blue">{text}</span>;
但他们没有你的缺点,这些有时让我想哭的缺陷、经过多年处理后变得如此烦人的缺陷、让我想尝试其他东西的缺陷。相比之下,所有其他框架都令人耳目一新。
可是宝贝我还是不能戒掉你
问题是我不能离开你。 首先,我爱你的朋友。MUI , Remix , react-query , react-testing-library , react-table ...当我和这些人在一起时,我总是做一些令人惊奇的事情。他们让我成为一个更好的开发者——他们让我成为一个更好的人。我不能离开你而不离开他们。
这是生态系统,stupid。
我不能否认你拥有最好的社区和最好的第三方模块。但老实说,很遗憾开发者选择你不是因为你的素质,而是因为你的生态系统的素质。
第二,我在你身上投入了太多。我已经和你一起构建了一个巨大的代码库,我如果不发疯,就不可能迁移到另一个框架。我围绕你建立了一个企业,让我能够以可持续的方式开发开源软件。
我依赖着你。
有空Call我
我对自己的感受非常坦诚。现在我希望你也这样做。你打算解决我上面列出的几点吗?如果是,什么时候?你如何看待像我这样的库开发者?我应该忘记你,然后去做别的事情吗?还是我们应该呆在一起,并努力发展我们的关系?
我们的下一步是什么?你告诉我。