当前业务中,存在许多需要打印的场景,为提升打印体验,开发了一套可以将 React 代码转换为打印机描述语言 ZPL 的类库:React ZPL。
本文将从需求背景出发,介绍 ZPL 的概念和 React 渲染过程,以及 React ZPL 是如何通过 React 的跨端能力,实现代码的转换的。
什么是 ZPL
ZPL 是斑马公司推出的一种工业上使用的控制打印机输出内容的语言,即一种页面描述语言(PDL),这种语言允许通过命令来描述页面,命令将交由打印机进行解释输出。
一段 ZPL 代码举例:
^LH0,0
^CI28
^PW800
^FO48,48^GB96,96,48,,^FS
^A@N,30,,E:SIMSUN.FNT
^FO192,48^FDInternational Shipping, Inc.^FS
^A@N,20,,E:SIMSUN.FNT
^FO192,96^FD1000 Shipping Lane^FS
^A@N,20,,E:SIMSUN.FNT
^FO192,134^FDShelbyville TN 38102^FS
^A@N,20,,E:SIMSUN.FNT
^FO192,172^FDUnited States (USA)^FS
^BY5,,270
^FO95,192^BC,,N,^FD12345678^FS
这段代码将打印出:
相较于打印 PDF,打印机直接执行 ZPL 效率是极高的且内容准确,但是如上所见,不同于语义化的 HTML 和 CSS,ZPL 是相对难理解和书写的,
那么有没有一种方法,可以减少甚至避免 ZPL 的学习成本,使得一线开发可以高效率生成 ZPL 代码?
将 React 代码转换为 ZPL 如何?
如果能像写 React 页面一样去定制打印的样式,并直接转换成 ZPL 代码,那么就可以提供极大的自由度以及避免直接接触难懂的 ZPL 代码。我们都知道 react 已经有了 react-native、react-canvas 等将 react 组件渲染到不同终端的类库。那么是不是也可以把打印机 ZPL 也当成一个“端”来实现自定义渲染?
为了实现这个目的,不妨先来看一下 react 是如何跨端渲染的。
React 是怎样跨端渲染的
React 一开始用于 Web 开发, 但它也能够适用于更多的地方, 例如 React Native 构建 IOS 和安卓移动设备,这也就是 React 的跨端渲染。
什么是跨端渲染呢?这里的“端”其实并不局限在传统的 PC 端和移动端,而是抽象的渲染层 (Renderer)。渲染层并不局限在浏览器 DOM 和移动端的原生 UI 控件,静态文件乃至虚拟现实等环境,都可以是渲染层。
那么我们可不可以认为,打印机也是一个“渲染层”,借助某种手段,可以实现 React 到 ZPL 的转换?当然可以。
在开始介绍如何为 React 适配不同渲染层之前,不妨先来了解一下 react 的三个核心:react、renderer、react-reconciler
我们都知道,在浏览器中使用 React 时,需要分别导入 react 和 react-dom,这时前端项目的整体结构可以用下图简略地表示:
但是,实际上 React 与 React DOM 之间还有一层用于连接它俩: React Reconciler(调节器),这一层平常显得有些默默无闻,书写 React 代码时根本不会触及到它,这个模块与我们定制渲染层的需求有什么关系呢?它的威力在于,只要我们为 Reconciler 提供了宿主渲染环境的配置,那么 React 就能无缝地渲染到这个环境。实际的结构如下图所示:
其中,React Dom 是 web 中的渲染模块(renderer),那么 react、react-reconciler、renderer 三者关系如下:
react
react 基础模块, 基础 API 及组件类,组件内定义 render 、setSta 方法和生命周期相关的回调方法。相关 API 如下
renderer
针对不同 host(宿主)环境采用不同的渲染方法实现,如 react-dom, react-webgl, react-native, react-canvas, 依赖 react-reconciler, 注入相应的渲染方法到 reconciler 中,相关 API 如下:
react-reconciler
调节器,连接 react 及 renderer 模块,负责调度算法及 diff,注入 setState 方法到 component 实例中,在 diff 阶段执行 react 组件中 render 方法,在 patch 阶段执行 react 组件中生命周期回调并调用 renderer 中注入的相应的方法渲染真实视图结构,其暴露的接口有:
这些接口大致可以分为以下几类:
1、 createInstance
在宿主中创建 UI 元素实例并返回该实例,比如在 react-dom 中进行,由于我们的目标是 dom 节点,将执行浏览器 document.createElement 操作并返回这个 node,如果是 react-native,那自然是调用系统 API 创建 UI 元素的操作。
2、createTextInstance
如果宿主允许创建文本在单独的文本节点,则此函数用于创建单独的文本节点。
UI 树操作
1、appendInitialChild
在 react-dom 中,映射到 domElement.appendChild 方法,调用此函数以创建初始 UI 树。
2、appendChild
在 react-dom 中,映射到 domElement.appendChild 方法,与 appendInitialChild 类似,但用于操作初始化后 ui 树
3、removeChild
映射到 domElement.removeChild,用于 ui 树的删除操作
4、appendChildToContainer
映射到 domElement.appendChild。在 react-reconciler 的 commitPhase 中调用
更新 Props
1、prepareUpdate
此接口用于在 oldProps 和 newProps 之间进行区分并决定是否更新,在外面的实现中,为简单起见,将其实现为直接返回 true
2、commitUpdate(domElement,updatePayload,type,oldProps,newProps)
此接口用于随后根据 newProps 更新 FiberProps 和 domProps
React-Reconciler 会执行这些定义的接口然后更新 UI。如果实现这些方法使用 DOM API, 那么目标就是 web。如果实现这些使用 IOS UI Kit API, 那么目标就是 IOS。如果实现这些方法使用 Andorid UI API, 目标就是 Android。
Reconciler 完整的接口列表可以在此查看: reconciler
接口列表
。
总结一下,react, react-reconciler, renderer 三者分离,针对不同的平台,定义相应的 renderer 实现即可。
在实际应用时,react-reconciler 是不可见的,被 renderer 依赖,可以到 react 的源代码仓库看一下 renderer 是如何借助 reconciler 渲染出真实 dom 的。
react-dom:
ReactDomHostConfig
react-native:
ReactFabricHostConfig
React ZPL 的总体架构
根据上文所述的概念和原理,我们实现了将 React 代码“渲染”为 ZPL 代码的类库:React ZPL
React ZPL 的总体架构如下:
接管渲染,转换为 Layer
首先,抽象出 ZPL 代码中最常用的命令,封装了一系列的 Basic Component 用来与 ZPL 中的命令映射,包括:纸张、矩形、文字、二维码、图片、表格的行、表格的列等。
文章最开始出现的那段 ZPL 代码,如果使用我们预设的基础组件来书写,将会是这个样子:
可以看到相比于直接书写 ZPL,这种以 React 代码书写的形式可以让开发者直观的感受到页面的结构,更利于开发时的排版、阅读以及后期维护。
为了实现自定义渲染这些元素,需要借助 Reconciler 的能力,重写一些接口,将 Basic Component 转换为我们期望的结果:布局对象。下面是 Reconciler 的一些配置代码:
在 hostConfig(宿主环境配置)中,通过 createInstance,将 Basic Component 根据它们的 type 进行区分,并创建一个个的 Layer 对象;在 appendChild 方法中,处理 Layer 和 Layer 之间的关系,这样就得到了一棵 Layer 树,如图:
ZPL 指令中,绘制元素都需要精确的指定元素在坐标轴上的位置以及大小。比如想要在坐标(50,50)处绘制一个 100x100 的矩形,那么指令为:
^FO50,50^GB100,100,100^FS
但是经过 Reconciler 处理后得到的 Layer 层是不具备位置信息的,每一个 Layer 中只含有上下级信息以及一些样式属性。
因此,在拿到 Layer 树后,为了得到树种每一个节点的位置信息,需要对这棵树进行布局计算。
关于布局各个平台都有自己的一套解决方案。iOS 平台有 AutoLayout,Android 有容器布局系统,而 Web 端有基于 CSS 的布局系统。因为 ¬¬¬¬ 我们的运算并不依赖浏览器环境,自然没法使用浏览器中的 CSSOM 布局。
Facebook 在 React Native 里引入了一种跨平台的基于 CSS 的布局系统,它实现了 Flexbox 规范,它就是 Yoga Layout。不同于其它的一些布局框架,比如 Bootstrap 的栅格系统或 Masonry,它们要么不够强大,要么不支持跨平台。Yoga Layout 遵循了 Flexbox 规范,同时又将布局元素抽象成 Yoga Node,为各个不同平台暴露出一组标准的接口,这样不同的平台只需实现这些接口就可以了。
基于以上原因,我们决定使用 Yoga Layout 作为布局引擎。与 Layer 层结合,在 Reconciler 生成与修改 Layer 时,在 createInstance 接口中创建 Yoga Node;appendChild 接口中为 Yoga Node 建立层级关系;removeChild 接口中删除层级关系。这样去创建、调整对应的 Yoga Node。
这样,在 Layer 树经过 Yoga Layout 的计算,将会得到一副不可见的、抽象的布局结果,如图所示:
得到 Yoga Layout 的布局结果后,通过自外向内的计算取值,便可以得到结构化的布局信息
生成 ZPL
在上一步中,我们得到了结构化的布局信息,最难的坎儿已被跨过,下面就是最终步骤了:生成 ZPL。
虽然 ZPL 是页面描述语言中使用较多的一种,但是页面描述语言也会有一些“方言”,比如爱普生公司推出的 EPL。所以根据 Basic Component 的种类,定义出抽象的 Printer,这样如果需要适配不同的“方言”,只要继承这个抽象类并重写接口即可。
这一步在整个流程中是比较简单的,但是需要很熟悉 ZPL 或其他方言的指令。同时,为了保证在不同型号不同分辨率的打印机中输出一致,在 Printer 中我们也引入“DPI”这一概念,DPI 全称 Dots Per Inch,意为每英寸长度的像素点数,用来表示打印机打印分辨率。从实际业务出发,React ZPL 中样式布局单位为毫米,只需要在 Printer 实例中设置好 DPI,组件上的毫米数将自动对应转换为像素点数。
至此,整个将 React 组件转换为 ZPL 的过程便结束了。
在整个开发过程中,对 React 的架构有了更深入的理解。React-reconciler 在 React v16 时被完全重写为了更好的实现新的算法架构 React Fiber(组织方式由栈变成了链表)。
作为为了优化体验两年重构的产物,让我们看到 Facebook 为了推进用户体验的极限所作出的努力。
这也正是 React ZPL 库的初衷:用技术为体验提供无限可能。
React ZPL 暂时没有开源打算