NW.js 的上下文问题

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 来自“能和页面交互的” windowdocument(实际上是 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()),这下上下文分离的事实立马就暴露了。

分享到 评论