Appearance
MVVM
MVVM 由以下三个内容组成:
- View: 界面
- Model: 数据模型
- ViewModel: 作为桥梁负责沟通 View 和 Model
在 JQuery 时期,如果需要刷新 UI 时,需要先取到对应的 DOM 再更新 UI,这样数据和业务的逻辑就和页面有强耦合。
在 MVVM 中,UI 是通过数据驱动的,数据一旦改变就会相应的刷新对应的 UI,UI 如果改变,也会改变对应的数据。这种方式就可以在业务处理中只关心数据的流转,而无需直接和页面打交道。ViewModel 只关心数据和业务的处理,不关心 View 如何处理数据,在这种情况下,View 和 Model 都可以独立出来,任何一方改变了也不一定需要改变另一方,并且可以将一些可复用的逻辑放在一个 ViewModel 中,让多个 View 复用这个 ViewModel。
在 MVVM 中,最核心的也就是数据双向绑定,例如 Angluar 的脏数据检测,Vue 中的数据劫持。
脏数据检测
当触发了指定事件后会进入脏数据检测,这时会调用 $digest 循环遍历所有的数据观察者,判断当前值是否和先前的值有区别,如果检测到变化的话,会调用 $watch 函数,然后再次调用 $digest 循环直到发现没有变化。循环至少为二次,至多为十次。
脏数据检测虽然存在低效的问题,但是不关心数据是通过什么方式改变的,都可以完成任务,但是这在 Vue 中的双向绑定是存在问题的。并且脏数据检测可以实现批量检测出更新的值,再去统一更新 UI,大大减少了操作 DOM 的次数。所以低效也是相对的,这就仁者见仁智者见智了。
数据劫持
Vue2 内部使用了 Object.defineProperty() 来实现双向绑定,通过这个函数可以监听到 set 和 get 的事件。
js
function observe(obj) {
// 判断类型
if (!obj || typeof obj !== 'object') {
return;
}
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key]);
});
}
function defineReactive(obj, key, val) {
// 递归子属性
observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log('get value');
return val;
},
set: function reactiveSetter(newVal) {
console.log('change value');
val = newVal;
}
});
}
// test
var data = { name: 'jimco' };
observe(data);
let name = data.name; // -> get value
data.name = 'aaa'; // -> change value以上代码简单的实现了如何监听数据的 set 和 get 的事件,但是仅仅如此是不够的,还需要在适当的时候给属性添加发布订阅:
html
<div>{{name}}</div>在解析如上模板代码时,遇到 就会给属性 name 添加发布订阅:
js
// 通过 Dep 解耦
class Dep {
constructor() {
this.subs = [];
}
addSub(sub) {
// sub 是 Watcher 实例
this.subs.push(sub);
}
notify() {
this.subs.forEach(sub => {
sub.update();
});
}
}
// 全局属性,通过该属性配置 Watcher
Dep.target = null;
class Watcher {
constructor(obj, key, cb) {
// 将 Dep.target 指向自己
// 然后触发属性的 getter 添加监听
// 最后将 Dep.target 置空
Dep.target = this;
this.cb = cb;
this.obj = obj;
this.key = key;
this.value = obj[key]; // 触发 getter,添加 watcher 订阅
Dep.target = null;
}
update() {
// 获得新值
this.value = this.obj[this.key];
// 调用 update 方法更新 Dom
this.cb(this.value);
}
}
function update(value) {
document.querySelector('div').innerText = value;
}
// test
var data = { name: 'jimco' };
observe(data);
// 模拟解析到 `{{name}}` 触发的操作
new Watcher(data, 'name', update);
// update Dom innerText
data.name = 'aaa';接下来,对 defineReactive 函数进行改造:
js
function defineReactive(obj, key, val) {
// 递归子属性
observe(val);
let dp = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
console.log('get value');
// 将 Watcher 添加到订阅
if (Dep.target) {
dp.addSub(Dep.target);
}
return val;
},
set: function reactiveSetter(newVal) {
console.log('change value');
val = newVal;
// 执行 watcher 的 update 方法
dp.notify();
},
});
}以上实现了一个简易的双向绑定,核心思路就是手动触发一次属性的 getter 来实现发布订阅的添加。
Proxy 与 Object.defineProperty 对比
Object.defineProperty 虽然已经能够实现双向绑定了,但是他还是有缺陷的。
- 只能对属性进行数据劫持,所以需要深度遍历整个对象
- 对于数组不能监听到数据的变化
虽然 Vue 中确实能检测到数组数据的变化,但是其实是使用了 hack 的办法,并且也是有缺陷的:
js
const arrayProto = Array.prototype;
export const arrayMethods = Object.create(arrayProto);
// hack 以下几个函数
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
methodsToPatch.forEach(function(method) {
// 获得原生函数
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...args) {
// 调用原生函数
const result = original.apply(this, args);
const ob = this.__ob__;
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) ob.observeArray(inserted);
// 触发更新
ob.dep.notify();
return result;
});
});反观 Proxy 就没以上的问题,原生支持监听数组变化,并且可以直接对整个对象进行拦截,所以 Vue3.0 用 Proxy 替换了 Object.defineProperty:
js
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
}
}
return new Proxy(obj, handler);
}
// test
let obj = { a: 1 };
let value;
let p = onWatch(
obj,
v => {
value = v;
},
(target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
},
);
p.a = 2; // bind `value` to `2`
p.a; // -> Get 'a' = 2路由原理
前端路由实现起来其实很简单,本质就是监听 URL 的变化,然后匹配路由规则,显示相应的页面,并且无须刷新。目前单页面使用的路由就只有两种实现方式:
- Hash 模式
- History 模式
Hash 模式
Hash 模式是一种把前端路由的路径用井号 # 拼接在真实 url 后面的模式。当井号 # 后面的路径发生变化时,浏览器并不会重新发起请求,而是会触发 onhashchange 事件。
hash 的特点:
hash变化会触发网页跳转,即浏览器的前进和后退。hash可以改变url(只能修改#后面的部分),但是不会触发页面重新加载(hash的改变是记录在window.history中),即不会刷新页面。也就是说,所有页面的跳转都是在客户端进行操作。因此,这并不算是一次http请求,所以这种模式不利于 SEO 优化hash通过window.onhashchange的方式监听,借此实现无刷新跳转的功能hash永远不会提交到 server 端(可以理解为只在前端自生自灭)
History 模式
history API 是 H5 提供的新特性,允许开发者直接更改前端路由,即更新浏览器 URL 地址而不重新发起请求。
history 的 API:
| API | 说明 |
|---|---|
history.pushState(data, title [, url]) | 主要用于往历史记录堆栈顶部添加一条记录。各参数解析如下: ① data 会在 onpopstate 事件触发时作为参数传递过去;② title 为页面标题,当前所有浏览器都会忽略此参数;③ url 为页面地址,可选,缺少时表示为当前页地址 |
history.replaceState(data, title [, url]) | 更改当前的历史记录,参数同上;上面的 pushState 是添加,这个更改 |
history.state | 用于存储以上方法的 data 数据,不同浏览器的读写权限不一样 |
window.onpopstate | 响应 pushState 或者 replaceState 的调用 |
history 的特点:
- 新的
url可以是与当前url同源的任意url,也可以是与当前url一样的地址,但是这样会导致的一个问题是,会把重复的这一次操作记录到栈当中 - 通过
history.state,添加任意类型的数据到记录中 - 可以额外设置
title属性,以便后续使用 - 通过
pushState,replaceState来实现无刷新跳转的功能
TIP
调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。
简单 Router 实现
js
class Router {
constructor(options) {
this.mode = options.mode || 'hash';
this.routes = options.routes || [];
const me = this;
switch (me.mode) {
case 'hash':
window.addEventListener('hashchange', function (evt) {
let hash = location.hash.slice(1);
let route = me.routes.find(item => item.path.test(hash)) || {};
route.func && route.func();
});
break;
case 'history':
/**
* history.pushState, history.replaceState 不会触发
* popstate 事件,需做劫持处理
*/
function hisRewrite(type) {
let origin = history[type];
return function () {
let result = origin.apply(me, [...arguments]);
let e = new Event(type);
e.arguments = arguments;
window.dispatchEvent(e);
return result;
}
}
function changeCallback(evt) {
let pathname = location.pathname;
let route = me.routes.find(item => item.path.test(pathname)) || {};
route.func && route.func();
}
history.pushState = hisRewrite('pushState');
history.replaceState = hisRewrite('replaceState');
window.addEventListener('popstate', changeCallback);
window.addEventListener('pushState', changeCallback);
window.addEventListener('replaceState', changeCallback);
break;
default:
break;
}
}
push(route) {
let mode = this.mode;
switch (mode) {
case 'hash':
location.hash = route.name;
break;
case 'history':
history.pushState(route.options, route.title, route.name);
break;
}
}
// replace() {}
}
// test
var router = new Router({
routes: [
{
path: /123/,
func: function () {
console.log(123);
}
}
]
});
router.push({ name: '123' });