读过《三体》的人都会对人列计算机印象深刻。
牛顿不知从什么地方掏出六面小旗.三白三黑,冯 • 诺伊曼接过来分给三名士兵,每人一白一黑,说:“白色代表0,黑色代表1。好,现在听我说,出,你转身看着入1和入2,如果他们都举黑旗,你就举黑旗,其他的情况你都举白旗,这种情况有三种:入l白,入2黑;入l黑,入2白;入1、入2都是白。”
为了预测恒纪元和乱纪元,故事里的冯诺依曼设计了人列计算机,不需要三千万个数学家。只需要三千万忠实的士兵,每个个体订阅相关单元的变化,忠实地反映状态即可。
实际上前端的MVVM就是一个精巧的状态机。
如果参考vue做一个简易版本的响应式框架,设计上应该分为四部分:
1、数据劫持器(Observer),对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者。
2、编译器Compile,对模板每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。
3、订阅器Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。
4、mvvm入口函数,整合以上三者。
数据劫持在上一篇文章已经介绍过defineProperty和Proxy。本文先用defineProperty。
有一个没解决的问题是如何实现深度监听。做法是在遍历每层时加多一个递归就行了。不妨先删除之前的get/setState和watch。
/ 响应式
class Observer {
constructor(data) {
this.observe(data);
}
defineResponsive(data,key,val){
this.observe(val);
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再define
get() {
return val;
},
set(nick) {
const old = data[key];
val = nick;
console.log(`${key}:${old}->${nick}`)
}
})
}
// 观察属性
observe(data) {
if (!data || typeof data !== 'object') {
return;
}
// 取出所有属性遍历
Object.keys(data).forEach(key => {
this.defineResponsive(data, key, data[key]);
});
}
}
我们测试下:
const data = {
name: 'djtao',
info: {
job: 'aaa'
}
}
const res = new Observer(data)
data.name = 'djt'; // name:djtao->djt
data.info.job = 'bbb'; // job:aaa->bbb
那么深度监听就实现了。很明显,监听点就是在我们打log日志的地方,上一篇的watch方法也是写在这里。
现在用发布订阅模式改造,又该怎么通知watcher呢?
回顾上篇中计算器的案例,文章中的watch方法中,是调用了一个calc方法。把所有需要响应数据变化的地方全部写进去并更新。
const calc = r => {
document.querySelector(`#r`).value = r;
document.querySelector(`#square`).innerHTML = Math.pow(r, 2);
document.querySelector(`#cube`).innerHTML = Math.pow(r, 3);
}
也就是说这个案例中关键值r,有三个来自视图层的订阅者(wathcers:[input#r,div#sqaure,div#cube]
)。
所以接下来需要实现一个消息订阅器,考虑维护一个数组,用来收集订阅者,数据变动触发notify,再遍历调用订阅者的update方法。
**
* 订阅器(依赖收集器)
*/
class Dep{
constructor(){
this.deps = [];
}
// 添加依赖
add(watcher){
this.deps.push(watcher);
}
// 遍历通知驶入更新
notify(){
this.subs.forEach(wathcer => watcher.update());
}
}
在Observer中,每set一次数据,都通知dep(dep.notify()
);每次get一次数据就把wather中的this放到deps中。
所以理论上,在defineProperty之前,new
出Dep,在监听点调用 dep 的 notify 方法就行了。
先来简单看Watcher的简单实现。
class Watcher{
constructor(opts){
// 实例化时,把自身缓存起来
Dep.target = this;
}
update(){
console.log('属性已更新')
}
}
class Observer {
// ...
defineResponsive(data,key,val){
const dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
// 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
Dep.target && dep.add(Dep.target);
return val;
},
set(nick) {
const old = data[key];
val = nick;
// 通知订阅者
console.log(`${key}:${old}->${nick}`)
dep.notify(nick)
}
})
// 递归调用
this.observe(val);
}
// ...
}
写编译器可以单独新建一个Compile.js单独引入。关于这部分的理解不难,但是零碎的要点极多。表述出来更是不易。本章尝试都做出解释。
在设计代码的时候,需要根据理想效果来确定编译的方式。设想我们的计算器用vue来写,可能是这样的:
<!-- html -->
<div id="app">
<!-- 事件,双向绑定 -->
<button @click="minus">-</button> <input type="number" v-model="r"> <button @click="plus">+</button>
<!-- 插值绑定 -->
<p>r = <span>{{r}}</span></p>
<!-- 计算属性 -->
<p>r<sup>2</sup> = <span v-text="square"></span></p>
<p>r<sup>3</sup> = <span v-text="cube"></span></p>
</div>
不妨就允许这样的代码直接写在html里,也就说编译器的目标是:至少要把这些语法写成的html转化为插值,指令,事件等。
此外,在更新指令或数据时,我们都希望编译器能拿到开发者写的具体业务逻辑比如method,定义的data等等——是时候考虑写下的mvvm入口了。模仿vue的使用方法,我希望它的简单用法是这样的:
// Vm/index.js
const vm = new Vm({
ele: '#app',
data: {
r: 0
},
method: {
plus() {
this.data.r += 1;
},
minus() {
this.data.r -= 1;
}
}
})
简单写个对象,把配置传进去:
/**
* 总体入口
*/
class Vm {
constructor(opts) {
this.opts = opts;
const { data } = this.opts;
const res = new Observer(data);
new Compile(this);
}
}
compile主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如图所示:
读取模板,转化为dom碎片->编译节点(解析指令,事件)或文本(解析插值内容)->添加到dom中
因此构造函数为
class Compile {
constructor(vm) {
this.$el = document.querySelector(vm.opts.ele);
this.$vm = vm;
if (this.$el) {
this.$fragment = this.node2Fragment(this.$el);
this.compile(this.$fragment);
this.$el.appendChild(this.$fragment);
}
}
// ...
}
同时还需要若干通用方法,这些方法都全部放一个对象里维护。不妨命名为compileUtils
。
const compileUtils = {
// 通用方法
}
这段使用了原生dom方法Document.createDocumentFragment()
,把真实dom转化为内存中的文档片段。方便进行编译。
DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。在DOM树中,文档片段被其所有的子元素所代替。 因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。 ——MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createDocumentFragment
node2Fragment(el){
console.log(el.firstChild)
const fragment = document.createDocumentFragment();
// 将原生节点拷贝到fragment
let child;
while (child = el.firstChild) {
fragment.appendChild(child);
}
return fragment
}
你也许会难以理解这里有个奇怪的while循环:
while((child = el.firstChild)){ // 1
fragment.appendChild(child) // 2
}
1.在第一行的括号里面进行了两次操作:第一次就是赋值: child = el.firstChild
;第二次就是判断child是否为空,即while(child)
.2.在第二行中,fragment就把el.firstChild(el.children[0])
抽离了出来,此操作导致el.children[0]
被抽出,在下次while循环执行firstChild = el.firstChild
时读取的是相对本次循环的el.children[1]
,以此达到循环转移dom的目的
Compile.compile负责具体的解析逻辑,并且与Watcher建立通信。同时负责更新具体的内容。
文档碎片(el)从html中拿到后,就是对其子元素进行递归遍历。对于dom集合,可以使用Array.from
方法,然后对遍历结果进行分类讨论:
1.如果是元素节点,执行compileElement(node)
;2.如果是插值文本节点,执行compileText(node)
;3.如果有子元素,则继续递归。
class Compile {
// ...
// 编译文档碎片
compile(el) {
const childNodes = el.childNodes;
Array.from(childNodes).forEach(node => {
if (compileUtils.isElement(node)) {
console.log('编译元素节点', node.nodeName);
this.compileElement(node);
} else if (compileUtils.isInterpolation(node)) {
// 双大括号
console.log('编译插值文本', node.textContent);
this.compileText(node);
}
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node);
}
});
}
}
然后在Utils新增两个判断方法:
// 编译器通用方法
const compileUtils = {
// 判断是否普通节点
isElement(node){
return node.nodeType == 1;
},
// 判断插值文本:是文本且符合双大括弧正则
isInterpolation(node) {
return node.nodeType == 3 && /\{\{(.*)\}\}/.test(node.textContent);
},
// ...
}
1.compileElement
抽取指令,事件2.compileText
抽取插值文本3.抽取之后根据抽取的内容丢给compileUtils对应的方法处理。4.“compileUtils对应的方法”,入参必须有vm(固定格式,node,exp[取值],vm)
class Compile {
// ...
// 编译元素节点
compileElement(node) {
let nodeAttrs = node.attributes;
Array.from(nodeAttrs).forEach(attr => {
// 获取属性
const attrName = attr.name;
// 获取等号后面的值
const exp = attr.value;
// 首先判断是事件还是命令
if (compileUtils.isEvent(attrName)) {
// 获取事件类型
const eventType = attrName.substring(1);
// 绑定事件,事件处理函数名就是exp
compileUtils.eventHandler(node, eventType, exp, this.$vm);
}
if (compileUtils.isDir(attrName)) {
// 获取指令名
const dir = attrName.substring(2);
// 在这里,exp是指令值。
compileUtils[dir] && compileUtils[dir](node, exp, this.$vm);
}
})
}
// 编译文本节点
compileText(node) {
const exp = RegExp.$1.trim();
compileUtils.text(node, exp,this.$vm);
}
}
在utils补充对应的方法:
// 编译器通用方法
const compileUtils = {
//...
// 判读是否事件:@开头
isEvent(attrName) {
return attrName.indexOf('@') == 0;
},
// 判读是否指令:v开头
isDir(attrName) {
return attrName.indexOf('v-') == 0;
},
// 事件处理函数
eventHandler(node, eventType, exp) {
console.log(node,`绑定了${eventType}事件,函数名为${exp}`);
},
// text指令
text(node, exp) {
console.log(node,`绑定了插值text指令,取值是${exp}`);
},
// model指令
model(node,exp){
console.log(node,`绑定了插值model指令,取值是${exp}`);
}
此时可以测试‘编译’一下:
const c = new Compile(vm)
运行结果如下:
看来各个节点和事件指令都已被正确识别。到目前为止,编译器能够解析出插值变量,函数名,指令变量。基本上已经初具规模了。
上面遗留了两个问题,一个是具体的指令逻辑没写。另一个是除了解析渲染,编译器还要响应watcher。
// 编译器通用方法
const compileUtils = {
// ...
// 分派update更新方法,在此处初始化Watcher
bind(node, exp, dir, vm) {
var updaterFn = this[dir + 'Updater'];
// 第一次初始化视图
updaterFn && updaterFn(node, vm.opts.data[exp]);
// 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher
new Watcher(vm, exp, (value, oldValue) => {
// 一旦属性值有变化,会收到通知执行此更新函数,更新视图
updaterFn && updaterFn(node, value, oldValue);
});
},
如上,给compileUtils.text
/compileUtils.model
设计一个通用的处理方法bind
。首先它会寻找方法库中的xxxUpdater
方法并尝试执行(绑定初始数据)。另一方面,会new一个Watcher对象,当数据变化时,Watcher会通知Compile,调用xxxUpdater
做出改变。
因此完善指令部分逻辑:
// text指令
text(node, exp, vm) {
console.log(node, `绑定了插值text指令,取值是${exp}`);
this.bind(node, exp, 'text', vm);
},
// text更新
textUpdater(node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
// model指令
model(node, exp,vm) {
console.log(node, `绑定了model指令,取值是${exp}`);
this.bind(node, exp, 'model', vm);
},
// model更新
modelUpdater: function(node, value, oldValue) {
node.value = typeof value == 'undefined' ? '' : value;
}
来测试下吧!
const vm = new Vm({
ele: '#app',
data: {
r: 0,
square:123,
cube:321
},
method: {
plus() {
// this.data.r += 1;
console.log('+')
},
minus() {
// this.data.r -= 1;
console.log('-')
}
}
})
new Compile(vm);
至少初始化已经正常了。只是改变关键值,没能得到正确响应。
现在我们已经知道Watcher就是做响应数据变化的事,订阅者作为Observer和Compile之间通信的桥梁,主要做的事情是:
1.Wathcer接受三个参数:vm,所监听的变量名key(也就是compile中的exp
),回调方法callbacl(也就是Compile中的XXXupdater
)。
2、自身必须有一个update()方法
3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。
/**
* 订阅者
*/
class Watcher {
constructor(vm,key,callback) {
this.vm = vm;
this.key = key;
this.callback = callback;
// 实例化时,把自身缓存起来
Dep.target = this;
// 读取vm值,从而触发get
this.vm.opts.data[key];
// 读取完之后清空
Dep.target = null;
}
update() {
console.log(this.callback)
this.callback.call(this.vm,this.vm.opts.data[this.key]);
console.log('属性已更新')
}
}
构造函数代码中有一段操作,是读取data值触发了get。现在来回顾一下这个过程发生了什么:
/**
* 数据劫持
*/
class Observer {
// ...
defineResponsive(data, key, val) {
const dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再define
get() {
// 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除
Dep.target && dep.add(Dep.target);
return val;
},
set(nick) {
const old = data[key];
val = nick;
// 通知订阅者
console.log(`${key}:${old}->${nick}`)
dep.notify();
}
})
// 递归调用
this.observe(val);
}
通过Dep.target = watcherInstance标记订阅者是当前watcher实例,强行触发属性定义的getter方法,getter方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcherInstance就能收到更新通知。
到目前为止,主体功能已经完成,但开发者读取data属性是比较困难的,我想把this.opts.r代理到this.r上面。
class Vm {
constructor(opts) {
this.opts = opts;
const { data,computed } = this.opts;
Object.keys(data).forEach(key=>{
this.proxyData(key)
});
new Observer(data);
new Compile(this);
}
// 代理
proxyData(key){
Object.defineProperty(this,key,{
get(){
return this.opts.data[key];
},
set(newVal){
this.opts.data[key]=newVal;
}
})
}
}
这样就可以直接使用使用this.r了。
现在我想写一个created的生命周期,直接在vm构造函数加一段就行了。
class Vm {
constructor(opts) {
this.opts = opts;
const { data,computed,created } = this.opts;
Object.keys(data).forEach(key=>{
this.proxyData(key)
});
new Observer(data);
new Compile(this);
// created 生命周期
created && created.call(this);
}
因为做的是计算器,因此肯定推荐使用计算属性(computed)。像这样子
const vm = new Vm({
ele: '#app',
data: {
r: 2,
},
method: {
plus() {
this.r += 1
},
minus() {
this.r -= 1
}
},
computed: {
square() {
return Math.pow(this.r,2);
},
cube(){
return Math.pow(this.r,3);
}
},
created(){
// console.log('创建完毕',this.square)
}
})
计算属性如果需要做,也要添加到响应式系统中,当初始化、关键数据变化时,先尝试读取data中的数据,如不成功,再尝试读取computed。
// vm.js
/**
* 总体入口
*/
class Vm {
constructor(opts) {
this.opts = opts;
const { data, computed, created } = this.opts;
Object.keys(data).forEach(key => {
this.proxyData(key);
});
// 计算属性
this.initComputed();
new Observer(data,this);
new Compile(this);
created && created.call(this);
}
// 代理
proxyData(key) {
Object.defineProperty(this, key, {
get() {
return this.opts.data[key];
},
set(newVal) {
this.opts.data[key] = newVal;
}
})
}
// 计算属性
initComputed() {
const computed = this.opts.computed;
if (!computed) {
return
}
if (typeof computed === 'object') {
Object.keys(computed).forEach(key => {
const getter = computed[key];
this.computedDep = new Dep();
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get:() => {
const value = getter.call(this);
Dep.target && this.computedDep.add(Dep.target);
// console.log('变了',value)
return value;
},
set: function () { }
});
});
}
}
}
在Observer中通知订阅者:
class Observer {
constructor(data,vm) {
// 现在需要拿到vm对象
this.vm = vm;
this.observe(data);
}
defineResponsive(data, key, val) {
const dep = new Dep();
const _this = this;
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再define
get() {
// ...
},
set(nick) {
const old = data[key];
val = nick;
// 通知订阅者
console.log(`${key}:${old}->${nick}`)
dep.notify();
// 计算属性的依赖收集器也要一并通知!
_this.vm.computedDep.notify();
}
})
// 递归调用
this.observe(val);
}
在complieUtils
的bind中,也增加尝试读取computed的逻辑:
// 编译器通用方法
const compileUtils = {
// 分派update更新方法,在此处初始化Watcher
bind(node, exp, dir, vm) {
var updaterFn = this[dir + 'Updater'];
if(vm.opts.computed[exp]){
vm.opts.computed[exp] = vm.opts.computed[exp].bind(vm);
}
// 尝试读取computed
let _value = vm.opts.data[exp] ? vm.opts.data[exp] : vm.opts.computed[exp]();
updaterFn && updaterFn(node, _value);
new Watcher(vm, exp, (value, oldValue) => {
// 一旦属性值有变化,会收到通知执行此更新函数,更新视图
updaterFn && updaterFn(node, value, oldValue);
});
},
最后,在watcher中,读取计算属性,并派发更新值:
/**
* 订阅者
*/
class Watcher {
constructor(vm,key,callback) {
this.vm = vm;
this.key = key;
this.callback = callback;
// 实例化时,把自身缓存起来
Dep.target = this;
// 读取vm值,从而触发get
this.vm.opts.data[key];
if(!this.vm.opts.data[key] && this.vm.opts.computed[key]){
// 查找计算属性
// 读取计算属性,触发get
this.vm.opts.computed[key]();
}
// 读取完之后清空
Dep.target = null;
}
update() {
if(this.vm.opts.data[this.key]){
// 普通data属性
this.callback.call(this.vm,this.vm.opts.data[this.key]);
}else if(this.vm.opts.computed[this.key]){
// 计算属性
this.callback.call(this.vm,this.vm.opts.computed[this.key]());
}
}
}
所以计算属性功能完成。
-----分割线----
现在有了自己的轮子,可以写个计算器了。
最终效果: