uni-app、Vue3 + ucharts 圖表 H5 無法渲染

李永寧發表於2022-02-17

文章已收錄到 github,歡迎 Watch 和 Star。

簡介

從問題定位開始,到給框架(uni-app)提 issue、出解決方案(PR),再到最後的思考,詳細記錄了整個過程。

前序

當你在業務中不幸踩了開源框架的某些坑,這是你的不幸,但這同時也是你的幸運,因為這是你給自己簡歷中增加亮點的絕佳機會。

而給開源社群貢獻 PR 是你證明自己技術側擁有 P7 實力的絕佳方式,P7 的評判標準無非是業務和技術,業務上有收益,技術上有深度和廣度(別人有的你能做的更好,別人沒有的你能有)。

這次整個過程歷時 3-4 天,在此之前我也沒讀過 uni-app 和 ucharts 的原始碼,所以這裡把整個過程分享出來也是給大家一個解決問題的思路。

環境

  • uni-app cli 版本 3.0.0-alpha-3030820220114011
  • hbuilder 版本 3.3.8.20220114-alpha
  • ucharts 版本 uni-modules 2.3.7-20220122

現象

uni-app、vue3 + ucharts 繪製圖表,開發環境正常,但是打包上線後,H5 無法繪製圖表,也不報任何錯誤。

開發 線上
APP 正常 正常
H5 正常 無法繪製

問題定位

給 ucharts 的社群提 issue,經過交流,維護者 “懷疑“ 是 uni-app 的 vue3 的 renderjs 有問題,但是他也給不了一個肯定的答覆,讓去 uni-app 的社群提 issue 而且示例中不能用 ucharts。個人對於該回答持懷疑態度,於是決定自己去定位問題。

懷疑是 ucharts 的 bug

  • ucharts 檢視部分的關鍵程式碼
<view ...其它屬性 :prop="uchartsOpts" :change:prop="rdcharts.ucinit">
  <canvas ...屬性 />
</view>

這裡有一個知識點需要補充:當 prop 發生改變,change:prop 的回撥會被呼叫,這是 uni-app 框架提供的能力,但官方文件沒有提及,從原始碼中可以看到。

  • 看了 ucharts 的原始碼,繪製圖表時的程式碼執行過程如下:

可是打包後的 H5 線上環境,當執行 this.uchartsOpts = newConfig 之後卻沒有觸發 change:prop 事件,所以這看起來似乎是 uni-app 的 view 元件有問題

感謝 ucharts 官方,在定位問題過程中,和社群進行交流後,ucharts 免費贈送了一個永久超級會員,感謝 ? ? !!

view 元件的 prop 和 change:prop

提供如下示例:

<template>
  <view>
    <view :prop="counter" :change:prop="changeProp"></view>
		<view>{{ msg }}</view>
  </view>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";

const counter = ref(1)
const msg = ref('hello')

function changeProp() {
	msg.value = 'hello' + counter.value
}

// @ts-ignore
let timer = null
onMounted(() => {
	timer = setInterval(() => {
		counter.value += 1
	}, 1000)
})

onBeforeUnmount(() => {
	// @ts-ignore
	clearInterval(timer)
})
</script>

<style>
</style>

H5 開發環境 H5 打包後
vue2 正常 正常
vue3 正常 change:prop 未執行

因為開發環境沒有問題,所以在開發環境中通過在 change:prop 方法中打斷點,檢視呼叫棧,找到觸發 change:prop 回撥的方法,再一步步往上看,終於發現了 uni-app 重寫渲染器(render 函式)的地方,在 @dcloudio/uni-h5-vue/dist/vue.runtime.esm.js 中。​

通過閱讀 uni-app 的原始碼,得到如下內容:

響應式資料發生變化,觸發 vue 的響應式更新。比如你的響應式資料作為元素的 prop 屬性傳遞,則在 patch 階段會觸發 patchProps 方法, 觸發該方法後,方法內判斷新老 props 是否發生改變,如果變了,則遍歷新的 props 物件,將其中的每個屬性、值和老的對比,如果不相等 或者 props 的 key 為 change:xx 則直接呼叫 patchProp 方法,如果 __UNI_FEATURE_WXS__為真並且 props 的 key 為 change: 開頭,則呼叫 patchWxs,patchWxs 方法最終會通過 nextTick 呼叫 change:prop 的回撥方法。

以下為上述執行過程的流程圖:

最終定位到問題就出在 __UNI_FEATURE_WXS__上,發現開發環境中它是 true,但是打包後就變成了 false。

__UNI_FEATURE_WXS__

__UNI_FEATURE_WXS__是一個全域性變數,所以肯定是通過 vite 的 define 選項進行設定的。

