元素
WebView
web view 在应用内显示一个网页,或你传入的一段 HTML:iOS 和 Mac Catalyst 上是 WKWebView,Android 上是 android.webkit.WebView。由于 NSH 用原生视图渲染,web view 和其他视图一样处在布局中,与周围视图按顺序排列。Rust 用 web_view(url) 或 web_view_html(html, base_url) 创建,TSX 用从 nsh 导入的 <WebView>。
import { mount, signal, Text, View, WebView, type WebViewNavigation, type WebViewRef } from "nsh";
import { platform, windowSize } from "nsh";
import { button, buttonText, ui } from "./palette";
// The page: a button that posts to the app, and a line that shows what the
// app posts back.
const page = (c: ReturnType<typeof ui>) => `<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inline page</title>
<style>
body { font: 15px system-ui, sans-serif; margin: 0; padding: 16px; background: ${c.page}; color: ${c.text}; }
button { font: inherit; font-weight: 600; padding: 10px 14px; border-radius: 10px; border: 1px solid ${c.border}; background: ${c.accent}; color: ${c.page}; }
.muted { color: ${c.muted}; }
#got { margin-top: 12px; }
#box { margin-top: 12px; height: 48px; border-radius: 10px; background: ${c.border}; transition: background 0.3s; }
</style>
<p class="muted">This is HTML in a web view.</p>
<button id="post">postMessage to the app</button>
<p id="got" class="muted">Nothing from the app yet.</p>
<div id="box"></div>
<script>
let taps = 0;
document.getElementById("post").onclick = () =>
window.nsh.postMessage("tap " + ++taps + " from the page");
window.addEventListener("message", (e) => { document.getElementById("got").textContent = "App says: " + e.data; });
</script>`;
function Button(props: { label: string; onPress: () => void }) {
return (
<View
onPress={props.onPress}
{...button()}
>
<Text style={buttonText()}>{props.label}</Text>
</View>
);
}
const colors = () => [ui().accent, ui().info, ui().violet, ui().down, ui().warn];
function App() {
const messages = signal<string[]>([]);
const nav = signal<WebViewNavigation | null>(null);
const sent = signal(0);
let painted = 0;
let web!: WebViewRef;
const wide = () => windowSize().width >= 700;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>web-view</Text>
<View style={{ flex: 1, flexDirection: wide() ? "row" : "column", gap: 16 }}>
<WebView
ref={web}
html={page(ui())}
// Runs after each load: the page reports its title.
injectedJavaScript={`window.nsh.postMessage("loaded: " + document.title)`}
onLoad={(e) => nav.set(e)}
onMessage={(e) => messages.set([e.data, ...messages()].slice(0, 4))}
style={{ flex: 1, minHeight: 240, borderRadius: 14, overflow: "hidden", borderWidth: 1, borderColor: ui().border }}
/>
<View style={{ gap: 12, width: wide() ? 320 : undefined }}>
<Text style={{ color: ui().muted }}>
{platform.desktop ? "Click" : "Tap"} the button in the page to post to the app; these buttons call the ref's methods.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Button
label="postMessage"
onPress={() => {
sent.update((n) => n + 1);
web.postMessage(`hello #${sent()}`);
}}
/>
<Button
label="injectJavaScript"
onPress={() => {
const color = colors()[painted++ % colors().length];
web.injectJavaScript(`document.getElementById("box").style.background = "${color}"`);
}}
/>
<Button label="reload" onPress={() => web.reload()} />
</View>
<View style={{ ...ui().card, borderRadius: 12, padding: 12, gap: 4 }}>
<Text style={{ color: ui().muted, fontSize: 12 }}>onLoad</Text>
<Text style={{ numberOfLines: 1, color: ui().text, fontFamily: "monospace", fontSize: 13 }}>
{nav() ? `title "${nav()!.title}"` : "…"}
</Text>
<Text style={{ color: ui().muted, fontSize: 12, marginTop: 6 }}>onMessage, newest first</Text>
<Each of={messages()}>
{(m) => <Text style={{ color: ui().text, fontFamily: "monospace", fontSize: 13 }}>{m()}</Text>}
</Each>
</View>
</View>
</View>
</View>
);
}
mount(App);use nsh_core::prelude::*;
fn browser() -> impl IntoElement {
let title = signal(SharedString::from("加载中…"));
view()
.flex_1()
.child(text(title).number_of_lines(1))
.child(
web_view("https://example.com")
.flex_1()
.on_navigation_state_change(move |e: &WebNavigation| title.set(e.title.clone().into())),
)
}import { signal, Text, View, WebView, type WebViewNavigation } from "nsh";
function Browser() {
const nav = signal<WebViewNavigation | null>(null);
return (
<View style={{ flex: 1 }}>
<Text style={{ numberOfLines: 1 }}>{nav()?.title ?? "加载中…"}</Text>
<WebView
src="https://example.com"
style={{ flex: 1 }}
onNavigationStateChange={(e) => nav.set(e)}
/>
</View>
);
}在 TSX 中 web view 接受 src 和 html 二者之一(参见 WebView API)。Web view 与视频指南介绍了通信模式和能力声明。
#属性
web view 接受 view 的属性(它没有子元素),另外还有:
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
src | web_view(url) | string | 要显示的页面的 URL。 | |
html | web_view_html(html, base_url)、.html(html, base_url) | string | 内联 HTML,代替 src 显示。 | |
baseUrl | web_view_html / .html 的 base_url | string | html 中相对链接的解析基准。 | |
injectedJavaScript | .injected_java_script(..) | string | 每个页面加载完成后执行。 | |
injectedJavaScriptBeforeContentLoaded | .injected_java_script_before_content(..) | string | 在每个页面自身的脚本之前执行。 | |
userAgent | .user_agent(..) | string | 浏览器的 user agent 字符串。 | |
javaScriptEnabled | .java_script_enabled(..) | boolean | true | 页面可以运行脚本。 |
scrollable | .scrollable(..) | boolean | true | 用户可以滚动页面。 |
ios 键 | Rust | 类型 | 说明 |
|---|---|---|---|
allowsBackForwardNavigationGestures | .back_forward_gestures(..) | boolean | 从边缘轻扫以后退和前进。 |
desktop 键 | Rust | 类型 | 说明 |
|---|---|---|---|
allowsBackForwardNavigationGestures | .back_forward_gestures(..) | boolean | macOS:双指在触控板上轻扫以后退和前进;Windows:在触摸屏上轻扫;Linux:在触控板上轻扫。 |
#事件
| 事件 | Rust | 载荷 | 时机 |
|---|---|---|---|
onLoadStart | .on_load_start | WebViewNavigation | 页面开始加载。 |
onLoad | .on_load | WebViewNavigation | 页面加载完成。 |
onError | .on_error | { message, code? }(&str,即消息) | 加载失败;页面或 html 不在应用的能力范围内时 code 为 "ERR_ACCESS_DENIED"。 |
onNavigationStateChange | .on_navigation_state_change | WebViewNavigation | URL、标题、加载状态或历史记录发生变化。 |
onMessage | .on_message | { data: string }(&str) | 页面调用了 window.nsh.postMessage(data)。 |
onDownload | WebViewDownload | 下载开始、完成或失败(macOS)。 |
WebViewNavigation 为 { url, title, loading, canGoBack, canGoForward };Rust 中为 WebNavigation,字段是 url、title、loading、can_go_back 和 can_go_forward。
WebViewDownload 为 { state, id, url, filename, path, bytes?, message? }:state 为 "started"、"finished"(带文件大小 bytes)或 "failed"(带原因 message)。设置了 onDownload 后,页面下载的内容(附件、web view 不显示的类型、download 链接)保存在应用数据目录的 downloads/<id>/<filename>,即 path 所指的位置,应用用 fs/promises 读取。
在 macOS 上,添加监听器后,webview 能力允许加载的任何页面都可以自动保存下载文件。应用负责管理存储空间,并在使用完毕后删除 downloads/ 中已完成的文件。移除 web view 会取消正在进行的下载,并删除其未完成的目录。下载通过 onDownload 报告;导航转为下载时,onError 只报告实际的导航失败。文件名会移除控制字符和双向格式控制符;过长的名称会缩短到 200 字节,并保留能容纳的扩展名。
#ref 方法
| 方法 | Rust | 说明 |
|---|---|---|
reload() | reload() | 重新加载页面。 |
goBack() / goForward() | go_back() / go_forward() | 在页面历史中后退 / 前进。 |
stopLoading() | stop_loading() | 停止加载。 |
injectJavaScript(script) | inject_java_script(script) | 在页面中执行 JavaScript。 |
postMessage(data) | post_message(data) | 向页面发送一个字符串,页面会在 window 上收到 message 事件。 |
#通信
应用和页面之间交换的是字符串。页面通过 window.nsh.postMessage(data) 发送;应用通过 ref 的 postMessage(data)(Rust 中为 post_message(..))发送。结构化数据请用 JSON。
use nsh_core::prelude::*;
const PAGE: &str = r#"<!doctype html>
<meta name="viewport" content="width=device-width">
<button onclick="window.nsh.postMessage('来自页面的问候')">发给应用</button>
<p id="got"></p>
<script>
window.addEventListener("message", (e) => { document.getElementById("got").textContent = e.data; });
</script>"#;
fn bridge() -> impl IntoElement {
let message = signal(String::new());
let web = element_ref();
view()
.gap_3()
.child(
web_view_html(PAGE.to_string(), None)
.element_ref(&web)
.h(px(200.0))
.on_message(move |data: &str| message.set(data.to_string())),
)
.child(
text(move || format!("发给页面 · 收到:{}", message.get()))
.on_press(move || web.post_message("来自应用的问候")),
)
}import { signal, Text, View, WebView, type WebViewRef } from "nsh";
const page = `<!doctype html>
<meta name="viewport" content="width=device-width">
<button onclick="window.nsh.postMessage('来自页面的问候')">发给应用</button>
<p id="got"></p>
<script>
window.addEventListener("message", (e) => { document.getElementById("got").textContent = e.data; });
</script>`;
function Bridge() {
const message = signal("");
let web!: WebViewRef;
return (
<View style={{ gap: 12 }}>
<WebView ref={web} html={page} style={{ height: 200 }} onMessage={(e) => message.set(e.data)} />
<Text onPress={() => web.postMessage("来自应用的问候")}>发给页面 · 收到:{message()}</Text>
</View>
);
}#能力声明
应用在 nsh.json 中声明了能力时,web view 显示和跳转到 webview 下列出的主机。指向其他主机的 src,或未声明 webview 的应用中的 html,显示为空视图;跳到其他主机的顶层导航(用户点击的链接)会在原生侧取消。每种情况都会触发 onError,其 code 为 "ERR_ACCESS_DENIED"。
{
"capabilities": {
"webview": { "hosts": ["example.com", "*.example.com"] }
}
}
#平台说明
- iOS 在第一次创建 web view 时才加载 WebKit,应用在显示 web view 时才为此付出开销。
- 在 macOS 上,
onDownload通过 WebKit 的WKDownload保存下载内容。 - 在 Android 上,页面的
postMessage调用会到达后台线程,再按顺序转发给应用。 - 要给 web view 加圆角,设置圆角半径和
overflow: "hidden"(.overflow_hidden())。