这是近期一次内部分享的PPT,从函数的相关概念上,主要内容有:
- 变量的概念。
- 闭包的表象。
- 核心概念,包括可执行代码、执行环境、词法环境、变量环境、环境数据、绑定对象等。
- 函数的相关过程,包括创建函数、进入函数、定义绑定初始化、变量查找等。
- 闭包对垃圾回收的影响的探究。
PPT权当对新事物的一种普及性展示,不会有深入的内容,也不会有详细的例子,如果有任何疑问,欢迎随时交流。
如需下载,请点击此处。
这是近期一次内部分享的PPT,从函数的相关概念上,主要内容有:
PPT权当对新事物的一种普及性展示,不会有深入的内容,也不会有详细的例子,如果有任何疑问,欢迎随时交流。
如需下载,请点击此处。
本文的诞生,源自近期打算做的一个关于javascript中的闭包的专题,由于需要解析闭包对垃圾回收的影响,特此针对不同的javascript引擎,做了相关的测试。
为了能从本文中得到需要的知识,看本文前,请明确自己知道闭包的概念,并对垃圾回收的常用算法有一定的了解。
假设有如下的代码:
function outer() {
var largeObject = LargeObject.fromSize('100MB');
return function() {
console.log('inner');
};
}
var inner = outer();
在这一段代码中,outer函数和inner函数间会形成一个闭包,致使inner函数能够访问到largeObject,但是显然inner并没有访问largeObject,那么在闭包中的largeObject对象是否能被回收呢?
如果引入更复杂的情况:
function outer() {
var largeObject = LargeObject.fromSize('100MB');
var anotherLargeObject = LargeObject.fromSize('100MB');
return function() {
largeObject.work();
console.log('inner');
};
}
var inner = outer();
首先一个显然的概念是largeObject肯定不能被回收,因为inner确实地需要使用它。但是anotherLargeObject又能不能被回收呢?它将跟随largeObject一起始终存在,还是和largeObject分离,独立地被回收呢?
带着这个疑问,对现有的几款现代javascript引擎分别进行了测试,参与测试的有:
测试的基本方案是,使用类似以下的代码:
function outer() {
var largeObject = LargeObject.fromSize('100MB');
return function() {
debugger;
};
}
var inner = outer();
通过各浏览器的开发者工具(Developer Tools、Firebug、Dragonfly等),在断点处停止javascript的执行,并通过控制台或本地变量查看功能检查largeObject的值,如果其值存在,则认为GC并没有回收该对象。
对于部分浏览器(特别是IE),考虑到对脚本执行有2种模式(执行模式和调试模式,IE通过开发者工具的Script面板中的“Start Debugging”按钮切换),在调试模式下才会命中断点,但是调试模式下可能存在不同的引擎优化方案,因此采用内存比对的方式进行测试。即打开资源浏览器,在var inner = outer();一行后强制执行一次垃圾回收(IE使用window.CollectGarbage();Opera使用window.opera.collect();),查看内存的变化。如果内存始终有100MB的占用,没有明显的下降现象,则认为GC并没有回收该对象。
对于用例的设计,由于从ECMAScript标准中可以得知,所有的变量访问是通过一个LexicalEnvironment对象进行的,因此目标在于在不同的LexicalEnvironment结构下进行测试。从标准中,搜索LexicalEnvironment不难得出能够改变LexicalEnvironment结构的情况有以下几种:
eval代码。with语句。catch语句。因此以下将针对这4种情况,进行多用例的测试。
function outer() {
var largeObject = LargeObject.fromSize('100MB');
return function() {
debugger;
};
}
var inner = outer();
inner();
outer函数执行前的状态。largeObject抛出ReferenceError。largeObject得到undefined。当一个函数outer返回另一个函数inner时,Chakra、V8和SpiderMonkey会对outer中声明,但inner中不使用的变量进行回收,其中V8直接将变量从LexicalEnvironment上解除绑定,而SpiderMonkey仅仅将变量的值设为undefined,并不解除绑定。
function outer() {
var largeObject = LargeObject.fromSize('100MB');
var anotherLargeObject = LargeObject.fromSize('100MB');
return function() {
largeObject;
debugger;
};
}
var inner = outer();
inner();
anotherLargeObject,内存会回到outer调用前并增加100MB左右。largeObject能得到正确的值,访问anotherLargeObject抛出ReferenceError。largeObject能得到正确的值,访问anotherLargeObject得到undefined。当一个LexicalEnvironment上存在多个变量绑定时,Chakra、V8和SpiderMonkey会针对不同的变量判断是否有被使用,该判断方法是扫描返回的函数inner的源码来实现的,随后会将没有被inner使用的变量从LexicalEnvironment中解除绑定(同样的,SpiderMonkey不解除绑定,仅赋值为undefined),而剩下的变量继续保留。
eval的影响function outer() {
var largeObject = LargeObject.fromSize('100MB');
return function() {
eval('');
debugger;
};
}
var inner = outer();
inner();
largeObject可得到正确的值。largeObject可得到正确的值。如果返回的inner函数中有使用eval函数,则不LexicalEnvironment中的任何变量进行解除绑定的操作,保留所有变量的绑定,以避免产生不可预期的结果。
evalfunction outer() {
var largeObject = LargeObject.fromSize('100MB');
return function() {
window.eval('');
debugger;
};
}
var inner = outer();
inner();
outer函数执行前的状态。largeObject抛出ReferenceError。largeObject得到undefined。由于ECMAScript规定间接调用eval时,代码将在全局作用域下执行,是无法访问到largeObject变量的。因此对于间接调用eval的情况,各javascript引擎将按标准的方式进行处理,无视该间接调用eval的存在。
同样的,对于new Function('return largeObject;')这种情形,由于标准规定new Function创建的函数的[[Scope]]是全局的LexicalEnvironment,因此也无法访问到largeObject,所有引擎都参照间接调用eval的方式,选择无视Function构造函数的调用。
function outer() {
var largeObject = LargeObject.fromSize('100MB');
function help() {
largeObject;
// eval('');
}
return function() {
debugger;
};
}
var inner = outer();
inner();
largeObject可得到正确的值。largeObject可得到正确的值。不仅仅是被返回的inner函数,如果在outer函数中定义的嵌套的help函数中使用了largeObject变量(或直接调用eval),也同样会造成largeObject变量无法回收。因此javascript引擎扫描的不仅仅是inner函数的源码,同样扫描了其他所有嵌套函数的源码,以判断是否可以解除某个特定变量的绑定。
with表达式function outer() {
var largeObject = LargeObject.fromSize('100MB');
var scope = { o: LargeObject.fromSize('100MB') };
with (scope) {
return function() {
debugger;
};
}
}
var inner = outer();
inner();
largeObject,但不回收scope.o,内存恢复至outer函数被调用前并增加100MB左右(无法得知scope是否被回收)。largeObject和scope以及o均可得到正确的值。largeObject和scope,访问该2个变量均得到undefined,不回收o,可得到正确的值。当有with表达式时,V8将会放弃所有变量的回收,保留LexicalEnvironment中所有变量的绑定。而SpiderMonkey则会保留由with表达式生成的新的LexicalEnvironment中的所有变量的绑定,而对于outer函数生成的LexicalEnvironment,按标准的方式进行处理,尽可能解除其中的变量绑定。
catch表达式function outer() {
var largeObject = LargeObject.fromSize('100MB');
try {
throw { o: LargeObject.fromSize('100MB'); }
}
catch (ex) {
return function() {
debugger;
};
}
}
var inner = outer();
inner();
largeObject和ex,内存会恢复到outer函数被调用前的状态。largeObject,访问largeObject抛出ReferenceError,但仍可访问到ex。largeObject,访问largeObject得到undefined,但仍可访问到ex。catch表达式虽然会增加一个LexicalEnvironment,但对闭包内变量的绑定解除算法几乎没有影响,这源于catch生成的LexicalEnvironment仅仅是追加了被catch的Error对象一个绑定,是可控的(相对的with则不可控),因此对变量回收的影响也可以控制和优化。但对于新生成并添加了Error对象的LexicalEnvironment,V8和SpiderMonkey均不会进一步优化回收,而Chakra则会对该LexicalEnvironment进行处理,如果其中的Error对象可以回收,则会解除其绑定。
function outer() {
var largeObject = LargeObject.fromSize('100MB');
return function(largeObject /* 或在函数体内声明 */) {
// var largeObject;
};
}
var inner = outer();
inner();
outer函数被调用前的状态。outer函数被调用前的状态。outer函数被调用前的状态。嵌套函数中有与外层函数同名的变量或参数时,不会影响到外层函数中该变量的回收优化。即javascript引擎会排除FormalParameterList和所有VariableDeclaration表达式中的Identifier,再扫描所有Identifier来分析变量的可回收性。
首先一个较为明确的结论是,以下内容会影响到闭包内变量的回收:
eval。with表达式。Chakra、V8和SpiderMonkey将受以上因素的影响,表现出不尽相同又较为相似的回收策略,而JScript.dll和Carakan则完全没有这方面的优化,会完整保留整个LexicalEnvironment中的所有变量绑定,造成一定的内存消耗。
由于对闭包内变量有回收优化策略的Chakra、V8和SpiderMonkey引擎的行为较为相似,因此可以总结如下,当返回一个函数fn时:
如果fn的[[Scope]]是ObjectEnvironment(with表达式生成ObjectEnvironment,函数和catch表达式生成DeclarativeEnvironment),则:
获取当前LexicalEnvironment下的所有类型为Function的对象,对于每一个Function对象,分析其FunctionBody:
name,根据查找变量引用的规则,从LexicalEnvironment中找出名称为name的绑定binding。binding添加notSwap属性,其值为true。检查当前LexicalEnvironment中的每一个变量绑定,如果该绑定有notSwap属性且值为true,则:
undefined,将删除notSwap属性。对于Chakra引擎,暂无法得知是按V8的模式还是按SpiderMonkey的模式进行。
从以上测试及结论来看,V8确实是一个优秀的javascript引擎,在这一方面的优化相当到位。而SpiderMonkey则采取一种更为友好的方式,不直接删除变量的绑定,而是将值赋为undefined,也许是SpiderMonkey团队考虑到有一些极端特殊的情况,依旧有可能导致使用到该变量,因此保证至少不会抛出ReferenceError打断代码的执行。而IE9的Chakra相比IE8的JScript.dll进步非常大,细节上的处理也很优秀。Opera的Carakan在这一方面则相对落后,完全没有对闭包内的变量回收进行优化,选择了最为稳妥但略显浪费的方式。
此外,所有带有优化策略的浏览器,都在内在开销和速度之间选择了一个平衡点,这也正是为什么“多个嵌套函数”这一测试用例中,虽然inner没有再使用largeObject对象,甚至在inner中的断点处,连help函数对象也已经解除绑定,却没有解除largeObject的绑定。基于这种现象,可以推测各引擎均只选择检查一层的关联性,即不去处理inner -> help -> largeObject这样深度的引用关系,只找inner -> largeObject和help -> largeObject并做一个合集来处理,以提高效率。也许这种方式依旧存在内存开销的浪费,但同时CPU资源也是非常贵重的,如何掌握这之间的平衡,便是javascript引擎的选择。
此外,根据部分开发者的测试,Chakra甚至有资格被称为现有最快速的javascript引擎,微软也一直在努力,而开发者更不应该一味地谩骂和嘲笑IE。
我们可以嘲笑IE6的落后,可以看不到低版本的IE曾经为互联网的发展做过的贡献,可以在这些历史产品已经没落的今天无情地给予打击,却最最不应该将整个IE系列一视同仁,挂上“垃圾”的名号。客观地去看待,去评价,正是一个技术人员应该具备的最基本的准则和素养。
继续大逆不道系列……
在上一篇中,提出了一个另类的MVC模型,与经典MVC模型有一些不同,那么自然需要描述这样的另类模型有什么优势,又能在怎么样的场景中使用。
正如上一篇所说,这种模式下,最大的优势莫过于逻辑的清晰划分。在该模式的作用下,每一个Action都只要处理真正与自己有关的逻辑及数据,而不需要关心一些“通用”的内容,因为这些通用内容也成了独立的Action。
例如,继续引用上一篇中的页面设计,根据经典的MVC模式,我们不得不在一个Action中准备所有数据:
public ActionResult ViewPost(int id) {
ViewBag.Friends = FriendsRepository.ByUser(CurrentUser);
ViewBag.RecentVisitors = VisitorsRepository.ByPost(id, TimeSpan.FromMinutes(30));
Post post = PostRepository.ById(id);
return View(post);
}
即便你按上一篇提到的方案使用Action Filter,依旧没有办法彻底地将这些数据从当前Action产生的数据模型中消除,当模块越来越多时,各种“通用”数据一起扔在ViewBag中,造成臃肿、冲突,没有清晰的文档更加大了维护的难度。
而如果使用修改后的MVC模型,每一个Action都只照顾自己相关的逻辑:
public ActionResult ViewPost(int id) {
Post post = PostRepository.ById(id);
return View(post);
}
public ActionResult Friends() {
IList<User> friends = FriendsRepository.ByUser(CurrentUser);
return View(friends);
}
public ActionResult RecentVisitors(int post) {
IList<User> visitors = VisitorsRepository.ByPost(post, TimeSpan.FromMinutes(30));
return View(visitors);
}
各个Action相互独立,同时负责本模块从逻辑到数据到视图的全套流程,清晰明了。
而另一方面,划分更清晰的Action,也产生了更多的切面,可以针对性地进行逻辑的拦截和优化。例如,因为用户的朋友关系并不常变,因此针对“好友列表”模块,可以加入缓存:
[CacheView]
public ActionResult Friends() {
IList<User> friends = FriendsRepository.ByUser(CurrentUser);
return View(friends);
}
而在经典的MVC模式中,由于数据是一并获取,再统一完成视图渲染的,因此至多可以在数据的层面进行缓存,要做到视图区块的缓存则需要较大的代价,例如Donut Hole Cache技术,但总得来说并不是非常方便。
至此,将讲述一下另类MVC模式的最大优势,即区块引入的多样化。
习惯了经典MVC模式的开发者,通常认为局部视图的引入是这样的:
<div>
@section.render('friends', data.friends);
</div>
这是非常经典的思路,其语义是“在当前位置引入名称为friends的区块,并将data.friends作为其数据”。这当然无可厚非,但是你是否有想过,我们可以用更有趣的手段引入一个视图?
例如异步地引入:
<div>
@section.renderAs.async('friends', { user: data.currentUser });
</div>
又例如使用BigPipe技术来输出:
<div>
@section.renderAs.bigPipe('friends', { user: data.currentUser });
</div>
再例如甚至在页面完全输出后发起AJAX请求来获取区块内容,由前端渲染:
<div>
@section.renderAs.ajax('friends', { user: data.currentUser });
</div>
利益于另类MVC模式中,每一个区块都有自己独立的Action->Model->Action->View的完整逻辑,因此一但在View的层面上异步引入,也即代表着“数据查询”、“区块视图拼装”等过程也隐式地被异步化,完全不需要在数据处理的逻辑中采用各种异步的手段来提升性能。
而使用BigPipe或者AJAX等技术,也能更进一步地在各个层面(HTTP响应、用户视觉体验)进行特定的优化,而这一切,仅仅是“不同方式引入区块”便可以做到的。当你的同事辛辛苦苦地修改框架响应模型,完成了一个页面的BigPipe输出而倍感高兴的时候,你却在嘴角掠过一个微笑的弧度,轻轻地敲击键盘,将renderAs.normal改成renderAs.bigPipe,刷新页面,BigPipe就在手中,这是多么美妙的事情啊。
更进一步地,我们可以来个“大一统”方案:
<div>
@section.renderAs.auto('friends', { user: data.currentUser });
</div>
所谓auto,即根据当前的情况和各种性能监控的数据,例如服务器可用带宽、该模块历史在客户端渲染用时、服务器负载等数据,自动化、智能化地选择输出的方式,可能是直接输出,可能是异步加载,可能是BigPipe,也可能是AJAX等等……
因为我们知道,不同的输出方式,有着不同的优势:
<script>标签,再通过一个chunked输出,形成一个BigPipe的输出效果。这种方案有效利用客户端的资源,当一个区块的内容较为复杂,客户端渲染需要消耗较多资源时,可以先输出其他内容,最后单独通过BigPipe输出区块,实现下载和渲染2条线程的复用效果,提高渲染的效率。<script>标签,该标签中使用javascript通过XMLHttpRequest向服务器发起针对Action的请求,获取视图并填充。此方案完全不会阻塞当前视图的输出,能保证重要的内容第一时间被用户所见,但会引起冗余的HTTP请求。正因为不同的输出方式有不同的优缺点,因此通过对性能的监控和历史性能数据的分析,动态、智能地选择最合适的输出方案,让系统变得可“自我进化”,很大程度上减少运维的成本,也是该另类MVC方案的目标之一。
综上所述,我认为该另类MVC模式的优点有:
而在拥有这些优势的前提之下,实现功能所需要的代码却没有特别的增长,视图依旧是视图,逻辑依旧是逻辑,实现了低成本高效率的开发方案。
关于实现,首先不得不行,我本没有想过去实现这个方案,因此肯定会被世人所唾弃,做不讨好的事不是我的风格。但是我又不能提了一个概念,却说其实我根本不知道能不能实现……所以在这里,就稍微说一下这个方案的简单的原理:
首先,请求将被Locator组件所接收,通过Locator定位到一个View,这个过程与现代几乎所有MVC框架的路由功能类似,因此不需要详细的提及。
在View中,视图引擎对视图的解析,以及如何再次调用不同的Action,如何异步调用还能拼装成统一的页面,这个才是关键。
于是视图引擎成了该方案的重中之重。在我的设想中,视图引擎依旧自上而下地扫描视图,但需要提供一个section对象:
section {
// 注册一个renderAs.xxx函数
registerRenderer: function(name, renderer){
},
// 默认自带
renderAs: {
normal: function(context) { /* ... */ },
async: function(context) { /* ... */ },
bigPipe: function(context) { /* ... */ },
ajax: function(context) { /* ... */ },
}
}
在视图引擎的解析过程中,已经解析的内容将会进入到Buffer中,可以使用一个简单的数组来表示。而当遇到section时,在Buffer中会添加一个占位符。因此最终Buffer可能是这个样子:
buffer: [
'<!DOCTYPE html>',
'<html manifest="xxx">',
'<body>',
'<div id="main">',
{ uid: 1, action: 'post', parameters: {} },
'</div>',
'<aside id="sidebar">',
'<h3>好友列表</h3>',
'<div id="friends>',
{ uid: 2, action: 'friends', parameters: {} },
'</div>',
'<h3>最近访客</h3>',
'<div id="visitors>',
{ uid: 3, action: 'visitors', parameters: {} },
'</div>',
'</aside>',
'</body>',
'</html>'
];
注意到以上buffer对象的定义中,有几块并不是字符串,而是一个对象,这个对象正是一个Section的占位。
视图引擎会拼装一个上下文对象,该对象中有Action执行需要的信息,并保证有足够的信息让Action执行完成后可找回这个占位符:
var context = {
request: request,
response: response,
parameters: parameters,
placeholder: uid,
callback: renderSection,
buffer: buffer
};
作为简易的实现,只使用uid作为标识,随后根据区块的输出方式,对应地调用Action,例如async则有可能通过folk来建立新的线程,利用多核资源最高效地处理逻辑并返回一个View:
function async(context) {
folk(
function() {
var view = framework.executeAction(action, parameters, context);
context.callback(view);
}
);
};
而在一个统一的回调函数中,视图引擎又会利用uid找到对应的占位,将之变换为字符串:
function callback(view) {
var buffer = this.buffer;
var uid = this.placeholder;
for (var i = 0; i < buffer.length; i++) {
var section = buffer[i];
if (typeof section === 'object' && section.uid === uid) {
// 注意此处processView又有可能有子section,视图处理是个递归过程
buffer[i] = framework.processView(view, context);
}
}
// 如果buffer中全是字符串,没有对象,则为true
if (framework.isViewComplete(buffer)) {
this.response.write(framework);
// 如果不存在BigPipe等需要在视图输出后再输出chunk的功能,则结束响应
if (this.responseEnd) {
response.end();
}
}
}
至此,视图引擎也就实现完毕,而framework.executeAction的方式和多数MVC框架是一样的,根据路由定位到Action并执行即可,没有什么难度。
在本文中,也许你已经看到,我的代码都是javascript编写的。这并不是因为我是一个前端工程师所以我只会写javascript,事实上不管你信不信,我的C#能力要强过javascript……
在此使用javascript作为实现的示例的原因是,由于在区块输出中引入了async、bigPipe等方式,这本质上形成了一个异步的效果,而我所认识的,无论是struts还是ASP.NET MVC,都是将同步放在第一位,而将异步作为一个备选或增强方案的。这直接导致在现阶段的多数平台下,异步的实现是一个比较麻烦的事。
除了NodeJS。
NodeJS因为其天生即异步的编程模型,致使让我来“实现”我的设计非常顺手,而且非常贴合多数该平台下开发者的思维模式,这才是NodeJS的真正优势。
要在最后提出NodeJS,是因为作为一个边缘人物,其实我是不懂NodeJS的(虽然厚脸皮地翻译了小半本书),但我所见到的,现在的CNodeJS社区推广NodeJS的方式并不合理,因此希望借本文的机会,提一些自己的想法:
无论是朴灵的EventProxy还是老赵的Jscex,都只是解决Node上开发的问题,让大家无法适应的模式变得能让更多人适应。
而其他的各种框架,只是在模仿已有的现成的开发框架,让Node拥有和其他平台相似的功能。
但是这一些,都没有解决一个问题:
虽然Node的问题解决了,其他平台的功能模块也有了,但为什么我不直接用其他平台?
你可能会说“效率高”,但事实上有多少人关心效率,又有多少人真正知道自己系统的瓶颈能够被Node的效率所改善,又有多少系统的真正效率瓶颈在V8和PHP的差距上?
你又可能会说“统一语言”,但是在这个“配置生成代码”的时代,又有多少系统真正需要这样的统一?君不见rails一路写ruby,不写一行javascript都能生成个带验证的漂亮表单,那又为何非要后端来写javascript?
所以,Node的真正优势何在?我认为其不同即为其劣,亦为其之所长。Node的不同在于天生异步的编程模型,这给很多人带来了烦恼,但绝境之下可逢生,他所带来的又何尝不是其在一定环境下的不可取代性和对应的开发人员吸引力?正如我上面所“实现”的内容,个人认为用Node比用.NET框架更为简单,也更为被该平台的开发者所普遍接受。而普遍接受意味着能迅速培养出社区,意味着有更多的人能参与进来……
所以,虽然个人对Node不冷不热,但我想对希望推广Node的那些拥护者们说,与其不断让Node变得更像其他框架,不如告诉别人有些事Node能用30%的代价来实现120%的效果,扬长避短的道理谁都懂,但又有多少人正在实践?
这是一篇大逆不道的文章,其作用就是供大家娱乐以及批斗,因为此文所提及的思想,试图改变现有的著名模式MVC的结构,因此如果认为MVC优秀甚至完美的话,还请直接忽略此文,以免影响心情。
本文将提出一种类似MVC但又不完全是现有的经典MVC的模式,该模式仅基于HTTP的Web系统中对经典的MVC模式进行改造,其特点是将View前置,通过View的切分来切分逻辑,形成多次M-V-C交互,最终生成响应。
对于经典的MVC模式,虽然从表面上看完全是个“不需要解释”的问题,但是每个人的理解又不尽相同。在我的理解中,MVC模式可以用下面这张图来表达:

基于HTTP的Web系统里,在经典的MVC模式中,一个请求的处理过程大致分为:
这个模式一直工作得非常好,我也完全没有理由反驳他存在的意义和优势,直到有一天我发现我的页面有了些奇怪的需求:

页面也是一个经典的“个人日志”的页面,只是加入了一些SNS的元素,如右边有“最近来访”及“好友列表”模块,同时还有一个标签云。
对于此类系统,右边的模块往往会较为通用,但又随着页面内容的不同而有所变化。例如在“个人资料”页面,则不会有“最近来访”的模块,取而代之的是加入了“关注人物”模块,这种既有稳定性,又有动态性的模式无疑是设计的一大难关。
通常来说,如果我使用的是ASP.NET MVC框架,当出现这样的需求时,会创建一些Action Filter,用于集中各个模块的逻辑,随后将数据放入ViewBag这一属性中。得益于.NET 4.0提供的dynamic数据类型,可以在不定义Model基类的前提下将此类通用的数据传递给View。
随后只要在Action上声明需要哪些模块的数据即可:
[Friends]
[RecentVisitor]
[TagCloud]
public ViewResult ViewPost(int id) {
// 获取日志数据
}
从设计上而言,我认为这是一个合适的方式,通过AOP的方式抽取了一些公共的数据,交由View进行组装,符合传统的MVC模型,且不造成过多的冗余逻辑,直到有一天,我又遇到了一个问题:
由于系统希望引入异步模型,更好地利用多核及IO资源,并行地去获取各项数据,因此顺序执行的Action Filter不能再满足场景。
也就是说,我们希望通过将MVC中Action与Model的交互进行一些改造,将彼此没有强烈联系的各项数据的获取过程并行化:

这种方案是对Model组件的修改,Controller对应地进行配合,保持response对象直到所有数据以异步的方式获取完毕,一次性递交给View即可。这显然是一种可行且简便的实现,例如.NET 4.0的Parallel库就能很轻松地完成这一工作。但是在看到并应用这种手段时,我却不由得想到一个问题:
“好友列表”或者“近期访客”这样的模块,和日志本身有什么关系?
进一步的:
如果没有关系的话,为何一个Action需要处理这些数据(无论是通过Action Filter还是硬编码)?
在不断反思这个问题的同时,又会有这样的想法:
为什么一个响应只能由一个Action处理?明明不相关的几个区块,是不是由多个Action处理,形成多个View,再组装起来更合适呢?
想到这一步,我就发现有1个常见的技术经常被用来处理此类页面,即“AJAX延迟加载”。在这种方案下,最初输出的View只包含对应模块的结构和容器,随后通过AJAX请求读取各模块的视图内容,由前端负责组装。
这种方案保证了页面的主要内容第一时间被送到客户端,也保证了各模块的逻辑(Action)互不重叠、冲突,实现干净利落的切分和隔离。但其代价是产生多个HTTP请求,在请求-响应级别的优化能力有限。
为了将HTTP请求数量进行缩减,此后又出现了BigPipe技术,虽然并没有改变经典MVC的结构,但做到了将“模块化页面”压缩至一个HTTP请求之中。同时BigPipe还能够更有效地利用浏览器的资源,让页面的渲染和内容的下载并行进行,对于复杂的、对浏览器渲染时间要求较高的页面有奇效。
综合分析了现有技术,却发现并没有理想的解答自己提出的问题。AJAX延迟加载虽然对Action进行了切分,却导致了HTTP请求过多等负面效果;而BigPipe并不是对逻辑处理的切分,只是一种HTTP响应输出技术,只能提供些许思路,却没有办法从根本上解决问题。
于是又经过多天的反思,以及和一些朋友的讨论,最终我发现自己走进了一个“误区”:
传统MVC模型的请求由Controller开始,依次经过Action->Model->Action->View,最终变为输出。但这并不代表着此流程一定是“完美”的。
也许这个我认为的“误区”,对多数人来说是“真理”,因此要推翻这一点,确实得抱有足够的勇气……
首先,无论我们使用什么样的技术手段,对于用户来说,我们的页面是怎么样的:
抛开底下的技术不谈,如果从视图和功能上进行划分,这显然是4个区块。但我们又是如何发现这是4个区块,而不是2个或者6个呢?答案是:在网页上用眼睛看。
是的,无论用什么样的技术,用户肉眼之所见,对他们来说永远是第一位。那么,为什么在逻辑上,却将供肉眼所见的“视图”放在最后呢?当然“因为一出来就给用户看了,当然要最靠近用户”这样的理由是成立的,但又为什么“用户一请求就应该收到了,因此要放在最前面”呢?
在这个近乎变态的想法的支撑下,我试图将View作为请求的入口,通过View的切割来产生不同的区块(Section),而不同的区块对应着不同的逻辑(Action< ->Model),又产生属于区块自己的视图,最终合并为整体:

