用 Swift 实现一个简单版 React

最近一直在用 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

猜你喜欢

转载自juejin.im/post/5c88bc8ff265da2de04af9b1