网络日志

值得一阅的vue源码解读

现在这个时候在聊起vue源码,不论是vue2还是vue3都有些老生常谈了吧。没得办法,谁让咱卷的慢呢,so 权当是个笔记吧

理解Vue的设计思想

MVVM框架的三要素:数据响应式、模板引擎及其渲染

  1. 数据响应式:监听数据变化并在视图中更新
  2. 模版引擎:提供描述视图的模版语法
  3. 渲染:如何将模板转换为html

先考虑new Vue之后都做了什么(vue2)

笼统的来说就做了组件实例化、初始话这么一个事

  1. 选项合并(mergeOptions)将全局注册的组件换入到new Vue的是实例中
  2. 组件是实例的一些属性方法的初始化
  3. 派发两个声明周期的钩子callHook(vm, 'beforeCreate')callHook(vm, 'created')
  4. 挂载
initLifecycle(vm) // $parent $children 实例属性
initEvent(vm) // 事件的监听
initRender(vm) // 插槽 $slots $scopedSlots _c()/$createElement 生成vdom
callHook(vm, 'beforeCreate')
initInjections(vm) // 注入祖辈传递下来的数据
initState(vm) // 处理props/data/computed/watch/methods
initProvide(vm) // 向后代传递
callHook(vm, 'created')  

new Vue

class Vue {
  constructor(options) {
    // 0.保存options
    this.$options = options;
    this.$data = options.data;
    // 1.将data做响应式处理
    new Observer(this.$data);
    // 2.为$data做代理
    proxy(this, "$data");
    // 3.编译模板
    if (options.el) {
      this.$mount(options.el);
    }
  }

  // 添加$mount
  $mount(el) {
    this.$el = document.querySelector(el);
    // 1.声明updateComponent
    const updateComponent = () => {
      // 渲染获取视图结构
     const el = this.$options.render.call(this);
      // 结果追加
      const parent = this.$el.parentElement;
      parent.insertBefore(el, this.$el.nextSibling);
      parent.removeChild(this.$el);
      this.$el = el;
    };
    // 2.new Watcher
    new Watcher(this, updateComponent);
  }
}

创建Vue时候的时候第一时间保存的了options选项并进行了数据响应式的处理,返回的app在调用$mount进行渲染挂载其实在new Watcher和$mount()之间还有一个compile的类存在,我这里没有写,因为全写会比较复杂。

从实现数据响应式开始

  • Vue.set(obj, "key", "value")set方法向obj追加key的时候要求obj必须是一个响应式数据,此方法只能用于向响应式数据中追加字段。
  • Vue.util.defineReactive(obj, "key", "value")这个方法对obj是不是响应式数据并没有要求,通过此方法可以在obj中追加字段并且将obj变成一个响应式数据
  • 直接new Vue({return {$$state: obj}})可以在返回的数据直接变成响应式,这里加上$$或者加_是为了在Vue实例过程中避免vue对这个是字段进行代理,只需要做成响应式即可

下面两个版本的代码都标记了依赖收集的入口,以便后续接入依赖收集函数,这里就不过多赘述了! 不同是vue3开始是用createApp不在使用render的方式

vue2的实现方式

// 根据传⼊value类型做不同操作
class Observer {
  constructor(value) {
    this.value = value;
    // 判断⼀下value类型
    // 遍历对象
    this.walk(value);
  }
  walk(obj) {
    if (typeof obj !== "object" || obj === null) {
      return obj
    }
    if (Array.isArray(obj)) {
      // 覆盖原型,替换我们自己的
      obj.__proto__ = arrProtoType;
      Object.keys(obj).forEach(key => new Observer(obj[key]))
    }
    else {
      Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]))
    }
  }
}
}
// 实现对数组响应式的拦截
const methods = ["shift", "unshift", "push", "pop", "splice", "reverse", "sort"]

const arrProtoType = Object.create(Array.prototype)

methods.forEach(method => {
  // 覆盖原有数组的方法
  arrProtoType[method] = function () {
    Array.prototype[method].call(this, ...arguments)
  }
})

