Qomolangma实现篇(一):内核载入模块system.js的实现

一、system.js 模块概要

system.js是Qomo的第一个载入模块。这个模块主要实现三个功能:

  • 基本的$debug()函数
  • 基本的$import()函数
  • 内核子系统的装载

system.js是firefox兼容的。

二、内核子系统的构成与载入

在Qomo中,所谓内核是指由直接在system.js中载入的模块构成的系统功能层。system.js实现了$import()函数,并通过它装载以下模块:

  $import('Names/NamedSystem.js'); //命名空间管理
  $import('RTL/JSEnhance.js');     //基于标准JS的增强特性

  $import('JSUnit/debug.js');      //增强的调试输出
  // more ...                      //调试分析相关功能(profiler/unit test等)

  $import('RTL/error.js');         //错误和异常处理
  $import('RTL/object.js');        //实现面向对象语言特性和基类TObject
  $import('RTL/ajax.js');          //tiny ajax sub system
  // more ...                      //其它内核级别的特性(SOA、Interface等)

Qomo的内核是可裁减的:如果开发人员不喜欢使用命名空间,那么可以不载入NamedSystem.js;或者根本就不使用增强的OOP特性,那么也可以不载入object.js。——换而言之,Qomo的结构可以让开发人员重新组织自己的内核子系统,并在这个基础上发展自己的语言和框架。

Qomo实现这个特性的方法,就是使用“函数重载”和“功能重述”的技术。system.js中的$debug使用了“函数重载”的技术,而$import()中的部分实现特性就利用了“功能重述”的技术。

简单的说:“函数重载”是指在延后的代码中重写当前函数;而“功能重述”则是指在延后的代码中对函数中的一个、多个特性重新描述(并代码实现)。——基本上来说,如果你有良好的代码习惯,那么可以用“更完美的面向对象的设计”+“OOP的多态特性”来实现这两种技术。然而,system.js是在一个很底层的、内核级别的实现,我不希望它变得庞大而低效,所以使用了技巧来替代设计。

三、$debug()的分析

在JScript系统中,提供一个调试期对象Debug,这个对象用于调试器控制台输入信息。如果你使用C以及其它的高级语言编程序,你应该知道OutputDebugString()函数。而这个Debug对象的作用就与它相同。这个对象提供了两个方法:Debug.write()和Debug.writeln()。

然而Qomo试图实现一个更有价值的调试输出子系统。例如向一个输出控制台发送对象,或者用于记录效率分析信息等等。因此Qomo提供了$debug()函数。

然而作为基础模块system.js中的$debug并不提供上述的这些(完整的)特性。system.js中的$debug()仅仅只是向document输出字符串信息。这与Debug.writeln()是一样的,只是输出信息的目标不一样:Debug面向调试控制台,而$debug()面向window.document对象。

在system.js中的$debug()实现起来可以非常简单。例如这样:

$debug = document.writeln;

然而这样的代码在firefox中会出错。firefox对一些对象的方法做了保护,使得它不能被赋值给JavaScript对象/变量,也不能反过来试图通过赋值来修改这些行为。因此在system.js采用的最终代码是这样:

$debug = function() {
  document.writeln(Array.prototype.join.call(arguments, ''))
};

这样就将$debug传入的参数arguments视作一个数组,并通过Array对象原型中的join()方法连接成一个字符串,最后使用document.writeln()输出。

我们前面说到过$debug()使用了“函数重载”的技术。这是因为这里的$debug()事实上只被随后载入的NamedSystem.js和JSEnhance.js使用。——如果在它们“加载的过程中”出了错误、异常(或者出于调试的需要),就可以通过$debug()来输出信息。然而接下来:

  • 第一步:在JSEnhance.js的未尾,会有一行代码将$debug置为空函数(NullFunction);
  • 第二步:在debug.js的头部,有一段代码重写$debug()函数,实现自己的输出控制台。

这样就保证了在任何的代码中都可以使用$debug()函数来输出信息。而这个输出的表现会是这样:

  • 如果是在内核载入中,则向document输出错误信息;否则,
  • 如果载入了debug.js模块,则会有一个输出控制台来显示信息或者对象(数据);否则,
  • $debug()信息被屏弊,或由用户加载的第三方模块来承接调试信息或错误信息的输出。

开发人员可以自由地、安全地使用$debug(),而无需关心它怎么实现,或者如何输出。如果不希望WEB浏览者看到它,只需要去掉debug.js(或者第三方的模块),而无需移除源代码中的函数调用。

四、$import()的实现

