最近答辩需要选择一个专业选题,于是在小导师的建议下选了这个题目,博客的内容大多是对大佬们精华的整理,学会了就是我的啦~
数据双向绑定
双向绑定:在单向绑定的基础上给可输入元素(input、textarea等)添加 change、input事件,来动态修改model和view。
MVVM框架实现数据绑定的主流做法:
- 发布者-订阅者模式 backbone.js
- 脏值检查 anglular.js
- 数据劫持 vue.js
VUE采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()
来劫持各个属性的setter、getter,在数据改变时发布消息给订阅者,触发相应的监听回调。
数据单向绑定
单向绑定:将 Model数据 绑定到 View。
当我们用JavaScript代码更新Model时,View就会自动更新。而View 变化,不会自动影响到 Model 状态。
VUE中的单向绑定:
- {{ data}} Mustache语法
- v-bind 指令
微信小程序的单向绑定:
- {{ data }} Mustache语法
- {{ flag ? '男' : '女' }} 三元运算
- {{ a + b }} 算术运算
- {{ “Hello" + world }} 字符串运算
- {{ [zero, 1, 2, 3 ] }} 数组
- {{ a: 1, b: 2 }} 对象
- {{ ...obj1, ...obj2, c: 3 }} 扩展运算符
实现单向绑定
1、通过Object.defineProperty
在 Javascript 中,如果改变一个属性的值,那么对应的 setter 函数会被执行。而Object.defineProperty()方法可以在对象上定义或修改属性的数据描述符或存取描述符。因此,我们可以通过Object.defineProperty()为一个新对象的属性定义get、set存取描述符,在getter函数中返回原data对象的属性值,在setter函数中修改data属性的值通知html页面刷新。
下面维护一个obj的属性:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<div id="app">
姓名:{{name}}<br>
</div>
<script>
var obj = {};
Object.defineProperty(obj, 'name', {
enumerable: true,
configurable: true,
get: function () { return data.name; },
set: function (val) {
data.name = val;
render();
}
});
let data = {
name: '阿中',
}
let el = document.getElementById('app');
let template = el.innerHTML;
render();
function render() {
el.innerHTML = template.replace(/\{\{(.+?)}\}/g, (...args) => {
return obj[args[1]];
});
}
</script>
</body>
</html>
在新的对象obj的set()操作中,完成数据修改操作的同时,更新视图内容。
2、通过代理对象
Proxy对象用于包装目标对象,可以拦截对代理对象的读取/写入属性和其他操作。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="app">
姓名:{{name}}<br>
年龄:{{age}}
</div>
</body>
<script>
let el = document.getElementById('app');
let template = el.innerHTML;
let _data = {
name: '阿信',
age: 25
};
let data = new Proxy(_data, {
set(obj, name, value) {
obj[name] = value; //或者使用反射完成
render();
}
});
render();
function render() {
el.innerHTML = template.replace(/\{\{\w+\}\}/g, str => {
str = str.substring(2, str.length - 2);
return _data[str];
});
}
</script>
</html>
实现思路和Object.defineProperty基本一致。
3、结合发布/订阅者模式
由于一个属性的值可能关联到多个DOM节点,一个DOM节点也可能依赖到多个属性的值,我们使用发布-订阅模式来维护属性、节点间一对多的关系。
发布-订阅者模式可以类比公众号与读者的关系,读者订阅公众号后,每当公众号的内容发生更新,微信就会主动向读者发送推送消息。类似地,data对象中的属性就是发布者,页面中的dom节点就是订阅者,一旦属性的值发生改变,订阅器就通知dom节点以判断是否更新视图内容。
当然,我们刚才是固定一个div节点进行文本替换,实际我们需要通过文档对象模型Dom,将整个页面内容表示为可以修改的节点对象,解析模板替换数据。
综上所述,实现数据的单向绑定,我们需要:
1、监听器Observer,用来劫持并监听所有属性,属性变动时通知订阅器。
2、订阅者Watcher,可以收到属性的变化通知并调用更新视图。
3、订阅器 Dep, 在发布者和订阅者之间进行统一管理。一方面添加订阅者,一方面将发布者Observer的变化消息传达给订阅者Wachter。
4、解析器Compile,扫描解析每个节点的相关指令,初始化模板数据和更新页面。
实现Observer
对data对象中属性进行递归遍历,为每个属性都加上getter、setter劫持,以便完成对属性赋值操作的监听。
// 发布者,实现数据劫持和属性监听
// 发布者,实现数据劫持和属性监听
class Observer {
constructor(data) {
this.observer(data);
}
observer(data) {
if (data && typeof data == 'object') { //仅针对对象
for (let key in data) {
this.defineReactive(data, key, data[key]);
}
}
}
defineReactive(obj, key, value) {
this.observer(value); // 监听子属性
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: false, // 不可配置
get() {
return value;
},
set: (newVal) => {
if (newVal !== value) {
this.observer(newVal); //改变后的值可能是一个对象
value = newVal;
}
}
})
}
}
实现Dep
Dep需具有发布、订阅功能。
// 订阅器,维护发布者与订阅者之间的管理
class Dep {
constructor() {
this.subs = []; //存放所有的watcher
}
// 订阅
addSub(watcher) { // 添加 watcher
this.subs.push(watcher);
}
// 发布
notify() {
this.subs.forEach(watcher => watcher.update()) //调用更新函数
}
}
为每个被监听的属性都创建一个Dep:
defineReactive(obj, key, value) {
this.observer(value); // 监听子属性
let dep = new Dep() //给每一个属性 都加上一个具有发布订阅功能的dep
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: false, // 不可配置
get() {
return value;
},
set: (newVal) => {
if (newVal !== value) {
this.observer(newVal); //改变后的值可能是一个对象
value = newVal;
dep.notify(); //将属性变化的消息发布给所有订阅者
}
}
}
实现Watcher
观察者Watcher,应该具备在所依赖的属性改变时触发更新页面的函数。
// 观察者
class Watcher {
constructor(vm, expr, cb) {
//vm viewModel 保存data; cb callback 回调函数
this.vm = vm;
this.expr = expr;
this.cb = cb;
//默认先存放一个原来的值
this.oldValue = this.get();
}
get() { //初始化时获取所依赖的属性原本的值
Dep.target = this; //将当前订阅者指向自己
let value = CompileUtil.getVal(this.vm, this.expr);//触发get()
Dep.target = null; //添加完毕
return value;
}
update() { //更新操作,数据变化后会调用观察者的update方法
//获取更新后的值
let newVal = CompileUtil.getVal(this.vm, this.expr);
if (newVal !== this.oldValue) {
this.cb(newVal);
}
}
}
在Watcher实例化的同时,应该往属性订阅器dep中添加自己;因此在Observer的get()操作中添加:
defineReactive(obj, key, value) {
this.observer(value);
let dep = new Dep() //给每一个属性 都加上一个具有发布订阅功能的dep
Object.defineProperty(obj, key, {
get() {
// 将当前订阅者添加到dep中
Dep.target && dep.addSub(Dep.target);
return value;
},
...
}
实现Compiler
编译器,解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图;并且将每个指令对应的节点绑定更新函数,添加监听数据的订阅者。
class Compiler {
constructor(el, vm) {
// 判断 el属性 是不是一个元素;若不是,则获取元素
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 将当前节点元素 获取到内存中 以提高效率
let fragment = this.node2fragment(this.el);
//编译模板
this.compile(fragment);
// 把内容塞回页面中
this.el.appendChild(fragment);
}
...
把节点获取进内存中的方法:创建文档碎片fragment
//把节点移动到内存中
node2fragment(node) {
//创建一个文档碎片
let fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = node.firstChild) {
//appendChild具有移动性,会把原本的节点移动而不是复制到文档碎片中
fragment.appendChild(firstChild);
}
return fragment;
}
接着,完成Compiler的核心编译功能。
//核心编译方法 编译内存中 DOM节点
compile(node) {
let childNodes = node.childNodes;
[...childNodes].forEach(child => {
if (this.isElementNode(child)) { //编译元素节点
this.compileElement(child);
// 递归编译子节点
this.compile(child);
} else {
this.compileText(child); //编译文本节点
}
});
}
对VUE进行分析可知,{{ }}插值表达式只能出现在文本节点中。这里由于仅讨论单向绑定中的插值表达式,故省略元素节点中的v-bind指令,仅讨论文本节点:
//编译文本
compileText(node) { //判断当前节点是否含有{{}},若无则无需处理
let content = node.textContent;
if (/\{\{(.+?)\}\}/.test(content)) {
CompileUtil['text'](node, content, this.vm);
}
}
这里使用了一个编译工具类CompileUtil,text方法主要用于为{{}}表达式创建Watcher,并且替换模板数据。
CompileUtil = {
text(node, expr, vm) { // exp = > {{a}} {{b}} {{c}} => a b c
let fn = this.updater['textUpdater'];
let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { //将{{}}替换为值
// 给表达式每个{{}}里的表达式都加上观察者
new Watcher(vm, args[1], (newVal) => {
fn(node, this.getContentValue(vm, expr)); //回调函数是文本结点更新函数
})
return this.getVal(vm, args[1]); // 获取文本节点的值
});
fn(node, content);
},
// 根据表达式获取对应的数据
// 不能直接用vm[expr]取,expr是一个字符串,无法取出嵌套对象的属性
getVal(vm, expr) { // vm.$data 'school.name'
return expr.split('.').reduce((data, current) => {
return data[current];
}, vm.$data);
},
setValue(vm, expr, value){
expr.split('.').reduce((data,current,index,arr)=>{
if(index === arr.length-1){
return data[current] = value;
}
return data[current];
}, vm.$data);
},
getContentValue(vm, expr) {
// 遍历表达式,将内容重新替换成一个完整的内容
return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getVal(vm, args[1]);
})
},
updater: {
// 把数据插入到节点里
textUpdater(node, value) {
node.textContent = value;
}
}
}
主要内容基本都完成了,实现MVVM构造器来作为数据绑定的入口吧:
class Vue {
constructor(options) {
this.$el = options.el;
this.$data = options.data;
// 若根元素存在 编译模板
if (this.$el) {
new Observer(this.$data);
this.proxyVm(this.$data);
new Compiler(this.$el, this);
}
}
}
目前只能使用vm.$data来对属性进行操作。
为了符合大众直接操作data的习惯,我们直接把对vm取值的操作都代理到 vm.$data上:
proxyVm(data){
for(let key in data){
Object.defineProperty(this, key, {
get(){
return data[key];
}
})
}
}
至此,插值文本表达式的单向绑定基本实现了。完整代码见:xxxxxxxx(还没上传,稍后更新地址)
实现双向绑定
单向绑定+UI事件
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。