用 Swift 解读 React/React Native: Part 1 - React Element & React Component

React & React Native 不只是一种框架,它更是一种思维方式和方法论。

Glow 使用 React Native 至今一年半有余,项目里也有越来越多的组件被重构成 React Native。在使用 React Native 开发的过程中,我们对 React 和 React Native 本身的思想、架构也有了越来越深入的理解。而这些思想又开始逐渐反作用到 Native 的开发,影响着我们在其他 Native 组件开发过程中的架构选择和实现思路,促使我们重新审视 Native 的开发方式。

通过这个系列的文章,我们想把从 React 和 React Native 中所学,总结成一些有用的经验,为团队将来无论是 React Native 还是 Native 的开发提供有价值的指导。更长远的,我们希望基于这些经验构建一个新的 Native 开发框架,以提升开发效率和代码质量。

因此,本文:

  • 不是 React 或 React Native 的教程,你并不能通过阅读本文学会如何进行 React 或 React Native 的开发。但如果你已经开始或正准备开始学习和使用 React 或 React Native,本文会对你理解它们的机制有所帮助。
  • 不是 Native 开发或 Swift 的教程,前半部分的教程并不涉及 UIKit,也没有太多 Swift 的奇技淫巧,所以你不能通过这些文章学会如何开发一个完整的 App。
  • 虽然基于 Swift 作解读,但是这些思想广泛适用于任何平台任何语言,它只是一种方法论。

初步打算分为以下方面来写:

  1. React 的核心思想,React Element 和 React Comopnent
  2. React 如何渲染和缓存 Components
  3. React Native 如何基于 React Components 布局和渲染 Native UI
  4. Props & State
  5. React Native 的线程模型
  6. Redux 的核心思想和应用

但是到你读到这一行的时候,除了第一章,其他章节的内容都还可能发生变化,我也会在写作过程中把更多的想法加入进来。


Part 1 正文现在开始,这一部分的代码都可以直接在 Xcode 的 Playground 中执行。

点这里从 Github 下载本文对应的 Playground

React 的核心概念?

React 里一个很重要的概念是:

所谓 UI(无论是一个 App,一个页面,还是一个组件)都可以理解成是一种数据结构(描述原始数据)到另一种数据结构(描述 UI)的转化(Transformation)

怎么理解呢,比如我们有一种描述“用户”的数据结构:

struct User {
    let name: String
    let job: String
}

我们有一个“用户”的实例:

let allen = User(name: "Allen", job: "iOS Engineer")

我们直接定义这么一个“名片组件”并用它生成一个实例:

func NameCard(user: User) -> String {
    return "<View><Text>Name: \(user.name)</Text><Text>Job: \(user.job)</Text></View>"
}

let result = NameCard(user: allen)

得到了:

<View><Text>Name: Allen</Text><Text>Job: iOS Engineer</Text></View>

这样我们就完成了从一种数据结构到另一种数据结构(这个例子里只是伪 XML 的一个 string)的转化,这就是 UI,也是 React 的本质。看似简单,但这种抽象的力量比看上去强大的多。这个“组件”其实就类似 React 里的 Component。在 React 或者说 JS 里,更有意思的是,非原生的 ES6 里的 class,其实真的也只是一个函数,而非真的类。

纯函数(Pure Function)

在继续展开之前我们先插一嘴纯函数的概念,对纯函数有所理解的读者可以跳过这段。

在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关。
  • 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

-- Wikipedia

举个例子,我们想为上一章节中的名片改一下字体大小,一种“不纯”的做法是:

struct Constants {
    static let nameFontSize = 16
}

func NameCard(user: User) -> String {
    return "<View><Text fontSize=\"\(Constants.nameFontSize)\">Name: \(user.name)</Text><Text>Job: \(user.job)</Text></View>"
}

主要的缺点很明显:

  1. 这个 NameCard 只支持一种 fontSize,可重用性差
  2. 同样的输入(user),会因为 Constants 的变化得到不同的输出,可测试性会变差
  3. 理论上的多线程安全性会变差

改成纯函数的实现则是:

struct Constants {
    static let nameFontSize = 16
}

func NameCard(user: User, nameFontSize: Int) -> String {
    return "<View><Text fontSize=\"\(nameFontSize)\">Name: \(user.name)</Text><Text>Job: \(user.job)</Text></View>"
}

let result = NameCard(user: allen, nameFontSize: Constants.nameFontSize)

这样一来,NameCard 的可重用性和可测试性都变得更好了。

这里只是一个用于区分纯函数和非纯函数例子,因为外部变量被定义为常量,所以前后的可测试性的差别不会太大,但想象如果一个函数内部依赖外部的一个全局变量而非常量,例如一个 timer,那它们的可测试性就会差很多。

所以无论是 React 还是 Swift 的开发过程中,我们都鼓励尽可能的抽象出和定义一系列纯函数来实现业务逻辑,以提高代码可读性、可维护性和可测试性。类似的,我们鼓励尽可能使用 Immutable 实例也是出于一样的目的,用以避免没有预期的副作用。

组合/构建(Composition)

前面提到的 NameCard 是一个相当原子(没有引用其他组件)的组件,但一个复杂的组件,或者一个页面,往往由很多子组件构成,或者可以把他们理解成一堆子组件的一个容器(container),比如:

let allen = User(name: "Allen", job: "iOS Engineer")
let nella = User(name: "Nella", job: "Reenigne SOi")

let users = [allen, nella]

// ...

func NameCardList(users: [User]) -> String {
    let nameBoxes = users.map { NameCard(user: $0) }
    let innerNodes = nameBoxes.joined(separator: "\n")
    return "<List>\n\(innerNodes)\n</List>"
}

let result = NameCardList(users: users)

得到:

<List>
<View><Text>Name: Allen</Text><Text>Job: iOS Engineer</Text></View>
<View><Text>Name: Nella</Text><Text>Job: Reenigne SOi</Text></View>
</List>

这样的抽象与组合,大大提高了代码的可读性(Readability)、可维护性(Maintainability)、可复用性(Reusability)和可测试性(Testability)。这也是 React 里用 Component 抽象所有 UI 的意义所在。

通过这种组合,我们也对各种逻辑进行了合理有效的封装,可以避免常见的 Massive View Controller。

React Element,抽象的抽象

就像《盗梦空间》里的多层梦境一样,如果说 Component 是对 UI 的抽象,那 React Element 就是第二层抽象,他把 Component 再一次抽象成另一种/层数据结构,用以描述 Component 的状态。

在讨论 React Element 的实现之前,我们先回头看一下上面的组件在实际应用中会有哪些缺点/弱点:

  1. UI 的构建是线性且同步的,意味着这个构建过程无法打断,也无法通过多线程/多任务提升效率
  2. 真正构建子组件的过程是内联(inline)的,不能很方便的在系统层面进行监督(supervise)和缓存结果
  3. 内存开销,这一点其实也是 1 带来的,每次实例化一个容器组件,所有的子组件都同时被实例化

React 中引入 Element 的作用就是解决以上问题,所以 Element 应该有以下特性:

  1. 把 Component 的状态描述与构建分离
  2. 高度抽象 Component 的状态,便于在系统层面做 diff 和缓存
  3. 轻量,降低渲染前的内存开销

简单来说,以上一节里的例子来说,(component: NameCardList, users: users) 这两个数据,已经足够描述整个 App 的状态了,即便子树中的 NameCard 还没有被渲染。Element 就是用来描述 (component: NameCardList, users: users) 这样的数据对。

参照 React 的实现和约定:为了把构建分离出来,我们把子树的构建,放入 Component 的 render 方法中去;为了统一 Component 初始化的接口,我们把 Component 所需参数统一为 props 参数,并通过范型加以约束;children 也是 React 中的 convention,用来传递子树。

基于这些条件,我们定义了如下 protocols 和 base classes:

public protocol PropsProtocol {
    var children: Array<ElementProtocol>? { get }
}

public protocol RenderableProtocol {
    func render() -> ElementProtocol?
}

public protocol ComponentProtocol: RenderableProtocol {
    associatedtype P: PropsProtocol
    var props: P { get set }
    init(props: P)
}

public protocol ElementProtocol {
    func createComponent() -> RenderableProtocol
}

struct Element<T: ComponentProtocol>: ElementProtocol {
    let componentClass = T.self
    let props: T.P
    
    func createComponent() -> RenderableProtocol {
        return componentClass.init(props: props)
    }
}

如何定义和使用 Component 和 Element 呢,以 NameCard 为例:

struct NameCardProps: PropsProtocol {
    let children: Array<ElementProtocol>?
    let user: User
}

class NameCard: Component<NameCardProps> {
    override func render() -> ElementProtocol? {
        return nil
    }
}

let result = Element<NameCard>(props: NameCardProps(children: nil, user: allen))
print(result)

得到结果:

Element<NameCard>(
  componentClass: __lldb_expr_4.NameCard,
  props: __lldb_expr_4.NameCardProps(
    children: nil,
    user: __lldb_expr_4.User(name: "Allen", job: "iOS Engineer")
  )
)

再比如在 NameCardListrender 方法里组合 NameCard

struct NameCardListProps: PropsProtocol {
    let children: Array<ElementProtocol>?
    let users: Array<User>
}

class NameCardList: Component<NameCardListProps> {
    override func render() -> ElementProtocol? {
        let children = props.users.map { Element<NameCard>(props: NameCardProps(children: nil, user: $0)) }
        return Element<View>(props: ViewProps(children: children))
    }
}

let root = Element<NameCardList>(props: NameCardListProps(children: nil, users: users))
print(root)

得到结果:

Element<NameCardList>(
  componentClass: __lldb_expr_4.NameCardList,
  props: __lldb_expr_4.NameCardListProps(
    children: nil,
    users: [
      __lldb_expr_4.User(name: "Allen", job: "iOS Engineer"),
      __lldb_expr_4.User(name: "Nella", job: "Reenigne SOi")
    ]
  )
)

