构建在原生 iOS WebView 中运行的 Svelte 组件与 NoSQL 后端的离线优先 CRDT 同步层


我们面临一个棘手的需求:在一个成熟的 iOS 原生应用中,嵌入一个高度动态、需要频繁迭代的“项目看板”模块。完全用 SwiftUI 或 UIKit 重写真实项目中的迭代速度跟不上业务需求,每次微小的文案或逻辑调整都意味着完整的 App Store 审核周期。初步的构想是使用 WKWebView 加载一个 Svelte 构建的单页应用(SPA),通过 API 与后端通信。这个方案在原型阶段看似可行,但很快就暴露了生产环境中的致命缺陷:网络延迟导致 UI 卡顿、状态不一致,并且完全无法离线工作。用户在地铁里打开看板,看到的要么是加载失败的白屏,要么是过时的数据。

传统的解决方案,如在原生代码中做复杂的缓存和请求重试逻辑,会将 Web 与 Native 的边界搞得一团糟,形成一个难以维护的混合体。问题的核心不在于 UI 的渲染技术,而在于状态同步。我们需要一个能在 Native Shell 和 Web Core 之间、以及客户端与服务器之间,实现无缝、低延迟、且支持离线修改的状态同步机制。这把我们引向了一个不那么常规的技术选型:无冲突复制数据类型(CRDTs)。

我们的目标架构是:

  1. Svelte UI: 负责渲染和交互,其状态完全由一个 CRDT 文档(Y.Doc)驱动。
  2. iOS Native Shell: 作为 Svelte 应用的容器,通过 WKWebView 的 JavaScript Bridge 监听并有限地修改 CRDT 状态,同时为 Web 环境提供原生能力(如离线存储)。
  3. NoSQL Backend (MongoDB): 存储 CRDT 文档的持久化状态。
  4. WebSocket Server: 在客户端和服务器之间实时广播 CRDT 的增量更新。

这个方案的本质,是将 Svelte 组件从一个简单的“视图”层,提升为一个自洽的、可离线工作的“状态引擎”,而原生 iOS 应用则为其提供运行环境与持久化支持。

后端基石:WebSocket 与 MongoDB 持久化

后端的核心职责不是处理复杂的业务逻辑,而是成为一个高效的 CRDT 更新广播器和持久化存储器。我们选择 Node.js、ws 库和 y-leveldb 结合 y-mongodb 的思路来实现。在真实项目中,直接使用 y-leveldb 可能不适合分布式部署,但 y-mongodb-provider 的核心逻辑是相似的,这里我们手动勾勒出关键部分。

// server.js
// A simplified WebSocket server for broadcasting CRDT updates.
// In a real project, you'd want more robust error handling, authentication, and scalability.

import { WebSocketServer } from 'ws';
import * as Y from 'yjs';
import { MongodbPersistence } from 'y-mongodb-provider';
import { setAwareness } from 'y-protocols/awareness.js';

const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/crdt_sync_db';
const PORT = process.env.PORT || 8080;

// Setup MongoDB persistence
// This handles fetching the initial document state and persisting updates.
const mdb = new MongodbPersistence(MONGODB_URI, {
  collectionName: 'yjs-docs',
  flushSize: 100,
  multipleCollections: true,
});

const wss = new WebSocketServer({ port: PORT });

// A map to hold all documents currently active in memory.
// Key: document ID (e.g., 'project-board-alpha')
// Value: { doc: Y.Doc, conns: Set<WebSocket> }
const docs = new Map();

