從實(shí)際項(xiàng)目出發(fā),告訴你vue3到底香不香
點(diǎn)擊上方關(guān)注 前端技術(shù)江湖,一起學(xué)習(xí),天天進(jìn)步
https://juejin.cn/post/6950487211368251399
背景
近期在研發(fā)一套物聯(lián)網(wǎng)設(shè)備管理系統(tǒng),其主要用途是將公司旗下所負(fù)責(zé)智能園區(qū)中的硬件設(shè)備通過(guò)物聯(lián)網(wǎng)云平臺(tái)來(lái)進(jìn)行綜合管控。
由于這個(gè)產(chǎn)品是實(shí)驗(yàn)性項(xiàng)目,沒(méi)有合同,沒(méi)有明確收益。所以能夠拿到的資源非常少。
產(chǎn)品具體的負(fù)責(zé)人,只有 1.5 人,幾乎只有我自己。所以既要擔(dān)任產(chǎn)品經(jīng)理,又要擔(dān)任開(kāi)發(fā)者,還要擔(dān)任運(yùn)維。不過(guò)從技術(shù)角度而言,選型可以更加自由。
整個(gè)系統(tǒng)在架構(gòu)上設(shè)計(jì)分為 4 層。自底向上分別是設(shè)備硬件、設(shè)備接入網(wǎng)關(guān)、物聯(lián)網(wǎng)平臺(tái)、設(shè)備管理系統(tǒng)。除去設(shè)備硬件,其它 3 層都屬于軟件范疇。
這篇文章主要記錄一下我在開(kāi)發(fā)最后一層-設(shè)備管理系統(tǒng)的前端開(kāi)發(fā)過(guò)程中的一些總結(jié)。
前端采用 Vite2.x、Vue3.x、Vuex4.x、VueRouter4.x、TypeScript、Element-Plus 進(jìn)行開(kāi)發(fā)。可以看到,這些框架和庫(kù)所采用的版本是比較激進(jìn)的,大部分都是最新版本,以及 rc 和 beta 版本。不過(guò)從項(xiàng)目開(kāi)始到寫(xiě)這篇總結(jié),其中的一些庫(kù)的版本已經(jīng)不是最新的了,不得不感慨前端技術(shù)變化之快。
一個(gè)組件的思考
首先來(lái)看一個(gè)組件。

