單執行緒與雙執行緒
怎麼選
| 單執行緒(預設) | 雙執行緒(runtime: 'dual') | |
|---|---|---|
| 程式在哪裡執行 | 全部在 WebView | 畫面在 WebView;業務邏輯在容器的獨立 JS 引擎 |
| 適合 | 大部分小程序 | 資料處理較重(大量格式化、排序、計算),或希望畫面層保持很薄 |
| 重運算會不會卡畫面 | 會(WebView 主執行緒被佔住) | 不會(邏輯層在另一個執行緒) |
| 最低容器版本 | 1.0.0 | 1.1.0(建議 1.2.0,見下方) |
不確定就先用單執行緒;之後改成雙執行緒時,SDK 的 auth、ui、system、http、storage、share、telemetry 介面完全相同,搬過去即可。
雙執行緒的架構
┌──────────── WebView(畫面層)────────────┐ ┌──── 容器 JS 引擎(邏輯層)────┐
│ sdk.view.connect(render) ◀── 資料 ──────┼──────┼── setData({ ... }) │
│ sdk.view.emit('refresh') ─── 事件 ──────┼──────┼─▶ handlers.refresh() │
│ sdk.ready() / sdk.theme / sdk.navigate │ │ sdk.auth / sdk.http / ... │
└──────────────────────────────────────────┘ └───────────────────────────────┘- 邏輯層用
definePage宣告初始資料、生命週期與事件處理,用setData更新資料。 - 畫面層用
sdk.view.connect拿到資料(先一份完整快照,之後每次更新都給合併後的完整資料),用sdk.view.emit把點擊等事件送給邏輯層。 - iOS 用系統的 JavaScriptCore,Android 用 QuickJS。
設定
ts
// miniapp.config.ts
export default defineConfig({
// ...
runtime: 'dual',
logic: 'src/logic.ts',
minContainer: '1.2.0',
});minContainer 最低 1.1.0;建議 1.2.0:1.2.0 起快照與更新帶有序號,畫面層不會漏掉和快照同時送達的更新。
邏輯層
ts
// src/logic.ts
import { definePage, getData, setData, sdk } from '@im/miniapp-sdk/logic';
interface ViewData {
status: 'loading' | 'ready' | 'error';
count: number;
[key: string]: unknown;
}
definePage<ViewData>({
data: { status: 'loading', count: 0 },
async onLoad(launch) {
// launch 與畫面層的 getLaunch() 欄位相同(appId、locale、path…)
const res = await sdk.http.fetch('https://api.example.com/count');
setData<ViewData>({ status: 'ready', count: res.json<{ count: number }>().count });
},
onShow() {
// 從背景回到前景、或關閉後再次打開(保活接回)時呼叫
},
onHide() {
// 退到背景、或關閉小程序(進入保活)時呼叫:要存的資料在這裡存
},
handlers: {
increment() {
setData<ViewData>({ count: getData<ViewData>().count + 1 });
},
},
});definePage整個 bundle 只能呼叫一次。setData(diff)是淺層合併:只覆寫你給的頂層 key,其他 key 保留。同一個 microtask 內的多次呼叫會合併成一次送出。- 值必須能 JSON 序列化(不能放函式、
Date、Map、循環參照)。 onLoad、onShow、onHide、handler 裡拋出的錯誤或被 reject 的 Promise 會上報給容器,不會讓邏輯層停止。- 畫面層送來不存在的事件名稱也會上報錯誤。
邏輯層可以用什麼
| 可以 | 不行 |
|---|---|
setTimeout、clearTimeout、setInterval、clearInterval | window、document 等 DOM |
console.log/info/warn/error/debug、queueMicrotask、Promise | fetch、XMLHttpRequest(網路一律用 sdk.http.fetch) |
JSON、Math、Date、RegExp 等語言內建 | Intl(Android 的 QuickJS 沒有;iOS 雖然有,也不要用,兩邊行為要一致) |
@im/miniapp-sdk/logic 的 sdk.auth/ui/system/http/storage/share/telemetry | sdk.ready、sdk.theme、sdk.navigate、sdk.view(只有畫面層有) |
- 語法目標是 ES2020,CLI 會轉譯。
- 邏輯層不讀專案的
vite.config.ts與.env:resolve.alias(例如@/)、Vite plugins、import.meta.env.VITE_*都不能用,請用相對路徑 import。 - 邏輯層會打包成單一檔案(動態
import()也會併進同一個檔案),不能 import CSS 或圖片。體積上限 2 MB。 - 計時器的延遲和瀏覽器相同,以 32 位元整數換算:
NaN、Infinity、負數視為 0;setInterval最小 4 ms。 - 數字、日期要自己格式化(例如千分位用正規表示式),不要依賴
toLocaleString。
無窮迴圈無法中斷
邏輯層卡在無窮迴圈(或無限的 Promise 鏈)時,容器無法中斷它,只能等使用者關閉小程序後丟棄整個邏輯層。迴圈要有明確的結束條件,大量資料要分批處理。
畫面層
vue
<script setup lang="ts">
import { sdk } from '@im/miniapp-sdk';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
interface ViewData {
status: 'loading' | 'ready' | 'error';
count: number;
[key: string]: unknown;
}
const data = ref<ViewData>({ status: 'loading', count: 0 });
let disconnect: (() => void) | undefined;
let readySent = false;
onMounted(() => {
disconnect = sdk.view.connect<ViewData>(
(next) => {
data.value = next;
// 邏輯層還沒啟動完成時,第一次可能是空物件:用明確的狀態判斷。
if (!readySent && (next.status === 'ready' || next.status === 'error')) {
readySent = true;
void nextTick(() => sdk.ready().catch(() => {}));
}
},
() => {
// 取快照失敗(例如不在容器內)
data.value = { ...data.value, status: 'error' };
},
);
});
onBeforeUnmount(() => disconnect?.());
const increment = () => void sdk.view.emit('increment').catch(() => {});
</script>connect的回呼每次都拿到新物件(合併後的完整資料),可以直接整個替換。- 邏輯層還沒啟動完成時,第一次回呼可能是空物件
{},初始資料隨後送達。 - 同一個頁面可以有多個元件各自
connect,每個都會拿到完整、正確的資料。 - 邏輯層還在啟動時送出的
emit會先排隊,啟動完成後依序送達,不會遺失。
資料更新的保證
- 每次更新送達容器,資料的序號(
rev)就加一(短時間內的多次setData會先合併成一次)。快照包含序號不大於它的所有更新。 - SDK 在快照回來前先暫存收到的更新,快照到了只套用比它新的;之後也會丟掉已包含在快照裡的更新。所以不論更新和快照誰先到,畫面層看到的資料都一致。
- 容器約每一幀把累積的更新送一次。更新只送有變的頂層 key:大型清單不要每次都整份重送,能分開的狀態放在不同的 key。
完整範例見工具裡的 examples/points(會員點數小程序)。