为了使得system.js等内核级别的模块不影响全局变量的定义,因此在内核的很多地方(当然也包括$import()函数),使用了以下技巧来声明函数:

$import = function() {       // <--- 匿名函数1
  var data= ...
  function foo() { ... }

  return function() {        // <--- 匿名函数2
    ...
  };
}();

这样一来,foo()和data都声明在一个“匿名函数1”的内部,因此不会对今后代码中对全局变量的命名造成影响(命名重复)。而$import()实际上是“匿名函数1”执行后返回的结果:匿名函数2。——JavaScript中,“函数”(对象)可作为其它函数的执行结果返回。

重要的是,由于“匿名函数2”与data、foo()在同一个“上下文环境”中,因此它可以自由地存取这些变量和方法。而外部、全局的其它代码就看不到$import()的实现细节了。

利用这种技巧,$import()实现了许多内部功能和信息的隐藏。其实现如下:

$import = function () {
  // for firefox only
  var _SYS_TAG =
  var _MOZ_TAG =
  var _CHARSET =

  // 使远程获取的脚本
  var toCurrentCharset = ...

  // 通过检测当前的网页字符集,来确定.js文件使用的编码
  var _uu = ...

  // 在firefox以及IE的不同版本下取HTTP连接对象(HTTPRequest)
  var getHttpConnect = ...

  // 在$import()中使用的一个唯一的http connect
  // (脚本执行是有先后的,所以没有必要使用异步的HTTP连接)
  var _http = ...

  // 是否使用XMLHTTP来取脚本代码。如果为false,则使用<script>标签载入
  var _xml = ...

  // 通过_http连接取代码,并转换编码的函数
  function httpGet ...

  // 取当前正在运行的脚本URL
  // (例如system.js的URL,实现使用文件相对路径来$import()的特性)
  function activeJS ...

  // 重要的、在后期可能使用或“重述”的系统信息
  var _sys = ...

  // 一个activeJS的栈,用于实现“在$import()的代码中再调用$import()”的特性
  var _stack = ...

  // 远程读取、装载指定src的脚本并执行
  var _load_and_execute = ...

  // 向外返回的函数
  function _import(src) {
    /* Qomo Core System.. */
    _load_and_execute( src );
  }

  // 其它代码(参见后文中“_sys对象的价值”)
  // ...

  return _import;
}

下面我们逐一讲述其中的主要功能:

1. 网页字符集、unicode及其解码

在ajax系统中,一个很重要的问题就是编解码的问题。因为不管是Microsoft的XMLHTTP控件,还是firefox中的XMLHttpRequest对象,都将远程获取的内容默认识别为Unicode编码。XMLHTTP控件默认通过远程内容中的前导字符来识别Uniocde的编码方式。因此在不特别指明的情况下,XMLHTTP可以正确的解析以下编码方式的远程内容:

  var _uu = _CHARSET in {
   'utf-8': null,
   'unicode': null,
   'utf-16': null,
   'UnicodeFFFE': null,
   'utf-32': null,
   'utf-32BE': null
  };

如果XMLHTTP不能通过前导字符来解析编码,那么它就默认远程内容使用了UTF-8的编码格式。

然而这只是说“远程内容”的格式(例如.js文件使用的编码存储格式)。大多数情况下,我们会在网页中用如下标签来描述“当前网页”的编码:

<meta http-equiv="Content-Type" content="text/html; charset=gb2312">

在没有这个HTML标签描述的情况下,IE会使用当前的默认设置来给网页解码并显示。这种情况下,document.charset将会置为_autodetect_all,或者你在IE菜单“查看->编码”中选择的字符集。在charset=="_autodetect_all"时,可以通过存取document.defaultCharset来得到解码时选择的字符集。

我们看到一个问题:“当前网页”解码与“远程内容”解码所依赖的字符集设定并不一致。

事实上,麻烦不仅于此。在JScript引擎中理解的字符串等内容,使用的也将会是unicode字符集。这一点,无论.js文件编码格式是什么,或者网页编码格式是什么,都不会被改变。

也就是说,在一个charsett=gb2312,且.js文件使用gb2312编码的系统中,你使用escape()或unescape()都将会在一个unicode环境中进行字符串编解码。更有甚者,你即使强行指定了一个字符串的解码方式,它最终显示在网页上的时候,也不会如你所愿。例如:

// 字符串"这是一个测试"的gb2312字节码
var s1 = '%D5%E2%CA%C7%D2%BB%B8%F6%B2%E2%CA%D4';

// 解码
var s2 = unescape(s1);

// 显示
document.writeln(s2);

这段代码在utf-8或gb2312字符集的网页上显示都不正常。

在Qomo中$import()函数的解码基于一个假设:“.js文件的‘远程内容’与‘当前网页’必然使用相同的字符集”。——必须说明的是,这是在一个封闭环境中的理想情况。如果你试图用$import()读取RSS的内容,你可能会必须面临“在gb2313网页中去处理utf-8编码的RSS数据”这样的问题。因而你应该清楚:内核一级的$import()主要用于处理Qomo系统(及扩展功能)的模块载入,其它的“远程内容”应该交由更复杂的ajax系统去做。

因此Qomo认为:远程跟当前网页采用相同编码,因此在网页字符集为unicode的情况下,远程内容不需要解码,否则应当从XMLHTTP所(错误)理解的unicode转换为当前字符集。这个转换依赖于当前网页字符串的设定,也就是$import()内部的_CHARSET变量的值。

Qomo在$import()中实现了解码函数:toCurrentCharset()。解码函数只实现了对gb2312字符集的处理,如果需要其它(非unicode)的解码,则需要修改toCurrentCharset()中的部分代码。

Qomo的解码函数最初实现gb2312字节码的处理时,借鉴网上流传很广泛的一个bytes2BSTR()函数,实现了改良版本的vbs_JoinBytes()。在vbs_JoinBytes()函数中减少了字符串连接和长度识别的次数,使效率大为提高。但在最终实现这个功能时,借鉴Hutia、bjhaoyun在“经典论坛”中公布的、使用unescape()/escape()函数来处理编码字符串的技巧。由于大量优化了代码,新的toCurrentCharset()比bjhaoyun提供的代码有30%左右的性能提升。

这几种解码方案中,toCurrentCharset()与bjhaoyun的reCode()采用相同的方案,但整体性能提升30%。在通常情况下,toCurrentCharset()比vbs_JoinBytes()快3倍以上;在以英文内容为主的情况下,可以快近10倍;但在中文字符量非常多(例如全中文文本)的情况下,vbs_JoinBytes()的性能表现会极佳,甚至会比toCurrentCharset()快50%。

由于$import()主要处理的主体内容是英文代码.js脚本,因此选用了toCurrentCharset()作为内置的解码函数。关于其它几个解码函数,可以参见测试网页T_DecodeUnicode.html。

(目前,)Qomo没有为firefox中使用XMLHttpRequest对象载入的内容提供解码函数。

2. XMLHTTP载入与<script>标签载入的区别

如果XMLHTTP对象不能创建,或者无法正常处理编码。Qomo中提供了后备方案,也就是使用<script>标签来载入模块及其它远程内容。

然而这两者原本是不能完全替代的,因此有一些差异之处必须补充说明。

首先XMLHTTP载入的内容存放在XMLHTTP对象(例如Qomo中的_http)的responseBody属性中,这是一个以Byte为基础类型的SafeArray数组,而JScript只能处理以Variant为基础类型的SafeArray。所以Qomo中调用VBScript的CStr()来使它变成字符串,然后进一步地交由toCurrentCharset()处理。

——然而如果使用<script>标签来载入,那么这整个的解码过程就不需要了。因为<script>可以指定charset属性,也可以直接使用“与当前网页相同”字符集的脚本文件。

如果仅这样看,<script>会比XMLHTTP好。但事实上XMLHTTP具备的另一项优势让<script>望尘莫及。

使用异步方式,XMLHTTP载入的内容可以被立即执行。因此在这个例子中:

<script>
$import('1.js');
$import('2.js');

foo_in_js1();
</script>

前两行的1.js和2.js被立即载入并执行了,因此在1.js文件中的foo_in_js1()可以得到执行。而在下面的例子中:

<script>
document.writeln('<script src="1.js"><', '/script>');
document.writeln('<script src="2.js"><', '/script>');

foo_in_js1();
</script>

document.writeln()向网页写入的内容会出现在标签之后。因此,1.js和2.js会在当前的脚本块被全部执行完之后,才被载入、解析并执行。——这也意味着函数foo_in_js1()调用不会成功。

很显然,我们在一个大的框架系统中,会利用下面这样的代码来说明当前模块(或单元)的依赖性:

$import('/OS/Win32/FileSystem/*');
$import('/OS/Win32/UI/*');

// some code ...

这种情况下在“some code”执行前FileSystem和UI模块就应该是被载入、执行过的。而我们已经看到<script>并不支持这种特性。