在这一模式中,请求将有一个不同于经典MVC模式的处理过程:
Locator的组件接收,Locator组件会通过对请求数据的分析,定位到需要的视图。视图引擎将解析View,当发现有Section时,进入处理Section的逻辑:
视图引擎将页面的框架组织,以及各个Section的输出内容,通过一个缓冲区(Buffer)进行合并,统一输出。
可见,这个另类的MVC模式有自己的一些特点:
下一篇将会讲述此种模式的优势及应用场景,并简要地涉及相关的实现方案。
时隔N久,继续生产,年底实在忙……
在上一篇发表之后,有网友指出这个沙箱过于复杂,事实上一些更简单的代码也能完成同样的效果,例如:
var proxy = {
document: new DocumentProxy()
};
function(window) {
// 开发者提交的代码
}.call(proxy, proxy);
通过call和函数参数来劫持this及window这两个指向全局对象的变量,以达到屏蔽全局对象的目的。
但是正如我在回复中指出的,这个方式虽然简单易懂,却有一个非常重要的环节无法照顾到,即全局环境下LexicalEnvironment和VariableEnvironment和ThisBinding均是全局对象,这一特性意味着在全局环境下执行的代码将同时满足以下4个条件:
var x = 3;定义的变量,可以使用console.log(x);输出。this.x = 3;定义的属性,可以使用console.log(this.x);输出。this.x = 3;定义的属性,可以使用console.log(x);输出。var x = 3;定义的变量,可以使用console.log(this.x);输出。其中第1点和第2点是任何环境下的代码都可以满足的,因此通过call和函数参数来支持全局对象没有问题。但是第3和第4点正是全局环境的特征,并不能简单地通过单层的函数代码来控制。而通过一个with语句则可以得到满足第3点,而第4点则需要一些tricky的手段来实现了,本文将会重点从标准出发来阐述这2点的推理过程。
首先,抛开沙箱这一目标,从本质上来看一下,以下几个语句分别是什么作用:
var x = 3;this.x = 3;x;this.x;只要有基本的javascript基础的开发者,都会很顺利地回答出来:
this对象上定义一个属性,属性名为x,其值为3。this对象上获取属性名为x的属性。因此,从这4句代码中,我们可以总结而得,第1节中提到的4个条件,其实质是处理以下几个问题:
由于事实上,我们希望定义的变量本身就在this对象上,而非其原型链上的其他对象上,因此这里不会涉及到属性查找这一环节,真正需要处理的是变量的定义和查找,以及this绑定的问题。
而接下去的章节,就将来分析一下这3个过程在标准中是如何定义的。
继续打开ECMAScript 5的HTML版本,搜索Variable关键字,并不会有太多的结果,一一过目,最终与变量定义有关的内容会集中在Declaration Binding Instantiation一章中,这一章概述了当进入一个函数时,函数名、形参、变量是如何进行初始化绑定的,其中内容非常多,因此只抽取与变量相关的部分摘录:
Every execution context has an associated VariableEnvironment. Variables and functions declared in ECMAScript code evaluated in an execution context are added as bindings in that VariableEnvironment’s Environment Record. For function code, parameters are also added as bindings to that Environment Record.
Which Environment Record is used to bind a declaration and its kind depends upon the type of ECMAScript code executed by the execution context, but the remainder of the behaviour is generic. On entering an execution context, bindings are created in the VariableEnvironment as follows using the caller provided code and, if it is function code, argument List args:
1. Let env be the environment record component of the running execution context’s VariableEnvironment.
…其他内容
8. For each VariableDeclaration and VariableDeclarationNoIn d in code, in source text order do
Let dn be the Identifier in d.
Let varAlreadyDeclared be the result of calling env’s HasBinding concrete method passing dn as the argument.
If varAlreadyDeclared is false, then
- Call env’s CreateMutableBinding concrete method passing dn and configurableBindings as the arguments.
- Call env’s SetMutableBinding concrete method passing dn, undefined, and strict as the arguments.
这一段告诉我们,一个变量是绑定在VariableEnvironment上的(env对象),并且同名的变量只会绑定一次(通过HasBinding判断)。
关于变量查找,很多开发者都明白存在一个称为“作用域链”的数据结构,变量是在作用域链上自下向上进行查找。而在ECMAScript 5中,作用域链的概念已经不复存在,相对应地引入了LexicalEnvironment这一概念,在Identifier Resolution一章中,对变量的查找过程进行了描述:
Identifier resolution is the process of determining the binding of an Identifier using the LexicalEnvironment of the running execution context.
During execution of ECMAScript code, the syntactic production PrimaryExpression : Identifier is evaluated using the following algorithm:
- Let env be the running execution context’s LexicalEnvironment.
- If the syntactic production that is being evaluated is contained in a strict mode code, then let strict be true, else let strict be false.
- Return the result of calling GetIdentifierReference function passing env, Identifier, and strict as arguments.
The result of evaluating an identifier is always a value of type Reference with its referenced name component equal to the Identifier String.
这一段说明查找一个变量,是通过将LexicalEnvironment及变量名称作为参数,传递给GetIdentifierReference函数来进行的,而GetIdentifierReference函数的说明在标准中有如下描述:
The abstract operation GetIdentifierReference is called with a Lexical Environment lex, an identifier String name, and a Boolean flag strict. The value of lex may be null. When called, the following steps are performed:
If lex is the value null, then
- Return a value of type Reference whose base value is undefined, whose referenced name is name, and whose strict mode flag is strict.
Let envRec be lex’s environment record.
Let exists be the result of calling the HasBinding(N) concrete method of envRec passing name as the argument N.
If exists is true, then
- Return a value of type Reference whose base value is envRec, whose referenced name is name, and whose strict mode flag is strict.
Else
- Let outer be the value of lex’s outer environment reference.
- Return the result of calling GetIdentifierReference passing outer, name, and strict as arguments.
简而言之,ECMAScript 5定义了一种自内向外的变量查找过程,如果使用大家熟悉的javascript代码来描述,则可以这样表达:
function LexcicalEnvironment(outer) {
this.outer = outer || null;
// 存放变量名-变量关系
this.environmentRecords = {};
}
function GetIdentifierReference(env, name) {
while (env) {
if (env.environmentRecords[name]) {
return env.environmentRecords[name];
}
env = env.outer;
}
}
至此,我们可以得出以下结论:
因此回到最初的问题,来分析一下为何通过一个with语句,可以使得this.x =3;定义的属性,能通过console.log(x);的形式访问。
为了解释上一节最后提出的问题,有必要再回顾一下我们上一篇得到的代码在执行过程中,LexicalEnvironment和VariableEnvironment是如何变化的:
new function() {
with (this) {
this.x = 3;
console.log(x);
}
};
在这段代码执行的过程中,我们可以很简单地看到,其中包含了一个函数的调用和一个with语句,同时肉眼不可见的自然有一个全局环境。根据上一篇中引用的标准:
因此,加上最外层的全局对象,我们不难得到这样的一个图:

