用 Swift 實現一個簡單版 React

瀟瀟瀟暮雨發表於2019-03-13

最近一直在用 React Native 進行跨端開發,作為一個 iOS 開發,期間遇到了不少問題,如何正確地使用 React 高效渲染 UI 一直是個挑戰,趁春節後不太忙抽時間看了看 React 的原始碼,感覺似懂非懂。為了搞清楚 React 內部工作原理,參考了一些資料,嘗試寫一個簡單版的 React,由於今年團隊在推 Swift,就把以前忘乾淨的 Swift 重新撿起來寫了這個 Demo。

React 可能是當前最值得學習的前端技術,本身優秀思想和架構也值得我們一探究竟,希望這個 Demo 能幫助大家瞭解 React 的執行機制,開拓我們的眼界。

接下來我們仿照 React,用 Swift 實現這個簡單版的 React。

Demo 下載地址:https://github.com/superzcj/swift-react-demo

React

React 是用於構建使用者介面的 Javascript 框架,它只負責渲染 view。

React 的 Virtual DOM 機制,讓 UI 渲染更高效。當介面發生變化時,我們能夠知道 Virtual DOM 的變化,從而高效的改動 DOM,避免了重新繪製真實 DOM。

React 是單向資料流,不直接操作 DOM,通過應用程式的狀態(資料)是驅動 UI 更新,具有可預期性。

React 元件化開發,使得元件程式碼複用、測試等更加容易。

建立 Element

參照 React 的實現,我們建立 Element 樹來描述希望展現的 UI 介面,Element並不是真實的 UI 元件樹,它是虛擬的物件。

我們知道操作真實的 UI 元件,代價較高,影響效能,通過創造虛擬的 Element 樹且把它儲存起來,每當狀態發生變化裡,創造新的虛擬 Element 樹,和舊的進行比較,讓變化的部分進行渲染。從而減少操作真實 UI 元件的次數,降低了 UI 渲染的負擔。

Element 是一個樹狀結構,它由一個個節點構成,每個節點包含兩個屬性: type:(string|ReactClass) 和 props:Object。 如果 type 是 string,表示一個 dom 節點,如果 type 是class 或 function,表示一個 element 節點,props 中可能會有一個 children 屬性,children 是 element 的節點。

每個節點對應一個 UI 元件節點,我們根據每個 Element 節點建立相應的 UI 元件節點,並把屬性一一設定。簡單起見,我們的 Demo 做了一些改動,type 表示 view 型別,如UIView、UILabel,frame 表示 view 的大小、位置,prop 表示該 view 的屬性,children 則表示該 view 的子 view,程式碼如下:

class ComponentNode {
    var type: String! = nil
    var frame: CGRect! = nil
    var prop: Dictionary<String, Any>? = nil
    var children: [ComponentNode] = []
    
    init(type: String, frame: CGRect, prop: Dictionary<String, Any>, children: [ComponentNode]) {
        self.type = type
        self.frame = frame
        self.prop = prop
        self.children = children
    }
}
複製程式碼

Element 渲染 UI

下一步是將 Element 渲染成真實的 UI 檢視,上面我們定義了 Element 的結構,那如何用 Element 描述要表達的 UI 呢,我們舉個例子:

let element = ComponentNode(type: "view", frame: timerFrame, prop: [:], children: [
            ComponentNode(type: "label", frame: textFrame, prop: ["text": "當前時間:"], children: []),
            ComponentNode(type: "label", frame: textFrame2, prop: ["text": "\(formatter.string(from: time as Date))"], children: [])
            ])
複製程式碼

描述的 UI

<View>
    <Label>當前時間:</Label>
    <Label>10:11:00</Label>
</View>
複製程式碼

從 element 到真實 UI,這一步是如何實現的?

我們使用一個 createView 函式,入參是一個節點 ComponentNode,出參是一個 UIView,該函式根據 ComponentNode 的 type 判斷節點型別,建立相應的真實 view,frame 是節點的佈局,prop中存入節點的屬性,children中放巢狀的子節點,生成真實 UI。程式碼如下:

    func createView(node: ComponentNode) -> UIView {
        
        switch node.type {
        case "view":
            let view = UIView(frame: node.frame)
            view.backgroundColor = UIColor(white: 0.0, alpha: 0.1)
            return view
            
        case "label":
            let view = UILabel(frame: node.frame)
            view.text = node.prop?["text"] as? String
            return view
            
        case .none:
            return UIView()
        case .some(_):
            return UIView()
        }
        
    }
