this,作用域与闭包

3,345 阅读18分钟

this

JS中的this指向非常灵活,可以根据函数的调用方式和所处环境的不同而发生变化。
在JS中,this关键字表示当前函数执行的环境。当一个函数被调用时,JS引擎会为该函数创建一个执行环境,其中包括了函数内部的变量和对象等。而this关键字则用来指代当前执行环境的上下文。

当我们在一个函数内部使用this关键字时,它会指向当前函数执行环境:一个DOM元素、一个全局对象或者是一个函数的实例等。如果我们想要在函数内部访问外部的变量或对象,就需要通过this关键字来引用它们。

this永远指向一个对象;
this的指向取决于函数调用的位置,或通过call、apply、bind修改
this 指向跟调用有关,跟定义无关
当绑定的this指向为null的时候,则this指向了window

全局作用域下的this

在JS中,全局作用域指的是没有包含在任何函数中的代码块。如果在全局作用域中使用this,它的指向就是浏览器的window对象(在Node.js中则是Global对象)
例如:

consloe.log(this); // window

需要注意的是:

<script>
function test(){
    consloe.log(this);//window
}
test()
</script>

在上述例子中test()指向并非test,这是因为this只会指向对象,不会指向其他的数据类型。
它在全局作用域里边,哪怕它写在函数里面,但这时的this始终指向全局。

在实际开发中,this的问题经常会导致程序出现意外的结果。为了避免这些问题,我们需要深入理解JS this的工作原理和作用域机制。同时,我们还可以利用闭包来实现一些实用的功能,eg:模块化、封装等。

样例1:

function fun(){
    console.log(this.s);
}
//对象初始化时,会绑定this
var obj = {
    s:'1',
    f:fun
}

var s = '2';
//this指向跟调用有关,跟定义无关
obj.f(); //1
fun(); //2

-> 那为什么在test中this指向全局,但是在此处obj.f却指向了obj对象?
因为:对象初始化时会绑定this。
-> 那为什么obj.f(); 和fun(); 的结果不一样?
因为:虽然它们都是调用fun(),但是前者是通过绑定obj,后者是直接调用fun(),那这样的话后者this指向的就是全局作用域。
总的来说就是:this指向跟调用有关,跟定义无关
如果想要前者也指向全局,可以用call(这在后面会补充)
样例2:

var btn = document.getElementById('btn');
btn.onclick = function(){
    this ;  // this指向本节点对象
}

样例3:

var obj = {
    fun:function(){
        this ;
    }
}

setInterval(obj.fun,1000);      // this指向window对象
setInterval('obj.fun()',1000);  // this指向obj对象

对象方法中(局部作用域下)的this

在JS中,一个对象可以包含多个方法。如果在对象的方法中使用this,它的指向就是该对象本身。
如下代码段中,this就指向obj对象:

let obj={
    name:'john',
    sayName(){console.log(this.name);}
}

obj.sayName();//john

但是!!!
对象的函数会进行 this 自动绑定,这并不代表函数的内部函数也会自动绑定 this

示例如下:

const obj2={
    name:"obj",
    fn:function () {
        consloe.log('fn1',this.name);//fn1 obj
        function fn2 () {
            consloe.log('fn2',this);//window
        }
        fn2();
    },
};
obj2.fn();

因此,通常我们想要封装一个方法或者库的时候,我们期望写完fn后再写一个fn(fn2)的指向能指向fn这个函数所对应的对象(obj2)时,其实是不好做的。
但是,可以通过箭头函数来解决this指向问题。

const obj2={
    name:"obj",
    fn:function () {
        consloe.log('fn1',this.name);//fn1 obj
        function fn2 = () => {
            consloe.log('fn2',this.name);//fn2 obj
        }
        fn2();
    },
};
obj2.fn();

箭头函数中的this

在JS中,箭头函数是ES6新增的语法。与传统的函数不同,箭头函数中的this指向调用该函数的上下文环境(即当前作用域所属的对象)。
例如,在如下代码段中,this指向obj:

