1. 什麼是結構型模式
結構型模式主要用於處理類和物件的組合,對應思維導圖:
2. 外觀模式: Facade Pattern
對介面二次封裝隱藏其複雜性,並簡化其使用。 外觀模式包含如下角色:
Facade
: 外觀角色SubSystem
: 子系統角色
當我們將系統分成多個子系統時,我們會降低程式碼複雜性。程式設計時的最佳實踐是最小化子系統之間的通訊和依賴關係。實現這一目標的一個好方法是引入一個facade
物件,為子系統提供單一且統一的介面。
1. 跨瀏覽器監聽事件
要保證處理事件的程式碼在大多數瀏覽器下一致執行,需要關注冒泡階段。
在做跨瀏覽器網站時,你已經不經意間使用了外觀模式:
var addMyEvent = function( el,ev,fn ){
if( el.addEventListener ){//存在DOM2級方法,則使用並傳入事件型別、事件處理程式函式和第3個引數false(表示冒泡階段)
el.addEventListener( ev,fn, false );
}else if(el.attachEvent){ // 為相容IE8及更早瀏覽器,注意事件型別必須加上"on"字首
el.attachEvent( "on" + ev, fn );
}else{
el["on" + ev] = fn;//其他方法都無效,預設採用DOM0級方法,使用方括號語法將屬性名指定為事件處理程式
}
};
複製程式碼
2. jQuery $(document).ready(..)
我們都熟悉$(document).ready(..)
。在原始碼中,這實際上是一個被呼叫的方法提供的bindReady()
:
載入事件共用兩種方法
:window.onload()
和$(document).ready()
bindReady: function() {
...
if ( document.addEventListener ) {
// Use the handy event callback
document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false );
// A fallback to window.onload, that will always work
window.addEventListener( "load", jQuery.ready, false );
// If IE event model is used
} else if ( document.attachEvent ) {
document.attachEvent( "onreadystatechange", DOMContentLoaded );
// A fallback to window.onload, that will always work
window.attachEvent( "onload", jQuery.ready );
複製程式碼
Facade
外觀模式大量應用於 jQuery
庫以讓其更容易被使用。譬如我們使用
jQuery
的$(el).css()
或 $(el).animate()
等方法 。
使我們不必手動在jQuery
核心中呼叫很多內部方法以便實現某些行為,也同時避免了手動與 DOM API
互動。
類似的還有
D3.js
3. 介面卡模式: Adapter Pattern
- 傳統:適配兩個及以上類介面不相容的問題
JS
: 可額外適配兩個及以上程式碼庫、前後端資料等。
使用時機 通常使用介面卡的情況:
- 需要整合新元件並與應用程式中的現有元件一起工作。
- 重構,程式的哪些部分用改進的介面重寫,但舊程式碼仍然需要原始介面。
1. jQuery.fn.css()
規範化顯示
// Cross browser opacity:
// opacity: 0.9; Chrome 4+, FF2+, Saf3.1+, Opera 9+, IE9, iOS 3.2+, Android 2.1+
// filter: alpha(opacity=90); IE6-IE8
// Setting opacity
$( ".container" ).css( { opacity: .5 } );
// Getting opacity
var currentOpacity = $( ".container" ).css('opacity');
複製程式碼
內部實現為:
get: function( elem, computed ) {
return ropacity.test( (
computed && elem.currentStyle ?
elem.currentStyle.filter : elem.style.filter) || "" ) ?
( parseFloat( RegExp.$1 ) / 100 ) + "" :
computed ? "1" : "";
},
set: function( elem, value ) {
var style = elem.style,
currentStyle = elem.currentStyle,
opacity = jQuery.isNumeric( value ) ?
"alpha(opacity=" + value * 100 + ")" : "",
filter = currentStyle && currentStyle.filter || style.filter || "";
style.zoom = 1;
// 如果將不透明度設定為1,則移除其他過濾器
//exist - attempt to remove filter attribute #6652
if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" ) {
style.removeAttribute( "filter" );
if ( currentStyle && !currentStyle.filter ) {
return;
}
}
// otherwise, set new filter values
style.filter = ralpha.test( filter ) ?
filter.replace( ralpha, opacity ) :
filter + " " + opacity;
}
};
複製程式碼
2. Vue
中的computed
yck - 《前端面試之道》
在 Vue
中,我們其實經常使用到介面卡模式。
比如父元件傳遞給子元件一個時間戳屬性,元件內部需要將時間戳轉為正常的日期顯示,一般會使用 computed
來做轉換這件事情,這個過程就使用到了介面卡模式。
4. 代理模式: Proxy Pattern
為其他物件提供一種代理以便控制對這個物件的訪問。
可以詳細控制訪問某個類(物件)的方法,在呼叫這個方法前作的前置處理(統一的流程程式碼放到代理中處理)。呼叫這個方法後做後置處理。例如:明星的經紀人,租房的中介等等都是代理
使用代理模式的意義是什麼?
-
“單一職責原則”:物件導向設計中鼓勵將不同的職責分佈到細粒度的物件中,Proxy 在原物件的基礎上進行了功能的衍生而又不影響原物件,符合鬆耦合高內聚的設計理念
-
遵循“開放-封閉原則”:代理可以隨時從程式中去掉,而不用對其他部分的程式碼進行修改,在實際場景中,隨著版本的迭代可能會有多種原因不再需要代理,那麼就可以容易的將代理物件換成原物件的呼叫。
特點:
- 解決系統之間的耦合度以及系統資源開銷大
- 通過代理物件可保護被代理的物件,使其擴充套件性不受外界的影響
- 在js中,它的執行常常依託於瀏覽器
- 事件代理就用到了代理模式。
分類:
- 遠端代理(
Remote Proxy
):為一個位於不同的地址空間的物件提供一個本地的代理物件 - 虛擬代理(
Virtual Proxy
):如果需要建立一個資源消耗較大的物件,先建立一個消耗相對較小的物件來表示,真實物件只在需要時才會被真正建立。 - 保護代理(
Protect Proxy
):控制對一個物件的訪問,可以給不同的使用者提供不同級別的使用許可權。 - 緩衝代理(
Cache Proxy
):為某一個目標操作的結果提供臨時的儲存空間,以便多個客戶端可以共享這些結果。 - 智慧引用代理(
Smart Reference Proxy
):當一個物件被引用時,提供一些額外的操作,例如將物件被呼叫的次數記錄下來等。
缺點::
-
由於在客戶端和真實主題之間增加了代理物件,因此有些型別的代理模式可能會造成請求的處理速度變慢,例如保護代理。
-
實現代理模式需要額外的工作,而且有些代理模式的實現過程較為複雜,例如遠端代理。
前端用得最多的是 虛擬代理、保護代理、緩衝代理
1. ES6
中的Proxy
ES6
所提供Proxy
建構函式能夠讓我們輕鬆的使用代理模式:
// target: 表示所要代理的物件,handler: 用來設定對所代理的物件的行為。
let proxy = new Proxy(target, handler);
複製程式碼
2. 圖片預載入
目前一般的網站都會有圖片預載入機制,也就是在真正的圖片在被載入完成之前用一張菊花圖(轉圈的gif圖片)表示正在載入圖片。
const img = new Image();
img.src = '/some/big/size/image.jpg';
document.body.appendChild(img);
複製程式碼
建立虛擬圖片節點virtualImg
並構造建立代理函式:
// 圖片懶載入: 虛擬代理
const createImgProxy = (img, loadingImg, realImg) => {
let hasLoaded = false;
const virtualImg = new Image();
virtualImg.src = realImg;
virtualImg.onload = () => {
Reflect.set(img, 'src', realImg);
hasLoaded = true;
}
return new Proxy(img, {
get(obj, prop) {
if (prop === 'src' && !hasLoaded) {
return loadingImg;
}
return obj[prop];
}
});
複製程式碼
最後是將原始的圖片節點替換為代理圖片進行呼叫:
const img = new Image();
const imgProxy = createImgProxy(img, '/loading.gif', '/some/big/size/img.jpg');
document.body.appendChild(imgProxy);
複製程式碼
3. 分頁資料:快取代理
如,前後端分離,向後端請求分頁的資料的時候,每次頁碼改變時都需要重新請求後端資料,我們可以將頁面和對應的結果進行快取,當請求同一頁的時候,就不再請求後端的介面而是從快取中去取資料。
const getFib = (number) => {
if (number <= 2) {
return 1;
} else {
return getFib(number - 1) + getFib(number - 2);
}
}
const getCacheProxy = (fn, cache = new Map()) => {
return new Proxy(fn, {
apply(target, context, args) {
const argsString = args.join(' ');
if (cache.has(argsString)) {
// 如果有快取,直接返回快取資料 console.log(`輸出${args}的快取結果: ${cache.get(argsString)}`);
return cache.get(argsString);
}
const result = fn(...args);
cache.set(argsString, result);
return result;
}
})
}
const getFibProxy = getCacheProxy(getFib);
getFibProxy(40); // 102334155getFibProxy(40); // 輸出40的快取結果: 102334155
複製程式碼
4. 事件代理
事件代理就用到了代理模式。
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
let ul = document.querySelector('#ul')
ul.addEventListener('click', (event) => {
console.log(event.target);
})
</script>
複製程式碼
通過給父節點繫結一個事件,讓父節點作為代理去拿到真實點選的節點。
5. 裝飾者模式: Decorator Pattern
在不改變原物件的基礎上,通過對其進行包裝擴充(新增屬性或者方法)使原有物件可以滿足使用者更復雜的需求
裝飾器類似於高階函式的概念。裝飾器將基本形式作為引數,並在其上新增處理並將其返回。 優點:
- 優點是把類(函式)的核心職責和裝飾功能區分開了。
問題:
- 裝飾鏈疊加了函式作用域,如果過長也會產生效能問題。
在JavaScript
中:
- 裝飾者模式提供比繼承更有彈性的替代方案。
- 裝飾者用於包裝同介面的物件,用於通過過載方法的形式新增新功能,該模式可以在被裝飾者的前面或後面加上自己的行為以達到特定的目的。
核心就是快取上一次的函式
1. 簡單例子
舉一個簡單的例子:
var xiaoming = function () {
this.run = function () {
return '跑步'
},
this.eat = function () {
return: '吃飯'
}
}
// 小明可以跑步,也可以吃飯
// 下面是一個裝飾類,給小明進行裝飾
var decor = function (xiaoming) {
this.run = function () {
return xiaoming.run + '很快'
}
this.eat = function () {
return xiaoming.eat + '很多'
}
}
複製程式碼
通過一個裝飾類,實現了對小明類的裝飾。
2. TypeScript
函式修飾符: @
“@”,與其說是修飾函式倒不如說是引用、呼叫它修飾的函式。
或者用句大白話描述:@: "下面的被我包圍了。"
舉個例子,下面的一段程式碼,裡面兩個函式,沒有被呼叫,也會有輸出結果:
test(f){
console.log("before ...");
f()
console.log("after ...");
}
@test
func(){
console.log("func was called");
}
複製程式碼
直接執行,輸出結果:
before ...
func was called
after ...
複製程式碼
3. React
中的裝飾器模式
在React
中,裝飾器模式隨處可見:
import React, { Component } from 'react';
import {connect} from 'react-redux';
class App extends Component {
render() {
//...
}
}
// const mapStateToProps
// const actionCreators
export default connect(mapStateToProps,actionCreators)(App);
複製程式碼
Ant Design
中建立表單的最後一步其實也算裝飾器模式
class CustomizedForm extends React.Component {}
CustomizedForm = Form.create({})(CustomizedForm);
複製程式碼
6. 橋接模式:Bridge Pattern
橋接模式將實現層與抽象次層解耦分離,使兩部分可以獨立變化。
該模式包含如下角色:
Abstraction
(抽象類)RefinedAbstraction
(擴充抽象類)Implementor
(實現類介面)ConcreteImplementor
(具體實現類)
應用程式寫入定義的資料庫API,例如ODBC
,但在此API之後,會發現每個驅動程式的實現對於每個資料庫供應商(SQL Server,MySQL,Oracle
等)都是完全不同的。
- 多見於驅動程式開發,在
JavaScript
中很少見。 - 一些軟體的跨平臺設計有時候也是應用了橋接模式
1. 網站主題替換
在大型網站中,不同模組可能會有不同主題,也有分白天/黑夜 或 使用者自主選擇的主題。
這時為每個主題建立每個頁面的多個副本明顯不合理,而橋接模式是更好的選擇:
不同模組:
class About{
constructor(theme) {
this.theme = theme
}
getContent() {
return "About page in " + this.theme.getColor()
}
}
class Careers{
constructor(theme) {
this.theme = theme
}
getContent() {
return "Careers page in " + this.theme.getColor()
}
}
複製程式碼
以及不同主題:
class DarkTheme{
getColor() {
return 'Dark Black'
}
}
class LightTheme{
getColor() {
return 'Off white'
}
}
class AquaTheme{
getColor() {
return 'Light blue'
}
}
複製程式碼
生成主題:
const darkTheme = new DarkTheme()
const about = new About(darkTheme)
const careers = new Careers(darkTheme)
console.log(about.getContent() )// "About page in Dark Black"
console.log(careers.getContent() )// "Careers page in Dark Black"
複製程式碼
7. 組合模式: Composite Pattern
- 又稱 部分-整體模式,將物件組合成樹形結構以表示“部分整體”的層次結構。
- 使得使用者對單個物件和組合物件的使用具有一致性。(參考卡片和表單組成)
該模式包含以下角色:
Component
- 宣告組合中物件的介面並實現預設行為(基於Composite
)Leaf
- 表示合成中的原始物件Composite
- 在Component
介面中實現與子相關的操作,並儲存Leaf(primitive)
物件。
1. 作業系統中的檔案目錄結構
計算機檔案結構是組合模式的一個例項。
如果你刪除某個資料夾,也將刪除該資料夾的所有內容,是嗎? 這實質上就是組合模式執行原理。 你你可以呼叫結構樹上較高層次的組合物件,訊息將沿這一層次結構向下傳輸。
2. 批量操作DOM
HTML
文件的DOM
結構就是天生的樹形結構,最基本的元素醉成DOM樹,最終形成DOM
文件,非常適用適用組合模式。
我們常用的jQuery
類庫,其中組合模式的應用更是頻繁,例如經常有下列程式碼實現:
$(".test").addClass("noTest").removeClass("test");
複製程式碼
不論$(“.test”)
是一個元素,還是多個元素,最終都是通過統一的addClass
和removeClass
介面進行呼叫。
我們簡單模擬一下addClass
的實現:
var addClass = function (eles, className) {
if (eles instanceof NodeList) {
for (var i = 0, length = eles.length; i < length; i++) {
eles[i].nodeType === 1 && (eles[i].className += (' ' + className + ' '));
}
}
else if (eles instanceof Node) {
eles.nodeType === 1 && (eles.className += (' ' + className + ' '));
}
else {
throw "eles is not a html node";
}
}
addClass(document.getElementById("div3"), "test");
addClass(document.querySelectorAll(".div"), "test");
複製程式碼
對於NodeList
或者是Node
來說,客戶端呼叫都是同樣的使用了addClass
這個介面,這個就是組合模式的最基本的思想,使部分和整體的使用具有一致性。
8. 享元模式:Flyweight Pattern
享元(flyweight
)模式是一種用於效能優化的模式,“fly
”在這裡是蒼蠅的意思,意為蠅量級。
- 主要用於減少建立物件的數量,以減少記憶體佔用和提高效能
- 運用共享技術來有效支援大量細粒度的物件
享元模式的核心是運用共享技術來有效支援大量細粒度的物件。
如果系統中因為建立了大量類似的物件而導致記憶體佔用過高,享元模式就非常有用了。在JavaScript
中,瀏覽器特別是移動端的瀏覽器分配的記憶體並不算多,如何節省記憶體就成了一件非常有意義的事情。
享元模式有以下角色:
- 客戶端:用來呼叫享元工廠來獲取內在資料的類,通常是應用程式所需的物件,
- 享元工廠:用來維護享後設資料的類
- 享元類:保持內在資料的類
1. 簡單例子
在下面的例子中,我們建立了一個“Book”類來處理有關特定書籍,然後建立一個“BookFactory
”類來控制如何建立這些Book物件。
為了獲得更好的記憶體效能,如果同一物件被例項化兩次,則會重用這些物件。
class Book {
constructor(title, isbn, author, ratings) {
this.title = title;
this.isbn = isbn;
this.author = author;
this.ratings = ratings;
}
getAverageReview() {
let averageReview = (this.ratings.reduce((a,b) => a+b)) / this.ratings.length
return averageReview;
}
}
class BookFactory {
constructor() {
this._books = [];
}
createBook(title, isbn, author, ratings) {
let book = this.getBookBy(isbn);
if (book) { //重用物件
return book;
} else {
const newBook = new Book(title, isbn, author, ratings);
this._books.push(newBook);
return newBook;
}
}
getBookBy(attr) {
return this._books.find(book => book.attr === attr);
}
}
複製程式碼
2. 線上表格思路實現
開啟谷歌線上表格,提取列印其節點元素。
可以看到就算是滾動至千行,它們都只是共用兩個檢視。
用的就是享元模式,來防止無限滾動造成卡頓。
以下是模擬實現:
首先是HTML<section id="app">
<table id="table"></table>
<div class="controls">
<input type="range" name="scroll" id="scroll" value="0">
</div>
</section>
複製程式碼
樣式:
#app {
position: relative;
padding: 30px 0 30px 10px;
#table {
padding: 20px;
border-radius: 10px;
min-width: 450px;
transition: background 0.5s;
background: rgba(73, 224, 56, 0.1);
&.low-range {
background: rgba(73, 224, 56, 0.47);
td {
border-bottom: 1px solid rgba(73, 224, 56, 0.9)
}
}
&.mid-range {
background: rgba(224, 196, 56, 0.47);
td {
border-bottom: 1px solid rgba(224, 196, 56, 0.9)
}
}
&.high-range {
background: rgba(224, 56, 56, 0.47);
td {
border-bottom: 1px solid rgba(224, 56, 56, 0.9)
}
}
&.ultra-high-range {
background: rgba(224, 56, 56, 0.9);
td {
border-bottom: 1px solid black
}
}
td {
border-bottom: 1px solid black;
padding: 10px;
font-weight: bold;
}
}
.controls {
padding-top: 20px;
#scroll {
width: 450px;
box-sizing: border-box;
}
}
}
複製程式碼
邏輯實現,請配合註釋食用:
// 生成單元格例項
const makeRowCells = data => data.map(value => new Cell(value));
// 定義常量
const scrollViewport = 10; // 當前表格檢視大小
const tableSize = 2000; // 行數
let scrollIndex = 0; // 初始滾動索引
let DATA = []; // 初始資料集
while (DATA.length < scrollViewport) {
const unit = DATA.length * 10;
DATA.push('12345678'.split('').map(() => unit));
}
/**
* cell類 - 列
*/
class Cell {
constructor(content) {
this.content = content;
}
// 更新列
updateContent(content) {
this.content = content;
this.cell.innerText = content;
}
// 渲染列
render() {
const cell = document.createElement('td');
this.cell = cell;
cell.innerText = this.content;
return cell;
}
}
/**
* row類 - 行
*/
class Row {
constructor(cellItems) {
this.cellItems = cellItems;
}
// 更新行
updateRowData(newData) {
this.cellItems.forEach((item, idx) => {
item.updateContent(newData[idx]);
});
}
// 渲染行
render() {
const row = document.createElement('tr');
this.cellItems.forEach(item => row.appendChild(item.render()));
return row;
}
}
/**
* 表格類
*/
class Table {
constructor(selector) {
this.$table = document.querySelector(selector);
}
// 新增行
addRows(rows) {
this.rows = rows;
this.rows.forEach(row => this.$table.appendChild(row.render()));
}
// 更新table資料
updateTableData(data) {
this.rows.forEach((row, idx) => row.updateRowData(data[idx]));
}
}
// 例項化新表
const table = new Table('#table');
// 匹配滾動條的DOM
const scrollControl = document.querySelector('#scroll');
// 在table下新增單元格行
table.addRows(
DATA.map(dataItem => new Row(makeRowCells(dataItem))));
const onScrollChange = event => {
// 為檢視準備新資料
DATA = DATA.map((item, idx) => item.map(cell => parseInt(event.target.value, 10)*10 + idx*10));
// 更新當前table的資料
table.updateTableData(DATA);
// 新增顏色區別樣式
scrollIndex = event.target.value;
if (event.target.value >= 0) {
table.$table.classList = 'low-range';
}
if (event.target.value > tableSize * 0.4) {
table.$table.classList = 'mid-range';
}
if (event.target.value > tableSize * 0.7) {
table.$table.classList = 'high-range';
}
if (event.target.value > tableSize * 0.9) {
table.$table.classList = 'ultra-high-range';
}
};
// 設定滾動條最小和最大範圍
scrollControl.setAttribute('min', 0);
scrollControl.setAttribute('max', tableSize);
// 新增滾動事件
scrollControl.addEventListener('input', onScrollChange);
// 初始化事件
const event = {target: {value: 0}};
onScrollChange(event);
複製程式碼
9. 結語及參考
至此,結構型設計模式已經講(水)完了,其中享元模式值得單獨拿出來寫一篇部落格。
參考文章- JavaScript 設計模式精講
- Javascript設計模式理論與實戰:享元模式
- Easy patterns: Flyweight
- Composite design pattern
- Javascript設計模式理論與實戰:組合模式
- yck - 《前端面試之道》
❤️ 看完三件事
如果你覺得這篇內容對你挺有啟發,我想邀請你幫我三個小忙:
- 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
- 關注公眾號「前端勸退師」,不定期分享原創知識。
- 也看看其它文章
- 那些你不經意間使用的設計模式(一) - 建立型模式
- 「資料視覺化庫王者」D3.js 極速上手到Vue應用
- 「真®全棧之路」Web前端開發的後端指南
- 「Vue實踐」5分鐘擼一個Vue CLI 外掛
- 「Vue實踐」武裝你的前端專案
- 「中高階前端面試」JavaScript手寫程式碼無敵祕籍
- 「從原始碼中學習」面試官都不知道的Vue題目答案
- 「從原始碼中學習」Vue原始碼中的JS騷操作
- 「Vue實踐」專案升級vue-cli3的正確姿勢
- 為何你始終理解不了JavaScript作用域鏈?
公眾號後臺回覆「設計模式」 領取作者精心自制的思維導圖。