前端高级面试题@JS篇

26,623 阅读5分钟

目录

JS

Es6

Node

性能优化

网络 / 浏览器

算法

说说js中的词法作用域

js中只有词法作用域,也就是说在定义时而不是执行时确定作用域。例如:

var value = 1;
 
function foo() {
    console.log(value);
}
 
function bar() {
    var value = 2;
    foo();
}
 
bar();<br>//1

注意: with和eval可以修改词法作用域

什么是闭包

《深入浅出nodejs》中对闭包的定义:

在js中,实现外部作用域访问内部作用域中变量的方法叫做“闭包”。

说说js的垃圾回收(GC)

v8的垃圾回收策略主要基于分代式垃圾回收机制。将内存分为新生代和老生代,分别采用不同的算法。

新生代采用Scavenge算法

Scavenge为新生代采用的算法,是一种采用复制的方式实现的垃圾回收算法。它将内存分为from和to两个空间。每次gc,会将from空间的存活对象复制到to空间。然后两个空间角色对换(又称反转)。
该算法是牺牲空间换时间,所以适合新生代,因为它的对象生存周期较短。

老生代采用Mark-Sweep 和 Mark-Compact

老生代中对象存活时间较长,不适合Scavenge算法。
Mark-Sweep是标记清除的意思。Scavenge是只复制存活对象,而Mark-Sweep是只清除死亡对象。该算法分为两个步骤:

  1. 遍历堆中所有对象并标记活着的对象
  2. 清除没有标记的对象

Mark-Sweep存在一个问题,清除死亡对象后会造成内存空间不连续,如果这时候再分配一个大对象,所有的空间碎片都无法完成此次分配,就会造成提前触发gc。这时候v8会使用Mark-Compact算法。
Mark-Copact是标记整理的意思。它会在标记完成之后将活着的对象往一端移动,移动完成后直接清理掉边界外的内存。因为存在整理过程,所以它的速度慢于Mark-Sweep,node中主要采用Mark-Sweep。

Incremental Marking

为了避免出现Javascript应用逻辑与垃圾回收器看到的情况不一致,垃圾回收时应用逻辑会停下来。这种行为被成为全停顿(stop-the-world)。这对老生代影响较大。
Incremental Marking称为增量标记,也就是拆分为许多小的“步进”,每次做完一“步进”,就让Javascript执行一会儿,垃圾回收与应用逻辑交替执行。
采用Incremental Marking后,gc的最大停顿时间较少到原来的 1 / 6 左右。

v8的内存限制

  • 64位系统最大约为1.4G
  • 32位系统最大约为0.7G

node中查看内存使用量

➜  ~ node
> process.memoryUsage()   //node进程内存使用
{ rss: 27054080,   // 进程常驻内存
  heapTotal: 7684096,  // 已申请到的堆内存
  heapUsed: 4850344,    // 当前使用的堆内存
  external: 9978  // 堆外内存(不是通过v8分配的内存)
  
> os.totalmem()  //系统总内存
17179869184

> os.freemem()  //系统闲置内存
3239858176

说说你了解的设计模式

发布订阅模式

在js中事件模型就相当于传统的发布订阅模式,具体实现参考实现一个node中的EventEmiter

策略模式

定义: 定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。

策略模式实现表单校验
const strategies = {
    isNoEmpty: function(value, errorMsg){
        if(value.trim() === ''){
            return errorMsg
        }
    },
    maxLength: function(value, errorMsg, len) {
        if(value.trim() > len) {
            return errorMsg
        }
    }
}

class Validator {
  constructor() {
    this.catch = [];
  }
  add(value, rule, errorMsg, ...others) {
    this.catch.push(function() {
      return strategies[rule].apply(this, [value, errorMsg, ...others]);
    });
  }
  start() {
    for (let i = 0, validatorFunc; (validatorFunc = this.catch[i++]); ) {
      let msg = validatorFunc();
      if (msg) {
        return msg;
      }
    }
  }
}

//使用
const validatorFunc = function() {
    const validator = new Validator();
    validator.add(username, 'isNoEmpty', '用户名不能为空');
    validator.add(password, 'isNoEmpty', '密码不能为空');
    const USERNAME_LEN = PASSWORD_LEN = 10;
    validator.add(username, 'maxLength', `用户名不能超过${USERNAME_LEN}个字`, USERNAME_LEN);
    validator.add(password, 'isNoEmpty', `密码不能为空${PASSWORD_LEN}个字`, PASSWORD_LEN);
    let msg = validator.start();
    if(msg) {
        return msg;
    }
}

命令模式

应用场景: 有时候我们要向某些对象发送请求,但不知道请求的接收者是谁,也不知道请求的操作是什么,此时希望以一种松耦合的方式来设计软件,使得请求的发送者和接收者能够消除彼此的耦合关系。

命令模式实现动画
class MoveCommand {
  constructor(reciever, pos) {
    this.reciever = reciever;
    this.pos = pos;
    this.oldPos = null;
  }
  excute() {
    this.reciever.start("left", this.pos, 1000);
    this.reciever.getPos();
  }
  undo() {
    this.reciever.start("left", this.oldPos, 1000);
  }
}


ES6 模块与 CommonJS 模块的差异

  1. CommonJS输出的是值的拷贝,ES6模块输出的是值的引用。
    也就是说CommonJS引用后改变模块内变量的值,其他引用模块不会改变,而ES6模块会改变。

  2. CommonJS是运行时加载,ES6模块是编译时输出接口。
    之所以Webpack的Tree Shaking是基于ES6的,就是因为ES6在编译的时候就能确定依赖。因为使用babel-preset-2015这个预设默认是会把ES6模块编译为CommonJS的,所以想使用Tree Shaking还需要手动修改这个预设。

module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [['babel-preset-es2015', {modules: false}]],
          }
        }
      }
    ]
}

async函数实现原理

async函数是基于generator实现,所以涉及到generator相关知识。在没有async函数之前,通常使用co库来执行generator,所以通过co我们也能模拟async的实现。

function Asyncfn() {
  return co(function*() {
    //.....
  });
}
function co(gen) {
  return new Promise((resolve, reject) => {
    const fn = gen();
    function next(data) {
      let { value, done } = fn.next(data);
      if (done) return resolve(value);
      Promise.resolve(value).then(res => {
        next(res);
      }, reject);
    }
    next();
  });
}

说说浏览器和node中的事件循环(EventLoop)

浏览器

如图:浏览器中相对简单,共有两个事件队列,当主线程空闲时会清空Microtask queue(微任务队列)依次执行Task Queue(宏任务队列)中的回调函数,每执行完一个之后再清空Microtask queue。

“当前执行栈” -> “micro-task” -> “task queue中取一个回调” -> “micro-task” -> ... (不断消费task queue) -> “micro-task”

nodejs

node中机制和浏览器有一些差异。node中的task queue是分为几个阶段,清空micro-task是在一个阶段结束之后(浏览器中是每一个任务结束之后),各个阶段如下:

   ┌───────────────────────┐
┌─>│        timers         │<————— 执行 setTimeout()、setInterval() 的回调
│  └──────────┬────────────┘
|             |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
│  ┌──────────┴────────────┐
│  │     pending callbacks │<————— 执行由上一个 Tick 延迟下来的 I/O 回调(待完善,可忽略)
│  └──────────┬────────────┘
|             |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
│  ┌──────────┴────────────┐
│  │     idle, prepare     │<————— 内部调用(可忽略)
│  └──────────┬────────────┘     
|             |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
|             |                   ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:- (执行几乎所有的回调,除了 close callbacks 以及 timers 调度的回调和 setImmediate() 调度的回调,在恰当的时机将会阻塞在此阶段)
│  │         poll          │<─────┤  connections, │ 
│  └──────────┬────────────┘      │   data, etc.  │ 
│             |                   |               | 
|             |                   └───────────────┘
|             |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
|  ┌──────────┴────────────┐      
│  │        check          │<————— setImmediate() 的回调将会在这个阶段执行
│  └──────────┬────────────┘
|             |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
│  ┌──────────┴────────────┐
└──┤    close callbacks    │<————— socket.on('close', ...)
   └───────────────────────┘

这里我们主要关注其中的3个阶段:timer、poll和check,其中poll队列相对复杂:

轮询 阶段有两个重要的功能:
1、计算应该阻塞和轮询 I/O 的时间。
2、然后,处理 轮询 队列里的事件。

当事件循环进入 轮询 阶段且 没有计划计时器时 ,将发生以下两种情况之一:
1、如果轮询队列不是空的,事件循环将循环访问其回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬限制。
2、如果轮询队列是空的,还有两件事发生:
a、如果脚本已按 setImmediate() 排定,则事件循环将结束 轮询 阶段,并继续 check阶段以执行这些计划脚本。
b、如果脚本 尚未 按 setImmediate()排定,则事件循环将等待回调添加到队列中,然后立即执行。