图中自外向内依次为全局环境、new function以及with语句生成的相关环境,并根据颜色,在下文中用R、G、B给予代替:
env为指代。同时在函数中,this指向new运算符的结果,使用obj为指代。obj对象为environment records组件,在图中简单地描述该LexicalEnvironment即为obj对象。同时with语句不会改变this对象,因此this依旧指向obj对象。这便是with语句的奇特之处,简单来说,with语句是唯一可以指定使用确定对象形成LexicalEnvironment的语句,也正因为这个特性,让我们有办法将this和LexicalEnvironment统一为一个对象。
在上图表示的结构中,根据标准定义的查找变量的方式,当执行console.log(x);的时候,会从B层的LexicalEnvironment中查找变量名为x的变量,而该LexicalEnvironment的environment records正好是obj对象,与this指向的对象相同,而obj对象上自然因为之前this.x = 3;这一语句,定义了一个名为x的属性,因此顺利地拿到了对应的值。
在正确地解释了为何this.x = 3;定义属性后可以通过console.log(x);输出之后,唯一需要解决的问题就是以上过程的反向,即如果使用var x = 3;定义变量后,现在还没有办法使得console.log(this.x);可以正确地输出值。
重新审视上面这张LexicalEnvironment和VariableEnvironment的关系图,当我们进入函数时,在搜索到var x = 3;这一语句后,发生了什么:
env对象。env对象的environment records中是否有名为x的绑定,由于没有在函数中定义任何变量,自然不会有。env的environment recrods添加一个绑定,变量名为x,值为undefined。var x = 3;这一句时,再对x赋值。由此可见,非常遗憾地,x被定义到了由函数代码生成的VariableEnvironment对象,即env对象中,而env和我们希望的obj自然不是同一个对象,且没有任何编码方式可以访问到env对象,也无法通过修改引用来使得obj和env指向同一对象。
因此,从这一结论上看,希望通过改变变量定义时的行为来达到目的,似乎是不可行的,只能另寻他法。
再次将目光回到var x = 3;这一语句上,这一语句究竟代表了什么,真的是定义一个名为x的变量并赋值为3这么简单的一句话吗?
作为一个javascript的开发者,你是否听过javascript有一个被称为Hosting Behavior的机制,即所有的var定义会被提前到函数开始这一通俗的言论?而本文前半部分摘录的Declaration Binding Instantiation相关内容,也正是对这个机制地阐释。事实上,变量的定义是在进入函数时进行的,而不是函数代码执行过程中进行的,换句更通俗的话说,var x = 3;这一简单的语句,包括了好多个步骤:
var x = 3;时,给x赋值,值为3。再仔细分解,就容易得出这样一个结论:var x = 3;事实上是var x;和x = 3;的整合。
因此,既然var x;这一部分已经被变量定义的过程所限制,无从下手给予变更,那么进而着眼于x = 3;这一语句,寻求突破口便成了最自然地举动。在此分析一下,x = 3;这一语句是一个怎么样的过程:
亦即是说,x = 3;这一语句是由变量查找和变量值修改这两个逻辑整合而成。至此,你是否又看到了一个熟悉的词汇,并因此而倍感兴奋?是的,在绕了一圈之后,变量定义的问题又变成了变量查找,而上一节中我们已经证明了变量查找是一个可控的过程。
到这一步,问题再一次被简化,我们需要做的是,当查找x变量时,可以在this对象中获得该变量,即在图中B部分的LexicalEnvironment中得到结果,即保证在obj对象中拥有x这一属性。
至于如何让obj对象拥有x属性,那自然是需要定义一下这个属性了,这是必须的无奈之举。好在如果抱着宁可错杀一万,不可漏杀一个的心态,从开发者提交的代码中提取定义的变量名并不是一个非常困难的事,通过简单的正则表达式提取var关键字以及其后的标识符即可,例如/var\s+([^\s]+)\s/g这样的正则就已经在很大程度上可以满足要求。即便提取了本不是变量定义的部分也不要紧,多出来的这些也很少会影响到逻辑。
虽然依旧避免不了对开发者的代码进行扫描,但至少不再需要进行词法、语法的分析,也不需要创建一个javascript的虚拟机来试运行,只需一个简单的正则,让问题变得简单很多,最终的代码可能是如下形式,以供大家讨论:
// new function负责创建一个新的、独立的this对象
new function() {
// 将用户提交的代码中的所有变量名抽取出来,形成varList数组
// 通过在this对象中定义对应的属性,来达到更完善地模拟全局环境
(function(o) {
for (var i = 0; i < varList.length; i++) {
o[varList[i]] = undefined;
}
}(this));
// with负责创建一个可控的LexicalEnvironment
// 保证变量查找和this对象下的属性查找会落在一个对象上
with (this) {
// 用户提交的代码
}
};
上一章中,经过对标准的查询、翻阅、解析,终于在绕了一个大圈之后,明白了全局对象的特征,并明确了模拟一个全局对象需要的内容,还为此编写了简单的测试用例。本文将从第1个测试用例着手,放眼与全局作用域的VariableEnvironment、ThisBinding以及window别名这三者的一致性,来推导出一个沙箱的原型。
在开始这一章以前,首先不得在这里道个歉,原因很简单,我对标准进行了误读,且在没有测试的情况下就开始了这一系列的博客。事实上,以我的思路,是无法制作出一个理想中的沙箱的,虽然可以控制住第三方代码的作用区域,但是无法做到完全的透明,其主要问题出在这里:
var x = 3;
console.log(this.x); // 打印出3
事实上,仅仅依靠Javascript本身,是没有办法在非全局环境下使上面这段代码得到正确的结果的。
因此,在这里,不得不先把标准给改上一改,放弃绝对透明性这一要求,事实上也确实很少有开发者会通过var声明变量并使用this.x或window.x来访问,不得以之下只能放弃这个需求,将var声明和this.x声明隔离开来。
事实上(开始狡辩),对标准的误读也是学习标准的一种过程,不会犯错的人也很难有太大的进步。我思考良久,最终的决定是不删除这一系列的博文,而是使之继续下去,在修改测试用例的情况下,继续来制作沙箱。当然这个难度要简单不少了。
这一系列已然变成了一个“边研究边写”的“研究笔记”,随着这一系列的进行,必然会出现越来越多的问题,事实上这个沙箱也不见得有可用性。还请以“标准对研究进行指导”这样的角度来看待这个问题吧,得出一个“无角”这样的结论,姑且也算一个结论……
依旧沿用上一章的思路,打开ECMAScript 5的HTML版本,通过CTRL+F组合键打开搜索框,查询VariableEnvironment,可以看到搜索的结果并不多,同样搜索ThisBinding,则发现多数内容与VariableBinding的搜索结果重叠,说明这两者经常放在一块描述。
首先是对VariableEnvironment一词的解释,在Execution Context一章的表格中有如下描述:
Identifies the Lexical Environment whose environment record holds bindings created by VariableStatements and FunctionDeclarations within this execution context.
对ThisBinding的描述也颇为简单:
The value associated with the this keyword within ECMAScript code associated with this execution context.
当然这并不是我们需要的内容,仅仅以此来对VariableEnvironment有一个基本的认知。VariableEnvironment是用于在文法环境中保存通过变量声明语句和函数声明创建的对象之用的,即var x和function fn() {}创建的对象会被保留在VariableEnvironment中。而ThisBinding则特指this关键字指向的对象。
而我们真正需要知道的是,在各种代码的执行环境下,VariableEnvironment和ThisBinding分别会是什么,通过在标准中的搜索,我们可以很容易地找到相关的内容,在Establishing an Execution Context一章中,分别描述了全局代码、函数代码和eval代码执行时的VariableEnvironment和ThisBinding:
Initial Global Execution Context中对全局代码执行时的描述:
- Set the VariableEnvironment to the Global Environment.
- Set the LexicalEnvironment to the Global Environment.
- Set the ThisBinding to the global object.
Entering Eval Code一章中对eval代码中VariableEnvironment的描述:
If there is no calling context or if the eval code is not being evaluated by a direct call (15.1.2.1.1) to the eval function then,
- Initialize the execution context as if it was a global execution context using the eval code as C as described in 10.4.1.1.
Else,
- Set the ThisBinding to the same value as the ThisBinding of the calling execution context.
- Set the LexicalEnvironment to the same value as the LexicalEnvironment of the calling execution context.
- Set the VariableEnvironment to the same value as the VariableEnvironment of the calling execution context.
If the eval code is strict code, then
- Let strictVarEnv be the result of calling NewDeclarativeEnvironment passing the LexicalEnvironment as the argument.
- Set the LexicalEnvironment to strictVarEnv.
- Set the VariableEnvironment to strictVarEnv.
Perform Declaration Binding Instantiation as described in 10.5 using the eval code.
可以看到,如果使用的是indirect eval,则与全局代码相同。如果使用普通的eval,则继续使用当前所在执行环境的VariableEnvironment和ThisBinding。如果是严格模式则有更复杂的过程,好在他虽然麻烦,但我们不用他就行了嘛,破坏严格模式还是很容易的一件事,现在先不作讨论。
Entering Function Code一章中描述了函数内代码执行时VariableEnvironment的情况:
- If the function code is strict code, set the ThisBinding to thisArg.
- Else if thisArg is null or undefined, set the ThisBinding to the global object.
- Else if Type(thisArg) is not Object, set the ThisBinding to ToObject(thisArg).
- Else set the ThisBinding to thisArg.
- Let localEnv be the result of calling NewDeclarativeEnvironment passing the value of the [[Scope]] internal property of F as the argument.
- Set the LexicalEnvironment to localEnv.
- Set the VariableEnvironment to localEnv.
- Let code be the value of F’s [[Code]] internal property.
- Perform Declaration Binding Instantiation using the function code code and argumentList as described in 10.5.
其中第1-3步说明在函数代码执行时,其ThisBinding指向一个叫thisArg的东西,而第5、第7步则说明其VariableEnvironment是一个全新创建的环境,仅与当前的作用域挂钩。
至于这个thisArg是什么,相信很多编写Javascript的代码工程师很非常了解,我在javascript中的对象查找一文中也专门抽一节讲述了this的查找规则。对于已经是现成知识的内容,也就不必再辛辛苦苦去翻阅标准了,还请时刻牢记标准是指导作用,而不是强迫查询。
对于标准的查阅也至此为止,既然已经知道了各种代码执行时的VariableEnvironment和ThisBinding,不如先总结一下各自的优劣:
到此,我们已经有了控制var声明变量的作用域的方式,其实这个非常简单,只要有一个新的作用域生成就行,最方便的自然是一个自调用函数表达式:
(function() {
// 放入开发者提交的代码
}());
但是以上代码至少有2个缺陷:
this.x = 3或window.x = 3就足以声明全局变量,即使是君子也很容易以这种形式不小心污染了全局。this.x = 3声明属性后,无法使用console.log(x)获得正确的输出。为了解决这2个问题,我们需要控制住this。对于第1点,只要随便有和个this对象即可,而对于第2点,则需要让ThisBinding变得和VariableEnvironment有一定关系(变量查找会找到当前this对象中)就行了,于是我们还是回顾一下ThisBinding有哪些情况:
粗略一看,似乎后两者更有实用价值,特别是call/apply调用有最大的自由度。然而这里却遇到一个棘手的问题,VariableEnvironment根本是一个不可能通过Javascript访问到的对象,因此即使使用call来指定this对象,也不可能将VariableEnvironment传递过去。
于是到这一步,似乎通过修改this为VariableEnvironment的方法并不是很现实,至少不可能主动获取VariableEnvironment并将之像普通对象一样传递。而事实也是如此,用“不可能”来形容“将this修改为VariableEnvironment”这一需求也不为过,如果哪位有什么方法,还望不吝赐教。
此路不通,在此时便只能回头。回头看看刚才摘录的标准相关内容,仔细地查找每一个陌生名词,经过无数的弯路(如果把走过的弯路都写出来,我可以写小说了,所以省略吧),我们发现在讲述VariableEnvironment的时候,总是有一个名词在附近晃悠,就像打一打就能升级但又逃得飞快的的天使波波羊一样,它就是LexicalEnvironment。
对于LexicalEnvironment,Execution Contexts一章中有非常明确的解释:
The LexicalEnvironment and VariableEnvironment components of an execution context are always Lexical Environments. When an execution context is created its LexicalEnvironment and VariableEnvironment components initially have the same value. The value of the VariableEnvironment component never changes while the value of the LexicalEnvironment component may change during execution of code within an execution context.
这段话绝对是令人振奋的,他说明VariableEnvironment没有什么特别,其实VariableEnvironment和LexicalEnvironment根本是一个东西。那么我们又可以将原先对准VariableEnvironment的矛头再一次指向LexicalEnvironment,从标准里找一找,有没有什么办法修改LexicalEnvironment,让它和this变成一样呢?
在标准中搜索LexicalEnvironment,并不会有很多的结果,经过细致的查阅和过滤,发现在2个地方有单独提到LexicalEnvironment而没有讲VariableEnvironment,这显然是突破口,因此必须牢牢抓住:
标准中The with Statement一章有如下的描述:
The production WithStatement : with ( Expression ) Statement is evaluated as follows:
- Let val be the result of evaluating Expression.
- Let obj be ToObject(GetValue(val)).
- Let oldEnv be the running execution context’s LexicalEnvironment.
- Let newEnv be the result of calling NewObjectEnvironment passing obj and oldEnv as the arguments.
- Set the provideThis flag of newEnv to true.
- Set the running execution context’s LexicalEnvironment to newEnv.
- Let C be the result of evaluating Statement but if an exception is thrown during the evaluation, let C be
- (throw, V, empty), where V is the exception. (Execution now proceeds as if no exception were thrown.)
- Set the running execution context’s Lexical Environment to oldEnv.
- Return C.
另外The try Statement一章中对catch块的描述:
The production Catch : catch ( Identifier ) Block is evaluated as follows:
- Let C be the parameter that has been passed to this production.
- Let oldEnv be the running execution context’s LexicalEnvironment.
- Let catchEnv be the result of calling NewDeclarativeEnvironment passing oldEnv as the argument.
- Call the CreateMutableBinding concrete method of catchEnv passing the Identifier String value as the argument.
- Call the SetMutableBinding concrete method of catchEnv passing the Identifier, C, and false as arguments. Note that the last argument is > . immaterial in this situation.
- Set the running execution context’s LexicalEnvironment to catchEnv.
- Let B be the result of evaluating Block.
- Set the running execution context’s LexicalEnvironment to oldEnv.
- Return B.
经过不小的折腾,终于找到了2个可以额外改变LexicalEnvironment的语法,即with和catch。
再仔细看一看标准中的描述,with可以指定任何一个对象,通过这个对象创建一个叫NewObjectEnvironment的东西,并把这个东西当做LexicalEnvironment。而catch则比较悲剧,只能在原有的LexicalEnvironment基础上增加一个catch的异常对象,形成新的LexicalEnvironment。
对于我们的需求,两者的优劣显而易见,with是有望符合我们的要求的,而catch则因为机制本身受限过多,基本不用抱以希望。那么现在的问题就是如何利用with语句,来创建一个和this相同的LexicalEnvironment。
其实再仔细研读标准中对LexicalEnvironment的描述,通过简单的总结,答案已经非常明显:
with可以指定任何对象使之变成LexicalEnvironment。于是不难看出,只要通过with指定this作为LexicalEnvironment,问题也就迎刃而解了。加之前文对this的理解,我们很容易就得到3种方法来创建一个干净、全新的this:
call或apply,主动传递一个干净的对象作为this。每个方法都不难,个人还是选择了第2种方法,没什么原因,写着方便而已,所以有了最后的代码:
new function() {
with(this) {
// 嵌入开发者提交的代码
}
};
最后再把上一章中的测试代码拿过来执行一下:
测试代码已经经过了修改,由于其中第1部分是无法做到的(参见本文第一段的声明),因此只测试this和window和读取变量的相通性,再次致以歉意!
// 测试var, this, window三者是否相通
function equals(x, y) {
console.log(x === y);
}
new function() {
// 动持window,无需多言
this.window = this;
with (this) {
this.x = 4;
equals(x, 4);
equals(window.x, 4);
window.y = 5;
equals(y, 5);
equals(this.y, 5);
}
};
console.log(window.x);
console.log(window.y);
在浏览器中运行,可以在console中得到4个true的结果,这个简单的沙箱还是能起到一定的作用的。
事实上,这里本身就是一个问题,如果将测试用例封装为函数,再直接调用函数,则this已经指向了window,这是标准所规定的ThisBinding方式,根本无从拦截,除非使用类似虚拟机的工具来完成。但是个人认为,能做到这一步已经不错,进一步只能给予开发者一定的限制,比如不得直接调用函数,必须以this.fn()的形式调用。虽然会麻烦(其实这个沙箱已经是越做洞越多了),却也是无奈之举。
上一章中对需求进行了分析,并对实现方案有了一定的探讨,最终得出从劫持全局对象这一环节入手的结论,而本文将讨论如何劫持这个全局对象。
全局对象,多数会进行Javascript编程的工程师都知道,在浏览器的执行环境中,叫做window,虽然事实上window和全局对象并不严格意义上是一个东西,不过这对我们的实现并不会造成什么障碍。
既然要去做劫持全局对象这样不人道的事,自然先要知道全局对象是个什么东西,有什么样的特点,以便针对目标,一刀致命。这个时候,标准就会成为我们最好的参考,首先打开ECMAScript 5的HTML版本,此后几乎所有的标准引用都会从这里产生。
所谓擒贼先擒王,经典理论告诉我们,不可以把整个洋洋洒洒数万字的标准给全看了,必须以快、狠、准的手段找到我们需要的东西。如果是纸质的书籍,对于一个不熟悉文档结构的人而言自然是无处下手,但好在现在是数字化的时代,我们有一个名为“搜索”的强大功能。
在浏览器中按下CTRL+F的组合键,随后输入Global,看看一共有多少内容。从目录来看,一共有7处内容,确实不多:
而这8处内容又可以分为2部分,其中1-3分别介绍了全局对象本身的特点,而4-7则专注于介绍全局对象上的内置属性(即本地对象)的相关内容。仅仅以劫持全局对象为目的的我们,自然可以先不管全局对象上的其它属性,只针对全局对象自身入手,因此将目光放置在前3者之上。
首先是来自于The Global Environment一节对全局对象概念上的介绍:
The global environment is a unique Lexical Environment which is created before any ECMAScript code is executed. The global environment’s Environment Record is an object environment record whose binding object is the global object (15.1). The global environment’s outer environment reference is null.
As ECMAScript code is executed, additional properties may be added to the global object and the initial properties may be modified.
这一段并没有带来太多的信息,仅仅说明了“全局对象是什么”这一理论,但是其中的第二段却给我们一点有利地信息:在代码执行过程中,全局对象上可能会增加额外的属性,当前的属性也可能被修改。这就告诉我们,需要模拟全局对象的时候,可以在上面添加自己的内容,也可以让原有的原生对象变化甚至消失,这些都不会违反标准,因此在全局对象上添加诸如container或者user之类的业务相关信息也合情合理,而去除eval之类的函数也合乎逻辑,使得API的设计更加容易。
随后,将重点放在Entering Global Code一章中:
The following steps are performed when control enters the execution context for global code:
- Initialize the execution context using the global code as described in 10.4.1.1.
- Perform Declaration Binding Instantiation as described in 10.5 using the global code.
这一段没有什么有效的信息,不过告诉我们10.4.1.1节会有相关的内容(当然10.5节也有内容,但这一节并不专为全局环境服务,因此不再赘述,有兴趣的可以自行翻阅):
The following steps are performed to initialize a global execution context for ECMAScript code C:
- Set the VariableEnvironment to the Global Environment.
- Set the LexicalEnvironment to the Global Environment.
- Set the ThisBinding to the global object.
这一段的信息就非常重要了,他告诉我们2个事实:
var声明的变量都会成为全局对象的属性。this对象为全局对象。这也是全局对象最大的特征,那通过var声明和通过this.x声明会有几乎相同的效果。
最后再看看15.1节带给我们的信息(摘录部分):
Unless otherwise specified, the standard built-in properties of the global object have attributes {[[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: true}.
The global object does not have a [[Construct]] internal property; it is not possible to use the global object as a constructor with the new operator.
The global object does not have a [[Call]] internal property; it is not possible to invoke the global object as a function.
The values of the [[Prototype]] and [[Class]] internal properties of the global object are implementation-dependent.
这一段告诉我们以下事实:
[[Prototype]]和[[Class]]是什么与标准没关系,因此模拟的时候不需要考虑这个环节。到这里,相关的标准也基本翻阅完毕,从标准中我们可以总结,如果需要模拟出一个全局对象,则需要满足以下条件:
var定义的变量和this.x或window.x定义的属性均会出现在这个对象上。Object、Array等,且这些对象可以被修改,但无法通过for..in遍历出来,且不可被delete运算符删除。本着满足上节中提到的2个要求的目的,为了可以验证最终实现的沙箱的可用性,我们必须有一些测试的代码,来检验成果。
根据2个要求,在此先设计2个测试用例:
// 测试var, this, window三者是否相通
function testBinding() {
var x = 3;
equals(this.x, 3);
equals(window.x, 3);
this.y = 4;
equals(y, 4);
equals(window.y, 4);
window.z = 5;
equals(z, 5);
equals(this.z, 5);
}
// 测试是否存在内置对象,且不可被遍历及删除
function testNative() {
var natives = ['Object', 'String', 'Number', 'RegExp', 'Array', 'Date'];
var keys = {};
for (var name in window) {
keys[name] = true;
}
for (var i = 0; i < natives.length; i++) {
var key = natives[i];
if (keys[key]) {
fail();
}
var value = window[key];
delete window[key];
if (window[key] !== value) {
fail();
}
}
}
至此,开工的先决条件已经具备,而未来的2个篇章,将着重从这2个测试用例下手,以一章一个问题的速度给予解决。而解决过程中自然少不了对标准的查阅、引用,毕竟这一系列的目的就是展示标准如何引导问题的解决。
在此之前,如果有兴趣的,可以尝试着来制作一个沙箱,沙箱的目标是在用户提交的脚本前后插入一些代码,让用户的代码只能在我们模拟的全局对象中运行,不可越级访问到真实的全局对象。只要符合以上2点的沙箱就是好沙箱哦~
PS:一开始从脑子里挖标准的时候,觉得对全局对象的形容也就是最后2句话而已,所以本想这一章可以将第一个测试用例搞定。但没想真正从标准是引用内容的时候,竟然有如此大的篇幅,导致本章还没有空余来真正着手解决问题,这也从另一方面展现了标准的严谨性和权威性。由于下面要处理的变量及对象绑定问题也会有大量的标准引用来支撑,因此本系列会多出一个章节,单独来处理这个问题,望见谅!
这将是一个系列的文章,大致由4篇博文组成,内容应该会是这样的:
起草这一系列博文的原因有2,第一自然是因为最近突然有这样的需求,而群里正好也在某个深夜(技术宅基本深夜活动)讨论到了一个类似的话题,给了我一些灵感,所以决定将近期“研究”的成果展示一下。
而第二个目的,也是我想将这么一个简单的东西(其中最终就10几行代码)用整整5个博文的篇幅来说的原因,是因为近期有不少朋友向我强烈地表达了标准的存在意义极其微弱,我能把代码写好就足够的想法,而且现在更是催生了一种叫做jQuery工程师的职位,大有星星之火的样子。
对于这样的态度,作为一个只会理论不懂实践的2B工程师,个人自然是坚持地抱以反击态度的,因此,这一系列的博文,将会从“构建一个沙箱”这样的场景出发,将对于这一话题的研究过程展现于众,期间将频繁涉及到对标准的参阅,以展现标准如何引导问题的解决这一事实。
在阅读这一系列的博文前,还请确认几件事:
最后,还是先声明一件事,其实我研究这个问题并没有这么麻烦,因为本系列博文中引用标准的内容几乎全在我的脑子里。我只是将这个思考、查找、得出结论的过程放大、放慢,进行分解,一点一点地通过文字表达出来而已。至于类似“根本不知道标准又怎么会往这方面想”的问题如果出现在你的大脑中,那么恭喜你似乎已经意识到了标准学习的些许价值。
如果你有一个小小的项目,想提供一个开放地平台,供第三方的开发者提交一些小程序(Widget)并嵌入到页面中使用。这有点类似与人人网。
对于此类的系统,一个比较合理的设计,自然是让开发者提交一个HTML片段,并配以相应的CSS和Javascript,随后在页面中提供一个容器,引入该HTML片段,并且通过一定的手段引入CSS和Javascript,并保证CSS和Javascript只影响到该容器内的所有DOM元素。
于是,这样的设计就引入了一个问题。正如上文所说,我们必须保证开发者提供的CSS及Javascript仅作用在容器及其子元素之上。对于CSS,可以使用style元素的scoped属性,虽然这个属性的定义一直处于讨论之中,但这并不妨碍它可以基本满足我们的需求。但是对于Javascript,却并没有这么简单。事实上,至今为止并没有任何的HTML属性或者Javascript函数,可以让一段Javascript在指定的作用域内执行,达到不污染全局、不影响外围DOM元素等目标。
基于此,我们必须人为地创造出一个环境,在这个环境中所执行的代码要求能满足以下几点:
eval和document.write。根据专业术语,我们将这个人为创造的环境,称为沙箱(sandbox),以下是Wikipedia上对sandbox一词的解释:
In computer security, a sandbox is a security mechanism for separating running programs. It is often used to execute untested code, or untrusted programs from unverified third-parties, suppliers, untrusted users and untrusted websites.
在追求Javascript的安全执行上,很多团队都有着丰富的积累和成果,其中以Facebook的FBML、微软的Web Sandbox和Google的caja最为典型,百度泛用户体验上也有一篇文章对几个较为成熟的技术方案进行了介绍。
但是这几个方案,对于一个小小的项目来说,无疑都太过沉重,不是引入一个新的语法,就是带上后端的虚拟机,同时又需要约束开发者的提交的代码,使得其对第三方小程序开发者无法透明,更加大了系统的推广成本。因此,我们试图以防君子不防小人为目标,去创造一个轻量级的沙箱环境,在保证Javascript代码基本受限地执行,又无需引入太多外部的环境依赖。
重新回顾一下我们对沙箱的要求:
反过来想,正常的开发者是怎么写代码的呢?先随便来一段:
var time = new Date;
document.getElementById('time').innerHTML = formatDate(time, 'yyyy-MM-dd HH:mm:ss');
document.getElementById('hello').innerHTML = 'Hello ' + username;
对于一个认为自己是在纯净的环境中开发的开发者而言,使用document.getElementById来获取DOM元素,使用var来定义变量,随后对DOM进行操作,是再正常不过的事。但是在一个拥有容器的环境中,以上代码却适时地暴露出了几个问题:
var定义的变量会污染全局。document.getElementById会获取到不应该能够获取的DOM元素。此两者是一个正常的开发者(非存心搞破坏的那类)最容易在不经意之间遭遇的问题,这不是他们蓄意的破坏,只是他们认为自己的Javascript片段的执行环境是纯净、隔离、独立的。因此抱着防君子之心,对此做出限制是合理且必要的。
纵观这些问题,经过一段时间的思考(思考过程过于繁琐就不说了),可以发现,其实问题集中在一点上,即拦截全局对象。由于在Javascript中,本地对象和宿主对象以及DOM对象都是和全局对象挂钩的,且全局对象是唯一的入口,因此只要将入口劫持,所有的东西都可以得到合理地控制。如以上的问题,如果能够对全局对象进行劫持,随后提供刻意“伪造”的document对象,则问题迎刃而解。
因此,从下一节开始,将从全局对象的概念开始,讲述如何支持全局对象。并在此之后,讲述如何在劫持后伪造出相关的本地和宿主对象。
这是今天一次内部分享的PPT,其中内容主要来自于Winter大大分享的相关材料,和How Browser Work这一文的一些内容。
由于内部分享是以讲为主,因此PPT并不包含所有内容,仅仅是一个摘要的形式,另有以下几点:
PPT仅说明了整个分享的主要内容点,由于涉及的内容较多,也无法在其中对每个细节进行详细的阐述,如果对任何细节有所疑问,可以回复本篇博客,我会在时间宽裕的时候努力回复(最近忙得想去撞动车,回复不及时请勿见怪)……
如需下载,请点击此处。