本頁由機器翻譯,可能有錯誤。 閱讀英文原文

文件

Web

使用 Native Shell 建構 Web 應用程式,並與原生平台共用相同的元件、樣式與應用程式邏輯。NSH 會將介面渲染為 HTML 元素與 CSS:文字、圖片、輸入、捲動、視覺樣式與互動皆使用瀏覽器的 DOM 與 Web API。

counter.tsx
counter.tsxLive
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, ui } from "./palette";

function Button(props: { label: string; accent?: boolean; onPress: () => void }) {
  return (
    <View onPress={props.onPress} {...button(props.accent ? "accent" : "secondary", { align: "center", style: { minWidth: 96 } })}>
      <Text style={buttonText(props.accent ? "accent" : "secondary")}>{props.label}</Text>
    </View>
  );
}

function App() {
  const count = signal(0);
  const history = signal<number[]>([]);

  const change = (delta: number) => {
    const next = count() + delta;
    count.set(next);
    history.update((h) => [next, ...h].slice(0, 5));
  };

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 24, paddingTop: 64, gap: 24 }}>
      <Text style={{ fontSize: 28, fontWeight: "bold", color: ui().text }}>nsh counter</Text>
      <View style={{ ...ui().card, borderRadius: 16, padding: 24, alignItems: "center", gap: 8 }}>
        <Text style={{ fontSize: 14, color: ui().muted }}>Current value</Text>
        <Text style={{ fontSize: 64, fontWeight: "bold", fontVariant: ["tabular-nums"], color: count() < 0 ? ui().down : ui().text }}>
          {count()}
        </Text>
      </View>
      <View style={{ flexDirection: "row", gap: 12, justifyContent: "center" }}>
        <Button label="−1" onPress={() => change(-1)} />
        <Button label="+1" accent onPress={() => change(1)} />
      </View>
      <View style={{ gap: 6 }}>
        <Text style={{ fontSize: 14, color: ui().muted }}>
          Recent values: <Text style={{ fontWeight: "bold", color: ui().text }}>{history().length}</Text>
        </Text>
        <Each of={history()}>{(value) => <Text style={{ fontSize: 16, color: ui().text }}>• {value()}</Text>}</Each>
      </View>
    </View>
  );
}

mount(App);

nsh package --target web 會將應用程式發佈為靜態網站,可部署到任何 Web 伺服器或 CDN(發佈應用程式),而 nsh run --target web 會在你編輯時於瀏覽器中執行應用程式(在瀏覽器中開發)。本網站上的每個即時示範都使用此 Web 後端。

已發佈的 TSX 應用程式使用瀏覽器的 JavaScript 引擎。打包時會為每個應用程式連結專屬的執行階段,其中只包含該應用程式匯入的 nsh/base 元件與標準模組:以核心元件建構的應用程式約需下載 1.1 MB。語法醬色資料會在第一次醬色程式碼時載入。

#各部分在哪裡執行

頁面由套用 CSS 樣式的 HTML 元素組成。NSH 會將元件更新轉換為批次的 DOM 變更,而瀏覽器負責顯示介面並處理捲動、文字選取與輸入。WebAssembly 承載共用的 Rust 引擎,負責元件狀態、版面配置與更新:可與 Rust 應用程式編譯進來的 crate 一起執行,或搭配瀏覽器的 JavaScript 引擎,由後者以 ES 模組執行 TSX 應用程式的程式碼:已發佈的應用程式、開發伺服器的頁面、本網站的示範與 playground 皆是如此。

在每個原生平台上,引擎都有自己的執行緒,並在每次提交時將一個 MountBatch 交給主執行緒。瀏覽器頁面只有一個執行緒,因此 Web 版本會從頁面的事件迴圈逐步驅動同一個引擎(nsh_engine::LocalEngine):

pointer / keyboard / scroll events ─┐
setTimeout (the engine's deadline) ─┼─▶ nsh.wasm: engine turn (Rust or JS, core, layout) ─▶ one JSON batch ─▶ nsh.js: DOM
requestAnimationFrame ──────────────┘                                                 ▲
                                                          native animation outputs ───┘
  • 版面配置由核心負責。 每個檢視都是依 Taffy 計算出的框架進行絕對定位的元素,因此 flexbox、grid、aspectRatio 與百分比的行為與在裝置上完全相同。瀏覽器本身的版面配置不會參與。
  • 樣式在 Rust 中轉換為 CSS。 背景、邊框、圓角、陰影(boxShadow、textShadow)、漸層、濾鏡、backdropFilter、變形、transformOrigin、outline、mixBlendMode、轉場與內建的關鍵影格動畫都會成為 CSS 屬性。Tailwind 類別、active:、hover: 與 focus: 狀態以及 dark: 皆在原生端解析,與在裝置上相同。
  • 文字在 Rust 中排版,字型度量來自瀏覽器(CanvasRenderingContext2D.measureText),並依版面配置選定的位置逐段繪製,因此量測的結果就是繪製的結果。換行會發生在空格之後、CJK 字元前後以及 \n;numberOfLines 會以省略號截斷。
  • 原生動畫在 Rust 中執行於 requestAnimationFrame:Animated 時間、彈簧與衰減、interpolate、綁定到 opacity、translate、rotate、scale 的動畫值、transform 與 backgroundColor 的步驟、以 pan 綁定的拖曳、與捲動連動的值以及 canvas 引數。與在裝置上一樣,每個影格都不會涉及 JS。LayoutAnimation、樣式轉場、animate-* 關鍵影格與畫面轉場的行為與桌面後端相同,而 prefers-reduced-motion 會將它們關閉(畫面改為淡入淡出)。
  • 輸入: 帶有 activeStyle / active: 回饋的按壓與長按、responder 系統(onTouch*、PanResponder)、原生拖曳與雙指縮放及旋轉、捲動檢視(使用瀏覽器的捲動)、下拉重新整理、可縮放的捲動檢視(縮放、ctrl+滾輪與觸控板縮放,zoomTo)、具有受控值的 <Input>、焦點與提交、Canvas、NavStack 與 NavPage,搭配頁面的 header、Dialog。
  • 系統 UI 繪製於頁面中: AlertDialog 在桌面瀏覽器上看起來像桌面後端的對話框,在手機上則像 iOS 的對話框(採用 desktop 慣用形式,詳見下文)。自訂字型會在瀏覽器載入完成後重新量測,而應用程式會等待其匯入的字型載入完成後才顯示第一個影格。
  • 對話框是覆蓋整個應用程式的一層,用來顯示應用程式的彈出視窗與背景遮罩,與桌面後端相同。Escape、瀏覽器的返回按鈕以及按壓背景遮罩都會關閉對話框(closeOnEscape、closeOnBackdropPress),而 animation、onOpen、onClose、onCancel 與 onOpenChange 的運作方式與在裝置上相同。若頁面將應用程式嵌入其版面配置的一部分,則可改為在整個瀏覽器視窗上方顯示對話框與警示(dialogs: "viewport"),就像 iOS 與 Android 會在整個螢幕上方顯示它們一樣。

#鍵盤、滑鼠與觸控

已發佈的應用程式在桌面瀏覽器中表現為桌面應用程式,在手機瀏覽器中則表現為手機應用程式:

  • 滾輪與觸控板會沿其軸向捲動仍可移動的最內層捲動檢視(當沒有任何可垂直捲動的檢視時,垂直滾輪會捲動水平清單),並在捲動結束前一直停留在該檢視上;滾輪刻度會平滑滑動,分頁與貼齊式檢視每次捲動移動一頁。Ctrl + 滾輪與觸控板縮放會以原生縮放值縮放檢視,而不是縮放頁面。如同桌面平台,以滑鼠拖曳不會捲動:滑鼠拖曳會交給平移、responder 系統與文字選取。捲動檢視會如同桌面平台一般回報 onScrollDragStart / onScrollDragEnd、onMomentumStart / End 與 onScrollEnd。
  • 鍵盤:Tab 與 Shift+Tab 在可按壓元素與輸入之間移動(應用程式的 focus: 樣式會顯示鍵盤焦點);Return 會按下取得焦點的可按壓元素,Space 則在放開時按下;方向鍵、Page Up / Page Down、Space、Home 與 End 會捲動焦點周圍或滑鼠下方的捲動檢視;Escape 會離開欄位、關閉對話框,然後返回。keymap 快速鍵可在瀏覽器中運作,並即時比對(mod 在 Mac 上的 Safari 與 Chrome 為 Command,其他環境為 Control)。
  • 返回:Escape、Alt+Left(Mac 上為 Cmd+[)與滑鼠的返回按鈕會關閉最新的對話框或彈出最內層的堆疊,與 Android 的返回按鈕相同;popOnBack 與 onBackPress 的運作方式也與該處相同。當應用程式佔據整個頁面時,每個推入的頁面與開啟的對話框都是一筆瀏覽器歷史記錄,因此瀏覽器的返回按鈕會在應用程式內返回。
  • 觸控:只有捲動檢視會平移;頁面本身絕不會在應用程式下捲動、縮放或下拉重新整理,且點按沒有延遲。在 iframe 中,應用程式未處理的滑動會捲動嵌入它的頁面。在具有原生平移、縮放與 responder 系統的檢視上拖曳,則由應用程式處理。
  • 無障礙:role、accessibilityLabel、accessibilityHint、accessibilityState、accessibilityValue、accessibilityLiveRegion 與 accessibilityElementsHidden 會轉換為 ARIA;announce 透過 live region 朗讀,setAccessibilityFocus 則會移動焦點。對話框或警示下方的檢視為 inert。
  • 選取與選單:在桌面瀏覽器中,文字預設可選取,如同網頁,但互動元素(具有按壓或觸控處理常式的檢視:按鈕、可按壓的列、分頁標籤)內部以及帶有 userSelect: "none" 或 select-none 的文字除外;以滑鼠拖曳即可選取,Ctrl/Cmd+C 會逐行複製,Ctrl/Cmd+A 會選取選取範圍所在位置的全部文字。在手機與平板瀏覽器中,與原生應用程式相同,只有帶有 userSelect: "text" 的文字可選取。瀏覽器的右鍵選單只會出現在可選取的文字與輸入上。

#使用 Rust 元件的應用程式

以 Rust 撰寫的應用程式(使用 Rust 建構)可從其目錄發佈並在瀏覽器中執行:

nsh package --target web           # .nsh/out/<Name>/: the app's own nsh.wasm, with its crate
nsh run --target web               # the dev server's page; editing the Rust code builds it again

當 nsh.json 指定應用程式的根元件("root")時,頁面僅包含應用程式的 Rust 與 NSH 的頁面指令碼,別無其他:nsh.wasm 包含引擎、元件與應用程式的 crate,網站沒有 app.js,而頁面會在執行階段與字型載入完成後立即掛載根元件。整個 Rust 圖庫為 3.3 MB(gzip 後 1.06 MB,brotli 後 0.75 MB),比 TSX 應用程式的執行階段還小。在其 TSX 進入點 (nsh package App.tsx --target web)之下,同一個應用程式會如同 TSX 應用程式一樣在瀏覽器的 JavaScript 引擎上執行,並匯入 crate 的 元件與模組。

無論採用哪種方式,crate 的資產(assets!())都會與應用程式的資產一同發佈,且其字型會在第一個影格之前載入。 在 nsh run --target web 期間,變更 crate 的 Rust 程式碼會重新建置執行階段,而已開啟的頁面會自行載入; TSX 的編輯仍為熱更新。開發用執行階段採增量建置,因此對 crate 的變更約一秒內即可就緒; nsh package 則會建置針對大小最佳化的發佈版本。

在每個平台(包括 Web)上執行的 Rust 程式碼會使用引擎的服務:fetch(url).await 透過與 JS 的 fetch 相同的網路堆疊發出 HTTP 請求(此處為瀏覽器的堆疊,並遵循其 CORS 規則);timer、 set_timeout 與 set_interval 用於等待;nsh_core::time::Instant 與 nsh_core::time::unix_ms() 用於讀取時鐘。 background 與 blocking 會在原生目標的執行緒上執行工作。

建置需要 Rust target 以及可編譯為 WebAssembly 的 clang,與執行階段本身的建置相同: rustup target add wasm32-unknown-unknown,加上 clang 與 llvm-ar(在 macOS 上,需要 LLVM 工具鏈,例如 Homebrew 的 llvm,並透過 CC_wasm32_unknown_unknown 與 AR_wasm32_unknown_unknown)。

#從 Rust 呼叫瀏覽器 API

crate 可透過 wasm-bindgen 及其 web-sys 與 js-sys crate,在 #[cfg(target_arch = "wasm32")] 之後呼叫瀏覽器自身的 API:WebSocket、IndexedDB、 剪貼簿、任何 Web API,以及 crate 自己的 JS(#[wasm_bindgen(inline_js = …)]、module = "/js/…")。當 應用程式的 Web 執行階段以這種方式呼叫 JS(包括 JsValue 轉換)時,nsh 會對其執行 wasm-bindgen CLI,並 將其膠合程式碼與 crate 的 JS 一併放在執行階段旁,開發與已發佈的網站皆是如此。CLI 的版本與執行階段所連結的 wasm-bindgen crate 相同,由 nsh 指定:cargo install wasm-bindgen-cli --version <version> --locked 會安裝它 (NSH_WASM_BINDGEN 可指定 PATH 以外位置的 CLI)。若執行階段連結了 wasm-bindgen 卻沒有透過它呼叫任何 JS (例如經由相依套件的預設功能),則其建置方式與其他執行階段相同。

crate 會以 web-sys 功能指定其使用的 API,而固定 wasm-bindgen 可讓其版本(以及 CLI 的版本)在每次建置之間保持一致:

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "=0.2.129"
web-sys = { version = "0.3", features = ["WebSocket", "MessageEvent"] }
futures-channel = "0.3"

瀏覽器的回呼(抵達的訊息、頁面的計時器)會喚醒應用程式的任務,任務會立即執行:

#[cfg(target_arch = "wasm32")]
async fn echo(url: &str, text: String) -> Result<String, String> {
    use wasm_bindgen::{JsCast, prelude::*};
    let ws = web_sys::WebSocket::new(url).map_err(|e| format!("{e:?}"))?;
    let (tx, rx) = futures_channel::oneshot::channel();
    let tx = std::cell::RefCell::new(Some(tx));
    let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
        if let (Some(tx), Some(data)) = (tx.borrow_mut().take(), e.data().as_string()) {
            let _ = tx.send(data);
        }
    });
    ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
    on_message.forget();
    let open = ws.clone();
    let on_open = Closure::<dyn FnMut()>::new(move || {
        let _ = open.send_with_str(&text);
    });
    ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
    on_open.forget();
    rx.await.map_err(|_| "closed".to_owned())
}

