【JavaScript】原型链/作用域/this指针/闭包

1.原型链

参考资料:Annotated ES5

ECMAScript起初并不支持如C++、Smalltalk 或 Java 中"类"的形式创建对象,而是通过字面量表示法或者构造函数创建对象。每个构造函数都是一个具有名为"prototype"的属性的函数,该属性用于实现基于原型的继承和共享属性。通过在new表达式中使用构造函数来创建对象;例如,new Date(2009,11) 创建一个新的 Date 对象。

继承:指的是将特征从父级传递给子级,以便新的代码段可以重用并基于现有代码的特性进行构建。

原型对象:由构造函数创建的每个对象都对其构造函数的"prototype"属性的值有一个隐式引用(称为该对象的原型对象),即在下列代码中,称"Animal.prototype"为"animal的原型对象","Animal.prototype"本身也是个对象。

javascript 复制代码
/*构造函数*/
function Animal(name,weight){
    this.name = name
    this.weight = weight
}
/*在new表达式中使用构造函数创建的对象*/
const animal = new Animal('米粒',10)

console.log('animal的原型:',Animal.prototype)

原型链 :原型对象本身也有自己的原型,以此类推,直到到达一个原型为 null 的对象(null 没有原型而作为引用链的终点)。我们称这一条完整的隐式引用链为原型链(prototype chain)。原型链的作用是为了实现继承和共享属性。

proto:对象的内置属性(非标准的属性,但已经被绝大部分Javascript引擎实现,对应于ECMAScript中的[[prototype]],ECMAScript标准的获取对象原型的API是Object.getPrototypeOf() ),用于指向该对象的原型对象prototype,即:

javascript 复制代码
/*构造函数*/
function Animal(name,weight){
    this.name = name
    this.weight = weight
}
/*在new表达式中使用构造函数创建的对象*/
const animal = new Animal('米粒',10)

console.log('animal的原型:',Animal.prototype)
console.log('animal的原型:',animal.__proto__)
console.log('animal.__proto__===Animal.prototype:',animal.__proto__===Animal.prototype)
/*通过Object.getPrototypeOf()规范地获取animal的原型*/
console.log('animal的原型:',Object.getPrototypeOf(animal))

constructor: 原型对象prototype上都有个预定义的constructor属性,用来引用它的函数对象。这是种循环引用的目的是允许人们从任何实例访问原始构造函数。

javascript 复制代码
/*构造函数*/
function Animal(name,weight){
    this.name = name
    this.weight = weight
}
/*在new表达式中使用构造函数创建的对象*/
const animal = new Animal('米粒',10)
// Animal.prototype.constructor === Animal: true
console.log('Animal.prototype.constructor === Animal:',Animal.prototype.constructor === Animal)

注意要点:

(1)构造函数是一个函数对象,所以其原型是Function.prototype。

(2)原型对象是一个对象,所以其原型是Object.prototype。

(3)Object.prototype.proto 指向 null,null是原型链的终点。

(4)尝试访问对象的属性时,不仅要在对象上查找该属性,还要在对象的原型prototype、原型的原型等上查找该属性,直到找到具有匹配名称的属性或到达原型链的末尾。

完整原型链示例图:(其中Animal、Object、Function是构造函数)

2.作用域

参考文档:ECMA-262-5 in detail. Chapter 3.1. Lexical environments: Common Theory. -- Dmitry Soshnikov

作用域(Scope):一个封闭的上下文,用于管理程序不同部分的变量的可见性和可访问性。我们也可以说,作用域是一个逻辑边界,其中的变量(或表达式)有其独特的含义。例如,全局变量、局部变量等,通常反映变量生存期(或范围)的逻辑范围。

作用域属性:嵌套、变量解析方式(静态作用域和动态作用域)。

作用域链:由于作用域可以逐层嵌套,所以各层作用域组成了一个逻辑链,我们称其为作用域链。当访问某个变量时,js引擎会从当前作用域开始在包裹当前作用域的父级作用域中查找,若未找到,则再向上查找父级作用域,以此类推。从执行上下文角度看,作用域链是由环境记录(EnvironmentRecord)中的外部环境引用属性(OuterEnv)串联而成。

块级作用域: ES6 之前, ECMAScript 不支持块级作用域:

javascript 复制代码
var x = 10;
 