於是接下來的目的就是需要找到 __UNI_FEATURE_WXS__是在什麼地方進行設定的。可以全域性搜該變數,然後找到在 @dcloudio/uni-cli-shared 包中找到一個叫 initFeatures 的方法,該方法中宣告瞭一個 features 物件:

const {
  wx,
  wxs,
  // ...其它變數
} = extend(
  initManifestFeature(options),
  // ... 其它方法
)

const features = {
  // vue
  __VUE_OPTIONS_API__: vueOptionsApi, // enable/disable Options API support, default: true
  __VUE_PROD_DEVTOOLS__: vueProdDevTools, // enable/disable devtools support in production, default: false
  // uni
  __UNI_FEATURE_WX__: wx, // 是否啟用小程式的元件例項 API,如:selectComponent 等(uni-core/src/service/plugin/appConfig)
  __UNI_FEATURE_WXS__: wxs, // 是否啟用 wxs 支援,如:getComponentDescriptor 等(uni-core/src/view/plugin/appConfig)
  // ... 其它屬性
}

看了該物件的設定沒什麼問題,wxs在開發和生產環境下都是 true。那接下來就需要找到誰呼叫了 initFeatures 方法,而且可能呼叫完了以後通過判斷當前命令,比如:執行 build 時,將 __UNI_FEATURE_WXS__設定為了 false。

剛開始想正向推導。vite-plugin-uni 是 uni-app 提供給 vite 的一個外掛框架,uni-app 中的 vite 配置都來自於這裡。

外掛當中的 uni 外掛提供了 config 選項,config 選項的值是呼叫 createConfig 方法返回的函式,該函式會返回一個物件,該物件會和 vite 的配置做深度合併;該物件有 define 選項,該選項的值為 createDefine 函式的返回值,該返回值是一個物件,其中呼叫了 initDefine,再往下看發現不對,然後路 走死了。

發現上面正向推導的方式走不通以後,於是開始反向推導,即全域性搜尋,都有哪些地方呼叫了 initFeatures,然後一步步的往下推,得到如下正確的流程圖:

經過最終的除錯,發現 啟動開發環境和打包時最終的呼叫路徑是:uniH5Plugin -> createConfig -> configDefine -> initFeatures。
而最終的問題也就是出在了 initFeatures 方法呼叫的 initManifestFeature 方法中。

答案

最終定位到出問題的地方在 @dcloudio/uni-cli-shared/src/vite/features.ts 檔案的 initManifestFeature 方法中。有如下對比:

  • github 倉庫的最新程式碼,版本號:3.0.0-alpha-3030820220114011
if (command === 'build') {
    // TODO 需要預編譯一遍?
    // features.wxs = false
    // features.longpress = false
  }
  • 已發版的程式碼,最高版本號:3.0.0-alpha-3031120220208001
if (command === 'build') {
    // TODO 需要預編譯一遍?
    features.wxs = false;
    features.longpress = false;
}

已發版的版本居然高於倉庫內的最新版本號。檢視 npm 上的釋出版本資訊:

發現版本號發生了回退。這幾次回退的版本號都是不符合規範的版本號,而且其中可能攜帶了 bug,比如上面提到的最高版本。

發版出現版本號不符合規範的情況是由於專案還沒有一個規範的發版流程導致的,但是已經是 alpha 版本了,這種低階錯誤還是應該避免的。

更致命的操作是,回退版本號。uni-app 目前每次升級都是升級的最小版本號後面的數值,而業務專案的 package.json 都是 "@dcloudio/uni-app": "^xxx" 的形式,這就意味著,你每次重新裝包(比如自動化部署時)或者升級包時,都會更新到這個存在 bug 的高版本,這就會導致線上系統報 bug。

解決方案

所以這裡正確的處理方式是重新發一個更高版本的包,而不是回退版本。因為該操作會導致使用者線上的系統出 bug,即以下程式碼無法正常執行:

<view :prop="msg" :change:prop="cb"></view>

當正常情況下,當 msg 改變後,change:prop 的回撥會執行。但是這個攜帶 bug 的高版本包,在打包時(npm run build)將 __UNI_FEATURE_WXS__設定為了 false,導致 change:prop 的回撥不會被呼叫。

總結

程式碼可以回退,但是版本號不要回退,應該基於當前穩定版本,重新發一版版本號更高的版本。

於是就給官方提了 issue 和 解決方案

結果

官方已採納該解決方案,基於當前穩定版重新發布一版版本號更高的版本。

思考

針對 uni-app 這種處於 alpha 版本的框架,專案內部也確實不應該繼續使用 ^ 符號,還是應該將版本號寫死為最新的 tag 版本,因為總跟隨 alpha 的最新版,確實可能會踩坑。

連結

文章已收錄到 github,歡迎 Watch 和 Star。

相關文章