跳转到内容

封装

刀刀

12/31/2024

0 字

0 分钟

多人合作开发中,往往会出现同一个组件、方法或样式多人需要使用,因此为了方便统一管理,也方便后续维护,封装是重要的步骤。

封装不能盲目的封装,要考虑使用的组件方法频率是否高,或者该封装的组件方法是否需要统一的维护。且封装的时候需要考虑到易用性、复用性和可拓展性。

下面从组件和方法两个层面来描述一下封装。

组件

组件封装中我封装了图层顶部组件封装,并对视频流组件的源码做了二次封装。

图层顶部组件

图层顶部组件最终效果如下图所示:

顶部组件效果

该组件分为两个组件:标题部分和二级机构与高速路下拉选择部分。

先看标题部分,主要在于右方的时间模块。获取年月日时分秒可以通过 new Date() 获取,利用定时器每秒获取一次实时时间。但是由于定时器是宏任务,需要等待同步任务和微任务都执行完毕才能执行,会出现时机上的不对,因此在实现该功能时目光放到了 @vueuse/core

其中 useIntervalFn 是Vue 3生态系统工具集 @vueuse/core 提供的一个自定义 hook ,用于在一定时间间隔内执行指定的函数。

hook 的语法为:

js
useIntervalFn(fn: () => void, interval: number | Ref<number>, { immediate = true } = {}): { start: Fn, stop: Fn, isList: Ref<boolean> }

其中,fn是需要执行的函数,interval是时间间隔,可以是一个数字或者一个ref对象,immediate为可选项,默认为true,表示是否在初始化时立即执行函数。

useIntervalFn 返回一个对象,包含了 startstopisList 三个属性。start 是一个函数,用于启动计时器并开始执行函数;stop 是一个函数,用于停止计时器和函数执行;isList 是一个 ref 对象,表示当前计时器是否处于活动状态。

使用 useIntervalFn 非常简单,只需要传入需要执行的函数和时间间隔即可。以本案例为例,代码如下:

js
import { useIntervalFn } from '@vueuse/core'

export default {
  setup() {
     let dateNow = ref('');
     let dateFormat = ref('');
     let dateTime = ref('');

     const updateTime = (date = new Date()) => {
         dateNow.value = date.toISOString().split('T')[0];
         dateFormat.value = date.toLocaleString('zh-CN', {weekday: 'long'});
         dateTime.value
             = date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
     };

     const {pause} = useIntervalFn(
         () => updateTime(),
         1000,
         {
             immediateCallback: true,
             immediate: true,
         }
     );

     onUnmounted(() => {
         pause?.();
     });
  }
}

其下方的组织机构下拉选择卡片可抽离出来单独封装为一个组件,在父组件通过插槽引用到需要的地方。组件效果如下所示:

效果

在封装的时候需要考虑到易用性、复用性和可拓展性,逻辑点梳理如下:

  • 左右两侧的数据通过接口获取,接口通过父子组件传参的形式,可以自定义想要调用的接口获取到自己想要的数据,并给定一个默认接口。且考虑到接口参数字段未必会统一,可以也通过父组件传参的方式指定获取哪个字段的值
  • 点击左侧二级机构切换右侧高速路的数组数据,上方的名称也要切换。此时赋值二级机构选中项,置空高速路选择项数据。默认渲染第一个二级机构下的高速路
  • 点击右侧高速路则进入微观二维视角,对应高速路高亮显示,其中:
    • 如果用户没点二级机构而是直接点了高速路,则说明点的是第一个二级机构下的,手动赋值第一个二级机构为激活项
    • 高速路的选择允许单选和多选,通过父组件传参来规定。为了统一,让高速路选择项类型为数组,如果是单选,直接空数组 push 即可;如果是多选,则判断是添加 push 还是取消 filter
  • 搜索出来的结果根据其是二级机构还是高速路动态调用不同的函数

代码如下所示:

