Abo

面试官系列-框架相关篇

整理自掘金大佬(寻找海蓝96)面试官系列


blonde-801985_1280

内容有点多,↘打开导航条


        

实现EVENTBUS(⭐⭐⭐)

前言

vue组件非常常见的有父子组件通信,兄弟组件通信。而父子组件通信就很简单,父组件会通过 props 向下传数据给子组件,当子组件有事情要告诉父组件时会通过 $emit 事件告诉父组件。今天就来说说如果两个页面没有任何引入和被引入关系,该如何通信了?如果咱们的应用程序不需要类似Vuex这样的库来处理组件之间的数据通信,就可以考虑Vue中的 事件总线 ,即 EventBus来通信。

EventBus 又称为事件总线。在Vue中可以使用 EventBus 来作为沟通桥梁的概念,就像是所有组件共用相同的事件中心,可以向该中心注册发送事件或接收事件,所以组件都可以上下平行地通知其他组件,但也就是太方便所以若使用不慎,就会造成难以维护的“灾难”,因此才需要更完善的Vuex作为状态管理中心,将通知的概念上升到共享状态层次。

使用EventBus

一、初始化
首先需要创建事件总线并将其导出,以便其它模块可以使用或者监听它。我们可以通过两种方式来处理。先来看第一种,新创建一个 .js 文件,比如 event-bus.js

1
2
3
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()

实质上EventBus是一个不具备 DOM 的组件,它具有的仅仅只是它实例方法而已,因此它非常的轻便。

另外一种方式,可以直接在项目中的 main.js 初始化 EventBus :

1
2
// main.js
Vue.prototype.$EventBus = new Vue()

注意,这种方式初始化的EventBus是一个全局的事件总线。稍后再来聊一聊全局的事件总线。

现在我们已经创建了 EventBus ,接下来你需要做到的就是在你的组件中加载它,并且调用同一个方法,就如你在父子组件中互相传递消息一样。

二、发送事件

假设你有两个Vue页面需要通信: A 和 B ,A页面 在按钮上面绑定了点击事件,发送一则消息,想=通知 B页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- A.vue -->
<template>
<button @click="sendMsg()">-</button>
</template>

<script>
import { EventBus } from "../event-bus.js";
export default {
methods: {
sendMsg() {
EventBus.$emit("aMsg", '来自A页面的消息');
}
}
};
</script>

接下来,我们需要在 B页面 中接收这则消息。

三、接收事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- IncrementCount.vue -->
<template>
<p>{{msg}}</p>
</template>

<script>
import {
EventBus
} from "../event-bus.js";
export default {
data(){
return {
msg: ''
}
},
mounted() {
EventBus.$on("aMsg", (msg) => {
// A发送来的消息
this.msg = msg;
});
}
};
</script>

同理我们也可以在 B页面 向 A页面 发送消息。这里主要用到的两个方法:

1
2
3
4
5
// 发送消息
EventBus.$emit(channel: string, callback(payload1,…))

// 监听接收消息
EventBus.$on(channel: string, callback(payload1,…))

前面提到过,如果使用不善,EventBus会是一种灾难,到底是什么样的“灾难”了?大家都知道vue是单页应用,如果你在某一个页面刷新了之后,与之相关的EventBus会被移除,这样就导致业务走不下去。还要就是如果业务有反复操作的页面,EventBus在监听的时候就会触发很多次,也是一个非常大的隐患。这时候我们就需要好好处理EventBus在项目中的关系。通常会用到,在vue页面销毁时,同时移除EventBus事件监听。

移除事件监听者

如果想移除事件的监听,可以像下面这样操作:

1
2
3
4
import { 
eventBus
} from './event-bus.js'
EventBus.$off('aMsg', {})

你也可以使用 EventBus.$off('aMsg') 来移除应用内所有对此某个事件的监听。或者直接调用 EventBus.$off() 来移除所有事件频道,不需要添加任何参数 。

上面就是 EventBus 的使用方法,是不是很简单。上面的示例中我们也看到了,每次使用 EventBus 时都需要在各组件中引入 event-bus.js 。事实上,我们还可以通过别的方式,让事情变得简单一些。那就是创建一个全局的 EventBus 。接下来的示例向大家演示如何在Vue项目中创建一个全局的 EventBus

全局EventBus

它的工作原理是发布/订阅方法,通常称为 Pub/Sub

创建全局EventBus

1
2
3
4
5
6
7
8
9
var EventBus = new Vue();

Object.defineProperties(Vue.prototype, {
$bus: {
get: function () {
return EventBus
}
}
})

在这个特定的总线中使用两个方法$on$emit。一个用于创建发出的事件,它就是$emit;另一个用于订阅$on

1
2
3
4
5
6
7
var EventBus = new Vue();

this.$bus.$emit('nameOfEvent', { ... pass some event data ...});

this.$bus.$on('nameOfEvent',($event) => {
// ...
})

然后我们可以在某个Vue页面使用this.$bus.$emit("sendMsg", '我是web秀');,另一个Vue页面使用

1
2
3
this.$bus.$on('updateMessage', function(value) {
console.log(value); // 我是web秀
})

同时也可以使用this.$bus.$off('sendMsg')来移除事件监听。

总结

本文主要通过简单的实例学习了Vue中有关于 EventBus 相关的知识点。主要涉及了 EventBus 如何实例化,又是如何通过 $emit 发送频道信号,又是如何通过 $on 来接收频道信号。最后简单介绍了如何创建全局的 EventBus 。从实例中我们可以了解到, EventBus 可以较好的实现兄弟组件之间的数据通讯。

React/Vue不同组件之间是怎么通信的?

Vue

  1. 父子组件用Props通信
  2. 非父子组件用Event Bus通信
  3. 如果项目够复杂,可能需要Vuex等全局状态管理库通信
  4. $dispatch(已经废除)和$broadcast(已经废除)

React

  1. 父子组件,父->子直接用Props,子->父用callback回调
  2. 非父子组件,用发布订阅模式的Event模块
  3. 项目复杂的话用Redux、Mobx等全局状态管理管库
  4. 用新的Context Api

我们大体上都会有以上回答,接下来很可能会问到如何实现Event(Bus),因为这个东西太重要了,几乎所有的模块通信都是基于类似的模式,包括安卓开发中的Event Bus, Node.js中的Event模块(Node中几乎所有的模块都依赖于Event,包括不限于http、stream、buffer、fs等).

基本构造

初始化class

我们利用ES6的class关键字对Event进行初始化,包括Event的事件清单和监听者上限.

我们选择了Map作为储存事件的结构,因为作为键值对的储存方式Map比一般对象更加适合,我们操作起来也更加简洁,可以先看一下Map的基本用法与特点.

1
2
3
4
5
6
class EventEmitter {
constructor() {
this._events = this._events || new Map(); // 储存事件/回调键值对
this._maxListeners = this._maxListeners || 10; // 设立监听上限
}
}

监听与触发

触发监听函数我们可以用applycall两种方法,在少数参数时call的性能更好,多个参数时apply性能更好,当年Node的Event模块就在三个参数以下用call否则用apply.

当然当Node全面拥抱ES6+之后,相应的call/apply操作用Reflect新关键字重写了,但是我们不想写的那么复杂,就做了一个简化版.

关于js反射

在国内的技术文章中你去搜索”JS 反射”得到的大部分的内容都是在说“利用JS的for(…in…)语句实现反射机制”,但其实反射机制在如今的ES6中可以得到更大的延伸以及运用。

