JavaScript高级语法笔记(一):函数

89 阅读14分钟

执行上下文和作用域

基本概念

  • 变量和函数的上下文决定了他们可以访问哪些数据,以及他们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。
  • 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文概念始终位于作用域链的最前端。
  • 内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。每个上下文都可以到上一级上下文中去搜索函数和变量,但任何上下文都不能到下一级上下文中去搜索。

面试题

var n = 100;
function foo() {
  n = 200;
}
foo();
​
console.log(n); // 200
function foo() {
  console.log(n); // undefined
  var n = 200;
  console.log(n); // 200
}
​
var n = 100;
foo()
var n = 100;
function foo1() {
 console.log(n); // 100
}
​
function foo2() {
  var n = 200;
  console.log(n); // 200
  fool();
}
​
foo2();
console.log(n) // 100
var a = 100;
​
function foo() {
  console.log(a); // undefined
  return;
  var a = 100
}
​
foo();
function foo() {
  var a = b = 100
}
​
foo();
​
console.log(a) // not defined
console.log(b) // not defined

闭包

基本概念

  • 闭包(closure)指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
  • 函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了,在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。

Example

创建如下函数:

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
} };
}

在createComparisonFunction()函数中,匿名函数的 作用域链中实际上包含createComparisonFunction()的活动对象。下图展示了以下代码执行后的结果。

 let compare = createComparisonFunction('name');
 let result = compare({ name: 'Nicholas' }, { name: 'Matt' });

image-20220324190731650.png

在createComparisonFunction()返回匿名函数后,它的作用域链被初始化为包含createComparisonFunction()的活动对象和全局变量对象。这样,匿名函数就可以访问到createComparisonFunction()可以访问的所有变量。另一个有意思的副作用就是,createComparisonFunction()的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍有对它的引用。在createComparisonFunction()执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁。

闭包的缺点

因为闭包会保留它们包含函数的作用域,所以比其他函数更 占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十 分必要时使用。V8等优化的JavaScript引擎会努力回收被闭包困住 的内存,不过我们还是建议在使用闭包时要谨慎。

this指向

  • this在全局作用域下的指向

    • 浏览器:window
    • node:空对象{}
  • this指向与函数调用

    • 函数在调用时,JavaScript会默认给this绑定一个值
    • this的绑定和函数定义的位置无关
    • this的绑定和调用方式及调用的位置有关
    • this是在运行时被绑定的
  • 绑定规则

    • 默认绑定:独立函数调用(指向window)

      • 独立函数调用可以理解成函数没有被绑定到某个对象上进行调用;

      • example

        function foo1() {
          console.log(this); // window
        }
        ​
        function foo2() {
          console.log(this); // window
          foo1();
        }
        ​
        function foo3() {
          console.log(this); // window
          foo2();
        }
        ​
        foo3();
        
        var obj = {
          foo: function() {
            console.log(this); // window
          }
        }
        ​
        var bar = obj.foo;
        bar();
        
        function foo() {
          function bar() {
            console.log(this); // window
          }
          return bar;
        }
        ​
        var fn = foo();
        fn();
        
    • 隐式绑定:常见的调用方式是通过某个对象进行调用(指向该对象)

      • 也就是通过某个对象发起的函数调用

      • example

        var obj = {
          foo: function() {
            console.log(this); // obj
          }
        }
        ​
        obj.foo();
        
        var obj1 = {
          foo: function() {
            console.log(this); // obj2
          }
        }
        ​
        var obj2 = {
          bar: obj1.foo;
        }
        ​
        obj2.bar();
        
    • 显示绑定:使用call、apply和bind方法调用(由call、apply和bind指定绑定对象)

      • example

        function sum(num1, num2, num3) {
          console.log(num1 + num2 + num3, this);
        }
        ​
        sum.call('call', 20, 30, 40); // 90 call
        sum.apply('apply', 20, 30, 40); // 90 apply
        
    • new绑定:JS中的函数可以当做一个类的构造函数来使用,也就是使用new关键字(构造器)。

      • 使用new关键字来调用函数时,会执行如下操作:

        • 创建一个全新的对象
        • 这个新对象会被执行prototype链接
        • 这个新对象会绑定到函数调用的this上(this绑定在这个步骤完成)
        • 如果函数没有返回其他对象,表达式会返回这个新对象
      • example

        function Person(name, age) {
          this.name = name;
          this.age = age;
        }
        ​
        var p1 = new Person("jack", 20);
        console.log(p1.name, p1.age) // jack 20var p2 = new Person("rose", 20);
        console.log(p2.name, p2.age) // rose 20
        

