知識
不管是網站,軟件還是小程序,都要直接或間接能為您產生價值,我們在追求其視覺表現的同時,更側重于功能的便捷,營銷的便利,運營的高效,讓網站成為營銷工具,讓軟件能切實提升企業(yè)內部管理水平和效率。優(yōu)秀的程序為后期升級提供便捷的支持!
小程序視角下同構方案思考
發(fā)表時間:2021-1-5
發(fā)布人:葵宇科技
瀏覽次數:59
NO.1
現有同構方案
其實,小程序之間的互轉相對比較簡單。得益于微信小程序的先行,各家在設計小程序 DSL 和 API 時,通常會盡量靠攏微信小程序,以降低學習成本和轉換成本。
現有同構方案大致可以分為兩類:靜態(tài)編譯 & 動態(tài)解析。
靜態(tài)編譯
靜態(tài)編譯的方案很多,基于 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 的設計;再加上其本身就是靜態(tài)語言,沒有運行時,所以類 Vue DSL 的框架,在轉譯方案上的設計實現心智成本會低很多。而 JSX 則不然:JSX 本質就是 JavaScript 的高階語法,對于眾多 React 開發(fā)者來講,這種完全的 JavaScript 環(huán)境為我們提供了巨大的便利。但問題是,JSX 直接運行在 JS 運行時上,對于許多表達式,完全無法在靜態(tài)編譯階段求值。
舉一些例子:
// 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 這種靜態(tài) 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) 語法,讓開發(fā)者使用聲明式的方式撰寫條件渲染、循環(huán)、slot 等代碼,以替代 Array.property.map,if / else 等。這樣的好處是,可以限制開發(fā)者在 children 中撰寫復雜的 JavaScript 表達式,同時又不至于讓 JSX 喪失諸如條件渲染等渲染能力。
而 Taro 的路子相對更「友好」一些:Taro 沒有去擴展 JSX 語法,而是通過 AST 分析,盡可能將代碼中的 Array.property.map、if / else ,三目表達式,枚舉渲染等轉換成了小程序可識別的靜態(tài) DSL 。這種轉換的心智成本固然是非常高的,而且有些語法(如 DEMO 3)是沒有辦法用靜態(tài) DSL 實現的,但是能夠盡可能的還原最「原汁原味」的 JSX 開發(fā)體驗。
動態(tài)解析
可能是由于 JSX 的接受度逐年提升,很多新生的小程序同構框架都在擁抱 React 。近兩年,在使用 JSX 撰寫 H5 + 小程序同構代碼上又有了新的思路 — 動態(tài)解析:既然 JSX 高度依賴 JavaScript 運行時,那么我們是否可以給它創(chuàng)造一個運行時。典型的方案代表: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 允許開發(fā)者自定義更新 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) ,用來幫助開發(fā)者動態(tài)化的調用小程序組件。通過 template 組件,便有機會解析 Virtual DOM,動態(tài)生成小程序 DOM 。此處不再贅述,感興趣的讀者可以閱讀以下 Remax 團隊的文章 Remax - 使用 React 開發(fā)小程序 (https://zhuanlan.zhihu.com/p/101909025) 。
NO.2
更進一步:性能
動態(tài)解析的方案完全還原了 React 的體驗,因為它提供了完整的 JavaScript 運行時。通過 React Reconciler,小開發(fā)者將自己從視圖層上完全解放了出來,心智停留在了 Virtual DOM 上,不再需要關心最終產物是 Web DOM 還是小程序 DOM。
但是,動態(tài)性帶來的代價也是很清晰的:性能損耗。沒有編譯器性能調優(yōu)(本來也沒有),沒有 Dead Code Elimination,沒有剪枝,對于 JavaScript 來講,就是實打實的,每一次 render ,每一個節(jié)點都要計算。再加上小程序 template 渲染本身的開銷,疊加在一起只性能敏感的場景下(低端機 / 長列表 / 多圖)會尤其捉襟見肘。
于是,開發(fā)者又有了新的問題:如何在保證靈活性的同時,盡可能提升渲染性能?
NO.3
業(yè)務封裝
在 Remax 的方案中,Remax 直接使用了小程序組件作為基礎 DOM Element ,這也就意味著,每一個業(yè)務組件都要從最原子的 view / text 等進行渲染。然而,對于業(yè)務來講,許多業(yè)務組件是固定且可復用的,比如商品列表中的商品卡片、推薦信息流列表等。既然如此,如果我們使用原生的方式撰寫好這些組件,并將其內置到小程序 DOM 中(類似 Web Component),也許可以降低某些場景(如長列表)下的性能開銷。這種動靜結合的方式,可以在不失靈活性的同時,使用原生的方式盡可能的解決渲染性能的問題。
但是,之前的問題又出現了:如何實現組件同構呢?
NO.4
再看同構
回顧一下靜態(tài)編譯的同構方案,不難發(fā)現一些特點:
-
同構的難點在視圖層 DSL
-
各個框架解決同構問題時,幾乎都是 Web 優(yōu)先,使用編譯工具向小程序靠攏
眾所周知,React 相比小程序要靈活得多。那么,我們是不是可以把思路反過來:小程序優(yōu)先,在小程序框架的限制內,使用 React 向小程序靠攏。
我們先忽略其他細節(jié),把同構的問題簡化一下:
-
生命周期 & 應用狀態(tài)管理(data / setData)
-
視圖層 DSL
生命周期 & 應用狀態(tài)管理
小程序的生命周期和應用狀態(tài)管理是可以幾乎完美對應到 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 = http://www.wxapp-union.com/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,用來動態(tài)注冊 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 = http://www.wxapp-union.com/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 必須要向小程序妥協(xié),因為小程序不可能支持所有的 HTML Element
-
同構方案高度依賴靜態(tài)編譯,在 JSX 場景下甚至依賴 AST,這其中的轉換是黑盒的,很難保證其中不會出現問題。一旦出現問題,這種靜態(tài)編譯生成的代碼非常難 debug (因為我們根本不知道 parser 做了什么)
無論是小程序的 DSL 還是 React 的 render function,其模型都是很清晰的:輸入 props 和 state(data),輸出結果。在實踐中,我發(fā)現,即便將小程序的 AXML 和 JSX 分開實現,也不會引入太大的心智負擔,反倒會因為沒有使用編譯工具讓整個渲染行為更加可控。
NO.5
總結
Remax 和 Frad 的 Virtual DOM 思路為小程序的同構方案打開了一扇新的大門。它最大的好處在于,整套方案稍加改造即可適配到 React Native 等基于其他視圖層實現的渲染框架上,未來具有無限可能。但是,正如文中所說,在對應用性能十分敏感的今天,渲染性能問題是 Remax 等動態(tài)解析框架必須要邁過去的坎。隨后我也會在這個方向做出更多的嘗試。
關于 H5 + 小程序多端構建的部分,涉及到諸如數據綁定、依賴注入、Tree Shaking 等各種問題,我會在隨后的分享中慢慢展開。