LOADING

加载过慢请开启缓存 浏览器默认开启

耀耀切克闹

《Vue.js的设计与实现》阅读笔记(二)

图书笔记 2023/10/14

前言

数接上文继续看《Vue.js的设计与实现》,第四章 响应系统的作用与实现部分内容笔记,老实说还有些地方不太通透,需要回头完善下

第四章 响应系统的作用与实现

1.响应式数据与副作用函数

副作用函数:函数的执行会直接或间接影响其他函数的执行

const obj = {text:'hello world'}
function effect() {
    //effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}

2.响应式数据的基本实现

如何实现响应式数据集?

如果我们能拦截一个对象的读取和设置操作,就能实现响应式,ES2015之前通过Object.defineProperty函数实现,这也是vue2.js采用的方式,在ES2015+中,我们可以使用代理对象Proxy来实现

// 存储副作用函数的桶
const bucket = new set()
// 原始数据
const obj = {text:'hello world'}
// 对原始数据代理
const obj = new Proxy(data,{
    get(target,key){
        // 将副作用函数effect添加到存储副作用函数的桶里
        bucket.add(effect)
        // 返回属性值
        return target[key]
    }
    set(target,key,newval) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        bucket,forEach(fn=>fn())
        // 返回true代表设置操作成功
        retrun true    
    }
}
function effect() {
    document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1秒后修改响应式数据
setTimeout(()=>{
    obj.text = 'hello vue3'
},1000)

如上通过拦截一个对象的读取和设置操作,可以实现一个简单的响应式数据。

并有上面例子,一个响应式系统的工作流程:

当读取操作发生时,将副作用函数收集到‘桶’中;

当设置操作发生时,从‘桶’中取出副作用函数并执行

3.实现一个相对完善的响应式系统

首先,你可以使用 WeakMap 来创建一个存储响应式数据的桶。WeakMap 是一个键值对的集合,其中键是对象,值是任意类型的数据。由于 WeakMap 的键是弱引用,不会对对象的垃圾回收产生影响,适合用于存储响应式数据。

然后,你可以使用 Map 来构建一个依赖收集系统,用于追踪依赖关系。在 Vue 3 中,响应式数据变化时需要通知相关的依赖进行更新,而 Map 可以帮助我们记录依赖关系,并在数据变化时触发更新。

如何使用 WeakMap 和 Map 来实现一个简单的响应系统?

// 创建一个全局的响应式数据桶
const reactiveMap = new WeakMap();

// 创建一个全局的依赖收集的 Map
const depMap = new Map();

// 通过该函数包装对象并实现响应化
function reactive(obj) {
  if (reactiveMap.has(obj)) {
    return reactiveMap.get(obj);
  }

  const proxy = new Proxy(obj, {
    get(target, key, receiver) {
      // 收集依赖
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      // 触发更新
      trigger(target, key);
      return result;
    },
  });

  reactiveMap.set(obj, proxy);
  return proxy;
}

// 收集依赖
function track(target, key) {
  const depKey = getDepKey(target, key);
  if (depMap.has(depKey)) {
    const deps = depMap.get(depKey);
    if (!deps.has(activeEffect)) {
      deps.add(activeEffect);
    }
  } else {
    const deps = new Set([activeEffect]);
    depMap.set(depKey, deps);
  }
}

// 触发更新
function trigger(target, key) {
  const depKey = getDepKey(target, key);
  const deps = depMap.get(depKey);
  if (deps) {
    deps.forEach(effect => {
      effect();
    });
  }
}

// 获取依赖键
function getDepKey(target, key) {
  return `${target}-${key}`;
}

// 在此处定义一个全局变量,它将被用于存储当前的依赖
let activeEffect = null;

// 在你需要追踪依赖关系的代码块中,使用 effect 函数包裹
function effect(fn) {
  activeEffect = fn;
  fn(); // 第一次执行以收集依赖
  activeEffect = null;
}

// 示例实现

const state = reactive({ count: 0 });

effect(() => {
  console.log(`Count: ${state.count}`);
});

state.count++; // 触发更新并打印 "Count: 1"

我们使用了 reactive 函数来实现对象的响应化,track 函数用于收集依赖,trigger 函数用于触发更新。我们还使用了 effect 函数来包裹需要追踪依赖关系的代码块。

当 state.count 发生变化时,trigger 函数会触发更新,并执行相关的依赖函数,从而实现响应式的更新机制。

总结来说,使用 WeakMap 结合 Map 可以很好地构建一个相对完善的响应系统,在 Vue 3 中可以更高效地管理响应式数据和依赖关系。

4.分支切换与cleanup

分支切换导致冗余副作用函数进行不必要的更新。vue3中为了解决这个问题,我们需要在每次副作用函数执行之前,清除上一次建立的响应联系(笔记有待完善,没太懂这一块的)

5.避免无限递归循环

假如在同一个副作用函数中同时读取和设置某个响应式数据的值,会产生什么结果呢?

effectRegister(() => {
    objProxy.count = objProxy.count + 1
})

image.png
浏览器控制台报错了,出现无限递归循环,栈溢出了。

原因很明显了,首先读取objProxy.count,并把副作用函数存储到依赖中,紧接着又修改objProxy.count,把副作用函数取出来执行,其结果就是,副作用函数在自己内部递归调用自己,栈就溢出了。

解决方法:

通过分析可以发现,读取和设置操作都是在同一个副作用函数中进行的,此时无论track收集的副作用函数还是trigger要触发执行的副作用函数,其实都是同一个,也就是当前的 activeEffect。因此,可以增加守卫条件,当 trigger 要触发执行的副作用函数就是当前正在执行的副作用函数(activeEffect)时,则不触发执行。

 function trigger (target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) {
      return
    }

    const deps = depsMap.get(key)
    const depsToRun = new Set(deps)
     deps && deps.forEach((effectFn) => {
      (effectFn !== activeEffect) && depsToRun.add(effectFn)
    })
    depsToRun && depsToRun.forEach(effectFn => effectFn())
  }

6.调度执行

什么是可调度

可调度性是响应式系统非常重要的特性。所谓可调度,指的是当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式

调度器

用户在调用 effectRegister 函数注册副作用函数时,可以传递第二个参数 options。options 中允许指定一个 scheduler 调度函数。

effectRegister(() => {
    console.log(objProxy.count)
}, {
    // 调度器函数,fn是副作用函数
    scheduler (fn) {
        // 控制执行时机
    }
})

同时,在 effectRegister 函数中我们需要把 options 挂载到副作用函数上:

function effectRegister (fn, options = {}) {
    const effectFn = () => {
      cleanup(effectFn)
      activeEffect = effectFn
      // 将当前副作用函数压入栈中
      effectStack.push(activeEffect)
      // 执行 fn(),进行依赖收集(track)  
      fn()
      // 执行完毕,弹出  
      effectStack.pop()
      // 让 activeEffect 始终指向栈顶的副作用函数,若effectStack中无值,则activeEffect还原为undefined
      activeEffect = effectStack[effectStack.length -1]
    }

    // 将 options 挂载到effectFn上
    effectFn.options = options
    effectFn.deps = []
    effectFn()
}

有了调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传进来的调度函数,从而把控制权交给用户:

function trigger (target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) {
      return
    }

    const deps = depsMap.get(key)
    const depsToRun = new Set(deps)
     deps && deps.forEach((effectFn) => {
      (effectFn !== activeEffect) && depsToRun.add(effectFn)
    })
    depsToRun && depsToRun.forEach(effectFn => {
        // 存在调度器时,则调用调度器,由调度器决定执行 effectFn 的时机
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn(
            )
        }
    })
}

个人博客

耀耀切克闹 (yaoyaoqiekenao.com)

其他相关文章

《Vue.js的设计与实现》阅读笔记(一) - 掘金 (juejin.cn)

阅读全文

《Vue.js的设计与实现》阅读笔记(一)

图书笔记 2023/9/22

当初刚开始学习vue3时就买了霍春阳大佬的《Vue.js的设计与实现》,看了1/3,当时还看的迷迷糊糊,有许多地方不太懂,经过一年多的工作,在这期间学习到了很多,对底层的许多东西,也有了自己的了解,重新再次看这本书,获益匪浅,以下是笔记。

第一章 权衡的艺术

1.命令式和声明式

早年间前端框架jquery就是典型的命令式框架,命令式框架的特点:关注过程,性能好,但用户心智负担大。

vue.js则是声明式框架,而声明式与命令式框架不同,声明式框架更加关注结果,也就是说Vue.js帮我们封装了过程,那么Vue.js的内部实现是命令式的,而暴露给用户的却更加声明式。

2.性能与可维护性的权衡

权衡的艺术则是体现在性能与可维护性之间的权衡。

声明式代码的性能不优于命令式代码的性能,声明式代码会比命令式代码多出找出差异的性能消耗,毕竟vue框架本身就是封装了命令式代码才实现了面向用户的声明式。

为什么Vue.js要选择声明式的设计方案呢?声明式代码的可维护行强

在保持可维护性的同时让性能损失最小化就是Vue.js要做的,虚拟Dom就是为了最小化找出差异这一步的性能消耗而出现的。

3.虚拟Dom的性能如何?

虚拟Dom innerHtml
纯js运算 创建新的js对象+Diff 渲染html字符串
Dom运算 必要的Dom更新 销毁所有旧Dom 新建所有新dom
性能因素 与数据变化量相关 与模板大小相关

innerHtml 虚拟Dom 原生js
心智负担中等 心智负担小 心智负担大
近乎没啥维护性 可维护性强 可维护性差
性能差 性能不错 性能高

由此可见vue.js为什么选择虚拟Dom

4.运行时和编译时

vue.js3是一个编译时+运行时的框架,它在保持灵活性的基础上,还能通过编译手段分析用户提供的内容,从而进一步提升更新性能。

第二章 框架的设计核心要素

1.提升用户的开发体验

例如:提供用户必要的警告信息

2.控制框架代码的体积

例如:使用的rollup.js对项目进行构建,通过rollup.js插件配置预定义_DEV_常量也就是是否开发环境,生产环境的话类似警告信息这些在构建时会被移除,这样就做到了在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的提示等等

3.框架要做到良好的Tree-Shaking

Tree-Shaking指的就是消除那些永远不会被执行的代码,tree-Shaking同样也是rollup.js插件提供的。
Tree-Shaking是依赖ESM的静态结构,就是说模块必须是ESM(ES moudle)

4.框架应该输出怎样的构建产物

框架的构建产物应该包括可执行的JavaScript文件、CSS样式文件、模板文件、静态资源文件以及文档和示例等内容。这些构建产物可以帮助开发者使用和集成框架,构建出符合需求的Web应用程序,同时把不必要Tree-Shaking。

5.特性开关

如vue3中仍可使用选项式api的方式编写代码,但如果明确不使用选项式api,用户就可以使用*Vue_OPTIONS_API*开关关闭该特性,那么生产构建时,这部分代码就不会打包在最终资源中,从而减小体积资源。

6.错误处理

如 Vue.js提供了全局的错误捕获机制,提供了registerErrorHandler函数,用户可以使用它注册错误处理程序,然后在callWithErrorHandling函数内部捕获错误后,把错误传递给用户注册的错误处理程序。

7.良好的ts支持

老生常谈的了,不多bb了,这2页我都没看,哈哈!

第三章 vue.js的设计思路

1.虚拟DOM

vue.js是一个声明式的UI框架,那又如何去描述ui视图呢?使用js对象来描述UI的方式,就是所谓的虚拟DOM。虚拟Dom其实就是用来描述真实DOM的普通js对象。

2.渲染器

那虚拟Dom又是怎么变成真实的Dom呢?就是渲染器,渲染器的作用就是把render函数返回的虚拟dom转换成真实Dom,同时也会处理组件的数据响应式更新。

3.组件

组件是什么?组件和虚拟dom的关系?渲染器是如何渲染组件的?

  1. 组件就是一组DOM元素的封装
  2. 组件的返回值也是虚拟DOM
  3. 会对虚拟DOM进行遍历,并根据虚拟Dom的类型和属性创建响应的Dom元素。ui视图发生变动时,结合Diff算法完成新老节点的对比,渲染更新。

4.模板的工作原理

无论时手写虚拟Dom(渲染函数)还是使用模板,都属于声明式地描述UI,上面说了渲染器是把虚拟DOM渲染成真实DOM,哪虚拟DOM又是怎么得到的呢?那就是编译器

5.编译器

编译器的作用其实就是将模板编译未渲染函数

总结来说无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟DOM渲染未真实DOM,这就是模板的工作原理,也是Vue.js渲染页面的流程。

模板=>编译器=>render函数(return 虚拟Dom)=>渲染器=>真实Dom

6.vue.js是各个模板组成的有机整体

由上面可知,组件的实现依赖于渲染器,模板的编译依赖于编译器。在vue2中更多的是在渲染时通过diff算法完成渲染更新,这个寻找的过程需要花费力气,,如果从编译器的角度分析,编译器有能力分析动态内容,并在编译阶段把这些信息提取出来。就不需要渲染器花费大力气了。

vue3在编译器编译完动态的属性会生成一个patchFlags属性去告诉渲染器,哪些属性是动态的,省去渲染器找的工作量,也就提升了性能。

阅读全文

Vue2源码学习笔记(六)—— 异步更新原理和diff算法原理

前言

要来力! diff算法,以前只是知其然而不知其所以然,接下来将完成diff算法原理的学习,至于为什么把他和异步更新原理一起学习,是我认为它们都是性能优化方面的,异步更新是对视图更新的性能优化,而diff是对渲染更新的性能优化。同样还是跟着鲨鱼大佬的源码学习,后面有大佬文章链接。

异步更新原理

异步更新原理主要是对视图更新的性能优化,总结:

在Vue 2中,异步更新是通过事件循环机制实现的。当Vue组件的响应式状态发生变化时,Vue会将更新操作推入一个队列中,而不是立即执行更新。然后,在下一个事件循环周期中,Vue会遍历队列并执行更新操作。

这种异步更新的机制有以下几个好处:

  1. 提高性能:将多个状态变化合并为一个更新操作,避免了频繁的DOM操作,从而提高了性能。
  2. 避免重复更新:如果在同一个事件循环周期内多次修改了同一个状态,Vue只会执行一次更新操作,避免了重复更新。
  3. 避免阻塞UI线程:由于更新操作是在下一个事件循环周期中执行的,所以不会阻塞UI线程,保持了页面的流畅性。

1.watcher

// src/observer/watcher.js
import { queueWatcher } from "./scheduler";
export default class Watcher {
  update() {
    // 每次watcher进行更新的时候  是否可以让他们先缓存起来  之后再一起调用
    // 异步队列机制
    queueWatcher(this);
  }
  run() {
    // 真正的触发更新
    this.get();
  }
}

2.queueWatcher 实现队列机制

queueWatcher函数是用于实现异步更新队列机制的关键函数之一。它的作用是将Watcher对象推入更新队列中,以便在下一个事件循环周期中执行更新操作

// src/observer/scheduler.js

import { nextTick } from "../util/next-tick";
// 定义一个全局的更新队列数组
const queue = [];
// 定义一个标志位,用于表示是否正在执行更新操作
let isUpdating = false;
// 定义一个函数,用于执行更新操作
function flushSchedulerQueue() {
  // 标记为正在执行更新操作
  isUpdating = true;

  // 遍历更新队列中的Watcher对象,并依次执行其更新操作
  for (let i = 0; i < queue.length; i++) {
    const watcher = queue[i];
    watcher.run();
  }

  // 清空更新队列
  queue.length = 0;

  // 标记更新操作已完成
  isUpdating = false;
}

// 定义一个函数,用于将Watcher对象推入更新队列中
function queueWatcher(watcher) {
  // 如果Watcher对象尚未在更新队列中,则将其推入队列中
  if (!queue.includes(watcher)) {
    queue.push(watcher);
  }

  // 如果更新队列为空且当前不处于更新操作中,则执行更新操作
  if (queue.length === 1 && !isUpdating) {
    nextTick(flushSchedulerQueue);
  }
}

3.nextTick的实现原理
可以看到在执行异步渲染时,我们使用nexTick,nexTick是在下次Dom更新执行延迟回调,那它的具体实现方式是什么呢?

简单版:

// 定义一个回调函数数组
const callbacks = [];

// 定义一个标志位,用于表示是否正在执行回调函数
let pending = false;

// 定义一个函数,用于执行回调函数
function flushCallbacks() {
  // 标记为正在执行回调函数
  pending = true;

  // 遍历回调函数数组,并依次执行回调函数
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]();
  }

  // 清空回调函数数组
  callbacks.length = 0;

  // 标记回调函数已执行完成
  pending = false;
}

// 定义一个函数,用于将回调函数推入回调函数数组中
function nextTick(callback) {
  // 将回调函数推入回调函数数组中
  callbacks.push(callback);

  // 如果当前不处于执行回调函数的状态,则执行回调函数
  if (!pending) {
    setTimeout(flushCallbacks, 0);
  }
}

采用优雅降级后的:

// src/util/next-tick.js

