【翻译】借助渲染引擎和React Native加速Zalando移动应用开发

22 阅读12分钟

原文链接:engineering.zalando.com/posts/2025/…

我们展示了如何通过Brownfield集成方法,将基于React的内部UI组合框架Rendering Engine与React Native相结合。这种方法使我们能够逐步现代化移动应用的技术栈,同时引入跨平台技术来构建共享体验。

作者:Rene Eichhorn

引言

近期,Zalando决定启动移动应用的大规模迁移计划。当前应用采用双架构开发模式,分别基于iOS和Android平台构建独立代码库。今年九月,我有幸在React Universe大会上分享了我们的整体实施策略。本文将深入剖析决策背景、集成策略,并阐述我们对跨平台客户体验开发的思考。

在探讨技术细节前,需先厘清选择React Native的初衷。我们的核心需求可归纳为三大支柱:

  • 更快构建与交付:重大架构变更往往源于提升长期速度与效率的目标。我们身处快节奏环境,致力于为5200多万时尚、美妆及生活方式领域的客户打造创新体验。因此,为支持团队持续迭代,我们需要确保功能能在所有平台快速构建,且投入精力最小化。
  • 渐进式迁移:鉴于系统复杂性,一次性重构全部应用超出可行性范围。一次性迁移90余个界面绝非可行方案。技术选型需通过安全渐进的方式推进,先在部分界面和流量中进行评估验证,再逐步推广至数百万用户,最终投入资源完成全部界面的迁移。
  • 涵盖Web端:迄今为止,Zalando网站与应用程序的技术方向几乎完全不同。虽然每个平台确实存在各自的挑战,但也有许多共同关切。多年来构建的许多能力,如后端驱动的UI、可组合的模块化组件等,在过渡过程中不应丢失,而应在此基础上进一步发展。

评估 React Native

我们决定采用 React Native 的过程经历了几个阶段。在将 React Native 集成到代码库之前,我们首先构建了一个简单却极具表现力的概念验证原型,完整复现了现有应用的用户体验。该原型涵盖了应用程序的典型需求:使用 react-navigation 实现导航功能,通过 react-native-reanimated 实现从简单到复杂的动画效果,借助 react-native-video 支持视频播放,并定制了 turbo 模块以展示原生互操作性。这些由社区维护的强大组件极大助力了内容丰富的原型构建。但要证明新技术具备生产就绪能力,还需满足可观测性、分析追踪事件、数据获取与缓存、状态管理、深度链接等前述功能要求。

构建可扩展的React Native应用通常需要完整的架构设计。然而我们面临独特条件:Zalando网站已拥有基于React构建的成熟可扩展框架。通过将该内部框架集成到React Native进行概念验证,我们仅用数周就实现了具备实时数据访问的生产就绪环境。

这个内部框架即我们所称的渲染引擎,其核心是渲染器概念——这些增强型 React 组件默认集成了常见应用需求。想象一下:你编写组件时,它会自动具备可观察性(含指标与追踪功能)、处理数据获取与缓存、管理状态,并提供触发分析事件的便捷方式,同时确保组件尽可能独立且与上下文无关。关于其工作原理的详细说明,可参阅我们先前深入解析渲染引擎的博文

import { module } from "@if/rendering-engine/api";
import * as React from "react";
import * as query from "./query.graphql";

export default tile()
  .withQueries(({ entity: { id } }) => ({
    carousel: { query, variables: { id } },
  }))
  .withProcessDependencies(({ data }) => {
    if (data === null) {
      return { action: "error", message: "No collection data found." };
    }
    return {
      action: "render",
      data,
      tiles: { entities: getCollectionEntities(data) },
    };
  })
  .withRender((props) => {
    const {
      data: { collection },
      tiles: { entities },
    } = props;
    return (
        <Carousel
          {...collection}
        >
          {entities}
        </Carousel>
    );
  });

实现开发与生产就绪

虽然在Zalando内部,使用渲染引擎构建Web应用已是成熟流程,但将React Native集成到现有大型代码库中却成为全新挑战。尝试集成过程中,我们遭遇了多个问题:

  • 原生依赖冲突 — React Native或社区包使用的原生包版本与我们不一致。
  • 缺乏清晰的模块分离 — 我们不断思考:React Native代码应置于何处?如何将其合理嵌入应用程序代码库?Git子模块虽是选项之一,却伴随诸多问题且无法实现严格分离。
  • 糟糕的开发者体验 — 即便启用构建缓存,大型原生应用的构建过程仍可能异常缓慢。尤其对来自Web背景的工程师(不熟悉Android Studio和Xcode等工具)而言,必须构建整个应用才能开始使用React Native,这成为影响生产力、阻碍Web工程师入职的主要障碍。