這是一個(gè)具有波紋效果、用來(lái)表示當(dāng)前 websocket 連接狀態(tài)的小圓點(diǎn)。是一個(gè)非常簡(jiǎn)單的純展示組件。樣式效果使用 css3 變量、動(dòng)畫(huà)、和 before、after 偽類(lèi)實(shí)現(xiàn)。
props 設(shè)計(jì)非常簡(jiǎn)單,只有一個(gè) type 字段。根據(jù) type 字段的不同,波紋的顏色也不同。
思路有了,下面是實(shí)現(xiàn)上的一些細(xì)節(jié)性問(wèn)題。
如何聲明字段名為枚舉的類(lèi)型?
根據(jù)設(shè)計(jì),type 字段應(yīng)該是一個(gè)枚舉值,不應(yīng)該由調(diào)用方隨意設(shè)置。
下面是 Type 的枚舉聲明,共有 6 個(gè)字段。
enum Type {
primary = "primary",
success = "success",
warning = "warning",
warn = "warn", // warning alias
danger = "danger",
info = "info",
}
復(fù)制代碼
TypeScript 中聲明類(lèi)型的關(guān)鍵字有兩個(gè),interface 和 type,在聲明 key 不確定類(lèi)型的字段時(shí)稍有不同。
使用 type 進(jìn)行聲明:
type ColorConfig = {
[key in Type]: Colors;
};
復(fù)制代碼
使用 interface 卻只能像下面這樣:
interface ColorConfig {
[key: string]: Colors;
}
復(fù)制代碼
因?yàn)?interface 的索引只能是基礎(chǔ)類(lèi)型,類(lèi)型別名也不可以。而 type 的索引可以是復(fù)合類(lèi)型。
Vue 3 如何獲取元素實(shí)例?
在 vue3 中,組件的邏輯可以放在 setup 函數(shù)里面,但是 setup 中不再有 this,所以 vue2 中的 this.$refs 的用法在 vue3 中無(wú)法使用。
新的用法是:
給元素添加 ref 屬性。 在 setup 中聲明與元素 ref 同名的變量。 在 setup 的 return 對(duì)象中將 ref 變量作為同名屬性返回。 在 onMounted 生命周期中訪問(wèn) ref 變量,既是元素實(shí)例。
第一步:
<div class="point point-flicker" ref="point"></div>
復(fù)制代碼
第二步:
const point = ref<HTMLDivElement | null>(null);
復(fù)制代碼
注意類(lèi)型要填寫(xiě) HTMLDivElement,這樣才能享受類(lèi)型推斷。
第三步:
return { point };
復(fù)制代碼
這一步必不可少,如果返回對(duì)象中不包含這個(gè)同名屬性,onMounted 中訪問(wèn)的 ref 對(duì)象會(huì)是 null。
第四步:
onMounted(() => {
if (point?.value) {
// logic
}
});
復(fù)制代碼
如何操作偽類(lèi)?
JavaScript 無(wú)法獲取到偽類(lèi)元素,但是可以換一種思路。偽類(lèi)樣式引用 css 變量,再通過(guò) js 控制 css 變量來(lái)完成間接操作偽類(lèi)的效果。
比如這是一個(gè)偽類(lèi):
.point-flicker:after {
background-color: var(--afterBg);
}
復(fù)制代碼
它依賴(lài)了 afterBg 變量。
如果需要修改它的內(nèi)容,只需要使用 js 操作 afterBg 的內(nèi)容即可。
point.value.style.setProperty("--bg", colorConfig[props.type].bg);
復(fù)制代碼
API 的變化
Vue3 中組件如何修改自身的 props?
有一種不是很常見(jiàn)的情況,需要組件修改父組件傳遞給自己的 Props。
比如抽屜組件、擬態(tài)框組件等。
在 vue2 中常見(jiàn)的用法是 sync 和 v-model。
vue3 中只推薦使用 v-model:xxx="" 的方式。
比如父組件傳遞:
<ws-log v-model="wsLogVisible" />
復(fù)制代碼
子組件:
<template>
<div v-model:visible="visible">
...
</div>
</template>
<script>
// ...
props: {
visible: {
type: Boolean,
},
},
</script>
復(fù)制代碼
Vue3 中 watch 用法的變化
watch 變得更加簡(jiǎn)單。
import { watch } from "vue";
watch(source, (currentValue, oldValue) => {
// logic
});
復(fù)制代碼
當(dāng) source 變化時(shí)自動(dòng)執(zhí)行 watch 第二個(gè)參數(shù)所傳入的函數(shù)。
Vue3 中 computed 用法的變化
computed 也變得更加簡(jiǎn)單。
import { computed } from "vue"
const v = computed(() => {
return x
});
復(fù)制代碼
computed 返回的變量是一個(gè)響應(yīng)式對(duì)象。
Vue3 中組件循環(huán)自身的技巧
這是一種開(kāi)發(fā)組件的技巧。
假設(shè)你有一個(gè)不確定深度的樹(shù)狀結(jié)構(gòu)數(shù)據(jù)。
{
"label": "root",
"children": [
{
"label": "a",
"children": [
{
"label": "a1",
"children": []
},
{
"label": "a2",
"children": []
}
]
}
]
}
復(fù)制代碼
它的類(lèi)型定義如下:
export interface Menu {
id: string;
label: string;
children: Menu | null;
}
復(fù)制代碼
你需要實(shí)現(xiàn)一種樹(shù)狀組件來(lái)渲染它們。這時(shí)就需要用到這種技巧。
<template>
<div>{{ menu.label }}</div>
<Menu
@select="select"
v-for="item in menu.children"
:key="item.id"
:menu="item"
/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "Menu",
props: {
menu: {
type: Object,
},
},
});
</script>
復(fù)制代碼
組件的 name 可以在自身中直接使用,而不需要在 component 中聲明。
一些坑
Vuex:慎用 Map
在 Vuex 中,我設(shè)計(jì)了一個(gè)數(shù)據(jù)結(jié)構(gòu)用于存儲(chǔ)模塊(業(yè)務(wù)概念)不同的狀態(tài)。
type Code = number;
export type ModuleState = Map<Code, StateProperty>;
復(fù)制代碼
但是我發(fā)現(xiàn)一個(gè)問(wèn)題,當(dāng)我修改 Map 中某一個(gè) value 中的屬性時(shí),不會(huì)觸發(fā) Vuex 的監(jiān)聽(tīng)。
所以我只好將數(shù)據(jù)結(jié)構(gòu)修改為對(duì)象的形式。
export type ModuleState = { [key in Code]: StateProperty };
復(fù)制代碼
ts 中索引不可以使用類(lèi)型別名,但是可以寫(xiě)成下面這樣:
type Code = number;
export type ModuleState = { [key in Code]: StateProperty };
復(fù)制代碼
除此之外,Map 還存在另外一個(gè)問(wèn)題。
當(dāng)一個(gè) Map 類(lèi)型的 Proxy 對(duì)象作為參數(shù)被傳遞時(shí),是無(wú)法使用 get、set、clear 等 Map 方法的,但是 TypeScript 會(huì)提示這些方法可用。如果使用了這些方法,會(huì)得到一個(gè) Uncaught TypeError。
如果使用 Object 則不會(huì)產(chǎn)生這個(gè)問(wèn)題。
WebSocket 發(fā)生異常無(wú)法被 try catch 監(jiān)聽(tīng)
ws 的異常只能在 onerror 和 onclose 兩個(gè)事件中進(jìn)行處理,try catch 是無(wú)法捕獲的。
有些時(shí)候,onerror 和 onclose 會(huì)連續(xù)執(zhí)行,比如觸發(fā) onerror,導(dǎo)致連接關(guān)閉,就會(huì)緊接著觸發(fā) onclose。
Vue Devtools
vue devtools 目前無(wú)法支持 Vue3,但是 vue devtools 幾乎是開(kāi)發(fā)中必不可少的工具,目前可以使用 vue devtools beta 版本,但存在一些 Bug。
下載地址
用法非常簡(jiǎn)單,安裝后重啟瀏覽器就可以。不需要設(shè)置 vue.config.devtools = true,在 vue3 中 vue.config 實(shí)例不存在 devtools 屬性。
ESbuild 安裝依賴(lài)
在使用 vite 啟動(dòng)服務(wù)的同時(shí)安裝依賴(lài),非常容易碰到一個(gè)錯(cuò)誤。
Error: EBUSY: resource busy or locked, open 'E:\gxt\property-relay-fed\node_modules\esbuild\esbuild.exe'
復(fù)制代碼
這個(gè)問(wèn)題的原因是 vite 依賴(lài)的編譯工具 esbuild.exe 被占用所導(dǎo)致的,解決方法很簡(jiǎn)單,就是停掉 vite,安裝完依賴(lài)后再重新啟動(dòng) vite。
Vite 在 Chrome 中調(diào)試的問(wèn)題
系統(tǒng)中有一些移動(dòng)頁(yè)面,需要嵌入在 App 中使用。
常見(jiàn)的調(diào)試 WebView 的方法有兩種,一種簡(jiǎn)單的方式是使用騰訊開(kāi)源的 vcosnole,另一種麻煩一些的調(diào)試方式是使用 Chrome 的 DevTools。
但是 vconsole 并沒(méi)有想象中那么好用。

所以我選擇使用 Chrome 調(diào)試,chrome://inspect/#devices
但是在調(diào)試過(guò)程中我發(fā)現(xiàn) Chrome 調(diào)試工具里面竟然運(yùn)行的是 TS 源碼,TS 的語(yǔ)法直接被認(rèn)為語(yǔ)法錯(cuò)誤。(我是使用 Vite 啟動(dòng)的開(kāi)發(fā)服務(wù)。)
解決方案很簡(jiǎn)單,但挺 Low。先使用 vite build 把 TS 代碼編譯成 JS,再使用 vite preview 啟動(dòng)服務(wù)。
WebSocket
websocket 和 Vue3 沒(méi)什么關(guān)系,但是在這里簡(jiǎn)單提一下。
設(shè)備管理系統(tǒng)的核心概念是設(shè)備,設(shè)備會(huì)有很多屬性,在硬件上也被稱(chēng)作數(shù)據(jù)點(diǎn)。這些屬性會(huì)經(jīng)歷非常長(zhǎng)的鏈路傳輸?shù)接脩?hù)界面上。整體流程大概是:硬件通過(guò) tcp 協(xié)議上傳到接入網(wǎng)關(guān),接入網(wǎng)關(guān)處理后再通過(guò) mqtt 協(xié)議上傳到物聯(lián)網(wǎng)平臺(tái),物聯(lián)網(wǎng)平臺(tái)再經(jīng)過(guò)規(guī)則引擎處理,通過(guò) webhook restful 的形式發(fā)送到業(yè)務(wù)系統(tǒng),業(yè)務(wù)系統(tǒng)再通過(guò) websocket 推送到前端。
雖然數(shù)據(jù)通過(guò)層層編解碼、不同的協(xié)議繞了非常遠(yuǎn)的距離呈現(xiàn)到用戶(hù)面前,但是前端只需要關(guān)心 websocket 就足夠了。
WebSocket 重連
在做重連時(shí),需要注意 onerror 和 onclose 連續(xù)執(zhí)行的問(wèn)題,通常是使用類(lèi)似防抖的方法來(lái)解決。
我的做法是增加一個(gè)變量來(lái)控制重連次數(shù)。
let connecting = false; // 斷開(kāi)連接后,先觸發(fā) onerror,再觸發(fā) onclose,主要用于防止重復(fù)觸發(fā)
conn();
function conn() {
connecting = false;
if (ctx.state.stateWS.instance && ctx.state.stateWS.instance.close) {
ctx.state.stateWS.instance.close();
}
const url = ctx.state.stateWS.url + "?Authorization=" + getAuthtication();
ctx.state.stateWS.instance = new WebSocket(url);
ctx.state.stateWS.instance.onopen = () => {
ctx.commit(ActionType.SUCCESS);
};
ctx.state.stateWS.instance.onclose = () => {
if (connecting) return;
ctx.commit(ActionType.CLOSE);
setTimeout(() => {
conn();
}, 10 * 1000);
connecting = true;
};
ctx.state.stateWS.instance.onerror = () => {
if (connecting) return;
ctx.commit(ActionType.ERROR);
setTimeout(() => {
conn();
}, 10 * 1000);
connecting = true;
};
ctx.state.stateWS.instance.onmessage = function (
this: WebSocket,
ev: MessageEvent
) {
// logic
} catch (e) {
console.log("e:", e);
}
};
}
復(fù)制代碼
WebSocket 連接活動(dòng)日志
系統(tǒng)是設(shè)計(jì)成 7*24 小時(shí)不間斷運(yùn)行。所以 websocket 很容易受到一些網(wǎng)絡(luò)因素或者其它因素的影響發(fā)生斷開(kāi),重連是一項(xiàng)非常重要的功能,同時(shí)還應(yīng)該具備重連日志功能。
在用戶(hù)的不同環(huán)境中,排查 WebSocket 的連接狀態(tài)很麻煩,添加一個(gè)連接日志功能是比較不錯(cuò)的方案,這樣可以很好的看到不同時(shí)間的連接情況。