let callbacks = [];
let pending = false;
function flushCallbacks() {
  pending = false; //把标志还原为false
  // 依次执行回调
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]();
  }
}
let timerFunc; //定义异步方法  采用优雅降级
if (typeof Promise !== "undefined") {
  // 如果支持promise
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
  };
} else if (typeof MutationObserver !== "undefined") {
  // MutationObserver 主要是监听dom变化 也是一个异步方法
  let counter = 1;
  const observer = new MutationObserver(flushCallbacks);
  const textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true,
  });
  timerFunc = () => {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
} else if (typeof setImmediate !== "undefined") {
  // 如果前面都不支持 判断setImmediate
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // 最后降级采用setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

export function nextTick(cb) {
  // 除了渲染watcher  还有用户自己手动调用的nextTick 一起被收集到数组
  callbacks.push(cb);
  if (!pending) {
    // 如果多次调用nextTick  只会执行一次异步 等异步队列清空之后再把标志变为false
    pending = true;
    timerFunc();
  }
}

3.nextTick原理总结

因此nextTcik说白了就是通过事件循环的机制(每次当一次事件循环结束后,即一个宏任务执行完成后以及微任务队列被清空后,浏览器就会进行一次页面更新渲染。),利用微任务和宏任务结合优雅降级的方式来执行回调函数,从而实现在下次Dom更新完后执行的回调函数。当某个由watcter监听观察的值发生,并通知相应的DOM更新,这时dom更新同步执行发现nextTcik是个微任务或宏任务,放在队列中,同步任务执行完了,浏览器就会进行一次页面更新渲染,再取出nextTick的队列,执行了nextTick的回调函数。

最后将其挂载到vue的原型方法上,就是我们经常使用的$nextTick()方法了

diff算法原理

前提纪要:Vue2源码学习笔记(三)——初次渲染原理 - 掘金 (juejin.cn)

Vue在初始化页面后,会将当前的真实DOM转换为虚拟DOM(Virtual DOM),并将其保存起来,这里称为oldVnode。然后当某个数据发变化后,Vue会先生成一个新的虚拟DOM——vnode,然后将vnode和oldVnode进行比较,找出需要更新的地方,然后直接在对应的真实DOM上进行修改。当修改结束后,就将vnode赋值给oldVnode存起来,作为下次更新比较的参照物。其中新旧vnode的比较,也就是我们常说的Diff算法

  • 为什么需要 diff ?

复用 DOM 比直接替换(移除旧 DOM,创建新 DOM )性能好的多。

  • diff 原则?

原地复用 > 移动后复用 >> 暴力替换

1.patch 核心渲染方法

patch()方法,该方法接收新旧虚拟Dom,即oldVnode,vnode.

1.首先,检查oldVnode是否是一个真实的DOM元素,如果是,则表示这是初次渲染,不需要进行Diff算法的比较。

2.如果oldVnode是一个虚拟DOM节点,那么就需要使用Diff算法进行更新过程。首先,代码检查新旧虚拟DOM节点的标签是否一致,如果不一致,则直接用新的虚拟DOM节点替换旧的真实DOM节点。

3.接下来,代码检查旧虚拟DOM节点是否是一个文本节点,如果是,则比较新旧文本内容是否一致,如果不一致,则更新旧文本节点的textContent。

4.如果既不是标签不一致的情况,也不是文本节点的情况,那么说明标签一致且不是文本节点。为了节点复用,代码将旧虚拟DOM节点对应的真实DOM节点赋值给新虚拟DOM节点的el属性。

5.然后,代码调用updateProperties函数来更新新虚拟DOM节点的属性。

6.接下来,代码获取旧虚拟DOM节点的子节点和新虚拟DOM节点的子节点,并进行比较和更新。

  • 如果旧节点和新节点都有子节点,代码调用updateChildren函数来比较和更新子节点。
  • 如果只有旧节点有子节点,代码将旧节点的innerHTML清空,相当于移除所有旧子节点。
  • 如果只有新节点有子节点,代码遍历新子节点,并将它们创建为真实DOM节点,并添加到旧节点的el中。

这样,根据新旧虚拟DOM节点的差异,代码完成了相应的更新操作。以下是简单的实现方式:

// src/vdom/patch.js

export function patch(oldVnode, vnode) {
  const isRealElement = oldVnode.nodeType;
  if (isRealElement) {
    // oldVnode是真实dom元素 就代表初次渲染
  } else {
    // oldVnode是虚拟dom 就是更新过程 使用diff算法
    if (oldVnode.tag !== vnode.tag) {
      // 如果新旧标签不一致 用新的替换旧的 oldVnode.el代表的是真实dom节点--同级比较
      oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
    }
    // 如果旧节点是一个文本节点
    if (!oldVnode.tag) {
      if (oldVnode.text !== vnode.text) {
        oldVnode.el.textContent = vnode.text;
      }
    }
    // 不符合上面两种 代表标签一致 并且不是文本节点
    // 为了节点复用 所以直接把旧的虚拟dom对应的真实dom赋值给新的虚拟dom的el属性
    const el = (vnode.el = oldVnode.el);
    updateProperties(vnode, oldVnode.data); // 更新属性
    const oldCh = oldVnode.children || []; // 老的儿子
    const newCh = vnode.children || []; // 新的儿子
    if (oldCh.length > 0 && newCh.length > 0) {
      // 新老都存在子节点
      updateChildren(el, oldCh, newCh);
    } else if (oldCh.length) {
      // 老的有儿子新的没有
      el.innerHTML = "";
    } else if (newCh.length) {
      // 新的有儿子
      for (let i = 0; i < newCh.length; i++) {
        const child = newCh[i];
        el.appendChild(createElm(child));
      }
    }
  }
}

2.调用updateProperties函数来更新新虚拟DOM节点的属性

updateProperties函数的主要作用就是将新虚拟DOM节点的属性映射到对应的真实DOM节点上,是根据新旧虚拟DOM节点的属性差异,代码完成了对真实DOM节点属性的更新操作。

//  src/vdom/patch.js

// 解析vnode的data属性 映射到真实dom上
function updateProperties(vnode, oldProps = {}) {
  const newProps = vnode.data || {}; //新的vnode的属性
  const el = vnode.el; // 真实节点
  // 如果新的节点没有 需要把老的节点属性移除
  for (const k in oldProps) {
    if (!newProps[k]) {
      el.removeAttribute(k);
    }
  }
  // 对style样式做特殊处理 如果新的没有 需要把老的style值置为空
  const newStyle = newProps.style || {};
  const oldStyle = oldProps.style || {};
  for (const key in oldStyle) {
    if (!newStyle[key]) {
      el.style[key] = "";
    }
  }
  // 遍历新的属性 进行增加操作
  for (const key in newProps) {
    if (key === "style") {
      for (const styleName in newProps.style) {
        el.style[styleName] = newProps.style[styleName];
      }
    } else if (key === "class") {
      el.className = newProps.class;
    } else {
      // 给这个元素添加属性 值就是对应的值
      el.setAttribute(key, newProps[key]);
    }
  }
}

3.updateChildren 更新子节点-diff 核心方法

updateChildren函数通过双指针的方式,新旧头尾指针进行比较,循环向中间靠拢,对比新旧子节点数组的差异,并根据差异进行相应的更新操作,实现了虚拟DOM的高效更新。

// src/vdom/patch.js

// 判断两个vnode的标签和key是否相同 如果相同 就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) {
  return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;
}
// diff算法核心 采用双指针的方式 对比新老vnode的儿子节点
function updateChildren(parent, oldCh, newCh) {
  let oldStartIndex = 0; //老儿子的起始下标
  let oldStartVnode = oldCh[0]; //老儿子的第一个节点
  let oldEndIndex = oldCh.length - 1; //老儿子的结束下标
  let oldEndVnode = oldCh[oldEndIndex]; //老儿子的起结束节点

  let newStartIndex = 0; //同上  新儿子的
  let newStartVnode = newCh[0];
  let newEndIndex = newCh.length - 1;
  let newEndVnode = newCh[newEndIndex];

  // 根据key来创建老的儿子的index映射表  类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
  function makeIndexByKey(children) {
    let map = {};
    children.forEach((item, index) => {
      map[item.key] = index;
    });
    return map;
  }
  // 生成的映射表
  let map = makeIndexByKey(oldCh);

  // 只有当新老儿子的双指标的起始位置不大于结束位置的时候  才能循环 一方停止了就需要结束循环
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 因为暴力对比过程把移动的vnode置为 undefined 如果不存在vnode节点 直接跳过
    if (!oldStartVnode) {
      oldStartVnode = oldCh[++oldStartIndex];
    } else if (!oldEndVnode) {
      oldEndVnode = oldCh[--oldEndIndex];
    } else if (isSameVnode(oldStartVnode, newStartVnode)) {
      // 头和头对比 依次向后追加
      patch(oldStartVnode, newStartVnode); //递归比较儿子以及他们的子节点
      oldStartVnode = oldCh[++oldStartIndex];
      newStartVnode = newCh[++newStartIndex];
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      //尾和尾对比 依次向前追加
      patch(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 老的头和新的尾相同 把老的头部移动到尾部
      patch(oldStartVnode, newEndVnode);
      parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); //insertBefore可以移动或者插入真实dom
      oldStartVnode = oldCh[++oldStartIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 老的尾和新的头相同 把老的尾部移动到头部
      patch(oldEndVnode, newStartVnode);
      parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
      oldEndVnode = oldCh[--oldEndIndex];
      newStartVnode = newCh[++newStartIndex];
    } else {
      // 上述四种情况都不满足 那么需要暴力对比
      // 根据老的子节点的key和index的映射表 从新的开始子节点进行查找 如果可以找到就进行移动操作 如果找不到则直接进行插入
      let moveIndex = map[newStartVnode.key];
      if (!moveIndex) {
        // 老的节点找不到  直接插入
        parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
      } else {
        let moveVnode = oldCh[moveIndex]; //找得到就拿到老的节点
        oldCh[moveIndex] = undefined; //这个是占位操作 避免数组塌陷  防止老节点移动走了之后破坏了初始的映射表位置
        parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到最前面
        patch(moveVnode, newStartVnode);
      }
    }
  }
  // 如果老节点循环完毕了 但是新节点还有  证明  新节点需要被添加到头部或者尾部
  if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 这是一个优化写法 insertBefore的第一个参数是null等同于appendChild作用
      const ele =
        newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].el;
      parent.insertBefore(createElm(newCh[i]), ele);
    }
  }
  // 如果新节点循环完毕 老节点还有  证明老的节点需要直接被删除
  if (oldStartIndex <= oldEndIndex) {
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      let child = oldCh[i];
      if (child != undefined) {
        parent.removeChild(child.el);
      }
    }
  }
}

4.改造原型渲染更新方法_update

当数据发生变化时订阅者watcher就会调用_update

// src/lifecycle.js

export function lifecycleMixin(Vue) {
  // 把_update挂载在Vue的原型
  Vue.prototype._update = function (vnode) {
    const vm = this;
    const prevVnode = vm._vnode; // 保留上一次的vnode
    vm._vnode = vnode;
    if (!prevVnode) {
      // patch是渲染vnode为真实dom核心
      vm.$el = patch(vm.$el, vnode); // 初次渲染 vm._vnode肯定不存在 要通过虚拟节点 渲染出真实的dom 赋值给$el属性
    } else {
      vm.$el = patch(prevVnode, vnode); // 更新时把上次的vnode和这次更新的vnode穿进去 进行diff算法
    }
  };
}

5.diff算法总结

  • 当数据发生改变时,订阅者watcher就会调用原型方法_update中的patch给真实的DOM打补丁

  • 判断是否是初始化渲染,是则不需要diff算法,否则就走diff算法

    • 找到对应的真实dom,称为el
    • 如果都有都有文本节点且不相等,将el文本节点设置为Vnode的文本节点
    • 如果oldVnode有子节点而VNode没有,则删除el子节点
    • 如果oldVnode没有子节点而VNode有,则将VNode的子节点真实化后添加到el
    • 如果两者都有子节点,则执行updateChildren函数比较子节点
  • updateChildren主要做了以下操作:
    • 设置新旧VNode的头尾指针
    • 新旧头尾指针进行比较,循环向中间靠拢,根据情况patch重复流程或调用createElem创建一个新节点
    • 从哈希表寻找 key一致的VNode 节点再分情况操作,如果标签,key值,属性都相同就可以就地复用

个人博客

耀耀切克闹 (yaoyaoqiekenao.com)

参考文章

手写Vue2.0源码(六)-diff算法原理 - 掘金 (juejin.cn)

阅读全文

Vue2源码学习笔记(五)—计算属性computed的原理和侦听属性watch的原理

前言

随着前面的学习,我对整个vue框架有了更深的理解,接下来学习的是计算属性computed的原理和侦听器watch的原理,这两基本也是在工作用的比较多的了。

侦听属性watch的原理

主要实现方式:当定义一个侦听属性时,Vue会在内部创建一个Watcher实例来监视指定的数据。这个Watcher实例会在初始化时获取初始值,并将自身添加到数据的依赖列表中。当被侦听的数据发生变化时,Watcher实例会被通知,并触发相应的回调函数。

1.侦听属性的初始化

在Vue实例化(initMixin(vue)的_init)时,会对侦听器进行初始化在initState中调用initWatch。在initWatch中Watch会对数组进行遍历处理,然后才调用createWatcher,通过原型方法$watch传入处理参数创建一个观察者收集依赖变化。

// src/state.js

// 统一初始化数据的方法
export function initState(vm) {
  // 获取传入的数据对象
  const opts = vm.$options;
  if (opts.watch) {
    //侦听属性初始化
    initWatch(vm);
  }
}