解决这些问题比预期复杂得多,但最终我们攻克了挑战,形成了我们称之为React Native作为包架构的解决方案。 React Native作为包架构 这种方法的核心在于,像开发任何其他React Native应用那样构建Zalando应用的React Native部分,但有一个细微调整:我们将React根组件和初始化逻辑封装到名为“入口点”的npm包中。这个入口点由独立的开发者应用通过标准React Native环境调用。因此在开发者应用中,我们既能享受React Native带来的全部优势(如同任何全新应用),又能与旧有架构实现完全隔离。我们在React Native默认开发菜单(摇动设备时可见)基础上添加了专属开发者菜单,支持开发者快速切换JavaScript程序包(发布版本、拉取请求构建版本和本地版本),并提供众多开发体验实用工具。 另一类消费者是框架(SDK),它是一个原生库/包,包含整个React Native技术栈,这些技术栈都隐藏在简单易用的接口背后。

public class ReactNativeViewFactory {
  public func initialize()
  public func loadView(
      _ deepLinkProps: DeepLinkProperties,
        launchOptions: [AnyHashable: Any]? = nil,
  ) -> UIView
}
public interface ReactNativeViewFactory {
    public fun initialize()
    public fun createViewHostedInActivity(
        activity: FragmentActivity,
        screenParameters: ReactNativeScreenParameters,
    ): View
    public fun createViewHostedInFragment(
        fragment: Fragment,
        screenParameters: ReactNativeScreenParameters,
    ): View
}

Callstack 的朋友们已将大量相关工作开源,打包成一个简洁的软件包,您可直接使用!

与现有原生应用的互操作性

尽管我们倾向于将新架构与现有原生应用隔离,但遗憾的是这并非总是可行。有时这两个截然不同的系统仍需进行通信。例如,当你在Zalando应用中将商品添加至心愿单时,心愿单数量会通过一个递增的小徽章显示。若该操作由React Native端发起,我们需要通知原生应用相应更新计数器。

为实现此功能,我们采用标准依赖注入流程,在保持系统间最大程度隔离的同时实现交互。所有通信均遵循以下流程:

  • 创建新的turbo模块,并使用TypeScript类型定义其接口。
export interface Spec extends TurboModule {
  addProduct(
    sku: string,
    shouldShowNotification?: boolean,
  ): Promise<void>;
  readonly onProductChange: EventEmitter<ProductChangeEvent>;
}
  • 定义一个兼容的接口(或协议),该接口将定义要在原生侧注入的API,并指定注入的位置。
@objc
public protocol WishlistProtocol: AnyObject {
    var onProductChange: ((String, String?, Bool) -> Void)? { get set }

    func addProduct(_ sku: String, shouldShowNotification: Bool, completion: @escaping ((Error?) -> Void))
}

@objc public class WishlistConfig: NSObject {
    @objc public static var delegate: TurboWishlistProtocol?
}
  • 最后,在原生应用中实现一个符合该接口的类,并将其注入到我们的框架(SDK)中。

这不仅建立了便捷的交互通道,更划定了清晰的边界与契约。一个巧妙的副作用是:现在我们的独立开发者应用可以使用模拟版本实现相同接口,这意味着即使在测试愿望清单等功能时,我们仍可继续使用开发者应用。

跨平台开发(含Web端)

依托最初为Zalando官网开发的框架,我们得以在应用程序和网页平台间共享核心功能与代码。此外,通过标准化渲染器等底层概念,该框架统一了Zalando面向客户的应用程序构建方式。这固然出色,但我们希望在跨平台开发领域探索更深层次的可能性。借助渲染引擎,我们能共享数据获取、分析追踪、缓存等核心基础逻辑,但若能共享用户界面呢?

在React Native中,编写跨平台UI组件主要有两种不同方式,各自基于不同视角:

  • 使用内置组件(如<View /><ScrollView /><Text />等)以常规React Native方式构建组件,然后让react-native-web负责将这些组件转换为浏览器所需的HTML元素。
  • 使用 react-strict-dom 构建基于 HTML 子集的组件,并将 HTML 元素映射至对应的 React Native 组件。

因此我们面临两种选择:编写 HTML 并映射至 React Native,或编写 React Native 并映射至 HTML。虽然两种方案均可行,但我们最终选择了 react-strict-dom。这一决策源于对最具未来适应性解决方案的追求,而 react-strict-dom 正符合这一要求。此外,我们认为HTML和CSS具有极强的表达力,经过多年演进,未来仍将保持重要地位。相比之下,其他任何形式的UI表示都可能随时过时。React-strict-dom在Web端也无需额外运行时开销,因为构建步骤会移除所有不必要的抽象层。

import { css, html } from 'react-strict-dom';

const styles = css.create({
  button: {
    backgroundColor: {
      default: 'white',
      ':hover': 'lightgray'
    },
    padding: 10
  }
});

function MyButton() {
  return (
    <html.button style={styles.button}>
      A cross-platform button
    </html.button>
  );
}

构建跨平台组件库

基于 react-strict-dom 作为跨平台 UI 层,我们为 Zalando 自有设计系统构建了组件库,涵盖排版、按钮、卡片、对话框等组件及样式。然而跨平台组件开发往往存在诸多限制:无论选用何种UI层,功能都将受限于跨平台兼容的子集,任何非通用特性都会被剔除。这对我们而言不可接受——我们追求跨平台代码的优势而非自我设限。所幸react-strict-dom与Metro打包器提供了若干实用工具来解决此问题。

  • 平台专属导入:若在 Foo.ts 文件旁创建 Foo.native.ts,当导入 “./Foo” 时,Metro 将根据目标平台自动选择对应文件;若需更精细控制,还可使用 .ios.ts.android.ts 进行区分。这种机制在组件库中尤为强大:即使不同平台的实现完全不同,只要组件的 props 保持一致,调用方就无需关心底层实现细节,完全被抽象化处理。我们采用了一个简单模式:将类型定义存放在独立文件中,从而实现多实现间的类型安全检查。

  • React Strict DOM 的兼容性:虽然 react-strict-dom 的映射效果出色,但有时我们需要扩展或调整传递给底层原生组件的 props,以获得更强的控制力。React Strict DOM 提供了一个简单易用的 API,正是为此而生。
export component CustomSpan(...props: FooProps) {
  return (
    <compat.native
      {...props}
      aria-label="label"
      as="span"
    >
      {(nativeProps: React.PropsOf<Text>)) => (
        <Text {...nativeProps} />
      )}
    </compat.native>
  )
}

跨平台组件库中最后一块尚未提及的缺失拼图是样式系统。我们通过StyleX增强了库的样式能力,该工具与react-strict-dom紧密配合,既支持主题化功能,又能为伪类和媒体查询等CSS子集提供兼容性补丁。这意味着我们能在所有平台上使用样式变量(如字体大小、颜色、边框等),这些变量被称为令牌,其用法与CSS变量完全一致。在Web端,所有样式和变量都会转换为常规CSS文件。

import { tokens } from "@zds/tokens/tokens.stylex";

export const DefaultMessage = ({ style, ...props }: MessageProps) => {
  const defaultStyle = [styles.primaryStyle, style];

  return <BaseMessage {...props} style={defaultStyle} />;
};

const styles = css.create({
  primaryStyle: {
    backgroundColor: tokens.colorBackgroundDefault,
    borderWidth: tokens.borderWidthS,
    borderColor: tokens.colorBorderSecondary,
    borderStyle: "solid",
  },
});

当前进展

对我们而言,迁移工作仍在持续推进,但已成功迁移若干界面——从核心功能到次要模块均涵盖其中,包括Zalando全新前端界面发现推送。该界面以媒体内容为核心,有力证明了React Native同样能高效呈现媒体密集型内容。 在软件工程中,犯错并从中学习是正常过程。在最初的版本发布过程中,我们不断有所发现。其中几个关键点包括:

  • 尽早发布至关重要。我们迁移的首个界面是流量低且极其简单的页面;然而即便在如此基础的场景下,我们仍收获颇丰。这不仅让我们能在不破坏核心功能的前提下提前测试技术,更基于真实用户体验建立了完善的可观测性体系。
  • 编写跨平台代码需要在节省开发时间与受限于跨平台约束之间寻求平衡。必须接受这样一个事实:实现所有平台(甚至iOS与Android之间)100%代码共享并非目标,正如完全使用JavaScript编写代码、避免原生代码也非目标——这完全没问题。
  • 此前我们提及了React Native与现有原生应用的互操作性方案,但实现过程绝非易事,需要完善的流程保障。尤其在整合三种开发环境(TypeScript、Swift和Kotlin)时,首要任务是明确定义API契约,并确保所有环境尽早兼容该契约。否则将面临API设计无法跨平台适配的困境,导致已完成的工作被迫回溯重做。

随着基础架构的稳固,我们正全力提升迁移速度,同时保持客户期待的质量标准。这对Zalando的移动开发团队而言是激动人心的时刻,我们衷心感谢强大的内部支持与成熟的开源生态系统使这一切成为可能。期待与社区携手合作,并将我们的经验贡献回生态圈。