JavaScript中函数的四种调用方式

在《JavaScript忍者秘籍》书中提到,我们有四种不同的方式进行函数调用:

  1. 作为一个函数进行调用,这是最简单的形式。
  2. 作为一个方法进行调用,在对象上进行调用,支持面向对象编程。
  3. 作为构造器进行调用,创建一个新对象。
  4. 通过 apply() call() 方法进行调用

作为一个曾经的小镇做题家,一些关键词自然而然就冒出来了,this、闭包、原型......这些概念都不陌生,但总感觉有些散乱。我发现,顺着书中这几种函数调用方式,层层递进,倒是可以帮助更好的理解和串联这些知识点。

1. 作为函数调用

这里指的就是最简单直接的调用方式:

js 复制代码
function sum(a, b) {
  return a + b;
}

sum(1, 2)

函数封装了一段逻辑,给一个输入,得到一个输出。函数最大的好处是它是可以复用的,而不是在每个需要的地方都写一段重复的代码。用好函数往往可以让代码变得更清晰。

使用函数时必然要关注它的作用域 。简单来说,函数内是局部变量,只能在这个函数中访问,而全局变量可以在程序的任何代码中访问。但在JS中比这复杂,因为函数是可以嵌套 的,也可以作为参数传递。比如下面的例子:

js 复制代码
function outer() {
  let value = 0;

  function inner(n) {
    value += n;
    return value;
  }

  return inner;
}

const fn = outer();
fn(1); // 1
fn(2); // 3

此时inner()作为内部函数 是可以访问到外部函数outer()内定义的局部变量的,即使它们是两个不同的函数 。另外,正常来说,一个函数执行完成后,所有函数内定义的局部变量都会被回收,但这里不同:outer()执行完成了,返回了一个函数,而这个函数内仍然能访问value这个局部变量(value并没有被回收 )------即"闭包 "。因为JS支持更灵活的函数使用,所以也就引出了这些概念,这在Go语言中也是一摸一样的。

2. 作为方法进行调用

即函数作为对象的一个属性,如下所示:

js 复制代码
const dog = {
  name: "wangcai",
  age: 1,
  sayHi: function () {
    console.log("Hello~");
  },
};

dog.sayHi(); // Hello~

JS中可以用对象表示一些属性的集合,比如一只狗有名字和年龄。当然这个对象也可以有方法,比如这只狗会打招呼。如果想在打招呼的时候报出自己的名字怎么做呢?当然我们可以直接用对象名访问,即dog.name,但还有一种更友好的方式------this(在Java中,关键字"this"表示当前对象的引用,而在JS中,"this"是支持面向对象编码的主要手段之一)。

js 复制代码
const dog = {
  name: "wangcai",
  age: 1,
  sayHi: function () {
    console.log("Hello,my name is ", this.name);
  },
};

dog.sayHi(); // Hello,my name is  wangcai

这样就清晰多了,不用管变量名的dog是哪个dog,this就是本狗了。那普通函数不在对象中,它调用时的this是什么呢?

js 复制代码
function getThis() {
  return this;
}

getThis() === window // true

在浏览器中,答案就是全局环境window。回到前面dog的例子,下面代码中this又是什么呢?是本狗吗?答案我们在后面揭晓。

js 复制代码
const dog = {
  name: "wangcai",
  age: 1,
  sayHi: function () {
    console.log("Hello,my name is ", this.name);
  },
};

const sayHi = dog.sayHi;

sayHi();

3. 作为构造器进行调用

如果我有很多条狗,那么用对象来一个个声明就略显笨拙了。你可能会写一个createDog的函数,来按模式批量生产dog。

js 复制代码
function createDog(name, age) {
  return {
    name,
    age,
    sayHi: function () {
      console.log("Hello,my name is ", this.name);
    },
  };
}
const dog = createDog("wangcai", 1);

但同样,有一种更"面向对象",更友好的方式------构造函数(constructor,ES6的"class"就是构造函数的语法糖)

js 复制代码
function Dog(name, age) {
  this.name = name;
  this.age = age;
  this.sayHi = function () {
    console.log("Hello,my name is ", this.name);
  };
}

const dog = new Dog("wangcai", 1);