1
2
3
4
5
6
for(var p in obj){
if(typeof(obj[p]=="function"){
obj[p]();
}else{
alert(obj[p]);
}

这段语句遍历obj对象的所有属性和方法,遇到属性则弹出它的值,遇到方法则立刻执行。在后面可以看到,在面向对象的JavaScript程序设计中,反射机制是很重要的一种技术,它在实现类的继承中发挥了很大的作用。

再看一组例子

1
2
3
4
5
6
7
function setStyle(_style){
//得到要改变样式的界面对象
var element=getElement();
for(var p in _style){
element.style[p]=_style[p];
}
}
关于Reflect(反射)

反射机制指的是程序在运行时能够获取自身的信息

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers的方法相同。Reflect不是一个函数对象,因此它是不可构造的。Reflect的所有属性和方法都是静态的,Reflect的所有属性和方法都是静态的,Reflect的所有属性和方法都是静态的。

与大多数全局对象不同Reflect并非一个构造函数,所以不能通过new运算符对其进行调用,或者将Reflect对象作为一个函数来调用。Reflect的所有属性和方法都是静态的(就像Math对象)。

那么为什么需要Reflect对象?

Reflect 对象提供了以下静态方法,这些方法与proxy handler methods的命名相同.

其中的一些方法与 Object相同, 尽管二者之间存在 某些细微上的差别 .

Reflect对象一共有 13 个静态方法(匹配Proxy的13种拦截行为)。

  • Reflect.apply(target, thisArg, args)

  • Reflect.construct(target, args)

  • Reflect.get(target, name, receiver)

  • Reflect.set(target, name, value, receiver)

  • Reflect.defineProperty(target, name, desc)

  • Reflect.deleteProperty(target, name)

  • Reflect.has(target, name)

  • Reflect.ownKeys(target)

  • Reflect.isExtensible(target)

  • Reflect.preventExtensions(target)

  • Reflect.getOwnPropertyDescriptor(target, name)

  • Reflect.getPrototypeOf(target)

  • Reflect.setPrototypeOf(target, prototype)

    大部分与Object对象的同名方法的作用都是相同的,而且它与Proxy对象的方法是一一对应的。

众所周知, ES6 新增了一个全局、内建、不可构造的 Reflect 对象,并提供了其下一系列可被拦截的操作方法。其中一个便是 Reflect.apply() 了。下面探究下它与传统 ES5 的 Function.prototype.apply() 之间有什么异同。

MDN 上两者的函数签名分别如下:

1
2
Reflect.apply(target, thisArgument, argumentsList)
function.apply(thisArg, [argsArray])

可以最直观看到的是, function.apply() 给函数的第二个传参「参数数组」是可选的,当不需要传递参数给被调用的函数时,可以不传或传递 nullundefined 值。而由于 function.apply() 只有两个参数,所以实践中连第一个参数也可以一起不传,原理上可以在实现中获得 undefined 值。

1
2
3
4
5
6
7
8
(function () { console.log('test1') }).apply()
// test1
(function () { console.log('test2') }).apply(undefined, [])
// test2
(function () { console.log('test3') }).apply(undefined, {})
// test3
(function (text) { console.log(text) }).apply(undefined, ['test4'])
// test4

Reflect.apply() 则要求所有参数都必传,如果希望不传参数给被调用的函数,则必须填一个空数组或者空的类数组对象(纯 JavaScript 下空对象也可以,若是 TypeScript 则需带上 length: 0 的键值对以通过类型检查)。

1
2
3
4
5
6
7
8
9
Reflect.apply(function () { console.log('test1') }, undefined)
// Thrown:
// TypeError: CreateListFromArrayLike called on non-object
Reflect.apply(function () { console.log('test2') }, undefined, [])
// test2
Reflect.apply(function () { console.log('test3') }, undefined, {})
// test3
Reflect.apply(function (text) { console.log(text) }, undefined, ['test4'])
// test4

Reflect.get()

Reflect.get() 方法与从 对象 (target[propertyKey]) 中读取属性类似,但它是通过一个函数执行来操作的。

1
2
3
4
Reflect.get(target, propertyKey[, receiver])
target 需要取值的目标对象
propertyKey 需要获取的值的键值
receiver 如果target对象中指定了getter,receiver则为getter调用时的this值。

使用例子

1
2
3
4
5
6
7
8
9
10
11
12
13
// Object
var obj = { x: 1, y: 2 };
Reflect.get(obj, "x"); // 1

// Array
Reflect.get(["zero", "one"], 1); // "one"

// Proxy with a get handler
var x = {p: 1};
var obj = new Proxy(x, {
get(t, k, r) { return k + "bar"; }
});
Reflect.get(obj, "foo"); // "foobar"

get() 方法可以继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let proxy = new Proxy({}, {
get(target, propKey, receiver) {
// 实现私有属性读取保护
if(propKey[0] === '_'){
throw new Erro(`Invalid attempt to get private "${propKey}"`);
}
console.log('Getting ' + propKey);
return target[propKey];
}
});

let obj = Object.create(proxy);
obj.name
// Getting name
1
2
3
4
5
6
7
8
9
10
11
12
13
let exam ={
name: "Tom",
age: 24
}
let proxy = new Proxy(exam, {
get(target, propKey, receiver) {
console.log('Getting ' + propKey);
return target[propKey];
}
})
proxy.name
// Getting name
// "Tom"
关于Object.create()

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的proto

Proxy直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于Object.defineProperty

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const person = {
isHuman: false,
printIntroduction: function() {
console.log(`My name is ${this.name}. Am I human? ${this.isHuman}`);
}
};

const me = Object.create(person);

me.name = 'Matthew'; // "name" is a property set on "me", but not on "person"
me.isHuman = true; // inherited properties can be overwritten

me.printIntroduction();
// expected output: "My name is Matthew. Am I human? true"
1
2
3
4
Object.create(proto[, propertiesObject])
proto 新创建对象的原型对象。
propertiesObject
可选。如果没有指定为 undefined,则是要添加到新创建对象的不可枚举(默认)属性(即其自身定义的属性,而不是其原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应Object.defineProperties()的第二个参数。

所以注意 :new proxy是可以作为原型对象被object.create所参考

对set也同样理解,也是四个参数(target, propKey, value, receiver)

1
2
3
4
5
6
7
8
9
10
11
12
13
const handler = {
set: function(obj, prop, value, receiver) {
obj[prop] = receiver;
}
};
const proxy = new Proxy({}, handler);
proxy.name= 'Tom';
proxy.name=== proxy // true

const exam = {}
Object.setPrototypeOf(exam, proxy)
exam.name = "Tom"
exam.name === exam // true
回归正题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 触发名为type的事件
EventEmitter.prototype.emit = function(type, ...args) {
let handler;
// 从储存事件键值对的this._events中获取对应事件回调函数
handler = this._events.get(type);
if (args.length > 0) {
handler.apply(this, args);
} else {
handler.call(this);
}
return true;
};

// 监听名为type的事件
EventEmitter.prototype.addListener = function(type, fn) {
// 将type事件以及对应的fn函数放入this._events中储存
if (!this._events.get(type)) {
this._events.set(type, fn);
}
};

我们实现了触发事件的emit方法和监听事件的addListener方法,至此我们就可以进行简单的实践了.

1
2
3
4
5
6
7
8
9
10
// 实例化
const emitter = new EventEmitter();

// 监听一个名为arson的事件对应一个回调函数
emitter.addListener('arson', man => {
console.log(`expel ${man}`);
});

// 我们触发arson事件,发现回调成功执行
emitter.emit('arson', 'low-end'); // expel low-end

似乎不错,我们实现了基本的触发/监听,但是如果有多个监听者呢?

1
2
3
4
5
6
7
8
9
// 重复监听同一个事件名
emitter.addListener('arson', man => {
console.log(`expel ${man}`);
});
emitter.addListener('arson', man => {
console.log(`save ${man}`);
});

emitter.emit('arson', 'low-end'); // expel low-end

是的,只会触发第一个,因此我们需要进行改造.

升级改造

监听/触发器升级

我们的addListener实现方法还不够健全,在绑定第一个监听者之后,我们就无法对后续监听者进行绑定了,因此我们需要将后续监听者与第一个监听者函数放到一个数组里.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 触发名为type的事件
EventEmitter.prototype.emit = function(type, ...args) {
let handler;
handler = this._events.get(type);
if (Array.isArray(handler)) {
// 如果是一个数组说明有多个监听者,需要依次此触发里面的函数
for (let i = 0; i < handler.length; i++) {
if (args.length > 0) {
handler[i].apply(this, args);
} else {
handler[i].call(this);
}
}
} else { // 单个函数的情况我们直接触发即可
if (args.length > 0) {
handler.apply(this, args);
} else {
handler.call(this);
}
}

return true;
};

// 监听名为type的事件
EventEmitter.prototype.addListener = function(type, fn) {
const handler = this._events.get(type); // 获取对应事件名称的函数清单
if (!handler) {
this._events.set(type, fn);
} else if (handler && typeof handler === 'function') {
// 如果handler是函数说明只有一个监听者
this._events.set(type, [handler, fn]); // 多个监听者我们需要用数组储存
} else {
handler.push(fn); // 已经有多个监听者,那么直接往数组里push函数即可
}
};

是的,从此以后可以愉快的触发多个监听者的函数了.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 监听同一个事件名
emitter.addListener('arson', man => {
console.log(`expel ${man}`);
});
emitter.addListener('arson', man => {
console.log(`save ${man}`);
});

emitter.addListener('arson', man => {
console.log(`kill ${man}`);
});

// 触发事件
emitter.emit('arson', 'low-end');
//expel low-end
//save low-end
//kill low-end

移除监听

我们会用removeListener函数移除监听函数,但是匿名函数是无法移除的.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
EventEmitter.prototype.removeListener = function(type, fn) {
const handler = this._events.get(type); // 获取对应事件名称的函数清单

// 如果是函数,说明只被监听了一次
if (handler && typeof handler === 'function') {
this._events.delete(type, fn);
} else {
let postion;
// 如果handler是数组,说明被监听多次要找到对应的函数
for (let i = 0; i < handler.length; i++) {
if (handler[i] === fn) {
postion = i;
} else {
postion = -1;
}
}
// 如果找到匹配的函数,从数组中清除
if (postion !== -1) {
// 找到数组对应的位置,直接清除此回调
handler.splice(postion, 1);
// 如果清除后只有一个函数,那么取消数组,以函数形式保存
if (handler.length === 1) {
this._events.set(type, handler[0]);
}
} else {
return this;
}
}
};

发现问题

我们已经基本完成了Event最重要的几个方法,也完成了升级改造,可以说一个Event的骨架是被我们开发出来了,但是它仍然有不足和需要补充的地方.

  1. 鲁棒性不足: 我们没有对参数进行充分的判断,没有完善的报错机制.
  2. 模拟不够充分: 除了removeAllListeners这些方法没有实现以外,例如监听时间后会触发newListener事件,我们也没有实现,另外最开始的监听者上限我们也没有利用到.

当然,这在面试中现场写一个Event已经是很够意思了,主要是体现出来对发布-订阅模式的理解,以及针对多个监听状况下的处理,不可能现场撸几百行写一个完整Event.

附上Event库源码:https://github.com/Gozala/events/blob/master/events.js

前端路由的实现(⭐⭐)

前端路由是现代SPA应用必备的功能,每个现代前端框架都有对应的实现,例如vue-router、react-router。

我们不想探究vue-router或者react-router们的实现,因为不管是哪种路由无外乎用兼容性更好的hash实现或者是H5 History实现,与框架几个只需要做相应的封装即可。

1599611058336

hash路由

初始化class

我们用Class关键字初始化一个路由.

1
2
3
4
5
6
7
8
class Routers {
constructor() {
// 以键值对的形式储存路由
this.routes = {};
// 当前路由的URL
this.currentUrl = '';
}
}

实现路由hash储存与执行

在初始化完毕后我们需要思考两个问题:

  1. 将路由的hash以及对应的callback函数储存
  2. 触发路由hash变化后,执行对应的callback函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Routers {
constructor() {
this.routes = {};
this.currentUrl = '';
}
// 将path路径与对应的callback函数储存
route(path, callback) {
this.routes[path] = callback || function() {};
}
// 刷新
refresh() {
// 获取当前URL中的hash路径
this.currentUrl = location.hash.slice(1) || '/';
// 执行当前hash路径的callback函数
this.routes[this.currentUrl]();
}
}

监听对应事件

那么我们只需要在实例化Class的时候监听上面的事件即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Routers {
constructor() {
this.routes = {};
this.currentUrl = '';
this.refresh = this.refresh.bind(this);
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}

route(path, callback) {
this.routes[path] = callback || function() {};
}

refresh() {
this.currentUrl = location.hash.slice(1) || '/';
this.routes[this.currentUrl]();
}
}

对应效果如下:

1597650071918

1597650077765

1597650091822

增加回退功能

上一节我们只实现了简单的路由功能,没有我们常用的回退前进功能,所以我们需要进行改造

实现后退功能

我们在需要创建一个数组history来储存过往的hash路由例如/blue,并且创建一个指针currentIndex来随着后退前进功能移动来指向不同的hash路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Routers {
constructor() {
// 储存hash与callback键值对
this.routes = {};
// 当前hash
this.currentUrl = '';
// 记录出现过的hash
this.history = [];
// 作为指针,默认指向this.history的末尾,根据后退前进指向history中不同的hash
this.currentIndex = this.history.length - 1;
this.refresh = this.refresh.bind(this);
this.backOff = this.backOff.bind(this);
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}

route(path, callback) {
this.routes[path] = callback || function() {};
}

refresh() {
this.currentUrl = location.hash.slice(1) || '/';
// 将当前hash路由推入数组储存
this.history.push(this.currentUrl);
// 指针向前移动
this.currentIndex++;
this.routes[this.currentUrl]();
}
// 后退功能
backOff() {
// 如果指针小于0的话就不存在对应hash路由了,因此锁定指针为0即可
this.currentIndex <= 0
? (this.currentIndex = 0)
: (this.currentIndex = this.currentIndex - 1);
// 随着后退,location.hash也应该随之变化
location.hash = `#${this.history[this.currentIndex]}`;
// 执行指针目前指向hash路由对应的callback
this.routes[this.history[this.currentIndex]]();
}
}

我们看起来实现的不错,可是出现了Bug,在后退的时候我们往往需要点击两下。

问题在于,我们每次在后退都会执行相应的callback,这会触发refresh()执行,因此每次我们后退,history中都会被push新的路由hash,currentIndex也会向前移动,这显然不是我们想要的。

1
2
3
4
5
6
7
8
9
  refresh() {
this.currentUrl = location.hash.slice(1) || '/';
// 将当前hash路由推入数组储存
this.history.push(this.currentUrl);
// 指针向前移动
this.currentIndex++;
this.routes[this.currentUrl]();
}
复制代码

如图所示,我们每次点击后退,对应的指针位置和数组被打印出来

1597650297622

第一次currentIndex-1,然后更改hash值,此时触发hashchange到refresh函数,则进行push操作以及currentIndex++,所以就结果来看currentIndex没有正常的发生-1,除此之外不要忘记立马执行路由函数,即绑定的回调函数,第二次点击回退的时候,hash值是相同的并没有触发hashchange所以不会进行refresh,才能正常currentIndex-1

完整实现hash Router

我们必须做一个判断,如果是后退的话,我们只需要执行回调函数,不需要添加数组和移动指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Routers {
constructor() {
// 储存hash与callback键值对
this.routes = {};
// 当前hash
this.currentUrl = '';
// 记录出现过的hash
this.history = [];
// 作为指针,默认指向this.history的末尾,根据后退前进指向history中不同的hash
this.currentIndex = this.history.length - 1;
this.refresh = this.refresh.bind(this);
this.backOff = this.backOff.bind(this);
// 默认不是后退操作
this.isBack = false;
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}

route(path, callback) {
this.routes[path] = callback || function() {};
}

refresh() {
this.currentUrl = location.hash.slice(1) || '/';
if (!this.isBack) {
// 如果不是后退操作,且当前指针小于数组总长度,直接截取指针之前的部分储存下来
// 此操作来避免当点击后退按钮之后,再进行正常跳转,指针会停留在原地,而数组添加新hash路由
// 避免再次造成指针的不匹配,我们直接截取指针之前的数组
// 此操作同时与浏览器自带后退功能的行为保持一致
if (this.currentIndex < this.history.length - 1)
this.history = this.history.slice(0, this.currentIndex + 1);
this.history.push(this.currentUrl);
this.currentIndex++;
}
this.routes[this.currentUrl]();
console.log('指针:', this.currentIndex, 'history:', this.history);
this.isBack = false;
}
// 后退功能
backOff() {
// 后退操作设置为true
this.isBack = true;
this.currentIndex <= 0
? (this.currentIndex = 0)
: (this.currentIndex = this.currentIndex - 1);
location.hash = `#${this.history[this.currentIndex]}`;
this.routes[this.history[this.currentIndex]]();
}
}

前进的部分就不实现了,思路我们已经讲得比较清楚了,可以看出来,hash路由这种方式确实有点繁琐,所以HTML5标准提供了History API供我们使用。

HTML5新路由方案

History API

我们可以直接在浏览器中查询出History API的方法和属性。

1597650904011

我们只简单看一下常用的API

1
2
3
window.history.back();       // 后退
window.history.forward(); // 前进
window.history.go(-3); // 后退三个页面

history.pushState用于在浏览历史中添加历史记录,但是并不触发跳转,此方法接受三个参数,依次为:

state:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null
title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null
url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

history.replaceState方法的参数与pushState方法一模一样,区别是它修改浏览历史中当前纪录,而非添加记录,同样不触发跳转。

popstate事件,每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。

需要注意的是,仅仅调用pushState方法或replaceState方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用backforwardgo方法时才会触发。

另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。

新标准下路由的实现

上一节我们介绍了新标准的History API,相比于我们在Hash 路由实现的那些操作,很显然新标准让我们的实现更加方便和可读。

所以一个mini路由实现起来其实很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Routers {
constructor() {
this.routes = {};
// 在初始化时监听popstate事件
this._bindPopState();
}
// 初始化路由
init(path) {
history.replaceState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
// 将路径和对应回调函数加入hashMap储存
route(path, callback) {
this.routes[path] = callback || function() {};
}

// 触发路由对应回调
go(path) {
history.pushState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
// 监听popstate事件
_bindPopState() {
window.addEventListener('popstate', e => {
const path = e.state && e.state.path;
this.routes[path] && this.routes[path]();
});
}
}

1597651623798

1597651633538

附上Vue-Router代码:https://github.com/vuejs/vue-router/blob/dev/src/index.js

实现双向绑定Proxy比defineproperty优劣如何?(⭐⭐⭐⭐⭐)

双向绑定原理

Vue 主要通过以下 4 个步骤来实现数据双向绑定的:

  • 实现一个监听器 Observer:对数据对象进行遍历,包括子属性对象的属性,利用 Object.defineProperty() 对属性都加上 setter 和 getter。这样的话,给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变化。
  • 实现一个解析器 Compile:解析 Vue 模板指令,将模板中的变量都替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,调用更新函数进行数据更新。
  • 实现一个订阅者 Watcher:Watcher 订阅者是 Observer 和 Compile 之间通信的桥梁 ,主要的任务是订阅 Observer 中的属性值变化的消息,当收到属性值变化的消息时,触发解析器 Compile 中对应的更新函数。
  • 实现一个订阅器 Dep:订阅器采用 发布-订阅 设计模式,用来收集订阅者 Watcher,对监听器 Observer 和 订阅者 Watcher 进行统一管理。

1597652421202

那 Vue3 将会用 Proxy 替换 Object.defineProperty()proxy 有什么优点?

关于defineProperty

对象是由多个名/值对组成的无序的集合。对象中每个属性对应任意类型的值。
定义对象可以使用构造函数或字面量的形式:

1
2
3
var obj = new Object;  //obj = {}
obj.name = "张三"; //添加描述
obj.say = function(){}; //添加行为

除了以上添加属性的方式,还可以使用Object.defineProperty定义新属性或修改原有的属性。

语法:

1
Object.defineProperty(obj, prop, descriptor)

参数说明:

obj:必需。目标对象
prop:必需。需定义或修改的属性的名字
descriptor:必需。目标属性所拥有的特性

返回值:

传入函数的对象。即第一个参数obj

针对属性,我们可以给这个属性设置一些特性,比如是否只读不可以写;是否可以被for..inObject.keys()遍历。

给对象的属性添加特性描述,目前提供两种形式:数据描述和存取器描述。

数据描述

当修改或定义对象的某个属性的时候,给这个属性添加一些特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var obj = {
test:"hello"
}
//对象已有的属性添加特性描述
Object.defineProperty(obj,"test",{
configurable:true | false,
enumerable:true | false,
value:任意类型的值,
writable:true | false
});
//对象新添加的属性的特性描述
Object.defineProperty(obj,"newKey",{
configurable:true | false,
enumerable:true | false,
value:任意类型的值,
writable:true | false
});

VALUE: 属性对应的值,可以使任意类型的值,默认为undefined

WRITABLE: 属性的值是否可以被重写。设置为true可以被重写;设置为false,不能被重写。默认为false。(并不会报错,只是修改不生效)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var obj = {}
//第一种情况:writable设置为false,不能重写。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false
});
//更改newKey的值
obj.newKey = "change value";
console.log( obj.newKey ); //hello

//第二种情况:writable设置为true,可以重写
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:true
});
//更改newKey的值
obj.newKey = "change value";
console.log( obj.newKey ); //change value

ENUMERABLE: 此属性是否可以被枚举(使用for…in或Object.keys())。设置为true可以被枚举;设置为false,不能被枚举。默认为false。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var obj = {}
//第一种情况:enumerable设置为false,不能被枚举。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false,
enumerable:false
});

