前端面试题(JS部分)

内置类型



JS中分为七种内置类型,其中内置类型又分为两大类型:

  • 基本类型
  • 对象(Object)

基本类型有六种:

  • null
  • undefined
  • string
  • number
  • boolean
  • symbol

其中JS的数字类型是浮点类型的,没有整型。并且浮点类型基于 IEEE 754 标准实现,在使用中会遇到某些 Bug。NaN 也属于 number 类型,并且 NaN 不等于自身。
对于基本类型来说,如果使用字面量的方式,那么这个变量只是个字面量,只有在必要的时候才会转换为对应的类型。

let a = 111    // 这只是字面量,不是 number 类型
a.toString()   // 使用的时候才会转换为对象类型

对象(Object)是引用类型,在使用过程中会遇到浅拷贝和深拷贝的问题。

let a = { name: 'FE' }
let b = a
b.name = 'EF'
console.log(a.name)    // EF

Typeof



typeof 对于基本类型,除了 null 都可以显示正确的类型。

typeof 1      // 'number'
typeof '1'    // 'string'
typeof undefined    // 'undefined'
typeof true    // 'boolean'
typeof Symbol()    // 'symbol'
typeof b    // b 没有声明,但是还是会显示 undefined

typeof 对于对象,除了函数都会显示 object。

typeof []    // 'object'
typeof {}    // 'object'
typeof console.log  // 'function'

对于 null 来说,虽然它是基本类型,但是会显示 object,这是一个存在很久了的 Bug。

typeof null // 'object'

类型转换


转Boolean

在条件判断时,除了 undefined、null、false、NaN、''、0、-0 ,其他所有值都转为 true ,包括所有对象。

对象转基本类型

对象在转换基本类型时,首先会调用 valueOf 然后调用 toString。并且这两个方法是可以重写的。

let a = {
  valueOf() {
    return 0
  }
}

当然也可以重写 Symbol.toPrimitive ,该方法在转基本类型时调用优先级最高。

let a = {
  valueOf() {
    return 0;
  },
  toString() {
    return '1';
  },
  [Symbol.toPrimitive]() {
    return 2;
  }
}
1 + a    // => 3
'1' + a   // => '12'

四则运算符

只有当加法运算时,其中一方是字符串类型,就会把另一个也转为字符串类型。其他运算只要其中一方是数字,那么另一方就转为数字。并且加法运算会触发三种类型转换:将值转换为原始值,转换为数字,转换为字符串。

1 + '1'    // '11'
2 * '2'    // 4
[1, 2] + [2, 1]    // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'

对于加号需要注意这个表达式 'a' + + 'b'

'a' + +  'b'    // -> 'aNaN'
// 因为 + 'b' -> NaN

== 操作符

比较运算 x == y,其中 x 和 y 是值,产生 true 或者 false。这样的比较按如下方式进行:
1.若 Type(x) 与 Type(y) 相同,则

  • 若 Type(x) 为 Undefined,返回 true。
  • 若 Type(x) 为Null,返回 true。
  • 若 Type(x) 为 Number,则
    • 若 x 为NaN,返回 false。
    • 若 y 为NaN,返回 false。
    • 若 x 与 y 为相等数值,返回 true。
    • 若 x 为 +0 且 y 为 -0,返回 true。
    • 若 x 为 -0 且 y 为 +0,返回 true。
    • 其它情况返回 false。
  • 若 Type(x) 为 String,则当 x 和 y 为完全相同的字符串序列(长度相等且相同字符在相同位置)时返回 true。否则,返回 false。
  • 若 Type(x) 为 Boolean,当 x 和 y 同为 true 或者 同为 false 时返回 true。否则,返回 false。
  • 当 x 和 y 为引用同一对象时返回 ture。否则,返回 false。

2.若 x 为 null 且 y 为 undefined,返回 true。
3.若 y 为 null 且 x 为 undefined,返回 true。
4.若 Type(x) 为 Number 且 Type(y) 为 String,返回 comparison x == ToNumber(y) 的结果。
5.若 Type(x) 为 String 且 Type(y) 为 Number,返回比较 ToNumber(x) == y 的结果。
6.若 Type(x) 为 Boolean,返回比较 ToNumber(x) == y 的结果。
7.若 Type(y) 为 Boolean,返回比较 x == ToNumber(y) 的结果。
8.若 Type(x) 为 String 或者 Number,且 Type(y) 为 Object,返回比较 x == ToPrimitive(y) 的结果。
9.若 Type(x) 为 Object 且 Type(y) 为 String 或 Number,返回比较 ToPrimitive(x) == y 的结果。
10.其他情况返回 false。
toPrimitive 就是对象转基本类型。

题目:
[ ] == ![ ] // -> true

// [] 转成 true,然后取反变成 false
[] == false
// 根据第 7 条得出
[] == ToNumber(false)
[] == 0
// 根据第 9 条得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根据第 6 条得出
0 == 0  // true

比较运算符

  • 如果是对象,就通过 toPrimitive 转换对象
  • 如果是字符串,就通过 unicode 字符索引来比较

原型



示例图.png

每个函数都有 prototype 属性,除了 Function.prototype.bind() ,该属性指向原型。
每个对象都有 proto 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]] ,但是 [[prototype]] 是内部属性,我们并不能访问到,所以使用 proto 来访问。
对象可以通过 proto 来寻找不属于该对象的属性,proto 将对象连接起来组成了原型链。

new



1.新生成了一个对象
2.链接到原型
3.绑定 this
4.返回新对象
在调用 new 的过程中会发生以上四件事情,我们可以试着来自己实现一个 new

function create() {
  // 创建一个空的对象
  let obj = new Object()
  // 获得构造函数
  let Con = [].shift.call(arguments)
  // 链接到原型
  obj.__proto__ = Con.prototype
  // 绑定 this ,执行构造函数
  let result = Con.apply(obj, arguments)
  // 确保 new 出来的是个对象
  return typeof result === 'object' ? result : obj
}

对应实例对象来说,都是通过 new 产生的,无论是 function Foo() 还是 let a = { b: 1 }。
对于创建一个对象来说,更推荐使用字面量的方式创建对象(无论性能上还是可读性)。因为你使用 new Object() 的方式创建对象需要作用域链一层层找到 Object,但是你使用字面量的方式就没这个问题。

function Foo() {
  // function 就是个语法糖
  // 内部等同于 new Function()
  let a = { b: 1 }
  // 这个字面量内部也是使用了 new Object()
}

对于 new 来说,还需要注意运算符优先级。

function Foo() {
  return this;
}
Foo.getName = function () {
  console.log('1');
}
Foo.prototype.getName = function () {
  console.log('2');
}

new Foo.getName();    // 1
new Foo().getName();  // 2
// 等价于
new (Foo.getName());
new (Foo()).getName();

instanceof



instanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype。
我们可以试着实现一些 instanceof

function instanceof(left, right) {
  // 获得类型的原型
  let prototype = right.prototype
  // 获得对象的原型
  left = left.__proto__
  // 判断对象的类型是否等于类型的原型
  while(true) {
    if (left === null)  return false
    if (prototype === left) return true
    left = left.__proto__
  }
}

this



this 是很多人会混淆的概念,但是其实它一点也不难,你只需记住几个规则就可以了。

function foo() {
  console.log(this.a)
}
var a = 1
foo()

var obj = {
  a: 2,
  foo: foo
}
obj.foo()

// 以上两者情况 `this` 只依赖于调用函数前的对象,优先级是第二个情况大于第一个情况

// 以下情况是优先级最高的, `this` 只会绑定在 `c` 上,不会被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)

// 还有种就是利用 call,apply,bind 改变 this,这个优先级仅次于 new

以上几种情况明白了,很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 this

function a() {
  return () => {
    return () => {
      console.log(this)
    }
  }
}
console.log(a()()())

箭头函数其实是没有 this 的,这个函数中的 this 只取决于它外面的第一个不是箭头函数的函数的 this。在这个例子中,因为调用 a 符合前面代码中的第一个情况,所以 this 是 window。并且 this 一旦绑定了上下文,就不会被任何代码改变。

执行上下文



当执行JS代码时,会产生三种执行上下文

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文

