• Home
  • Archives
  Evan的博客
  • Home
  • Archives
  • 面试
  • 原理笔记
  • 项目实践
  • 其他

常见 JS 面试题

2020/10/09 posted in  面试

总结了一些常见的 JS 面试题,分享给大家,也供自己复习的时候看。

其他类型的常见面试题:

  • 常见 HTML 和 CSS 面试题
  • 常见 JS 面试题
  • 常见 Vue 面试题
  • 常见性能相关面试题
  • 常见大前端相关面试题

1. JS 的基本数据类型和引用数据类型有哪些,他们的存储结构有什么不同?

基本数据类型有 String,Number,Boolean,Null,Undefined,Symbol。引用数据类型有 Object,Array,Function。

  • 基本数据类型存储在内存的栈空间中,每个自定义起就是唯一的
  • 引用数据类型是存储在内存的堆空间中,然后堆地址存在栈空间里,其实读的是堆空间的引用。当一个引用类型赋值给一个变量,本质上是把这个地址的指针赋值过去了,这个地址指向的其实还是同一个堆空间

2. 如何判断 JS 数据类型?

  1. 基础类型用 typeof 就可以了,但是 null 是个例外,null 要用 === 来判断,因为一个早期的 bug 导致了 typeof null 是 Object
  2. typeof 引用数据类型(除了 Function)都是 Object,Array.isArray 判断数组
  3. isNaN 来判断 NaN,NaN 连他自己都不等

3. null,undefined 的区别?

null 表示这是一个空值,而 undefined 表示这是一个值,只是还没定义而已

4. NaN?

NaN 和全世界都不相等,包括它自己。用 isNaN()判断一个参数是不是 NaN

5. 介绍 JavaScript 的原型,原型链

原型链

6. JavaScript 如何实现一个类?

JS 的类是一个语法糖,本质上就是 new 一个对象出来,然后 return 出去,具体看 new 的原理,以及手写 new

7. 什么是节流?介绍一下应用场景和原理,且手写一个节流函数

节流指的是某一段时间内,某段代码或者说方法,仅执行一次。是触发立即执行的,然后某一段时间内重复执行将被无视,直到过了限制期,下一次触发才生效

常见的应用场景比如滚动触发某些事件,或者 touchmove 之类的事件,可以用节流

/**
 *
 * @param {Function} func 需要节流的函数
 * @param {Number} wait 需要节流的时间
 * @param {Boolean} immediate 是否立即执行
 * @param  {...any} args 节流函数的参数
 */
const throttle = (func, wait, immediate, ...args) => {if (immediate) {
    let pre = 0
    return function() {const now = +new Date()
      if (now - pre > wait) {func.apply(this, args)
        pre = now
      }
    }
  } else {
    let timeout = null
    return function() {
      const ctx = this
      if (!timeout) {timeout = setTimeout(() => {
          timeout = null
          func.apply(ctx, args)
        }, wait)}}
  }
}

const fn = n => {console.log(n)
}

const run = throttle(fn, 1000, 1, 123)

setInterval(() => {run()
}, 0)

8. 什么是防抖?介绍一下应用场景和原理,且手写一个防抖函数

防抖就是避免频繁触发同一个事件,两个事件之间必须相隔一段时间才可执行,如果一个事件触发后,还没间隔够就触发下一个事件,那么下一个事件不执行

/**
 *
 * @param {Function} func 需要防抖的方法
 * @param {Number} wait 等待时长
 * @param {Boolean} immediate 是否立即执行
 * @param  {...any} args 需要防抖的方法所需的参数
 */
const debounce = (func, wait, immediate, ...args) => {
  let timeout = null
  return function() {
    const ctx = this
    if (timeout) clearTimeout(timeout)
    if (immediate) {
      const callNow = !timeout
      timeout = setTimeout(() => {timeout = null}, wait)if (callNow) func.apply(ctx, args)
    } else {timeout = setTimeout(() => {func.apply(ctx, args)
      }, wait)}}
}

9. == 和 === 的区别

双等号会做类型隐式转换,三等号不会做类型隐式转换

10. 什么是作用域,什么是作用域链

作用域就是当前环境下的变量和内存空间的可访问性。在 ES6 的 const 和 let 出现之前,是没有块级作用域的,只有全局作用域和函数作用域。这也是为什么一些框架代码写在 IIFE 内,这是为了产生一个独立的作用域,避免污染全局环境。

当代码执行的时候,在当前作用域找不到变量,会去父级的作用域查找,这种链式查找就是作用域链。注意这里的作用域是定义时候就产生的,也就是说 js 在解释阶段就确定了作用域,而非在运行阶段

JS 是解释型语言,分为解释和执行两个阶段。

解释阶段包括:

  • 语法分析
  • 词法环境分析(也就是确定作用域)

执行阶段包括:

  • 创建执行上下文
  • 将函数推入执行栈顶并且执行函数
  • 垃圾回收

11. 什么是闭包

函数外部访问函数内部的变量。闭包本是为了纯函数式编程的。函数内声明函数的同时,还会保留当前函数作用域内的变量,return 函数出去或者赋值给外部对象,这些变量会跟着携带出去。只要闭包函数还存在,就会一直携带这些变量

闭包能避免全局污染: 闭包携带了属于自己的函数执行时需要的上下文(私有属性)就像在气泡内,每个闭包环境有自己的变量,如果定义在全局,那变量是可共享的,状态不唯一就无法追踪

12. 介绍一下 this

this 是执行时的上下文对象。一般可归纳为三种:

  • 正常执行语句,调用方法的时候,谁调用了方法,this 就绑定在谁身上。如果直接使用方法,就绑定在 window 对象上(node 在 global 上)
const obj = {
  name: 'Evan',
  fn: function () {console.log(this.name)
  }
}
const newFn = obj.fn()obj.fn() // obj 调用 fn,this(上下文对象)就是 obj,输出 obj.name 即 Evan
newFn() // 直接使用方法,浏览器中等价于 window 调用,this(上下文对象)就是 window,输出 window.name 即 undefined
  • new xxx()的时候,会生成一个新的对象(最终构造函数会 return 这个对象)this 就绑定在这个对象身上。this.xx 就相当于这个对象.xx
  • 箭头函数没有 this,而是继承于定义时的外层词法环境,如果没有则跟着作用域链一路查找直到最外层(浏览器是 window 对象,node 环境是 exports 对象,后面解释)
const obj = {a: function() {console.log(this)
  },
  b: () => {console.log(this)
  },
  c: {d: function() {console.log(this)
    },
    e: () => {console.log(this)
    }
  }
}

// 正常函数,谁调用 this 就在谁身上
obj.a() // obj
// 箭头函数没有 this,this 取决于定义时的最外层上下文
obj.b() // 浏览器 => window;node => exports
// 正常函数,谁调用 this 就在谁身上
obj.c.d() // obj.c
// 箭头函数没有 this,this 取决于定义时的最外层上下文
obj.c.e() // 浏览器 => window;node => exports
  • 为什么在 node 环境执行箭头函数 this 会指向 exports 而不是 global(module.exports),因为 Node 是模块化的,node 执行每个文件会给一个单独的作用域(模块),这是一个闭包环境。
  • 在一个模块(文件)中,声明的变量都是在这个闭包环境内的,不会污染 global,而 this 指向的是 module.exports,而不是 global
  • 如果直接调用一个正常定义的函数,那么这个就是由 global 对象调用的,this 会绑定在 global 上。如果调用一个箭头函数,this 是向上级词法环境查找的,如果上级没有作用域,就跟着作用域链一路找到定义时的最外层对象上(exports)
  • 所以 obj.c.e() 输出的是 exports,也就是 module.exports,这是当前文件的顶级对象,箭头函数没有 this,依赖定义时的外层对象,外层对象也没有词法环境,就一路往上找到 exports

13. 箭头函数的特点

箭头函数没有 this,而是继承于定义时的外层词法环境,如果没有则跟着作用域链一路查找直到最外层

14. 原生 JavaScript 如何实现对 DOM 节点的增删查改

// 增
const fatherDOM = document.querySelector('#father')
const sonDOM = document.createElement('div')
fatherDOM.appendChild(sonDOM)

// 删
const fatherDOM = document.querySelector('#father')
const sonDOM = document.querySelector('#son')
fatherDOM.removeChild(sonDOM)

// 查
const DOM = document.querySelectorAll('.box')

15. Array.slice()与 Array.splice() 的区别?

slice() 方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括 end)。原始数组不会被改变。

splice() 方法通过删除或替换现有元素或者原地添加新的元素来修改数组, 并以数组形式返回被修改的内容。此方法会改变原数组。

16. 数组有哪些方法不改变原数组?有哪些方法会改变原数组?

不改变原数组的方法:

  1. slice
  2. map
  3. fliter
  4. concat
  5. join

改变原数组的方法:

  1. pop
  2. push
  3. shift
  4. unshift
  5. splice

17. 介绍一下数组高阶方法

  1. find:找出第一个符合条件的成员
  2. findindex:找出第一个符合条件的成员下标
  3. includes:数组中是否存在某成员
  4. reduce:递归方法
  5. map:映射方法
  6. filter:过滤方法

18. typeof 和 instanceof 的区别

typeof 是判断变量是否属于某个类型,instanceof 是判断实例是否属于某个构造函数。

instanceof 的原理就是,在对象的原型链中能否找到构造函数的 prototype

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

19. Javascript 有哪些兼容性写法(这种题其实很少问了)

document.body 和 document.documentElement

20. 什么是变量提升

var 声明的变量会变量提升到顶部,也就是提前声明,但是还未赋值

21. 显示转换 / 隐式转换

  • 强制转换

    • Number
        Number(Number) -> 不变  
        Number(String) -> 可解析成数字或者 NaN
    Number(Boolean) -> true 为 1,false 为 0
    Number(undefined) -> NaN
    Number(null) -> 0
    Number(Array) -> 单数字 /NaN
    Number(Object) -> NaN
    • String
        String(基础类型) -> 字符串  
        String(Object) -> [object object](字符串)  
    String(Array) -> 相当于 Array.prototype.join
    • Boolean
        Boolean(undefined、0、-0、NaN、null、'') -> false  
        Boolean(其他) -> true
    
  • 隐式转换

    • 逻辑类如 if,三元运算,! 等,以布尔类型为准,其他转换成布尔
    • 算数类 +、-、*、/、% 以数字类型为准,其他转换成数字类型
    • 特殊 + 以字符串为准,其他转换成字符串
    • == 比较运算符
      • null == undefined
      • 数字为准,其他遵循:obj 先转,后转布尔,再转字符串

22. 手写一个 Promise

请参考这篇文章

23. promise 和 await async 有什么差别

  1. 从用法上,promise 是链式调用,await async 是类似同步的写法,await async 其实是自动执行 next 的 generator 的语法糖
  2. 从错误捕获上,promise 有 catch,await async 可以用 try catch
  3. 从性能上 await async 有堆栈追踪,性能比 promise 好。await/async 本质是生成器和迭代器的语法糖,它是将异步暂停且挂起,它不需要保留完整的堆栈信息,只需要保留读取堆栈的地址指针就可以了。而 promise 的链式调用并没有 "暂停代码",它通过 then 收集到了所有的回调依赖,保存在 resolveQueue 或者 rejectQueue 中。当它 resolve 或者 reject 的时候,从队列中把回调全部拿出来执行。这意味着它仍要保存回调的堆栈信息,那怕 promise 已经执行完毕了。

24. 介绍一下 promise.all 和 promise.race

promise.all 是等待一个 promise 数组,全部 resolve 了 promise.all 才会 resolve,其中任何一个 reject 了,promise.all 都会被 reject

promise.race 是等待若干个 promise 中最快 resolve 的那个结果

25. Object.create() 的时候做了什么

Object.create() 用来创建一个对象,接收两个参数

  1. 第一个是 __proto__ 指向的对象,如果设置为 null,那么这个新创建的对象不会拥有 Object 原型上的方法(包括 toString() 等)
  2. 第二个参数是对象上的属性所拥有的属性(是否可读写等,作用与 Object.defineProperties 一样)
const obj = Object.create(null, {
  name: {
    writable: true,
    configurable: true,
    value: 'Evan'
  }
})

26. 什么称之为“对象属性的属性”,如何查看,如何修改

  1. 对象上的属性都有其定义自身的属性,如是否可修改,删除,枚举等
  2. 通过 Object.getOwnPropertyDescriptor(obj,'property') 可查看这个属性的属性
  3. 通过 Object.defineProperty(obj,'property',{define:prop})。接收 3 个参数,第一个是对象本身,第二个是对象所需要修改的属性,第三个是对这个属性的描述,是一个对象。常见的描述有:
{ 
  value: 'Evan',  // 值
  writable: true,  // 是否可修改 value
  enumerable: true,  // 是否可枚举
  configurable: true  // 是否可删除
}

27. 介绍一下宏任务与微任务

宏任务和微任务都是事件循环队列里的一部分。队列中的每个宏任务都有一个属于其自己的微任务队列。每一轮事件循环只会执行一个宏任务和其对应的微任务队列,当宏任务执行完,就会去执行其对应的微任务队列。其下所有微任务执行完,结束这轮的事件循环,回过头检查宏任务队列,执行新的宏任务和其下微任务队列中的所有微任务。

宏任务 微任务
同步的代码 promise(注意如果用 setTimeout 模拟的 promise,那还是宏任务)
setTimeout node 中的 process.nextTick
setInterval
requestAnimationFrame
node 中的 setImmediate
// 描述这段代码的输出结果
console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2 end')
}

async1()

setTimeout(function() {
  console.log('setTimeout')
}, 0)

