不造个轮子,你还真以为你会写代码了?
Posted 最骚的就是你
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了不造个轮子,你还真以为你会写代码了?相关的知识,希望对你有一定的参考价值。
链接:https://zhuanlan.zhihu.com/p/24435564
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
最近在琢磨Vue的实现原理,参照着Vue捣鼓了一个轮子,一个轻量的前端MVVM框架,Vue的绑定指令基本都实现了一遍。轮子姑且叫vueuv.js吧,GitHub:qieguo2016/Vueuv,欢迎围观上星星~~
MVVM原理实现非常巧妙,真心佩服作者的构思;编译部分没用源码的方式实现,自己捣鼓着实现的,过程真是既烧脑也获益良多:
不造个轮子,你还真以为你会写代码了?
How to use
引入vueuv.js后,用法就跟Vue一毛一样了:
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: ‘#app‘,
data: {
message: ‘Hello Vue!‘
}
})
渲染后的html是这样的:
<div id="app">
Hello Vue!
</div>
其他的指令也是一样的语法,更多指令请看Vue的文档http://cn.vuejs.org/v2/guide/, 这里就不再赘述了。现在Vueuv还没加Filter语法,另外CSS和style指令暂时只支持对象语法,数组语法还没来得及做,什么时候爽了的话我会考虑补上去的~~
项目地址:qieguo2016/Vueuv,欢迎交流点赞上星星。
代码目前还是用es5写的,打包也是手动拼装的,这方面不打算折腾了,下面来点干货,分享下基本实现和编码过程的一些思考吧。
双向绑定核心
双向绑定的实现核心有两点:1、Object.defineProperty劫持对象的getter、setter,从而实现对数据的监控。2、发布/订阅者模式实现数据与视图的自动同步。
-
Object.defineProperty顾名思义,就是用来定义对象属性的,这里我们主要在getter和setter函数里面插入一些处理方法,当对象被读写的时候处理方法就会被执行了。 关于这个方法的更具体解释,可以看MDN上的解释(戳我);
-
发布/订阅者模式,其实就是我们addEventListener那套东西。自己手动实现一个也非常简单:
function EventHandle() {
var events = {};
this.on = function (event, callback) {
callback = callback || function () { };
if (typeof events[event] === ‘undefined‘) {
events[event] = [callback];
} else {
events[event].push(callback);
}
};
this.emit = function (event, args) {
events[event].forEach(function (fn) {
fn(args);
});
};
this.off = function (event) {
delete events[event];
};
}
视图的变化引发数据更新可以用监听input事件的方式直接修改数据来实现,而数据的变动驱动视图的更新则需要手动实现。 参照订阅发布者模式,我们可以将视图更新方法注册到事件列表中,而更新消息则由setter触发,更新消息会触发视图更新函数,这样就实现了数据到视图的更新。
模块分析
为了更好分析整个系统,接下来分成三个大模块来展开。首先是订阅/发布者模式中的发布者,在Vue中发布者就是观察数据模型并发出更新消息的Observer。
Observer
我们都知道要在setter里面发布更新消息,但是一个变量会被多个表达式所依赖,怎么找出依赖的表达式并更新呢?如果是用Angular1.x中的脏检查来实现,那么遍历所有被监视的值,找出脏数据然后更新视图就可以了。 但是Vue的实现却是更为精细的依赖管理,找到依赖该变量的表达式列表,然后更新列表中表达式的值,再去更新视图。显然,关键的一步就是依赖列表的构建了。
想当然的我们肯定是在解析表达式的时候收集变量,然后用一个依赖列表[变量a]的数组/哈希来依次保存依赖该变量a的表达式。Vue的做法也是类似,但是实在是高明太多。直接看代码:(完整版)
Observer.prototype.observe = function (data) {
var self = this;
// 设置开始和递归终止条件
if (!data || typeof data !== ‘object‘) {
return;
}
Object.keys(data).forEach(function (key) {
self.defineReactive(data, key, data[key]);
});
};
Observer.prototype.defineReactive = function (data, key, val) {
var dep = new Dep();
var self = this;
self.observe(val); // 递归对象属性到基本类型为止
Object.defineProperty(data, key, {
enumerable : true, // 枚举
configurable: false, // 不可再配置
get : function () {
Dep.target && dep.addSub(Dep.target);
return val;
},
set : function (newVal) {
if (val === newVal) {
return;
}
val = newVal; // val作为一个闭包,保存最新值
self.observe(newVal);
dep.notify(); // 触发通知
},
});
};
setter里面跟我们想的一样,更新数据的时候发出通知,这里我们可能会漏掉的是对newVal的监控,设置值之后当然也要监控新值了。
再看看getter,可以看到依赖列表是在getter里面添加的!并不是在解析的时候另调用一个方法来创建依赖列表! 而且依赖列表是作为一个闭包存在,每个变量单独一个列表!并不是像我想的那样用一个全局的结构来保存依赖列表! 而由于getter除了初次编译之外后面每次使用都会触发,所以还增加了一个标识来控制是否添加依赖列表,为了能从外部传入,标识挂在了Dep构造函数上! Dep上的属性是被所有Dep的实例共享的,但由于js是单线程的,所以在一个时刻只有一个Dep生效,在添加完监视后移掉target即可保证不会影响到其他变量!
这一做法堪称神来之笔,并没有很高深的东西,但我相信绝大部分人永远也想不到如此巧妙的实现。
依赖Dep的构造就很简单了,跟我们上文的EventHandle是一样的,这里加了一点去重(完整代码)。
function Dep() {
this.subs = {};
};
Dep.prototype.addSub = function (target) {
if (!this.subs[target.uid]) { //防止重复添加
this.subs[target.uid] = target;
}
};
Dep.prototype.notify = function () {
for (var uid in this.subs) {
this.subs[uid].update();
}
};
Watcher
看完了发布者,接下来看看订阅者Watcher。订阅者的功能比较简单,就是接收发布者的消息,然后调用相应的更新方法去更新视图。 每一个订阅者对应一个表达式,这里要注意的就是Dep.target的赋值与清除。这里最重要最有意思的是用来计算表达式的computeExpression这个方法,文末会结合编译器一起介绍(完整代码)。
function Watcher(exp, scope, callback) {
this.value = null;
this.update(); //初始化时,触发添加到监听队列
}
Watcher.prototype = {
get : function () {
Dep.target = this;
var value = computeExpression(this.exp, this.scope); // 表达式求值的时候调用getter从而添加监听
Dep.target = null;
return value;
},
update: function () {
var newVal = this.get();
if (this.value != newVal) {
this.callback && this.callback(newVal, this.value);
this.value = newVal;
}
}
}
Compiler
以上两步已经实现了一个订阅/发布者模式,接下来就是如何将模板与这两者关联起来了,这就轮到Compiler出场了。Compiler主要是提取模板中的指令,然后将数据与模板绑定起来。
PS:这里参照的是Vue 1.x版的Compiler,2.x的实现已经用上了AST了,有时间你们就研究一下吧~~~
为了提高效率,Vue首先将模板的dom结构复制到文档片段中,然后在文档片段中进行编译,最后将编译好的文档片段插入dom树中(完整代码)。主体代码如下:
function Compiler(options) {
this.$el = options.el;
this.vm = options.vm;
if (this.$el) {
this.$fragment = nodeToFragment(this.$el);
this.compile(this.$fragment);
this.$el.appendChild(this.$fragment);
}
}
Compiler.prototype = {
compile: function (node, scope) {
var self = this;
if (node.childNodes && node.childNodes.length) {
[].slice.call(node.childNodes).forEach(function (child) {
if (child.nodeType === 3) {
self.compileTextNode(child, scope);
} else if (child.nodeType === 1) {
self.compileElementNode(child, scope);
}
});
}
},
compileTextNode: function (node, scope) {
var text = node.textContent.trim();
if (!text) {
return;
}
var exp = parseTextExp(text);
scope = scope || this.vm;
this.textHandler(node, scope, exp);
},
compileElementNode: function (node, scope) {
var attrs = node.attributes;
var self = this;
scope = scope || this.vm;
[].forEach.call(attrs, function (attr) {
var attrName = attr.