函数的apply、call和bind方法

call和apply

调用方式:
  • apply():apply()方法接收两个参数:函数内this的值和一个参数数组。第二个参数可以 是Array的实例,但也可以是arguments对象。
  • call():call()方法与apply()的作用一样,只是传参的形式不同。第一个参数跟apply()一样,也是this值,而剩下的要传给被调用函数的参数则是逐个传递的。换句话说,通过call()向函数传参时,必须将参数一个一个地列出来。
作用:
  • 这两个方法都会以指定的this值来调用函数,即会设置调用函数时函数体内this对象的值,改变函数内部的this指向。
特点:
  • 是添加在函数原型上的方法(Function.prototype)
  • 调用 call() 和 apply() 的函数会立即执行。
  • call() 和 apply() 的返回值就是函数的返回值。
  • 在严格模式下,调用函数时如果没有指定上下文对象, 则this值不会指向window。除非使用apply()或call()把函数指定给 一个对象,否则this的值会变成undefined。
区别:
  • call()方法与apply()的作用一样,只是传参的形式不同。第一个参数 跟apply()一样,也是this值,而剩下的要传给被调用函数的参数则是逐个传递的。换句话说,通过call()向函数传参时,必须将参数一个一个地列出来。
手写call()和apply()
  • 手写call()

    Function.prototype.myCall = function (ctx) {
            // 判断上下文类型 如果是undefined或者 null 指向window
            // 否则使用 Object() 将上下文包装成对象
            const context = ctx == undefined ? window : Object(ctx);
            // 把函数的this指向ctx这个上下文呢
            context.fn = this;
            // 获取参数
            const args = [...arguments].slice(1);
            // 调用函数
            const result = context.fn(...args);
            // 将属性删除
            delete context.fn;
            // 函数返回值
            return result;
          };
    
  • 手写apply():call() 和 apply() 的唯一区别就是传递参数的不同,所以我们只需要改一下对参数的处理,其它的和 call() 一致就可以了

     Function.prototype.myApply = function (ctx) {
            // 判断上下文类型 如果是undefined或者 null 指向window
            // 否则使用 Object() 将上下文包装成对象
            const context = ctx == undefined ? window : Object(ctx);
            // 把函数的this指向ctx这个上下文呢
            context.fn = this;
            let result = null;
            // 获取参数和调用函数
            if (arguments[1]) {
              result = context.fn(...arguments[1]);
            } else {
              result = context.fn();
            }
            // 将属性删除
            delete context.fn;
            // 函数返回值
            return result;
          };
    

bind

调用方法:
  • bind()方法会创建一个新的函数实例,其this值会被绑定到传给bind()的对象。比如:

    window.color = 'red';
    var o = {
      color: 'blue'
    };
    function sayColor() {
      console.log(this.color);
    }
    let objectSayColor = sayColor.bind(o);
    objectSayColor();  // blue
    
作用:
  • 也是用来改变函数内部this的指向。
bind与call/apply的区别

是否立刻执行

  • call/apply改变了函数的this上下文后马上执行该函数。
  • bind则是返回改变了上下文后的函数, 不执行该函数 。

返回值的区别:

  • call/apply返回fun的执行结果。
  • bind 返回 fun 的拷贝,并指定了fun 的 this 指向,保存了fun 的参数。
手写bind()
Function.prototype.myBind = function (ctx) {
        // 下面的this就是调用_bind的函数,保存给_this
        const _this = this;
        // 获取参数
        const args = [...arguments].slice(1);
​
        // bind 要返回一个函数, 就不会立即执行了
        const F = function (...rest) {
          // 调用 call 修改 this 指向
          return _this.call(ctx, ...args, ...rest);
        };
​
        console.log(_this.prototype);
        if (_this.prototype) {
          // 复制源函数的prototype给newFn 一些情况下函数没有prototype,比如箭头函数
          F.prototype = Object.create(_this.prototype);
        }
​
        return F;
      };

纯函数

概念:
  • 相同的输入,总是会的到相同的输出,并且在执行过程中没有任何副作用。
副作用:
  • 副作用指的是函数在执行过程中产生了外部可观察变化。例如:

    • 发起HTTP请求
    • 操作DOM
    • 修改外部数据
    • console.log()打印数据
    • 调用Date.now()或者Math.random()
