前言
在前端面试中,用来区分高级工程师的一个衡量标准就是考察你对设计模式的理解了。
它很奇妙,可能平时代码中你已经运用了很多,但是你并不知道它是设计模式的一种。
另外一种情况就是,当你还是初入前端的菜鸟程序员时,你想通过阅读设计模式相关的书籍快速提升代码质量也是一件很难的事情,原因可能就是在你码代码不到一定量时,很难体会得到设计模式以及编码的一些基本原则的精妙之处。
本文既然属于面试系列,肯定不会从头到尾细致的去讲解每一种设计模式的实现,因为那样篇幅就太大了,本文旨在用更容易理解的方式讲解前端常见的设计模式以及面试常问的设计模式。会摒弃专业设计模式书中大量考虑的边界条件。
设计原则
设计原则非常多样,本文将讲解最为常用的两大原则:单一职责原则,开放—封闭原则。
每种设计模式都是为了让代码迎合其中一个或多个原则而出现的。
单一职责原则
SRP原则体现为:一个对象(方法)只做一件事情。
如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大。
我们以一段实际代码来演示:
const list = [
{
id:"1",
name:"jack1",
age:11,
address:"a1"
},
{
id:"4",
name:"jack4",
age:20,
address:"a4"
},
{
id:"4",
name:"jack4",
age:20,
address:"a4"
},
{
id:"2",
name:"jack2",
age:12,
address:"a2"
},
{
id:"3",
name:"jack3",
age:21,
address:"a3"
}
];
我们从后台拉取到一堆数据,要在界面中插入一堆列表,要求有几点:
- 不能展示重复的数据
- 按照年龄大小排序
我们开始实现代码:
const handleData = (list)=>{
// 去重
const temp = {};
for(let i=0;i<list.length;i++){
if(temp[list[i]["id"]]){
list.splice(i,1);
i--;
}else{
temp[list[i]["id"]] = true;
}
}
// 排序
const sortedList = list.sort((a,b)=>{
return a.age - b.age;
})
// 插入页面
const ul = document.createElement("ul");
sortedList.forEach((item)=>{
let li = document.createElement("li");
li.textContent = `${item.name} - ${item.age}`;
ul.appendChild(li);
})
document.body.append(ul);
}
handleData(list);
handleData 我们把list的传入进去,然后按照需求渲染到界面上,完美的满足了需求,如果需求永远不在变化,那么这样写代码时没有问题的。我们遵守单一职责原则的一个前提是拥抱变化。
遵守单一职责原则实现代码:
// 去重
const uniq = (list)=>{
const temp = {};
for(let i=0;i<list.length;i++){
if(temp[list[i]["id"]]){
list.splice(i,1);
i--;
}else{
temp[list[i]["id"]] = true;
}
}
return list;
}
// 按年龄升序
const sort = (list)=>{
return list.sort((a,b)=>{
return a.age - b.age;
})
}
// 插入界面
const insertBody = (list)=>{
// 插入页面
const ul = document.createElement("ul");
list.forEach((item)=>{
let li = document.createElement("li");
li.textContent = `${item.name} - ${item.age}`;
ul.appendChild(li);
})
document.body.append(ul);
}
// 组合调度
const handleData = (list)=>{
const uniqList = uniq(list);
const sortedList = sort(uniqList);
insertBody(sortedList);
}
handleData(list);
代码体量没有多大变化,但是层次和结构非常明了,这样重构的代码有几个优点
- 每个函数都有自己独特的职责;
- 便于阅读与维护;
- 容易抽象出重用方法,例如排序,去重等方法。
开放—封闭原则
在面向对象的程序设计中,开放-封闭原则(OCP)是最重要的一条原则。很多时候,一个程序具有良好的设计,往往说明它是符合开放-封闭原则的。
定义:软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。
当我们对函数扩展功能时,有两种方式。一种是修改原有的代码,另一种是增加一段新的代码。
现在可以引出开放-封闭原则的思想:当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。
个人认为很好的去遵循这个原则是一件非常难的事情,因为你要有一定预知未来的能力,总的来说尽力去往这方便面靠拢,然后才能体会到其中的奥秘。
看一个案例:
假设现在有一个很无厘头的需求,希望前端所有的打印都要先打印当前页面的URL。利用开发-封闭原则,我们不去也不能修改console.log
的源码,因此我们可以往console.log
中增加一个方法:
console.log = (func => {
return (...args) => {
console.info(window.location.href);
func.apply(console, args);
}
})(console.log);
开放-封闭原则是一个看起来比较虚幻的原则,并没有实际的模板教导我们怎样亦步亦趋地实现它。但我们还是能找到一些让程序尽量遵守开放-封闭原则的规律,最明显的就是找出程序中将要发生变化的地方,然后把变化封装起来。
通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来。在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经被封装好的,那么替换起来也相对容易。而变化部分之外的就是稳定的部分。在系统的演变过程中,稳定的部分是不需要改变的。
比较典型的例子就是回调函数
$.ajax( 'http:// xxx.com/getUserInfo', callback );
每次获取到数据后我们要做的动作都是变化的,因此我们使用回调的方式抽象出来。
设计模式
设计模式的核心思想——封装变化
单例模式
单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建。
要做到这点并不难,只需有一个变量可以判断该实例是否已经被创建过。
class Singleton{
static getInstance(){
if(!Singleton.instance){
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
}
const i1 = Singleton.getInstance();
const i2 = Singleton.getInstance();
console.log(i1 === i2); // true
在类Singleton中存一个属性保存创建好的实例,然后以后都依据这个属性来判断。
像这种情况,闭包是肯定可以实现的。
const singleton = (function () {
let instance;
return function () {
if (!instance){
instance = new Singleton();
}
return instance;
}
})();
const i3 = singleton();
const i4 = singleton();
console.log(i3 === i4); // true
上面两种实现方式都违反单一职责原则的,创建对象和管理单例的逻辑都放在一起实现。
导致如果我们需要创建多个单例对象就得去实现多少次单例。应该得把两种逻辑分开实现。
var getSingle = function( fn ){
var result;
return function(){
return result || ( result = fn .apply(this, arguments ) );
}
};
fn 是创建对象的方法。
var createLoginLayer = function(){
var div = document.createElement( 'div' );
div.innerHTML = '我是登录浮窗';
div.style.display = 'none';
document.body.appendChild( div );
return div;
};
var createSingleLoginLayer = getSingle( createLoginLayer );
document.getElementById( 'loginBtn' ).onclick = function(){
var loginLayer = createSingleLoginLayer();
loginLayer.style.display = 'block';
};
装饰器模式
装饰器模式,又名装饰者模式。它的定义是“在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求”。
angular 中的装饰器
写过angular的同学应该知道它的组件是这样的结构:
import { Component, OnInit } from "@angular/core";
@Component({
selector: "index",
templateUrl: "./index.component.html",
styleUrls: ["./index.component.scss"]
})
export class IndexComponent implements OnInit {
constructor() {}
ngOnInit() { }
}
其中的@Component
就是装饰器,给每个组件注入初始化数据。
React 中的装饰器
1、使用 create-react-app
创建项目
2、执行 npm run eject
暴露配置文件
3、package.json
中添加配置以支持装饰器语法
"babel": {
"presets": [
"react-app"
],
+ "plugins": [
+ [
+ "@babel/plugin-proposal-decorators", // React 中使用装饰器要使用插件进行编译
+ { "legacy": true }
+ ]
+ ]
}
创建一个装饰器setTitle
,主要功能是设置页面标题;
import React from 'react';
const setTitle = (title) => (WrappedComponent) => {
return class extends React.Component {
componentDidMount() {
document.title = title
}
render() {
return <WrappedComponent {...this.props} />
}
}
}
export default setTitle;
使用装饰器
import React from 'react';
import './App.css';
import setTitle from "./setTitle";
@setTitle('这是通过装饰器设置的标题')
class App extends React.Component {
render() {
return <div>App</div>;
}
}
export default App;
可以看到使用起来还是非常方便的,而且语义好一目了然就知道要实现什么功能。
ES7 的装饰器
ES7已经原生支持了装饰器,我们来看看如何实现。
@testable
class MyTestableClass {}
function testable(target) {
target.isTestable = true;
}
console.log(MyTestableClass.isTestable);
@testable
这个便是装饰器模式的精髓了,它就相当于一个装饰品,可以装饰在类、类属性、类方法上面。
我们看看用babel编译后的结果:
var _class;
let MyTestableClass = testable(_class = class MyTestableClass {
}) || _class; // {1}
function testable(target) {
target.isTestable = true; // {2}
}
console.log(MyTestableClass.isTestable); // {3}
代码解释:
- {1} 把
MyTestableClass
类 传入装饰器testable
,经过装饰器一番包装。 - {2}
MyTestableClass
添加一个属性isTestable
值为true
- {3} 输出
true
到这里我们可以理解,其实就是类通过装饰器包装了一些功能。我们常常会把一些通用的功能封装成装饰器,例如发送ajax请求之前需要统一配置token
以及修改属性的状态。
具体ES7的装饰器如何使用可以参考阮一峰的《ECMAScript 6 入门》里面有详细的讲解,本文旨在理解什么是装饰器。
ES6 以前的装饰器
ES7是原生提供了装饰器,在ES6以前JavaScript是如何实现该模式的?
JavaScript 中函数被称为一等对象。在平时的开发工作中,也许大部分时间都在和函数打交道。在JavaScript中可以很方便地给某个对象扩展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。在代码的运行期间,我们很难切入某个函数的执行环境,于是社区就有用AOP装饰函数的方案。
Function.prototype.before = function( beforefn ){
var __self = this; // 保存原函数的引用
return function(){ // 返回包含了原函数和新函数的"代理"函数
beforefn.apply( this, arguments ); // 执行新函数,且保证this不被劫持,新函数接受的参数
// 也会被原封不动地传入原函数,新函数在原函数之前执行
return __self.apply( this, arguments ); // 执行原函数并返回原函数的执行结果,
// 并且保证this不被劫持
}
}
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
实际应用:点击某个按钮时向服务器上报数据,由于没有服务器,我们使用localStorage
替代
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Aop</title>
</head>
<body>
<button id="button">点击打开登录浮层</button>
<script>
Function.prototype.after = function( afterfn ){
var __self = this;
return function(){
var ret = __self.apply( this, arguments );
afterfn.apply( this, arguments );
return ret;
}
};
var showLogin = function(){
console.log( '打开登录浮层' );
}
var log = function(){
var tagNum = window.localStorage.getItem("tagNum");
if(!tagNum){
window.localStorage.setItem("tagNum","1");
return ;
}
+tagNum ++;
window.localStorage.setItem("tagNum",tagNum);
console.log( '上报数据为: ' + tagNum );
}
showLogin = showLogin.after(log);
document.getElementById( 'button' ).onclick = showLogin;
</script>
</body>
</html>
AOP 就是一个切面,可以在某个函数执行之后或之前介入进去执行,且原函数是不需要做任何改动的。这就是用AOP装饰函数。
当我们有一个逻辑需要反复写时,记得考虑考虑装饰器模式。
适配器模式
适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。
现实生活中我们已经碰到很多这样的案例,例如我们去国外旅游就会碰到插座型号不一样而需要购买转换插座。
在代码中的表现则是:
const cOutlet = function(){
return [{name:"插座"}] // 中国插座的结构
}
const render = function( fn ){
console.log(JSON.stringify( fn() )) // 渲染中国插座"[{name:"插座"}]"
};
render(cOutlet); // 目前我们人在中国可以正常使用插座
某一天我们来到韩国旅游发现插座型号是kOutlet
,很明显用不了。
const kOutlet = function(){
return ["插座"]; // 韩国插座是这种结构,很明显不能使用render插入,不然输出的结构不一样肯定是匹配不了的。
}
// 我们定制一个转换器函数,输入中国插座,希望输出的是韩国插座的结构
const adapter = function(cOutlet){
return function () {
return cOutlet().map(item=>item.name);
}
}
render(adapter(cOutlet)); // 通过转换器函数,我们就把中国插座的结构"[{name:"插座"}]" 转换成符合韩国插座的结构 "["插座"]",并且没有改动中国插座的任何代码。这就是转换器的应用。
代理模式
代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。
去办公楼底下会有保安拦着,相亲的婚介所,这都是代理在生活总的应用场景。
ES6 Proxy
相当于是ES6语言层面就支持了对象的代理。
Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
var proxy = new Proxy(target, handler);
- target参数表示所要拦截的目标对象
- handler参数也是一个对象,用来定制拦截行为。
有这么一个常见,小A和小B想去某个娱乐场所,但是只有VIP才可以进去。我们用代码表示:
const A = {
isVip: false
};
const B = {
isVip: true
}
const club = {
wine:100,
girl:100
}
const visit = function (user){
return new Proxy(club, {
get: function(target, key){
if(!user.isVip){
return "抱歉只有VIP可以访问";
}
return target[key];
}
});
}
// 小A访问club的属性时
console.log(visit(A).wine) // 输出抱歉只有VIP可以访问
// 小B访问club的属性时
console.log(visit(B).wine) // 输出 100
当小A访问时,判断是否有vip,发现没有就输出"抱歉只有VIP可以访问",小B访问时返现他是vip,因此就输出了指定访问的数据。
代理模式的应用
事件代理
利用事件的冒泡机制,我们本来需要给每个LI绑定一个事件,现在使用代理的方式,让UL代理所有LI的事件
<ul id="myLinks">
<li id="goSomewhere">Go somewhere</li>
<li id="doSomething">Do something</li>
<li id="sayHi">Say hi</li>
</ul>
var ul = document.getElementById("myLinks");
ul.addEventListener("click", function(event){
switch(event.target.id){
case "doSomething":
document.title = "I changed the document's title";
break;
case "goSomewhere":
location.href = "http://www.wrox.com";
break;
case "sayHi":
alert("hi");
break;
}
},false);
缓存代理
假设我们有一个计算函数是用来计算数字的乘积的。
var mult = function(){
console.log( '开始计算乘积' );
var a = 1;
for ( var i = 0, l = arguments.length; i < l; i++ ){
a = a * arguments[i];
}
return a;
};
mult( 2, 3 ); // 输出:6
mult( 2, 3, 4 ); // 输出:24
当计算一定量时,我们会发现其实很多都是重复计算的。那么这个时候我们就可以引入缓存代理。
var proxyMult = (function(){
var cache = {};
return function(){
var args = Array.prototype.join.call( arguments, ',' );
if ( args in cache ){
return cache[ args ];
}
return cache[ args ] = mult.apply( this, arguments );
}
})();
proxyMult( 1, 2, 3, 4 ); // 输出:24
proxyMult( 1, 2, 3, 4 ); // 输出:24
当我们第二次调用proxyMult( 1, 2, 3, 4 )的时候,本体mult函数并没有被计算,proxyMult直接返回了之前缓存好的计算结果。
通过增加缓存代理的方式,mult函数可以继续专注于自身的职责——计算乘积,缓存的功能是由代理对象实现的。
策略模式
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。经过衍生“算法”可以扩展为“业务逻辑”。
这个模式应该说是一个重构利器,我们以一个实际场景来带入,根据用户的VIP等级去获取商品的优惠价格。
// 根据用户的vip等级
const outputPrice = (vip,originPrice)=>{
if(vip === 'v1'){
return originPrice * 0.9;
}
if(vip === 'v2'){
return originPrice * 0.8;
}
if(vip === 'v3'){
return originPrice * 0.7;
}
if(vip === 'v4'){
return originPrice * 0.6;
}
}
const A = {
vip:"v1",
originPrice: 200
}
const B = {
vip:"v3",
originPrice: 200
}
console.log(outputPrice(A.vip,A.originPrice)); // 180
console.log(outputPrice(B.vip,B.originPrice)); // 140
当有新的会员等级添加时,我们不得不去修改outputPrice
的源码,这违反开放—封闭原则了。
那么我们现在就使用策略模式进行重构
const outputPriceStrategy = {
'v1': (originPrice)=>{
return originPrice * 0.9;
},
'v2':(originPrice)=>{
return originPrice * 0.8;
},
'v3':(originPrice)=>{
return originPrice * 0.7;
},
'v4':(originPrice)=>{
return originPrice * 0.6;
}
}
const A = {
vip:"v1",
originPrice: 200
}
const B = {
vip:"v3",
originPrice: 200
}
const getDiscounts = (user)=>{
return outputPriceStrategy[user.vip](user.originPrice);
}
console.log(getDiscounts(A)); // 180
console.log(getDiscounts(B)); // 140
重构之后你会发现一条if...else
语句都没有了,转变成策略对象outputPriceStrategy
,如果以后vip结构变换了,我们只需要去更新策略即可。
策略模式的优点总结:
- 策略模式利用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句。
- 策略模式提供了对开放—封闭原则的完美支持,将算法封装在独立的strategy中,使得它们易于切换,易于理解,易于扩展。
- 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
迭代器模式
定义:迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。
迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前的绝大部分语言都内置了迭代器。
ES5中 Array.prototype.forEach
是比较简陋的数组迭代器的实现。相信大家都已经非常熟悉它了,其实这就是迭代器模式了,但是如果只讲这些那就太敷衍了,今天我们主要讲讲ES6中更加通用的迭代器是如何实现的。
在ES6中,针对Array、Map、Set、String、TypedArray、函数的 arguments 对象、NodeList 对象这些原生的数据结构都可以通过for...of...进行遍历。
原理是因为这些数据结构都部署了Symbol.iterator
属性,通过该属性可以获取迭代器对象。
const arr = [1, 2, 3]
// 通过调用iterator,拿到迭代器对象
const iterator = arr[Symbol.iterator]()
// 对迭代器对象执行next,就能逐个访问集合的成员
iterator.next() // {value:1,done:false}
iterator.next() // {value:2,done:false}
iterator.next() // {value:3,done:true}
因此它就可以被for...of遍历
const arr = [1, 2, 3]
const len = arr.length
for(item of arr) {
console.log(`当前元素是${item}`)
}
for...of...做的事情,基本等价于下面这通操作:
// 通过调用iterator,拿到迭代器对象
const iterator = arr[Symbol.iterator]()
// 初始化一个迭代结果
let now = { done: false }
// 循环往外迭代成员
while(!now.done) {
now = iterator.next()
if(!now.done) {
console.log(`现在遍历到了${now.value}`)
}
}
实现迭代器函数
通过 Generator 生成
// 编写一个迭代器生成函数
function *iteratorGenerator() {
yield '1'
yield '2'
yield '3'
}
const iterator = iteratorGenerator()
iterator.next()
iterator.next()
iterator.next()
手动实现
// 定义生成器函数,入参是任意集合
function iteratorGenerator(list) {
// i 记录当前访问的索引
var i = 0
// len记录传入集合的长度
var len = list.length
return {
// 自定义next方法
next: function() {
// 如果索引还没有超出集合长度,done为false
var done = i >= len
// 如果done为false,则可以继续取值
var value = !done ? list[i++] : undefined
// 将当前值与遍历是否完毕(done)返回
return {
done: done,
value: value
}
}
}
}
var iterator = iteratorGenerator(['1号选手', '2号选手', '3号选手'])
iterator.next()
iterator.next()
iterator.next()
实现起来是不是非常简单,同时 ES6 的迭代器也是面试重点。
发布-订阅模式
发布-订阅模式模式,是所有 JavaScript 设计模式中使用频率最高,面试频率也最高的设计模式,但是它非常好理解。
发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
在JavaScript开发中,我们一般用事件模型来替代传统的发布—订阅模式。
DOM事件
document.body.addEventListener( 'click', function(){
alert(2);
}, false );
document.body.click(); // 模拟用户点击
在这里需要监控用户点击document.body的动作,但是我们没办法预知用户将在什么时候点击。所以我们订阅document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息。
发布-订阅模式的通用实现
const eventEmitter = {
list: [], // {1}
on: function( key, fn ){ // {2}
if ( !this.list[ key ] ){
this.list[ key ] = [];
}
this.list[ key ].push( fn ); // 订阅的消息添加进缓存列表
},
emit: function(){ // {3}
const key = Array.prototype.shift.call( arguments ), // 获取传入的第一个参数
fns = this.list[ key ]; // 通过key获取到相应的事件集合
if ( !fns || fns.length === 0 ){
return false;
}
for( let i = 0; i<fns.length; i++ ){
fns[i].apply( this, arguments ); // 调用相应订阅函数
}
},
remove : function( key, fn ){ // {4}
const fns = this.list[ key ];
if ( !fns ){ // 如果key对应的消息没有被人订阅,则直接返回
return false;
}
if ( !fn ){ // 如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
fns && ( fns.length = 0 );
}else{
for ( let l = fns.length - 1; l >=0; l-- ){ // 反向遍历订阅的回调函数列表
let _fn = fns[ l ];
if ( _fn === fn ){
fns.splice( l, 1 ); // 删除订阅者的回调函数
}
}
}
}
};
代码解释:
- {1} list 数组用来存储订阅数据
- {2} on 用来订阅事件
- {3} emit 用来发布事件
- {4} remove 删除相关订阅
function user1 (content) {
console.log('用户1订阅了:', content);
}
function user2 (content) {
console.log('用户2订阅了:', content);
}
eventEmitter.on("订阅事件A",user1);
eventEmitter.on("订阅事件A",user2);
eventEmitter.emit("订阅事件A","哈哈");
发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。 从架构上来看,无论是MVC还是MVVM(vue.js),都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。
小结
设计模式有很多,本文只是把前端经常使用的或者说面试中经常会触及的设计模式拿出来讲解。设计模式的重点是理解,只有理解了才能做到平时项目中活学活用。