每个执行上下文中都有三个重要的属性

  • 变量对象(VO),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问
  • 作用域链(JS 采用词法作用域,也就是说变量的作用域是在定义时就决定了)
  • this
var a = 10;
function foo(i) {
  var b = 20;
}
foo();

对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。

stack = [
  globalContext,
  fooContext
]

对于全局上下文来说,VO 大概是这样的

globalContext.VO === globe
globalContext.VO = {
  a: undefined,
  foo: 
}

对于函数 foo 来说,VO 不能访问,只能访问到活动对象(AO)

fooContext.VO === foo.AO
fooContext.AO = {
  i: undefined,
  b: undefined,
  arguments: <>
}
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是一个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调用者

对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过 [[Scope]] 属性查找上级变量

fooContext.[[Scope]] = [
  globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
  fooContext.VO,
  globalContext.VO
]

接下来让我们看一个老生常谈的例子,var

b()    // call b
console.log(a)    // undefined

var a = 'Hello world'

function b() {
  console.log('call b')
}

想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建VO),JS 解释器会找到需要提升的变量和函数,并且给它们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。
在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升

b()    // call b second

function b() {
  console.log('call b first')
}

function b() {
  console.log('call b second')
}

var b = 'Hello world'

var 会产生很多错误,所以在ES6中引入了 let。let 不能在声明前使用,但是这并不是常说的 let 不会提示,let 提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。
对于非匿名的立即执行函数需要注意以下一点

var foo = 1
(function foo() {
  foo = 10
  console.log(foo)
}())    // -> ƒ foo() { foo = 10 ; console.log(foo) }

因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。

specialObject = {};

Scope = specialObject + Scope;

foo = new FuncionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo;    // {DontDelete}, {ReadOnly}

delete Scope[0];    // remove specialObject from the front of scope chain

闭包



闭包的定义很简单:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。

function A() {
  let a = 1
  function B() {
    console.log(a)
  }
  return B
}

经典面试题,循环中使用闭包解决 var 定义函数的问题

for (var i = 0; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

首先因为 setTimeout 是个异步函数,所以会先把循环全部执行完毕,这时候 i 就是 6 了,所以会输出一堆 6。

解决办法两种,第一种使用闭包

for (var i = 0; i <= 5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j)
    }, j * 1000)
  })(i)
}

第二种就是使用 setTimeout 的第三个参数

for (var i = 0; i <= 5; i++) {
  setTimeout(function timer(j) {
    console.log(j)
  }, i * 1000, i)
}

第三种就是使用 let 定义 i 了

for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

因为对于 let 来说,它会创建一个块级作用域,相当于

{  // 形成块级作用域
  let i = 0
  {
    let i1 = i
    setTimeout(function timer() {
      console.log(i1)
    }, i*1000)
  }
  i++
  {
    let i1 = i
  }
  i++
  {
    let i1 = i
  }
  ...
}

深浅拷贝


let a = {
  age: 1
}
let b = a
a.age = 2
console.log(b.age)    // 2

从上述例子中我们可以发现,如果给一个变量赋值一个对象,那么两者的值会是同一个引用,其中一方改变,另一方也会相应改变。
通常在开发中我们不希望出现这样的问题,我们可以使用浅拷贝来解决这个问题。

浅拷贝

首先可以通过 Object.assign 来解决这个问题

let a = {
  age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age)    // 1

当然我们也可以通过展开运算符(...)来解决

let a = {
  age: 1
}
let b = {...a}
a.age = 2
console.log(b.age)  // 1

浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到刚开始的话题了,两者享有相同的引用。要解决这个问题,我们需要引入深拷贝。

深拷贝

这个问题通常可以通过 JSON.parse(JSON.stringify(object)) 来解决。

let a = {
  age: 1,
  jobs: {
    first: 'FE'
  }
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first)  // FE

但是该方法也是有局限性的:

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 不能解决循环引用的对象
let obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
}
obj.c = obj.b
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c
let newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)

如果有这么一个循环引用对象,你会发现你不能通过该方法深拷贝



在遇到函数、undefined 或者 symbol 的时候,该对象也不能正常的序列化

let a = {
    age: undefined,
    sex: Symbol('male'),
    jobs: function() {},
    name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}

