现在这个时候在聊起vue源码,不论是vue2还是vue3都有些老生常谈了吧。没得办法,谁让咱卷的慢呢,so 权当是个笔记吧
理解Vue的设计思想
MVVM框架的三要素:数据响应式、模板引擎及其渲染
- 数据响应式:监听数据变化并在视图中更新
- 模版引擎:提供描述视图的模版语法
- 渲染:如何将模板转换为html
先考虑new Vue之后都做了什么(vue2)
笼统的来说就做了组件实例化、初始话这么一个事
- 选项合并(mergeOptions)将全局注册的组件换入到new Vue的是实例中
- 组件是实例的一些属性方法的初始化
- 派发两个声明周期的钩子
callHook(vm, 'beforeCreate')
和callHook(vm, 'created')
- 挂载
initLifecycle(vm) // $parent $children 实例属性
initEvent(vm) // 事件的监听
initRender(vm) // 插槽 $slots $scopedSlots _c()/$createElement 生成vdom
callHook(vm, 'beforeCreate')
initInjections(vm) // 注入祖辈传递下来的数据
initState(vm) // 处理props/data/computed/watch/methods
initProvide(vm) // 向后代传递
callHook(vm, 'created')
new Vue
class Vue {
constructor(options) {
// 0.保存options
this.$options = options;
this.$data = options.data;
// 1.将data做响应式处理
new Observer(this.$data);
// 2.为$data做代理
proxy(this, "$data");
// 3.编译模板
if (options.el) {
this.$mount(options.el);
}
}
// 添加$mount
$mount(el) {
this.$el = document.querySelector(el);
// 1.声明updateComponent
const updateComponent = () => {
// 渲染获取视图结构
const el = this.$options.render.call(this);
// 结果追加
const parent = this.$el.parentElement;
parent.insertBefore(el, this.$el.nextSibling);
parent.removeChild(this.$el);
this.$el = el;
};
// 2.new Watcher
new Watcher(this, updateComponent);
}
}
创建Vue时候的时候第一时间保存的了options选项并进行了数据响应式的处理,返回的app在调用$mount进行渲染挂载
其实在new Watcher和$mount()之间还有一个compile的类存在,我这里没有写,因为全写会比较复杂。
从实现数据响应式开始
- Vue.set(obj, "key", "value")
set方法向obj追加key的时候要求obj必须是一个响应式数据,此方法只能用于向响应式数据中追加字段。 - Vue.util.defineReactive(obj, "key", "value")
这个方法对obj是不是响应式数据并没有要求,通过此方法可以在obj中追加字段并且将obj变成一个响应式数据 - 直接new Vue({return {$$state: obj}})
可以在返回的数据直接变成响应式,这里加上$$或者加_是为了在Vue实例过程中避免vue对这个是字段进行代理,只需要做成响应式即可
下面两个版本的代码都标记了依赖收集的入口,以便后续接入依赖收集函数,这里就不过多赘述了! 不同是vue3开始是用createApp
不在使用render的方式
vue2的实现方式
// 根据传⼊value类型做不同操作
class Observer {
constructor(value) {
this.value = value;
// 判断⼀下value类型
// 遍历对象
this.walk(value);
}
walk(obj) {
if (typeof obj !== "object" || obj === null) {
return obj
}
if (Array.isArray(obj)) {
// 覆盖原型,替换我们自己的
obj.__proto__ = arrProtoType;
Object.keys(obj).forEach(key => new Observer(obj[key]))
}
else {
Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]))
}
}
}
}
// 实现对数组响应式的拦截
const methods = ["shift", "unshift", "push", "pop", "splice", "reverse", "sort"]
const arrProtoType = Object.create(Array.prototype)
methods.forEach(method => {
// 覆盖原有数组的方法
arrProtoType[method] = function () {
Array.prototype[method].call(this, ...arguments)
}
})
// 实现对象响应式拦截
const defineReactive = (obj, key, val) => {
new Observer(val)
// 创建dep实例和可以对应
const dep = new Dep()
return Object.defineProperty(obj, key, {
get: () => {
// 在这里做依赖收集
Dep.target && dep.addDep(Dep.target)
return val
},
set: (v) => {
if (v !== val) {
new Observer(v)
val = v
dep.notify()
}
}
})
}
数组实现是拦截是通过修改原型的方式来操作的
vue3的实现方式
function reactive(obj) {
if (typeof obj !== "object" || obj === null) {
return obj
}
return new Proxy(obj, {
get(target, key) {
// 依赖收集
track(target, key)
const targData = Reflect.get(target, key)
return typeof targData === 'object'
? reactive(targData)
: targData
},
set(target, key, val) {
// notify
Reflect.set(target, key, val)
trigger(target, key)
}
})
}
vue3使用proxy代替defineProperty,借助proxy惰性监听的性质提高框架的性能,也修正了对于数组以及新增删除等操作的额外监听需求,因此去除了Vue.set()、Vue.delete()这样的尴尬操作,不过proxy也同样存在问题,对数组进行代理的时候,unshift、pop等操作会多次触发get、set方法,导致重复触发收集依赖函数,vue3中也是做了相应的处理的
编译 Compile
编译的主要任务处理各种节点以及事件监听等工作,熟悉的v-modal
的双向绑定就是在这里实现的
具体实现逻辑可以查看代码
const regExp = /\{\{(.*)\}\}/;
class Compile {
constructor(el, vm) {
// 1、首先保存下Vue的实例,后续会调用
this.$vm = vm
// 编译模板树
this.compile(document.querySelector(el))
}
compile(el) {
// 遍历el
// 判断el的子元素类型
el.childNodes.forEach(node => {
if (node.nodeType === 1) {
// 代表节点为元素
this.compileElement(node)
// 元素需要递归不然就看不到元素内不得值,只能看到当前元素的标签
if (node.childNodes.length) {
this.compile(node)
}
} else if (this.isInter(node)) {
// 插值文本
this.compileText(node)
}
})
}
// 统一做初始化和更新处理
update(node, exp, dir) {
// 初始化
const fn = this[dir + "Updater"];
fn && fn(node, this.$vm[exp])
// 更新
new Watcher(this.$vm, exp, function (val) {
fn && fn(node, val)
})
}
compileElement(node) {
// 获取当前元素的所有属性,并判断他们是不是动态的
const nodeAttrs = node.attributes
Array.from(nodeAttrs).forEach(attr => {
const attrName = attr.name;
const exp = attr.value // 指令的内容
// 判断是指令或者是事件是否是动态的
if (attrName.startsWith("v-")) {
const dir = attrName.substring(2)
// 判断this实力上是否存在dir函数如果存在则调用
this[dir] && this[dir](node, exp)
}
// 事件的处理
if (this.isEvent(attrName)) {
const dir = attrName.substring(1) // 事件名称
// 事件监听
this.eventHandler(node, exp, dir)
}
})
}
// 解析插值文本
compileText(node) {
const regexp = regExp.exec(node.textContent)
this.update(node, regexp[1], "text")
// node.textContent = this.$vm[regexp[1]]
}
textUpdater(node, val) {
node.textContent = val
}
text(node, exp) {
this.update(node, exp, "text")
// node.textContent = this.$vm[exp]
}
htmlUpdater(node, val) {
node.innerHTML = val
}
html(node, exp) {
this.update(node, exp, "html")
// node.innerHTML = this.$vm[exp]
}
modelUpdater(node, val) {
// 只考虑大部分情况
node.value = val
}
model(node, exp) {
// update只负责赋值
this.update(node, exp, "model")
// 监听节点事件
node.addEventListener(node.tagName.toLowerCase(), e => {
// 对原数据进行反向赋值
this.$vm[exp] = e.target.value
})
}
// {{xxoo}}
isInter(node) {
return node.nodeType === 3 && regExp.test(node.textContent)
}
isEvent(dir) {
return dir.startsWith("@")
}
eventHandler(node, exp, dir) {
const methods = this.$vm.$options.methods
const fn = methods && methods[exp]
// 需要修改fn函数的this指向为当前的this.$vm
node.addEventListener(dir, fn.bind(this.$vm))
}
}
依赖收集
视图中会⽤到data中某key,这称为依赖。同⼀个key可能出现多次,每次都需要收集出来⽤⼀个Watcher来维护它们,此过程称为依赖收集
。多个Watcher需要⼀个Dep来管理,需要更新时由Dep统⼀通知。
vue2的依赖收集
原理分析:
- new Vue() ⾸先执⾏初始化,对data执⾏响应化处理,这个过程发⽣在Observer中
- 同时对模板执⾏编译,找到其中动态绑定的数据,从data中获取并初始化视图,这个过程发⽣在Compile中
- 同时定义⼀个更新函数和Watcher,将来对应数据变化时Watcher会调⽤更新函数
- 由于data的某个key在⼀个视图中可能出现多次,所以每个key都需要⼀个管家Dep来管理多个Watcher
将来data中数据⼀旦发⽣变化,会⾸先找到对应的Dep,通知所有Watcher执⾏更新函数
class Watcher { constructor(vm, fn) { this.vm = vm; this.getter = fn; this.get(); } get() { // 依赖收集触发 Dep.target = this; this.getter.call(this.vm); Dep.target = null; } update() { this.get(); } } // 管家:和某个key,⼀⼀对应,管理多个秘书,数据更新时通知他们做更新⼯作 class Dep { constructor() { this.deps = new Set(); } addDep(watcher) { this.deps.add(watcher); } notify() { this.deps.forEach((watcher) => watcher.update()); } }
响应式数据构建过程中每出现一个obj,就会生成一个obsever对象,每一个key对应也有一个dep,
但是在源码中dep和watcher是属于多对多的关系,每一个组件会有一个watcher,
正常情况下一个watcher和dep是1对多,但是源码中提供了$watch("key", function(){})(也叫useWatcher
)的方法,导致dep和watcher是属于多对多的关系
watcher和dep的关系 dep知道自己管理了哪些watcher,同样的每个watcher也知道自己被哪些dep管理,目的是提供$unwatch方法用于解绑。
vue3的依赖收集
vue3中删除了Watcher,取而代之是effect,收集依赖的过程也有所变化;
相关api有
- effect(fn):传⼊fn,返回的函数将是响应式的,内部代理的数据发⽣变化,它会再次执⾏
- track(target, key):建⽴响应式函数与其访问的⽬标(target)和键(key)之间的映射关系
- trigger(target, key):根据track()建⽴的映射关系,找到对应响应式函数并执⾏它
// 临时存储副作用函数
const effectStack = []
// 1.依赖收集函数: 包装fn,立刻执行fn,返回包装结果
function effect(fn) {
const e = createReactiveEffect(fn)
e()
return e
}
function createReactiveEffect(fn) {
const effect = function () {
try {
effectStack.push(fn)
return fn()
} finally {
effectStack.pop()
}
}
return effect
}
// 保存依赖关系的数据结构
const targetMap = new WeakMap()
// 依赖收集:建立target/key和fn之间映射关系
function track(target, key) {
// 1.获取当前的副作用函数
const effect = effectStack[effectStack.length - 1]
if (effect) {
// 2.取出target/key对应的map
let depMap = targetMap.get(target)
if (!depMap) {
depMap = new Map()
targetMap.set(target, depMap)
}
// 3.获取key对应的set
let deps = depMap.get(key)
if (!deps) {
deps = new Set()
depMap.set(key, deps)
}
// 4.存入set
deps.add(effect)
}
}
// 触发更新:当某个响应式数据发生变化,根据target、key获取对应的fn并执行他们
function trigger(target, key) {
// 1.获取target/key对应的set,并遍历执行他们
const depMap = targetMap.get(target)
if (depMap) {
const deps = depMap.get(key)
if (deps) {
deps.forEach(dep => dep())
}
}
}
vue最早是一个key对应一个Watcher,但是随着项目和组件的体积增大,这种方式内存消耗也很大,所以不适用大项目,在后来的升级中粒度被切分变成一个组件一个Watcher并且逐步引入了虚拟dom和diff算法,如今在最新的vue3中Watcher已被删除,也新增了compiler的优化策略
虚拟dom
虚拟DOM(Virtual DOM)是对DOM的JS抽象表示,它们是JS对象,能够描述DOM结构和关系。应用的各种状态变化会作用于虚拟DOM,最终映射到DOM上。
优点:
- 轻量、快速:当他们发生变化的时候时通过新旧DOM比对可以得到最小DOM操作量,配合异步更新策略减少更新频率,提高性能
- 跨平台:将虚拟DOM更新转换不同运行时特殊操作实现跨平台
- 兼容性:还可以加入兼容性代码,增强操作的兼容性
必要性
vue 1.0中有细粒度的数据变化侦测,它是不需要虚拟DOM的,但是细粒度造成了大量开销,这对于大型项目来说是不可接受的。因此,vue 2.0选择了中等粒度的解决方案,每一个组件一个watcher实例,这样状态变化时只能通知到组件,再通过引入虚拟DOM去进行比对和渲染。
patch
vue3的patch函数的主要功能是将vnode转换成真是的node的过程,使用vnode可以携带更多的信息,以便后续的diff和优化策略;
vnode
新的vnode结构中携带了块 block
的相关信息,比如patchFlag
、dynamicChildren
、dynamicProps
等;
patch的过程
- 创建vnode mount()执⾏时,创建根组件VNode
- 渲染vnode render(vnode, rootContainer)⽅法将创建的vnode渲染到根容器上。
- 初始patch 传⼊oldVnode为null,初始patch为创建⾏为
- 创建⼀个渲染副作⽤(setupRenderEffec),执⾏render,获得vnode之后,在执⾏patch转换为dom
- setupRenderEffect在初始化阶段核⼼任务是执⾏instance的render函数获取subTree,最后patch这个subTree,在这个过程中会使用shapeFlag这个字段,主要作用是标记当前的节点的组件形态的,比如:当前节点是一个文本节点,那么只需要在后续的patch中创建完文本节点并设置节点的内容即可,不需要像vue2中那样patch完文本节点还要patch文本节点的内容
- 更新阶段,patch函数对⽐新旧vnode,得出dom操作内容,编译过程中通过观察patchFlag、dynamicChildren等做出优化,patchFlag是确定当前节点在更新时候使用什么方式,比如:样式、属性等,dynamicChildren是存放子元素中动态变化的子元素,只需要将其存放的子元素拿出来递归patch进行精准更新即可,不需要遍历当前节点下的所有子节点。
- 如果同时存在多个⼦元素,⽐如使⽤v-for时的情况:即典型的重排操作,使⽤patchChildren进行diff操作(数组中本来就是不规律的动态变化,使用dynamicChildren意义不大),是否是多个子节点的判定也是使用patchFlag来判定的
diff
diff算法这个东西属实不想写,之前的人写的太多了,都写烂了,无非就是双端比较,新首旧首、新尾旧尾、新首旧尾、旧首新尾之间的比较,然后剩下的做增删操作,这是vue2的算法。
咱们今天来说说vue3的吧
在vue3diff算法也有不小的改动,虽然保留了双端比较,但是只保留了新首旧首、新尾旧尾之间的比较,不在有交叉比较了,在以上两种情况比较完成以后,diff算法将剩余的节点分成了以下几种情况:
- 老节点没有了,则新增
- 新节点没有了,则删除
- 新老节点都有,则将老节点转成Map,循环新节点去老节点中查找是否存在,不存在新增,存在则判定为移动,这里和react的diff类似,不过这里有一个细节,就是用了一个很经典的算法(最长递增子序列)
编译器(compiler)
- 编译器的作用将是生成渲染函数,将模板进行编译、解析
执行时刻需要区分不同的使用环境
- runtime-compiler
步骤:template --> ast --> render函数 --> vdom --> 真实DOM
优点:可以使用template选项,选择更灵活
执行时机:在vue.$mount()挂载的时候执行
ast2也是一个抽象语法树,但是与ast1不同的是其携带的信息不同,ast2主要是的作用是用后续的patch和diff。
- runtime-only
步骤:render函数 --> vdom --> 真实DOM
优点:体积小、运行速度快
执行时机:使用webpack的vue-loader进行预编译
- runtime-compiler
这里简单说下runtime-compiler
下编译器的工作过程
- app.mount()获取template
- compile将传⼊template编译为render函数
- 第⼀步解析-parse:解析字符串template为抽象语法树ast
- 第⼆步转换-transform:解析属性、样式、指令等
- 第三步⽣成-generate:将ast转换为渲染函数,这一步渲染函数是一个字符串,需要在compile中
return new Function(code)()
编译器的优化策略
静态节点提升
将静态节点进行缓存,用内存换时间
补丁标记和动态属性记录(patchFlag)
只关注节点动态变化的部分,对其进行标记,下次更新的时候只更新能变化的部分
缓存事件处理程序
缓存事件,避免直接书写事件函数导致的不必要更新,直接写箭头函数,会会导致每次编译到这里的时候都会生成新的函数,使得子树更新,缓存以后即可避免,功能类似于react的useCallback.
块 block
将模板切分成块,将动态的节点进行保存(保存在dynamicChildren字段中),这样下次更新就不需要遍历整棵树,而是对保存的动态节点进行遍历即可,降低复杂度。
注意:
- jsx转换过程中也会生成ast,有区别的是jsx本质还是js,没有进行预编译,那么所携带的信息会很少。所以理论上vue中的jsx也不能享受到
完整优化策略
带来的性能提升。 - vue也好,react也好,他们都用到了ast,但是它们都会自己单独维护自己的规则。
关于编译器的调试
在vue3源码的package.json中找到dev-compiler
命令并执行,之后框架会在packages/template-explorer
这个包下面输出一个dist文件,这个文件出现说明编译成功,我可以直接查看packages/template-explorer
包下的local.html
,直接在浏览器中打开即可调试。
vue中$nexttick(异步渲染)实现过程中异步降级
在vue2版本中是有这样一个降级的过程的,主要是为了兼容不同版本的浏览器,但是在vue3中,就比较激进了,直接使用promise.then
入队,兼容性相对vue2会差一些。
- 先是promise.then
- 其次是MutationObsever
- 然后setImmediate
- 最后是setTimeout
升级vue3的动机
- 类型支持更友好
- 有利于tree-shaking
- API简化、一致性:render函数、sync修饰符、指令等
- 复用性:composition API
- 性能优化:响应式、编译优化
- 扩展性:自定义渲染器
vue源码调试
叨叨了这么多想必你也想调试源码了吧,手动狗头!
获取源码
我们可以直接在github上直接克隆迁出
目录结构
调试环境搭建
- 安装依赖: npm i
e2e工具安装时间会很长时间,可以选择在安装phantom.js时终止,并不会影响我们调试 - 安装rollup:npm i rollup
- 修改dev脚本,配置sourcemap
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
- 运行开发命令:npm run dev, 输出dist文件
- 新建一个index.html文件,引入vue.js文件
<script src="../../dist/vue.js"></script>
- 开始愉快的调试旅程
goodbye
手写vue代码参考
至此,vue2和vue3的区别以及两个版本大致的流程就全过了一遍了,有兴趣的可以自己去看下源码,个人觉得还是挺有意思的,希望我的文章对你有所帮助。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。