因此Qomo内核中使用<script>来替代$import()仅仅是权益之计,它不能完成$import()的全部工作。——但是在一些简化的、小型的、经过定制Qomo系统中,他仍旧是可用的。只不过要注意XMLHTTP与<script>之间的这种差异,以及这种差异带来的负面影响。

3. execScript()与eval()的不同表现

JScript中有window.execScript()方法,但JavaScript规范中却没有它。因此firefox并没有实现一个execScript。另一个与之相近的是Global.eval()方法。

在IE的JScript中,eval()执行一个字符串并返回结果。在执行时,使用的是调用函数的上下文环境。因此如果函数A中调用了eval(Str),那么字符串Str中的脚本代码可以使用、修改和影响函数A中的局部变量。而window.execScript()将直接使用全局上下文环境,因此,execScript(Str)中的字符串Str可以影响全局变量。——也包括声明全局变量、函数以及对象构造器。

因此我们在用XMLHTTP来远程地取得.js文件的内容之后,我们就可以利用execScript()来执行它。这种执行与在<script>标签中的执行效果是一致的。

从JavaScript的约定来说,Global.eval()不具有在全局的上下文环境中执行的能力。在做一个偶然的代码测试时,我发现firefox中的eval()存在一个奇怪的特性:

  • 如果在函数中使用window.eval()来执行,则使用全局上下文环境;
  • 如果使用eval()来执行,则使用当前函数的上下文环境。

我不确知这是FireFox为ajax而提供的语言特性呢,还是它一个JavaScript实现上的BUG。

但我测试过的几个版本都呈现这种效果。因此$import()中我使用了window.eval来替代window.execScript(),以实现firefox版本的Qomo内核。

关于这个特性请参见测试网页T_eval.html。

4. 载入路径与activeJS的关系

在Qomo系统中载入一个.js时,采用比较灵活的路径定位策略:

  • 如果src以"xxxx://"的形式开始,则使用“完整路径”定位;
  • 如果src以"/"开始,则使用以当前document所在网站的根路径开始的绝对路径;
  • 否则使用相对路径定位。

但接下来的问题就比较麻烦了。如果网页的URL是http://site/sub/a.html,而Qomo系统被部署在http://site/Qomo/路径上,则可用如下两种方式之一在a.html中载入system.js:

<script src="../Framework/system.js"></script>      <!-- 之一 -->
<script src="/Qomo/Framework/system.js"></script>   <!-- 之二 -->

因为XMLHTTP也使用当前网页来做相对定位,因此在这方面他与<script>对象相同。那么显然我们在system.js中导入NameSystem.js应该使用下面这样的代码:

// $import()使用XMLHTTP来实现
$import('../Framework/Names/NameSystem.js');     // 方法一
$import('/Qomo/Framework/Names/NameSystem.js');  // 方法二

各个.js中都要使用当前网页来做相对定位,这导致了系统中的脚本很不灵活,编写代码时要留意其它.js所在位置,目录转移时也不方便。——当然你也可以使用"/"开始的绝对定位,但如果这样,Qomo在应用系统中的可部署性就很差了。

因此Qomo对载入路径的理解是基于“当前.js文件”的。这样一来,在system.js中就可以这样来导入NameSystem.js:

// system.js位于/Qomo/Framework/路径上
$import('Names/NameSystem.js');

而在NameSystem.js中如果要导入同样位于Names目录中的A.js文件,则只需要:

$import('A.js');

为此,Qomo在$import()实现了一个_stack数组变量。它被当做一个后入先出队列,以保证curScript中总是存在当前正在执行的.js文件的URL。这样$import()就可以据此来计算“正在执行的.js中新导入的.js的相对路径”。

麻烦并没有解除。因为Qomo对系统的理解是“可拆解”的,因此它会允许下面这样的代码:

<script src="../Qomo/Framework/system.js"></script>
<script src="../Qomo/Controls/Components.js"></script>
<script src="../Qomo/DB/DB.js"></script>

<script>
  $import('../Qomo/Common/Tools.js');
</script>

在这样的一个系统中,system.js、Components.js和DB.js中所理解的“当前路径”都不一样。因此我们必须有办法来知道$import()当前正在哪一个.js文件中执行,并取得它的src。

函数activeJS()用于取得由<script>导入的.js文件的URL,然后在.js中就可以使用相对路径来载入其它.js文件了。

在IE中有机会取到“当前.js的路径”。在使用中我发现<script>对象的readyState属性可以帮助我们来实现这个需求。简单的说,.js文件执行时,script.readyState的值将会是"interactive"。如果我们列举所有的<script>标签,则可以找到这个script对象,从而得到src的值。