new Promise(resolve => {
  console.log('Promise')
  resolve()
}).then(function() {
  console.log('promise1')
}).then(function() {
  console.log('promise2')
})

console.log('script end')

// 对于代码中的输出结果,思路只要找到所有宏任务和微任务就好了:
// 第一轮宏任务输出 -> 第一轮宏任务中的微任务输出 -> 第二轮宏任务输出...

// 输出结果是:
// (开始输出第一轮宏任务)script start => async2 end => Promise => script end(第一轮宏任务输出结束) =>
// (开始输出第一轮宏任务所属的微任务)async1 end => promise1 => promise2(第一轮宏任务所属的微任务结束) => 
// (第二轮宏任务)setTimeout

28. 介绍一下 Set 数据结构何其方法,并说出常用的场景

Set 的成员都是唯一的,有 add,delete,has,clear 等方法,也是可枚举的,所以可遍历,也可以用扩展运算符展开。常用来存储唯一结果的数据,或者去重等

29. 介绍一下 Map 数据结构何其方法,并说出常用的场景

Map 是一种键值对形式的,映射(字典)数据结构。有 set,delete,get,has,clear 等方法。也是可以遍历的,常用于做映射

30. 介绍一下 get 和 set 方法

get 和 set 都是对象属性的一种特性(可通过 Object.defineProperty 设置)

在访问对象某个属性的时候,会触发 get,比如 obj.a 将会触发 a 的 getter;在设置对象某个属性的时候,会触发 set,比如 obj.a = 123 将会触发 a 的 setter。换句话说这两个东西可以作为钩子去处理一些逻辑。

31. 说一下运算符优先级

算数类>逻辑类>赋值

括号>算数运算符>大小判断运算符>等号与非>与>或>三元运算>赋值

32. 介绍一下深拷贝和浅拷贝,手写一个深拷贝

对于引用类型来说,赋值的时候其实是把地址赋值过去,并不是真正意义上的复制。于是有了浅拷贝和深拷贝的概念。

浅拷贝就是把对象第一层拥有的属性复制一份,但是如果属性中有引用类型的话,还是复制了它的引用地址

深拷贝就是递归下去的浅拷贝,保证每个属性和他的子属性都复制一份

function deepClone(obj) {function isObject(o) {return (typeof o === 'object' || typeof o === 'function') && o !== null
  }
  if (!isObject(obj)) return obj
  let newObj = Array.isArray(obj) ? [] : {}
  Reflect.ownKeys(obj).forEach(key => {newObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
  })return newObj}

33. indexOf() findIndex() find() 三者的区别

  1. indexOf() 在 String 和 Array 类型中都有,用来匹配字符串(如果是数组就匹配元素),找到了返回目标位置,找不到就返回 -1
  2. findIndex() 是 ES6 数组新增的方法,查找数组中符合条件的下标,找不到就返回 -1
  3. find() 是 ES6 数组新增的方法,查找符合条件的元素,找不到就返回 -1

34. 介绍一下 Date 对象常用方法

Date 是 JS 内置的日期对象,常用如 getTime(),getFullYear(),getMonth(),getDate()等,通过 +new Date() 可以很方便转换为时间戳

35. 介绍一下 Math 对象常用方法

Math 是 JS 内置的数学处理库,常见如 Math.floor(),Math.ceil(),Math.round() 等

36. vue 如何实现双向绑定

Vue 2.x 是通过 defineProperty 来劫持对象的属性,从而感知数据的变化,进而驱动视图变更。 在这个过程中,通过一个发布订阅机制来控制什么数据需要响应式,什么数据又不需要响应

比如没有渲染的数据,其实没必要追踪变化,只需要追踪 template 中的 {{}} 即可。需要渲染的数据发起订阅,收集依赖;数据改变时通知各个订阅方,未订阅的不通知

defineProperty 有几个弊端:

  1. 并不能劫持数组,所以 Vue 重写了数组相关的劫持方法
  2. defineProperty 不能响应式追踪新增的元素,所以 Vue 提供了 vue.$set
  3. defineProperty 是深度遍历的,不管多少层,反正就递归遍历完,对性能损耗很大

Vue 3.x 是通过 Proxy 来劫持对象的(包括数组)所以原生上就解决了无法追踪数组的问题(包括新增元素也是一个道理)。而且这里不需要深度递归,只需要递归到目标层级就好了

Proxy 还能结合 Reflect 很方便的获取和设置函数,这里就不演示了

// vue 2.x
const observe = obj => {if (!obj || typeof obj !== 'object') return obj
  Reflect.ownKeys(obj).forEach(item => {reactiveFn(obj, item, obj[item])
  })
}

