漸進地瞭解漸進式框架Vue

前言

長文警告!閱讀時長5-10m。以代碼爲主,你將瞭解Vue響應式原理和運行機制。前端

v1-minimalist

原理:vue

  • Object.defineProperty劫持數據變換,更新dom
  • 事件監聽,改變數據

image

<body>
  <main>
    <input type="text" id="input">
    <br/>
    <label>值:<span id="span"></span></label>
  </main>
  <script src="./main.js"></script>
</body>
複製代碼
const obj = {};
const inputDom = document.querySelector('#input');
const spanDom = document.querySelector('#span');

Object.defineProperty(obj, 'txt', {
  get() {},
  set(newVal) {
    inputDom.value = newVal;
    spanDom.innerHTML = newVal;
  }
})

inputDom.addEventListener('input', (e) => {
  obj.txt = e.target.value
})

複製代碼

看看效果node

KBw5UU.gif

v2-observer

原理:git

  • 監聽者Observer: 用來劫持數據變化,通知發佈者Dep。
  • 發佈者Dep: 負責收集訂閱者Watcher,當收到監聽者Observer的通知時,傳遞訂閱者Watcher
  • 訂閱者Watcher: 當收到發佈者消息時,執行對應函數

KBw5UU.gif

  1. 發佈者Dep
let uid = 0;

class Dep {
  constructor() {
    this.id = uid++;
    this.subs = [];
  }
  // 添加訂閱者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知訂閱者更新
  notify() {
    this.subs.forEach(sub => sub.update())
  }
  // 
  depend() {
    Dep.target.addDep(this)
    // 如果新Dep,則會觸發addSub從新添加訂閱
  }
}

// 當指向當前活躍的Watcher => 執行get 便於收集依賴時(排除沒必要要的依賴)
Dep.target = null;
複製代碼
  1. 訂閱者Watcher
import Dep from './Dep'

class Watcher {
  constructor(vm, expOrFn, cb) {
    this.depIds = {}; // 存儲訂閱者的id
    this.vm = vm; // vue實例
    this.expOrFn = expOrFn; // 訂閱數據的key
    this.cb = cb; // 數據更新回調
    this.val = this.get();  // 首次實例,觸發get,收集依賴
  }
  get() {
    // 當前訂閱者(Watcher)讀取被訂閱數據的值時,通知訂閱者管理員收集當前訂閱者
    Dep.target = this;
    // 執行一次get
    const val = this.vm._data[this.expOrFn];
    Dep.target = null;
    return val
  }
  update() {
    this.run()
  }
  run () {
    const val = this.get();
    if (val !== this.val || isObject(val)) {
      this.val = val;
      this.cb.call(this.vm, val);
    }
  }
  addDep(dep) {
    if (!this.depIds.hasOwnProperty(dep.id)) {
      dep.addSub(this)
      this.depIds[dep.id] = dep;
    }
  }
}

function isObject (obj) {
  return obj !== null && typeof obj === 'object'
}

export default Watcher;
複製代碼
  1. 監聽者Observer
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep()
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      /// 數組,包裝數組響應式方法
      protoAugment(value, arrayMethods)
      this.observeArray(value)
    } else {
      // 對象,遍歷屬性,劫持數據
      this.walk(value)
    }
  }
  walk(value) {
    Object.keys(value).forEach(key => this.convert(key, value[key]))
  }
  convert(key, val) {
    defineReactive(this.value, key, val)
  }
  observeArray (items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

function defineReactive(obj, key, val) {
  const dep = new Dep();
  // 遞歸添加數據劫持
  let chlidOb = observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      if (Dep.target) {
        dep.depend();
        if (chlidOb) {
          chlidOb.dep.depend()
          if (Array.isArray(val)) {
            dependArray(val)
          }
        }
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      chlidOb = observe(newVal);
      dep.notify()
    }
  })
}
複製代碼

值得一提的是,defineProperty沒法監聽數組變化,這也是咱們在使用vue初期,困擾的this.arr[index] = xxx不會更新頁面的問題,必須在使用array的方法(經vue包裝過)才能達到預期效果,下面試着改造下Array的方法。github

  1. observeArray
import { def } from './util'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 會修改原數組的方法
const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]

methodsToPatch.forEach(function (method) {
  // cache original 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
  })
})

複製代碼

實際使用面試

const vm = new Vue({
  data: {
    txt: '',
    arr: []
  },
});