一旦轮询队列为空,事件循环将检查已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回计时器阶段以执行这些计时器的回调。

细节请参考The Node.js Event Loop, Timers, and process.nextTick()
中文:Node.js 事件循环,定时器和 process.nextTick()

通过程序理解浏览器和node中的差异

setTimeout(() => {
  console.log("timer1");
  Promise.resolve().then(function() {
    console.log("promise1");
  });
}, 0);

setTimeout(() => {
  console.log("timer2");
  Promise.resolve().then(function() {
    console.log("promise2");
  });
}, 0);

在浏览器中的顺序是:timer1 -> promise1 -> timer2 -> pormise2
node中顺序是: timer1 -> timer2 -> promise1 -> promise2
这道题目很好的说明了node中的micro-task是在一个阶段的任务执行完之后才清空的。

实现一个node中的EventEmiter

简单实现:

class EventsEmiter {
  constructor() {
    this.events = {};
  }
  on(type, fn) {
    const events = this.events;
    if (!events[type]) {
      events[type] = [fn];
    } else {
      events[type].push(fn);
    }
  }
  emit(type, ...res) {
    const events = this.events;
    if (events[type]) {
      events[type].forEach(fn => fn.apply(this, res));
    }
  }
  remove(type, fn) {
    const events = this.events;
    if (events[type]) {
      events[type] = events[type].filer(lisener => lisener !== fn);
    }
  }
}

实现一个node中util模块的promisify方法

let fs = require("fs");
let read = fs.readFile;

function promisify(fn) {
  return function(...args) {
    return new Promise((resolve, reject) => {
      fn(...args, (err, data) => {
        if (err) {
          reject(err);
        }
        resolve(data);
      });
    });
  };
}

// 回调用法
// read("./test.json", (err, data) => {
//   if (err) {
//     console.error("err", err);
//   }
//   console.log("data", data.toString());
// });

// promise用法
let readPromise = promisify(read);

readPromise("./test.json").then(res => {
  console.log("data", res.toString());
});

如何实现一个自定义流

根据所创建的流类型,新的流类必须实现一个或多个特定的方法,如下图所示:

用例需实现的方法
只读流Readable_read
只写流Writable_write, _writev, _final
可读可写流Duplex_read, _write, _writev, _final
对写入的数据进行操作,然后读取结果Transform_transform, _flush, _final

以双工流为例:

const { Duplex } = require('stream');

class Myduplex extends  Duplex {
  constructor(arr, opt) {
    super(opt);
    this.arr = arr
    this.index = 0
  }
  //实现可读流部分
  _read(size) {
    this.index++
    if(this.index === 3) {
        this.push(null) 
    } else {
        this.push(this.index.toString())
    }
  }
  //实现可写流
  _write(chunk, encoding, callback) {
    this.arr.push(chunk.toString())
    callback()
  }
}

更多内容可以参考我的另一篇文章:说说node中可读流和可写流nodejs官网

性能优化之dns-prefetch、prefetch、preload、defer、async

dns-prefetch

域名转化为ip是一个比较耗时的过程,dns-prefetch能让浏览器空闲的时候帮你做这件事。尤其大型网站会使用多域名,这时候更加需要dns预取。

//来自百度首页
<link rel="dns-prefetch" href="//m.baidu.com">

prefetch

prefetch一般用来预加载可能使用的资源,一般是对用户行为的一种判断,浏览器会在空闲的时候加载prefetch的资源。

<link rel="prefetch" href="http://www.example.com/">

preload

和prefetch不同,prefecth通常是加载接下来可能用到的页面资源,而preload是加载当前页面要用的脚本、样式、字体、图片等资源。所以preload不是空闲时加载,它的优先级更强,并且会占用http请求数量。

<link rel='preload' href='style.css' as="style" onload="console.log('style loaded')"

as值包括

  • "script"
  • "style"
  • "image"
  • "media"
  • "document" onload方法是资源加载完成的回调函数

defer和async

//defer
<script defer src="script.js"></script>
//async
<script async src="script.js"></script>

defer和async都是异步(并行)加载资源,不同点是async是加载完立即执行,而defer是加载完不执行,等到所有元素解析完再执行,也就是DOMContentLoaded事件触发之前。
因为async加载的资源是加载完执行,所以它比不能保证顺序,而defer会按顺序执行脚本。

说说react性能优化

shouldComponentUpdate

举例:下面是antd-design-mobile的Modal组件中对的内部蒙层组件的处理

import * as React from "react";

export interface lazyRenderProps {
  style: {};
  visible?: boolean;
  className?: string;
}

export default class LazyRender extends React.Component<lazyRenderProps, any> {
  shouldComponentUpdate(nextProps: lazyRenderProps) {
    return !!nextProps.visible;
  }
  render() {
    const props: any = { ...this.props };
    delete props.visible;
    return <div {...props} />;
  }
}

immutable

像上面这种只比较了一个visible属性,并且它是string类型,如果是一个object类型那么就不能直接比较了,这时候使用immutable库更好一些。
immutable优势:

  • 性能更好
  • 更加安全 immutable劣势:
  • 库比较大(压缩后大约16k)
  • api和js不兼容

解决方案:seamless-immutable seamless-immutable这个库没有完整实现Persistent Data Structure,而是使用了Object.defineProperty扩展了JS的Object和Array对象,所以保持了相同的Api,同时库的代码量更少,压缩后大约2k

基于key的优化

文档中已经强调,key需要保证在当前的作用域中唯一,不要使用当前循环的index(尤其在长列表中)。
参考 reactjs.org/docs/reconc…

说说浏览器渲染流程

浏览器的主进程:Browser进程

  1. 负责下载资源
  2. 创建销毁renderer进程
  3. 负责将renderer进程生成的位图渲染到页面上
  4. 与用户交互

浏览器内核:renderer进程

js引擎线程

由一个主线程和多个web worder线程组成,web worker线程不能操作dom

GUI线程

用于解析html生成DOM树,解析css生成CSSOM,布局layout、绘制paint。回流和重绘依赖该线程

事件线程

当事件触发时,该线程将事件的回调函数放入callback queue(任务队列)中,等待js引擎线程处理

定时触发线程

setTimeout和setInterval由该线程来记时,记时结束,将回调函数放入任务队列

http请求线程

每有一个http请求就开一个该线程,每当检测到状态变更就会产生一个状态变更事件,如果这个事件由对应的回掉函数,将这个函数放入任务队列

任务队列轮询线程

用于轮询监听任务队列

流程

  1. 获取html文件
  2. 从上到下解析html
  3. 并行请求资源(css资源不会阻塞html解析,但是会阻塞页面渲染。js资源会组织html解析)
  4. 生成DOM tree 和 style rules
  5. 构建render tree
  6. 执行布局过程(layout、也叫回流),确定元素在屏幕上的具体坐标
  7. 绘制到屏幕上(paint)

事件

DOMContentLoaded

当初始的HTML文档被完全加载和解析完成(script脚本执行完,所属的script脚本之前的样式表加载解析完成)之后,DOMContentLoaded事件被触发

onload

所有资源加载完成触发window的onload事件

参考流程图:www.processon.com/view/5a6861…

说说http2.0

http2.0是对SPDY协议的一个升级版。和http1.0相比主要有以下特性:

  • 二进制分帧
  • 首部压缩
  • 多路复用
  • 请求优先级
  • 服务端推送(server push)

详细可参考: HTTP----HTTP2.0新特性

实现一个reduce方法

注意边界条件:1、数组长度为0,并且reduce没有传入初始参数时,抛出错误。2、reduce有返回值。

Array.prototype.myReduce = function(fn, initial) {
  if (this.length === 0 && !initial) {
    throw new Error("no initial and array is empty");
  }
  let start = 1;
  let pre = this[0];
  if (initial) {
    start = 0;
    pre = initial;
  }
  for (let i = start; i < this.length; i++) {
    let current = this[i];
    pre = fn.call(this, pre, current, i);
  }
  return pre;
};

实现一个promise.all方法,要求保留错误并且并发数为3

标准的all方法是遇到错误会立即将promise置为失败态,并触发error回调。保留错误的定义为:promise遇到错误保存在返回的结果中。

function promiseall(promises) {
  return new Promise(resolve => {
    let result = [];
    let flag = 0;
    let taskQueue = promises.slice(0, 3); //任务队列,初始为最大并发数3
    let others = promises.slice(3); //排队的任务

    taskQueue.forEach((promise, i) => {
      singleTaskRun(promise, i);
    });

    let i = 3; //新的任务从索引3开始
    function next() {
      if (others.length === 0) {
        return;
      }
      const newTask = others.shift();
      singleTaskRun(newTask, i++);
    }

    function singleTaskRun(promise, i) {
      promise
        .then(res => {
          check();
          result[i] = res;
          next();
        })
        .catch(err => {
          check();
          result[i] = err;
          next();
        });
    }
    function check() {
      flag++;
      if (flag === promises.length) {
        resolve(result);
      }
    }
  });
}

