站在coderwhy的肩膀上
这里简单说明一下,这是第一次写掘金文章,所以文章中有哪里说得不对的,欢迎大家进行纠正,大家共同进步,之所以要写这篇文章纯粹是突发奇想,想记录一下自己写代码的过程。文章的内容也稍微会比较多一些,因为里面的细节比较多,所以希望大家可以耐心的看完,保证你看完也可以手写一个mini-vue。 实现过程中不会过多的去判断边界情况,在进行比较的时候也不会有key情况的介入 在国内vue这个框架大家应该都是非常的熟悉的了,可以说vue在国内就是大小通吃,而且前几天尤大本人宣布vue3将成为默认版本,这也意味着vue3将会迅速的覆盖到以后大大小小的开发中,所以赶紧将vue学起来吧!
一、了解vue的工作流程
既然要实现一个mini-vue,那么首先就得过一遍vue的工作流程,然后掐头去尾取出最重要的部分进行简单的实现
1.元素在vue内部的变化
<div id="app"></div>
<template id="my-app">
<div>Hello Vue</div>
</template>
<script>
const app = Vue.createApp({template:"#my-app"})
app.mount("#app")
</script>
这里有一张前几个月尤大实现mini-vue时所用到的图,简单的演示了一下我们所写的代码是如何渲染到浏览器上面的
可以看到,图中的div会转换成VNode节点,也就是虚拟节点,然后再转换成真实的元素,最终交给浏览器进行渲染
什么是VNode呢?
VNode可以说是一个虚拟节点,多个VNode组成的VNodes称为虚拟DOM。为什么要有虚拟DOM呢?我们平时的开发中是会经常给浏览器打交道的,最熟悉不过的就是操作DOM,但是我们频繁的直接操作DOM会经常的触发回流操作,而且找到所需的 DOM 节点并用 JavaScript 更新它们的成本很高,导致我们的性能消耗较高,所以为了解决这个问题,虚拟DOM就登场了。 虚拟DOM做了什么事情? 我们的每一个元素节点,都会对应一个VNode,多个VNode组成的就叫做虚拟DOM,当我们的数据发生了改变,我们是对虚拟DOM进行修改,然后虚拟DOM会去跟真实的DOM进行比对,它们之间执行diff,计算出需要更新的地方然后更新,所以虚拟DOM帮我们最小化了对DOM的操作 简单介绍完之后重新看上面的代码是不是就明白了一点vue对元素转化的过程了,但是我们上面的代码还包裹了一层template模板,这个模板vue在转化的时候是怎么进行转化的呢?这里还是放一张尤大之前实现mini-vue时放的图
当 Vue 接收到一个
模板(template)时,在创建一个虚拟节点之前,它首先将它编译成一个渲染函数,也就是说我们的template会被编译成我们的render函数,然后执行render函数返回一个VNode,然后再转换成真实节点进行渲染 上面就是元素在vue内部具体转换的过程,当然,真实情况下肯定是执行了很多其他的操作的,包括vue3新出的Block Tree,还有很多其他边界的判断,这里都略过了
2.响应式的简单说明
我们上面看到的是元素在vue内部转化的过程,但是还少了一部分就是对数据响应式的处理,我们来看看官方是怎么解释响应式的。
我们可以很清楚的看到,
组件模板,也就是我们的template,在编译成render函数之后会被包裹在一个副作用中,这个副作用是什么后面会具体说到,包裹之后就会使我们的render变成响应式,当我们模板中依赖到的数据发生变化时,就会重新执行render函数生成新的VNodes跟旧的进行对比然后更新我们的页面
二、mini-vue实现前的了解
1.三大核心模块
从上面的转换结果我们应该可以看出,vue的核心模块应该是三个,分别是
编译系统,渲染系统和响应式系统,渲染系统包括了生成vnode、挂载、更新这三个操作,下面使用一张图片简单的介绍一下他们之间的是如何分工合作的 这里用的是全栈然叔文章里面的一张动图,点击前往文章
2.h函数的介绍
我们上面介绍完元素和数据具体的转化后我们就要开始mini-vue的编写了,但是在编写之前这里先声明一下,我们的模板转换成render函数的过程这里就不具体实现了,主要原因是因为自己的能力不够,所以我们直接从render函数开始实现
那我们就具体来看一下render函数里面是如何生成VNode的,
其实render函数内部是使用了一个h函数来生成我们的VNode的,在vue2会将h函数传给render作为参数,vue3则是需要自己从vue导入h函数,h函数的作用就是生成一个VNode对象,然后返回。所以render函数内部是返回了h函数的调用结果
3.h函数的使用
知己知彼才能百战百胜,既然我们要实现它,那么得先知道它简单的执行原理和过程,生成vnode之后就将我们的vnode转换成真实DOM然后挂载到到我们要挂载的元素上
render(){
// h函数接收三个参数 type、props和children
// 执行h函数生成vnode然后返回
return h("h2", {class: "title"}, "Hello Render")
}
三、渲染系统的实现
1.h函数的简单实现
首先,我们知道h函数接收三个参数,分别是type、props、children,然后会返回一个VNode,VNode其实是一个js对象,所以我们可以很简单的模拟一下h函数
// type 是元素的类型
// props 是传递过去的属性
// children 是元素的内容或者它的子元素
function h(type,props,children){
return {
type,
props,
children
}
}
console.log(h('div',{class:'h'},'Hello H'))
这样h函数就简单的实现了,接下来就是mount挂载过程的实现
2.mountEl挂载函数的简单实现
mountEl函数主要是帮我们将vnode挂载到目标元素的身上,所以至少要接收
两个参数,一个是vnode,另外一个是目标元素
2.1 将vnode转换成真实元素,然后将el添加到vnode上面做为vnode的属性,方便下次进行diff比较
function mountEl(vnode,container){
const el = vnode.el = document.createElement(vnode.tag)
}
2.2 给创建出来的el元素添加属性
function mountEl(vnode,container){
const el = vnode.el = document.createElement(vnode.tag)
// 判断是否有传入属性
if(vnode.props){
// 拿到所有的key
for(const key in vnode.props){
// 这里需要对key进行判断,判断是否有以on开头的事件,如果有要单独处理
if (key.startsWith('on')) {
// 将前面两位on截掉,并且转换成小写,给el添加事件属性
el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key])
} else {
// 给el添加属性
el.setAttribute(key, vnode.props[key])
}
}
}
}
2.3 给el元素添加子元素
function mountEl(vnode,container){
const el = vnode.el = document.createElement(vnode.tag)
if(vnode.props){
for(const key in vnode.props){
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key])
} else {
el.setAttribute(key, vnode.props[key])
}
}
}
// 判断是否有子元素
if(vnode.children){
// 判断子元素类型,如果传过来的子元素类型是字符串,则直接做为el的textContent属性
if(typeof vnode.children === "string"){
el.textContenxt = vnode.children
}else{
// 这里不考虑对象或者其他的类型
// 如果有其他的子节点就进行递归调用,不对插槽和其他情况进行边界处理
vnode.childer.forEach(item => {
// 将递归创建出来的元素挂载到 el 元素下面
mount(item, el)
})
}
}
// 最后将创建出来的el元素挂载到container上
container.appendChild(el)
}
2.4 写完之后看一下结果展示
<div id="app"></div>
const vnodes = h('div',{class:"div"},[
h('h2',{class:"h2"},"你好"),
h('button',{onClick:() => console.log("你好")},"点击打印")
])
mountEl(vnodes,document.querySelector("#app"))
3.patch函数简单实现
上面我们已经简单的实现了h函数和挂载函数,那么渲染系统就剩下最后一个patch函数,接下来就实现一个简单版的patch函数 patch函数主要是对两个VNodes之间进行diff比较,对需要修改的地方进行修改
- 既然patch需要
对比两个VNodes之间的差异,那么肯定至少需要接收两个参数,一个是新的VNodes一个是旧的VNodes,那么我们拿到两个VNodes之后第一步需要做什么呢?首先先对两个VNodes的类型进行判断,如果你们两个VNodes的类型都不一样,那么肯定是需要重新挂载的
// n1:旧的VNodes
// n2:新的VNodes
function patch(n1,n2){
// 如果两个类型不一样,那么就卸载n1,挂载n2
if(n1.type !== n2.type){
// 拿到n1的父元素,在父元素卸载n1
const n1ElParent = n1.el.parentElement
// 卸载n1
n1ElParent.removeChild(n1.el)
// 挂载n2到n1ElParent
mount(n2, n1ElParent)
}
}
- 如果两个VNodes的type一样,那么就需要进行额外的处理,首先是对props的处理
function patch(n1,n2){
if(n1.type !== n2.type){
const n1ElParent = n1.el.parentElement
n1ElParent.removeChild(n1.el)
mount(n2, n1ElParent)
} else{
// 首先,拿到n1的el对象,然后保存到n2上面,这里修改el,n2里面的el也会同时改变,因为指向同一个内存地址
const el = n2.el = n1.el
// 拿到n1和n2的props
const oldProps = n1.props || {}
const newProps = n2.props || {}
// 判断两个对象之间属性的差异
for(const key in newProps){
const oldValue = oldProps[key]
const newValue = newProps[key]
// 如果他们之间的属性值不一样,就进行替换
if(newValue !== oldValue){
// 这里还是对事件进行判断
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue)
}
}
}
// 删除旧VNodes上面的属性
for(const key in oldProps){
const oldValue = oldProps[key]
if (key.startsWith('on')) {
el.removeEventListener(key.slice(2).toLowerCase(), oldValue)
}
if (!(key in newProps)) {
el.removeAttribute(key)
}
}
}
}
- 上面的props就简单的处理一下,没有考虑过多的边界情况和条件,处理完props之后就对两个VNodes之间的子元素进行处理了
function patch(n1,n2){
if(n1.type !== n2.type){
const n1ElParent = n1.el.parentElement
n1ElParent.removeChild(n1.el)
mount(n2, n1ElParent)
} else{
const el = n2.el = n1.el
const oldProps = n1.props || {}
const newProps = n2.props || {}
for(const key in newProps){
const oldValue = oldProps[key]
const newValue = newProps[key]
if(newValue !== oldValue){
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue)
}
}
}
for(const key in oldProps){
const oldValue = oldProps[key]
if (key.startsWith('on')) {
el.removeEventListener(key.slice(2).toLowerCase(), oldValue)
}
if (!(key in newProps)) {
el.removeAttribute(key)
}
}
// 对两个VNodes的children进行处理
// 如果n2.children的类型为string,那么直接替换掉n1的子元素
const oldChildren = n1.children || []
const newChildren = n2.children || []
if(typeof newChildren === "string"){
// 也可以对n1的children进行类型判断,可以做一定程度的优化
if(typeof oldChildren === "string"){
// 如果都为字符串类型,那么就只有当他们之间的值不一样的时候才进行替换
if(newChildren !== oldChildren){
el.textContenxt = newChildren
}
}else{
// 如果oldChildren类型不为字符串,则直接将n1的innerHTML更换为newChildren
el.innerHTML = newChildren
}
} else{
// 如果newChildren的类型不为字符串,那么就判断oldChildren的类型是否为字符串
// 注意:这里不会对其他类型进行考虑,例如插槽的对象类型,这里只对字符串和数组类型进行判断
if(typeof oldChildren === "string"){
// 如果oldChildren的类型为字符串,就将newChildren的元素挂载到el上面
el.innerHTML = ''
newChildren.forEach(item => mount(item,el))
}else{
// 如果newChildren和oldChildren都为数组,这种情况下面说明一下在进行处理
}
}
}
}
3.1 如果newChildren和oldChildren都为数组类型
如果两个类型都为数组,只会按照顺序进行比较,不会考虑key的情况,也不会考虑头尾比较的情况,这里只是简单的对两个数组从头开始比较
function patch(n1,n2){
if(n1.type !== n2.type){
const n1ElParent = n1.el.parentElement
n1ElParent.removeChild(n1.el)
mount(n2, n1ElParent)
} else{
const el = n2.el = n1.el
const oldProps = n1.props || {}
const newProps = n2.props || {}
for(const key in newProps){
const oldValue = oldProps[key]
const newValue = newProps[key]
if(newValue !== oldValue){
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue)
}
}
}
for(const key in oldProps){
const oldValue = oldProps[key]
if (key.startsWith('on')) {
el.removeEventListener(key.slice(2).toLowerCase(), oldValue)
}
if (!(key in newProps)) {
el.removeAttribute(key)
}
}
const oldChildren = n1.children || []
const newChildren = n2.children || []
if(typeof newChildren === "string"){
if(typeof oldChildren === "string"){
if(newChildren !== oldChildren){
el.textContenxt = newChildren
}
}else{
el.innerHTML = newChildren
}
} else{
if(typeof oldChildren === "string"){
el.innerHTML = ''
newChildren.forEach(item => mount(item,el))
}else{
// 如果newChildren和oldChildren都为数组
// oldChildren:[v1,v5,v6,v7,v8]
// newChildren:[v1,v2,v3]
// 拿两个数组之间长度最短的值
const MinLength = Math.min(newChildren.length,oldChildren.length)
for(let i = 0; i < MinLength; i++){
// 获取他们之间的VNode进行patch递归调用
patch(oldChildren[i],newChildren[i])
}
// 如果oldChildren的长度比newChildren的长度长,那么就卸载多出来的VNode
if(newChildren.length < oldChildren.length){
oldChildren.slice(newChildren.length).forEach(item => el.removeChild(item.el))
}
// 如果newChildren的长度比oldChildren的长度长,那么就挂载元素
if(newChildren.length > oldChildren.length){
newChildren.slice(oldChildren.length).forEach(item => mount(item,el))
}
}
}
}
}
- ok,上面就是patch的简单实现,再说一遍,上面是省略了很多功能的patch,下面看一下效果
const VNode = h('div', { class: 'title' }, [
h('h2', { class: "h2" }, '你好'),
h('button', { onclick: function () { console.log('你好') } }, '点击打印')
])
mount(VNode, document.querySelector('#app'))
const VNode1 = h('a', { class: 'header', id: 'div' }, [
h('h2', null, '新的h2'),
h('button', { onclick: function () { console.log('按钮点击了') } }, '点击-1')
])
// 为了看出效果,延迟两秒执行更新操作
setTimeout(() => {
patch(VNode, VNode1)
}, 2000)
可以看到最终元素的类型和属性点击事件都有发生,那么到这里渲染系统就先告一段落了,下面就是响应式系统的简单实现了
四、响应式系统的实现
1.什么是响应式
在实现响应式系统之前我们想了解一下什么是响应式,比如我们有一个变量,我们其他代码对他进行了加一或者乘以一的操作,如果在某个时刻,这个变量他的值发生了变化,我们希望其他依赖到这个变量的代码也自动的跟着重新执行,这就是响应式的简单描述 比如,我们有以下代码
var count = 1;
console.log(count + 2);
console.log(count - 2);
console.log(count * 2);
console.log(count / 2);
// 当变量发生改变的时候我们希望上面的一堆代码也跟跟着一起改变
count = 5
那么现在问题来了,我们要怎么知道在值发生变化之后执行那些代码呢?这里我引用一下vue官网的方案
从vue官网的方案中我们知道了,想要知道值发生之后执行哪些代码,我们需要对这些代码用一个函数包裹起来,然后值发生变化的时候执行这个函数即可
var count = 1;
// console.log(count + 2);
// console.log(count - 2);
// console.log(count * 2);
// console.log(count / 2);
count = 5
function foo() {
console.log(count + 2);
console.log(count - 2);
console.log(count * 2);
console.log(count / 2);
}
// 当值发生变化之后我们重新调用这个函数即可实现更新操作
foo()
上面虽然可以知道哪些代码需要执行,但是有一个问题,就是如果有多个函数都需要在值发生变化之后进行调用,难道我们都这样手动进行调用吗?答案肯定是不会的啦,下面我们就要封装一个响应式函数的,替我们收集这些函数和执行这些函数
2.响应式函数的封装
那我们具体要怎么实现这个需求呢?先想一下,我们需要拿到这些需要重新执行的函数,所以封装的响应式函数肯定需要别人传给我一个函数
const person = {
name:'小红',
sex:'女'
}
// 别人传给我一个需要响应式的函数
const reactiveFns = []
function watchFn(fn){
// 但是肯定是不止一个函数需要响应式,所以我们需要创建一个数组来存放这多个响应式函数
// 将别人传给我的函数存到数组里
reactiveFns.push(fn)
}
// 别人使用的时候,只需要将要响应式的函数传给watchFn即可
watchFn(function(){
console.log(person.name)
})
watchFn(function(){
console.log(person.name)
})
watchFn(function(){
console.log(person.sex)
})
person.sex = '男'
// 当我们的值发生变化之后,拿到reactiveFns数组进行遍历执行里面的响应式函数,这样就能实现多个响应式函数自动执行的效果
reactiveFns.map(fn => fn())
OK,让我们来看一下效果
我们可以看到,上面已经可以实现我们多个函数的执行,但是这样子还是会有问题,具体是什么问题呢?我们上面也看到了,我们明明是
sex属性发生了变化,按道理来讲,我们只需要最后一个函数重新执行就好了,但是结果却是全部都重新执行了,这是为什么呢? 因为我们在存放函数的时候是将他们全部的放到一个数组里面去了,我们在遍历执行的时候会全部进行重新执行,就导致了这样的结果,显然,我们将函数全部放在一个数组里是不太好的,所以我们需要对函数存放的位置进行重构,那我们要使用声明替代呢?这里我们使用的是类来替代,下面我们使用类对reactiveFns数组进行重构
3.使用类对响应式函数进行重构
// 创建一个 Depend 类,来对我们的响应式函数进行保存
class Depend {
constructor() {
this.reactive = []
}
// 给Depend类添加一个addDep方法,可以将响应式函数添加到实例对象的reactive数组里
addDep(fn) {
this.reactive.push(fn)
}
// 添加一个执行函数的方法
notify() {
this.reactive.forEach(fn => fn())
}
}
// 使用类来存放我们的响应式函数,多个对象和多个属性如何来管理等到到后面再说
const dep = new Depend()
function watchFn(fn) {
dep.addDep(fn)
}
const person = {
name: '小红',
sex:'女'
}
watchFn(function () {
console.log(person.name);
})
watchFn(function () {
console.log(person.name);
})
watchFn(function () {
console.log(person.sex);
})
person.sex = '男'
// 当name发生改变的时候只需要调用dep的notify方法即可
dep.notify()
具体的转换过程就是这样,还是将我们的函数存放在数组里面,这里只是他们之间的逻辑进行了修改,不同依赖之间如何存放后面再说,先看一下效果
可以看到,最终的运行结果是没有问题的,但是到了这里我们会想到另一个问题,就是我们每次在变量发生
改变的时候都是自己手动去调用函数,显得很不智能,所以我们接下来就要对代码进行进一步的修改,让它可以在变量发生变化的时候自己去执行需要响应的函数
4.监听值的变化
我们可以想一下我们要如何知道属性的变化呢?这里有两个办法,分别是
Object.defineProperty和proxy,vue官方也是使用这两个对我们数据的变化进行监听,Object.defineProperty是vue2实现响应式使用的,后来vue3换成了proxy进行监听,我们这里先用vue2的方法,也就是Object.defineProperty,后面再用proxy,也会说一下vue为什么要换成peoxy的主要原因,所以我们这里先来简单的认识一下Object.defineProperty是如何监听我们数据的变化的
4.1 Object.defineProperty的简单了解
既然要使用这个方法,那么我们就需要先了解一下这个方法怎么使用,它具体的一些参数和使用方法,这里放一张MDN对Object.defineProperty的说明,顺带附上文字
对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。
看了上面来自MDN的解释我们可以知道,使用Object.defineProperty主要有
两种方法,一种是数据描述符,一种是存取描述符。什么是数据描述符呢?简单来说就是我们可以通过这种方法给对象添加属性,也可以给他赋值,对这个属性进行描述,很显然这达不到我们想要的效果,那么就是另外一个了,存取属性描述符,可以监听对象的获取和赋值,那么这个就是我们要使用的方法了,那么我们下面就简单的了解一下,如果你还想了解更多,可以点击这里进行了解
const person = {
name: '小红',
age: 20,
sex: '女'
}
Object.keys(person).map(key => {
let value = person[key]
Object.defineProperty(person, key, {
get() {
console.log('执行了取值方法',key)
return value
},
set(newValue) {
console.log('执行了赋值方法', newValue);
value = newValue
}
})
})
person.sex = '男'
console.log(person.name)
ok,上面就使用了Object.defineProperty对person对象进行了数据劫持,那么下面我们就用Object.defineProperty来对我们的响应式函数进行重构,达到自动执行函数的效果
4.2 使用Object.defineProperty实现自动执行
class Depend {
constructor() {
this.reactive = []
}
addDep(fn) {
this.reactive.push(fn)
}
notify() {
this.reactive.forEach(fn => fn())
}
}
const dep = new Depend()
function watchFn(fn) {
dep.addDep(fn)
}
const person = {
name: '小红',
sex:'女'
}
// 对person对象进行数据劫持
Object.keys(person).map(key => {
let value = person[key]
Object.defineProperty(person, key, {
get() {
return value
},
set(newValue) {
value = newValue
// 将我们的值设置完之后就执行我们上面的dep的notify方法重新执行响应式函数
dep.notify()
}
})
})
watchFn(function () {
console.log(person.name);
})
watchFn(function () {
console.log(person.name);
})
watchFn(function () {
console.log(person.sex);
})
person.sex = '男'
我们可以看到最终的结果是一样的,我们没有手动的调用方法,而是换成了设置值的时候给我们自动执行,但是我们还是可以看到,没有依赖sex属性的函数也是发生了变化,甚至我们创建不同的对象,然后对不同的对象进行依赖的时候,这些响应式函数都是放在一起的,这样显然是不行的,那么我们应该怎么办呢?我们可以先来捋一捋属性和依赖函数之间的关系
4.2对象、属性和依赖函数之间的关系
- 我们上面可以看到我们无论哪个属性发生变化,我们所有的函数都会重新执行,这样是不对的,理想状态应该是每一个属性都有自己的依赖函数,例如这样
graph TD
person --> name --> name的依赖函数
person --> sex --> sex的依赖函数
2.它们之间的关系理清楚之后那么应该怎么实现呢?这里我们就可以用到一个数据结构,那就是Map了,用Map替我们管理它们之间的关系
// 创建一个person的Map对象
const personMap = new Map()
personMap.set('name',[fn1,fn2,fn3])
personMap.set('sex',[fn1,fn2,fn3])
这样就可以用Map管理它们之间的关系,那么我们对象属性的依赖关系理清楚了,那我们不同对象之间应该怎么管理呢?这个其实就是多了一层关系,我们继续用图来表示一下
graph TD
container --> person
container --> person1
person --> name --> name的依赖函数
person --> sex --> sex的依赖函数
person1 --> name1 --> name1的依赖函数
person1 --> sex1 --> sex1的依赖函数
- 通过上面的图我们就可以很清楚的看明白它们之间的关系了,无非就是
多了一层包起来而已,每一个对象的Map对象都有自己属性的依赖管理,通过container来管理不同的对象,那么这种情况我们应该怎么实现呢?相信聪明的你已经想到了,没错,就是使用Map,但是这次不是使用普通的Map了,是使用WeakMap,WeakMap是什么,相信有少部分人是不知道的,这里就简单的说一下WeakMap和Map之间的区别
- 一、WeakMap对象的key不能为基本数据类型,必须是对象
- 二、WeakMap不能进行遍历
- 三、WeakMap的引用是弱引用
一和二的区别你们可以自己去简单的验证一下,这里主要说一下第三个区别
4.3 WeakMap是弱引用
老样子,这里还是引用一下MDN对WeakMap的解释
- 相信看完之后你会发现一个词,那就是
弱引用,那么既然有弱引用了,那肯定就会有强引用了,这里就简单的说一下弱引用和强引用它们之间的区别
// 我们平时写这样引用赋值的代码,它们之间的引用就是强引用
cosnt obj = {name:'小红'}
// Map的引用也是强引用
const objMap = new Map()
objMap.set(obj,'aaa')
我们可以想一下,如果我们的obj赋值为null,让他指向空,那么{name:'小红'}这个对象会不会被销毁呢?
答案是不会的,为什么呢,我们可以画一张图看一下他们之间的引用关系
我们可以看到,如果将obj
赋值为null,那么它最开始的指向就会销毁,但是这个时候,0x100这个对象会被垃圾回收算法进行回收吗?很显然是不会的,因为Map的key是obj,引用着这个0x100对象,有引用指着它,所以这个对象是不会销毁的,但是这样就明显是有问题的,明明我obj都赋值为null了,你这个对象还没有销毁,这就导致了内存泄露的问题,所以为了解决这种问题,就有了WeakMap,它的引用是弱引用,我们还是用代码和图来解释
2. WeakMap的使用
cosnt obj = {name:'小红'}
// WeakMap的引用是弱引用
const objWeakMap = new WeakMap()
objWeakMap.set(obj,'aaa')
我们可以看到,当我们的obj指向null时,objWeakMap它key的引用也会消失,这就是
弱引用的特点,如果没有其他任何引用着0x100时,那么弱引用的指向就会消失,这个时候0x100这个对象就没有对象引用着它,那么它就会被垃圾回收算法回收掉,不会造成内存泄露的影响,这就是为什么我们上面在管理对象的时候要用WeakMap的原因,这也是为什么WeakMap不能被遍历的原因,因为它的引用是弱引用所以WeakMap的 key 是不可枚举的(没有方法能给出所有的 key),这里也有一篇证明WeakMap是弱引用的文章,点击前往
五、使用Map、WeakMap进行依赖管理
OK,我们上面说了那么多,现在我们就具体用代码来实现一下它们之间的依赖关系
class Depend {
constructor() {
this.reactive = new Set()
}
addDep(fn) {
if (!fn) return
this.reactive.add(fn)
}
notify() {
this.reactive.forEach(fn => fn())
}
}
// 这个时候就需要来到watchFn函数这里了,我们现在已经不需要这个dep对象了
// get操作的时候我们拿不到传到这里的函数,但是watchFn可以拿到这个函数啊
// 那么我们就可以在全局创建一个变量,然后将传给watchFn的函数赋值给这个变量
// const dep = new Depend()
let dependFn = null
function watchFn(fn) {
dependFn = fn
// 这里需要将传入进来的fn执行一次,触发get操作捕捉依赖
fn()
// 执行完之后将dependFn初始化
dependFn = null;
}
// 封装一个获取正确depend函数
// 创建一个WeakMap对象来保存
const targetWeakMap = new WeakMap()
function getDepend(tag, key) {
// 根据传入的对象,取出这个对象的 Map值
let map = targetWeakMap.get(tag)
// 当然,我们在第一次进行获取的时候肯定是没有值的,所以我们需要在这里做一下判断
if (!map) {
map = new Map()
targetWeakMap.set(tag, map)
}
// map就是存放对象各个属性对应依赖函数的对象,然后根据key去这个map对象里面取出对应的依赖函数
let dep = map.get(key)
// 这里的dep也一样,在第一次取的时候也是没有值的,所以需要做判断
if (!dep) {
dep = new Depend()
map.set(key, dep)
}
// dep就是我们对象里面各个属性对应的依赖函数了
return dep
}
const person = {
name: '小红',
sex:'女'
}
Object.keys(person).forEach(key => {
let value = person[key]
Object.defineProperty(person, key, {
get() {
// 我们在get操作的时候就应该获取dep然后给里面添加依赖函数,但是我们要怎么拿到传给watchFn的依赖函数呢
const dep = getDepend(person, key)
// 上面有了dependFn全局变量之后,我们就可以将这个dependFn里面保存的依赖函数添加到正确的依赖里面了
dep.addDep(dependFn)
return value
},
set(newValue) {
value = newValue
// 设置值之后通过getDepend函数获取正确的依赖函数,然后执行
const dep = getDepend(person, key)
// 但是我们这里获取到的还是空的,这是为什么呢,这是因为我们在get的时候没有给他添加依赖进去
console.log(dep);
dep.notify()
}
})
})
watchFn(function () {
console.log(person.name);
})
watchFn(function () {
console.log(person.name);
})
watchFn(function () {
console.log(person.sex);
})
person.sex = '男'
我们可以看到,现在我们在修改sex属性的时候其他依赖其他属性的函数已经不会跟着改变了,这就说明它们之间的依赖现在已经管理清楚了,
根据传进去的对象,获取对象的Map,然后根据key,去Map里面获取属性对相应的依赖函数,然后执行
graph TD
container --> person
container --> person1
person --> name --> name的依赖函数
person --> sex --> sex的依赖函数
person1 --> name1 --> name1的依赖函数
person1 --> sex1 --> sex1的依赖函数
除了更改依赖管理之外,还对其他代码进行了修改,因为我们的虽然是设置值的时候可以获取对应的依赖,但是获取到的依赖都是空的,这是因为我们没有经过get操作,去添加对应的依赖函数,所以在watchFn里面先执行一遍,获取依赖
但是这样还是会存在一些问题,就是我们如果是
新创建的对象,我们依赖了这个对象里面的属性,那么我们在修改值的时候会自动执行吗,很显然这是不会的,因为我们只对person对象进行了数据劫持,其他新增的对象如果想要实现响应式,就得重新执行Object.defineProperty操作,很显然这样子是不妥的,如果对象多了,这是很费劲的一件事,所以我们需要对这个问题做一些优化,
六.依赖对象的自动响应式
这个其实比较简单,我们只需要封装一个reactive函数,然后别人传进来一个对象,我们对这个对象做数据劫持,然后返回出去就行了
class Depend {
constructor() {
this.reactive = []
}
addDep() {
if (!dependFn) return
this.reactive.push(dependFn)
}
notify() {
this.reactive.forEach(fn => fn())
}
}
let dependFn = null
function watchFn(fn) {
dependFn = fn
fn()
dependFn = null;
}
const targetWeakMap = new WeakMap()
function getDepend(tag, key) {
let map = targetWeakMap.get(tag)
if (!map) {
map = new Map()
targetWeakMap.set(tag, map)
}
let dep = map.get(key)
if (!dep) {
dep = new Depend()
map.set(key, dep)
}
return dep
}
// 我们可以封装一个函数,来帮我们自动的劫持对象属性的变化
function reactive(obj) {
// 将传递进来的对象进行数据劫持,然后把传进来的对象返回出去
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
get() {
const dep = getDepend(obj, key)
// 如果我们希望在添加依赖函数的时候更简单的话,我们可以直接不传参数,直接调用即可,看9行
dep.addDep()
return value
},
set(newValue) {
value = newValue
const dep = getDepend(obj, key)
dep.notify()
}
})
})
return obj
}
// 所以,我们在用响应式对象的时候就应该这样子用
const person = reactive({
name: '小红',
sex:'男'
})
const obj = reactive({
name: 'YANG',
age: 18
})
watchFn(function () {
console.log(person.name);
console.log(person.name);
})
watchFn(function () {
console.log(obj.age);
})
person.name = 'A-YANG'
obj.age = 666
我们可以看到,我们不同的对象也可以实现响应式了,但是你们也看到了,还有另一个问题,就是为什么我们的A-YANG会执行那么多次呢?其实这很简单,就是我们取了两次person.name的值,所以触发了两个get操作,就将我们的函数添加了两次,执行的时候自然也会执行两次。怎么解决呢?我们存的时候不要是数组就行了,换成new Set,因为Set里面的数据不会重复,记得更换了set需要将push换成add,看一下效果
七、proxy实现
响应式系统到这里就已经实现完毕了,但其实还有另外一种实现响应式的方法,就是现在
vue3使用proxy,那为什么vue3要使用proxy呢?
- vue2使用的Object.defineProperty监听,如果对象有
新增或者删除的操作时,Object.defineProperty是监听不到的,这是最主要的原因,Object.defineProperty是对属性的监听,所以新增或者删除需要重新执行Object.defineProperty - Proxy 对象用于
创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。这是来自MDN的解释,可以看出来proxy是对一整个对象的代理,可以监听到属性的增删改查,同样也支持其他的监听方式,例如 in、apply等等,感兴趣的可以点击前往详细了解,这里就不过多的介绍了,我们的代码只需要对reactive函数进行重构即可,下面就直接上代码吧!
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const dep = getDepend(target, key)
dep.addDep()
// 使用Reflect进行get操作
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
// 使用Reflect进行set操作
Reflect.set(target, key, newValue, receiver)
const dep = getDepend(target, key)
dep.notify()
}
})
}
- 这里只需要对reactive函数进行替换即可,代码不难理解,这里就不做过多的介绍了,这里主要要说一下Reflect和receiver这两个东西,可能有些人不知道这两个是干嘛的,这里就简单的介绍一下
1.Reflect和receiver
1.1 Reflect
首先,先介绍一下
Reflect,这是一个ES6新增的API,它是一个对象,它提供了很多操作对象的方法,类似于Object中操作对象的方法 比如Reflect.getPrototypeOf() 类似于 Object.getPrototypeOf()
Reflect.definePrototype() 类似于 Object.definePrototype()
既然Reflect和Object操作对象的方式那么相同,为什么还要有Reflect这个API呢?
这个网上的看法都各不相同,有一部分说是为了语义化有一部分是说js语言早期ECMA规范中没有考虑到对对象本身的操作如何设计会更加规范
所以会将一些API都放到Object上面,但是Object作为一个构造函数,这些操作放在它身上并不合适,另外还包含一些in delete操作符
所以在ES6中新增了Reflect,让这些操作都集中到Reflect上面
1.2 receiver
了解完Reflect之后我们来了解一下receiver参数是干嘛的。 receiver参数是proxy里get和set操作特有的,只有这两个方法有,其他的方法是没有的,那它为什么要多这个参数呢?这个参数又是干嘛的?下面我们就用一段代码来演示一下,你们就懂了
const obj = {
_name: 'yang',
get name() {
return this._name
},
set name(newValue) {
this._name = newValue
}
}
const proxyObj = new Proxy(obj, {
get(target, key, receiver) {
console.log('get操作被执行了',key)
return Reflect.get(target, key)
},
set(target, key, newValue, receiver) {
Reflect.set(target, key, newValue)
}
})
console.log(proxyObj.name)
从上面的例子中我们可以看到,我们创建了一个对象,对象里面有一个
私有属性,和name的get、set方法 我们对这个对象进行了代理,当我们访问代理对象的name的时候,会被我们的get操作捕获到,所以会帮我们去obj对象获取name的值,去到obj对象name的get操作里,里面执行的代码是 返回 this._name
这个时候问题就来了,这个this指向的是谁?其实答案已经显而易见了,这个this指向的是obj对象,有什么办法可以证明呢?
其实这里不可能是proxyObj对象,如果这里是ProxyObj对象的话,那么proxyObj对象的get操作会被执行两次
-
proxyObj.name执行一次,第二次是proxyObj._name执行一次,很显然,我们这里只执行了一次,所以这里的this指向的是obj对象
-
那么现在问题就来了,既然我们都对obj对象进行了代理,但是obj里面某些属性在进行操作的时候,却可以绕过我们的代理对象
这样就显得不太合理了,我们的代理操作看起来就毫无意义了,那我们应该怎么解决呢?
这个时候就要轮到我们的receiver上场了,这个receiver其实就是我们的代理对象proxyObj,哪具体要怎么用呢
const obj = {
_name: 'yang',
get name() {
return this._name
},
set name(newValue) {
this._name = newValue
}
}
const proxyObj = new Proxy(obj, {
get(target, key, receiver) {
console.log('get操作被执行了',key)
return Reflect.get(target, key,receiver)
},
set(target, key, newValue, receiver) {
Reflect.set(target, key, newValue,receiver)
}
})
console.log(proxyObj.name)
其实我们的Reflect的get和set方法还可以传入另外一个值,就是receiver,只有get和set操作享有这个参数
将我们的receiver传递进去,就可以使得我们的obj对象里面的this是我们的代理对象proxyObj了
这样一来,我们obj对象在使用this._name的时候就相当于 proxyObj._name了,就会再次执行我们的get操作,被我们拦截到
所以最终我们的get操作会被执行两次,我们可以来看一下
ok,到这里我们就将响应式系统实现完了,响应式系统需要说的地方比较多,细节也比较多,所以花了很长的时间来解释说明
八、createApp和mount的实现
到这里我们就要接近胜利了,终于要到头了🦾,我们回想一下我们平时是如何使用vue的,是不是调用了
vue的createApp方法,然后将我们的组件对象传进去,然后用返回对象的mount挂载元素。ok,既然清楚了,那么我们就来实现一个超简单的createApp和mount
1.createApp
首先,我们使用vue.createApp的时候是不是要传入一个对象,那么我们等下的createApp方法肯定需要接收一个参数,然后调用createApp方法是不是会返回一个对象,所以也需要返回一个对象
2.mount
返回的对象里面是不是有mount方法,mount方法我们是不是需要传入一个element元素或者元素标记,ok那么我们的mount函数也需要接收一个参数
既然知道要怎么做了,那么就上代码吧
// 传入一个跟组件,返回一个对象,对象里面有mount方法,mount方法接收一个参数
// ok,我们就简单的实现了这两个方法,接下来就是一些逻辑的处理了
class Vue {
static createApp(rootComponent) {
return {
// 挂载方法
mount(tag) {
}
}
}
}
3.传入的根组件
> 这就是我们最终将所有代码结合起来的效果,我们创建一个APP根组件,组件里面有data,data的值是我们封装的reactive响应式对象函数,还有我们的render函数,render函数返回的是我们封装的h函数调用的结果,然后将APP传给createApp,会返回一个对象,用app进行接收,调用app里面的mount,传入元素标记。
<script>
// 1.创建根组件
const App = {
data: reactive({
message: 'Hello mini-vue'
}),
render() {
return h('div', null, [
h('h2', { class: 'h2' }, `${this.data.message}`),
h("input", {
oninput: (e) => {
this.data.message = e.target.value
}
, value: this.data.message
})
])
}
}
// 2.使用Vue.createApp创建App对象
const app = Vue.createApp(App)
// 3.挂载元素
app.mount('#app')
</script>
4.mount逻辑处理
-
我们传给mount的元素标记首先需要获取到对应的DOM元素
-
我们的挂载和更新操作都是在mount函数里面执行,所以需要一个变量来记录是挂载还是更新
-
因为我们要在mount里面执行我们的render函数,又因为我们的数据发生变化的时候需要重新执行render函数,所以很显然这个render函数是需要进行响应式的,不知道大家还记不记得最开始上面的一张图
4. 所以我们这里的render函数需要包裹一个函数,然后传给我们的watchFn进行响应式副作用,最后再函数内部判断是更新还是挂载操作即可
class Vue {
static createApp(rootComponent) {
return {
mount(tag) {
const el = document.querySelector(tag)
// 是否已经挂载
let isMount = false
// 存放上一次的VNodes
let oldVNodes = null
// 因为render函数是每次数据发生变化之后都会重新执行的,所以这里将render函数放到watchFn里面监听变化
watchFn(function () {
// 因为第一次执行render函数的时候是执行挂载,但是因为render函数是执行多次的,所以需要一个变量来记录是否已经挂载
if (isMount) {
// 如果已经挂载过了,就进行patch操作
const newVNodes = rootComponent.render()
patch(oldVNodes, newVNodes)
// 将最新的值赋值给oldVNodes,方便下一次进来的时候可以进行比较
oldVNodes = newVNodes
} else {
// 将第一次生成的VNodes赋值给oldVNodes
oldVNodes = rootComponent.render()
// 第一次挂载
mount(oldVNodes, el)
// 挂载完将变量改为true
isMount = true
}
})
}
}
}
}
ok,最后我们将代码串起来,来实现一个简单的案例吧!!!
九、效果展示
首先需要整理一下代码顺序
>接下来就看一下我们的最终效果吧
到这里我们手写丐版mini-vue就完成啦,整篇文章也是非常的长,相信能看完的同学肯定是很厉害的,肯定也能收获到不少东西,同时今天也是大年初一,AY在这里祝大家新年快乐,新的一年代码无BUG
这也是我第一次写掘金文章,我也没想到能写这么多,学到了很多东西,希望以后能多写一些文章分享给大家,同时也希望文章中有什么不对的地方大家可以帮忙纠正,欢迎在评论区纠正,那今天的文章就到这里结束了,谢谢大家✨✨✨