// 实现对象响应式拦截
const defineReactive = (obj, key, val) => {
  new Observer(val)
  // 创建dep实例和可以对应
  const dep = new Dep()
  return Object.defineProperty(obj, key, {
    get: () => {
      // 在这里做依赖收集
      Dep.target && dep.addDep(Dep.target)
      return val
    },
    set: (v) => {
      if (v !== val) {
        new Observer(v)
        val = v
        dep.notify()
      }
    }
  })
}

数组实现是拦截是通过修改原型的方式来操作的

vue3的实现方式

function reactive(obj) {
  if (typeof obj !== "object" || obj === null) {
    return obj
  }
  return new Proxy(obj, {
    get(target, key) {
      // 依赖收集
      track(target, key)
      const targData =  Reflect.get(target, key)
      return typeof targData === 'object'
        ? reactive(targData)
        : targData
    },
    set(target, key, val) {
      // notify
      Reflect.set(target, key, val)
      trigger(target, key)
    }
  })
}

vue3使用proxy代替defineProperty,借助proxy惰性监听的性质提高框架的性能,也修正了对于数组以及新增删除等操作的额外监听需求,因此去除了Vue.set()、Vue.delete()这样的尴尬操作,不过proxy也同样存在问题,对数组进行代理的时候,unshift、pop等操作会多次触发get、set方法,导致重复触发收集依赖函数,vue3中也是做了相应的处理的

编译 Compile

编译的主要任务处理各种节点以及事件监听等工作,熟悉的v-modal的双向绑定就是在这里实现的具体实现逻辑可以查看代码

const regExp = /\{\{(.*)\}\}/;
class Compile {
  constructor(el, vm) {
    // 1、首先保存下Vue的实例,后续会调用
    this.$vm = vm
    // 编译模板树
    this.compile(document.querySelector(el))
  }

  compile(el) {
    // 遍历el
    // 判断el的子元素类型
    el.childNodes.forEach(node => {
      if (node.nodeType === 1) {
        // 代表节点为元素
        this.compileElement(node)
        // 元素需要递归不然就看不到元素内不得值,只能看到当前元素的标签
        if (node.childNodes.length) {
          this.compile(node)
        }
      } else if (this.isInter(node)) {
        // 插值文本
        this.compileText(node)
      }
    })
  }

  // 统一做初始化和更新处理
  update(node, exp, dir) {
    // 初始化
    const fn = this[dir + "Updater"];

    fn && fn(node, this.$vm[exp])
    // 更新
    new Watcher(this.$vm, exp, function (val) {
      fn && fn(node, val)
    })
  }

  compileElement(node) {
    // 获取当前元素的所有属性,并判断他们是不是动态的
    const nodeAttrs = node.attributes

    Array.from(nodeAttrs).forEach(attr => {
      const attrName = attr.name;
      const exp = attr.value // 指令的内容
      // 判断是指令或者是事件是否是动态的
      if (attrName.startsWith("v-")) {
        const dir = attrName.substring(2)
        // 判断this实力上是否存在dir函数如果存在则调用
        this[dir] && this[dir](node, exp)
      }
      // 事件的处理
      if (this.isEvent(attrName)) {
        const dir = attrName.substring(1) // 事件名称
        // 事件监听
        this.eventHandler(node, exp, dir)
      }
    })
  }

  // 解析插值文本
  compileText(node) {
    const regexp = regExp.exec(node.textContent)
    this.update(node, regexp[1], "text")
    // node.textContent = this.$vm[regexp[1]]
  }

  textUpdater(node, val) {
    node.textContent = val
  }
  text(node, exp) {
    this.update(node, exp, "text")
    // node.textContent = this.$vm[exp]
  }
  htmlUpdater(node, val) {
    node.innerHTML = val
  }
  html(node, exp) {
    this.update(node, exp, "html")
    // node.innerHTML = this.$vm[exp]
  }
  modelUpdater(node, val) {
    // 只考虑大部分情况
    node.value = val
  }
  model(node, exp) {
    // update只负责赋值
    this.update(node, exp, "model")

    // 监听节点事件
    node.addEventListener(node.tagName.toLowerCase(), e => {
      // 对原数据进行反向赋值
      this.$vm[exp] = e.target.value
    })
  }

