深入JS

JS这门语言,入门容易,深入难,因此对于工作3年以上的码农,面试官会经常考察关于JS底层原理的一些知识点,这里收集了这几年我真实面试过程中遇到的一些问题。

如何理解原型?

原型是原型对象的简称,JS如此设计主要用于实现继承,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现。

原型对象的所有属性和方法,都能被实例对象所共享,也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。

JavaScript 规定,每个函数都有一个prototype属性,指向一个对象:

function func() {}
typeof func.prototype // "object"

可以看到,func函数具有prototype这个默认属性,指向一个对象。对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。

原型对象的属性不是实例对象自身的属性,只要修改,就会立刻会体现在所有实例对象上。

function Coder(name) {
  this.name = name;
}
Coder.prototype.sex = 'male';

const c1 = new Coder('小明');
const c2 = new Coder('小二');
c1.sex // 'male'
c2.sex // 'male'

Coder.prototype.sex = 'female';
c1.sex // 'female'
c2.sex // 'female'

可以看到,Coder构造函数原型对象的sex属性的值变为female,两个实例对象的sex属性立刻跟着变了。

这是因为实例对象其实没有sex属性,都是读取原型对象的color属性。

  • 当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法;
  • 如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。

总之,原型对象的作用,就是定义所有实例对象共享的属性和方法。

如何理解原型链?

通过前面,我们了解到 JS 规定,所有对象都有自己的原型对象,这意味着:

  • 任何一个对象,都可以充当其他对象的原型;
  • 由于原型对象也是对象,也有自己的原型。

显然,在读取对象属性时,从对象到原型、再到原型的原型,直到原型变为null,就形成一个原型链(prototype chain)。

在JS中,所有对象通过原型链,最终会到Object.prototype,即Object构造函数的prototype属性。

因此,所有对象都继承了内置 Object 对象的prototype属性,这就是所有对象都有valueOf和toString方法的原因,因为这是从Object.prototype继承的。

Object.prototype的原型是null,因为null没有任何属性和方法,所以原型链的尽头就是null。

Object.getPrototypeOf(Object.prototype) // null

由此可以知道,读取对象的某个属性时,JavaScript 引擎会按照原型链的顺序查找:

  • 先寻找对象本身的属性;
  • 如果找不到,就到它的原型去找;
  • 如果还是找不到,就到原型的原型去找;
  • 如果直到最顶层的Object.prototype还是找不到,则返回undefined。

对于原型链需要注意:

  • “覆盖”(overriding)问题:如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性;
  • 性能问题:如果寻找某个不存在的属性,将会遍历整个原型链,所寻找的属性在越上层的原型对象,对性能的影响越大。

深入聊聊call、apply 及 bind 函数?

之所以有这三个函数,是因为 this 的动态切换,使得JS编程变得困难和模糊,所以JS得提供切换/固定this的指向的机制,避免出现意想不到的情况。

什么是this呢?其实它是this对象的简称,就是属性或方法“当前”所在的对象。

在JS 中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是函数运行时所在的对象(环境)。

但是 JS支持运行环境动态切换,意味着没有办法事先确定到底 this 指向哪个对象,所以得提供call、apply 及 bind 函数,让开发者控制this指向。

JS支持函数式编程,使得函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。

所以,this就出现了,它的设计目的就是在函数体内部,获取函数当前的运行环境。

谈完了为何需要call、apply 及 bind 函数,我们聊聊他们的异同:

  • 都可以改变this指向,第一个参数都是this要指向的对象,也就是想指定的上下文,都可以利用后续参数传参;
  • 传参方式不同,apply接收一个数组作为函数执行时的参数,其他可以传多个参数;
  • bind 是返回对应函数,便于稍后调用,而apply 、call 则是立即调用 。

手动实现call方法

  • 给新对象添加一个函数,并让this 指向这个函数;
  • 执行这个函数
  • 执行完后删除
  • 将执行结果返回
Function.prototype.myCall = function(ctx) {
    if(typeof this !== 'function') {
        throw new TypeError('请传入函数!');
    }
    const args = [...arguments].slice(1);
    // 为当前对象添加函数fn, 值为要调用的函数;
    ctx.fn = this ;
    // 执行添加的函数fn
    const res = ctx.fn(...args);
    // 执行完后删除
    delete ctx.fn;
    return res;
}

var n = 1;
var obj = { n: 2 };

function getValue() {
  console.log(this.n);
}

getValue.myCall(window) // 1
getValue.myCall(obj) // 2

手动实现apply方法

