前言
最近拜读了一下修言大神的JavaScript 设计模式核⼼原理与应⽤实践, 对于现阶段的我,可以说受益匪浅,自己也学着总结下,分享下干货,力求共同进步!
在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。 ——维基百科
先提炼下,文章缺少小册前两章,概括来说:
- 技术寒冬,前端真的很缺人,经得起推敲的人太少;
- 前端技术面很大,想把每一面都涵盖到很难,不建议大家死磕;
- 追求极致设计,讲究道术结合;
- 掌握核心科技,以‘不变’应万变;
- 前端工程师,首先是软件工程师;
这里强调一下以不变应万变的中不变的是什么,因为这关系到你的核心竞争力是什么在哪里。所谓‘不变的东西’说的驾驭技术的能力,具体来说分以下三个层次:
- 能用健壮的代码去解决具体问题;
- 能用抽象的思维去应对复杂的系统;
- 能用工程化的思想去规划更大规模的业务;
这三种能力在你的成长过程中是层层递进的关系,而后两种能力可以说是对架构师的要求。能做到第一点,并且把它做到扎实、做到娴熟的人,已经堪称同辈楷模
很多人缺乏的并不是这种高瞻远瞩的激情,而是我们前面提到的“不变能力”中最基本的那一点——用健壮的代码去解决具体的问题的能力。这个能力在软件工程领域所对标的经典知识体系,恰恰就是设计模式。所以说,想做靠谱开发,先掌握设计模式。
小册的知识体系与格局,用思维导图展示如下:
下面涉及到的是小册中细讲的设计模式;
目录:
- 工厂模式
- 单例模式
- 原型模式
- 修饰器模式
- 适配器模式
- 代理模式
- 策略模式
- 状态模式
- 观察者模式
- 迭代器模式
工厂模式
定义: 工厂模式其实就是将创建的对象的过程单独封装;
简单工厂模式
结合定义我们来看一段需求,公司需要编写一个员工信息录入系统,当系统里面只创建自己的时候我们可以:
const lilei = {
name = 'lilei',
age: 18,
career: 'coder'
}
当然员工肯定不会是一个,并且会不断加入,所以使用构造函数写成:
function User(name, age, career) {
this.name = name;
this.age = age;
this.career = career;
}
const lilei = new User('lilei', 18, 'coder')
const lilei = new User('hanmeimei', 20, 'product manager')
// ...
上面的代码其实就是构造器,关于构造器模式后面会有具体介绍,我们采用ES5的构造函数来实现,ES6的class其本质还是函数,class只不过是语法糖,构造函数,才是它的这面目。
需求继续增加,career字段能携带的信息有限,无法完整诠释人员职能,要给每个工种的用户添加上一个个性字段,来描述相应的职能。
function Coder(name, age){
this.name = name;
this.age = age;
this.career = 'coder';
this.work = ['敲代码', '摸鱼', '写bug'];
}
function ProductManager(name, age) {
this.name = name;
this.age = age;
this.career = 'product manager';
this.work = ['订会议室', '写PRD', '催更']
}
function Factory(name, age, career) {
switch(career) {
case 'coder':
return new Coder(name, age);
break;
case 'product manager':
return new ProductManager(name, age);
break;
...
}
}
现在看至少我们不用操心构造函数的分配问题了,那么问题来了,大家都看到了省略号了吧,这就意味着每多一个工种就要手动添加一个类上去,假如有几十个工种,那么就会有几十个类?相对来说,我们还是需要不停的声明新的构造函数。
so:
function User(name, age, career, work) {
this.name = name;
this.age = age;
this.career = career;
this.work = work;
}
function Factory(name, age, career) {
let work;
switch() {
case'coder':
work = ['写代码','摸鱼', '写bug'];
break;
case 'product manager':
work = ['订会议室', '写PRD', '催更']
break
case 'boss':
work = ['喝茶', '看报', '见客户']
case 'xxx':
// 其它工种的职责分配
...
}
return new User(name, age, career)
}
这样一来我们需要做事情就简单多了,只需要无脑传参就可以了,不需要手写无数个构造函数,剩下的Factory都帮我们处理了。
工厂模式的目的就是为了实现无脑传参,就是为了爽。 -修言
乍一看没什么问题,但是经不起推敲呀。首先映入眼帘的 Bug,是我们把 Boss 这个角色和普通员工塞进了一个工厂。职能和权限会有很大区别,因此我们需要对这个群体的对象进行单独的逻辑处理。
怎么办?去修改 Factory的函数体、增加管理层相关的判断和处理逻辑吗?单从功能上来讲是可行的,但是这样操作到后期会导致Factory异常庞大,稍有不慎就有可能摧毁整个系统,这一切悲剧的根源只有一个——没有遵守开放封闭原则;
开放封闭原则:对拓展开放,对修改封闭。说得更准确点,软件实体(类、模块、函数)可以扩展,但是不可修改。
由此我们引出抽象工厂模式;
抽象工厂模式
抽象工厂这块知识,对入行以来一直写纯 JavaScript 的同学可能不太友好——因为抽象工厂在很长一段时间里,都被认为是 Java/C++ 这类语言的专利。
定义:抽象工厂模式是指当有多个抽象角色时,使用的一种工厂模式。抽象工厂模式可以向客户端提供一个接口,使客户端在不必指定产品的具体的情况下,创建多个产品族中的产品对象。
说白了抽象工厂模式,我认为就是工厂模式的扩充版,简单工厂生产实例,抽象工厂生产的是工厂,其实是实现子类继承父类的方法。
这里比较绕,所以我可耻的把原文的例子搬过来了括弧笑,让我们来看一下:
假如要做一个山寨手机,基本组成是操作系统(Operating System,我们下面缩写作 OS)和硬件(HardWare)组成,我们需要开一个手机工厂才能量产,但是我们又不知道具体生产的是什么手机,只知道有这两部分组成,所以我先来一个抽象类来约定住这台手机的基本组成:
class MobilePhoneFactory {
// 提供操作系统的接口
createOS (){
throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!');
}
// 提供硬件的接口
createHardWare(){
throw new Error('抽象工厂方法不允许直接调用,你需要将我重写!');
}
}
楼上这个类除了约定手机流水线的通用能力之外,啥也不干,如果你尝试new一个MobilePhoneFactory
实力并调用里面的方法,它都会给你报错。在抽象工厂模式里,楼上这个类就是我们食物链顶端最大的Boss——AbstractFactory
(抽象工厂);
抽象工厂不干活,具体工厂(ConcreteFactory)干活!当我们明确了生产方案以后就可以化抽象为具体,比如现在需要生产Android系统 + 高通硬件手机的生产线,我们给手机型号起名叫FakeStar,那我就可以定制一个具体工厂:
//具体工厂继承自抽象工厂
class FakeStarFactory entends MobilePhptoFactory {
cresteOS() {
// 提供安卓系统视力
return new AndroidOS();
}
createHardWare() {
// 提供高通硬件实例
return new QualcommHardeWare()
}
}
这里我们在提供按安卓系统的时候,调用了两个构造函数:AndroidOS和QualcommHardWare,它们分别用于生成具体的操作系统和硬件实例。像这种被我们拿来用于 new 出具体对象的类,叫做具体产品类(ConcreteProduct)。具体产品类往往不会孤立存在,不同的具体产品类往往有着共同的功能,比如安卓系统类和苹果系统类,它们都是操作系统,都有着可以操控手机硬件系统这样一个最基本的功能。因此我们可以用一个抽象产品(AbstractProduct)类来声明这一类产品应该具有的基本功能。
// 定义操作系统这类产品的抽象产品类
class OS {
controlHardWare() {
throw new Error('抽象产品方法不允许直接调用,你需要将我重写!');
}
}
// 定义具体操作系统的具体产品类
class AndroidOS extends OS {
controlHardWare() {
console.log('我会用安卓的方式去操作硬件')
}
}
class AppleOS extends OS {
controlHardWare() {
console.log('我会用🍎的方式去操作硬件')
}
}
...
硬件产品同理这里就不重复了。如此一来,当我们需要生产一台FakeStar手机时,我们只需要:
// 这是我的手机
const myPhone = new FakeStarFactory()
// 让它拥有操作系统
const myOS = myPhone.createOS()
// 让它拥有硬件
const myHardWare = myPhone.createHardWare()
// 启动操作系统(输出‘我会用安卓的方式去操作硬件’)
myOS.controlHardWare()
// 唤醒硬件(输出‘我会用高通的方式去运转’)
myHardWare.operateByOrder()
当有一天需要产出一款新机投入市场的时候,我们是不是不需要对抽象工厂MobilePhoneFactory做任何修改,只需要拓展它的种类:
class newStarFactory extends MobilePhoneFactory {
createOS() {
// 操作系统实现代码
}
createHardWare() {
// 硬件实现代码
}
}
这么个操作,对原有的系统不会造成任何潜在影响所谓的“对拓展开放,对修改封闭”就这么圆满实现了。
总结
抽象工厂模式的四个角色:
- 抽象工厂(不能用于生成具体实例):用于成名最终目标产品的共性。
- 具体工厂:继承抽象工厂、实现抽象工厂里声明的方法,用于创建具体的产品类。
- 抽象产品(不能用于生成具体实例):用于具体产品中共性的抽离。
- 具体产品:比如上面我们提到的具体的硬件等;
单例模式
定义: 保证一个类只有一个实例,并提供一个访问他的全局访问点。
一般情况下我们创建一个类(本质是构造函数)后,可以通过new关键字调用构造函数进而生成任意多的实例对象:
class SingleDog {
show() {
console.log('我是一只单身狗');
}
}
const s1 = new SingleDog();
const s2 = new SingleDog();
// false
s1 === s2
很明显s1与s2没有任何瓜葛,因为每次new出来的实例都会给我们开辟一块新的内存空间。那么我们怎么才能让对此new出来都是那唯一的一个实例呢?那就需要我们的构造函数具备判断自己是否被创建过一个实例的能力。
核心代码:
// 定义Storage
class SingleDog {
show() {
console.log('我是一只单身狗');
}
getInstace() {
// 判断是否已经new过一个实例
if(!SingleDog.instance){
// 若这个唯一实例不存在,则创建它
SingleDog.instance = new SingleDog();
}
// 如果有则直接返回
return SingleDog.instance;
}
}
const s1 = SingleDog.getInstance()
const s2 = SingleDog.getInstance()
// true
s1 === s2
生产实践:redux、vuex中的Store,或者我们经常使用的Storage都是单例模式。
我们来实现一下Storage:
class Storage{
static getInstance() {
if(!Storage.instance) {
Storage.instance = new Storage();
}
return Storage.instance;
}
getItem(key) {
return localStorage.getItem(key);
}
setItem(key, value){
return localStorage.setItem(key, value);
}
}
const storage1 = Storage.getInstance()
const storage2 = Storage.getInstance()
storage1.setItem('name', '李雷')
// 李雷
storage1.getItem('name')
// 也是李雷
storage2.getItem('name')
// 返回true
storage1 === storage2
思考一下如何实现一个全局唯一的模态框呢?
原型模式
原型模式不仅是一种设计模式,它还是一种编程范式(programming paradigm),是 JavaScript 面向对象系统实现的根基。
原型模式这一章节小册并没有讲述什么稀奇的知识点主要是关于Prototype
相关的需要强调的是javascript是以原型为中心的语言,ES6中的类其实是原型继承的语法糖。
ECMAScript 2015 中引入的JavaScript类实质上是JavaScript现有的基于原型的继承的语法糖。类语法不会为 JavaScript 引入新的面向对象的继承模型。 ——MDN
在原型模式下当我们想要创建一个对象时会先找到一个对象作为原型,然后在通过克隆原型的方式来创建出一个与原型一样(共享一套数据/方法)的对象。
其实谈原型模式就是在谈原型范式,原型编程范式的核心思想就是利用实例来描述对象,用实例作为定义对象和继承的基础。在JavaScript中,原型编程范式的体现就是基于原型链的继承。这其中,对原型、原型链的理解是关键。
这里应当注意,在一些面试中,面试官可能会可以混淆javascript中的原型范式和强类型语言中的原型模式,当他们这么做的时候很有可能是为了考察你对对象深拷贝的理解。
在JavaScript中实现深拷贝,有一种取巧的方式——JSON.stringify:
注意这方法是自己的局限性的,比如无法处理function、无法处理正则等等,我们在面试中不应该局限于这种方法,应该拓展出更多的可实施方案,比如递归等其他方法,回答递归的时候应该注意递归函数中值的类型的判断以及递归爆栈的问题。
深拷贝是没有完美方案的,每一种方案都有他自己的case。
关于深拷贝,有想深入研究的,小册作者在这里推荐了个比较好的地址可以关注下:
装饰器模式
装饰器模式(DecoratorPattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装。
优点:装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。
缺点:多层装饰比较复杂。
类装饰器的参数
当我们给一个类添加装饰器时:
function classDecorator(target) {
target.hasDecorator = true
return target
}
// 将装饰器“安装”到Button类上
@classDecorator
class Button {
// Button类的相关逻辑
}
此处的 target 就是被装饰的类本身。看着眼熟不?react中的高级组件(HOC)就是使用这个实现的。
方法装饰器的参数
而当我们给一个方法添加装饰器时:
function funcDecorator(target, name, descriptor) {
let originalMethod = descriptor.value
descriptor.value = function() {
console.log('我是Func的装饰器逻辑');
... // 你需要拓展的操作
return originalMethod.apply(this, arguments);
}
return descriptor
}
class Button {
@funcDecorator
onClick () {
console.log('我是Func的原有逻辑')
}
}
- 第一个参数target 变成了
Button.prototype
,即类的原型对象。这是因为 onClick 方法总是要依附其实例存在的,修饰onClik其实是修饰它的实例。但我们的装饰器函数执行的时候,Button 实例还并不存在。为了确保实例生成后可以顺利调用被装饰好的方法,装饰器只能去修饰 Button 类的原型对象 - 第二个参 数name,是我们修饰的目标属性属性名。
- 第三个参数descriptor,它的真面目就是“属性描述对象”(attributes object),它由各种各样的属性描述符组成,这些描述符又分为数据描述符和存取描述符,很明显,拿到了 descriptor,就相当于拿到了目标方法的控制权。:
这里需要注意:
- 当我们在react中给方法添加装饰器的时候,方法样使用上边写法,不能使用
()=>{}
箭头函数的写法,原因是箭头函数写法如果class类没有实例出来是获取不到的 - 接着上一条说,使用上述写法的时候应该在组件的
constructor
中使用bind
修改onClick
方法的this
指向。
高阶组件(HOC)的应用
高阶组件(HOC)的主要有两个类型:
- 属性代理
新组件类继承子React.component类,对传入的组件进行一系列操作,从而产生一个新的组件,达到增强组件的作用。
1、 操作props
2、 访问ref
3、 抽取state
4、 封装组件
class WrappedComponent extends Component {
render() {
return <input name="name" {...this.props.name} />;
}
}
const HOC = (WrappedComponent) =>
class extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
};
this.onNameChange = this.onNameChange.bind(this);
}
onNameChange(event) {
this.setState({
name: event.target.value,
})
}
render() {
const newProps = {
name: {
value: this.state.name,
onChange: this.onNameChange,
},
}
return <WrappedComponent {...this.props} {...newProps} />;
}
}
- 反向继承
新组件类继承子原组件类,拦截生命周期、渲染劫持和控制state。
export default function ConsoleLog(WrappedComponent, params = []) {
return class extends WrappedComponent {
consoleLog() {
if (params && params.length > 0) {
params.forEach((info) => {
console.log(`${info}==` + JSON.stringify(this.props[info]));
})
} else {
console.log("this.props", JSON.stringify(this.props))
}
}
render() {
this.consoleLog()
return super.render();
}
}
}
反向继承不能保证完整的子组件树被解析。React Components, Elements, and Instances这篇文章主要明确了一下几个点:
- 元素(element)是一个是用DOM节点或者组件来描述屏幕显示的纯对象,元素可以在属性(props.children)中包含其他的元素,一旦创建就不会改变。我们通过JSX和React.createClass创建的都是元素。
- 组件(component)可以接受属性(props)作为输入,然后返回一个元素树(element tree)作为输出。有多种实现方式:Class或者函数(Function)。
所以, 反向继承不能保证完整的子组件树被解析的意思的解析的元素树中包含了组件(函数类型或者Class类型),就不能再操作组件的子组件了,这就是所谓的不能完全解析。
小结(未完待续)
关于react的高阶组件过后我会在整理出一份详细完整的博客,因为可操作性很强,一段两段也说不清。
由于后半部分作者还在更新中,所以没有加进去,有兴趣的可以关注下,之后就可以愉快的阅读了。
关注我然后带走它!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。