  // {{xxoo}}
  isInter(node) {
    return node.nodeType === 3 && regExp.test(node.textContent)
  }

  isEvent(dir) {
    return dir.startsWith("@")
  }

  eventHandler(node, exp, dir) {

    const methods = this.$vm.$options.methods

    const fn = methods && methods[exp]
    // 需要修改fn函数的this指向为当前的this.$vm
    node.addEventListener(dir, fn.bind(this.$vm))
  }
}

依赖收集

视图中会⽤到data中某key,这称为依赖。同⼀个key可能出现多次,每次都需要收集出来⽤⼀个Watcher来维护它们,此过程称为依赖收集。多个Watcher需要⼀个Dep来管理,需要更新时由Dep统⼀通知。

vue2的依赖收集

原理分析:

  1. new Vue() ⾸先执⾏初始化,对data执⾏响应化处理,这个过程发⽣在Observer中
  2. 同时对模板执⾏编译,找到其中动态绑定的数据,从data中获取并初始化视图,这个过程发⽣在Compile中
  3. 同时定义⼀个更新函数和Watcher,将来对应数据变化时Watcher会调⽤更新函数
  4. 由于data的某个key在⼀个视图中可能出现多次,所以每个key都需要⼀个管家Dep来管理多个Watcher
  5. 将来data中数据⼀旦发⽣变化,会⾸先找到对应的Dep,通知所有Watcher执⾏更新函数

    class Watcher {
      constructor(vm, fn) {
     this.vm = vm;
     this.getter = fn;
    
     this.get();
      }
    
      get() {
     // 依赖收集触发
     Dep.target = this;
     this.getter.call(this.vm);
     Dep.target = null;
      }
    
      update() {
     this.get();
      }
    }
    // 管家:和某个key,⼀⼀对应,管理多个秘书,数据更新时通知他们做更新⼯作
    class Dep {
      constructor() {
     this.deps = new Set();
      }
      addDep(watcher) {
     this.deps.add(watcher);
      }
      notify() {
     this.deps.forEach((watcher) => watcher.update());
      }
    }

    响应式数据构建过程中每出现一个obj,就会生成一个obsever对象,每一个key对应也有一个dep,但是在源码中dep和watcher是属于多对多的关系,每一个组件会有一个watcher,正常情况下一个watcher和dep是1对多,但是源码中提供了$watch("key", function(){})(也叫useWatcher)的方法,导致dep和watcher是属于多对多的关系

watcher和dep的关系 dep知道自己管理了哪些watcher,同样的每个watcher也知道自己被哪些dep管理,目的是提供$unwatch方法用于解绑。

vue3的依赖收集

vue3中删除了Watcher,取而代之是effect,收集依赖的过程也有所变化;

相关api有

  • effect(fn):传⼊fn,返回的函数将是响应式的,内部代理的数据发⽣变化,它会再次执⾏
  • track(target, key):建⽴响应式函数与其访问的⽬标(target)和键(key)之间的映射关系
  • trigger(target, key):根据track()建⽴的映射关系,找到对应响应式函数并执⾏它
// 临时存储副作用函数
const effectStack = []
// 1.依赖收集函数: 包装fn,立刻执行fn,返回包装结果
function effect(fn) {
  const e = createReactiveEffect(fn)
  e()
  return e
}
function createReactiveEffect(fn) {
  const effect = function () {
    try {
      effectStack.push(fn)
      return fn()
    } finally {
      effectStack.pop()
    }
  }
  return effect
}

// 保存依赖关系的数据结构
const targetMap = new WeakMap()

// 依赖收集:建立target/key和fn之间映射关系
function track(target, key) {
  // 1.获取当前的副作用函数
  const effect = effectStack[effectStack.length - 1]
  if (effect) {
    // 2.取出target/key对应的map
    let depMap = targetMap.get(target)
    if (!depMap) {
      depMap = new Map()
      targetMap.set(target, depMap)
    }

    // 3.获取key对应的set
    let deps = depMap.get(key)
    if (!deps) {
      deps = new Set()
      depMap.set(key, deps)
    }

    // 4.存入set
    deps.add(effect)
  }
}

