一、真实场景:从React迁移到Svelte的3个坑
2024年Q1,我接手一个中型后台管理系统(React 18 + Redux + Webpack 5),页面数120+,组件树深度平均6层。首屏加载时间在Chrome DevTools下测得3.2秒(3G网络模拟),包体积1.8MB(gzip后680KB)。用户反馈操作卡顿,特别是表格筛选和弹窗打开。
尝试优化React:用React.memo、useMemo、useCallback包裹,效果有限(首屏降到2.8秒)。决定用Svelte 4重写核心模块(数据看板、用户管理、权限配置),对比Vue 3.4作为对照组。
迁移过程中踩了3个坑:
- 坑1:Svelte的响应式声明必须用
$:,不能像Vue用ref()或React用useState直接赋值。 - 坑2:Svelte的
on:click事件绑定在原生元素上,自定义组件需要createEventDispatcher。 - 坑3:Svelte的
each块不支持key属性,用{#each items as item (item.id)}语法。
下面直接上代码对比。
二、方案对比:编译时 vs 运行时
2.1 核心差异
| 维度 | Svelte 4 | Vue 3.4 | React 18 |
|---|---|---|---|
| 编译时/运行时 | 编译时(无虚拟DOM) | 编译时+运行时(虚拟DOM) | 运行时(虚拟DOM) |
| 响应式原理 | 编译器注入赋值语句 | Proxy代理对象 | 不可变状态+diff |
| 包体积(min+gzip) | 1.6KB(Hello World) | 16.7KB(Hello World) | 42.8KB(Hello World) |
| 首次渲染耗时(1000组件) | 12ms | 28ms | 45ms |
| 更新耗时(1000组件) | 8ms | 18ms | 32ms |
| 内存占用(1000组件) | 2.3MB | 3.8MB | 5.1MB |
数据来源:用Chrome Performance面板在MacBook Pro M1上测试,每个框架渲染1000个简单计数器组件(含点击事件),取10次平均值。
2.2 语法对比:计数器组件
先看最基础的计数器,感受语法差异。
Svelte 4 版本
<!-- Counter.svelte -->
<script>
let count = 0;
$: doubled = count * 2; // 响应式声明
function increment() {
count += 1;
}
</script>
<button on:click={increment}>
Clicked {count} times
</button>
<p>Doubled: {doubled}</p>
<style>
button {
background: #ff3e00;
color: white;
padding: 8px 16px;
}
</style>
Vue 3.4 版本
<!-- Counter.vue -->
<template>
<button @click="increment">
Clicked {{ count }} times
</button>
<p>Doubled: {{ doubled }}</p>
</template>
<script setup>
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
</script>
<style scoped>
button {
background: #42b883;
color: white;
padding: 8px 16px;
}
</style>
React 18 版本
// Counter.jsx
import { useState, useMemo } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const doubled = useMemo(() => count * 2, [count]);
const increment = () => setCount(c => c + 1);
return (
<button onClick={increment}>
Clicked {count} times
</button>
<p>Doubled: {doubled}</p>
);
}
关键差异:
- Svelte用
$:声明派生状态,Vue用computed,React用useMemo。 - Svelte的样式默认作用域(scoped),Vue需要
scoped属性,React需要CSS Modules或styled-components。 - Svelte的
on:click直接绑定,Vue用@click,React用onClick。
三、完整代码实现:TodoList应用
用三个框架实现一个完整的TodoList:添加、删除、切换完成状态、过滤(全部/已完成/未完成)。
3.1 Svelte 4 实现
<!-- TodoList.svelte -->
<script>
let todos = [];
let newTodo = '';
let filter = 'all'; // 'all' | 'active' | 'completed'
$: filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
function addTodo() {
if (!newTodo.trim()) return;
todos = [...todos, { id: Date.now(), text: newTodo, completed: false }];
newTodo = '';
}
function toggleTodo(id) {
todos = todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
}
function deleteTodo(id) {
todos = todos.filter(t => t.id !== id);
}
</script>
<div class="todo-app">
<h1>Svelte TodoList</h1>
<input bind:value={newTodo} placeholder="Add a todo" on:keydown={(e) => e.key === 'Enter' && addTodo()}/>
<button on:click={addTodo}>Add</button>
<div class="filters">
<button class:active={filter === 'all'} on:click={() => filter = 'all'}>All</button>
<button class:active={filter === 'active'} on:click={() => filter = 'active'}>Active</button>
<button class:active={filter === 'completed'} on:click={() => filter = 'completed'}>Completed</button>
</div>
<ul>
{#each filteredTodos as todo (todo.id)}
<li>
<input type="checkbox" checked={todo.completed} on:change={() => toggleTodo(todo.id)}/>
<span class:completed={todo.completed}>{todo.text}</span>
<button on:click={() => deleteTodo(todo.id)}>Delete</button>
</li>
{/each}
</ul>
</div>
<style>
.todo-app { max-width: 400px; margin: 0 auto; font-family: sans-serif; }
.filters button { margin-right: 8px; }
.filters button.active { background: #ff3e00; color: white; }
.completed { text-decoration: line-through; opacity: 0.6; }
</style>
3.2 Vue 3.4 实现
<!-- TodoList.vue -->
<template>
<div class="todo-app">
<h1>Vue TodoList</h1>
<input v-model="newTodo" placeholder="Add a todo" @keydown.enter="addTodo"/>
<button @click="addTodo">Add</button>
<div class="filters">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">All</button>
<button :class="{ active: filter === 'active' }" @click="filter = 'active'">Active</button>
<button :class="{ active: filter === 'completed' }" @click="filter = 'completed'">Completed</button>
</div>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" :checked="todo.completed" @change="toggleTodo(todo.id)"/>
<span :class="{ completed: todo.completed }">{{ todo.text }}</span>
<button @click="deleteTodo(todo.id)">Delete</button>
</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const todos = ref([]);
const newTodo = ref('');
const filter = ref('all');
const filteredTodos = computed(() => {
return todos.value.filter(todo => {
if (filter.value === 'active') return !todo.completed;
if (filter.value === 'completed') return todo.completed;
return true;
});
});
function addTodo() {
if (!newTodo.value.trim()) return;
todos.value.push({ id: Date.now(), text: newTodo.value, completed: false });
newTodo.value = '';
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id);
if (todo) todo.completed = !todo.completed;
}
function deleteTodo(id) {
todos.value = todos.value.filter(t => t.id !== id);
}
</script>
<style scoped>
.todo-app { max-width: 400px; margin: 0 auto; font-family: sans-serif; }
.filters button { margin-right: 8px; }
.filters button.active { background: #42b883; color: white; }
.completed { text-decoration: line-through; opacity: 0.6; }
</style>
3.3 React 18 实现
// TodoList.jsx
import { useState, useMemo } from 'react';
import './TodoList.css';
export default function TodoList() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
const [filter, setFilter] = useState('all');
const filteredTodos = useMemo(() => {
return todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
}, [todos, filter]);
const addTodo = () => {
if (!newTodo.trim()) return;
setTodos([...todos, { id: Date.now(), text: newTodo, completed: false }]);
setNewTodo('');
};
const toggleTodo = (id) => {
setTodos(todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
};
const deleteTodo = (id) => {
setTodos(todos.filter(t => t.id !== id));
};
return (
<div className="todo-app">
<h1>React TodoList</h1>
<input value={newTodo} onChange={e => setNewTodo(e.target.value)} placeholder="Add a todo" onKeyDown={e => e.key === 'Enter' && addTodo()}/>
<button onClick={addTodo}>Add</button>
<div className="filters">
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button>
<button className={filter === 'active' ? 'active' : ''} onClick={() => setFilter('active')}>Active</button>
<button className={filter === 'completed' ? 'active' : ''} onClick={() => setFilter('completed')}>Completed</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)}/>
<span className={todo.completed ? 'completed' : ''}>{todo.text}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
/* TodoList.css */
.todo-app { max-width: 400px; margin: 0 auto; font-family: sans-serif; }
.filters button { margin-right: 8px; }
.filters button.active { background: #61dafb; color: white; }
.completed { text-decoration: line-through; opacity: 0.6; }
四、状态管理对比
当应用规模变大,需要全局状态管理。Svelte用store,Vue用Pinia,React用Zustand。
4.1 Svelte Store
// store.js
import { writable, derived } from 'svelte/store';
export const count = writable(0);
export const doubled = derived(count, $count => $count * 2);
// 在组件中使用
// <script>
// import { count, doubled } from './store.js';
// </script>
// <p>Count: {$count}, Doubled: {$doubled}</p>
// <button on:click={() => count.update(n => n + 1)}>+1</button>
4.2 Vue Pinia
// stores/counter.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() { count.value++; }
return { count, doubled, increment };
});
// 在组件中使用
// <script setup>
// import { useCounterStore } from '@/stores/counter';
// const counter = useCounterStore();
// </script>
// <p>Count: {{ counter.count }}, Doubled: {{ counter.doubled }}</p>
// <button @click="counter.increment()">+1</button>
4.3 React Zustand
// store.js
import { create } from 'zustand';
export const useCounterStore = create((set) => ({
count: 0,
doubled: 0,
increment: () => set((state) => ({ count: state.count + 1, doubled: (state.count + 1) * 2 })),
}));
// 在组件中使用
// import { useCounterStore } from './store';
// const { count, doubled, increment } = useCounterStore();
// <p>Count: {count}, Doubled: {doubled}</p>
// <button onClick={increment}>+1</button>
对比:
- Svelte store最轻量,writable/derived直接导出,组件内用
$前缀自动订阅。 - Pinia需要定义store,但提供devtools集成和模块化。
- Zustand最灵活,但需要手动计算派生状态。
五、构建配置对比
用Vite 5作为构建工具,配置三个框架的入口。
5.1 Svelte + Vite
// vite.config.js
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
build: {
target: 'es2020',
minify: 'esbuild',
},
});
5.2 Vue + Vite
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
target: 'es2020',
minify: 'esbuild',
},
});
5.3 React + Vite
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
target: 'es2020',
minify: 'esbuild',
},
});
配置几乎一样,只是插件不同。Svelte的构建产物最小,因为编译器在构建时做了大量优化。
六、效果数据
用真实项目的数据看板模块做对比测试,环境:MacBook Pro M1, Chrome 120, 3G网络模拟。
| 指标 | Svelte 4 | Vue 3.4 | React 18 |
|---|---|---|---|
| 包体积(gzip) | 42KB | 68KB | 112KB |
| 首次渲染耗时 | 180ms | 240ms | 320ms |
| 交互响应时间(点击筛选) | 8ms | 15ms | 22ms |
| 内存占用(稳定后) | 4.2MB | 5.8MB | 7.3MB |
| Lighthouse Performance | 98 | 94 | 89 |
Svelte在包体积和渲染性能上优势明显,Vue居中,React最重。但React的生态和工具链更成熟。
七、避坑指南
实际迁移过程中遇到的坑,按框架列出。
7.1 Svelte 避坑
- 响应式声明必须用
$:,不能直接赋值:let doubled = count * 2不会自动更新,必须写成$: doubled = count * 2。如果忘记,派生值不会变化。 - 数组/对象更新必须创建新引用:
todos.push(newTodo)不会触发更新,必须todos = [...todos, newTodo]。Svelte的响应式依赖赋值语句,不是Proxy。 - 自定义组件事件需要
createEventDispatcher:子组件不能直接on:click,必须import { createEventDispatcher } from 'svelte'; const dispatch = createEventDispatcher(); dispatch('eventName', data);。 - 生命周期差异:
onMount对应Vue的onMounted,但Svelte没有beforeUpdate,用beforeUpdate需要从svelte导入。 - SSR兼容性:SvelteKit的SSR默认开启,但某些浏览器API(如
localStorage)需要检查browser变量。
7.2 Vue 避坑
- ref和reactive混用:
ref用于基本类型,reactive用于对象。如果混用,响应式可能丢失。 - v-for和v-if不能同时使用:Vue 3中
v-for优先级高于v-if,会导致性能问题。用computed过滤后再遍历。 - Pinia的store解构会丢失响应式:
const { count } = useCounterStore()会失去响应性,必须用storeToRefs。
7.3 React 避坑
- useEffect依赖数组遗漏:如果忘记添加依赖,闭包会捕获旧值,导致bug。用ESLint的
react-hooks/exhaustive-deps规则。 - 状态更新是异步的:
setCount(count + 1)在同一个事件循环中多次调用只会生效一次,必须用函数形式setCount(c => c + 1)。 - useMemo和useCallback滥用:过度优化反而增加内存开销。只在计算昂贵或引用稳定时使用。
八、总结
选择框架取决于项目场景:
- 小到中型应用,追求极致性能和包体积:选Svelte。
- 中型应用,需要成熟生态和工具链:选Vue。
- 大型应用,团队React经验丰富,需要灵活性和社区支持:选React。
没有银弹,但Svelte的编译时思路值得所有前端开发者学习。