// 初始化watch
function initWatch(vm) {
  let watch = vm.$options.watch;
  for (let k in watch) {
    const handler = watch[k]; //用户自定义watch的写法可能是数组 对象 函数 字符串
    if (Array.isArray(handler)) {
      // 如果是数组就遍历进行创建
      handler.forEach((handle) => {
        createWatcher(vm, k, handle);
      });
    } else {
      createWatcher(vm, k, handler);
    }
  }
}
// 创建watcher的核心
function createWatcher(vm, exprOrFn, handler, options = {}) {
  if (typeof handler === "object") {
    options = handler; //保存用户传入的对象
    handler = handler.handler; //这个代表真正用户传入的函数
  }
  if (typeof handler === "string") {
    //   代表传入的是定义好的methods方法
    handler = vm[handler];
  }
  //   调用vm.$watch创建用户watcher
  return vm.$watch(exprOrFn, cb, options);
}

2.$watch

//  src/state.js
import Watcher from "./observer/watcher";
Vue.prototype.$watch = function (exprOrFn, cb, options) {
  const vm = this;
  //  user: true 这里表示是一个用户watcher
  let watcher = new Watcher(vm, exprOrFn, cb, { ...options, user: true });
  // 如果有immediate属性 代表需要立即执行回调
  if (options.immediate) {
    handler(); //如果立刻执行watch中的回调函数
  }
};

3.Watcher

在之前写的Watcher中,通过调用 get 方法获取并保存一次旧值

// src/observer/watcher.js

import { pushTarget, popTarget } from "./dep";

// 全局变量id  每次new Watcher都会自增
let id = 0;

export default class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm;
    this.exprOrFn = exprOrFn;
    this.cb = cb; //回调函数 比如在watcher更新之前可以执行beforeUpdate方法
    this.options = options; //额外的选项 true代表渲染watcher
    this.id = id++; // watcher的唯一标识
    this.deps = []; //存放dep的容器
    this.depsId = new Set(); //用来去重dep
    this.user = options.user; //标识用户watcher
    // 如果表达式是一个函数
    if (typeof exprOrFn === "function") {
      this.getter = exprOrFn;
    } 
    // 实例化就会默认调用get方法
    this.get();
  }
  get() {
    pushTarget(this); // 在调用方法之前先把当前watcher实例推到全局Dep.target上
    this.getter(); //如果watcher是渲染watcher 那么就相当于执行  vm._update(vm._render()) 这个方法在render函数执行的时候会取值 从而实现依赖收集
    popTarget(); // 在调用方法之后把当前watcher实例从全局Dep.target移除
  },
  set(newValue) { 
      if (newValue === value) return; 
      // 如果赋值的新值也是一个对象 需要观测 
      observe(newValue); 
      value = newValue; 
      dep.notify(); // 通知渲染watcher去更新--派发更新 },
 }     
  //把dep放到deps里面 同时保证同一个dep只被保存到watcher一次  同样的  同一个watcher也只会保存在dep一次
  addDep(dep) {
    let id = dep.id;
    if (!this.depsId.has(id)) {
      this.depsId.add(id);
      this.deps.push(dep);
      //   直接调用dep的addSub方法  把自己--watcher实例添加到dep的subs容器里面
      dep.addSub(this);
    }
  }
  //   这里简单的就执行以下get方法  之后涉及到计算属性就不一样了
  update() {
    this.get();
  }
  
  //run方法会在监听的数据发生改变时,set劫持的数据通知渲染watcher去更新--派发更新,在这个时候执行 run()
  run() {
    const newVal = this.get(); //新值
    const oldVal = this.value; //老值
    this.value = newVal; //现在的新值将成为下一次变化的老值
    if (this.user) {
      // 如果两次的值不相同  或者值是引用类型 因为引用类型新老值是相等的 他们是指向同一引用地址
      if (newVal !== oldVal || isObject(newVal)) {
        this.cb.call(this.vm, newVal, oldVal);
      }
    } else {
      // 渲染watcher
      this.cb.call(this.vm);
    }
  }
}

计算属性computed的原理

具体原理如下:

  1. 在Vue实例化时,会对计算属性进行初始化。计算属性的定义包括一个计算函数和一个缓存属性。
  2. 当计算属性被访问时,会触发计算函数的执行。在计算函数中,可以访问其他响应式数据。
  3. 计算函数会根据依赖的响应式数据进行计算,并返回计算结果。
  4. 计算属性会将计算结果缓存起来,下次访问时直接返回缓存的值。
  5. 当依赖的响应式数据发生变化时,计算属性会被标记为”dirty”(脏),下次访问时会重新计算并更新缓存的值。

通过计算属性,我们可以将复杂的逻辑封装成一个属性,并在模板中直接使用。计算属性会自动追踪依赖的数据,并在需要时进行更新,提供了一种简洁和高效的方式来处理衍生数据。

注意: 计算属性适用于那些依赖其他响应式数据的场景,而不适用于需要进行异步操作或有副作用的场景。对于这些情况,可以使用侦听器(watcher)或使用methods来处理。

书接上文,在Vue实例化(initMixin(vue)的*_init*)时,不仅会对侦听器进行初始化同时也会对计算属性进行初始化。

1.计算属性初始化

在initComputed函数中,遍历计算属性对象,为每个计算属性创建一个Watcher实例,并将其存储在vm._computedWatchers中。

// src/state.js
export function initState(vm) {
  // 获取传入的数据对象
  const opts = vm.$options;
  if (opts.watch) {
    //侦听属性初始化
    initWatch(vm);
  }
  if(opts.computed) {
    initComputed(vm) 
  }
}
function initComputed(vm) {
  const computed = vm.$options.computed;
  //用来存放计算watcher
  const watchers = vm._computedWatchers = Object.create(null);
  for (const key in computed) {
     //获取用户定义的计算属性
    const userDef = computed[key];
    //创建计算属性watcher使用
    const getter = typeof userDef === 'function' ? userDef : userDef.get;
    //-vm:计算属性所属的Vue实例。
    //- getter:计算属性的获取函数,用于计算计算属性的值。 
    //- noop:一个空操作函数,作为回调函数的占位符。  
    //- computedWatcherOptions:一个包含特定于计算属性观察者的选项的对象。
   // watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions)
   // 创建计算watcher lazy设置为true 
   watchers[key] = new Watcher(vm, getter, () => {}, { lazy: true });
    if (!(key in vm)) {
      defineComputed(vm, key, userDef);
    }
  }
}

2.对计算属性进行属性劫持

defineComputed 方法主要是重新定义计算属性,其实最主要的是劫持get方法也就是计算属性依赖的值。

为啥要劫持呢? 因为我们需要根据依赖值是否发生变化来判断计算属性是否需要重新计算

createComputedGetter方法就是判断计算属性依赖的值是否变化的核心了 我们在计算属性创建的Watcher增加dirty标志位,如果标志变为true代表需要调用watcher.evaluate来进行重新计算了

function defineComputed(target, key, userDef) {
// 定义普通对象用来劫持计算属性
  const sharedPropertyDefinition = {
    enumerable: true,
    configurable: true,
    get: noop,
    set: noop
  };
  // 重新定义计算属性 对get和set劫持
  if (typeof userDef === 'function') {
  // 如果是一个函数 需要手动赋值到get上
    sharedPropertyDefinition.get = createComputedGetter(key);
  } else {
  // 利用Object.defineProperty来对计算属性的get和set进行劫持
    sharedPropertyDefinition.get = createComputedGetter(key);
    sharedPropertyDefinition.set = userDef.set;
  }
  
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

// 在getter函数中,首先获取对应的Watcher实例,然后判断是否需要重新计算计算属性的值。如果需要重新计算,则调用watcher.evaluate()进行计算,并在需要时进行依赖收集。最后,返回计算属性的值
function createComputedGetter(key) {
  return function computedGetter() {
  //获取对应的计算属性watcher
    const watcher = this._computedWatchers && this._computedWatchers[key];
    
    if (watcher) {
    //计算属性取值的时候 如果是脏的 需要重新求值
      if (watcher.dirty) {
        watcher.evaluate();
      }
     // 如果Dep还存在target 这个时候一般为渲染watcher 计算属性依赖的数据也需要收集
      if (Dep.target) {
        watcher.depend();
      }
      
      return watcher.value;
    }
  };
}

3.watcher的改造

// src/observer/watcher.js

// import { pushTarget, popTarget } from "./dep";
// import { queueWatcher } from "./scheduler";
// import {isObject} from '../util/index'
// // 全局变量id  每次new Watcher都会自增
// let id = 0;

export default class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    // this.vm = vm;
    // this.exprOrFn = exprOrFn;
    // this.cb = cb; //回调函数 比如在watcher更新之前可以执行beforeUpdate方法
    // this.options = options; //额外的选项 true代表渲染watcher
    // this.id = id++; // watcher的唯一标识
    // this.deps = []; //存放dep的容器
    // this.depsId = new Set(); //用来去重dep
    // this.user = options.user; //标识用户watcher
    this.lazy = options.lazy; //标识计算属性watcher
    this.dirty = this.lazy; //dirty可变  表示计算watcher是否需要重新计算 默认值是true

    // 如果表达式是一个函数
    // if (typeof exprOrFn === "function") {
    //   this.getter = exprOrFn;
    // } else {
    //   this.getter = function () {
    //     //用户watcher传过来的可能是一个字符串   类似a.a.a.a.b
    //     let path = exprOrFn.split(".");
    //     let obj = vm;
    //     for (let i = 0; i < path.length; i++) {
    //       obj = obj[path[i]]; //vm.a.a.a.a.b
    //     }
    //     return obj;
    //   };
    // }
    // 非计算属性实例化就会默认调用get方法 进行取值  保留结果 计算属性实例化的时候不会去调用get
    this.value = this.lazy ? undefined : this.get();
  }
  get() {
    pushTarget(this); // 在调用方法之前先把当前watcher实例推到全局Dep.target上
    const res = this.getter.call(this.vm); //计算属性在这里执行用户定义的get函数 访问计算属性的依赖项 从而把自身计算Watcher添加到依赖项dep里面收集起来
    popTarget(); // 在调用方法之后把当前watcher实例从全局Dep.target移除
    return res;
  }
  //   把dep放到deps里面 同时保证同一个dep只被保存到watcher一次  同样的  同一个watcher也只会保存在dep一次
  //   addDep(dep) {
  //     let id = dep.id;
  //     if (!this.depsId.has(id)) {
  //       this.depsId.add(id);
  //       this.deps.push(dep);
  //       //   直接调用dep的addSub方法  把自己--watcher实例添加到dep的subs容器里面
  //       dep.addSub(this);
  //     }
  //   }
  //   这里简单的就执行以下get方法  之后涉及到计算属性就不一样了
  update() {
    // 计算属性依赖的值发生变化 只需要把dirty置为true  下次访问到了重新计算
    if (this.lazy) {
      this.dirty = true;
    } else {
      // 每次watcher进行更新的时候  可以让他们先缓存起来  之后再一起调用
      // 异步队列机制
      queueWatcher(this);
    }
  }
  //   计算属性重新进行计算 并且计算完成把dirty置为false
  evaluate() {
    this.value = this.get();
    this.dirty = false;
  }
  depend() {
    // 计算属性的watcher存储了依赖项的dep
    let i = this.deps.length;
    while (i--) {
      this.deps[i].depend(); //调用依赖项的dep去收集渲染watcher
    }
  }
  //   run() {
  //     const newVal = this.get(); //新值
  //     const oldVal = this.value; //老值
  //     this.value = newVal; //跟着之后  老值就成为了现在的值
  //     if (this.user) {
  //       if(newVal!==oldVal||isObject(newVal)){
  //         this.cb.call(this.vm, newVal, oldVal);
  //       }
  //     } else {
  //       // 渲染watcher
  //       this.cb.call(this.vm);
  //     }
  //   }
}

