前端高階面試題@JS篇

寒東設計師發表於2019-10-18

目錄

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)

瀏覽器

前端高階面試題@JS篇

如圖:瀏覽器中相對簡單,共有兩個事件佇列,當主執行緒空閒時會清空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 : "");
}
複製程式碼

相關文章