// When a client connects...
wss.on('connection', (conn, req) => {
  // Extract document ID from the URL, e.g., ws://localhost:8080/project-board-alpha
  const docId = req.url.slice(1).split('?')[0];

  let docData = docs.get(docId);

  if (!docData) {
    // If the document is not in memory, create it.
    const doc = new Y.Doc();
    docData = { doc, conns: new Set() };
    docs.set(docId, docData);

    // Fetch the document's content from MongoDB.
    mdb.getYDoc(docId).then((persistedDoc) => {
      // Apply the persisted state to our in-memory document.
      Y.applyUpdate(doc, Y.encodeStateAsUpdate(persistedDoc));
    });

    // Listen for updates on the in-memory doc and persist them.
    doc.on('update', (update) => {
      mdb.storeUpdate(docId, update);
    });
  }

  docData.conns.add(conn);

  // Handle messages from the client.
  conn.on('message', (message) => {
    try {
      const update = new Uint8Array(message);
      // Apply the client's update to the server's version of the document.
      Y.applyUpdate(docData.doc, update, conn);
      
      // Broadcast the update to all other connected clients for this document.
      docData.conns.forEach(client => {
        if (client !== conn && client.readyState === client.OPEN) {
          client.send(update);
        }
      });
    } catch (err) {
      // Proper logging is crucial here.
      console.error(`Failed to process message for docId: ${docId}`, err);
    }
  });

  // When a client disconnects...
  conn.on('close', () => {
    docData.conns.delete(conn);
    // If no clients are connected to this document, we can remove it from memory
    // to conserve resources. It's already persisted in MongoDB.
    if (docData.conns.size === 0) {
      docs.delete(docId);
      console.log(`Document ${docId} removed from memory.`);
    }
  });

  // Send the initial state of the document to the newly connected client.
  const stateVector = Y.encodeStateVector(docData.doc);
  const initialState = Y.encodeStateAsUpdate(docData.doc);
  conn.send(initialState);
});

console.log(`WebSocket server running on port ${PORT}`);

这个后端实现非常精炼,但包含了核心逻辑:

  • 文档隔离: 通过 URL (/docId) 来区分不同的协作文档。
  • 内存与持久化: 活跃的文档保留在内存中以实现低延迟广播,同时通过 y-mongodb-provider 的逻辑将更新异步写入 MongoDB。
  • 增量同步: Y.applyUpdatedoc.on('update', ...) 是 CRDT 的精髓,只交换和存储增量变化,而不是整个文档。

Svelte 前端:状态管理的革命

在 Svelte 侧,我们将彻底抛弃传统的 useStatefetch 模式。UI 的状态将直接绑定到 Y.Doc 实例。我们使用 yjsy-websocket 库来处理这一切。

<!-- src/components/ProjectBoard.svelte -->
<script>
  import { onMount } from 'svelte';
  import * as Y from 'yjs';
  import { WebsocketProvider } from 'y-websocket';

  // The core of our state management: a Yjs document.
  const ydoc = new Y.Doc();
  let provider;
  
  // Create a Yjs Map to store our board data.
  // This is a CRDT-aware Map structure.
  const boardData = ydoc.getMap('boardData');

  let columns = [];
  let tasks = {}; // An object to hold tasks, keyed by column id

  onMount(() => {
    // The docId should be passed from the native side or determined dynamically.
    const docId = 'project-board-alpha';
    provider = new WebsocketProvider('ws://localhost:8080', docId, ydoc);

    provider.on('status', event => {
      console.log('WebSocket Connection Status:', event.status); // 'connected' or 'disconnected'
      // The native shell can listen for these logs to display connection status.
    });

    // Observe changes on the 'boardData' map.
    boardData.observe(event => {
      console.log('Board data changed, re-rendering...');
      // A simple but effective way to trigger Svelte's reactivity.
      updateLocalState();
    });
    
    // Initialize the local state from the Y.Doc
    updateLocalState();
    
    // A one-time setup for initial board structure if it doesn't exist.
    if (!boardData.get('columns')) {
      boardData.set('columns', [
        { id: 'col-1', title: 'To Do' },
        { id: 'col-2', title: 'In Progress' },
        { id: 'col-3', title: 'Done' }
      ]);
      boardData.set('tasks', {
        'task-1': { id: 'task-1', content: 'Setup CRDT backend' },
        'task-2': { id: 'task-2', content: 'Integrate Svelte with Yjs' },
      });
      boardData.set('columnOrder', ['col-1', 'col-2', 'col-3']);
      boardData.set('taskOrder', {
        'col-1': ['task-1', 'task-2'],
        'col-2': [],
        'col-3': []
      });
    }

    return () => {
      // Cleanup on component destruction.
      provider.disconnect();
      ydoc.destroy();
    };
  });

  function updateLocalState() {
    // This function synchronizes the Svelte component's local variables
    // with the state in the Y.Doc. It's called whenever the Y.Doc changes.
    const data = boardData.toJSON();
    columns = data.columns || [];
    tasks = data.tasks || {};
    // ... update other state variables like columnOrder etc.
  }

  function handleAddTask(columnId) {
    const newTaskId = `task-${Date.now()}`;
    const newTaskContent = `New Task ${Object.keys(tasks).length + 1}`;
    
    // All state modifications are transactions on the Y.Doc.
    // This is an atomic operation.
    ydoc.transact(() => {
      const currentTasks = boardData.get('tasks');
      boardData.set('tasks', { ...currentTasks, [newTaskId]: { id: newTaskId, content: newTaskContent } });
      
      const currentTaskOrder = boardData.get('taskOrder');
      const columnTasks = currentTaskOrder[columnId] || [];
      currentTaskOrder[columnId] = [...columnTasks, newTaskId];
      boardData.set('taskOrder', { ...currentTaskOrder });
    });
  }
  
  // Drag-and-drop logic would also manipulate the Y.Doc via transactions.
  // ... (Implementation for drag and drop handlers omitted for brevity)