构造函数需要搭配"new"关键字使用 ,它将自动返回一个新的对象。另外,构造函数一般用大写开头,同时它应该是一个名词,而不是动词。

构造函数只是一个约定,语言本身并没有限制你如何使用它。你可以直接执行Dog("wangcai",i),但这样没有什么意义,还会产生一些意外的全局变量。

我们可以结合前一节的"对象方法"来理解构造器的调用过程:

  1. 创建一个空对象
  2. 在该对象上执行这个函数(函数调用时的this指向这个对象)
  3. 最后将这个对象返回,如果没有显式的返回值(还差了一个步骤,暂时没涉及,后面再说)

JS中几乎所有对象都有自己的构造函数,对于使用字面量语法 声明的对象,它的构造函数就是Object

js 复制代码
const dog = { name: "wangcai" };

JS中有三种方式创建一个对象。

  1. 字面量语法,如const obj = { value: 100 }
  2. Object.create()
  3. 使用new调用构造函数初始化一个对象

只有通过Object.create(null)创建的对象是没有构造函数的,也没有"原型"。

对象与构造函数之间通过原型进行关联。还是以我们的dog为例:

js 复制代码
const dog = new Dog("wangcai", 1);

Object.getPrototypeOf(dog) === Dog.prototype // true

"当构造函数搭配 new使用时,该函数的 prototype数据属性将用作新对象的原型 。默认情况下,函数的prototype是一个普通的对象 。这个对象具有一个属性:constructor,它是对这个函数本身的一个引用。 constructor 属性是可编辑、可配置但不可枚举的"。所以,我们通过Object.getPrototypeOf(dog).constructor可以直接获取到对象的构造函数。下面就是浏览器控制台打印出的dog对象:

那对象的原型又是什么呢?它是JS中一种独特的机制,它的特点如下:

  • 每个对象都有一个私有属性指向另一个名为原型 (prototype)的对象。当访问一个对象属性时,如果属性不存在,就会继续查找这个对象的原型属性
  • 原型对象也有一个自己的原型,层层向上直到一个对象的原型为nullObject.prototype的原型始终为null且不可更改。

所以,一个对象不仅有实例属性,还有原型属性,它们都可以被访问到,只是原型属性是不可枚举的

js 复制代码
const o = { value: 100};

Object.keys(o); // ['value']

o.valueOf(); // valueOf是构造函数Object的prototype上定义的方法,可以正常访问

再回顾new进行初始化的过程,是缺了什么步骤呢?就是将对象的原型指向构造函数的prototype。我们可以按这个规则模拟一个new函数。

js 复制代码
function Dog(name, age) {
  this.name = name;
  this.age = age;
}

Dog.prototype.sayHi = function () {
  console.log("Hello,my name is ", this.name);
};

function myNew(fn, ...args) {
  const obj = Object.create(fn.prototype); // 创建一个对象,将对象的原型指向fn.prototype
  const res = fn.apply(obj, args);

  return res ?? obj;
}

const dog = myNew(Dog, "wangcai", 1);

dog.sayHi(); // Hello,my name is wangcai

Object.getPrototypeOf(dog) === Dog.prototype; // true

大家细心会发现,这个例子中将sayHi函数放在了Dog的prototype 对象上,而不像之前在函数内通过this.sayHi声明。两种方式new出来的对象都是能调用sayHi()的,唯一的区别是:一个是通过对象的实例属性访问,而另一个是通过原型属性访问。后者看起来是这样的:

原型属性具有一些优点,比如在这个例子中,每个new出来的对象访问的都是同一个sayHi函数(定义在Dog的prototype对象上),而不是重新拷贝,这样节省内存。同时,sayHi函数只需修改一次,就能应用到所有实例化的对象中

js 复制代码
Dog.prototype.sayHi = function () {
  console.log("Good morning~");
};

dog.sayHi(); // Good morning~

原型链的特性还能支持我们实现对象的"继承"。首先我们用class语法试下继承的效果。

js 复制代码
// 基类
class Animal {
  constructor(name) {
    this.name = name;
  }

  eat() {
    console.log(`${this.name} is eating`);
  }
}

//派生类
class Dog extends Animal {
  constructor(name, age) {
    super(name);
    this.age = age;
  }

  sayHi() {
    console.log(`Hello, my name is ${this.name}, I am ${this.age} years old`);
  }
}