优点:
  • 更容易进行测试,结果只依赖输入,测试时可以确保输出稳定
  • 更容易维护和重构,我们可以写出质量更高的代码
  • 更容易调用,我们不用担心函数会有什么副作用
  • 结果可以缓存,因为相同的输入总是会得到相同的输出

组合函数

概念:
  • 需要将多个函数依次执行。将这些函数组合起来,自动依次执行,这个过程就是函数的组合(compose)。
用途:

假设我们有这样一个需求:给你一个字符串,将这个字符串转化成大写,然后逆序。

我们的常规思路如下:

let str = 'jspool'//先转成大写,然后逆序
function fn(str) {
    let upperStr = str.toUpperCase()
    return upperStr.split('').reverse().join('')
}
​
fn(str) // => "LOOPSJ"
复制代码

这段代码实现起来没什么问题,但现在更改了需求,需要在将字符串大写之后,将每个字符拆开并封装成一个数组:

"jspool" => ["J","S","P","O","O","L"]

为了实现这个目标,我们需要更改我们之前封装的函数,这其实就破坏了设计模式中的开闭原则。

  • 开闭原则:软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

那么在需求未变更,依然是字符串大写并逆序,应用组合的思想来怎么写呢?

原需求,我们可以这样实现:

let str = 'jspool'function stringToUpper(str) {
    return str.toUpperCase()
}
​
function stringReverse(str) {
    return str.split('').reverse().join('')
}
​
let toUpperAndReverse = compose(stringReverse, stringToUpper)
let result = toUpperAndReverse(str) // "LOOPSJ"

那么当我们需求变化为字符串大写并拆分为数组时,我们根本不需要修改之前封装过的函数:

let str = 'jspool'function stringToUpper(str) {
    return str.toUpperCase()
}
​
function stringReverse(str) {
    return str.split('').reverse().join('')
}
​
function stringToArray(str) {
    return str.split('')
}
​
let toUpperAndArray = compose(stringToArray, stringToUpper)
let result = toUpperAndArray(str) // => ["J","S","P","O","O","L"]

可以看到当变更需求的时候,我们没有打破以前封装的代码,只是新增了函数功能,然后把函数进行重新组合。

  • 可能有人会有疑问,应用组合的方式书写代码,当需求变更时,依然也修改了代码,不是也算破坏了开闭原则么?其实我们修改的是调用的逻辑代码,并没有修改封装、抽象出来的代码,而这种书写方式也正是开闭原则所提倡的。

我们假设,现在又修改了需求,现在的需求是,将字符串转换为大写之后,截取前3个字符,然后转换为数组,那么我们可以这样实现:

let str = 'jspool'function stringToUpper(str) {
    return str.toUpperCase()
}
​
function stringReverse(str) {
    return str.split('').reverse().join('')
}
​
function getThreeCharacters(str){
    return str.substring(0,3)
}
​
function stringToArray(str) {
    return str.split('')
}
​
let toUpperAndGetThreeAndArray = compose(stringToArray, getThreeCharacters,stringToUpper)
let result = toUpperAndGetThreeAndArray(str) // => ["J","S","P"]

从这个例子,我们可以知道,组合的方式是真的就是抽象单一功能的函数,然后再组成复杂功能,不仅代码逻辑更加清晰,也给维护带来巨大的方便。

实现compose函数

先看看compose函数到底做了什么事:

// compose(f,g)(x) === f(g(x))
// compose(f,g,m)(x) === f(g(m(x)))
// compose(f,g,m)(x) === f(g(m(x)))
// compose(f,g,m,n)(x) === f(g(m(n(x))))
//···

概括来说,就是接收若干个函数作为参数,返回一个新函数。新函数执行时,按照由右向左的顺序依次执行传入compose中的函数,每个函数的执行结果作为为下一个函数的输入,直至最后一个函数的输出作为最终的输出结果。

  • 实现

    通过数组的reduceRight函数来实现:

    function compose(...fns){
        return function(x){
            return fns.reduceRight(function(arg,fn){
                return fn(arg);
            },x)
        }
    }
    

    compose的数据流是从右至左的,因为最右侧的函数首先执行,最左侧的函数最后执行

    但是也有最左侧的函数首先执行,最右侧的函数最后执行的,从左至右处理数据流的过程称之为管道(pipeline)。

    管道(pipeline)的实现同compose的实现方式很类似,因为二者的区别仅仅是数据流的方向不同而已。

    对比compose函数的实现,仅需将reduceRight替换reduce即可:

    function pipe(...fns){
        return function(x){
            return fns.reduce(function(arg,fn){
                return fn(arg);
            },x)
        }
    }
    