测试代码:

let p1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("1");
  }, 1000);
});
let p2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("2");
  }, 1500);
});
let p3 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("3");
  }, 2000);
});
let p4 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("4");
  }, 2500);
});
let p_e = new Promise((resolve, reject) => {
  // throw new Error("出错");
  reject("错误");
});
let p5 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("5");
  }, 5000);
});

let all = promiseall([p_e, p1, p3, p2, p4, p5]);
all.then(
  data => {
    console.log("data", data);    // [ '错误', '1', '3', '2', '4', '5' ]
  }
);

不用递归函数求一个二叉树的高度

先看一下递归的实现(二叉树的深度优先遍历):

function getBinaryTreeHeigth(node) {
  let maxDeep = 0;
  function next(n, deep) {
    deep++;
    if (n.l) {
      let newDeep = next(n.l, deep);
      if (newDeep > maxDeep) {
        maxDeep = newDeep;
      }
    }
    if (n.r) {
      let newDeep = next(n.r, deep);
      if (newDeep > maxDeep) {
        maxDeep = newDeep;
      }
    }
    return deep;
  }
  next(node, 0);
  return maxDeep;
}

function Node(v, l, r) {
  this.v = v;
  this.l = l;
  this.r = r;
}

非递归的实现(二叉树的广度优先遍历):

function getBinaryTreeHeigth(node) {
  if (!node) {
    return 0;
  }
  const queue = [node];
  let deep = 0;
  while (queue.length) {
    deep++;
    for (let i = 0; i < queue.length; i++) {
      const cur = queue.pop();
      if (cur.l) {
        queue.unshift(cur.l);
      }
      if (cur.r) {
        queue.unshift(cur.r);
      }
    }
  }
  return deep;
}

function Node(v, l, r) {
  this.v = v;
  this.l = l;
  this.r = r;
}

js中求两个大数相加

给定两个以字符串形式表示的非负整数 num1 和 num2,返回它们的和,仍用字符串表示。

输入:num1 = '1234', num2 = '987'
输出:'2221'

function bigIntAdd(str1, str2) {
  let result = [];
  let ary1 = str1.split("");
  let ary2 = str2.split("");
  let flag = false; //是否进位
  while (ary1.length || ary2.length) {
    let result_c = sigle_pos_add(ary1.pop(), ary2.pop());
    if (flag) {
      result_c = result_c + 1;
    }
    result.unshift(result_c % 10);

    if (result_c >= 10) {
      flag = true;
    } else {
      flag = false;
    }
  }
  if(flag) {
    result.unshift('1');
  }
  return result.join("");
}

function sigle_pos_add(str1_c, str2_c) {
  let l = (r = 0);
  if (str1_c) {
    l = Number(str1_c);
  }
  if (str2_c) {
    r = Number(str2_c);
  }
  return l + r;
}

测试代码:

const str1 = "1234";
const str2 = "987654321";
const str3 = "4566786445677555";
const str4 = "987";

console.log(bigIntAdd(str1, str4))  //'2221'
console.log(bigIntAdd(str2, str3))  //'4566787433331876'

实现一个数组随机打乱算法

function disOrder(ary) {
  for (let i = 0; i < ary.length; i++) {
    let randomIndex = Math.floor(Math.random() * ary.length);
    swap(ary, i, randomIndex);
  }
}

function swap(ary, a, b) {
  let temp = ary[a];
  ary[a] = ary[b];
  ary[b] = temp;
}

let ary = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
disOrder(ary);

console.log(ary);

给数字增加“逗号”分隔

输入: '"123456789.012"' 输出:123,456,789.012

正则解法:

function parseNumber(num) {
  if (!num) return "";
  return num.replace(/(\d)(?=(\d{3})+(\.|$))/g, "$1,");
}

非正则:

function formatNumber(num) {
  if (!num) return "";
  let [int, float] = num.split(".");
  let intArr = int.split("");
  let result = [];
  let i = 0;
  while (intArr.length) {
    if (i !== 0 && i % 3 === 0) {
      result.unshift(intArr.pop() + ",");
    } else {
      result.unshift(intArr.pop());
    }
    i++;
  }

  return result.join("") + "." + (float ? float : "");
}