为什么要学习原理部分的内容?
响应式原理
数据驱动
在学习 Vue.js 的过程中,我们经常看到三个概念:
- 数据驱动
- 数据响应式
- 双向数据绑定
响应式的核心原理
Vue 2.x 版本与 Vue 3.x 版本的响应式实现有所不同,我们将分别讲解。
- Vue 2.x 响应式基于 ES5 的 Object.defineProperty 实现。
-
设置 data 后,遍历所有属性,转换为 Getter、Setter,从而在数据变化时进行视图更新等操作。
Object.defineProperty(obj, 'gender', { value: '男', // 设置值 writable: true, // 是否可写 enumerable: true, // 是否可遍历 configurable: true // 是否可以进行后续的配置 })var genderValue = '男' Object.defineProperty(obj, 'gender', { get () { console.log('任意获取时需要的自定义操作') return genderValue }, set (newValue) { console.log('任意设置时需要的自定义操作') genderValue = newValue } });vue 2 响应式原理:
<div id="app">原始内容</div> <script> // 声明数据对象,模拟 Vue 实例的 data 属性 let data = { msg: 'hello' } // 模拟 Vue 实例的对象 let vm = {} // 通过数据劫持的方式,将 data 的属性设置为 getter/setter Object.defineProperty(vm, 'msg', { // 可遍历 enumerable: true, // 可配置 configurable: true, get () { console.log('访问了属性') return data.msg }, set (newValue) { // 更新数据 data.msg = newValue // 数据更改,更新视图中 DOM 元素的内容 document.querySelector('#app').textContent = data.msg } }); </script> -
上述版本只是雏形,问题如下:
- 操作中只监听了一个属性,多个属性无法处理
- 无法监听数组变化(Vue 中同样存在)
- 无法处理属性也为对象的情况
- 下面我们来进行改进
<div id="app">原始内容</div> <script> // 声明数据对象,模拟 Vue 实例的 data 属性 let data = { msg1: 'hello', msg2: 'world', arr: [1, 2, 3], obj: { name: 'jack', age: 18 } } // 模拟 Vue 实例的对象 let vm = {} // 封装为函数,用于对数据进行响应式处理 const createReactive = (function () { // --- 添加数组方法支持 --- const arrMethodName = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'] // 用于存储处理结果的对象,准备替换掉数组实例的原型指针 __proto__ const customProto = {} // 为了避免数组实例无法再使用其他的数组方法 customProto.__proto__ = Array.prototype arrMethodName.forEach(method => { customProto[method] = function () { // 确保原始功能可以使用(this 为数组实例) const result = Array.prototype[method].apply(this, arguments) // 进行其他自定义功能设置,例如,更新视图 document.querySelector('#app').textContent = this return result } }) // 需要进行数据劫持的主体功能,也是递归时需要的功能 return function (data, vm) { // 遍历被劫持对象的所有属性 Object.keys(data).forEach(key => { // 检测是否为数组 if (Array.isArray(data[key])) { // 将当前数组实例的 __proto__ 更换为 customProto 即可 data[key].__proto__ = customProto } else if (typeof data[key] === 'object' && data[key] !== null) { // 检测是否为对象,如果为对象,进行递归操作 vm[key] = {} createReactive(data[key], vm[key]) return } // 通过数据劫持的方式,将 data 的属性设置为 getter/setter Object.defineProperty(vm, key, { enumerable: true, configurable: true, get () { console.log('访问了属性') return data[key] }, set (newValue) { // 更新数据 data[key] = newValue // 数据更改,更新视图中 DOM 元素的内容 document.querySelector('#app').textContent = data[key] } }) }) } })() createReactive(data, vm); </script>
-
- Vue 3.x 响应式基于 ES6 的 Proxy 实现。
- Proxy 回顾
const p = new Proxy(target, handler)
// target:要使用 `Proxy` 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
// handler: 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 `p` 的行为。
<script>
const data = {
msg1: '内容',
arr: [1, 2, 3],
obj: {
name: 'william',
age: 18
}
}
const p = new Proxy(data, {
get (target, property, receiver) {
console.log(target, property, receiver)
return target[property]
},
set (target, property, value, receiver) {
console.log(target, property, value, receiver)
target[property] = value
}
});
</script>
- vue3响应式原理
<div id="app">原始内容</div>
<script>
const data = {
msg: 'hello',
content: 'world',
arr: [1, 2, 3],
obj: {
name: 'william',
age: 18
}
}
const vm = new Proxy(data, {
get (target, key) {
return target[key]
},
set (target, key, newValue) {
// 数据更新
target[key] = newValue
// 视图更新
document.querySelector('#app').textContent = target[key]
}
});
</script>
相关设计模式
设计模式(design pattern)是针对软件设计中普遍存在的各种问题所提出的解决方案。
观察者模式
观察者模式(Observer pattern)指的是在对象间定义一个一对多(被观察者与多个观察者)的关联,当一个对象改变了状态,所有其他相关的对象会被通知并且自动刷新。
核心概念:
- 观察者 Observer
- 被观察者(观察目标)Subject
<script>
// 被观察者 (观察目标)
// 1 添加观察者
// 2 通知所有观察者
class Subject {
constructor () {
// 存储所有的观察者
this.observers = []
}
// 添加观察者功能
addObserver (observer) {
// 检测传入的参数是否为 观察者 实例
if (observer && observer.update) {
this.observers.push(observer)
}
}
// 通知所有观察者
notify () {
// 调用观察者列表中每个观察者的更新方法
this.observers.forEach(observer => {
observer.update()
})
}
}
// 观察者
// 1 当观察目标发生状态变化时,进行"更新"
class Observer {
update () {
console.log('事件发生了,进行相应的处理...')
}
}
// 功能测试
const subject = new Subject()
const ob1 = new Observer()
const ob2 = new Observer()
// 将观察者添加给要观察的观察目标
subject.addObserver(ob1)
subject.addObserver(ob2)
// 通知观察者进行操作(某些具体的场景下)
subject.notify();
</script>
发布-订阅模式
发布-订阅模式(Publish-subscribe pattern)可认为是为观察者模式解耦的进阶版本,特点如下:
- 在发布者与订阅者之间添加消息中心,所有的消息均通过消息中心管理, 而发布者与订阅者不会直接联系,实现了两者的解耦。 核心概念:
- 消息中心 Dep
- 订阅者 Subscriber
- 发布者 Publisher
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12"></script>
<script>
// 创建了一个 Vue 实例(消息中心)
const eventBus = new Vue()
// 注册事件(设置订阅者)
eventBus.$on('dataChange', () => {
console.log('事件处理功能1')
})
eventBus.$on('dataChange', () => {
console.log('事件处理功能2')
})
// 触发事件(设置发布者)
eventBus.$emit('dataChange');
</script>
设计模式小结
观察者模式是由观察者与观察目标组成的,适合组件内操作。
- 特性:特殊事件发生后,观察目标统一通知所有观察者。 发布/订阅模式是由发布者与订阅者以及消息中心组成,更加适合消息类型复杂的情况。
- 特性:特殊事件发生,消息中心接到发布指令后,会根据事件类型给对 应的订阅者发送信息。
Vue 响应式原理模拟
整体分析
要模拟 Vue 实现响应式数据,首先我们观察一下 Vue 实例的结构,分析要实现哪些属性与功能。
Vue
- 目标:将 data 数据注入到 Vue 实例,便于方法内操作。 Observer(发布者)
- 目标:数据劫持,监听数据变化,并在变化时通知 Dep Dep(消息中心)
- 目标:存储订阅者以及管理消息的发送 Watcher(订阅者)
- 目标:订阅数据变化,进行视图更新 Compiler
- 目标:解析模板中的指令与插值表达式,并替换成相应的数据
Vue 类
• 功能:
- 接收配置信息
- 将 data 的属性转换成 Getter、Setter,并注入到 Vue 实例中。
- *监听 data 中所有属性的变化,设置成响应式数据
- *调用解析功能(解析模板内的插值表达式、指令等)
class Vue {
constructor (options) {
// 1 存储属性
this.$options = options || {}
this.$data = options.data || {}
// 判断 el 值的类型,并进行相应处理
const { el } = options
this.$el = typeof el === 'string' ? document.querySelector(el) : el
// 2 将 data 属性注入到 Vue 实例中
_proxyData(this, this.$data)
// *3. 创建 Observer 实例监视 data 的属性变化
new Observer(this.$data)
// *4. 调用 Compiler
new Compiler(this)
}
}
// 将 data 的属性注入到 Vue 实例
function _proxyData (target, data) {
Object.keys(data).forEach(key => {
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get () {
return data[key]
},
set (newValue) {
data[key] = newValue
}
})
})
}
observer类
功能:
- 通过数据劫持方式监视data中的属性变化,变化时通知消息中心Dep
- 需要考虑data的属性也可能为对象,也要转换成响应式数据
class Observer {
// 接收传入的对象,将这个对象的属性转换为 Getter/Setter
constructor (data) {
this.data = data
// 遍历数据
this.walk(data)
}
// 封装用于数据遍历的方法
walk (data) {
// 将遍历后的属性都转换为 Getter、Setter
Object.keys(data).forEach(key => this.convert(key, data[key]))
}
// 封装用于将对象转换为响应式数据的方法
convert (key, value) {
defineReactive(this.data, key, value)
}
}
// 用于为对象定义一个响应式的属性
function defineReactive (data, key, value) {
// 创建消息中心
const dep = new Dep()
// 检测是否为对象,如果是,创建一个新的 Observer 实例进行管理
observer(value)
// 进行数据劫持
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get () {
console.log('获取了属性')
// * 在触发 Getter 时添加订阅者
Dep.target && dep.addSub(Dep.target)
return value
},
set (newValue) {
console.log('设置了属性')
if (newValue === value) return
value = newValue
observer(value)
// * 数据变化时,通知消息中心
dep.notify()
}
})
}
function observer (value) {
if (typeof value === 'object' && value !== null) {
return new Observer(value)
}
}
Dep类
- Dep是Dependency的简写,含义为“依赖”,指的是Dep用于收集与管理订阅者与发布者之间的依赖关系
- 功能
- *为每个数据收集对应的依赖,存储依赖
- 添加并存储订阅者
- 数据变化时,通知所有的观察者
class Dep {
constructor () {
// 存储订阅者
this.subs = []
}
// 添加订阅者
addSub (sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 通知订阅者的方法
notify () {
// 遍历订阅者,并执行更新功能即可
this.subs.forEach(sub => {
sub.update()
})
}
}
Watcher类
功能:
- 实例化Watch时,往dep对象中添加自己
- 当数据变化触发dep,dep通知所有对应的Watcher实例更新视图
class Watcher {
constructor (vm, key, cb) {
// 当前 Vue 实例
this.vm = vm
// 订阅的属性名
this.key = key
// 数据变化后,要执行的回调
this.cb = cb
// 触发 Getter 前,将当前订阅者实例存储给 Dep 类
Dep.target = this
// 记录属性更改之前的值,用于进行更新状态检测(导致了属性 Getter 的触发)
this.oldValue = vm[key]
// 操作完毕后清除 target,用于存储下一个 Watcher 实例
Dep.target = null
}
// 封装数据变化时更新视图的功能
update () {
const newValue = this.vm[this.key]
// 如果数据不变,无需更新
if (newValue === this.oldValue) return
// 数据改变,调用更新后的回调
this.cb(newValue)
}
}
Compiler类
这里使用的是dom,而vue使用的是virtual dom
功能:
- 进行编译模板,并解析内部指令与插值表达式
- 进行页面的首次渲染
- 数据变化时,重新渲染视图
class Compiler {
constructor (vm) {
this.vm = vm
this.el = vm.$el
// 初始化模板编译方法
this.compile(this.el)
}
// 基础模板方法
compile (el) {
const childNodes = el.childNodes
Array.from(childNodes).forEach(node => {
// 检测节点类型(文本节点、元素节点)
if (isTextNode(node)) {
// 编译文本节点内容
this.compileText(node)
} else if (isElementNode(node)) {
// 编译元素节点内容
this.compileElement(node)
}
// 检测当前节点是否存在子节点
if (node.childNodes && node.childNodes.length) {
this.compile(node)
}
})
}
// 封装文本节点编译方法
compileText (node) {
const reg = /\{\{(.+?)\}\}/g
// 去除内容中不必要的空格与换行
const value = node.textContent.replace(/\s/g, '')
// 声明数据存储多段文本
const tokens = []
// 记录已经操作过的位置的索引
let lastIndex = 0
// 记录当前提取内容的初始索引
let index
let result
while (result = reg.exec(value)) {
// 本次提取内容的初始索引
index = result.index
// 处理普通文本
if (index > lastIndex) {
// 将中间部分的内容存储到 tokens 中
tokens.push(value.slice(lastIndex, index))
}
// 处理插值表达式内容(去除空格的操作可省略)
const key = result[1].trim()
// 根据 key 获取对应属性值,存储到 tokens
tokens.push(this.vm[key])
// 更新 lastIndex 为了获取后面的内容
lastIndex = index + result[0].length
// 创建订阅者 Watcher 实时订阅数据变化
const pos = tokens.length - 1
new Watcher(this.vm, key, newValue => {
// 数据变化,修改 tokens 中的对应数据
tokens[pos] = newValue
node.textContent = tokens.join('')
})
}
if (tokens.length) {
// 页面初始渲染
node.textContent = tokens.join('')
}
}
// 封装元素节点处理方法
compileElement (node) {
// 获取属性节点
Array.from(node.attributes).forEach(attr => {
// 保存属性名称,并检测属性的功能
let attrName = attr.name
if (!isDirective(attrName)) return
// 获取指令的具体名称
attrName = attrName.slice(2)
// 获取指令的值,代表响应式数据的名称
let key = attr.value
// 封装 update 方法,用于进行不同指令的功能分配
this.update(node, key, attrName)
})
}
// 用于进行指令分配的方法
update (node, key, attrName) {
// 名称处理
let updateFn = this[attrName + 'Updater']
// 检测并调用
updateFn && updateFn.call(this, node, key, this.vm[key])
}
// v-text 处理
textUpdater (node, key, value) {
// 给元素设置内容
node.textContent = value
// 订阅数据变化
new Watcher(this.vm, key, newValue => {
node.textContent = newValue
})
}
// v-model 处理
modelUpdater (node, key, value) {
// 给元素设置数据
node.value = value
// 订阅数据变化
new Watcher(this.vm, key, newValue => {
node.value = newValue
})
// 监听 input 事件,实现双向绑定
node.addEventListener('input', () => {
this.vm[key] = node.value
})
}
}
// 判断节点是否为元素节点
function isElementNode (node) {
return node.nodeType === 1
}
// 判断节点是否为文本节点
function isTextNode (node) {
return node.nodeType === 3
}
// 判断属性名是否为指令
function isDirective (attrName) {
return attrName.startsWith('v-')
}
功能回顾与总结
- vue类
- 把data的属性注入到Vue实例
- 调用Observer实现数据响应式处理
- 调用Compiler编译模板
- Observer类
- 将data的属性转换为Getter/Setter
- 为Dep添加订阅者Watcher
- 数据变化发送时通知Dep
- Dep类
- 收集依赖,添加订阅者(watcher)
- 通知订阅者
- Watcher类
- 编译模板时创建订阅者,订阅数据变化
- 接到Dep通知时,调用Compiler中的模板功能更新视图
- Compiler
- 编译模板,解析指令与插值表达式
- 负责页面首次渲染与数据变化后重新渲染
Virtual DOM
课程目标:
- 了解什么是虚拟DOM,以及虚拟DOM的作用
- 了解如何使用Virtual DOM,Snabbdom的基本使用
- Snabbdom的源码解析
什么是Virtual DOM
- Virtual DOM(虚拟DOM),是由普通的js对象来描述DOM对象
- 使用Virtual DOM来描述真实DOM
为什么要使用 Virtual DOM
- 前端开发刀耕火种的时代
- MVVM 框架解决视图和状态同步问题
- 模板引擎可以简化视图操作,没办法跟踪状态
- 虚拟 DOM 跟踪状态变化
- 参考 github 上 virtual-dom 的动机描述
- 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
- 通过比较前后两次状态差异更新真实 DOM Version:0.9 StartHTML:0000000105 EndHTML:0000001513 StartFragment:0000000141 EndFragment:0000001473
虚拟 DOM 的作用
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 跨平台
- 浏览器平台渲染
- DOM原生应用(Weex/React Native)
- 服务端渲染 SSR(Nuxt.js/Next.js)
- 小程序(mpvue/uni-app)等
虚拟 DOM 库
- Snabbdom
- Vue.js 2.x 内部使用的虚拟 DOM 就是改造的 Snabbdom
- 大约 200 SLOC (single line of code)
- 通过模块可扩展
- 源码使用 TypeScript 开发
- 最快的 Virtual DOM 之一
- virtual-dom Snabbdom的使用:
- 安装parcel打包工具
- 导入Snabbdom 官方文档:github.com/snabbdom/sn…
- 安装 Snabbdom
- npm install snabbdom@2.1.0
- 导入 Snabbdom
- Snabbdom 的两个核心函数 init 和 h()
- init() 是一个高阶函数,返回 patch()
- h() 返回虚拟节点 VNode,这个函数我们在使用 Vue.js 的时候见过
- 官方文档中导入的方式
- 实际导入的方式 • parcel/webpack 4 不支持 package.json 中的 exports 字段
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
// 1 通过 h 函数创建 VNode
let vNode = h('div#box.container', '新内容')
// 获取挂载元素
const dom = document.querySelector('#app')
// 2 通过 init 函数得到 patch 函数
const patch = init([])
// 3 通过 patch,将 vNode 渲染到 DOM
let oldVNode = patch(dom, vNode)
// 4 创建新的 VNode,更新给 oldVNode
vNode = h('p#text.abc', '这是p标签的内容')
patch(oldVNode, vNode)
- 包含子节点
import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'
const patch = init([])
// 创建包含子节点的 VNode
// - 参数2的数组为子节点列表,内部就应该传入 vNode
let vNode = h('div#container', [
h('h1', '标题文本'),
h('p', '内容文本')
])
// 获取挂载元素
const dom = document.querySelector('#app')
// 渲染 vNode
const oldVNode = patch(dom, vNode)
// 清空操作更新页面,h('!')代表生成一个注释节点
patch(oldVNode, h('!'))
- Snabbdom模块相关内容
- 模块的作用
- Snabbdom 的核心库并不能处理 DOM 元素的属性/样式/事件等,可以通过注册 Snabbdom 默认提供的模块来实现
- Snabbdom 中的模块可以用来扩展 Snabbdom的功能
- Snabbdom 中的模块的实现是通过注册全局的钩子函数来实现的
- 官方提供的模块
- attributes
- 设置 DOM 元素的属性,使用 setattribute()
- 处理布尔类型的属性
- props
- 和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = calue
- 不处理布尔类型的属性
- dataset
- 设置 data-* 的自定义属性
- class
- 切换类样式
- 注意:给元素设置样式是通过 sel 选择器
- style
- 设置行内样式,支持动画
- delayed/remove/destroy
- eventlisteners
- 事件监听器模块
- attributes
- 模块使用步骤
- 导入需要的模块
- init() 中注册模块
- h() 函数的第二个参数处使用模块
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
// 1 导入模块(注意拼写,导入的名称不要拼错)
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
// 2 注册模块(为 patch 函数添加模块对应的能力)
const patch = init([
styleModule,
eventListenersModule
])
// 3 使用模块
let vNode = h('div#box', {
style: {
backgroundColor: 'green',
height: '200px',
width: '200px'
}
}, [
h('h1#title', {
style: {
color: '#fff'
},
on: {
click () {
console.log('点击了 h1 标签')
}
}
}, '这是标题内容'),
h('p', '这是内容文本')
])
const dom = document.getElementById('app')
patch(dom, vNode)
Snabbdom 源码解析
-
如何学习源码
- 宏观了解
- 带着目标看源码
- 看源码的过程要不求甚解
- 调试
- 参考资料
-
Snabbdom 的核心
- init() 设置模块,创建 patch() 函数
- 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
- patch() 比较新旧两个 VNode
- 把变化的内容更新到真实 DOM 树
-
Snabbdom 源码
- 源码地址
- github.com/snabbdom/sn…
- 当前版本:v2.1.0
- 克隆代码
- git clone -b v2.1.0 --depth=1github.com/snabbdom/sn…
- 源码地址
-
h 函数介绍
- 作用:创建 VNode 对象
- Vue 中的 h 函数
-
函数重载
- 参数个数或参数类型不同的函数
- JavaScript 中没有重载的概念
- TypeScript 中有重载,不过重载的实现还是通过代码调整参数
- 函数重载-参数个数
- 函数重载-参数类型
-
vnode函数
import { Hooks } from './hooks'
import { AttachData } from './helpers/attachto'
import { VNodeStyle } from './modules/style'
import { On } from './modules/eventlisteners'
import { Attrs } from './modules/attributes'
import { Classes } from './modules/class'
import { Props } from './modules/props'
import { Dataset } from './modules/dataset'
import { Hero } from './modules/hero'
export type Key = string | number
export interface VNode {
sel: string | undefined
data: VNodeData | undefined
children: Array<VNode | string> | undefined
elm: Node | undefined
text: string | undefined
key: Key | undefined
}
export interface VNodeData {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
dataset?: Dataset
on?: On
hero?: Hero
attachData?: AttachData
hook?: Hooks
key?: Key
ns?: string // for SVGs
fn?: () => VNode // for thunks
args?: any[] // for thunks
[key: string]: any // for any other 3rd party module
}
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key }
}
- patch 整体过程分析
- patch(oldVnode, newVnode)
- 把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化
- init函数
- 接收一个数组,数组中包含了模块的一些相关功能,生命周期函数的声明
- 高阶函数,返回一个patch函数