当前位置:   article > 正文

前端业务系统单元测试落地

前端vue单测

b9af3d41300026a558c57822c3c664be.png

今日文章由作者@愚坤(秦少卫)授权分享, 曾就职优信二手车,现就职水滴筹大数据前端团队,掘金优秀作者。阅读原文关注作者!

一直对单测很感兴趣,但对单测覆盖率、测试报告等关键词懵懵懂懂,最近几个月一直在摸索如何在Vue业务系统中落地单元测试,看到慢慢增长的覆盖率,慢慢清晰的模块,对单元测试的理解也比以前更加深入,也有一些心得和收获。

今天把自己的笔记分享出来,和大家一起交流我在2个较为复杂的Vue业务系统中落地单测的一些思路和方法,算是入门实践类的笔记,资深大佬还请跳过。

大纲

  1. 定义

  2. 安装与使用

  3. 常用API

  4. 落地单元测试

  5. 演进:构建可测试的单元模块

  6. 可维护的单元模块

  7. 回顾

  8. 讨论 && Thank

1. 定义

单元测试定义:

单元测试是指对软件中的最小可测试单元进行检查和验证。单元在质量保证中是非常重要的环节,根据测试金字塔原理,越往上层的测试,所需的测试投入比例越大,效果也越差,而单元测试的成本要小的多,也更容易发现问题。

也有不同的测试分层策略(冰淇淋模型、冠军模型)。

dfc50fcd80539d5620e52aedf26d70ff.png

2. 安装与使用

1. vue项目添加 @vue/unit-jest 文档
$ vue add @vue/unit-jest

安装完成后,在package.json中会多出test:unit脚本选项,并生成jest.config.js文件。

  1. // package.json
  2. {
  3.   "name""avatar",
  4.   "scripts": {
  5.     "test:unit""vue-cli-service test:unit"// 新增的脚本
  6.   },
  7.   "dependencies": {
  8.     ...
  9.   },
  10.   "devDependencies": {
  11.     ...
  12.   },
  13. }

生成测试报告的脚本:增加--coverage自定义参数

  1. // package.json
  2. {
  3.   "name""avatar",
  4.   "scripts": {
  5.     "test:unit""vue-cli-service test:unit",
  6.     "test:unitc""vue-cli-service test:unit  --coverage"// 测试并生成测试报告
  7.   },
  8.   "dependencies": {
  9.     ...
  10.   },
  11.   "devDependencies": {
  12.     ...
  13.   },
  14. }
2. VScode vscode-jest-runner 插件配置

作用:VS Code打开测试文件后,可直接运行用例。4f8d8582366c0f0426667042d9793048.png运行效果:2ee8b323a96505e29b9c8dcec92a0aec.png不通过效果:0b82a1b8d528ef074ca534a432bf762b.png

安装插件:https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner配置项:设置 => jest-Runner Config

  • Code Lens Selector:匹配的文件,**/*.{test,spec}.{js,jsx,ts,tsx}

  • Jest Command:定义Jest命令,默认为Jest 全局命令。

将Jest Command替换为 test:unit,使用vue脚手架提供的 test:unit 进行单元测试。9db4b17cbaedb996560a6fdf9fdddddf.png

3. githook 配置

作用:在提交时执行所有测试用例,有测试用例不通过或覆盖率不达标时取消提交。f6188aa5214787bf91a1bce679f0b374.png8cec9d304cd92e5da63141273ae92e54.png

安装:

$ npm install husky --save-dev

配置:

  1. // package.json
  2. {
  3.   "name""avatar",
  4.   "scripts": {
  5.     "test:unit""vue-cli-service test:unit",
  6.     "test:unitc""vue-cli-service test:unit  --coverage"// 测试并生成测试报告
  7.   },
  8.   "husky": {
  9.     "hooks": {
  10.       "pre-commit""npm run test:unitc" // commit时执行参单元测试 并生成测试报告
  11.     }
  12.   },
  13. }

设置牵引指标:jest.config.js,可全局设置、对文件夹设置、对单个文件设置。

  1. module.exports = {
  2.   preset: '@vue/cli-plugin-unit-jest',
  3.   timers: 'fake',
  4.   coverageThreshold: {
  5.    global: { // 全局
  6.       branches: 10,
  7.       functions: 10,
  8.       lines: 10,
  9.       statements: 10
  10.     },
  11.     './src/common/**/*.js': { // 文件夹
  12.       branches: 0,
  13.       statements: 0
  14.     },
  15.     './src/common/agoraClientUtils.js': { // 单个文件
  16.       branches: 80,
  17.       functions: 80,
  18.       lines: 80,
  19.       statements: 80
  20.     }
  21.   }
  22. }
