JavaScript 設計模式

大家好,在這篇文章中我將介紹設計模式是什麼以及爲什麼很重要。

我還將介紹一些最流行的設計模式,併爲每一種模式舉例說明。讓我們開始吧!

目錄

什麼是設計模式

設計模式這個概念是由一本名爲《設計模式:可複用面向對象軟件的基礎》的書推廣而來,這本書在 1994 年由四個 C++ 工程師編寫的。

這本書探討了面向對象的編程的能力和陷阱,並介紹了 23 種可以用來解決編程問題的模式。

這些模式並不是算法或者具體的實現。它們更像是想法、觀點和抽象,輔助你去解決一些特定問題。

根據要素的不同模式的實現也各不相同,重要的是模式背後的概念,它可以幫助我們更好地解決問題。

話雖如此,但是請記住,這些模式建立在 C++ 的 OOP 的基礎之上,當使用更現代的編程語言如 JavaScript 時,模式可能不等效,甚至給代碼添加了不必要的樣本。

不過把這些模式當作一般的編程知識來了解沒有壞處。

旁註:如果你不熟悉編程範式或者 OOP,推薦你閱讀我最近寫的這兩篇文章。😉

設計模式的簡介就到這裏。設計模式可以被分爲三大類:創建、結構、行爲範例。讓我們逐個瞭解。🧐

創建範例

創建範例包括不同的創建對象的機制。

單例模式

單例模式確保對象的類只有一個不可更改實例。簡言之,單例模式包含一個不能被複制和修改的對象。當你希望應用遵循 “真理的單點性” 的觀點時,這個模式就能發揮作用。

比方說,我們想在一個單一對象中包含應用程序的所有配置,而且禁止對該對象進行任何複製或修改。

可以通過對象字面量和類這兩種方法來實現:

const Config = {
  start: () => console.log('App has started'),
  update: () => console.log('App has updated'),
}

// 通過凍結對象來限制增加新的屬性或者修改已有屬性
Object.freeze(Config)

Config.start() // "App has started"
Config.update() // "App has updated"

Config.name = "Robert" // 嘗試添加一個新的鍵
console.log(Config) // 添加失敗: { start: [Function: start], update: [Function: update] }

使用對象的字面量

class Config {
    constructor() {}
    start(){ console.log('App has started') }  
    update(){ console.log('App has updated') }
}
  
const instance = new Config()
Object.freeze(instance)

使用類

工廠方法

工廠方法提供創建對象的接口,對象被創建後可以修改。這樣做的好處是,創建對象的邏輯集中在一個地方,這樣簡化了代碼,使得代碼更易組織。

這種模式被大量應用。可以通過類和工廠函數(返回對象的函數)來實現:

class Alien {
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

const alien1 = new Alien("Ali""I'm Ali the alien!")
console.log(alien1.name) // 輸出:"Ali"

使用類

function Alien(name, phrase) {
    this.name = name
    this.phrase = phrase
    this.species = "alien"
}

Alien.prototype.fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
Alien.prototype.sayPhrase = () => console.log(this.phrase)

const alien1 = new Alien("Ali""I'm Ali the alien!")

console.log(alien1.name) // 輸出 "Ali"
console.log(alien1.phrase) // 輸出 "I'm Ali the alien!"
alien1.fly() // 輸出 "Zzzzzziiiiiinnnnnggggg"

使用工廠函數

抽象工廠

抽象工廠允許在不指定具體類的情況下生成一系列相關的對象。當你想要創建僅共享某些屬性和方法的對象時,抽象工廠模式就可以派上用場。

它的工作方式是給客戶端提供一個可以交互的抽象工廠。抽象工廠通過特定邏輯調用具體工廠,具體工廠返回最終的對象。

這樣做給工廠模式添加了一個抽象層,我們通過僅和單個工廠函數或者類交互來創建各種不同類型的對象。

讓我們來看幾個例子。假設我們是汽車公司,我們除了生產小汽車以外,還生產摩托車和卡車。

// 每個汽車種類有一個類或者“具體工廠”
class Car {
    constructor () {
        this.name = "Car"
        this.wheels = 4
    }
    turnOn = () => console.log("Chacabúm!!")
}

class Truck {
    constructor () {
        this.name = "Truck"
        this.wheels = 8
    }
    turnOn = () => console.log("RRRRRRRRUUUUUUUUUMMMMMMMMMM!!")
}

class Motorcycle {
    constructor () {
        this.name = "Motorcycle"
        this.wheels = 2
    }
    turnOn = () => console.log("sssssssssssssssssssssssssssssshhhhhhhhhhham!!")
}

// 抽象工廠作爲單一交互點和客戶端交互
// 接受特定汽車類型作爲參數,調用對應類型的具體工廠
const vehicleFactory = {
    createVehicle: function (type) {
        switch (type) {
            case "car":
                return new Car()
            case "truck":
                return new Truck()
            case "motorcycle":
                return new Motorcycle()
            default:
                return null
        }
    }
}

const car = vehicleFactory.createVehicle("car") // Car { turnOn: [Function: turnOn], name: 'Car', wheels: 4 }
const truck = vehicleFactory.createVehicle("truck") // Truck { turnOn: [Function: turnOn], name: 'Truck', wheels: 8 }
const motorcycle = vehicleFactory.createVehicle("motorcycle") // Motorcycle { turnOn: [Function: turnOn], name: 'Motorcycle', wheels: 2 }

構造器

構造器模式分 “步驟” 創建對象。通常我們通過不同的函數和方法向對象添加屬性和方法。

構造器的好處在於通過不同實體分開創建屬性和方法。

通過類或者構造函數創建的實例通常繼承了所有的屬性和方法,但是如果使用構造器,我們可以只應用我們需要的 “步驟” 來創建對象,這樣就更靈活。

這個概念和對象組合相關, 我在這篇‍文章討論過這個話題。

// 聲明一個對象
const bug1 = {
    name: "Buggy McFly",
    phrase: "Your debugger doesn't work with me!"
}

const bug2 = {
    name: "Martiniano Buggland",
    phrase: "Can't touch this! Na na na na..."
}

// 這些函數將對象作爲參數,併爲對象添加方法
const addFlyingAbility = obj ={
    obj.fly = () => console.log(`Now ${obj.name} can fly!`)
}

const addSpeechAbility = obj ={
    obj.saySmthg = () => console.log(`${obj.name} walks the walk and talks the talk!`)
}

// 最後傳入對象作爲參數,調用構造器函數
addFlyingAbility(bug1)
bug1.fly() // 輸出: "Now Buggy McFly can fly!"

addSpeechAbility(bug2)
bug2.saySmthg() // 輸出: "Martiniano Buggland walks the walk and talks the talk!"

原型

原型允許把一個對象作爲藍圖創建另一個對象,新對象繼承原對象的屬性和方法。

如果你已經使用過一段時間的 JavaScript,你應該對原型繼承有一定了解。

原型鏈繼承的結果和使用類相似,只是更爲靈活,因爲屬性和方法可以不通過同一個類在對象之間共享。

// 聲明一個有兩個方法的原型對象
const enemy = {
    attack: () => console.log("Pim Pam Pum!"),
    flyAway: () => console.log("Flyyyy like an eagle!")
}

// 聲明另外一個對象,這個對象將繼承原型
const bug1 = {
    name: "Buggy McFly",
    phrase: "Your debugger doesn't work with me!"
}

// 使用setPrototypeOf設置對象的原型
Object.setPrototypeOf(bug1, enemy)

// 使用getPrototypeOf來確認我們是否設置成功
console.log(Object.getPrototypeOf(bug1)) // { attack: [Function: attack], flyAway: [Function: flyAway] }

console.log(bug1.phrase) // Your debugger doesn't work with me!
console.log(bug1.attack()) // Pim Pam Pum!
console.log(bug1.flyAway()) // Flyyyy like an eagle!

結構範例

結構範例將對象和類組合成更大的結構。

適配器

適配器允許兩個接口不兼容的對象相互交互。

假設你的應用程序調用一個 API 並會返回一個 XML,然後將結果發送給另一個 API 來處理信息,但是處理信息的 API 期待的是 JSON 格式。因爲格式不兼容,所以你不能直接發送信息,需要先適配結果。😉

我們可以舉一個更簡單的例子來具象化這個概念。假設我們有一個以城市爲元素的數組,以及一個可以返回擁有最多人口城市的函數。數組中的城市人口以百萬爲單位計數,但是有一個新城市的人口單位不是百萬:

// 城市數組
const citiesHabitantsInMillions = [
    { city: "London", habitants: 8.9 },
    { city: "Rome", habitants: 2.8 },
    { city: "New york", habitants: 8.8 },
    { city: "Paris", habitants: 2.1 },
] 

// 待添加的新城市
const BuenosAires = {
    city: "Buenos Aires",
    habitants: 3100000
}

// 適配器函數將城市的人口屬性轉換成統一的計數單位
const toMillionsAdapter = city ={ city.habitants = parseFloat((city.habitants/1000000).toFixed(1)) }

toMillionsAdapter(BuenosAires)

// 將新城市添加到數組
citiesHabitantsInMillions.push(BuenosAires)

// 函數返回人口最多的城市
const MostHabitantsInMillions = () ={
    return Math.max(...citiesHabitantsInMillions.map(city => city.habitants))
}

console.log(MostHabitantsInMillions()) // 8.9

裝飾

裝飾通過增加一個修飾對象來包裹原來的對象,從而給原來的對象添加新的行爲。如果你熟悉 React 或者高階組件(HOC),你內心的小鈴鐺可能會叮噹一下。

從技術上講,React 中的組件是函數而不是對象。但如果你仔細思索 React 上下文(React Context)或者 Memo 是怎麼運作的,你會發現我們將組件作爲子組件傳入 HOC 後,子組件可以訪問某些功能。

在下面的例子裏中 ContextProvider 組件接受子組件作爲 prop:

import { useState } from 'react'
import Context from './Context'

const ContextProvider: React.FC = ({children}) ={

    const [darkModeOn, setDarkModeOn] = useState(true)
    const [englishLanguage, setEnglishLanguage] = useState(true)

    return (
        <Context.Provider value={{
            darkModeOn,
            setDarkModeOn,
            englishLanguage,
            setEnglishLanguage
        }} >
            {children}
        </Context.Provider>
    )
}

export default ContextProvider

然後我們包裹整個應用:

export default function App() {
  return (
    <ContextProvider>
      <Router>

        <ErrorBoundary>
          <Suspense fallback={<></>}>
            <Header />
          </Suspense>

          <Routes>
              <Route path='/' element={<Suspense fallback={<></>}><AboutPage /></Suspense>}/>

              <Route path='/projects' element={<Suspense fallback={<></>}><ProjectsPage /></Suspense>}/>

              <Route path='/projects/helpr' element={<Suspense fallback={<></>}><HelprProject /></Suspense>}/>

              <Route path='/projects/myWebsite' element={<Suspense fallback={<></>}><MyWebsiteProject /></Suspense>}/>

              <Route path='/projects/mixr' element={<Suspense fallback={<></>}><MixrProject /></Suspense>}/>

              <Route path='/projects/shortr' element={<Suspense fallback={<></>}><ShortrProject /></Suspense>}/>

              <Route path='/curriculum' element={<Suspense fallback={<></>}><CurriculumPage /></Suspense>}/>

              <Route path='/blog' element={<Suspense fallback={<></>}><BlogPage /></Suspense>}/>

              <Route path='/contact' element={<Suspense fallback={<></>}><ContactPage /></Suspense>}/>
          </Routes>
        </ErrorBoundary>

      </Router>
    </ContextProvider>
  )
}

接着,我們使用useContext鉤子,使得應用內所有組件都可以獲得定義在 Context 的狀態(state):

const AboutPage: React.FC = () ={

    const { darkModeOn, englishLanguage } = useContext(Context)
    
    return (...)
}

export default AboutPage

這個例子可能不是書的作者在寫這個模式時想到的確切實現,但我相信想法是一樣的:把一個對象放在另一個對象中,這樣它就可以訪問某些功能。;)