js
// 点击刷新按钮
handleRefreshFn() {
    this.meso = {};
    this.microList = [];
    this.$emit('refresh');
},
// 点击左侧桥路切换右侧高速
mesoChoseFn(e) {
    // 保存二级机构数据
    this.meso = e;
    // 清空高速路数据
    this.setroadListById({orgId: e.orgId});
    this.$emit('meso-chose', e, [this.meso]);
},
// 点击右侧具体的高速、桥获取微观二维结构物数据和告警结构物数据
microChoseFn(e, searchType = false) {
    // 如果直接点击高速路,则把第一个二级机构数据保存
    if (!this.mesoList) {
        this.meso = this.relationList[0];
    }

    // 是否允许其多选
    if (this.microCheck) {
        // 点击重复数据
        if (this.microList.includes(e.sectionId)) {
            this.microList = this.microList.filter(item => item !== e.sectionId);
        }
        // 点击新数据
        else {
            this.microList.push(e.sectionId);
        }
    }
    else {
        this.microList.push(e.sectionId);
    }

    this.$emit(
        'microChoseFn',
        e,
        [this.meso, this.microList[0]]
    );
},
// 点击搜索的选项
choseSearchBackFn(e) {
    if (e.orgSectionTpye === 0) {
        // orgSectionTpye为0,表示搜索到的是二级机构,直接调用二级机构点击事件
        this.mesoChoseFn(e);
    }
    else {
        // orgSectionTpye为1,表示搜索到的是高速路,渲染右侧高速列表;二级机构和高速激活项保存
        this.microChoseFn(e);
        this.setroadListById({orgId: e.orgId});
    }
    this.$emit('search-back', e);
},

在代码实现过程,需要考虑父组件点击扎点子组件也能激活对应的激活项和更换城市名称。因此城市名称变量写在点击事件内切换是不够满足需求的,我的解决方法为:

  1. 父子传参相关 key
  2. 在侦听器 watch 中侦听传进来的 key 值,如果为 nullundefined ,则把相关数据清空;反之则通过 findfilter 过滤出需要的数据
  3. 城市名称则在计算属性中统一获取,如果有高速路数据,说明此时是微观二维,遍历数组获取每一项的高速名称;否则判断二级机构对象是否是空对象,不是空对象说明当前是中观层级,显示二级机构名称;都不是最后才显示默认值
js
watch: {
    mesoKey(newVal) {
        // 如果为null,则把数据都滞空
        if (!newVal) {
            this.meso = {};
        }
        else {
            this.meso = this.relationList.find(item => item.orgId === newVal);
            this.setroadListById({orgId: newVal});
        }
    },
    microKey(newVal) {
        // 如果为null,则把数组都滞空
        if (!newVal) {
            this.microList = [];
        }
        else if (typeof newVal === 'string') {
            this.microList = this.roadList.filter(item => item.sectionId === newVal);
        }
        else {
            this.microList = this.roadList.filter(item => newVal.includes(item.sectionId));
        }
    },
},
computed: {
    // 被选中的二级机构或高速路名称
    cityName() {
        // 如果为空名称恢复为二级机构;不为空则显示高速路
        if (this.microList.length > 0) return this.microList.map(e => e.name).join('、');
        else if (this.meso?.orgName) return this.meso.orgName;
        return '交通集团';
    },
},

视频流组件

由于客户侧的视频都是 wssflv 流,因此视频流组件是使用了客户侧提供的视频流组件。但是在使用时因各种原因导致依赖报错,因此基于他们的源码做了二次封装和开发。

在阅读他们的源码时,发现他们的代码主要是引用了阿里云播放器 SDK 实现播放,主要逻辑为:

  1. 保存他们的视频流组件相关样式、组件、事件等配置文件到项目中,在 index.html 中通过 scriptlink 引入
  2. 注册阿里云播放器 SDK 组件
  3. 从父组件接收数据,调用接口获取视频的流地址
  4. 调用播放器的播放功能

一开始封装的时候能够实现效果,到后面本部同事打包后失败,和我说了一下需要改为动态引入的形式,后续所有静态文件都不能在 index.html 中引入,改为动态引入。

现在需要考虑的问题是,客户提供的播放器组件放到了 playercomponents.min.js 文件中,只有引入了该文件,才能使用对应的组件。因此在执行时机和顺序需要注意先引入文件后调用组件。

动态引入方式为:

  1. 创建标签 scriptlink
  2. 判断曾经是否有缓存的记录或者当前缓存状态是否成功,如果曾经已经缓存过或者当前缓存中、缓存成功,则返回不继续执行
  3. 获取到当前环境的路径,调用接口的形式获取文件资源并立即执行
  4. 保存缓存记录,且如果接口调用成功,则把缓存状态改为 fulfilled ;接口调用失败,缓存状态改为 rejected ;接口调用中,缓存状态改为 padding
  5. 样式文件则动态保存到 head 标签上

代码示例如下所示:

js
// 动态判断获取文件路径的前缀
const publicPath = () => {
    return FileHost.value || window.location.origin;
};
// 获取资源文件服务器的路径
const f = () => Promise.all(
    [
        request.get(`${publicPath()}/xxx1/yyy/zzz.js`),
        request.get(`${publicPath()}/xxx2/yyy/playercomponents.js`),
    ]
);
let UnloadState = '';

