以下文章纯属阅读JavaScript忍者秘籍之后的个人理解和总结,经验尚浅,如若有误,敬请指正,谢谢!
ps:分享不易,如果本篇博客对你有所帮助,点赞 + 收藏吧,后续博客会有更多惊喜!
函数入门
一:认识JavaScript函数
1.函数是对象
所有JavaScript函数都是对象,所以它具有对象的全部特征:
- 可以通过字面量创建
function fn() {
}
- 可以赋值给变量,数组或其他对象的属性
- 可以作为函数的参数来传递
- 可以作为函数的返回值
- 可以动态创建和分配属性
2.函数作为对象,具有属性:如下案例,用作标记属性、计算结果属性、工具函数属性等
标记属性
可以在函数上挂载一些标记属性,如该函数是否可用、函数序号、使用环境等等与该函数有关的特征信息。
// 例1:函数的封装者标记
function fn(){
}
fn.useAble = false
// 例2:函数集合去重存储
var fnStore = {
nextId: 1,
cache: {
},
add: function(fn) {
if (!fn.id) {
fn.id = this.nextId++;
this.cache[fn.id] = fn;
return true;
}
}
};
function fn1(){
}
fnStore.add(fn1)
计算结果属性
在某函数运行的时间复杂度高或返回值比较密集时,可以用空间换时间,通过在该函数上挂载一个结果集对象的属性的方式,在具体运算前先对该对象进行搜索,有则取值返回,无则运算后得值存储下来并返回,这样可以大大加快运行速度,如下例:
// 例1:
function fn(param){
if(fn.results){
fn.results = {
}
}
if(fn.results[param] !== undefined){
return fn.results[param]
}
else{
const result = ...xxx // 函数具体逻辑
fn.results[param] = result
}
}
工具函数属性
在我们经常见到的api当中,很多除核心逻辑外的封装以及语法糖,尤其是对该函数的一些常用的构造方式,都会被封装成一个工具函数,然后作为属性挂载在该封装函数上,如下例:
// 例1:Promise的工具函数resolve、reject、all、race等函数都通过函数属性方式挂载在Promise上,而不是作为实例方法或原型方法挂载在每个实例对象或者原型对象上。
function Promise(excutor){
}
Promise.resolve = function(value){
} // 注意这里不是回调函数参数resolve,而是Promise工具函数resolve,用来快速得到一个resolved状态的Promise。
Promise.reject = function(error){
} // 与上同理
Promise.all/race/xxx = function(){
}
// 例2:axios
function axios(){
}
axios.get = function(url){
} // 以及axios.post/delete/put等工具函数都是挂载在axios上
3.函数作为对象,可以传递:如下案例,回调函数
熟悉Java的人都知道,在Java中,方法并不是一个独立的对象,也没有方法单独的存储方式,所以它不可能作为引用类型或者基本类型传递。但是在JavaScript中函数或者方法传递是可以做到的,因为函数是对象,所以它可以被存储并被传递。
function fn(){
console.log(111) };
setInterval(fn1, 1000)
二:定义JavaScript函数
方式1:函数声明语句(通过字面量创建函数)
普通函数
function fn(){
}
生成器函数
function* myGen(){
yield 1; }
有关生成器函数更详细的内容,会在另外一篇博客再做研究,在这里不展开讨论。
方式2:函数表达式(通过表达式创建函数)
const fn = function(){
}
- 和函数声明方式的区别:函数声明语句是语句,函数表达式是表达式,除了表达式方式创建函数更灵活之外,比如支持立即函数和箭头函数,二者在解析时,在词法环境的注册规则也不一样,后面会讨论。
立即函数(IIFE)
(function(){
})()// 用()告知JavaScript解析器把它解析为表达式而不是函数声明语句,避免因为被解析为函数声明并因为没有名字而报错。
- 告知JavaScript解析器将其识别为表达式而不是函数声明语句的方式有很多种,但这种就够用了并且阅读性最好,其它的方式在这里不做讨论。
箭头函数(Lambda):是函数表达式的简化版
const fn = () => (1)
- 箭头函数的简化不仅仅体现在定义方式上,在函数的调用上也有区别,比如函数运行时,它没有单独的this值,它的this指向与声明所在的上下文的this指向相同。
方式3:构造函数(通过Function构造函数创建)
const fn = new Function('a', 'b', 'return a + b')
- 函数都是内置函数对象 Function 的实例,所以它可以通过使用new关键字调用Function构造函数的方式得到函数对象。
三:JavaScript函数参数
0.认识形参和实参
- 形参是我们 定义函数时 所列举的变量。
- 实参是我们 调用函数时 所传递给函数的值。
1.形实参 1 对 1 匹配
const av,bv = 1,2
function fn(a,b){
}
fn(av,bv) // 实参 av-> 形参a,实参 bv-> 形参b
形实参不对等时的匹配规则
- 形参数量大于实参数量,后续未被匹配的形参值为undefined。
- 实参数量大于形参数量,不会匹配到任何一个形参,但是并不意味着丢失,因为还有隐式参数arguments,后面会讲到。
2.形实参 1 对 多 匹配(剩余参数)
为函数的最后一个命名参数前加上省略号(…)前缀,这个参数就变成了一个叫作剩余参数的数组,数组内包含着传入的剩余的参数,如下例:
// 使用ab当中更大的一个值加上除a、b匹配的实参外的后续所有实参的值
function fn(a,b,...rest) {
const sum = a>b?a:b
for(i of rest){
sum += i
}
return sum
}
fn(1, 2, 3, 4) // 结果为9
3.形实参 1 对 0 匹配(默认参数)
function fn(a = 1,b = 2) {
return a+b;
}
fn(1) // 结果为3
4.隐式参数this和arguments
隐式参数在函数声明中没有明确定义,在函数调用时也没有显示传入,但会默认传递给函数并且可以在函数内正常访问,同时在函数内可以像其他明确定义的参数一样引用它们,详细内容后面会再讨论。
函数进阶:重在理解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)
- arguments的元素可以作为对应形参的别名,拥有对该形参的读写能力,但是在严格模式下被禁用,此文不做更多讨论。
2.指向函数上下文的this
this参数代表函数调用相关联的对象,通常也称之为函数上下文。在Java中,this通常指向定义当前方法的类的实例,但是在JavaScript中this参数不仅由该函数定义的方式和位置决定,同时还严重受到函数调用方式的影响,接下来即讨论函数的调用方式对函数上下文的影响。
二:函数调用方式对函数上下文的影响
方式1:作为普通函数调用:this指向undefined(严格模式下)
"use strict"; // 不在严格模式下,下述console则会输出window
function fn(){
return this
}
console.log(fn()) // 输出undefined
方式2:作为对象方法调用:this指向调用者
const obj ={
fn: function(){
return this
}
}
console.log(obj.fn()) // 输出obj
方式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()) // 输出张三
使用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
- 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
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,证明得到新函数对象
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失效了
通过上述论证,call和apply不能混用,bind只能在call、apply之前,但其之后通过call和apply再次绑定this则会失效。结论:三者绑定this之间无所谓优先级,因为要么报错,要么没软用。
2.箭头函数和apply、call、bind
(() => (this)).apply({
}) // 结果为window
(() => (this)).call({
}) // 结果为window
(() => (this)).bind({
})() // 结果为window
通过上述代码论证,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");
调用栈
通过chrome调试工具和debugger查看运行中的调用栈
二:词法环境 / 作用域
词法环境(lexical environment)是JavaScript引擎内部用来跟踪标识符与特定变量之间的映射关系,人们也称之为作用域。一个函数、一段代码片段或者try catch语句都可以具有独立的标识符映射表,在ES6之前,JavaScript的词法环境只能与函数关联,没有块级作用域的概念,但在ES6之后,随着let、const的出现才出现了块级作用域的概念。
1.创建词法环境和变量搜索规则
无论何时创建函数,都会创建一个与之相关联的词法环境,并存储在名为[[Environment]]的内部属性上(也就是说无法直接访问或操作),作为函数对象的属性一起存储在堆中。
无论何时调用函数,都会创建一个新的执行环境,被推入执行上下文栈。除此之外,还会创建一个与外部环境(以全局环境为底的多个词法环境的栈结构)相关联的词法环境(入栈),并且这个词法环境(栈内)会与创建该函数时的词法环境(堆内)进行关联,通过这么一种方式,就实现了作用域链,这是理解闭包的关键。【!!!这里个人理解可能有误,通过chrome实验,并没有发现[[Environment]]属性,而是仅看到[[Scopes]]属性,如若有误,希望看官能够指正,谢谢】
JavaScript忍者秘籍中一个案例
在这个案例中,函数调用的环境与函数定义的环境相同,但事实上,类似回调函数的调用环境常常与其定义环境不同。
变量搜索规则
标识符搜索就是沿着作用域链这条链来搜索的,从最近的栈内词法环境找到最远的栈内词法环境。如上图查找ninja变量,从report环境向外查到skulk环境,最后搜索到全局环境。
2.在词法环境下注册标识符
变量声明注册:var、let、const的区别
- 通过var声明的变量,会在距离最近的函数或全局词法环境下注册(忽略块级词法环境)。
- let、const声明的变量,会在距离最近的词法环境下注册(可以是在块级作用域内、循环内、函数内或全局环境内)。
注册过程
JavaScript代码的执行事实上是分两个阶段进行的。一旦创建了新的词法环境,就会执行第一阶段。在第一阶段,没有执行代码,但是JavaScript引擎会访问并注册在当前词法环境中所声明的变量和函数。JavaScript在第一阶段完成之后开始执行第二阶段,具体如何执行取决于变量的类型(let、var、const和函数声明)以及环境类型 (全局环境、函数环境或块级作用域)。
正是因为词法环境的注册遵从上述规则,才导致了一些操作可行以及怪异的bug:
- 在函数声明之前可以调用函数
fn()
function fn(){
console.log('aa')} // 输出aa
- 函数和变量重载
由上述过程可知,JavaScript代码在第一阶段的执行当中,函数声明的扫描注册先于变量声明的扫描注册(注意这里说的是函数声明的扫描注册不是函数的扫描注册,以下示例可以论证:
// 以下立即执行函数中,先把fn定义为数字3,而后定义为一个方法,但是在词法环境下的注册顺序却相反,先是识别为函数而后才是数字。
(()=>{
console.log(typeof(fn))// function
var fn = 3
function fn(){
}
console.log(typeof(fn)) // number
})()
三:嵌套闭包
如果理解了上文所述的执行环境、词法环境、作用域链这三个概念,那么就已经理解闭包了,闭包就是利用了函数调用时创建的词法环境(栈内)会指向函数创建时的词法环境,而如果函数创建时的词法环境内的某个变量(堆内)指向了定义所在环境的某个外部变量(堆内),就会因为这个引用关系,导致外部环境创建的词法环境就不会被回收,进而产生了所谓的闭包,也实现了私有化变量的功能。
一个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>
- 运行结果
ps:分享不易,如果本篇博客对你有所帮助,点赞 + 收藏吧,后续博客会有更多惊喜!