inputDom.addEventListener('input', e => vm.txt = e.target.value);

buttonDom.addEventListener('click', e => vm.arr.push(1));

vm.$watch('txt', txt => spanDom.innerHTML = txt);
vm.$watch('arr', arr => span1Dom.innerHTML = arr);
複製代碼

看看效果算法

KB0YIU.gif

v3-template

v2須要開發者操做dom,這一點也不mvvm。向vue看齊,實現一個簡單的模版compiler,處理模版;綁定數據;掛載dom;達到隔離dom操做的效果。小程序

原理:segmentfault

  • 將模版字符串經過innerHTML生成dom樹
  • 遍歷dom節點,解析指令(v-if/v-for/...),綁定數據({{...}}),掛載更新函數

Kd0ynK.png

  1. parser
export default function parseHTML(template) {
  const box = document.createElement('div')
  box.innerHTML = template
  const fragment = nodeToFragment(box);
  return fragment
}
export function nodeToFragment(el) {
  const fragment = document.createDocumentFragment();
  let child = el.firstChild;
  while (child) {
    fragment.appendChild(child);
    child = el.firstChild
  }
  return fragment;
}

複製代碼
  1. patch
export default function patch(el, vm) {
  const childNodes = el.childNodes;
  [].slice.call(childNodes).forEach(function(node) {
      const text = node.textContent;

      if (node.nodeType == 1) {  
        // 元素節點
        patchElement(node, vm);
      } else if (node.nodeType == 3) {
        // 文本節點
        patchText(node, vm, text);
      }

      if (node.childNodes && node.childNodes.length) {
        patch(node, vm);
      }
  });
  return el
}
<!--patchElement-->
export default function patchElement(node, vm) {
  const nodeAttrs = node.attributes;
  const nodeAttrsArr = Array.from(nodeAttrs)
  nodeAttrsArr.forEach((attr) => {
    const { name, value } = attr;
    // 默認指令
    if (dirRE.test(name)) {
      if (bindRE.test(name)) {  // v-bind
        const dir = name.replace(bindRE, '')
        handleBind(node, vm, value, dir)
      } else if (modelRE.test(name)) {  // v-model
        const dir = name.replace(modelRE, '')
        handleModel(node, vm, value, dir)
      } else if (onRE.test(name)) {  // v-on/@
        const dir = name.replace(onRE, '')
        handleEvent(node, vm, value, dir)
      } else if (ifArr.includes(name)) {  // v-if
        handleIf(node, vm, value, name)
      } else if (forRE.test(name)) {  // v-for
        handleFor(node, vm, value)
      }
      node.removeAttribute(name);
    }
  })
  return node
};

<!--patchText-->
const defaultTagRE = /\{\{(.*)\}\}/

export default function patchText(node, vm, text) {
  if (defaultTagRE.test(text)) {
    const exp = defaultTagRE.exec(text)[1]
    const initText = vm[exp];
    updateText(node, initText);
    new Watcher(vm, exp, (value) => updateText(node, value));
  }
}

function updateText(node, value) {
  node.textContent = isUndef(value) ? '' : value;
}

複製代碼
  1. directives(舉例說明)
<!--v-bind-->
export function handleBind (node, vm, exp, dir) {
  const val = vm[exp];
  updateAttr(node, val);
  new Watcher(vm, exp, (value) => updateAttr(node, value));
}

const updateAttr = (node, attr, value) => node.setAttribute(attr, isUndef(value) ? '' : value);

<!--v-model-->
export function handleModel (node, vm, exp, dir) {
  let val = vm[exp];
  updateModel(node, val);
  new Watcher(vm, exp, (value) => updateModel(node, value));
  handleEvent(node, vm, (e) => {
    const newValue = e.target.value;
    if (val === newValue) return;
    vm[exp] = newValue;
    val = newValue;
  }, 'input')
}

export function handleEvent (node, vm, exp, dir) {
  const eventType = dir;
  const cb = isFun(exp) ? exp : vm[exp].bind(vm);
  if (eventType && cb) {
    node.addEventListener(eventType, e => cb(e), false);
  }
}

const updateModel = (node, value) => node.value = isUndef(value) ? '' : value;