activeJS()利用这个特性来实现。但firefox中DOM的Script对象不具有readyState属性,因此firefox部分的代码采用了识别文件名"system.js"的方法来实现。——但需要注意的是,我没有办法为Qomo在firefox中提供与IE一样的特性。它们之间的差异表现在:

  • (除非在system.js中,)$import()在firefox中不能使用相对路径的特性
  • 如果修改system.js的文件名,则firefox在system.js也不能使用相对路径

最后,在一个普通的<script>脚本块中使用$import(),它将会以当前的document的路径作为计算相对路径的基础。这时,activeJS()返回空串。——正好script.src也是空串。

4. 为什么不是命名空间

Qomo支持但不强制使用命名空间。这是Qomo如此复杂地实现$import()的原因。因为在一个支持“命名空间注册”的系统中,可以这样来做:

// 1. in system.js
Names.registerNamespace('/Framework', 'currentPath');

// 2. in my_script.js
$import('/Framework/Debug/debug.js');

这样,由于命名空间的存在,系统的确可以快速地反映模块的位置和相互关系。然而这种通过registerNamespace()手工注册的形式,导致用户必须强制使用命名空间。——尽管这没有什么不好,也的确是firefox上可以采用的最佳方式。然而我还是在Qomo中实现了自注册的命名空间管理子系统,这使得在大多数情况下,命名空间都可以通过$import()调用时的路径关系来自动获取和计算。

即使不加载命名空间模块,Qomo系统也能正常的运行。这是Qomo的一个特点。尽管这个特性在firefox中不能被实现,然而我还是为“喜欢快捷轻巧的内核”的开发者们提供了一种可能的选择。

不过关于NameSystem.js的具体实现我们以后再讲。今次我们讨论的,只是system.js。

5. _sys对象的价值

在Qomo的内核中,一部分代码是可被外部使用的。例如解码用的toCurrentCharset()以及用于导入.js文件的、异步的XMLHTTP对象。

然而$import()封装了这些细节。在Qomo对代码的理解里面,是“没有必要,就不要公布”,这样尽可能地少占用一些全局的变量名。

那么有价值的资源能如何被使用呢?Qomo在$import()函数声明了对象_sys,:

  var _sys = {
    // 读取这些内容可以了解$import()的运行情况
    'scripts': {},
    'curScript': '',

    // ajax kernal
    'httpGet': httpGet,
    'httpConn': getHttpConnect,

    // decode for XMLHTTP.responseBody
    'bodyDecode': toCurrentCharset,

    // 取当前正在执行中的脚本的src
    'activeJS' : activeJS

    // ...
  }

然后为$import实现了一些用于存取这个对象的方法:

  _import.get = function(n) {
    return eval('_sys[n]');
  }

  _import.set = function(n, v) {
    return eval('_sys[n] = v');
  }

  _import.OnSysInitialized = function() { ... }

这样,在Qomo中就可以写下面这样的代码来使用_sys对象了:

var httpGet = $import.get('httpGet');
var str = httpGet('http://www.sina.com.cn/');

alert(str);

而$import.set()的提供,就与我们在最前说到的“功能重述”技术有关了。因为$import.set()修改的是_sys对象所存放的“内部功能入口”,因此可以通过调用set()方法来“重新描述”这些功能入口的实现方法。这些能被重述的内容取决于_sys对象公开了哪些内容。在Qomo中,这些是可以被重述的:

  var _sys = {
    'transitionUrl': function(url){ ... }
    'srcBase': function() { ... }

    // ...
  }

一些人应该已经注意到$import()并没有实现“导入包或命名空间”这样的功能。而这里公开这些功能入口,就是使得它们可以被“重述”:如果在NameSystem.js中重述transitionUrl()和srcBase()的实现,那么就可以在不修改$import()的情况下,支持命名空间和包。

然而这些“可重述”的特性仍然是与内核直接相关的。因此$import()还公开了一个事件OnSysInitialized(),并在system.js的最未尾激活了这个事件。——这个事件的响应代码所做的工作,就是为$import()清除set()/set()等这些方法。

通过$import.get()/set(),我们可以在system.js所导入的其它.js中为$import()进行重述,也可以利用$import()已经实现过的特性和代码。而在system.js载入并执行完成之后,这些预留给系统内核的功能就随着OnSysInitialized()的触发而被清除了。

system.js模块实现了一个具有张力和包容性的载入框架,为后面实现可裁剪的Qomo系统提供了充分的基础。