if (true) {
  var x = 20;
  console.log(x); // 20
}
 
console.log(x); // 20

可以通过立即调用函数表达式IIFE实现:

javascript 复制代码
var x = 10;
 
if (true) {
  (function (x) {
    console.log(x); // 20
  })(20);
}
 
console.log(x); // 10

通过函数作用域隔离变量,实现了个性化块级作用域效果,这也是ES6之前js模块化开发的依据。直到ES6 中标准化了 let 关键字,创建块范围的变量就变得方便多了:

javascript 复制代码
let x = 10;
 
if (true) {
  let x = 20;
  console.log(x); // 20
}
 
console.log(x); // 10

**静态作用域:**亦称词法作用域。在静态作用域中,标识符指向其最近的词法环境,变量的作用域在代码编写时就确定的,并且在程序执行期间不会改变。具有以下特点:

  1. 定义位置决定作用域:变量的可访问范围由其定义的位置决定。
  2. 函数内部的变量:只能在该函数内部访问和使用。
  3. 块级作用域:可能存在块级别的作用域,限制变量的可见性。
  4. 稳定性:在运行时作用域不会改变。

例如,下列例子中,变量 x 在全局作用域中进行了词法定义------这意味着在运行时它也在全局作用域中进行解析,即解析为10。而对于 y 这个变量,我们定义了两次。但正如前面所说,总是考虑包含该变量的最近的词法作用域。自身作用域具有最高优先级并被首先考虑。因此,在 bar 函数的情况下,y 变量被解析为 30。bar 函数的本地变量 y 被称为遮蔽了全局作用域中同名的变量 y。然而,在 foo 函数的情况下,同名的 y 被解析为 20------即使它是在包含另一个 y 的 bar 函数内部被调用的。也就是说,标识符的解析独立于调用者的环境(在这种情况下,bar 是 foo 的调用者,而 foo 是被调用者)。同样,这是因为在定义 foo 函数时,具有 y 名称的最近的词法上下文------是全局上下文。

javascript 复制代码
var x = 10;
var y = 20;
 
function foo() {
  console.log(x, y);
}
 
foo(); // 10, 20
 
function bar() {
  var y = 30;
  console.log(x, y); // 10, 30
  foo(); // 10, 20
}
 
bar();

动态作用域:在程序运行时才能确定变量的作用域。在该作用域中,变量不是在词法环境中解析,而是在动态形成的全局变量堆栈环境中解析。每次遇到变量声明,只是将变量的名称放在堆栈上。变量的作用域(生命周期)结束后,变量将从堆栈中移除(弹出)。这意味着,对于单个函数,我们可能有相同变量名的无限解析方式------这取决于调用该函数的上下文。

Javascript使用的是静态作用域。虽然ECMAScript中的with指令和eval指令具有动态效果,但不像标准动态作用域定义中那样涉及全局变量堆栈,这两个指令对静态作用域起到的效果可以称之为:"Runtime scope augmentation"(运行时作用域增强)

可执行代码: 在ECMAScript 中有三种可执行代码:

(1)全局代码global code:是被视为 ECMAScript 程序的源文本,在全局作用域中执行的代码。

(2)Eval 代码eval code:通过 eval 函数动态执行的字符串形式的代码。提供给内置 eval 函数的源文本。更准确地说,如果内置 eval 函数的参数是 String,则将其视为 ECMAScript 程序。特定调用的 eval 代码 eval 是该程序的全局代码部分。

(3)函数代码function code:作为 FunctionBody 的一部分进行分析的源文本,包含在函数定义内部的代码。

全局代码在程序的任何地方都可以访问和执行。函数体代码只有在函数被调用时才会执行。eval 函数可以动态地解析和执行字符串中的代码,但由于其安全性和性能方面的考虑,在实际开发中应谨慎使用。

每种可执行代码在执行前,会经js引擎编译并创建对应的执行上下文。全局代码在全局上下文中执行,函数代码在函数上下文中执行,Eval代码在Eval上下文中执行。每一个js文件只有一个全局上下文。

执行上下文: 包含跟踪其关联代码的执行进度所需的任何状态。每个执行上下文均包含三个状态组件:

