Vue3源码学习

本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。

真实的DOM渲染

我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的,那么它是什么样的过程呢?

虚拟DOM的优势

目前框架都会引入虚拟DOM来对真实的DOM进行抽象,这样做有很多的好处:

  1. 首先是可以对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行各种操作。
    因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这些,就变得非常的简单。
    我们可以使用JavaScript来表达非常多的逻辑,而对于DOM本身来说是非常不方便的。
  2. 其次是方便实现跨平台,包括你可以将VNode节点渲染成任意你想要的节点。
    如渲染在canvas、WebGL、SSR、Native(iOS、Android)上。
    并且Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染。

虚拟DOM的渲染过程

Vue3源码三大核心系统

Vue3源码地址:https://github.com/iamkata/Vue3_source

事实上Vue的源码包含三大核心:

  • Compiler模块:编译模板系统。主要职责是将template编译成虚拟节点。
  • Runtime模块:也可以称之为Renderer模块,真正负责渲染的模块。主要职责是将虚拟节点渲染成真实元素,然后显示到浏览器上。
  • Reactivity模块:响应式系统。主要职责是监听响应式的数据,然后通过diff算法判断VNode是否有变化,如果有变化,会通知渲染系统重新渲染元素,然后展示。

三大系统如何协同工作

实现Mini-Vue

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

  • 渲染系统模块(renderer.js)
  • 响应式系统模块(reactive.js)
  • 应用程序入口模块(index.js)

我们不使用template,所以没必要编译系统了,所以直接在程序入口模块,也就是index.html里面使用渲染模块和可响应式系统模块即可。

渲染系统实现

渲染系统,该模块主要包含三个功能:
功能一:h函数,用于返回一个VNode对象;
功能二:mount函数,用于将VNode挂载到DOM上;
功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;

新建renderer.js文件,这个文件就是我们的渲染系统,在renderer.js中编写代码如下:

① h函数的实现

直接返回一个VNode对象即可。

// 实现h函数
const h = (tag, props, children) => {
  // vnode -> javascript对象 -> {}
  return {
    tag,
    props,
    children
  }
}

② mount函数的实现

第一步:根据tag,创建HTML元素,并且存储到vnode的el中。
第二步:处理props属性,如果以on开头,那么监听事件,普通属性直接通过 setAttribute 添加即可。
第三步:处理子节点,如果是字符串节点,那么直接设置textContent,如果是数组节点,那么遍历调用 mount 函数。

//实现mount挂载函数
const mount = (vnode, container) => {
  // vnode -> element
  // 1.创建出真实的原生el, 并且在vnode上保留el
  const el = vnode.el = document.createElement(vnode.tag);

  // 2.处理props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];

      if (key.startsWith("on")) { // 对事件监听的判断
        el.addEventListener(key.slice(2).toLowerCase(), value)
      } else {
        el.setAttribute(key, value);
      }
    }
  }

  // 3.处理children
  if (vnode.children) {
    if (typeof vnode.children === "string") {
      el.textContent = vnode.children;
    } else {
      vnode.children.forEach(item => {
        mount(item, el);
      })
    }
  }

  // 4.将el挂载到container上
  container.appendChild(el);
}

③ patch函数的实现 - 对比两个VNode

//实现patch方法用于对比新旧VNode
const patch = (n1, n2) => {
  //节点不相同直接替换
  if (n1.tag !== n2.tag) {
    const n1ElParent = n1.el.parentElement;
    n1ElParent.removeChild(n1.el);
    mount(n2, n1ElParent);
  } else {
    // 1.取出element对象, 并且在n2中进行保存
    const el = n2.el = n1.el;

    // 2.处理props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    // 2.1.获取所有的newProps添加到el
    for (const key in newProps) {
      const oldValue = oldProps[key];
      const newValue = newProps[key];
      if (newValue !== oldValue) {
        if (key.startsWith("on")) { // 对事件监听的判断
          el.addEventListener(key.slice(2).toLowerCase(), newValue)
        } else {
          el.setAttribute(key, newValue);
        }
      }
    }

    // 2.2.删除旧的props
    for (const key in oldProps) {
      if (key.startsWith("on")) { // 对事件监听的判断
        const value = oldProps[key];
        el.removeEventListener(key.slice(2).toLowerCase(), value)
      } 
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }

    // 3.处理children
    const oldChildren = n1.children || [];
    const newChidlren = n2.children || [];

    if (typeof newChidlren === "string") { // 情况一: newChildren本身是一个string
      // 边界情况 (edge case)
      if (typeof oldChildren === "string") {
        if (newChidlren !== oldChildren) {
          el.textContent = newChidlren
        }
      } else {
        el.innerHTML = newChidlren;
      }
    } else { // 情况二: newChildren本身是一个数组
      if (typeof oldChildren === "string") {
        el.innerHTML = "";
        newChidlren.forEach(item => {
          mount(item, el);
        })
      } else {
        // oldChildren: [v1, v2, v3, v8, v9]
        // newChildren: [v1, v5, v6]
        // 1.前面有相同节点的原生进行patch操作
        const commonLength = Math.min(oldChildren.length, newChidlren.length);
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChidlren[i]);
        }

        // 2.newChildren.length > oldChildren.length
        if (newChidlren.length > oldChildren.length) {
          newChidlren.slice(oldChildren.length).forEach(item => {
            mount(item, el);
          })
        }

        // 3.newChildren.length < oldChildren.length
        if (newChidlren.length < oldChildren.length) {
          oldChildren.slice(newChidlren.length).forEach(item => {
            el.removeChild(item.el);
          })
        }
      }
    }
  }
}

