VUE源码解读学习

111 阅读11分钟

Vue 与模板

使用步骤

  1. 编写页面模板
    • 直接在HTML标签中写标签
    • 使用template
    • 使用单文件(template/)
  2. 创建Vue的实例
    • 在Vue的构造函数中提供:data,methods,computed,watcher,props,....
  3. 将Vue挂载到页面中(mount)

数据驱动模型

Vue的执行流程

  1. 获得模板:模板中有'坑' {{ }}
  2. 利用Vue构造函数中所提供的数据来'填坑', 得到可以在页面中显示的'标签了'
  3. 将标签替换页面中原来有坑的标签

Vue利用我们提供的数据和页面中的模板生成了一个新的HTML标签(node元素),替换到了页面中放置模板的位置。

实现方式:

简单的模板渲染

虚拟 DOM

目标:

  1. 怎么将真正的DOM转化为虚拟DOM
  2. 怎么将虚拟DOM转化为真正的DOM

思路与深拷贝类似

函数科里化

概念:

  1. 科里化:一个函数原本有多个函数,之传入一个参数,生成一个新函数,由新函数接收剩下的参数来运行得到结构。
  2. 偏函数:一个函数原本有多个函数,之传入一部分参数,生成一个新函数,由新函数接收剩下的参数来运行得到结构。
  3. 高阶函数:一个函数参数是一个函数,该函数对参数这个函数进行加工,得到一个函数,这个加工用的函数就是高阶函数。

为什么要是用科里化?为了提升性能,使用科里化可以缓存一部分能力。 使用两个案例来说明:

  1. 判断元素

  2. 虚拟DOM的render方法

  3. 判断元素 Vue本质上是使用HTML的字符串作为模板的,将字符串的模板转换为AST,在转换为VNode。(AST抽象语法树)

  • 模板 -> AST
  • AST -> VNode
  • VNode -> DOM(相对不那么消耗性能)

哪一个阶段最消耗性能?

最消耗性能的是字符串解析(模板 -> AST)

例子: let s = 1 + 2 * (3 + 4 * (5 + 6));
写一个程序,解析这个表达式,得到结果(一般化)
我们一般会将这个表达式转化为"波兰式"表达式,然后使用栈结构来运算

在Vue中每一个标签可以是真正的HTML标签,也可以是自定义组件,问怎么区分???

在Vue源码中其实将所有可用的HTML标签已经存起来了

假设这里只考虑几个标签:

let tag = 'div,p,a,img,ul,li'.split(',');

需要一个函数,判断一个标签名是否为内置的标签

function isHTMLTag(tagName) {
	tagName = tagName.toLowerCase();
    if(tags.indexof(tagName) > -1) return true;
    return false;
}

模板是任意编写的,可以写的很简单,也可以写的很复杂,indexof 内部也是要循环的

如果有6种内置标签,而模板中有10个标签需要判断,那么就需要执行60次循环

  1. 虚拟DOM的render方法

思考:vue项目模板转换为抽象语法树需要执行几次???

  • 页面一开始加载需要渲染
  • 每一个属性(响应式)数据在发生变化的时候要渲染
  • watch,computed等等

我们昨天写的代码 每次需要渲染的时候,模板就会被解析一次(注意,这里我们简化了解析方法)

render的作用是将虚拟DOM转换为真正的DOM加到页面中

  • 虚拟DOM可以降级理解为AST
  • 一个项目运行的时候模板是不会变的,就表示AST是不会变的
  • 我们可以将代码进行优化, 将虚拟DOM缓存起来,生成一个函数,函数只需要传入数据,就可以得到一个真正的DOM

问题

问题:

  • 没明白科里化怎么就只要循环一次
    • 缓存一部分行为

响应式原理

  • 我们在使用Vue的时候, 赋值属性获得属性都是直接使用的Vue实例
  • 我们在设计属性值的时候,页面的数据更新
