Svelte 5迁移实战:对比Vue/React后我选了它
发布日期: 2026/08/18 阅读总量: 0

先说我踩的坑

2024年3月,我们后台管理系统的核心数据表格页卡死了。2100行、每行24个单元格,加上行内编辑、分页、排序、列筛选。Chrome DevTools Performance面板录了12秒,主线程被占有9.7秒,内存从初始的74MB一路飙到1.2GB然后页面直接白屏。

那会儿我们用的Vue 3.4.21。先说结论:Vue 3的Proxy响应式在小规模组件(小于500个响应式对象)下表现优秀,但到了数千个响应式对象+高频更新的时候,依赖追踪的开销就是天花板。我看了performance profile,71%的时间花在triggerRefValue和依赖通知上。

当时我的任务是:保住这个页面的功能,让它在4秒内能交互。我试了两条路,React 18.2.0和Svelte 5.0.0。

方案对比:为什么不是React

先别急着说「用React的useMemo优化就行」。我实际做了个最小复现实验,3200行表格数据,每行6个可编辑单元格,输入框绑定了受控组件(value+onChange)。React 18.2.0每次键盘输入触发整个Table组件re-render,子组件虽然用React.memo包了,但匿名函数onChange导致memo无效。实际测试:按下数字键后,渲染耗时368ms,交互体感明显卡顿。

我知道你会说「用useCallback包一下」。但我给你算笔账:这个表格有24个列,每列有排序、筛选、编辑三个事件处理函数,就是72个useCallback依赖数组要维护。加上状态提升到父组件后,任何一行的单元格数据变化,整个页面的context消费者全部重渲染。

Svelte 5的编译时响应式完全绕开了这个问题。它的更新粒度是「组件内具体那个DOM节点」,不是整个组件。没有虚拟DOM diff,没有re-render概念。改一行数据,编译器直接生成更新那一行的原生DOM操作代码。

我的迁移决策:保留原业务逻辑,只把表格组件从Vue 3改写为Svelte 5,外层页面框架(Layout、导航)继续用Vue 3。用Web Components做隔离。微前端方案我用的是qiankun 2.2.4,Svelte组件打包成umd格式挂到window上,Vue外壳通过custom element调用。

代码实现:完整迁移过程

3.1 原Vue 3组件痛点分析

这是压缩后的核心逻辑,注意watch和computed的数量。

<template>
  <table>
    <tr v-for="row in filteredRows">
      <td v-for="col in columns">
        <input 
          :value="row[col.field]"
          @input="updateCell(row, col.field, $event.target.value)"
        />
      </td>
    </tr>
  </table>
</template>

<script setup>
import { reactive, computed, watch } from 'vue'

// 2100行 x 24列的响应式数据
const rows = reactive(Array.from({ length: 2100 }, () => ({
  id: crypto.randomUUID(),
  name: '',
  sku: '',
  price: 0,
  // ... 20个业务字段
})))

const columns = reactive([
  { field: 'name', label: '商品名', sortable: true, filterable: true },
  { field: 'sku', label: 'SKU', sortable: true, filterable: true },
  // ... 22个列配置
])

const sortKey = reactive({ field: 'name', dir: 'asc' })
const filterText = reactive({})

const filteredRows = computed(() => {
  let result = rows
  // 先排序
  result = [...result].sort((a, b) => {
    const valA = a[sortKey.field]
    const valB = b[sortKey.field]
    return sortKey.dir === 'asc' ? String(valA).localeCompare(String(valB)) : String(valB).localeCompare(String(valA))
  })
  // 再筛选
  result = result.filter(row => {
    return Object.entries(filterText).every(([field, keyword]) => {
      if (!keyword) return true
      return String(row[field]).toLowerCase().includes(keyword.toLowerCase())
    })
  })
  return result
})

function updateCell(row, field, value) {
  row[field] = value
  // 这里会触发computed重新计算,filteredRows内的sort会跑一遍
  // 2100条数据的localeCompare排序大概要40-60ms
}
</script>

问题在哪?updateCell每触发一次,filteredRows重新排序2100条数据。用户连续输入10个字符,就是10次全量排序+10次全量DOM diff。Vue的响应式系统是真的快,它把每次更新压缩到了60ms,但60ms x 10次= 600ms的同步阻塞。

还有Vue 3 Proxy的深层响应式开销。2100行x24列=50400个响应式对象,每个都通过Proxy包装。内存增量(对比非响应式普通对象)大约27.6MB。用户操作导致某一行变动时,Vue要遍历该行的24个属性触发依赖。

3.2 Svelte 5核心实现

Svelte 5用runes语法,注意$state$derived$effect替代了原来的let/const/onMount。下面是完整可运行的表格组件。

<script lang="ts">
  // SvelteTable.svelte
  // 直接用runes,不需要import
  
  interface Column {
    field: string;
    label: string;
    sortable?: boolean;
    filterable?: boolean;
  }
  
  interface Row {
    id: string;
    [key: string]: string | number;
  }
  
  let { 
    initialRows = [], 
    columns = [] 
  }: { 
    initialRows: Row[]; 
    columns: Column[] 
  } = $props();
  
  // $state就是把变量变成响应式,但注意:Svelte的响应式是「变量级别」,
  // 不是「对象属性级别」。row对象内的属性变化不会触发UI更新。
  // 所以我们必须用不可变数据模式。
  let rows: Row[] = $state(initialRows);
  let sortKey: { field: string; dir: 'asc' | 'desc' } = $state({ field: columns[0]?.field || '', dir: 'asc' });
  let filterText: Record<string, string> = $state({});
  
  // $derived: 类似于Vue的computed,但Svelte是编译时追踪依赖,
  // 不是在运行时收集。这里依赖了rows、sortKey、filterText,
  // 任何一个变化,这个表达式重新计算。
  let filteredRows: Row[] = $derived.by(() => {
    let result = [...rows];  // 浅拷贝一行引用
    
    // 排序
    if (sortKey.field) {
      result.sort((a, b) => {
        const valA = a[sortKey.field];
        const valB = b[sortKey.field];
        const cmp = String(valA).localeCompare(String(valB), 'zh-CN', { numeric: true });
        return sortKey.dir === 'asc' ? cmp : -cmp;
      });
    }
    
    // 筛选
    const activeFilters = Object.entries(filterText).filter(([, v]) => v);
    if (activeFilters.length > 0) {
      result = result.filter(row => 
        activeFilters.every(([field, kw]) => 
          String(row[field]).toLowerCase().includes(kw.toLowerCase())
        )
      );
    }
    
    return result;
  });
  
  function updateCell(rowId: string, field: string, value: string) {
    // 关键点:不可变更新。找出行索引,替换整行对象。
    // 但不触发所有行的重新渲染,Svelte编译器生成的更新代码
    // 只针对这个单元格对应的DOM节点。
    const index = rows.findIndex(r => r.id === rowId);
    if (index >= 0) {
      rows[index] = { ...rows[index], [field]: value };
      // 注意:这里更新了rows数组的某一项,Svelte的$state
      // 会检测到assign操作,触发依赖rows的$derived.by重新计算。
    }
  }
  
  function onSort(field: string) {
    if (!columns.find(c => c.field === field)?.sortable) return;
    if (sortKey.field === field) {
      sortKey.dir = sortKey.dir === 'asc' ? 'desc' : 'asc';
    } else {
      sortKey = { field, dir: 'asc' };
    }
  }
</script>