小结

计算属性computed和侦听属性watch还是有很大区别的计算属性一般用在需要对依赖项进行计算并且可以缓存下来,当依赖项变化会自动执行计算属性的逻辑,一般用在模板里面较多而侦听属性用法是对某个响应式的值进行观察(也可以观察计算属性的值)一旦变化之后就可以执行自己定义的方法

个人博客

耀耀切克闹 (yaoyaoqiekenao.com)

参考文章

手写Vue2.0源码(十)-计算属性原理 - 掘金 (juejin.cn)

阅读全文

Vue2源码学习(四)——渲染更新原理

前言

跟着鲨鱼大佬的源码看,前面说了初次渲染先经过模版编译=>在挂载时调用_render方法把render函数转化为虚拟dom,虚拟dom通过_update中patch函数渲染为真实dom了。也就是上篇我们调用的vm._update(vm._render())来实现更新功能,但当数据改变时页面并不会自动更新,这是为什么?。

因为我们不可能每次数据变化都要求用户自己去调用渲染方法更新视图 我们需要一个机制在数据变动的时候自动去更新。这个时候就涉及到渲染更新的原理了,vue通过观察者模式定 Watcher和 Dep完成依赖收集和派发更新从而实现渲染更新。

1.定义Watcher

当数据变动之后,通知它去执行某些方法,本质上就是一个构造函数,初始化的时候会去执行 get 方法。

// src/observer/watcher.js
// 全局变量id  每次new Watcher都会自增
let id = 0;

export default class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm;
    this.exprOrFn = exprOrFn;
    this.cb = cb; //回调函数 比如在watcher更新之前可以执行beforeUpdate方法
    this.options = options; //额外的选项 true代表渲染watcher
    this.id = id++; // watcher的唯一标识
    // 如果表达式是一个函数
    if (typeof exprOrFn === "function") {
      this.getter = exprOrFn;
    }
    // 实例化就会默认调用get方法
    this.get();
  }
  get() {
    this.getter();
  }
}

2.创建渲染Watcher

我们在组件挂载方法里面 定义一个渲染 Watcher 主要功能就是执行核心渲染页面的方法

// src/lifecycle.js
export function mountComponent(vm, el) {
  //   _update和._render方法都是挂载在Vue原型的方法  类似_init

  // 引入watcher的概念 这里注册一个渲染watcher 执行vm._update(vm._render())方法渲染视图

  let updateComponent = () => {
    console.log("刷新页面");
    vm._update(vm._render());
  };
  new Watcher(vm, updateComponent, null, true);
}

3.定义Dep

Dep 也是一个构造函数 可以把他理解为观察者模式里面的被观察者 在 subs 里面收集 watcher 当数据变动的时候通知自身 subs 所有的 watcher 更新

Dep.target 是一个全局 Watcher 指向 初始状态是 null

// src/observer/dep.js

// dep和watcher是多对多的关系

// 每个属性都有自己的dep

let id = 0; //dep实例的唯一标识
export default class Dep {
  constructor() {
    this.id = id++;
    this.subs = []; // 这个是存放watcher的容器
  }
}
// 默认Dep.target为null
Dep.target = null;

对象的依赖收集

Vue 2中的对象的依赖收集是通过使用getter/setter和观察者模式来实现的。在getter中进行依赖收集,将当前的Watcher实例添加到属性的依赖列表中。在setter中通知依赖该属性的Watcher实例进行更新=,这种机制使得Vue能够自动追踪数据的依赖关系。

在vue2源码学习(一)中,我们初始化了一个vue实例,并使用defineReactive对其进行了响应式处理。接下来我们需要在访问组件的数据属性时完成一下处理来实现依赖收集

  1. 在getter中,Vue会进行依赖收集。当组件渲染时,会创建一个Watcher实例,并将其设置为全局的Dep.target。当访问组件的数据属性时,会触发属性的getter,并将当前的Watcher实例添加到属性的依赖列表中。
  2. 在setter中,Vue会通知依赖该属性的Watcher实例进行更新。当属性的值发生变化时,会触发属性的setter,并调用依赖列表中每个Watcher实例的更新方法。
// src/observer/index.js

// Object.defineProperty数据劫持核心 兼容性在ie9以及以上
function defineReactive(data, key, value) {
  observe(value);

  let dep = new Dep(); // 为每个属性实例化一个Dep

  Object.defineProperty(data, key, {
    get() {
      // 页面取值的时候 可以把watcher收集到dep里面--依赖收集
      if (Dep.target) {
        // 如果有watcher dep就会保存watcher 同时watcher也会保存dep
        dep.depend();
      }
      return value;
    },
    set(newValue) {
      if (newValue === value) return;
      // 如果赋值的新值也是一个对象  需要观测
      observe(newValue);
      value = newValue;
      dep.notify(); // 通知渲染watcher去更新--派发更新
    },
  });
}

5.完善watcher

// src/observer/watcher.js

import { pushTarget, popTarget } from "./dep";

// 全局变量id  每次new Watcher都会自增
let id = 0;

export default class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm;
    this.exprOrFn = exprOrFn;
    this.cb = cb; //回调函数 比如在watcher更新之前可以执行beforeUpdate方法
    this.options = options; //额外的选项 true代表渲染watcher
    this.id = id++; // watcher的唯一标识
    this.deps = []; //存放dep的容器
    this.depsId = new Set(); //用来去重dep
    // 如果表达式是一个函数
    if (typeof exprOrFn === "function") {
      this.getter = exprOrFn;
    }
    // 实例化就会默认调用get方法
    this.get();
  }
  get() {
    pushTarget(this); // 在调用方法之前先把当前watcher实例推到全局Dep.target上
    this.getter(); //如果watcher是渲染watcher 那么就相当于执行  vm._update(vm._render()) 这个方法在render函数执行的时候会取值 从而实现依赖收集
    popTarget(); // 在调用方法之后把当前watcher实例从全局Dep.target移除
  }
  //   把dep放到deps里面 同时保证同一个dep只被保存到watcher一次  同样的  同一个watcher也只会保存在dep一次
  addDep(dep) {
    let id = dep.id;
    if (!this.depsId.has(id)) {
      this.depsId.add(id);
      this.deps.push(dep);
      //   直接调用dep的addSub方法  把自己--watcher实例添加到dep的subs容器里面
      dep.addSub(this);
    }
  }
  //   这里简单的就执行以下get方法  之后涉及到计算属性就不一样了
  update() {
    this.get();
  }
}

6.完善dep

// src/observer/dep.js

// dep和watcher是多对多的关系
// 每个属性都有自己的dep
let id = 0; //dep实例的唯一标识
export default class Dep {
  constructor() {
    this.id = id++;
    this.subs = []; // 这个是存放watcher的容器
  }
  depend() {
    //   如果当前存在watcher
    if (Dep.target) {
      Dep.target.addDep(this); // 把自身-dep实例存放在watcher里面
    }
  }
  notify() {
    //   依次执行subs里面的watcher更新方法
    this.subs.forEach((watcher) => watcher.update());
  }
  addSub(watcher) {
    //   把watcher加入到自身的subs容器
    this.subs.push(watcher);
  }
}
// 默认Dep.target为null
Dep.target = null;
// 栈结构用来存watcher
const targetStack = [];

export function pushTarget(watcher) {
  targetStack.push(watcher);
  Dep.target = watcher; // Dep.target指向当前watcher
}
export function popTarget() {
  targetStack.pop(); // 当前watcher出栈 拿到上一个watcher
  Dep.target = targetStack[targetStack.length - 1];
}

7.数组的依赖收集

Vue中,数组的依赖收集相对于对象稍微复杂一些。由于JavaScript的限制,Vue无法通过getter/setter直接捕获数组的变化。因此,Vue采用了一些特殊的技巧来实现数组的依赖收集。

当Vue观察一个数组时,它会重写数组的一些原型方法(如push、pop、shift等),并在这些方法被调用时触发依赖收集和派发更新。

  1. 首先,Vue会调用原始的数组方法,以确保数组的正常操作。
  2. 然后,Vue会遍历数组中的每个元素,并对每个元素进行观察(observe)。这是为了确保数组中的每个元素都是响应式的。
  3. 接下来,Vue会检查是否有正在进行依赖收集的Watcher实例(即是否存在全局的Dep.target)。如果有,Vue会将这个Watcher实例添加到数组的依赖列表中。
  4. 最后,Vue会通过调用依赖列表中每个Watcher实例的更新方法来派发更新。
// src/observer/index.js

