Javascript有一个特性叫做域。尽管对于初学者来说理解域是有难度的,但我会尽力用最简单的方式让你理解域。理解域能让你的代码更优秀,减少错误,及有助于你做出更强大的模式设计。
什么是域
域是在运行时,在特定的代码区域内,变量、函数及对象能相互访问的一个概念。换句话说,域决定了你代码的区域内变量和其它资源的可见性。
域?最小访问原则
限制变量的可见性而不是代码所有地方都可见的重点是什么?为什么这样做?一个益处是域提供了代码的层级安全。一个共识的计算机安全原则是用户仅能访问他们在这个时间段需要的东西。
想象一下公司的电脑管理员。他们有公司系统的很多权限,如果授权完全的权限给他们也是可以的。假如你有一个公司,公司里有三个管理员,他们都拥有系统完全权限,一切运行的很完美。突然,一些不好的事情发生了,你的一个系统被恶意病毒感染了。现在你不知道是谁的失误,你意识到给他们基础的权限,只授权他们需要的权限多么重要。这能帮助你追踪变化,追踪每个账号做了什么事情。这就是所谓的最小访问原则。凭直觉,这个原则同样适用于编程语言设计,包含Javascript的大部分编程语言都称作域。接下来我们将要学习它。
当你在你的编程的路上,你就能察觉域的部分能使代码看起来更有效率,易于追踪问题以及减少问题。域也很好的解决了命名问题,同样名称的变量可以定义在不同的域中。记住不要混淆了域和上下文的概念。他们是不同的特性。
Javascript域
Javascript中有两种不同类型的域:
- 全局域
- 本地域
定义在一个函数内的变量是本地域,而定义在函数外的在全局域中。每一个函数被调用就创建一个新的域。
全局域
当你在一个文档中开始写Javascript代码,你已经在全局域里了。只有一个全局域贯穿一个js文档。一个定义在函数外面的变量是在全局域中的。
// 域是默认全局的 var name = 'Hammad';
全局域中的变量,在其它域中,都能访问和修改它。
var name = 'Hammad'; console.log(name); // logs 'Hammad' function logName() { console.log(name); // 'name' is accessible here and everywhere else } logName(); // logs 'Hammad'
本地域
函数里的变量是在本地域中。并且每次调函数时都会产生一个不同的域。这就意味着拥有相同名称的变量可以在不同的函数中使用。这是因为那些变量都分别和各自的函数绑在了一起,不同域之间不能被访问到。
// Global Scope function someFunction() { // Local Scope #1 function someOtherFunction() { // Local Scope #2 } }// Global Scope function anotherFunction() { // Local Scope #3 }// Global Scope
块声明
跟函数不同,像if,switch条件语句及for,while循环语句的块声明不会产生新的域。块中定义的变量会留在它们所在的域中。
if (true) { // this 'if' conditional block doesn't create a new scope var name = 'Hammad'; // name is still in the global scope } console.log(name); // logs 'Hammad'
ECMAScript 6介绍了let和const关键字。这些关键字可以替代var。
var name = 'Hammad'; let likes = 'Coding'; const skills = 'Javascript and PHP';
跟var相反,let和const关键字支持在本地域中的块中声明。
if (true) { // this 'if' conditional block doesn't create a scope // name is in the global scope because of the 'var' keyword var name = 'Hammad'; // likes is in the local scope because of the 'let' keyword let likes = 'Coding'; // skills is in the local scope because of the 'const' keyword const skills = 'JavaScript and PHP'; } console.log(name); // logs 'Hammad' console.log(likes); // Uncaught ReferenceError: likes is not defined console.log(skills); // Uncaught ReferenceError: skills is not defined
全局域的生命周期和应用的周期是一致的。而本地域的周期在函数被调用 执行完后就结束了。
上下文
许多开发者经常混淆域和上下文的概念,经常认为它们是一个概念。域就是我们上面讨论的样子,而上下文指你代码里部分区域在this的值。域指变量的可见性,上下文指相同域中this的值。我们用函数方法也可以改变上下文,这个接下来我们会讨论到。全局域的上下文就是window对象。
// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…} console.log(this); function logFunction() { console.log(this); }// logs: Window {speechSynthesis: SpeechSynthesis, caches: CacheStorage, localStorage: Storage…} // because logFunction() is not a property of an object logFunction();
如果域在一个对象的方法中,上下文就是这个对象。
class User { logName() { console.log(this); } } (new User).logName(); // logs User {}
(new User).logName()是一个便捷的调用方式,你不用定义一个变量来存储对象来调用logName函数。
注意到没?上下文的值变的不同,如果你调用一个函数用了new关键字。调用函数时,上下文就是赋到一个实例上。一个函数被new关键字调用 的例子:
function logFunction() { console.log(this); } new logFunction(); // logs logFunction {}
当一个函数在严格模式下被调用 ,上下文默认是undefined。
执行上下文
注意:没有看过的同学可以参考以前发的文章《执行上下文与调用栈》
忘记所有疑惑和我们上面所学的,“执行上下文”中的“上下文”是指域,而非上面所说的“上下文”。听起来很怪,但这就是JS特性的一个命名惯例,我们无解啊。
Javascript是单线程语言,所以它在一个时间点只能执行一个单任务。其余的待执行任务在执行上下文中排队。正如我之前说的,当Javascript引擎开始编译你的代码, 这个上下文就被默认就是全局的。全局上下文被附加到你的执行上下文(域)中,这时的执行上下文(域)的确是第一个执行上下文(域)的上下文。
这之后,每个函数的调用都将把他们的上下文附加给执行上下文。一个函数调用另一个函数时,或者在其它地方调用时,也是这样的步骤。
注意:函数会创建自己的执行上下文。
一旦引擎在那个上下文中结束了代码的执行,那么该上下文会被从执行上下文中请出,这时执行上下文中的当前上下文就被转移给父上下文。引擎总是会先执行在执行栈顶端的执行上下文(这里是指你代码中最深层的域)。
注意:只会有一个全局上下文,但可以有无数个函数的上下文。
执行上下文的创建和代码执行有两个阶段。
创建阶段
第一个阶段是创建阶段,当函数被调用还没执行代码时。创建阶段发生了三件事情:
- 创建变量对象
- 域链的创建
- 上下文值的设置
变量对象
变量对象,也叫活动对象,包含所有像变量、函数、其它被定义在执行上下文一个分支的声明。当函数被调用 时,编译器会扫描它,包括它的参数,变量和其它声明。所有东西,当被打包给一个单对象,就变成了一个变量对象。
'variableObject': { // contains function arguments, inner variable and function declarations }
域链
在执行上下文的创建阶段,域链是在变量对象之后被创建。域自己本身包含变量对象。域链就是来解决变量问题的。当执行遇到一个变量时,Javascript总是从最底层的代码层级块中查找,如果没有,则继续跳到父域中去查找直到找到该变量,这仅是查找变量,查找其它资源也是这个套路。域链能被简单的定义为一个包含变量对象的对象,且拥有自己的执行上下文和其它父执行上下文,一个对象拥有一打其它对象。
'scopeChain': { // contains its own variable object and other variable objects of the parent execution contexts}
执行上下文对象
执行上下文能被表示为一个抽象的对象:
executionContextObject = { 'scopeChain': {}, // contains its own variableObject and other variableObject of the parent execution contexts 'variableObject': {}, // contains function arguments, inner variable and function declarations 'this': valueOfThis }
代码执行阶段
执行上下文的第二个阶段,是代码执行阶段,从值被分配,到代码最后被执行。
作用域
作用域是指,有一组嵌套函数,内部函数拥有访问父域变量的其它资源的权限。这就是说子函数的作用域被绑到了父的执行上下文上。作用域有时也被当作静态域。
function grandfather() { var name = 'Hammad'; // likes is not accessible here function parent() { // name is accessible here // likes is not accessible here function child() { // Innermost level of the scope chain // name is also accessible here var likes = 'Coding'; } } }
你注意到作用域是向前工作,上面代码中的name变量能被子的执行上下文访问。不过它不能反向提供给父级,意思是上面的变量likes不能被父函数访问。这也告诉我们变量在不同的执行上下文中若有同样的名字,获取它们的优先级是根据执行栈自上而下的。一个变量,和另一个变量有相似的名字,在最里面的函数(执行栈的最高上下文)有更高的优先级。
闭包
闭包的概念跟作用域很接近。当一个内部的函数试图访问外部函数在即时作用域中的变量,一个闭包就产生了。一个闭包包含两个自己的域链,父级的和全局域。
闭包既能访问外部函数的变量,又能访问外部函数的参数。
一个闭包即使在函数已经返回值的情况下依旧可以访问外部函数中的变量。这就允许返回的函数维持对外部函数的资源的权限。
当你调用一个函数时返回一个内函数,这个返回的函数在调用外部函数时不会被调用。你必须把调用函数的结果赋给一个变量,然后将该变量当作函数执行。看下例子:
function greet() { name = 'Hammad'; return function () { console.log('Hi ' + name); } } greet(); // nothing happens, no errors // the returned function from greet() gets saved in greetLetter greetLetter = greet(); // calling greetLetter calls the returned function from the greet() functiongreetLetter(); // logs 'Hi Hammad'
关键的信息是函数greetLetter能访问函数greet的变量,即使它已经返回了结果。执行返回函数greet的方法有定义的变量后面加括号(),或者函数本身加双括号()():
function greet() { name = 'Hammad'; return function () { console.log('Hi ' + name); } } greet()(); // logs 'Hi Hammad'
公共域和私有域
在许多其它编程语言中,你能通过设置public, private和protected属性给类的方法来标识哪种级别的域可以访问。如下面PHP的例子:
// Public Scope public $property; public function method() { // ...} // Private Sccpe private $property; private function method() { // ...} // Protected Scope protected $property; protected function method() { // ...}
在全局域上封装函数是易受攻击的。但在Javascript中没有public,private等关键字来标识域。然而,我们可以用闭包来仿造这种特性。把所有东西与全局域分开,我们必须用下面的方式来封装我们的函数:
(function () { // 私人领地 })();
后面的括号是函数告诉编译器,请立即执行我。我们可以添加函数和变量在里面,他们在外部是没法被访问的。万一我们想在外面访问怎么办?这就要标明它们哪些部分是私有的,哪些部分是公用的。闭包的一个类型,我们就用到了,叫做模块模式,它允许我们在一个对象中定义公有和私有域。
模块模式
看起来如下:
var Module = (function() { function privateMethod() { // do something } return { publicMethod: function() { // can call privateMethod(); } }; })();
返回的模块声明包含我们的公共函数。私有函数不会返回。没有返回的函数在模块命名空间外是访问不到的。不过我们的公共函数就能访问私有函数,这就很便捷了,对于帮助函数,Ajax调用和其它东西。
Module.publicMethod(); // works Module.privateMethod(); // Uncaught ReferenceError: privateMethod is not defined
共识的写法是私有函数名字前面都加上下划线,公用函数放在一个匿名对象中返回。这使得一个长的对象容易维护。像下面这样写:
var Module = (function () { function _privateMethod() { // do something } function publicMethod() { // do something } return { publicMethod: publicMethod, } })();
即执行函数表达式(IIFE)
闭包的另一种类型是即执行函数。这是个在window对象的上下文中自我调用的匿名函数,this值赋给window。这就暴露出一个单全局接口。如下:
(function(window) { // do anything })(this);
用.call(),.apply()和.bind()方法改变上下文
调用函数时,Call和Apply函数用来改变上下文。这特性赋予你难以置信的编程能力。用call和apply函数,你只需用该函数代替函数的括号,然后将上下文作为参数传进去。函数自身的参数可以放在context参数后面传过去。
function hello() { // do something... } hello(); // the way you usually call it hello.call(context); // here you can pass the context(value of this) as the first argument hello.apply(context); // here you can pass the context(value of this) as the first argument
.call()和.apply()的不同点在于前者传的参数是直接逗号隔开,后者允许传一个参数数组。
function introduce(name, interest) { console.log('Hi! I\'m '+ name +' and I like '+ interest +'.'); console.log('The value of this is '+ this +'.') } introduce('Hammad', 'Coding'); // the way you usually call it introduce.call(window, 'Batman', 'to save Gotham'); // pass the arguments one by one after the contextt introduce.apply('Hi', ['Bruce Wayne', 'businesses']); // pass the arguments in an array after the context // Output:// Hi! I'm Hammad and I like Coding. // The value of this is [object Window]. // Hi! I'm Batman and I like to save Gotham. // The value of this is [object Window]. // Hi! I'm Bruce Wayne and I like businesses. // The value of this is Hi.
注意:Call比Apply轻,运行的快。
下面的是一个列表文档,然后在控制台一项一项的日志打出来:
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <title>Things to learn</title></head><body> <h1>Things to Learn to Rule the World</h1> <ul> <li>Learn PHP</li> <li>Learn Laravel</li> <li>Learn JavaScript</li> <li>Learn VueJS</li> <li>Learn CLI</li> <li>Learn Git</li> <li>Learn Astral Projection</li> </ul> <script> // Saves a NodeList of all list items on the page in listItems var listItems = document.querySelectorAll('ul li'); // Loops through each of the Node in the listItems NodeList and logs its content for (var i = 0; i < listItems.length; i++) { (function () { console.log(this.innerHTML); }).call(listItems[i]); } // Output logs: // Learn PHP // Learn Laravel // Learn JavaScript // Learn VueJS // Learn CLI // Learn Git // Learn Astral Projection </script> </body> </html>
HTML只包含一个列表项。Javascript选取出他们的dom并循环,在循环内部,我们把列表项里的内容在控制台打印出来。
日志打印的方法被包在一个括号中,然后调用call函数。把响应的列表项作为参数给call以保证打印日志的方法在dom的正确对象上执行。
对象可以有方法,同时函数作为对象的一种也可以有方法。实际上,一个Javascript函数创建时有三个内置方法:
- Function.prototype.apply()
- Function.prototype.bind() (Introduced in ECMAScript 5 (ES5))
- Function.prototype.call()
- Function.prototype.toString()
注意:Function.prototype.toString()返回函数源码的字符
目前,我们讨论了.call(),.appy()和toString()。不同于call和apply,bind不会自己调用函数,它仅是用来绑定上下文的值和其它参数的,这发生在调用函数之前。用bind的例子如下:
(function introduce(name, interest) { console.log('Hi! I\'m '+ name +' and I like '+ interest +'.'); console.log('The value of this is '+ this +'.') }).bind(window, 'Hammad', 'Cosmology')(); // logs: // Hi! I'm Hammad and I like Cosmology. // The value of this is [object Window].
bind更像call,传的参数一样,一个接着一个用逗号隔开。
总结
这些概念是Javascript基础的知识,理解它们也很重要,如果你想接触一些高端的话题。希望你能更好的理解Javascript域及其周边的知识。
注:本文章摘自开发爱好者Hammad Ahmed,由码上打卡【公众号(mashangdaka)】团队编译,如有错误,请联系修改。