1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        Vue 業(yè)務(wù)系統(tǒng)如何落地單元測試

        共 52564字,需瀏覽 106分鐘

         ·

        2021-07-09 14:34

        點擊上方 程序員成長指北,關(guān)注公眾號

        回復1,加入高級Node交流群

        今日文章由作者@愚坤(秦少衛(wèi))Node.js技術(shù)棧@五月君 授權(quán)分享, 曾就職優(yōu)信二手車,現(xiàn)就職水滴籌大數(shù)據(jù)前端團隊,掘金優(yōu)秀作者。閱讀原文關(guān)注作者!

        一直對單測很感興趣,但對單測覆蓋率、測試報告等關(guān)鍵詞懵懵懂懂,最近幾個月一直在摸索如何在Vue業(yè)務(wù)系統(tǒng)中落地單元測試,看到慢慢增長的覆蓋率,慢慢清晰的模塊,對單元測試的理解也比以前更加深入,也有一些心得和收獲。

        今天把自己的筆記分享出來,和大家一起交流我在2個較為復雜的Vue業(yè)務(wù)系統(tǒng)中落地單測的一些思路和方法,算是入門實踐類的筆記,資深大佬還請?zhí)^。

        大綱

        1. 定義
        2. 安裝與使用
        3. 常用API
        4. 落地單元測試
        5. 演進:構(gòu)建可測試的單元模塊
        6. 可維護的單元模塊
        7. 回顧
        8. 討論 && Thank

        1. 定義

        單元測試定義:

        單元測試是指對軟件中的最小可測試單元進行檢查和驗證。單元在質(zhì)量保證中是非常重要的環(huán)節(jié),根據(jù)測試金字塔原理,越往上層的測試,所需的測試投入比例越大,效果也越差,而單元測試的成本要小的多,也更容易發(fā)現(xiàn)問題。

        也有不同的測試分層策略(冰淇淋模型、冠軍模型)。

        2. 安裝與使用

        1. vue項目添加 @vue/unit-jest 文檔

        $ vue add @vue/unit-jest

        安裝完成后,在package.json中會多出test:unit腳本選項,并生成jest.config.js文件。

        // package.json
        {
          "name""avatar",
          "scripts": {
            "test:unit""vue-cli-service test:unit"// 新增的腳本
          },
          "dependencies": {
            ...
          },
          "devDependencies": {
            ...
          },
        }

        生成測試報告的腳本:增加--coverage自定義參數(shù)

        // package.json
        {
          "name""avatar",
          "scripts": {
            "test:unit""vue-cli-service test:unit",
            "test:unitc""vue-cli-service test:unit  --coverage"// 測試并生成測試報告
          },
          "dependencies": {
            ...
          },
          "devDependencies": {
            ...
          },
        }

        2. VScode vscode-jest-runner 插件配置

        作用:VS Code打開測試文件后,可直接運行用例。運行效果:不通過效果:

        安裝插件:https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner配置項:設(shè)置 => jest-Runner Config

        • Code Lens Selector:匹配的文件,**/*.{test,spec}.{js,jsx,ts,tsx}
        • Jest Command:定義Jest命令,默認為Jest 全局命令。

        將Jest Command替換為 test:unit,使用vue腳手架提供的 test:unit 進行單元測試。

        3. githook 配置

        作用:在提交時執(zhí)行所有測試用例,有測試用例不通過或覆蓋率不達標時取消提交。

        安裝:

        $ npm install husky --save-dev

        配置:

        // package.json
        {
          "name""avatar",
          "scripts": {
            "test:unit""vue-cli-service test:unit",
            "test:unitc""vue-cli-service test:unit  --coverage"// 測試并生成測試報告
          },
          "husky": {
            "hooks": {
              "pre-commit""npm run test:unitc" // commit時執(zhí)行參單元測試 并生成測試報告
            }
          },
        }

        設(shè)置牽引指標:jest.config.js,可全局設(shè)置、對文件夾設(shè)置、對單個文件設(shè)置。

        module.exports = {
          preset'@vue/cli-plugin-unit-jest',
          timers'fake',
          coverageThreshold: {
           global: { // 全局
              branches10,
              functions10,
              lines10,
              statements10
            },
            './src/common/**/*.js': { // 文件夾
              branches0,
              statements0
            },
            './src/common/agoraClientUtils.js': { // 單個文件
              branches80,
              functions80,
              lines80,
              statements80
            }
          }
        }

        4. 測試報告

        生成的測試報告在跟目錄下的coverage文件夾下,主要是4個指標。

        • 語句覆蓋率(statement coverage)每個語句是否都執(zhí)行
        • 分支覆蓋率(branch coverage)每個if代碼塊是否都執(zhí)行
        • 函數(shù)覆蓋率(function coverage)每個函數(shù)是否都調(diào)用
        • 行覆蓋率(line coverage) 每一行是否都執(zhí)行了

        根目錄截圖文件夾目錄截圖:三種顏色代表三種狀態(tài):紅色、黃色、綠色。單個文件截圖:紅色行為未覆蓋,綠色行為運行次數(shù)。

        3. 常用API

        拋磚引玉,只展示簡單的用法,具體可參見文檔。

        Jest常用方法:文檔

        // 例子
        describe('versionToNum 版本號轉(zhuǎn)數(shù)字', () => {
          it('10.2.3 => 10.2', () => {
            expect(versionToNum('10.2.3')).toBe(10.2)
          })
          it('11.2.3 => 11.2', () => {
            expect(versionToNum('11.2.3')).toBe(11.2)
          })
        })

        /*------------------------------------------------*/

        // 值對比
        expect(2 + 2).toBe(4); 
        expect(operationServe.operationPower).toBe(true)
        // 對象對比
        expect(data).toEqual({one1two2}); 
        // JSON 對比
        expect(data).toStrictEqual(afterJson)


        // 每次執(zhí)行前
        beforeEach(() => {
         // do  some thing....
          // DOM 設(shè)置
          document.body.innerHTML = `<div id="pc" class="live-umcamera-video" style="position: relative;">
                <div style="width:200px; height:300px; position:absolute; top:20px; left:500px;">
                    <video style="width:300px; height:400px;"
                        autoplay="" muted="" playsinline=""></video>
                </div>
            </div>`

        })

        // Mock
        const getCondition =  jest.fn().mockImplementation(() => Promise.resolve({ ret0content: [{ parameterName'hulala' }] }))

        // Promise 方法
        it('獲取預置埋點 - pages', () => {
          return getCondition('hz''pages').then(() => {
            // logType不包含presetEvent、不等于 pages,獲取預置埋點
            expect($api.analysis.findPresetList).toBeCalled()
        })

        // 定時器方法  
        it('定時器 新建 執(zhí)行', () => {
          const timer = new IntervalStore()
          const callback = jest.fn()
          timer.start('oneset', callback, 2000)
          expect(callback).not.toBeCalled()
          jest.runTimersToTime(2000// 等待2秒
          expect(callback).toBeCalled()
        })
         

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

        // 例子
        import { mount } from '@vue/test-utils'
        import Counter from './counter'

        describe('Counter', () => {
          // 現(xiàn)在掛載組件,你便得到了這個包裹器
          const wrapper = mount(Counter)

          it('renders the correct markup', () => {
            expect(wrapper.html()).toContain('<span class="count">0</span>')
          })

          // 也便于檢查已存在的元素
          it('has a button', () => {
            expect(wrapper.contains('button')).toBe(true)
          })
        })

        /*------------------------------------------------*/


        import { shallowMount, mount, render, renderToString, createLocalVue } from '@vue/test-utils'
        import Component from '../HelloWorld.vue'

        // router模擬
        import VueRouter from 'vue-router'
        const localVue = createLocalVue()
        localVue.use(VueRouter)
        shallowMount(Component, { localVue })

        // 偽造
        const $route = {
          path'/some/path'
        }
        const wrapper = shallowMount(Component, {
          mocks: {
            $route
          }
        })


        // store 模擬
        const store = new Vuex.Store({
              state: {},
              actions
         })
        shallowMount(Component, { localVue, store })


        it('錯誤信息展示'async () => {
            // shallowMount  入?yún)⒛M
            const wrapper = shallowMount(cloudPhone, {
              propsData: {
                mosaicStatusfalse,
                customerOnLinetrue,
                cloudPhoneStatefalse,
                cloudPhoneErrortrue,
                cloudPhoneTip'發(fā)生錯誤',
                delay''
              }
            })
            
            
            // 子組件是否展示
            expect(wrapper.getComponent(Tip).exists()).toBe(true)
            // html判斷
            expect(wrapper.html().includes('發(fā)生錯誤')).toBe(true)
            // DOM 元素判斷
            expect(wrapper.get('.mosaicStatus').isVisible()).toBe(true)
            // 執(zhí)行點擊事件
            await wrapper.find('button').trigger('click')
           // class
            expect(wrapper.classes()).toContain('bar')
            expect(wrapper.classes('bar')).toBe(true)
          // 子組件查找   
           wrapper.findComponent(Bar)
            // 銷毀
            wrapper.destroy()
            // 
            wrapper.setData({ foo'bar' })
           
           // axios模擬
            jest.mock('axios', () => ({
              getPromise.resolve('value')
            }))
          
          })

        4. 落地單元測試

        ? 直接對一個較大的業(yè)務(wù)組件添加單元測試,需要模擬一系列的全局函數(shù),無法直接運行。

        問題:

        1. 邏輯多:業(yè)務(wù)邏輯不清楚,1000+ 行
        2. 依賴多:$dayjs、$api、$validate、$route、$echarts、mixins、$store...
        3. 路徑不一致:有@、./../

        單元測試是用來對一個模塊、一個函數(shù)或者一個類來進行正確性檢驗的測試工作。-- 廖雪峰的官方網(wǎng)站

        落地:

        ? 對業(yè)務(wù)邏輯關(guān)鍵點,抽出純函數(shù)、類方法、組件,并單獨增加測試代碼。

        例子:獲取分組參數(shù),由7個接口聚合。

        image.png
        image.png
        image.png

        原有邏輯:系統(tǒng)參數(shù)存全局變量,自定義參數(shù)存全局變量

        • 無法看出多少種類型與接口數(shù)量
        • 無法在多個位置直接復用
        getCondition (fIndex, oneFunnel) { // 添加限制條件,如果該事件沒有先拉取
              const {biz, logType, event, feCreateType} = oneFunnel
              return new Promise((resolve, reject) => {
                // 私有限制條件為空,且不是預置事件 或 頁面組,就拉取私有限制條件
                try {
                  this.$set(this.extraParamsList.parameterList, fIndex, {})
                  if (logType !== 'pages' && logType.indexOf('presetEvent') === -1) {
                    this.$api.analysis[`${logType}ParameterList`]({
                      biz: logType === 'server' && feCreateType === 0 ? '' : biz,
                      event: event,
                      terminalthis.customType[logType],
                      platform: logType === 'server' && feCreateType === 0 ? 'common' : '',
                      pageNum-1
                    }).then(res => {
                      if (res.ret === 0) {
                        res.content.forEach(element => {
                          this.$set(this.extraParamsList.parameterList[fIndex], element.parameterName || element.parameter_name, element)
                        })
                        resolve()
                      } else {
                        reject('獲取事件屬性失敗,請聯(lián)系后臺管理員')
                      }
                    })
                  } else if ((logType === 'presetEvents' ||  logType === 'presetEventsApp')) {
                    this.$api.analysis.findPresetList({
                      biz,
                      appTerminal: logType,
                      operation: event
                    }).then(res => {
                      if (res.code === 0) {
                        res.data.forEach(item => {
                          item.description = item.name
                          this.$set(this.extraParamsList.parameterList[fIndex], item.name, item)
                        })
                        resolve()
                      }
                    })
                  } else {
                    resolve('無需拉取')
                  }
                } catch (e) {
                  reject(e)
                }
              })
            },
              
             getGlobalCondition (funnelId) { // 獲取 全局 基礎(chǔ)選項
              return new Promise((resolve, reject) => {
                this.$api.analysis.getGlobalCondition({
                  funnelId: funnelId,
                  typethis.conditionMode
                }).then(res => {
                  if (res.code === 0) {
                    const {bizList, expressions, expressionsNumber, comBizList} = res.data
                    this.bizList = Object.assign(...bizList)
                    this.comBizList = Object.assign(...comBizList)
                    this.comBizKeyList = Object.keys(this.comBizList)
                    this.operatorList = expressions
                    this.numberOperatorList = expressionsNumber
                    this.comBizKey = Object.keys(this.comBizList)
                    this.getComBizEvent()
                    resolve(res)
                  } else {
                    this.$message.error('獲取基礎(chǔ)選項失敗,請聯(lián)系后臺管理員')
                    reject('獲取基礎(chǔ)選項失敗,請聯(lián)系后臺管理員')
                  }
                })
              })
            },  
              
           setCommonPropertiesList (data) { // 初始化 公共限制條件列表 commonPropertiesList
              const commonPropertiesList = {
                auto: data.h5AutoCommonProperties,
                pages: data.h5PagesCommonProperties,
                presetEvents: data.h5PresetCommonProperties, // h5 預置事件 公共屬性
                customH5: data.h5CustomCommonProperties,
                customApp: data.appCustomCommonProperties,
                presetEventsApp: data.appPresetCommonProperties, // App 預置事件 公共屬性
                server: data.serverCommonProperties,
                customWeapp: data.weappCustomCommonProperties,
                presetEventsWeapp: data.weappPresetCommonProperties, // Weapp 預置事件 公共屬性
                presetEventsServer: data.serverPresetCommonProperties || [], // Server 預置事件 公共屬性
                presetEventsAd: data.adPresetCommonProperties
              }
              for (let type in commonPropertiesList) { // 將parameter_name的值作為key,item作為value,組合為k-v形式
                let properties = {}
                if (!commonPropertiesList[type]) continue
                commonPropertiesList[type].forEach(item => {
                  properties[item.parameter_name] = item
                })
                commonPropertiesList[type] = properties
              }
              this.commonPropertiesList = commonPropertiesList
            },
              

        拆分模塊后:建立GetParamsServer主類,該類由2個子類構(gòu)成,并聚合子類接口。

        這是其中一個子類,獲取私有參數(shù)的單元測試:

        import GetParamsServer, { GetPrivateParamsServer } from '@/views/analysis/components/getParamsServer.js'

        describe('GetPrivateParamsServer 私有參數(shù)獲取', () => {
          let $api
            beforeEach(() => {
              $api = {
                analysis: {
                  findPresetList: jest.fn().mockImplementation(() => Promise.resolve({
                    code0data: [{ name'hulala'description'234234'data_type'event' }]
                  })), // 預置埋點
                  serverParameterList: jest.fn().mockImplementation(() => Promise.resolve({
                    ret0content: [{ parameterName'hulala' }]
                  })), // 服務(wù)端埋點
                  autoParameterList: jest.fn().mockImplementation(() => Promise.resolve({
                    ret0content: [{ parameter_name'hulala' }]
                  })), // H5全埋點
                  customH5ParameterList: jest.fn().mockImplementation(() => Promise.resolve({
                    ret0content: [{ parameterName'hulala' }]
                  })), // H5自定義
                  customWeappParameterList: jest.fn().mockImplementation(() => Promise.resolve({
                    ret0content: [{ parameter_name'hulala'description'234234'data_type'event' }]
                  })), // Weapp自定義
                  customAppParameterList: jest.fn().mockImplementation(() => Promise.resolve({
                    ret0content: [{ parameterName'hulala'description'asdfafd'data_type'event' }]
                  })) // App自定義
                }
              }
            })
          describe('GetPrivateParamsServer 不同類型獲取', () => {
            it('獲取預置埋點 - pages', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''pages').then(() => {
                // logType不包含presetEvent、不等于 pages,獲取預置埋點
                expect($api.analysis.findPresetList).toBeCalled()
              })
            })

            it('獲取預置埋點 - presetEvent ', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''presetEvent').then(() => {
                // logType不包含presetEvent、不等于 pages,獲取預置埋點
                expect($api.analysis.findPresetList).toBeCalled()
              })
            })

            it('獲取非預置埋點 - 其他', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''12312').then(() => {
                expect($api.analysis.findPresetList).not.toBeCalled()
              })
            })
            

            it('獲取非預置埋點 - server', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''server').then(() => {
                expect($api.analysis.serverParameterList).toBeCalled()
              })
            })

            it('獲取非預置埋點 - auto', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''auto').then(() => {
                expect($api.analysis.autoParameterList).toBeCalled()
              })
            })

            it('獲取非預置埋點 - customH5', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''customH5').then(() => {
                expect($api.analysis.customH5ParameterList).toBeCalled()
              })
            })

            it('獲取非預置埋點 - customWeapp', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''customWeapp').then(() => {
                expect($api.analysis.customWeappParameterList).toBeCalled()
              })
            })

            it('獲取非預置埋點 - customApp', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''customApp').then(() => {
                expect($api.analysis.customAppParameterList).toBeCalled()
              })
            })

            it('獲取非預置埋點 - 不存在類型', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getCondition('hz''哈哈哈哈').then(res => {
                expect(res.length).toBe(0)
              })
            })
          })


          describe('GetPrivateParamsServer 結(jié)果轉(zhuǎn)換為label', () => {
            it('獲取預置埋點 - pages', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getConditionLabel('hz''pages').then((res) => {
                expect(res.length).toBe(1)
                expect(!!res[0].value).toBeTruthy()
                expect(!!res[0].label).toBeTruthy()
                expect(res[0].types).toBe('custom')
                expect(res[0].dataType).toBe('event')
              })
            })

            it('獲取非預置埋點 - customWeapp', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getConditionLabel('hz''customWeapp').then((res) => {
                expect(res.length).toBe(1)
                expect(!!res[0].value).toBeTruthy()
                expect(!!res[0].label).toBeTruthy()
                expect(res[0].types).toBe('custom')
                expect(res[0].dataType).toBe('event')
              })
            })

            it('獲取非預置埋點 - customApp', () => {
              const paramsServer = new GetPrivateParamsServer()
              paramsServer.initApi($api)
              return paramsServer.getConditionLabel('hz''customApp').then((res) => {
                expect(res.length).toBe(1)
                expect(!!res[0].value).toBeTruthy()
                expect(!!res[0].label).toBeTruthy()
                expect(res[0].types).toBe('custom')
                expect(res[0].dataType).toBe('event')
              })
            })
          })
        })
        image.png

        從測試用例看到的代碼邏輯:

        • 6個接口
        • 6種事件類型
        • 類型與接口的對應關(guān)系
        • 接口格式有三種

        作用:

        1. 復用:將復雜的業(yè)務(wù)邏輯封閉在黑盒里,更方便復用。
        2. 質(zhì)量:模塊的功能通過測試用例得到保障。
        3. 維護:測試即文檔,方便了解業(yè)務(wù)邏輯。

        實踐:在添加單測的過程中,抽象模塊,重構(gòu)部分功能,并對單一職責的模塊增加單測。

        5. 演進:構(gòu)建可測試單元模塊

        將業(yè)務(wù)代碼代碼演變?yōu)榭蓽y試代碼,重點在:

        1. 設(shè)計:將業(yè)務(wù)邏輯拆分為單元模塊(UI組件、功能模塊)。
        2. 時間:可行的重構(gòu)目標與重構(gòu)方法,要有長期重構(gòu)的心理預期。

        為單一職責的模塊設(shè)計測試用例,才會對功能覆蓋的更全面,所以設(shè)計這一步尤為重要。

        如果挽救一個系統(tǒng)的辦法是重新設(shè)計一個新的系統(tǒng),那么,我們有什么理由認為從頭開始,結(jié)果會更好呢? --《架構(gòu)整潔之道》

        原來模塊也是有設(shè)計,我們?nèi)绾伪WC重構(gòu)后真的比之前更好嗎?還是要根據(jù)設(shè)計原則客觀的來判斷。

        設(shè)計原則 SOLID:

        • SRP-單一職責
        • OCP-開閉:易與擴展,抗拒修改。
        • LSP-里氏替換:子類接口統(tǒng)一,可相互替換。
        • ISP-接口隔離:不依賴不需要的東西。
        • DIP-依賴反轉(zhuǎn):構(gòu)建穩(wěn)定的抽象層,單向依賴(例:A => B => C, 反例:A  => B => C => A)。

        在應接不暇的需求面前,還要拆模塊、重構(gòu)、加單測,無疑是增加工作量,顯得不切實際,《重構(gòu)》這本書給了我很多指導。

        重構(gòu)方法:

        • 預備性重構(gòu)
        • 幫助理解的重構(gòu)
        • 撿垃圾式重構(gòu)(營地法則:遇到一個重構(gòu)一個,像見垃圾一樣,讓你離開時的代碼比來時更干凈、健康)
        • 有計劃的重構(gòu)與見機行事的重構(gòu)
        • 長期重構(gòu)

        業(yè)務(wù)系統(tǒng)1的模塊與UI梳理:

        image.png

        業(yè)務(wù)系統(tǒng)2的模塊與UI梳理:

        image.png

        6. 可維護的單元模塊

        避免重構(gòu)后再次寫出壞味道的代碼,提取執(zhí)行成本更低的規(guī)范。

        代碼壞味道:

        • 神秘命名-無法取出好名字,背后可能潛藏著更深的設(shè)計問題。
        • 重復代碼
        • 過長函數(shù)-小函數(shù)、純函數(shù)。
        • 過長參數(shù)
        • 全局數(shù)據(jù)-數(shù)量越多處理難度會指數(shù)上升。
        • 可變數(shù)據(jù)-不知道在哪個節(jié)點修改了數(shù)據(jù)。
        • 發(fā)散式變化-只關(guān)注當前修改,不用關(guān)注其他關(guān)聯(lián)。
        • 霰彈式修改-修改代碼散布四處。
        • 依戀情結(jié)-與外部模塊交流數(shù)據(jù)勝過內(nèi)部數(shù)據(jù)。
        • 數(shù)據(jù)泥團-相同的參數(shù)在多個函數(shù)間傳遞。
        • 基本類型偏執(zhí)
        • 重復的switch
        • 循環(huán)語句
        • 冗贅的元素
        • 夸夸其談通用性
        • 臨時字段
        • 過長的消息鏈
        • 中間人
        • 內(nèi)幕交易
        • 過大的類
        • 異曲同工的類
        • 純數(shù)據(jù)類
        • 被拒絕的遺贈-繼承父類無用的屬性或方法
        • 注釋-當你感覺需要撰寫注釋時,請先嘗試重構(gòu),試著讓所有注釋都變得多余。

        規(guī)范:

        • 全局變量數(shù)量:20 ±
        • 方法方法行數(shù):15 ±
        • 代碼行數(shù):300-500
        • 內(nèi)部方法、內(nèi)聯(lián)方法:下劃線開頭

        技巧:

        • 使用class語法:將緊密關(guān)聯(lián)的方法和變量封裝在一起。
        • 使用Eventemitter 工具庫:實現(xiàn)簡單發(fā)布訂閱。
        • 使用vue  provide語法:傳遞實例。
        • 使用koroFileHeader插件:統(tǒng)一注釋規(guī)范。
        • 使用Git-commit-plugin插件:統(tǒng)一commit規(guī)范。
        • 使用eslint + stylelint(未使用變量、誤改變量名、debugger,自動優(yōu)化的css)。

        示例代碼:

        /*
         * @name: 輕量級message提示插件
         * @Description: 模仿iview的$message方法,api與樣式保持一致。
         */


        class Message {
            constructor() {
                this._prefixCls = 'i-message-';
                this._default = {
                    top16,
                    duration2
                }
            }
            info(options) {
                return this._message('info', options);
            }
            success(options) {
                return this._message('success', options);
            }
            warning(options) {
                return this._message('warning', options);
            }
            error(options) {
                return this._message('error', options);
            }
            loading(options) {
                return this._message('loading', options);
            }
            config({ top = this._default.top, duration = this._default.duration }) {
                this._default = {
                    top,
                    duration
                }
                this._setContentBoxTop()
            }
            destroy() {
                const boxId = 'messageBox'
                const contentBox = document.querySelector('#' + boxId)
                if (contentBox) {
                    document.body.removeChild(contentBox)
                }
                this._resetDefault()
            }

            /**
             * @description: 渲染消息
             * @param {String} type 類型
             * @param {Object | String} options 詳細格式
             */

            _message(type, options) {
                if (typeof options === 'string') {
                    options = {
                        content: options
                    };
                }
                return this._render(options.content, options.duration, type, options.onClose, options.closable);
            }

            /**
             * @description: 渲染消息
             * @param {String} content 消息內(nèi)容
             * @param {Number} duration 持續(xù)時間
             * @param {String} type 消息類型
             */

            _render(content = '', duration = this._default.duration, type = 'info',
                onClose = () => { }, closable = false
            ) {
                // 獲取節(jié)點信息
                const messageDOM = this._getMsgHtml(type, content, closable)
                // 插入父容器
                const contentBox = this._getContentBox()
                contentBox.appendChild(messageDOM);
                // 刪除方法
                const remove = () => this._removeMsg(contentBox, messageDOM, onClose)
                let removeTimer
                if(duration !== 0){
                    removeTimer = setTimeout(remove, duration * 1000);
                }
                // 關(guān)閉按鈕
                closable && this._addClosBtn(messageDOM, remove, removeTimer)
            }

            /**
             * @description: 刪除消息
             * @param {Element} contentBox 父節(jié)點
             * @param {Element} messageDOM 消息節(jié)點
             * @param {Number} duration 持續(xù)時間
             */

            _removeMsg(contentBox, messageDOM, onClose) {
                messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeOutUp`
                messageDOM.style.height = 0
                setTimeout(() => {
                    contentBox.removeChild(messageDOM)
                    onClose()
                }, 400);
            }

            /**
             * @description: 獲取圖標
             * @param {String} type
             * @return {String} DOM HTML 字符串
             */

            _getIcon(type = 'info') {
                const map = {
                    info`<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                   <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" />
                 </svg>`
        ,
                    success`<svg style="color:#19be6b"  xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                   <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" />
                 </svg>`
        ,
                    warning`<svg style="color:#ff9900" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                   <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" />
                 </svg>`
        ,
                    error`<svg style="color:#ed4014" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                   <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" />
                 </svg>`
        ,
                    loading`<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" class="loading" viewBox="0 0 20 20" fill="currentColor">
                   <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" />
                 </svg>`

                }
                return map[type]
            }

            /**
             * @description: 獲取消息節(jié)點
             * @param {String} type 類型
             * @param {String} content 消息內(nèi)容
             * @return {Element} 節(jié)點DOM對象
             */

            _getMsgHtml(type, content) {
                const messageDOM = document.createElement("div")
                messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeInDown`
                messageDOM.style.height = 36 + 'px'
                messageDOM.innerHTML = `
                        <div class="${this._prefixCls}message" >
                            ${this._getIcon(type)}
                            <div class="${this._prefixCls}content-text">${content}</div>
                        </div>
                `

                return messageDOM
            }

            /**
             * @description: 添加關(guān)閉按鈕
             * @param {Element} messageDOM 消息節(jié)點DOM
             */

            _addClosBtn(messageDOM, remove, removeTimer) {
                const svgStr = `<svg class="${this._prefixCls}btn" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                </svg>`

                const closBtn = new DOMParser().parseFromString(svgStr, 'text/html').body.childNodes[0];
                closBtn.onclick = () => {
                    removeTimer && clearTimeout(removeTimer)
                    remove()
                }
                messageDOM.querySelector(`.${this._prefixCls}message`).appendChild(closBtn)
            }

            /**
             * @description: 獲取父節(jié)點容器
             * @return {Element} 節(jié)點DOM對象
             */

            _getContentBox() {
                const boxId = 'messageBox'
                if (document.querySelector('#' + boxId)) {
                    return document.querySelector('#' + boxId)
                } else {
                    const contentBox = document.createElement("div")
                    contentBox.id = boxId
                    contentBox.style.top = this._default.top + 'px'
                    document.body.appendChild(contentBox)
                    return contentBox
                }
            }

            /**
             * @description: 重新設(shè)置父節(jié)點高度
             */

            _setContentBoxTop() {
                const boxId = 'messageBox'
                const contentBox = document.querySelector('#' + boxId)
                if (contentBox) {
                    contentBox.style.top = this._default.top + 'px'
                }
            }
            /**
             * @description: 恢復默認值
             */

            _resetDefault() {
                this._default = {
                    top16,
                    duration2
                }
            }
        }

        if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
            module.exports = new Message();
        else {
            window.$message = new Message();
        }
        image.png

        6. 回顧

        • 定義
        • 安裝與使用(安裝、調(diào)試、git攔截、測試報告)
        • 常用API(jest、vue組件)
        • 落地單元測試(拆分關(guān)鍵模塊加單測)
        • 演進:構(gòu)建可測試單元模塊(設(shè)計原則、重構(gòu))
        • 可維護的單元模塊(代碼規(guī)范)

        落地線路:

        ① 安裝使用 => ② API學習 => ③ 落地:拆分關(guān)鍵模塊加單測 =>  ④ 演進:架構(gòu)設(shè)計與重構(gòu) =>  ⑤ 代碼規(guī)范

        未來:

        ⑥ 文檔先行(待探索)

        在較為復雜的業(yè)務(wù)系統(tǒng)開發(fā)過程中,從第一版代碼到逐步劃分模塊、增加單測,還是走了一段彎路。如果能夠養(yǎng)成文檔先行的習慣,先設(shè)計模塊、測試用例,再編寫代碼,會更高效。

        理解:

        • 單元測試有長期價值,也有執(zhí)行成本。
        • 好的架構(gòu)設(shè)計是單測的土壤,為單一職責的模塊設(shè)計單測、增加單元測試更加順暢。
        • 每個項目的業(yè)務(wù)形態(tài)與階段不一樣,不一定都適合,找到適合項目的平衡點

        7. 討論 && Thank

        感謝各位能夠看到最后,前半部偏干,后半部分偏水,為內(nèi)部分享筆記,部分代碼和圖片經(jīng)過處理,重在分享和大家一起交流,懇請斧正,有收獲還請點贊收藏。

        關(guān)于本文

        • 作者:@愚坤(秦少衛(wèi))
        • https://juejin.cn/post/6978831511164289055
        • https://github.com/nihaojob
        如果覺得這篇文章還不錯
        點擊下面卡片關(guān)注我
        來個【分享、點贊、在看】三連支持一下吧

           “分享、點贊、在看” 支持一波 

        瀏覽 41
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            国内天天操天天色 | 国产原创一区 | 市来美保3p | 五月婷婷丁香六月 | 裸身不知火舞被羞羞漫画 | 无码一二三 | 翔田千里AV在线 | 国产不卡视频在线播放 | 日本加勒比少妇无码A∨ | 丰满少妇被猛烈 |