let obj={
    name:'jack',
    func1:function(){
        setTimeout(() => console.log(this.name),1000);
    }
}

obj.func1();//jack

如果有需要更改函数内部this指向的需求,就不能使用箭头函数
因为,用了箭头函数再用call,并不好发生this指向偏移

构造函数中的this

在函数JS中,构造函数用于创建对象。每个构造函数都包含一个this关键字,它指向新构造的对象。
例如,如下代码段中,this指向新创建的Person对象:

const name="I am window"'

function Person(name){
    this.name=name;
    this.sayName = function () {
        consloe.log(this.name);//p1
        function fn(){
            console.log(this.name);//指向window的name
        }
        fn()//在没有在外部定义name时,打印为空;
            //定义name后,打印为:I am window
    };
}
//构造器函数,通过 new 关键字调用
const p1=new Person('P1');
p1.sayName();

由上可知,this 在构造器中其实和在对象中类似。

借助函数实现 this 指向偏移

call、apply、bind都可以改变this的指向

它们可以用来改变函数执行环境、实现函数的高阶调用、绑定函数的上下文等。

  • call():将函数的this指定到call()的第一个参数值,同时将剩余参数指定的情况下,调用某个函数或方法
    例1:
var obj1 = {
    a: 10,
    fn: function(x) {
        console.log(this.a + x)
    }
}

var obj2 = {
    a : 20,
    fn: function(x) {
        console.log(this.a - x)
    }
}

obj1.fn.call(obj2, 20) //40

obj1.fn fnthis指向到 obj2,最后还是执行 obj1.fn 中的函数。
例2:

<div class="wrapper"></div>
<div class="wrapper"></div>
<div class="wrapper"></div>
<div class="wrapper"></div>

<script>
const wrappers=document.querySelectoeAll(".wrapper");
console.log("->",wrappers)//NodeList(4)
const wrapperDoms = Array.prototype.slice.call(wrappers);//call其实就是做了个转化,让slice内部指向wrappers(这一步其实就是把一个节点list转换为数组)
//其实这一步还有个更简单的:
const wrapperDoms2=Array.from(wrappers);//将类数组转换成数组
console.log("->",wrapperDoms)//(4)
console.log("->",wrapperDoms2)//(4)
</script>
  • apply():和call基本一致,两者唯一的不同是:apply 的第一个参数还是调用函数this的指向,第二个参数是数组[arg1, arg2...],call的第二参数是列表(arg1, arg2...)
var name = 'AA'
var obj = {
    name: '林一一',
    fn: function() {
        return `${this.name + [...arguments]}`
    }
}
obj.fn.apply(window, [12, 23, 34, 56])    // "AA 12,23,34,56"
  • bind():bind()和 call、apply 的区别是 它不会立即执行,而是返回一个新的函数。即bind()方法会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。
//这个新函数就是调用 bind 的函数,bind 调用后会将调用 bind 的函数拷贝一份返回。

var name = 'BD'
var obj = {
    name: '林一一'
}

function fn(){
    return `${this.name} ` + [...arguments]
}

let f = fn.bind(obj, 12, 23, 45, 67, 90)
f() // "林一一 12,23,45,67,90"

上述代码段的新函数就是 f(),f() 就是 bind 拷贝函数 fn后返回的。

<div onclick="say.call({name: 'he'})">111</div>
<div onclick="say.bind({name: 'he'})()">222</div>

<script>
function say(){
    console.log(this,"hello world");
}
</script>

正是因为bind不会立即执行,而是返回一个函数,所以在上述例子里与call的写法不同,因为如果不加上(),它就不会被调用。

实现call (以面试题的形式出现,面试官想考察的是你对 this 的理解):

首先,我们需要理解原型链
其次,我们需要知道创建对象的整个过程。

简易版:

Function.prototype.myCall = function (context){
    //this 指向调用 myCall 的函数
    //1.将函数挂载到 context 上
    context.fn=this;
    //2.执行函数
    context.fn();
    //3.删除函数
    delete context.fn;
};

const a={
    name:"qiyu",
};