</script>

<div class="board">
  {#each columns as column}
    <div class="column">
      <h3>{column.title}</h3>
      <div class="task-list">
        <!-- Render tasks based on tasks and taskOrder state -->
      </div>
      <button on:click={() => handleAddTask(column.id)}>Add Task</button>
    </div>
  {/each}
</div>

<style>
  /* Basic styling for the board */
  .board { display: flex; gap: 16px; }
  .column { background: #f4f4f4; padding: 8px; border-radius: 4px; min-width: 250px; }
  .task-list { min-height: 100px; }
</style>

Svelte 代码的关键点在于:

  • 单一数据源: ydoc 是唯一的状态来源。所有用户交互(如添加任务)都通过 ydoc.transact 来修改状态。
  • 响应式绑定: 通过 boardData.observe 监听 CRDT 的变化,然后手动调用一个函数 updateLocalState 来更新 Svelte 的局部变量,从而触发 UI 重新渲染。这虽然不是 Svelte Stores 那样的全自动响应,但在 CRDT 场景下是一种清晰且高效的模式。
  • 离线能力: y-websocket 自动处理网络断开和重连。当离线时,所有对 ydoc 的修改都会被记录在本地。一旦网络恢复,它会自动将本地的变更发送给服务器,并接收服务器端的变更,CRDT 的算法保证了最终状态的一致性。

iOS 原生桥:WKWebView 与 CRDT 状态的交互

这是整个架构中最具挑战性的部分。iOS 原生代码需要与 WebView 中的 Svelte/Yjs 环境进行双向通信。我们不直接传递整个状态树,而是通过一个定义好的协议来交换信息和命令。

sequenceDiagram
    participant iOS_Native as iOS (Swift)
    participant WebView_Bridge as JavaScript Bridge
    participant Svelte_App as Svelte (Yjs)
    participant WebSocket_Server as Server

    iOS_Native->>WebView_Bridge: setupMessageHandler("crdtBridge")
    
    Note right of Svelte_App: Svelte App Initializes
    Svelte_App->>WebSocket_Server: Connects & Syncs Y.Doc

    Svelte_App->>WebView_Bridge: postMessage({ type: "docLoaded", payload: { ... } })
    WebView_Bridge->>iOS_Native: userContentController(didReceive message)
    iOS_Native->>iOS_Native: Update native UI (e.g., task count badge)
    
    Note over iOS_Native, Svelte_App: User interacts with native button
    iOS_Native->>WebView_Bridge: evaluateJavaScript("window.crdtAPI.addTask('col-1')")
    WebView_Bridge->>Svelte_App: Executes addTask function
    Svelte_App->>Svelte_App: ydoc.transact(...)
    Svelte_App->>WebSocket_Server: Broadcasts CRDT update

Swift 端实现:

// HybridWebViewController.swift
import UIKit
import WebKit

// Define the contract for messages sent from JS to Swift
struct ScriptMessage: Decodable {
    let type: String
    // Using a generic dictionary for payload for flexibility
    let payload: [String: Any]?

    // Custom decoder to handle dynamic payload
    private struct DynamicCodingKeys: CodingKey {
        var stringValue: String
        init?(stringValue: String) { self.stringValue = stringValue }
        var intValue: Int?
        init?(intValue: Int) { return nil }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
        type = try container.decode(String.self, forKey: DynamicCodingKeys(stringValue: "type")!)
        payload = try container.decodeIfPresent([String: Any].self, forKey: DynamicCodingKeys(stringValue: "payload")!)
    }
}

class HybridWebViewController: UIViewController, WKScriptMessageHandler {

    private var webView: WKWebView!
    private let messageHandlerName = "crdtBridge"

    override func viewDidLoad() {
        super.viewDidLoad()
        setupWebView()
        loadSvelteApp()
    }

    private func setupWebView() {
        let contentController = WKUserContentController()
        // Register the message handler. This is how JS communicates with Swift.
        contentController.add(self, name: messageHandlerName)

        let configuration = WKWebViewConfiguration()
        configuration.userContentController = contentController
        
        // Enable file access for local development. In production, this might differ.
        configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")

        webView = WKWebView(frame: view.bounds, configuration: configuration)
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(webView)
    }

    private func loadSvelteApp() {
        // In a real app, the Svelte build output (index.html, js, css) would be bundled.
        guard let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "svelte-dist") else {
            // Critical error: The web app is missing from the bundle.
            fatalError("Could not find Svelte app in bundle.")
        }
        webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
    }

    // This method is the entry point for all messages from the WebView.
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard message.name == messageHandlerName else { return }
        
        guard let bodyString = message.body as? String,
              let bodyData = bodyString.data(using: .utf8) else {
            print("Error: Could not parse message body from WebView.")
            return
        }
        
        do {
            let scriptMessage = try JSONDecoder().decode(ScriptMessage.self, from: bodyData)
            handleScriptMessage(scriptMessage)
        } catch {
            print("Error decoding message from JS: \(error)")
        }
    }

    private func handleScriptMessage(_ message: ScriptMessage) {
        // Here we route messages based on their type.
        // This acts as a mini-API endpoint within the native app.
        switch message.type {
        case "docLoaded":
            print("Svelte app reported document loaded.")
            if let taskCount = (message.payload?["taskCount"] as? NSNumber)?.intValue {
                 // Example: update a native UI element based on web state.
                 self.navigationItem.title = "Project Board (\(taskCount) tasks)"
            }
        case "syncStatusChanged":
            if let status = message.payload?["status"] as? String {
                print("Sync status is now: \(status)") // e.g., "connected", "disconnected"
                // Update a native connection status indicator.
            }
        default:
            print("Received unknown message type: \(message.type)")
        }
    }
    
    // --- Methods for Swift to call JS ---
    
    // Example: A native button press triggers this function to add a task.
    @objc func didTapAddTaskButton() {
        let columnId = "col-1" // Determined by native logic
        let jsCode = "window.crdtAPI.addTask('\(columnId)');"
        
        evaluateJavaScript(jsCode)
    }
    
    private func evaluateJavaScript(_ script: String) {
        webView.evaluateJavaScript(script) { result, error in
            if let error = error {
                // In production, this should go to a proper logging service.
                print("JavaScript evaluation error: \(error)")
            }
            if let result = result {
                print("JavaScript evaluation result: \(result)")
            }
        }
    }
}