//枚举对象的属性
for( var attr in obj ){
console.log( attr );
}
//第二种情况:enumerable设置为true,可以被枚举。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false,
enumerable:true
});

//枚举对象的属性
for( var attr in obj ){
console.log( attr ); //newKey
}

configurable: 是否可以删除目标属性或是否可以再次修改属性的特性(writable, configurable, enumerable)。设置为true可以被删除或可以重新设置特性;设置为false,不能被可以被删除或不可以重新设置特性。默认为false。

这个属性起到两个作用:

  1. 目标属性是否可以使用delete删除
  2. 目标属性是否可以再次设置特性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/-----------------测试目标属性是否能被删除------------------------
var obj = {}
//第一种情况:configurable设置为false,不能被删除。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false,
enumerable:false,
configurable:false
});
//删除属性
delete obj.newKey;
console.log( obj.newKey ); //hello

//第二种情况:configurable设置为true,可以被删除。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false,
enumerable:false,
configurable:true
});
//删除属性
delete obj.newKey;
console.log( obj.newKey ); //undefined

//-----------------测试是否可以再次修改特性------------------------
var obj = {}
//第一种情况:configurable设置为false,不能再次修改特性。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false,
enumerable:false,
configurable:false
});

//重新修改特性
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:true,
enumerable:true,
configurable:true
});
console.log( obj.newKey ); //报错:Uncaught TypeError: Cannot redefine property: newKey

//第二种情况:configurable设置为true,可以再次修改特性。
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:false,
enumerable:false,
configurable:true
});

//重新修改特性
Object.defineProperty(obj,"newKey",{
value:"hello",
writable:true,
enumerable:true,
configurable:true
});
console.log( obj.newKey ); //hello

除了可以给新定义的属性设置特性,也可以给已有的属性设置特性

提示:一旦使用Object.defineProperty给对象添加属性,那么如果不设置属性的特性,那么configurable、enumerable、writable这些值都为默认的false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var obj = {};
//定义的新属性后,这个属性的特性中configurable,enumerable,writable都为默认的值false
//这就导致了neykey这个是不能重写、不能枚举、不能再次设置特性
//
Object.defineProperty(obj,'newKey',{

});

//设置值
obj.newKey = 'hello';
console.log(obj.newKey); //undefined

//枚举
for( var attr in obj ){
console.log(attr);
}

注意上面的例子是使用defineProperty给对象添加属性,而不是自建对象属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//定义对象的时候添加的属性,是可删除、可重写、可枚举的。
var obj = {
test:"hello"
}

//改写值
obj.test = 'change value';

console.log( obj.test ); //'change value'

Object.defineProperty(obj,"test",{
writable:false
})


//再次改写值
obj.test = 'change value again';

console.log( obj.test ); //依然是:'change value'

getter/setter

当设置或获取对象的某个属性的值的时候,可以提供getter/setter方法。

  • getter 是一种获得属性值的方法
  • setter是一种设置属性值的方法。

在特性中使用get/set属性来定义对应的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var obj = {};
var initValue = 'hello';
Object.defineProperty(obj,"newKey",{
get:function (){
//当获取值的时候触发的函数
return initValue;
},
set:function (value){
//当设置值的时候触发的函数,设置的新值通过参数value拿到
initValue = value;
}
});
//获取值
console.log( obj.newKey ); //hello

//设置值
obj.newKey = 'change value';

console.log( obj.newKey ); //change value

注意:get或set不是必须成对出现,任写其一就可以。如果不设置方法,则get和set的默认值为undefined

1597654074343

我们也可以这样写:

1
2
3
4
5
6
7
8
9
10
    var val = 1
var obj = {
get ['sum']() {
return val
},
set ['sum'](newValue) {
val += newValue
}
}
复制代码

这段代码和上面代码结果是一样的。

这里的'sum'就是被监听的属性名,也就是我们需要监听的“一个”属性。

正是因为使用defineProperty每次只能绑定一个属性监听,所以Vue在遍历 + 递归时要有更大的性能消耗和更多的代码。

关于proxy

介绍proxy之前先丢一段代码,开门见山

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
let obj = {
a: 1,
b: 2,
}

const p = new Proxy(obj, {
get(target, key, value) {
if (key === 'c') {
return '我是自定义的一个结果';
} else {
return target[key];
}
},

set(target, key, value) {
if (value === 4) {
target[key] = '我是自定义的一个结果';
} else {
target[key] = value;
}
}
})

console.log(obj.a) // 1
console.log(obj.c) // undefined
console.log(p.a) // 1
console.log(p.c) // 我是自定义的一个结果

obj.name = '李白';
console.log(obj.name); // 李白
obj.age = 4;
console.log(obj.age); // 4

p.name = '李白';
console.log(p.name); // 李白
p.age = 4;
console.log(p.age); // 我是自定义的一个结果

在上面代码中,我们看到了构造一个代理对象时所传的第二个参数handler,这个handler对象是由getset两个函数方法组成的.这两个方法会在一个对象被getset时被调用执行,以代替原生对象上的操作.那么为什么在handler,定义getset这两个函数名之后就代理对象上的getset操作了呢?

实际上handler本身就是ES6所新设计的一个对象.它的作用就是用来自定义代理对象的各种可代理操作。它本身一共有13中方法,每种方法都可以代理一种操作.其13种方法如下:(Proxy支持13种拦截行为(handle))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
handler.getPrototypeOf()

// 在读取代理对象的原型时触发该操作,比如在执行Object.getPrototypeOf(proxy) 时。
// Object.getPrototypeOf() 方法返回指定对象的原型(内部[[Prototype]]属性的值)。


handler.setPrototypeOf()

// 在设置代理对象的原型时触发该操作,比如在执行 Object.setPrototypeOf(proxy, null) 时。

handler.isExtensible()

// 在判断一个代理对象是否是可扩展时触发该操作,比如在执行 Object.isExtensible(proxy) 时。

handler.preventExtensions()

// 在让一个代理对象不可扩展时触发该操作,比如在执行 Object.preventExtensions(proxy) 时。

handler.getOwnPropertyDescriptor()

// 在获取代理对象某个属性的属性描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时。

handler.defineProperty()

// 在定义代理对象某个属性时的属性描述时触发该操作,比如在执行 Object.defineProperty(proxy, "foo", {}) 时。

handler.has()

// 在判断代理对象是否拥有某个属性时触发该操作,比如在执行 "foo" in proxy 时。

handler.get()

// 在读取代理对象的某个属性时触发该操作,比如在执行 proxy.foo 时。

handler.set()

// 在给代理对象的某个属性赋值时触发该操作,比如在执行 proxy.foo = 1 时。

handler.deleteProperty()

// 在删除代理对象的某个属性时触发该操作,比如在执行 delete proxy.foo 时。

handler.ownKeys()

// 在获取代理对象的所有属性键时触发该操作,比如在执行 Object.getOwnPropertyNames(proxy) 时。

handler.apply()

// 在调用一个目标对象为函数的代理对象时触发该操作,比如在执行 proxy() 时。

handler.construct()

// 在给一个目标对象为构造函数的代理对象构造实例时触发该操作,比如在执行new proxy() 时。

Proxy的作用

对于代理模式Proxy的作用主要体现在三个

简单理解

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

1
var proxy = new Proxy(target, handler);

Proxy 对象的所有用法,都是上面这种形式,不同的只是handler参数的写法。其中,new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。

下面是另一个拦截读取属性行为的例子。

1
2
3
4
5
6
7
8
9
var proxy = new Proxy({}, {
get: function(target, propKey) {
return 35;
}
});

proxy.time // 35
proxy.name // 35
proxy.title // 35