function sayName(){
    console.log(this.name);
}
sayName.myCall(a);//"qiyu"

-> 为什么执行函数又删掉?
因为:给context加上了一个fn,执行完了得删掉,不然就破坏了context的完整性了。

其实,可以这么理解:
sayName执行了myCall想让asyName的this指向a,那相当于:

const a={
    name:"qiyu",
    sayName(){
        console.log(this.name);
    }
};

而 context.fn=this; 的意思其实就是把 sayName 这个函数挂到a上(如上)
这个时候this确实指向了a,但是多了一个属性,所以需要把这个属性删掉。

至此,还需要考虑到参数问题、this为null的问题以及返回值的问题。

完善后:

Function.prototype.selfCall=function( context, ...params ){
    context == null ? context = window :null;//如果不传入第一个参数或者第一个参数为null ,则指向window
    //Object 此时充当了一个构造函数的作用,可以理解为类型转换
    !/^(object|function)&/.test(typeof context ) ? context=object(context):null// 需要保证context必须是对象类型的值:只有对象才能设置属性
    let _this=this, 
        result=null,
        UNIQUE_KET = Symbol('UNIQUE_KEY')//新增的属性名保证唯一性,防止污染了原始对象中的成员
    context[UNIQUE_KEY]=_this
    result = context[UNIQUE_KEY](...params)
    delete context[UNIQUE_KEY]
    return result
};
  • 判断context是不是没传,这里偷懒了,undefind==null是ture
  • 判断context是不是引用数据类型的,不是的话转成引用数据类型
  • 保留this指向,创建一个Symbol key,不乱写一个是怕自己定义的域context上的冲突
  • 把当前函数放到context中,即改变等会函数执行的this指向
  • 执行函数,注意:这里函数执行是通过context.UNIQUEE_KEY()执行的,也就是函数的this指向context,拿到结果
  • 返回结果

实现bind

Function.prototype.selfBind=function(context,...outParams){
    let _this=this
    return function(...innerParams){
        _this.selfCall(context, ...outParams.concat(...innerParams))
    }
}

事件绑定中的this

在Web开发中,经常需要将某个函数绑定到DOM元素的事件上。此时,函数中的this指向通常是绑定事件的元素。
例如,在下面的代码中,this指向button元素:

<button id="btn" onclick="consloe.log(this);">Click me</button>

函数作为参数和返回值时的this

很多开发过程中,this 通常可以返回

function $(selector){
    //querySelectorAll返回的是一个类数组
    //querySelector返回的是一个DOM对象
    const doms=Array.prototype.slice.call(
        document.querySelector(selector)
    );
    return{
        css: function(styleobj){
            Object.entries(styleobj).forEach(([key,value]) => {
                if(Array.isArray(doms)){
                    doms.forEach(dom) =>{
                        dom.style[key]=value;
                    });
                    return this;
                }
                dom.style[key]=value;
            })
            console.log("css");
            return this;
        },
        animate: function(style){
            console.log("animate",style);
            return this;
        },
    };
}

$(".wrapper")
    .css({
        color: "red",
    })
    .animate({
        width: "100px",
    });

JS中的函数可以被传递给其他函数作为参数或返回值。如果一个函数作为参数被传递给另一个函数,那么其中的this指向可能会发生变化。这在很多框架中处理链式调用时常用。 例如,在如下代码段中,this指向全局对象window而不是obj:

let obj={name:'Lily'};

function func2(){
    consloe.log(this.name);
}

function func1(){
    func2();
}

func1.call(obj);//undefined

同样的,在下面的代码段中,this也不会指向obj:

let obj ={name:'Lily'};

function func2(){
    consloe.log(this.name);
}

function func1(){
    return func2;
}

let f=func1().bind(obj);
f();//undefined

this可能出现的坑点

由于JS中的this指向比较灵活,所以在实际开发中,可能会出现一些常见的坑点。
例如,在下面的代码段中,this指向全局对象Window而不是obj:

let obj ={name:'Lucy'};

document.querySelector('#btn').addEventListener('click',function(){
    console.log(this.name);
})

同样的,在下面的代码段中,this也不会指向obj:

let obj={name:'Lucy'};

setTimeout(function(){
    consloe.log(this.name);
},1000);

这是因为,在第一个例子中,事件绑定时的this实际上是指向触发事件的元素,而不是obj。在第二个例子中,由于setTimeout不属于任何对象,所以this的指向默认为全局对象window。

按绑定形式分类

this指向方式可以总结为三点:

  • 默认绑定:就是我们正常书写代码,this的指向处理,全局作用域下的this
  • 隐式绑定:在函数调用时机,this的指向取决于当前上下文环境
  • 显式绑定:通过使用apply、call、bind函数改变this指向

存储空间与context

程序自始至终都是在解决这样两件事:1.存储;2.运算
存储其实包含我们工作中常用的数据结构
运算包含我们工作中经常使用的算法
而在存储与运算的过程中,变量与执行环境的状态是我们需要重点关注的

封装框架的时候,需要时刻注意 this 的指向,因此通常我们都会使用一个对象——context

作用域

在JS中,变量的作用域分为两种:词法作用域动态作用域
词法作用域指的是变量只能在定义它的代码块内使用,一旦离开该代码块,变量就会消失。而动态作用域则是指变量可以在函数内部被重新定义,并且可以在不同的函数调用之间共享。

在JS中,函数内部的变量被称为局部变量,而函数外部的变量被称为全局变量。局部变量只能在函数内部被访问和修改,而全局变量则可以在任何地方被访问和修改。

为了解决作用域问题,JS还提供了一些特殊的语法结构,如with语句、IIFE(立即执行函数表达式、即调函数)等。

//即调函数
(function(){
    console.log(this);
})()

即掉函数通常会在面试的时候考一个内容:setTimeout

const doms=document.querySelectorAll(selector);
doms.forEach(dom,i) =>{
    dom.innerHTML=i;
    dom.addEventListener("click",function(){
        setTimeout(() => {
            console.log(i);
        },1000)
    })
});

with语句可以用来简化对全局变量的引用IIFE则可以将函数的作用域限制在当前代码块内部,避免污染全局命名空间。

闭包

闭包的概念

闭包是指一个函数能访问并操作其创建时所在的词法作用域中的变量的能力。换言之就是闭包就是能够将变量保存到函数外部的一种机制

在JS中,我们可以通过两种方式来创建闭包: 使用嵌套函数和使用bind()、apply()、call() 等方法。
使用嵌套函数的方式比较常见,它通常用于实现模块化编程、封装私有属性等场景。而使用bind()、apply()、call()等方法则可以用来改变函数执行环境、实现高阶调用等。

闭包有很多应用场景,比如可以使用它来实现私有属性、缓存计算结果、模拟事件处理程序等。
但是,闭包容易导致内存泄漏、难以维护等。
因此,在使用闭包时需要注意这些缺点,并采取相应的措施来避免它们的出现。

期望使用闭包解决的问题
1.函数内部的变量,私有变量
2.缓存计算,Recat中用很多
3.模拟事件

来段代码

//这种函数在使用的过程中其实很危险,因为age是全局变量,任何地方都可以修改
let age = 18;
function cat(){
    age++;
    console.log(age);// cat函数内输出age,该作用域没有,则向外层寻找,结果找到了,输出[19];
}
cat();//19
cat();//20

因为age是全局变量,任何地方都可以修改,所以我们需要使用闭包来隔离变量。

//这部分放上述代码段(并将后两行注释掉)

//通过闭包来实现私有变量的拒绝访问
function person(){
    var age = 18;
    function cat(){
        age++;
        console.log(age);
    }
    return cat;
}
const p=new person();
p();// 19  即cat() 这样每次调用不在经过age的初始值,这样就可以一直增加了
p();// 20

const p2=new person();//这里其实就是产出一个新的
p2();// 19
p2();// 20

闭包的使用场景

闭包的使用场景很广泛,特别在函数式编程思想中,闭包的使用场景更多,我们列举一些具体的使用场景,包含:

  1. 函数柯里化
  2. 单例模式处理、工厂模式处理
  3. react hooks

