不知不觉接触前端的时间已经过去半年了, 越来越发觉对知识的学习不应该只停留在会用的层面, 这在我学 jQuery 的一段时间后便有这样的体会.
虽然 jQuery 只是一个 JS 的代码库, 只要会一些 JS 的基本操作学习一两天就能很快掌握 jQuery 的基本语法并熟练使用, 但是如果不了解 jQUery 库背后的实现原理, 相信只要你一段时间不再使用 jQuery 的话就会把 jQuery 忘得一干二净, 这也许就是知其然不知其所以然的后果.
最近在学 vue 的时候又再一次经历了这样的困惑, 虽然能够比较熟练的掌握 vue 的基本使用, 也能够对 MV * 模式, 数据劫持, 双向数据绑定, 数据代理侃上两句. 但是要是稍微深入一点就有点吃力了. 所以这几天痛下决心研究大量技术文章(起初尝试看早期源码, 无奈 vue 与 jQuery 不是一个层级的, 相比于 jQuery,vue 是真正意义上的前端框架. 只能无奈弃坑转而看技术博客), 对 vue 也算有了一个管中窥豹的认识. 最后尝试实践一下自己学到的知识, 基于数据代理, 数据劫持, 模板解析, 双向绑定实现了一个小型的 vue 框架.
温馨提示: 文章是按照每个模块的实现依赖关系来进行分析的, 但是在阅读的时候可以按照 vue 的执行顺序来分析, 这样对初学者更加的友好. 推荐的阅读顺序为: 实现 VMVM, 数据代理, 实现 Observe, 实现 Complie, 实现 Watcher.
源码: https://github.com/yuliangbin/MVVM
功能演示如下所示:
数据代理
以下面这个模板为例, 要替换的根元素 "#mvvm-app" 内只有一个文本节点 #text,#text 的内容为{{name}}. 我们就以下面这个模板详细了解一下 VUE 框架的大体实现流程.
<body>
<div id="mvvm-app">
{{name}}
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/mvvm.js"></script>
<script>
let vm = new MVVM({
el: "#mvvm-app",
data: {
name: "hello world"
},
})
</script>
</body>
数据代理
1, 什么是数据代理
在 vue 里面, 我们将数据写在 data 对象中. 但是我们在访问 data 里的数据时, 既可以通过 vm.data.name 访问, 也可以通过 vm.name 访问. 这就是数据代理: 在一个对象中, 可以动态的访问和设置另一个对象的属性.
2, 实现原理
我们知道静态绑定 (如 vm.name = vm.data.name) 可以一次性的将结果赋给变量, 而使用 Object.defineProperty()方法来绑定则可以通过 set 和 get 函数实现赋值的中间过程, 从而实现数据的动态绑定. 具体实现如下:
- let obj = {};
- let obj1 = {
- name: 'xiaoyu',
- age: 18,
- }
- // 实现 origin 对象代理 target 对象
- function proxyData(origin,target){
- Object.keys(target).forEach(function(key){
- Object.defineProperty(origin,key,{// 定义 origin 对象的 key 属性
- enumerable: false,
- configurable: true,
- get: function getter(){
- return target[key];//origin[key] = target[key];
- },
- set: function setter(newValue){
- target[key] = newValue;
- }
- })
- })
- }
vue 中的数据代理也是通过这种方式来实现的.
- function MVVM(options) {
- this.$options = options || {};
- var data = this._data = this.$options.data;
- var _this = this;// 当前实例 vm
- // 数据代理
- // 实现 vm._data.xxx -> vm.xxx
- Object.keys(data).forEach(function(key) {
- _this._proxyData(key);
- });
- observe(data, this);
- this.$compile = new Compile(options.el || document.body, this);
- }
- MVVM.prototype = {
- _proxyData: function(key) {
- var _this = this;
- if (typeof key == 'object' && !(key instanceof Array)){// 这里只实现了对对象的监听, 没有实现数组的
- this._proxyData(key);
- }
- Object.defineProperty(_this, key, {
- configurable: false,
- enumerable: true,
- get: function proxyGetter() {
- return _this._data[key];
- },
- set: function proxySetter(newVal) {
- _this._data[key] = newVal;
- }
- });
- },
- };
实现 Observe
1, 双向数据绑定
数据变动 ---> 视图更新
视图更新 ---> 数据变动
要想实现当数据变动时视图更新, 首先要做的就是如何知道数据变动了, 可以通过 Object.defineProperty()函数监听 data 对象里的数据, 当数据变动了就会触发 set()方法. 所以我们需要实现一个数据监听器 Observe, 来对数据对象中的所有属性进行监听, 当某一属性数据发生变化时, 拿到最新的数据通知绑定了该属性的订阅器, 订阅器再执行相应的数据更新回调函数, 从而实现视图的刷新.
当设置 this.name = 'hello vue'时, 就会执行 set 函数, 通知订阅器里的订阅者执行相应的回调函数, 实现数据变动, 对应视图更新.
- function observe(data){
- if (typeof data != 'object') {
- return ;
- }
- return new Observe(data);
- }
- function Observe(data){
- this.data = data;
- this.walk(data);
- }
- Observe.prototype = {
- walk: function(data){
- let _this = this;
- for (key in data) {
- if (data.hasOwnProperty(key)){
- let value = data[key];
- if (typeof value == 'object'){
- observe(value);
- }
- _this.defineReactive(data,key,data[key]);
- }
- }
- },
- defineReactive: function(data,key,value){
- Object.defineProperty(data,key,{
- enumerable: true,// 可枚举
- configurable: false,// 不能再 define
- get: function(){
- console.log('你访问了' + key);return value;
- },
- set: function(newValue){
- console.log('你设置了' + key);
- if (newValue == value) return;
- value = newValue;
- observe(newValue);// 监听新设置的值
- }
- })
- }
- }
2, 实现一个订阅器
要想通知订阅者, 首先得要有一个订阅器(统一管理所有的订阅者). 为了方便管理, 我们会为每一个 data 对象的属性都添加一个订阅器(new Dep).
订阅器里存着的是订阅者 Watcher(后面会讲到), 由于订阅者可能会有多个, 我们需要建立一个数组来维护. 一旦数据变化, 就会触发订阅器的 notify()方法, 订阅者就会调用自身的 update 方法实现视图更新.
- function Dep(){
- this.subs = [];
- }
- Dep.prototype = {
- addSub: function(sub){this.subs.push(sub);
- },
- notify: function(){
- this.subs.forEach(function(sub) {
- sub.update();
- })
- }
- }
每次响应属性的 set()函数调用的时候, 都会触发订阅器, 所以代码补充完整.
- Observe.prototype = {
- // 省略的代码未作更改
- defineReactive: function(data,key,value){
- let dep = new Dep();// 创建一个订阅器, 会被闭包在 key 属性的 get/set 函数内, 因此每个属性对应唯一一个订阅器 dep 实例
- Object.defineProperty(data,key,{
- enumerable: true,// 可枚举
- configurable: false,// 不能再 define
- get: function(){
- console.log('你访问了' + key);
- return value;
- },
- set: function(newValue){
- console.log('你设置了' + key);
- if (newValue == value) return;
- value = newValue;
- observe(newValue);// 监听新设置的值
- dep.notify();// 通知所有的订阅者
- }
- })
- }
- }
实现 Complie
compile 主要做的事情是解析模板指令, 将模板中的 data 属性替换成 data 属性对应的值 (比如将{{name}} 替换成 data.name 值), 然后初始化渲染页面视图, 并且为每个 data 属性添加一个监听数据的订阅者(new Watcher), 一旦数据有变动, 收到通知, 更新视图.
遍历解析需要替换的根元素 el 下的 HTML 标签必然会涉及到多次的 DOM 节点操作, 因此不可避免的会引发页面的重排或重绘, 为了提高性能和效率, 我们把根元素 el 下的所有节点转换为文档碎片 fragment 进行解析编译操作, 解析完成, 再将 fragment 添加回原来的真实 dom 节点中.
注: 文档碎片本身也是一个节点, 但是当将该节点 append 进页面时, 该节点标签作为根节点不会显示 html 文档中, 其里面的子节点则可以完全显示.
Compile 解析模板, 将模板内的子元素 #text 添加进文档碎片节点 fragment.
- function Compile(el,vm){
- this.$vm = vm;//vm 为当前实例
- this.$el = document.querySelector(el);// 获得要解析的根元素
- if (this.$el){
- this.$fragment = this.nodeToFragment(this.$el);
- this.init();
- this.$el.appendChild(this.$fragment);
- }
- }
- Compile.prototype = {
- nodeToFragment: function(el){
- let fragment = document.createDocumentFragment();
- let child;
- while (child = el.firstChild){
- fragment.appendChild(child);//append 相当于剪切的功能
- }
- return fragment;
- },
- };
compileElement 方法将遍历所有节点及其子节点, 进行扫描解析编译, 调用对应的指令渲染函数进行数据渲染, 并调用对应的指令更新函数进行绑定, 详看代码及注释说明:
因为我们的模板只含有一个文本节点 #text, 因此 compileElement 方法执行后会进入_this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'
- Compile.prototype = {
- nodeToFragment: function(el){
- let fragment = document.createDocumentFragment();
- let child;
- while (child = el.firstChild){
- fragment.appendChild(child);//append 相当于剪切的功能
- }
- return fragment;
- },
- init: function(){
- this.compileElement(this.$fragment);
- },
- compileElement: function(node){
- let childNodes = node.childNodes;
- const _this = this;
- let reg = /\{\{(.*)\}\}/g;
- [].slice.call(childNodes).forEach(function(node){
- if (_this.isElementNode(node)){// 如果为元素节点, 则进行相应操作
- _this.compile(node);
- } else if (_this.isTextNode(node) && reg.test(node.textContent)){
- // 如果为文本节点, 并且包含 data 属性(如{{name}}), 则进行相应操作
- _this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'
- }
- if (node.childNodes && node.childNodes.length){
- // 如果节点内还有子节点, 则递归继续解析节点
- _this.compileElement(node);
- }
- })
- },
- compileText: function(node,exp){//#text,'name'
- compileUtil.text(node,this.$vm,exp);//#text,vm,'name'
- },};
CompileText()函数实现初始化渲染页面视图(将 data.name 的值通过 #text.textContent = data.name 显示在页面上), 并且为每个 DOM 节点添加一个监听数据的订阅者(这里是为 #text 节点新增一个 Wather).
- let updater = {
- textUpdater: function(node,value){
- node.textContent = typeof value == 'undefined' ? '' : value;
- },
- }
- let compileUtil = {
- text: function(node,vm,exp){//#text,vm,'name'
- this.bind(node,vm,exp,'text');
- },
- bind: function(node,vm,exp,dir){//#text,vm,'name','text'
- let updaterFn = updater[dir + 'Updater'];
- updaterFn && updaterFn(node,this._getVMVal(vm,exp));
- new Watcher(vm,exp,function(value){
- updaterFn && updaterFn(node,value)
- });
- console.log('加进去了');
- }
- };
现在我们完成了一个能实现文本节点解析的 Compile()函数, 接下来我们实现一个 Watcher()函数.
实现 Watcher
我们前面讲过, Observe()函数实现 data 对象的属性劫持, 并在属性值改变时触发订阅器的 notify()通知订阅者 Watcher, 订阅者就会调用自身的 update 方法实现视图更新.
Compile()函数负责解析模板, 初始化页面, 并且为每个 data 属性新增一个监听数据的订阅者(new Watcher).
Watcher 订阅者作为 Observer 和 Compile 之间通信的桥梁, 所以我们可以大致知道 Watcher 的作用是什么.
主要做的事情是:
在自身实例化时往订阅器 (dep) 里面添加自己.
自身必须有一个 update()方法 .
待属性变动 dep.notice()通知时, 能调用自身的 update()方法, 并触发 Compile 中绑定的回调.
先给出全部代码, 再分析具体的功能.
- //Watcher
- function Watcher(vm, exp, cb) {
- this.vm = vm;
- this.cb = cb;
- this.exp = exp;
- this.value = this.get();// 初始化时将自己添加进订阅器
- };
- Watcher.prototype = {
- update: function(){
- this.run();
- },
- run: function(){
- const value = this.vm[this.exp];
- //console.log('me:'+value);
- if (value != this.value){
- this.value = value;
- this.cb.call(this.vm,value);
- }
- },
- get: function() {
- Dep.target = this; // 缓存自己
- var value = this.vm[this.exp] // 访问自己, 执行 defineProperty 里的 get 函数
- Dep.target = null; // 释放自己
- return value;
- }
- }
- // 这里列出 Observe 和 Dep, 方便理解
- Observe.prototype = {
- defineReactive: function(data,key,value){
- let dep = new Dep();
- Object.defineProperty(data,key,{
- enumerable: true,// 可枚举
- configurable: false,// 不能再 define
- get: function(){
- console.log('你访问了' + key);
- // 说明这是实例化 Watcher 时引起的, 则添加进订阅器
- if (Dep.target){
- //console.log('访问了 Dep.target');
- dep.addSub(Dep.target);
- }
- return value;
- },
- })
- }
- }
- Dep.prototype = {
- addSub: function(sub){this.subs.push(sub);
- },
- }
我们知道在 Observe()函数执行时, 我们为每个属性都添加了一个订阅器 dep, 而这个 dep 被闭包在属性的 get/set 函数内. 所以, 我们可以在实例化 Watcher 时调用 this.get()函数访问 data.name 属性, 这会触发 defineProperty()函数内的 get 函数, get 方法执行的时候, 就会在属性的订阅器 dep 添加当前 watcher 实例, 从而在属性值有变化的时候, watcher 实例就能收到更新通知.
那么 Watcher()函数中的 get()函数内 Dep.taeger = this 又有什么特殊的含义呢? 我们希望的是在实例化 Watcher 时将相应的 Watcher 实例添加一次进 dep 订阅器即可, 而不希望在以后每次访问 data.name 属性时都加入一次 dep 订阅器. 所以我们在实例化执行 this.get()函数时用 Dep.target = this 来标识当前 Watcher 实例, 当添加进 dep 订阅器后设置 Dep.target=null.
实现 VMVM
MVVM 作为数据绑定的入口, 整合 Observer,Compile 和 Watcher 三者, 通过 Observer 来监听自己的 model 数据变化, 通过 Compile 来解析编译模板指令, 最终利用 Watcher 搭起 Observer 和 Compile 之间的通信桥梁, 达到数据变化 -> 视图更新; 视图交互变化(input) -> 数据 model 变更的双向绑定效果.
- function MVVM(options) {
- this.$options = options || {};
- var data = this._data = this.$options.data;
- var _this = this;
- // 数据代理
- // 实现 vm._data.xxx -> vm.xxx
- Object.keys(data).forEach(function(key) {
- _this._proxyData(key);
- });
- observe(data, this);
- this.$compile = new Compile(options.el || document.body, this);
- }
来源: http://www.jb51.net/article/145076.htm