[2022]透過製作一個倒數計時器,來認識前端的邏輯複用吧!

Brian Lai
13 min readNov 18, 2023

--

這篇是舊文,只是因為之前的 blog 不用了,搬來 Medium 。

這篇文章本來是公司內部分享用的簡報,當初設計的面向是給 PM / 後端 / 設計… 等非前端的工程面向職位看的,但我覺得只發表一次做了 20 幾頁的簡報有點浪費,所以就順便寫成一篇文了。

主要目的是簡單介紹一下前端工程師在面對一個新的需求時會怎麼處理,也側面說明為什麼有些看起來簡單的東西,前端工程師在考慮到日後的複用性時,會花比較久的時間做開發。

什麼是邏輯複用?為何要進行邏輯複用?

在進到今天的主題之前,讓我們先來做一下名詞解釋,首先什麼是邏輯複用呢?

邏輯複用從字面上解釋其實很簡單,就是重複使用邏輯,而重複使用邏輯,可以有以下的好處:

  • 不用一直寫重複的 code,以後要用到時不用再重想一次
  • 也可以分享給別人使用,節省工程師的時間成本
  • 如果是你 JavaScript 的工程師,那你一定要知道 Lodash

舉例來說,你是一位很有個性的餐廳老闆,遇到正常的客人你都會準時出餐,而遇到奧客時,你會故意延遲 ( delay )個 30 秒出餐;結帳時也是如此,你遇到正常客人時會準時幫他們結帳,但是遇到奧客時,也會再故意 delay 個 10 秒。

這時我們想像中的 code 可能長這樣:

// 送餐
if (isBadCustomer) await delay(30000)
sendMeal()

// 結帳
if (isBadCustomer) await delay(10000)
check()

這時你就會發現,你有個共同的邏輯叫做 delay,這時候就應該將這個邏輯抽離出來,製作成一個 function,如此一來就只要寫一次這個 delay function,然後兩邊就可以共用,讓程式更簡潔更好管理。

// ./delay.ts

function delay(millisecond: number) {
return new Promise((resolve) => {
setTimeout(resolve, millisecond)
})
}

除了在你工作的餐廳中使用之外,之後你在跟新認識的對象搞曖昧時,也可以把之前為餐廳寫好的 delay 邏輯拿出來使用,慢一點回覆對方、營造自己很忙很有行情,不用再重寫一次 code,而這就是邏輯複用的好處。

// ./dating.ts
import delay from './delay'

if(isReceivedText) await delay("3 days")
responseText()

前端如何進行邏輯複用?

好了,那如果你知道邏輯複用是什麼了之後,接著就讓我們進到本次的主題「前端如何進行邏輯複用」吧!

剛剛我們已經解釋了邏輯複用是什麼,所以接著就讓我們 focus 在現在的前端領域,是如何進行邏輯複用的,接下來我會以一個常見的倒數計時器功能為例子,然後使用 3 種不同的做法循序漸進完成這個作品:

  • Version 1:Hardcore HTML
  • Version 2:利用 JavaScript 動態產生
  • Version 3:前端框架 hook ( React ) / composable ( Vue ) 概念

需求:PM 要求你做一個倒數計時器

相信大家應該在一些網站註冊時都有過收驗證信的經驗,就是點下按鈕之後,會開始倒數 60 秒,然後叫你去收信,填寫信件中的數字,然後才可以進行註冊,目的是為了確認你真的是這個 email 的本人。

所以這件事情其實是很常見的,Google 要驗證、Facebook 要驗證、蝦皮也要驗證,於是聰明的你應該想到了,這是一個可以複用的邏輯,不過為了不破接下來的梗,就先請你記在心裡就好。

想像你現在是一位剛入職的前端工程師,通常你的 Team 都是有一位 PM ( Product/Project Manager ) 來分派任務給你的。

總之,你家 PM 看到大家都有做這個功能之後也有樣學樣,希望你也為公司的網站做一個一樣的倒數計時按鈕。

( 本公司 PM 就長這樣 )

Version 1:阿不就直接寫個 HTML 就好了

