前端面試題(二)框架篇

MVVM

MVVM 由如下三個內容組成html

  • View:界面
  • Model:數據模型
  • ViewModel:做爲橋樑負責溝通 View 和 Model

在 JQuery 時期,若是須要刷新 UI 時,須要先取到對應的 DOM 再更新 UI,這樣數據和業務的邏輯就和頁面有強耦合。前端

在 MVVM 中,UI 是經過數據驅動的,數據一旦改變就會相應的刷新對應的 UI,UI 若是改變,也會改變對應的數據。這種方式就能夠在業務處理中只關心數據的流轉,而無需直接和頁面打交道。ViewModel 只關心數據和業務的處理,不關心 View 如何處理數據,在這種狀況下,View 和 Model 均可以獨立出來,任何一方改變了也不必定須要改變另外一方,而且能夠將一些可複用的邏輯放在一個 ViewModel 中,讓多個 View 複用這個 ViewModel。node

在 MVVM 中,最核心的也就是數據雙向綁定,例如 Angluar 的髒數據檢測,Vue 中的數據劫持。react

髒數據檢測

當觸發了指定事件後會進入髒數據檢測,這時會調用 $digest 循環遍歷全部的數據觀察者,判斷當前值是否和先前的值有區別,若是檢測到變化的話,會調用 $watch 函數,而後再次調用 $digest 循環直到發現沒有變化。循環至少爲二次 ,至多爲十次。git

髒數據檢測雖然存在低效的問題,可是不關心數據是經過什麼方式改變的,均可以完成任務,可是這在 Vue 中的雙向綁定是存在問題的。而且髒數據檢測能夠實現批量檢測出更新的值,再去統一更新 UI,大大減小了操做 DOM 的次數。因此低效也是相對的,這就仁者見仁智者見智了。github

數據劫持

Vue 內部使用了 Object.defineProperty() 來實現雙向綁定,經過這個函數能夠監聽到 setget 的事件。算法

var data = { name: 'yck' }
observe(data)
let name = data.name // -> get value
data.name = 'yyy' // -> change value

function observe(obj) {
  // 判斷類型
  if (!obj || typeof obj !== 'object') {
    return
  }
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

function defineReactive(obj, key, val) {
  // 遞歸子屬性
  observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log('get value')
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log('change value')
      val = newVal
    }
  })
}
複製代碼

以上代碼簡單的實現瞭如何監聽數據的 setget 的事件,可是僅僅如此是不夠的,還須要在適當的時候給屬性添加發布訂閱數組

<div>
    {{name}}
</div>
複製代碼

::: v-pre 在解析如上模板代碼時,遇到 {{name}} 就會給屬性 name 添加發布訂閱。 :::服務器

// 經過 Dep 解耦
class Dep {
  constructor() {
    this.subs = []
  }
  addSub(sub) {
    // sub 是 Watcher 實例
    this.subs.push(sub)
  }
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// 全局屬性,經過該屬性配置 Watcher
Dep.target = null

function update(value) {
  document.querySelector('div').innerText = value
}

class Watcher {
  constructor(obj, key, cb) {
    // 將 Dep.target 指向本身
    // 而後觸發屬性的 getter 添加監聽
    // 最後將 Dep.target 置空
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
    Dep.target = null
  }
  update() {
    // 得到新值
    this.value = this.obj[this.key]
    // 調用 update 方法更新 Dom
    this.cb(this.value)
  }
}
var data = { name: 'yck' }
observe(data)
// 模擬解析到 `{{name}}` 觸發的操做
new Watcher(data, 'name', update)
// update Dom innerText
data.name = 'yyy' 
複製代碼

接下來,對 defineReactive 函數進行改造app

function defineReactive(obj, key, val) {
  // 遞歸子屬性
  observe(val)
  let dp = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log('get value')
      // 將 Watcher 添加到訂閱
      if (Dep.target) {
        dp.addSub(Dep.target)
      }
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log('change value')
      val = newVal
      // 執行 watcher 的 update 方法
      dp.notify()
    }
  })
}
複製代碼

以上實現了一個簡易的雙向綁定,核心思路就是手動觸發一次屬性的 getter 來實現發佈訂閱的添加。

Proxy 與 Object.defineProperty 對比

Object.defineProperty 雖然已經可以實現雙向綁定了,可是他仍是有缺陷的。

  1. 只能對屬性進行數據劫持,因此須要深度遍歷整個對象
  2. 對於數組不能監聽到數據的變化

雖然 Vue 中確實能檢測到數組數據的變化,可是實際上是使用了 hack 的辦法,而且也是有缺陷的。

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 如下幾個函數
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
methodsToPatch.forEach(function (method) {
  // 得到原生函數
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // 調用原生函數
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 觸發更新
    ob.dep.notify()
    return result
  })
})
複製代碼

反觀 Proxy 就沒以上的問題,原生支持監聽數組變化,而且能夠直接對整個對象進行攔截,因此 Vue 也將在下個大版本中使用 Proxy 替換 Object.defineProperty

let onWatch = (obj, setBind, getLogger) => {
  let handler = {
    get(target, property, receiver) {
      getLogger(target, property)
      return Reflect.get(target, property, receiver);
    },
    set(target, property, value, receiver) {
      setBind(value);
      return Reflect.set(target, property, value);
    }
  };
  return new Proxy(obj, handler);
};

let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
  value = v
}, (target, property) => {
  console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // bind `value` to `2`
p.a // -> Get 'a' = 2
複製代碼

路由原理

前端路由實現起來其實很簡單,本質就是監聽 URL 的變化,而後匹配路由規則,顯示相應的頁面,而且無須刷新。目前單頁面使用的路由就只有兩種實現方式

  • hash 模式
  • history 模式

www.test.com/#/ 就是 Hash URL,當 # 後面的哈希值發生變化時,不會向服務器請求數據,能夠經過 hashchange 事件來監聽到 URL 的變化,從而進行跳轉頁面。

image

History 模式是 HTML5 新推出的功能,比之 Hash URL 更加美觀

image

Virtual Dom

代碼地址

爲何須要 Virtual Dom

衆所周知,操做 DOM 是很耗費性能的一件事情,既然如此,咱們能夠考慮經過 JS 對象來模擬 DOM 對象,畢竟操做 JS 對象比操做 DOM 省時的多。

舉個例子

// 假設這裏模擬一個 ul,其中包含了 5 個 li
[1, 2, 3, 4, 5]
// 這裏替換上面的 li
[1, 2, 5, 4]
複製代碼

從上述例子中,咱們一眼就能夠看出先前的 ul 中的第三個 li 被移除了,四五替換了位置。

若是以上操做對應到 DOM 中,那麼就是如下代碼

// 刪除第三個 li
ul.childNodes[2].remove()
// 將第四個 li 和第五個交換位置
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)
複製代碼

固然在實際操做中,咱們還須要給每一個節點一個標識,做爲判斷是同一個節點的依據。因此這也是 Vue 和 React 中官方推薦列表裏的節點使用惟一的 key 來保證性能。

那麼既然 DOM 對象能夠經過 JS 對象來模擬,反之也能夠經過 JS 對象來渲染出對應的 DOM

如下是一個 JS 對象模擬 DOM 對象的簡單實現

export default class Element {
  /** * @param {String} tag 'div' * @param {Object} props { class: 'item' } * @param {Array} children [ Element1, 'text'] * @param {String} key option */
  constructor(tag, props, children, key) {
    this.tag = tag
    this.props = props
    if (Array.isArray(children)) {
      this.children = children
    } else if (isString(children)) {
      this.key = children
      this.children = null
    }
    if (key) this.key = key
  }
  // 渲染
  render() {
    let root = this._createElement(
      this.tag,
      this.props,
      this.children,
      this.key
    )
    document.body.appendChild(root)
    return root
  }
  create() {
    return this._createElement(this.tag, this.props, this.children, this.key)
  }
  // 建立節點
  _createElement(tag, props, child, key) {
    // 經過 tag 建立節點
    let el = document.createElement(tag)
    // 設置節點屬性
    for (const key in props) {
      if (props.hasOwnProperty(key)) {
        const value = props[key]
        el.setAttribute(key, value)
      }
    }
    if (key) {
      el.setAttribute('key', key)
    }
    // 遞歸添加子節點
    if (child) {
      child.forEach(element => {
        let child
        if (element instanceof Element) {
          child = this._createElement(
            element.tag,
            element.props,
            element.children,
            element.key
          )
        } else {
          child = document.createTextNode(element)
        }
        el.appendChild(child)
      })
    }
    return el
  }
}
複製代碼

Virtual Dom 算法簡述

既然咱們已經經過 JS 來模擬實現了 DOM,那麼接下來的難點就在於如何判斷舊的對象和新的對象之間的差別。

DOM 是多叉樹的結構,若是須要完整的對比兩顆樹的差別,那麼須要的時間複雜度會是 O(n ^ 3),這個複雜度確定是不能接受的。因而 React 團隊優化了算法,實現了 O(n) 的複雜度來對比差別。

實現 O(n) 複雜度的關鍵就是隻對比同層的節點,而不是跨層對比,這也是考慮到在實際業務中不多會去跨層的移動 DOM 元素。

因此判斷差別的算法就分爲了兩步

  • 首先從上至下,從左往右遍歷對象,也就是樹的深度遍歷,這一步中會給每一個節點添加索引,便於最後渲染差別
  • 一旦節點有子元素,就去判斷子元素是否有不一樣

Virtual Dom 算法實現

樹的遞歸

首先咱們來實現樹的遞歸算法,在實現該算法前,先來考慮下兩個節點對比會有幾種狀況

  1. 新的節點的 tagName 或者 key 和舊的不一樣,這種狀況表明須要替換舊的節點,而且也再也不須要遍歷新舊節點的子元素了,由於整個舊節點都被刪掉了
  2. 新的節點的 tagNamekey(可能都沒有)和舊的相同,開始遍歷子樹
  3. 沒有新的節點,那麼什麼都不用作
import { StateEnums, isString, move } from './util'
import Element from './element'

export default function diff(oldDomTree, newDomTree) {
  // 用於記錄差別
  let pathchs = {}
  // 一開始的索引爲 0
  dfs(oldDomTree, newDomTree, 0, pathchs)
  return pathchs
}

function dfs(oldNode, newNode, index, patches) {
  // 用於保存子樹的更改
  let curPatches = []
  // 須要判斷三種狀況
  // 1.沒有新的節點,那麼什麼都不用作
  // 2.新的節點的 tagName 和 `key` 和舊的不一樣,就替換
  // 3.新的節點的 tagName 和 key(可能都沒有) 和舊的相同,開始遍歷子樹
  if (!newNode) {
  } else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
    // 判斷屬性是否變動
    let props = diffProps(oldNode.props, newNode.props)
    if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
    // 遍歷子樹
    diffChildren(oldNode.children, newNode.children, index, patches)
  } else {
    // 節點不一樣,須要替換
    curPatches.push({ type: StateEnums.Replace, node: newNode })
  }

  if (curPatches.length) {
    if (patches[index]) {
      patches[index] = patches[index].concat(curPatches)
    } else {
      patches[index] = curPatches
    }
  }
}
複製代碼

判斷屬性的更改

判斷屬性的更改也分三個步驟

  1. 遍歷舊的屬性列表,查看每一個屬性是否還存在於新的屬性列表中
  2. 遍歷新的屬性列表,判斷兩個列表中都存在的屬性的值是否有變化
  3. 在第二步中同時查看是否有屬性不存在與舊的屬性列列表中
function diffProps(oldProps, newProps) {
  // 判斷 Props 分如下三步驟
  // 先遍歷 oldProps 查看是否存在刪除的屬性
  // 而後遍歷 newProps 查看是否有屬性值被修改
  // 最後查看是否有屬性新增
  let change = []
  for (const key in oldProps) {
    if (oldProps.hasOwnProperty(key) && !newProps[key]) {
      change.push({
        prop: key
      })
    }
  }
  for (const key in newProps) {
    if (newProps.hasOwnProperty(key)) {
      const prop = newProps[key]
      if (oldProps[key] && oldProps[key] !== newProps[key]) {
        change.push({
          prop: key,
          value: newProps[key]
        })
      } else if (!oldProps[key]) {
        change.push({
          prop: key,
          value: newProps[key]
        })
      }
    }
  }
  return change
}

複製代碼

判斷列表差別算法實現

這個算法是整個 Virtual Dom 中最核心的算法,且讓我一一爲你道來。 這裏的主要步驟其實和判斷屬性差別是相似的,也是分爲三步

  1. 遍歷舊的節點列表,查看每一個節點是否還存在於新的節點列表中
  2. 遍歷新的節點列表,判斷是否有新的節點
  3. 在第二步中同時判斷節點是否有移動

PS:該算法只對有 key 的節點作處理

function listDiff(oldList, newList, index, patches) {
  // 爲了遍歷方便,先取出兩個 list 的全部 keys
  let oldKeys = getKeys(oldList)
  let newKeys = getKeys(newList)
  let changes = []

  // 用於保存變動後的節點數據
  // 使用該數組保存有如下好處
  // 1.能夠正確得到被刪除節點索引
  // 2.交換節點位置只須要操做一遍 DOM
  // 3.用於 `diffChildren` 函數中的判斷,只須要遍歷
  // 兩個樹中都存在的節點,而對於新增或者刪除的節點來講,徹底不必
  // 再去判斷一遍
  let list = []
  oldList &&
    oldList.forEach(item => {
      let key = item.key
      if (isString(item)) {
        key = item
      }
      // 尋找新的 children 中是否含有當前節點
      // 沒有的話須要刪除
      let index = newKeys.indexOf(key)
      if (index === -1) {
        list.push(null)
      } else list.push(key)
    })
  // 遍歷變動後的數組
  let length = list.length
  // 由於刪除數組元素是會更改索引的
  // 全部從後往前刪能夠保證索引不變
  for (let i = length - 1; i >= 0; i--) {
    // 判斷當前元素是否爲空,爲空表示須要刪除
    if (!list[i]) {
      list.splice(i, 1)
      changes.push({
        type: StateEnums.Remove,
        index: i
      })
    }
  }
  // 遍歷新的 list,判斷是否有節點新增或移動
  // 同時也對 `list` 作節點新增和移動節點的操做
  newList &&
    newList.forEach((item, i) => {
      let key = item.key
      if (isString(item)) {
        key = item
      }
      // 尋找舊的 children 中是否含有當前節點
      let index = list.indexOf(key)
      // 沒找到表明新節點,須要插入
      if (index === -1 || key == null) {
        changes.push({
          type: StateEnums.Insert,
          node: item,
          index: i
        })
        list.splice(i, 0, key)
      } else {
        // 找到了,須要判斷是否須要移動
        if (index !== i) {
          changes.push({
            type: StateEnums.Move,
            from: index,
            to: i
          })
          move(list, index, i)
        }
      }
    })
  return { changes, list }
}

function getKeys(list) {
  let keys = []
  let text
  list &&
    list.forEach(item => {
      let key
      if (isString(item)) {
        key = [item]
      } else if (item instanceof Element) {
        key = item.key
      }
      keys.push(key)
    })
  return keys
}
複製代碼

遍歷子元素打標識

對於這個函數來講,主要功能就兩個

  1. 判斷兩個列表差別
  2. 給節點打上標記

整體來講,該函數實現的功能很簡單

function diffChildren(oldChild, newChild, index, patches) {
  let { changes, list } = listDiff(oldChild, newChild, index, patches)
  if (changes.length) {
    if (patches[index]) {
      patches[index] = patches[index].concat(changes)
    } else {
      patches[index] = changes
    }
  }
  // 記錄上一個遍歷過的節點
  let last = null
  oldChild &&
    oldChild.forEach((item, i) => {
      let child = item && item.children
      if (child) {
        index =
          last && last.children ? index + last.children.length + 1 : index + 1
        let keyIndex = list.indexOf(item.key)
        let node = newChild[keyIndex]
        // 只遍歷新舊中都存在的節點,其餘新增或者刪除的不必遍歷
        if (node) {
          dfs(item, node, index, patches)
        }
      } else index += 1
      last = item
    })
}
複製代碼

渲染差別

經過以前的算法,咱們已經能夠得出兩個樹的差別了。既然知道了差別,就須要局部去更新 DOM 了,下面就讓咱們來看看 Virtual Dom 算法的最後一步驟

這個函數主要兩個功能

  1. 深度遍歷樹,將須要作變動操做的取出來
  2. 局部更新 DOM

總體來講這部分代碼仍是很好理解的

let index = 0
export default function patch(node, patchs) {
  let changes = patchs[index]
  let childNodes = node && node.childNodes
  // 這裏的深度遍歷和 diff 中是同樣的
  if (!childNodes) index += 1
  if (changes && changes.length && patchs[index]) {
    changeDom(node, changes)
  }
  let last = null
  if (childNodes && childNodes.length) {
    childNodes.forEach((item, i) => {
      index =
        last && last.children ? index + last.children.length + 1 : index + 1
      patch(item, patchs)
      last = item
    })
  }
}

function changeDom(node, changes, noChild) {
  changes &&
    changes.forEach(change => {
      let { type } = change
      switch (type) {
        case StateEnums.ChangeProps:
          let { props } = change
          props.forEach(item => {
            if (item.value) {
              node.setAttribute(item.prop, item.value)
            } else {
              node.removeAttribute(item.prop)
            }
          })
          break
        case StateEnums.Remove:
          node.childNodes[change.index].remove()
          break
        case StateEnums.Insert:
          let dom
          if (isString(change.node)) {
            dom = document.createTextNode(change.node)
          } else if (change.node instanceof Element) {
            dom = change.node.create()
          }
          node.insertBefore(dom, node.childNodes[change.index])
          break
        case StateEnums.Replace:
          node.parentNode.replaceChild(change.node.create(), node)
          break
        case StateEnums.Move:
          let fromNode = node.childNodes[change.from]
          let toNode = node.childNodes[change.to]
          let cloneFromNode = fromNode.cloneNode(true)
          let cloenToNode = toNode.cloneNode(true)
          node.replaceChild(cloneFromNode, toNode)
          node.replaceChild(cloenToNode, fromNode)
          break
        default:
          break
      }
    })
}
複製代碼

最後

Virtual Dom 算法的實現也就是如下三步

  1. 經過 JS 來模擬建立 DOM 對象
  2. 判斷兩個對象的差別
  3. 渲染差別
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])

let test1 = new Element('div', { class: 'my-div' }, [test4])

let test2 = new Element('div', { id: '11' }, [test5, test4])

let root = test1.render()

let pathchs = diff(test1, test2)
console.log(pathchs)

setTimeout(() => {
  console.log('開始更新')
  patch(root, pathchs)
  console.log('結束更新')
}, 1000)
複製代碼

固然目前的實現還略顯粗糙,可是對於理解 Virtual Dom 算法來講已是徹底足夠的了。

相關文章
相關標籤/搜索