crate 對瀏覽器 API 的呼叫會以頁面自身的權限執行,就像 crate 的原生程式碼在其他平台上以應用程式的 權限執行一樣:nsh.json 的能力控管經由引擎的內容(fetch、應用程式的 JS), 而瀏覽器的規則(CORS、頁面的 Content-Security-Policy)則控管頁面發出的每個請求。

#發佈應用程式

# A Rust app, from its directory
nsh package --target web                   # .nsh/out/<Name>/: a static site
nsh package --target web --run             # and serve it on this machine (open the address it prints)

# A TSX app
nsh package App.tsx --target web
nsh package App.tsx --target web --run

nsh package --target web 會寫入 .nsh/out/<Name>/(使用 --out 可指定其他目錄),其配置如同前端建置:一個 頁面,以及以內容命名的檔案。

檔案說明
index.html應用程式的頁面:以其名稱作為標題、圖示、其 appearance 的顏色、載入指示器,以及應用程式無法載入或啟動時的訊息
manifest.webmanifest瀏覽器用來安裝應用程式的名稱、id、圖示與顏色
nsh.<digest>.jsDOM 執行階段,已最小化(79 KB,brotli 後 22.5 KB)
nsh.<digest>.wasmRust 引擎、版面配置與元件,加上 Rust 應用程式自己的 crate。TSX 在瀏覽器 JavaScript 中執行;打包時只會連結應用程式第一個畫面所匯入的元件與標準模組。
nsh-<name>.<digest>.wasm應用程式第一個畫面之外所使用的元件、標準模組與著色器:在畫面顯示後於背景下載
<file>.br、<file>.gz預先壓縮的每個檔案,供會傳送預壓縮檔案的主機使用
nsh-syntaxes.<digest>.bin程式碼醬色所用的語法(bat 的集合:0.92 MB,brotli 後 0.8 MB),在程式碼區塊、Editor 或 DiffView 首次以某種語言顯示程式碼時下載
nsh-bindgen.<digest>.js其 crate 呼叫從 Rust 呼叫瀏覽器 API之應用程式的 wasm-bindgen 膠合程式碼,已最小化
snippets.<digest>/<crate>-<hash>/…膠合程式碼所匯入的這些 crate 的 JS(inline_js、module = "/js/…"),位於 wasm-bindgen 指定的路徑下
app.<digest>.js已編譯的 TSX 進入點,為 ES 模組,由瀏覽器載入。
host.<digest>.js頁面的主機,nsh.json web.host(主機的函式)
<name>.<digest>.js由 import() 按需載入的 ES 模組。
assets/<path>.<digest>.<ext>應用程式匯入的每個資產,在使用時從其 URL 載入(字型會在註冊它們的程式碼執行之前載入,因為文字是以它們量測的:在應用程式啟動之前,或隨匯入它們的 chunk 一起載入)
icons/<size>.<digest>.pngnsh.json 的圖示,尺寸為 32(favicon)、180(apple-touch-icon)、192 與 512 像素

