背景
近期在研发一套物联网设备管理系统,其主要用途是将公司旗下所负责智能园区中的硬件设备通过物联网云平台来进行综合管控。
由于这个产品是实验性项目,没有合同,没有明确收益。所以能够拿到的资源非常少。
产品具体的负责人,只有 1.5 人,几乎只有我自己。所以既要担任产品经理,又要担任开发者,还要担任运维。不过从技术角度而言,选型可以更加自由。
整个系统在架构上设计分为 4 层。自底向上分别是设备硬件、设备接入网关、物联网平台、设备管理系统。除去设备硬件,其它 3 层都属于软件范畴。
这篇文章主要记录一下我在开发最后一层-设备管理系统的前端开发过程中的一些总结。
前端采用 Vite2.x、Vue3.x、Vuex4.x、VueRouter4.x、TypeScript、Element-Plus 进行开发。可以看到,这些框架和库所采用的版本是比较激进的,大部分都是最新版本,以及 rc 和 beta 版本。不过从项目开始到写这篇总结,其中的一些库的版本已经不是最新的了,不得不感慨前端技术变化之快。
一个组件的思考
首先来看一个组件。
这是一个具有波纹效果、用来表示当前 websocket 连接状态的小圆点。是一个非常简单的纯展示组件。样式效果使用 css3 变量、动画、和 before、after 伪类实现。
props 设计非常简单,只有一个 type 字段。根据 type 字段的不同,波纹的颜色也不同。
思路有了,下面是实现上的一些细节性问题。
如何声明字段名为枚举的类型?
根据设计,type 字段应该是一个枚举值,不应该由调用方随意设置。
下面是 Type 的枚举声明,共有 6 个字段。
enum Type { primary = "primary", success = "success", warning = "warning", warn = "warn", // warning alias danger = "danger", info = "info", }
TypeScript 中声明类型的关键字有两个,interface 和 type,在声明 key 不确定类型的字段时稍有不同。
使用 type 进行声明:
type ColorConfig = { [key in Type]: Colors; };
使用 interface 却只能像下面这样:
interface ColorConfig { [key: string]: Colors; }
因为 interface 的索引只能是基础类型,类型别名也不可以。而 type 的索引可以是复合类型。
Vue 3 如何获取元素实例?
在 vue3 中,组件的逻辑可以放在 setup 函数里面,但是 setup 中不再有 this,所以 vue2 中的 this.$refs 的用法在 vue3 中无法使用。
新的用法是:
- 给元素添加 ref 属性。
- 在 setup 中声明与元素 ref 同名的变量。
- 在 setup 的 return 对象中将 ref 变量作为同名属性返回。
- 在 onMounted 生命周期中访问 ref 变量,既是元素实例。
第一步:
<div class="point point-flicker" ref="point"></div>
第二步:
const point = ref<HTMLDivElement | null>(null);
注意类型要填写 HTMLDivElement,这样才能享受类型推断。
第三步:
return { point };
这一步必不可少,如果返回对象中不包含这个同名属性,onMounted 中访问的 ref 对象会是 null。
第四步:
onMounted(() => { if (point?.value) { // logic } });
如何操作伪类?
JavaScript 无法获取到伪类元素,但是可以换一种思路。伪类样式引用 css 变量,再通过 js 控制 css 变量来完成间接操作伪类的效果。
比如这是一个伪类:
.point-flicker:after { background-color: var(--afterBg); }
它依赖了 afterBg 变量。
如果需要修改它的内容,只需要使用 js 操作 afterBg 的内容即可。
point.value.style.setProperty("--bg", colorConfig[props.type].bg);
API 的变化
Vue3 中组件如何修改自身的 props?
有一种不是很常见的情况,需要组件修改父组件传递给自己的 Props。
比如抽屉组件、拟态框组件等。
在 vue2 中常见的用法是 sync 和 v-model。
vue3 中只推荐使用 v-model:xxx="" 的方式。
比如父组件传递:
<ws-log v-model="wsLogVisible" />
子组件:
<template> <div v-model:visible="visible"> ... </div> </template> <script> // ... props: { visible: { type: Boolean, }, }, </script>
Vue3 中 watch 用法的变化
watch 变得更加简单。
import { watch } from "vue"; watch(source, (currentValue, oldValue) => { // logic });
当 source 变化时自动执行 watch 第二个参数所传入的函数。
Vue3 中 computed 用法的变化
computed 也变得更加简单。
import { computed } from "vue" const v = computed(() => { return x });
computed 返回的变量是一个响应式对象。
Vue3 中组件循环自身的技巧
这是一种开发组件的技巧。
假设你有一个不确定深度的树状结构数据。
{ "label": "root", "children": [ { "label": "a", "children": [ { "label": "a1", "children": [] }, { "label": "a2", "children": [] } ] } ] }
它的类型定义如下:
export interface Menu { id: string; label: string; children: Menu | null; }
你需要实现一种树状组件来渲染它们。这时就需要用到这种技巧。
<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>
组件的 name 可以在自身中直接使用,而不需要在 component 中声明。
一些坑
Vuex:慎用 Map
在 Vuex 中,我设计了一个数据结构用于存储模块(业务概念)不同的状态。
type Code = number; export type ModuleState = Map<Code, StateProperty>;
但是我发现一个问题,当我修改 Map 中某一个 value 中的属性时,不会触发 Vuex 的监听。
所以我只好将数据结构修改为对象的形式。
export type ModuleState = { [key in Code]: StateProperty };
ts 中索引不可以使用类型别名,但是可以写成下面这样:
type Code = number; export type ModuleState = { [key in Code]: StateProperty };
除此之外,Map 还存在另外一个问题。
当一个 Map 类型的 Proxy 对象作为参数被传递时,是无法使用 get、set、clear 等 Map 方法的,但是 TypeScript 会提示这些方法可用。如果使用了这些方法,会得到一个 Uncaught TypeError。
如果使用 Object 则不会产生这个问题。
WebSocket 发生异常无法被 try catch 监听
ws 的异常只能在 onerror 和 onclose 两个事件中进行处理,try catch 是无法捕获的。
有些时候,onerror 和 onclose 会连续执行,比如触发 onerror,导致连接关闭,就会紧接着触发 onclose。
Vue Devtools
vue devtools 目前无法支持 Vue3,但是 vue devtools 几乎是开发中必不可少的工具,目前可以使用 vue devtools beta 版本,但存在一些 Bug。
用法非常简单,安装后重启浏览器就可以。不需要设置 vue.config.devtools = true
,在 vue3 中 vue.config 实例不存在 devtools 属性。
ESbuild 安装依赖
在使用 vite 启动服务的同时安装依赖,非常容易碰到一个错误。
Error: EBUSY: resource busy or locked, open 'E:\gxt\property-relay-fed\node_modules\esbuild\esbuild.exe'
这个问题的原因是 vite 依赖的编译工具 esbuild.exe 被占用所导致的,解决方法很简单,就是停掉 vite,安装完依赖后再重新启动 vite。
Vite 在 Chrome 中调试的问题
系统中有一些移动页面,需要嵌入在 App 中使用。
常见的调试 WebView 的方法有两种,一种简单的方式是使用腾讯开源的 vcosnole,另一种麻烦一些的调试方式是使用 Chrome 的 DevTools。
但是 vconsole 并没有想象中那么好用。
所以我选择使用 Chrome 调试,chrome://inspect/#devices
但是在调试过程中我发现 Chrome 调试工具里面竟然运行的是 TS 源码,TS 的语法直接被认为语法错误。(我是使用 Vite 启动的开发服务。)
解决方案很简单,但挺 Low。先使用 vite build 把 TS 代码编译成 JS,再使用 vite preview 启动服务。
WebSocket
websocket 和 Vue3 没什么关系,但是在这里简单提一下。
设备管理系统的核心概念是设备,设备会有很多属性,在硬件上也被称作数据点。这些属性会经历非常长的链路传输到用户界面上。整体流程大概是:硬件通过 tcp 协议上传到接入网关,接入网关处理后再通过 mqtt 协议上传到物联网平台,物联网平台再经过规则引擎处理,通过 webhook restful 的形式发送到业务系统,业务系统再通过 websocket 推送到前端。
虽然数据通过层层编解码、不同的协议绕了非常远的距离呈现到用户面前,但是前端只需要关心 websocket 就足够了。
WebSocket 重连
在做重连时,需要注意 onerror 和 onclose 连续执行的问题,通常是使用类似防抖的方法来解决。
我的做法是增加一个变量来控制重连次数。
let connecting = false; // 断开连接后,先触发 onerror,再触发 onclose,主要用于防止重复触发 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); } }; }
WebSocket 连接活动日志
系统是设计成 7*24 小时不间断运行。所以 websocket 很容易受到一些网络因素或者其它因素的影响发生断开,重连是一项非常重要的功能,同时还应该具备重连日志功能。
在用户的不同环境中,排查 WebSocket 的连接状态很麻烦,添加一个连接日志功能是比较不错的方案,这样可以很好的看到不同时间的连接情况。
需要注意,这些日志是存储在用户的浏览器内存中的,需要设置上限,到达上限要自动清除早期日志。
WebSocket 鉴权
websocket 的鉴权是很多人容易忽视的一个点。
我在系统设计中,restful API 的鉴权是通过在 request header 上附带 Authorization 字段,设置生成的 JWT 来实现的。
websocket 无法设置 header,但是可以设置 query,实现思路类似 restful 的认证设计。
关于 ws 鉴权的过期、续期、权限等问题,和 restful 保持一致即可。
script setup:更加清爽的 API
script setup 至今仍是一个实验性特性,但它确实非常清爽。
单文件组件的 setup 常规用法像下面这样:
<script lang="ts"> import { defineComponent } from 'vue' export default defineComponent({ setup () { return {} } }) </script>
使用 script setup 后,代码变成了下面这样:
<script setup lang="ts"> </script>
在 sciprt 标签中的顶层变量、函数都会 return 出去。
在这种模式下,减少了大量代码,可以提高开发效率、降低心智负担。
但这时也存在几个问题,比如在 script setup 中怎么使用生命周期和 watch/computed 函数?怎么使用组件?怎么获取 props 和 context?
使用组件
直接导入组件后,vue 会自动识别,无需使用 component 挂载。
<script setup lang="ts"> import C from "component" </script>
使用生命周期和监听计算函数
和标准写法基本无差异。
<script setup lang="ts"> import { watch, computed, onMounted } from "vue" </script>
使用 props 和 context
由于 setup 被提升到 script 标签上了,自然也就没办法接收 props 和 context 这两个参数。
所以 vue 提供了 defineProps、defineEmit、useContext 函数。
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>
defineEmit
defineEmit 的用法和 OptionsAPI 中的 emit 用法也几乎一致。
<script setup lang="ts"> import { defineEmit } from "vue"; const emit = defineEmit(["select"]); console.log(emit("select")); </script>
emit 的第一个参数是事件名称,后面支持传递不定个数的参数。
useContext
useContext 是一个 hook 函数,返回 context 对象。
const ctx = useContext()
原理
原理相当简单。增加了一层编译过程,将 script setup 编译成标准模式的代码。
但是实现上有非常多的细节,所以导致至今仍未推出正式版。
Vue3 Composition 所带来的模块化开发方式
这套技术栈带给我最深的感受还是开发方式上的变化。
在 Vue2 的开发中,Options API 在面对业务逻辑复杂的页面时非常吃力。当逻辑长达千行时,追踪一个变量的变化是一件非常头痛的事情。
但是有了 Composition API 后,这将不再是问题,它带来了一种全新的开发方式,虽然有种 React 的感觉,但这相比之前已经非常棒了!
这项目中所有的页面,我都使用 hooks 的方式开发。
在设备模块中,我的 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>
每个模块各司其职,各自有自己的内部数据,各个模块如果需要共享数据,可以通过 Vuex,或者在顶层组件的 setup 中传递,比如上面的 reload 函数。
我的目录结构是这样的。
整体上非常清爽,工程化的感觉越来越强。
前端架构不同于后端架构。
后端考虑的更多是高可用、高性能、可扩展。前端考虑的问题更多是如何实现高内聚低耦合的分层设计,架构即设计。
良好的架构设计能够极大的开发效率,降低开发人员的心智负担。
这也是我们一直以来所关注的问题。