外觀

外觀模式給庫、框架以及其他複雜的類集提供簡化的接口。

嗯…… 我們可以舉的例子非常多,不是嗎?React 本身以及各種各樣的軟件開發相關的庫就是基於這個模式。特別是當你思考聲明式編程,會發現這個範式就是使用抽象的方法對開發者隱藏複雜性。

JavaScript 中的 mapsortreducefilter函數都是很好的例子,這些函數的背後其實是我們的老朋友for循環。

另一個例子是一些 UI 庫,如:MUI。正如以下示例所展現的這樣,庫提供了組件,組件帶來了內置特性和功能,幫助我們更快、更輕鬆地構建代碼。

這些代碼最後都會編譯成簡單的 HTML 元素,這是瀏覽器唯一能理解的東西。組件只是採用了抽象的辦法,使得我們的編碼過程更容易。

一個外觀模式......

import * as React from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

function createData(
  name: string,
  calories: number,
  fat: number,
  carbs: number,
  protein: number,
) {
  return { name, calories, fat, carbs, protein };
}

const rows = [
  createData('Frozen yoghurt', 159, 6.0, 24, 4.0),
  createData('Ice cream sandwich', 237, 9.0, 37, 4.3),
  createData('Eclair', 262, 16.0, 24, 6.0),
  createData('Cupcake', 305, 3.7, 67, 4.3),
  createData('Gingerbread', 356, 16.0, 49, 3.9),
];

