100行代码实现一个迷你Vue…
从MVVM谈起
MVVM全称是Model-View-ViewModel。经历过还没有React、Vue等框架的那个年代的前端们应该都知道,手动去同步页面上的数据与视图是一件多么麻烦的事情,特别是在数据和交互较复杂的页面上,手动同步会带来代码冗余、容易出错、难以追踪错误等问题。
MVVM框架的出现解决了这个问题。我们只要建立好数据结构以及数据与视图的对应关系,在之后的开发中,只需要把关注点放在处理数据上,框架会自动帮我们处理好数据变动带来的视图更新。
如何造一个MVVM
MVVM的核心功能在于将数据和视图关联起来。即监听数据变动的事件,事件触发时对关联这个数据的视图进行更新。
我们先明确想要实现的功能,再去思考怎么实现。这里用一个例子来说明: 现在有格式如下的数据
let data = { text: 'world' };
以及语法如下的视图
<body>
<div>hello</div>
<div>{{ text }}</div>
</body>
咱们想要实现的是: 初始化后,
会被解析为要达到这个效果,我们需要做的东西有:
- 能监听数据变化的观察者(Observer),来监听data.text的变化
- 编译DOM的编译器(Compiler),将中的text替换成对应数据,并更新该DOM节点。
- 连接Observer和Compiler的桥梁(Dependence & Watcher),它的作用是当Observer监听到某数据变化时,去通知所有依赖该数据的Compiler,让它们重新编译并更新DOM。
OK,思路整理完毕,接下来一步步实现。
动手实现迷你Vue
Observer
数据是存放在data对象上的,要监听数据变化也就是监听对象的键值变化(这里先不讨论有数组的情况),此时可以借助Reflect的defineProperty来实现。
先来做个小实验,执行下列代码
let data = { text: 'world' };
let val = data.text;
Reflect.defineProperty(data, 'text', {
get () {
console.log('get: ' + val);
return val;
},
set (newVal) {
console.log('set: ' + newVal);
val = newVal;
}
});
data.text = 'vue';
console.log(data.text);
// 控制台输出如下
// 'set: vue'
// 'get: vue'
// 'vue'
可以看到date.text设值和取值的过程都被监听到了,这样我们可以在getter和setter中加入逻辑来完成对数据变动的监听。
虽然监听成功了,但是还需要对这个方法加工一下,使其能够应对对象有多个键以及对象里面还有对象的情况
let data = { text: 'world' };
function observe (data) { // 监听对象
if (Object.prototype.toString.call(data) === '[object Object]') { // 确保监听对象是Object
for (let prop in data) {
defineReactive(data, prop, data[prop]);
}
}
}
function defineReactive (obj, key, val) { // 对对象的key进行监听
Reflect.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
return val;
},
set (newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal); // 监听新的键值
}
});
observe(val); // 递归监听对象的键值
}
observe(data);
这时我们已经能够对data对象里的数据变动进行监听了。这里要注意的是,直接对对象的一个新key赋值是不会被监听到的(因为这个新的key并没有被设置getter/setter),所以需要在一开始就把data中需要的key先列出来,使其被监听到。
接下来我们为每个key构造一个容器。这个容器(Dependence)需要能够接受这个key的数据变动事件,然后通知给关心该事件的所有订阅者(Subscription),所以它的方法应该有:1. 添加订阅者 2. 删除订阅者 3. 通知所有订阅者。这一步我们不去管订阅者怎么实现,先把这个容器写出来,后面再完善。
class Dep {
constructor () {
this.subs = new Set();
}
addSub (sub) { // 添加订阅者,因为用了Set,所以这里可以保证添加进来的订阅者不会重复
this.subs.add(sub);
}
removeSub (sub) { // 删除订阅者
this.subs.delete(sub);
}
notify () { // 通知所有订阅者(调用每个订阅者的update方法)
for (let sub of this.subs) {
sub.update();
}
}
}
然后把这个容器和前面的defineReactive函数结合起来
function defineReactive (obj, key, val) {
const dep = new Dep(); // 为每个key构造一个存放关心该key的订阅者的容器
Reflect.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
return val;
},
set (newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal);
dep.notify(); // 当key变化时通知该容器下的所有订阅者
}
});
observe(val);
}
观察者(Observer)已经基本上完成了。下一步我们来实现编译器(Compiler)。
Compiler
现在我们需要一个编译器来遍历DOM树,并把需要编译的部分替换成编译的结果。
第一步是模板编译。
我们以一个简单例子开始,定义一个数据源
let data = { text: 'world' };
以及这样一段模板:hello {{ text }} !
我们想要的编译效果是,把{{}}之外的内容用字符串原封不动地输出,把{{}}之内的代码作为js表达式输出,然后把它们拼接到一起,供之后的代码使用。要实现这个效果,需要对模板进行正则解析
function textToExp (text) {
let pieces = text.split(/({{.+?}})/g);
pieces = pieces.map(piece => {
if (piece.match(/{{.+?}}/g)) { // {{}}内的代码,以js表达式输出
piece = '(' + piece.replace(/^{{|}}$/g, '') + ')';
} else { // {{}}外的代码,以字符串输出
piece = '`' + piece.replace(/`/g, '\\`') + '`'; // 需要对字符串中的`转义
}
return piece;
});
return pieces.join('+');
}
console.log(textToExp('hello {{ text }} !'));
// 控制台输出
// '`hello `+( text )+` !`'
拼接好的这段字符串可以用Function的构造函数转化成可执行代码,用法类似这样
function expToFunc (exp) {
return new Function('return ' + exp);
}
let fn = expToFunc('`hi ` + name');
let name = 'nossika';
console.log(fn());
// 控制台输出
// 'hi nossika'
但这样有一个问题,数据源data是一个对象,如何把表达式中的变量对应到data的属性上(比如text要对应到data.text)?
这个问题好像只要编译的时候在变量前面加上前缀data.编译成data.key这样的格式就可以解决。但是表达式不一定只是类似于的一个简单变量,也有可能是类似于 hi undefined的一些复杂情况,这时候可能就得再引入一个词法解析器来把表达式解析成抽象语法树,才能找出并操作里面的变量。
有个方法可以方便地解决这个加前缀的问题:使用with。虽然with并不被推荐使用,但对于这个问题使用with确实能省事不少。下面使用with来改造一下expToFunc函数,结合模板编译的例子 测试一下:
let data = { text: 'world' };
function expToFunc (exp, scope) {
// 把数据源绑定到函数的this,函数内部加上with(this){},这样函数内的变量访问的就是数据源上的key
return new Function('with(this){return ' + exp + '}').bind(scope);
}
let fn = expToFunc('`hello `+( text )+` !`', data);
console.log(fn());
// 控制台输出
// 'hello world !'
现在我们已经能够结合数据源对文本进行编译了,但我们需要的功能是:传入一个DOM树和数据源,DOM树上的节点会被遍历并编译,所以得再封装一下:
function textToExp (text) {
// ...
}
function expToFunc (exp, scope) {
// ...
}
function walkChildren (el, scope) {
// el.childNodes获取到的是NodeList对象,先把它转化Array
[].slice.call(el.childNodes).forEach(node => {
if (node.nodeType === 3) { // 对文本节点编译并替换文本内容
compileText(node, scope);
} else if (node.nodeType === 1) { // 对元素节点则继续遍历
walkChildren(node, scope);
}
});
}
function compileText (node, scope) { // 结合 textToExp 和 expToFunc 来完成文本编译
let exp = textToExp(node.textContent);
node.textContent = expToFunc(exp, scope)();
}
然后结合DOM测试一下walkChildren:
<body>
<div id="app">
<div>hello</div>
<div>{{ text }}</div>
</div>
</body>
<script>
// ...
let data = { text: 'world' };
walkChildren(document.querySelector('#app'), data);
</script>
执行完walkChildren后,页面上最终的DOM被渲染成:
<body>
<div id="app">
<div>hello</div>
<div>world</div>
</div>
</body>
编译器(Compiler)也基本上完成了,现在还剩下最后一步:我们需要在data中的数据改变的时候,重新触发对应的Compiler来对DOM进行更新。所以我们再引入一个Watcher,来把Observer和Compiler关联起来。
Watcher
在前面的Observer一节中,我们为数据源的每个key创建了一个Dep实例,这个Dep能够容纳多个订阅者,并且能够在key改变的时候通知这些订阅者(即触发订阅者的update方法)。这里所说的订阅者就是本节的Watcher,订阅者的update方法就是去调用Compiler。
为满足我们的需求,Watcher应该存放三样东西:原始表达式(exp)、数据源(scope)、DOM更新函数(callback)。当触发它的update方法时,它会根据数据源和原始表达式来编译出最新的文本,然后调用DOM更新函数来更新DOM。下面先把Watcher的大致样子写出来:
class Watcher {
constructor (exp, scope, callback) {
this.value = null; // 存放当前编译结果
this.getValue = expToFunc(exp, scope); // 生成编译结果的函数
this.callback = callback;
this.update(); // 绑定时需要编译一次
}
get () {
return this.getValue();
}
update () {
let newVal = this.get(); // 获取最新编译结果
if (this.value !== newVal) { // 如果最新编译结果和当前的不一样,则调用callback更新DOM
this.value = newVal;
this.callback && this.callback(newVal);
}
}
}
然后修改一下Compiler中的逻辑,编译文本节点时,为这个文本节点创建一个Watcher,把它交给Watcher来更新。其实就是对compileText函数小小的修改一下:
// 原来的compileText
function compileText (node, scope) {
let exp = textToExp(node.textContent);
node.textContent = expToFunc(exp, scope)();
}
改为
// 结合Watcher的原来的compileText
function compileText (node, scope) {
let exp = textToExp(node.textContent);
// Watcher绑定时会先编译一次,所以这里不需要再手动修改node.textContent
new Watcher(exp, scope, newVal => {
node.textContent = newVal;
});
}
现在再理一下思路,我们整理出来的流程是:数据源的key改变时,会去调用该key下的Dep中的每个Watcher的update方法来更新DOM。现在Dep有了,Watcher也有了。
接下来就是最关键的一步了,怎么把Watcher添加到对应的Dep中呢?
似乎应该在绑定Watcher的时候,去解析表达式中关联了数据源中的哪些key,然后把这个Watcher添加到这些key下的Dep中。如果这样处理的话还是得引入一个词法解析器才能解析出表达式中关联的key。
Vue中用了一个非常精妙的方法解决了这个问题:利用getter。
Watcher的编译是用到了expToFunc(this.exp, this.scope)(),这个过程会把表达式变成类似with(scope){return prop1 + prop2}的函数,写得更直观一点就是return scope.prop1 + scope.prop2,并且执行它来求表达式的值。所以在这个过程中其实已经调用了表达式中关联的scope的key的getter。所以只要在key的getter中把这个Watcher添加到Dep就可以了。
为实现这个效果,我们需要引入一个全局标记,初始值是null,在Watcher执行编译前把这个标记指向当前Watcher实例。编译过程中会触发各个key的getter,getter中需要判断标记指向的是否指向一个Watcher,是的话把该Watcher添加到Dep中。编译结束后再把这个标记指向null。
这个全局标记可以挂在Dep的静态属性上,我们给Dep添加一条静态属性target:
class Dep {
static target = null;
// ...
}
然后修改一下Watcher,在编译前后改变target的指向:
class Watcher {
// ...
get () {
Dep.target = this; // 编译前把target指向当前Watcher实例
let value = this.getValue(); // 这个编译过程中会触发关联的key的getter
Dep.target = null; // 编译后把target重置
return value;
}
// ...
}
最后修改一下Observer中defineReactive,在getter中把target指向的Watcher添加到Dep:
function defineReactive (obj, key, val) {
const dep = new Dep();
Reflect.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
// 如果target指向了Watcher,就把这个Watcher添加到Dep
// Dep的addSub是一个Set,所以不会把一个相同的Watcher添加多次
Dep.target && dep.addSub(Dep.target);
return val;
},
set (newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal);
dep.notify();
}
});
observe(val);
}
Vue
到这里我们已经把所有的部分(Observer,Compiler,Dep,Watcher)都实现了,现在把它们组装到一起:
class Vue {
constructor (options) {
this.$data = options.data;
this.$el = document.querySelector(options.el);
observe(this.$data);
walkChildren(this.$el, this.$data);
}
}
然后像这样去调用:
let vm = new Vue({
el: '#app',
data: {
text: 'world'
}
});
这里还可以做一个优化,目前改变数据需要像vm.$data.text = ‘new value’这样写,如果能够像vm.text = ‘new value’这样就要方便多了。我们加个代理函数proxy:
function proxy (vm, options) {
for (let prop in options.data) {
Reflect.defineProperty(vm, prop, {
enumerable: true,
configurable: true,
get () {
return vm.$data[prop]; // 调用vm.$data的getter
},
set (newVal) {
vm.$data[prop] = newVal; // 调用vm.$data的setter
}
})
}
}
class Vue {
constructor (options) {
this.$data = options.data;
this.$el = document.querySelector(options.el);
observe(this.$data);
proxy(this, options);
walkChildren(this.$el, this.$data);
}
}
这样就可以直接对vm.text直接取值和赋值了。
到这里,我们要做的迷你Vue已经全部完成了,它实现了MVVM核心的数据视图绑定功能。有兴趣的读者可移步到github:little-vue自行查看源码,其中实现了更多Vue的api(支持对数组监听,支持v-if等属性节点编译、更多options选项,nextTick优化等)。
完整代码:
// Observer
function observe (data) {
if (Object.prototype.toString.call(data) === '[object Object]') {
for (let prop in data) {
defineReactive(data, prop, data[prop]);
}
}
}
function defineReactive (obj, key, val) {
const dep = new Dep();
Reflect.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get () {
Dep.target && dep.addSub(Dep.target);
return val;
},
set (newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal);
dep.notify();
}
});
observe(val);
}
// Dep
class Dep {
constructor () {
this.subs = new Set();
}
addSub (sub) {
this.subs.add(sub);
}
removeSub (sub) {
this.subs.delete(sub);
}
notify () {
for (let sub of this.subs) {
sub.update();
}
}
}
Dep.target = null;
// Compiler
function textToExp (text) {
let pieces = text.split(/({{.+?}})/g);
pieces = pieces.map(piece => {
if (piece.match(/{{.+?}}/g)) {
piece = '(' + piece.replace(/^{{|}}$/g, '') + ')';
} else {
piece = '`' + piece.replace(/`/g, '\\`') + '`';
}
return piece;
});
return pieces.join('+');
}
function expToFunc (exp, scope) {
return new Function('with(this){return ' + exp + '}').bind(scope);
}
function walkChildren (el, scope) {
[].slice.call(el.childNodes).forEach(node => {
if (node.nodeType === 3) {
compileText(node, scope);
} else if (node.nodeType === 1) {
walkChildren(node, scope);
}
});
}
function compileText (node, scope) {
let exp = textToExp(node.textContent);
new Watcher(exp, scope, newVal => {
node.textContent = newVal;
});
}
// Watcher
class Watcher {
constructor (exp, scope, callback) {
this.value = null;
this.getValue = expToFunc(exp, scope);
this.callback = callback;
this.update();
}
get () {
Dep.target = this;
let value = this.getValue();
Dep.target = null;
return value;
}
update () {
let newVal = this.get();
if (this.value !== newVal) {
this.value = newVal;
this.callback && this.callback(newVal);
}
}
}
// Vue
function proxy (vm, options) {
for (let prop in options.data) {
Reflect.defineProperty(vm, prop, {
enumerable: true,
configurable: true,
get () {
return vm.$data[prop];
},
set (newVal) {
vm.$data[prop] = newVal;
}
})
}
}
class Vue {
constructor (options) {
this.$data = options.data;
this.$el = document.querySelector(options.el);
observe(this.$data);
proxy(this, options);
walkChildren(this.$el, this.$data);
}
}
转载于:100行代码实现一个迷你Vue