Vue全家桶源码系列

手写Vue核心原理

使用 Rollup搭建开发环境

安装依赖

  1. npm install @babel/preset-env @babel/core rollup rollup-plugin-babel rollup-plugin-serve cross-env -D

配置 rollup.config.js

  1. import babel from 'rollup-plugin-babel';
  2. import serve from 'rollup-plugin-serve';
  3. export default {
  4. input: './src/index.js',
  5. output: {
  6. format: 'umd', // 模块化类型
  7. file: 'dist/umd/vue.js',
  8. name: 'Vue', // 打包后的全局变量的名字
  9. sourcemap: true
  10. },
  11. plugins: [
  12. babel({
  13. exclude: 'node_modules/**'
  14. }),
  15. process.env.ENV === 'development'?serve({
  16. open: true,
  17. openPage: '/public/index.html',
  18. port: 3000,
  19. contentBase: ''
  20. }):null
  21. ]
  22. }

.babelrc文件

  1. {
  2. "presets": [
  3. "@babel/preset-env"
  4. ]
  5. }

执行脚本

  1. "scripts": {
  2. "build:dev": "rollup -c",
  3. "serve": "cross-env ENV=development rollup -c -w"
  4. }

数据劫持

Vue会对我们在data中传入的数据进行拦截:

  • 对象: 递归的为对象的每个属性都设置 get /set 方法。
  • 数组: 修改数组的原型方法,对于会修改原数组的方法进行了重写。

在用户为 data 中的对象设置值 修改值以及调用修改原数组的方法时,都可以添加一些逻辑来进行处理,实现数据更新页面也同时更新。

Vue中的响应式(reactive):对对象属性或数组方法进行了拦截,在属性或者数组更新时可以同时自动地更新视图。在代码中被观测过的数据具有响应性。

数据劫持存在的问题

  • 对于对象,我们只是拦截了它的取值和赋值操作,添加值和删除值并不会进行拦截
  • 对于数组,用索引修改值以及修改数组长度不会被观测 $set $delete

    文本编译

    在完成对Vue中data数据响应式处理后,需要将html字符串编译为 render函数,其核心逻辑如下:
    有render函数的情况下会直接使用传入的render函数,而在没有render函数的情况下,需要将template编译为render函数。其具体逻辑如下:
  1. 获取template字符串
  2. 将template字符串解析为ast抽象语法树
  3. 将ast抽象语法树生成代码字符串
  4. 将字符串处理为render函数赋值给vm.$options.render

    获取template字符串

    image.png

    解析html

    当拿到对应的html字符串后,需要通过正则来将其解析为ast抽象语法树。简单来说就是将html处理为一个树形结构,可以很好的表示每个节点的父子关系。

    1. <body>
    2. <div id="app">
    3. hh
    4. <div id="aa" style="font-size: 18px;">hello {{name}} world</div>
    5. </div>
    6. <script>
    7. const vm = new Vue({
    8. el: '#app',
    9. data () {
    10. return {
    11. name: 'zs',
    12. };
    13. },
    14. });
    15. </script>
    16. </body>

    image.png

    1. const ast = {
    2. tag: 'div', // 标签名
    3. attrs: [{ name: 'id', value: 'app' }], // 属性数组
    4. type: 1, // type:1 是元素,type: 3 是文本
    5. parent: null, // 父节点
    6. children: [] // 孩子节点
    7. }

    html 的解析逻辑如下:

  5. 通过正则匹配开始标签的开始符号、匹配标签的属性、匹配开始标签结束符号、匹配文本、匹配结束标签

  6. while循环html字符串,每次删除掉已经匹配的字符串,直到html为空字符串时,说明整个文本匹配完成
  7. 通过栈数据结构来记录所有正在处理的标签,并且根据标签的入栈出栈顺序生成树结构

image.png 通过对象的引用关系,最终便能得到一个树形结构对象root。
最后来处理结束标签。
匹配到结束标签时要将stack中最后一个元素出栈,更新currentParent,直到stack中的元素为空时。

生成代码字符串

  1. const code = `_c("div",{id:"app"},_v("hh"),_c("div"),{id:"aa",style:{color: "red"}},_v("hello"+_s(name)+"world"))`

生成render 函数

  1. const render = new Function(`with(this){return ${code}}`)

组件渲染

在Vue进行文本编译之后,会得到代码字符串生成的render函数。

  • 执行render函数生成虚拟节点
  • 通过vm._update方法,将虚拟节点渲染为真实DOM

    生成虚拟节点

    1. const vNode = vm.createVElement('div', { id: 'app' },
    2. vm.createVElement('span', undefined,
    3. vm.createTextVNode('hello') + vm.createTextVNode('world') + vm.stringify(vm.name)
    4. )
    5. )

    image.png

    将虚拟节点处理为真实的节点

    createElement中是用虚拟节点生成真实节点的逻辑:

  • 通过document.createElement来创建元素节点

  • 元素节点通过updateProperties方法来设置它的属性
  • 通过document.createTextNode来创建文本节点