export default function BasicTable() {
  return (
    <TableContainer component={Paper}>
      <Table sx={{ minWidth: 650 }} aria-label="simple table">
        <TableHead>
          <TableRow>
            <TableCell>Dessert (100g serving)</TableCell>
            <TableCell align="right">Calories</TableCell>
            <TableCell align="right">Fat (g)</TableCell>
            <TableCell align="right">Carbs (g)</TableCell>
            <TableCell align="right">Protein (g)</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {rows.map((row) =(
            <TableRow
              key={row.name}
              sx={{ '&:last-child td, &:last-child th'{ border: 0 } }}
            >
              <TableCell component="th" scope="row">
                {row.name}
              </TableCell>
              <TableCell align="right">{row.calories}</TableCell>
              <TableCell align="right">{row.fat}</TableCell>
              <TableCell align="right">{row.carbs}</TableCell>
              <TableCell align="right">{row.protein}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
}

代理

代理模式爲另一個對象提供替代或者佔位符。這個想法是控制對原始對象的訪問,當請求到達實際的原始對象之前或者之後再執行某種操作。

如果你熟悉 ExpressJS 的話,這個概念就不陌生。Express 是用於開發 NodeJS API 的框架,其中一個功能就是中間件的使用。中間件是我們可以在請求到達終點之前、之中和之後執行的一段代碼。

讓我們看一個例子。是一個驗證身份令牌的函數,不用太關注驗證是如何實現的,但是要注意函數接受令牌作爲參數,一旦驗證完畢就會調用next()函數。

const jwt = require('jsonwebtoken')

module.exports = function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]
  
    if (token === null) return res.status(401).send(JSON.stringify('No access token provided'))
  
    jwt.verify(token, process.env.TOKEN_SECRET, (err, user) ={
      if (err) return res.status(403).send(JSON.stringify('Wrong token provided'))
      req.user = user
      next()
    })
}

這個函數就是一箇中間件,我們可以 API 中的任意終點使用這個中間件。只需要將其添加在終點地址之後,終點的函數聲明之前:

router.get('/:jobRecordId', authenticateToken, async (req, res) ={
  try {
    const job = await JobRecord.findOne({_id: req.params.jobRecordId})
    res.status(200).send(job)

  } catch (err) {
    res.status(500).json(err)
  }
})

如果沒有提供令牌或者提供了錯誤的令牌,中間件就會返回相應的錯誤響應。如果提供了有效令牌,中間件將調用next()函數,然後將執行終點函數。

我們可以在終點內部編寫相同的代碼來驗證令牌,這樣就用不着中間件了,但使用了抽象的方法,我們可以在不同的終點複用中間件。😉

同樣這個例子可能不是作者的確切想法,但我相信這是一個有效的例子。我們控制對象的訪問,以便我們可以在特定時刻執行操作。

行爲範式

行爲範式控制不同對象之間的通訊。

責任鏈

責任鏈將請求通過處理鏈傳遞,鏈條上的每一個處理程序決定要麼處理請求,要麼將請求傳遞給鏈條上的下一個處理程序。

我們可以使用之前示例來演示這個模式,因爲 Express 的中間件就是一種處理程序,要麼處理請求,要麼將其傳遞給下一個處理程序。

如果你想要另一個示例,可以考慮任何需要通過步驟來一步一步實現信息處理的系統。在每個步驟中,不同的實體負責執行操作,並且只有在滿足特定條件時,信息纔會傳遞給另一個實體。

需要使用 API 的前端應用程序就是很好的例子:

可以看到這裏有許多不同的實體協作執行任務。每個都負責該任務的一個 “步驟”,這有助於代碼模塊化和關注點分離。👌👌

迭代器

迭代器用於遍歷集合的元素。這在現代編程語言中顯得微不足道,但並非如此。

JavaScript 內置函數(forforEachfor...offor...inmapreducefilter等)就是手邊可以拿來遍歷數據結構的方法。

遍歷算法以及更爲複雜的樹和圖這樣的數據結構使用的代碼也是迭代器的例子。

觀察者

觀察者模式允許你定義一個訂閱機制來通知多個對象它們正在觀察的對象發生的任何事件。基本上,這就像在給定對象上有一個事件偵聽器,當該對象執行我們正在偵聽的操作時,我們會採取一些行動。

React 的 useEffect 鉤子就是一個很好的例子。useEffect 在我們聲明的那一刻執行給定的函數。

鉤子分爲兩個主要部分:可執行函數和依賴數組。如果數組爲空,如下例所示,每次渲染組件時都會執行該函數。

  useEffect(() ={ console.log('The component has rendered') }[])

如果在依賴數組中聲明任何變量,則該函數將僅在這些變量發生變化時執行。

  useEffect(() ={ console.log('var1 has changed') }[var1])

也可以將 JavaScript 的事件監聽器視爲觀察者模式。另外,響應式編程和庫如 RxJS,用來處理異步信息和事件的方法也是這個模式。

總結

如果你想了解更多相關信息,推薦觀看這個視頻或訪問這個網站,你可以找到每個模式的配圖詳細介紹。


原文鏈接:https://www.freecodecamp.org/news/javascript-design-patterns-explained/

作者:Germán Cocca

譯者:PapayaHUANG

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uruxNqjKQ7YUDIXOA3p4Zw