前端开发的概念在近十年内才被细化定型出来,而虽然 JS 开发的历史更长远些,但其逐渐的规范化,尤其是模块化的概念,也就是近几年才改善的事情。
让我们把眼光投向微生活各个时期项目中的 JS 代码,看一看时代在其中投下的烙印,也许只是管中窥豹、走马观花,但通过其组织形式的异同,或可一瞥 JS 模块化层面的历史脉络。
早年间,JS 还只是<script>标签中的内联代码;或被封装到专门的脚本文件中调用。所有脚本代码共享一个全局作用域。
在这些文件或内联标签里面定义的任何变量都是全局对象 window 的成员,由此可能带来的所有不相关脚本中的互相污染,将导致冲突甚至破坏体验;某个脚本中的变量可能会在无意之间被全局中或者其他脚本中的变量覆盖。
随着 web 应用开始变得越来越庞杂,作用域和全局作用域的危害等概念变得显而易见。立即调用函数表达式(IIFE: Immediately-invoking function expressions)被发明出来并广为应用。
一个 IIFE 就是把整个或部分 JS 文件包裹进一个函数,并在对其求值后立即执行。因为 JS 中的每个函数都会创建一个新一级的作用域,所以用 var 声明的变量就被绑定在所处的 IIFE 中了,这避免了定义全局变量时的脆弱性。
下面的代码片段展示了各种形式的 IIFE。
- (function() {
- console.log('IIFE 1')
- })()
-
- (function() {
- console.log('IIFE 2')
- }())
-
- ~function() {
- console.log('IIFE 3')
- }()
-
- void function() {
- console.log('IIFE 4')
- }()
除非在 IIFE 中用window.foo = 'bar'这种形式定义一个全局上下文的变量(或错误的赋值未声明的变量),否则每个 IIFE 中的代码都是独立的。
通过使用 IIFE 模式,库就可以通过暴露一个绑定到 window 的变量并在之后对其重用的方式,来创建一个典型的模块了。
- void function() {
- window.mathlib = window.mathlib || {}
- window.mathlib.sum = sum
-
- function sum(...values) {
- return values.reduce((a, b) => a + b, 0)
- }
- }()
-
- window.mathlib.baz = (function() {
- var a = 1;
- var b = 2;
-
- return {
- foo: a,
- bar: b
- }
- })()
-
- mathlib.sum(1, 2, 3) //6
- console.log(mathlib.baz) //{foo: 1, bar: 2}
IIFE 这种实现方法的问题在于,依然没有一个明确的依赖树。这意味着不得不去特意维护组件的明确顺序,以做到模块的依赖必须先于其被加载,还得考虑递归引用的情况。
作为一个典型的传统门户类网站,主要由 JS 为其提供页面控件、插件等支持,规划的代码结构如下:
js基础代码和根模块对象:
- // base.js
-
- var Meishi = (function(){
- var
- //负责确保所需模块命名空间存在
- namespace = function(ns_string) {
- //...
- },
- //向目标对象复制属性
- copyProperties = function(target, source, isOverride){
- //...
- };
- return {
- _namespace: namespace,
- _copyProperties: copyProperties
- };
- }());
-
- window.Meishi = Meishi;
可以看到此处用了典型的 IIFE 方式定义了一个根模块。
用法大致如下,用基础代码中的方法定义一个模块对象(或向某个对象添加方法):
- // util/EventUtil.js
-
- Meishi._namespace('util');
- Meishi._copyProperties(Meishi.util, {
- /**
- * 准确判断mouseout事件
- * @static
- * @method
- * @memberOf Meishi.util#
- * @param {Event} evt
- * @param {number} offset
- * @return {Boolean}
- */
- checkMouseoutByEvent: function(evt, offset){
- //...
- },
- /**
- * 触发事件
- */
- fireEvent: function(element, event){
- //...
- }
- });
除此之外,该项目中同时提供了 OOP-like 的语法糖,也可以这样定义某一个模块类:
- // ui/Dialog.js
-
- $_package('Meishi.ui',
- $_Class('Dialog',{
- $_extends: null,
- $_implements: 'IDialog',
- $_: function(type, content, title, iconType, showButton, okLabel, cancelLabel, size, modal)
- { //构造函数
- //*** private ***
- var _thisRef = this,
- _type = type + " " + size,
- _getHTML = function(){},
- _dispose = function(){};
- //*** public ***
- this.toString = function(){
- return '[class Meishi.ui.Dialog] classid:'+this.$_getClassId();
- };
- //显示对话框
- this.show = function(){};
- //关闭对话框
- this.close = function(){};
- //取得dom
- this.getDom = function(){};
- //取得尺寸
- this.getSize = function(){};
- //渲染到界面
- this.render = function(){};
- }
- })
- );
这样就能分门别类的构建不同的模块,并以目录结构分布存储;不过,当时 grunt 等前端领域的项目构建工具尚未成型,模块无法自动化的打包,需要手动对其进行处理。
当时 Node.js 也不太成熟,考虑到易用性等因素,在机器上简单部署 php 环境后,用其命令行模式完成这些磁盘读写工作:
build 时手动执行以下命令:
- php _do.php "_portal.lst" "portal"
第二个参数("_portal.lst")定义了打包时的模块清单:
- base
- class.class
- interfaces
- event.DataEvent
- event.EventDispatcher
- util.util
- util.EventUtil
- util.TimeUtil
- util.UIUtil
- util.DialogUtil
- util.Cookie
- ui.Fixeder
- ui.Modal
- ui.Dialog
- ui.DialogKeeper
- ui.DomWindow
- ui.GotoTop
- ui.SosoMapKeeper
- ui.Waterfall
- widget.SosoMap
- widget.CanvasDrawer
- widget.Gallary
- widget.MSelect
- placeholder
- suggest
- portalExt
负责最终构建的php:
- <?php
- //接收到的命令行参数
- $arg_cfgpath = $argv[1];
- $arg_basename = $argv[2];
- $arg_needonload = $argv[3];
-
- $out_folder = './';
- $out_name1 = $arg_basename;
- $s_list = explode("\r\n", file_get_contents( $arg_cfgpath ));
- $result = "";
-
- //添加 “jQuery(function(){” 等所需头部代码
- if ($arg_needonload == 'needonload')
- $result .= file_get_contents('meishi/_warpper_jqOnload_begin.js')."\r\n";
- $result .= file_get_contents('meishi/_warpper_begin.js');
-
- //根据白名单拼合主体代码
- foreach ($s_list as $path) {
- $path = explode(".", $path);
- array_unshift($path, "meishi");
- $path = implode("/", $path);
- $path = str_replace("/\/$/", "", $path).".js";
- $content = file_get_contents($path);
- $result .= "\r\n\r\n".$content;
- }
-
- // 添加 “}());” 等所需头部代码
- $result .= "\r\n\r\n".file_get_contents('meishi/_warpper_end.js');
- if ($arg_needonload == 'needonload')
- $result .= "\r\n".file_get_contents('meishi/_warpper_jqOnload_end.js');
-
- function _write($w_path, $w_cont){
- $opath = $out_folder."_meishi_".$w_path."_v0.2.js";
- $out = fopen($opath, "w");
- fwrite($out, $w_cont);
- fclose($out);
- system('move '.$opath.' ../');
- }
- _write($out_name1, $result); //写入磁盘
- ?>
打包后的文件类似如下结构:
可以留意,此处自动在头尾插入代码,已将内容包裹为一个函数的行为,也将是之后出现在 Node.js 和 Webpack 中最主要的自动处理手段之一。
同期的第一版会员卡,同样用这种代码组织方法,实现了 MVC 结构和基于 hash 的单页应用:
很明显,这样编写代码虽然较好的解决了模块分文件编写和私有变量的问题,但开发过程难免不太自然;无法明确指定模块间的互相依赖,只能靠全局变量手动维护。随着项目体量的增长,这种组织方式存在很大的局限性。
随着模块系统 RequireJS 的出现,纯 IIFE 模块化方案的问题第一次被较好的解决了。
接下来的例子展示了使用 RequireJS 的 define 函数定义 mathlib/sum.js ;define 是添加到全局作用域中的,而随后其回调的返回值会成为模块的公开接口。
- define(function() {
- function sum(...values) {
- return values.reduce((a, b) => a + b, 0)
- }
-
- return sum;
- })
如果有依赖,就增加一个数组参数,其顺序前后一致即可。
- define(['mathlib/sum'], function(sum) {
- return { sum }
- })
这样就定义好了一个库,并且能用 require 函数调用了。其处理依赖链的方式和 define 定义时如出一辙。
- require(['mathlib'], function(mathlib) {
- mathlib.sum(1, 2, 3)
- // <- 6
- })
在模块层面描述依赖的明确性,使得组件如何关联到应用中其他部分变得显而易见。这种明确性反过来又培育出更大程度的模块化;这在以前是无法做到的,因为难以跟踪依赖链。
伴随着业务的增长,后台系统的功能也在不断的增加;在项目的初期引入了 RequireJS,并对其进行了简单的应用:
值得注意的是,这里明确指定了 define() 的第一个参数,用于在打包后区分模块。
而在 grunt 中,只是简单的调用插件将所有 js 拼接在一起完成打包。
其项目典型的输出结构为:
- www/
- ├── .htaccess
- ├── index.html
- ├── assets/
- │ ├── css/
- │ │ └── all.css
- │ ├── js/
- │ │ └── all.js
- │ ├── fonts/
- │ ├── img/
- │ └── json/
- ├── includes/
- ├── tmpl/
- └── ...business_html/
和商家后台同期的会员卡项目,则基于 Backbone.js 实现了 MVC 结构,并对 RequireJS 进一步优化的使用:
由于此处采用了 RequireJS 官方的 r.js 来打包和优化文件,所以和商家后台中的用法不同的是,在模块定义中就不再需要明确指定第一个模块名称参数了。
其项目典型的输出结构为:
- public
- ├── .htaccess
- ├── index.php
- ├── index.html
- ├── css/
- │ └── style.css
- ├── img/
- ├── js/
- │ ├── main.js
- │ └── lib/
- └── test/
- ├── lib/
- └── spec/
RequireJS并非没有问题。比如,需要一个 RequireJS 函数、一个可能很冗长的依赖列表、一个可能有同样冗长参数的回调;所有这些只为实现“声明一个有依赖的模块”一件事,这使得其应用复杂化,其 API 也显得不是很直观。
AngularJS 中的依赖注入(DI - dependency injection)系统有着许多同样的问题。作为当时一个优雅的解决方案,依靠巧妙的字符串解析以避免依赖数组,使用函数参数名来处理依赖。但是这个机制和代码压缩工具不兼容,将导致参数被重新命名成单字符,从而破坏了依赖的注入。
在之后的 AngularJS v1 版本中,引入了一个 build task 来转换如下的代码:
- module.factory('calculator', function(mathlib) {
- // …
- })
会转换为下面这种格式的代码,因为包含了明确的依赖列表,就可以安全的使用压缩工具了。
- module.factory('calculator', ['mathlib', function(mathlib) {
- // …
- }])
在由 Node.js 催生的若干创新中,CommonJS 模块系统算得上一个,也被简称为 CJS。
利用 Node.js 程序可以访问文件系统的优势(在头尾自动包裹一层代码),在 CommonJS 中,每个文件都是拥有自己的作用域和上下文的单独模块。
- // node/lib/internal/bootstrap_node.js
-
- NativeModule.wrapper = [
- '(function (exports, require, module, __filename, __dirname) { ',
- '\n});'
- ];
-
- NativeModule.wrap = function(script) {
- return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
- };
使用一个异步的 require 函数来加载依赖项,并且可以在该模块生命周期中的任何时候动态调用,就像下面这样:
- const mathlib = require('./mathlib')
和 RequireJS 以及 AngularJS 很像的是,CommonJS 中的依赖也是靠路径名称实现的。主要的区别在于,不再需要样板函数和依赖数组什么的了,而是将模块的接口指派到一个绑定的变量中,或是在任何地方由 JS 表达式使用。
在 RequireJS 和 AngularJS 中,每个文件中可以包含若干个动态定义的模块,而 CommonJS 则限制了每个文件只能一个模块。同时,RequireJS 有多种声明模块的途径,而 AngularJS 则有不同种类的 factories、services、providers 等等 -- 以及幕后和其依赖注入机制紧密耦合的框架本身。
与前面提到的两者不同的是,CommonJS 更加严格,其描述模块的方式是唯一的。JS 文件皆模块,调用 require 就加载依赖,并且其暴露的接口就是指定给 module.exports 的东西。
Browserify 等工具的出现,为 CommonJS 模块和浏览器之间架起了桥梁。可以将无论多少个模块打包成一个浏览器适用的单独文件。而 CommonJS 的杀手级特性:npm 包注册器,为其统治模块加载生态系统起到了决定性作用。
可以说,不管是 grunt、gulp,还是现在的 webpack、rollup;正是 node.js 的出现支持了这些项目的产生,而 CJS 模块化保证了功能的合理分工和复用,使得前端真正进入了工程化的时代。
除了在上述打包/开发工具的配置文件中使用 nodejs 和 CJS,甚至直接基于 npm scripts 驱动一个前端项目也是可行的:
当 ES6 在 2015 年中标准化,加之在此很久之前就已经可以用 Babel 将 ES6 转换为 ES5 了,JS 模块化开发进入了新的时代。ES6 规范包括了一个 JS 原生的模块系统,一般被称为 ECMAScript Modules (ESM)。
ESM 深受 CJS 及其前辈的影响,提供了一个静态声明式 API,以及一个基于 promise 的动态可编程 API。如下所示:
- import mathlib from './mathlib'
-
- import('./mathlib').then(mathlib => {
- // …
- })
在 ESM 中,和 CJS 一样,每个文件都是拥有自己的作用域和上下文的单独模块。
在 Node.js v8.5.0 中,引入了 ESM 模块支持。大部分现代浏览器也已经支持。
作为 Browserify 的接班人,Webpack 主要接管了通用模块打包器的角色,这归功于其具备的大量新特性。正如 Babel 之于 ES6,Webpack 也一直支持着 ESM。其引入的“代码分割(code-splitting)”机制,更是凭借能将应用分为不同部分打包的能力提升了首次加载时的使用体验。
AMD 是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义",也就是前面提到过的由 RequireJS 定义的模块形式。
UMD 是 AMD 和 CommonJS 的糅合。
- (function (window, factory) {
- if (typeof exports === 'object') {
- module.exports = factory();
- } else if (typeof define === 'function' && define.amd) {
- define(factory);
- } else {
- window.eventUtil = factory();
- }
- })(this, function () {
- //module ...
- });
UMD 先判断是否支持 Node.js 的模块(exports)是否存在,存在则使用 Node.js 模块模式。 再判断是否支持 AMD(define是否存在),存在则使用 AMD 方式加载模块。
对于已经开发了一段时间的 RequireJS 项目,转化为 ES6 的 ESM 并不困难。
首先,将由 define() 定义的模块,转化为 import 形式。可以借由一些工具(比如 npm 上的amd-to-es6),此项工作是一次性的:
- npm install amd-to-es6 -g
-
- ...
-
- amdtoes6 --dir src --out src
-
- ...
-
- //转化前
- define(['path/to/a', 'path/to/b'], function (a, b) {
- return function (x) {
- return a(b(x));
- };
- });
-
- //转化后
- import a from 'path/to/a';
- import b from 'path/to/b';
-
- export default function (x) {
- return a(b(x));
- };
其次,在 grunt 的配置中,由 babel 处理 js 文件,注意使用了transform-es2015-modules-umd插件,将 ESM 转化为 UMD:
- babel: {
- options: {
- sourceMap: false,
- plugins: [
- 'transform-es2015-modules-umd'
- ],
- },
- js: {
- options: {
- presets: [
- 'es2015-loose'
- ],
- },
- files: [{
- expand: true,
- cwd: 'src/javascript/',
- src: [
- '**/*.js',
- '!lib/*.js'
- ],
- dest: DEBUG_BASE + 'js/'
- }]
- },
- jsx: {
- options: {
- presets: [
- 'es2015-loose',
- 'react'
- ],
- },
- files: [{
- expand: true,
- cwd: 'src/javascript/',
- src: ['**/*.jsx'],
- dest: DEBUG_BASE + 'js/',
- ext: '.js'
- }]
- }
- },
由于 Webpack 可以直接识别 AMD 和 CJS 等,配置时注意无需让 babel 再多转换一次就行了:
- //webpack.config.js
- {
- test: /\.jsx?$/,
- use: [ 'babel-loader' ],
- exclude: /node_modules/
- },
-
- ...
-
- //.babelrc
- {
- "presets": [
- ["es2015", {"modules": false}],
- "stage-1",
- "react"
- ],
- "plugins": [
- "transform-decorators-legacy"
- ]
- }
此类项目典型的输出结构为: