为什么要网页模块化?
这篇文章讨论的是为什么Web模块化是很有用的,并介绍了现在可以用来实现Web模块化的一些机制。这里有另一篇文章介绍了RequireJS使用的函数包装格式的设计理念。
问题§1
-
网站逐渐转化为Web apps
-
代码复杂度逐渐提高
-
组装变的困难
-
开发者想要分离的JS文件/模块
-
部署时可以把代码优化成几个HTTP请求
解决方案§2
前端开发者需要这样的解决方案:
-
一些这类的API #include/import/require
-
有能力加载嵌套的依赖
-
对开发者来说易于使用,并且有优化工具在后面支持,有助于部署
脚本载入API § 3
首先梳理出脚本载入API。这里有几个选择:
-
Dojo: dojo.require("some.module")
-
LABjs: $LAB.script("some/module.js")
-
CommonJS: require("some/module")
所有的都映射到载入 some/path/some/module.js。理想情况下,我们可以选择CommonJS的语法,因为它很可能会变得更加常见,而且我们想要重用代码。
当前我们也希望一些语法能够载入已存在的纯文本JavaScript文件,因此开发者不用重写所有的JavaScript来从脚本载入中获益。
但是,我们需要一些能在浏览器中更好的工作的事物。CommonJS 的require()是一个同步调用,它期望能够立即返回那个模块。不过这在浏览器中工作的不是很好。
异步与同步§ 4
下面这个例子说明了浏览器的基本问题。假设我们有一个Employee对象,我们想要一个派生自Employee对象的Manager对象。获取该例子,我们可能会用我们的脚步载入API来这样编码:
1
2
3
|
var Employee = require( "types/Employee" ); function Manager () { this .reports = []; } //Error if require call is asyncManager.prototype = new Employee(); |
如上面注释中所示,如果require()是异步的,这段代码不会工作。但是,在浏览器中同步载入脚步将会抹杀性能。那么,怎么办?
脚本载入:XHR§ 5
使用XMLHttpRequest(XHR)载入脚本是很有吸引力的。如果使用XHR,我们就可以触摸上面的文本,也就是可以通过正则表达式来查找require()调用,以确保我们载入了这些脚本,然后再用eval()或script元素将文本内容传给使用XHR载入的脚本。
-
使用eval()来评估模块不太好:
-
开发者已经被告知eval()不好用。
-
有些环境不支持eval()。
-
难以调试。Firebug和WebKit的检查器有一个//@ sourceURL= 约定,用来给被评估的文本命名,不过这个特性不是所有的浏览器都支持。
-
不同的浏览器评估上下文环境是不同的。IE中的execScript或许可以做到,但是同时也意味着更多的移动部件。
使用带文本内容的script标签来设置为文件文本也不太好:
-
调试的时候,你得到的错误行号和源文件对不上号。
XHR 在跨域请求的时候还有问题。一些浏览器现在有跨域XHR的支持,但并不是全部。并且 IE 决定创建一个不同的API对象:XDomainRequest来实现跨域请求。出现了更多的需要改动的地方,更容易出错。特别是,你需要确定不发送任何不标准的HTTPheader或者还需要另外一个"预检"的请求来保证这次跨域的请求是被允许的。
Dojo 通过eval()使用基于XHR的loader,但是,虽然它能用,但是一直是困扰开发者的源头。Dojo 有一个 xdomain loader但是它需要通过使用一个函数wrapper来修改require的模块,所以script src=""标签可以用来加载模块了。还有很多边界情况和变化的地方来给程序员增加困难。
如果我们创建一个新的脚本加载器,我们可以做的更好。
脚本载入:Web Workers § 6
web worker可能是另一个加载脚本的方法,但是:
-
它的跨平台性不好
-
它是一个消息传递API,并且该脚本可能要与DOM交互,它只是使用worker获取脚本的文本,然后将文本回传给主窗口,再用eval/script来执行脚本。这种方法带有上面提到的XHR的全部问题。
脚本载入:document.write()§ 7
document.write()可以用来载入脚本,它可以从其他的域载入脚本并且映射了浏览器通常是如何使用脚本的,因此它可以用来进行简单的调试。
但是,在异步VS同步的例子中,我们不能直接执行脚本。理想情况下,在执行脚本前我们能够通过require()知道相关依赖项,并且确保这些依赖项被首先载入。但是我们不能在脚本执行前访问它。
而且,document.write()在页面载入后就不工作了。对于你的网站,一个好的方法是在用户需要进行下一步操作时来载入脚本。
最后,通过document.write()载入脚本或阻塞页面的渲染。要让你的网站有最佳表现,这个方法是不可取的。
脚本载入:head.appendChild(script)§ 8
我们可以在需要时创建脚本并将它们添加到头部:
1
2
3
4
5
|
var head = document.getElementsByTagName( 'head' )[0], script = document.createElement( 'script' ); script.src = url; head.appendChild(script); |
上面的脚本片段多了一点东西,不过那正是基本的思想。这种方法比document.write要好,因为它不会阻塞页面的渲染并且在页面载入后仍能工作。
但是,它仍然有同步VS异步例子的问题:理想情况下,在执行脚本前我们能够通过require()知道相关依赖项,并且确保这些依赖项被首先载入。
函数封装 § 9
在执行我们的脚本前,我们需要知道相关依赖项并确保已经将其载入。做这件事的最好方法是通过函数封装来构造我们的模块载入API。像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
define( //The name of this module "types/Manager" , //The array of dependencies [ "types/Employee" ], //The function to execute when all dependencies have loaded. The //arguments to this function are the array of dependencies mentioned //above. function (Employee) { function Manager () { this .reports = []; } //This will now work Manager.prototype = new Employee(); //return the Manager constructor function so it can be used by //other modules. return Manager; } ); |
这是ReguireJS的句法。如果你想载入没有定义成模块的纯文本的JavaScript的话,有一种简单的句法:
1
2
3
|
require([ "some/script.js" ], function () { //This function is called after some/script.js has loaded. }); |
选择这种句法是因为,它足够简洁并且允许载入者使用head.appendChild(script)载入类型。
出于在浏览器中良好工作的需要,它有不同于普通的CommonJS句法。有建议说普通的CommonJS句法可以使用head.appendChild(script)的载入类型,如果服务器进程有封装的函数可以将模块转换成传输格式的话。
我相信不强制使用一个运行时服务器进程来转换代码是很重要的事:
-
一是调试变的很怪异,因为服务器在注入封装函数时会导致源文件的行号关闭。
-
二是需要做更多的工作。前端开发应该尽可能的使用静态文件。
关于设计的力量和功能封装格式的使用案例的更多细节,被叫做异步模块定义(Asynchronous Module Definition (AMD)),请前往为什么是AMD?