你会发现在上述情况中,该方法会忽略掉函数和 undefined。
但是在通常情况下,复杂数据都是可以序列化的,所以这个函数可以解决大部分问题,并且该函数是内置函数中处理深拷贝性能最快的。当然如果你的数据中含有以上三种情况下,可以使用 loadash 的深拷贝函数。
如果你所需拷贝的对象含有内置类型并且不包含函数,可以使用 MessageChannel

function structuralClone(obj) {
  return new Promise(resolve => {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => resolve(ev.data);
    port1.postMessage(obj);
  });
}

var obj = {a: 1, b: {
    c: b
}}
// 注意该方法是异步的
// 可以处理 undefined 和循环引用对象
(async () => {
  const clone = await structuralClone(obj)
})()

模块化



在有 Babel 的情况下,我们可以直接使用 ES6 的模块化

// file a.js
export function a() {}
export function b() {}
// file b.js
export default function() {}

import {a, b} from './a.js'
import XXX from './b.js'

CommonJS

CommonJs 是 Node 独有的规范,浏览器中使用就需要用到 Browserify 解析了。

// a.js
module.exports = {
  a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a  // -> log 1

在上述代码中,module.exports 和 exports 很容易混淆,让我们来看看大致内容实现

var module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
  a: 1
}
// 基本实现
var module = {
  exports: {}  // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
  // 导出的东西
  var a = 1
  module.exports = a
  return module.exports
}

再来说说 module.exports 和 exports,用法其实是相似的,但是不能对 exports 直接赋值,不会有任何效果。
对于 CommonJS 和 ES6 中的模块化的两者区别是:

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

AMD

AMD 是由 RequireJS 提出的

define(['./a', './b'], function(a, b) {
  a.do()
  b.do()
})
define(function(require, exports, module) {
  var a = require('./a')
  a.doSomething()
  var b = require('./b')
  b.doSomething()
})

防抖



防抖和节流的作用都是防止函数多次调用。区别在于,假设一个用户一直触发这个函数,且每次触发函数的间隔小于 wait,防抖的情况下只会调用一次,而节流的情况会每隔一定的时间(参数 wait)调用函数。
我们先来看一个袖珍版的防抖理解以下防抖的实现:

// func 是用户传入需要防抖的函数
// wait 是等待时间
const debounce = (func, wait = 50) => {
  // 缓存一个定时器 id
  let timer = 0
  // 这里返回的函数是每次用户实际调用的防抖函数
  // 如果已经设定过定时器了就清空上一次的定时器
  // 开始一个新的定时器,延迟执行用户传入的方法
  return function(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}
// 不难看出如果用户调用该函数的间隔小于 wait 的情况下,上一次的时间还未到就被清除了,并不会执行函数

这是一个简单版的防抖,但是有缺陷,这个防抖只能在最后调用。一般的防抖会有 immediate 选项,表示是否立即调用。这两者的区别,举个例子来说:

  • 例如在搜索引擎问题的时候,我们当然是希望用户输入完最后一个字才调用查询接口,这个时候适用延迟执行的防抖函数,它总是在一连串(间隔小于 wait的)函数触发之后调用。
  • 例如用户给 interviewMap 点 star 的时候,我们希望用户点第一下的时候就去调用接口,并且成功之后改变 star 按钮的样子,用户就可以立马得到反馈是否 star 成功了,这个情况适用立即执行的防抖函数,它总是在第一次调用,并且下次调用必须与前一次调用的时间间隔大于 wait 才会触发。

下面我们来实现一个带有立即执行选项的防抖函数

// 这里是用来获取当前时间戳的
function now() {
  return +new Date()
}
/**
 * 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
 *
 * @param  {function} func        回调函数
 * @param  {number}   wait        表示时间窗口的间隔
 * @param  {boolean}  immediate   设置为ture时,是否立即调用函数
 * @return {function}             返回客户调用函数
 */
function debounce(func, wait = 50, immediate = true) {
  let timer, context, args

  // 延迟执行函数
  const later = () => setTimeout(() => {
    // 延迟执行完毕,清空缓存的定时器序号
    timer = null
    // 延迟执行的情况下,函数会在延迟函数种中执行
    // 使用到之前缓存的参数和上下文
    if (!immediate) {
      func.apply(context, args)
      context = args = null
    }
  }, wait)

  // 这里返回的函数是每次实际调用的函数
  return function(...params) {
    // 如果没有创建延迟执行函数(later),就会创建一个
    if (!timer) {
      timer = later()
      // 如果是立即执行,调用函数
      // 否则缓存参数和调用上下文
      if (immediate) {
        func.apply(this, params)
      } else {
        context = this
        args = params
      }
      // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
      // 这样做延迟函数会重新计时
    } else {
      clearTimeout(timer)
      timer = later()
    }
  }
}

整体函数实现的不难,总结:

  • 对于按钮防点击来说的实现:如果函数是立即执行的,就立即调用,如果函数是延迟执行的,就缓存上下文和参数,放在延迟函数中去执行。一旦开始一个定时器,只要定时器还在,每次点击都重新计时。一旦定时器时间到了,定时器重置为 null,就可以再次点击了。
  • 对于延迟执行函数来说的实现:清除定时器 ID,如果是延迟调用就调用函数。

节流



防抖动和节流本质是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行。

/**
 * underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
 *
 * @param  {function}   func      回调函数
 * @param  {number}     wait      表示时间窗口的间隔
 * @param  {object}     options   如果想忽略开始函数的的调用,传入{leading: false}。
 *                                如果想忽略结尾函数的调用,传入{trailing: false}
 *                                两者不能共存,否则函数不能执行
 * @return {function}             返回客户调用函数
 */
_.throttle = function(func, wait, options) {
  var context, args, result;
  var timeout = null;
  // 之前的时间戳
  var previous = 0;
  // 如果 options 没传则设为空对象
  if (!options) options = {};
  // 定时器回调函数
  var later = function() {
    // 如果设置了 leading,就将 previous 设为 0
    // 用于下面函数的第一个 if 判断
    previous = options.leading === false ? 0 : _.now();
    // 置空一是为了防止内存泄漏,二是为了下面的定时器判断
    timeout = null;
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };
  return function() {
    // 获取当前时间戳
    var now = _.now();
    // 首次进入前者肯定为 true
    // 如果需要第一次不执行函数
    // 就将上次时间戳设为当前的
    // 这样在接下来计算 remaining 的值时会大于 0
    if (!previous && options.leading === false) previous = now;
    // 计算剩余时间
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 如果当前调用已经大于上次调用时间 + wait
    // 或者用户手动调了时间
    // 如果设置了 trailing,只会进入这个条件
    // 如果没有设置 leading,那么第一次会进入这个条件
    // 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
    // 其实还是会进入的,因为定时器的延时
    // 并不是准确的时间,很可能你设置了2秒
    // 但是他需要2.2秒才触发,这时候就会进入这个条件
    if (remaining <= 0 || remaining > wait) {
        // 如果存在定时器就清理掉否则会调用二次回调
        if (timeout) {
          clearTimeout(timeout);
          timeout = null;
        }
        previous = now;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
        // 判断是否设置了定时器和 trailing
        // 没有的话就开启一个定时器
        // 并且不能不能同时设置 leading 和 trailing
        timeout = setTimeout(later, remaining);
    }
    return result;
  }
}

继承



在 ES5 中,我们可以使用如下方式解决继承的问题

function Super() {}
Super.prototype.getNumber = function() {
  return 1
}

function Sub() {}
let s = new Sub()
Sub.prototype = Object.create(Super.prototype, {
  constructor: {
    value: Sub,
    enumerable: false,
    writable: true,
    configurable: true
  }
})

以上继承实现思路就是将子类的原型设置为父类的原型
在 ES6 中,我们可以通过 class 语法轻松解决这个问题

class MyDate extends Date {
  test() {
    return this.getTime()
  }
}
let myDate = new MyDate()
myDate.test()

但是 ES6 不是所有浏览器都兼容,所以我们需要使用 Babel 来编译这段代码
如果你使用编译过的代码调用 myDate.test() 你就会发现出现了报错

效果图.png

因为在 JS 底层有限制,如果不是由 Date 构造出来的实例的话,是不能调用 Date 里的函数的。所以这也侧面的说明了:ES6 中的 class 继承与 ES5 中的一般继承写法是不同的。
既然底层限制了实例必须由 Date 构造出来,那么我们可以改变下思路实现继承

function MyData() {

}
MyData.prototype.test = function() {
  return this.getTime()
}
let d = new Date()
Object.setPrototypeOf(d, MyData.prototype)
Object.setPrototypeOf(MyData.prototype, Date.prototype)

以上继承实现思路:先创建父类实例 => 改变实例原先的 proto 转而连接到子类的 prototype => 子类的 prototype 的 proto 改为父类的 prototype。
通过以上方法实现的继承就可以完美解决 JS 底层的这个限制。

call,apply,bind 区别



首先说下前两者的区别。
call 和 apply 都是为了解决改变 this 指向。作用都是相同的,只是传参的方式不同。
除了第一个参数外,call 可以接收一个参数列表,apply 只接收一个参数数组。

let a = {
  value: 1
}
function getValue(name, age) {
  console.log(name)
  console.log(age)
  console.log(this.value)
}
getValue.call(a, 'yck', '24')
getValue.apply(a, ['yck', '24'])

模拟实现 call 和 apply

可以从一下几点来考虑如何实现

  • 不传入第一个参数,那么默认为 window
  • 改变了 this 的指向,让新的对象可以执行该函数。那么思路是否可以变成给新的对象添加一个函数,然后在执行完以后删除?
Function.prototype.myCall = function (context) {
  var context = context || window
  // 给 context 添加一个属性
  // getValue.call(a, 'yck', '24') => a.fn = getValue
  context.fn = this
  // 将 context 后面的参数取出来
  var args = [...arguments].slice(1)
  var result = context.fn(...args)
  // 删除 fn
  delete context.fn
  return result
}

以上就是 call 的思路,apply 的实现类似

Function.prototype.myApply = function (context) {
  var context = context || window
  context.fn = this

  var result
  // 需要判断是否存储第二个参数
  // 如果存在,就将第二个参数展开
  if (arguments[1]) {
    result = context.fn(...arguments[1])
  } else {
    result = context.fn()
  }

  delete context.fn
  return result
}

bind 和其他两个方法作用也是一致的,只是该方法会返回一个函数,并且我们可以通过 bind 实现柯里化。
同样的,也来模拟实现下 bind

Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  var _this = this
  var args = [...arguments].slice(1)
  // 返回一个函数
  return function F() {
    // 因为返回了一个函数,我们可以 new F(),所以需要判断
    if (this instanceof F) {
      return new _this(...args, ...arguments)
    }
    return _this.apply(context, args.concat(...arguments))
  }
}

