目录
- 作用域链 (内含执行上下文)
- 闭包(内含使用场景 7 个)
- 原型链
一 作用域链
什么是作用域(Scope)
作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。
作用域链分为
- 全局作用域
- 局部作用域(包含函数作用域与块级作用域)
作用域就是一个独立的地盘,让变量不会外泄、暴露出去。也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。
ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数作用域。ES6的到来,为我们提供了‘块级作用域’,可通过新增命令let和const来体现。
全局作用域
最外层函数和在最外层函数外面定义的变量拥有全局作用域。- 所有末定义直接赋值的变量自动声明为拥有全局作用域
- 所有window对象的属性拥有全局作用域
一般情况下,window对象的内置属性都拥有全局作用域,例如window.name、window.location、window.top等等。
全局作用域有个弊端:
如果我们写了很多行 JS 代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。这样就会 污染全局命名空间, 容易引起命名冲突。
这就是为何 jQuery、Zepto 等库的源码,所有的代码都会放在
(function(){....})()中。因为放在里面的所有变量,都不会被外泄和暴露,不会污染到外面,不会对其他的库或者 JS 脚本造成影响。这是函数作用域的一个体现。
局部作用域(包含函数作用域与块级作用域)
函数作用域
是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部。
值得注意的是:块语句(大括号“{}”中间的语句),如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。在块语句中定义的变量将保留在它们已经存在的作用域中。
if (true) {
// 'if' 条件语句块不会创建一个新的作用域
var name = 'a'; // name 依然在全局作用域中
}
console.log(name); // logs 'a'
JS 的初学者经常需要花点时间才能习惯
变量提升,而如果不理解这种特有行为,就可能导致 bug 。正因为如此, ES6 引入了块级作用域,让变量的生命周期更加可控。
块级作用域
块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:
- 在一个函数内部
- 在一个代码块(由一对花括号包裹)内部
let 声明的语法与 var 的语法一致。你基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:
声明变量不会提升到代码块顶部禁止重复声明循环中的绑定块作用域的妙用
let/const 声明并不会被提升到当前代码块的顶部,因此你需要手动将 let/const 声明放置到顶部,以便让变量在整个代码块内部可用。
作用域链
作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行
什么是作用域链
变量的查找,当前作用域没有,就去父级找,如果父级也没呢?再一层一层向上寻找,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就是
作用域链。
关于自由变量的取值
在fn函数中,取自由变量x的值时,要到哪个作用域中取?——要到创建fn函数的那个作用域中取,无论fn函数将在哪里调用。
所以,不要在用以上说法了。相比而言,用这句话描述会更加贴切:要到
创建这个函数的那个域”。 作用域中取值,这里强调的是“创建”,而不是“调用” ,切记切记——其实这就是所谓的"静态作用域"
var a = 10
function fn() {
var b = 20
function bar() {
console.log(a + b) //30
}
return bar
}
var x = fn();
var b = 200;
x();// 30 // look here
bar(); // bar is not defined
作用域 与执行上下文
许多开发人员经常混淆作用域和执行上下文的概念,误认为它们是相同的概念,但事实并非如此。
我们知道JavaScript属于解释型语言,JavaScript的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样:
解释阶段:
- 词法分析
- 语法分析
作用域规则确定
执行阶段:
创建执行上下文- 执行函数代码
- 垃圾回收
JavaScript
解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是this的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。
作用域和执行上下文之间最大的区别是: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变。
一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。
二 闭包
背景:堆栈内存释放
- 堆内存:存储引用类型值,对象类型就是键值对,函数就是代码字符串。
- 堆内存释放:将引用类型的空间地址变量赋值成
null,或没有变量占用堆内存了浏览器就会释放掉这个地址 - 栈内存:提供代码执行的环境和存储基本类型值。
- 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。
但栈内存的释放也有特殊情况:① 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。② 全局下的栈内存只有页面被关闭的时候才会被释放
是什么?
在
JS 忍者秘籍中对闭包的定义:闭包允许函数访问并操作函数外部的变量。红宝书上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数。MDN对闭包的定义为:闭包是指那些能够访问自由变量的函数。这里的自由变量是外部函数作用域中的变量。
概述上面的话,
闭包是突破了作用域,有权访问另一个函数作用户域变量的函数。
一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)
形成闭包的原因
内部的函数存在外部作用域的引用就会导致闭包。
var a = 0
function foo(){
var b =14
function fo(){
console.log(a, b)
}
fo()
}
foo()
闭包经典使用场景
-
return返回一个函数
var n = 10
function fn(){
var n =20
function f() {
n++;
console.log(n)
}
return f
}
var x = fn()
x() // 21
这里的 return
f,f()就是一个闭包,存在上级作用域的引用。
-
函数作为参数
var a = '1'
function foo(){
var a = 'foo'
function fo(){
console.log(a)
}
return fo
}
function f(p){
var a = 'f'
p()
}
f(foo())
// 输出 foo
使用 return
fo返回回来,fo()就是闭包,f(foo())执行的参数就是函数fo,因为fo() 中的 a的上级作用域就是函数foo(),所以输出就是foo
-
IIFE(自执行函数)
var n = 1;
(function p(){
console.log(n)
})()
// 输出 1
同样也是产生了闭包
p(),存在window下的引用n。
-
循环赋值
for(var i = 0; i<10; i++){
(function(j){
setTimeout(function(){
console.log(j)
}, 1000)
})(i)
}
因为存在闭包的原因上面能依次输出1~10,闭包形成了10个互不干扰的私有作用域。将外层的自执行函数去掉后就不存在外部作用域的引用了,输出的结果就是连续的 10。为什么会连续输出10,因为 JS 是单线程的遇到异步的代码不会先执行(会入栈),等到同步的代码执行完
i++到 10时,异步代码才开始执行此时的i=10输出的都是 10。
-
使用回调函数`就是在`使用闭包
window.name = 'a'
setTimeout(function timeHandler(){
console.log(window.name);
}, 100)
-
节流防抖
// 节流
function throttle(fn, timeout) {
let timer = null
return function (...arg) {
if(timer) return
timer = setTimeout(() => {
fn.apply(this, arg)
timer = null
}, timeout)
}
}
// 防抖
function debounce(fn, timeout){
let timer = null
return function(...arg){
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, arg)
}, timeout)
}
}
-
柯里化实现
function curry(fn, len = fn.length) {
return _curry(fn, len)
}
function _curry(fn, len, ...arg) {
return function (...params) {
let _arg = [...arg, ...params]
if (_arg.length >= len) {
return fn.apply(this, _arg)
} else {
return _curry.call(this, fn, len, ..._arg)
}
}
}
let fn = curry(function (a, b, c, d, e) {
console.log(a + b + c + d + e)
})
fn(1, 2, 3, 4, 5) // 15
fn(1, 2)(3, 4, 5)
fn(1, 2)(3)(4)(5)
fn(1)(2)(3)(4)(5)
闭包变量存储的位置
闭包中的变量存储的位置是
堆内存。
- 假如闭包中的变量存储在
栈内存中,那么栈的回收会把处于栈顶的变量自动回收。所以闭包中的变量如果处于栈中那么变量被销毁后,闭包中的变量就没有了。所以闭包引用的变量是出于堆内存中的。
闭包的作用
保护函数的私有变量不受外部的干扰。形成不销毁的栈内存。保存,把一些函数内的值保存下来。闭包可以实现方法和属性的私有化。
使用闭包需要注意什么
容易导致内存泄漏。闭包会携带包含其它的函数作用域,因此会比其他函数占用更多的内存。过度使用闭包会导致内存占用过多,所以要谨慎使用闭包。不正当地使用闭包可能会造成内存泄漏。
先看以下两个例子:
function fn1(){
let test = new Array(1000).fill('a')
return function(){
console.log('A')
}
}
let fn1Child = fn1()
fn1Child()
上例是一个闭包,但是它造成内存泄漏了吗?并没有,因为返回的函数中并没有对 fn1 函数内部的引用,也就是说,函数 fn1 内部的 test 变量完全是可以被回收的,那我们再来看:
function fn2(){
let test = new Array(1000).fill('a')
return function(){
console.log(test)
return test
}
}
let fn2Child = fn2()
fn2Child()
显然它也是闭包,并且因为
return的函数中存在函数fn2中的test变量引用,所以test并不会被回收,也就造成了内存泄漏。
那么怎样解决呢?
其实在函数调用后,把
外部的引用关系置空就好了,如下:
function fn2(){
let test = new Array(1000).fill('a')
return function(){
console.log(test)
return test
}
}
let fn2Child = fn2()
fn2Child()
fn2Child = null // 置空
综上,我们以后就不要再说闭包会造成内存泄漏啦!
应该说成:不正当地使用闭包可能会造成内存泄漏。
经典面试题
- for 循环和闭包(号称必刷题)
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]()
/* 输出
3
3
3
/
这里的
i是全局下的i,共用一个作用域,当函数被执行的时候这时的i=3,导致输出的都是3。
- 使用
闭包改善上面的写法达到预期效果, 写法1:自执行函数和闭包
var data = [];
for (var i = 0; i < 3; i++) {
(function(j){
setTimeout( data[j] = function () {
console.log(j);
}, 0)
})(i)
}
data[0]();
data[1]();
data[2]()
let 具有块级作用域,形成的3个私有作用域都是互不干扰的。
写法2:使用 let
var data = [];
for (let i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]()
三 原型链
引用类型的四个规则:
引用类型:Object、Array、Function、Date、RegExp。
-
引用类型,都具有对象特性,即可
自由扩展属性。 -
引用类型,都有一个隐式原型
__proto__属性,属性值是一个普通的对象。 -
引用类型,
隐式原型__proto__的属性值指向它的构造函数的显式原型prototype属性值。 -
当你试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么它会去它的隐式原型
__proto__(也就是它的构造函数的显式原型prototype)中寻找。
instanceof
instanceof 运算符用于测试构造函数的 prototype 属性是否出现在对象原型链中的任何位置。 instanceof 的简易手写版,如下所示:
// 变量R的原型 存在于 变量L的原型链上
function instance_of (L, R) {
// 验证如果为基本数据类型,就直接返回 false
const baseType = ['string', 'number', 'boolean', 'undefined', 'symbol']
if(baseType.includes(typeof(L))) { return false }
let RP = R.prototype; // 取 R 的显示原型
L = L.__proto__; // 取 L 的隐式原型
while (true) {
if (L === null) { // 找到最顶层
return false;
}
if (L === RP) { // 严格相等
return true;
}
L = L.__proto__; // 没找到继续向上一层原型链查找
}
}
四 为什么做「 JS 基础回顾 」
写作是一个
回顾的过程,尝试写这个系列也主要是为了巩固JavaScript 基础,并查漏补缺,更加深入理解。如果有错误或者不严谨的地方,请务必给予指正,十分感谢! 整个系列会持续更新,不会完结。
参考
总结
作用域和执行上下文之间最大的区别是: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变。执行上下文最明显的就是this的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)闭包突破作用域,有权访问另一个函数作用户域变量的函数。不正当地使用闭包可能会造成内存泄漏(变量不会被释放回收)原型链就是一个过程,原型是原型链这个过程中的一个单位,贯穿整个原型链。