回复 刷新

暂无评论

小程序视角下同构方案思考

随着各家闭环生态的建设发展,小程序已经成为了各个业务不可缺少的一部分。各家为了提升自己在应用内生态上的可控性,都给出了自己的小程序方案,如:支付宝小程序、微信小程序、京东小程序等。对于业务研发团队来讲,如何实现多平台适配(H5 + 各端小程序)一直是摆在面前的一道难题。

NO.1、现有同构方案

其实,小程序之间的互转相对比较简单。得益于微信小程序的先行,各家在设计小程序 DSL 和 API 时,通常会尽量靠拢微信小程序,以降低学习成本和转换成本。

现有同构方案大致可以分为两类:静态编译 & 动态解析。

静态编译

静态编译的方案很多,基于 Vue DSL 的有 Chameleon、MPVue等,基于 React JSX 的有 Taro、Rax 等。

由于小程序的 DSL 本身就有参考 Vue 的设计;再加上其本身就是静态语言,没有运行时,所以类 Vue DSL 的框架,在转译方案上的设计实现心智成本会低很多。而 JSX 则不然:JSX 本质就是 JavaScript 的高阶语法,对于众多 React 开发者来讲,这种完全的 JavaScript 环境为我们提供了巨大的便利。但问题是,JSX 直接运行在 JS 运行时上,对于许多表达式,完全无法在静态编译阶段求值。

举一些例子:

// DEMO 1 function DemoA({list}) { return ( <div> {list.map(item => <div key={item.id}>{item.content}</div>)} </div> ) } // DEMO 2 function DemoB({visible}) { if (!visible) { return null } return <div>cool</div> } // DEMO 3 function SomeFunctionalRender({children, ...props}) { return typeof children === 'function' ? children(props) : null } function DemoC() { return ( <SomeFunctionalRender> {props => <div>{props.content}</div>} </SomeFunctionalRender> ) }

这三个 DEMO 最终的 DOM(VDOM)结果都需要在运行时获知。如果说 DEMO 1 和 DEMO 2 还能通过 AST 解析强行转换成小程序 DSL(a:for / a:if),那 DEMO 3 就是小程序 DSL 这种静态 DSL 的噩梦。可能有些读者会觉得 DEMO 3 的写法很「抬杠」,事实上这种语法在 React 世界非常常见,如著名的动画库 react-spring。

那么,Taro 和 Rax 是如何解这些问题的呢?

做减法。通过对 JSX 进行「裁剪」,限制 JSX 的可用语法,以尽可能对小程序语法兼容。

先说我们比较熟悉的 Rax:Rax 在 JSX 语法的基础上,扩展了一套 JSX+ 语法,让开发者使用声明式的方式撰写条件渲染、循环、slot 等代码,以替代 Array.property.map,if / else 等。这样的好处是,可以限制开发者在 children 中撰写复杂的 JavaScript 表达式,同时又不至于让 JSX 丧失诸如条件渲染等渲染能力。

而 Taro 的路子相对更「友好」一些:Taro 没有去扩展 JSX 语法,而是通过 AST 分析,尽可能将代码中的 Array.property.map、if / else ,三目表达式,枚举渲染等转换成了小程序可识别的静态 DSL 。这种转换的心智成本固然是非常高的,而且有些语法(如 DEMO 3)是没有办法用静态 DSL 实现的,但是能够尽可能的还原最「原汁原味」的 JSX 开发体验。

动态解析

可能是由于 JSX 的接受度逐年提升,很多新生的小程序同构框架都在拥抱 React 。近两年,在使用 JSX 撰写 H5 + 小程序同构代码上又有了新的思路 — 动态解析:既然 JSX 高度依赖 JavaScript 运行时,那么我们是否可以给它创造一个运行时。

回顾一下 React 的渲染路径:

在这里插入图片描述

