Vue3源码解析(一)

381 阅读6分钟

框架设计概述

前言

作为学习者,我们在学习框架的时候,很容易被细节困住,看不清全貌。所以我们要先从全局的角度对框架的设计拥有清晰的认知。

这篇文章作为学习vue3源码细节的前篇,让大家对vue3有个全面大体的认识

声明式与命令式

从范式的角度来看,框架应该设计成命令式还是声明式的、要设计成纯运行时还是纯编译型的、甚至是运行时+编译时的呢

我们先举一个现实中的例子:

张三的妈妈让张三去买酱油

张三需要这么做:

  1. 拿起钱
  2. 打开门
  3. 到商店
  4. 拿钱买酱油
  5. 回到家

这里张三妈妈的行为就是声明式,张三就是命令式

我们再用一个编程的例子来理解声明式与命令式,需求:

1. 获取 id 为 app 的 div 标签
2. 它的文本内容为 hello world
3. 为其绑定点击事件
4. 当点击时弹出提示:ok

命令式:看重过程,指示编译器每一步该怎么做,最终实现结果

const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件

声明式:看重结果,将结果告诉编译器,编译器帮你完成过程

<div @click="() => alert('ok')">hello world</div>

两者差异:对于用户来说,声明式更快捷方便,可维护性更强,命令式不方便快捷,但性能更好

举例:

假设现在我们要将 div 标签的文本内容修改为 hello vue3

命令式:

div.textContent = 'hello vue3' // 直接修改需要改动的地方

声明式:

01 <!-- 之前: -->
02 <div @click="() => alert('ok')">hello world</div>
03 <!-- 之后: -->
04 <div @click="() => alert('ok')">hello vue3</div>  // 需要全部重新渲染

这里我们可以发现两者主要的区别是声明式无论修改了什么,整条语句都会重新执行

所以为了实现最优的更新性能,声明式需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:

div.textContent = 'hello vue3' // 直接修改需要改动的地方

这也是vue3相较于vue2做出的调整「diff算法的优化」

运行时与编译时

我们用一个例子来理解声明式与命令式

纯运行时:

我们设计一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。我们规定树型结构的数据对象如下:

const obj = {
  tag: 'div',
  children: [
    { tag: 'span', children: 'hello world' }
  ]
}

Render函数:

function Render(obj, root) {
  const el = document.createElement(obj.tag)
  if (typeof obj.children === 'string') {
    const text = document.createTextNode(obj.children)
    el.appendChild(text)
  } else if (obj.children) {
    // 数组,递归调用 Render,使用 el 作为 root 参数
    obj.children.forEach((child) => Render(child, el))
  }
​
  // 将元素添加到 root
  root.appendChild(el)
}

用户可以这样使用:

const obj = {
  tag: 'div',
  children: [
    { tag: 'span', children: 'hello world' }
  ]
}
// 渲染到 body 下
Render(obj, document.body)

这就是纯运行时框架:让用户生成树型结构对象,框架提供reader函数将对象渲染成页面,最重要的是等代码执行的时候,也就是render函数执行的时候才会生成html标签。

缺点:是html对象需要用户提供,并且不能进行差别更新

运行时 + 编译时:

于是有一天,你的用户抱怨说:“手写树型结构的数据对象太麻烦了,而且不直观,能不能支持用类似于HTML 标签的方式描述树型结构的数据对象

为此,你编写了一个叫作 Compiler 的程序,它的作用就是把 HTML 字符串编译成树型结构的数据对象

用户可以这么用:

const html = `
<div>
  <span>hello world</span>
</div>
`
// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用 Render 进行渲染
Render(obj, document.body)

这就是运行时+编译时: 框架提供Compiler函数「将html标签生成html对象」和Render对象,这样我们可以根据html标签的不同,进行差别更新html对象,不需要全部更新。用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。

Vue采用的就是这种方式

纯编译时:

简单来说就是将HTML字符串直接编译成命令时代码

vue3纯编译时.png

优缺点:直接编译成可执行的 JavaScript 代码,因此性能会更好,但有损灵活性,即用户提供的内容必须编译后才能用

Tree Shaking

消除那些永远不会执行的代码

vue.js对于那些永远不会用到的代码,在打包的时候会将它清除,减少包的体积

编译器与渲染器

编译器

作用:将模版(template标签)编译成虚拟DOM,交给渲染器,在script标签中,所有在vue中使用模版或者虚拟DOM对象编写页面最后的结果是一样

举例:

<template>
  <div @click="handler">
    click me
  </div>
</template><script>
export default {
  data() {/* ... */},
  methods: {
    handler: () => {/* ... */}
  }
}
</script>

其中<template> 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script>标签块的组件对象上,所以最终在浏览器里运行的代码就是:

01 export default {
02   data() {/* ... */},
03   methods: {
04     handler: () => {/* ... */}
05   },
06   render() {
07     return h('div', { onClick: handler }, 'click me')
08   }
09 }

如果你学过react,你会觉得编译后的代码很react很相似。

所以,无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM

渲染器

渲染器(reader函数)将虚拟DOM渲染成真实DOM

01 function renderer(vnode, container) {
02   // 使用 vnode.tag 作为标签名称创建 DOM 元素
03   const el = document.createElement(vnode.tag)
04   // 遍历 vnode.props,将属性、事件添加到 DOM 元素
05   for (const key in vnode.props) {
06     if (/^on/.test(key)) {
07       // 如果 key 以 on 开头,说明它是事件
08       el.addEventListener(
09         key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
10         vnode.props[key] // 事件处理函数
11       )
12     }
13   }
14
15   // 处理 children
16   if (typeof vnode.children === 'string') {
17     // 如果 children 是字符串,说明它是元素的文本子节点
18     el.appendChild(document.createTextNode(vnode.children))
19   } else if (Array.isArray(vnode.children)) {
20     // 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
21     vnode.children.forEach(child => renderer(child, el))
22   }
23
24   // 将元素添加到挂载点下
25   container.appendChild(el)
26 }

编译器和渲染器本质都是函数

如前所述,组件的实现依赖于渲染器,模板的编译依赖于编译器

总结

  1. 声明式:Vue.js 是一个声明式的框架,好处在于,它直接描述结果,用户不需要关注过程。vue.js同样支持使用虚拟DOM来描述UI。

    1. 运行时+编译时:vue.js采 用 运行时+编译时的方式 来降低用户的心智负担,并且保证框架的性能
  2. TreeShaking: vue.js对于那些永远不会用到的代码,在打包的时候会将它清除,减少包的体积

  3. 编译器:vue.js的模版会被编译器编译成渲染函数

  4. 渲染器:将虚拟 DOM 对象渲染为真实 DOM 元素