开篇碎碎念:作为vue2的长期用户,在日常工作中,逐渐手拿把掐业务需求,但是每当谈到原理和底层的时候,总感觉没有形成完整的体系,比如我知道响应式原理、知道diff算法、知道生命周期以及各种API。但如果聊起,vue是怎么实现的?我只能支支吾吾。所以,我决定啃一遍《深入浅出Vue.js》、结合源码梳理一遍。于是,第一篇先来前三篇、后续将继续更新…
一、变化侦测
Object
使用Object.defineProperty(vue3中使用Proxy),
在getter中收集依赖、在setter中触发依赖
递归侦测所有key:封装一个Observer类,但是Object.defineproperty是将对象
转换成getter和setter的形式追踪的,(依赖保存在defineReactive中)它只能追踪一个数据是否被修改,无法追踪新增属性和删除属性。所以vue提供了vm.set、vm.set、vm.set、vm.delete
Array
由于数组的方法是无法触发getter的,我们在array的原型上通过拦截器覆盖Array.prototype,我们创建arrayMethods,继承Array.prototype,再去覆盖Array.prototype。在arrayMethods方法中我们可以使用Object.defineProperty将数组的原生方法中直接改变原数组的方法比如 (push、pop、shift、splice、unshift、sort、reverse)等进行封装,然后使用拦截器覆盖Array原型,将arrayMethods赋值给value.proto;不过部分浏览器不支持,此时会使用copyAugment函数,将加工后的拦截操作的原型方法直接添加到value属性中!
const hasProto = '_proto_' in {}
if(Array.isArray(value)){
const augment = hasProto ? protoAugment : copyAugment
augment(value,arrayMethods,arrayKeys)
}
function copyAugment(target,src,keys){
for(let i = 0,l = keys.length; i < l; i++){
const key = keys[i]
def(target,key,src[key])
}
}
Array收集依赖方式和Object一样,在getter中收集,但是依赖保存在Observer实例上。
我们在value上新增一个属性_ob_,借此访问Observer实例,当侦测到数组变化时。在实例中拿到dep属性然后ob.dep.notify通知Watcher数据发生变化。当数组中有新增元素时,我们获取新增元素,使用Observer把inserted中的元素转换成响应式的。
但是要注意的是,这些都是通过拦截数组方法实现的侦测数据变化。当你直接赋值比如this.list[0] = 2 、this.list.length = 0,这种事无法侦测的。不过这种vue提供了$set等方法
变化侦测相关API
vm.$watch
用于观察一个表达式或者computed函数在vue实例上的变化,它能够拿到新数据和旧数据,当数据变化时,会触发回调函数。有两个可选参数:
deep:深度侦测,
immediate:初始化时触发回调函数
// 使用
vm.$watch(expOrFn,callback,[options])
//options:deep、immediate
原理
通过对Watcher封装,判断immediate属性为true时,立即执行一次回调函数cb,最后返回一个
unwatchFn,取消观察数据
Vue.prototype.$watch = function (
expOrFn,
cb,
options
) {
var vm = this;
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {};
options.user = true;
// 创建watcher
var watcher = new Watcher(vm, expOrFn, cb, options);
if (options.immediate) {
// 立即执行回调函数
try {
cb.call(vm, watcher.value);
} catch (error) {
handleError(error, vm, ("callback for immediate watcher \"" + (watcher.expression) + "\""));
}
}
// 返回取消观察数据的函数
return function unwatchFn () {
watcher.teardown();
}
};
deep参数实现原理
除了要触发当前的被监听数据的收集依赖逻辑之外,还要把当前监听的这个值在内的所有子值都触发一遍收集依赖的逻辑,
if(this.deep){
traverse(val)
}
window.target = undefined
注意:这里deep调用traverse函数,要在window.target = undefined之前调用,这样才能保证子集收集的依赖是当前这个watcher
vm.$set
vm.$set是为了解决新增属性无法侦测的问题
用法
vm.$set(target,key,value)
原理
通过splice把val值设置到target中,利用defineReactive将新增属性转换成getter/setter形式,然后调用ob.dep.notify()通知依赖更新。
function set (target, key, val) {
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
}
// 处理数组
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key);
// 之前提到过,splice方法把val值设置到target中的时候,
//数组拦截器就会侦测到target发生了变化,也就是转换成了响应式
target.splice(key, 1, val);
return val
}
// key已经存在在对象中时,已经能被侦测了,直接用key和val改数据
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val
}
// 新增属性
var ob = (target).__ob__;
// 排除vuejs实例和实例的跟数据对象
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
);
return val
}
// 追踪新属性
if (!ob) {
target[key] = val;
return val
}
// defineReactive将新增属性转换成getter/setter形式
defineReactive$$1(ob.value, key, val);
// 向依赖触发通知,返回val
ob.dep.notify();
return val
}
vm.$delete
vm.$delete是为了解决使用delete关键字删除对象属性,无法侦测的问题
用法
vm.$delete(target,key)
原理
通过splice把key指定的索引位置的元素删除,然后调用ob.dep.notify()通知依赖更新。
function del (target, key) {
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(("Cannot delete reactive property on undefined, null, or primitive value: " + ((target))));
}
// 处理数组
if (Array.isArray(target) && isValidArrayIndex(key)) {
// 删除只需要用splice把key指定的索引位置的元素删除
target.splice(key, 1);
return
}
var ob = (target).__ob__;
// 这里和$set一样排除vuejs实例和实例的跟数据对象
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid deleting properties on a Vue instance or its root $data ' +
'- just set it to null.'
);
return
}
//判断要删除的key这个属性在target是否存在
if (!hasOwn(target, key)) {
return
}
delete target[key];
// 判断ob是不是响应式数据,只有响应式数据才能发送通知,非响应式直接删除就行
if (!ob) {
return
}
ob.dep.notify();
}
二、虚拟DOM
虚拟DOM
虚拟DOM的解决方式是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染,在渲染前,会用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分。这种直接使用虚拟节点覆盖的方式,会减少许多不必要的DOM操作,节省性能开销。
得力于Vue的变化侦测,它能够在一定程度上知道哪些状态发生变化,vue1是节点颗粒度的,vue2是组件级别是一个watcher的。
虚拟DOM的核心是patch算法,
VNode
vnode可以理解为节点描述对象,包括tag、data、text、children等;vue在每次渲染视图时先创建Vnode,然后利用它创建真实的DOM插入到页面中,每当视图更新时,将新创建的Vnode和上一次缓存的Vnode进行对比,找出差异然后创建真实DOM插入到视图中;
Vnode包含:注释节点(isComment = true 用来标识一个节点是注释节点)、文本节点、元素节点、组件节点、函数式节点、克隆节点(isCloned为true)
patching算法(diff)
patch的目的是渲染视图,但不是暴力的替换节点,而是在现有的DOM上进行修改,大致分为三步:创建新节点、删除废弃节点、更新需要更新的节点。
新增节点
什么时候需要新增?
- 当oldVnode不存在,直接用新增的vnode渲染视图
- 当oldVnode和vnode完全不是同一节点,也就是说oldVnode就是一个被废弃的节点
删除节点
什么时候需要删除?
- 当一个节点只在oldVnode中存在时,
更新节点
我认为更新节点是patch算法的核心。也是提高DOM操作性能的核心
更新的逻辑:
// 更新节点
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// vnode与oldVnode是否完全一样?若是,退出程序
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
// vnode与oldVnode是否都是静态节点?若是,退出程序
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
return
}
const oldCh = oldVnode.children
const ch = vnode.children
// vnode有text属性?若没有:
if (isUndef(vnode.text)) {
// vnode的子节点与oldVnode的子节点是否都存在?
if (isDef(oldCh) && isDef(ch)) {
// 若都存在,判断子节点是否相同,不同则更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 若只有vnode的子节点存在
else if (isDef(ch)) {
/**
* 判断oldVnode是否有文本?
* 若没有,则把vnode的子节点添加到真实DOM中
* 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中
*/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 若只有oldnode的子节点存在
else if (isDef(oldCh)) {
// 清空DOM中的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 若vnode和oldnode都没有子节点,但是oldnode中有文本
else if (isDef(oldVnode.text)) {
// 清空oldnode文本
nodeOps.setTextContent(elm, '')
}
// 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么
}
// 若有,vnode的text属性与oldVnode的text属性是否相同?
else if (oldVnode.text !== vnode.text) {
// 若不相同:则用vnode的text替换真实DOM的文本
nodeOps.setTextContent(elm, vnode.text)
}
}
三、模板编译
源码位置:src/compiler/parser/html-parser.js
将模板解析成AST,遍历AST标记静态节点,使用AST生成渲染函数
AST抽象语法树转换平台:https://astexplorer.net/
(1)解析器
HTML解析器
解析HTML标签的开始位置、结束为止、文本或注释时,触发钩子函数,将相关参数传递。最终生成AST
模板的开始位置会触发钩子函数 start,结束为止触发end,文本触发chars,注释触发comment,
AST是有层级关系的,也就是父节点和子节点,这里通过维护一个栈(stack)来维护父子关系,当遇到开始标签时,将当前标签入栈,当遇到结束标签时,将栈顶元素出栈。
1、截取开始标签
他是如何确定模板是不是开始标签呢?
使用正则匹配
const ncname = `[a-zA-Z_][\\w\\-\\.]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
解析标签属性->解析自闭和标识
标签属性是可选的,<div class = "box"></div>
比如这个class,这时上方的正则就不足以处理解析了,为解决这个问题,我们没解析一个属性就截取一个属性,在遇到结束标签时,判断是否还有属性,若有则继续解析属性。直到不存在属性,结束标签解析。
ps:自闭合标签(如<img src=""/>
)非自闭合标签(如<div></div>
)
2、截取结束标签
利用正则分辨出结束标签后,截取模板并触发钩子函数
3、截取注释
/^<!–/
这里当shouldKeepComment为true时,才会触发钩子函数,否则只截取模板
条件注释
只截取,不触发钩子
4、截取DOCTYPE
不需要触发钩子函数
5、截取文本
如果在整个模板中找不到< 就认为是文本
特殊情况之 纯文本内容元素处理
如:script、style、textarea,解析他们的时候当作文本处理,这个会在while循环的最外层判断
if(!lastTag || !isPlainTextElement(lastTag)){
// 上面的1、2、3、4、5步骤
}else{
// 纯文本
}
文本解析器
利用chars函数,将带变量的文本二次加工,(通过检查文本中是否包含{{xxx}}来确定是否含变量)构建AST,
(2)优化器
步骤:遍历AST,检测静态子树并标记,重新渲染时,不需要为打上标记的静态节点创建新的虚拟节点,直接克隆即可。
好处:
每次重新渲染时,不需要为静态子树创建新节点
在虚拟DOM中打补丁的过程可以跳过
静态节点&静态根节点
静态节点:所有子节点也都是静态节点,通过markStatic()标记静态节点。
静态根节点:从上向下,找到第一个静态节点就是静态根节点,通过markStaticRoots()标记静态根节点。这里要注意当元素节点只有一个文本节点,即便他是静态根节点也不会被标记:因为优化成本大于收益
(3)代码生成器
代码生成器就是字符串拼接的过程,通过递归AST生成字符串,然后将子节点字符串拼接到根节点的参数上,从顶向下依次处理每个AST节点,一层一层直到完整。
节点有三种类型:元素节点、文本节点、注释节点,这三种对应三种生成字符串的方式
以元素节点举例:它有三个参数:子节点会插入到children的位置上
_C(<tagname>,<data>,<children>)
到这,vue实现的基础我们就了解清楚了…