小程序視角下(xià)同構方案思考 - 新聞資(zī)訊 - 雲南小程序開發|雲南軟件開發|雲南網站(zhàn)建設-西山區知普網絡科技工作室

159-8711-8523

雲南網建設/小程序開發/軟件開發

知識

不管是網站(zhàn),軟件還是小程序,都要直接或間接能為您産生價值,我們在追求其視覺表現的同時,更側重于功能的便捷,營銷的便利,運營的高效,讓網站(zhàn)成為營銷工具,讓軟件能切實提升企業(yè)内部管理水平和(hé)效率。優秀的程序為後期升級提供便捷的支持!

小程序視角下(xià)同構方案思考

發表時間:2021-1-5

發布人:葵宇科技

浏覽次數:29

着各家閉環生态的建設發展,小程序已經成為了各個(gè)業(yè)務不可(kě)缺少(shǎo)的一部分。各家為了提升自己在應用内生态上的可(kě)控性,都給出了自己的小程序方案,如(rú):支付寶小程序、微信小程序、京東小程序等。對于業(yè)務研發團隊來講,如(rú)何實現多平台适配(H5 + 各端小程序)一直是擺在面前的一道難題。

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)慢慢展開。

相關(guān)案例查看更多