<!--v-for-->
export function handleFor (node, vm, exp) {
  const inMatch = exp.match(forAliasRE)
  if (!inMatch) return;
  exp = inMatch[2].trim();
  const alias = inMatch[1].trim();
  const val = vm[exp];
  const oldIndex = getIndex(node);
  const parentNode = node.parentNode;
  parentNode.removeChild(node);
  node.removeAttribute('v-for');
  const templateNode = node.cloneNode(true);
  appendForNode(parentNode, templateNode, val, alias, oldIndex);
  new Watcher(vm, exp, (value) => appendForNode(parentNode, templateNode, val, alias, oldIndex));
}

function appendForNode(parentNode, node, arr, alias, oldIndex) {
  removeOldNode(parentNode, oldIndex)
  for (const key in arr) {
    const templateNode = node.cloneNode(true)
    const patchNode = patch(templateNode, {[alias]: arr[key]})
    patchNode.setAttribute('data-for', true)
    parentNode.appendChild(patchNode)
  }
}
複製代碼

如今,咱們用模版試下效果數組

let vm = new Vue({
  el: '#app',
  template: 
  `<div>
    <input v-model="txt" type="text"/>
    <input @input="input" type="text"/>
    <br />
    <label>值:<span>{{txt}}</span></label>
    <br />
    <button @click="addArr">數組+1</button>
    <br />
    <label>數組:<span v-for="item in arr">{{item}}</span></label>
    <br />
    <label v-if="txt">是:<span>{{txt}}</span></label>
    <label v-else="txt">否</label>
  </div>`,
  data: {
    txt: '',
    arr: [1, 2, 3]
  },
  methods: {
    input(e) {
      const newValue = e.target.value;
      if (this.txt === newValue) return;
      this.txt = newValue;
    },
    addArr() {
      this.arr.push(this.arr.length + 1)
    }
  }
});
複製代碼

KBI97F.gif

v4-vdom

做爲消費級的框架而不是玩具(呵呵!依然是玩具。。。),固然是但願在保證可開發維護同時,咱們的性能要過得去。

顯然,由於數據變化而頻繁地更新dom,不是咱們想要。vue給的方案是VNode(對象的方式描述dom)

原理:

  • parse:將模板編譯成AST
  • generate:根據AST,拼接成函數字符串,經過new Function構造render函數(借用with延長做用域)

例:

K22FFU.jpg

  • 藉助Vnode的建立函數,執行render生成虛擬DOM樹
  • 經過diff算法,對比出更新的dom操做,執行更新。vue的diff算法參考了sanbbdom,想了解diff算法的發展歷程能夠參考部分diff算法演進

Kd060O.png

  1. parse代碼有點瑣碎,能夠直接看源碼
  2. generate
export function generate (ast) {
  const code = ast ? genElement(ast) : '_c("div")'
  return {
    render: `with(this){return ${code}}`
  }
}

export function genElement (el) {
  if (el.for && !el.forProcessed) {
    return genFor(el)
  } else if (el.if && !el.ifProcessed) {
    return genIf(el)
  } else {
    let code
    let data
    if (!el.plain) {
      data = genData(el)
    }

    const children = genChildren(el, true)
    code = `_c('${el.tag}'${
      data ? `,${data}` : '' // data
    }${
      children ? `,${children}` : '' // children
    })`
    return code
  }
}
....
複製代碼
  1. patch 大體規則以下:
    1. 只針對同層級節點對比(下降複雜度)
    2. 雙端比較,找到末端位置相同的節點(找到操做次數最少的更新路線)
    3. 雙端比較後未找到相同節點則遍歷查找
    4. 判斷兩個是否爲同節點(sameVnode:a.key === b.key && a.tag === b.tag),不然刪除舊節點,建立新節點;是則執行4
    5. 文本節點則替換文本,元素節點則比較子節點(遞歸)
function updateChildren (parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) { // oldStart == newStart  更新節點
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) { // oldEnd == newEnd  更新節點
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // oldStart == newEnd   更新節點 節點右移
      patchVnode(oldStartVnode, newEndVnode)
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // oldStart == newEnd   更新節點 節點左移
      patchVnode(oldEndVnode, newStartVnode)
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode)
          oldCh[idxInOld] = undefined
          nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 相同的鍵,但不一樣的元素。看成新元素對待
          createElm(newStartVnode, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  if (oldStartIdx > oldEndIdx) { // 須要新增節點
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx)
  } else if (newStartIdx > newEndIdx) { // 須要移除節點
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}
複製代碼

看一下分解動做:

MSTptS.gif

從sameVnode判斷上不難看出,在v-for循環出的列表的場景中,對元素設置key,直接指導diff是否複用DOM。

