将应用程序从 React 重写为 Svelte,你可以期待什么
Published: 2021-04-08
最近,我将一个中型的 React 应用程序(约 250 个 React 组件)重写为 Svelte。重写后的应用程序更加高效,维护更简单,代码也更易读。但在转换为 Svelte 代码的过程中遇到了一些问题和不太直接的情况。本文将描述其中的一些情况。
这不是为 React 程序员准备的 Svelte 教程,如果你还不了解 Svelte,最好的起点是官方的 Svelte 教程。本文包含了许多 Svelte 教程的链接,而不是解释 Svelte 的原理。
CSS
Svelte 以非常简洁的方式直接支持 CSS。在 React 应用程序中,我使用了 styled components。React 有很多 CSS 库,但据我所知,它们都使用 CSS-in-JS 范式,而在 Svelte 中,你可以使用相当简洁的 CSS。
在下面的代码片段中,我将展示工具栏按钮的简化实现,分别用 React(带有 styled components)和 Svelte 实现。
工具栏按钮有一个属性(disabled),用于禁用悬停功能。同时它还使用了一些来自主题的变量。
// React 组件 ToolbarButton({disabled, toolBarHeight})
const ToolbarButton = styled.div`
padding: 5px 15px;
height: ${props.toolBarHeight}px;
${(props) =>
!props.disabled &&
`
&:hover {
background-color: gray;
}
`}
`;
<!-- Svelte 组件 -->
<script>
export let disabled;
</script>
<div class="button" class:disabled />
<style>
.button {
padding: 5px 15px;
height: var(--theme-toolbar-height); /* 不能直接使用 prop,必须使用 CSS 变量 */
}
.button.disabled {
background-color: gray;
}
</style>
Svelte 不支持在 CSS 中使用任何变量。因此无法将属性传递给 Svelte 组件并在 CSS 中使用。唯一的方法是切换 CSS 类的开关。当 disabled 设置为 true 时,使用选择器 button.disabled。
当你想使用一些值(例如 CSS 中的尺寸)时,可以使用 CSS 变量。
React 钩子,尤其是 useEffect
React 钩子是非常强大的功能。在 Svelte 中,你找不到类似的东西。但大多数 React 钩子,在 Svelte 中你不会想念。
- useState - 状态是用 let 命令定义的
- useMemo - 你可以使用响应式命令来重新计算内部状态
- useCallback - 函数表达式只会被评估一次,所以没有意义
- useContext - Svelte 有非常简单直接的上下文 API(getContext(), setContext())
- useReducer - 你必须创建 Svelte 版本,使用 stores(这可能 非常简单)
- useRef - 你可以使用 bind:this 代替(或 let 变量用于实例变量)
- useEffect - 这比较复杂…
useEffect - 简单用法
我在 React 中经常使用 useEffect,不幸的是,你必须考虑如何在 Svelte 中实现相同的逻辑。
最简单的用法是在挂载和卸载时执行一些代码。
// React
React.useEffect(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT');
}, []);
这种变体可以用 Svelte 方法 onMount 和 onDestroy 转换
// Svelte
onMount(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT 1');
});
onDestroy(() => {
console.log('UNMOUNT 2');
});
如你所见,你有两种变体可以在卸载时执行代码 - 使用 onDestroy 函数和 onMount 函数的返回值。
useEffect - 响应式语句
当你想在表达式更改时执行一些代码时,可以使用 Svelte 响应式语句。
// React
React.useEffect(() => {
console.log('HEIGHT changed, new value:', height);
}, [height]);
// Svelte
$: console.log('HEIGHT changed, new value:', height);
在 Svelte 中,响应式语句在语句的任何依赖项被赋值时执行。因此,如果你需要仅在依赖项更改时执行某些操作,你必须自己检查值。
如果你想显式列出依赖项(如 React.useEffect 方法的第二个参数),你可以使用这种模式:
// Svelte
$: {
height;
width;
handleChanged();
}
这将在 height 或 width 变量赋值后调用。你可以在 handleChanged 函数中使用其他变量,但这些依赖项不会触发此响应式语句。
useEffect - 带清理的响应式语句
React useEffect 也可以用于分配一些依赖于属性值的资源。在下面的示例中,组件显示 SQL 数据库中的表列表。它监听数据库结构的更改。当属性 connectionId 更改时,有必要断开旧套接字并连接到新套接字。
// React
function SqlTableList({ connectionId }) {
React.useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
}, [connectionId]);
}
Svelte 不像 React 那样直接支持这种场景。但你可以使用简单的技巧来实现这种行为。
// Svelte
const useEffect = subscribe => ({ subscribe });
$: effect = useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
});
$: $effect;
这个技巧是如何工作的?你需要了解 stores,尤其是自定义 stores(教程)。函数 useEffects 创建了一个带有订阅方法的自定义存储。存储的订阅方法必须返回取消订阅方法,当不再需要存储订阅时调用。
最后一行( $: $effect )管理自动存储订阅(教程)。因此,当 connectionId 的值更改时(或仅在 Svelte 中赋值),它会正确调用 socket.on,然后调用 socket.off,与 React 的对应方式类似。
为什么我在 Svelte 中想念 React 的 props
当然,Svelte 中也有 props。它们的工作方式与 React 类似。但在 React 中,组件的唯一外部接口是 props。在 Svelte 中,你有几种机制来管理组件的行为:
- props - 与 React 中的工作方式相同
- events - 使用特殊语法,事件不是包含所有 props 的 $$props 对象的一部分
- actions(语法 use:action)- 绑定到 HTML 元素的逻辑重用机制
- slots - 目的与 React 属性 children 相同,但有一些扩展
所有这些机制都非常有用,但它们没有像 React 中的 props 那样统一的访问方式。下面讨论了一些我遇到的问题。
转发事件
如果你创建更复杂的组件层次结构,某些组件只转发来自父组件的数据。
// React - 转发所有事件,它们是 props 的一部分
function Outer(props) {
return <Inner {...props} />;
}
<!-- Svelte - 仅显式命名的事件 click 和 keydown 被转发 -->
<Inner {...$$props} on:click on:keydown />;
}
在 Svelte 中,你不能转发所有事件,但可以转发列举的事件。但如果你需要转发所有事件,可以使用回调函数(onClick 代替 on:click),那么 onClick 将成为 $$props 的一部分。
实现 TabControl
在 React 中,实现 TabControl 组件相当容易,可以如下使用:
// React
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
实现将遍历 children 数组,并可以轻松提取所需的信息(页面标题和内容)。这种方法在 Svelte 中不起作用,它没有类似 children 的东西。你可以使用 Svelte 片段,使用方式如下:
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', slot: 1},
{ label: 'Page 2', slot: 2},
]}>
<svelte:fragment slot='1'>
Page 1 content
</svelte:fragment>
<svelte:fragment slot='2'>
Page 2 content
</svelte:fragment>
</TabControl>
选项卡在数组中定义,选项卡布局(children)在片段中定义,这些片段作为插槽传递给 TabControl 组件。这不像 React 那样直观,但它有效。只有一个相当大的陷阱,你可以在 TabControl 实现中看到:
// TabControl.svelte
<script>
export let tabs = []
</script>
<div>
{#each _.compact(tabs) as tab, index}
<div class="container" class:isInline class:tabVisible={index == value}>
{#if tab.slot == 0}<slot name="0" />
{:else if tab.slot == 1}<slot name="1" />
{:else if tab.slot == 2}<slot name="2" />
{:else if tab.slot == 3}<slot name="3" />
{:else if tab.slot == 4}<slot name="4" />
{:else if tab.slot == 5}<slot name="5" />
{:else if tab.slot == 6}<slot name="6" />
{:else if tab.slot == 7}<slot name="7" />
{/if}
</div>
{/each}
</div>
插槽名称必须是静态字符串,因此你必须这样做才能使其工作。
使用动态组件
不同的方法可以为每个选项卡页面定义组件,但这样你将有很多小文件,因为每个 Svelte 组件必须在其自己的文件中定义。
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', component: Tab1},
{ label: 'Page 2', component: Tab2},
]}/>
// Tab1.svelte
Page 1 content
// Tab2.svelte
Page 2 content
实现将使用 svelte:component 来实例化适当的选项卡。
使用上下文
这是最不明显的方法。但实际上,你可以实现与 React 中相同的语法。
<!-- Svelte -->
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
在 TabControl 组件中必须定义一些“收集点”,例如子选项卡的数组
<!-- TabControl.svelte -->
<script>
const tabs = [];
setContext('tabs', tabs);
</script>
在选项卡页面中,我们只需在父数组中注册选项卡:
<!-- TabPage.svelte -->
<script>
export let label;
const tabs = getContext('tabs');
tabs.push({ label });
</script>
这种方法的唯一陷阱是,如果某些选项卡页面是有条件渲染的,它不会保留定义的顺序。
<TabControl>
{#if condition_will_be_true_later}
<TabPage label='Page 1'>
Page 1 content
</TabPage>
{/if}
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
这将首先渲染 Page 2,然后是 Page 1,这可能不是期望的结果。
使用 bind:clientHeight 和 bind:clientWidth 时的注意事项
绑定尺寸(教程)是一个很棒的功能。但在使用时要小心。它是通过隐藏的 iframe 实现的(你可以在这个 REPL 中看到)。根据我的经验,有时在更复杂的情况下,它在 FireFox 中不起作用。有时使用 ResizeObserver 更安全(参见这个 action 的实现)
错误边界
React 有一个很棒的错误边界概念(组件的“try-catch”)。它不支持在函数组件中使用(实际上,ErrorBoundary 是我 React 应用程序中唯一的类组件),但在函数式 React 应用程序中使用这个类组件没有问题。
// React
<ErrorBoundary>
{(null).read()}
</ErrorBoundary>
没有错误边界时,当你在 React 应用程序中有这段代码时,整个应用程序将失败。使用错误边界时,只有边界内部会失败,应用程序的其他部分将照常渲染。
不幸的是,Svelte 没有类似的东西。它有一个名为 svelte-error-boundary 的 NPM 包,但实际上,它只解决了问题的一小部分,Svelte 应用程序中的大多数错误仍然会导致应用程序崩溃。
Svelte 有自己的崩溃方式 - Svelte 组件将停止响应,因此应用程序看起来像冻结了一样。
你唯一能做的就是检测这种情况并让用户重新加载页面。
<!-- Svelte -->
<script>
let counter = 0;
$: counterCopy = counter;
const onunhandledrejection = async e => {
console.log('未处理的错误,检查是否崩溃', e);
const oldCounter = counter;
counter++;
window.setTimeout(() => {
if (counterCopy <= oldCounter) {
console.log('检测到崩溃!!!');
if (window.confirm('抱歉,应用程序已崩溃。\n重新加载页面?')) {
window.location.reload();
}
}
}, 500);
};
</script>
<svelte:window on:unhandledrejection={onunhandledrejection} />
为了检测,使用了 unhandledrejection 事件。这个事件可能在多种情况下触发,其中一些不会导致 Svelte 崩溃。这就是使用变量 counter 和 counterCopy 的原因。如果响应式语句不起作用,这意味着整个 Svelte 崩溃了,唯一的恢复方法是重新加载页面。
你可以查看完整的 ErrorHandler 组件。
结论
尽管存在这些问题,Svelte 是一个很棒的框架,我对将我的应用程序从 React 转换为 Svelte 的结果感到非常满意。现在用 Svelte 实现的一些功能,用 React 几乎是不可能做到的。
当然,如果你使用了很多仅适用于 React 的第三方库,这可能是个严重的问题。但这不是我的情况,我在 React 中的依赖很少,在这种情况下,重写为 Svelte 也可能相当简单和快速。