@levinzhang
2022-09-25T10:40:51.000000Z
字数 10934
阅读 450
React是一个有着近十年历史的优秀框架,但是在不断的版本迭代中,它也积累了不少的问题,比如表单处理、DOM访问、Hook规则等,作者对这些影响开发效率和性能的问题进行了形象化的阐述。
本文最初发表于Marmelab网站,由InfoQ中文站翻译分享。
亲爱的React.js:
我们在一起已经快十年了,我们携手走过了漫长的旅程。但是,事情正在变得越来越糟糕,我们真的需要谈谈了。
这确实有点令人尴尬,我知道,没人愿意进行这样的谈话,所以我就以歌曲的形式来进行表达吧。(作者的每一个标题都是一首英文歌的名称,在此我们不做翻译——译者注)
我并不是JS方面的新手。在遇到你之前,我已经和jQuery、Backbone.js以及Angular.js打过很久的交道。我知道可以从JavaScript框架中得到什么:更好的用户界面,更高的生产力,以及更流畅的开发体验。但是,这也意味着我不得不改变我对代码的思考方式,以匹配框架的思维模式,这会带来一定的挫败感。
当我遇见你的时候,我刚刚结束了与Angular.js的一段长期感情。我已经被它的watch
和digest
搞得焦头烂额,更不用提scope
了。我正在寻找不会让我感到如此痛苦的东西。
我对你一见钟情。相对于其他的方案,你的单向数据绑定让我感到惊艳。我之前遇到的数据同步和性能等一系列问题在你身上根本就不存在。你纯粹基于JavaScript,而不是在HTML元素中以字符串的形式进行笨拙的表述。你拥有“声明式组件”,它实在太迷人了,吸引了所有人的目光。
当然,你并不易于相处。为了与你保持和谐,我不得不改变自己的编码习惯,但这都是值得的。最初,我对你非常满意,以至于我一直向所有的人介绍你。
当我开始要求你处理表单的时候,事情就开始变得不对劲了。在vanilla JS中,处理表单和输入域是很困难的,但是在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的时候,Redux-form看上去是一个很自然的选择,但后来它的主要开发人员放弃了它,然后建立了React-final-form,这个框架全是未解决的缺陷,而且其主要的开发人员又放弃了它。所以,我又看了一下Formik,它很流行,但它是一个重量级的框架,大型表单运行缓慢并且特性有限。所以,我决定使用React-hook-form,它很快,但是有隐藏的缺陷,而且其文档就像迷宫一样。
在使用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>
我们见面不久之后,你就向我介绍了你的小宠物Redux。没有它,你什么都做不了。起初我并不介意,因为它确实很可爱。但是,后来我意识到所有的一切都在围绕它来构建。而且,在构建框架的时候,它让我的生活变得更加困难,其他的开发人员很难使用现有reducer来调整应用。
似乎你也注意到了这一点,于是决定摆脱Redux,转而使用自己的useContext
。只不过,useContext
缺少了Redux的一个关键特性,那就是响应上下文中局部变更的能力。在性能上,二者是不能同日而语的。
// Redux
const name = useSelector(state => state.user.name);
// React context
const { name } = useContext(UserContext);
在第一个样例中,该组件只会在用户名发生变化的时候进行重新渲染。但是在第二个样例中,当用户的任何部分发生变更都会导致重新渲染。这一点很重要,以至于我们不得不拆分上下文以避免不必要的重新渲染。
// 这种写法看上去非常疯狂,但是我们别无选择
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>
);
};
当我遇到性能问题的时候,大多数情况都是因为庞大的上下文,我别无选择,只能对其进行拆分。
我不想使用useMemo
或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} />;
};
都已经十年了,这个缺陷依然还存在。我想问一下,提供一个useContextSelector
能有多难呢?
你当然意识到了这一点。但你在顾左右而言他,即便大家都知道这是你最重要的性能瓶颈。
你跟我说,我不应该直接访问DOM,这都是为我好。我从来不认为DOM是多脏的东西,但是它却让你坐立不安,所以我就听你的了。现在,按照你的要求,我不得不使用ref。
但是ref这东西很快就像病毒一样四处传播。大多数时候,当某个组件使用ref的时候,它会将其传递到子组件中,如果第二个组件是React组件,它必须要将ref转发至另一个组件,以此类推,直到树中的某个组件渲染HTML元素为止。所以,代码中到处都是转发ref的代码,降低了代码的易读性。
转发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>
));
你可能会问,为什么这么难呢?这是因为我们无法使用forwardRef构建一个通用组件(在Typescript语言下)。
// 我该如何使用forwardRef呢?
const MyComponent = <T>(props: <ComponentProps<T>) => (
<div ref={/* pass ref here */}>Hello, {props.name}!</div>
);
此外,你认为ref不仅仅适用于DOM节点,还等价于函数组件的this
。换句话说,“不触发重新渲染的状态”。按照我的经验,每次我不得不使用ref的时候,都是因为你,因为你那诡异的useEffect
API。也就是说,ref
是你创造出来的问题的解决方案。
说到useEffect
,我本人对它有一个疑问。我承认它是优雅的创新,它在一个统一的API中,涵盖了挂载、卸载和更新事件。但是,这怎么能算是进步呢?
// 使用生命周期回调
class MyComponent {
componentWillUnmount: () => {
// 执行某些操作
};
}
// 使用useEffect
const MyComponent = () => {
useEffect(() => {
return () => {
// 执行某些操作
};
}, []);
};
看,就这一行代码就反应了我对useEffect
的忧虑。
}, []);
我看到我的代码中到处都是这种难以理解的格式,而这些都是因为useEffect
。另外,你还强迫我跟踪依赖,比如这段代码:
// 如果没有数据的话,对页面进行变更
useEffect(() => {
if (
query.page <= 0 ||
(!isFetching && query.page > 1 && data?.length === 0)
) {
// 查询不存在的页数时,将页数设置为1
queryModifiers.setPage(1);
return;
}
if (total == null) {
return;
}
const totalPages = Math.ceil(total / query.perPage) || 1;
if (!isFetching && query.page > totalPages) {
// 查询范围之外的页数时,将页数设置为最后一页
// 这种情况会在删除最后一页的最后一条数据时出现
queryModifiers.setPage(totalPages);
}
}, [isFetching, query.page, query.perPage, data, queryModifiers, total]);
看到最后一行了吗?我必须在依赖数组中包含所有的反应式变量(reactive variable)。我以前还认为对于支持垃圾收集的所有语言来说,引用计数是一项原生提供的功能,但是并非如此,我必须对依赖关系进行微观管理,因为你不知道该怎样进行处理。
而且,在很多情况下,其中的某项依赖是我创建的函数。因为你没有区分变量和函数,我必须通过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);
},
[
// 天啊,真不想这么做
rowClick,
id,
resource,
record,
navigate,
createPath,
handleToggleExpand,
handleToggleSelection,
],
);
如果一个简单组件有多个事件处理器和生命周期回调的话,代码瞬间就会变得乱七八糟,因为我必须要管理这个像地狱似的依赖关系。所有的这一切都是因为你决定一个组件可以执行任意多次。
举例来说,如果我想要实现一个计数器,每过一秒以及用户每次点击按钮时,它都会增加,我必须这样实现:
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不会解除订阅
() => navigator.onLine, // 如何获取客户端的值
() => true, // 如何获取服务器的值
);
}
但这对我来讲,这就是狗尾续貂。如果反应式effect更易于使用的话,我们根本没有必要增加其他的hook。
换句话说,随着时间的推移,除了不断增加核心API之外,你别无选择。对于像我这样要维护巨大代码库的人来说,这种持续的API膨胀是一个噩梦。看到你每天涂脂抹粉,这反过来就是在不断提醒你,想想你在试图掩饰些什么呢。
你的hook是一个很好的主意,但它们是有成本的。这就是Hook规则。它们很难记,难以付诸实践。但是,它们迫使我们必须在不必要的代码上耗费时间。
例如,我有一个“inspector”组件,终端用户可以将它拖来拖去。用户也可以隐藏它。当隐藏时,inspector组件不会渲染任何东西。所以,定义组件时我希望“尽早离开”,避免无谓的注册事件监听器。
const Inspector = ({ isVisible }) => {
if (!isVisible) {
// 尽早离开
return null;
}
useEffect(() => {
// 注册事件处理器
return () => {
// 解除事件处理器
};
}, []);
return <div>...</div>;
};
但是,这样是不行的,因为这违反了Hook规则,useEffect
hook是否执行取决于props。所以,我必须在所有的effect上添加一个条件,使其能够在isVisible
属性为false时尽早离开:
const Inspector = ({ isVisible }) => {
useEffect(() => {
if (!isVisible) {
return;
}
// 注册事件处理器
return () => {
// 解除事件处理器
};
}, [isVisible]);
if (!isVisible) {
// 不像前文那样,进入之后立即离开
return null;
}
return <div>...</div>;
};
因此,所有的effect在它们的依赖关系中都要有isVisible属性,并且可能会频繁运行(这会损害性能)。我知道,我应该创建一个中间组件,如果isVisible
为false,就不渲染。但我凭什么要这样做呢?这只是Hook规则妨碍我的一个例子,我还有很多其他的例子。这样带来的后果就是,我的React代码库中有很大一部分都是用来满足Hook规则的。
Hook规则是实现细节导致的结果,也就是你为hook所选择的实现。但是,它并非必须要这样。
你从2013年就开始存在了,而且尽可能地保持了向后兼容性。为此我要感谢你,这也是我能够与你构建一个庞大代码库的原因之一。但是,这种向后兼容性是有代价的,文档和社区资源往好了说是过时的,往坏了说就是有误导性的。
例如,当我在StackOverflow上搜索“React mouse position”时,第一个结果建议使用如下的解决方案,而这个解决方案在一个世纪前就已经过时了:
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包的时候,我经常会找到语法陈旧、过时的废弃包。以react-draggable为例。它是用React实现拖放的事实标准。它有许多未解决的问题,而且开发活跃性很低。可能这是因为它仍然是基于类组件的,当代码库如此老旧时,很难吸引贡献者。
至于你的官方文档,仍然建议使用 componentDidMount
和componentWillUnmount
而不是useEffect
。在过去的两年里,核心团队一直在开发一个新的版本,称为Beta docs。但是他们依然没有做好最后的准备。
总而言之,向hook的漫长迁移仍未完成,而且它在社区中产生了明显的分裂现象。新的开发者努力在React生态系统中找到自己的方向,而老的开发者则努力跟上最新的发展。
起初,你的父亲Facebook看起来特别酷。Facebook想要“让人们更紧密地联系在一起”。每当我登录Facebook时,都会遇到一些新朋友
但后来事情就变得很混乱了。Facebook加入了一个操纵人群的计划。他们发明了“假新闻”的概念。他们未经同意就开始保留每个人的档案。访问Facebook变得很可怕,以至于几年前我删除了自己的账户。
我知道,不能让孩子为父母的行为负责。但你仍然和它生活在一起。他们资助你的发展。他们是你最大的用户。你依赖他们。如果有一天,他们因为自己的行为而倒下,你就会和他们一起倒下。
其他主要的JS框架已经能够从它们的父母那里挣脱出来,变得变得独立,并加入了The OpenJS Foundation基金会。Node.js、Electron、webpack、lodash、eslint,甚至Jest现在都是由一些公司和个人集体资助的。既然它们可以,你也可以。但你没有。你被你的父亲困住了,为什么呢?
你和我有相同的生活目的,也就是帮助开发者建立更好的用户界面。我正在用React-admin实现这一点。所以我理解你面临的挑战,以及你必须做出的权衡。你并不容易,可能正在解决大量我甚至不知道的问题。
但我发现自己正在不断地隐藏你的缺陷。当我谈到你的时候,我从不会提及上述问题,我假装我们是一对伟大的夫妇,生活没有阴云。在react-admin中,我引入了API,消除了直接与你打交道的麻烦。当人们抱怨react-admin时,我尽力解决他们的问题,但大多数时候,它们都是你的问题。作为框架的开发者,我也位于第一线,比其他人能够更早看到所有的问题。
我看了其他的框架,他们有自己的缺陷,比如Svelte不是JavaScript,SolidJS有讨厌的陷阱:
// 这可以在SolidJS中运行
const BlueText = props => <span style="color: blue">{props.text}</span>;
// 这无法在SolidJS中运行
const BlueText = ({ text }) => <span style="color: blue">{text}</span>;
但它们没有你身上的缺陷。那些让我有时想哭的缺陷,那些经过多年处理后变得非常烦人的缺陷,那些让我想尝试其他新框架的缺陷。相比之下,所有其他框架都令人耳目一新。
问题在于,我无法离开你。
首先,我喜欢你的朋友们。MUI、Remix、react-query、react-testing-library、react-table... 当我和它们在一起的时候,总是能做出美妙的成果。它们使我成为更好的开发者,它们使我成为更好的人。要离开你,我就必须离开它们。
这就是生态系统。
我不能否认,你有最好的社区和最好的第三方模块。但坦率地说,令人遗憾的是,开发者选择你不是因为你的品质,而是因为你的生态系统的品质。
第二,我在你身上投资了太多。我已经用你建立了一个巨大的代码库,迁移到其他框架会让我感到崩溃。我已经围绕你建立了自己的商业模式,让我能够以可持续的方式开发开源软件。
我依赖你。
我对我的感受一直很坦诚。现在,我希望你也能这样做。你是否有计划解决我上面列出的问题,如果是的话,在什么时候做呢?你对像我这样的库开发人员有什么看法?我是否应该忘记你,转而去尝试其他框架,还是应该呆在一起,为保持我们的关系而继续努力?
我们下一步要走向何方?你能告诉我吗?
后续:React的开发人员在推特上对作者的这些问题进行了答复,React承认这些问题,并致力于解决和完善,但这似乎不是一朝一夕能够完成的。