(1)词法环境(LexicalEnvironment):用于实现作用域。包含两个组成部分:

  • 环境记录(EnvironmentRecord):将变量名映射到变量值(类似于Map)。作用域变量的实际存储空间。记录中的**「名称-值」**条目称为绑定。

  • 对外部环境的引用(OuterEnv):当前可以访问的外部词法环境,是一个抽象类,有3个具体的子类:
    A.声明式环境记录:又派生出函数环境记录和module环境记录。
    B.对象环境记录;
    C.全局环境记录。

    因此,可以认为存在三种作用域:
    A.声明式作用域:可以通过 var/const/let/class/module/import/function生成。ES6块级作用域是函数作用域的子级。
    B.对象作用域:使用with关键字指定代码块的作用域范围:

    javascript 复制代码
    function test(){
        let ob = { name:'javascript' };
        // 利用with扩展了作用域
        with(ob){
            console.log(`Hello ${name}`)
        }
    }
    
    test(); // Hello javascript

    C.全局作用域:最外层的作用域。Outer Env为null。通过声明式环境记录(使用内部对象存储变量)和对象环境记录(将变量存储在全局对象中)来管理变量。

(2)变量环境(VariableEnvironment):本质是一个对象,记录此执行上下文中由 VariableStatements 和 FunctionDeclarations 定义的变量和函数(即var和function声明的标识符)。

(3)this绑定(ThisBinding):与此执行上下文关联的 ECMAScript 代码中 this 的关键字关联的值。

ES6中执行到代码块时,如果代码块中有 let 或者 const 声明的变量,针对变量的查询路径为: 1. 词法环境 2. 变量环境 3. OuterEnv对象(上一层作用域继续先1后2)

工作过程:当控制转移到 ECMAScript 可执行代码时,控制正在进入执行上下文。活动执行上下文在逻辑上形成一个堆栈。此逻辑堆栈上的顶级执行上下文是正在运行的执行上下文。每当将控制从与当前正在运行的执行上下文关联的可执行代码传输到与该执行上下文无关的可执行代码时,都会创建一个新的执行上下文。新创建的执行上下文被推送到堆栈上,并成为正在运行的执行上下文。

作用域和执行上下文的关系:作用域是执行上下文有权访问的一组有限的变量或对象,同一个执行上下文上可能存在多个作用域,每个执行上下文均有自己的作用域。

执行上下文堆栈(ECS:Execution context Stack)ECS:ECMAScript 用来管理函数执行的调用堆栈模型称为执行上下文堆栈。工作过程如下:

  • 首先创建全局执行上下文, 压入栈底
  • 每当调用一个函数时,创建函数的函数执行上下文。并且压入栈顶
  • 当函数执行完成后,会从执行上下文栈中弹出,js引擎继续执栈顶的函数。

变量提升: js代码在执行前还有一个编译的过程,在编译过程中,var变量和function函数部分会被js引擎放入到对应上下文的变量环境中,并且变量会被默认设置为undefined。在执行阶段,js引擎才会在变量环境中查找到声明的变量和函数。在程序员角度看来,就出现了变量在声明前就可以访问,并且函数在定义之前便可调用的现象。我们称该现象为变量提升。

需要注意的是,var变量只有创建和初始化被提升,赋值并没有被提升;而function的创建、初始化和赋值均会被提升。所以在变量的声明之前访问,该变量的值是undefined,而函数则可以在声明之前正常调用。

暂时性死区 :ES6中引入了let、const关键字,解决了变量提升问题,使得js支持块级作用域。严格意义上,letconst没有解决变量提升问题,当js代码被编译时,letconst变量代码会被存放在词法环境中。此时letconst变量已经被提升到了作用域顶部,但是只是声明被提升,初始化和赋值并没有被提升,如果在赋值代码行之前去读写该变量,便会报错。我们将这种从作用域开始到赋值变量代码之前暂时无法读写对应变量的区域称为"暂时性死区"

头等函数( first-class functions ):可以以变量使用的函数,如:将函数作为另一个函数的参数,将函数赋值给变量,将函数作为另一个函数的返回值。Javascript就是具有头等函数的语言。

**高阶函数:**满足两个条件之一的函数:接受函数作为参数传递的函数;返回一个另一个函数的函数。

**回调函数:**被当做参数传递给另一个函数的函数。

3.this指针

this是一个的关键字,用于指向一段代码(如函数的主体)应该运行的上下文。JavaScript 中"this"的值取决于函数是如何被调用的(即运行时绑定),而不是它是如何被定义的。

