為京東PLUS會員保駕護航的日子

京東設計中心JDC發表於2020-07-01

前言

流光容易把人拋,紅了櫻桃,綠了芭蕉。悄然間牆上的鐘擺已經指向了 2020 年中旬,時間就像是一隻藏在黑暗中溫柔的手,在你一出神一恍惚之間,斗轉星移。2020 年對人類來說是異常坎坷的一年,無論是澳大利亞的森林火災還是席捲全球的瘟疫,都給地球蒙上了一層灰色的面紗,然而人類並沒有坐以待斃,而是奮起反擊!京東作為國民品牌,更是承擔起社會責任,始終奮鬥在抗爭的第一線。

京東 PLUS 會員,是京東為向核心客戶提供更優質的購物體驗推出的服務,在庚子年間,歷經半載,茁壯成長:幾大頻道頁輪番改版換新顏,聯名卡也迎來了騰訊 QQ 音樂、芒果 TV 的強勢入駐,計算器頁面改版以及 PLUS 會員自建風控體系的建立,無論是在使用者吸引度還是在專案可配置化上都下足了功夫,比如:

頻道首頁增加彈窗信封動畫,增強趣味性,加入挽留彈窗功能,減少佛系使用者來去匆匆,不帶走一片雲彩的情況;

img

改版頁面增加沉浸式以及樓層換膚功能,烘托氛圍,增強使用者感知;以及整個頁面實現配置化,減少後期前端維護上線成本。

img

此外,2020 年上半年主要從頁面改版、新增權益、優化完善功能、研發優化等方面支援了以下需求:

img

目前正式使用者已經接近兩千萬大關,一切是那麼的欣欣向榮、朝氣蓬勃。

然而沉下心來,無論是技術的升級,還是專案的不斷完善,在《2019年京東PLUS會員前端開發之路》一文中所提及的優化,都相當於萬里長征第一步,我們要做的事情還很多,尤其是隨著一次次需求的快速迭代,一些新的問題逐漸暴露出來,我們逐漸意識到一個優秀的專案,必須能夠建立起完善的架構以及周邊系統,才能保證專案的不斷更新迭代和高效的開發。誠然,到目前為止我們可以做的仍然有很多,但是不妨暫停腳步,回首梳理一下這半年來走過的路,取其精華,棄其糟粕。

接下來本文將從提高開發效率、優化專案架構、完善使用者體驗等方面入手,和大家分享我們在 2020 年上半年開發專案過程中的心得體會,旨在拋磚引玉,共同學習。

img

一、提高開發效率

在專案開發過程中,往往不起眼的優化,總能帶來意想不到的收穫。持續不斷的發現開發中遇到的問題,如何改進過程,提高開發效率,也是我們孜孜不倦的追求。

1.1 自動生成新模板

隨著需求的迭代,以前的頻道頁逐漸無法滿足當前的需求,尤其是 PLUS 會員自建風控體系頁面、頻道頁改版的需求,都要新增頁面,那麼新增一個頁面需要幾個步驟呢?

img

如上圖所示,新增一個頁面,需要五個步驟:

1、首先要新增一個 Html 頁面,用於掛載載入靜態資源和骨架屏等內容;

2、新增入口 JS 檔案,是新頁面的入口檔案,即 Webpack 打包的入口檔案;

3、新增 Vue 主檔案,用於開發新頁面的邏輯;

4、修改 Webpack 配置檔案,增加 entry 入口,以及增加對應的 Html 外掛配置,例如:

new HtmlWebpackPlugin({
    template: './src/template/new-expired.html',
    filename: path.resolve(__dirname, 'build/new-expired.html'),
    inject: false
}),

5、修改上傳程式碼元件,新增新增加的入口 JS;

因此,每次新增頁面,都要修改上面的五個配置,步驟繁瑣不說,偶爾遺漏一項,導致頁面發生錯誤也不是沒有發生過。那麼如何簡化新增頁面的步驟呢?
於是借鑑團隊的 NutUI 元件庫 新增元件,自動生成相應配置檔案的思路,我們引入 inquirer 庫,一個使用者與命令列互動的工具。執行一個命令,即可自動生成修改以上五個配置檔案豈不美哉。

首先引入使用者與命令列互動工具,用來輸入新頁面的名稱:

// 關鍵程式碼
inquirer.prompt([
    {
        type: 'input',
        name: 'pageName',
        message: '新建頁面英文名稱:',
        validate(value) {
            const pass = value && value.length <= 20;
            if (pass) {
                return true;
            }
            return '不能為空,且不能超過20個字元';
        },
    }
])
.then(function (answers) {
    createDir(answers);
});

執行該檔案,則在命令列展示如下所示:

img

待我們輸入新檔案的英文名稱,以及中文標題之後,就可以繼續往下進行了,比如向指定資料夾中生成 Html 新檔案:

function createHtml(value) {
   const htmlCode = templateHtml.replace(/\{template\}/g, value.pageName).replace(/{title}/g, value.pageTitle);
   const createHtml = path.resolve(__dirname, `../createTemplate/${value.pageName}.html`);
   fs.writeFileSync(createHtml, htmlCode);
}

此外,還需自動修改 json 格式的配置檔案,

function createJson(value) {
    entrys[value.pageName] = `./src/entry/${value.pageName}.js`;
    const createJson = path.resolve(__dirname, './entrys.json');
    fs.writeFileSync(createJson, JSON.stringify(entrys));
}

根據自動生成的 json 檔案,在後續啟動本地服務或者打包編譯程式碼時,Webpack 就可以生成對應的 entry 入口和 HtmlWebpackPlugin 外掛等配置項。

const entryConfigs = require('./templates/entrys.json');
Reflect.ownKeys(entryConfigs).forEach( key => {      //迴圈遍歷物件
    webpackConfig.plugins = (webpackConfig.plugins || []).concat([
        new HtmlWebpackPlugin({
            template: `./src/template/${key}.html`,
            filename: path.resolve(__dirname, `build/${key}.html`),
            inject: false
        })
    ])
});

這樣,原來每次新增頁面都要修改或者新建五個檔案的歷史一去不復返了,一行命令就可以完成新頁面的構建,即提高了效率,又減少了人為操作帶來遺漏的風險。

1.2 程式碼上線前自動提示

俗話說大禮不辭小讓,細節決定成敗。人生如此,程式亦如此。由於需求迭代快且平均每週要並行開發五六個需求,我們採用多個分支並行開發,每次上線更新版本號來避免使用者靜態資源的快取,然而設想一下如果辛辛苦苦的開發完需求,滿懷期待的上線後,突然發現沒有合併之前分支程式碼!或者沒有更改版本號!想必猶如五雷轟頂,腦海中肯定一萬頭羊駝飛奔而過。。。
不要問我為何會想到這個問題,那肯定是痛苦的回憶!永遠不要靠人為的記憶來保證上線前的必要操作,否則你將和我感同身受。那麼我們如何來避免這類問題的發生呢?能不能在上線前有人告訴我一下,檢查一下必要的操作呢?

梳理一下要實現這個功能需要滿足以下要求:

  1. 開發者提交程式碼到 master 分支上,才觸發該提示功能;
  2. 攔截提交程式碼程式,提示開發者注意事項,如果開發者選擇了 true,則繼續提交程式碼程式,否則退出提交程式碼。

於是我把目光轉向了 git-hook 技術,

專案要使用 git 進行程式碼提交時,使用 pre-commit 的 git 鉤子,在呼叫 git commit 命令時自動執行某些指令碼檢測程式碼,若檢測出錯,則阻止 commit 程式碼,也就無法 push,保證了出錯程式碼只在我們本地,不會把問題提交到遠端倉庫。

git-hooks 存在在 git 倉庫的 .git/hooks 資料夾下,包含了很多的 hooks,這些檔案都是在建立 git 倉庫的時候自動生成的,開啟 hooks 資料夾可以看到:

img

字尾為 .sample 表示預設是不生效的,所以我們要做的就是生成 pre-commit 檔案,該檔案會在提交程式碼時執行該檔案的程式碼,因此我們就可以把提示功能的程式碼放在該檔案中開發。

如下所示,再提交程式碼時,首先判斷是否為 master 分支,如果是 master 分支,表示要上線了,於是執行 pre-commit 檔案中的程式碼,如下圖所示:

img

只有所有回答為 y,才會繼續提交程式碼程式。

那麼還有個問題,如何讓全團隊的人都用到該功能呢?難道要讓每個成員都在本地新增這個檔案嗎?我們在執行一個專案時,一般都會執行 npm run dev,也就是本地啟動服務,所以我們可以利用這一個必要步驟,把修改 pre-commit 檔案的程式碼放在這一步驟中,這樣每個成員在啟動本地服務的時候,就會向 hooks 中加入該檔案,於是就在團隊成員神不知鬼不覺的情況下注入了 pre-commit 功能,待小夥伴上線前發現這個彩蛋吧~

1.3 提交程式碼前自動檢查

雖然 1.2 中的方法只是在上線前進行了提示攔截,在開發過程中是否還有可操作空間呢?下面先來看幾種常見場景:

1、新需求在開發的過程中,主幹分支可能已經更新過很多次了,所以我們要及時合併主幹分支,來保持當前的開發分支是最新的;

2、不同開發者使用一個分支的時候,經常會忘記其他人合併程式碼就編譯上傳,導致別人的程式碼被覆蓋;

3、自己合併完主幹分支,偶爾會忘記上傳;

實現目標

這些問題,我們可以靠指令碼去執行,用指令碼去逐條檢查所有的專案是否完成。下面我們先來看下實現方法。
在更新之前首先要定義我們要實現的目的:自動檢查當前分支和本地分支的狀態。

1、需要把所有的檔案fetch到本地

因為只有本地和線上保持同步才能進行正確的判斷,這個操作只會把伺服器的下載到本地不會合並。

git fetch

2、檢查主幹分支是否有未提交或者待更新內容

在 master 分支下,我們可以通過命令去檢視當前的狀態,下面可能有兩種狀態,ahead or behind 代表了跟遠端分支的一種關係,待更新 or 待提交,下圖就是一種待提交的狀態

git status

img

3、判斷當前分支與主幹分支的狀態

如果當前所在分支不是主幹分支,需要進行這項判斷,如果發現有待合併,則提示使用者需要合併程式碼。可以通過以下命令去檢視當前分支已經合併過的分支來判斷是否合併主幹分支。

git branch --merged

4、判斷當前分支是否有未提交或者待更新內容

如果當前分支不為主幹分支的話則進行這項內容,判斷邏輯同2。

流程圖如下:

img

具體實現

用執行指令碼的方法來代替人工操作,前端的腳手架環境為 node,第一步就是在 npm 庫選擇一個可操作 git 的庫,我選擇的是 simple-git

1、第一步先判斷是否有 remote,如果沒有 remote 地址那麼就無法 fetch,後面的比較也就無正確性可言。

const git = require('simple-git');
git().getRemotes(true, (err, res) => {
    //do something
})

2、判斷主幹是否需要提交, 通過 isBehind 和 isAhead 的狀態去判斷主幹分支的狀態:

const mainBranch = master;
const behindReg = /behind(?= \d+\])/gi;
const aheadReg = /ahead(?= \d+\])/gi;
git().branch(['-vv'], (err, res) => { 
     const mainBranchInfo = res.branches[mainBranch]
    const isBehind = mainBranchInfo.label.search(behindReg);
    const isAhead = mainBranchInfo.label.search(aheadReg);
});
     

3、判斷當前分支是否合併主幹,通過如下方法去檢查是否合併,如果沒有合併可以提示使用者:

const mainBranch = master;

git().branch(['--merged'], (err, res) => {
    const isMerged = res.all.includes(mainBranch);
});

4、判斷當前分支的是否需要提交,方法如下,通過判斷 變數 Behind 是否需要更新:

getCurrentBehind() {
    return new Promise((resolve, reject) => {
        git().status((err, res) => {
            if (err) reject();
            else resolve(res.behind);
        });
    });
}
const Behind = await gitFn.getCurrentBehind();

最後形成如下提示:

img

1.4 自動生成說明文件

PLUS 會員 M 端專案在不斷壯大,程式的維護和除錯難度也在上升,有時候我們封裝的工具函式或者業務邏輯由於缺少註釋,其他小夥伴在使用或者二次開發的時候理解起來較為麻煩。為了增加程式的可讀性和程式的健壯性,我們在專案中加入了 API 文件,方便團隊成員能夠快速查詢和入手專案開發,另外為了節約 API 文件的維護成本,我們使用了 jsDoc 自動化生成文件。

JsDoc可以根據規範化的註釋、自動生成介面文件,舉個例子:

/**
 * @description 判斷是否在小程式環境中
 * @returns {Boolean} result 結果
 */
export function isMiniprogram() {
    const plusFrom = getCookie('plusFrom');
    return !!~['xcx', 'xcxplus'].indexOf(plusFrom);
}

這樣一個函式就會被jsDoc收集起來,放到開發文件裡了,然後我們可以自己建立一個 npm script 工作流,方便在命令列中啟動:

"docs": "rimraf docs && jsdoc -c ./jsdoc-conf.js && live-server docs"

jsdoc-conf.jsjsdoc的配置檔案,包括了一些文件的配置項,然後我們就可以開始進行文件的自動構建了!

執行 npm run docs,或者可以把該命令合併到 npm run dev 中執行。

接下來瀏覽器會在本地自動開啟文件頁面:
enter image description here

頁面左邊是 api 目錄,Globals 下的 api 就是我們在 JS 檔案裡邊寫的工具函式了,而 Modules下的就是 Vue 元件模組,下面我們來看如何給 Vue 元件模組新增註釋:

/**
 * @module drag
 * @description 拖拽元件,用於頁面中需要拖拽的元素
 * @vue-prop {Boolean} [isSide=true] - 拖拽元素是否需要吸邊
 * @vue-prop {String} [direction='h'] - 拖拽元素的拖拽方向
 * @vue-prop {Number | String} [zIndex=11] - 拖拽元素的堆疊順序
 * @vue-prop {Number | String} [opacity=1] - 拖拽元素的不透明級別
 * @vue-prop {Object} [boundary={top: 0,left: 0,right: 0,bottom: 0}] - 拖拽元素的拖拽邊界
 * @vue-data {Object} position 滑鼠點選的位置,包含距離x軸和y軸的距離
 */
 //業務程式碼。。。

由於 Vue 元件不能被原生的 jsDoc 支援,這裡我們藉助了 jsDoc-vue ,所以元件的註釋寫法也有所不同,具體的規範大家可以查詢官方文件。然後我們就可以在 api 文件中看到這個元件相關的註釋了。

enter image description here

vue元件的註釋規範可以查詢jsdoc-vue的官方文件。當我們寫完註釋之後,需要執行一下npm run docs來重新生成文件。

1.5 優化版本號邏輯

為了保證每次上線後,使用者都能夠獲取到最新的程式碼,而不是用快取資源,所以要修改每次上線的版本號,比如修改下面連結中的 4.1.2:

https://static.360buyimg.com/exploit/mplus/4.1.2/v4/js/index.js

由於我們接入了公司的頭尾系統,所以只需要改動頭尾系統中的版本號即可。

什麼是頭尾系統呢?可以簡單的理解為:伺服器從這個系統中引入 A 配置檔案,前端在 A 檔案中輸入程式碼,一鍵推送到指定的伺服器上,來更新該伺服器上引入的前端資源。

但是我們發現,每次上線的時候,都要在頭尾系統中推送幾十臺伺服器,往往總有伺服器推送失敗,或者上線後在預發中發現有新的問題,要緊急回滾版本號,於是又要重新推送頭尾檔案,往往要花費很長時間。

於是我們在想是不是有更好的方法來解決?抑或是緩解此類問題呢。這時版本號比較邏輯重新進入我們眼簾。之所以用“重新”一詞,是因為這個邏輯之前就有,不過當時沒有解決動態生成靜態資源指令碼,導致無法保證執行順序,從而頁面白屏的問題(該問題詳見文章:《2019年京東PLUS會員前端開發之路》),所以當時就把該功能去掉了。現在是時候重新審視這個功能了:

1、伺服器端的 Html 模板中維護一個版本號 V1;

2、前端在頭尾系統中維護一個版本號 V2;

前端在 Html 中開發好版本號比較的邏輯,使用兩個版本號中較大值來動態生成對應的靜態資源。

/*
V1 就是放在 Html 中的版本號
V2 就是前端在頭尾檔案中放置的版本號
最終使用的是較大的版本號
*/
if (typeof V2 != 'undefined' 
&& Number(V2.replace(/\./g, '')) > Number(V1.replace(/\./g, ''))) {
    V1 = V2;
}

對應的有兩種情況:

1、只需要前端上線的需求,前端在頭尾系統中推送更新的版本號,這個和之前一樣;

2、如果是前、後端都要上線的專案,則後端在 Html 中修改版本號 V1,根據 Html 中版本號比較邏輯,會使用兩個版本號中較大的那個,則生成的靜態資源使用了後端在 Html 中設定的版本號,於是前端省去了推動頭尾檔案的步驟,只需要後端上線即可

不過按下葫蘆瓢又起,雖然緩解了前端推送頭尾檔案的情況,但是從此要求保證版本號有大小順序關係。於是我們按照需求的上線順序,規定好每個需求的版本號,執行了一段時間,緊急需求過來了、線上 bug 緊急需求過來了,都要在原來已經排序好的版本號之間插入版本號,類似於一個有序陣列,如果向陣列的前面插入元素,則後續元素都要跟著變化,經過商量之後我們改為如下策略:

1、只有三個數字表示版本號,則使用前兩位作為主版本號,比如:4.1.0、4.2.0...4.99.0;

2、使用第三個版本號數字表示緊急需求的版本號,比如目前版本號是 4.2.0,如果插入緊急需求則為 4.2.1;

這樣擴充套件了主版本號的個數:前兩位數字可以擴充套件到很大;而且插入的緊急需求版本號不會影響之前已經排序好的版本號,還能夠減少上線步驟,可謂一箭三雕!

1.6 自動圖片壓縮

圖片壓縮一直是前端優化中很重要的一部分,也可以說是開發流程中必不可少的一個環節。之前 PLUS 專案的圖片壓縮,一直處於自發的、手動的處理狀態,這就非常考驗大家的細心程度和自覺性了。

為了規範這一流程,我們引入了 Gaea 腳手架中自動壓縮圖片及轉換 webp 的功能。話不多說,上程式碼

const imagemin = require('imagemin');
const imageminWebp = require('imagemin-webp');
const path =  require('path');
const fs = require('fs');
const tinify = require("tinify");
const config = require("./package.json");
tinify.key = config.tinypngkey;
const filePath = './src/asset/img';
const files = fs.readdirSync(filePath);
const reg = /\.(jpg|png)$/;
async function compress(){
    for(let file of files){
        let filePathAll = path.join(filePath,file);
        if(reg.test(file)){
            await new Promise((resolve,reject)=>{
                fs.readFile(filePathAll,(err,sourceData)=>{
                    tinify.fromBuffer(sourceData).toBuffer((err,resultData)=>{
                        //將壓縮後的檔案儲存覆蓋
                        fs.writeFile(filePathAll,resultData,err=>{
                            resolve();
                        })
                    })
                })
            })
        }
    }
    imagemin(['./src/asset/img/*.{jpg,png}'],'src/asset/img/webp',{
        use:[
            imageminWebp()
        ]
    }).then(()=>{
        console.log(chalk.green(`webp轉換已完成~`));
    })
}
compress();

在 css 中使用方式:

@mixin webpbg($url, $name) {
  background-image: url($url + $name);
  background-repeat: no-repeat;
  @at-root .webp & {
    background-image: url($url + "webp/" + (
        str-slice($name, 0, str-index($name, ".") - 1)
      ) + ".webp");
  }
}
str-slice(string, start, end) 從 string 中擷取子字串,通過 start 和 end 設定始末位置,未指定結束索引值則預設擷取到字串末尾。
str-index(string, substring) 返回 substring 子字串第一次在 string 中出現的位置。如果沒有匹配到子字串,則返回 null。

由於這個 webpbg 方法定義在公共的 common-mixin.scss 裡,而呼叫是分佈在各個元件中的,因此元件中的呼叫會報錯找不到這個 webpbg 函式。如果要在全域性使用這個 webpbg 方法,就需要在 webpack.config.js 中全域性匯入,修改方式如下

@include webpbg("../../asset/img/index-formal/", "formal-title.png");

結果,出師不利,竟然報錯了!

image

我們一般是把 Mixin 放在當前 Sass 檔案中,如果要在全域性使用,需要在 webpack.config.js 中全域性匯入。

image

好了,圖片壓縮,自動轉 webp,樣式支援 webp,一切順利的進行著。

然而,隨著我們圖片的增多,壓縮次數的增加,問題又來了:
由於壓縮圖片用到的是 tinypng 工具,我們在使用時需要用郵箱註冊得到個 key。對於免費使用者,同一個 key 在同一個月中只能壓縮 500 張圖片。
image

因此,我們需要破除這個限制。除了多申請幾個 key,能不能從優化策略上進行改善呢?

實際上,一般圖片切好後,不會經常去改動,尤其是已上線的部分,改動的可能性更小。因此,我們可以將圖片的全量壓縮,改為增量壓縮,只壓縮修改過的或者是新增的。基於這種策略,壓縮圖片的數量不會很大,限制就這樣破除了~
下面來看看具體實現步驟吧:

img

下面是生成 hash 值的程式碼片段:

let rs = fs.createReadStream(filedir); //開啟一個可讀的檔案流並且返回一個fs.ReadStream物件
let hash = crypto.createHash("md5"); //建立並返回一個 Hash 物件,該物件可用於生成雜湊摘要
let hex;

return await new Promise((resolve, reject) => {
  //在內部不斷觸發rs.emit('data',資料);
  rs.on("data", hash.update.bind(hash)); // hash.update使用給定的 data 更新雜湊的內容

  //end事件表示這個流已經到末尾了 ,沒有資料可以讀取了
  rs.on("end", function () {
    hex = hash.digest("hex"); //計算傳入要被雜湊的所有資料的摘要,返回字串
    result[filedir.replace(/\/|\\/g, "/")] = hex; // 統一mac及windows下的檔案路徑,將其作為key值,生成的hash值為value,存入result中
    resolve();
  });
  //error事件表示出錯了
  rs.on("error", function (msg) {
    console.log("error", filedir);
    reject();
  });
});

二、優化專案架構

為什麼要持續進行專案架構的優化?專案就像一座建好的大廈,如果時不時的要砸掉承重牆進行裝修,不及時維護的話,最終會千瘡百孔,岌岌可危。類似地,京東 PLUS 會員專案作為一個長期維護的專案,隨著需求的快速迭代,緊急需求的插入實現,最初沒有考慮到的問題,或者阻礙專案開發進度的問題慢慢浮出水面,為了專案能夠長久執行,避免程式碼更加臃腫,我們主要做了以下工作:

2.1 提取基礎元件

一千個人眼中有一千個哈姆雷特。如何劃分元件,想必每位開發者心中的認識都所有不同。那麼 PLUS 會員專案的元件是如何劃分的呢?

比如一個下面這個彈窗:

img

我們首先使用了 NutUI 元件庫中的基礎元件——Dialog彈窗元件,然後以該元件為基礎,開發了業務計算器彈窗元件,為了更好的提高元件複用性,以及減少業務邏輯改動對元件的影響,應該是由以下形式構成:

img

當前我們專案中的元件也在朝著這個方向努力,因為發現 PLUS 專案中引入了一些 NutUI 基礎元件,之後又做一些開發來匹配業務需求。在經歷了很長時間的穩定執行後,這些元件的改動很少,因此為了給專案瘦身,我們將這些基礎元件抽取出來釋出到 npm 中,最終將其打包到 node_modules 資料夾中,這樣專案中就會大量減少這些基礎元件的程式碼,並且不需要每次都打包編譯這些元件程式碼。

值得一提的是,在本地開發時所有元件都完美執行,但是打包部署後卻失敗了。經過排查原來是 npm 包只是存放的元件原始碼,並未對其編譯,所以在專案中直接使用就會報錯,問題暴露出來了。那麼要為了這幾個元件,去單獨搭個腳手架處理嗎?
我們機智的小夥伴想到了一個借雞生蛋的方法——使用團隊的 NutUI 元件庫腳手架作為載體,在元件庫中中建立 PLUS 的基礎元件,然後把這些需要打包的元件都通過元件庫編譯後匯出。這樣把編譯後的程式碼部署到 npm上,就能夠在專案中直接安裝依賴包使用了,效果如下,可以看到,打包後專案中的引入程式碼也精簡不少:

image

2.2 縮減現有分支

PLUS 專案平均每週都需要並行五、六個需求進行開發,如何才能保證並行開發的需求不會互相干擾,我們採用的是多分支的方法,每位研發從主幹分支 v2、v3、v4的基礎上新建分支,分支的名字使用當時需求的拼音縮寫,比如正式改版需求就是:zsgb;開發完畢後,每次上線時再合併到 master 分支上準備上線,如下所示:

img

不知大家有沒有發現什麼端倪?這樣命名分支會不會有所隱患?
在執行了半年之後,我們發現程式碼庫中的分支越來越多,很多新建的分支在開發完之後,就不在使用了,但是每次都要人為的去刪除,而需要人為自發的去操作的都是不可靠的。經過思索之後,我們決定使用每個開發成員的姓名縮寫作為分支名,比如名字叫“張大胖”的同學,新建的分支就是“zdp”,如果多人開發同一個需求,則在某個人的分支上開發即可,這樣的好處如下:

  1. 每個主分支下的子分支個數是固定的,每個研發有一個對應的子分支進行開發;
  2. 避免了人為的刪除程式碼庫中冗餘分支步驟,且減少誤刪分支的情況;
  3. 避免了子分支的不斷增多的問題;

經過上述操作,原來程式碼庫中幾十個子分支縮減到幾個分支,大大減少了程式碼量,下載程式碼速度也快了起來。

2.3 PC端腳手架優化

由於歷史原因,PC 端的 PLUS 會員專案用的是 React 技術棧進行的開發,且隨著科技的進步,移動端所佔比例越來越大,相應的 PC 端所佔比例逐年縮小,很多功能也是採取的引流到 M 端,這就導致一個問題,我們對 PC 端的改動也隨之變少,但是最初的 PC 端腳手架已經比較老舊,在編譯的過程中會時常出現問題,比如:

1、打包程式碼速度巨慢,打包期間可以喝茶、嗑瓜子、打豆豆;

2、生成 hash 值命名的檔案,每次聯調和版本回滾都要挨個替換每個hash值,不利於心情舒暢,容易讓人暴躁;

3、不支援熱更新,導致每次更改都要人為的去重新整理頁面,副作用是可以矯正處女座強迫症;

4、不支援按需打包檔案,每次打包都會把所有的檔案都打包,而上線只是上其中的幾個檔案;

忍無可忍,則無需再忍,基於此,我們主要做了以下優化,升級了 Webpack 從 2 到 4 版本,所有的配置檔案重新開發,並做了以下的優化:

img

打包效率較低,開發聯調比較麻煩,所以腳手架有待挑戰優化。並且隨著技術的發展,很多新的技術可以用於我們專案中,來提升開發效率。由於此處程式碼量巨大,所以只是寫明瞭方向,有疑問的小夥伴可以在評論區留言討論~

2.4 程式碼提交規範

程式碼提交規範化的目的是為了更好的追溯程式碼、篩選和快速的定位提交程式碼所涉及的範圍和實現功能。對於 PLUS 這樣一個不斷開發迭代的專案,增加程式碼提交規範化是很有必要的。正所謂,無規矩不成方圓。因此我們在專案中引入了 vue-cli-plugin-commitlint 來約束和規範程式碼提交。它既可以增強團隊成員對 commit 規範的概念,同時也可以統一我們程式碼的提交風格,更重要的是,它可以自動生成自動 ChangeLog,方便我們查詢提交版本,對後期遇到問題,快速定位提供便利。

vue-cli-plugin-commitlint 是開箱即用的 git commit 規範,它結合了 commitizen、commitlint、conventional-changelog-cli 和 husky。

下面我們看一下如何在專案中使用。

安裝依賴

npm i vue-cli-plugin-commitlint commitizen commitlint conventional-changelog-cli husky -D 

在 package.json 中新增

{
    ...
    "scripts": {
        "log": "conventional-changelog --config ./node_modules/vue-cli-plugin-commitlint/lib/log -i CHANGELOG.md -s -r 0",
        "cz": "npm run log && git add . && git status && git cz"
    },
    "husky": {
        "hooks": {
            "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
        }
    },
    "config": {
        "commitizen": {
            "path": "./node_modules/vue-cli-plugin-commitlint/lib/cz"
        }
    }
}

增加 commitlint.config.js 檔案

module.exports = {
    extends: ['./node_modules/vue-cli-plugin-commitlint/lib/lint']
};

然後執行 npm run cz, 這時,就會提示你選擇,然後根據提示依次填寫,生成符合格式的 Commit message。

image

提交完成之後,會在專案根目錄生成 CHANGELOG.md 日誌檔案,點選檔案中的 commitId, 就可跳轉檢視對應提交內容了。

img

2.5 提高腳手架打包速度

隨著專案的不斷迭代,檔案數量不斷增多,專案變得龐大,從而導致 Webpack 構建變得越來越慢。每次構建都需要一定時間,提升構建速度,變得格外有必要。

眾所周知,Webpack 在 Node.js 上執行都是採用單執行緒模型的,處理任務也是依次去執行,不能併發處理多個任務,需要排隊,那是否有什麼方法可以讓 Webpack 同時並行處理多個任務呢?

為了解決上述疑問,我們接入了 HappyPack,它能讓 Webpack 做到這點,可以把任務分解給多個子程式去併發的執行,子程式處理完後再把結果傳送給主程式。HappyPack 的核心原理就是把這部分任務分解到多個程式去並行處理,從而減少了總的構建時間。

下面,我們來看一下如何在專案中接入。

安裝依賴

npm install happypack -D

對 webpack.config.js 進行配置

在整個 Webpack 構建流程中,最耗時的就是 Loader 對檔案的轉換操作,因為要轉換的檔案資料特別多,而且轉換操作需要依次排隊處理。

配置 Loader,上程式碼。

module.exports = (env, argv) => {
  const webpackConfig = {
    //...
    module: {
      rules: [{
        test: /\.js$/,
        loader: 'happypack/loader?id=happyBabel',
        // 排除 node_modules 目錄下的檔案
        exclude: [
          path.resolve(__dirname, 'node_modules'),
          path.resolve(__dirname, 'jssdk.min.js')
        ]
      },
      //...
      ]
    },
  //...
  }
}

我們把對 .js 的檔案處理交給 happypack/loader,然後通過 id 標識確定 happypack/loader 選擇哪個 HappyPack 例項處理檔案。

增加對應的 HappyPack 例項。

const HappyPack = require('happypack');
const os = require('os');
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
//...
module.exports = (env, argv) => {
const webpackConfig = {
//...
plugins: [
  new HappyPack({
  // 唯一的id標識
  id: 'happyBabel',
  // 如何處理 .js 檔案,用法和 Loader 的配置一樣
  loaders: [{
    loader: 'babel-loader?cacheDirectory=true',
  }],
  // 共享程式池
  threadPool: happyThreadPool,
  // 允許 HappyPack 輸出日誌,預設為 true,可不寫
  verbose: true
  }),
],
//...
}
}

我們先建立共享程式池,藉助 Node.js os,讓程式池中包含 os.cpus().length 個子程式,然後增加對應的 HappyPack 例項,傳入之前定義好的 id 標識, 告訴 happypack/loader 去處理 .js 檔案,loaders 屬性和上面 Loader 配置中一樣, threadPool 屬性 傳入 預先定義好的 happyThreadPool 引數, 告訴 HappyPack 例項都使用同一個共享程式池中的子程式去處理任務。

然後執行打包編譯構建,構建完成之後我們可以看到 HappyPack 的構建日誌。

image

我們可以看到,HappyPack 啟動了8個子程式去並行處理任務。激動地鼓鼓掌!

最後,再讓我們看一下,載入速度的對比圖,省了1488ms,提升了將近20%。

image

2.6 構建結果輸出分析

視覺化的資源分析工具有很多,我們選用了 webpack-bundle-analyze ,它以圖形的方式展示,相比其他工具更簡單、直觀,輸出分析結果,可以讓我們快速分析到問題所在。下面我們看一下如何在專案中接入。

安裝依賴

npm install webpack-bundle-analyzer  -D

對webpack.config.js進行配置

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
...
module.exports = (env, argv) => {
    const webpackConfig = {
        ...
        plugins: [
            ...
        new BundleAnalyzerPlugin()
        ],
        ...
    }
}

配置方法也很簡單,一般預設的選項就足夠使用, 無需修改。

接著,我們在package.json 的 scripts 中增加

 "analyz": "NODE_ENV=production npm_config_report=true npm run build"

然後執行 npm run analyz 後,瀏覽器會自動開啟 http://127.0.0.1:8888/ ,顯示分析檢視介面。

image

如果不想每次都自動彈出,可以把引數 openAnalyzer 的值改為 false,然後按需手動開啟。

通過檢視,可以看到專案各模組的大小,看到檔案打包壓縮後真正的內容,然後分析出拿些檔案佔用比較大,有了分析思路,就有了優化的目標。

通過最後的分析圖先發現公用的 toast.js 檔案佔比是比較大的,可以會對 toast.js 檔案進行優化。另外佔比比較大的第三方庫 Swiper.js 和 lazyload.js,也可以考慮進行 DLL 抽離,進一步的提升空間。

2.7 重新劃分元件

在 PLUS 專案中我們有一個 component 資料夾,這裡邊放的是一些公用的元件,或者一些頁面的子元件。由於歷史遺留問題,該資料夾經過專案的迭代和頁面的增加,這裡有許多冗餘的元件,整個資料夾的目錄顯得很臃腫。所以關於重新劃分元件勢在必行,下面讓我們來看看如何對其劃分。

首先讓我來看看它裡邊都有什麼?

component資料夾

抱歉一屏截不下!

根據上圖我們大致將 component 資料夾中的內容歸了下類。

image

1、歷史遺留類:這類檔案可以追溯到初始建立的元老級檔案,當時目錄規劃還是單頁面形式的,這類檔案大多是針對首頁的子元件檔案,也有一些根據使用者狀態做的頁面子元件檔案。

2、頁面元件類:隨著專案迭代,專案裡新增了一些新的頁面,而這些頁面中複雜的子元件也散落在了 component 資料夾中。

3、功能類元件:功能類的元件如彈窗元件、返回頂部元件、計算器元件等公共元件也放到這個資料夾下邊管理。

要如何劃分呢?

首先,對於歷史遺留類的頁面檔案,在改版時有意識的將其歸類到其對應的頁面資料夾中,方便維護。對於零散的頁面子元件,也統一歸類到其對應的頁面資料夾中。

而對於有些公共的頁面元件,我們建立一個公共的業務元件資料夾 plus-components 將其統一放到裡邊管理。

像上邊提到的功能類的元件如 dialogcountdown 等我們用npm的形式去引用,不再佔用本地資料夾,縮減了 component 目錄。優化後如下:

image

從上圖可以看出優化後的 component 資料夾根目錄下建立了 plus-components 資料夾存放公用業務元件,同級存放著各個頁面的頁面資料夾,並且在跟目錄下建立了一個 other-components檔案來管理其他功能元件。這樣一來 component 的目錄結構看起來是不是清晰很多,更方便管理。

2.8 Vuex 優化

Vuex 是 Vue 專案中的一種狀態管理模式,通俗點說就是集中管理專案中所有元件公享狀態的一個機制。Vuex 一般運用在中大型的單頁面應用中,如果是簡單的單頁面專案就不建議使用它,因為 Vuex 對於簡單的應用可能是繁瑣冗餘的。

而對於 PLUS 專案來說,Vuex 的存在是非常必要的,因為 PLUS 專案有太多需要共享的狀態了。

我們先來看下專案原有的 Vuex 程式碼:

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
    state: {...},
    getters: {...},
    mutations: {...},
    actions: {...}
});

原有 Vuex 程式碼把所有的公共狀態寫在同一個 index 檔案中,state、getters、mutations、actions 全部是統一管理。這樣寫沒有錯,大多數中小型專案也都是這麼做的。但對於一個大型專案來說,所有頁面的共享狀態都放在一起,會使整個 Vuex 檔案看起來很臃腫,不利於維護。並且有些公共方法經過需求的迭代及改動,會產生很多冗餘程式碼,而一些公共方法針對不同頁面的引用會產生耦合使用問題。

所以對於優化 Vuex ,我們嘗試引入modules來模組化管理 Vuex。

我們在store的根目錄建立一個 modules資料夾,在資料夾中建立 refund檔案。

image

modules 資料夾中可以放任意你想區分管理的模組,這裡僅以 refund 為例。在 modules 資料夾中建立好 js 或 ts 模組後,在 index 根檔案中引入。

import Vue from 'vue';
import Vuex,  { StoreOptions }  from 'vuex';
import state from './states';
import mutations from './mutations';
import actions from './actions';
import getters from './getters';
import refundModule from './modules/refund';

Vue.use(Vuex);

export default new Vuex.Store({
    state,
    mutations,
    actions,
    getters,
    modules:{
        refundModule
    }

});

上述程式碼展示了在 store 根目錄下的 index.ts 中引入 refund,並將其命名為 refundModule 以便呼叫。而對於 refund 檔案,我們可以向以往寫 Vuex 一樣將需要的state 、action、 mutation 寫在裡邊。

const state = {
    orderId: null,
};
const actions = {
    getOrderId: ({ commit }, data) => {
        commit("setOrderid", data);
    }
};
const mutations = {
    setOrderId: (state, data) => {
        state.orderId = data;
    },
};
export default {
    namespaced: true, //  module 名稱空間,
    state,
    actions,
    mutations
};

如程式碼所示,該檔案中記錄了與 refund 頁面有關的 state、action、mutations。另外可以看到程式碼中多了一行 namespaced:true。折行程式碼意思是開啟名稱空間,既當模組被註冊後,它的所有 getter、action 及 mutation 都會自動根據模組註冊的路徑調整命名。簡單來說,它是用來區分你呼叫的是哪個模組中的 state、getter、action、mutations的。

在做完以上工作後,要如何呼叫呢?

如果你是用 JS 寫的 Vuex,可以這麼引用:

import { mapState,  mapActions } from "vuex";