// Object.defineProperty数据劫持核心 兼容性在ie9以及以上
function defineReactive(data, key, value) {
  let childOb = observe(value); // childOb就是Observer实例

  let dep = new Dep(); // 为每个属性实例化一个Dep

  Object.defineProperty(data, key, {
    get() {
      // 页面取值的时候 可以把watcher收集到dep里面--依赖收集
      if (Dep.target) {
        // 如果有watcher dep就会保存watcher 同时watcher也会保存dep
        dep.depend();
        if (childOb) {
          // 这里表示 属性的值依然是一个对象 包含数组和对象 childOb指代的就是Observer实例对象  里面的dep进行依赖收集
          // 比如{a:[1,2,3]} 属性a对应的值是一个数组 观测数组的返回值就是对应数组的Observer实例对象
          childOb.dep.depend();
          if (Array.isArray(value)) {
            // 如果数据结构类似 {a:[1,2,[3,4,[5,6]]]} 这种数组多层嵌套  数组包含数组的情况  那么我们访问a的时候 只是对第一层的数组进行了依赖收集 里面的数组因为没访问到  所以五大收集依赖  但是如果我们改变了a里面的第二层数组的值  是需要更新页面的  所以需要对数组递归进行依赖收集
            if (Array.isArray(value)) {
              // 如果内部还是数组
              dependArray(value); // 不停的进行依赖收集
            }
          }
        }
      }
      return value;
    },
    set(newValue) {
      if (newValue === value) return;
      // 如果赋值的新值也是一个对象  需要观测
      observe(newValue);
      value = newValue;
      dep.notify(); // 通知渲染watcher去更新--派发更新
    },
  });
}
// 递归收集数组依赖
function dependArray(value) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i];
    // e.__ob__代表e已经被响应式观测了 但是没有收集依赖 所以把他们收集到自己的Observer实例的dep里面
    e && e.__ob__ && e.__ob__.dep.depend();
    if (Array.isArray(e)) {
      // 如果数组里面还有数组  就递归去收集依赖
      dependArray(e);
    }
  }
}

8.数组的派发更新

// src/observer/array.js

methodsToPatch.forEach((method) => {
  arrayMethods[method] = function (...args) {
    //   这里保留原型方法的执行结果
    const result = arrayProto[method].apply(this, args);
    // 这句话是关键
    // this代表的就是数据本身 比如数据是{a:[1,2,3]} 那么我们使用a.push(4)  this就是a  ob就是a.__ob__ 这个属性代表的是该数据已经被响应式观察过了 __ob__对象指的就是Observer实例
    const ob = this.__ob__;
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args;
        break;
      case "splice":
        inserted = args.slice(2);
      default:
        break;
    }
    if (inserted) ob.observeArray(inserted); // 对新增的每一项进行观测
    ob.dep.notify(); //数组派发更新 ob指的就是数组对应的Observer实例 我们在get的时候判断如果属性的值还是对象那么就在Observer实例的dep收集依赖 所以这里是一一对应的  可以直接更新
    return result;
  };
});

总结

image.png

参考文章

手写Vue2.0源码(三)-初始渲染原理|技术点评 - 掘金 (juejin.cn)

阅读全文

Vue2源码学习笔记(三)——初次渲染原理

前言

根据vue官网所说,虽然是vue3文档的说法,但我的理解是大同小异:

1691632435050.png

书接上文,vue组件在初始化渲染时经过了模板编译生成了render函数,那么按照官方所说,就需要再这个时候渲染器调用渲染函数,遍历返回虚拟Dom树。

1.组件挂载入口

我们去找$mount看看,也就是vue在渲染挂载时做了什么

// src/init.js

import { initState } from "./state";
import { compileToFunctions } from "./compiler/index";
export function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this;
    // 这里的this代表调用_init方法的对象(实例对象)
    //  this.$options就是用户new Vue的时候传入的属性
    vm.$options = options;
    // 初始化状态
    initState(vm);

    // 如果有el属性 进行模板渲染
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };

  // 这块代码在源码里面的位置其实是放在entry-runtime-with-compiler.js里面
  // 代表的是Vue源码里面包含了compile编译功能 这个和runtime-only版本需要区分开
  Vue.prototype.$mount = function (el) {
    const vm = this;
    const options = vm.$options;
    el = document.querySelector(el);

    // 如果不存在render属性
    if (!options.render) {
      // 如果存在template属性
      let template = options.template;

      if (!template && el) {
        // 如果不存在render和template 但是存在el属性 直接将模板赋值到el所在的外层html结构(就是el本身 并不是父元素)
        template = el.outerHTML;
      }

      // 最终需要把tempalte模板转化成render函数
      if (template) {
        const render = compileToFunctions(template);
        options.render = render;
      }
    }
    // 将当前组件实例挂载到真实的el节点上面 
    return mountComponent(vm, el);
  };
}

2. mountComponent函数(渲染的入口函数)

可以看出当生成完了render函数后,开始调用mountComponent方法

// src/lifecycle.js
export function mountComponent(vm, el) {
  // 上一步模板编译解析生成了render函数
  // 下一步就是执行vm._render()方法 调用生成的render函数 生成虚拟dom
  // 最后使用vm._update()方法把虚拟dom渲染到页面

  // 真实的el选项赋值给实例的$el属性 为之后虚拟dom产生的新的dom替换老的dom做铺垫
  vm.$el = el;
  //   _update和._render方法都是挂载在Vue原型的方法  类似_init
  vm._update(vm._render());
}

3. _render函数(执行render函数,获得虚拟DOM)

通过_render将render函数转化为虚拟dom

// src/render.js

import { createElement, createTextNode } from "./vdom/index";

export function renderMixin(Vue) {
  Vue.prototype._render = function () {
    const vm = this;
    // 获取模板编译生成的render方法
    const { render } = vm.$options;
    // 生成vnode--虚拟dom
    const vnode = render.call(vm);
    return vnode;
  };

  // render函数里面有_c _v _s方法需要定义
  Vue.prototype._c = function (...args) {
    // 创建虚拟dom元素
    return createElement(...args);
  };

  Vue.prototype._v = function (text) {
    // 创建虚拟dom文本
    return createTextNode(text);
  };
  Vue.prototype._s = function (val) {
    // 如果模板里面的是一个对象  需要JSON.stringify
    return val == null
      ? ""
      : typeof val === "object"
      ? JSON.stringify(val)
      : val;
  };
}

虚拟 dom相关功能,定义 Vnode 类 以及 createElement 和 createTextNode 方法

// src/vdom/index.js
// 定义Vnode类
export default class Vnode {
  constructor(tag, data, key, children, text) {
    this.tag = tag;
    this.data = data;
    this.key = key;
    this.children = children;
    this.text = text;
  }
}

// 创建元素vnode 等于render函数里面的 h=>h(App)
export function createElement(tag, data = {}, ...children) {
  let key = data.key;
  return new Vnode(tag, data, key, children);
}

// 创建文本vnode
export function createTextNode(text) {
  return new Vnode(undefined, undefined, undefined, undefined, text);
}

经过上面_render方法一个个遍历把render函数渲染成虚拟dom,接下来就是通过_update把虚拟dom渲染成真实dom了

// src/lifecycle.js

import { patch } from "./vdom/patch";
export function lifecycleMixin(Vue) {
  // 把_update挂载在Vue的原型
  Vue.prototype._update = function (vnode) {
    const vm = this;
    // patch是渲染vnode为真实dom核心
    patch(vm.$el, vnode);
  };
}
// src/vdom/patch.js

// patch用来渲染和更新视图 只介绍初次渲染的逻辑
export function patch(oldVnode, vnode) {
  // 判断传入的oldVnode是否是一个真实元素
  // 这里很关键  初次渲染 传入的vm.$el就是咱们传入的el选项  所以是真实dom
  // 如果不是初始渲染而是视图更新的时候  vm.$el就被替换成了更新之前的老的虚拟dom
  const isRealElement = oldVnode.nodeType;
  if (isRealElement) {
    // 这里是初次渲染的逻辑
    const oldElm = oldVnode;
    const parentElm = oldElm.parentNode;
    // 将虚拟dom转化成真实dom节点
    let el = createElm(vnode);
    // 插入到 老的el节点下一个节点的前面 就相当于插入到老的el节点的后面
    // 这里不直接使用父元素appendChild是为了不破坏替换的位置
    parentElm.insertBefore(el, oldElm.nextSibling);
    // 删除老的el节点
    parentElm.removeChild(oldVnode);
    return el;
  }
}
// 虚拟dom转成真实dom 就是调用原生方法生成dom树
function createElm(vnode) {
  let { tag, data, key, children, text } = vnode;
  //   判断虚拟dom 是元素节点还是文本节点
  if (typeof tag === "string") {
    //   虚拟dom的el属性指向真实dom
    vnode.el = document.createElement(tag);
    // 解析虚拟dom属性
    updateProperties(vnode);
    // 如果有子节点就递归插入到父节点里面
    children.forEach((child) => {
      return vnode.el.appendChild(createElm(child));
    });
  } else {
    //   文本节点
    vnode.el = document.createTextNode(text);
  }
  return vnode.el;
}

// 解析vnode的data属性 映射到真实dom上
function updateProperties(vnode) {
  let newProps = vnode.data || {};
  let el = vnode.el; //真实节点
  for (let key in newProps) {
    // style需要特殊处理下
    if (key === "style") {
      for (let styleName in newProps.style) {
        el.style[styleName] = newProps.style[styleName];
      }
    } else if (key === "class") {
      el.className = newProps.class;
    } else {
      // 给这个元素添加属性 值就是对应的值
      el.setAttribute(key, newProps[key]);
    }
  }
}

流程图

image.png

参考文章

「Vue源码学习(三)」你不知道的-初次渲染原理 - 掘金 (juejin.cn)

阅读全文

Vue2源码学习笔记(二)——编译模板原理

前言

Vue 2的模板编译原理具体可以分为以下几步:

  1. 模板解析:Vue的编译器会将模板字符串解析成抽象语法树(AST),这个AST表示了模板的结构和内容。
  2. 静态分析:编译器会对AST进行静态分析,检测模板中的指令、表达式和属性等,并生成相应的代码。
  3. 优化处理:编译器会对模板进行优化处理,包括静态节点的标记、静态属性的提取和静态文本的优化等。这些优化可以减少运行时的开销,提高渲染性能。
  4. 代码生成:根据AST生成可执行的渲染函数。渲染函数是一个JavaScript函数,它接收数据作为参数,并返回一个虚拟DOM节点。
  5. 渲染函数执行:在组件实例化或数据更新时,会调用渲染函数来生成虚拟DOM节点。渲染函数会根据数据的变化,生成新的虚拟DOM节点,并与旧的虚拟DOM节点进行比较,找出差异并更新实际的DOM。