// 触发更新:当某个响应式数据发生变化,根据target、key获取对应的fn并执行他们
function trigger(target, key) {
  // 1.获取target/key对应的set,并遍历执行他们
  const depMap = targetMap.get(target)

  if (depMap) {
    const deps = depMap.get(key)
    if (deps) {
      deps.forEach(dep => dep())
    }
  }
}

vue最早是一个key对应一个Watcher,但是随着项目和组件的体积增大,这种方式内存消耗也很大,所以不适用大项目,在后来的升级中粒度被切分变成一个组件一个Watcher并且逐步引入了虚拟dom和diff算法,如今在最新的vue3中Watcher已被删除,也新增了compiler的优化策略

虚拟dom

虚拟DOM(Virtual DOM)是对DOM的JS抽象表示,它们是JS对象,能够描述DOM结构和关系。应用的各种状态变化会作用于虚拟DOM,最终映射到DOM上。

优点:

  1. 轻量、快速:当他们发生变化的时候时通过新旧DOM比对可以得到最小DOM操作量,配合异步更新策略减少更新频率,提高性能
  2. 跨平台:将虚拟DOM更新转换不同运行时特殊操作实现跨平台
  3. 兼容性:还可以加入兼容性代码,增强操作的兼容性

必要性

vue 1.0中有细粒度的数据变化侦测,它是不需要虚拟DOM的,但是细粒度造成了大量开销,这对于大型项目来说是不可接受的。因此,vue 2.0选择了中等粒度的解决方案,每一个组件一个watcher实例,这样状态变化时只能通知到组件,再通过引入虚拟DOM去进行比对和渲染。

patch

vue3的patch函数的主要功能是将vnode转换成真是的node的过程,使用vnode可以携带更多的信息,以便后续的diff和优化策略;

vnode

新的vnode结构中携带了块 block的相关信息,比如patchFlagdynamicChildrendynamicProps等;

patch的过程

  • 创建vnode mount()执⾏时,创建根组件VNode
  • 渲染vnode render(vnode, rootContainer)⽅法将创建的vnode渲染到根容器上。
  • 初始patch 传⼊oldVnode为null,初始patch为创建⾏为
  • 创建⼀个渲染副作⽤(setupRenderEffec),执⾏render,获得vnode之后,在执⾏patch转换为dom
  • setupRenderEffect在初始化阶段核⼼任务是执⾏instance的render函数获取subTree,最后patch这个subTree,在这个过程中会使用shapeFlag这个字段,主要作用是标记当前的节点的组件形态的,比如:当前节点是一个文本节点,那么只需要在后续的patch中创建完文本节点并设置节点的内容即可,不需要像vue2中那样patch完文本节点还要patch文本节点的内容
  • 更新阶段,patch函数对⽐新旧vnode,得出dom操作内容,编译过程中通过观察patchFlag、dynamicChildren等做出优化,patchFlag是确定当前节点在更新时候使用什么方式,比如:样式、属性等,dynamicChildren是存放子元素中动态变化的子元素,只需要将其存放的子元素拿出来递归patch进行精准更新即可,不需要遍历当前节点下的所有子节点。
  • 如果同时存在多个⼦元素,⽐如使⽤v-for时的情况:即典型的重排操作,使⽤patchChildren进行diff操作(数组中本来就是不规律的动态变化,使用dynamicChildren意义不大),是否是多个子节点的判定也是使用patchFlag来判定的

diff

diff算法这个东西属实不想写,之前的人写的太多了,都写烂了,无非就是双端比较,新首旧首、新尾旧尾、新首旧尾、旧首新尾之间的比较,然后剩下的做增删操作,这是vue2的算法。咱们今天来说说vue3的吧在vue3diff算法也有不小的改动,虽然保留了双端比较,但是只保留了新首旧首、新尾旧尾之间的比较,不在有交叉比较了,在以上两种情况比较完成以后,diff算法将剩余的节点分成了以下几种情况:

  1. 老节点没有了,则新增
  2. 新节点没有了,则删除
  3. 新老节点都有,则将老节点转成Map,循环新节点去老节点中查找是否存在,不存在新增,存在则判定为移动,这里和react的diff类似,不过这里有一个细节,就是用了一个很经典的算法(最长递增子序列)

