您當前位置>首頁 » 新聞資(zī)訊 » 小程序相關(guān) >
小程序視角下(xià)同構方案思考
發表時間:2021-1-5
發布人:葵宇科技
浏覽次數:29
NO.1
現有同構方案
其實,小程序之間的互轉相對比較簡單。得益于微信小程序的先行,各家在設計小程序 DSL 和(hé) API 時,通(tōng)常會盡量靠攏微信小程序,以降低學習成本和(hé)轉換成本。
現有同構方案大緻可(kě)以分為兩類:靜态編譯 & 動(dòng)态解析。
靜态編譯
靜态編譯的方案很多,基于 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 環境為我們提供了巨大的便利。但問(wèn)題是,JSX 直接運行在 JS 運行時上,對于許多表達式,完全無法在靜态編譯階段求值。
舉一些例子(zǐ):
// 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>
)
}
這三個(gè) DEMO 最終的 DOM(VDOM)結果都需要在運行時獲知。如(rú)果說 DEMO 1 和(hé) DEMO 2 還能通(tōng)過 AST 解析強行轉換成小程序 DSL(a:for / a:if),那 DEMO 3 就是小程序 DSL 這種靜态 DSL 的噩夢。可(kě)能有些讀者會覺得 DEMO 3 的寫法很「擡杠」,事實上這種語法在 React 世界非常常見,如(rú)著名的動(dòng)畫庫 react-spring (https://www.react-spring.io/) 。
那麼,Taro 和(hé) Rax 是如(rú)何解這些問(wèn)題的呢(ne)?
做減法。通(tōng)過對 JSX 進行「裁剪」,限制 JSX 的可(kě)用語法,以盡可(kě)能對小程序語法兼容。
先說我們比較熟悉的 Rax:Rax 在 JSX 語法的基礎上,擴展了一套 JSX+ (https://rax.js.org/docs/guide/jsxplus) 語法,讓開發者使用聲明式的方式撰寫條件渲染、循環、slot 等代碼,以替代 Array.property.map,if / else 等。這樣的好處是,可(kě)以限制開發者在 children 中(zhōng)撰寫複雜的 JavaScript 表達式,同時又不至于讓 JSX 喪失諸如(rú)條件渲染等渲染能力。
而 Taro 的路(lù)子(zǐ)相對更「友好」一些:Taro 沒有去擴展 JSX 語法,而是通(tōng)過 AST 分析,盡可(kě)能将代碼中(zhōng)的 Array.property.map、if / else ,三目表達式,枚舉渲染等轉換成了小程序可(kě)識别的靜态 DSL 。這種轉換的心智成本固然是非常高的,而且有些語法(如(rú) DEMO 3)是沒有辦法用靜态 DSL 實現的,但是能夠盡可(kě)能的還原最「原汁原味」的 JSX 開發體驗。
動(dòng)态解析
可(kě)能是由于 JSX 的接受度逐年提升,很多新生的小程序同構框架都在擁抱 React 。近兩年,在使用 JSX 撰寫 H5 + 小程序同構代碼上又有了新的思路(lù) — 動(dòng)态解析:既然 JSX 高度依賴 JavaScript 運行時,那麼我們是否可(kě)以給它創造一個(gè)運行時。典型的方案代表:Remax (https://remaxjs.org/) 和(hé) Frad (https://github.com/yisar/fard) 。
回顧一下(xià) React 的渲染路(lù)徑:
React 默認提供了 State to Virtual DOM to DOM 的方法。重點在後者:Virtual DOM to DOM。React 使用 React Reconciler 完成了 Virtual DOM to DOM 的工作。React Reconciler 允許開發者自定義更新 DOM(也可(kě)能是别的視圖層)的方式,詳見 react-reconciler (https://github.com/facebook/react/tree/master/packages/react-reconciler) 。React Native 也是通(tōng)過實現自己的 reconciler 實現視圖更新的。
既然 State to Virtual DOM 的方式 React 提供了,Virtual DOM to DOM 的方式我們又可(kě)以自定義,那麼,也許我們可(kě)以找到在小程序上通(tōng)過 Virtual DOM 表達生成小程序 DOM 的方法。
小程序提供了 template 組件 (https://opendocs.alipay.com/mini/framework/axml-template) ,用來幫助開發者動(dòng)态化的調用小程序組件。通(tōng)過 template 組件,便有機會解析 Virtual DOM,動(dòng)态生成小程序 DOM 。此處不再贅述,感興趣的讀者可(kě)以閱讀以下(xià) Remax 團隊的文(wén)章 Remax - 使用 React 開發小程序 (https://zhuanlan.zhihu.com/p/101909025) 。
NO.2
更進一步:性能
動(dòng)态解析的方案完全還原了 React 的體驗,因為它提供了完整的 JavaScript 運行時。通(tōng)過 React Reconciler,小開發者将自己從視圖層上完全解放了出來,心智停留在了 Virtual DOM 上,不再需要關(guān)心最終産物是 Web DOM 還是小程序 DOM。
但是,動(dòng)态性帶來的代價也是很清晰的:性能損耗。沒有編譯器(qì)性能調優(本來也沒有),沒有 Dead Code Elimination,沒有剪枝,對于 JavaScript 來講,就是實打實的,每一次 render ,每一個(gè)節點都要計算。再加上小程序 template 渲染本身的開銷,疊加在一起隻性能敏感的場景下(xià)(低端機 / 長列表 / 多圖)會尤其捉襟見肘。
于是,開發者又有了新的問(wèn)題:如(rú)何在保證靈活性的同時,盡可(kě)能提升渲染性能?
NO.3
業(yè)務封裝
在 Remax 的方案中(zhōng),Remax 直接使用了小程序組件作為基礎 DOM Element ,這也就意味着,每一個(gè)業(yè)務組件都要從最原子(zǐ)的 view / text 等進行渲染。然而,對于業(yè)務來講,許多業(yè)務組件是固定且可(kě)複用的,比如(rú)商(shāng)品列表中(zhōng)的商(shāng)品卡片、推薦信息流列表等。既然如(rú)此,如(rú)果我們使用原生的方式撰寫好這些組件,并将其内置到小程序 DOM 中(zhōng)(類似 Web Component),也許可(kě)以降低某些場景(如(rú)長列表)下(xià)的性能開銷。這種動(dòng)靜結合的方式,可(kě)以在不失靈活性的同時,使用原生的方式盡可(kě)能的解決渲染性能的問(wèn)題。
但是,之前的問(wèn)題又出現了:如(rú)何實現組件同構呢(ne)?
NO.4
再看同構
回顧一下(xià)靜态編譯的同構方案,不難發現一些特點:
-
同構的難點在視圖層 DSL
-
各個(gè)框架解決同構問(wèn)題時,幾乎都是 Web 優先,使用編譯工具向小程序靠攏
衆所周知,React 相比小程序要靈活得多。那麼,我們是不是可(kě)以把思路(lù)反過來:小程序優先,在小程序框架的限制内,使用 React 向小程序靠攏。
我們先忽略其他細節,把同構的問(wèn)題簡化一下(xià):
-
生命周期 & 應用狀态管理(data / setData)
-
視圖層 DSL
生命周期 & 應用狀态管理
小程序的生命周期和(hé)應用狀态管理是可(kě)以幾乎完美對應到 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
有一個(gè)問(wèn)題是,相比 React Web 應用,小程序應用在 app.js 中(zhōng)多出來一個(gè)應用啟動(dòng) / 關(guān)閉的生命周期。同時,小程序将「組件」分為了 App、Page 和(hé) Component 三種,這一點和(hé) React 是不太一樣的。為了能夠盡可(kě)能完美還原 App 的生命周期,我嘗試利用 window 對象做了一個(gè) bridge,用來動(dòng)态注冊 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 和(hé)各個(gè)生命周期函數
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'))
}
應用初始化時會預埋一個(gè) pageRegister 到 window 上,供頁面向 App 中(zhōng)注冊自己,調用方式如(rú)下(xià):
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
(以下(xià)的内容可(kě)能有一些投機取巧的成分,但也是思考良久之後寫下(xià)來的)
在研究并使用了許多視圖層同構方案之後,我想抛出一個(gè)問(wèn)題:視圖層 DSL 一定要同構麼?我認為不一定。
視圖層同構的問(wèn)題是顯而易見的:
-
Web 必須要向小程序妥協,因為小程序不可(kě)能支持所有的 HTML Element
-
同構方案高度依賴靜态編譯,在 JSX 場景下(xià)甚至依賴 AST,這其中(zhōng)的轉換是黑盒的,很難保證其中(zhōng)不會出現問(wèn)題。一旦出現問(wèn)題,這種靜态編譯生成的代碼非常難 debug (因為我們根本不知道 parser 做了什麼)
無論是小程序的 DSL 還是 React 的 render function,其模型都是很清晰的:輸入 props 和(hé) state(data),輸出結果。在實踐中(zhōng),我發現,即便将小程序的 AXML 和(hé) JSX 分開實現,也不會引入太大的心智負擔,反倒會因為沒有使用編譯工具讓整個(gè)渲染行為更加可(kě)控。
NO.5
總結
Remax 和(hé) Frad 的 Virtual DOM 思路(lù)為小程序的同構方案打開了一扇新的大門。它最大的好處在于,整套方案稍加改造即可(kě)适配到 React Native 等基于其他視圖層實現的渲染框架上,未來具有無限可(kě)能。但是,正如(rú)文(wén)中(zhōng)所說,在對應用性能十分敏感的今天,渲染性能問(wèn)題是 Remax 等動(dòng)态解析框架必須要邁過去的坎。随後我也會在這個(gè)方向做出更多的嘗試。
關(guān)于 H5 + 小程序多端構建的部分,涉及到諸如(rú)數據綁定、依賴注入、Tree Shaking 等各種問(wèn)題,我會在随後的分享中(zhōng)慢慢展開。