使用renderer.js

我们在index.html中使用上面的渲染系统,直接用script标签导入即可。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  
  <div id="app"></div>

  <!-- 导入渲染系统 -->
  <script src="./renderer.js"></script>
  <script>

    // 1.通过h函数来创建一个vnode
    const vnode = h('div', {class: "why", id: "aaa"}, [
      h("h2", null, "当前计数: 100"),
      h("button", {onClick: function() {}}, "+1")
    ]); // vdom

    // 2.通过mount函数, 将vnode挂载到div#app上
    mount(vnode, document.querySelector("#app"))

    // 3.创建新的vnode
    setTimeout(() => {
      const vnode1 = h('div', {class: "coderwhy", id: "aaa"}, [
        h("h2", null, "呵呵呵"),
        h("button", {onClick: function() {}}, "-1")
      ]); 
      //使用diff算法对比两个VNode
      patch(vnode, vnode1);
    }, 2000)

  </script>

</body>
</html>

响应式系统实现

比如一个数据发生了改变,那么使用该数据的所有方法都要调用一次,这就是响应式系统思想。但是如果数据改变了,我们一个一个手动调用方法会很麻烦,一般我们会把这些依赖都保存下来,等数据改变了,再将保存的方法全部调用一次就行了。

依赖收集系统

class Dep {
  constructor() {
    // 使用集合,里面的元素不能重复
    this.subscribers = new Set();
  }

  // 添加依赖
  addEffect(effect) {
    this.subscribers.add(effect);
  }

  notify() {
    this.subscribers.forEach(effect => {
      // 执行保存的函数
      effect();
    })
  }
}

const info = {counter: 100};
const dep = new Dep();

function doubleCounter() {
  console.log(info.counter * 2);
}

function powerCounter() {
  console.log(info.counter * info.counter);
}

// 手动添加依赖
dep.addEffect(doubleCounter);
dep.addEffect(powerCounter);

info.counter++;
//手动调用
dep.notify();

上面的依赖收集都是我们手动实现的,比如手动添加依赖,手动调用,比较麻烦。我们想要的目标是如果有方法使用了数据,就自动将方法添加到依赖,然后数据改变了会自动调用方法。

响应式系统Vue2实现

创建一个reactive.js,代码如下:

class Dep {
  constructor() {
    this.subscribers = new Set();
  }

  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect);
    }
  }

  notify() {
    this.subscribers.forEach(effect => {
      // 调用方法
      effect();
    })
  }
}

let activeEffect = null;
//传入一个函数,监听函数内引用的数据
function watchEffect(effect) {
  activeEffect = effect;
  effect();
  activeEffect = null;
}

// Map({key: value}): key是一个字符串
// WeakMap({key(对象): value}): key是一个对象, 弱引用
const targetMap = new WeakMap();
function getDep(target, key) {
  // 1.根据对象(target)取出对应的Map对象
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  // 2.取出具体的dep对象
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Dep();
    depsMap.set(key, dep);
  }
  return dep;
}

// Vue2对raw进行数据劫持,然后在get方法中,搜集依赖,在set方法中调用搜集的依赖
function reactive(raw) {
  Object.keys(raw).forEach(key => {
    // 在WeakMap中获取对应的依赖
     const dep = getDep(raw, key);
    let value = raw[key];

    Object.defineProperty(raw, key, {
      get() {
        // 获取数据的时候添加到依赖
        dep.depend();
        return value;
      },
      set(newValue) {
        if (value !== newValue) {
          value = newValue;
          // 设置数据的时候再调用添加的依赖
          dep.notify();
        }
      }
    })
  })

  return raw;
}