敲黑板,這裏指出兩個咱們編寫時的問題

  1. 使用index做爲key值(爲了騙過idea?)與不設置key效果其實相同,這兩種狀況下,都會複用DOM。由於index === index(undefind === undefind);
  2. 須要對列表進行增刪或改變順序時,建議設定獨特的id做爲key,這樣能夠最大限度指導diff,同時避免錯誤渲染;

寫到這裏一步,完成了vue的基本操縱,剩下擴展component/filter/mixin/生命週期等等特性就不一一分解了。 以上代碼主要爲了描述vue運行過程,部分借鑑vue源碼,但丟失了不少細節,有興趣的同窗能夠參考vue源碼分析

事實上,虛擬dom的意義遠非提升性能這麼簡單。咱們有了描述UI的規則後,單從vue來說,不依賴常規宿主環境,能夠是瀏覽器,是weex,或者node跑ssr;從大環境來說,這爲原生級跨端提供可能,好比RN;固然也有從編譯上階段實現跨平臺的,好比Taro/uniapp。

關於下一版,參考vue3.x,實現一些新特性。

聊一聊Vue3+

數據劫持的痛點

前面提到Vue2.x採用defineProperty劫持數據,這個作法有兩個問題。

一是須要初始化時,遍歷遞歸一必定義OB;
二是沒法劫持數組的變化,倒不是沒有方案劫持數組,基於性能考量,Vue採用了改造數組方法的方式;
複製代碼

Vue3.0採用了新的劫持方案Proxy,一次性解決上述問題。但就目前國內環境而言,依然存在大量低版本ie用戶,兼容版還會沿用2.x的機制

邏輯複用的歷程

  1. mixins

✅ 解決的問題:

將任意個組件特徵(屬性和方法)拷貝到須要的組件中,達到複用的目的
複製代碼

❌ 形成的困擾:

當多個mixins配合時,會出現數據源不清晰和命名可能衝突的問題
複製代碼
  1. slot-scope

✅ 解決的問題:

讓組件通用功能獲得封裝,而不一樣邏輯經過插槽分發
複製代碼

❌ 形成的困擾:

多層組件嵌套時,沒法清晰的體現具體是哪一個組件在模板中提供哪一個變量。
須要額外實例組件,形成額外性能開銷
複製代碼
  1. HOC

✅ 解決的問題:

秉承分層的思想,能夠處理和分發傳入的參數和方法
複製代碼

❌ 形成的困擾:

來自民間的用法,相較與React,Vue的HOC使用起來尤其雞肋。
由於原來的父子組件關係被分割,產生了屬性和方法以及真實ref傳遞問題,好比v-model之類的,都須要高階組件手動處理。
與slot-scope相似,由於須要額外的實例組件而形成性能開銷
複製代碼
  1. Function-based API

✅ 解決的問題(案例

從官方給出的案例來看,確實不存在上述方案形成的反作用。
至因而否會像社區所反應的,基於函數的 API 會形成大量麪條代碼產生,這就須要你們實踐了才知道了。
複製代碼

關於下一代

如下內容,純屬我的YY,不喜輕噴。

關於下一代,React已經指明瞭一個小方向---Fiber。且先不談它的出現會不會像vdom同樣爲前端帶來革命性的性能提高,單單循環任務調度的思路就很契合js的開發思路,Vue會不會借鑑暫時還不清楚,但至少會有適合Vue的方案出現。

在編譯階段作更多文章,在開發者和機器之間作更多,一方面能讓開發者更加專一邏輯而不是代碼組織;另外一方面提升運行時的效率,借鑑一個現下很熱門的例子---WebAssembly,固然編譯成機器更易於理解和執行的代碼,勢必讓框架編寫更多的判斷來解決適配以及線上調試難以定位等等問題。合理分割compiler和runtime的代碼也是框架必須思考的問題。

而後是Service Worker,目前看真正獲得普遍應用的仍是PWA方面,相信在Google的進一步推廣下(Apple依然會從中做梗),成爲標準也將會在各大框架中獲得應用,好比把diff放到WebWorker中去。這遠比小程序的思路---雙線程要來得有意思的多,固然我仍是尊重小程序做爲平臺向的做用。只是各家小程序接口和質量不一,沒有標準,要坐等小程序消費大戶---JD繼續探索。。。

參考

Vue2.6.10源碼

部分關於diff算法演進

Vue編譯對照

React Fiber架構

相關文章
相關標籤/搜索