欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

JavaScript 函数高级特性

程序员文章站 2022-05-08 16:01:38
...

函数进阶:重在理解this

一:隐式参数

隐式参数在函数声明中没有明确定义,在函数调用时也没有显示传入,但会默认传递给函数并且可以在函数内正常访问,同时在函数内可以像其他明确定义的参数一样引用它们。

1.实参容器arguments

arguments是一个实参容器,它会接收函数调用时传入的所有实参。但要注意的是,它只是一个类数组对象,并不像rest剩余参数一样是Array的实例对象,所以它不能访问Array原型上的方法,但它有length属性并且实现了迭代器接口。

function sum(){
	let count = 0;
	console.log(arguments.length);// 有length属性
	for(i of arguments){// 因为实现了迭代器接口,所以可以使用for of
		count += i
	}
	console.log(count)
}
sum(1,2,3,4,5)
123456789
  • arguments的元素可以作为对应形参的别名,拥有对该形参的读写能力,但是在严格模式下被禁用,此文不做更多讨论。

2.指向函数上下文的this

this参数代表函数调用相关联的对象,通常也称之为函数上下文。在Java中,this通常指向定义当前方法的类的实例,但是在JavaScript中this参数不仅由该函数定义的方式和位置决定,同时还严重受到函数调用方式的影响,接下来即讨论函数的调用方式对函数上下文的影响。

二:函数调用方式对函数上下文的影响

方式1:作为普通函数调用:this指向undefined(严格模式下)

"use strict";  // 不在严格模式下,下述console则会输出window
function fn(){
	return this
}
console.log(fn()) // 输出undefined
12345

方式2:作为对象方法调用:this指向调用者

const obj ={
	fn: function(){
		return this
	}
}
console.log(obj.fn()) // 输出obj
123456

方式3:作为构造函数调用:this指向将作为实例的新对象(通常情况下)

与普通函数调用相比,构造函数调用的区别在于使用了关键字new,它会触发以下几个动作
  • 1.创建一个原型指向对应构造函数原型的空对象
  • 2.该对象作为this参数传递给构造函数,从而成为构造函数的函数上下文
  • 3.新构造的对象作为new运算符的返回值(通常情况下)
如下示例
let that = null;
function Person(name){
	console.log(this)// 这里别被console窗口误导,由于是输出对象,通过console打印并交互点击查看对象时构造函数已经运行结束,所以并不能看到运行中的this对象状态。所以推荐使用debugger查看运行中this对象
	debugger    // this为空对象,并且其proto属性指向Peron原型,证明 new 第一条动作
	that = this	// 浅拷贝
	this.name = name
	this.getName = function(){
		return this.name
	}
	// return this
}
const p = new Person('张三')
console.log(that==p)	// 输出true,证明 new 第2、3条动作
console.log(p.getName())  // 输出张三
1234567891011121314
使用new关键字调用函数,返回结果的所有情况(包含特殊情况)
  • 如果构造函数返回一个对象,则该对象将作为整个表达式的值返回,而传入构造函数的this将被丢弃。
  • 如果构造函数返回的是非对象类型(包括不返回),则忽略返回值,返回新创建的对象。

方式4:通过Apply、Call调用:this指向,显示控制

上述三种调用方式,都是由调用规则方式不同而自动根据规则绑定的函数上下文。但是由于JavaScript函数是对象,在作为回调函数调用时,其执行环境外部词法环境的情况往往不可控(下文会说这两个名词),导致运行时的作用域链非预期,变量搜索出现问题(下文也会说)。所以我们常常有显示控制回调函数的函数上下文的需求,使其作用域链稳定,变量搜索符合预期。

幸运的是,JavaScript早已为我们提供了一种调用函数的方式,从而可以显式地指定任何对象作为函数的上下文,这就是接下来要说的apply方法和call方法。同时因为apply、call是Function原型上的方法,函数都是Function的实例对象,所以我们可以很轻松的通过函数名.apply/call的方式显示绑定上下文并直接调用得到结果。

apply和call方法
  • 使用示例
let a = 1
let obj1 = {
	a:2
}
let obj = {
	a:3
}
function fn(b,c) {
 return this.a+b+c
}
console.log('apply',fn.apply(obj1,[3,4]))  // console 9
console.log('call',fn.call(obj2, 3,4)) // console 10
123456789101112
  • apply和call方法的区别与选择
    功能类似,都是显示绑定this并调用,区别是两者方法定义上的参数区别,apply形参接收一个数组参数,call形参则接收连续的参数,所以二者的选用也即可根据我们拥有的实参类型进行选择,实参为数组类型就用apply,实参为一组无关的值则用call。