// 使用上面的reactive函数
const info = reactive({counter: 100, name: "why"});
const foo = reactive({height: 1.88});

// watchEffect1
watchEffect(function () {
  console.log("effect1:", info.counter * 2, info.name);
})

// watchEffect2
watchEffect(function () {
  console.log("effect2:", info.counter * info.counter);
})

// watchEffect3
watchEffect(function () {
  console.log("effect3:", info.counter + 10, info.name);
})

watchEffect(function () {
  console.log("effect4:", foo.height);
})

// info.counter++;
// info.name = "why";

foo.height = 2;

上面代码是Vue2响应式系统的简单实现,主要是使用reactive函数对数据进行劫持,然后在reactive的get中添加依赖,在reactive的set中调用所有的依赖,依赖维护在一个WeakMap中,这样就完成了自动添加依赖和自动调用依赖。

响应式系统Vue3实现

和Vue2相比,Vue3的数据劫持使用的是Proxy。

// Vue3对raw进行数据劫持
function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const dep = getDep(target, key);
      dep.depend();
      return target[key];
    },
    set(target, key, newValue) {
      const dep = getDep(target, key);
      target[key] = newValue;
      dep.notify();
    }
  })
}

为什么Vue3选择Proxy呢?

  1. Object.definedProperty 是劫持对象的属性,如果我们又动态新增属性了,那么Vue2需要再次调用definedProperty,而 Proxy 劫持的是整个对象,不需要做特殊处理。
  2. 修改对象的不同。使用 defineProperty 时,我们修改原来的 obj 对象就可以触发拦截,而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截。
  3. Proxy 能观察的类型比 defineProperty 更丰富。
    has:in操作符的捕获器;
    deleteProperty:delete 操作符的捕捉器;
    等等其他操作;
  4. Proxy 作为新标准将受到浏览器厂商重点持续的性能优化。
  5. 缺点:Proxy 不兼容IE,也没有 polyfill,defineProperty 能支持到IE9。

框架外层API设计

这样我们就知道了,从框架的层面来说,我们需要有两部分内容:

  • createApp用于创建一个app对象;
  • 该app对象有一个mount方法,可以将根组件挂载到某一个dom元素上;

我们创建一个index.js文件,代码如下:

function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector);
      let isMounted = false;
      let oldVNode = null;

      watchEffect(function() {
        if (!isMounted) {
          oldVNode = rootComponent.render();
          mount(oldVNode, container);
          isMounted = true;
        } else {
          const newVNode = rootComponent.render();
          patch(oldVNode, newVNode);
          oldVNode = newVNode;
        }
      })
    }
  }
}

使用Mini-Vue

创建一个index.html就可以使用Mini-Vue了,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  
  <div id="app"></div>
  <script src="../02_渲染器实现/renderer.js"></script>
  <script src="../03_响应式系统/reactive.js"></script>
  <script src="./index.js"></script>

  <script>
    // 1.创建根组件
    const App = {
      data: reactive({
        counter: 0
      }),
      render() {
        return h("div", null, [
          h("h2", null, `当前计数: ${this.data.counter}`),
          h("button", {
            onClick: () => {
              this.data.counter++
              console.log(this.data.counter);
            }
          }, "+1")
        ])
      }
    }

    // 2.挂载根组件
    const app = createApp(App);
    app.mount("#app");
  </script>

</body>
</html>

这样我们把renderer.js,reactive.js,index.js放到一个文件夹就是一个Mini-Vue了,就可以直接给其他项目使用了。

Mini-Vue源码地址:https://github.com/iamkata/Mini-Vue

源码阅读之createApp

Vue3源码地址:https://github.com/iamkata/Vue3_source

源码阅读推荐使用BOOKMARKS插件来给代码打标签。

源码阅读之挂载根组件

const app = {props: {message: String}
instance
// 1.处理props和attrs
instance.props
instance.attrs
// 2.处理slots
instance.slots
// 3.执行setup
const result = setup()
instance.setupState = proxyRefs(result);
// 4.编译template -> compile
<template> -> render函数
instance.render = Component.render = render函数
// 5.对vue2的options api进行支持
data/methods/computed/生命周期

组件化的初始化

Compile过程

BlockTree分析

生命周期回调

template中数据的使用顺序

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,110评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,443评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,474评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,881评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,902评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,698评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,418评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,332评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,796评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,968评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,110评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,792评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,455评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,003评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,130评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,348评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,047评论 2 355

推荐阅读更多精彩内容