精通
英语
和
开源
,
擅长
开发
与
培训
,
胸怀四海
第一信赖
有许多情况下,您必须使用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的窗口,并从应用程序文件夹加载index.html文件。
浏览器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入口点(WinMainproc)并使用Windows子系统(/SUBSYSTEM:Windowsflag)编译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应用程序运行。
带有Webview(或Web浏览器控件)的窗口在C ++中实现,并从node.js addon中暴露给javascript
为了减少加载时间,模块与所有其他节点本地静态链接。这样做是通过以下方式完成的:
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
可以编译为节点插件(它不会工作,但原始想法是构建为插件),因此初始化可以在两种模式下执行。
然后我们以这种方式加载本地绑定:
process.binding('ui_wnd');
这个本机绑定被加载到模块文件ui.js中,该文件包含在其他本地的build中,这是从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中找到该文件。
在C ++ node.js addon中创建了一个表示OS窗口的类,并从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方法处理窗口功能,它调用os特定的实现(Windows上的WinAPI,Mac上的Cocoa和Linux上的GTK +),这就像任何其他典型的应用程序,没有什么特别或有趣的,所以我不会注意它。
Mac和Linux上的WebView集成非常简单。Internet Explorer被创建为一个ActiveX控件; 我不得不添加几个补丁,以防止不期望的键盘事件,对话框和导航。
窗口可以发出事件:显示,关闭,移动等...窗口UI代码在主线程上执行,与node.js不同,因此要与节点线程交互,我们需要一些同步。为了减少os特定的代码,这是以跨平台的方式执行,其中uv库内置到node.js中:
首先,在窗口创建时,我们存储节点线程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);
当事件实际发生时,os特定的实现调用EmitEvent函数:
void UiWindow::EmitEvent(WindowEventData* ev) {
ev->Sender = this;
// … add pending event to list
uv_async_send(&this->_uvAsyncHandle); }
然后,uv在节点线程中调用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; });
为了和浏览器交互,将后端对象提供的消息传递方法添加到窗口的全局上下文中。这个对象创建时附带有脚本,脚本注入到webview中,注入点在JavaScript上下文初始化时刻,注入也使用了不同的方法,这取决于所使用的浏览器。在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 web视图初始化在didCommitLoadForFrame信号上:
- (void)webView:(WebView *)sender didCommitLoadForFrame:(WebFrame *)frame {}
从窗口调用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
@end
- (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; }
在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); };
并不是所有的IE版本现在都是实用的,而旧的OS市场份额现在还不是零,所以我不得不通过嵌入Chrome嵌入式框架(CEF)来增加对Windows XP的支持。CEF包括完整的渲染引擎(Blink)和V8; 它的二进制是一组DLL,资源文件和语言环境。当应用程序启动时,首先检查CEF二进制文件是否存在,如果是,则启动CEF主机。为了减少启动时间,CEF以单过程模式启动:
CefSettings appSettings; appSettings.single_process = true;
无论如何,如果渲染器进程崩溃,则无需继续执行应用程序,因此多进程体系结构将不会有任何好处,因此只会减慢应用程序的速度。
Chrome Embedded Framework的大小(约30MB压缩),这就是为什么只能在旧系统上下载,而不是嵌入到应用程序中。应用程序使用用户提供的要求(从support键)检查浏览器版本,如果低于预期,则显示进度对话框并开始下载CEF。下载完成后,将提取存档并将CEF DLL加载到应用程序中。
我的项目的一个要求是从档案提供视频文件。我没有发现任何JavaScript实现能够从ZIP存档流式传输文件,而不需要在内存中读取整个存档,这就是为什么我已经分拣了 adm-zip 并创建了node-stream-zip ,它可以从大档案流式传输文件,并实时解压缩与节点的内置zlib模块。首先,它读取ZIP标题,从中获取文件大小和偏移量,并在请求时流式传输压缩数据,传递zlib解压缩流和CRC校验直通流。这个工作相当快,不会减慢应用程序启动速度,并且不会消耗太多的内存。