三大核心系统

vue的源码包含三大核心:

  • Compiler模块:编译模板系统
  • Runtime模块:也可以称之为Renderer模块,真正渲染的模块
  • Reactivity模块:响应式系统

    三大系统如何协同工作?

    image.png
    这里我们实现一个简洁版的Mini-Vue框架,该Vue包含三个模块:

  • 渲染系统模块

  • 可响应式系统模块
  • 应用程序入口模块

    渲染系统实现

    渲染系统,该模块主要包含三个功能:

  • 功能一:h函数,用于返回一个VNode对象

  • 功能二:mount函数,用于将VNode挂载到DOM上
  • 功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode

    h函数的实现

    1. const vnode = h('div', { class: 'zx' }, [
    2. h("h2", null, "当前计数:100"),
    3. h("button", null, "+1")
    4. ])
    1. const h = (tag, props, children) => {
    2. return {
    3. tag,
    4. props,
    5. children,
    6. };
    7. };

    非常的不可思议对不对?本以为h函数会非常复杂,但其实h函数非常的简单,就是返回一个对象,用于描述dom节点的对象,也就是vnode,真实的h函数也和我们这个差不多,不过是多了一些边界处理。

    mount函数的实现

    1. mount(vnode, document.querySelector('#app'))
    1. const mount = (vnode, container) => {
    2. // 1. 创建出真实的原生,并且在vnode上保留el
    3. const el = (vnode.el = document.createElement(vnode.tag));
    4. // 2. 处理props
    5. if (vnode.props) {
    6. for (const key in vnode.props) {
    7. const value = vnode.props[key];
    8. if (key.startsWith("on")) {
    9. el.addEventListener(key.slice(2));
    10. } else {
    11. el.setAttribute(key, value);
    12. }
    13. }
    14. }
    15. // 3. 处理children
    16. if (vnode.children) {
    17. if (typeof vnode.children === "string") {
    18. el.textContent = vnode.children;
    19. } else {
    20. vnode.children.forEach((item) => {
    21. mount(item, el);
    22. });
    23. }
    24. }
    25. container.appendChild(el);
    26. };

    mount函数的主要作用就是实现将vnode对象转换成真实的dom,也就是说,解析前面h函数生成的对象,根据解析出的对象创建真实的dom节点。

创建节点就是document.createElement(vnode.tag),这个原生的API就能实现这个功能。这里也做了很多的简化,比如没有考虑传入的是组件,但其实即使是组件,也不过是一个不同的vnode罢了,虽然代码会复杂,因为会用到正则表达式,但是原理是一样的。

处理props那部分,就是遍历一遍props的参数,然后通过setAttribute(key, value)这个原生API实现属性绑定。

最后是处理children这部分,也只考虑了string和数组,而数组也只考虑了普通的html标签,而没有考虑组件标签等。如果是字符串,就通过el.textContent = vnode.children绑定字符串;如果是数组,就遍历这个数组,并且这里使用了递归,就是为了对数组内的元素也进行一遍mount函数的操作。

全部处理完成后,我们mount函数的第二个参数是要挂载的节点,将最终得出的el(要创建的真实dom)挂载到我们想要挂载的节点,就实现了mount函数。

patch函数的实现

patch函数的实现要稍微复杂一些,主要的原理就是传入两个vnode,根据这两个vnode的差异来进行diff算法,最终修改dom,实现节点的改变。

  1. const patch = (vnode1, vnode2) => {
  2. if (vnode1.tag !== vnode2.tag) {
  3. const vnode1ElParent = vnode1.el.parentElement;
  4. vnode1ElParent.removeChild(vnode1.el);
  5. mount(vnode2, vnode1ElParent);
  6. } else {
  7. // 1. 取出element对象,并且在n2中进行保存
  8. const el = (vnode2.el = vnode1.el);
  9. // 2. 处理props
  10. const oldProps = vnode1.props || {};
  11. const newProps = vnode2.props || {};
  12. // 2.1 获取所有的newProps添加到el
  13. for (const key in newProps) {
  14. const oldValue = oldProps[key];
  15. const newValue = newProps[key];
  16. if (newValue !== oldValue) {
  17. if (key.startsWith("on")) {
  18. el.addEventListener(key.slice(2).toLocaleLowerCase(), newValue);
  19. } else {
  20. el.setAttribute(key, newValue);
  21. }
  22. }
  23. }
  24. // 2.2 删除旧的props
  25. for (const key in oldProps) {
  26. if (!(key in newProps)) {
  27. if (key.startsWith("on")) {
  28. const value = oldProps[key];
  29. el.removeEventListener(key.slice(2).toLocaleLowerCase(), value);
  30. } else {
  31. el.removeAttribute(key);
  32. }
  33. }
  34. }
  35. // 3. 处理children
  36. const oldChildren = vnode1.children || [];
  37. const newChildren = vnode2.children || [];
  38. if (typeof newChildren === "string") {
  39. if (typeof oldChildren === "string") {
  40. // 边界判断
  41. if (newChildren !== oldChildren) {
  42. el.textContent = newChildren;
  43. }
  44. } else {
  45. el.innerHTML = newChildren;
  46. }
  47. } else {
  48. // 情况二:newChildren本身是一个数组
  49. if (typeof oldChildren === "string") {
  50. el.innerHTML = "";
  51. newChildren.forEach((item) => {
  52. mount(item, el);
  53. });
  54. } else {
  55. // oldChildren:[v1,v2,v3]
  56. // newChildren:[v1,v5,v6,v8,v9]
  57. // 1. 前面有相同节点的元素进行patch操作
  58. const commonLength = Math.min(oldChildren.length, newChildren.length);
  59. for (let i = 0; i < commonLength; i++) {
  60. patch(oldChildren[i], newChildren[i]);
  61. }
  62. // 2. newChildren > oldChildren
  63. if (newChildren.length > oldChildren.length) {
  64. newChildren.slice(oldChildren.length).forEach((item) => {
  65. mount(item, el);
  66. });
  67. }
  68. // 2. newChildren < oldChildren
  69. if (newChildren.length < oldChildren.length) {
  70. oldChildren.slice(newChildren.length).forEach((item) => {
  71. el.removeChild(item.el);
  72. });
  73. }
  74. }
  75. }
  76. }
  77. };

分段讲解

  1. const patch = (vnode1, vnode2) => {
  2. if (vnode1.tag !== vnode2.tag) {
  3. const vnode1ElParent = vnode1.el.parentElement;
  4. vnode1ElParent.removeChild(vnode1.el);
  5. mount(vnode2, vnode1ElParent);
  6. }

patch函数首先接收两个参数,一个是旧的vnode节点vnode1,一个是新的vnode节点vnode2。首先我们比较新旧两个vnode节点的类型,如果类型都不同,那么直接删除旧节点,生成新的节点。如果类型相同,那么进入下一部分。

  1. else {
  2. // 1. 取出element对象,并且在n2中进行保存
  3. const el = (vnode2.el = vnode1.el);
  4. // 2. 处理props
  5. const oldProps = vnode1.props || {};
  6. const newProps = vnode2.props || {};
  7. // 2.1 获取所有的newProps添加到el
  8. for (const key in newProps) {
  9. const oldValue = oldProps[key];
  10. const newValue = newProps[key];
  11. if (newValue !== oldValue) {
  12. if (key.startsWith("on")) {
  13. el.addEventListener(key.slice(2).toLocaleLowerCase(), newValue);
  14. } else {
  15. el.setAttribute(key, newValue);
  16. }
  17. }
  18. }
  19. // 2.2 删除旧的props
  20. for (const key in oldProps) {
  21. if (!(key in newProps)) {
  22. if (key.startsWith("on")) {
  23. const value = oldProps[key];
  24. el.removeEventListener(key.slice(2).toLocaleLowerCase(), value);
  25. } else {
  26. el.removeAttribute(key);
  27. }
  28. }
  29. }

首先获取到el对象,也就是节点本身的真实dom,然后遍历新vnode的attribute,以新attribute的键名分别获取新旧attribute的值,如果旧值和新值不相等,那么将新值添加给el。然后遍历旧vnode的attribute,如果和新值不相等就删除。这就实现了attribute的更新。

  1. // 3. 处理children
  2. const oldChildren = vnode1.children || [];
  3. const newChildren = vnode2.children || [];
  4. if (typeof newChildren === "string") {
  5. if (typeof oldChildren === "string") {
  6. // 边界判断
  7. if (newChildren !== oldChildren) {
  8. el.textContent = newChildren;
  9. }
  10. } else {
  11. el.innerHTML = newChildren;
  12. }
  13. } else {
  14. // 情况二:newChildren本身是一个数组
  15. if (typeof oldChildren === "string") {
  16. el.innerHTML = "";
  17. newChildren.forEach((item) => {
  18. mount(item, el);
  19. });
  20. } else {
  21. // oldChildren:[v1,v2,v3]
  22. // newChildren:[v1,v5,v6,v8,v9]
  23. // 1. 前面有相同节点的元素进行patch操作
  24. const commonLength = Math.min(oldChildren.length, newChildren.length);
  25. for (let i = 0; i < commonLength; i++) {
  26. patch(oldChildren[i], newChildren[i]);
  27. }
  28. // 2. newChildren > oldChildren
  29. if (newChildren.length > oldChildren.length) {
  30. newChildren.slice(oldChildren.length).forEach((item) => {
  31. mount(item, el);
  32. });
  33. }
  34. // 2. newChildren < oldChildren
  35. if (newChildren.length < oldChildren.length) {
  36. oldChildren.slice(newChildren.length).forEach((item) => {
  37. el.removeChild(item.el);
  38. });
  39. }
  40. }
  41. }

这一部分是最复杂的,就是比较子节点。首先是判断新节点是否为字符串类型,如果是的话,再判断旧节点是否为字符串,如果是的话,直接更改textContent,也就是更改文本内容即可;如果不是,那么将旧节点直接更改为新的字符串节点。
然后是边界处理情况,也就是新旧children的不同类型,如果新节点为数组,旧节点为字符串,那么清空节点的文本内容,然后遍历新数组,调用mount函数实现节点的创建和挂载。
如果新旧节点都为数组,那么首先获取新旧节点数组中短的那一方,比如新数组长度为5,旧数组长度为3,那么执行一个0-3的循环,循环的item为新旧树组0-3的值,比较这部分值,递归调用patch函数比较(就是将数组打开,拿到单独的一个个节点,调用patch函数比较,重复之前的步骤,从比较两个节点是否为相同类型结点开始)。
如果新节点比旧节点长,那么新节点截取掉旧节点的长度后,多出来的那部分通过调用mount函数实现节点的创建;如果旧节点比新节点长,那么旧节点截取掉和新节点相同长度的部分后,剩下的那部分通过调用unmount函数实现节点的卸载(这里只简单的通过removeChild实现了节点的卸载,实际实现要复杂一些)。
上面这部分代码就是diff算法的基本逻辑,不论是vue还是react都是基于这个实现的,不过它们的代码处理的边界情况更多。

响应式系统实现

简单实现

  1. class Dep {
  2. constructor() {
  3. this.subscribers = new Set();
  4. }
  5. addEffect(effect) {
  6. this.subscribers.add(effect);
  7. }
  8. notify() {
  9. this.subscribers.forEach((effect) => {
  10. effect();
  11. });
  12. }
  13. }
  14. // 测试数据
  15. const info = { counter: 100 };
  16. const dep = new Dep();
  17. function doubleCounter() {
  18. console.log(info.counter * 2);
  19. }
  20. function powerCounter() {
  21. console.log(info.counter * info.counter);
  22. }
  23. dep.addEffect(doubleCounter);
  24. dep.addEffect(powerCounter);
  25. info.counter++;
  26. dep.notify();

分析一下代码:
首先,我们定义了一个Dep类,类中有一个subscribers,是一个不允许重复的数组。然后这个类有两个方法,addEffect方法用于向subscribers数组添加副作用(修改变量的操作);notify方法用于遍历存入subscribers数组的方法并且执行。

然后在测试数据中,我们定义了对象类型变量info,然后新建了Dep的实例dep,然后是两个函数,doubleCounter用于打印info的counter值*2,powerCounter用于打印info的counter的平方。
通过实例的addEffect方法将这两个函数存入subscribers数组。

接着我们将info的counter做一个加一,最后调用实例的notify方法。

那么执行结果是什么呢?

首先info.counter++会使counter的值变为101,然后执行dep.notify(),这时因为之前dep.addEffect(doubleCounter)和dep.addEffect(powerCounter)执行使得subscribers数组内有两个函数分别是doubleCounter和powerCounter,notify会依次执行这两个函数,所以会打印 101 2 = 202,还会打印 101 * 101 = 10201
image.png
上面的代码实现了在info数据改变的时候监听到每一次改变。

上面的代码,实现了简单的响应式功能,但是需要在数据发生改变时手动添加到subscribers数组,而真正的响应式系统是自动跟踪改变,所以还需要进行改进。

vue2实现思路

  1. class Dep {
  2. constructor() {
  3. this.subscribers = new Set();
  4. }
  5. depend() {
  6. if (activeEffect) {
  7. this.subscribers.add(activeEffect);
  8. }
  9. }
  10. notify() {
  11. this.subscribers.forEach((effect) => {
  12. effect();
  13. });
  14. }
  15. }
  16. let activeEffect = null;
  17. function watchEffect(effect) {
  18. activeEffect = effect;
  19. effect();
  20. activeEffect = null;
  21. }
  22. const targetMap = new WeakMap();
  23. function getDep(target, key) {
  24. // 1. 根据对象(target)取出对应的Map对象
  25. let depsMap = targetMap.get(target);
  26. if (!depsMap) {
  27. depsMap = new Map();
  28. targetMap.set(target, depsMap);
  29. }
  30. // 2. 取出具体的dep对象
  31. let dep = depsMap.get(key);
  32. if (!dep) {
  33. dep = new Dep();
  34. depsMap.set(key, dep);
  35. }
  36. return dep;
  37. }
  38. function reactive(raw) {
  39. Object.keys(raw).forEach((key) => {
  40. const dep = getDep(raw, key);
  41. let value = raw[key];
  42. Object.defineProperty(raw, key, {
  43. get() {
  44. dep.depend();
  45. return value;
  46. },
  47. set(newValue) {
  48. if (value !== newValue) {
  49. value = newValue;
  50. dep.notify();
  51. }
  52. },
  53. });
  54. });
  55. return raw;
  56. }
  57. const info = reactive({ counter: 100, name: "zx" });
  58. // watchEffect1
  59. watchEffect(function () {
  60. console.log("effect1", info.counter * 2, info.name);
  61. });
  62. // watchEffect2
  63. watchEffect(function () {
  64. console.log("effect2", info.counter * info.counter);
  65. });
  66. // watchEffect3
  67. watchEffect(function () {
  68. console.log("effect3", info.counter + 10, info.name);
  69. });

我们来分析一下:
前面提到简单实现需要手动调用addEffect来将改变变量的函数添加到subscribers数组中,所以我们创建一个watchEffect函数来实现自动添加。

vue3实现思路

  1. class Dep {
  2. constructor() {
  3. this.subscribers = new Set();
  4. }
  5. depend() {
  6. if (activeEffect) {
  7. this.subscribers.add(activeEffect);
  8. }
  9. }
  10. notify() {
  11. this.subscribers.forEach((effect) => {
  12. effect();
  13. });
  14. }
  15. }
  16. let activeEffect = null;
  17. function watchEffect(effect) {
  18. activeEffect = effect;
  19. effect();
  20. activeEffect = null;
  21. }
  22. const targetMap = new WeakMap();
  23. function getDep(target, key) {
  24. // 1. 根据对象(target)取出对应的Map对象
  25. let depsMap = targetMap.get(target);
  26. if (!depsMap) {
  27. depsMap = new Map();
  28. targetMap.set(target, depsMap);
  29. }
  30. // 2. 取出具体的dep对象
  31. let dep = depsMap.get(key);
  32. if (!dep) {
  33. dep = new Dep();
  34. depsMap.set(key, dep);
  35. }
  36. return dep;
  37. }
  38. // vue3对raw进行数据劫持
  39. function reactive(raw) {
  40. return new Proxy(raw, {
  41. get(target, key) {
  42. const dep = getDep(target, key);
  43. dep.depend();
  44. return target[key];
  45. },
  46. set(target, key, newValue) {
  47. const dep = getDep(target, key);
  48. target[key] = newValue;
  49. dep.notify();
  50. },
  51. });
  52. }

为什么vue3选择proxy?

Object.defineProperty在劫持对象的属性时,如果新增元素:

  • 那么vue2需要再次调用definedProperty,而Proxy劫持的是整个对象,不需要做特殊处理

修改对象的不同:

  • 使用defineProperty时,我们修改原来的obj对象就可以触发拦截
  • 而使用proxy,就必须修改代理对象,即Proxy的实例才可以触发拦截

Proxy能观察的类型比defineProperty更丰富

  • has:in操作符的捕获器
  • deleteProperty:delete操作符的捕捉器
  • 等等其他操作

Proxy作为新标准将受到浏览器厂商重点持续的性能优化
缺点:Proxy不兼容IE,也没有polyfi
,defineProperty能支持到IE9