Object.defineProperty(对象, '设置什么属性名', {
	writeable,
        configurable,
        enumerable:控制属性是否可以被for...in...取出来,
        set() {} 赋值触发,
        get() {} 取值触发,
})
 // 简化后的版本
    function defineReactive(target, key, value, enumerable) {
      // 函数内部就是一个局部作用域,这个value就只在函数内使用的变量(闭包)
      Object.defineProperty(target, key, {
        configurable: true,
        enumerable: !!enumerable,
        get() {
          console.log(`设置 o 的 ${key} 属性`);
          return value;
        },
        set(newVal) {
          console.log(`设置 o 的 ${key} 属性为: ${newVal}`)
          value = newVal;
        }
      })
    }

实际开发中对象一般有多级

let o = {
	list: [{}],
    ads: [{}],
    user: {}
}

怎么处理呢?递归

对于对象可以使用 递归来响应式化,但是数组我们也需要处理

  • push
  • pop
  • shift
  • unshift
  • reverse
  • sort
  • splice

要做什么事情?

  1. 在改变数组的数据的时候,要发出通知
  • Vue 2中的缺陷,数组发生变化,设置length没法通知(Vue 3 中使用 Proxy 语法 ES6 的语法解决了这个问题)
  1. 加入的元素应该变成响应式的

技巧:如果一个函数已经定义了,但是我们需要扩展其功能,我们一般的处理办法:

  1. 使用一个临时的函数名存储函数
  2. 重新定义原来的函数
  3. 定义扩展的功能
  4. 调用临时的那个函数

扩展数组的 push 和 pop 怎么处理呢???

  • 直接修改 prototype 不行
  • 修改要进行响应式化的数组的原型 (proto)

已经将对象改成响应式的了,但是如果直接给对象赋值,赋值另一个对象,那么就不是响应式的了,怎么办?

发布订阅模式

  • 代理方法(app.name, app._data.name)
  • 事件模型(node: event 模块)
  • vue 中 Observer 与 Watcher 和 Dep

代理方法,就是要将app._data中的成员给映射到app上

由于需要在更新数据的时候,更新页面的内容

所以 app._data 访问的成员与app访问的成员应该是同一个成员

由于app._data 已经是响应式的对象了,所以只需要让app访问的成员去访问app._data的对应的成员就可以了。

例如:

app.name 转换为app._data.name
app.xxx 转换为app._data.xxx

引入一个函数proxy(target, src, prop), 将target的操作映射到src.prop上

这里是因为当时没有Proxy语法(ES6)

我们之前处理的rectify方法已经不行了,我们需要一个新的方法来处理

提供一个Observer的方法,在方法中对属性进行处理
可以将这个方法封装到initData方法中

解释 proxy

app._data.name
// vue 设计,不希望访问 _开头的数据
// vue 中有一个潜规则:
//  - _开头的数据时私有数据
//  - $开头的是只读数据
app.name
// 将 _data.xxx 的访问 交给了 实例

// 重点:访问app的xxx就是再访问app._data.xxx

假设:

