查看原文
其他

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

锦襜 淘系前端团队 2020-10-28

文末福利:淘系 618 实践小册

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

NO.1

现有同构方案

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

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

静态编译


静态编译的方案很多,基于 Vue DSL 的有 Chameleon(https://cml.js.org/) 、MPVue(http://mpvue.com/) 等,基于 React JSX 的有 Taro(https://nervjs.github.io/taro/)、Rax(https://rax.js.org/) 等。

由于小程序的 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(https://www.react-spring.io/) 。

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

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

先说我们比较熟悉的 Rax:Rax 在 JSX 语法的基础上,扩展了一套 JSX+(https://rax.js.org/docs/guide/jsxplus) 语法,让开发者使用声明式的方式撰写条件渲染、循环、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 运行时,那么我们是否可以给它创造一个运行时。典型的方案代表:Remax(https://remaxjs.org/) 和 Frad(https://github.com/yisar/fard) 。

回顾一下 React 的渲染路径:

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

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

小程序提供了 template 组件(https://opendocs.alipay.com/mini/framework/axml-template),用来帮助开发者动态化的调用小程序组件。通过 template 组件,便有机会解析 Virtual DOM,动态生成小程序 DOM 。此处不再赘述,感兴趣的读者可以阅读以下 Remax 团队的文章 Remax - 使用 React 开发小程序(https://zhuanlan.zhihu.com/p/101909025) 。

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

再看同构

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

  • 同构的难点在视图层 DSL

  • 各个框架解决同构问题时,几乎都是 Web 优先,使用编译工具向小程序靠拢

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

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

  • 生命周期 & 应用状态管理(data / setData)

  • 视图层 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 一定要同构么?我认为不一定。

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

  • Web 必须要向小程序妥协,因为小程序不可能支持所有的 HTML Element

  • 同构方案高度依赖静态编译,在 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 等各种问题,我会在随后的分享中慢慢展开。

感谢阅读。


✿   拓展阅读


请务必给 child_process 加上 on('data') 处理

淘系前端互动引擎EVAJS架构与生态实现

10 个你可能还不知道 VS Code 使用技巧

✿   文末福利

回复 “618” 有福利

欢迎关注东半球最大的前端团队


喜欢就点这里

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存