上面代码中,作为构造函数,Proxy接受两个参数。第一个参数是所要代理的目标对象(上例是一个空对象),即如果没有Proxy的介入,操作原来要访问的就是这个对象;第二个参数是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。比如,上面代码中,配置对象有一个get方法,用来拦截对目标对象属性的访问请求。get方法的两个参数分别是目标对象和所要访问的属性。可以看到,由于拦截函数总是返回35,所以访问任何属性都得到35

注意,要使得Proxy起作用,必须针对Proxy实例(上例是proxy对象)进行操作,而不是针对目标对象(上例是空对象)进行操作。

如果handler没有设置任何拦截,那就等同于直接通向原对象。

1
2
3
4
5
var target = {};
var handler = {};
var proxy = new Proxy(target, handler);
proxy.a = 'b';
target.a // "b"

上面代码中,handler是一个空对象,没有任何拦截效果,访问proxy就等同于访问target

一个技巧是将 Proxy 对象,设置到object.proxy属性,从而可以在object对象上调用。

1
var object = { proxy: new Proxy(target, handler) };

Proxy 实例也可以作为其他对象的原型对象。

1
2
3
4
5
6
7
8
var proxy = new Proxy({}, {
get: function(target, propKey) {
return 35;
}
});

let obj = Object.create(proxy);
obj.time // 35

上面代码中,proxy对象是obj对象的原型,obj对象本身并没有time属性,所以根据原型链,会在proxy对象上读取该属性,导致被拦截。

Create方法

1
2
3
Object.create(proto[, propertiesObject])
proto: 新创建对象的原型对象。
propertiesObject:可选。如果没有指定为 undefined,则是要添加到新创建对象的不可枚举(默认)属性(即其自身定义的属性,而不是其原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应Object.defineProperties()的第二个参数。

前言

双向绑定其实已经是一个老掉牙的问题了,只要涉及到MVVM框架就不得不谈的知识点,但它毕竟是Vue的三要素之一

Vue三要素

  • 响应式: 例如如何监听数据变化,其中的实现方法就是我们提到的双向绑定
  • 模板引擎: 如何解析模板
  • 渲染: Vue如何将监听到的数据变化和解析后的HTML进行渲染

可以实现双向绑定的方法有很多,KnockoutJS基于观察者模式的双向绑定,Ember基于数据模型的双向绑定,Angular基于脏检查的双向绑定, 我们重点讲面试中常见的基于数据劫持的双向绑定。

常见的基于数据劫持的双向绑定有两种实现,一个是目前Vue在用的Object.defineProperty,另一个是ES2015中新增的Proxy,而Vue的作者宣称将在Vue3.0版本后加入Proxy从而代替Object.defineProperty,通过本文你也可以知道为什么Vue未来会选择Proxy

严格来讲Proxy应该被称为『代理』而非『劫持』,不过由于作用有很多相似之处,我们在下文中就不再做区分,统一叫『劫持』。

我们可以通过下图清楚看到以上两种方法在双向绑定体系中的关系.

1597660580614

基于数据劫持实现的双向绑定的特点

什么是数据劫持

数据劫持比较好理解,通常我们利用Object.defineProperty劫持对象的访问器,在属性值发生变化时我们可以获取变化,从而进行进一步操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 这是将要被劫持的对象
const data = {
name: '',
};

function say(name) {
if (name === '古天乐') {
console.log('给大家推荐一款超好玩的游戏');
} else if (name === '渣渣辉') {
console.log('戏我演过很多,可游戏我只玩贪玩懒月');
} else {
console.log('来做我的兄弟');
}
}

// 遍历对象,对其属性值进行劫持
Object.keys(data).forEach(function(key) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
console.log('get');
},
set: function(newVal) {
// 当属性值发生变化时我们可以进行额外操作
console.log(`大家好,我系${newVal}`);
say(newVal);
},
});
});

data.name = '渣渣辉';
//大家好,我系渣渣辉
//戏我演过很多,可游戏我只玩贪玩懒月
复制代码

数据劫持的优势

目前业界分为两个大的流派,一个是以React为首的单向数据绑定,另一个是以Angular、Vue为主的双向数据绑定。

其实三大框架都是既可以双向绑定也可以单向绑定,比如React可以手动绑定onChange和value实现双向绑定,也可以调用一些双向绑定库,Vue也加入了props这种单向流的api,不过都并非主流卖点。

单向或者双向的优劣不在我们的讨论范围,我们需要讨论一下对比其他双向绑定的实现方法,数据劫持的优势所在。

无需显示调用: 例如Vue运用数据劫持+发布订阅,直接可以通知变化并驱动视图,上面的例子也是比较简单的实现data.name = '渣渣辉'后直接触发变更,而比如Angular的脏检测则需要显示调用markForCheck(可以用zone.js避免显示调用,不展开),react需要显示调用setState

可精确得知变化数据:还是上面的小例子,我们劫持了属性的setter,当属性值改变,我们可以精确获知变化的内容newVal,因此在这部分不需要额外的diff操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这个时候需要大量diff来找出变化值,这是额外性能损耗。

基于数据劫持双向绑定的实现思路

数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是Vue

基于数据劫持的双向绑定离不开ProxyObject.defineProperty等方法对对象/对象属性的”劫持”,我们要实现一个完整的双向绑定需要以下几个要点。

利用ProxyObject.defineProperty生成的Observer针对对象/对象的属性进行”劫持”,在属性发生变化后通知订阅者

解析器Compile解析模板中的Directive(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染

Watcher属于Observer和Compile桥梁,它将接收到的Observer产生的数据变化,并根据Compile提供的指令进行视图渲染,使得数据变化促使视图变化

虽然Vue运用了数据劫持,但是依然离不开发布订阅的模式(Event Bus的实现就是因为我们不管在学习一些框架的原理还是一些流行库(例如Redux、Vuex),基本上都离不开发布订阅模式,而Event模块则是此模式的经典实现)

基于Object.defineProperty双向绑定的特点

关于Object.defineProperty的文章在网络上已经汗牛充栋,我们不想花过多时间在Object.defineProperty上面,本节我们主要讲解Object.defineProperty的特点,方便接下来与Proxy进行对比。

极简版的双向绑定

我们都知道,Object.defineProperty的作用就是劫持一个对象的属性,通常我们对属性的gettersetter方法进行劫持,在对象的属性发生变化时进行特定的操作。

我们就对对象objtext属性进行劫持,在获取此属性的值时打印'get val',在更改属性值的时候对DOM进行操作,这就是一个极简的双向绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const obj = {};
Object.defineProperty(obj, 'text', {
get: function() {
console.log('get val');&emsp;
},
set: function(newVal) {
console.log('set val:' + newVal);
document.getElementById('input').value = newVal;
document.getElementById('span').innerHTML = newVal;
}
});

const input = document.getElementById('input');
input.addEventListener('keyup', function(e){
obj.text = e.target.value;
})

升级改造

我们很快会发现,这个所谓的双向绑定貌似并没有什么乱用。。。

原因如下:

  1. 我们只监听了一个属性,一个对象不可能只有一个属性,我们需要对对象每个属性进行监听。
  2. 违反开放封闭原则,我们如果了解开放封闭原则的话,上述代码是明显违反此原则,我们每次修改都需要进入方法内部,这是需要坚决杜绝的。
  3. 代码耦合严重,我们的数据、方法和DOM都是耦合在一起的,就是传说中的面条代码。
拓展:开放封闭原则(Open Closed Principle)

在面向对象的设计中有很多流行的思想,比如说 “所有的成员变量都应该设置为私有(Private)”,”要避免使用全局变量(Global Variables)”,”使用运行时类型识别(RTTI:Run Time Type Identification,例如 dynamic_cast)是危险的” 等等

开放封闭原则(Open Closed Principle)描述

符合开放封闭原则的模块都有两个主要特性:

1. 它们 “面向扩展开放(Open For Extension)”。

也就是说模块的行为是能够被扩展的。当应用程序的需求变化时,我们可以使模块表现出全新的或与以往不同的行为,以满足新的需求。

2. 它们 “面向修改封闭(Closed For Modification)”。

模块的源代码是不能被侵犯的,任何人都不允许修改已有源代码

回归正题

那么如何解决上述问题?

Vue的操作就是加入了发布订阅模式,结合Object.defineProperty的劫持能力,实现了可用性很高的双向绑定。

首先,我们以发布订阅的角度看我们第一部分写的那一坨代码,会发现它的监听发布订阅都是写在一起的,我们首先要做的就是解耦。

我们先实现一个订阅发布中心,即消息管理员(Dep),它负责储存订阅者和消息的分发,不管是订阅者还是发布者都需要依赖于它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let uid = 0;
// 用于储存订阅者并发布消息
class Dep {
constructor() {
// 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher
this.id = uid++;
// 储存订阅者的数组
this.subs = [];
}
// 触发target上的Watcher中的addDep方法,参数为dep的实例本身
depend() {
Dep.target.addDep(this);
}
// 添加订阅者
addSub(sub) {
this.subs.push(sub);
}
notify() {
// 通知所有的订阅者(Watcher),触发订阅者的相应逻辑处理
this.subs.forEach(sub => sub.update());
}
}
// 为Dep类设置一个静态属性,默认为null,工作时指向当前的Watcher
Dep.target = null;

现在我们需要实现监听者(Observer),用于监听属性值的变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 监听者,监听对象属性值的变化
class Observer {
constructor(value) {
this.value = value;
this.walk(value);
}
// 遍历属性值并监听
walk(value) {
Object.keys(value).forEach(key => this.convert(key, value[key]));
}
// 执行监听的具体方法
convert(key, val) {
defineReactive(this.value, key, val);
}
}

function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let chlidOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果Dep类存在target属性,将其添加到dep实例的subs数组中
// target指向一个Watcher实例,每个Watcher都是一个订阅者
// Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
if (Dep.target) {
dep.depend();
}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
// 对新值进行监听
chlidOb = observe(newVal);
// 通知所有订阅者,数值被改变了
dep.notify();
},
});
}

function observe(value) {
// 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}

那么接下来就简单了,我们需要实现一个订阅者(Watcher)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
  class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者
this.vm = vm; // 被订阅的数据一定来自于当前Vue实例
this.cb = cb; // 当数据更新时想要做的事情
this.expOrFn = expOrFn; // 被订阅的数据
this.val = this.get(); // 维护更新之前的数据
}
// 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
update() {
this.run();
}
addDep(dep) {
// 如果在depIds的hash中没有当前的id,可以判断是新Watcher,因此可以添加到dep的数组中储存
// 此判断是避免同id的Watcher被多次储存
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
run() {
const val = this.get();
console.log(val);
if (val !== this.val) {
this.val = val;
this.cb.call(this.vm, val);
}
}
get() {
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
const val = this.vm._data[this.expOrFn];
// 置空,用于下一个Watcher使用
Dep.target = null;
return val;
}
}
复制代码

那么我们最后完成Vue,将上述方法挂载在Vue上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Vue {
constructor(options = {}) {
// 简化了$options的处理
this.$options = options;
// 简化了对data的处理
let data = (this._data = this.$options.data);
// 将所有data最外层属性代理到Vue实例上
Object.keys(data).forEach(key => this._proxy(key));
// 监听数据
observe(data);
}
// 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
$watch(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
}
_proxy(key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key],
set: val => {
this._data[key] = val;
},
});
}
}

演示

1
2
3
4
5
<main>
<p>请输入:</p>
<input type="text" id="input">
<p id="p"></p>
</main>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
const Vue = (function() {
let uid = 0;
// 用于储存订阅者并发布消息
class Dep {
constructor() {
// 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher
this.id = uid++;
// 储存订阅者的数组
this.subs = [];
}
// 触发target上的Watcher中的addDep方法,参数为dep的实例本身
depend() {
Dep.target.addDep(this);
}
// 添加订阅者
addSub(sub) {
this.subs.push(sub);
}
notify() {
// 通知所有的订阅者(Watcher),触发订阅者的相应逻辑处理
this.subs.forEach(sub => sub.update());
}
}
// 为Dep类设置一个静态属性,默认为null,工作时指向当前的Watcher
Dep.target = null;
// 监听者,监听对象属性值的变化
class Observer {
constructor(value) {
this.value = value;
this.walk(value);
}
// 遍历属性值并监听
walk(value) {
Object.keys(value).forEach(key => this.convert(key, value[key]));
}
// 执行监听的具体方法
convert(key, val) {
defineReactive(this.value, key, val);
}
}

function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let chlidOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果Dep类存在target属性,将其添加到dep实例的subs数组中
// target指向一个Watcher实例,每个Watcher都是一个订阅者
// Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
if (Dep.target) {
dep.depend();
}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
// 对新值进行监听
chlidOb = observe(newVal);
// 通知所有订阅者,数值被改变了
dep.notify();
},
});
}

