57

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

 5 years ago
source link: http://tech.glowing.com/cn/react-native-explained-in-swift-part-1-element-and-component/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 = 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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK