第一章:响应式原理
一、观察者模式
- 观察者(订阅者) -- Watcher
update():当事件发生时,具体要做的事情 - 目标(发布者) -- Dep
subs 数组:存储所有的观察者
addSub():添加观察者
notify():当事件发生,调用所有观察者的 update() 方法 - 没有事件中心
// 目标(发布者)
// Dependency
class Dep {
constructor() {
// 存储所有的观察者
this.subs = []
}
// 添加观察者
addSub(sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 通知所有观察者
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
// 观察者(订阅者)
class Watcher {
update() {
console.log('update')
}
}
// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
- 总结
观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。
发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。
二、响应式原理
- watcher在第一次视图绑定变量的时候(插值表达式、指令处理时)创建,每个key有自己的一个watcher。
- 创建watcher的时候应该加到对应的发布者里去,所以在watcher构造函数处调用
dep.addSub(watcher)
。 - 发布者当数据变更时需要通知观察者
dep.notify()
,所以发布者的实例创建即new Dep()
应该是在数据变更处,即Observer的walk里。 - 而watcher构造函数里为了能调用到Observer的walk里defineProperty的dep实例,可以借用
this.oldValue = vm[key]
去操作。所以可以把dep.addSub(watcher)
写在getter里,并通过Dep.target控制只有第一次才addSub。
// 处理插值表达式
compileText(node) {
const regExp = /\{\{(.+?)\}\}/
const value = node.textContent
if (regExp.test(value)) {
const key = RegExp.$1.trim()
node.textContent = value.replace(regExp, this.vm[key])
// 为当前的key创建watcher,当key变化时更新视图
new Watcher(this.vm, key, (newValue) => {
node.textContent = newValue
})
}
}
class Watcher {
// 每个数据变化都要触发更新,所以每个key对应一个watcher
constructor(vm, key, cb) {
this.vm = vm
this.key = key
this.cb = cb
// 创建watcher的时候应该加到对应的发布者里去,发布者当数据变更时需要通知观察者,所以发布者应该是在数据变更处,那么添加watcher也可以放在对应地方即Observer的walk里
// 此处跟Observer里walk相关的也就获取旧数据,为了在walk里面触发添加watcher,可以把添加写在getter里,并通过Dep.target控制只有第一次才addSub
// 把watcher对象记录到Dep类的静态属性target
Dep.target = this
// 触发get方法,在get方法中会调用addSub
this.oldValue = vm[key]
Dep.target = null
}
// 当数据变化的时候更新视图
update() {
let newValue = this.vm[this.key]
if (this.oldValue === newValue) {
return
}
this.cb(newValue)
}
}
class Observer {
constructor(data) {
this.walk(data)
}
walk(data) {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
const that = this
const val = data[key] // 避免循环先存下来
const dep = new Dep() // 负责收集依赖,当数据变化的时候通知观察者
this.walk(val)
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// 只有创建watcher时Dep.target才存在,所以只有创建watcher的时候获取数据才会触发添加watcher
Dep.target && dep.addSub(Dep.target)
return val
},
set(newVal) {
if (newVal === val) {
return
}
that.walk(newVal)
val = newVal // val被闭包缓存了,此处修改的是当前作用域下的val,get返回的也是同一个val
dep.notify() // 通知观察者调用update
}
})
})
}
}
一、对象监测
object.defineProperty只能观测到取值和改值,如果是增加或删除key是监测不到的,还得通过Vue.set
和Vue.delete
解决。
二、数组监测
数组的读取和整个替换是可以通过object.defineProperty监测到的,但数据内部元素的增删改查无法监测到。可以自行实现数组的增删改查方法,在里面实现监测和调用watcher。但数组下标的修改无法监测。
Array原型中可以改变数组自身内容的方法有7个,分别是:push,pop,shift,unshift,splice,sort,reverse
。
// 源码位置:/src/core/observer/array.js
const arrayProto = Array.prototype
// 创建一个对象作为拦截器,确保不是直接修改原型上的方法
export const arrayMethods = Object.create(arrayProto)
// 改变数组自身内容的7个方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
const original = arrayProto[method] // 缓存原生方法
Object.defineProperty(arrayMethods, method, {
enumerable: false,
configurable: true,
writable: true,
value:function mutator(...args){
const result = original.apply(this, args)
return result
}
})
})
做好数组方法拦截器之后,还要让用户的数组实例默认都用这些修改后的方法。
- 因为数组实例本身没有push之类的方法,都是用原型上的,所以我们可以直接把实例的原型proto改成修改后的原型。
- 若不支持proto,则直接往实例上加修改后的方法。
// 源码位置:/src/core/observer/index.js
export class Observer {
constructor (value) {
this.value = value
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
} else {
this.walk(value)
}
}
}
// 能力检测:判断__proto__是否可用,因为有的浏览器不支持该属性
export const hasProto = '__proto__' in {}
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
function protoAugment (target, src: Object, keys: any) {
target.__proto__ = src
}
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
第二章:模板编译
将一堆字符串模板解析成抽象语法树AST后,我们就可以对其进行各种操作处理了,处理完后用处理后的AST来生成render函数。其具体流程可大致分为三个阶段:
- 模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST;
- 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
- 代码生成阶段:将AST转换成渲染函数;
一、模板解析
- 解析html
- 遇到文本就parseText()
- 遇到过滤器就parseFilters()
(一)解析html
先判断是否有父节点且父节点为纯文本标签 (script,style,textarea),如果是则不需要解析里面的内容。否则才进一步解析
- 解析注释
- 解析条件语句
- 解析doctype
- 解析开始标签,并推入栈中
(1) 解析标签名
const cname = '[a-zA-Z_][\\w\\-\\.]*'; // 利用RegExp需要做转义
标签名可以为字母或下划线开头,然后-或.连接的单词
(2) 解析标签属性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
- 匹配属性名:
[^\s"'<>\/=]+
非关闭的特殊字符 - 匹配属性值:
"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+
值里面多了个es6的模板字符串
(3) 解析结束标记,判断是否自闭合标签
const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
'></div>'.match(startTagClose) // [">", "", index: 0, input: "></div>", groups: undefined]
'/>'.match(startTagClose) // ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
自闭合标签的end[1]是"/",非自闭合标签是""。
- 解析结束标签,并出栈判断标签是否一致
(1)从后往前找栈里闭合标签的位置,正常应该是在栈顶。
(2)若不在栈顶,则栈顶往后的标签都没正确闭合,要提示并调用options.end自动闭合。
(3)若栈里找不到闭合标签对应的,且为</p>或者</br>,根据浏览器特性则会自动把这两个标签补全。 - 解析文本
(1)如果整个都没有<,则都是文本
(2)如果有,则<之前的是文本。<之后的先判断是否是五种类型,不是的话也是文本;查找下一个<,如有没有则都是文本;如果有,继续上面的循环。
(二)解析文本
分为有变量文本和无变量文本。用户如果传了解析符就用用户的解析,否则正则匹配{{}}
。
(三)标记静态节点
经过上两步处理后的AST节点是带有type的,1表示元素节点,2表示带变量文本,3表示纯文本,可以根据这个判断是否为静态节点。
- 静态节点的纯文本内容不包含变量
- 子节点全是静态节点的节点称为静态根节点
这些节点不会变化,所以不需要vnode去patch变化,标记出来忽略patch可以提升性能。
如果元素节点是静态节点,那就必须满足以下几点要求:
- 如果节点使用了v-pre指令,那就断定它是静态节点;
- 如果节点没有使用v-pre指令,那它要成为静态节点必须满足:
- 不能使用v-if、v-else、v-for指令;
- 当前节点的父节点不能是带有 v-for 的 template 标签;
- 不能是内置组件,即标签名不能是slot和component;
- 标签名必须是平台保留标签,即不能是组件;
- 不能使用动态绑定语法,即标签上不能有v-、@、:开头的属性;
- 节点的所有属性的 key 都必须是静态节点才有的 key,注:静态节点的key是有限的,它只能是type,tag,attrsList,attrsMap,plain,parent,children,attrs之一;
标记完当前节点是否为静态节点之后,如果该节点是元素节点,那么还要继续去递归判断它的子节点。如果当前节点的子节点中有标签带有v-if、v-else-if、v-else等指令时,这些子节点在每次渲染时都只渲染一个,所以其余没有被渲染的肯定不在node.children中,而是存在于node.ifConditions,所以我们还要把node.ifConditions循环一遍。
一个节点要想成为静态根节点,它必须满足以下要求:
- 节点本身必须是静态节点;
- 必须拥有子节点 children;
- 子节点不能只是只有一个文本节点;
否则的话,对它的优化成本将大于优化后带来的收益。
如果当前节点不是静态根节点,那就继续递归遍历它的子节点node.children和node.ifConditions
第三章、虚拟Dom
一、虚拟Dom
用JS的计算性能来换取操作DOM所消耗的性能。不要盲目的去更新视图,而是通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方。
在视图渲染之前,把写好的template模板先编译成VNode并缓存下来,等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。
VNode类型:
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件节点
- 克隆节点
二、diff算法
整个patch无非就是干三件事:
- 创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
- 删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
- 更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。
(一)创建节点
VNode类可以描述6种类型的节点,而实际上只有3种类型的节点能够被创建并插入到DOM中,它们分别是:元素节点、文本节点、注释节点。所以Vue在创建节点的时候会判断在新的VNode中有而旧的oldVNode中没有的这个节点是属于哪种类型的节点,从而调用不同的方法创建并插入到DOM中。
// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点
createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else {
vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
}
}
(二)删除节点
如果某些节点再新的VNode中没有而在旧的oldVNode中有,那么就需要把这些节点从旧的oldVNode中删除。删除节点非常简单,只需在要删除节点的父元素上调用removeChild方法即可。源码如下:
function removeNode (el) {
const parent = nodeOps.parentNode(el) // 获取父节点
if (isDef(parent)) {
nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法
}
}
(三)更新节点
- 如果newNode和oldNode都是静态节点,则跳过。
- 如果newNode是文本节点:
- oldNode也是文本节点,则对比是否相同,不同改文本成相同。
- oldNode不是文本节点,则改成文本节点,并替换文本成相同。
- 如果newNode是元素节点:
- newNode有子节点,如果oldNode有子节点则递归对比更新替换。
如果oldNode没有子节点:
a. 且为空节点,则直接创建子节点并插入。
b. 且为文本节点,则清空文本,并创建子节点插入。 - newNode没有子节点,而且第二步已经排查不是文本节点,则直接清空oldNode。
三、更新子节点
创建新的节点要插入到所有未处理节点之前,而不是已处理节点之后,否则会出现下面的问题。
![](https://img.haomeiwen.com/i9901864/a2da5138de5b017c.png)
先比较oldStart和newStart,直到不相同;比较oldEnd和newEnd;再比较oldStart和newEnd;再比较oldEnd和newStart;都不同相同则遍历。当老节点或新节点的所有子节点全部遍历完(oldStart>oldEnd或者newStart>newEnd),则循环结束。
- 新旧开始节点如果是sameVnode(key和sel相同),会重用旧的dom节点只是修改内容,调用patchVnode()对比和更新节点。把旧开始和新开始索引往后移动。
- 旧开始和新结束如果相同,调用patchVnode()对比和更新,把oldStart对应的dom元素移动到oldEnd之后(因为旧开始和新结束是相同节点),并且oldStart++,newEnd--。
- 旧结束和新开始,调用patchVnode()对比和更新,把oldEnd对应的dom元素移动到oldStart之前,并且oldEnd--,newStart++。
- 如果都不相同,则遍历旧节点中是否有newStart相同节点,有的话对比更新,并把对应节点移到oldStart之前,清空旧节点索引;如果没有,则直接在oldStart之前创建新的节点。
第四章、生命周期
Vue实例的生命周期大致可分为4个阶段:
- 初始化阶段:为Vue实例上初始化一些属性,事件以及响应式数据;
- 模板编译阶段:将模板编译成渲染函数;
- 挂载阶段:将实例挂载到指定的DOM上,即将模板渲染到真实DOM中;
- 销毁阶段:将实例自身从父组件中删除,并取消依赖追踪及事件监听器;
初始化
- 所以data之类的要在created钩子之后才能拿到。
export function initMixin (Vue) {
Vue.prototype._init = function (options) {
const vm = this
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
vm._self = vm
initLifecycle(vm) // 初始化生命周期
initEvents(vm) // 初始化事件
initRender(vm) // 初始化渲染
callHook(vm, 'beforeCreate') // 调用生命周期钩子函数
initInjections(vm) //初始化injections
initState(vm) // 初始化props,methods,data,computed,watch
initProvide(vm) // 初始化 provide
callHook(vm, 'created') // 调用生命周期钩子函数
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
- 合并options的时候,把一些内置组件扩展到 Vue.options.components 上,Vue 的内置组件目前 有<keep-alive>、<transition> 和<transition-group> 组件,这也就是为什么我们在其它组件中使用这些组件不需要注册的原因。以及合并mixins和extends,不同watch和data采用不同合并策略(策略模式)。钩子函数合并成数组,按顺序执行所有钩子。
- initLifecycle往实例上挂
$parent
(第一个非抽象组件的父组件)、$children
、$root
等。 - initEvents父组件给子组件的注册事件中,把自定义事件传给子组件,在子组件实例化的时候进行初始化;而浏览器原生事件是在父组件中处理。
换句话说:实例初始化阶段调用的初始化事件函数initEvents
实际上初始化的是父组件在模板中使用v-on或@注册的监听子组件内触发的事件。 - 由于inject的数据在data和props里可用,所以要先
initInjections
,再initState
再initProvide
。initInjections
就是把vm.$options.inject
取出来放实例上,而且指定shouldObserve = false
将其标识未非响应式,所以
provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。
- 先规范化成标准对象写法。
- 然后每一个数据key从当前组件起,不断的向上游父级组件中查找该数据key对应的值,直到找到为止。
- 如果在上游所有父级组件中没找到,那么就看在inject 选项是否为该数据key设置了默认值,如果设置了就使用默认值。
- 如果没有设置,则抛出异常。
-
initState
初始化顺序:props,methods,data,computed,watch
,所以data可以用props和methods里的数据,watch和computed可以用data、props、methods里的数据。
通过属性拦截监听的话,数据越多依赖越多,依赖追踪的内存开销就会很大。所以从Vue 2.0版本起,Vue只对每个组件进行侦测,所以在每个组件上新增了vm._watchers属性,用来存放用到的所有状态依赖,当其中一个状态发生变化时就会通知到组件,然后由组件内部使用虚拟DOM进行数据比对,从而降低内存开销,提高性能。 -
initProps
如果传入的prop是Boolean类型,则以下三种情况的默认值为true。
<Child name></Child>
<Child name="name"></Child>
<Child userName="user-name"></Child>
-
初始化methods无非就干了三件事:判断method有没有?method的命名符不符合命名规范?如果method既有又符合规范那就把它挂载到vm实例上。
-
初始化计算属性:计算属性的结果会被缓存,除非依赖的响应式属性变化才会重新计算。非服务端渲染环境下计算属性才应该有缓存。
如果是函数则直接设置为getter,对象则直接认为是{getter, setter}
。通过dirty字段判断,如果dirty为true则重新计算返回,否则返回缓存值。只有每次依赖变化的时候会将dirty设置为true。
计算属性原理
-
最后,会判断用户是否传入了el选项,如果传入了则调用
$mount
函数进入模板编译与挂载阶段,如果没有传入el选项,则不进入下一个生命周期阶段,需要用户手动执行vm.$mount
方法才进入下一个生命周期阶段。
编译阶段
运行时版本没有编译阶段,因为vue-loader或vueify在构建时会把*.vue文件内部的模板预编译成渲染函数。
在只包含运行时版本的$mount
方法中获取到DOM元素后直接进入挂载阶段,而在完整版本的$mount
方法中是先将模板进行编译,然后回过头调只包含运行时版本的$mount
方法进入挂载阶段。它们的区别在于在$mount方法中是否进行了模板编译。
挂载阶段
挂载阶段所做的主要工作是创建Vue实例并用其替换el选项对应的DOM元素,同时还要开启对模板中数据(状态)的监控,当数据(状态)发生变化时通知其依赖进行视图更新。
销毁阶段
- 将当前的Vue实例从其父级实例中删除,取消当前实例上的所有依赖追踪并且移除实例上的所有事件监听器。
- 实例身上的依赖包含两部分:一部分是实例自身依赖其他数据,需要将实例自身从其他数据的依赖列表中删除;另一部分是实例内的数据对其他数据的依赖(如用户使用$watch创建的依赖),也需要从其他数据的依赖列表中删除实例内数据。
keep-alive
- 不常变动的组件或者需要缓存的组件用
<keep-alive>
包裹起来,这样<keep-alive>
就会帮我们把组件保存在内存中,而不是直接的销毁,这样做可以保留组件的状态或避免多次重新渲染,以提高页面性能。 -
<keep-alive>
组件可接收三个属性:
- include - 字符串或正则表达式。只有名称匹配的组件会被缓存。
- exclude - 字符串或正则表达式。任何名称匹配的组件都不会被缓存。
- max - 数字。最多可以缓存多少组件实例。一旦这个数字达到了,在新实例被创建之前,已缓存组件中最久没有被访问的实例会被销毁掉。
- <keep-alive> 只处理第一个子元素,所以一般和它搭配使用的有 component 动态组件或者是 router-view。
- 从cache里面找组件的key,如果有则直接取出来实例,并调整放在cache最后一位。如果没有,则把vnode加到cache里,并把key加到keys最后面(最前面最少用的缓存不够最早被删除,LRU(Least recently used,最近最少使用)“如果数据最近被访问过,那么将来被访问的几率也更高”)。
指令
在虚拟DOM渲染更新的create、update、destory阶段都得处理指令逻辑。
自定义指令有五个生命周期(也叫钩子函数),分别是 bind、inserted、update、componentUpdated、unbind
- bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
- inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
- update:被绑定于元素所在的模板更新时调用,而无论绑定值是否变化。通过比较更新前后的绑定值,可以忽略不必要的模板更新。
- componentUpdated:被绑定元素所在模板完成一次更新周期时调用。
- unbind:只调用一次,指令与元素解绑时调用。
原理
1.在生成 ast 语法树时,遇到指令会给当前元素添加 directives 属性
2.通过 genDirectives 生成指令代码
3.在 patch 前将指令的钩子提取到 cbs 中,在 patch 过程中调用对应的钩子
4.当执行指令对应钩子函数时,调用对应指令定义的方法
nextTick
- Vue 在内部对异步队列尝试使用原生的
Promise.then、MutationObserver(两个微任务)
和setImmediate
,MessageChannel
如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。 - vue内部第一次执行nextTick的时候,这个时候把nextTick当作微任务插入到微任务队列。
- 插入后它往自己的回调函数队列先插了一个更新dom函数(flushCallbacks里有watcher.run)。
- 往后再调用nextTick,就不再往微任务队列插入了。而是往上面的回调函数队列里面插入回调函数。
vm.msg = 'Hello world'; // 往微任务队列里面加一个任务,并加锁;创建一个callback队列,并往队列里添加一个更新虚拟dom的函数;此时callback['更新dom方法']
Vue.nextTick(cb1) // 遇到锁不再往微任务队列里加任务,而是直接把cb1添加到已有的callback队列;此时callback['更新dom方法', 'cb1']
// 等到下次微任务执行,拿出这个nextTick回调函数,回调函数里的内容就是遍历执行callback['更新dom方法', 'cb1']
https://juejin.cn/post/6939704519668432910#heading-4
https://blog.csdn.net/weixin_42707287/article/details/111931861
Vue.set和Vue.delete
- 如果是数组,则调用数组改装好后的splice。
- 如果是对象,非响应式的(没observer实例)则直接加,否则定义成响应式
defineReactive(ob.value, key, val)
并触发视图更新ob.dep.notify()
。 - 删除的话响应式删除之后直接
ob.dep.notify()
,非响应式直接删除。
网友评论