65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Node.js 从系统默认浏览器 UI

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (4投票s)

2015 年 5 月 24 日

MIT

8分钟阅读

viewsIcon

16623

一个允许从 Node.js 使用内置浏览器 Webview 的库。类似 node-webkit,但使用系统浏览器。

引言

在许多情况下,您需要使用 Web 技术创建桌面应用程序。

我记得多年前曾为此使用过 HTA。现在我们有了 node-webkit,但它有一个有时很重要的限制:应用程序大小。Blink 是一个优秀的引擎,但现代 Windows 和 Mac OS X 都内置了不需要太多浏览器特定工作的浏览器。为什么不利用它们呢?

本文描述了一个开源项目的创建,该项目旨在帮助创建针对现代操作系统、跨平台的轻量级 JavaScript+HTML 应用程序。

源代码和二进制发行版可从 GitHub 下载。

背景

这里的一些东西相当棘手。在完成了几个与浏览器嵌入、Webview 应用、网站到应用相关的项目后,我决定创建一个开源框架来简化工作,将所有技巧集中在一处并共享代码。我找到了现已废弃的 app.js,并从中借鉴了一些想法,用不同的浏览器宿主替换了 WebKit(Chrome 嵌入式框架)。

该项目已开源,因为我认为有人可能会发现它有用。现在没有 Twitter、邮件列表,唯一的文档是 GitHub 上的 wiki 页面,因为我不确定这种概念目前是否相关且可用,所以欢迎任何评论和想法。

用法

所有功能都包含在 ui 模块中,本文对此进行了详细描述。通过这种方式创建了一个非常基础的应用程序。

var ui = require('ui');
var app = ui.run({
  url: '/',
  server: { basePath: 'app' },
  window: { width: 600, height: 400 },
  support: { msie: '6.0.2', webkit: '533.16', webkitgtk: '2.3' }
});
app.server.backend = function (req, res) { /*serve non-static content*/ };
app.window.onMessage = function(data) { /*handle message*/ };

此应用程序会检查是否满足最低支持的浏览器要求(如果未满足,则在 Windows 上下载 Webkit),创建一个带有浏览器 Webview 的窗口,并加载 _app_ 文件夹中的 index.html 文件。

Node.js

浏览器 JavaScript API 不支持所有对桌面应用程序至关重要的功能。为了提供缺失的功能,Node.js 已链接到可执行文件中。
首先,应用程序按通常的 Node.js 应用程序方式启动。Node.js 允许我们用 _third_party_main.js_ 覆盖应用程序启动脚本。

(comment from node.js source)
// To allow people to extend Node in different ways, this hook allows
// one to drop a file lib/_third_party_main.js into the build
// directory which will be executed instead of Node's normal loading.

要禁用 Windows 上的终端窗口,我们必须创建 Windows 入口点(WinMain 过程)并使用 Windows 子系统(/SUBSYSTEM:Windows 标志)编译 Node.js。在这里,我们遇到了第一个麻烦:Node.js 在启动时失败。如果我们对此进行调查,我们会发现 Node.js 实际上在与 stdio(stdout、stderr 和 stdin)的交互时失败。所以,为了解决这个问题,必须替换标准流。
首先,我们检测是否有可用的 stdio。如果没有,则从进程中删除该属性,并替换为空的 PassThrough 流。

function fixStdio() {
    var
        tty_wrap = process.binding('tty_wrap'),
        knownHandleTypes = ['TTY', 'FILE', 'PIPE', 'TCP'];
    ['stdin', 'stdout', 'stderr'].forEach(function(name, fd) {
        var handleType = tty_wrap.guessHandleType(fd);
        if (knownHandleTypes.indexOf(handleType) < 0) {
            delete process[name];
            process[name] = new stream.PassThrough();
        }
    });
}

这样就行了——现在 Node.js 可以作为 GUI 应用程序运行了。

添加原生内置 Node.js 模块

带有 Webview(或网页浏览器控件)的窗口是用 C++ 实现的,并通过 Node.js 插件暴露给 JavaScript。
为了减少加载时间,该模块像所有其他 Node 原生模块一样静态链接。方法如下:

void UiInit(Handle<Object> exports
#ifndef BUILDING_NODE_EXTENSION
    ,Handle<Value> unused, Handle<Context> context, void* priv
#endif
        ) {
  // module initialization goes here
}
#ifdef BUILDING_NODE_EXTENSION
NODE_MODULE(ui_wnd, UiInit)
#else
NODE_MODULE_CONTEXT_AWARE_BUILTIN(ui_wnd, UiInit)
#endif

可以将其编译为 Node 插件(尽管不起作用,但最初的想法是将其构建为插件),因此初始化可以以两种模式进行。
然后我们以这种方式加载原生绑定:

process.binding('ui_wnd');

此原生绑定加载在模块文件 _ui.js_ 中,该文件与其他原生模块一起包含在构建中,并且将是 `require('ui')` 调用返回的模块。

将应用程序打包成可执行文件

为了减少可分发应用程序工作目录中的垃圾文件并制作便携式应用程序,可以打包 JavaScript 文件。它们实际上以 ZIP 格式压缩到可执行文件中,就像 SFX 存档一样。

可执行文件包含其代码和应用程序负载。启动时,引擎读取存档头,并在需要时提取文件内容。文件读取设计得如此巧妙,它不会将整个存档加载到内存中,而是在需要时读取文件。

虚拟文件系统

要访问打包到可执行文件中的文件,Node.js 需要一个文件名,即 <executable_dir>/folder/.../file.ext。无法设置某种文件系统链接并在特定点从内存中提供文件。为了告诉 `fs` 模块,此文件夹内的文件应以自定义方式读取,而不是通过文件系统访问,我们替换了原生的 fs 绑定并在其中创建了一些检查。

var binding = process.binding('fs');
var functions = { binding: { access: binding.access } }
binding.access = function(path, mode, req) {
    var f = getFile(path);
    if (!f)
        return functions.binding.access.apply(binding, arguments);
    // custom implementation
};

如果文件在虚拟文件系统中找到,它将从那里提供。否则,请求将被重定向到文件系统。例外情况是写入请求:我们无法写入应用程序存档,因此所有以写入权限打开的文件都直接转到文件系统。

使用这种技术,我们可以将应用程序文件、_node_modules_ 和内容文件打包到存档中,并将它们像在真实文件系统中一样使用,对应用程序和 Node.js 透明。但是,如果应用程序知道 VFS 并想执行一些优化,则有可能区分文件是在 VFS 中找到的:`fs.statSync('file').vfs`。

创建带 Webview 的窗口

代表 OS 窗口的类在 C++ Node.js 插件中创建,并继承自 `node::ObjectWrap`。

class UiWindow : public node::ObjectWrap {
    // ...
    virtual void Show(WindowRect& rect) = 0;
    virtual void Close() = 0;
private:

    static v8::Persistent<v8::Function> _constructor;

    static void New(const v8::FunctionCallbackInfo<v8::Value>& args);
    static void Show(const v8::FunctionCallbackInfo<v8::Value>& args);
    static void Close(const v8::FunctionCallbackInfo<v8::Value>& args);
    // ...
}

为了将符号导出到 JavaScript,我们按如下方式初始化它们:

void UiWindow::Init(Handle<Object> exports) {
    Isolate *isolate = Isolate::GetCurrent();
    HandleScope scope(isolate);
    Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, New);
    tpl->SetClassName(String::NewFromUtf8(isolate, "UiWindow"));

    // static methods
    NODE_SET_METHOD(tpl, "alert", Alert);
    // prototype methods
    NODE_SET_PROTOTYPE_METHOD(tpl, "show", Show);
    
    auto protoTpl = tpl->PrototypeTemplate();
    // properties
    protoTpl->SetAccessor(String::NewFromUtf8(isolate, "width"), GetWidth, SetWidth, Handle<Value>(), DEFAULT, PropertyAttribute::DontDelete);
    // constants (static fields)
    tpl->Set(isolate, "STATE_NORMAL", Int32::New(isolate, WINDOW_STATE::WINDOW_STATE_NORMAL));
    // constructor function 
    _constructor.Reset(isolate, tpl->GetFunction());
    // class export
    exports->Set(String::NewFromUtf8(isolate, "Window"), tpl->GetFunction());
}

