Vue2 + ant-design jsx 使用案例

Lastor
Code 隨筆放置場
18 min readJul 16, 2023

這篇文章大概分兩段,一個是標題的 Vue 裡面使用 jsx 會更優的案例分享。另一個是,我試著用 stackblitz codeflow 做 demo 時碰到的一些 vite + vue2 + ts + babel 相關坑點。

以前稍微研究過在 Vue 裡面使用 jsx。但始終好奇到底有甚麼樣的場景會迫切的需要 jsx,最近總算給我遇到了一個用 jsx 會更好處理的情境。

先上完成的 demo code,裡面有 ant-design-vue Tabale 使用 template API 與 columns config + jsx 兩種寫法的範例。

Ant Design 的 Table

antd 的 Table 組件有兩種設定表單 columns 的方式,一種是直接寫 html 的 template API,另一種是寫 object 的 columns config。

例如我有一組這樣的 data,要用 table 列出 ID 與 Name 欄位。

const tableData = [
{ id: 1, name: 'productA' },
// ...
]

用 columns 設定是這種感覺。其中,可以透過 scopedSlots 去定義 Vue 的插槽。

<script>
const columns = [
{
title: 'ID', // thead 欄位 title
dataIndex: 'id', // 對應 data 裡 object 的 key name
},
{
title: 'Name',
dataIndex: 'name',
scopedSlots: { customRender: 'name' }, // 定義 slot name
}
]
</script>

<template>
<a-table :columns="columns" :data-source="tableData" row-key="id">
<!-- 可以用定義好的 slot name 去控制該欄位要 render 的內容 -->
<template #name="val">
<span>{{ val }}</span>
</template>
</a-table>
</template>

而 template API 則是 columns config 的語法糖,可以直接在 html 寫設定。

<a-table :data-source="tableData" row-key="id">
<a-table-column data-index="id" title="ID" />
<a-table-column data-index="name" title="Name">
<template #default="val">
<span>{{ val }}</span>
</template>
</a-table-column>
</a-table>

這邊其實就體現出了一個差異,使用 columns 設定時,如果想要在 <template> 去自訂 render,就必須要定義一個 slot name 才能讓 antd 知道這段 html 對應的是哪個欄位。

但是用 template API 的時候,由於是包在 <a-table-column> 裡面,直接就能對應到目標欄位,所以不需要定義 slot name,直接用 default 就可以了。

這會在處理動態欄位時出現顯著的差異。

動態欄位處理

假設現在要做一張這樣的 table。

右段會顯示不同 product 每個月份的收入與支出。而月份的 data 數量是不固定的,每個 product 有的月份資料也會不一樣,例如有的有 1 月,有的卻沒有。

tableData 大概會是這樣:

const tableData = [
{
id: 1,
name: 'productA',
monthDetail: {
'2023-01': { cost: 50, income: 100 },
'2023-02': { cost: 100, income: 150 },
// ...
},
},
// ...
]

// 從 data 整理出來的不重複月份 list
const months = ['2023-01', '2023-02', ...]

ps. 這邊直覺可能會認為 monthDetail 應該是 array,但用 object 後面欄位設定會比較好抓值。

勢必月份的地方要跑迴圈來做,這時候用 template API 是很容易的。直接打一個 v-for 就能一次處理 Cost 與 Income、甚至更多欄位。
(以下省略上圖中的月份 ColumnGroup)

<a-table :data-source="tableData" row-key="id">
<template v-for="month in months">
<!-- Cost 欄位 -->
<a-table-column
:key="`${month}_cost`"
:data-index="`monthDetail.${month}.cost`"
title="Cost"
>
<template #default="val">{{ val }}</template>
</a-table-column>

<!-- 其他 n 個欄位... -->
</template>
</a-table>

但如果是用 columns config 就會碰到問題,寫下去會發現 slot name 也是動態產生的,那要寫 slot 時就會卡住。

<script>
const columns = [
{ title: 'ID', ... },
{ title: 'Name', ... },
// 迴圈生成月份欄位
...months.map((month) => (
{
title: 'Cost',
dataIndex: `monthDetail.${month}.cost`,
scopedSlots: {
customRender: `${month}_cost`, // slot name 也變動態的
},
}
// ...
))
]
</script>

<template>
<a-table :columns="columns" :data-source="tableData" row-key="id">
<!-- 取不到 slot name -->
<template #???="val">...</template>
</a-table>
</template>

slot 的機制與前面 template API 不同,帶有 slot 的 html 一定要在 a-table 底下的第一層。所以沒辦法只包一層 template v-for,就生成所有欄位。

<!-- NG -->

<a-table :columns="columns" :data-source="tableData" row-key="id">
<template v-for="month in months">
<!-- slot 被包在裡面, Vue 會抓不到 -->
<template :key="`${month}_cost`" #[`${month}_cost`]="val">
<span>{{ val }}</span>
</template>

<!-- 其他欄位... -->
</template>
</a-table>

所以這種情況,如果要用 slot 自訂 render 的話,就會被迫有幾欄就得寫幾個 v-for,相當的不理想。

<a-table :columns="columns" :data-source="tableData" row-key="id">
<!-- 得把 v-for 與 slot 寫在同一層 -->
<template v-for="month in months" #[`${month}_cost`]="val">
<span :key="month">{{ val }}</span>
</template>

<!-- v-for 欄位B -->
<!-- v-for 欄位C -->
<!-- v-for ... -->
</a-table>

而這就是 jsx 出場的時候了,columns config 裡面是可以不定義 slot 直接寫 customRender 的。

const columns = [
// ...
...months.map((month) => (
{
title: 'Cost',
dataIndex: `monthDetail.${month}.cost`,
// 直接用 jsx 去寫原本要寫在 slot 的內容
customRender: (val, row) => {
return <span>{ val }</span>
},
},
)),
]

這種情況就充分體現出了使用 jsx 的好處,直接在 columns 設定的迴圈之中一次解決,就不會被迫多寫一堆 v-for 了。

專案架設撞坑篇

接下來是第二部分,主要是紀錄在 stackblitz codeflow 做 demo 時碰到的坑。

stackblitz codeflow 這個 Web IDE 有興趣的可以去玩玩,基本免費。這跟 codeSandbox 那類平台比起來,速度明顯快到飛起。

可以直接在 Web 上模擬終端機操作,直接執行 npm installnpm run dev 等各種操作、安裝套件、運行 Node,甚至可以直接開啟 GitHub 上的公開 repo 來運行。

但畢竟是在瀏覽器上虛擬環境,終究還是有些地方沒辦法很完美。

typescript 支援性問題

ts 專案很容易會有 editor 抓不到 package 的 type 定義的問題,即使用平台預設的 ts 專案也會有這問題。但是他只是 editor 會跳紅線,並不影響專案執行,就是會看得很難受。

由於 package 的型別定義檔抓不到,缺少套件的智能提示,其實很大程度也就失去使用 ts 的意義了。

研究了老半天,發現是 typescript config 有問題,可以在本機上起個 vite-ts 專案,然後把 vite 預設的 ts config 複製過去即可以解決。

最關鍵的是 moduleResolution 這個設定,stackblitz 預設的是 Bundler 將其改成 Node 就可以了。注意,不要用 NodeNext

{
"moduleResolution": "Node",
}

但是!! Vue SFCs 是依賴 Volar 這個編輯器套件去做 ts check 的,可是 stackblitz 的編輯器是沒法手動去裝 Volar 的,因此所有的 .vue file 都是無法偵測 type 並且沒有智能提示的。

所以最終我還是選擇用 js 就好……

vite 無法直接使用 webpack 的 babel plugins

由於前面的案例我是在公司的 Vue2 webpack 老專案碰到的,為了模擬環境,所以我起的是一個素的 vite 專案,自己去裝 Vue2 環境。

vite 官方有做 Vue2 的插件 @vitejs/plugin-vue2,所以問題不大。但是 jsx 以及 antd Vue2 版本的按需加載都是需要用到 babel 的。

目前去查這方面相關的 bable 插件,基本都是 for webpack 的,vite 無法直接用。嘗試用了一些 vite-plugin-babel 之類的玩意,也都不 work。

後來發現 webpack 使用的那些 babel plugins,在搜尋的時候前面加個 vite 關鍵字,就能找到包裝成 vite plugins 的版本。但由於是 Vue2 相關的插件,很多都比較舊沒有再維護了。

但這方面的問題是因為我要在 vite 上面裝舊的 Vue2 相關環境才會碰到,畢竟 vite 基本是 for Vue3 體系的,使用 Vue3 的話就不太會有這方面的問題。

Vue2 還是用 webpack 的 vue-cli 會比較完善。雖然現在起新的 Vue 專案根本不會用 vue-cli 跟 Vue2 了。 (笑)

vite 官方有個頁面,有列出幾個比較重要的,由官方維護的插件。包含 Vue2、Vue3 的 plugin,以及這兩個版本的 jsx plugin。

Vue2 使用 jsx

要在 Vue2 要使用 jsx,打包工具是 webpack 還是 vite 會有比較大的差異。現在最新的 vue-cli 除了內建 composition-api 與 <script setup> 語法糖之外,jsx 的配置也是開箱即用的。

但是 Vue2 的老專案,可能不是用最新的 vue-cli 起的,多半還是得自己裝。webpack 的情況下,以前查過一次,稍微有點忘了。隱約記得 Vue + jsx 相關的 babel 插件有新版跟舊版的。

新版的可以直接在 setup 裡面寫 jsx,但舊版的限定只能寫在 Vue 的 render function 裡面。

如果是老 Vue2 專案,途中才添加 jsx 配置的話,多半既有 code 都不是用 render function 去寫 html 的,jsx 會很難加上去。像上述 antd Table 的 columns 設定,根本沒辦法直接在 customRender() 裡面寫 jsx。

關於這問題我也是卡關卡了很久,後來去翻這個 babel jsx 插件的 GitHub 才注意到,他除了 render function 之外,也可以寫在 methods 上面。所以只要轉一手就可以了。

