JavaScript执行环境在很多方面都有其独特之处. 全局变量和函数便是其中之一. 事实上, js的初始执行环境是由多种多样的全局变量所定义的, 这写全局变量在脚本环境创建之初就已经存在了. 我们说这些都是挂载在'全局对象'(global object) 上的, '全局对象'是一个神秘的对象, 它表示了脚本最外层上下文.
在浏览器中, window对象往往重载并等同于全局对象, 因此任何的全局作用域中声明的变量和函数都是window对象的属性:
var color = 'red';function sayColor() { alert(color);}console.log(window.color); // 'red'console.log(typeof window.sayColor); // 'function'
这段代码中定义了全局变量color和全局函数sayColor(), 两者都是window对象的属性, 尽管我们并没有显示的执行给window对象挂载属性的操作.
6.1 全局变量带来的问题
创建全局变量被认为是最糟糕的实践, 尤其是在团队开发的大背景下更是问题多多. 随着代码量的增长, 全局变量就会导致一些非常重要的可维护性难题. 全局变量越多, 引入错误的概率将会因此变得越来越高.
6.1.1 命名冲突
脚本中的全局变脸和全局函数越来越多时, 发生命名冲突的概率也随之增高, 很可能无意间就使用了一个已经声明了的变量. 所有的变量都被定义为全局变量, 这样的代码才是最容易维护的.6.1.2 代码的脆弱性
一个依赖于全局变量的函数即是深耦合于上下文环境之中. 如果环境发生改变, 函数很可能就失效了. 在上一个例子中, 如果全局变脸color不再存在, sayColor()的方法将会报错. 这意味着任何对全局环境的修改都可能在成某处代码出错. 同样, 任何函数也会不经意间修改全局变量. 导致对全局变量值的依赖变得不稳定. 在上个例子中, 如果color被当做参数传入, 代码可维护性会变得更佳.var color = 'red';function sayColor() { alert(color); // 不好的做法用全局变量}function sayColor(color) { alert(color); // 好的做法}
修改后这个函数不再依赖于全局变量, 因此任何对全局环境的修改都不会影响到它. 由于color是一个参数. 唯一值得注意的是传入函数的值得合法性. 其他修改都不会对这个函数完成它本身的任务有任何影响.
当定义函数的时候, 最好尽可能多地将数据至于局部作用域内. 在函数内定义的任何都应当采用这种写法. 任何来自函数外部的数据都应当以参数形式传进来. 这样做可以将函数和其外部环境隔离开来. 并且你的修改不会对程序其他部分造成影响.
6.1.3 难以测试
尝试着在一个大型web应用中实施一些单元测试. 在我即将完成核心框架的搭建时, 我加入了一个团队, 此后我努力让我的代码变得易于理解以便后续为其执行测试. 我非常吃惊的发现, 执行测试编程一项积极困难的工序, 因为整个框架要依赖于一些全局变量才会正常工作.任何依赖全局变量才能正常工作的函数, 只有为其重新创建完整的全局环境才能正确的测试它. 事实上, 这意味着你除了要管理全局环境的修改, 你还要在两个全局环境中管理它们: 生产环境和测试环境. 保持两者的同步是很消耗成本的, 很快你就会发现(代码)可维护性的噩梦才刚刚开始. 越到后来越难于理清头绪.
确保你的函数不会对全局变量有依赖, 这将增强你的代码的可测试性(testability). 当然你的函数可能会依赖原生的js全局对象, 比如Date、Array等. 他们是全局环境的一部分, 适合js引擎相关的, 你的函数总是会用到这些全局对象. 总之, 为了保证你的代码具有最佳的可测试性, 不要让函数对全局变量有依赖.
6.2 意外的全局变量
js中有不少陷阱, 其中有一个就是不小心会创建全局变量. 当你给一个未被var的语句证明过的变量赋值时, js就会自动创建爱你一个全局变量, 比如:function doSomeThing() { var count = 10; title = '编写可维护的js'; // 不好的写法: 创建了全局变量}
6.3 单全局变量方式
依赖尽可能少的全局变量, 即只创建一个全局变量.单全局变量模式已经在各种流行的类库中广泛使用了.- jQuery定义了两个全局对象, $和jQuery. 只有在$被其他的类库使用了的情况下, 为了避免冲突, 应当使用jQuery.
- vue实例化的对象 自己定义的vm等
'单全局变量'的意思是所创建的这个唯一的全局对象名是独一无二的(不会和内置API冲突), 并将你的所有的功能代码都挂载到这个全局对象上. 因此每个可能的全局变量都成为你唯一的全局对象的属性, 从而不会创建多个全局变量. 比如, 假设我想让一个对象表示本书的一章, 代码看起来会像下面这样.
function Book(title) { this.title = title; this.page = 1;}Book.prototype.turnPage = function(direction) { this.page += direction;};var Chapter1 = new Book('第一章');var Chapter2 = new Book('第二章');var Chapter3 = new Book('第三章');
这段代码创建了4个全局对象: Book、Chapter1、Chapter2、Chapter3. 单全局变量模式则只会创建一个全局对象并将这些对象都赋值为它的属性.
var MaintainableJS = {};MaintainableJS.Book = function(title) { this.title = title; this.page = 1;}MaintainableJS.Book.prototype.turnPage = function(direction) { this.page += direction;};MaintainableJS.Chapter1 = new MaintainableJS.Book('第一章');MaintainableJS.Chapter2 = new MaintainableJS.Book('第二章');MaintainableJS.Chapter3 = new MaintainableJS.Book('第三章');
这段代码只有一个全局对象, 即MaintainableJS, 其他任何信息都挂载到这个对象上. 因为团队中每个人度知道这个全局对下是哪个, 因此很容易做到继续为它添加属性以避免全局污染.
6.3.1 命名空间
即使你的代码只有一个全局对象, 也可能污染全局. 大多数使用单全局变量模式的项目同样包含'命名空间'的概念. 命名空间是简单的通过全局对象的单一属性表示的功能性分组. 比如, YUI就是已超命名空间的思路来管理代码的. Y.DOM下的所有的方法都是和DOM操作相关的, Y.Event下所有的方法是和时间相关的.将功能按照命名空间进行分组, 可以让你的全局对象变得井然有序, 同事可以让团队成员能够知晓性性能应该属于哪个部分, 或者知道去哪里查找已有的功能. 当作者在YaHoo!工作的时候, 就有一个不成文的约定, 即每个站点杜江自己的命名空间挂载至Y对象上, 因此"My YaHoo!" 使用Y.My, 邮箱使用Y.Mail, 等等. 这样团队成员则可以放心大胆的使用其他人的代码, 而不必担心冲突.在js中你可以使用对象来轻而易举的创建你自己的命名空间, 比如:
var ZakasBooks = {};// 表示这本书的命名空间ZakasBooks.MaintainableJs = {};// 表示另一本书的命名空间ZakasBooks.HighPerformanceJs = {};
一个常见的约定在每一个文件中都通过创建新的全局对象来声明自己的命名空间. 在这种情况下, 上面的这个例子给出的方法是够用的.
同样有另外一个常见, 每个文件都是需要给一个命名空间挂载东西. 这种情况下, 你需要首先保证这个命名空间是已经存在的. 这是全局对象非破坏性的处理命名空间的方式则变得非常有用, 成成这项操作的基本模式是像下面这样的.
var YourGlobal = { namespace: function(ns){ var parts = ns.split('.'), object = this, i, len; for(i = 0, len = parts.length; i < len; i++) { if(!object[parts[i]]){ object[parts[i]] = {}; } object = object[parts[i]]; } return object; }}
变量YourGlobal 实际上可以表示任意名字. 最重要的部分在于namespace()方法, 我们给这个方法传入一个表示命名空间对象的字符串, 它会非破坏性的(nondestructively) 创建这个命名空间, 基本一用法如下.
/* * 同时创建YourGlobal.Books和YourGlobal.Books.MaintainableJs * 因为之前没有创建过它们, 因此每个都是全新创建的 */YourGlobal.namespace('book.MaintainableJs');// 现在你可以使用这个命名空间YourGlobal.Books,MaintainableJs.author = 'Nicholas C. Zakas';/* * 不会操作YourGlobal.Books本身, 同时会给它添加HighPerformanceJs * 它会保持YourGlobal.Books.MaintainableJs原封不动 */YourGlobal.namespace('Books.HighPerformanceJs');// 仍然是合法的引用console.log(YourGlobal.Books.MaintainableJs.author);// 你同样可以在方法调用之后立即给它添加新属性YourGlobal.namespace('Books').ANewBook = {};
基于你的单全局对象使用namespace()方法可以让开发者放心地认为命名空间总是存在的. 这样, 每个文件度可以首先调用namespace()来声明开发者将要使用的命名空间, 这样做不会对已有的命名空间造成任何破坏. 这个方法可以让开发者解放出来, 在使用命名空间之前不必再去判断它是否存在.
由于你的代码不是独立存在的, 因此要围绕命名空间定义一些约定. 是否应该以首字母大写的形式来定义命名空间, 就像YUI? 还是都用小写字母形式来定义命名空间, 就像Dojo? 这个是个人喜好问题, 但是首先定义这些约定可以让后续团队成员在使用但全局变量时更加高效.
6.3.2 模块
另一种基于单全局变量的扩充方法是使用模块(modules). 模块是一种通用的功能片段, 它并没有创建新的全局变量或者命名空间. 相反, 所有的这些代码都存放于一个表示执行一个任务或发布一个接口的但函数中. 可以用一个名称来表示这个模块, 同样这个模块可以依赖其它模块.js本身不包含模块概念, 自然也没有模块语法(es6支持 import export), 单的确有一些通用的模式来创建模块. 两种最流行的类型是'YUI模块'模式和'异步模块定义'(Asynchronous Module Definition, 简称AMD)模式.
YUI模块
从字面含义理解, YUI模块就是使用YUI Js类库来创建新模块的一种模式. YUI3中包含了模块的概念, 写法如下.YUI.add('module-name', function(Y) { // 模块正文 }, 'version', { requires: [ 'dependecy1', 'dependency2' ] });
我们通过调用YUI.add()并给它传入模块名字、待执行的函数(被称作工厂方法)和可选的依赖列表来添加YUI模块. '模块正文'处则是你写所有的模块代码的地方. 参数Y是YUI的一个实例, 这个实例包含所有以来的模块提供的内容. YUI中约定在每一个模块内使用命名空间的方式来管理模块代码, 比如:
YUI.add('my-books', function(Y) { // 添加一个命名空间 Y.namespace('Books.MaintainableJs'); Y.Books.MaintainableJs.author = 'Nicholas C. Zakas';}, '1.0.0', { requires: [ 'dependecy1', 'dependency2' ] });
同样, 依赖也是以Y对象命名空间的形式传入进来. 因此YUI实际上是将命名空间和模块的概念合并在了一起, 总体上提供一种灵活的解决方案.
通过调用YUI().use()函数并传入想加载的模块名称来使用你的模块.
YUI.use('my-books', 'another-modult', function(Y) { console.log(Y.Books.MaintainableJs.author); })
这段代码以加载名叫'my-books'和'another-module'的两个模块开始, YUI会确保这些模块的依赖都会完全加载完成, 然后执行模块的正文代码, 最后才会执行行传入YUI().use()的回调函数. 回调函数会带回Y对象, Y对象里包含了加载模块对它做的修改, 这时你的应用代码就可以放心的执行了.
'异步模块定义'(AMD)
AMD模块和YUI模块有诸多相似之处. 你指定模块名称、依赖和一个工厂方法, 依赖加载完成后执行这个工厂方法. 这些内容全部作为参数传入一个全局函数define()中, 其中第一个参数是模块名称, 然后是依赖列表, 最后是工厂方法. AMD模块和YUI模块最大的不同在于, (AMD中) 每一个依赖都会对应到独立的参数传入工厂方法里, 比如:define('module-name', [ 'dependency1', 'dependency2' ], function(dependency1, dependency2) { //模块正文})
因此, 每个被命名的依赖最后都会创建一个对象, 这个对象会被带入工程方法中. AMD以这种方式来尝试避免命名冲突, 因为直接在模块中使用命名空间有可能发生命名冲突. 和YUI模块中创建新的命名空间的方法不同, AMD模块则期望从工厂方法中返回它们的公有接口, 比如:
define('my-books', [ 'dependency1', 'dependency2' ], function(dependency1, dependency2) { var Books = {}; Books.MaintainableJs = { author: 'Nicholas C. Zakas' } return Books;})
AMD模块同样可以是匿名的, 完全省略模块名称. 因为模块加载器可以将js文件名当做模块名称. 所以如果你有一个名叫my-books.js的文件, 你的模块可以只通过模块加载器来加载, 你可以像这样定义你的模块.
define([ 'dependency1', 'dependency2' ], function(dependency1, dependency2) { var Books = {}; Books.MaintainableJs = { author: 'Nicholas C. Zakas' } return Books;})
想要使用AMD模块, 你需要使用一个与之兼容的模块加载器. Dojo的标准模块加载器支持AMD模块的加载, 因此你可以向下面这样来加载'my-books' 模块.
// 使用Dojo加载AMD模块var books = dojo.require('my-books');console.log(books.MaintainableJs.author);
Dojo同样将自己的也封装为AMD模块, 叫做'dojo', 因此它也可以被其他AMD模块加载.
另一个模块加载器是Require.js. RequireJS添加了另一个全局函数require(), 专门用来加载指定的依赖和执行回调函数, 比如:
// 使用RequireJS加载AMD模块require(['my-books'], function(books) { console.log(books.MaintainableJs.author); })
调用require()时首先会立即加载依赖, 这些依赖都加载完成后会立即执行回调函数.
RequireJS模块加载器包含很多内置逻辑来让模块的加载更加方便, 包括名字到目录的对应表以及多语种选项.
6.4 零全局变量
你的Js代码注入到页面时可以做到不用创建全局变量的. 这种方法应用场景不多, 因此只有在某些特殊场景下才会有用. 最常见的情形就是一段不会被其他脚本访问到的完全独立的脚本. 之所以存在这种情形, 是因为所有所需要的脚本都会合并到一个文件, 或者因为这段非常短小且不提供任何借口的代码会被插入至一个页面中. 最常见的用法是创建一个书签.书签是独立的, 它们并不知晓页面中包含什么且不需要页面知道它的存在. 最终我们需要一段'零全局变量'的脚本嵌入到页面中, 实现方法就是使用一个立即执行的函数调用并将所有的脚本放置其中, 比如:
(function(win) { var doc = win.document; // 这里定义其他的变量 // 其他相关代码})(window);
这段立即执行的代码传入了window对象, 因此这段代码不需要直接引用任何全局变量. 在这函数内部, 变量doc是指向document对象的引用, 只要函数代码中没有直接修改window或doc且所有变量都是用var关键字来定义, 这顿啊脚本则可以注入到页面中而不会产生任何全局变量. 之后你可以通过将函数设置为严格模式(strict mode)来避免创建全局变量.
(function(win) { 'use strict' var doc = win.document; // 这里定义其他的变量 // 其他相关代码})(window);
这个函数包装器(function wrapper) 可以用于任何不需要创建全局对象的场景. 正如上文提到的, 这种模式的使用场景有限. 只要你的代码需要被其他的代码所依赖, 就不能使用这种零全局变量的方式. 如果你的代码需要在运行时被不断扩展或修改也不能使用零全局变量的方式. 但是, 如果你的脚本非常短, 且不需要和其他代码产生交互, 可以考虑使用零全局变量的方式来实现代码.