最近一直在用 React Native 進行跨端開發,做爲一個 iOS 開發,期間遇到了很多問題,如何正確地使用 React 高效渲染 UI 一直是個挑戰,趁春節後不太忙抽時間看了看 React 的源碼,感受似懂非懂。爲了搞清楚 React 內部工做原理,參考了一些資料,嘗試寫一個簡單版的 React,因爲今年團隊在推 Swift,就把之前忘乾淨的 Swift 從新撿起來寫了這個 Demo。前端
React 多是當前最值得學習的前端技術,自己優秀思想和架構也值得咱們一探究竟,但願這個 Demo 能幫助你們瞭解 React 的運行機制,開拓咱們的眼界。node
接下來咱們仿照 React,用 Swift 實現這個簡單版的 React。react
Demo 下載地址:https://github.com/superzcj/swift-react-demogit
React 是用於構建用戶界面的 Javascript 框架,它只負責渲染 view。github
React 的 Virtual DOM 機制,讓 UI 渲染更高效。當界面發生變化時,咱們可以知道 Virtual DOM 的變化,從而高效的改動 DOM,避免了從新繪製真實 DOM。算法
React 是單向數據流,不直接操做 DOM,經過應用程序的狀態(數據)是驅動 UI 更新,具備可預期性。swift
React 組件化開發,使得組件代碼複用、測試等更加容易。bash
參照 React 的實現,咱們建立 Element 樹來描述但願展示的 UI 界面,Element並非真實的 UI 組件樹,它是虛擬的對象。架構
咱們知道操做真實的 UI 組件,代價較高,影響性能,經過創造虛擬的 Element 樹且把它存儲起來,每當狀態發生變化裏,創造新的虛擬 Element 樹,和舊的進行比較,讓變化的部分進行渲染。從而減小操做真實 UI 組件的次數,下降了 UI 渲染的負擔。app
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 的結構,那如何用 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 上,代碼能夠正常運行,結果以下。
在上面的代碼中,咱們只實現了一個根據數據驅動 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