const reactiveFn = (obj, key, val) => {observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {console.log('getter', val)
      return val
    },
    set(newV) {console.log('setter', newV)
      val = newV
    }
  })
}

let data = {
  name: 'Evan',
  age: 24
}

observe(data)
const name = data.name
data.age = 25
// vue 3.x
const proxyGenerator = obj => {if (!obj || typeof obj !== 'object') return obj

  const handler = {get(obj, key) {console.log('getter', obj[key])
      // 仅递归到目标层次,不用深度递归
      return proxyGenerator(obj[key])
    },
    set(obj, key, newV) {console.log('setter', newV)
      obj[key] = newV
    }
  }

  return new Proxy(obj, handler)
}

const userInfo = {
  name: 'Evan',
  age: 24
}

let userInfoProxy = proxyGenerator(userInfo)

const name = userInfoProxy.name
userInfoProxy.age = 25

更详细的介绍可以参考这篇文章

37. new XXX() 的时候发生了什么

  1. 创建新对象并且 this 指向这个对象,即 this 就是这个对象。this.xx 其实就是这个对象.xx。
  2. 新对象的 __proto__ 指向构造函数的原型,因此 new 的对象拥有构造函数原型上的所有属性与方法。这也是 instanceof 的原理。
  3. 最终构造函数会返回这个 this 对象。所以构造函数的目的本身是用于批量初始化对象,需要初始化某个对象就很适合用构造函数。
  4. 如果构造函数显式返回一个非对象返回值,则会被忽略,由新创建的对象(也就是 this)所覆盖。如果显式返回一个对象,则这个对象会覆盖掉新创建的 this 对象
  5. 此外构造函数虽然可以直接调用,但是意义不大,因为这样 window 会被当作 this 传入,构造的对象属性全部会被绑定到全局上,容易造成污染。并且严格模式下由于 this 不再默认指向 window,直接调用构造函数会导致 this 为定义,直接报错

38. 说一下 call,apply,bind 的区别

  • call、apply、bind 三者都是显式绑定 this 的一种方法
  • call 会执行方法,且带参是独立的一个个参数
  • apply 会执行方法,且带参是一个参数数组
  • bind 只是绑定 this,并不会执行
const A = {name: 'Evan'}
const B = {name: 'Fiona'}
function showInfo(age) {
  this.age = age
  console.log(this.name, this.age)
}
// this 绑定在对象 A 上,由对象 A 执行 showInfo,带参 21
showInfo.call(A, 21) // Evan,21
// this 绑定在对象 B 上,由对象 B 执行 showInfo,带参 20
showInfo.call(B, 20) // Fiona,20
// this 绑定在对象 A 上,由对象 A 执行 showInfo,带参 22
showInfo.apply(A, [22]) // Evan,22
// this 绑定在对象 A 上,赋值给 test,带参 23
const test = showInfo.bind(A, 23) // Evan,23
test()

39. 说一下运算符优先级

括号>算数运算符>大小判断运算符>等号与非>与>或>三元运算>赋值。总结就是算数类>逻辑类,逻辑类>赋值

40. 请描述一下 cookies,sessionStorage 和 localStorage 的区别?

  • cookies 只有 4kb,sessionstorage 和 localstorage 有 5m
  • cookies 在设置的时间之前不会过期,localstorange 在本地不会过期,sessionstorage 在窗口关闭之前不会过期

41. 什么是剩余参数数组,有什么用处

函数接收的参数如果用扩展运算符去展开,将会得到一个数组

function plus(...arr) {
  let sum = 0
  for (const i in arr) {sum += arr[i]
  }
  return sum
}

const a = plus(1, 2, 3, 4, 5)
console.log(a)

42. 什么是类数组结构

有 length,可遍历,但是没有数组相关的方法

43. js 如何追踪执行环境

  1. 如果是创建一个函数环境,那么创建形参及函数参数的默认值。如果是非函数环境,将跳过此步骤。
  2. 如果是创建全局或函数环境,就扫描当前代码进行函数声明(不会扫描其他函数的函数体),但是不会扫描函数表达式或箭头函数。对于所找到的函数声明,将创建函数,并绑定到当前环境与函数名相同的标识符上。若该标识符已经存在,那么该标识符的值将被重写。如果是块级作川域,将跳过此步骤。
  3. 扫描当前代码进行变量声明。在函数或全局环境中,找到所有当前函数以及其他函数之外通过 var 声明的变量,并找到所有在其他函数或代码块之外通过 let 或 const 定义的变量。在块级环境中,仅查找当前块中通过 let 或 const 定义的变量。对于所査找到的变量,若该标识符不有在,进行注册并将其初始化为 undefined。若该标识符已经存在,将保留

44. 模拟私有变量

// 构造函数模拟闭包
function SelfIncreasing() {
  let num = 0
  this.getNum = () => {return num}
  this.setNum = () => {num++}
}

// IIFE return 函数模拟闭包
const outter = (function() {
  let num = 0
  return function() {
    return {
      a: num,
      b: 1
    }
  }
})()

45. 闭包实现动画效果

function animateBox() {const box = document.getElementById('box')
  var tick = 0
  var timer = setInterval(function() {if (tick < 100) {
      box.style.left = box.style.top = tick + 'px'
      tick++
    } else {clearInterval(timer)
    }
  }, 10)
}

46. 什么是生成器

生成器通过 function* (){} 声明,在函数体内有 yield 关键字。生成器用于生成一系列返回值。

47. 什么是迭代器

迭代器用来控制生成器的执行,它有一个 next() 方法,每次执行这个方法。生成器就开始执行代码,当代码执行到 yield 关键字时,就会生成一个中间结果(生成值序列中的一项),然后返回一个新对象,其中封装了结果值和一个指示完成的指示器。

48. 描述一下生成器和迭代器的关系

  1. 初次运行生成器的时候并没有执行生成器内的任何代码,而是将生成器挂起并且生成了一个迭代器。
  2. 执行迭代器 next 方法的时候,生成器激活,执行代码直到遇到第一个 yield。这时生成器将会返回一个 yield 对象,这个对象包含一个生成的值(value)、下一个返回值的指示器(done,布尔类型),然后生成器挂起。
  3. 随后每次调用迭代器的 next,生成器就会重新激活,直到遇到 yield 再挂起,然后再 next 再激活,循环往复。当生成器无值返回时,value 将会返回 undefined,然后指示器 done 为 true,生成器结束。
  4. 用 for of 遍历迭代器时,将自动执行迭代器的 next 方法。

49. 描述一下生成器是如果做到暂停代码的

  1. 运行生成器的时候,js 引擎会把生成器执行上下文(本质上生成器也是一个函数)推进执行上下文栈顶。这时候会创建并且返回一个迭代器。这个迭代器其实就是对生成器的引用。返回迭代器后,生成器挂起,其执行上下文从栈顶弹出。但是由于还有迭代器的存在(这玩意是生成器的引用)所以其实生成器没有被完全销毁(有点类似闭包)内存中仍然有生成器的堆空间,迭代器可以看成是他的地址指针。
  2. 每次执行迭代器的 next 方法时,迭代器再通过引用找到被保存下来的生成器的上下文,把它推进执行上下文栈顶。生成器开始运作,当遇到 yield 的时候,返回包含 value 和指示器的对象,执行上下文从栈顶弹出,迭代器仍然保存着引用
  3. 往后的每一次 next 和 yield,都是如此。这就是生成器与普通函数的不同,普通函数每次调用都是生成一个新的上下文推进栈顶,执行完毕从栈顶移除并销毁;而生成器是把之前的上下文重新找出来,执行完毕弹出栈顶但是保存引用。

50. 使用生成器生成唯一 id

function* IdGenerator() {
  let id = 0
  while (true) {yield ++id}
}
const idIterator = IdGenerator()const id = idIterator.next().value
console.log(id)

51. Proxy 和 getter setter 的区别

Proxy 是代理一个对象,他接收两个参数,第一个参数是代理的目标,第二个参数是拦截处理器。

const handler = {get(obj, key) {console.log('getter', obj[key])
    return obj[key]
  },
  set(obj, key, newV) {console.log('setter', newV)
    obj[key] = newV
  }
}
const proxy = new Proxy(obj, handler)

Proxy 的 handler 可以做很多操作,除了 getter 和 setter 之外,还能拦截属性的读取,设置,甚至拦截 new 实例化

getter 和 setter 是 Object 的一个特性,他允许返回动态计算值的属性,换句话说就是钩子。在获取属性的时候触发 get,设置属性的时候触发 set。getter 和 setter 还能结合 Object.defineProperty 来做一些自定义的返回或者拦截

const obj = {log: ['a', 'b', 'c'],
  get latest() {if (this.log.length === 0) {return undefined;}
    return this.log[this.log.length - 1];
  }
};

console.log(obj.latest);

52. 描述一下事件循环

每一轮事件循环都包含一个宏任务队列和一个微任务队列:

  1. 每一轮循环只会处理队列中的第一个宏任务
  2. 每一轮循环会清空微任务队列,也就是说其实每个微任务队列是对应某一个宏任务的

所以每一轮事件循环,只执行对顶的宏任务和其对应的微任务队列,完成后当前事件结束。下一事件再处理新的宏任务和其对应的微任务队列

浏览器环境和 node 环境的事件循环有些不同,node 事件循环请看 #55 号问题

53. CommonJS 和 ES6 模块化有什么区别

  1. CommonJS 是被加载的时候运行,ES6 模块化是编译的时候确定依赖,也就是说重复引入同一个模块,也只会执行一次代码。tree-sharking 原理就是 ES6 模块依赖是编译时确定的。
  2. CommonJS 输出的是值的浅拷贝,并且写到内存中。ES6 模块化输出值的引用
  3. CommonJS 支持动态导入,也就是 require(${path}/xx.js)
  4. CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
// CommonJS
// counter.js
let count = 1
function increment() {count++}
module.exports = {
  count,
  increment
}

// main.js
const counter = require('counter.cjs')
counter.increment()
// CommonJS 这里输出 1,因为 CommonJS 输出的是值的浅拷贝过后的对象
// count++ 修改的是模块内的值,和输出的对象无关
console.log(counter.count)
// ES6 模块
// counter.mjs
export let count = 1
export function increment() {count++}

// main.mjs
import {increment, count} from './counter.mjs'
increment()
// ES6 模块输出 2,因为 ES6 模块化输出值的引用
console.log(count) // 2

54. 手写 call,apply,bind

// call
Function.prototype.myCall = function(ctx, ...args) {
  ctx = ctx || window
  ctx.fn = this
  const result = ctx.fn(...args)
  delete ctx.fn
  return result
}

// apply
Function.prototype.myApply = function(ctx, params) {
  ctx = ctx || window
  ctx.fn = this
  const result = params ? ctx.fn(...params) : ctx.fn()
  delete ctx.fn
  return result
}

// bind
Function.prototype.myBind = function(ctx, ...args) {
  // 存一下调用者(需要被绑定)的 this
  const _this = this
  // bind 返回的是一个 function,所以这个被返回的东西其实是可以 new 的
  return function F() {
    // 如果 new 了这个玩意,this 会被 new 改变,所以需要手动重新设置 this 的指向
    return this instanceof F ? new _this(...args, ...arguments) : _this.apply(ctx, args.concat(...arguments))
  }
}

55. 解释一下 node 的事件循环

node 的事件循环,每个循环都有这几个阶段

  1. timers

    首先是执行定时器,如果在当前循环种存在 setTimeout / setInterval 之类的定时器,将在这时候处理

  2. pending callbacks

    这个阶段也称为 I/O callbacks 阶段,主要是执行一些从上个循环延迟过来的 I/O 回调

  3. idle, prepare

    node 内部机制

  4. poll

    轮询,在这里检索新的 I/O 事件; 执行与 I/O 相关的回调,其余情况 node 将在适当的时候在此阻塞,直到达到最快的一个计时器阈值为止(比如 setTimeout 100ms,那如果这时候没事做,那他就等这个 setTimeout 100ms)

  5. check

    执行 setImmediate 的回调

  6. close callbacks

    关闭当前循环

  • 例子 1
const fs = require('fs')