const dog = new Dog("wangcai", 1);

dog.sayHi(); // Hello, my name is wangcai, I am 1 years old
dog.eat(); // wangcai is eating

分析一下,agename都在dog对象的实例属性上,而sayHi() 函数在dog对象的原型上,即 Dog.prototype。再往上一层,Dog.prototype这个对象的原型是 Animal.prototypeAnimal.prototype上具有eat()方法。那再往上一层呢?Animal.prototype这个对象的原型是Object,再往上就到原型链顶端null了。

接下来相信大家也有概念怎么手动实现继承了。下面是一个示例:

js 复制代码
function Animal(name) {
  this.name = name;
}

Animal.prototype.eat = function () {
  console.log(`${this.name} is eating`);
};

function Dog(name, age) {
  this.age = age;
  Animal.call(this, name); // 将Animal的实例属性放到Dog对象上

  Object.setPrototypeOf(Dog.prototype, Animal.prototype); // 设置原型链
}

Dog.prototype.sayHi = function () {
  console.log(`Hello, my name is ${this.name}, I am ${this.age} years old`);
};

const dog = new Dog("wangcai", 1);

dog.sayHi();
dog.eat();

4. 通过 apply() call()方法进行调用

js 复制代码
const dog = {
  name: "wangcai",
  age: 1,
  sayHi: function () {
    console.log("Hello,my name is ", this.name);
  },
};

const sayHi = dog.sayHi;

sayHi();

再回顾前面的例子,揭晓答案,结果是Hello,my name is undifined,显然不是本狗了。

为什么会这样呢?因为JS中函数的"this"是由调用点决定的(在运行时确定) ,而不是在函数声明处决定。这就让人很困扰了,本狗的名字都对不上了,这样真的好吗?其实这样是为了提供更大的灵活性------支持动态上下文。

js 复制代码
function sayHi() {
  console.log("Hello,my name is ", this.name);
}

const dog = {
  name: "wangcai",
  sayHi,
};

const cat = {
  name: "miaomiao",
  sayHi,
};

dog.sayHi(); // Hello,my name is wangcai
cat.sayHi(); // Hello,my name is miaomiao

通过允许this由调用点动态确定,可以让同一个函数在不同的对象上使用。另外,JS也提供了显式指定函数执行时的this为某个对象的方法,即apply()call()

js 复制代码
const dog = {
  name: "wangcai",
  age: 1,
  sayHi: function () {
    console.log("Hello,my name is ", this.name);
  },
};

const sayHi = dog.sayHi;

sayHi.apply(dog); // Hello,my name is  wangcai
sayHi.call(dog); // Hello,my name is  wangcai

很开心,本狗又回来了。apply()call()只有函数传参上的差异,在使用时看哪个方便用哪个就行。

总结

通过体验函数这四种不同的调用方式,我们逐渐接触了JS中一些底层的知识点:作为函数调用时的闭包 ,作为方法调用时的this 指向,作为构造器调用时的原型。JS中的函数非常灵活,也很强大,并且与对象有着密切的联系。

参考:

相关推荐
大G哥2 分钟前
python 数据类型----可变数据类型
linux·服务器·开发语言·前端·python
hong_zc26 分钟前
初始 html
前端·html
小小吱31 分钟前
HTML动画
前端·html
Xiao Fei Xiangζั͡ޓއއ36 分钟前
一觉睡醒,全世界计算机水平下降100倍,而我却精通C语言——scanf函数
c语言·开发语言·笔记·程序人生·面试·蓝桥杯·学习方法
Bio Coder1 小时前
学习用 Javascript、HTML、CSS 以及 Node.js 开发一个 uTools 插件,学习计划及其周期
javascript·学习·html·开发·utools
糊涂涂是个小盆友1 小时前
前端 - 使用uniapp+vue搭建前端项目(app端)
前端·vue.js·uni-app
NMBG221 小时前
[JAVAEE] 面试题(四) - 多线程下使用ArrayList涉及到的线程安全问题及解决
java·开发语言·面试·java-ee·intellij-idea
浮华似水1 小时前
Javascirpt时区——脱坑指南
前端
王二端茶倒水1 小时前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
_oP_i1 小时前
Web 与 Unity 之间的交互
前端·unity·交互