通过模板编译,Vue将模板转换为可执行的渲染函数,这样在组件实例化和数据更新时,可以快速生成和更新虚拟DOM节点,从而实现高效的视图更新。编译过程中的优化处理可以减少不必要的计算和操作,提高渲染性能。同时,模板编译也提供了更简洁、易读和可维护的模板语法,使开发者能够更方便地编写和维护Vue组件的模板。

简单的例子看看图一乐吧:

// 假设有一个名为parseTemplate的函数,用于将模板解析成AST
function parseTemplate(template) {
  // 解析模板,生成AST
  // 省略具体实现
  return ast;
}

// 假设有一个名为optimize的函数,用于优化AST
function optimize(ast) {
  // 优化AST
  // 省略具体实现
  return optimizedAst;
}

// 假设有一个名为generateCode的函数,用于根据AST生成代码
function generateCode(ast) {
  // 根据AST生成代码
  // 省略具体实现
  return code;
}

// 假设有一个名为compileTemplate的函数,用于编译模板
function compileTemplate(template) {
  // 解析模板,生成AST
  const ast = parseTemplate(template);

  // 优化AST
  const optimizedAst = optimize(ast);

  // 根据AST生成代码
  const code = generateCode(optimizedAst);

  // 返回生成的代码
  return code;
}

// 示例使用
const template = `
  <div>
    <h1>{{ title }}</h1>
    <p v-if="showMessage">{{ message }}</p>
    <button @click="handleClick">Click me</button>
  </div>
`;

const compiledCode = compileTemplate(template);
console.log(compiledCode);

接下来我们看看具体室怎么实现的吧。

1.模板编译入口

上篇响应式原理中就说过中initMixin不仅仅会对初始化状态,还会在这个时候调用$mount方法进行挂载,vue也是在这个地方对模版进行编译的。

// src/init.js

import { initState } from "./state";
import { compileToFunctions } from "./compiler/index";
export function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this;
    // 这里的this代表调用_init方法的对象(实例对象)
    //  this.$options就是用户new Vue的时候传入的属性
    vm.$options = options;
    // 初始化状态
    initState(vm);

    // 如果有el属性 进行模板渲染
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };

  // 这块代码在源码里面的位置其实是放在entry-runtime-with-compiler.js里面
  // 代表的是Vue源码里面包含了compile编译功能 这个和runtime-only版本需要区分开
  Vue.prototype.$mount = function (el) {
    const vm = this;
    const options = vm.$options;
    el = document.querySelector(el);

    // 如果不存在render属性
    if (!options.render) {
      // 如果存在template属性
      let template = options.template;

      if (!template && el) {
        // 如果不存在render和template 但是存在el属性 直接将模板赋值到el所在的外层html结构(就是el本身 并不是父元素)
        template = el.outerHTML;
      }

      // 最终需要把tempalte模板转化成render函数
      if (template) {
        const render = compileToFunctions(template);
        options.render = render;
      }
    }
  };
}

首先需要对模板进行转化,其中核心方法就是compileToFunctions。

// src/compiler/index.js

import { parse } from "./parse";
import { generate } from "./codegen";
export function compileToFunctions(template) {
  // 我们需要把html字符串变成render函数
  // 1.把html代码转成ast语法树  ast用来描述代码本身形成树结构 不仅可以描述html 也能描述css以及js语法
  // 很多库都运用到了ast 比如 webpack babel eslint等等
  let ast = parse(template);
  // 2.优化静态节点
     if (options.optimize !== false) {
       optimize(ast, options);
     }

  // 3.通过ast 重新生成代码
  // 我们最后生成的代码需要和render函数一样
  // 类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))
  // _c代表创建元素 _v代表创建文本 _s代表文Json.stringify--把对象解析成文本
  let code = generate(ast);
  //   使用with语法改变作用域为this  之后调用render函数可以使用call改变this 方便code里面的变量取值
  let renderFn = new Function(`with(this){return ${code}}`);
  return renderFn;
}

2.模板解析

解析Html并生成ast

  • 首先需要各种规则匹配的正则表达式(开始标签,结束标签,花括号等)

  • createASTElement:将某一节点转为AST对象的函数

  • handleStartTag: 处理开始标签的函数

  • handleEndTag:处理结尾标签的函数

  • handleChars:处理文本节点的函数

  • parse:转AST的入口函数

// src/compiler/parse.js

// 以下为源码的正则  对正则表达式不清楚的同学可以参考小编之前写的文章(前端进阶高薪必看 - 正则篇);
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束  >
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性  形如 id="app"

let root, currentParent; //代表根节点 和当前父节点
// 栈结构 来表示开始和结束标签
let stack = [];
// 标识元素和文本type
const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
// 生成ast方法
function createASTElement(tagName, attrs) {
  return {
    tag: tagName,
    type: ELEMENT_TYPE,
    children: [],
    attrs,
    parent: null,
  };
}

// 对开始标签进行处理
function handleStartTag({ tagName, attrs }) {
  let element = createASTElement(tagName, attrs);
  if (!root) {
    root = element;
  }
  currentParent = element;
  stack.push(element);
}

// 对结束标签进行处理
function handleEndTag(tagName) {
  // 栈结构 []
  // 比如 <div><span></span></div> 当遇到第一个结束标签</span>时 会匹配到栈顶<span>元素对应的ast 并取出来
  let element = stack.pop();
  // 当前父元素就是栈顶的上一个元素 在这里就类似div
  currentParent = stack[stack.length - 1];
  // 建立parent和children关系
  if (currentParent) {
    element.parent = currentParent;
    currentParent.children.push(element);
  }
}

// 对文本进行处理
function handleChars(text) {
  // 去掉空格
  text = text.replace(/\s/g, "");
  if (text) {
    currentParent.children.push({
      type: TEXT_TYPE,
      text,
    });
  }
}

// 解析标签生成ast核心
export function parse(html) {
  while (html) {
    // 查找<
    let textEnd = html.indexOf("<");
    // 如果<在第一个 那么证明接下来就是一个标签 不管是开始还是结束标签
    if (textEnd === 0) {
      // 如果开始标签解析有结果
      const startTagMatch = parseStartTag();
      if (startTagMatch) {
        // 把解析好的标签名和属性解析生成ast
        handleStartTag(startTagMatch);
        continue;
      }

      // 匹配结束标签</
      const endTagMatch = html.match(endTag);
      if (endTagMatch) {
        advance(endTagMatch[0].length);
        handleEndTag(endTagMatch[1]);
        continue;
      }
    }

    let text;
    // 形如 hello<div></div>
    if (textEnd >= 0) {
      // 获取文本
      text = html.substring(0, textEnd);
    }
    if (text) {
      advance(text.length);
      handleChars(text);
    }
  }

  // 匹配开始标签
  function parseStartTag() {
    const start = html.match(startTagOpen);

    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
      };
      //匹配到了开始标签 就截取掉
      advance(start[0].length);

      // 开始匹配属性
      // end代表结束符号>  如果不是匹配到了结束标签
      // attr 表示匹配的属性
      let end, attr;
      while (
        !(end = html.match(startTagClose)) &&
        (attr = html.match(attribute))
      ) {
        advance(attr[0].length);
        attr = {
          name: attr[1],
          value: attr[3] || attr[4] || attr[5], //这里是因为正则捕获支持双引号 单引号 和无引号的属性值
        };
        match.attrs.push(attr);
      }
      if (end) {
        //   代表一个标签匹配到结束的>了 代表开始标签解析完毕
        advance(1);
        return match;
      }
    }
  }
  //截取html字符串 每次匹配到了就往前继续匹配
  function advance(n) {
    html = html.substring(n);
  }
  //   返回生成的ast
  return root;
}

3.静态分析和优化处理

 function optimize(ast,option) {
  // 遍历AST的所有节点
  traverse(ast, {
    // 对于每个元素节点
    ElementNode(node) {
      // 检查节点是否有静态属性
      if (hasStaticAttributes(node)) {
        // 标记节点为静态节点
        node.static = true;
      }
    },
    // 对于每个文本节点
    TextNode(node) {
      // 检查节点是否为静态文本
      if (isStaticText(node)) {
        // 标记节点为静态节点
        node.static = true;
      }
    }
  });

  return ast;
}

// 辅助函数:检查节点是否有静态属性
function hasStaticAttributes(node) {
  // 检查节点的属性是否都是静态的
  return node.attributes.every(attr => attr.static);
}

// 辅助函数:检查节点是否为静态文本
function isStaticText(node) {
  // 检查文本节点的内容是否是静态的
  return !/\{\{.*\}\}/.test(node.content);
}

// 辅助函数:遍历AST的所有节点
function traverse(ast, visitor) {
  function traverseNode(node) {
    // 调用对应节点类型的处理函数
    const handler = visitor[node.type];
    if (handler) {
      handler(node);
    }

    // 递归遍历子节点
    if (node.children) {
      node.children.forEach(traverseNode);
    }
  }

  traverseNode(ast);
}

// 示例使用
const ast = {
  type: 'ElementNode',
  tag: 'div',
  attributes: [
    { name: 'class', value: 'container', static: true },
    { name: 'id', value: 'app', static: true }
  ],
  children: [
    {
      type: 'TextNode',
      content: 'Hello, world!',
      static: true
    }
  ]
};

const optimizedAst = optimize(ast);
console.log(optimizedAst);

4.代码生成(将AST转换成render函数格式的数据)

// src/compiler/gen.js

const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; //匹配花括号 {{  }} 捕获花括号里面的内容

function gen(node) {
    if (node.type === 1) {
        // 元素节点处理
        return generate(node)
    } else {
        // 文本节点处理
        const text = node.text

        // 检测是否有花括号{{}}
        if (!defaultTagRE.test(text)) {
            // 没有的话直接返回 _v,创建文本节点
            return `_v(${JSON.stringify(text)})`
        }


        // 每次赋值完要重置defaultTagRE.lastIndex
        // 因为正则规则加上全局g的话,lastIndex会逐步递增,具体可以百度查一查正则的全局g情况下的test方法执行后的lastIndex
        let lastIndex = (defaultTagRE.lastIndex = 0);
        const tokens = []
        let match, index

        while ((match = defaultTagRE.exec(text))) {
            // 文本里只要还存在{{}}就会一直正则匹配
            index = match.index
            if (index > lastIndex) {
                // 截取{{xxx}}中的文本xxx
                tokens.push(JSON.stringify(text.slice(lastIndex, index)))
            }

            tokens.push(`_s(${match[1].trim()})`)


            // 推进lastIndex
            lastIndex = index + match[0].length

        }

        // 匹配完{{}}了,但是还有剩余的文本,那就还是push进去
        if (lastIndex < text.length) {
            tokens.push(JSON.stringify(text.slice(lastIndex)))
        }

        // return _v函数创建文本节点
        return `_v(${tokens.join('+')})`
    }

}


// 生成render函数格式的code的函数
function generate(el) {
    const children = getChildren(el)
    const code = `_c('${el.tag}',${el.attrs.length ? `${genProps(el.attrs)}` : "undefined"
        }${children ? `,${children}` : ""})`;;
    return code
}

// 处理attrs的函数
function genProps(attrs) {
    let str = ''
    for (let i = 0; i < attrs.length; i++) {
        const attr = attrs[i]

        if (attr.name === 'style') {
            const obj = {}

            attr.value.split(';').forEach(item => {
                const [key, value] = item.split(':')
                obj[key] = value
            })
            attr.value = obj
        }
        str += `${attr.name}:${JSON.stringify(attr.value)},`
    }
    return `{${str.slice(0, str.length)}}`
}

// 获取子节点,进行gen的递归
function getChildren(el) {
    const children = el.children
    if (children && children.length) {
        return `${children.map(c => gen(c)).join(',')}`
    }
}

module.exports = {
    generate
}

5.思维导图

image.png

参考文章

手写Vue2.0源码(二)-模板编译原理|技术点评 - 掘金 (juejin.cn)

阅读全文

Vue2源码学习笔记(一)——Vue实例数据初始化和响应式原理

前言

这段时间我司需求没那么急了,开始闲下来了。之前也一直想学习Vue2源码,打开vue2源码后,摸不着头脑,不知从何开始看起。后面看到鲨鱼大佬文章,便开始跟着敲跟着学习,同时感谢这些大佬手写源码,并对其一步步的分析。总的来说跟着敲完这些代码,获益匪浅。

1.Vue实例数据初始化

Vue 其实就是一个构造函数,通过initMixin把_init方法挂载在Vue原型上,供Vue实例调用。

// index.js
const { initMixin } = require('./init')

//这是一个Vue构造函数的定义。当创建一个新的Vue实例时,会调用构造函数,并将传入的options作为参数传递给_init方法
function Vue(options) {
    // 初始化传进来的options配置
    this._init(options)
}

// 配置Vue构造函数的_init方法

// Vue构造函数添加实例初始化
initMixin(Vue)
// 实例一个Vue对象
let vue = new Vue({
    props: {},
    data() {
        return {
            a: 1,
            b: [1],
            c: { d: 1 }
        }
    },
    watch: {},
    render: () => {}
})

其中
在Vue中,initMixin函数是一个用于混入初始化逻辑的函数。它主要的作用是给Vue构造函数添加实例初始化的方法,以便在创建Vue实例时执行一些初始化操作。

具体来说,initMixin函数会向Vue构造函数的原型对象上混入一个名为_init的方法。这个_init方法是Vue实例初始化的入口,它会在创建Vue实例时被调用。

_init方法的主要职责是:

  1. 合并配置:将用户传入的配置选项与Vue构造函数的默认选项进行合并,得到最终的配置对象。
  2. 初始化生命周期:创建Vue实例的生命周期钩子,并为$options添加一些属性用于存储生命周期状态。
  3. 初始化事件:初始化Vue实例的事件系统,包括事件的订阅与派发。
  4. 初始化渲染:创建Vue实例的渲染函数和虚拟DOM的相关属性。
  5. 初始化状态:设置Vue实例的响应式数据,并创建数据观测的实例。
  6. 调用$mount:如果配置中存在el选项,会自动调用$mount方法进行挂载。
// init.js
const { initState } = require('./state')

function initMixin(Vue) {
    // 在Vue的原型上挂载_init函数
    Vue.prototype._init = function (options) {
        // vm变量赋值为Vue实例
        const vm = this

        // 将传进来的options对象赋值给vm上的$options变量
        vm.$options = options

        // 执行初始化状态函数
        initState(vm)
    }
}
module.exports = {
    initMixin: initMixin
}

initState的具体处理

// 初始化状态 注意这里的顺序,是否能在data里面直接使用prop的值 为什么?
// 这里初始化的顺序依次是 prop>methods>data>computed>watch
export function initState(vm) {
  // 获取传入的数据对象
  const opts = vm.$options;
  if (opts.props) {
    initProps(vm);
  }
  if (opts.methods) {
    initMethod(vm);
  }
  if (opts.data) {
    // 初始化data
    initData(vm); //下面有具体怎么初始化数据的
  }
  if (opts.computed) {
    initComputed(vm);
  }
  if (opts.watch) {
    initWatch(vm);
  }
}
// src/state.js 
import { observe } from "./observer/index.js";
// 初始化data数据
function initData(vm) {
  let data = vm.$options.data;
  //   实例的_data属性就是传入的data
  // vue组件data推荐使用函数 防止数据在组件之间共享
  data = vm._data = typeof data === "function" ? data.call(vm) : data || {};

  // 把data数据代理到vm 也就是Vue实例上面 我们可以使用this.a来访问this._data.a
  for (let key in data) {
    proxy(vm, `_data`, key);
  }
  // 对数据进行观测 --响应式数据核心
  observe(data);
}
// 数据代理
function proxy(object, sourceKey, key) {
  Object.defineProperty(object, key, {
    get() {
      return object[sourceKey][key];
    },
    set(newValue) {
      object[sourceKey][key] = newValue;
    },
  });
}

2.响应式处理

可以看到在初始化过程中涉及一个方法 observe(data);这个就是响应式数据核心,也就是大名鼎鼎的数据劫持结合观察者的模式实现vue响应式数据。

// src/obserber/index.js
import { arrayMethods } from "./array";

// 观察者对象,使用es6的class来构建会比较方便
class Observer {
    constructor(value) {
        // 给传进来的value对象或者数组设置一个__ob__对象
        // 这个__ob__对象大有用处,如果value上有这个__ob__,则说明value已经做了响应式处理
        Object.defineProperty(value, '__ob__', {
            value: this, // 值为this,也就是new出来的Observer实例
            enumerable: false, // 不可被枚举
            writable: true, // 可用赋值运算符改写__ob__
            configurable: true // 可改写可删除
        })

        // 判断value是函数还是对象
        if(Array.isArray(value)) {
            // 如果是数组的话就修改数组的原型
            value.__proto__ = arrayMethods
            // 对数组进行响应式处理
            this.observeArray(value)
        } else {
            // 如果是对象,则执行walk函数对对象进行响应式处理
            this.walk(value)
        }
    }

    walk(data) {
        // 获取data对象的所有key
        let keys = Object.keys(data)
        // 遍历所有key,对每个key的值进行响应式处理
        for(let i = 0; i < keys.length; i++) {
            const key = keys[i]
            const value = data[key]
            // 传入data对象,key,以及value
            defineReactive(data, key, value)
        }
    }

    observeArray(items) {
        // 遍历传进来的数组,对数组的每一个元素进行响应式处理
        for(let i = 0; i < items.length; i++) {
            observe(items[i])
        }
    }
}

function defineReactive(data, key, value) {
    // 递归重要步骤
    // 因为对象里可能有对象或者数组,所以需要递归
    observe(value)


    // 核心
    // 拦截对象里每个key的get和set属性,进行读写监听
    // 从而实现了读写都能捕捉到,响应式的底层原理
    Object.defineProperty(data, key, {
        get() {
            console.log('获取值')
            return value
        },
        set(newVal) {
            if (newVal === value) return
            console.log('设置值')
            value = newVal
        }
    })
}


function observe(value) {
    // 如果传进来的是对象或者数组,则进行响应式处理
    if (Object.prototype.toString.call(value) === '[object Object]' || Array.isArray(value)) {
        return new Observer(value)
    }
}

module.exports = {
    observe: observe
}

数据劫持核心是 defineReactive 函数,主要使用 Object.defineProperty 来对数据 get 和 set 进行劫持 这里就解决了之前的问题为啥数据变动了会自动更新视图,因为我们可以在 set 里面去通知视图更新

3.响应式处理的注意点

可以看到vue源码中数组和对象是分开处理的,数组并没有和对象一样层层递归,给每一个属性都劫持set和get

  • 对象的属性通常比较少,对每一个属性都劫持set和get,并不会消耗很多性能

  • 数组有可能有成千上万个元素,如果每一个元素都劫持set和get,无疑消耗太多性能了

  • 所以对象通过defineProperty进行正常的劫持set和get

4.对数组的观测

上面的arrayMethods就是响应式对数组的处理, 数组是通过修改数组原型上的部分方法,来实现修改数组触发响应式

// src/obserber/array.js
// 先保留数组原型
const arrayProto = Array.prototype;
// 然后将arrayMethods继承自数组原型
// 这里是面向切片编程思想(AOP)--不破坏封装的前提下,动态的扩展功能
export const arrayMethods = Object.create(arrayProto);
let methodsToPatch = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "reverse",
  "sort",
];
methodsToPatch.forEach((method) => {
  arrayMethods[method] = function (...args) {
    //   这里保留原型方法的执行结果
    const result = arrayProto[method].apply(this, args);
    // 这句话是关键
    // this代表的就是数据本身 比如数据是{a:[1,2,3]} 那么我们使用a.push(4)  this就是a  ob就是a.__ob__ 这个属性就是上段代码增加的 代表的是该数据已经被响应式观察过了指向Observer实例
    const ob = this.__ob__;

    // 这里的标志就是代表数组有新增操作
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args;
        break;
      case "splice":
        inserted = args.slice(2);
      default:
        break;
    }
    // 如果有新增的元素 inserted是一个数组 调用Observer实例的observeArray对数组每一项进行观测
    if (inserted) ob.observeArray(inserted);
    // 之后咱们还可以在这里检测到数组改变了之后从而触发视图更新的操作--后续源码会揭晓
    return result;
  };
});

4.思维导图

image.png

参考文章

鲨鱼哥-手写 Vue2.0 源码(一)-响应式数据原理

阅读全文
1 ... 4 5 6 ... 9
avatar
耀耀切克闹

欢迎来到耀耀切克闹的博客

不是因为是主角才成功,而是成功了才成为主角

想要的都拥有,得不到的都释怀