三:通过另外的方式来控制函数上下文

1.箭头函数

上文说到,箭头函数的简化不仅仅体现在定义方式上,在调用箭头函数时,不会隐式传入this参数,而是从定义时的函数继承上下文,也即箭头函数的this始终指向函数声明所在的上下文。

// 例1:箭头函数fn在new Test语句后才定义,根据规则,它没有自己的this,而是继承外部的this,并且根据上文所述new方式调用的规则,二者this和new函数返回的结果都指向新的实例对象。
function Test(){
	this.fn = () => {
		return this;
	}
}
const t = new Test();
console.log(t.fn() == t); // true

// 例2:箭头函数在对象中定义,由于test对象的定义以及解析所在的上下文都在window环境下,所以该箭头函数内部的this指向window
const test = {
	fn: () => {
		return this;
	}
}
console.log(test.fn() == test); // false
12345678910111213141516

2.bind方法

和apply、call一样,bind也是Function原型上的方法,通过函数名.bind的方式显示绑定this,但与apply、call方法不同的是,bind的调用者函数在绑定this后并不会立即调用,而是仅返回一个绑定this的新的函数。

function Test(){
	this.fn = () => {
		return this;
	}
}
const t = new Test()
const fn = t.fn.bind(window);
console.log(t.fn === fn) // false,证明得到新函数对象
12345678
bind和apply、call的区别和选择
  • 在绑定this后,仅需要调用者函数的运行结果时选择call和apply。
  • 在绑定this后,需要调用者函数绑定this后的新函数时选择bind,这时它不会立即调用,并且因为是对象它可以存储、传递后在别处运行。

四:多种方式绑定this的优先级问题

1.bind和call、apply

const obj1 = {name:'张三'}
const obj2 = {name:'李四'}
function fn(){return this}
fn.call(obj1)// 返回{name:'张三'}
fn.apply(obj2)// 返回{name:'李四'}
// fn.call(obj1).apply(obj2) // 错误,普通对象不是函数,不能访问Function原型上的方法call、apply等
fn.bind(obj1).apply(obj2) //  返回{name:'张三'},在bind之后,apply失效了
fn.bind(obj1).call(obj2) // 返回{name:'张三'},在bind之后,call失效了
12345678

通过上述论证,call和apply不能混用,bind只能在call、apply之前,但其之后通过call和apply再次绑定this则会失效。结论:三者绑定this之间无所谓优先级,因为要么报错,要么没软用。

2.箭头函数和apply、call、bind

(() => (this)).apply({}) // 结果为window
(() => (this)).call({}) // 结果为window
(() => (this)).bind({})() // 结果为window
123

通过上述代码论证,apply、call、bind方法虽然可以显示绑定函数的this,但是对箭头函数无效,这符合箭头函数没有单独的this,而是继承其定义时所在的函数上下文(也就是外部this指向)的规则。

函数精通:重在理解闭包

一:执行环境 / 调用栈

JavaScript引擎在执行代码时,每一条语句都处于特定的执行上下文中。全局代码在所有函数外部定义,在全局执行上下文环境下执行。函数代码在函数内部定义,在函数执行上下文中(和上文中的this指向的函数上下文不是一个东西,而且一个在栈内一个在堆内)执行。全局执行上下文只有一个,其生命周期从JavaScript程序开始执行到结束。函数执行上下文可以有多个,在每次函数调用时创建,结束后销毁。多个执行上下文之间的关系可以用栈结构表示,如下JavaScript忍者秘籍中案例:

1.案例说明:各执行上下文的关系

function skulk(ninja) { report(ninja + " skulking");
function report(message) { console.log(message); debugger;}
skulk("Kuma"); 
skulk("Yoshi");
1234
调用栈

JavaScript 函数高级特性

通过chrome调试工具和debugger查看运行中的调用栈

JavaScript 函数高级特性

二:词法环境 / 作用域

词法环境(lexical environment)是JavaScript引擎内部用来跟踪标识符与特定变量之间的映射关系,人们也称之为作用域。一个函数、一段代码片段或者try catch语句都可以具有独立的标识符映射表,在ES6之前,JavaScript的词法环境只能与函数关联,没有块级作用域的概念,但在ES6之后,随着let、const的出现才出现了块级作用域的概念。

1.创建词法环境和变量搜索规则

无论何时创建函数,都会创建一个与之相关联的词法环境,并存储在名为[[Environment]]的内部属性上(也就是说无法直接访问或操作),作为函数对象的属性一起存储在堆中。
无论何时调用函数,都会创建一个新的执行环境,被推入执行上下文栈。除此之外,还会创建一个与外部环境(以全局环境为底的多个词法环境的栈结构)相关联的词法环境(入栈),并且这个词法环境(栈内)会与创建该函数时的词法环境(堆内)进行关联,通过这么一种方式,就实现了作用域链,这是理解闭包的关键。【!!!这里个人理解可能有误,通过chrome实验,并没有发现[[Environment]]属性,而是仅看到[[Scopes]]属性,如若有误,希望看官能够指正,谢谢】

JavaScript忍者秘籍中一个案例

在这个案例中,函数调用的环境与函数定义的环境相同,但事实上,类似回调函数的调用环境常常与其定义环境不同。
JavaScript 函数高级特性

变量搜索规则

标识符搜索就是沿着作用域链这条链来搜索的,从最近的栈内词法环境找到最远的栈内词法环境。如上图查找ninja变量,从report环境向外查到skulk环境,最后搜索到全局环境。

2.在词法环境**册标识符

变量声明注册:var、let、const的区别
  • 通过var声明的变量,会在距离最近的函数或全局词法环境**册(忽略块级词法环境)。
  • let、const声明的变量,会在距离最近的词法环境**册(可以是在块级作用域内、循环内、函数内或全局环境内)。
注册过程

JavaScript代码的执行事实上是分两个阶段进行的。一旦创建了新的词法环境,就会执行第一阶段。在第一阶段,没有执行代码,但是JavaScript引擎会访问并注册在当前词法环境中所声明的变量和函数。JavaScript在第一阶段完成之后开始执行第二阶段,具体如何执行取决于变量的类型(let、var、const和函数声明)以及环境类型 (全局环境、函数环境或块级作用域)。
JavaScript 函数高级特性
正是因为词法环境的注册遵从上述规则,才导致了一些操作可行以及怪异的bug:

  • 在函数声明之前可以调用函数
fn()
function fn(){console.log('aa')} // 输出aa
12
  • 函数和变量重载

由上述过程可知,JavaScript代码在第一阶段的执行当中,函数声明的扫描注册先于变量声明的扫描注册(注意这里说的是函数声明的扫描注册不是函数的扫描注册,以下示例可以论证:

// 以下立即执行函数中,先把fn定义为数字3,而后定义为一个方法,但是在词法环境下的注册顺序却相反,先是识别为函数而后才是数字。
(()=>{
console.log(typeof(fn))// function
var fn = 3
function fn(){}
console.log(typeof(fn)) // number
})()
1234567

三:嵌套闭包

如果理解了上文所述的执行环境、词法环境、作用域链这三个概念,那么就已经理解闭包了,闭包就是利用了函数调用时创建的词法环境(栈内)会指向函数创建时的词法环境,而如果函数创建时的词法环境内的某个变量(堆内)指向了定义所在环境的某个外部变量(堆内),就会因为这个引用关系,导致外部环境创建的词法环境就不会被回收,进而产生了所谓的闭包,也实现了私有化变量的功能。

一个JavaScript忍者秘籍里的案例

JavaScript 函数高级特性
内部setInterver的回调函数引用了外部函数animateIt的tick变量,在该timer未被clear之前,timer在等待调用(栈内),该回调函数对象(堆内)始终没有被释放,而它又引用了外部函数animateIt的变量,导致存储animateIt词法环境(堆内)在animateIt运行结束后也被引用,进而无法被释放。直到clear之后,该回调函数对象被释放,存储animateIt的词法环境(堆内)才能被释放。

经典面试题

最后留个闭包经典面试题,如果你能很清楚的明白为什么以下代码的输出为以下图片,那么你的闭包理解可以吊打面试官了。

  • 代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>作用域链</title>
</head>
<body>
<script type="text/javascript">
    function fun(n,o){
        console.log(o);
        return {
            fun:function(m){
                return fun(m,n)
            }
        }
    }
    var a = fun(0);
    a.fun(1);
    a.fun(2);
    a.fun(3);

    console.log('------------------------');
    var b = fun(0).fun(1).fun(2).fun(3);

    console.log('------------------------');
    var c = fun(0).fun(1);
    c.fun(2);
    c.fun(3);
</script>
</body>
</html>
12345678910111213141516171819202122232425262728293031
  • 运行结果
    JavaScript 函数高级特性