const getStaticUrl = () => {
    // 动态引入静态js资源
    const staticCache = window.ApaasMaplayerVideoPlayerStatic;
    const loadJs = async () => {
        // 如果有缓存记录,则不再下载
        if (staticCache && staticCache?.js) return;
        // 如果当前正在下载或下载成功,也不下载
        if (globalState && globalState !== 'rejected') return;
        f().then(res => {
            // 动态地执行从请求返回的脚本
            res.forEach(item => {
                new Function(item)();
            });
          
            // 缓存了,做个记录
            window.ApaasMaplayerVideoPlayerStatic = {
                ...(window.ApaasMaplayerVideoPlayerStatic || {}),
                js: true,
            };
            this.staticLoaded.js = true;
            // 状态变为成功状态
            UnloadState = 'fulfilled';
        }).catch(e => {
            // 状态变为失败状态
            UnloadState = 'rejected';
        });
        // 状态变为正在缓存状态
        UnloadState = 'pending';
    };
    loadJs();

    // 动态引入css样式资源
    const loadStyles = url => {
        if (staticCache && staticCache?.css) {
            return;
        }
        let link = document.createElement('link');
        link.rel = 'stylesheet';
        link.type = 'text/css';
        link.href = url;
        let head = document.getElementsByTagName('head')[0];
        head.appendChild(link);
        window.ApaasMaplayerVideoPlayerStatic = {
            ...(window.ApaasMaplayerVideoPlayerStatic || {}),
            css: true,
        };
        this.staticLoaded.css = true;
    };
    loadStyles('maplayer/assets/css/video.css');
}
getStaticUrl();

关于 res.forEach(item => { new Function(item)(); }); 这段代码的详细解读

在这段代码中,res.forEach(item => { new Function(item)(); }); 的作用是动态地执行从请求返回的脚本。其中:

  1. res.forEach(item => { new Function(item)(); }):这一行代码使用了 forEach 方法遍历 res 数组中的每个元素,并对每个元素执行一个新创建的函数。在这里,new Function(item) 创建了一个新的函数对象,然后立即执行这个函数,相当于动态地执行了从请求返回的脚本内容。
  2. new Function(item):这里使用了 JavaScript 中的 Function 构造函数,它允许你在运行时动态创建并编译函数。传入的参数 item 应该是一个包含有效 JavaScript 代码的字符串,new Function(item) 将会将这段代码编译成一个可执行的函数对象。

所以,整体来说,这段代码的目的是从请求返回的脚本字符串中动态地创建函数并立即执行,这样就可以动态地加载并执行从服务器端获取的脚本内容。

方法

项目中方法的封装首当其冲都是 axios 二次封装。关于二次封装,主要封装其基准路径、超时时间、拦截器等。请求拦截器主要是请求头设置;响应拦截器主要是判断接口响应状态并做对应的处理。

在请求时添加一个 loading 效果,请求成功后再取消掉。响应失败时还需要弹出相应的提示。拦截器除了直接写,也可以通过对象的形式添加,这样也能对应功能单独设置。

js
// 响应拦截 - 处理异常
export const commonErrorHandler = {
    // 拦截业务异常响应
    onFulfilled: (response) => {
        const { data, config } = response;
        if (+data.code !== 200) {
            const errorCode = +data.code;
            const errorInfo = data.message || data.msg;
            // 除非传入 noErrorHint 参数,否则都会进行错误提示
            const noErrorHint = config.extraInfo && config.extraInfo.noErrorHint;
            console.log(errorInfo, noErrorHint)
            if (!noErrorHint && errorInfo !== 'ok') {
                console.log('tip1');
                hintNetError(errorCode, errorInfo);
            }
            // 登录状态异常
            if (LOGOUT_CODE.includes(errorCode)) {
                exceptionLogout(errorCode, response);
            }
        }
        return Promise.resolve(response);
    },
    // 拦截网络异常响应,进行提示
    onRejected(err) {
        const noErrorHint = err.config && err.config.extraInfo && err.config.extraInfo.noErrorHint;
        const response = err && err.response ? err.response : {};
        const message = err && err.message ? err.message : '';
        // 超时提示
        if (!noErrorHint) {
            if (message && message.includes('timeout')) {
                hintNetError(1001);
            } else {
                hintNetError(+response.status);
            }
        }
        // 登录状态异常
        if (LOGOUT_CODE.includes(+response.status)) {
            exceptionLogout(+response.status, response);
        }
        return Promise.reject(response);
    }
};

axiosInstance.interceptors.response.use(commonErrorHandler.onFulfilled, commonErrorHandler.onRejected);