var o1 = {name: '张三'};
// 要有一个对象 o2,在访问o2.name的时候想要访问的是o1.name
Object.defineProperty(o2, 'name', {
	get() {
    	return o1.name
    }
}

访问 app的xxx就是在访问app._data.xxx

Object.defineProperty(app, 'name', {
	get() {
    	return app._data.name
    },
	set(newVal) {
    	app._data.name = newVal;
    }
})

将属性的操作转换为参数

function proxy(app, key) {
	Object.defineProperty(app, key, {
    	get() {
        	return app._data[key]
        },
        set(newVal) {
        	app._data[key] = newVal;
        }
    })
}

问题:
在vue中不仅仅是只有data属性,properties等等都会挂载到Vue实例上

function proxy(app, prop, key) {
	Object.defineProperty(app, key, {
    	get() {
        	return app[prop][key]
        },
        set(newVal) {
        	app[prop][key] = newVal;
        }
    })
}
# 如果将_data的成员映射到实例上
proxy(实例, '_data', 属性名)
# 如果要 _properties 的成员映射到实例上
proxy(实例, '_properties', 属性名)

发布订阅模式

目标:解耦,让各个模块之间没有紧密的联系

现在的处理办法是 属性在更新的 时候 调用 mountComponent 方法

问题:mountComponent 更新的是什么???(现在) 全部的页面 -> 当前虚拟DOM对应的页面DOM

在 Vue 中,整个的更新是按照组件为单位进行 判断,以节点为单位进行更新

  • 如果代码中没有自定义组件,那么在比较算法的时候,我们呢会将全部的模板对应的虚拟DOM进行比较。
  • 如果代码中含有自定义组件,那么在比较算法的时候,就会判断更新的是哪一些组件中的属性,指回判断更新数据的组件,其他组件不会更新。

复杂的页面是有很多组件构成。每一个属性要更新的时候都要调用更新的方法

目标,如果修改了什么属性,就尽可能只更新这些属性对应的页面DOM

这样就一定不能将更新的代码写死

例子: 预售可能一个东西没有现货,告诉老板,如果东西到了就告诉我

老板就是发布者
订阅什么东西作为中间媒介
我就是订阅者

使用代码的结构来描述:

  1. 老板提供一个 账簿(数组)
  2. 我可以根据需求订阅我的商品(老板要记录下谁定了什么东西,在数组中存储某些东西)
  3. 等待,可以做其他的事情
  4. 当货品来到的时候,老板就查看账簿,挨个的打电话(遍历数组,取出数组的元素来使用)

实际上就是事件模型

  1. 有一个 event 对象
  2. on, off, emit 方法

实现事件模型,思考怎么用?

  1. event 是一个全局对象
  2. event.on('事件名', 处理函数),订阅事件
    • 事件可以连续订阅
    • 可以移除
      • 移除所有
      • 移除某一个类型的事件
      • 移除某一个类型的某一个处理函数
  3. 写别的代码
  4. evnet.emit('事件名', 参数),先前注册的事件处理函数就会依次调用

原因:

  1. 描述发布订阅模式
  2. 后面会实用到事件

发布订阅模式(形式不局限于函数,形式可以是对象等):

  1. 中间的全局的容器,用来存储可以被触发的东西(函数,对象)
  2. 需要一个方法,可以往容器中传入东西(函数,对象)
  3. 需要一个方法,可以将容器中的东西取出来使用(函数调用,对象的方法调用)

Vue 模型

页面中的变更 (diff) 是以组件为单位

  • 如果页面中只有一个组件(Vue实例), 不会有性能损失
  • 但是如果页面中有多个组件(多watcher的一种情况),第一次会有多个组件的watcher存入到全局watcher中。
    • 如果修改了局部的数据(例如其中一个组件的数据)
    • 表示只会对该组件进行diff算法,也就是说只会重新生成该组件的抽象语法树
    • 只会访问该组建的 watcher
    • 也就表示再次往全局存储的只有该组件的watcher
    • 页面更新的时候也就只需要更新一部分

改写 observe 函数

缺陷:

  • 无法处理数组
  • 响应式无法在中间集成 Watcher 处理
  • 我们实现的 rectify 需要和实例紧紧的绑定在一起, 分离(解耦)

问题

  • observe 还没有对单独的数组元素做处理吧

引入watcher

问题:

  • 模型(图)
  • 关于this的问题

实现:

分成两步:

  1. 只考虑修改后刷新(响应式)
  2. 再考虑依赖收集(优化)

在Vue中提供一个构造函数Watcher
Watcher会有以下方法:

  • get()用来进行计算执行处理函数
  • updata() 公共的外部方法,该方法会触发内部的run方法
  • run() 运行,用来判断内部是使用异步运行还是同步运行等,这个方法最终会调用内部的get方法
  • celanupDep() 简答理解为清除队列

我们的页面渲染是上面的哪一个方法执行的呢???get方法

我们的watcher实例有一个属性vm,表示的就是当前的vue实例

引入Dep对象

该对象提供 依赖(depend)的功能,和派发更新(notify)的功能
在notify中去调用watcher的update方法

Watcher 与 Dep

之前将渲染 Watcher放在全局作用域上,这样处理是有问题的

  • vue项目中包含很多的组件,各个组件是自治
    • 那么watcher就可能会有多个
    • 每一个watcher用于描述一个渲染行为或计算行为
      • 子组件发生数据的更新,页面需要重新渲染(真正的vue中是局部渲染)
      • 例如vue中推荐是使用计算属性代替复杂的插值表达式
        • 计算属性是会伴随其使用的属性的变化而变化的
        • name: () => this.first + this.lastName
          • 计算属性依赖于属性firstName和属性lastName
          • 只要被以来的属性发生变化,那么就会促使计算属性重新计算(Watcher)
  • 依赖收集与派发更新是怎么运行起来的

我们在访问的时候就会进行收集,在修改的时候就会更新,那么收集到什么就更新什么

所谓的依赖收集 实际上就是告诉当前的watcher什么属性被访问了
那么在这个watcher计算的时候或渲染页面的时候,就会将这些收集到的属性进行更新

如何将属性与当前watcher关联起来???

  • 在全局准备一个tagetStack(watcher 栈,简单的理解为watcher"数组",把一个操作中需要使用的watcher都存储起来)
  • 在Watcher调用get方法的时候,将当前Watcher放到全局,在get执行结果的时候(之后),将这个全局watcher移除。提供:pushTarget,popTarget
  • 在每一个属性中都有一个Dep对象

Dep的subs中存储的是知道要渲染什么属性的watcher

Dep与watcher有互相关联关系

梳理Watcher与Dep与属性的关系

假设: 有三个属性name,age,gender,页面将三个属性渲染出来

Observer 对象

vue源码解读

  1. 各个文件夹的作用
  2. Vue的初始化流程

各个文件夹的作用

  1. compiler 编译用的
    • vue 使用字符串作为模板
    • 在编译文件夹中存放对模板字符串的解析的算法,抽象语法树,优化等
  2. core核心,vue构造函数,以及生命周期等方法的部分
  3. platforms平台
    • 针对运行的环境(设备),有不同的实现
    • 也是vue的入口
  4. server服务端,主要是将vue用在服务端的处理代码(略)
  5. sfc,单文件组件(略)
  6. shared公用工具,方法

主要内容

  1. vue源码
    1. Observer
    2. watch 和 computed
    3. 简单的说明一下patch

observer 文件夹中各个文件的作用

  • array.js 创建含有重写 数组方法的数组,让所有的响应式数据数组继承自该数组
  • dep.js Dep类
  • index.js Observer类,observe 的工厂函数
  • scheduler.js vue 当中的任务调度的工具, watcher执行的核心
  • traverse.js 递归遍历响应式数据,目的是触发依赖收集。
  • watcher.js Watcher类

面试题:对数组去重

let arr = [1, 1, 1, 2, 2, 3, 3, 3]; // => [1, 2, 3]

// 一般的做法
// let newarr = [];
arr.forEach(v => newarr.indexOf(v) === -1 && newarr.push(v)) // indexOf 原本隐含着循环

// 利用 集合 来简化实现 ( ES6 Set )
let _set = {};
let _newarr = [];
arr.forEach( v => _set[v] || (_set[v] = true, _newarr.push(v))) // 减少赋值行为
// Object.keys( _set ) // 获得去重后的数组

// { 1: true }
// { 1: true, 2: true }

// 在网络中有一个终极的算法, 就是如何 "判同"