setTimeout(() => {// do something}, 1000)

// 假设这个异步操作需要 10s
fs.readFile('./Demo.txt', (err, data) => {
  // do something
  // 通过 while 循环将 fs 回调强制阻塞 5s
  while (endTime - readFileStart < 5000) {// do something}
})setImmediate(() => {// do something})

// 1. 首先进入 timers 阶段,这时候并没有需要执行的 timers 回调(1s 后才会执行)代码进入异步的 I/O 操作
// 2. 进入 pending callbacks 后发现也没有要干的(这里假设异步 10s)不讨论 idle 和 prepare 内部机制,代码进入 poll 阶段
// 3. 进入 poll 轮询,一开始没有什么要干的(队列为空),但是发现一个 setImmediate,于是进入 check 阶段
// 4. 进入 check 阶段把 setImmediate 干了,结束当前循环
// 5. 开启新的一轮循环,这时候并没有需要执行的 timers 回调(可能还剩几百 ms 后才会执行)代码进入异步的 I/O 操作
// 6. 进入 pending callbacks 同理,没啥干的进入 poll 阶段
// 7. 进入 poll 轮询,但是也没啥干的,也没有 setImmediate 了,那就开始等待,然后等到了定时器的阀值到了,分发了一个事件这时候回到 timer 把定时器干了,然后开启下一轮事件循环
// 8. timer 和 pending callbacks 同理,到了 poll 之后,fs 终于好了,执行 I/O 的回调,结束循环
  • 例子 2
const fs = require('fs');

fs.readFile(__filename, () => {setTimeout(() => {console.log('timeout');
  }, 0);
  setImmediate(() => {console.log('immediate');
  });});

// 1. 首先进入 timer,啥也没,进入 I/O 读写
// 2. I/O 读写也没东西,进入轮询
// 3. 进入 poll,这时候开始等待,直到 I/O 回调到来,回调带来一个 setTimeout,写入 timer(注意这时候已经在 poll 阶段了,当前循环的 timer 期已经过了,要等下一个循环的 timer)还有一个 setImmediate,到 check 阶段
// 4. check 阶段把 setImmediate 干了,结束当前循环
// 5. 开启新的一轮循环,timer 中有个 setTimeout,把它干掉,继续当前循环

setTimeout 和 setImmediate 的区别在于,setTimeout 是到了时间阀值之后执行回掉,而 setImmediate 是在 check 阶段执行的回调

56. 手写 new 操作符

// new 的原理:
// 1. 生成一个新的对象
// 2. 这个对象拥有构造函数原型上的方法
// 3. this 指向这个对象(利用 apply 绑定 this)
// 4. 如果构造函数返回对象,则返回构造函数创建的对象,否则返回 new 出来的对象
function create(constructor, ...args) {let obj = {}
  obj.__proto__ = constructor.prototype
  const result = constructor.apply(obj, args)
  return result instanceof Object ? result : obj
}

// 测试
function Test(name, age) {
  this.name = name
  this.age = age
}
Test.prototype.sayName = function() {console.log(this.name)
}
const test = create(Test, 'Evan', 24)
console.log(test.name)
console.log(test.age)

57. 虚拟 dom 有什么优势

虚拟 dom 就是一个 js 的树结构,模拟了一颗 dom 树。

虚拟 dom 的优势:

  1. 直接操作 dom 效率是很低的,要重新布局,递归,渲染。但是操作 js 就很快了。如果是虚拟 dom,直接在 js 层就能把树的更改给确定,然后重新渲染改动的节点就好了。这样就不需要频繁修改 dom 节点,不用频繁回流重绘。
  2. 虚拟 dom 还可以在一些没有 dom 环境的地方模拟 dom,比如 node 实现 ssr 那也要虚拟 dom 来模拟一颗 dom 树,直接输出 html 给前端。

为了提高性能,dom 树的更改只会更改变动的部分,这是用的 diff 算法,(先序遍历深度优先)来确定什么枝叶变动了,也就是对比两个新旧 obj 不同的地方。

vue 3.x 的改动:

  1. 2.x 时代虚拟 dom 会记录所有的节点,无论这个节点是静态的(比如 <h1>Evan</h1> 这种)还是动态的(<h1>{{name}}</h1>)这样有个弊端就是,那种完全不会变的节点,每次 diff 遍历也要查一遍,白白浪费时间。3.x 时代给每个 dom 打上标记,静态的就不遍历了

不单是静态节点,这个标记的种类有很多种,比如是否有 key,是否带监听器之类的,每种类型都是一个数字。而且静态节点会被提前提升标记出来(类似变量提升)

  1. 3.x 多了事件缓存,事件会被写到一个可追踪的 cache 中,然后绑定事件的 dom 就变成静态节点了,事件怎么改反正就是一个 cache,拿出来用就好了

58. 用 genarator 实现 async await

const getData = ()=> new Promise(resolve => setTimeout(() => resolve('data'), 1000))function asyncToGenerator(genF) {return function() {return new Promise(function(resolve, reject) {const gen = genF()
      const step = nextF => {let { value, done} = nextF()if (done) return resolve(value)
        Promise.resolve(value).then(res => step(() => gen.next(res)),
          err => step(()=> gen.throw(err))
        ) }
      step(()=> gen.next())
    })}}

// 实现一个类似 async await 的方法
// async 关键字由 generator 代替
// await 关键字由 yield 代替
const test = asyncToGenerator(function*() {console.log(yield 'start')
  console.log('data:', yield getData())
  console.log('data2:', yield getData())
  return 'end'
})test().then(res => console.log(res))

« 白屏优化与性能指标

常见 Vue 面试题 »

Evan的博客

Evan 的博客 - 非典型码农,bug永动机
Instagram Weibo GitHub Email RSS

Categories

面试 原理笔记 项目实践 其他 JS Vue 性能优化 算法 计算机网络

Recent Posts

  • 从 HTTP 发展历程重学计算机网络
  • 应届前端的逆袭(中)
  • 应届前端的逆袭(上)
  • 应届前端的逆袭(下)
  • 前端面经复盘

Copyright © 2015 Powered by MWeb,  Theme used GitHub CSS.