JS函数

200 阅读15分钟

JS 的学习过程中,你需要知道哪些东西是难的,如果你觉得这个东西很难,你就去着重地学一下,如果它是简单的,你就稍微过一下。

然后你在面试的时候再去单独把那些要记的东西再背一遍。

JS 的三座大山。第一座:原型,包括原型链;第二座:this;第三座:AJAX。把这三个都搞清楚了,你就可以去学习 vue、react、安久拉。这三个有任何一个没搞清楚,不好意思,你永远就是 JS 没有入门。

函数是对象

定义一个函数

具名函数

function 函数名(形式参数1,形式参数2){
  语句
  return 返回值
}

匿名函数

  • 上面的具名函数,去掉函数名就是匿名函数。匿名函数因为去掉了函数名,所以需要一个变量来容纳它,不然它就没有了
  • let a=function(x,y){return x+y}
  • 也叫函数表达式。注意:等于号右边叫函数表达式,等于号左边叫“声明一个变量 a 并且把它赋值为”,变量 a 容纳了函数的地址
  • let a=function fn(x,y){return x+y},调用 fn(1,2)会报错
  • 因为如果函数声明是在等于号右边的,那么这个 fn 的作用域就只在等于号右边。其它地方你要用这个函数只能用 a。如果没有等于号,它就是一个全局作用域,函数在哪里都能调用。

箭头函数

  • let f1=x=>x*x
  • let f2=(x,y)=>x+y // 圆括号不能省
  • let f3=(x,y)=>{return x+y} //花括号不能省,return 不能省,因为如果有多个语句,不写 returnJS 不知道你要 return 哪一句
  • let f4=(x,y)=>({name:x,age:y}) //直接返回对象会出错,需要加个圆括号(头疼)
  • let f4=x=>{name:x},这个语法是错的,因为 JS 里面花括号优先被当作块的起始,JS 会认为这是一个 label 标签,可是我要返回对象呀,那怎么办,难道写let f4=x=>{return {name:x}}?这样很丑很智障,正确写法let f4=x=>({name:x}),这样的写法就可以得到一个对象。

构造函数

  • let f=new Function('x','y','console.log(\'hi\');return x+y')
  • 基本没人用,但是能让你知道函数是谁构造出来的
  • 所有函数都是 Function 构造出来的
  • 包括 Object、Array、Function 也是

函数自身 V.S.函数调用

fn V.S. fn()

代码

let fn=()=>console.log('hi')
let fn2=fn
fn2()

结果

  • fn 保存了匿名函数的地址
  • 这个地址被复制给了 fn2
  • fn2()调用了匿名函数
  • fn 和 fn2 都是匿名函数的引用而已
  • 真正的函数既不是 fn 也不是 fn2

函数的要素

每一个函数都有这些东西

  • 调用时机
  • 作用域
  • 闭包
  • 形式参数
  • 返回值
  • 调用栈
  • 函数提升
  • arguments(除了箭头函数)
  • this(除了箭头函数)

调用时机——时机不同,结果不同

刻舟求剑的故事

  • 楚国有人坐船渡河,不慎把剑掉入河中,他在舟上刻下记号,说:“这是我把见掉下的地方。”当舟行驶时,船已经走了很远,剑是不会走动的。他还沿着记号跳入而河中找剑,遍寻不获。

例 1

代码

let a=1
function fn(){
  console.log(a)
}

问打印出多少

  • 不知,因为没有调用代码

例 2

代码

let a=1
function fn(){
  console.log(a)
}
fn()

问打印出多少

  • 1

例 3

代码

let a=1
function fn(){
  console.log(a)
}
a=2
fn()

问打印出多少

  • 2

例 4

代码

let a=1
function fn(){
  console.log(a)
}
fn()
a=2

问打印出多少

  • 1

例 5

代码

let a=1
function fn(){
  setTimeout(()=>{
    console.log(a)
  },0)
}
fn()
a=2

问打印出多少

  • 2

例 6

代码

let i=0
for(i=0;i<6;i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}

问打印出多少

  • 不是 0、1、2、3、4、5
  • 而是 6 个 6

例 7

代码

for(let i=0;i<6;i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}

问打印出多少

  • 是 0、1、2、3、4、5
  • 因为 JS 在 for 和 let 一起用的时候会加东西。每次循环把 i 复制一份留在这里,不跟随新的 i 变化。这六次循环一共生成了 6 个新的 i,再加上本身 i=0 的那个 i 一共有七个 i
  • 每次循环会多创建一个 i(我服了 JS)
  • 这是 JS 为了迎合新人的理解能力而做的改变。这下刻舟求剑竟然实现了,你在舟的行驶过程中你只要在那刻了一下,天哪那个剑竟然会跟着舟走

