前端多框架融合本身一直都是可能的,當然一般情況下肯定是不建議的,畢竟多一個框架就多一份維護成本。但有時候在特定情境下,我們不得不這麼做。

  • 部分元件想透過特定框架優點已達到更好的效能
  • 專案框架遷移不想被迫全部一次重新寫,而是逐步替換

多框架共存

在前端 bundler tool 功能越來越強的今天,本身就能處理各式各樣的檔案,尤其是不同框架有自己的模版語法,這時候 bundler tool 就會把這些檔案轉譯成 JavaScript,所以最終還是可以操作這些轉譯後的元件。

但轉譯歸轉譯,我們想要是融合這些框架,而不是完全各自做自己的事,並且同時能共享狀態。這次我以 React 為主要框架,並在其中嵌入 Svelte 元件為例,但嵌入前要先考量幾個問題。

  • 樣式不一致
  • 全域狀態同步
  • props 狀態同步
flowchart TD
    A[React App] -->|Render| B[React Component]
    B -->|Props State Sync| C[Svelte Component]
    D[Global State] <-->|State Sync| B
    D[Global State] <-->|State Sync| C

樣式不一致

這相較於狀態比較沒有什麼難點,如果本身使用 shadcn, tailwindcss, pandacss 等本身就不完全依附於框架的 UI library,設定檔可以共享,這樣成本就只會花在重建元件上,如果不是就會花更多時間寫樣式,因此這個問題單純就是時間成本的問題。

全域狀態同步

畢竟是嵌入,有時還是需要取全域狀態使用,這時得看一下框架有提供什麼與外部對接的方法,像 React 就是 useExternalStore,Svelte 則是 store 內提供的方法。

props 狀態同步

除了單純對接把 component 渲染出來之外,還需要 props 狀態同步,也就是說當 React 傳給 Svelte 的 props 改變時,Svelte 也要跟著更新。

Svelte 嵌入 React

我們需要做一個對接層,把 Svelte 元件封装成 React component,這樣 React 才能使用它,不過要先把 Svelte 拉回現實世界渲染出來,不然是沒辦法使用的,這裡用 mount 方法渲染在 React 節點上。大部分框架都會有這個的方法,像 React 就是 createRoot(target).render(<App />)

import { memo, useLayoutEffect, useRef } from "react";
import { type Component, mount } from "svelte";
 
export const createSvelteComponent = <Props extends Record<string, any>>(
  component: Component<Props>,
) => {
  function SvComponent(props: Props) {
    const ref = useRef<HTMLDivElement>(null);
 
    useLayoutEffect(() => {
      while (ref.current?.firstChild) {
        ref.current.firstChild.remove();
      }
 
      if (ref.current) {
        mount(component, { target: ref.current, props });
      }
    }, []);
 
    return <div ref={ref}></div>;
  }
 
  SvComponent.displayName = component.name;
 
  return memo(SvComponent);
};
import Demo from "./Demo.svelte";
 
const ReactDemo = createSvelteComponent(Demo);
 
export default ReactDemo;

共享全域狀態

我目前在 Svelte 內找到唯一能在外部建立狀態並且能讓 Svelte component 監聽的 API 是 svelte/storewritablereadable,而 React 只要有辦法提供給 userExternalStore 監聽的 API 即可。而且就這麼剛好 writablereadable 本身就有提供 subscribe 方法,這樣就能讓 React 監聽。

將這個建立共享狀態的流程抽象成一個 function。

export const createShareValue = <T>(value: T) => {
  const writableValue = writable(value);
  const sub = (callback: Subscriber<T>) => {
    const unsub = writableValue.subscribe(callback);
 
    return unsub;
  };
 
  const getter = () => get(writableValue);
 
  const useStore = () => {
    const value = useSyncExternalStore(sub, getter);
 
    const set = useCallback((v: SetStateAction<T>) => {
      if (typeof v === "function") {
        return writableValue.update(v as Updater<T>);
      }
 
      return writableValue.set(v);
    }, []);
 
    return [value, set] as const;
  };
 
  return [writableValue, useStore] as const;
};
 
// create new share value
export const [countStore, useCount] = createShareValue(0);