React 默认提供了 State to Virtual DOM to DOM 的方法。重点在后者:Virtual DOM to DOM。React 使用 React Reconciler 完成了 Virtual DOM to DOM 的工作。React Reconciler 允许开发者自定义更新 DOM(也可能是别的视图层)的方式,详见 react-reconcile。React Native 也是通过实现自己的 reconciler 实现视图更新的。

既然 State to Virtual DOM 的方式 React 提供了,Virtual DOM to DOM 的方式我们又可以自定义,那么,也许我们可以找到在小程序上通过 Virtual DOM 表达生成小程序 DOM 的方法。

小程序提供了 template 组件,用来帮助开发者动态化的调用小程序组件。通过 template 组件,便有机会解析 Virtual DOM,动态生成小程序 DOM 。

NO.2、更进一步:性能

动态解析的方案完全还原了 React 的体验,因为它提供了完整的 JavaScript 运行时。通过 React Reconciler,小开发者将自己从视图层上完全解放了出来,心智停留在了 Virtual DOM 上,不再需要关心最终产物是 Web DOM 还是小程序 DOM。

但是,动态性带来的代价也是很清晰的:性能损耗。没有编译器性能调优(本来也没有),没有 Dead Code Elimination,没有剪枝,对于 JavaScript 来讲,就是实打实的,每一次 render ,每一个节点都要计算。再加上小程序 template 渲染本身的开销,叠加在一起只性能敏感的场景下(低端机 / 长列表 / 多图)会尤其捉襟见肘。

于是,开发者又有了新的问题:如何在保证灵活性的同时,尽可能提升渲染性能?

NO.3、业务封装

在 Remax 的方案中,Remax 直接使用了小程序组件作为基础 DOM Element ,这也就意味着,每一个业务组件都要从最原子的 view / text 等进行渲染。然而,对于业务来讲,许多业务组件是固定且可复用的,比如商品列表中的商品卡片、推荐信息流列表等。既然如此,如果我们使用原生的方式撰写好这些组件,并将其内置到小程序 DOM 中(类似 Web Component),也许可以降低某些场景(如长列表)下的性能开销。这种动静结合的方式,可以在不失灵活性的同时,使用原生的方式尽可能的解决渲染性能的问题。

但是,之前的问题又出现了:如何实现组件同构呢?

NO.4、再看同构

回顾一下静态编译的同构方案,不难发现一些特点:

  1. 同构的难点在视图层 DSL
  2. 各个框架解决同构问题时,几乎都是 Web 优先,使用编译工具向小程序靠拢

众所周知,React 相比小程序要灵活得多。那么,我们是不是可以把思路反过来:小程序优先,在小程序框架的限制内,使用 React 向小程序靠拢。

我们先忽略其他细节,把同构的问题简化一下:

  1. 生命周期 & 应用状态管理(data / setData)
  2. 视图层 DSL

生命周期 & 应用状态管理

小程序的生命周期和应用状态管理是可以几乎完美对应到 React 的 Class Component 上的。话不多说,上代码:

import React from 'react' import omit from 'lodash/omit' import noop from 'lodash/noop' function createComponent(comp) { const { data, onInit = noop, deriveDataFromProps = noop, didMount = noop, didUpdate = noop, didUnmount = noop, methods = {}, render, } = comp return class extends React.Component { constructor(props) { super(props) this.state = { ...data, } this.setData = this.setState this.__init() } get data() { return this.state } __init() { for (let key in methods) { this[key] = methods[key] } onInit.call(this, data) } componentWillMount() { deriveDataFromProps.call(this, this.props) } componentDidMount() { didMount.call(this) } componentWillReceiveProps(nextProps) { deriveDataFromProps.call(this, nextProps) } componentWillUpdate(nextProps, nextState) { deriveDataFromProps.call(this, nextProps) } componentDidUpdate(prevProps, prevState) { didUpdate.call(this, prevProps, prevState) } componentWillUnmount() { didUnmount.call(this) } render() { if (render) { return render.call(this) } return null } } } export default createComponent