加载插件后,在 JavaScript 中,我们可以使用:

var window = new ui.Window({ /* config object */ });
window.show();
window.close();

在 Show 方法中,窗口会调用特定于操作系统的实现(Windows 上的 WinAPI,Mac 上的 Cocoa,Linux 上的 GTK+),这与其他典型的应用程序一样,没有什么特别或有趣的地方,所以我不会过多关注。

Mac 和 Linux 上的 Webview 集成非常直接。Internet Explorer 被创建为一个 ActiveX 控件;我不得不添加一些技巧来阻止不希望出现的键盘事件、对话框和导航。

事件发射

窗口可以发出事件:show、close、move 等……窗口 UI 代码在主线程上执行,与 Node.js 不同,为了与 Node 线程交互,我们需要一些同步。为了减少特定于操作系统的代码,这通过 Node.js 内置的 uv 库以跨平台的方式进行。

首先,在创建窗口时,我们存储 Node 线程 ID 和异步句柄。

class UiWindow {
	// ...
    uv_thread_t _threadId;
    static uv_async_t _uvAsyncHandle;
    static void AsyncCallback(uv_async_t *handle);
}
uv_async_init(uv_default_loop(), &_this->_uvAsyncHandle, &UiWindow::AsyncCallback);

当事件实际发生时,特定于操作系统的实现会调用 EmitEvent 函数。

void UiWindow::EmitEvent(WindowEventData* ev) {
    ev->Sender = this;
    // … add pending event to list
    uv_async_send(&this->_uvAsyncHandle);
}

然后,uv 在 Node 线程中调用 AsyncCallback。

void UiWindow::AsyncCallback(uv_async_t *handle) {
    uv_mutex_lock(&_pendingEventsLock);
    WindowEventData* ev = _pendingEvents;
    _pendingEvents = NULL;
    uv_mutex_unlock(&_pendingEventsLock);
    // handle pending events
}

事件被添加到列表中,因为 UV 可能会一次调用 AsyncCallback 处理多个事件;不能保证它会为每个事件调用一次。窗口继承自 EventEmitter(在 ui 模块中),这样 emit 函数就被添加到原型中。然后我们获取这个 emit 函数并调用它。

Local<Value> emit = _this->handle()->Get(String::NewFromUtf8(isolate, "emit"));
Local<Function> emitFn = Local<Function>::Cast(emit);
Handle<Value> argv[] = { String::NewFromUtf8(isolate, "ready") };
emitFn->Call(hndl, 1, argv);

就这样。窗口现在可以从任何线程调用事件,传递参数并处理事件订阅者的输出,例如窗口关闭取消。

window.on('close', function(e) { e.cancel = true; });

浏览器交互

添加通信 API 对象

为了与浏览器交互,一个提供消息传递方法的后端对象被添加到窗口的全局上下文中。该对象通过在 JavaScript 上下文初始化时注入到 Webview 中的脚本创建,具体方法取决于使用的浏览器。在 IE 上,我们可以利用 NavigateComplete 事件。

void IoUiBrowserEventHandler::NavigateComplete() {
    _host->OleWebObject->DoVerb(OLEIVERB_UIACTIVATE, NULL, _host->Site, -1, *_host->Window, &rect);
    _host->ExecScript(L"window.backend = {"
        L"postMessage: function(data, cb) { external.pm(JSON.stringify(data), cb ? function(res, err) { if (typeof cb === 'function') cb(JSON.parse(res), err); } : null); },"
        L"onMessage: null"
        L"};");
}

在 CEF 上,有一个 OnContextCreated 回调。

void IoUiCefApp::OnContextCreated(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context) {}

Mac 上的 WebKit Webview 初始化发生在 didCommitLoadForFrame 信号上。

- (void)webView:(WebView *)sender didCommitLoadForFrame:(WebFrame *)frame {}

从 JavaScript 调用 C++

为了从窗口调用 JavaScript 方法,在 IE 中使用 window.external 对象;它被添加为一个实现 IDispatch 接口的 COM 对象。函数直接在该对象上调用。