当一个普通函数作为对象的方法被调用(obj.method())时,"this"指向该对象。

当作为一个独立的函数被调用(没有附着在任何一个对象上:func())时,"this"通常指的是全局对象(在非严格模式下)或undefined(在严格模式下)。Function.prototype.bind()方法可以创建一个"this"绑定不会改变的函数,并且方法 apply()和 call()也可以为特定的调用设置"this"值。

箭头函数在处理 this 时有所不同:它们在定义时从父作用域继承 this。这种行为使箭头函数对于回调和保留上下文特别有用。然而,箭头函数没有自己的 this 绑定。因此,它们的 this 值不能通过 bind()、apply() 或 call() 方法进行设置。

在非严格模式下, this 始终指向一个对象。在严格模式下,可以指向任何值。this的取值由其出现的上下文环境决定,具体情况如下:

(1)函数上下文

在函数内部,this 的值取决于函数如何被调用。可以将 this 看作是函数的一个隐藏参数(就像函数定义中声明的普通参数一样),this 是在函数体被执行时创建的绑定。

对于典型的函数,this 的值是函数被访问的对象。换句话说,如果函数调用的形式是 obj.f(),那么 this 就指向 obj

严格模式下:

如果函数在没有被任何东西访问的情况下被调用,this 将是 undefined。

非严格模式下:

如果一个函数被调用时 this 被设置为 undefinednull,则this 会被替换为globalThis;

如果函数被调用时 this 被设置为一个原始值,this 会被替换为原始值的包装对象。

javascript 复制代码
function bar() {
  console.log(Object.prototype.toString.call(this));
}

bar.call(7); // [object Number]
bar.call("foo"); // [object String]
bar.call(undefined); // [object Window]

(2)全局上下文

在脚本的顶层,无论是否在严格模式下,this 会指向globalThis。如果源代码放在 HTML 的<script>元素内并作为脚本执行,则this === window

如果源代码作为模块加载(对于 HTML,这意味着在 <script> 标签中添加 type="module"),在顶层,this 总是 undefined

如果源代码使用eval()执行,this 与直接调用eval() 的闭合上下文相同,或者与间接调用 eval 的 globalThis(就像它在单独的全局脚本中运行一样)相同。

bind():

调用f.bind(someObj)会创建一个新函数,这个新函数具有与 f 相同的函数体和作用域,但 this 的值永久绑定(一旦绑定,后续再次绑定到其他对象的操作将会被忽略,绑定不生效但入参生效)到 bind 的第一个参数,无论函数如何被调用。调用绑定函数通常会执行其所包装的函数,也称为目标函数(target function) 。绑定函数将绑定时传入的参数(包括 this 的值和前几个参数)提前存储为其内部状态,而不是等到实际调用时才传入。

javascript 复制代码
"use strict"; // 防止 `this` 被封装到到包装对象中

function log(...args) {
  console.log(this, ...args);
}
const boundLog = log.bind("this value", 1, 2);
const boundLog2 = boundLog.bind("new this value", 3, 4);
boundLog2(5, 6); // "this value", 1, 2, 3, 4, 5, 6

用法:其中arg1,arg2......可选,

javascript 复制代码
bind(thisArg)
bind(thisArg, arg1)
bind(thisArg, arg1, arg2)
bind(thisArg, arg1, arg2, /* ..., */ argN)

手写实现

javascript 复制代码
Function.prototype.myBind = function () {
    const _this= this
    const args = Array.prototype.slice.call(arguments)
    const newThis = args.shift()
    return function () {
      return _this.apply(newThis, args)
    }
}

apply():

通常情况下,在调用函数时,函数内部的this的值是访问该函数的对象。使用 apply(),你可以在调用现有函数时将任意值分配给 this,而无需先将函数作为属性附加到对象上。

可以使用任何类数组对象作为第二个参数。实际上,这意味着它需要具有 length 属性,并且整数("索引")属性的范围在 (0..length - 1) 之间。像 { 'length': 2, '0': 'eat', '1': 'bananas' }这样的对象或['a','b']这样的数组甚至直接使用arguments,当然还可以使用剩余参数或者展开运算符。