4. 测试报告

生成的测试报告在跟目录下的coverage文件夹下,主要是4个指标。

  • 语句覆盖率(statement coverage)每个语句是否都执行

  • 分支覆盖率(branch coverage)每个if代码块是否都执行

  • 函数覆盖率(function coverage)每个函数是否都调用

  • 行覆盖率(line coverage) 每一行是否都执行了

根目录截图0406791a1278d319d6b0ecd4eb1c569e.png文件夹目录截图:三种颜色代表三种状态:红色、黄色、绿色。a4864655baee9ba42d9eb481b8041418.png单个文件截图:红色行为未覆盖,绿色行为运行次数。64c3f1d623d36e6a2407720c1879ab03.png

3. 常用API

抛砖引玉,只展示简单的用法,具体可参见文档。

Jest常用方法:文档

  1. // 例子
  2. describe('versionToNum 版本号转数字', () => {
  3.   it('10.2.3 => 10.2', () => {
  4.     expect(versionToNum('10.2.3')).toBe(10.2)
  5.   })
  6.   it('11.2.3 => 11.2', () => {
  7.     expect(versionToNum('11.2.3')).toBe(11.2)
  8.   })
  9. })
  10. /*------------------------------------------------*/
  11. // 值对比
  12. expect(2 + 2).toBe(4); 
  13. expect(operationServe.operationPower).toBe(true)
  14. // 对象对比
  15. expect(data).toEqual({one: 1, two: 2}); 
  16. // JSON 对比
  17. expect(data).toStrictEqual(afterJson)
  18. // 每次执行前
  19. beforeEach(() => {
  20.  // do  some thing....
  21.   // DOM 设置
  22.   document.body.innerHTML = `<div id="pc" class="live-umcamera-video" style="position: relative;">
  23.         <div style="width:200px; height:300px; position:absolute; top:20px; left:500px;">
  24.             <video style="width:300px; height:400px;"
  25.                 autoplay="" muted="" playsinline=""></video>
  26.         </div>
  27.     </div>`
  28. })
  29. // Mock
  30. const getCondition =  jest.fn().mockImplementation(() => Promise.resolve({ ret: 0, content: [{ parameterName: 'hulala' }] }))
  31. // Promise 方法
  32. it('获取预置埋点 - pages', () => {
  33.   return getCondition('hz''pages').then(() => {
  34.     // logType不包含presetEvent、不等于 pages,获取预置埋点
  35.     expect($api.analysis.findPresetList).toBeCalled()
  36. })
  37. // 定时器方法  
  38. it('定时器 新建 执行', () => {
  39.   const timer = new IntervalStore()
  40.   const callback = jest.fn()
  41.   timer.start('oneset', callback, 2000)
  42.   expect(callback).not.toBeCalled()
  43.   jest.runTimersToTime(2000// 等待2秒
  44.   expect(callback).toBeCalled()
  45. })

@vue/test-utils常用方法:文档

  1. // 例子
  2. import { mount } from '@vue/test-utils'
  3. import Counter from './counter'
  4. describe('Counter', () => {
  5.   // 现在挂载组件,你便得到了这个包裹器
  6.   const wrapper = mount(Counter)
  7.   it('renders the correct markup', () => {
  8.     expect(wrapper.html()).toContain('<span class="count">0</span>')
  9.   })
  10.   // 也便于检查已存在的元素
  11.   it('has a button', () => {
  12.     expect(wrapper.contains('button')).toBe(true)
  13.   })
  14. })
  15. /*------------------------------------------------*/
  16. import { shallowMount, mount, render, renderToString, createLocalVue } from '@vue/test-utils'
  17. import Component from '../HelloWorld.vue'
  18. // router模拟
  19. import VueRouter from 'vue-router'
  20. const localVue = createLocalVue()
  21. localVue.use(VueRouter)
  22. shallowMount(Component, { localVue })
  23. // 伪造
  24. const $route = {
  25.   path: '/some/path'
  26. }
  27. const wrapper = shallowMount(Component, {
  28.   mocks: {
  29.     $route
  30.   }
  31. })
  32. // store 模拟
  33. const store = new Vuex.Store({
  34.       state: {},
  35.       actions
  36.  })
  37. shallowMount(Component, { localVue, store })
  38. it('错误信息展示', async () => {
  39.     // shallowMount  入参模拟
  40.     const wrapper = shallowMount(cloudPhone, {
  41.       propsData: {
  42.         mosaicStatus: false,
  43.         customerOnLine: true,
  44.         cloudPhoneState: false,
  45.         cloudPhoneError: true,
  46.         cloudPhoneTip: '发生错误',
  47.         delay: ''
  48.       }
  49.     })
  50.     
  51.     
  52.     // 子组件是否展示
  53.     expect(wrapper.getComponent(Tip).exists()).toBe(true)
  54.     // html判断
  55.     expect(wrapper.html().includes('发生错误')).toBe(true)
  56.     // DOM 元素判断
  57.     expect(wrapper.get('.mosaicStatus').isVisible()).toBe(true)
  58.     // 执行点击事件
  59.     await wrapper.find('button').trigger('click')
  60.    // class
  61.     expect(wrapper.classes()).toContain('bar')
  62.     expect(wrapper.classes('bar')).toBe(true)
  63.   // 子组件查找   
  64.    wrapper.findComponent(Bar)
  65.     // 销毁
  66.     wrapper.destroy()
  67.     // 
  68.     wrapper.setData({ foo: 'bar' })
  69.    
  70.    // axios模拟
  71.     jest.mock('axios', () => ({
  72.       get: Promise.resolve('value')
  73.     }))
  74.   
  75.   })

4. 落地单元测试

❌ 直接对一个较大的业务组件添加单元测试,需要模拟一系列的全局函数,无法直接运行。

问题:

  1. 逻辑多:业务逻辑不清楚,1000+ 行

  2. 依赖多:$dayjs、$api、$validate、$route、$echarts、mixins、$store...

  3. 路径不一致:有@./../

单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。-- 廖雪峰的官方网站

落地:

✅ 对业务逻辑关键点,抽出纯函数、类方法、组件,并单独增加测试代码

例子:获取分组参数,由7个接口聚合。

70f75ec2d3df8162e5963a6a1f28f74b.png
image.png
51cc69660cf08f9d8b36721be2a16370.png
image.png
03afb39fcdf2d5328de64365bd72f8c9.png
image.png

原有逻辑:系统参数存全局变量,自定义参数存全局变量

  • 无法看出多少种类型与接口数量

  • 无法在多个位置直接复用

  1. getCondition (fIndex, oneFunnel) { // 添加限制条件,如果该事件没有先拉取
  2.       const {biz, logType, event, feCreateType} = oneFunnel
  3.       return new Promise((resolve, reject) => {
  4.         // 私有限制条件为空,且不是预置事件 或 页面组,就拉取私有限制条件
  5.         try {
  6.           this.$set(this.extraParamsList.parameterList, fIndex, {})
  7.           if (logType !== 'pages' && logType.indexOf('presetEvent') === -1) {
  8.             this.$api.analysis[`${logType}ParameterList`]({
  9.               biz: logType === 'server' && feCreateType === 0 ? '' : biz,
  10.               event: event,
  11.               terminal: this.customType[logType],
  12.               platform: logType === 'server' && feCreateType === 0 ? 'common' : '',
  13.               pageNum: -1
  14.             }).then(res => {
  15.               if (res.ret === 0) {
  16.                 res.content.forEach(element => {
  17.                   this.$set(this.extraParamsList.parameterList[fIndex], element.parameterName || element.parameter_name, element)
  18.                 })
  19.                 resolve()
  20.               } else {
  21.                 reject('获取事件属性失败,请联系后台管理员')
  22.               }
  23.             })
  24.           } else if ((logType === 'presetEvents' ||  logType === 'presetEventsApp')) {
  25.             this.$api.analysis.findPresetList({
  26.               biz,
  27.               appTerminal: logType,
  28.               operation: event
  29.             }).then(res => {
  30.               if (res.code === 0) {
  31.                 res.data.forEach(item => {
  32.                   item.description = item.name
  33.                   this.$set(this.extraParamsList.parameterList[fIndex], item.name, item)
  34.                 })
  35.                 resolve()
  36.               }
  37.             })
  38.           } else {
  39.             resolve('无需拉取')
  40.           }
  41.         } catch (e) {
  42.           reject(e)
  43.         }
  44.       })
  45.     },
  46.       
  47.      getGlobalCondition (funnelId) { // 获取 全局 基础选项
  48.       return new Promise((resolve, reject) => {
  49.         this.$api.analysis.getGlobalCondition({
  50.           funnelId: funnelId,
  51.           type: this.conditionMode
  52.         }).then(res => {
  53.           if (res.code === 0) {
  54.             const {bizList, expressions, expressionsNumber, comBizList} = res.data
  55.             this.bizList = Object.assign(...bizList)
  56.             this.comBizList = Object.assign(...comBizList)
  57.             this.comBizKeyList = Object.keys(this.comBizList)
  58.             this.operatorList = expressions
  59.             this.numberOperatorList = expressionsNumber
  60.             this.comBizKey = Object.keys(this.comBizList)
  61.             this.getComBizEvent()
  62.             resolve(res)
  63.           } else {
  64.             this.$message.error('获取基础选项失败,请联系后台管理员')
  65.             reject('获取基础选项失败,请联系后台管理员')
  66.           }
  67.         })
  68.       })
  69.     },  
  70.       
  71.    setCommonPropertiesList (data) { // 初始化 公共限制条件列表 commonPropertiesList
  72.       const commonPropertiesList = {
  73.         auto: data.h5AutoCommonProperties,
  74.         pages: data.h5PagesCommonProperties,
  75.         presetEvents: data.h5PresetCommonProperties, // h5 预置事件 公共属性
  76.         customH5: data.h5CustomCommonProperties,
  77.         customApp: data.appCustomCommonProperties,
  78.         presetEventsApp: data.appPresetCommonProperties, // App 预置事件 公共属性
  79.         server: data.serverCommonProperties,
  80.         customWeapp: data.weappCustomCommonProperties,
  81.         presetEventsWeapp: data.weappPresetCommonProperties, // Weapp 预置事件 公共属性
  82.         presetEventsServer: data.serverPresetCommonProperties || [], // Server 预置事件 公共属性
  83.         presetEventsAd: data.adPresetCommonProperties
  84.       }
  85.       for (let type in commonPropertiesList) { // 将parameter_name的值作为key,item作为value,组合为k-v形式
  86.         let properties = {}
  87.         if (!commonPropertiesList[type]) continue
  88.         commonPropertiesList[type].forEach(item => {
  89.           properties[item.parameter_name] = item
  90.         })
  91.         commonPropertiesList[type] = properties
  92.       }
  93.       this.commonPropertiesList = commonPropertiesList
  94.     },

拆分模块后:建立GetParamsServer主类,该类由2个子类构成,并聚合子类接口。15bf31ec5920798262cb4d860f8c9ed4.png

这是其中一个子类,获取私有参数的单元测试:

  1. import GetParamsServer, { GetPrivateParamsServer } from '@/views/analysis/components/getParamsServer.js'
  2. describe('GetPrivateParamsServer 私有参数获取', () => {
  3.   let $api
  4.     beforeEach(() => {
  5.       $api = {
  6.         analysis: {
  7.           findPresetList: jest.fn().mockImplementation(() => Promise.resolve({
  8.             code: 0, data: [{ name: 'hulala', description: '234234', data_type: 'event' }]
  9.           })), // 预置埋点
  10.           serverParameterList: jest.fn().mockImplementation(() => Promise.resolve({
  11.             ret: 0, content: [{ parameterName: 'hulala' }]
  12.           })), // 服务端埋点
  13.           autoParameterList: jest.fn().mockImplementation(() => Promise.resolve({
  14.             ret: 0, content: [{ parameter_name: 'hulala' }]
  15.           })), // H5全埋点
  16.           customH5ParameterList: jest.fn().mockImplementation(() => Promise.resolve({
  17.             ret: 0, content: [{ parameterName: 'hulala' }]
  18.           })), // H5自定义
  19.           customWeappParameterList: jest.fn().mockImplementation(() => Promise.resolve({
  20.             ret: 0, content: [{ parameter_name: 'hulala', description: '234234', data_type: 'event' }]
  21.           })), // Weapp自定义
  22.           customAppParameterList: jest.fn().mockImplementation(() => Promise.resolve({
  23.             ret: 0, content: [{ parameterName: 'hulala', description: 'asdfafd', data_type: 'event' }]
  24.           })) // App自定义
  25.         }
  26.       }
  27.     })
  28.   describe('GetPrivateParamsServer 不同类型获取', () => {
  29.     it('获取预置埋点 - pages', () => {
  30.       const paramsServer = new GetPrivateParamsServer()
  31.       paramsServer.initApi($api)
  32.       return paramsServer.getCondition('hz''pages').then(() => {
  33.         // logType不包含presetEvent、不等于 pages,获取预置埋点
  34.         expect($api.analysis.findPresetList).toBeCalled()
  35.       })
  36.     })
  37.     it('获取预置埋点 - presetEvent ', () => {
  38.       const paramsServer = new GetPrivateParamsServer()
  39.       paramsServer.initApi($api)
  40.       return paramsServer.getCondition('hz''presetEvent').then(() => {
  41.         // logType不包含presetEvent、不等于 pages,获取预置埋点
  42.         expect($api.analysis.findPresetList).toBeCalled()
  43.       })
  44.     })
  45.     it('获取非预置埋点 - 其他', () => {
  46.       const paramsServer = new GetPrivateParamsServer()
  47.       paramsServer.initApi($api)
  48.       return paramsServer.getCondition('hz''12312').then(() => {
  49.         expect($api.analysis.findPresetList).not.toBeCalled()
  50.       })
  51.     })
  52.     
  53.     it('获取非预置埋点 - server', () => {
  54.       const paramsServer = new GetPrivateParamsServer()
  55.       paramsServer.initApi($api)
  56.       return paramsServer.getCondition('hz''server').then(() => {
  57.         expect($api.analysis.serverParameterList).toBeCalled()
  58.       })
  59.     })
  60.     it('获取非预置埋点 - auto', () => {
  61.       const paramsServer = new GetPrivateParamsServer()
  62.       paramsServer.initApi($api)
  63.       return paramsServer.getCondition('hz''auto').then(() => {
  64.         expect($api.analysis.autoParameterList).toBeCalled()
  65.       })
  66.     })
  67.     it('获取非预置埋点 - customH5', () => {
  68.       const paramsServer = new GetPrivateParamsServer()
  69.       paramsServer.initApi($api)
  70.       return paramsServer.getCondition('hz''customH5').then(() => {
  71.         expect($api.analysis.customH5ParameterList).toBeCalled()
  72.       })
  73.     })
  74.     it('获取非预置埋点 - customWeapp', () => {
  75.       const paramsServer = new GetPrivateParamsServer()
  76.       paramsServer.initApi($api)
  77.       return paramsServer.getCondition('hz''customWeapp').then(() => {
  78.         expect($api.analysis.customWeappParameterList).toBeCalled()
  79.       })
  80.     })
  81.     it('获取非预置埋点 - customApp', () => {
  82.       const paramsServer = new GetPrivateParamsServer()
  83.       paramsServer.initApi($api)
  84.       return paramsServer.getCondition('hz''customApp').then(() => {
  85.         expect($api.analysis.customAppParameterList).toBeCalled()
  86.       })
  87.     })
  88.     it('获取非预置埋点 - 不存在类型', () => {
  89.       const paramsServer = new GetPrivateParamsServer()
  90.       paramsServer.initApi($api)
  91.       return paramsServer.getCondition('hz''哈哈哈哈').then(res => {
  92.         expect(res.length).toBe(0)
  93.       })
  94.     })
  95.   })
  96.   describe('GetPrivateParamsServer 结果转换为label', () => {
  97.     it('获取预置埋点 - pages', () => {
  98.       const paramsServer = new GetPrivateParamsServer()
  99.       paramsServer.initApi($api)
  100.       return paramsServer.getConditionLabel('hz''pages').then((res) => {
  101.         expect(res.length).toBe(1)
  102.         expect(!!res[0].value).toBeTruthy()
  103.         expect(!!res[0].label).toBeTruthy()
  104.         expect(res[0].types).toBe('custom')
  105.         expect(res[0].dataType).toBe('event')
  106.       })
  107.     })
  108.     it('获取非预置埋点 - customWeapp', () => {
  109.       const paramsServer = new GetPrivateParamsServer()
  110.       paramsServer.initApi($api)
  111.       return paramsServer.getConditionLabel('hz''customWeapp').then((res) => {
  112.         expect(res.length).toBe(1)
  113.         expect(!!res[0].value).toBeTruthy()
  114.         expect(!!res[0].label).toBeTruthy()
  115.         expect(res[0].types).toBe('custom')
  116.         expect(res[0].dataType).toBe('event')
  117.       })
  118.     })
  119.     it('获取非预置埋点 - customApp', () => {
  120.       const paramsServer = new GetPrivateParamsServer()
  121.       paramsServer.initApi($api)
  122.       return paramsServer.getConditionLabel('hz''customApp').then((res) => {
  123.         expect(res.length).toBe(1)
  124.         expect(!!res[0].value).toBeTruthy()
  125.         expect(!!res[0].label).toBeTruthy()
  126.         expect(res[0].types).toBe('custom')
  127.         expect(res[0].dataType).toBe('event')
  128.       })
  129.     })
  130.   })
  131. })
3bd27b26d74b9215be9b422246f60d3a.png
image.png

从测试用例看到的代码逻辑:

  • 6个接口

  • 6种事件类型

  • 类型与接口的对应关系

  • 接口格式有三种

作用:

  1. 复用:将复杂的业务逻辑封闭在黑盒里,更方便复用。

  2. 质量:模块的功能通过测试用例得到保障。

  3. 维护:测试即文档,方便了解业务逻辑。

实践:在添加单测的过程中,抽象模块,重构部分功能,并对单一职责的模块增加单测。

5. 演进:构建可测试单元模块

将业务代码代码演变为可测试代码,重点在:

  1. 设计:将业务逻辑拆分为单元模块(UI组件、功能模块)。

  2. 时间:可行的重构目标与重构方法,要有长期重构的心理预期。

为单一职责的模块设计测试用例,才会对功能覆盖的更全面,所以设计这一步尤为重要。

如果挽救一个系统的办法是重新设计一个新的系统,那么,我们有什么理由认为从头开始,结果会更好呢? --《架构整洁之道》

原来模块也是有设计,我们如何保证重构后真的比之前更好吗?还是要根据设计原则客观的来判断。

设计原则 SOLID:

  • SRP-单一职责

  • OCP-开闭:易与扩展,抗拒修改。

  • LSP-里氏替换:子类接口统一,可相互替换。

  • ISP-接口隔离:不依赖不需要的东西。

  • DIP-依赖反转:构建稳定的抽象层,单向依赖(例:A => B => C, 反例:A  => B => C => A)。

在应接不暇的需求面前,还要拆模块、重构、加单测,无疑是增加工作量,显得不切实际,《重构》这本书给了我很多指导。

重构方法:

  • 预备性重构

  • 帮助理解的重构

  • 捡垃圾式重构(营地法则:遇到一个重构一个,像见垃圾一样,让你离开时的代码比来时更干净、健康)

  • 有计划的重构与见机行事的重构

  • 长期重构

业务系统1的模块与UI梳理:

bdfe43ea93a2d272881ad827957f8c67.png
image.png

业务系统2的模块与UI梳理:

207062094dd7143d556dbf5545a578ce.png
image.png

6. 可维护的单元模块

避免重构后再次写出坏味道的代码,提取执行成本更低的规范。

代码坏味道:

  • 神秘命名-无法取出好名字,背后可能潜藏着更深的设计问题。

  • 重复代码

  • 过长函数-小函数、纯函数

  • 过长参数

  • 全局数据-数量越多处理难度会指数上升。

  • 可变数据-不知道在哪个节点修改了数据。

  • 发散式变化-只关注当前修改,不用关注其他关联。

  • 霰弹式修改-修改代码散布四处

  • 依恋情结-与外部模块交流数据胜过内部数据。

  • 数据泥团-相同的参数在多个函数间传递。

  • 基本类型偏执

  • 重复的switch

  • 循环语句

  • 冗赘的元素

  • 夸夸其谈通用性

  • 临时字段

  • 过长的消息链

  • 中间人

  • 内幕交易

  • 过大的类

  • 异曲同工的类

  • 纯数据类

  • 被拒绝的遗赠-继承父类无用的属性或方法

  • 注释-当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

规范:

  • 全局变量数量:20 ±

  • 方法方法行数:15 ±

  • 代码行数:300-500

  • 内部方法、内联方法:下划线开头

技巧:

  • 使用class语法:将紧密关联的方法和变量封装在一起。

  • 使用Eventemitter 工具库:实现简单发布订阅。

  • 使用vue  provide语法:传递实例。

  • 使用koroFileHeader插件:统一注释规范。

  • 使用Git-commit-plugin插件:统一commit规范。

  • 使用eslint + stylelint(未使用变量、误改变量名、debugger,自动优化的css)。

示例代码:

  1. /*
  2.  * @name: 轻量级message提示插件
  3.  * @Description: 模仿iview的$message方法,api与样式保持一致。
  4.  */
  5. class Message {
  6.     constructor() {
  7.         this._prefixCls = 'i-message-';
  8.         this._default = {
  9.             top: 16,
  10.             duration: 2
  11.         }
  12.     }
  13.     info(options) {
  14.         return this._message('info', options);
  15.     }
  16.     success(options) {
  17.         return this._message('success', options);
  18.     }
  19.     warning(options) {
  20.         return this._message('warning', options);
  21.     }
  22.     error(options) {
  23.         return this._message('error', options);
  24.     }
  25.     loading(options) {
  26.         return this._message('loading', options);
  27.     }
  28.     config({ top = this._default.top, duration = this._default.duration }) {
  29.         this._default = {
  30.             top,
  31.             duration
  32.         }
  33.         this._setContentBoxTop()
  34.     }
  35.     destroy() {
  36.         const boxId = 'messageBox'
  37.         const contentBox = document.querySelector('#' + boxId)
  38.         if (contentBox) {
  39.             document.body.removeChild(contentBox)
  40.         }
  41.         this._resetDefault()
  42.     }
  43.     /**
  44.      * @description: 渲染消息
  45.      * @param {String} type 类型
  46.      * @param {Object | String} options 详细格式
  47.      */
  48.     _message(type, options) {
  49.         if (typeof options === 'string') {
  50.             options = {
  51.                 content: options
  52.             };
  53.         }
  54.         return this._render(options.content, options.duration, type, options.onClose, options.closable);
  55.     }
  56.     /**
  57.      * @description: 渲染消息
  58.      * @param {String} content 消息内容
  59.      * @param {Number} duration 持续时间
  60.      * @param {String} type 消息类型
  61.      */
  62.     _render(content = '', duration = this._default.duration, type = 'info',
  63.         onClose = () => { }, closable = false
  64.     ) {
  65.         // 获取节点信息
  66.         const messageDOM = this._getMsgHtml(type, content, closable)
  67.         // 插入父容器
  68.         const contentBox = this._getContentBox()
  69.         contentBox.appendChild(messageDOM);
  70.         // 删除方法
  71.         const remove = () => this._removeMsg(contentBox, messageDOM, onClose)
  72.         let removeTimer
  73.         if(duration !== 0){
  74.             removeTimer = setTimeout(remove, duration * 1000);
  75.         }
  76.         // 关闭按钮
  77.         closable && this._addClosBtn(messageDOM, remove, removeTimer)
  78.     }
  79.     /**
  80.      * @description: 删除消息
  81.      * @param {Element} contentBox 父节点
  82.      * @param {Element} messageDOM 消息节点
  83.      * @param {Number} duration 持续时间
  84.      */
  85.     _removeMsg(contentBox, messageDOM, onClose) {
  86.         messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeOutUp`
  87.         messageDOM.style.height = 0
  88.         setTimeout(() => {
  89.             contentBox.removeChild(messageDOM)
  90.             onClose()
  91.         }, 400);
  92.     }
  93.     /**
  94.      * @description: 获取图标
  95.      * @param {String} type
  96.      * @return {String} DOM HTML 字符串
  97.      */
  98.     _getIcon(type = 'info') {
  99.         const map = {
  100.             info: `<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  101.            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  102.          </svg>`,
  103.             success: `<svg style="color:#19be6b"  xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
  104.            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
  105.          </svg>`,
  106.             warning: `<svg style="color:#ff9900" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  107.            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  108.          </svg>`,
  109.             error`<svg style="color:#ed4014" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
  110.            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
  111.          </svg>`,
  112.             loading: `<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" class="loading" viewBox="0 0 20 20" fill="currentColor">
  113.            <path fill-rule="evenodd" d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z" clip-rule="evenodd" />
  114.          </svg>`
  115.         }
  116.         return map[type]
  117.     }
  118.     /**
  119.      * @description: 获取消息节点
  120.      * @param {String} type 类型
  121.      * @param {String} content 消息内容
  122.      * @return {Element} 节点DOM对象
  123.      */
  124.     _getMsgHtml(type, content) {
  125.         const messageDOM = document.createElement("div")
  126.         messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeInDown`
  127.         messageDOM.style.height = 36 + 'px'
  128.         messageDOM.innerHTML = `
  129.                 <div class="${this._prefixCls}message" >
  130.                     ${this._getIcon(type)}
  131.                     <div class="${this._prefixCls}content-text">${content}</div>
  132.                 </div>
  133.         `
  134.         return messageDOM
  135.     }
  136.     /**
  137.      * @description: 添加关闭按钮
  138.      * @param {Element} messageDOM 消息节点DOM
  139.      */
  140.     _addClosBtn(messageDOM, remove, removeTimer) {
  141.         const svgStr = `<svg class="${this._prefixCls}btn" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  142.             <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  143.         </svg>`
  144.         const closBtn = new DOMParser().parseFromString(svgStr, 'text/html').body.childNodes[0];
  145.         closBtn.onclick = () => {
  146.             removeTimer && clearTimeout(removeTimer)
  147.             remove()
  148.         }
  149.         messageDOM.querySelector(`.${this._prefixCls}message`).appendChild(closBtn)
  150.     }
  151.     /**
  152.      * @description: 获取父节点容器
  153.      * @return {Element} 节点DOM对象
  154.      */
  155.     _getContentBox() {
  156.         const boxId = 'messageBox'
  157.         if (document.querySelector('#' + boxId)) {
  158.             return document.querySelector('#' + boxId)
  159.         } else {
  160.             const contentBox = document.createElement("div")
  161.             contentBox.id = boxId
  162.             contentBox.style.top = this._default.top + 'px'
  163.             document.body.appendChild(contentBox)
  164.             return contentBox
  165.         }
  166.     }
  167.     /**
  168.      * @description: 重新设置父节点高度
  169.      */
  170.     _setContentBoxTop() {
  171.         const boxId = 'messageBox'
  172.         const contentBox = document.querySelector('#' + boxId)
  173.         if (contentBox) {
  174.             contentBox.style.top = this._default.top + 'px'
  175.         }
  176.     }
  177.     /**
  178.      * @description: 恢复默认值
  179.      */
  180.     _resetDefault() {
  181.         this._default = {
  182.             top: 16,
  183.             duration: 2
  184.         }
  185.     }
  186. }
  187. if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
  188.     module.exports = new Message();
  189. else {
  190.     window.$message = new Message();
  191. }
13edf1a9c435b33e6688a7a6b0e9d1a9.png
image.png

6. 回顾

  • 定义

  • 安装与使用(安装、调试、git拦截、测试报告)

  • 常用API(jest、vue组件)

  • 落地单元测试(拆分关键模块加单测)

  • 演进:构建可测试单元模块(设计原则、重构)

  • 可维护的单元模块(代码规范)

落地线路:

① 安装使用 => ② API学习 => ③ 落地:拆分关键模块加单测 =>  ④ 演进:架构设计与重构 =>  ⑤ 代码规范

未来:

⑥ 文档先行(待探索)

在较为复杂的业务系统开发过程中,从第一版代码到逐步划分模块、增加单测,还是走了一段弯路。如果能够养成文档先行的习惯,先设计模块、测试用例,再编写代码,会更高效。

理解:

  • 单元测试有长期价值,也有执行成本。

  • 好的架构设计是单测的土壤,为单一职责的模块设计单测、增加单元测试更加顺畅

  • 每个项目的业务形态与阶段不一样,不一定都适合,找到适合项目的平衡点

7. 讨论 && Thank

感谢各位能够看到最后,前半部偏干,后半部分偏水,为内部分享笔记,部分代码和图片经过处理,重在分享和大家一起交流,恳请斧正,有收获还请点赞收藏。

关于本文

  • 作者:@愚坤(秦少卫)

  • https://juejin.cn/post/6978831511164289055

  • https://github.com/nihaojob

作者往期分享

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/代码探险家/article/detail/753200
推荐阅读
相关标签
  

闽ICP备14008679号