image.png

总结

Vue的组件挂载vm.$mount(el)过程如下:

  1. 将template编译为render函数
  2. 使用render函数生成虚拟节点,函数中需要的变量和方法会去vm的自身和原型链中查找
  3. 将虚拟节点创建为真实节点,并递归的插入到页面中
  4. 使用真实节点替换之前老的节点

image.png

生命周期

Vue为用户提供了许多生命周期钩子函数,可以让用户在组件运行的不同阶段书写自己的逻辑。

Vue.mixin

Vue.mixin是Vue的全局混合器,它影响Vue创建的每一个实例,会将mixin 中传入的配置项与组件实例化时的配置项按照一定规则进行合并。对于生命周期钩子函数,相同名字的生命周期将会合并到一个数组中,混合器中的钩子函数将会先于组件中的钩子函数放入到数组中。在特定时机时,从左到右执行数组中的每一个钩子函数。

  1. <div id="app">
  2. </div>
  3. <script>
  4. // 生命周期:
  5. Vue.mixin({
  6. created () {
  7. console.log('global created');
  8. }
  9. });
  10. const vm = new Vue({
  11. el: '#app',
  12. data () {
  13. },
  14. created () {
  15. console.log('component created');
  16. }
  17. });
  18. // global created
  19. // component created
  20. </script>

生命周期选项合并

对于生命周期,我们会将每个钩子函数都通过mergeHook合并为一个数组:

  1. function mergeHook (parentVal, childVal) {
  2. if (parentVal) {
  3. if (childVal) {
  4. // concat可以拼接值和数组,但是相对于push来说,会返回拼接后新数组,不会改变原数组
  5. return parentVal.concat(childVal);
  6. }
  7. return parentVal;
  8. } else {
  9. return [childVal];
  10. }
  11. }

image.png

调用生命周期函数

完成上述代码后,我们已经成功将所有合并后的生命周期放到了vm.$options中对应的生命周期数组中:

  1. vm.$options = {
  2. created: [f1, f2, f3],
  3. mounted: [f4, f5, f6]
  4. // ...
  5. }
  6. // 调用
  7. export function callHook (vm, hook) {
  8. const handlers = vm.$options[hook];
  9. if (handlers) {
  10. handlers.forEach(handler => handler.call(vm));
  11. }
  12. }

总结

生命周期函数本质上就是我们在配置项中传入回调函数,Vue会将我们传入的配置项收集到数组中,然后在特定时机统一执行。
Vue的生命周期从定义到执行一共经历了如下几个步骤:

  1. 在组件实例化时作为选项传入
  2. 首先将Vue.mixin中传入的配置项和Vue.options中的生命周期函数合并为一个数组
  3. 将组件实例化时传入的选项和Vue.options中的生命周期继续进行合并
  4. 封装callHook函数,从vm.$options中找到指定生命周期函数对应的数组
  5. 在特定时机执行特定的生命周期函数

    依赖收集

    思路梳理

    image.png用文字描述的话,其流程如下:

  6. 组挂载,执行render方法生成虚拟DOM。此时在模板中用到的数据,会从vm实例上进行取值

  7. 取值会触发data选项中定义属性的get方法
  8. get方法会将渲染页面的watcher作为依赖收集到dep中
  9. 当修改模板中用到的data中定义的属性时,会通知dep中收集的watcher执行update方法来更新视图
  10. 重新利用最新的数据来执行render方法生成虚拟DOM。此时不会再收集重复的渲染watcher

    渲染watcher就是用来更新视图的watcher,具体的执行过程在组件初渲染中有详细介绍,它的主要作用如下:
    1. 执行vm._render方法生成虚拟节点
    2. 执行vm._update方法将虚拟节点处理为真实节点挂载到页面中

需要注意的是,数组并没有为每个索引添加set/get方法,而是重写了数组的原型。所以当通过调用原型方法修改数组时,会通知watcher来更新视图,保证页面更新。

Dep

收集watcher并且在数据更新后通知watcher更新DOM的功能主要是通过Dep来实现的。
Dep会将watcher收集到内部数组subs中,之后通过notify方法进行统一执行。
目前代码并没有用到栈,在之后实现计算属性时,会利用栈中存储的渲染watcher来更新视图

Watcher

Watcher的主要功能:

  • 收集dep,用于之后实现computed的更新
  • 通过get方法来更新视图

Watcher接收的参数如下:

  • vm: Vue组件实例
  • exprOrFn: 表达式或者函数
  • cb: 回调函数
  • options: 执行watcher的一些选项

image.png

依赖收集

