NW.js 有一个或两个上下文,使用时要多加小心。
最近因为做项目,需要用到 NW.js。在搭环境的时候,我自然要写一个最简单的功能来测试了。我的例子是一个关闭按钮。简明起见,下面的代码复现的是在第二次试验。注释“2”的是第二次测试添加的输出,“1”是第一次的。
bootstrap.ts
:
import * as main from "./main";
console.log("imported"); // 2
main.bindCloseKey();
bootstrap.ts
是编译为 ECMAScript 5 之后,加了个简单的兼容片段(解决 exports
找不到的问题),直接从页面链接的。
main.ts
:
console.log("exported"); // 2
export function bindCloseKey(): void {
console.log("called"); // 2
const button: HTMLButtonElement = document.querySelector("#close");
if (!button) {
console.log("Button not found: " + selector); // 1
} else {
console.log("Button found"); // 1
button.addEventListener("close", () => {
window.close();
});
}
}
主要功能如上所示,功能很简单,如果找到按钮就尝试绑定事件,不管找没找到都有一个输出。
但奇怪的是,实际运行的时候,点击按钮没有反应;打开开发者控制台(直接按 F12)却看不见任何输出。
于是加了几条注释之后再跑,只见到了第一条输出,也就是“imported”。
那么其他的输出在哪里?功能到底执行了没有?我猜想是目标问题,于是切换到 ECMAScript 6,顺便认识和试用了一下 ESM(就是那些 .mjs
文件),但问题还是没有解决。
无奈之下只好谷歌了。但是大多数人是希望将开发者控制台的输出重定向到系统控制台(确实有解决方案,在 package.json
里加入 "chromium-args": "--enable-logging=stderr"
),我是纳闷为什么看不见开发者控制台的输出。结果发现了答案,也就是该做的是手动右键检查背景页;F12 是审查应用的当前页,而 NW.js 的 app 是作为扩展载入的,所以两个页面不同。
考虑到项目完工时手动右键检查背景页未必可用,我想了一个变通的方法。console
看上去是个全局对象,实际上是 window
的属性。那么我将 window
的引用传过去不就好了?
bootstrap.ts
:
import * as main from "./main";
main.setWindow(window);
main.bindCloseKey();
main.ts
:
let $window: Window = null;
export function setWindow(window: Window): void {
$window = window;
}
export function bindCloseKey(): void {
const button: HTMLButtonElement = document.querySelector("#close");
if (!button) {
$window.console.log("Button not found: " + selector); // 1
} else {
$window.console.log("Button found"); // 1
button.addEventListener("close", () => {
$window.close();
});
}
}
这次能看到输出了。但是显示的居然是“Button not found”!
我再次确认了,HTML 页面是能找到这个按钮的元素的。好吧,手动执行一下试试。分别在 app 页和背景页的开发者控制台执行 document.querySelector("#close")
,前者返回的是 null
,而后者返回了一个 <button>
。
这下我就知道为什么了。两个页面之间有一堵无形的“墙”,一个负责前台(Chromium 端),一个负责逻辑(Node.js 端),有点类似 WebWorker。但和 WebWorker 不同的是,这个隔离的双方都能访问通用全局对象(window
等等),然而访问的却不是同一个对象。上面 $window.console
来自“能和页面交互的” window
,document
(实际上是 window.document
)来自“不能和页面交互的” window
。
后来再看,原来是 0.13 加入的上下文分离,在 NW.js 的文档里其实也写明白了。调试不同上下文的代码则需要分不同的开发者工具。用 require()
加载的模块都会运行在 Node.js 上下文中,路径解析和能访问的对象都是和浏览器上下文不同的。Node.js 上下文中确实有一个 window
对象,但指向的是背景页。如果需要在 Node.js 上下文中访问浏览器的对象,包括全局对象,必须要将这个对象传递过去(就像我上面做的那样)。当然如果偷懒可以用文档后面提到的上下文混合模式(mixed context mode),但它有潜在的上下文不同导致的问题(路径、原型链;见文档)。所以最保险的方法就是在设计的时候就注意,尽量将前后完全分离,消息交换只用上下文无关的对象(例如普通的 {}
、Object.create(null)
创建的对象)。
那么既然上面说在上下文混合模式下会有问题,那么默认的上下文分离模式(separate context mode)下,传递对象会不会出错呢?答案是不会。NW.js 自动处理了对象封送,在传递的时候切换了上下文。我们可以做一个小实验。
bootstrap.ts
:
import * as main from "./main";
console.log("From Chromium? " + (window.close instanceof (window as any).Function));
main.testWindow(window);
main.ts
:
export function testWindow(window: Window): void {
window.console.log("Context of Node? " + (window.close instanceof Function));
window.console.log("Context of Chromium? " + (window.close instanceof (window as any).Function));
}
运行,结果:
From Chromium? true
Context of Node? true
Context of Chromium? false
由于 main.ts
是被用 require()
加载的,所以它运行在 Node.js 的上下文下,直接访问全局的 Function
访问到的是 Node.js 的 Function
。而传入的 window
参数,原对象是在 Chromium 上下文中的。访问它带的 Function
(不会被重新指派上下文)访问的到的是 Chromium 的 Function
。由此可见,在对象传递过来的时候,也包括传递回去的时候,对象的上下文都会自动发生变化。
可能要问了,既然是上古版本更新引入的分离,为什么我以前没发现呢?因为以前为了做即时示例,要在没有 Node.js 的浏览器环境下运行,所以都用了 Browserify。这样在 NW.js 中实际上所有代码都是在 Chromium 的上下文里执行的,不会引发问题。而这次我并没有想把它完全部署为普通网页,就用了分文件、模块加载(毕竟 NW.js 也支持 require()
),这下上下文分离的事实立马就暴露了。