前言
我對 iOS 開發、手機開發、SwiftUI 開發經驗有限,若有理解錯誤的部分歡迎指正。
從 UI 的角度來看,前端與手機開發會遇到問題是類似的,儘管使用的語言或是開發手法不盡相同,我們都需要打造一個易用的使用者介面。既然如此,彼此也會遇到類似的問題,元件化開發、狀態管理、資料流、管理副作用(API 或是 IO)等等,對我來說是個很適合互相學習的領域。
從過往的經驗可以發現,像是 ReSwift(Redux 的中心思想)這樣的函式庫,或多或少也借鏡了前端不斷演進的開發手法,不難看出雙方會遇到的問題其實有類似的地方。
雖然這個時間點提起已經有點後話了,但還是想把我入門 SwiftUI 後的感想寫下。
SwiftUI 與 React 的類似之處
我們可以將前端框架歸納為幾個要素:
- 元件化
- 響應式機制
- 狀態管理
- 事件監聽
- 生命週期
在下面的段落中,我們也會以這幾個主題為核心做討論。為了不模糊焦點,我會盡可能只用 React 當做舉例,但套用到其他前端框架原理應該也相同。
從 class 邁向 struct;從 class 邁向 function
在寫 SwiftUI 的時候總是讓我想到 React 的發展史。最初 React 建立元件的方式是透過 JavaScript 的 class 語法,每個 React 的元件都是一個類別。
class MyComponent extends React.Component {
constructor() {
this.state = {
name: 'kalan'
}
}
componentDidMount() {
console.log('component is mounted')
}
render() {
return <div>my name is {this.state.name}</div>
}
}
透過類別定義元件雖為前端元件化帶來了很大的影響,但也因為繁瑣的方法定義與 this
混淆,在 React16 hooks 出現之後,逐漸提倡使用 function component 與 hooks 的方式來建立元件。
省去了繼承與各種 OO 的花式設計模式,建構元件的心智負擔變得更小了。從 SwiftUI 當中我們也可以看到類似的演進,原本 ViewController 龐大的 class 以及職責,要負責 view 與 model 的互動,掌管生命週期,轉為更輕量的 struct,讓開發者可以更專注在 UI 互動上,減輕認知負擔。
元件狀態管理
React 16 採取了 hooks 來做元件的邏輯複用與狀態管理,例如 useState
。
const MyComponent = () => {
const [name, setName] = useState({ name: 'kalan' })
useEffect(() => { console.log('component is mounted') }, [])
return <div>my name is {name}</div>
}
在 SwiftUI 當中,我們可以透過修飾符 @State
讓 View 也具有類似效果。兩者都具備響應式機制,當狀態變數發生改變時,React/Vue 會偵測改變並反映到畫面當中。雖然不知道 SwiftUI 背後的實作,但背後應該也有類似 diff 機制的東西來達到響應式機制與最小更新的效果。
然而 SwiftUI 的狀態管理與 React hooks 仍有差異。在 React 當中我們可以將 hook 拆成獨立的函數,並且在不同的元件當中使用,例如:
function useToggle(initialValue) {
const [toggle, set] = useState(initialValue)
const setToggle = useCallback(() => { set((state) => !state) }, [toggle])
useEffect(() => { console.log('toggle is set') }, [toggle])
return [toggle, setToggle]
}
const MyComponent = () => {
const [toggle, setToggle] = useToggle(false)
return <button onClick={() => setToggle()}>Click me</button>
}
const MyToggle = () => {
const [toggle, setToggle] = useToggle(true)
return <button onClick={() => setToggle()}>Toggle, but fancy one</button>
}
在 React 當中,我們可以將 toggle 的邏輯拆出,並在不同元件之間使用。由於 useToggle
是一個純函數,因此內部的狀態也不會互相影響。
然而在 SwiftUI 當中 @State
只能作用在 struct 的 private var 當中,不能進一步拆出。如果想要將重複的邏輯抽出,需要另外使用 @Observable
與 @StateObject
這樣的修飾符,另外建立一個類別來處理。
class ToggleUtil: ObservableObject {
@Published var toggle = false
func setToggle() {
self.toggle = !self.toggle
}
}
struct ContentView: View {
@StateObject var toggleUtil = ToggleUtil()
var body: some View {
Button("Text") {
toggleUtil.setToggle()
}
if toggleUtil.toggle {
Text("Show me!")
}
}
}
在這個例子當中把 toggle 的邏輯拆成一個 class 似乎有點小題大作了,不過仔細想想像 React 提供的 hook 功能,讓輕量的邏輯共用就算單獨拆成 hook 也不會覺得過於冗長,若要封裝更複雜的邏輯也可以再拆分成更多 hooks,從這點來看 hook 的確是一個相當優秀的機制。不知道 SwiftUI 是否有類似的機制。1
以 React 來說,在還沒有出現 hooks 之前,主要有三個方式來實作邏輯共用:
- HOC(Higher Order Component)2:將共同邏輯包裝成函數後返回全新的 class,避免直接修改元件內部的實作。例如早期
react-redux
中的connect
。 - render props3:將實際渲染的元件當作屬性(props)傳入,並提供必要的參數供實作端使用。
- children function:children 只傳入必要的參數,由實作端自行決定要渲染的元件。
儘管 hooks 用更優雅的方式解決邏輯共用的問題,我認為上面的開發手法演變也很值得參考。
Redux 與 TCA
受到 Redux 的影響,在 Swift 當中也有部分開發者使用了採用了類似手法,甚至也有相對應的實作 ReSwift 的說明文。從說明文可以看到主要原因。傳統的 ViewController 職責曖昧,容易變得肥大導致難以維護,透過 Reducer、Action、Store 訂閱來確保單向資料流,所有的操作都是向 store dispatch 一個 action,而資料的改動(mutation)則在 reducer 處理。
而最近的趨勢似乎從 Redux 演變成了 TCA(The Composable Architecture),跟 Redux 的中心思想類似,更容易與 SwiftUI 整合,比較不一樣的地方在於以往涉及 side effect 的操作在 Redux 當中會統一由 middleware 處理,而在 TCA 的架構中 reducer 可以回傳一個 Effect,代表接收 action 時所要執行的 IO 操作或是 API 呼叫。
既然採用了類似 redux 的手法,不知道 SwiftUI 是否會遇到與前端開發類似的問題,例如 immutability 確保更新可以被感知;透過優化 subscribe 機制確保 store 更新時只有對應的元件會更新;reducer 與 action 帶來的 boilerplate 問題。
雖然 Redux 在前端仍然具有一定地位,也仍然有許多公司正在導入,然而在前端也越來越多棄用 Redux 的聲音,主要因為 redux 對 pure function 的追求以及 reducer、action 的重複性極高,在應用沒有到一定複雜程度之前很難看出帶來的好處,甚至連 Redux 作者本人也開始棄坑 redux 了4。與此同時,react-redux 仍然有在持續更新,也推出了 redux-toolkit 來試圖解決導入 redux 時常見的問題。
取而代之的是更加輕量的狀態管理機制,在前端也衍生出了幾個流派:
全域狀態管理
在全域狀態管理上,SwiftUI 也有內建機制叫做 @EnvrionmentObject
,其運作機制很像 React 的 context,讓元件可以跨階層存取變數,當 context 改變時也會更新元件。
class User: ObservableObject {
@Published var name = "kalan"
@Published var age = 20
}
struct UserInfo: View {
@EnvironmentObject var user: User
var body: some View {
Text(user.name)
Text(String(user.age))
}
}
struct ContentView: View {
var body: some View {
UserInfo()
}
}
ContentView().envrionmentObject(User())
從上面這個範例可以發現,我們不需要另外傳入 user
給 UserInfo,透過 @EnvrionmentObject
可以拿到當前的 context。轉換成 React 的話會像這樣:
const userContext = createContext({})
const UserInfo = () => {
const { name, age } = useContext(userContext)
return <>
<p>{name}</p>
<p>{age}</p>
</>
}
const App = () => {
<userContext.Provider value={{ user: 'kalan', age: 20 }}>
<UserInfo />
</userContext.Provider>
}
React 的 context 可讓元件跨階層存取變數,當 context 改變時也會更新元件。雖然有效避免了 prop drilling 的問題,然而 context 的存在會讓測試比較麻煩一些,因為使用 context 時代表了某種程度的耦合。
響應機制
在 React 當中,狀態或是 props 有變動時都會觸發元件更新,透過框架實作的 diff 機制比較後反映到畫面上。在 SwfitUI 中也可以看到類似的機制:
struct MyView: View {
var name: String
@State private var isHidden = false
var body: some View {
Toggle(isOn: $isHidden) {
Text("Hidden")
}
Text("Hello world")
if !isHidden {
Text("Show me \(name)")
}
}
}
一個典型的 SwiftUI 元件是一個 struct
,透過定義 body
變數來決定 UI。跟 React 相同,他們都只是對 UI 的抽象描述,透過比對資料結構計算最小差異後,再更新到畫面上。
我還蠻想了解 SwiftUI 背後是怎麼計算 diff 的,希望之後有類似的文章出現。
@State
修飾符可用來定義元件內部狀態,當狀態改變時會更新並反映到畫面中。
在 SwiftUI 當中,屬性(MyView 當中的 name
)可以由外部傳入,跟 React 當中的屬性(props)類似。
// 在其他 View 當中使用 MyView
struct ContentView: View {
var body: some View {
MyView(name: "kalan")
}
}
用 React 改寫這個元件的話會像這樣:
const MyView = ({ name }) => {
const [isHidden, setIsHidden] = useState(false)
return <div>
<button onClick={() => setIsHidden(state => !state)}>hidden</button>
<p>Hello world</p>
{isHidden ? null : `show me ${name}`}
</div>
}
在撰寫 SwiftUI 時會發現這跟以往用 UIKit、UIController 的開發方式不太一樣,
列表
SwiftUI 與 React 當中都可以渲染列表,而撰寫的方式也有雷同之處。在 SwiftUI 當中可以這樣寫:
struct TextListView: View {
var body: some View {
List {
ForEach([
"iPhone",
"Android",
"Mac"
], id: \.self) { value in
Text(value)
}
}
}
}
轉成 React 大概會像這樣子:
const TextList = () => {
const list = ['iPhone', 'Android', 'Mac']
return list.map(item => <p key={item}>{item}</p>)
}
在渲染列表時為了確保效能,減少不必要的比對,React 會要求開發者提供 key,而在 SwiftUI 當中也有類似的機制,開發者必須實作叫做 Identifiable 的 protocol,或是顯式地傳入 id。
Binding
除了將變數綁定到畫面之外,我們也可以將互動綁定到變數之中。例如在 SwiftUI 當中我們可以這樣寫:
struct MyInput: View {
@State private var text = ""
var body: some View {
TextField("Please type something", text: $text)
}
}
在這個範例當中,就算不監聽輸入事件,使用 $text
也可以直接改變 text 變數,當使用 @State
時會加入 property wrapper,會自動加入一個前綴 $
,型別為 Binding。
React 並沒有雙向綁定機制,必須要顯式監聽輸入事件確保單向資料流。不過像 Vue、Svelte 都有雙向綁定機制,節省開發者手動監聽事件的成本。
Combine 的出現
雖然我對 Combine 還不夠熟悉,但從官方文件與影片看起來,很像 RxJS 的 Swift 特化版,提供的 API 與操作符大幅度地簡化了複雜資料流。這讓我想起了以前研究 RxJS 與 redux-observable 各種花式操作的時光,真令人懷念。
本質上的差異
前面提到那麼多,然而網頁與手機開發仍然有相當大的差異,其中對我來說最顯著的一點是靜態編譯與動態執行。動態執行可以說是網頁最大的特色之一。
只要有瀏覽器,JavaScript、HTML、CSS,不管在任何裝置上都可以成功執行,網頁不需要事先下載 1xMB ~ 幾百 MB 的內容,可以動態執行腳本,根據瀏覽的頁面動態載入內容。
由於不需要事先編譯,任何人都可以看到網頁的內容與執行腳本,加上 HTML 可以 streaming 的特性,可以一邊渲染一邊讀取內容。難能可貴的一點是,網頁是去中心化的,只要有伺服器、ip 位址與網域,任何人都可以存取網站內容;而 App 如果要上架必須事先通過審查。
不過兩者的生態圈與開發手法有很大的不同,仍然建議參考一下彼此的發展,就算平時不會碰也沒關係,從不同的角度看往往可以發現不同的事情,也可以培養對技術的敏銳度。