函数柯里化

概念:
  • 柯里化(Currying)是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

    举例来说,一个接收3个参数的普通函数,在进行柯里化后, 柯里化版本的函数接收一个参数并返回接收下一个参数的函数, 该函数返回一个接收第三个参数的函数。 最后一个函数在接收第三个参数后, 将之前接收到的三个参数应用于原普通函数中,并返回最终结果。

    比如:

    //普通函数
    function fn(a,b,c,d,e) {
      console.log(a,b,c,d,e)
    }
    //生成的柯里化函数
    let _fn = curry(fn);
    ​
    _fn(1,2,3,4,5);     // print: 1,2,3,4,5
    _fn(1)(2)(3,4,5);   // print: 1,2,3,4,5
    _fn(1,2)(3,4)(5);   // print: 1,2,3,4,5
    _fn(1)(2)(3)(4)(5); // print: 1,2,3,4,5
    
用途:

柯里化实际是把简答的问题复杂化了,但是复杂化的同时,我们在使用函数时拥有了更加多的自由度。 而这里对于函数参数的自由处理,正是柯里化的核心所在。 柯里化本质上是降低通用性,提高适用性。来看一个例子:

我们工作中会遇到各种需要通过正则检验的需求,比如校验电话号码、校验邮箱、校验身份证号、校验密码等, 这时我们会封装一个通用函数 checkByRegExp ,接收两个参数,校验的正则对象和待校验的字符串

function checkByRegExp(regExp,string) {
    return regExp.test(string);  
}
​
checkByRegExp(/^1\d{10}$/, '18642838455'); // 校验电话号码
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'test@163.com'); // 校验邮箱

但如果要需要校验多个电话号码或者校验多个邮箱呢?我们可能会这样做:

checkByRegExp(/^1\d{10}$/, '18642838455'); // 校验电话号码
checkByRegExp(/^1\d{10}$/, '13109840560'); // 校验电话号码
checkByRegExp(/^1\d{10}$/, '13204061212'); // 校验电话号码checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'test@163.com'); // 校验邮箱
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'test@qq.com'); // 校验邮箱
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'test@gmail.com'); // 校验邮箱

再校验同一类型的数据时,相同的正则我们需要写多次, 这就导致我们在使用的时候效率低下,也不利于阅读和维护。

此时,我们可以借助柯里化对 checkByRegExp 函数进行封装,以简化代码书写,提高代码可读性。

//进行柯里化
let _check = curry(checkByRegExp);
//生成工具函数,验证电话号码
let checkCellPhone = _check(/^1\d{10}$/);
//生成工具函数,验证邮箱
let checkEmail = _check(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/);
​
checkCellPhone('18642838455'); // 校验电话号码
checkCellPhone('13109840560'); // 校验电话号码
checkCellPhone('13204061212'); // 校验电话号码checkEmail('test@163.com'); // 校验邮箱
checkEmail('test@qq.com'); // 校验邮箱
checkEmail('test@gmail.com'); // 校验邮箱

经过柯里化后,我们生成了两个函数 checkCellPhone 和 checkEmail, checkCellPhone 函数只能验证传入的字符串是否是电话号码, checkEmail 函数只能验证传入的字符串是否是邮箱, 它们与原函数 checkByRegExp 相比,从功能上通用性降低了,但适用性提升了。 柯里化的这种用途可以被理解为:参数复用。

实现:
/**
 * @param fn    待柯里化的原函数
 * @param args  已接收的参数列表
 * @param args2  新传入的参数列表
 */
function _curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function (...args2) {
        return curried.apply(this, [...args, ...args2]);
      };
    }
  };
}

验证:

let _fn = _curry(function (a, b, c, d, e) {
  console.log(a, b, c, d, e);
});
​
_fn(1, 2, 3, 4, 5); // print: 1,2,3,4,5
_fn(1)(2)(3, 4, 5); // print: 1,2,3,4,5
_fn(1, 2)(3, 4)(5); // print: 1,2,3,4,5
_fn(1)(2)(3)(4)(5); // print: 1,2,3,4,5