// Helper to decode [String: Any]
extension KeyedDecodingContainer {
    func decode(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any] {
        var dictionary = [String: Any]()
        for key in allKeys {
            if let intValue = try? decode(Int.self, forKey: key) {
                dictionary[key.stringValue] = intValue
            } else if let stringValue = try? decode(String.self, forKey: key) {
                dictionary[key.stringValue] = stringValue
            } else if let boolValue = try? decode(Bool.self, forKey: key) {
                dictionary[key.stringValue] = boolValue
            } else if let nestedDictionary = try? decode([String: Any].self, forKey: key) {
                dictionary[key.stringValue] = nestedDictionary
            } else if let nestedArray = try? decode([Any].self, forKey: key) {
                dictionary[key.stringValue] = nestedArray
            }
        }
        return dictionary
    }
    func decodeIfPresent(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any]? {
        return try? decode(type, forKey: key)
    }
    func decode(_ type: [Any].Type, forKey key: K) throws -> [Any] {
        var container = try nestedUnkeyedContainer(forKey: key)
        return try container.decode(type)
    }
}

extension UnkeyedDecodingContainer {
    mutating func decode(_ type: [Any].Type) throws -> [Any] {
        var array: [Any] = []
        while isAtEnd == false {
            if let value = try? decode(String.self) {
                array.append(value)
            } else if let value = try? decode(Int.self) {
                array.append(value)
            } else if let value = try? decode(Bool.self) {
                array.append(value)
            } else if let nestedDictionary = try? decode([String: Any].self) {
                array.append(nestedDictionary)
            } else if let nestedArray = try? decode([Any].self) {
                array.append(nestedArray)
            }
        }
        return array
    }
}