有一个问题是,相比 React Web 应用,小程序应用在 app.js 中多出来一个应用启动 / 关闭的生命周期。同时,小程序将「组件」分为了 App、Page 和 Component 三种,这一点和 React 是不太一样的。为了能够尽可能完美还原 App 的生命周期,我尝试利用 window 对象做了一个 bridge,用来动态注册 Page:

import React from 'react' import ReactDOM from 'react-dom' import { hot } from 'react-hot-loader' export class PageRegister { constructor() { if (window.__PageRegister) { return window.__PageRegister } this.__page = () => null this.__handlers = [] window.__PageRegister = this } subscribe = (cb) => { this.__handlers.push(cb) } unsubscribe = (cb) => { this.__handlers = this.__handlers.filter((handler) => handler !== cb) } destroy() { this.__handlers = [] this.__page = function () { return null } } setPage = (page) => { this.__page = page this.__handlers.map((cb) => typeof cb === 'function' && cb(page)) } getPage = () => this.__page } // TODO: 处理 App globalData 和各个生命周期函数 export default function createApp(app) { const pageRegister = new PageRegister() class __App extends React.Component { constructor(props) { super(props) this.state = { page: pageRegister.getPage(), } pageRegister.subscribe((page) => this.setState({ page })) } componentWillUnmount() { pageRegister.destroy() } render() { const { page: Page } = this.state return <Page /> } } const App = __DEV__ ? hot(module)(__App) : __App ReactDOM.render(<App />, document.getElementById('root')) } 应用初始化时会预埋一个 pageRegister 到 window 上,供页面向 App 中注册自己,调用方式如下: import React from 'react' import noop from 'lodash/' import { PageRegister } from '../createApp' function createPage(page) { const pageRegister = new PageRegister() const { data, onInit = noop, methods, render } = page class Page extends React.Component { constructor(props) { super(props) this.state = { ...data } this.setData = this.setState this.__init() } get data() { return this.state } __init() { for (let key in methods) { this[key] = methods[key] } onInit.call(this, data) } render() { if (render) { return render.call(this) } return null } } pageRegister.setPage(Page) return Page } export default createPage

视图层 DSL

(以下的内容可能有一些投机取巧的成分,但也是思考良久之后写下来的)

在研究并使用了许多视图层同构方案之后,我想抛出一个问题:视图层 DSL 一定要同构么?我认为不一定。

视图层同构的问题是显而易见的

  1. Web 必须要向小程序妥协,因为小程序不可能支持所有的 HTML Element
  2. 同构方案高度依赖静态编译,在 JSX 场景下甚至依赖 AST,这其中的转换是黑盒的,很难保证其中不会出现问题。一旦出现问题,这种静态编译生成的代码非常难 debug (因为我们根本不知道parser 做了什么)

无论是小程序的 DSL 还是 React 的 render function,其模型都是很清晰的:输入 props 和 state(data),输出结果。在实践中,我发现,即便将小程序的 AXML 和 JSX 分开实现,也不会引入太大的心智负担,反倒会因为没有使用编译工具让整个渲染行为更加可控。

NO.5、总结

Remax 和 Frad 的 Virtual DOM 思路为小程序的同构方案打开了一扇新的大门。它最大的好处在于,整套方案稍加改造即可适配到 React Native 等基于其他视图层实现的渲染框架上,未来具有无限可能。但是,正如文中所说,在对应用性能十分敏感的今天,渲染性能问题是 Remax 等动态解析框架必须要迈过去的坎。随后我也会在这个方向做出更多的尝试。

关于 H5 + 小程序多端构建的部分,涉及到诸如数据绑定、依赖注入、Tree Shaking 等各种问题,我会在随后的分享中慢慢展开。

文章转载自公众号:淘系前端团队 作者:锦襜

  • 70
  • 0
  • 0