Promise 实现



Promise 是 ES6 新增的语法,解决了回调地狱的问题。
可以把 Promise 看出一个状态机。初始是 pending 状态,可以通过函数 resolve 和 reject,将状态转变为 resolved 或者 rejected 状态,状态一旦改变就不能再次变化。
then 函数会返回一个 Promise 实例,并且该返回值是一个新的实例而不是之前的实例。因为 Promise 规范规定除了 pending 状态,其他状态是不可以改变的,如果返回的是一个相同实例的话,多个 then 调用就失去意义了。
对于 then 来说,本质上可以把它看成是 flatMap

// 三种状态
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
// promise 接收一个函数参数,该函数会立即执行
function MyPromise(fn) {
  let _this = this;
  _this.currentState = PENDING;
  _this.value = undefined;
  // 用于保存 then 中的回调,只有当 promise
  // 状态为 pending 时才会缓存,并且每个实例至多缓存一个
  _this.resolvedCallbacks = [];
  _this.rejectedCallbacks = [];

  _this.resolve = function (value) {
    if (value instanceof MyPromise) {
      // 如果 value 是个 Promise,递归执行
      return value.then(_this.resolve, _this.reject)
    }
    setTimeout(() => { // 异步执行,保证执行顺序
      if (_this.currentState === PENDING) {
        _this.currentState = RESOLVED;
        _this.value = value;
        _this.resolvedCallbacks.forEach(cb => cb());
      }
    })
  };

  _this.reject = function (reason) {
    setTimeout(() => { // 异步执行,保证执行顺序
      if (_this.currentState === PENDING) {
        _this.currentState = REJECTED;
        _this.value = reason;
        _this.rejectedCallbacks.forEach(cb => cb());
      }
    })
  }
  // 用于解决以下问题
  // new Promise(() => throw Error('error))
  try {
    fn(_this.resolve, _this.reject);
  } catch (e) {
    _this.reject(e);
  }
}

