渐进地了解渐进式框架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架构

相关文章
相关标签/搜索