一般而言,fn.apply(null, args) 等同于使用参数展开语法的 fn(...args),只是在前者的情况下,args 期望是类数组对象,而在后者的情况下,args 期望是可迭代对象。

用法如下:

javascript 复制代码
apply(thisArg)
apply(thisArg, argsArray)

手写实现

javascript 复制代码
Function.prototype.myApply = function (context, args) {
    context = context || window
    args = [...args]
    const fn = Symbol()
    context[fn] = this
    const result = context[fn](...args)
    delete context[fn]
    return result
}

call():

功能与apply一致,只不过传参不同,函数的参数以列表的形式逐个传递给 call()的。

javascript 复制代码
function greet() {
  console.log(this.animal, "的睡眠时间一般在", this.sleepDuration, "之间");
}

const obj = {
  animal: "猫",
  sleepDuration: "12 到 16 小时",
};

greet.call(obj); // 猫 的睡眠时间一般在 12 到 16 小时 之间

用法:

javascript 复制代码
call(thisArg)
call(thisArg, arg1)
call(thisArg, arg1, arg2)
call(thisArg, arg1, arg2, /* ..., */ argN)

如果省略第一个 thisArg 参数,则默认为 undefined。在非严格模式下,this 值将被替换为全局对象globalThis。

手写实现

javascript 复制代码
Function.prototype.myCall = function (context) {
    context = context || window
    const arg = [...arguments].slice(1)
    const fn = Symbol()
    context[fn] = this
    const result = context[fn](...arg)
    delete context[fn]
    return result
}

bind()、apply()、bind()三者的异同

(1)相同点

bind、call、apply都能为函数内部的this分配指定值。 第一个参数均为this的目标值。均可以利用后续参数向目标函数传参。

(2)不同点

call和bind传参相同,均是以此传入多个参数。apply只有两个参数,第二个参数为数组或类数组。call和apply均是直接调用目标函数,而bind方法不会立即调用函数,而是返回一个修改this后的新函数。

(3)使用场景

call函数的使用多用于类的继承。

apply函数可配合Math.max()用于计算数组最大值等。

bind函数可用于函数内部有定时器,改变定时器内部的this指向。

4.闭包

概念:在 JS 中,根据词法作用域 的规则,内部函数总是可以访问其外部函数中声明的变量。当通过调用一个外部函数返回 一个内部函数后,即使该外部函数已经执行结束了。但是内部函数引用外部函数的变量依然保存在内存中,就把这些变量的集合称为闭包。简单而言,闭包是一个函数,其有权访问其词法作用域内部的变量即使该函数在词法作用域外部被调用。

柯里化

在JavaScript中,函数柯里化是一种将多个参数的函数转变为一系列接受单一参数的函数的过程。这种转变过程可通过闭包和递归的方式实现。利用闭包,可以形成一个不销毁的私有作用域,把预先处理的内容都存在这个不销毁的作用域里面。

javascript 复制代码
function multiply(a) {
  return function executeMultiply(b) {
    return a * b;
  }
}
const double = multiply(2);
double(3); // => 6
double(5); // => 10
const triple = multiply(3);
triple(4); // => 12
相关推荐
未来之窗软件服务23 分钟前
资源管理器必要性———仙盟创梦IDE
前端·javascript·ide·仙盟创梦ide
西哥写代码2 小时前
基于cornerstone3D的dicom影像浏览器 第十八章 自定义序列自动播放条
前端·javascript·vue
清风细雨_林木木2 小时前
Vue 中生成源码映射文件,配置 map
前端·javascript·vue.js
雪芽蓝域zzs2 小时前
JavaScript splice() 方法
开发语言·javascript·ecmascript
森叶3 小时前
Electron 主进程中使用Worker来创建不同间隔的定时器实现过程
前端·javascript·electron
霸王蟹3 小时前
React 19 中的useRef得到了进一步加强。
前端·javascript·笔记·学习·react.js·ts
霸王蟹3 小时前
React 19版本refs也支持清理函数了。
前端·javascript·笔记·react.js·前端框架·ts
codelxy4 小时前
vue引用cesium,解决“Not allowed to load local resource”报错
javascript·vue.js
程序猿阿伟5 小时前
《社交应用动态表情:RN与Flutter实战解码》
javascript·flutter·react native
明似水5 小时前
Flutter 开发入门:从一个简单的计数器应用开始
前端·javascript·flutter