function observe(value) {
// 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}

class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者
this.vm = vm; // 被订阅的数据一定来自于当前Vue实例
this.cb = cb; // 当数据更新时想要做的事情
this.expOrFn = expOrFn; // 被订阅的数据
this.val = this.get(); // 维护更新之前的数据
}
// 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
update() {
this.run();
}
addDep(dep) {
// 如果在depIds的hash中没有当前的id,可以判断是新Watcher,因此可以添加到dep的数组中储存
// 此判断是避免同id的Watcher被多次储存
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
run() {
const val = this.get();
console.log(val);
if (val !== this.val) {
this.val = val;
this.cb.call(this.vm, val);
}
}
get() {
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
const val = this.vm._data[this.expOrFn];
// 置空,用于下一个Watcher使用
Dep.target = null;
console.log(Dep.target, 2);
return val;
}
}

class Vue {
constructor(options = {}) {
// 简化了$options的处理
this.$options = options;
// 简化了对data的处理
let data = (this._data = this.$options.data);
// 将所有data最外层属性代理到Vue实例上
Object.keys(data).forEach(key => this._proxy(key));
// 监听数据
observe(data);
}
// 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
$watch(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
}
_proxy(key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key],
set: val => {
this._data[key] = val;
},
});
}
}

return Vue;
})();

let demo = new Vue({
data: {
text: '',
},
});

const p = document.getElementById('p');
const input = document.getElementById('input');

input.addEventListener('keyup', function(e) {
demo.text = e.target.value;
});

demo.$watch('text', str => p.innerHTML = str);

Object.defineProperty的缺陷

其实我们升级版的双向绑定依然存在漏洞,比如我们将属性值改为数组。

补充:下面用到的createDocumentFragment()

1
2
let fragment = document.createDocumentFragment();
// fragment 是一个指向空DocumentFragment对象的引用。

DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。在DOM树中,文档片段被其所有的子元素所代替。

因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。

用法如:

1
2
3
4
5
6
7
8
9
10
11
12
var element  = document.getElementById('ul'); // assuming ul exists
var fragment = document.createDocumentFragment();
var browsers = ['Firefox', 'Chrome', 'Opera',
'Safari', 'Internet Explorer'];

browsers.forEach(function(browser) {
var li = document.createElement('li');
li.textContent = browser;
fragment.appendChild(li);
});

element.appendChild(fragment);

回归正题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let demo = new Vue({
data: {
list: [1],
},
});

const list = document.getElementById('list');
const btn = document.getElementById('btn');

btn.addEventListener('click', function() {
demo.list.push(1);
});


const render = arr => {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
};

// 监听数组,每次数组变化则触发渲染函数,然而...无法监听
demo.$watch('list', list => render(list));

setTimeout(
function() {
alert(demo.list);
},
5000,
);

是的,Object.defineProperty的第一个缺陷,无法监听数组变化。 然而Vue的文档提到了Vue是可以检测到数组变化的,但是只有以下八种方法,vm.items[indexOfItem] = newValue这种是无法检测的。

1
2
3
4
5
6
7
push()
pop()
shift()
unshift()
splice()
sort()
reverse()

其实作者在这里用了一些奇技淫巧,把无法监听数组的情况hack掉了,以下是方法示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayAugmentations = [];

aryMethods.forEach((method)=> {

// 这里是原生Array的原型方法
let original = Array.prototype[method];

// 将push, pop等封装好的方法定义在对象arrayAugmentations的属性上
// 注意:是属性而非原型属性
arrayAugmentations[method] = function () {
console.log('我被改变啦!');

// 调用对应的原生方法并返回结果
return original.apply(this, arguments);
};

});

let list = ['a', 'b', 'c'];
// 将我们要监听的数组的原型指针指向上面定义的空数组对象
// 别忘了这个空数组的属性上定义了我们封装好的push等方法
list.__proto__ = arrayAugmentations;
list.push('d'); // 我被改变啦! 4

// 这里的list2没有被重新定义原型指针,所以就正常输出
let list2 = ['a', 'b', 'c'];
list2.push('d'); // 4
复制代码

由于只针对了八种方法进行了hack,所以其他数组的属性也是检测不到的,其中的坑很多,可以阅读上面提到的文档。

我们应该注意到在上文中的实现里,我们多次用遍历方法遍历对象的属性,这就引出了Object.defineProperty的第二个缺陷,只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历,如果属性值也是对象那么需要深度遍历,显然能劫持一个完整的对象是更好的选择。

1
Object.keys(value).forEach(key => this.convert(key, value[key]));

Proxy实现的双向绑定的特点

Proxy在ES2015规范中被正式发布,它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写,我们可以这样认为,Proxy是Object.defineProperty的全方位加强版

注意:proxy在使用中,调用new proxy之后,我们调用的是new完之后的结果,这点要做出跟defineProperty的区别。

Proxy可以直接监听对象而非属性

我们还是以上文中用Object.defineProperty实现的极简版双向绑定为例,用Proxy进行改写

1
2
3
4
5
<main>
<p>请输入:</p>
<input type="text" id="input">
<p id="p"></p>
</main>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const input = document.getElementById('input');
const p = document.getElementById('p');
const obj = {};

const newObj = new Proxy(obj, {
get: function(target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key === 'text') {
input.value = value;
p.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});

input.addEventListener('keyup', function(e) {
newObj.text = e.target.value;
});

我们可以看到,Proxy直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于Object.defineProperty

Proxy可以直接监听数组的变化

当我们对数组进行操作(push、shift、splice等)时,会触发对应的方法名称和length的变化,我们可以借此进行操作,以上文中Object.defineProperty无法生效的列表渲染为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const list = document.getElementById('list');
const btn = document.getElementById('btn');

// 渲染列表
const Render = {
// 初始化
init: function(arr) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
},
// 我们只考虑了增加的情况,仅作为示例
change: function(val) {
const li = document.createElement('li');
li.textContent = val;
list.appendChild(li);
},
};

// 初始数组
const arr = [1, 2, 3, 4];

// 监听数组
const newArr = new Proxy(arr, {
get: function(target, key, receiver) {
console.log(key);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key !== 'length') {
Render.change(value);
}
return Reflect.set(target, key, value, receiver);
},
});

// 初始化
window.onload = function() {
Render.init(arr);
}

// push数字
btn.addEventListener('click', function() {
newArr.push(6);
});

很显然,Proxy不需要那么多hack(即使hack也无法完美实现监听)就可以无压力监听数组的变化,我们都知道,标准永远优先于hack。

Proxy的其他优势

Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。

Proxy返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty只能遍历对象属性直接修改。

Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。

当然,Proxy的劣势就是兼容性问题,而且无法用polyfill磨平,因此Vue的作者才声明需要等到下个大版本(3.0)才能用Proxy重写。

你为什么使用前端框架?(⭐⭐)

前端框架的根本意义

前端框架的好处

最开始学习前端框架的时候并不理解框架能带来什么,只是因为大家都在用框架,最实际的一个用途就是所有企业几乎都在用框架,不用框架就 out 了.

随着使用的深入我逐渐理解到框架的好处:

  1. 组件化: 其中以 React 的组件化最为彻底,甚至可以到函数级别的原子组件,高度的组件化可以是我们的工程易于维护、易于组合拓展。
  2. 天然分层: JQuery 时代的代码大部分情况下是面条代码,耦合严重,现代框架不管是 MVC、MVP还是MVVM 模式都能帮助我们进行分层,代码解耦更易于读写。
  3. 生态: 现在主流前端框架都自带生态,不管是数据流管理架构还是 UI 库都有成熟的解决方案。

前端框架的根本意义

你以为的原因:

  • 组件化。
  • 拥有强大的开源社区。
  • 拥有大量第三方库解决大部分问题。
  • 拥有大量现成的第三方组件。
  • 拥有浏览器拓展/工具帮助快速 debug。
  • 友好的支持单页应用。

不,这些都不是根本原因,最多算前端框架的营销手段

简单来说,前端框架的根本意义是解决了UI 与状态同步问题

在 Vue 中我们如果要在todos中添加一条,只需要app4.todos.push({ text: '新项目' }),这时由于 Vue 内置的响应式系统会自动帮我们进行 UI 与状态的同步工作.

1
2
3
4
5
6
7
<div id="app-4">
<ol>
<li v-for="todo in todos">
{{ todo.text }}
</li>
</ol>
</div>
1
2
3
4
5
6
7
8
9
10
var app4 = new Vue({
el: '#app-4',
data: {
todos: [
{ text: '学习 JavaScript' },
{ text: '学习 Vue' },
{ text: '整个牛项目' }
]
}
})

如果我们用 JQuery 或者 JS 进行操作,免不了一大堆li.appendChilddocument.createElement等 DOM 操作,我们需要一长串 DOM 操作保证状态与 UI 的同步,其中一个环节出错就会导致 BUG,手动操作的缺点如下:

  1. 频繁操作 DOM 性能低下.
  2. 中间步骤过多,易产生 bug且不易维护,而且心智要求较高不利于开发效率

不管是 vue 的数据劫持、Angular 的脏检测还是 React 的组件级 reRender都是帮助我们解决 ui 与状态同步问题的利器。

这也解释了Backbone作为前端框架鼻祖在之后落寞的原因,Backbone只是引入了 MVC 的思想,并没有解决 View 与 Modal 同步的问题,相比于现代的三大框架直接操作 Modal 就可以同步 UI 的特性, Backbone 仍然与 JQuery 绑定,在 View 里操作 Dom来达到同步 UI 的目的,这显然是不符合现代前端框架设计要求的。

Vue 如何保证 UI 与状态同步

UI 在 MVVM 中指的是 View,状态在 MVVM 中指的是 Modal,而保证 View 和 Modal 同步的是 View-Modal。

Vue 通过一个响应式系统保证了View 与 Modal的同步,由于要兼容IE,Vue 选择了 Object.defineProperty作为响应式系统的实现,但是如果不考虑 IE 用户的话,Object.defineProperty并不是一个好的选择,具体请看基于proxy数据劫持的双向绑定优势所在

我们用 Proxy 实现一个响应式系统

1597897550017

发布订阅中心

一个响应式系统离不开发布订阅模式,因为我们需要一个 Dep保存订阅者,并在 Observer 发生变化时通知保存在 Dep 中的订阅者,让订阅者得知变化并更新视图,这样才能保证视图与状态的同步。

发布订阅模式请阅读Event Bus的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* [subs description] 订阅器,储存订阅者,通知订阅者
* @type {Map}
*/
export default class Dep {
constructor() {
// 我们用 hash 储存订阅者
this.subs = new Map();
}
// 添加订阅者
addSub(key, sub) {
// 取出键为 key 的订阅者
const currentSub = this.subs.get(key);
// 如果能取出说明有相同的 key 的订阅者已经存在,直接添加
if (currentSub) {
currentSub.add(sub);
} else {
// 用 Set 数据结构储存,保证唯一值
this.subs.set(key, new Set([sub]));
}
}
// 通知
notify(key) {
// 触发键为 key 的订阅者们
if (this.subs.get(key)) {
this.subs.get(key).forEach(sub => {
sub.update();
});
}
}
}

监听者的实现

我们在订阅器 Dep 中实现了一个notify方法来通知相应的订阅这们,然而notify方法到底什么时候被触发呢?

当然是当状态发生变化时,即 MVVM 中的 Modal 变化时触发通知,然而Dep 显然无法得知 Modal 是否发生了变化,因此我们需要创建一个监听者Observer来监听 Modal, 当 Modal 发生变化的时候我们就执行通知操作。

vue 基于Object.defineProperty来实现了监听者,我们用 Proxy 来实现监听者.