Svelte 可以直接拿 countStore 來使用,也能做更新,所以不需要多做什麼

<script lang="ts">
  import { countStore } from "./store"; 
</script>
 
<p>{$countStore}</p>

這樣就能同時在 React 與 Svelte 共享同個狀態並且誰都能觸發更新。

props 狀態同步

對於第一個與 Svelte 元件對接的 React component 來說,props 狀態有個問題是只會取第一次 mount 的狀態,之後 React 的 props 改變都不會觸發 Svelte 更新。那這裡辦法就是把所有 props 都塞進 useLayoutEffect 的依賴陣列中,這樣每次 props 改變都會觸發 useLayoutEffect,然後重新 mount 一次 Svelte 元件。

import { memo, useLayoutEffect, useRef } from "react";
import { type Component, mount } from "svelte";
 
export const createSvelteComponent = <Props extends Record<string, any>>(
  component: Component<Props>,
) => {
  function SvComponent(props: Props) {
    const ref = useRef<HTMLDivElement>(null);
 
    useLayoutEffect(() => {
      while (ref.current?.firstChild) {
        ref.current.firstChild.remove();
      }
 
      if (ref.current) {
        mount(component, { target: ref.current, props });
      }
-   }, []);
+   }, Object.values(props));
 
    return <div ref={ref}></div>;
  }
 
  SvComponent.displayName = component.name;
 
  return memo(SvComponent);
};

不過這樣每次 props 改變都會重新 mount 一次 Svelte 元件,這樣會導致元件內部狀態被重置掉,而且所有節點會重新渲染,這並不夠好。

透過處理全域狀態的思路做變化可以解決這個問題,在 React 用 useRef 創建一個臨時的 writable 狀態,並且把 props 全部塞進去,這樣 Svelte 只要在 $props 監聽這個傳進來的狀態就能達到 props 狀態同步的效果,React 則是在更新這個 writable 的 ref,而不是強制重新 mount。

不過 Svelte component 的 props 結構上會需要多隔一層,而且為了配合 React 的單向資料流,在此刻意將 props ref 轉成 readonly 再帶入,這樣可以避免 Svelte component 直接從內部修改 props 資訊。

import { memo, useLayoutEffect, useRef } from "react";
import { type Component, mount } from "svelte";
import { type Readable, readonly, type Writable, writable } from "svelte/store";
 
// 多隔一層讓 Svelte 能直接監聽 props 物件
type ReactSvProps<Props extends Record<string, any>> = {
  props: Readable<Props>;
};
 
export const createSvelteComponentStore = <Props extends Record<string, any>>(
  component: Component<ReactSvProps<Props>>,
) => {
  function SvComponent(props: Props) {
    const ref = useRef<HTMLDivElement>(null);
    const propsRef = useRef<Writable<Props> | null>(null);
 
    if (!propsRef.current) {
      propsRef.current = writable(props);
    }
 
    useLayoutEffect(() => {
      while (ref.current?.firstChild) {
        ref.current.firstChild.remove();
      }
 
      if (ref.current && propsRef.current) {
        mount(component, {
          target: ref.current,
          props: {
            props: readonly(propsRef.current),
          },
        });
      }
    }, []);
 
    useLayoutEffect(() => {
      propsRef.current?.set(props);
    }, [props]);
 
    return <div ref={ref}></div>;
  }
 
  SvComponent.displayName = component.name;
 
  return memo(SvComponent);
};
<script lang="ts">
  import { type Readable } from "svelte/store";
  const { props: readonlyProps }: ReactSvProps<{ count: number }>  = $props();
 
  // 解構並監聽變化
  const { count } = $derive($readonlyProps);
 
</script>

這樣就能將在 React 中使用 Svelte component 與 React component 的運作思維保持一致,而不必多思考傳入的 props 是否只是第一次 mount 時才帶入。

不是接完結束了

我們從一開始到現在只是解決了最基本讓 React 與 Svelte 元件能夠互相配合的問題,但實際上還要處理諸如環境設定衝突、與既有狀態管理工具的對接、路由替換等問題,這些都需要根據實際情況來調整與優化。

不過至少可以透過這種方式作小型試驗,等到確認可行後再進行大規模的改動,這樣也能降低風險。