编译器(compiler)

  1. 编译器的作用将是生成渲染函数,将模板进行编译、解析
  2. 执行时刻需要区分不同的使用环境

    • runtime-compiler 步骤:template --> ast --> render函数 --> vdom --> 真实DOM 优点:可以使用template选项,选择更灵活 执行时机:在vue.$mount()挂载的时候执行 ast2也是一个抽象语法树,但是与ast1不同的是其携带的信息不同,ast2主要是的作用是用后续的patch和diff。
    • runtime-only 步骤:render函数 --> vdom --> 真实DOM 优点:体积小、运行速度快 执行时机:使用webpack的vue-loader进行预编译

这里简单说下runtime-compiler下编译器的工作过程

  1. app.mount()获取template
  2. compile将传⼊template编译为render函数
  3. 第⼀步解析-parse:解析字符串template为抽象语法树ast
  4. 第⼆步转换-transform:解析属性、样式、指令等
  5. 第三步⽣成-generate:将ast转换为渲染函数,这一步渲染函数是一个字符串,需要在compile中return new Function(code)()

编译器的优化策略

  1. 静态节点提升

    将静态节点进行缓存,用内存换时间

  2. 补丁标记和动态属性记录(patchFlag)

    只关注节点动态变化的部分,对其进行标记,下次更新的时候只更新能变化的部分

  3. 缓存事件处理程序

    缓存事件,避免直接书写事件函数导致的不必要更新,直接写箭头函数,会会导致每次编译到这里的时候都会生成新的函数,使得子树更新,缓存以后即可避免,功能类似于react的useCallback.

  4. 块 block

    将模板切分成块,将动态的节点进行保存(保存在dynamicChildren字段中),这样下次更新就不需要遍历整棵树,而是对保存的动态节点进行遍历即可,降低复杂度。

注意:

  1. jsx转换过程中也会生成ast,有区别的是jsx本质还是js,没有进行预编译,那么所携带的信息会很少。所以理论上vue中的jsx也不能享受到完整优化策略带来的性能提升。
  2. vue也好,react也好,他们都用到了ast,但是它们都会自己单独维护自己的规则。

关于编译器的调试

在vue3源码的package.json中找到dev-compiler命令并执行,之后框架会在packages/template-explorer这个包下面输出一个dist文件,这个文件出现说明编译成功,我可以直接查看packages/template-explorer包下的local.html,直接在浏览器中打开即可调试。

vue中$nexttick(异步渲染)实现过程中异步降级

在vue2版本中是有这样一个降级的过程的,主要是为了兼容不同版本的浏览器,但是在vue3中,就比较激进了,直接使用promise.then入队,兼容性相对vue2会差一些。

  • 先是promise.then
  • 其次是MutationObsever
  • 然后setImmediate
  • 最后是setTimeout

升级vue3的动机

  1. 类型支持更友好
  2. 有利于tree-shaking
  3. API简化、一致性:render函数、sync修饰符、指令等
  4. 复用性:composition API
  5. 性能优化:响应式、编译优化
  6. 扩展性:自定义渲染器

vue源码调试

叨叨了这么多想必你也想调试源码了吧,手动狗头!

获取源码

我们可以直接在github上直接克隆迁出

目录结构

调试环境搭建

  • 安装依赖: npm ie2e工具安装时间会很长时间,可以选择在安装phantom.js时终止,并不会影响我们调试
  • 安装rollup:npm i rollup
  • 修改dev脚本,配置sourcemap"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
  • 运行开发命令:npm run dev, 输出dist文件
  • 新建一个index.html文件,引入vue.js文件<script src="../../dist/vue.js"></script>
  • 开始愉快的调试旅程

goodbye

手写vue代码参考至此,vue2和vue3的区别以及两个版本大致的流程就全过了一遍了,有兴趣的可以自己去看下源码,个人觉得还是挺有意思的,希望我的文章对你有所帮助。