Object.defineProperty监听属性不同, Proxy 可以监听(实际是代理)整个对象,因此就不需要遍历对象的属性依次监听了,但是如果对象的属性依然是个对象,那么 Proxy 也无法监听,所以我们实现了一个observify进行递归监听即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
**
* [Observer description] 监听器,监听对象,触发后通知订阅
* @param {[type]} obj [description] 需要被监听的对象
*/
const Observer = obj => {
const dep = new Dep();
return new Proxy(obj, {
get: function(target, key, receiver) {
// 如果订阅者存在,直接添加订阅
if (Dep.target) {
dep.addSub(key, Dep.target);
}
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
// 如果对象值没有变,那么不触发下面的操作直接返回
if (Reflect.get(receiver, key) === value) {
return; //此处值为receiver,是因为我们修改值是可以针对proxy实例返回的新对象。
}
const res = Reflect.set(target, key, observify(value), receiver);
// 当值被触发更改的时候,触发 Dep 的通知方法
dep.notify(key);
return res;
},
});
};

/**
* 将对象转为监听对象
* @param {*} obj 要监听的对象
*/
export default function observify(obj) {
if (!isObject(obj)) {
return obj;
}

// 深度监听
Object.keys(obj).forEach(key => {
obj[key] = observify(obj[key]);
});

return Observer(obj);
}

订阅者的实现

我们目前已经解决了两个问题,一个是如何得知 Modal 发生了改变(利用监听者 Observer 监听 Modal 对象),一个是如何收集订阅者并通知其变化(利用订阅器收集订阅者,并用notify通知订阅者)。

我们目前还差一个订阅者(Watcher)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 订阅者
export default class Watcher {
constructor(vm, exp, cb) {
this.vm = vm; // vm 是 vue 的实例
this.exp = exp; // 被订阅的数据
this.cb = cb; // 触发更新后的回调
this.value = this.get(); // 获取老数据
}
get() {
const exp = this.exp;
let value;
Dep.target = this;
if (typeof exp === 'function') {
value = exp.call(this.vm);
} else if (typeof exp === 'string') {
value = this.vm[exp];
}
Dep.target = null;
return value;
}
// 将订阅者放入待更新队列等待批量更新
update() {
pushQueue(this);
}
// 触发真正的更新操作
run() {
const val = this.get(); // 获取新数据
this.cb.call(this.vm, val, this.value);
this.value = val;
}
}

批量更新的实现

我们在上一节中实现了订阅者( Watcher),但是其中的update方法是将订阅者放入了一个待更新的队列中,而不是直接触发,原因如下:

1598001616177

因此这个队列需要做的是异步去重,因此我们用 Set作为数据结构储存 Watcher 来去重,同时用Promise模拟异步更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 创建异步更新队列
let queue = new Set()

// 用Promise模拟nextTick
function nextTick(cb) {
Promise.resolve().then(cb)
}

// 执行刷新队列
function flushQueue(args) {
queue.forEach(watcher => {
watcher.run()
})
// 清空
queue = new Set()
}

// 添加到队列
export default function pushQueue(watcher) {
queue.add(watcher)
// 下一个循环调用
nextTick(flushQueue)
}

小结

我们梳理一下流程, 一个响应式系统是如何做到 UI(View)与状态(Modal)同步的?

我们首先需要监听 Modal, 本文中我们用 Proxy 来监听了 Modal 对象,因此在 Modal 对象被修改的时候我们的 Observer 就可以得知。

我们得知Modal发生变化后如何通知 View 呢?要知道,一个 Modal 的改变可能触发多个 UI 的更新,比如一个用户的用户名改变了,它的个人信息组件、通知组件等等组件中的用户名都需要改变,对于这种情况我们很容易想到利用发布订阅模式来解决,我们需要一个订阅器(Dep)来储存订阅者(Watcher),当监听到 Modal 改变时,我们只需要通知相关的订阅者进行更新即可。

那么订阅者来自哪里呢?其实每一个组件实例对应着一个订阅者(正因为一个组件实例对应一个订阅者,才能利用 Dep 通知到相应组件,不然乱套了,通知订阅者就相当于间接通知了组件)。

当订阅者得知了具体变化后它会进行相应的更新,将更新体现在 UI(View)上,至此UI 与 Modal 的同步完成了。

github: https://github.com/xiaomuzhu/proxy-vue

响应式系统并不是全部

响应式系统虽然是 Vue 的核心概念,但是一个响应式系统并不够.

响应式系统虽然得知了数据值的变化,但是当值不能完整映射 UI 时,我们依然需要进行组件级别的 reRender,这种情况并不高效,因此 Vue 在2.0版本引入了虚拟 DOM, 虚拟 DOM进行进一步的 diff 操作可以进行细粒度更高的操作,可以保证 reReander 的下限(保证不那么慢)。

除此之外为了方便开发者,vue 内置了众多的指令,因此我们还需要一个 vue 模板解析器.

观察者模式与订阅发布模式的区别(⭐)

观察者模式

观察者模式定义了对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知,并自动更新。观察者模式属于行为型模式,行为型模式关注的是对象之间的通讯,观察者模式就是观察者和被观察者之间的通讯。

观察者模式有一个别名叫“发布-订阅模式”,或者说是“订阅-发布模式”,订阅者和订阅目标是联系在一起的,当订阅目标发生改变时,逐个通知订阅者。我们可以用报纸期刊的订阅来形象的说明,当你订阅了一份报纸,每天都会有一份最新的报纸送到你手上,有多少人订阅报纸,报社就会发多少份报纸,报社和订报纸的客户就是上面文章开头所说的“一对多”的依赖关系。

订阅-发布模式

其实24种基本的设计模式中并没有发布订阅模式,上面也说了,他只是观察者模式的一个别称。

但是经过时间的沉淀,似乎他已经强大了起来,已经独立于观察者模式,成为另外一种不同的设计模式。

在现在的发布订阅模式中,称为发布者的消息发送者不会将消息直接发送给订阅者,这意味着发布者和订阅者不知道彼此的存在。在发布者和订阅者之间存在第三个组件,称为调度中心或事件通道,它维持着发布者和订阅者之间的联系,过滤所有发布者传入的消息并相应地分发它们给订阅者。

举一个例子,你在微博上关注了A,同时其他很多人也关注了A,那么当A发布动态的时候,微博就会为你们推送这条动态。A就是发布者,你是订阅者,微博就是调度中心,你和A是没有直接的消息往来的,全是通过微博来协调的(你的关注,A的发布动态)。

两种模式的区别

1598011406792

可以看出,发布订阅模式相比观察者模式多了个事件通道,事件通道作为调度中心,管理事件的订阅和发布工作,彻底隔绝了订阅者和发布者的依赖关系。即订阅者在订阅事件的时候,只关注事件本身,而不关心谁会发布这个事件;发布者在发布事件的时候,只关注事件本身,而不关心谁订阅了这个事件。

观察者模式有两个重要的角色,即目标和观察者。在目标和观察者之间是没有事件通道的。一方面,观察者要想订阅目标事件,由于没有事件通道,因此必须将自己添加到目标(Subject) 中进行管理;另一方面,目标在触发事件的时候,也无法将通知操作(notify) 委托给事件通道,因此只能亲自去通知所有的观察者。

发布-订阅模式是面向调度中心编程的,而观察者模式则是面向目标和观察者编程的。前者用于解耦发布者和订阅者,后者用于耦合目标和观察者,不可同日而语也

我们把这些差异快速总结一下:

  • 观察者模式中,观察者是知道Subject的,Subject一直保持对观察者进行记录。然而,在发布订阅模式中,发布者和订阅者不知道对方的存在。它们只有通过消息代理进行通信。
  • 发布订阅模式中,组件是松散耦合的,正好和观察者模式相反。
  • 观察者模式大多数时候是同步的,比如当事件触发,Subject就会去调用观察者的方法。而发布-订阅模式大多数时候是异步的(使用消息队列)。
  • 观察者 模式需要在单个应用程序地址空间中实现,而发布-订阅更像交叉应用模式。

尽管它们之间有区别,但有些人可能会说发布-订阅模式是观察者模式的变异,因为它们概念上是相似的。

写过『通用前端组件』吗?(⭐⭐⭐)

前言

设计前端组件是最能考验开发者基本功的测试之一,因为调用Material design、Antd、iView 等现成组件库的 API 每个人都可以做到,但是很多人并不知道很多常用组件的设计原理。

能否设计出通用前端组件也是区分前端工程师和前端api调用师的标准之一,那么应该如何设计出一个通用组件呢?

下文中提到的组件库通常是指单个组件,而非集合的概念,集合概念的组件库是 Antd iView这种,我们所说的组件库是指集合中的单个组件,集合性质的组件库需要考虑的要更多.

前端组件库的设计原则

细粒度的考量

我们在学习设计模式的时候会遇到很多种设计原则,其中一个设计原则就是单一职责原则,在组件库的开发中同样适用,我们原则上一个组件只专注一件事情,单一职责的组件的好处很明显,由于职责单一就可以最大可能性地复用组件,但是这也带来一个问题,过度单一职责的组件也可能会导致过度抽象,造成组件库的碎片化。

举个例子,一个自动完成组件(AutoComplete),他其实是由 Input 组件和 Select 组件组合而成的,因此我们完全可以复用之前的相关组件,就比如 Antd 的AutoComplete组件中就复用了Select组件,同时Calendar、 Form 等等一系列组件都复用了 Select 组件,那么Select 的细粒度就是合适的,因为 Select 保持的这种细粒度很容易被复用.

那么还有一个例子,一个徽章数组件(Badge),它的右上角会有红点提示,可能是数字也可能是 icon,他的职责当然也很单一,这个红点提示也理所当然也可以被单独抽象为一个独立组件,但是我们通常不会将他作为独立组件,因为在其他场景中这个组件是无法被复用的,因为没有类似的场景再需要小红点这个小组件了,所以作为独立组件就属于细粒度过小,因此我们往往将它作为 Badge 的内部组件,比如在 Antd 中它以ScrollNumber的名称作为Badge的内部组件存在。

所以,所谓的单一职责组件要建立在可复用的基础上,对于不可复用的单一职责组件我们仅仅作为独立组件的内部组件即可。

通用性考量

我们要设计的本身就是通用组件库,不同于我们常见的业务组件,通用组件是与业务解耦但是又服务于业务开发的,那么问题来了,如何保证组件的通用性,通用性高一定是好事吗?

比如我们设计一个选择器(Select)组件,通常我们会设计成这样

1598029876951

这是一个我们最常见也最常用的选择器,但是问题是其通用性大打折扣

当我们有一个需求是长这样的时候,我们之前的选择器组件就不符合要求了,因为这个 Select 组件的最下部需要有一个可拓展的条目的按钮

1598029894873

这个时候我们难道要重新修改之前的选择器组件,甚至再造一个符合要求的选择器组件吗?一旦有这种情况发生,那么只能说明之前的选择器组件通用性不够,需要我们重新设计.

Antd 的 Select 组件预留了dropdownRender来进行自定义渲染,其依赖的 rc-select组件中的代码如下

当然类似的设计还有很多,通用性设计其实是一定意义上放弃对 DOM 的掌控,而将 DOM 结构的决定权转移给开发者,dropdownRender其实就是放弃对 Select 下拉菜单中条目的掌控,Antd 的 Select 组件其实还有一个没有在文档中体现的方法getInputElement应该是对 Input 组件的自定义方法,Antd整个 Select 的组件设计非常复杂,基本将所有的 DOM 结构控制权全部暴露给了开发者,其本身只负责底层逻辑和最基本的 DOM 结构.

1
2
3
<Select defaultValue="lucy" style={{ width: 120 }} disabled>
<Option value="lucy">Lucy</Option>
</Select>

组件的形态(DOM结构)永远是千变万化的,但是其行为(逻辑)是固定的,因此通用组件的秘诀之一就是将 DOM 结构的控制权交给开发者,组件只负责行为和最基本的 DOM 结构

技术选型

css 解决方案

由于CSS 本身的众多缺陷,如书写繁琐(不支持嵌套)、样式易冲突(没有作用域概念)、缺少变量(不便于一键换主题)等不一而足。为了解决这些问题,社区里的解决方案也是出了一茬又一茬,从最早的 CSS prepocessor(SASS、LESS、Stylus)到后来的后起之秀 PostCSS,再到 CSS Modules、Styled-Components 等

Antd 选择了 less 作为 css 的预处理方案,Bootstrap 选择了 Scss,这两种方案孰优孰劣已经争论了很多年了:

SCSS和LESS相比有什么优势?

但是不管是哪种方案都有一个很烦人的点,就是需要额外引入 css,比如 Antd 需要这样显示引入:

1
2
import Button from 'antd/lib/button';
import 'antd/lib/button/style';

为了解决这种尴尬的情况,Antd 用 Babel 插件将这种情况 Hack 掉了

material-ui并不存在这种情况,他不需要显示引入 css,这个最流行的 React 前端组件库里面只有 js 和 ts 两种代码,并不存在 css 相关的代码,为什么呢?

他们用 jss 作为css-in-js 的解决方案,jsx 的引入已经将 js 和 html 耦合,css-in-js将 css 也耦合进去,此时组件便不需要显示引入 css,而是直接引用 js 即可.

这不是退化到史前前端那种写内联样式的时代了吗?

并不是,史前前端的内联样式是整个项目耦合的状态,当然要被抛弃到历史的垃圾堆中,后来的样式和逻辑分离,实际上是以页面为维度将 js css html 解耦的过程,如今的时代是组件化的时代了,jsx 已经将 js 和 html 框定到一个组件中,css 依然处于分离状态,这就导致了每次引用组件却还需要显示引入 css,css-in-js 正式彻底组件化的解决方案.

js 解决方案

选 Typescript ,因为巨硬大法好…

你为什么不用 Typescript

如何快速启动一个组件库项目

组件的具体实现部分当然是组件库的核心,但是在现代前端库中其他部分也必不可少,我们需要一堆工具来辅助我们开发,例如编译工具、代码检测工具、打包工具等等。

打包工具(rollup vs webpack)

市面上打包工具数不胜数,最火爆的当然是需要配置工程师专门配置的webpack,但是在类库开发领域它有一个强大的对手就是 rollup。

现代市面上主流的库基本都选择了 rollup 作为打包工具,包括Angular React 和 Vue, 作为基础类库的打包工具 rollup 的优势如下:

  • Tree Shaking: 自动移除未使用的代码, 输出更小的文件
  • Scope Hoisting: 所有模块构建在一个函数内, 执行效率更高
  • Config 文件支持通过 ESM 模块格式书写 可以一次输出多种格式:
  • 模块规范: IIFE, AMD, CJS, UMD, ESM Development 与 production 版本: .js, .min.js

虽然上面部分功能已经被 webpack 实现了,但是 rollup 明显引入得更早,而Scope Hoisting更是杀手锏,由于 webpack 不得不在打包代码中构建模块系统来适应 app 开发(模块系统对于单一类库用处很小),Scope Hoisting将模块构建在一个函数内的做法更适合类库的打包.

代码检测

由于 JavaScript 各种诡异的特性和大型前端项目的出现,代码检测工具已经是前端开发者的标配了,Douglas Crockford最早于2002创造出了 JSLint,但是其无法拓展,具有极强的Douglas Crockford个人色彩,Anton Kovalyov由于无法忍受 JSLint 无法拓展的行为在2011年发布了可拓展的JSHint,一时之间JSHint成为了前端代码检测的流行解决方案.

随后的2013年,Nicholas C. Zakas鉴于JSHint拓展的灵活度不够的问题开发了全新的基于 AST 的 Lint 工具 ESLint,并随着 ES6的流行统治了前端界,ESLint 基于Esprima进行 JavaScript 解析的特性极易拓展,JSHint 在很长一段时间无法支持 ES6语法导致被 ESLint 超越.

代码检测工具是一方面,代码检测风格也需要我们做选择,市面上最流行的代码检测风格应该是 Airbnb 出品的eslint-config-airbnb,其最大的特点就是极其严格,没有给开发者任何选择的余地,当然在大型前端项目的开发中这种严格的代码风格是有利于协作的,但是作为一个类库的代码检测工具而言并不适合,所以我们选择了eslint-config-standard这种相对更为宽松的代码检测风格

commit 规范

写好 commit message 不仅有助于他人 review, 还可以有效的输出 CHANGELOG, 对项目的管理实际至关重要.

目前流行的方案是 Angular 团队的规范,其关于 head 的大致规范如下:

  • type: commit 的类型
  • feat: 新特性
  • fix: 修改问题
  • refactor: 代码重构
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改
  • test: 测试用例修改
  • chore: 其他修改, 比如构建流程, 依赖管理.
  • scope: commit 影响的范围, 比如: route, component, utils, build…
  • subject: commit 的概述, 建议符合 50/72 formatting
  • body: commit 具体修改内容, 可以分为多行, 建议符合 50/72 formatting
  • footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

当然规范人们不一定会遵守,我最初知道此类规范的时候也并没有严格遵循,因为人总会偷懒,直到用commitizen将此规范集成到工具流中,每个 commit 就不得不遵循规范了.

优雅的提交你的 Git Commit Message

测试工具

业务开发中由于前端需求变动频繁的特性,导致前端对测试的要求并没有后端那么高,后端业务逻辑一旦定型变动很少,比较适合测试.

但是基础类库作为被反复依赖的模块和较为稳定的需求是必须做测试的,前端测试库也可谓是种类繁多了,经过比对之后我还是选择了目前最流行也是被三大框架同时选择了的 Jest 作为测试工具,其优点很明显:

  1. 开箱即用,内置断言、测试覆盖率工具,如果你用 MoCha 那可得自己手动配置 n 多了
  2. 快照功能,Jest 可以利用其特有的快照测试功能,通过比对 UI 代码生成的快照文件
  3. 速度优势,Jest 的测试用例是并行执行的,而且只执行发生改变的文件所对应的测试,提升了测试速度

快速启动脚手架

那么以上这么多配置难道要我们每次都自己写吗?组件的具体实现才是组件库的核心,我们为什么要花这么多时间在配置上面?

我们在建立 APP 项目时通常会用到框架官方提供的脚手架,比如 React 的 create-react-app,Angular 的 Angular-Cli,Vue的Vue-Cli 等等,那么能不能有一个专门用于组件开发的快速启动的脚手架呢?

能否开发一款自定义的脚手架!

如何设计一个轮播图组件

轮播图基本原理

轮播图(Carousel),在 Antd 中被称为走马灯,可能是前端开发者最常见的组件之一了,不管是在 PC 端还是在移动端我们总能见到他的身影.

Antd

1
2
3
4
5
6
<Carousel>
<div><h3>1</h3></div>
<div><h3>2</h3></div>
<div><h3>3</h3></div>
<div><h3>4</h3></div>
</Carousel>

实现流程

聊一聊 Babel?(⭐)

Babel 是现代 JavaScript 语法转换器,几乎在任何现代前端项目中都能看到他的身影,其背后的原理对于大部分开发者还属于黑盒,不过 Babel 作为一个工具真的有了解背后原理的必要吗?

如果只是 Babel 可能真没有必要,问题是其背后的原理在我们开发中应用过于广泛了,包括不限于: eslint jshint stylelint css-in-js prettier jsx vue-template uglify-js postcss less 等等等等,从模板到代码检测,从混淆压缩到代码转换,甚至编辑器的代码高亮都与之息息相关.

如果有兴趣就可以搞一些黑魔法: 前端工程师可以用编译原理做什么?

前置

Babel 大概分为三大部分:

  • 解析: 将代码(其实就是字符串)转换成 AST( 抽象语法树)
  • 转换: 访问 AST 的节点进行变换操作生成新的 AST
  • 生成: 以新的 AST 为基础生成代码

代码解析

parser 概念

代码解析,也就是我们常说的 Parser, 用于将一段代码(文本)解析成一个数据结构.

例如这段 es6的代码

1
const add = (a, b) => a + b

我们用 babel 解析后便是这种形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
{
"type": "File",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"program": {
"type": "Program",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"sourceType": "module",
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 27
}
},
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 9
},
"identifierName": "add"
},
"name": "add"
},
"init": {
"type": "ArrowFunctionExpression",
"start": 12,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 12
},
"end": {
"line": 1,
"column": 27
}
},
"id": null,
"generator": false,
"expression": true,
"async": false,
"params": [
{
"type": "Identifier",
"start": 13,
"end": 14,
"loc": {
"start": {
"line": 1,
"column": 13
},
"end": {
"line": 1,
"column": 14
},
"identifierName": "a"
},
"name": "a"
},
{
"type": "Identifier",
"start": 16,
"end": 17,
"loc": {
"start": {
"line": 1,
"column": 16
},
"end": {
"line": 1,
"column": 17
},
"identifierName": "b"
},
"name": "b"
}
],
"body": {
"type": "BinaryExpression",
"start": 22,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 27
}
},
"left": {
"type": "Identifier",
"start": 22,
"end": 23,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 23
},
"identifierName": "a"
},
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 26,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 26
},
"end": {
"line": 1,
"column": 27
},
"identifierName": "b"
},
"name": "b"
}
}
}
}
],
"kind": "const"
}
],
"directives": []
}
}

我们以解析上面的 es6箭头函数为目标,来写一个简单的 parser.

文本 —> AST 的过程中有两个关键步骤:

  • 词法分析: 将代码(字符串)分割为token流,即语法单元成的数组
  • 语法分析: 分析token流(上面生成的数组)并生成 AST

词法分析(Tokenizer – 词法分析器)

要做词法分析,首先我们需要明白在 JavaScript 中哪些属于语法单元

  • 数字:JavaScript 中的科学记数法以及普通数组都属于语法单元.
  • 括号:『(』『)』只要出现,不管任何意义都算是语法单元
  • 标识符:连续字符,常见的有变量,常量(例如: null true),关键字(if break)等等
  • 运算符:+、-、*、/等等
  • 当然还有注释,中括号等

在我们 parser 的过程中,应该换一个角度看待代码,我们平时工作用的代码.本质是就是字符串或者一段文本,它没有任何意义,是 JavaScript 引擎赋予了它意义,所以我们在解析过程中代码只是一段字符串.

仍然以下面代码为例

const add = (a, b) => a + b

我们期望的结果是类似这样的

1
2
3
4
5
[
{ type: "identifier", value: "const" },
{ type: "whitespace", value: " " },
...
]

那么我们现在开始打造一个Tokenizer(词法分析器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// 词法分析器,接收字符串返回token数组
export const tokenizer = (code) => {

// 储存 token 的数组
const tokens = [];

// 指针
let current = 0;

while (current < code.length) {
// 获取指针指向的字符
const char = code[current];

// 我们先处理单字符的语法单元 类似于`;` `(` `)`等等这种
if (char === '(' || char === ')') {
tokens.push({
type: 'parens',
value: char,
});

current ++;

continue;
}

// 我们接着处理标识符,标识符一般为以字母、_、$开头的连续字符
if (/[a-zA-Z\$\_]/.test(char)) {
let value = '';
value += char;
current ++;

// 如果是连续字那么将其拼接在一起,随后指针后移
while (/[a-zA-Z0-9\$\_]/.test(code[current]) && current < code.length) {
value += code[current];
current ++;
}

tokens.push({
type: 'identifier',
value,
});

continue;
}


// 处理空白字符
if (/\s/.test(char)) {
let value = '';
value += char;
current ++;

//道理同上
while (/\s]/.test(code[current]) && current < code.length) {
value += code[current];
current ++;
}

tokens.push({
type: 'whitespace',
value,
});

continue;
}


// 处理逗号分隔符
if (/,/.test(char)) {

tokens.push({
type: ',',
value: ',',
});

current ++;
continue;
}

// 处理运算符
if (/=|\+|>/.test(char)) {
let value = '';
value += char;
current ++;

while (/=|\+|>/.test(code[current])) {
value += code[current];
current ++;
}

// 当 = 后面有 > 时为箭头函数而非运算符
if (value === '=>') {
tokens.push({
type: 'ArrowFunctionExpression',
value,
});
continue;
}

tokens.push({
type: 'operator',
value,
});

continue;
}

// 如果碰到我们词法分析器以外的字符,则报错
throw new TypeError('I dont know what this character is: ' + char);
}

return tokens;
};

那么我们基本的词法分析器就打造完成,因为只针对这一个es6函数,所以没有做额外的工作(额外的工作量会非常庞大).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const result = tokenizer('const add = (a, b) => a + b')

console.log(result);

/**
[ { type: 'identifier', value: 'const' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'add' },
{ type: 'whitespace', value: ' ' },
{ type: 'operator', value: '=' },
{ type: 'whitespace', value: ' ' },
{ type: 'parens', value: '(' },
{ type: 'identifier', value: 'a' },
{ type: ',', value: ',' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'b' },
{ type: 'parens', value: ')' },
{ type: 'whitespace', value: ' ' },
{ type: 'ArrowFunctionExpression', value: '=>' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'a' },
{ type: 'whitespace', value: ' ' },
{ type: 'operator', value: '+' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'b' } ]
**/

语法分析

法分析要比词法分析复杂得多,因为我们接下来的是示意代码,所以做了很多“武断”的判断来省略代码,即使这样也是整个微型 babel 中代码量最多的.

语法分析之所以复杂,是因为要分析各种语法的可能性,需要开发者根据token流(上一节我们生成的 token 数组)提供的信息来分析出代码之间的逻辑关系,只有经过词法分析 token 流才能成为有结构的抽象语法树.

做语法分析最好依照标准,大多数 JavaScript Parser 都遵循estree规范

由于标准内容很多,感兴趣的可以去阅读,我们目前只介绍几个比较重要的标准:

语句(Statements): 语句是 JavaScript 中非常常见的语法,我们常见的循环、if 判断、异常处理语句、with 语句等等都属于语句

1
2
3
4
// 典型的for 循环语句
for (var i = 0; i < 7; i++) {
console.log(i);
}

表达式(Expressions): 表达式是一组代码的集合,它返回一个值,表达式是另一个十分常见的语法,函数表达式就是一种典型的表达式,如果你不理解什么是表达式, MDN上有很详细的解释.

1
2
3
4
// 函数表达式
var add = function(a, b) {
return a + b
}

声明(Declarations): 声明分为变量声明和函数声明,表达式(Expressions)中的函数表达式的例子用声明的写法就是下面这样.

1
2
3
4
// 函数声明
function add(a, b) {
return a + b
}

你可能有点糊涂,为了理清其中的关系,我们就下面的代码为例来解读

1
2
3
4
// 函数表达式
var add = function(a, b) {
return a + b
}

首先这段代码的整体本质是是一个变量声明(VariableDeclarator)

而变量被声明为一个函数表达式(FunctionExpression)

函数表达式中的大括号在内的为块状语句(BlockStatement)

块状语句内 return 的部分是返回语句(ReturnStatement)

而 return 的其实是一个二元运算符或者叫二元表达式(BinaryExpression)

上面提到的这些有些属于表达式,有些属于声明也有些属于语句,当然还有更多我们没提到的,它们被语法分析之后被叫做AST(抽象语法树).

我们做语法分析的时候思路也是类似的,要分析哪一层的 token 到底属于表达式或者说语句,如果是语句那么是块状语句(BlockStatement)还是Loops,如果是 Loops 那么属于while 循环(WhileStatement)还是for 循环(ForStatement)等等,其中甚至难免要考虑作用域的问题,因此语法分析的复杂也体现在此.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
const parser = tokens => {
// 声明一个全时指针,它会一直存在
let current = -1;

// 声明一个暂存栈,用于存放临时指针
const tem = [];

// 指针指向的当前token
let token = tokens[current];

const parseDeclarations = () => {

// 暂存当前指针
setTem();

// 指针后移
next();

// 如果字符为'const'可见是一个声明
if (token.type === 'identifier' && token.value === 'const') {
const declarations = {
type: 'VariableDeclaration',
kind: token.value
};

next();

// const 后面要跟变量的,如果不是则报错
if (token.type !== 'identifier') {
throw new Error('Expected Variable after const');
}

// 我们获取到了变量名称
declarations.identifierName = token.value;

next();

// 如果跟着 '=' 那么后面应该是个表达式或者常量之类的,额外判断的代码就忽略了,直接解析函数表达式
if (token.type === 'operator' && token.value === '=') {
declarations.init = parseFunctionExpression();
}

return declarations;
}
};

const parseFunctionExpression = () => {
next();

let init;
// 如果 '=' 后面跟着括号或者字符那基本判断是一个表达式
if (
(token.type === 'parens' && token.value === '(') ||
token.type === 'identifier'
) {
setTem();
next();
while (token.type === 'identifier' || token.type === ',') {
next();
}

// 如果括号后跟着箭头,那么判断是箭头函数表达式
if (token.type === 'parens' && token.value === ')') {
next();
if (token.type === 'ArrowFunctionExpression') {
init = {
type: 'ArrowFunctionExpression',
params: [],
body: {}
};

backTem();

// 解析箭头函数的参数
init.params = parseParams();

// 解析箭头函数的函数主体
init.body = parseExpression();
} else {
backTem();
}
}
}

return init;
};

const parseParams = () => {
const params = [];
if (token.type === 'parens' && token.value === '(') {
next();
while (token.type !== 'parens' && token.value !== ')') {
if (token.type === 'identifier') {
params.push({
type: token.type,
identifierName: token.value
});
}
next();
}
}

return params;
};

const parseExpression = () => {
next();
let body;
while (token.type === 'ArrowFunctionExpression') {
next();
}

// 如果以(开头或者变量开头说明不是 BlockStatement,我们以二元表达式来解析
if (token.type === 'identifier') {
body = {
type: 'BinaryExpression',
left: {
type: 'identifier',
identifierName: token.value
},
operator: '',
right: {
type: '',
identifierName: ''
}
};
next();

if (token.type === 'operator') {
body.operator = token.value;
}

next();

if (token.type === 'identifier') {
body.right = {
type: 'identifier',
identifierName: token.value
};
}
}

return body;
};

// 指针后移的函数
const next = () => {
do {
++current;
token = tokens[current]
? tokens[current]
: { type: 'eof', value: '' };
} while (token.type === 'whitespace');
};

// 指针暂存的函数
const setTem = () => {
tem.push(current);
};

// 指针回退的函数
const backTem = () => {
current = tem.pop();
token = tokens[current];
};

const ast = {
type: 'Program',
body: []
};

while (current < tokens.length) {
const statement = parseDeclarations();
if (!statement) {
break;
}
ast.body.push(statement);
}
return ast;
};

至此我们暴力 parser 了token 流,最终得到了简陋的抽象语法树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"identifierName": "add",
"init": {
"type": "ArrowFunctionExpression",
"params": [
{
"type": "identifier",
"identifierName": "a"
},
{
"type": "identifier",
"identifierName": "b"
}
],
"body": {
"type": "BinaryExpression",
"left": {
"type": "identifier",
"identifierName": "a"
},
"operator": "+",
"right": {
"type": "identifier",
"identifierName": "b"
}
}
}
}
]
}

代码转换

如何转换代码?

在 Babel 中我们使用者最常使用的地方就是代码转换,大家常用的 Babel 插件就是定义代码转换规则而生的,而代码解析和生成这一头一尾都主要是 Babel 负责。

比如我们要用 babel 做一个React 转小程序的转换器,babel工作流程的粗略情况是这样的:

  1. babel 将 React 代码解析为抽象语法树
  2. 开发者利用 babel 插件定义转换规则,根据原本的抽象语法树生成一个符合小程序规则的新抽象语法树
  3. babel 则根据新的抽象语法树生成代码,此时的代码就是符合小程序规则的新代码

例如 Taro就是用 babel 完成的小程序语法转换.

到这里大家就明白了,我们转换代码的关键就是根据当前的抽象语法树,以我们定义的规则生成新的抽象语法树,转换的过程就是生成新抽象语法树的过程.

遍历抽象语法树(实现遍历器traverser)

抽象语法树是一个树状数据结构,我们要生成新语法树,那么一定需要访问 AST 上的节点,因此我们需要一个工具来遍历抽象语法树的节点.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const traverser = (ast, visitor) => {

// 如果节点是数组那么遍历数组
const traverseArray = (array, parent) => {
array.forEach((child) => {
traverseNode(child, parent);
});
};

// 遍历 ast 节点
const traverseNode = (node, parent) => {
const method = visitor[node.type];
if (method) {
method(node, parent);
}

switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;

case 'VariableDeclaration':
traverseArray(node.init.params, node.init);
break;

case 'identifier':
break;

default:
throw new TypeError(node.type);
}
};
traverseNode(ast, null);
};

转换代码(实现转换器transformer)

我们要转换的代码const add = (a, b) => a + b其实是个变量声明,按理来讲我们要转换为es5的代码也应该是个变量声明,比如这种:

1
2
3
var add = function(a, b) {
return a + b
}

当然也可以不按规则,直接生成一个函数声明,像这样:

1
2
3
function add(a, b) {
return a + b
}

1598071209678

我们之前的遍历器traverser接收两个参数,一个是 ast 节点对象,一个是 visitor,visitor本质是挂载不同方法的 JavaScript 对象,visitor 也叫做访问者,顾名思义它会访问 ast 上每个节点,然后根据针对不同节点用相应的方法做出不同的转换.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const transformer = (ast) => {

// 新 ast
const newAst = {
type: 'Program',
body: []
};

// 在老 ast 上加一个指针指向新 ast
ast._context = newAst.body;

traverser(ast, {
// 对于变量声明的处理方法
VariableDeclaration: (node, parent) => {
let functionDeclaration = {
params: []
};
if (node.init.type === 'ArrowFunctionExpression') {
functionDeclaration.type = 'FunctionDeclaration';
functionDeclaration.identifierName = node.identifierName;
}


if (node.init.body.type === 'BinaryExpression') {
functionDeclaration.body = {
type: 'BlockStatement',
body: [{
type: 'ReturnStatement',
argument: node.init.body
}],
};
}

parent._context.push(functionDeclaration);
},

//对于字符的处理方法
identifier: (node, parent) => {
if (parent.type === 'ArrowFunctionExpression') {
// 忽略我这暴力的操作....领略大意即可..
ast._context[0].params.push({
type: 'identifier',
identifierName: node.identifierName
});
}
}
});

return newAst;
};

生成代码(实现生成器generator)

我们之前提到过,生成代码这一步实际上是根据我们转换后的抽象语法树来生成新的代码,我们会实现一个函数, 他接受一个对象( ast),通过递归生成最终的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const generator = (node) => {
switch (node.type) {
// 如果是 `Program` 结点,那么我们会遍历它的 `body` 属性中的每一个结点,并且递归地
// 对这些结点再次调用 codeGenerator,再把结果打印进入新的一行中。
case 'Program':
return node.body.map(generator)
.join('\n');

// 如果是FunctionDeclaration我们分别遍历调用其参数数组以及调用其 body 的属性
case 'FunctionDeclaration':
return 'function' + ' ' + node.identifierName + '(' + node.params.map(generator) + ')' + ' ' + generator(node.body);

// 对于 `Identifiers` 我们只是返回 `node` 的 identifierName
case 'identifier':
return node.identifierName;

// 如果是BlockStatement我们遍历调用其body数组
case 'BlockStatement':
return '{' + node.body.map(generator) + '}';

// 如果是ReturnStatement我们调用其 argument 的属性
case 'ReturnStatement':
return 'return' + ' ' + generator(node.argument);

// 如果是ReturnStatement我们调用其左右节点并拼接
case 'BinaryExpression':
return generator(node.left) + ' ' + node.operator + ' ' + generator(node.right);

// 没有符合的则报错
default:
throw new TypeError(node.type);

}
};

至此我们完成了一个简陋的微型 babel,我们开始试验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const compiler = (input) => {
const tokens = tokenizer(input); //词法分析得抽象语法树节点,注意还不是树
const ast = parser(tokens); // 语法分析得到抽象语法树结构
const newAst = transformer(ast); //转换成目标AST(新)
const output = generator(newAst); //根据抽象语法树生成代码

return output;
};

const str = 'const add = (a, b) => a + b';

const result = compiler(str);

console.log(result);
// function add(a,b) {return a + b}

语法分析之后被叫做AST(抽象语法树).

我们成功地将一个es6的箭头函数转换为es5的function函数.

最后

我们可以通过这个微型 babel 了解 babel 的工作原理,如果让你对编译原理产生兴趣并去深入那是更好的, babel集合包 是有数十万行代码的巨大工程,我们用区区几百行代码只能展示其最基本的原理,代码有很多不合理之处,如果想真正的了解 babel 欢迎阅读器源码.

前端可以利用编译原理相关的东西还有很多,除了我们常见的es6转换工具 babel,代码检测的 eslint等等,我们还可以:

  1. 小程序多端转义 Taro
  2. 小程序热更新js 解释器
  3. babel与错误监控浏览器端 JavaScript 异常监控
  4. 模板引擎
  5. css 预处理后处理等等