這時你應該會心想:「蛤?就這?這也太簡單了吧,阿不就直接寫段 JS 在 HTML 裡面就好了?」,所以你用了 5 分鐘寫出了以下的 code,並且信心滿滿地交差了:

  <body>
<div id="counter">
<button>60</button>
</div>
<script>
const button = document.querySelector('#counter > button')
const initalCounter = parseInt(button.innerText)

let interval
let counter = initalCounter

interval = setInterval(() => {
if (counter <= 0) {
return clearInterval(interval)
}
counter -= 1
button.innerHTML = counter
}, 1000)
</script>
</body>

新的需求:PM 表示,別的專案也要使用

PM 看到之後覺得很滿意,他說:「嗯嗯跟我要的東西完全一致,但公司還有另一個專案要用到一樣的功能,可以請你也加進去嗎?」

打開新專案後你突然就面有難色了,因為你知道你這段 code 的 JavaScript 是綁定在特定的 HTML 元素之下 ( button ),但新專案運用了某種技術,為了保持專案架構的完整,總之前輩不准你在裡面寫 html,只允許你插入一行 HTML 以及引入一隻 JS 檔案。

Version 2:ok,那我用 JavaScript 動態產生 DOM 元素

山不轉路轉,這時你想:「既然不許我寫 HTML,那我只要用 JavaScript 動態產生 我缺少的 DOM 元素後,在掛上去指定的 DOM 元素不就好了嗎?」。

於是你寫出了以下的 code:

<body>
<div id="counter"></div>
<script type="module" src="/counter.js"></script>
</body>
main()

function main() {
const counterElement = document.querySelector('#counter')
createCounter(counterElement)
}

function createCounter(element, options = {}) {
const { initialCount = 60, interval = 1000 } = options
let counter = 0
let timer
const setCounter = (count) => {
counter = count
render()
}
const render = () => {
element.innerHTML = `<button>${counter}</button>`
}
setCounter(initialCount)
timer = setInterval(() => {
if (counter <= 0) {
return clearInterval(timer)
}
setCounter(counter - 1)
}, interval)
}

上面這段 code 也會得出一樣的結果,不僅如此,因為新專案比較大,所以可能要倒數 120 秒發一次驗證信才安全,你還預料到 PM 可能會有修改參數的需求,因此設計了一個 options 的接口,心想這次總萬無一失了吧!

新的需求:這個倒數計時器一定要長得像按鈕嗎?我可以在輸入框也倒數一波嗎?

這時 PM 又出了一道難題給你:「這個按鈕做得不錯,但是我們想在別的地方的輸入框也使用這個倒數的機制耶?有辦法做到嗎?」

雖然你知道你上一版的 code 已經重構的很不錯,但你發現你還是將跟視圖 ( view ) 有關的元素 ( <button> ) 寫進 JS 之中,導致做出來的倒數計時器永遠長得像按鈕。

於是你知道你可能可以在 createCounter 中新增一個參數,來取代寫死 <button></button> ,但問題來了,你怎麼知道使用者要輸入哪種 HTML tag?

如果今天使用者想要輸入 <div></div> 這種有 closing tag 的 HTML tag,那的確是可以這樣做;但如果今天使用者想要製作成 <input>,input 是沒有 closing tag 的,也就是 input 沒有 </input>,而是應該用 <input placeholder="${counter}"> 的方式來做,那這樣 case 就會變得很複雜。

我們必須將框起來的這段 code 抽離出去。

Version 3:利用前端框架,切分邏輯與視圖 ( view )

用原生的 JavaScript 來解決這個問題,並不是不可能,但是你必須要寫很多 if else 來進行條件判斷,其實複雜度是會很高的。

要優雅地解決這個問題,這時就該引出大名鼎鼎的前端框架 — React 或是 Vue 了 ( 因為公司寫 Vue,所以以下我以 Vue 作為範例 ) 。

因為前端框架不是這篇文章的重點,以後有機會再寫篇文章給大家講解,所以我就直接在下面 po 出範例程式碼了:

<template>
<div id="counter">
<div id="app1" class="app">
<h1>App 1</h1>
<CountDownButton />
</div>
<div id="app2" class="app">
<h1>App 2</h1>
<CountDownButton :initial-count="30" :interval="500" />
</div>
<div id="app3" class="app">
<h1>App 3</h1>
<CountDownInput />
</div>
</div>
</template>

利用前端框架的優點是,它可以輕易的將視圖邏輯 ( 原理你得先理解 state 的概念 ) 切分開來,以下是 Vue 實作 hook/composable 的範例:

/* ./components/CountDownInput.vue */
<script setup lang="ts">
import { useCountDown } from '../composable'

const { counter } = useCountDown()
</script>

<template>
<input :placeholder="String(counter)" />
</template>
// ./composable.js

import { ref } from 'vue'

export function useCountDown(options = {}) {
const { initialCount = 60, interval = 1000 } = options
const counter = ref(initialCount)
let timer

timer = setInterval(() => {
if (counter.value <= 0) {
return clearInterval(timer)
}
counter.value -= 1
}, interval)

return { counter }
}

其實能寫到上面的 code 已經代表你是一個合格的前端工程師了,因為目前很多 Github 上的開源專案或 SDK 都會使用到 hook ( 大多會長得像 use 開頭的 function ) ,你必須要懂得這個概念,你才有可能優雅地解決現代前端會遇到的各種問題。

新的需求:公司的 IOS App 可不可以用呢?

你完美地達成 PM 的各種需求,你的 PM 表示相當滿意,但是因為公司還有其它裝置上的產品,於是 PM 好奇的問你:「能不能也把這個邏輯拿去給公司 IOS App 使用呢?」

Version 4 ( 假想 ):製造 parser,將邏輯轉譯成對應語言

身為一名前端工程師 ( Web ) ,有時還是要知道自己的職責與能力界限在哪,這時你必須很誠實地告訴 PM 說做不到,因為 IOS 是寫 Swift,而 Web 是寫 TypeScript/JavaScript。

不過作為一名軟體工程師,你必須進一步說明這部分的技術困難在哪。

舉例來說,我可能會提出一個假想的架構,說明我們的確是可以將 countDown 這個倒數計時的邏輯再次往上層抽離出來,但是中間必須製作一個 parser 的機制將邏輯解析成 TypeScript 或是 Swift 等語言。

但是要製作一個程式語言的 parser 談何容易?可能做了也沒有這個效益,所以我個人認為這個需求的 Refactor 應該到前端框架就可以了。

Btw, 但的確是有人做出類似的概念:那就是 Google 發明的寫手機 App 雙平台框架 Flutter,它讓我們只要寫 Dart 這個語言,就會幫你轉譯成 objective-c 以及 java,分別對應到 IOS 與 Android,讓你可以寫一個語言就部署到 2 個平台上 ,不過 Google 一定是花了可能不下百位的頂級工程師才有可能做出來,一般小公司真的不用想。

結語

最後讓我們從架構上來回顧一下今天從 Version 1 重構到 Version 3 的 code:

可以看出 Version 1 在架構上是整個混在一起的,如果你要重複使用它,就必須複製貼上到別的專案,非常土法煉鋼;除此之外,如果不小心改動了 HTML,很有可能會導致 JS 發生 runtime error。

而 Version 2 就進步了很多,已經將 HTML 與 JS 完全分離,即使今天少掉了 HTML,也不會影響 JS 整份 code 的完整度;美中不足的點是仍有部分的視圖元素 ( <button> )混在邏輯之中,沒辦法真正做到高度客製化。

而針對今天的案例來說,Version 3 就是真正意義上的最佳解,完美地將程式分層成 View layer 以及 Logic layer,意思是左邊的 View layer 不管怎麼變動、擴充甚至刪除,都不會影響到右邊的 Logic layer。

算是第一次嘗試寫技術文章,希望能幫助到正在學習前端的各位。

--

--

Brian Lai
Brian Lai

Written by Brian Lai

Senior Frontend Developer | @XY Finance

No responses yet