複製程式碼

我們把以上程式碼組裝起來,


class Component {
    public var hostView: UIView!
    public var element: ComponentNode!
    
    init() {
        self.element = self.render()
    }
    
    func renderComponent() {
        let new = self.render()
        self.element = new
        
        let uiView = createView(node: self.element)
        
        for subview in (hostView?.subviews)! {
            subview.removeFromSuperview()
        }
        
        hostView!.addSubview(uiView)
    }
    
    func render() -> ComponentNode {
        return ComponentNode(type: "view", frame: CGRect.zero, prop: [:], children: [])
    }
}
複製程式碼

從資料驅動 UI 進行渲染的功能,我們寫好了,基於這個Component,我們寫個 demo 呼叫下,看看能否按我們的期望執行。


let timerFrame = CGRect(x: 100, y: 100, width: 200, height: 65)
let textFrame = CGRect(x: 60, y: 10, width: 100, height: 20)
let textFrame2 = CGRect(x: 60, y: 30, width: 100, height: 20)

class TimerComponent: Component, ComponentProtocol {
    
    var time = NSDate()
    
    override func render() -> ComponentNode {
        
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm:ss"
        
        return ComponentNode(type: "view", frame: timerFrame, prop: [:], children: [
            ComponentNode(type: "label", frame: textFrame, prop: ["text": "當前時間:"], children: []),
            ComponentNode(type: "label", frame: textFrame2, prop: ["text": "\(formatter.string(from: time as Date))"], children: [])
            ])
    }
}
複製程式碼

建立一個定時器,定時重新整理頁面,展示當前的時間,頁面層級也比較簡單,一個底層的view,上面放兩個 label,分別顯示 “當前時間” 和時間。

class ViewController: UIViewController {
    
    lazy var component: TimerComponent = {
        let component = TimerComponent()
        component.hostView = view
        return component
    }()

    @objc func tick() {
        self.component.time = NSDate()
        self.component.renderComponent()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
        
    }
}
複製程式碼

把這個 Component 新增我們的 VC 上,程式碼可以正常執行,結果如下。

2019-03-01 16.27.41.gif

Diff

在上面的程式碼中,我們只實現了一個根據資料驅動 UI 渲染的 Demo,接下來我們繼續擴充套件,給 Demo 也賦予 diff 演算法,能夠找出有變化的頁面元素並進行渲染。

首先我們新增一個屬性 currentElement,用於儲存當前的節點樹。每次資料有變化時,將呼叫 renderComponent 函式,這個函式將找到新、舊節點樹並對比。

    public var parentView: UIView!
    private var currentElement: ComponentNode?

    func renderComponent() {
        let old = self.currentElement
        let new = self.render()
        let element = reconcile(old: old, new: new, parentView: self.parentView)
        self.currentElement = element
    }
複製程式碼

reconcile 是進行新舊節點樹對比的演算法,先檢查 old 節點樹是否為空,若為空,說明是第一次渲染,初始化 UI。然後比對是否有更新,包括節點view的型別、frame、prop,若有更新,則移除舊節點的view,重新渲染新節點及其子節點。最後比對子節點,遞迴呼叫該函式找出有更新的節點並重新渲染。

    func reconcile(old: ComponentNode?, new: ComponentNode?, parentView: UIView) -> ComponentNode? {
        // 首次渲染,old為空,初始化 UI
        if old == nil {
            instantiate(node: new!, parentView: parentView)
            return new!
        }
        
        let oldNode = old!
        let newNode = new!
        
        //新舊節點對比,如果有更新則重新渲染新節點及其子節點
        if oldNode.type != newNode.type || oldNode.frame != newNode.frame || oldNode.prop != newNode.prop {
            if oldNode.view != nil {
                oldNode.view?.removeFromSuperview()
                oldNode.view = nil
            }
            instantiate(node: newNode, parentView: parentView)
            return newNode
        }
        
        //子節點對比
        newNode.children = reconcileChildren(old: oldNode, new: newNode)
        newNode.view = oldNode.view
        return newNode
    }
    
複製程式碼

