跨端開發一直是前端領域的重要部分,旨在實現一套代碼在多個平台運行。國內使用 uniapp 框架人數較多,一直有外部聲音想要 uniapp 版本的 TDesign,如 TDesign Miniprogram 下的眾多 issue。

原生小程序和 uniapp 有差異,有人在 uniapp 項目裡用了原生小程序組件,需要魔改內部組件代碼。
基於以上需求,寫了 TDesign UniApp 項目。支持:
歡迎使用,歡迎 star,歡迎反饋!
掃碼查看 ↓

(注:其他平台同樣支持,僅因平台審核等原因未能上架預覽,不影響組件庫正常使用。)
NPM 方式
npm i tdesign-uniapp
UNI_MODULES 方式
已上傳插件到 DCloud 插件市場,請打開插件詳情頁並點擊 使用 HBuilderX 導入插件。
main.ts 中引入樣式文件
import 'tdesign-uniapp/common/style/theme/index.css';
在文件中使用
<template>
<t-loading />
</template>
<script lang="ts" setup>
import TLoading from 'tdesign-uniapp/loading/loading.vue';
</script>
在 pages.json 配置 easycom,可實現自動導入。
使用 CLI 模式,即使用 node_modules 下的 tdesign-uniapp 時,配置如下。
{
"easycom": {
"custom": {
"^t-(.*)": "tdesign-uniapp/$1/$1.vue"
}
}
}
使用 uni_modules 下的 tdesign-uniapp 時,配置如下。
{
"easycom": {
"custom": {
"^t-(.*)": "@/uni_modules/tdesign-uniapp/components/$1/$1.vue"
}
}
}
| 平台 | Vue2 | Vue3 | H5 | Android | iOS | App-nvue | 微信小程序 | QQ小程序 |
|---|---|---|---|---|---|---|---|---|
| 支持情況 | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅ |
| 平台 | 支付寶小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 小紅書小程序 | 京東小程序 |
|---|---|---|---|---|---|---|
| 支持情況 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
有幾點是做之前要想清楚的。
tdesign-uniapp 有獨立的版本,並不與 tdesign-miniprogram 的版本相同。這是因為轉換後的產物很有可能有自己的 feature/bug,處理需要發版,必然導致版本分叉。
多個 tdesign-uniapp 版本會對應一個 tdesign-miniprogram 版本,會儘量提供 miniprogram 最新版本的轉換產物。
API 一定要與官方一致,這是最不能妥協的,包括 props、events、事件參數,參數類型、插槽、CSS變數。
這樣做的好處是,開發者沒有額外心智負擔,同時限制開發人員的胡亂發揮,以及減少開發者的決策成本。
API 儘量與小程序對齊,而不是 mobile-vue/mobile-react,因為 uniapp 語法主要是小程序的語法。
之前寫過 Press UI,整體思路差不多。就是將小程序的 wxml/wxss/js/json 轉成 uniapp 的 Vue,四個文件合成一個文件。以及將小程序的語法進行轉化,以下是核心部分:
value: ([^{]+),to: default: $1其他部分,如 externalClasses、relations,以及組件庫特有的受控屬性、命令調用等都需要進行額外的處理。
tdesign-miniprogram 中的事件參數,在 tdesign-uniapp 中都被去掉了 detail 一層。以 Picker 組件為例,在 tdesign-miniprogram 中,這樣獲取參數
onPickerChange(e) {
console.log(e.detail.value);
}
在 tdesign-uniapp 中,需要去掉 .detail,即
onPickerChange(e) {
console.log(e.value);
}
這樣做是為了簡化使用。tdesign-uniapp 中所有組件都採用了這種方式。
tdesign-uniapp 中支持命令調用的組件有
TDesign UniApp 下,命令調用的核心思路是數據轉化,就是把所有 props 都聲明成 data,比如 visible => dataVisible,這樣組件自身才能既能從方法(methods)中得到值,又能從 props 中得到值。要改的地方包括
data 中初始化watch 中監聽setData 收口,設置的時候都加上特殊開頭每個組件具體實現不同。
Message 嵌套了一層 message-item,message-item 沒有 props,都是 setData 直接給的 data,所以根本不需要轉換。
props,而是調用子組件內部方法。setMessage(組件調用、命令調用都走) => addMessage ( => showMessageItem) 或者 updateMessagesetMessage/addMessage/showMessageItem 都是指的內部的 message-item,是循環的 messageList,而不是頁面級別的 t-messageDialog、ActionSheet 需要轉換
setData,將屬性(包含 visible: true)傳進去,同時將 instance 的 _onConfirm 設置為 promise 的 resolveToast 沒有組件調用,只有命令式,無需數據轉換。
instance.show,內部還是 setData存在受控屬性的非表單組件有
TDesign UniApp 中受控屬性的處理,和小程序版本差不多。是將其轉成 data 開頭的內部屬性,初始化的時候,會判斷受控和非受控值。同時觸發事件的時候也要判斷當前是否存在受控屬性,非受控的時候直接改變內部值並拋出事件,受控的時候只拋出事件。以及,props 中受控屬性的默認值需是 null 或 undefined。
不同的是,小程序受控屬性,可以使用 this.setData({ [value]: this.defaultValue }),也就是 data 中聲明了與 properties 名稱一樣的變數,Vue 中不可以,會報錯 'set' on proxy: trap returned falsish for property 'value'
總結下來,受控屬性要處理的:
watch 中監聽created 中初始化methods 中新增 _trigger,作為拋出事件的收口tdesign-miniprogram 執行 npm run build,在 miniprogram_dist/node_modules 目錄下 拿到 dayjs 和 tinycolor2 的產物,複製到 tdesign-uniapp 的 npm 目錄下,用啥拿啥。
一次性工作,一般不會改。
H5 下,uni-app 封裝了 input,且不支持受控。
Input 限制中文字符在 uni-app 實現的話,解決方案是先設置一次,然後在 nextTick 中再設置一次。
參考:ask.dcloud.net.cn/article/39736
其他方案:
input 元素,不用 uni-app 包裹的,缺點是更新屬性麻煩。maxlength,用瀏覽器原生屬性約束,缺點是實現稍複雜、程式碼量稍多。uni-app 下,externalClasses 是不生效的。
參考:
所以 styleIsolation: apply-shared 不夠用,只能改成 styleIsolation: shared,這樣開發者才能在任意使用的地方覆蓋組件樣式。
可以改下 packages/site/node_modules/@dcloudio/uni-mp-compiler/dist/transforms/transformComponent.js,把 isComponentProp 方法,將 t-class 排除,就能解決,但官方不會推出。
tdesign-uniapp 必須加 scoped,否則一個自訂組件加了 styleIsolation: shared,同一頁面下其他沒加此屬性的自訂組件也會生效,只要 class 相同!
統一用 tClass,而不是 class。

Drawer 頂部過高,是因為子組件 popup 中使用的 --td-popup-distance-top 變數為 0,這個變數由 distanceTop 生成,distanceTop 又是由 using-custom-navbar 這個 mixin 生成。
distanceTop 由 uni.getMenuButtonBoundingClientRect 計算生成,H5 和 App 下沒有這個API,可以直接傳入 customNavbarHeight,這個值由業務自行計算得到。
目前使用到 using-custom-navbar 這個 mixin 的組件有
APP-PLUS 下,動態監聽 onPageScroll 不生效,需要業務自己在頁面中監聽,下面給出最佳實踐之一。
// 頁面 Vue 文件下,引入組件庫提供的監聽方法
// 該方法內部會通過 event-bus,傳遞參數給對應的組件
import { handlePageScroll } from 'tdesign-uniapp/mixins/page-scroll';
export default {
onPageScroll(e) {
handlePageScroll(e);
},
};
目前使用到 page-scroll 這個 mixin 的組件有
示例頁面有
Cannot read properties of null (reading 'parentElement')

這種就是 mounted 之後沒延時,沒獲取到對應元素。
tdesign-uniapp 在 H5 下使用 vite.config 中的 alias,不使用 workspace,可解決修改組件後必須重啟才能生效。
小程序下,這種方式需要進一步改造,只能引用同一個子工程,即不能跨 src,解決方案就是監聽組件變動,同步複製到 site 工程下。
小程序的 observers 和 vue 的 watch 邏輯並不完全相同,小程序下,如果 prop 接收外部傳入的實參與該 prop 的默認值不相等時,會導致 observer 被立即調用一次,Vue 而不是。
image 中 calcSize 中就用到了。
開發了 auto-import-resolver 插件,但發現微信小程序下編譯有問題,H5 下正常,推測是 uniapp 自己的問題。

可以使用 easycom 模式。
⚠️ 注意,easycom 不支持 TIcon 這種大駝峰,只能是 t-icon,這種中劃線形式。
下面幾個組件在關閉時,需要父組件中設置 visible 為 false,否則無法再次開啟。也就是 visible 只能是受控的。可以給 visible 屬性增加 v-model 語法糖。
支付寶小程序只支持在 json 文件中配置 styleIsolation,參考文檔。
uni-app 會靜態分析組件中的 styleIsolation 配置,放到組件對應的 json 文件中。源碼地址:packages/uni-mp-vite/src/plugins/entry.ts。
正則表達式如下:
const styleIsolationRE = [
/defineOptions\s*[\s\S]*?styleIsolation\s*:\s*['"](isolated|apply-shared|shared)['"]/,
/export\s+default\s+[\s\S]*?styleIsolation\s*:\s*['|"](isolated|apply-shared|shared)['|"]/,
];
所以,不能用 uniComponent 在運行時添加,只能在 Vue 中顯式聲明。
Stepper 中需顯式聲明 background 和 padding。


Search 中同樣問題。


滾動穿透問題,uniapp 有通用方案,支付寶下無效,需要設置 disable-scroll。參考文檔。

⚠️ 注意,設置 disable-scroll 為 true 後,所有子元素的滾動都不能冒泡了,即便子元素設置的 disable-scroll 為 false,所以也盡可能減少 disable-scroll 屬性的覆蓋範圍。
避免 less 中兩個 :deep 嵌套,其中一個不會被轉化。


微信小程序 scroll-view,寬度 100%。支付寶小程序不是,需手動設置,不設置的話,撐不開。


遇到一個點擊事件不能傳遞的問題,排查下來以為是不能用 uniComponent 包裹,猜測其內部會靜態檢測 js 文件。後面發現是不能使用 virtualHost: true,不止 button 組件,其他組件也不一樣。
抖音小程序原生的話,可以用 externalClasses 來進行樣式覆蓋,但前面提到過 uni-app 不支持。
它也不支持標籤選擇器,加上剛說的不能用 virtualHost: true,所以它的樣式穿透是最麻煩的。
解決方案是,根據具體情況,對 class/t-class/style/custom-style 這些屬性區分平臺處理,比如
btn 用了 class/t-class 區分,radio-group/checkbox-group 用了 custom-styleavatar 用了 setStyle(children 獲取),因為 avatar 是外部定義的,無法用 custom-styleclass,不能用 custom-style抖音小程序給兩個組件綁定父子關係也是最複雜的,其他小程序及H5可以通過 provide/inject 來收集 parent,抖音小程序中找不到(下面部分截圖是放的 PressUI 組件庫的)。

這裡想到一個辦法是遞歸調用 $parent,找最近的一個和目標組件名稱相同的 parent。比如 picker-item 中就找組件名稱為 TPicker 最近的父組件。
但是,抖音小程序子孫組件的 $parent 竟然就是頁面,頁面的所有 $children 都是拉平的。基於此,想到的辦法是從上往下遍歷這個拉平的 $children,找距離子組件最近的一個父組件。


但是,頁面的 $children 並不是"父子父子父子.."這樣順序排列的,而是"父父父子子子...",導致 $children 收集有問題,要麼多於實際,要麼為空。

想到的辦法是父子組件之間傳遞一個 relationKey,這個值是唯一的,找 $parent 時就不會找錯了。
function findNearListParent(children = [], name) {
let temp;
for (const item of children) {
const parentRelationKey = item.$props?.relationKey;
const thisRelationKey = this.$props?.relationKey;
if (item.$options.name === name && parentRelationKey === thisRelationKey) {
temp = item;
}
if (item === this && temp) {
return temp;
}
}
return temp;
}
上面的 relationKey 應該永遠從業務傳入。內部組件,不管父子,都只接受 props,不自己生成,減少複雜度。這樣的話,不管用 slot, <x><x-item></x> 還是用一個 <x>,都能保證 relationKey 同一個,且不論空還是不空,都相等的。
此外,還有這種游離在依賴樹之外的 vm 實例,也拿不到 provide 的值。

這種主要發生在 Popup 組件內部的父子關係,比如 dropdown-menu 組件中的 radio-group/radio、 cascader 組件 tab 模式的 tabs/tab-panel。
這種問題的一個解決方案是在使用它們的地方手動關聯。
Vue 中父子組件生命週期正常的執行順序是:父組件先創建,然後子組件創建;子組件先掛載,然後父組件掛載,即“父beforeCreate-> 父create -> 子beforeCreate-> 子created -> 子mounted -> 父mounted”。
抖音小程序並不遵循這樣的規律。

這個問題會導致父子組件的初始化數據出問題,之前在父組件 mounted 中執行的初始邏輯,會因為還沒收集完 $children,而失敗。

解決辦法有兩種,可用延時,也可用回調。回調更安全,延時可能跟機器性能有關。回調就是在子組件 mounted 的時候調用父組件的數據初始化方法。
button 不是最簡單的,loading/icon 才是最簡單的,它們是 button 的子元素。

導航類
s 結尾反饋類
輸入類
只有流量大的、用戶多的APP,才可能有小程序。國內小程序生態百花齊放,沒有兩個是完全一樣的。每一種小程序框架、文檔、運營平台、開發者工具、審核等都需要不少的工作量、不少的人力。看得出來中國互聯網過去幾年發展的可以。

上面是幾個小程序開發者工具的圖標
有意思的是,大家想的都差不多
tdesign-miniprogram 中 wxComponent 類的作用:
default* 屬性的默認值,增加 style/customStyle 屬性,增加 aria* 相關屬性externalClasses,增加 class_trigger,兼容受控情況下的拋出事件,非生命週期函數掛載在 methods 對象上lifetimes 上src/core/runtime/mp/polyfill/index.js
uni-app 中運行時對 vant-weapp 的 polyfill 核心邏輯
只要不在模板中使用,data 不用提前聲明,created 中動態聲明即可
created() {
this.xxx = 'xxx';
}
前置變數:
initLeft = boxLeft - halfblockinitRight = boxRight - halfblockmaxRange = boxRight - boxLeft - blockSize - 6 ( 6 是邊框)capsule 模式下:
offset = blockSize + 3,currentLeft = clientLeft - initLeft - offset,就是 clientLeft - boxLeft - halfBlock - 3offset = - 3,currentIRight = -(clientRight - initRight - offset),就是 boxRight - clientRight - halfBlock - 3假設 boxLeft = 0,boxRight = 100, halfBlock = 10,
clientLeft - 13,左邊最小是 1387 - clientRight,右邊最大是 87maxRange 就是 74
圖中分別是左、右、邊框。
有任何問題,建議通過 Github issues 反饋。