可见,当我们定义一个 NameCardList Element 时,内存里仅有描述该状态的最小数据集,我们会在下一节讲如何构建真正的 Component 树。

至此,我们完成了把 UI 抽象成 Component,和把 Component 抽象成 Element 两大任务。结果看似简单,但这是整个 React 中的基石,也是后面章节展开的基础。

直到现在,所有的代码尚未涉及 UIKit,所以这些代码完全可以脱离 UIKit 运行。这样一来:

  1. 我们的 UI 逻辑也可以像业务逻辑一样,脱离平台特性而存在,提高了代码的可复用性
  2. 我们把可单元测试的粒度也从业务逻辑扩展到了 UI 层面,让以往需要 UI Automation 覆盖的代码逻辑可以用 UT 覆盖

所谓 JSX

写过 React 或者 React Native 的同学可能会说,这里的 render 和 React 的 JSX 完全不一样,React 中的 render 可能是这样:

const element = (
  <h1 className="greeting">
    Hello, world!
  </h1>
);

其实 JSX 只是一种语法糖,上述代码最终会被翻译成:

const element = React.createElement(
  'h1',
  {className: 'greeting'},
  'Hello, world!'
);

createElement 的前三个参数就分别是 typepropschildren,其实与本文描述的结构是一致的。

Component 树的渲染

上一节我们已经得到了 Element 这一数据结构,他的渲染就变得很简单,我们如下定义一个 Global 的 render 方法,通过遍历,得到完整的树:

struct Node {
    let component: RenderableProtocol
    let children: Array<Node>?
}

func render(_ root: ElementProtocol) -> Node {
    let component = root.createComponent()
    var children: Array<Node> = []
    if let childElement = component.render() {
        children = [render(childElement)]
    }
    return Node(component: component, children: children)
}

print(render(root))

我们用这个方法渲染上一节得到的 Root Element,得到:

Node(
  component: NameCardList(
    props: NameCardListProps(
      children: nil,
      users: [
        __lldb_expr_6.User(name: "Allen", job: "iOS Engineer"),
        __lldb_expr_6.User(name: "Nella", job: "Reenigne SOi")
      ]
    )
  ),
  children: Optional([__lldb_expr_6.Node(
    component: View(
      props: ViewProps(
        children: Optional([
          __lldb_expr_6.Element<__lldb_expr_6.NameCard>(
            componentClass: __lldb_expr_6.NameCard,
            props: __lldb_expr_6.NameCardProps(
              children: nil,
              user: __lldb_expr_6.User(name: "Allen", job: "iOS Engineer")
            )
          ),
          __lldb_expr_6.Element<__lldb_expr_6.NameCard>(
            componentClass: __lldb_expr_6.NameCard,
            props: __lldb_expr_6.NameCardProps(
              children: nil,
              user: __lldb_expr_6.User(name: "Nella", job: "Reenigne SOi")
            )
          )
        ])
      )
    ),
    children: Optional([])
  )])
)

注意,我们新定义的 Node 是用来 hold Component 的实例的,所以可以理解为 Node Tree 就是 Component 的实例树。这里有一点容易搞混,Node 的 childrenprops 中的 children 并非一种东西,前者是 Component 实例的数组,后者是 Element 的数组。

因此,每一棵 Node Tree 就对应着整个 App 某一时刻的完整状态,当某些数据发生变化的时候,我们就可以通过重新遍历 Element 来决定是否需要增删改 Node Tree,这就是之后会提到的 diff 算法、rerender 过程以及 cache 的基础。

但细心的读者会发现这里的 render 到 View 为止就没有继续往下了,因为 View、Text、Image 这类 Component 被称为 Native UI Component,他们最终会被映射到一个真正的 Native View 上,因此,他们的 render 过程会涉及到 UIKit 以及最终的渲染,会在后续文章中再做展开。

总结

  • Component: “所谓 UI 就是一种数据结构到另一种数据结构的转化”,Component 就扮演这一角色,把数据从 props 转化成 Elements
  • Element: 描述 UI 状态的、轻量的、临时的中间数据结构(未实例化 Component)
  • Node: Component 的实例树,Element 的渲染结果,描述了一个 Component 完整的当前状态

通过定义 Component、Element 和 Node,我们完成了从数据到 UI 的转化,UI 的组合,UI 状态与渲染的分离,UI 的渲染。这些概念,就是 React 最核心、最基本的概念。

这里无形中也引入了“单向数据流”的概念(一个 user 数据从全局变量,被传递到 NameCardList 再到 NameCard,最终被组装成 View 和 Text),这一概念也是该模型的优点之一,后面讲到 state 的时候也会再次展开。

因为生成和销毁 Element 的开销要远小于操作 Node Tree 和/或 Native UI 的开销,所以这样一种“开发过程用 Element 来描述 UI,渲染引擎负责维护 Component 实例,以及最终 Native UI 的映射关系”的框架,很大程度上提高了开发效率,也提高了代码的规范化和最终的执行效率。

点这里从 Github 下载本文对应的 Playground