class IoUiSite : public IDocHostUIHandler {
STDMETHODIMP GetExternal(IDispatch **ppDispatch) {
    *ppDispatch = _host->External;
    return S_OK;
}
}
class IoUiExternal : public IDispatch {
	// ...implement Invoke and handle calls
}

在 Mac OS X 上,我们可以创建一个简单的对象,为其添加方法,并通过响应 webScriptNameForSelector 和 isSelectorExcludedFromWebScript 来允许从 WebView 调用它们。

@interface IoUiWebExternal: NSObject
- (void) pm:(NSString*)msg withCallback:(WebScriptObject*)callback;
@end

@implementation IoUiWebExternal
- (void) pm:(NSString*)msg withCallback:(WebScriptObject*)callback {
	// handle call
}
+ (NSString *) webScriptNameForSelector:(SEL)sel {
    // tell javascriptcore engine about the method
    if (sel == @selector(pm:withCallback:))
        return @"pm";
    return nil;
}
+ (BOOL) isSelectorExcludedFromWebScript:(SEL)sel { return NO; }
@end

在 CEF 上,我们可以直接向现有对象添加原生方法。方法如下:

auto window = context->GetGlobal();
auto backend = window->GetValue("backend");
auto pmFn = window->CreateFunction("_pm", new IoUiBackendObjectPostMessageFn(browser));
backend->SetValue("_pm", pmFn, CefV8Value::PropertyAttribute::V8_PROPERTY_ATTRIBUTE_DONTDELETE);

函数对象实际上是一个实现函数调用方法的类。

class IoUiBackendObjectPostMessageFn : public CefV8Handler {
public:
    virtual bool Execute(const CefString& name, CefRefPtr<CefV8Value> object, const CefV8ValueList& arguments,
        CefRefPtr<CefV8Value>& retval, CefString& exception) override;
private:
    IMPLEMENT_REFCOUNTING(IoUiBackendObjectPostMessageFn);
};

Chrome 嵌入式框架

如今,并非所有 IE 版本都切实可用,尽管旧操作系统的市场份额仍然不是零,所以我不得不通过嵌入 Chrome 嵌入式框架 (CEF) 来支持 Windows XP。CEF 包含完整的渲染引擎 (Blink) 和 V8;其二进制文件是一组 DLL、资源文件和区域设置。当应用程序启动时,首先检查 CEF 二进制文件是否存在,如果存在,则启动 CEF 主机。为了减少启动时间,CEF 以单进程模式启动。

CefSettings appSettings;
appSettings.single_process = true;

无论如何,如果渲染进程崩溃了,应用程序就没有必要继续执行了,所以多进程架构也不会带来什么好处,只会减慢应用程序的速度。

下载 CEF

Chrome 嵌入式框架体积很大(压缩后约 30MB),因此它只在旧系统上下载,而不是嵌入到应用程序中。应用程序会使用用户提供的要求(来自 support 键)检查浏览器版本,如果低于预期,则显示进度对话框并开始下载 CEF。下载完成后,将存档解压并将 CEF DLL 加载到应用程序中。

使用 Node.js 读取 ZIP 文件

我的项目的一个要求是从存档中提供视频文件。我找不到任何能够流式传输 ZIP 存档中的文件而无需将整个存档加载到内存中的 JavaScript 实现,因此我 fork 了 adm-zip 并创建了 node-stream-zip,它可以从大型存档中流式传输文件,并使用 Node.js 内置的 zlib 模块即时解压它们。首先,它读取 ZIP 头,从中获取文件大小和偏移量,并在请求时流式传输压缩数据,通过 zlib 解压流和 CRC 校验直通流。这运行速度很快,不会减慢应用程序启动速度,也不会占用过多内存。

关注点

  • MSIE:`typeof window.external === 'unknown'`(这是所谓的 _宿主对象_)
  • MSIE:静默模式并不能关闭所有 IE 控件版本的其他用户交互对话框。
  • Node.js 默认使用 V8 作为共享库进行编译,所有函数都从可执行文件中导出;它被关闭以减小可执行文件的大小。

下载次数

我没有在文章中附带下载,您可以在 GitHub 上找到它们。

历史

2015-04-19:首次公开导入
2015-05-10:一些 bug 修复和网站

© . All rights reserved.