依赖收集时分别对对象和数组进行了不同的操作:
取值时:

  • 对象:在对象每一个属性的get方法中,利用属性对应的dep来收集当前正在执行的watcher
  • 数组:在Observer中,为所有data中的对象和数组都添加了ob属性,可以获取Observer实例。并且为Observer实例设置了dep 属性,可以直接通过array.ob.depend()来收集依赖。

设置值时:

  • 对象:通过被修改属性的set方法,调用dep.notify来执行收集的watcher的update方法
  • 数组:通过调用数组方法来修改数组,在对应的数组方法更新完数组后,还会执行数组对应的array.ob.notify来通知视图更新

    $set 和 $delete

    对于数组,其实只是调用了splice方法进行元素的添加和删除。
    如果是对象,$set方法会通过defineReactive为对象新增属性,并保证属性具有响应性,而$delete 会帮用户将对象中的对应属性删除。最终,$set和$delete都会利用之前在Observer中设置的dep属性通知视图更新

    总结

    依赖收集的核心其实就是:

  • 获取数据的值时将视图更新函数放到一个数组中

  • 设置数据的值时依次执行数组中的函数来更新视图

    异步更新

    Vue在数据修改后,并没有直接更新视图,而是将视图更新的方法放到异步任务中执行。

    收集去重后的watcher进行更新

    依赖收集的相关知识:

  • 页面首次挂载,会从vm实例上获取data中的值,从而调用属性的get方法来收集watcher

  • 当vm实例上的属性更新它的值时,会执行收集到的watcher的update方法

    实现nextTick方法

  • Promsie.resolve().then()

  • MutationObserver
  • setImmediate
  • setTimeout

image.png

实现Watch属性

watch对象中的value分别支持函数、数组、字符串、对象,较为常用的是函数的方式,当想要观察一个对象以及对象中的每一个属性的变化时,便会用到对象的方式。

初始化watch

image.png
initWatch 中的本质上是 为每一个 watch 中的属性对应的回调创建了一个watcher

deep immdediate 属性

DOM Diff

Vue创建视图分为俩种情况:

  1. 首次渲染,会用组件template转换成的真实DOM来替换应用中的根元素
  2. 当数据更新后,视图重新渲染,此时并不会重新通过组件template对应的虚拟节点来创建真实DOM,而是会用老的虚拟节点和新的虚拟节点进行比对,根据比对结果来更新DOM

Vue React 都是用于网页开发,基于 DOM 结构,对 diff 算法都进行了优化(或者简化)

  1. 只在同一层级比较,不夸层级 (DOM 结构的变化,很少有跨层级移动)
  2. tag` 不同则直接删掉重建,不去对比内部细节(DOM 结构变化,很少有只改外层,不改内层)
  3. 同一个节点下的子节点,通过 key 区分

    整体思路

    老的虚拟节点和新的虚拟节点是俩棵树,会对两棵树每层的虚拟节点进行对比操作。
    image.png
    在每一层进行对比时,会分别为老节点和新节点设置头尾指针:
    image.png
    整体的孩子节点比对思路如下:
  • 在老的虚拟节点和新的虚拟节点的头尾指针之间都有元素时进行遍历
  • 对以下情况进行优化
    • 老节点的头指针和新节点的头指针相同
    • 老节点的尾指针和新节点的尾指针相同
    • 老节点的头指针和新节点的尾指针相同
    • 老节点的尾指针和新节点的头指针相同
    • 乱序排列时,要用新节点的头节点到老节点中查找,如果能找到,对其复用并移动到相应的位置。如果没有找到,将其插入到真实节点中
    • 遍历完成后,将新节点头指针和尾指针之间的元素插入到真实节点中,老节点头指针和尾指针之间的元素删除
  • 在我们渲染视图之前,需要保存当前渲染的虚拟节点。在下一次渲染视图时,它就是老的虚拟节点,要和新的虚拟节点进行对比

    处理简单情况

  1. 如果新的虚拟节点和老的虚拟节点标签不一样,直接用新的虚拟节点创建真实节点,然后替换老的真实节点即可
  2. 如果老节点和新节点都是文本标签,那么直接用新节点的文本替换老节点即可:
  3. 当老节点和新节点的标签相同时,要更新标签对应真实元素的属性,更新规则如下:
  • 用新节点中的属性替换老节点中的属性
  • 删除老节点中多余的属性
  • 在比对完当前节点后,要继续比对孩子节点。孩子节点可能有以下情况:
  1. 老节点孩子为空,新节点有孩子:将新节点的每一个孩子节点创建为真实节点,插入到老节点对应的真实父节点中
  2. 老节点有孩子,新节点孩子为空:将老节点的父节点的孩子节点清空
  3. 老节点和新节点都有孩子: 采用双指针进行对比

    计算属性

组件渲染