JavaScript Bridge 在 Svelte 端:

// This code would live in your Svelte app, probably in the main App.svelte or a dedicated service.
// It exposes a global API for Swift to call and posts messages back to Swift.

function postToNative(type, payload) {
  if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.crdtBridge) {
    const message = JSON.stringify({ type, payload });
    window.webkit.messageHandlers.crdtBridge.postMessage(message);
  } else {
    console.warn('Native message handler (crdtBridge) is not available.');
  }
}

// Expose a global API for the native shell to interact with.
window.crdtAPI = {
  addTask: (columnId) => {
    // This assumes `ydoc` is accessible in this scope.
    // In a real Svelte app, you'd get this from a store or context.
    const boardData = ydoc.getMap('boardData');
    const newTaskId = `task-${Date.now()}`;
    const newTaskContent = `Task from Native`;
    
    ydoc.transact(() => {
      const currentTasks = boardData.get('tasks');
      boardData.set('tasks', { ...currentTasks, [newTaskId]: { id: newTaskId, content: newTaskContent } });
      
      const currentTaskOrder = boardData.get('taskOrder');
      const columnTasks = currentTaskOrder[columnId] || [];
      currentTaskOrder[columnId] = [...columnTasks, newTaskId];
      boardData.set('taskOrder', { ...currentTaskOrder });
    });
    
    console.log(`Task added to column ${columnId} via native call.`);
  },
  // Other methods like `deleteTask`, `moveTask` could be exposed here.
};

// Example of sending data to native when the document loads/changes
boardData.observe(event => {
  const tasks = boardData.get('tasks');
  const taskCount = tasks ? Object.keys(tasks).length : 0;
  postToNative('docLoaded', { taskCount });
});

provider.on('status', ({ status }) => {
  postToNative('syncStatusChanged', { status });
});

这套桥接机制的健壮性在于其清晰的边界。Swift 不关心 DOM,JavaScript 也不关心 UIKit。它们通过一个定义良好的、基于消息的 API (crdtBridge) 和一个命令 API (window.crdtAPI) 进行通信,而底层的 CRDT 状态同步则由 Yjs 自动处理。

局限性与未来路径

这套架构并非没有成本。它的主要复杂度在于引入了 CRDT 这一概念,团队需要对其工作原理有所了解。同时,原生与 Web 之间的通信桥需要精心设计和维护,避免成为性能瓶颈或出现难以调试的竞态条件。一个常见的错误是在这个桥上发送大量或频繁的数据,我们的设计通过只发送事件通知和高级命令来规避了这一点,真正的状态同步由 Yjs 的高效二进制协议处理。

当前的实现中,CRDT 的“权威”实例存在于 WebView 的 JavaScript 环境中。原生代码通过执行 JS 来间接修改它。一个更深度的集成方案可能是在 Swift 中实现一个原生的 CRDT 库(或绑定一个 Rust/C++ 的核心实现),让原生代码和 WebView 共享同一个 CRDT 状态模型。这将消除 JS Bridge 的部分开销,并提供更强的类型安全,但这同样也带来了更高的实现和维护成本。

此外,对于极其复杂的文档结构,Yjs 在内存中的对象图也可能占用可观的内存。需要针对具体场景进行性能分析,确保其在移动设备的资源限制下表现良好。


  目录