<digest> 是檔案內容 SHA-256 的開頭部分:檔案名稱只有在其內容改變時才會改變,因此 新版本只會變更 index.html 以及真正有變更的檔案。所有項目皆使用相對 URL,因此網站 可從任何主機的任何目錄運作(https://example.com/apps/counter/)。

#執行階段大小

打包時會為每個應用程式連結專屬的 nsh.<digest>.wasm,其中包含引擎,以及應用程式第一個畫面所匯入的 nsh/base 元件、標準模組與著色器(shader)。應用程式其他畫面所匯入的內容,會在第一個畫面顯示後於背景下載。若應用程式的 crate 會從 Rust 呼叫瀏覽器 API,其 nsh.<digest>.wasm 會包含完整的執行階段(含其 crate)。以 nsh package --target web 測量(MB = 10^6 位元組):

應用程式使用內容nsh.<digest>.wasmgzipBrotli
examples/counter核心元件3.96 MB1.32 MB0.97 MB
examples/gallery所有元件、zlib 與著色器4.13 MB1.36 MB1.01 MB

#部署

將目錄上傳到任何靜態主機:CDN 後方的物件儲存、Cloudflare Pages、Netlify、GitHub Pages、nginx。以下兩項規則可讓它載入快速且更新可靠:

檔案Cache-Control
index.html、manifest.webmanifestno-cache(每次載入都會重新驗證,因此新版本會立即生效)
其他所有檔案public, max-age=31536000, immutable

請將 .wasm 以 application/wasm 提供(瀏覽器便能在下載的同時進行編譯),並將預先壓縮的副本傳送給支援的瀏覽器:nginx 的 brotli_static on; gzip_static on;、Caddy 的 file_server { precompressed br gzip },或使用 Brotli 壓縮的 CDN。使用 Brotli 時,執行階段的大小約比 gzip 小四分之一。nsh package --target web --run 就是以這種方式在 http://127.0.0.1:4173/ 提供網站。

為網頁發佈的應用程式,其執行方式與打包後的原生應用程式相同:以正式版(release)執行,具備其 nsh.json 所宣告的權限(沙盒與權限),TSX 應用程式則在瀏覽器的 JavaScript 引擎上執行。當這些權限限制了 network 時,頁面還會帶有 Content-Security-Policy connect-src,其中列出所宣告的主機(以及頁面本身的來源),因此瀏覽器本身會拒絕對任何其他主機的請求與重新導向跳轉。導致應用程式無法啟動的錯誤會顯示在頁面上;之後的錯誤則會輸出到瀏覽器的主控台。

#以 iframe 嵌入

網頁上沒有嵌入 API:請以 <iframe> 嵌入已發佈的應用程式。其大小即為應用程式的視窗:

<iframe src="https://apps.example.com/counter/" title="Counter"
        style="width: 390px; height: 844px; border: 0"></iframe>

#在瀏覽器中開發

nsh run --target web               # a Rust app, from its directory
nsh run App.tsx --target web       # a TSX app

會啟動開發伺服器並印出其頁面的位址 http://127.0.0.1:8790/:請在任何瀏覽器中開啟。此頁面與其他開發宿主一樣:它會執行應用程式的每一次建置,將建置錯誤與執行階段錯誤顯示在應用程式上方,並將應用程式的日誌傳送到終端機。變更 Rust 應用程式的 crate 會重新建置其執行階段,已開啟的頁面會自動載入;TSX 的編輯則會就地套用(熱更新)。同一網路上的手機可開啟 nsh run 在其下方印出的位址(on this network: http://192.168.…:8790/)。一旦有 Web 執行階段(在發行版中一律具備),nsh dev 就會提供相同的頁面。

#建置網站的播放器

cargo xtask web                       # nsh.wasm, nsh.js, the player and website/demos/*.tsx
cargo xtask web --out target/play     # somewhere else

輸出目錄包含:

檔案說明
gallery/nsh.<digest>.wasmGallery 的執行階段,由 nsh package 為其連結(使用所有元件與 zlib:約 11.8 MB,gzip 後 3.6 MB)
gallery/nsh-syntaxes.<digest>.bin執行階段用來醒目提示程式碼的語法,在示範首次顯示某種語言的程式碼時才會擷取
nsh-playground.wasm同上,另加 NSH 的 TSX 編譯器,供 playground 使用(約 8 MB,gzip 後 2.4 MB)
gallery/nsh.<digest>.jsDOM 執行階段(已壓縮):載入 wasm、套用批次更新,並將輸入轉換為事件
index.html播放器頁面:為 #<name> 執行 demos/<name>.js
nsh.wasm、nsh.js、browser/播放器載入的執行階段:引擎、DOM 執行階段與瀏覽器宿主的模組
demos/<name>.js由 nsh build --release 編譯的每個示範:一個 ES 模組
gallery/以 nsh package --target web 發佈的 Gallery 應用程式,顯示於 Gallery 頁面

請以任何靜態伺服器提供此目錄(串流編譯需要 application/wasm MIME 類型),然後開啟 index.html#counter。

#Playground

Playground 可在瀏覽器中編輯並執行應用程式:頁面會以 NSH 自己的 JSX 編譯器(內建於 nsh-playground.wasm,不使用 rolldown)編譯 TSX,並在每次停止輸入時於瀏覽器的 JavaScript 引擎上執行。本站的每個示範都有 Edit in Playground 連結,而 Share 會將程式碼放進連結本身,因此不會在伺服器上儲存任何內容。

Playground 應用程式是單一模組:它可匯入 NSH 的模組(nsh 與 nsh/*),但不能匯入其他檔案或套件。console.log 的輸出與錯誤會顯示在手機下方。

此編譯器同時也是一個 Rust 函式 nsh_bundler::compile_module(name, source, ui_sets),不需啟用該 crate 的 bundle 功能即可使用。

#嵌入頁面

Rust 應用程式會以其已發佈的網站嵌入,使用 iframe。對於 TSX,nsh.js 是一個 ES 模組。createPlayer 會將執行階段載入容器;runModule(url) 會啟動由 nsh build 寫出的 bundle(bundle.js,一個 ES 模組),在瀏覽器的 JavaScript 引擎上、於應用程式專屬的 realm 中執行,並取代原本執行的內容。它會回傳一個 promise,在模組載入完成後 resolve,並以使其停止的錯誤 reject,播放器也會回報該錯誤(onError):

import { createPlayer } from "./nsh.js";

const player = await createPlayer(document.querySelector("#phone"), {
  wasm: "./nsh.wasm",
  assetBase: "./assets/",   // where `asset:/…` URIs (image imports) are served from
  // dark: true,            // force an appearance; otherwise it follows the system
  // idiom: "phone",        // force "phone", "pad" or "desktop"; otherwise the pointer and the size decide
  // history: false,        // pages and dialogs as browser history entries; by default only when the app is the page, not in an iframe
  // dialogs: "viewport",   // Dialog, Sheet and AlertDialog over the whole browser window; by default over the container
});
await player.runModule(new URL("./bundle.js", location.href).href);

assets(從 asset:/ 路徑對應到 URL 的對照表)會取代 assetBase,readAsset(uri) 會回傳引擎需要立即取得的資源(如字型)的位元組,或 null,data 會將執行階段首次需要時才擷取的檔案對應到其 URL(syntaxes:nsh-syntaxes.<digest>.bin;預設為 nsh.js 旁的 nsh-<name>.bin),而 glue 是執行階段 wasm-bindgen glue 的 URL,該執行階段的 crate 會呼叫從 Rust 呼叫瀏覽器 API(nsh-bindgen.<digest>.js),播放器會隨執行階段一併載入;player.runModule(url, { capabilities, appId }) 會依應用程式所宣告的權限(其 capabilities.json)執行應用程式,並將其儲存空間保存在其 nsh.json ID 之下(若沒有 ID,則保存在頁面的路徑之下)。nsh package --target web 寫出的頁面會用到以上所有項目。

runModule(url, { chunks }) 也接受分割 bundle 的各個 chunk 所在位置。

player.runSource(code, { name, sourceMap, capabilities, appId }) 會執行以原始碼文字給定的 ES 模組,選項相同。搭配 nsh-playground.wasm 時,player.compileAndRun(tsx, name, options) 會先編譯 TSX,以 runSource 的選項執行,並 resolve 為編譯錯誤,或 null;player.compile(tsx) 會回傳 { code } 或 { error }。

容器的大小即為應用程式的視窗(windowSize());調整容器大小也會調整應用程式大小。player.setDark(true) 可在執行階段切換外觀,應用程式可透過 colorScheme() 與 dark: 類別觀察到變化。

對話框與警示會覆蓋容器。使用 dialogs: "viewport" 時,它們會覆蓋整個瀏覽器視窗,如同頁面自己的強制回應對話框,而應用程式仍以容器作為其視窗:Dialog 會以視窗的大小與安全區域進行版面配置,並從其底部邊緣滑入,其背景遮罩會覆蓋整個頁面,且在其上滾動滑鼠滾輪不會使頁面移動。對話框會顯示在瀏覽器的最上層(top layer,Chrome 114、Safari 17、Firefox 125 及之後版本),因此無論容器位於何處,都會位於頁面其餘內容之上;在較早的瀏覽器中,則只會覆蓋容器。

Sheet 與 Dialog 和 AlertDialog 遵循相同的呈現設定。在同源的 iframe 中,視口(viewport)呈現會自動覆蓋外層頁面,而應用程式仍保持其原本的版面大小。當最後一個彈出視窗關閉時,iframe 會回到其原本的位置與大小;其引擎與狀態會持續保留。文件中的示範皆啟用了此呈現方式。

對於跨來源或沙盒化的 iframe,嵌入頁面須在載入播放器之前註冊宿主:

import { hostPlayerFrame } from "./nsh.js";
const frame = document.querySelector("iframe");
const disconnect = hostPlayerFrame(frame);
frame.src = "https://apps.example.com/player.html"; // player uses dialogs: "viewport"
// When removing the embedding: disconnect();

宿主會接受來自此 iframe 視窗的請求,在彈出視窗顯示期間讓周圍的頁面保持惰性並鎖定其捲動,並在關閉時還原兩者。每一層跨來源的嵌入邊界都需要其宿主的配合;若無配合,呈現會維持在該文件之內,且播放器會回報警告。

#宿主的函式

在網頁上,頁面就是應用程式的宿主:它會回應 host 的 host.call(name, args),並傳送 host.on 所監聽的事件。createPlayer 的 host 是一個物件,其 call(name, args) 會回傳結果或結果的 promise;擲出例外或 reject 會使應用程式的呼叫以該錯誤的訊息 reject。player.hostEvent(event, payload) 會傳送帶有 JSON 酬載的事件(非 JSON 的酬載會擲出例外)。有 host 時,host.available() 為 true。

const player = await createPlayer(container, {
  wasm: "./nsh.wasm",
  host: {
    async call(name, args) {
      if (name === "user.info") return { name: "Ada" };
      throw new Error(`no host function ${name}`);
    },
  },
});
player.hostEvent("theme", { dark: true });

已發佈的應用程式會從 nsh.json 取得其宿主:web.host 指定應用程式目錄中的一個 JS 模組(相對於 nsh.json),並由它匯出 createHost(player)。nsh package --target web 會將其以 host.<digest>.js 之名複製到網站中,頁面會在應用程式啟動前,連同播放器一起執行 createHost;其回傳(或 resolve)的內容必須具有 call 函式,否則頁面會顯示應用程式無法載入。播放器 createHost 自此起會接收傳送的事件:

{
  "capabilities": {
    "host": ["user.*"]
  },
  "web": { "host": "./host.js", "connect": ["api.example.com"] }
}
// host.js
const post = (url) => (args) =>
  fetch(url, { method: "POST", body: JSON.stringify(args) }).then((r) => r.json());

// The functions the app may call, by their exact names.
const functions = new Map([
  ["user.info", post("https://api.example.com/user/info")],
  ["user.settings", post("https://api.example.com/user/settings")],
]);

export async function createHost(player) {
  addEventListener("online", () => player.hostEvent("online", {}));
  return {
    call(name, args) {
      const f = functions.get(name);
      if (!f) throw new Error(`no host function ${name}`);
      return f(args);
    },
  };
}

宿主會依名稱原樣在其函式表中查找,如上所示,並讓其 URL 與路徑保持為自己所有:capabilities.host 是依名稱的開頭授予權限(user.*),因此若宿主將名稱轉換成路徑,就可能觸及授權範圍之外的內容。執行階段會放行長度為 1 到 128 個字元、由 A–Z、a–z、0–9、_、.、: 與 - 組成且不含 .. 的名稱,並拒絕任何其他名稱的呼叫。前綴授權以分隔符號結尾(bridge.*):bridge* 也會比對到 bridgeAdmin。

在應用程式執行之前傳送的事件,例如來自 createHost 本身的事件,會在其啟動後立即依序傳遞,因此應用程式頂層的 host.on 監聽器可以收到它們。在監聽器註冊之前傳送的事件不會重新傳送給它:應用程式應透過呼叫(host.call("user.info"))讀取其起始狀態,並透過事件追蹤其變化。

宿主模組在應用程式的頁面中執行,並共用其 Content-Security-Policy:當應用程式的權限限制了 network 時,頁面的 connect-src 會包含頁面的來源、capabilities.network 列出的主機,以及 web.connect 列出的主機。web.connect 指定宿主模組自己的請求(fetch、XMLHttpRequest、WebSocket)可到達的主機,其模式與 capabilities.network.hosts 相同(api.example.com、*.example.com、"*"),如上方的 api.example.com;應用程式自己的請求仍依其 capabilities 檢查。

模組會原樣複製,因此它以絕對 URL 匯入其他程式碼。應用程式對其呼叫的每個函式,仍需要其 host 權限。nsh run --target web 會在開發頁面上執行它,而 nsh test 會為其測試所開啟的每個應用程式(無頭模式或在瀏覽器中,--target web)執行同一個模組,因此應用程式與其宿主會一起接受測試(測試)。

#作為平台的瀏覽器

platform.os 是 "web"。瀏覽器所在的裝置類型決定應用程式的行為,如 platform.idiom:

  • "desktop":使用滑鼠(可懸停的精確指標)且容器寬度至少 600 點。platform.desktop 為 true,platform.select 選擇 desktop,並套用 desktop={{…}} 屬性。
  • "pad":短邊至少 600 點的觸控螢幕;否則為 "phone"。這些屬於行動裝置:platform.select 選擇 mobile。

慣用型態(idiom)在應用程式啟動時決定,並在其執行期間保持不變,與所有平台相同;窄的桌面視窗仍然是桌面,會依其 windowSize() 進行版面配置。web={{…}} 屬性、platform.select({ web }) 與 web: 類別僅適用於瀏覽器;ios={{…}}、native 與 ios: 不適用於瀏覽器。

頁面會回報其餘的環境資訊,並保持其為最新:

值來自瀏覽器
地區設定(Intl 的預設值)navigator.languages[0]
時區(Date 的本地時間、Intl 的預設 timeZone)瀏覽器的時區(Intl.DateTimeFormat().resolvedOptions().timeZone),包含日光節約時間
版面方向(rtl: / ltr:)有設定時取自 <html dir>,否則取自地區設定的文字書寫系統
reduceMotion()prefers-reduced-motion
fontScale()根元素的字型大小 / 16 px(瀏覽器的文字大小設定)
pixelRatio()devicePixelRatio(會隨縮放與螢幕而改變)
colorScheme()prefers-color-scheme
safeArea()env(safe-area-inset-*) 與容器重疊之處(手機上的 viewport-fit=cover 頁面)
keyboardFrame()螢幕鍵盤覆蓋容器的程度(visualViewport)

platform.version 為 "0.0",而 screenReaderEnabled() 維持 false(瀏覽器不會告知)。頁面周圍的瀏覽器工具列屬於瀏覽器;頁面的 statusBar 是為手機而設。

#網路

fetch(包含串流、AbortController 與 FormData)以及 <Image> 的 URL 都會經由瀏覽器自身的網路堆疊處理,而在這之前 NSH 會先檢查應用程式的權限。由於這些請求是由瀏覽器發出的,因此適用瀏覽器的規則:

  • 相對 URL 與瀏覽器一樣,會以頁面的 URL 作為基準來解析:已發佈的應用程式可用 fetch("/api/items") 存取自己的 API,<Image src="/photos/a.jpg" /> 則從自己的伺服器載入。(原生平台沒有頁面,因此需要使用絕對 URL。)
  • CORS。 對頁面以外的其他來源發出的請求,只有在該伺服器允許頁面的來源(Access-Control-Allow-Origin)時才會成功;否則 fetch 會以網路錯誤拒絕。只有伺服器公開的回應標頭才看得到。
  • Cookie(以及 HTTP 驗證)只會傳送給頁面自身的來源(credentials: "same-origin"),而瀏覽器禁止的標頭(Cookie、User-Agent、Host 等)會被捨棄。
  • 重新導向由瀏覽器自動跟隨。若請求被重新導向後不應該繼續跟隨,則該請求會失敗:redirect: "manual" 與 "error",以及應用程式的權限限制了 network 的請求(NSH 會檢查每一次跳轉,而瀏覽器在跟隨重新導向之前不會透露該次重新導向)。
  • 請求本文若為 ReadableStream,會先全部收集後再一次性送出;回應本文則會在抵達時以串流方式處理。

#拆分的 bundle

TSX 網頁應用程式不需要在顯示第一個畫面之前就下載全部程式碼(Rust 應用程式則是單一的 nsh.wasm,瀏覽器會在下載的同時進行編譯)。nsh package --target web 會以拆分的方式發佈 TSX 應用程式,每個 chunk 都是 <name>.<digest>.js,並由頁面將 chunk 對應到這些檔案。若要自行拆分 bundle,nsh build --split 會把 import() 載入的模組放入各自獨立的 chunk,與 bundle.js 並列:

nsh build --release --split -o site/app
# bundle.js        the app up to its first screen
# Screen.js        import("./Screen") and what only it uses
# detail.js        what two chunks share, when they do (made by the bundler)

第一次 import("./Screen") 會一次下載 Screen.js 及其所匯入的 chunk,接著再進行求值;之後對它的匯入會立即完成解析。頁面會指定 chunk 的位置:預設為與 bundle 放在一起,或是每個 chunk 各自指定一個 URL,用於以內容摘要重新命名的檔案,讓 CDN 可以永久快取:

await player.runModule(entry, {
  chunks: { "Screen.js": "/app.3f2a9c.js", "detail.js": "/app.81bb07.js" },
});

無法下載的 chunk 會使 import() 以指出該 chunk 的 TypeError 拒絕;下一次 import() 會再次嘗試。Chunk 名稱是 bundle 中固定的檔案名稱:應用程式無法讓頁面下載任何其他內容。

範例庫就是以這種方式載入每個示範:lazy(位於 examples/gallery/ui.tsx)會建立一個元件,在第一次渲染時開始匯入,在匯入完成前顯示佔位內容,完成後再渲染所載入的內容。它的第一次載入包含索引以及最先顯示的示範;示範所匯入的字型會隨該示範的 chunk 一起載入。原生建置包含所有模組,因此在原生平台上匯入會立即完成解析:

const Stocks = lazy(() => import("./demos/Stocks").then((m) => m.Stocks));

#限制

以下由瀏覽器決定,Web 後端會遵循瀏覽器的行為:

  • 其他網站的 Web view:WebView 是一個 <iframe>。內嵌 HTML(html)可使用全部功能:onMessage、postMessage、injectJavaScript、注入的腳本與載入事件(它以自己獨立的來源執行,與頁面分離)。其他網站的頁面則只有 postMessage 以及帶有所給定 URL 的載入事件:瀏覽器會對外圍的頁面隱藏它的導覽、標題、歷史紀錄與錯誤,而且無法設定 userAgent。
  • 自動開始播放且帶有聲音的影片:Video 是一個具備所有屬性與事件的 <video>,但瀏覽器只有在使用者手勢之後才會開始播放帶有聲音的影片(靜音影片則可播放)。
  • 原生元件:已註冊的原生元件屬於嵌入 NSH 的原生應用程式;在 Web 上它們的檢視會保持空白,並在主控台顯示警告。
  • 平台服務:讀取剪貼簿(Clipboard.readText,以及 hasText)需要安全環境(HTTPS),並可能會詢問使用者。圖片不會回報 onProgress。
  • 文字排版細節:換行規則是 UAX #14 的子集(空格、CJK、連字號,以及 CJK 標點符號的行首與行尾規則:。、, 與 」 等結束標點不會出現在行首,( 與 「 等開始標點不會出現在行尾),不支援斷字連接,也不支援段落方向以外的雙向重新排序。

播放器與開發伺服器會以開發設定執行應用程式;已發佈的應用程式則以正式版本執行。兩者都會在瀏覽器的 JavaScript 引擎上,於應用程式專屬的 realm 中執行 TSX 應用程式的程式碼。

#另請參閱

  • Packaging:nsh package 的其他目標。
  • Architecture:Web 建置所重用的引擎、核心與掛載批次。
  • Testing:nsh test 以近似的文字引擎,以無頭模式執行相同的引擎。