作用域——每个函数都会默认创建一个作用域

例 1

function fn(){
  let a=1
}
console.log(a) //a不存在

  • 是不是因为 fn 没执行导致

  • 就算 fn 执行了,因为访问不到作用域里面的 a
  • let 的作用域非常好找,往前找到一个正的花括号,往后找到一个对应的反的花括号,那这两个花括号就是 let 唯一可以生存的空间,出了这个空间它就不存在。

例 2

function fn(){
  let a=1
}
fn()
console.log(a) // a还是不存在

全局变量 V.S.局部变量

  • 在顶级作用域声明的变量是全局变量
  • window 的属性是全局变量
  • 其他都是局部变量

函数可嵌套——作用域也可嵌套

例 3

function f1(){
  let a=1
  function f2(){
    let a=2
    console.log(a)
  }
  console.log(a)
  a=3
  f2()
}
f1()

静态作用域也叫词法作用域,词法是属于编译原理的知识

作用域规则

如果多个作用域有同名变量 a

  • 那么查找 a 的声明时,就向上去最近的作用域
  • 简称[就近原则]
  • 查找 a 的过程与函数执行无关
  • 但 a 的值与函数执行有关

例 4

function f1(){
  let a = 1
  function f2(){
    let a = 2
    function f3(){
      console.log(a)
    }
    a = 22
    f3()
  }
  console.log(a)
  a = 100
  f2()
}
f1()

打印出 1、22。

  • 在确定是哪个 a 的时候是不看执行的,只有在确定 a 的值的时候才需要看这个执行顺序是怎样的。
  • 只有在确定值的时候才去看 f2 是在 a=22 的上面呢还是下面呢。
  • 变量 a 和变量 a 的值是两个东西

闭包刚刚讲过了——讲过了吗?!

JS 的函数会就近寻找最近的变量,这就是闭包

重看例 4

function f1(){
  let a = 1
  function f2(){
    let a = 2
    function f3(){
      console.log(a)
    }
    a = 22
    f3()
  }
  console.log(a)
  a = 100
  f2()
}
f1()

f3 用到了外面的变量 a,那么 a 和 f3 就是闭包。

如果一个函数用到了外部的变量,那么这个函数加这个变量,就叫做闭包。左边的 a 和 f3 组成了闭包。闭包的用途以后学习,你也可以先搜一下。

形式参数

形式参数的意思就是非实际参数

function add(x, y){
return x+y
}
// 其中x和y就是形参,因为并不是实际的参数
add(1,2)
// 调用add时,1和2是实际参数,会被赋值给x y

赋值是什么意思呢?如果你看过其它的 JS 教程,它可能会说,JS 传参的时候分为值传递和地址传递。但如果你看过内存图,你就知道,没有什么值传递和地址传递,就是把 stack 里面的地址复制一遍(所有东西都是根据内存图全部复制)

形参可认为是变量声明

// 上面代码近似等价于下面代码
function add(){
  var x=arguments[0]
  var y=arguments[1]
  return x+y
}

形参的本质就是变量声明

形参可多可少——形参只是给参数取名字而已,它并没有什么实际的功能

例 1

function add(x){ //只声明一个,怎么拿到第二个参数
  return x+arguments[1] //arguments可以拿到它的所有参数,arguments[1]就是第二个参数
}
add(1,2) //3

例 2

function add(x,y,z){
  return x+y
}
add(3,4) //7

JS 的代码就是这么随意,你爱多就多爱少就少,这也造成了后来的一些问题。最近 TypeScript 兴起,TypeScript 要求形参必须按照严格的类型和顺序。

返回值

每个函数都有返回值

function hi(){ console.log('hi') }
hi()
  • 没写 return,所以返回值是 undefined
function hi(){ return console.log('hi') }
hi()
  • 返回值为 console.log('hi')的值,即 undefined
  • 注意 console.log('hi')的返回值不是 hi,hi 是它的打印值。打印值是打印值,返回值是返回值。console.log 的返回值永远是 undefined

函数执行完了后才会返回

只有函数有返回值

  • 口误:1+2 返回值为 3。1+2 又没有 return,哪来的返回值,返回值是 return 后面的那个值
  • 正确说法:1+2 值为 3

调用栈

什么是调用栈——调用栈实际上就是记录进入了一个函数之后回去回到哪里

  • JS 引擎在调用一个函数前
  • 需要把函数所在的环境 push 到一个数组里
  • 这个数组叫做调用栈
  • 等函数执行完了,就会把函数刚才所在的环境弹(pop)出来
  • 然后 return 到之前的环境,继续执行后续代码

递归函数

阶乘