函数科里化

函数科里化通常用于函数式编程问题中
函数式编程:函数是一等公民,可以作为参数,也可以作为返回值。
科里化:将一个函数拆分成多个函数,每次调用只传递一个参数,这些函数会缓存这些参数,最后返回一个函数,这个函数会遍历之前缓存的参数,然后执行。

function add(a, b, c) {
  return a + b + c;
}
console.log(add(1)(2)(3));//会报错,因为它不是一个函数

那怎样能让它成为一个函数呢:

function curry(fn) {
  const arity = fn.length;

  return function curried(...args) {
    //递归终止
    if (args.length >= arity) {
      return fn.apply(this, args);
    } else {
      return function (...moreArgs) {
        //参数一定进行合并
        //请注意函数调用的方式,call、apply
        return curried.apply(this, args.concat(moreArgs));//这里如果要用call就要写成:...args.concat(moreArgs)
      };
    }
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 输出 6

Compose 函数(面试里问的好像也多)
这个通常用在koa、redux 中间件的合并。

function compose(...funcs: Function[]) {
  if (funcs.length === 0) {
    return <T>(arg: T) => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce(
    (a, b) =>
      (...args: any) =>
        a(b(...args))//逐步调用(标准的洋葱模型)
  )
}

function fn1(x){
    return x+1;
}
function fn2(x){
    return x+2;
}

const fn=compose(fn1,fn2);
console.log(fn(1));//4

单例模式、工厂模式处理

class Person{
    constructor(){}
    static getInstance(){
        if(!Person.instance){
            Person.instance=newPerson();
        }
        return Person.instance;
    }
}
//单例模式————保证实例唯一
function Singleton () {}

const getSingletonInstance = (function () {
  let instance = null
  return function () {
    //如果创建实例,就返回该实例,如果没有,返回一个新实例
    if (!instance) {
      instance = new Singleton()
    }
    return instance
  }
})()

const s1 = getSingletonInstance()
const s2 = getSingletonInstance()

console.log(s1 === s2) // true

const p1=Person.getInstance();
const p2=Person.getInstance();
console.log(p1 === p2) // true
//工厂模式——为了屏蔽创建对象的复杂度,只需要传递参数,就利用创建对象
function createPerson(name) {
    const privateProperties = {}

    const person = {
        setName(name) {
            if (!name) {
                throw new Error('A person must have a name')
            }
            privateProperties.name = name
        },
        getName() {
            return privateProperties.name
        }
    }

    person.setName(name)
    return person
}

person = createPerson('John')
console.log(person.getName())
// => John
person.setName('Michael')
console.log(person.getName())
// => Michael

闭包在 react hook 设计中的应用

基本原理

React Hook 是 React 16.8 引入的新特性,它可以让你在函数组件中使用状态管理和其他 React 特性。React 16.8 中的 Hook 采用闭包的方式实现了状态的保存和更新。

在函数组件中,每次渲染时都会重新执行函数,那么状态和变量的值也会被重新初始化。为了在不同的渲染周期中保留状态值,Hook 采用了闭包的机制实现了状态的一致性。闭包是指一个函数可以访问并修改其外部作用域中的变量的能力。

比如 useState Hook 就是通过闭包来保存状态的。useState 是一个函数,它返回一个包含状态值和更新状态值的数组,如下所示:

const [count, setCount] = useState(0);

在首次渲染组件时,useState 函数会被调用,闭包会留住 count 变量,使得组件重渲染时可以保持状态值的连续性。同时,setCount 函数也被保存在闭包中,以便后续更新状态值。

因此,React Hook 通过闭包引用了上一次渲染的状态和函数,实现了状态的一致性和可更新性。这样我们就可以在函数组件中使用像类组件中那样的状态管理和其他 React 特性了。

手写实现 react hook

当我们调用 useState Hook 时,它会返回一个数组,这个数组包含当前状态值和一个更新状态值的函数(常用的命名方式是 [state, setState])。我们可以使用这个函数来更新状态值,并在组件的多次渲染中保留状态。

具体来说,useState 的实现方式是通过闭包将状态值保存在函数组件在第一次渲染时的作用域中,这样就可以在函数组件的任意一次渲染中访问到这个状态值。

让我们看一下下面这个例子:

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

实际上,React 在内部会维护一个私有的哈希表来保存每个 useState 的状态值,哈希表的键是每个 useState 函数的调用序号,值是当前状态的值。每个 useState 函数的调用序号保证了每个 useState 调用的唯一性,而当前状态的值是根据调用序号从哈希表中获取的。

说回闭包,这些 useState 函数通过闭包保留了它们被调用时的作用域(也就是函数组件)中的状态值。这些状态值在组件多次渲染时会一直存在,并被用于更新组件中的内容。

下面是 useState 官方实现的简化版代码,以帮助更好地理解闭包的实现原理:

let state = []; // 声明一个数组保存所有的 state 值
let setters = []; // 声明一个数组保存所有的 setState 函数

let firstRun = true; // 定义首次运行的标志

function useState(initialValue) {
  if (firstRun) {
    // 如果是首次运行,则向 state 数组推入初始化的值
    state.push(initialValue);
  }

  const currentIndex = state.length - 1; // 获取当前 state 值的索引
  function setState(newState) {
    state[currentIndex] = newState; // 根据索引更新 state 数组的某一项
    render(); // 触发组件重新渲染
  }

  if (firstRun) {//每次执行都需要处理状态和设置状态的方法,压栈的过程
    // 如果是首次运行,则向 setters 数组推入对应的 setState 函数
    setters.push(setState);
  }

  firstRun = false; // 将首次运行的标志设为 false

  return [state[currentIndex], setState];//从这里就可以看出,为什么react hooks不能写在判断里面
}

function render() {
  firstRun = true; // 要设置首次运行的标志,以避免新的 state 状态被重复推入 state 数组
  index = 0; // 声明计数器,记录当前渲染的子组件的下标(即 useState 调用的序号)
  ReactDOM.render(<App />, document.getElementById("root")); // 渲染组件
}

在这个例子中,useState 函数返回的数组包含了当前状态值和一个更新状态值的函数(我们称为 setState 函数)。具体来说,useState 函数通过 push 方法向 state 数组推入初始值,并通过 push 方法将 setState 函数推入 setters 数组。在组件的某一次渲染中,我们可以从 state 数组中获取当前的状态值,而在 setState 函数被调用时,则会根据索引更新当前状态值。

通过这种方式,React 可以很好地管理函数组件中的状态,并且可以在不使用类组件的情况下使用各种 React 特性。

避免内存泄漏

下面是一些清除闭包变量和函数的方法:

手动清除:在闭包的最后,手动将外部变量和函数置为 null,以释放内存。例如:

function outer() {
  let a = 1;
  return function inner() {
    console.log(a);
    a = null; // 手动清除外部变量
    inner = null; // 手动清除内部函数
  }
}

在上面的例子中,我们在 inner 函数的最后手动将 a 变量和 inner 函数置为 null,以释放内存。

使用 IIFE:可以使用立即执行函数表达式(Immediately Invoked Function Expression,IIFE)来创建一个独立的作用域,并在作用域结束时自动清除变量和函数。例如:

function outer() {
  let a = 1;
  return (function() {
    console.log(a);
  }());
}

避免循环引用:当闭包和外部对象之间存在循环引用时,需要特别注意变量的清除。可以在外部对象中定义一个方法来清除闭包变量。例如:

function outer() {
  let a = 1;
  const obj = {
    inner: function() {
      console.log(a);
    },
    clear: function() {
      a = null;
      obj.inner = null;
    }
  };
  return obj;
}

面试常问

1.  谈谈你对 this 指向问题的理解
2.  箭头函数的一些特性
3.  手写实现 call、apply、bind 函数
4.  new 创建一个对象时,做了哪些事情
5.  闭包的概念
6.  什么时候需要使用闭包
7.  函数柯里化
8.  React Hooks 的原理