defineComponent({
computed: {
columns() {
return [
// ...
...months.map((month) => (
{
title: 'Cost',
dataIndex: `monthDetail.${month}.cost`,
customRender: (val, row) => {
return this.MyJsx({ val }) // 透過 methods 過一手
},
},
)),
]
},
},
methods: {
MyJsx(props) {
return <span>{props.val}</span>
},
}
})

如果是 vite 環境的話,官方有直接提供 Vue2 的 jsx 插件。這個插件對應的是 Vue2.7,所以是可以直接寫在 setup 上面的。

(Vue2.7 整合了 composition-api 以及 setup 語法糖)

ant-design-vue 與 unplugin-vue-components 自動按需加載

其實不想搞 babel 的話,手動按需加載也可以。官方文件有相關說明,antd 原本就有拆模塊,只要個別 import 需要的就好。但他麻煩就在 style 也要手動引入。

import Button from 'ant-design-vue/lib/button'
import 'ant-design-vue/lib/button/style'

而自動按需加載主要就是簡化這個操作,讓 style 可以自動引入,就不用寫到手斷掉。

老版本的文件上,都是推薦使用 babel-plugin-import 來做自動化。

可是這玩意是 for webpack 的,vite 我研究了老半天,始終不 work。後來發現新版本文件,有寫說 vite 可使用 unplugin-vue-components

這玩意似乎是讓各大 UI 庫使用按需加載的集成,概念上也只是幫你自動化。例如設定了 ant-design-vue 解析之後,他會偵測你的 template 裡面寫的 關鍵字時,有出現 antd 組件的名稱時,自動幫你 import 該組件以及 style。

// 偵測到 antd 組件的關鍵字 a-xxx
<a-button>Click</a-button>

// 自動補 import 並註冊到組件上
import { Button } from 'ant-design-vue'
import 'ant-design-vue/es/date-picker/style/css'

export default defineComponent({
components: {
AButton: Button,
}
})

這個是我撞到一個坑之後,去讀這插件的文檔,研究了好陣子才理解到原來是這樣 work 的。這個坑後面再細說。

由於他是這樣的註冊方式,按照 Vue 的規則,其實寫 <AButton> 也是可以的。

因為這玩意跟 babel 一樣,只是分析 code 去做編譯轉換,所以 antd 無論是用哪個版本,只要他的命名規則沒有變,那應該都是可以用這個插件的。

實際測試之後,antd-vue v1 也可以用 unplugin-vue-components 。而隔壁棚的 element-plus,在按需加載的部分也是推薦使用這個插件。

要注意的是,如果你手動去寫了 import 並註冊到組件上,那編譯轉換就不會執行,也就不會自動拉 style 了。

a-table-column 按需加載的坑

這個坑真的折騰了我很多時間才搞明白是怎麼回事。上面說明的 unplugin-vue-components 的運作方式,會跟這個 columns 語法糖起衝突。

在寫 a-table 使用 template API 時,會直接報錯。

<a-table ...>
<a-table-column ... />
</a-table>

// Error:
// The requested module '/node_modules/.../ant-design-vue_es.js'
// does not provide an export named 'TableColumn'

為什麼呢? 看這錯誤訊息,vite 在 antd 裡面,找不到叫做 TableColumn 的 module 有被 export 出來。

也就是說,編譯之後高概率是這樣寫 import 的。

import { TableColumn } from 'ant-design-vue'

然後,去爬 antd 的文件在 Get Started 的地方舉了一個 Button 跟 ButtonGroup 的例子,他是這樣寫的啊!! 這個 ButtonGroup 不是一個獨立 export 出來的組件!!

import { Button } from 'ant-design-vue'
const ButtonGroup = Button.Group

export default {
components: {
AButton: Button,
AButtonGroup: ButtonGroup,
},
}

實際去找 TableColumn 這個組件,果然發現他跟 ButtonGroup 一樣,只是底下的的一個屬性。

// export 裡面不存在單獨的 TableColumn
import { Table } from 'ant-design-vue'
const TableColumn = Table.Column

因為這算是特規了,跟其他組件都不一樣,所以 unplugin-vue-components 寫好的編譯規則直接在這就掛了……

如果希望繼續使用 unplugin-vue-components 自動按需加載,又想使用這類特殊的 antd 子組件,該怎麼辦呢?

研究了好久才發現,我們可以這樣寫:

<script>
import { Table } from 'ant-design-vue'
export default {
components: {
Column: Table.Column, // 不要取名為 ATableColumn
},
}
</script>

<script setup>
import { ref, computed } from 'vue'
// ...
</script>

<template>
<div>
<a-table ...>
<!-- 使用自定義的組件名 -->
<Column ... />
</a-table>
</div>
</template>

概念上是把 a-table-column 的部分手動註冊,然後換個名字去規避 unplugin 的偵測。這樣 a-table-column 就不會被編譯出錯誤的 import 內容。

然後 a-table 的部分照舊,讓 unplugin 去自動註冊組件。由於 Column 是隸屬在 Table 底下的,所以是吃同一份 style。而這份 style 會在 a-table 的部分被自動載入進來。

如此,就成功解決了這該死的坑。 (灑花)

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。