function f(n){
  return n!==1?n*f(n-1)
}

理解递归

f(4)
=4*f(3)
=4*(3*f(2))
=4*(3*(2*f(1)))
=4*(3*(2*(1)))
=4*(3*(2))
=4*(6)
24
  • 先递进,再回归,就是递归。
  • 其它所有的说法,比如递归就是不停地调用自己,错的!不是调用自己就是递归,调用自己有时候会死循环的,死循环不是递归的。
  • 递归就是先递进再回归,那它的尽头在哪里呢,尽头就在f(1)=1这个关键点,如果 f(1)还有后面的值,那就结束不了了
  • 递归的过程就是压栈的过程,回归的过程就是弹栈的过程。压栈和弹栈跟递归完美地结合在一起

爆栈——如果调用栈中压入的帧过多,程序就会崩溃

函数提升

什么是函数提升

  • function fn(){}
  • 不管你把具名函数声明在哪里,它都会跑到第一行

什么不是函数提升

  • let fn=function(){}
  • 这是赋值,右边的匿名函数声明不会提升
add(1,2)
let add=function(x,y){ return x+y } //报错

上面的代码会报错。

arguments 和 this——每个函数都有,除了箭头函数

代码

function fn(){
  console.log(arguments)
  console.log(this)
}

如何传 arguments

  • 调用 fn 即可传 arguments
  • fn(1,2,3)那么 arguments 就是[1,2,3]伪数组

如何传 this

  • 目前可以用 fn.call(xxx,1,2,3)传 this 和 arguments
  • 而且 xxx 会被自动转化成对象(JS 的糟粕)

如果不给任何的条件,那么 this 默认指向 window。其实如果要用 window,那你直接就用 window 就好了吗。所以这是 JS 的一个糟粕。

this 变态的地方:如果你传的这个 this 不是对象,那么 JS 会自动帮你封装成对象

this 是隐藏参数

arguments 是普通参数

this 是参数(此结论是我个人的)

我们要理解 this 很简单,我们先把 this 从 JS 里面排除出去,就是我们不用 this 能不能达到跟 this 一样的功能呢。研究一下这个问题,假设 JS 里面没有 this:

代码:

let person={
  name:'frank',
  sayHi(){
    console.log(`你好,我叫`+person.name)
  }
}

分析

  • 我们可以用直接保存了对象地址的变量获取对象的'name'
  • 我们把这种办法简称为引用。(保存了一个东西的地址,就叫做引用)

问题一

如果代码这样写:

let sayHi=function(){
  console.log(`你好,我叫`+person.name)
}
let person={
  name:'frank',
  'sayHi':sayHi
}

分析:

  • person 如果改名,sayHi 函数就挂了
  • sayHi 函数甚至有可能在另一个文件里面
  • 所以我们不希望 sayHi 函数里出现 person 引用

问题二

代码

class Person{
  constructor(name){
    this.name=name
    // 这里的this是new强制指定的
  }
  sayHi(){
    console.log(???)
  }
}

分析:

  • 这里只有类,还没创建对象,故不可能获取对象的引用
  • 那么如何拿到对象的 name?

需要一种办法拿到对象,这样才能获取对象的 name 属性(怎么在不知道一个对象的名字的情况下拿到对象的引用呢)

一种土办法,用参数

对象

let person={
  name:'frank',
  sayHi(p){
    console.log(`你好,我叫`+p.name)
  }
}
person.sayHi(person)

class Person{
  constructor(name){ this.name=name }
  sayHi(p){
    console.log(`你好,我叫`+p.name)
  }
}

谁会用这样的办法,Python 就用了......(在每一个函数前面加上一个参数,这个参数表示要进来的对象)

JS 没有模仿 Python 的思路,它走了另一条更难理解的思路。非常不幸,这就是 JS 的第二座大山。

JS 在每个函数里加了 this

用 this 获取那个对象

let person={
  name:'frank',
  sayHi(){
    console.log(`你好,我叫`+this.name)
  }
}

person.sayHi()

相当于

person.sayHi(person)

然后 person 被传给 this 了(person 是个地址)

这样,每个函数都能用 this 获取一个未知对象的引用了

person.sayHi()会隐式地把 person 作为 this 传给 sayHi

方便 sayHi 获取 person 对应的对象(因为我们要解决的问题就是一个函数怎么获取一个对象)

总结一下目前的知识

我们想让函数获取对象的引用。这个对象叫什么名字不知道,但我知道肯定是有这个对象的

但是并不想通过变量名做到。因为通过变量名有点不好,有的时候也没有变量名

Python 通过额外的 self 参数做到

JS 通过额外的 this 做到:

  • person.sayHi()会把 person 自动传给 sayHi,sayHi 可以通过 this 引用 person