// 与call的思想类似,只是需要判断一下参数数组是否存在
Function.prototype.myApply = function(ctx) {
    if(typeof this != 'function') {
        throw new TypeError('请传入函数!');
    }
    ctx.fn = this
    const  res = arguments[1] ? ctx.fn(...arguments[1]: ctx.fn();
    delete ctx.fn;
    return res;
}

var n = 1;
var obj = { n: 2 };

function getValue() {
  console.log(this.n);
}

getValue.myApply(window) // 1
getValue.myApply(obj) // 2

手动实现bind方法

  • 返回一个函数,其他与call, apply类似;
  • 如果返回的函数作为构造函数,bind时指定的 this 值会失效,但传入的参数依然生效。
Function.prototype.myBind = function(ctx, ...rest) {
  var self = this;
  return function func(...args) {
    if (this instanceof func) {
      return new self(...rest, ...args)
    }
    return self.apply(ctx, rest.concat(args))
  }
}

var n = 1;
var obj = { n: 2 };

function getValue() {
  console.log(this.n);
}

const getValue1 = getValue.myBind(window) 
getValue1(); // 1
const getValue2 =getValue.myBind(obj)
getValue2(); // 2

深入谈谈 new 的原理?

谈new之前,得先了解JS中的构造函数:我们知道JS是面向对象编程的语言,第一步就是要生成对象,而”构造函数”,就是专门用来生成实例对象的函数。

构造函数的特点有:

  • 第一个字母通常大写;
  • 函数体内部使用了this关键字,代表了所要生成的对象实例;
  • 生成对象的时候,必须使用new命令。

因此,我们可以知道,new命令的作用,就是执行构造函数,返回一个实例对象,会依次执行下面的步骤:

  • 创建一个空对象,作为将要返回的对象实例;
  • 将这个空对象的原型,指向构造函数的prototype属性;
  • 将这个空对象赋值给函数内部的this关键字;
  • 开始执行构造函数内部的代码。

对于创建一个对象来说,无论性能上还是可读性,一般都推荐使用字面量的方式创建对象,因为使用 new Object() 的方式创建对象需要通过作用域链一层层找到 Object。

自己手动实现一个new:

function myNew (func, ...arg) {
    // 创建一个新对象且将其隐式原型指向构造函数原型
    const obj = {
        __proto__: func.prototype 
    }
    // 执行构造函数
    func.apply(obj, arg);
    // 返回该对象
    return obj;
}

function Person (name, age) {
    this.name = name ;
    this.age = age
}

const p = myNew(Person, 'lilei', '12');
console.log(p);

instanceof 的原理是什么?

instanceof 的内部机制是通过判断对象的原型链中是不是能找到类型的 prototype,所以可以正确判断对象的类型,其原理如下:

  • 获取类型的原型;
  • 获得对象的原型;
  • 循环判断对象的原型是否等于类型的原型,直到对象原型为 null。

自己实现 instanceof:

function myInstanceof(left, right) {
  const prototype = right.prototype;
  left = left.__proto__;
  while (true) {
    if (left === null || left === undefined) {
        return false;
    } 
    if (prototype === left){
       return true;
    }
    left = left.__proto__;
  }
}

谈谈JS中的模块化?

在ES6之前,JS一直没有模块体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。

这对开发大型的、复杂的项目形成了巨大障碍,使用模块化的有以下好处:

  • 解决命名冲突
  • 提供复用性
  • 提高代码可维护性

在早期,使用立即执行函数实现模块化是常见的手段,通过函数作用域解决了命名冲突、污染全局作用域的问题:

(function(globalVariable){
   globalVariable.test = function() {}
   // ... 声明的变量、函数都不会污染全局作用域
})(globalVariable)

在 ES6 之前,社区制定了 CommonJS(用于服务器) 和 AMD(用于浏览器) 等模块加载方案,直到ES6 在语言标准的层面上,实现了模块功能。

CommonJS 最早在 Nodejs 中使用,虽然现在有了 ES Module,但目前服务端还在广泛使用,比如在 Webpack 中你就能见到它:

// x.js
module.exports = {
    x: 0
}
// 或
exports.x = 0;

// y.js
const x = require('./x.js')
x.x // 0

在 CommonJs 的模块化规范中,每一个文件就是一个模块,拥有自己独立的作用域、变量、以及方法等,对其他的模块都不可见。

CommonJS规范规定,每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(module.exports)是对外的接口。

加载某个模块,其实是加载该模块的 module.exports 属性,require 方法用于加载模块。

虽然 exports 和 module.exports 用法相似,但是不能对 exports 直接赋值。

因为 const exports = module.exports 这句代码表明了 exports 和 module.exports 享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports 赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports 起效。

ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别

  • CommonJS 支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案
  • CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大;
  • ES Module是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响;
  • CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次;
  • ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
  • ES Module 会编译成 require/exports 来执行的
import x from './x.js'
import { x } from './x.js'
// 导出模块 API
export function a() {}
export default function() {}

谈谈JS中的异步?

对于有一定开发经验的码农,应该都知道JavaScript 采用单线程,而不是多线程。

因为多线程需要共享资源、且有可能修改彼此的运行结果,浏览器实现JS这个脚本语言,就会变得很复杂。

所以,为了避免复杂性,JavaScript 一开始就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

单线程模型虽然对 JavaScript 构成了很大的限制,但也因此使它具备了其他语言不具备的优势。

如果用得好,JavaScript 程序是不会出现堵塞的,这就是为什么 Node 可以用很少的资源,应付大流量访问的原因。

程序里面所有的任务,可以分成:

  • 同步任务(synchronous):没有被引擎挂起、在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
  • 异步任务(asynchronous):被引擎放在一边,不进入主线程、而进入任务队列的任务,排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。

JS运行时,除了一个正在运行的主线程,引擎还提供一个任务队列,里面是各种需要当前程序处理的异步任务。

为了处理异步任务,JS引擎提供了事件循环的机制:不停地检查,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。

异步操作的模式有:

  • 回调函数:异步操作最基本的方法,优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序结构混乱、流程难以追踪。
  • 事件监听:执行不取决于代码的顺序,而取决于某个事件是否发生,优点是容易理解和绑定多个事件及回调函数,耦合低且利于模块化,缺点是整个程序都要变成事件驱动型,运行流程会不清晰,很难看出主流程。
  • 发布/订阅:存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。
  • Promise 对象:不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用,写法只是回调函数的改进,使用then方法以后,异步任务的执行看得更清楚,存在的最大问题是代码冗余。
  • ES6 Generator 函数:执行后会返回一个指向内部状态的遍历器指针对象,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象,异步操作需要暂停的地方,都用yield语句注明,Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。
  • ES7 async 函数:只是 Generator 函数的语法糖,只是内置了执行器,通过async和await 提供更好的语义,返回值是 Promise 对象,比 Generator 函数的返回值是 Iterator 对象方便,这可以看作是多个异步操作包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。

手动实现promise对象:

  • 面试时只需写出简单版即可:
class MyPromise {
  constructor(fn) {
      this.value = null; // 定义传递到then的value
      this.state = 'PENDING';  // 定义当前Promise运行状态
      this.resolvedCallBacks = [];// 定义Promise失败状态的回调函数集合
      this.rejectedCallBacks = []; // 定义Promise成功状态的回调函数集合
      MyPromise.that = this; // 为静态方法定义其内部使用的that  
      try {
          // 执行 new MyPromise() 内传入的方法
          fn(MyPromise.resolve, MyPromise.reject);
      } catch (error) {
          MyPromise.reject(this.value)
      }
  }
  static resolve(value) {}
  static reject(value) {}
  then(onFulfilled, onRejected) {}
}
  • 完整实现参考版:
class MyPromise {
  constructor(fn) {
      this.states = {
          PENDING: 'PENDING', 
          RESOLVED: 'RESOLVED', 
          REJECTED: 'REJECTED'
      }
      this.value = null; // 定义传递到then的value
      this.state = this.states.PENDING;  // 定义当前Promise运行状态
      this.resolvedCallBacks = [];// 定义Promise失败状态的回调函数集合
      this.rejectedCallBacks = []; // 定义Promise成功状态的回调函数集合
      MyPromise.that = this; // 为静态方法定义其内部使用的that  
      try {
          // 执行 new MyPromise() 内传入的方法
          fn(MyPromise.resolve, MyPromise.reject);
      } catch (error) {
          MyPromise.reject(this.value)
      }
  }
   // 静态resolve方法,MyPromise实例不可访问;
  static resolve(value) {
      const that = MyPromise.that;
      // 判断是否是MyPromise实例访问resolve
      const f = that instanceof MyPromise
      // MyPromise实例对象访问resolve
      if (f && that.state == that.states.PENDING) {
          that.state = that.states.RESOLVED
          that.value = value
          that.resolvedCallBacks.map(cb => (that.value = cb(that.value)))
      }
      // MyPromise类访问resolve
      if (!f) {
          const obj = new MyPromise()
          return Object.assign(obj, {
              state: obj.states.RESOLVED,
              value
          })
      }
  }
  static reject(value) {
      const that = MyPromise.that;
      const f = that instanceof MyPromise
      if (f && that.state == that.states.PENDING) {
          that.state = that.states.REJECTED
          that.value = value
          that.rejectedCallBacks.map(cb => (that.value = cb(that.value)))
      }
      if (!f) {
          const obj = new MyPromise()
          return Object.assign(obj, {
              state: obj.states.REJECTED,
              value
          })
      }
  }
  // 定义在MyPromise原型上的then方法
  then(onFulfilled, onRejected) {
      const { PENDING, RESOLVED, REJECTED } = this.states;
      const f = typeof onFulfilled == "function" ? onFulfilled : c => c;
      const r = typeof onRejected == "function" ? onRejected : c => throw c;
      switch (this.state) {
          case PENDING:
              this.resolvedCallBacks.push(f)
              this.rejectedCallBacks.push(r)
              break;
          case RESOLVED:
              this.value = f(this.value)
              break;
          case REJECTED:
              this.value = r(this.value)
              break;
          default:
              break;
      }
      // 满足链式调用then,返回MyPromise实例对象
      return this;
  }
}

小结

这里只是列举了一些JS中底层原理或疑难的知识点,一般面试时间都有限,面试官只会挑其中的一两道进行考察,只有架构师岗位,或者大公司的中高级岗位,才可能会考察。

实际上,我在真实面试中,被问到的也极少,只能那些不着急招人的大公司,才有可能在面试中和你聊一些JS比较深入的话题,因此大家了解即可。

因为前端技术变化很快,企业生存压力大,尽可能掌握多端技术框架响应业务需求才是真正用人方看重的技能。