前端面试题(二)框架篇

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 算法来讲已是彻底足够的了。

相关文章
相关标签/搜索