这就引出另一个问题

到底哪个对

let person={
  name:'frank',
  sayHi(){
    console.log(`你好,我叫·+this.name)
  }
}
  • person.sayHi()
  • person.sayHi(person)
  • 省略形式反而对了,完整形式反而是错的?

JS 怎么解决这种不和谐

  • 提供两种调用形式

两种调用

小白调用法

  • person.sayHi()
  • 会自动把 person 传到函数里,作为 this

大师调用法

  • person.sayHi.call(person)
  • 需要自己手动把 person 传到函数里,作为 this

应该学习哪种?

  • 学习大师调用法,因为小白调用法你早就会了
  • 从现在开始,默认用大师调用法

把一个函数放到一个对象上面,那这个函数和这个对象没有任何关系,它们只是恰好出现在一起而已。

例 1

function add(x,y){
  return x+y
}

没有用到 this

  • add.call(undefined,1,2) // 3

为什么要多写一个 undefined

  • 因为第一个参数要作为 this
  • 但是代码例没有用 this
  • 所以只能用 undefined 占位
  • 其实用 null 也可以

例 2

代码 1:

Array.prototype.forEach2=function(){
  console.log(this)
}
array.forEach2.call(array) //等价于array.forEach2()

代码 2:

Array.prototype.forEach2=function(fn){
  for(let i=0;i<this.length;i++){
    fn(this[i],i)
  }
}
  • 如果你在看代码 2 的时候脑子里面在想,这个 this 值到底是什么呢,那你就出问题了,这个 this 的值是不确定的,没有人知道 this 是什么,你说那我怎么知道 this 是什么,你用大师调法调进来不就行了吗,你把那个数组传进来不就知道是什么了吗。
  • 这个函数的作用就是遍历当前数组。你说当前数组在哪呢,就是 this 啊,this 可以引用未来的那个当前数组。
  • 这个 forEach2 就是对 forEach 的手工再写一遍
Array.prototype.forEach.call({0:'a',1:'b',length:2},(item)=>console.log(item)) // a,b
  • 通过这个例子可以看出,forEach 里面的 this 根本就是一个参数而已,传什么就是什么
  • 只不过你用小白调用法的时候恰巧 JS 猜你想要的 this 是什么,它是猜的
  • 所以这个 this 就是一个可以任意指定的参数而已
Array.prototype.forEach2=function(fn){
  for(let i=0;i<this.length;i++){
    fn(this[i],i,this)
  }
}

this 是什么

  • 由于大家使用 forEach2 的时候总是会用 arr.forEach2
  • 所以 arr 就被自动传给 forEach2 了

this 一定是数组吗

  • 不一定,比如Array.prototype.forEach.call({0:'a',1:'b',length:2},(item)=>console.log(item))

this 的两种使用方法

隐式传递

  • fn(1,2) // 等价于 fn.call(undefined,1,2)
  • obj.child.fn(1) // 等价于 obj.child.fn.call(obj.child,1)

显式传递

  • fn.call(undefined,1,2)
  • fn.apply(undefined,[1,2])

绑定 this

使用.bind 可以让 this 不被改变

function f1(p1,p2){
  console.log(this,p1,p2)
}
let f2=f1.bind({name:'frank'})// 那么f2就是f1绑定了this之后的新函数
f2()//等价于f1.call({name:'frank'})

f2 是 f1 的 this 绑定之后的版本,调 f2 相当于调 f1,唯一的区别是你把 this 绑定了,绑定成你传给 bind 的第一个参数

.bind 还可以绑定其他参数

let f3=f1.bind({name:'frank'},'hi') // 参数是this,p1
f3() // 等价于 f1.call({name:'frank'},hi)

箭头函数——没有 arguments 和 this

里面的 this 就是外面的 this

  • console.log(this) // window
  • let fn=()=>console.log(this)
  • fn() // window

就算你加 call 都没用

  • fn.call({name:'frank'}) // window

立即执行函数——只有 JS 有的变态玩意,现在用得少

原理

  • ES5 时代,为了得到局部变量,必须引入一个函数
  • 但是这个函数如果有名字,就得不偿失
  • 于是这个函数必须是匿名函数
  • 声明匿名函数,然后立即加个()执行它
  • 但是 JS 标准认为这种语法不合法
  • 所以 JS 程序员寻求各种办法
  • 最终发现,只要在匿名函数前面加个运算符即可
  • !、~、()、+、- 都可以
  • 但是这里面有些运算符会往上走
  • 所以推荐永远用!来解决
  • 匿名函数用括号,前面需要加分号,这是 JS 里面唯一需要加分号的地方,其它的任何代码都不需要加分号