需要注意,這些日志是存儲(chǔ)在用戶(hù)的瀏覽器內(nèi)存中的,需要設(shè)置上限,到達(dá)上限要自動(dòng)清除早期日志。
WebSocket 鑒權(quán)
websocket 的鑒權(quán)是很多人容易忽視的一個(gè)點(diǎn)。
我在系統(tǒng)設(shè)計(jì)中,restful API 的鑒權(quán)是通過(guò)在 request header 上附帶 Authorization 字段,設(shè)置生成的 JWT 來(lái)實(shí)現(xiàn)的。
websocket 無(wú)法設(shè)置 header,但是可以設(shè)置 query,實(shí)現(xiàn)思路類(lèi)似 restful 的認(rèn)證設(shè)計(jì)。
關(guān)于 ws 鑒權(quán)的過(guò)期、續(xù)期、權(quán)限等問(wèn)題,和 restful 保持一致即可。
script setup:更加清爽的 API
script setup 至今仍是一個(gè)實(shí)驗(yàn)性特性,但它確實(shí)非常清爽。
單文件組件的 setup 常規(guī)用法像下面這樣:
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
return {}
}
})
</script>
復(fù)制代碼
使用 script setup 后,代碼變成了下面這樣:
<script setup lang="ts">
</script>
復(fù)制代碼
在 sciprt 標(biāo)簽中的頂層變量、函數(shù)都會(huì) return 出去。
在這種模式下,減少了大量代碼,可以提高開(kāi)發(fā)效率、降低心智負(fù)擔(dān)。
但這時(shí)也存在幾個(gè)問(wèn)題,比如在 script setup 中怎么使用生命周期和 watch/computed 函數(shù)?怎么使用組件?怎么獲取 props 和 context?
使用組件
直接導(dǎo)入組件后,vue 會(huì)自動(dòng)識(shí)別,無(wú)需使用 component 掛載。
<script setup lang="ts">
import C from "component"
</script>
復(fù)制代碼
使用生命周期和監(jiān)聽(tīng)計(jì)算函數(shù)
和標(biāo)準(zhǔn)寫(xiě)法基本無(wú)差異。
<script setup lang="ts">
import { watch, computed, onMounted } from "vue"
</script>
復(fù)制代碼
使用 props 和 context
由于 setup 被提升到 script 標(biāo)簽上了,自然也就沒(méi)辦法接收 props 和 context 這兩個(gè)參數(shù)。
所以 vue 提供了 defineProps、defineEmit、useContext 函數(shù)。
defineProps
defineProps 的用法和 OptionsAPI 中的 props 用法幾乎一致。
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
moduleID: string;
}
const props = defineProps<Props>(["moduleID"]);
console.log(props.moduleID);
</script>
復(fù)制代碼
defineEmit
defineEmit 的用法和 OptionsAPI 中的 emit 用法也幾乎一致。
<script setup lang="ts">
import { defineEmit } from "vue";
const emit = defineEmit(["select"]);
console.log(emit("select"));
</script>
復(fù)制代碼
emit 的第一個(gè)參數(shù)是事件名稱(chēng),后面支持傳遞不定個(gè)數(shù)的參數(shù)。
useContext
useContext 是一個(gè) hook 函數(shù),返回 context 對(duì)象。
const ctx = useContext()
復(fù)制代碼
原理
原理相當(dāng)簡(jiǎn)單。增加了一層編譯過(guò)程,將 script setup 編譯成標(biāo)準(zhǔn)模式的代碼。
但是實(shí)現(xiàn)上有非常多的細(xì)節(jié),所以導(dǎo)致至今仍未推出正式版。
Vue3 Composition 所帶來(lái)的模塊化開(kāi)發(fā)方式
這套技術(shù)棧帶給我最深的感受還是開(kāi)發(fā)方式上的變化。
在 Vue2 的開(kāi)發(fā)中,Options API 在面對(duì)業(yè)務(wù)邏輯復(fù)雜的頁(yè)面時(shí)非常吃力。當(dāng)邏輯長(zhǎng)達(dá)千行時(shí),追蹤一個(gè)變量的變化是一件非常頭痛的事情。
但是有了 Composition API 后,這將不再是問(wèn)題,它帶來(lái)了一種全新的開(kāi)發(fā)方式,雖然有種 React 的感覺(jué),但這相比之前已經(jīng)非常棒了!
這項(xiàng)目中所有的頁(yè)面,我都使用 hooks 的方式開(kāi)發(fā)。
在設(shè)備模塊中,我的 js 代碼是這樣的。
<script lang="ts">
import { defineComponent, toRefs } from "vue";
import { useDeviceCreate } from "./create";
import { useDeviceQuery } from "./query";
import { useDeviceDelete } from "./delete";
import { useUnbind } from "./unbind";
import { useBind } from "./bind";
import { useDeviceEdit } from "./edit";
import { useState } from "./state";
import { useAssign } from "./assign";
export default defineComponent({
setup() {
const queryObj = useDeviceQuery();
const { query, devices } = queryObj;
const reload = query;
return {
...toRefs(useDeviceCreate(reload)),
...toRefs(queryObj),
...toRefs(useDeviceDelete(reload)),
...toRefs(useUnbind(reload)),
...toRefs(useBind(reload)),
...toRefs(useDeviceEdit(reload)),
...toRefs(useState(devices)),
...toRefs(useAssign()),
};
},
});
</script>
復(fù)制代碼
每個(gè)模塊各司其職,各自有自己的內(nèi)部數(shù)據(jù),各個(gè)模塊如果需要共享數(shù)據(jù),可以通過(guò) Vuex,或者在頂層組件的 setup 中傳遞,比如上面的 reload 函數(shù)。
我的目錄結(jié)構(gòu)是這樣的。

整體上非常清爽,工程化的感覺(jué)越來(lái)越強(qiáng)。
前端架構(gòu)不同于后端架構(gòu)。
后端考慮的更多是高可用、高性能、可擴(kuò)展。前端考慮的問(wèn)題更多是如何實(shí)現(xiàn)高內(nèi)聚低耦合的分層設(shè)計(jì),架構(gòu)即設(shè)計(jì)。
良好的架構(gòu)設(shè)計(jì)能夠極大的開(kāi)發(fā)效率,降低開(kāi)發(fā)人員的心智負(fù)擔(dān)。
這也是我們一直以來(lái)所關(guān)注的問(wèn)題。
The End
歡迎自薦投稿到《前端技術(shù)江湖》,如果你覺(jué)得這篇內(nèi)容對(duì)你挺有啟發(fā),記得點(diǎn)個(gè) 「在看」哦
點(diǎn)個(gè)『在看』支持下 