instantiate 函式是根據節點樹渲染真實 view,createView 是真正建立 view,遞迴呼叫自身完成 view 生成。

    func instantiate(node: ComponentNode, parentView: UIView) {
        let newView = createView(node: node)
        for index in 0..<node.children.count {
            instantiate(node: node.children[index], parentView: newView)
        }
        parentView.addSubview(newView)
        node.view = newView
    }
    
複製程式碼

迴圈遍歷子節點,對比生成新的view

    func reconcileChildren(old: ComponentNode, new: ComponentNode) -> [ComponentNode] {
        var newChildInstances: [ComponentNode] = []
        for index in 0..<new.children.count {
            let oldChild = old.children[index]
            let newChild = new.children[index]
            let newChildInstance = reconcile(old: oldChild, new: newChild, parentView: old.view!)
            newChildInstances.append(newChildInstance!)
        }
        return newChildInstances
    }
複製程式碼

刪除節點

上面的程式碼沒有考慮節點刪除的情況,我們改造下程式碼,當 new 為空時,把舊的view 從檢視層級上刪除。子節點對比時,過濾為空的項。程式碼如下:


    func reconcile(old: ComponentNode?, new: ComponentNode?, parentView: UIView) -> ComponentNode? {
        // 首次渲染,old為空,初始化 UI
        if old == nil {
            instantiate(node: new!, parentView: parentView)
            return new!
        } else if (new == nil) {
            old?.view?.removeFromSuperview()
            return nil
        }

        let oldNode = old!
        let newNode = new!

        //新舊節點對比,如果有更新則重新渲染新節點及其子節點
        if oldNode.type != newNode.type || oldNode.frame != newNode.frame || oldNode.prop != newNode.prop {
            if oldNode.view != nil {
                oldNode.view?.removeFromSuperview()
                oldNode.view = nil
            }
            instantiate(node: newNode, parentView: parentView)
            return newNode
        }

        //子節點對比
        newNode.children = reconcileChildren(old: oldNode, new: newNode)
        newNode.view = oldNode.view
        return newNode
    }
複製程式碼
    func reconcileChildren(old: ComponentNode, new: ComponentNode) -> [ComponentNode] {
        var newChildInstances: [ComponentNode] = []
        let count = max(old.children.count, new.children.count)
        for index in 0..<count {
            let oldChild = old.children.count > index ? old.children[index] : nil
            let newChild = new.children.count > index ? new.children[index] : nil
            let newChildInstance = reconcile(old: oldChild, new: newChild, parentView: old.view!)
            if newChildInstance != nil {
                newChildInstances.append(newChildInstance!)
            }
        }
        return newChildInstances
    }
複製程式碼

最後把我們的 Demo 也改下,每次重新整理時,顯示不同的內容,看看最終效果

class TimerComponent: Component {

    var time = NSDate()

    var flag = false

    override func render() -> ComponentNode {

        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm:ss"

        self.flag = !self.flag

        if self.flag {
            return ComponentNode(type: "view", frame: timerFrame, prop: [:], children: [
                ComponentNode(type: "label", frame: textFrame, prop: ["text": "當前時間:"], children: []),
                ComponentNode(type: "label", frame: textFrame2, prop: ["text": "\(formatter.string(from: time as Date))"], children: []),
                ComponentNode(type: "label", frame: textFrame3, prop: ["text": "\(formatter.string(from: time as Date))"], children: [])
                ])
        }
        return ComponentNode(type: "view", frame: timerFrame, prop: [:], children: [
            ComponentNode(type: "label", frame: textFrame, prop: ["text": "當前時間:"], children: []),
            ComponentNode(type: "label", frame: textFrame2, prop: ["text": "\(formatter.string(from: time as Date))"], children: [])
            ])
    }
}
複製程式碼

Demo 下載地址:https://github.com/superzcj/swift-react-demo

總結

在這個 Demo 中,我們完成了用 Element 描述 UI 檢視,從 Element 渲染生成 UI,以資料為中心,驅動 UI 變化。這種方式讓 UI 跟資料保持一致,當資料變了,React 自動更新UI,讓 UI 更容易管理和維護。

在資料到 UI 的轉化過程中,根據 Diff 演算法,找出真正有更新的 view 並渲染,也在很大程式上提高了渲染效率。

參考資料: 如何從頭開始逐步構建React Render

相關文章