碎碎念:
亲爱的读者:你好!我叫 Changlon —— 一个非科班程序员、一个致力于前端的开发者、一个热爱生活且又时有忧郁的思考者。
如果我的文章能给你带来一些收获,你的点赞收藏将是对我莫大的鼓励!
我的邮箱:thinker_changlon@163.com
我的Github: https://github.com/Changlon
项目源码:myvue github源码地址
本章我们通过手写实现一个简单的Vue响应式系统,只用300多行代码实现包括Vue的响应式系统,模板解析,指令解析,data, methods选项的处理等。 难度不算大,沉下心来,拿起键盘跟我一起来撸一个出来,相信之后你就会豁然开朗的!
建议将源码下载下来仔细研究,源码地址已在上面给出。
一 、什么是MVVM?
概念
MVVM 模式,顾名思义即 Model-View-ViewModel 模式。它萌芽于2005年微软推出的基于 Windows 的用户界面框架 WPF ,前端最早的 MVVM 框架 knockout 在2010年发布。
一句话总结 Web 前端 MVVM:操作数据,就是操作视图,就是操作 DOM(所以无须操作 DOM )。 无须操作 DOM !借助 MVVM 框架,开发者只需完成包含 声明绑定 的视图模板,编写 ViewModel 中业务数据变更逻辑,View 层则完全实现了自动化。这将极大的降低前端应用的操作复杂度、极大提升应用的开发效率。MVVM 最标志性的特性就是 数据绑定 ,MVVM 的核心理念就是通过 声明式的数据绑定 来实现 View 层和其他层的分离。完全解耦 View 层这种理念,也使得 Web 前端的单元测试用例编写变得更容易。
MVVM,说到底还是一种分层架构。它的分层如下:
- Model: 域模型,用于持久化
- View: 作为视图模板存在
- ViewModel: 作为视图的模型,为视图服务
Model 层
Model 层,对应数据层的域模型,它主要做域模型的同步。通过 Ajax/fetch 等 API 完成客户端和服务端业务 Model 的同步。在层间关系里,它主要用于抽象出 ViewModel 中视图的 Model。
简单的来讲就是你要展示在页面中的数据。比如Vue实例中的data
View 层
View 层,作为视图模板存在,在 MVVM 里,整个 View 是一个动态模板。除了定义结构、布局外,它展示的是 ViewModel 层的数据和状态。View 层不负责处理状态,View 层做的是 数据绑定的声明、 指令的声明、 事件绑定的声明。
简单的理解就是需要操作的dom模型
ViewModel 层
ViewModel 层把 View 需要的层数据暴露,并对 View 层的 数据绑定声明、 指令声明、 事件绑定声明 负责,也就是处理 View 层的具体业务逻辑。ViewModel 底层会做好绑定属性的监听。当 ViewModel 中数据变化,View 层会得到更新;而当 View 中声明了数据的双向绑定(通常是表单元素),框架也会监听 View 层(表单)值的变化。一旦值变化,View 层绑定的 ViewModel 中的数据也会得到自动更新。
简单的理解就是 数据变化了 viewModel会将变更的数据自动更新到dom中
前端MVVM模型图解
二 、Object.defineProperty()
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
- 语法 Object.defineProperty(obj, prop, descriptor)
- 参数 obj要定义属性的对象。prop要定义或修改的属性的名称或 Symbol 。descriptor 要定义或修改的属性描述符。
- 用法
const object1 = {};
Object.defineProperty(object1, 'property1', {
value: 42,
writable: false
});
object1.property1 = 77;
console.log(object1.property1);
- setter & getter
我们可以为一个对象的属性描述符中设置get 和set函数;get函数可以确保在访问属性值时可以做哪些操作, set函数可以确保我们在更改属性值时可以做哪些操作。
let data = {
name :'changlon'
}
let name = data.name
Object.defineProperty(data,'name',{
set(newValue) {
console.log(`数据劫持:${newValue}`)
name = newValue
},
get() {
console.log(`访问属性name:${name}`)
return name
}
})
console.log(data.name)
data.name = "jack"
console.log(data)
上面的例子中我们对data中的name属性做了一个数据劫持,当访问name属性时进行一个打印操作,当更改name属性时打印并返回原来的值。这就是vue2中的响应式基本原理。我们可以在数据更改时去改变dom,这样就能实现dom和数据的同步。
三、一个简单的例子
下面我们通过一个简单的例子来加深这一原理的理解。
响应式系统的基本思路
接下来我们将重点讲解数据响应系统的实现,在具体到源码之前我们有必要了解一下数据响应系统实现的基本思路,这有助于我们更好的去实现vue的响应式代码。
在 Vue 中,我们可以使用 $watch 观测一个字段,当字段的值发生变化的时候执行指定的观察者,如下:
const ins = new Vue({
data: {
a: 1
}
})
ins.$watch('a', () => {
console.log('修改了 a')
})
这样当我们试图修改 a 的值时:ins.a = 2 ,在控制台将会打印 '修改了 a' 。现在我们将这个问题抽象一下,假设我们有数据对象 data,如下:
const data = {
a: 1
}
我们还有一个叫做 $watch 的函数:
function $watch () {...}
$watch 函数接收两个参数,第一个参数是要观测的字段,第二个参数是当该字段的值发生变化后要执行的函数,如下:
$watch('a', () => {
console.log('修改了 a')
})
要实现这个功能,说复杂也复杂说简单也简单,复杂在于我们需要考虑的内容比较多,比如如何避免收集重复的依赖,如何深度观测,如何处理数组以及其他边界条件等等。简单在于如果不考虑那么多边界条件的话,要实现这样一个功能还是很容易的,这一小节我们就从简入手,致力于让大家思路清晰。
要实现上文的功能,我们面临的第一个问题是,如何才能知道属性被修改了(或被设置了)。这时候我们就要依赖 Object.defineProperty 函数,通过该函数为对象的每个属性设置一对 getter/setter 从而得知属性被读取和被设置,如下:
Object.defineProperty(data, 'a', {
set () {
console.log('设置了属性 a')
},
get () {
console.log('读取了属性 a')
}
})
这样我们就实现了对属性 a 的设置和获取操作的拦截,有了它我们就可以大胆地思考一些事情,比如: 能不能在获取属性 a 的时候收集依赖,然后在设置属性 a 的时候触发之前收集的依赖呢? 嗯,这是一个好思路,不过既然要收集依赖,我们起码需要一个”筐“ ,然后将所有收集到的依赖通通放到这个”筐” 里,当属性被设置的时候将“筐” 里所有的依赖都拿出来执行就可以了,落实到代码如下:
const dep = []
Object.defineProperty(data, 'a', {
set () {
dep.forEach(fn => fn())
},
get () {
dep.push(fn)
}
})
如上代码所示,我们定义了常量 dep ,它是一个数组,这个数组就是我们所说的“筐”,当获取属性 a 的值时将触发 get 函数,在 get 函数中,我们将收集到的依赖放入“筐”内,当设置属性 a 的值时将触发 set 函数,在 set 函数内我们将“筐”里的依赖全部拿出来执行。
但是新的问题出现了,上面的代码中我们假设 fn 函数就是我们需要收集的依赖(观察者),但 fn 从何而来呢? 也就是说如何在获取属性 a 的值时收集依赖呢? 为了解决这个问题我们需要思考一下我们现在都掌握了哪些条件,这个时候我们就需要在 $watch 函数中做文章了,我们知道 $watch 函数接收两个参数,第一个参数是一个字符串,即数据字段名,比如 'a' ,第二个参数是依赖该字段的函数:
$watch('a', () => {
console.log('设置了 a')
})
重点在于 $watch 函数是知道当前正在观测的是哪一个字段的,所以一个思路是我们在 $watch 函数中读取该字段的值,从而触发字段的 get 函数,同时将依赖收集,如下代码:
const data = {
a: 1
}
const dep = []
Object.defineProperty(data, 'a', {
set () {
dep.forEach(fn => fn())
},
get () {
dep.push(Target)
}
})
let Target = null
function $watch (exp, fn) {
Target = fn
data[exp]
}
上面的代码中,首先我们定义了全局变量 Target ,然后在 $watch 中将 Target 的值设置为 fn 也就是依赖,接着读取字段的值 data[exp] 从而触发被设置的属性的 get 函数,在 get 函数中,由于此时 Target 变量就是我们要收集的依赖,所以将 Target 添加到 dep 数组。现在我们添加如下测试代码:
$watch('a', () => {
console.log('第一个依赖')
})
$watch('a', () => {
console.log('第二个依赖')
})
此时当你尝试设置 data.a = 3 时,在控制台将分别打印字符串 '第一个依赖' 和 '第二个依赖' 。我们仅仅用十几行代码就实现了这样一个最基本的功能,但其实现在的实现存在很多缺陷,比如目前的代码仅仅能够实现对字段 a 的观测,如果添加一个字段 b 呢?所以最起码我们应该使用一个循环将定义访问器属性的代码包裹起来,如下:
const data = {
a: 1,
b: 1
}
for (const key in data) {
const dep = []
Object.defineProperty(data, key, {
set () {
dep.forEach(fn => fn())
},
get () {
dep.push(Target)
}
})
}
这样我们就可以使用 $watch 函数观测任意一个 data 对象下的字段了,但是细心的同学可能早已发现上面代码的坑,即:
console.log(data.a)
直接在控制台打印 data.a 输出的值为 undefined ,这是因为 get 函数没有任何返回值,所以获取任何属性的值都将是 undefined ,其实这个问题很好解决,如下:
for (let key in data) {
const dep = []
let val = data[key]
Object.defineProperty(data, key, {
set (newVal) {
if (newVal === val) return
val = newVal
dep.forEach(fn => fn())
},
get () {
dep.push(Target)
return val
}
})
}
只需要在使用 Object.defineProperty 函数定义访问器属性之前缓存一下原来的值即 val,然后在 get 函数中将 val 返回即可,除此之外还要记得在 set 函数中使用新值(newVal)重写旧值(val)。
但这样就完美了吗?当然没有,这距离完美可以说还相差十万八千里,比如当数据 data 是嵌套的对象时,我们的程序只能检测到第一层对象的属性,如果数据对象如下:
const data = {
a: {
b: 1
}
}
对于以上对象结构,我们的程序只能把 data.a 字段转换成响应式属性,而 data.a.b 依然不是响应式属性,但是这个问题还是比较容易解决的,只需要递归定义即可:
function walk (data) {
for (let key in data) {
const dep = []
let val = data[key]
const nativeString = Object.prototype.toString.call(val)
if (nativeString === '[object Object]') {
walk(val)
}
Object.defineProperty(data, key, {
set (newVal) {
if (newVal === val) return
val = newVal
dep.forEach(fn => fn())
},
get () {
dep.push(Target)
return val
}
})
}
}
walk(data)
如上代码我们将定义访问器属性的逻辑放到了函数 walk 中,并增加了一段判断逻辑如果某个属性的值仍然是对象,则递归调用 walk 函数。这样我们就实现了深度定义访问器属性。
但是虽然经过上面的改造 data.a.b 已经是访问器属性了,但是如下代码依然不能正确执行:
$watch('a.b', () => {
console.log('修改了字段 a.b')
})
来看看目前 $watch 函数的代码:
function $watch (exp, fn) {
Target = fn
data[exp]
}
读取字段值的时候我们直接使用 data[exp] ,如果按照 $watch('a.b', fn) 这样调用 $watch 函数,那么 data[exp] 等价于 data['a.b'] ,这显然是不正确的,正确的读取字段值的方式应该是 data['a']['b'] 。所以我们需要稍微做一点小小的改造:
const data = {
a: {
b: 1
}
}
function $watch (exp, fn) {
Target = fn
let pathArr,
obj = data
if (/\./.test(exp)) {
pathArr = exp.split('.')
pathArr.forEach(p => {
obj = obj[p]
})
return
}
data[exp]
}
我们对 $watch 函数做了一些改造,首先检查要读取的字段是否包含 .,如果包含 . 说明读取嵌套对象的字段,这时候我们使用字符串的 split('.') 函数将字符串转为数组,所以如果访问的路径是 a.b 那么转换后的数组就是 ['a', 'b'] ,然后使用一个循环从而读取到嵌套对象的属性值,不过需要注意的是读取到嵌套对象的属性值之后应该立即 return ,不需要再执行后面的代码。
下面我们再进一步,我们思考一下 $watch 函数的原理是什么?其实 $watch 函数所做的事情就是想方设法地访问到你要观测的字段,从而触发该字段的 get 函数,进而收集依赖(观察者)。现在我们传递给 $watch 函数的第一个参数是一个字符串,代表要访问数据的哪一个字段属性,那么除了字符串之外可不可以是一个函数呢?假设我们有一个函数叫做 render ,如下
const data = {
name: '霍春阳',
age: 24
}
function render () {
return document.write(`姓名:${data.name}; 年龄:${data.age}`)
}
可以看到 render 函数依赖了数据对象 data ,那么 render 函数的执行是不是会触发 data.name 和 data.age 这两个字段的 get 拦截器呢?答案是肯定的,当然会!所以我们可以将 render 函数作为 $watch 函数的第一个参数:
$watch(render, render)
为了能够保证 $watch 函数正常执行,我们需要对 $watch 函数做如下修改:
function $watch (exp, fn) {
Target = fn
let pathArr,
obj = data
if (typeof exp === 'function') {
exp()
return
}
if (/\./.test(exp)) {
pathArr = exp.split('.')
pathArr.forEach(p => {
obj = obj[p]
})
return
}
data[exp]
}
在上面的代码中,我们检测了 exp 的类型,如果是函数则直接执行之,由于 render 函数的执行会触发数据字段的 get 拦截器,所以依赖会被收集。同时我们要注意传递给 $watch 函数的第二个参数:
$watch(render, render)
第二个参数依然是 render 函数,也就是说当依赖发生变化时,会重新执行 render 函数,这样我们就实现了数据变化,并将变化自动应用到 DOM。其实这大概就是 Vue 的原理,但我们做的还远远不够,比如上面这句代码,第一个参数中 render 函数的执行使得我们能够收集依赖,当依赖变化时会重新执行第二个参数中的 render 函数,但不要忘了这又会触发一次数据字段的 get 拦截器,所以此时已经收集了两遍重复的依赖,那么我们是不是要想办法避免收集冗余的依赖呢?除此之外我们也没有对数组做处理,我们将这些问题留到后面。
现在我们这个不严谨的实现暂时就到这里,意图在于让大家明白数据响应系统的整体思路,为接下来实现Vue做必要的铺垫。
四、编写Vue构造函数
现在我们终于可以来实现一个自己的简单的Vue了,有了之前的铺垫相信读者也能大概理解了vue的响应式实现思路。 我们首先建立一个 index.html 用来引入我们自己的vue ,再新建一个src 目录用来存放我们的vue源码。
创建项目目录&文件
新建 src 目录 和index.html 在index.html 中我们编写如下结构:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>实现一个简单的vue</title>
</head>
<body>
<div id="app">
测试 v-text
<hr>
<p :class="a b c" style="background-color: red;width: 50%;" v-text="name"></p>
<br>
测试 v-html
<hr>
<div v-html="html"></div>
<br>
测试v-model
<hr>
<input type="text" v-model="name" >
<hr>
<br>
测试v-if
<hr>
<div v-if="isShow">
我出现了,哈哈哈!!!<br>
这是vue的响应式,指令原理!!!
</div>
<br>
测试v-on:event|@click
<hr>
<button type="button" v-on:click="click1">show</button>
<button type="button" v-on:click="click2">render Html</button>
<button type="button" @click="click3">@绑定事件</button>
</div>
<script src="./src/index.js" type="module"></script>
</body>
</html>
注意 type= “module” 以模块化的方式读取js文件 结构里面包含了我们要测试的功能
接着我们在 src 下新建 index.js
import Vue from './vue.js'
var vm = new Vue({
el:'#app',
data:{
name:'changlon',
age:21,
family:{
father:{
name:'henzhong',
age:50
},
mother:{
name:'haihua',
age:46
}
},
model:'hello v-model',
isShow:false,
html:''
},
methods:{
walk() {
console.log('walk')
},
click1() {
this.isShow = true
},
click2() {
this.html = `
<h1 style="text-align:center;color:red;" >Title</h1>
<p>this is a simple-vue, about derective v-html usage</p>
`
},
click3() {
alert(`大家好 我叫${this.name},今年 ${this.age}岁!`)
}
}
})
这是我们一般开发vue所要写的内容。
然后在 src 下新建 vue.js 编写我们的vue实例
export default class Vue {
constructor(option) {
}
}
数据代理
分析一下我们在new 一个Vue 的实例中一般会传入: el , data , methods 等参数,我们这里就以这三个参数为例子进行选项的初始化。
checkEl(el){
const $el = typeof el ==='string' ?
document.querySelector(el)
: el
if(!$el) throw new Error('myvue warn: The option el is invalid!')
return $el
}
this.$el = this.checkEl(option.el)
this.$data = option.data || {}
this.$methods = option.methods ||{}
- 代理data数据
一般我们是可以通过vue实例vm来访问data中的参数的,所以我们需要进行数据的代理。
proxy(data) {
Object.keys(data).forEach(key=>{
Object.defineProperty(this,key,{
enumerable:true,
configurable:true,
get(){
return data[key]
},
set(newVal) {
if(newVal===data[key]) return
data[key]= newVal
}
})
})
}
为什么要进行代理data中的数据呢? 因为这样方便我们直接通过this. 的形式访问数据。
五、Observer & Dep
在初始化选项和代理完数据后我们就要开始建立响应式系统了。我们需要在src 目录下新建 Observer.js Dep.js Compiler.js Watcher.js
Observer.js : 主要用来递归遍历data数据下的属性,并设置 setter , getter ; 它依赖 Dep.js
import Dep from './dep.js'
export default class Observer {
constructor(data) {
this.traverse(data)
}
traverse(data) {}
defineReactive(obj,key,val){}
}
Dep.js : 负责收集依赖和通知更新,针对每一个响应式属性都会new 一个Dep
export default class Dep {
constructor() {
this.subs = []
}
addSub(watcher){
if(watcher) {
this.subs.push(Dep.target)
}
}
notify(){
this.subs.forEach(watcher=>{
watcher.update()
})
}
}
Watcher.js : 用来触发依赖收集,执行更新方法;它依赖Dep.js
import Dep from './dep.js'
export default class Watch {
constructor(vm,key,cb) {
this.vm = vm
this.key = key
this.cb = cb
Dep.target = this
this.oldValue = vm[key]
Dep.target = null
}
update() {
let newValue = this.vm[this.key]
if(this.oldValue===newValue) return
this.cb(this.oldValue,newValue)
this.oldValue = newValue
}
}
Compiler.js : 用来解析html模板,解析指令,vue语法,生成Watcher实例,触发依赖收集;其依赖Watcher.js;可以将Compiler.js类比为上面的render 函数
import Watcher from './watch.js'
export default class Compiler{
constructor(vm) {
this.el = vm.$el
this.vm = vm
this.methods = vm.$methods
this.compile(this.el)
}
compile(el) {
}
compileText(node) {
}
compileElement(node) {
}
update(node,key,directiveName,type) {
}
htmlUpdater(node,value,key) {
}
modelUpdater(node,value,key) {
}
ifUpdater(node,value,key) {
}
clickUpdater(node,value,key) {
}
}
上面几个文件的执行顺序:
- 在vue实例化时,先
new 执行 observer 建立响应式系统,为每一个属性new Dep 生成依赖。 - 然后 new 执行
compiler 编译模板,解析指令,new 生成 watch 观察者,触发依赖的收集(将watch 添加到对应dep )。 - 当数据变更时触发
dep.notify() 通知观察者执行变更(执行watch.update() )。
Observer递归响应式
traverse(data) {
if(typeof data!=='object') return
const that = this
Object.keys(data).forEach(key=>{
if(typeof data[key]==='object') {
that.traverse(data[key])
}else{
that.defineReactive(data,key,data[key])
}
})
}
defineReactive(obj,key,val) {
let dep = new Dep()
const that = this
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
get(){
Dep.target && dep.addSub(Dep.target)
return val
},
set(newVal) {
if(newVal===val) return
val = newVal
that.traverse(newVal)
dep.notify()
}
})
}
六、Compiler & Watcher
compile编译文本节点的实现
compile(el) {
const that = this
const childNodes = el.childNodes
const nodeType = el.nodeType
switch (nodeType) {
case 1:
that.compileElement(el)
break
case 3:
that.compileText(el)
break
}
if(childNodes&&childNodes.length>0) {
for(let i =0;i<childNodes.length;++i) {
const node = childNodes[i]
this.compile(node)
}
}
}
compileText(node) {
let content = node.textContent
const expressionReg = /\{\{(.+?)\}\}/g
const matches = content.match(expressionReg)
if(!matches) return
for(let match of matches) {
let matchKey = match.trim()
matchKey = matchKey.substring(2,matchKey.length)
matchKey = matchKey.substring(0,matchKey.length-2).trim()
const value = this.vm[matchKey]
if(value===undefined) {
try {
value = eval(`${matchKey}`)
}catch(e) {
throw new Error(`template render error: the key ${matchKey} is not exists in vm or the expression is invalid !`)
}
}
content = content.replace(match,value)
}
node.textContent = content
}
编译元素的实现
compileElement(node) {
const attributes = node.attributes
if(attributes&&attributes.length>0) {
for(const attr of attributes){
let name = attr.name ,value = attr.value
let type = 0
if(name.startsWith('v-')) {
name = name.substring(2,name.length).trim()
if(name.indexOf(':')>-1){
type = 1
name = name.substring(name.indexOf(':')+1,name.length)
}
this.update(node,value,name,type)
}else if(name.startsWith('@')) {
type = 1
name = name.substring(1,name.length).trim()
this.update(node,value,name,type)
}
}
}
}
update(node,key,directiveName,type) {
const updateFn = this[`${directiveName}Updater`]
if(!updateFn) throw new Error(`invalid directive:${directiveName}`)
let value = type==0?
this.vm[key]:
type==1?
this.vm.$methods[key]:
null
if(value===undefined) {
try {
value = eval(`${key}`)
}catch(e) {
throw new Error(`template render error: the key ${matchKey} is not exists in vm or the expression is invalid !`)
}
}
updateFn.call(this,node,value,key)
}
处理v-text,v-html的实现
textUpdater(node,value,key) {
node.innerText = value
new Watcher(this.vm,key,(oldValue,newValue)=>{
node.innerText = newValue
})
}
htmlUpdater(node,value,key) {
node.innerHTML = value
new Watcher(this.vm,key,(oldValue,newValue)=>{
node.innerHTML = newValue
})
}
测试运行
配置 mywebpack :
module.exports = {
mode:'production',
entry:{
index:'./src/index.js'
},
output:{
filename:'myvue.js'
}
}
打包输出: myvue.js 引入到你的html结构中就可以测试运行了 打包文件github: myvue.min.js
到这里我希望读者可以模仿上面的去自己实现更多的指令和功能,记住一定要把例子源码下载下来好好研究。自己从头到尾写一遍,你对vue的理解才会更加透彻深入!
|