<div class="svelte-table-wrapper" style:max-height="600px" style:overflow="auto">
  <table class="svelte-table">
    <thead>
      <tr>
        {#each columns as col (col.field)}
          <th>
            {#if col.sortable}
              <button 
                type="button"
                onclick={() => onSort(col.field)}
                aria-label="Sort by {col.label}"
              >
                {col.label}
                {#if sortKey.field === col.field}
                  {sortKey.dir === 'asc' ? '▲' : '▼'}
                {/if}
              </button>
            {:else}
              {col.label}
            {/if}
            
            {#if col.filterable}
              <input 
                type="text"
                placeholder="筛选..."
                value={filterText[col.field] ?? ''}
                oninput={(e) => {
                  const el = e.currentTarget;
                  filterText = { ...filterText, [col.field]: el.value };
                }}
              />
            {/if}
          </th>
        {/each}
      </tr>
    </thead>
    <tbody>
      {#each filteredRows as row (row.id)}
        <tr>
          {#each columns as col (col.field)}
            <td>
              <input 
                type="text"
                value={row[col.field]}
                oninput={(e) => {
                  const el = e.currentTarget;
                  updateCell(row.id, col.field, el.value);
                }}
              />
            </td>
          {/each}
        </tr>
      {/each}
    </tbody>
  </table>
</div>

<style>
  .svelte-table {
    border-collapse: collapse;
    font-size: 13px;
  }
  .svelte-table th, .svelte-table td {
    border: 1px solid #ddd;
    padding: 6px 8px;
    white-space: nowrap;
  }
  .svelte-table input {
    border: none;
    outline: none;
    padding: 4px;
  }
  .svelte-table th input {
    width: 80px;
    font-size: 12px;
  }
</style>

3.3 Vue 3外壳如何加载Svelte组件

用qiankun的micro app方式最省事。先把Svelte组件打包成独立bundle,暴露为一个web component。

// svelte-component-entry.js
// 作为qiankun子应用的入口,把Svelte组件包装成Web Component
import App from './SvelteTable.svelte';

class SvelteTableElement extends HTMLElement {
  constructor() {
    super();
    this._host = document.createElement('div');
    this._app = null;
  }
  
  connectedCallback() {
    this.appendChild(this._host);
    
    // 从attribute读取props
    const columnsAttr = this.getAttribute('columns');
    const rowsAttr = this.getAttribute('rows');
    
    let columns = [];
    let rows = [];
    try {
      columns = columnsAttr ? JSON.parse(columnsAttr) : [];
      rows = rowsAttr ? JSON.parse(rowsAttr) : [];
    } catch (e) {
      console.error('解析columns/rows属性失败', e);
    }
    
    this._app = new App({
      target: this._host,
      props: {
        initialRows: rows,
        columns: columns
      }
    });
  }
  
  disconnectedCallback() {
    if (this._app) {
      this._app.$destroy();
      this._app = null;
    }
  }
  
  // 当Vue侧更新数据时,通过attribute方式传递
  static get observedAttributes() {
    return ['rows'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'rows' && parseFloat(oldValue) !== parseFloat(newValue)) {
      // 实际项目里这里调用组件实例的更新方法
      // 通过dispatchEvent通知Svelte内部更新
      if (this._app) {
        this._app.$set({ initialRows: JSON.parse(newValue) });
      }
    }
  }
}

customElements.define('svelte-table', SvelteTableElement);

// qiankun生命周期
export async function bootstrap() {
  console.log('svelte table micro app bootstrap');
}

export async function mount(props) {
  console.log('svelte table mounted');
}

export async function unmount() {
  console.log('svelte table unmounted');
}
// qiankun配置:注册子应用
{
  "name": "svelte-table-app",
  "entry": "//localhost:4173/svelte-table-app.js",
  "container": "#svelte-container",
  "activeRule": "/admin/products",
  "props": {
    "sharedStore": "vuexStore"
  }
}
# 打包命令,svelte组件构建为umd格式
# package.json scripts
"build:svelte": "vite build --config vite.svelte.config.js --outDir dist/svelte --format umd"
// 在Vue 3组件中调用Svelte Web Component
<template>
  <div>
    <!-- Vue组件内直接写Svelte的自定义元素 -->
    <svelte-table 
      :columns="JSON.stringify(columns)"
      :rows="JSON.stringify(rows)"
      @row-change="handleRowChange"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';

const store = useStore();
const columns = ref([
  { field: 'name', label: '商品名', sortable: true, filterable: true },
  { field: 'sku', label: 'SKU', sortable: true, filterable: true },
  // 项目中的完整列配置
]);

const rows = ref(store.state.productRows);

function handleRowChange(event) {
  // 从Svelte组件发送的CustomEvent中取数据
  const { rowId, field, value } = event.detail;
  store.commit('updateProductCell', { rowId, field, value });
}
</script>

性能数据对比:不是玄学,是实测

测试环境:MacBook Pro M1 Pro 16GB,Chrome 123.0.6312.58,开发环境Vite 5.1.4,生产构建后部署到Nginx静态服务器。数据:3200行×24列。

指标Vue 3.4.21React 18.2.0Svelte 5.0.0
首屏可交互时间(domInteractive到load)3.8s5.2s0.9s
内存占用(加载完成稳定后)287MB341MB178MB
键盘输入响应延迟(单字符)320ms368ms12ms
连续输入10个字符主线程阻塞2.8s3.4s120ms
排序操作耗时(全部数据)890ms1.2s210ms
筛选操作耗时(关键字匹配)540ms780ms85ms
打包体积(gzip)187KB201KB96KB

这个数据是生产构建后的结果。开发环境区别更大,Vite的HMR在Svelte上几乎瞬时的,因为编译粒度小。

Svelte为什么快?原理拆解

三个原因:编译时消除框架开销、细粒度更新、没有虚拟DOM。

Vue和React的共性是运行时框架。Vue 3把响应式对象通过Proxy包装,运行时需要收集依赖、触发回调。React每个组件维护Fiber节点,状态变化执行render函数。这些都需要框架代码在运行时做大量工作。

Svelte把组件编译成原生JavaScript。看下面这行输入绑定编译前后对比。

// 你的源码
let name = $state('张三');
function handleInput(e) {
  name = e.target.value;
}
// UI里:<input bind:value={name} />

// Svelte 5编译后的产物(简化)
let name = '张三';
// 编译器为name生成一套信号机制
const name_signal = signal(name);
function handleInput(e) {
  name_signal.set(e.target.value);
  // 编译器直接生成更新DOM的代码:
  input_element.value = e.target.value;
  // 而不是调用框架的re-render
}
// 监听name变化时,编译器在赋值语句后直接插入DOM更新代码

这就是Svelte的核心设计:响应式状态和DOM更新之间是直接编译出来的代码。没有「状态变化→框架调度→diff→patch」这条链路,省掉了中间层。

Vue 3的优化其实已经很强了,它能摸到「组件级更新」。但组件级别意味着:某个响应式数据变了,整个组件的render函数跑一遍生成新的VNode,再diff。数据对象嵌套越深,Proxy递归的开销越高。我的2100行表格里,sortKey变化触发的computed重算,底层是依赖sortKey的函数重新执行,但表格行的UI更新还是以组件为粒度来跑。

Svelte 5的rune系统更细。它把$state变量编译成一个信号量,读取这个变量的DOM更新点会被标记为这个信号的订阅者。更新时只通知具体那个DOM节点。在这个表格场景里,修改第10行第3列的单元格,只会更新对应的那个input元素,不需要重新跑整个表格的渲染函数。

还有个关键点:Svelte的$derived是惰性计算的。filteredRows只有当它的依赖变化时才重新计算,计算完直接把结果输出到DOM。没有中间VNode的分配和垃圾收集。

避坑指南:这11个坑我替你踩过了

坑1:Svelte的$state是引用相等判断。 你写rows[index].name = value不会触发更新。Svelte编译器只追踪赋值操作,不是对象属性的setter。必须整体替换:rows[index] = {...rows[index], name: value}。这个坑很隐蔽,它不报错,只是UI不更新。

坑2:{#each}列表的key必须稳定。 用index做key的话,排序功能一旦执行,会导致所有输入框重新渲染,获取焦点的输入框失去焦点。用业务唯一id做key,排序后焦点丢失问题消失,因为Svelte复用DOM节点。

坑3:Svelte的transition在有大量DOM更新时可能卡顿。 我给行设置了一个fade transition,结果排序时2100行同时触发fade动画,帧率掉到个位数。解决方案:列表超过2000行就不要用transtion,或者用css动画代替JS动画。

坑4:Web Component在Vue中的class/style继承问题。 Svelte组件编译成web component后,外部传入class默认不会应用到web component内部的根元素,因为Shadow DOM的样式隔离。需要在Svelte组件内部用export let className = ''接收,然后手动加到根元素上。

坑5:JSON.parse的double parse问题。 Vue侧通过attribute传JSON字符串给web component时,HTML解析器会把引号转义搞乱。用v-bind="{ 'rows': String(JSON.stringify(rows)) }" 传,不要直接在模板里写:rows="JSON.stringify(rows)",后者会被HTML实体化。

坑6:qiankun的沙箱隔离可能与web component冲突。 qiankun的js沙箱劫持了window上的事件监听器,Svelte内部使用自定义事件派发(CustomEvent)不受影响,但如果Svelte组件监听document级事件,可能被沙箱拦。建议Svelte组件只通过props和自定义事件通信,别碰全局路由和状态。

坑7:包体积是双刃剑。 Svelte单组件打包96KB,比Vue少了近一半。但如果你在Vue项目中引入多个Svelte组件,每个都打包一份Svelte runtime,最终体积是叠加的。用webpack module federation或vite shared chunks把runtime提取出来共享,我忘了这个导致dist目录增大了70KB。

坑8:Svelte 5的runes与旧版Svelte 4的export let语法不兼容。 迁移时老教程全是export let name,Svelte 5里要用let { name } = $props()。迁移代码时不要照抄Svelte 4的写法,编译器会给出警告,但运行时props会是undefined不报错,排查起来很难受。

坑9:HMR在Svelte 5里有边界情况。 当你修改组件顶部的<script>里的某个$state定义的位置时,hmr可能丢状态导致页面白屏。开发中遇到这情况,Ctrl+R刷新就好。不要浪费时间查代码。

坑10:SSR水合。 如果以后要上SSR,Svelte 5的水合会忽略所有绑定对象的事件监听器,它靠序列化数据到hydratable标记来恢复交互状态,意味着web component方式创建的Svelte在SSR场景下事件会全部失效。目前的替代是继续用CSR。

坑11:最后,你可能会忽略的——Svelte的IDE支持。 Volar(以前是Vetur)对Vue的支持很成熟,但Svelte的VSCode插件在ts类型推断上有bug。当你使用$derived.by()时,类型可能被推断为unknown,需要手动标注。如果遇到类型报错但是代码运行正常,检查一下svelte.config.js里是否配置了typescript插件。

什么场景才值得迁移到Svelte

做这个迁移花了一周时间,数据是真实的。但我必须说:不是所有项目都应该换Svelte。我建议你先检查自己项目的性能面板,主线程长任务出现了多少个。

适合的画像:中后台管理系统(表格密集、表单密集、CRUD)。因为这类应用的状态更新是「小范围高频变化」,Svelte的细粒度更新优势明显。不适合的画像:以「交互少、状态多但变化频率极低」为主的内容站,或者重动画的首页,Vue/React的生态更成熟,动画库和组件库的坑少很多。

如果你们团队已经熟练使用Vue/React,并且业务增长平稳,不建议换。因为换框架的学习成本远高于性能收益。

但如果你遇到的是「表格卡死、页面内存膨胀、交互延迟」这类硬伤,Svelte这个方案是可以救急的。我的迁移方案保留Vue外壳,用web component隔离Svelte组件,三个月跑下来,没有线上故障。

最后补一个验证:Gzip后Svelte 5 runtime只有4.3KB,Vue 3 runtime是34.8KB,React 18 runtime是42.3KB。小是真的小,这个差异不是写文章编出来的,是我构建产物里摘出来的。