MyPromise.prototype.then = function (onResolved, onRejected) {
  var self = this;
  // 规范 2.2.7,then 必须返回一个新的 promise
  var promise2;
  // 规范 2.2.onResolved 和 onRejected 都为可选参数
  // 如果类型不是函数需要忽略,同时也实现了透传
  // Promise.resolve(4).then().then((value) => console.log(value))
  onResolved = typeof onResolved === 'function' ? onResolved : v => v;
  onRejected = typeof onRejected === 'function' ? onRejected : r => throw r;

  if (self.currentState === RESOLVED) {
    return (promise2 = new MyPromise(function (resolve, reject) {
      // 规范 2.2.4,保证 onFulfilled,onRjected 异步执行
      // 所以用了 setTimeout 包裹下
      setTimeout(function () {
        try {
          var x = onResolved(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (reason) {
          reject(reason);
        }
      });
    }));
  }

  if (self.currentState === REJECTED) {
    return (promise2 = new MyPromise(function (resolve, reject) {
      setTimeout(function () {
        // 异步执行onRejected
        try {
          var x = onRejected(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (reason) {
          reject(reason);
        }
      });
    }));
  }

  if (self.currentState === PENDING) {
    return (promise2 = new MyPromise(function (resolve, reject) {
      self.resolvedCallbacks.push(function () {
        // 考虑到可能会有报错,所以使用 try/catch 包裹
        try {
          var x = onResolved(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (r) {
          reject(r);
        }
      });

      self.rejectedCallbacks.push(function () {
        try {
          var x = onRejected(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (r) {
          reject(r);
        }
      });
    }));
  }
};
// 规范 2.3
function resolutionProcedure(promise2, x, resolve, reject) {
  // 规范 2.3.1,x 不能和 promise2 相同,避免循环引用
  if (promise2 === x) {
    return reject(new TypeError("Error"));
  }
  // 规范 2.3.2
  // 如果 x 为 Promise,状态为 pending 需要继续等待否则执行
  if (x instanceof MyPromise) {
    if (x.currentState === PENDING) {
      x.then(function (value) {
        // 再次调用该函数是为了确认 x resolve 的
        // 参数是什么类型,如果是基本类型就再次 resolve
        // 把值传给下个 then
        resolutionProcedure(promise2, value, resolve, reject);
      }, reject);
    } else {
      x.then(resolve, reject);
    }
    return;
  }
  // 规范 2.3.3.3.3
  // reject 或者 resolve 其中一个执行过得话,忽略其他的
  let called = false;
  // 规范 2.3.3,判断 x 是否为对象或者函数
  if (x !== null && (typeof x === "object" || typeof x === "function")) {
    // 规范 2.3.3.2,如果不能取出 then,就 reject
    try {
      // 规范 2.3.3.1
      let then = x.then;
      // 如果 then 是函数,调用 x.then
      if (typeof then === "function") {
        // 规范 2.3.3.3
        then.call(
          x,
          y => {
            if (called) return;
            called = true;
            // 规范 2.3.3.3.1
            resolutionProcedure(promise2, y, resolve, reject);
          },
          e => {
            if (called) return;
            called = true;
            reject(e);
          }
        );
      } else {
        // 规范 2.3.3.4
        resolve(x);
      }
    } catch (e) {
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    // 规范 2.3.4,x 为基本类型
    resolve(x);
  }
}

以上就是根据 Promise / A+ 规范来实现的代码,可以通过 promises-aplus-tests 的完整测试。

Generator 实现



Generator 是 ES6 中新增的语法,和 Promise 一样,都可以用来异步编程

// 使用 * 表示这是一个 Generator 函数
// 内部可以通过 yield 暂停代码
// 通过调用 next 恢复执行
function* test() {
  let a = 1 + 2;
  yield 2;
  yield 3;
}
let b = test();
console.log(b.next()); // >  { value: 2, done: false }
console.log(b.next()); // >  { value: 3, done: false }
console.log(b.next()); // >  { value: undefined, done: true }

从以上代码可以发现,加上 * 的函数执行后拥有了 next 函数,也就是说函数执行后返回了一个对象。每次调用 next 函数可以继续执行被暂停的代码。以下是 Generator 函数的简单实现

// cb 也就是编译过的 test 函数
function generator(cb) {
  return (function() {
    var object = {
      next: 0,
      stop: function() {}
    };

    return {
      next: function() {
        var ret = cb(object);
        if (ret === undefined) return { value: undefined, done: true };
        return {
          value: ret,
          done: false
        };
      }
    };
  })();
}
// 如果你使用 babel 编译后可以发现 test 函数变成了这样
function test() {
  var a;
  return generator(function(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        // 可以发现通过 yield 将代码分割成几块
        // 每次执行 next 函数就执行一块代码
        // 并且表明下次需要执行哪块代码
        case 0:
          a = 1 + 2;
          _context.next = 4;
          return 2;
        case 4:
          _context.next = 6;
          return 3;
        // 执行完毕
        case 6:
        case "end":
          return _context.stop();
      }
    }
  });
}

map、flatMap 和 reduce



Map 作用是生成一个新数组,遍历原数组,将每个元素拿出来做一些变换然后 append 到新数组中。

[1, 2, 3].map((v) => v + 1)
// [2, 3, 4]

map 有三个参数,分别是当前索引元素、索引、原数组

['1', '2', '3'].map(parseInt)
// parseInt('1', 0) -> 1
// parseInt('2', 1) -> NaN
// parseInt('3', 2) -> NaN

flatMap 和 map 的作用几乎是相同的,但是对于多维数组来说,会将原数组降维,可以将 flatMap 看成是 map + flatten,目前该函数在浏览器中还不支持。

[1, [2], 3].map((v) => v + 1)
// [2, 3, 4]

如果将想将一个多维数组彻底的降维,可以这样实现

const flattenDeep = (arr) => Array.isArray(arr)
  ? arr.reduce((a, b) => [...a, ...flattenDeep(b)], [])
  : [arr])

reduce 作用是数组中的值组合起来,最终得到一个值

function a(){
  console.log(1)
}
function b(){
  console.log(2)
}
[a, b].reduce((a, b) => a(b()))
// -> 2 1

async 和 await



一个函数如果加上 async,那么该函数就会返回一个 Promise

async function test(){
  return '1'
}
console.log(test());
// Promise {: '1'}

可以把 async 看成函数返回值使用 Promise.resolve() 包裹了下。
await 只能在 async 函数中使用

function sleep(){
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('finish')
      resolve('sleep')
    }, 2000)
  })
}
async function test(){
  let value = await sleep()
  console.log('object')
}
test()

上面代码会先打印 finish,然后再打印 object。因为 await 会等待 sleep 函数 resolve,所以即使后面是同步代码,也不会先去执行同步代码再来执行异步代码。
async 和 await 相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码。缺点在于滥用 await 可能会导致性能问题,因为 await 会阻塞代码,也许之后的异步代码并不依赖于前者,但仍然需要等待前者完成,导致代码失去了并发性。
下面来看一个使用 await 的代码

var a = 0
var b = async () => {
  a = a + await 10
  console.log('2', a)
  a = (await 10) + a
  console.log('3', a)
}
b()
a++
console.log('1', a)
  • 首先函数 b 先执行,在执行到 await 10 之前变量 a 还是 0,因为在 await 内部实现了 generators,generators 会保留堆栈中的东西,所以这时候 a = 0 被保存了下来。
  • 因为 await 是异步操作,遇到 await 就会立即返回一个 pending 状态的 Promise 对象,暂时返回执行代码的 控制权,使得函数外的代码得以继续执行,所以会先执行 console.log('1', a)。
  • 这时候同步代码执行完毕,开始执行异步代码,将保存下来的值拿出来使用,这时候 a = 10。
  • 然后后面就是常规执行代码了。

转载于 https://yuchengkai.cn/docs/frontend/

你可能感兴趣的:(前端面试题(JS部分))