export default {
computed:{
    ...mapStated('模組名(比如:menuNav/getNavMenus)',{ 
        a:state=>state.a,
        b:state=>state.b
    })
},
methods:{
    ...mapActions('模組名(比如:menuNav/getNavMenus)',[  
        'foo',
        'bar'
    ])
}

在 Vuex 官網有很多類似的例子,感興趣的同學可以查閱官閘道器於 modules 的飲用方法。

如果你是用 TS 寫的 Vuex,在頁面中呼叫 modules 可以這麼寫:

import {Component, Prop, Vue, Watch} from 'vue-property-decorator';
import {State, Action, Getter,namespace} from 'vuex-class';

const refundMdl = namespace('refundModule')

@Component
export default class Refund extends Vue {
    @refundMdl.State(state => state.orderId) orderId;
    @refundMdl.Action('getOrderId') getOrderId;
}

在呼叫時一定要引入 namespace ,並且通過 namespace 去呼叫 modules 的公用狀態和方法。

經過上邊對 Vuex 模組化的改造,我們可以有針對性的對需要被區別開的模組狀態進行管理,為 Vuex 檔案減重。

三、優化使用者體驗

在 2020 年伊始,PLUS 會員幾個頻道首頁輪番改版換新顏,產品團隊也是嘔心瀝血的給使用者呈現更加友好的介面和功能,作為前端團隊,除了完成需求之外,也有自己的小九九,我們作為其中的一份子,也想給使用者體驗作出應有的優化改善:

3.1 減少使用者等待時間

在 PLUS 會員多個頻道頁大刀闊斧的進行改版之後,每個人展示的樓層以及樓層順序都有可能是不一樣的,所以前端需要依賴後端介面的資料來進行樓層的展示,這就導致了頁面需要等待配置介面返回資料後才能展示出來,一旦介面返回緩慢,頁面很久才能渲染出來,這就對於使用者體驗相當不好,但是我們無法控制後端返回介面的速度,那麼從前端入手,我們做了以下工作:

1、增加骨架屏,在渲染頁面前就顯示出頁面的整體樣式,減少白屏時間;

2、HTML 頁面直接放置使用者資訊的資料,前端不用再請求介面,直接可以渲染個人卡片一些區域;

3、和產品確認首屏會出現的樓層,比如個人卡片樓層、會員尊享權益樓層,這些樓層都做了前端佔位區域,在介面返回前就顯示出這幾個樓層的初步樣式,避免待配置介面返回後,導致的樓層順序的抖動;

4、這幾個樓層,按照後端返回的資料格式,前端設定初步資料,來渲染初步的樓層頁面,待介面返回資料後,再替換頁面中的關鍵欄位;

img

如上圖所示,在還沒有拿到資料介面的時候,頁面已經可以顯示出基本架構了,縮減了頁面白屏時間,減少了使用者等待時長。

3.2 多重保障樓層顯示

除了上面介紹到減少使用者等待時間,我們還做了多重保護頁面的展示。由於整個頁面的樓層都是根據介面返回的資料做的配置化顯示。一般來說,一旦沒有介面返回資料,則頁面不再展示,甩給使用者一個骨架屏。但是為了追求更好的體驗,我們是不是可以採取多重保險來最大化的解決某個介面掛掉的帶來的問題呢?
首先,我們要求後端直接把樓層配置資訊放在 Html 頁面中,前端根據返回的資訊渲染首屏的樓層,這樣可以直接根據介面資訊判斷頁面要顯示哪些樓層,如果這一層失敗了,則再去呼叫對應的樓層配置介面,退一步說,這個介面也掛了,為了避免使用者掀桌子的心情,我們會直接調取首屏肯定會請求的幾個樓層的介面,這樣經過三層介面保障,減少了因為某個介面有問題,導致頁面白屏的風險。

img

多重保證顯示頁面樓層,首先獲取到首屏直出資料,否則獲取樓層資料介面,再不行直接請求首屏樓層內部資料,避免因一層資料有誤導致頁面白屏,減少使用者投訴;

3.3 完善優化 PWA

自從去年在風控使用者狀態頁面增加了 PWA 快取技術之後,原本要趁熱打鐵推進全量,卻發現同一個域名下所有的請求都會被 serverWork 的攔截,於是戛然而止,從長計議。

那麼我們怎麼控制 PWA 在指定的使用者狀態下生效呢?

方案一

我做了很多嘗試,在 PLUS 專案中不同使用者狀態是在不同的 Html 中,我們是不是可以在 Html 中向 serivceWorker 中傳送訊息,只在某些場景在起作用
在 html 中

navigator.serviceWorker.controller.postMessage()

在 serviceWorker 中

self.addEventListener('message', function(e) {
  
})

但是這個方案是行不通的,因為 serviceWorker 一旦註冊,下次 PWA 啟動是在 Html 讀取成功之前,所以這個方案存在某些問題

方案二

PWA 可以設定指定的作用域

 navigator.serviceWorker.register('service-worker.js', {scope: './xxx'})

但是針對我們的不同狀態的域名全部為 plus.m.jd.com/index 所以這種方案也不太適合我們。

方案三

在 service-worker 的 fetch 做攔截,通過判斷某些標誌,去控制頁面的讀取。比如通過 getUserInfo 介面返回的使用者狀態去判斷當前是否需要開啟 fetch 攔截,通過黑名單的方式去禁止掉在某些狀態下啟動 PWA,程式碼如下所示:

self.addEventListener('fetch', function (event) {
    event.respondWith(
      caches.match(event.request).then(
        (response) =>
          response ||
          fetch(event.request.clone()).then(function (httpRes) {
            if (/getUserInfo/gi.test(event.request.url)) {
              httpRes.json().then((res) => {
                //do something 
              });
            }
            return httpRes;
          })
      )
    );
  });

經過上述處理,原來只要訪問過風控首頁,再訪問同域名下的所有狀態,都會經過 serviceWorker 攔截,如下圖所示:

img

經過修改之後,在其他使用者狀態頁面上可以禁止掉 PWA 的攔截,如下圖所示:

img

3.4 圖片處理

現如今網頁中圖片使用了大量的圖片,能夠給使用者帶來更為直接的視覺衝擊,作為 PLUS 會員的入口,更是展示了大量的商品圖,如何在圖片處理上下功夫,我們也用了些心思。
PLUS 會員頁面中的圖片使用的都是京東圖片系統,其中讓我們眼前一亮的是,可以在 url 上配置引數來處理圖片,譬如說:

http://img30.360buyimg.com/test/s720x540_jfs/t2362/199/2707005502/100242/616257ce/56e66b21N7b8c2be8.jpg

s720x540_jfs ,向業務名和檔案地址間新增的引數,表示把圖片縮放到寬 720、高540;
直接向url後面新增 webp 字尾,則轉成訪問 webp 格式的圖片,這樣訪問伺服器端的圖片,就可以像以下操作了:

function imgCut(item, str) {
 if (/(((img){1}\d{2})|m{1}).360buyimg.com/.test(item)) {
  if (str) {
   item = item.replace('jfs', 's' + str + '_jfs');
  }
  if (check_support_webp()) {
   return item + '.webp'; //需要判斷支援webp的情況下寫上webp字尾
  } else {
   return item;
  }
 } else {
  return item;
 }
}

按照上述方式,請求服務端的圖片,既可以進行圖片的裁剪,保證頁面中圖片的尺寸一致,並且還可以無縫轉換 webp 圖片,真是研發一大利器!注意的是,該功能是處理的向服務端請求的圖片,而不是前端本地提供的圖片。

總結

回首望去,PLUS 會員專案從最初的懵懂,轉眼間已經彙集了數十個複雜邏輯的頁面,迭代了多個版本,建立了數個分支。最近從專案中脫離出來,才發現缺少從一個大局上把握專案的走向,保證一個專案能夠歷經迭代需求,PLUS 會員專案仍有很多待以完善的地方,在此期間也收到了一些團隊的大力支援和很好的建議,之後我們會繼續打磨下去,建立完善的機制,提高程式碼質量,完善使用者體驗,為 PLUS 會員保駕護航。

最後,用我最喜歡的一句話結尾 “我雖隻身前行,彷彿率領百萬雄兵。身在井隅,心向星光,眼裡有詩,自在遠方!”,對生活,對未來充滿希望,與君共勉之~

相關文章