手把手帶你入門前端工程化——超詳細教程

作者:譚光志
原文:https://segmentfault.com/a/1190000037752931

本文將分成以下 7 個小節(jié):

  1. 技術選型
  2. 統(tǒng)一規(guī)范
  3. 測試
  4. 部署
  5. 監(jiān)控
  6. 性能優(yōu)化
  7. 重構

部分小節(jié)提供了非常詳細的實戰(zhàn)教程,讓大家動手實踐。

另外我還寫了一個前端工程化 demo 放在 github 上。這個 demo 包含了 js、css、git 驗證,其中 js、css 驗證需要安裝 VSCode,具體教程在下文中會有提及。

技術選型

對于前端來說,技術選型挺簡單的。就是做選擇題,三大框架中選一個。個人認為可以依據(jù)以下兩個特點來選:

  1. 選你或團隊最熟的,保證在遇到棘手的問題時有人能填坑。
  2. 選市場占有率高的。換句話說,就是選好招人的。

第二點對于小公司來說,特別重要。本來小公司就不好招人,要是還選一個市場占有率不高的框架(例如 Angular),簡歷你都看不到幾個...

image
image

UI 組件庫更簡單,github 上哪個 star 多就用哪個。star 多,說明用的人就多,很多坑別人都替你踩過了,省事。

統(tǒng)一規(guī)范

代碼規(guī)范

先來看看統(tǒng)一代碼規(guī)范的好處:

  • 規(guī)范的代碼可以促進團隊合作
  • 規(guī)范的代碼可以降低維護成本
  • 規(guī)范的代碼有助于 code review(代碼審查)
  • 養(yǎng)成代碼規(guī)范的習慣,有助于程序員自身的成長

當團隊的成員都嚴格按照代碼規(guī)范來寫代碼時,可以保證每個人的代碼看起來都像是一個人寫的,看別人的代碼就像是在看自己的代碼。更重要的是我們能夠認識到規(guī)范的重要性,并堅持規(guī)范的開發(fā)習慣。

如何制訂代碼規(guī)范

建議找一份好的代碼規(guī)范,在此基礎上結合團隊的需求作個性化修改。

下面列舉一些 star 較多的 js 代碼規(guī)范:

css 代碼規(guī)范也有不少,例如:

如何檢查代碼規(guī)范

使用 eslint 可以檢查代碼符不符合團隊制訂的規(guī)范,下面來看一下如何配置 eslint 來檢查代碼。

  1. 下載依賴
// eslint-config-airbnb-base 使用 airbnb 代碼規(guī)范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
  1. package.jsonscripts 加上這行代碼 "lint": "eslint --ext .js test/ src/ bin/"。然后執(zhí)行 npm run lint 即可開始驗證代碼。

不過這樣檢查代碼效率太低,每次都得手動檢查。并且報錯了還得手動修改代碼。

為了改善以上缺點,我們可以使用 VSCode。使用它并加上適當?shù)呐渲每梢栽诿看伪4娲a的時候,自動驗證代碼并進行格式化,省去了動手的麻煩。

css 檢查代碼規(guī)范則使用 stylelint 插件。

由于篇幅有限,具體如何配置請看我的另一篇文章ESlint + stylelint + VSCode自動格式化代碼(2020)

image
在這里插入圖片描述

git 規(guī)范

git 規(guī)范包括兩點:分支管理規(guī)范、git commit 規(guī)范。

分支管理規(guī)范

一般項目分主分支(master)和其他分支。

當有團隊成員要開發(fā)新功能或改 BUG 時,就從 master 分支開一個新的分支。例如項目要從客戶端渲染改成服務端渲染,就開一個分支叫 ssr,開發(fā)完了再合并回 master 分支。

如果改一個 BUG,也可以從 master 分支開一個新分支,并用 BUG 號命名(不過我們小團隊嫌麻煩,沒這樣做,除非有特別大的 BUG)。

git commit 規(guī)范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

大致分為三個部分(使用空行分割):

  1. 標題行: 必填, 描述主要修改類型和內容
  2. 主題內容: 描述為什么修改, 做了什么樣的修改, 以及開發(fā)的思路等等
  3. 頁腳注釋: 可以寫注釋,BUG 號鏈接

type: commit 的類型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代碼,以提高性能
  • refactor: 代碼重構(重構,在不影響代碼內部行為、功能下的代碼修改)
  • docs: 文檔修改
  • style: 代碼格式修改, 注意不是 css 修改(例如分號修改)
  • test: 測試用例新增、修改
  • build: 影響項目構建或依賴項修改
  • revert: 恢復上一次提交
  • ci: 持續(xù)集成相關文件修改
  • chore: 其他修改(不在上述類型中的修改)
  • release: 發(fā)布新版本
  • workflow: 工作流相關文件修改
  1. scope: commit 影響的范圍, 比如: route, component, utils, build...
  2. subject: commit 的概述
  3. body: commit 具體修改內容, 可以分為多行.
  4. footer: 一些備注, 通常是 BREAKING CHANGE 或修復的 bug 的鏈接.

示例

fix(修復BUG)

如果修復的這個BUG只影響當前修改的文件,可不加范圍。如果影響的范圍比較大,要加上范圍描述。

例如這次 BUG 修復影響到全局,可以加個 global。如果影響的是某個目錄或某個功能,可以加上該目錄的路徑,或者對應的功能名稱。

// 示例1
fix(global):修復checkbox不能復選的問題
// 示例2 下面圓括號里的 common 為通用管理的名稱
fix(common): 修復字體過小的BUG,將通用管理下所有頁面的默認字體大小修改為 14px
// 示例3
fix: value.length -> values.length
feat(添加新功能或新頁面)
feat: 添加網(wǎng)站主頁靜態(tài)頁面

這是一個示例,假設對點檢任務靜態(tài)頁面進行了一些描述。
 
這里是備注,可以是放BUG鏈接或者一些重要性的東西。
chore(其他修改)

chore 的中文翻譯為日常事務、例行工作,顧名思義,即不在其他 commit 類型中的修改,都可以用 chore 表示。

chore: 將表格中的查看詳情改為詳情

其他類型的 commit 和上面三個示例差不多,就不說了。

驗證 git commit 規(guī)范

驗證 git commit 規(guī)范,主要通過 git 的 pre-commit 鉤子函數(shù)來進行。當然,你還需要下載一個輔助工具來幫助你進行驗證。

下載輔助工具

npm i -D husky

package.json 加上下面的代碼

"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}

然后在你項目根目錄下新建一個文件夾 script,并在下面新建一個文件 verify-commit.js,輸入以下代碼:

const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        請查看 git commit 提交規(guī)范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

現(xiàn)在來解釋下各個鉤子的含義:

  1. "pre-commit": "npm run lint",在 git commit 前執(zhí)行 npm run lint 檢查代碼格式。
  2. "commit-msg": "node script/verify-commit.js",在 git commit 時執(zhí)行腳本 verify-commit.js 驗證 commit 消息。如果不符合腳本中定義的格式,將會報錯。
  3. "pre-push": "npm test",在你執(zhí)行 git push 將代碼推送到遠程倉庫前,執(zhí)行 npm test 進行測試。如果測試失敗,將不會執(zhí)行這次推送。

項目規(guī)范

主要是項目文件的組織方式和命名方式。

用我們的 Vue 項目舉個例子。

├─public
├─src
├─test

一個項目包含 public(公共資源,不會被 webpack 處理)、src(源碼)、test(測試代碼),其中 src 目錄,又可以細分。

├─api (接口)
├─assets (靜態(tài)資源)
├─components (公共組件)
├─styles (公共樣式)
├─router (路由)
├─store (vuex 全局數(shù)據(jù))
├─utils (工具函數(shù))
└─views (頁面)

文件名稱如果過長則用 - 隔開。

UI 規(guī)范

UI 規(guī)范需要前端、UI、產品溝通,互相商量,最后制定下來,建議使用統(tǒng)一的 UI 組件庫。

制定 UI 規(guī)范的好處:

  • 統(tǒng)一頁面 UI 標準,節(jié)省 UI 設計時間
  • 提高前端開發(fā)效率

測試

測試是前端工程化建設必不可少的一部分,它的作用就是找出 bug,越早發(fā)現(xiàn) bug,所需要付出的成本就越低。并且,它更重要的作用是在將來,而不是當下。

設想一下半年后,你的項目要加一個新功能。在加完新功能后,你不確定有沒有影響到原有的功能,需要測試一下。由于時間過去太久,你對項目的代碼已經不了解了。在這種情況下,如果沒有寫測試,你就得手動一遍一遍的去試。而如果寫了測試,你只需要跑一遍測試代碼就 OK 了,省時省力。

寫測試還可以讓你修改代碼時沒有心理負擔,不用一直想著改這里有沒有問題?會不會引起 BUG?而寫了測試就沒有這種擔心了。

在前端用得最多的就是單元測試(主要是端到端測試我用得很少,不熟),這里著重講解一下。

單元測試

單元測試就是對一個函數(shù)、一個組件、一個類做的測試,它針對的粒度比較小。

它應該怎么寫呢?

  1. 根據(jù)正確性寫測試,即正確的輸入應該有正常的結果。
  2. 根據(jù)異常寫測試,即錯誤的輸入應該是錯誤的結果。

對一個函數(shù)做測試

例如一個取絕對值的函數(shù) abs(),輸入 1,2,結果應該與輸入相同;輸入 -1,-2,結果應該與輸入相反。如果輸入非數(shù)字,例如 "abc",應該拋出一個類型錯誤。

對一個類做測試

假設有這樣一個類:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

單元測試,必須把這個類的所有方法都測一遍。

對一個組件做測試

組件測試比較難,因為很多組件都涉及了 DOM 操作。

例如一個上傳圖片組件,它有一個將圖片轉成 base64 碼的方法,那要怎么測試呢?一般測試都是跑在 node 環(huán)境下的,而 node 環(huán)境沒有 DOM 對象。

我們先來回顧一下上傳圖片的過程:

  1. 點擊 <input type="file" />,選擇圖片上傳。
  2. 觸發(fā) inputchange 事件,獲取 file 對象。
  3. FileReader 將圖片轉換成 base64 碼。

這個過程和下面的代碼是一樣的:

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) => {
        const fileResult = res.target.result
        console.log(fileResult) // 輸出 base64 碼
    }

    reader.readAsDataURL(file)
}

上面的代碼只是模擬,真實情況下應該是這樣使用

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = (res) => {
            const fileResult = res.target.result
            resolve(fileResult) // 輸出 base64 碼
        }

        reader.readAsDataURL(file)
    })
}

可以看到,上面代碼出現(xiàn)了 window 的事件對象 event、FileReader。也就是說,只要我們能夠提供這兩個對象,就可以在任何環(huán)境下運行它。所以我們可以在測試環(huán)境下加上這兩個對象:

// 重寫 File
window.File = function () {}

// 重寫 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            && this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}

然后測試可以這樣寫:

// 提前寫好文件內容
const fileData = 'data:image/test'

// 提供一個假的 file 對象給 tobase64() 函數(shù)
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) => {
        tobase64(file).then(base64 => {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 執(zhí)行測試
test()

通過這種 hack 的方式,我們就實現(xiàn)了對涉及 DOM 操作的組件的測試。我的 vue-upload-imgs 庫就是通過這種方式寫的單元測試,有興趣可以了解一下。

TDD 測試驅動開發(fā)

TDD 就是根據(jù)需求提前把測試代碼寫好,然后根據(jù)測試代碼實現(xiàn)功能。

TDD 的初衷是好的,但如果你的需求經常變(你懂的),那就不是一件好事了。很有可能你天天都在改測試代碼,業(yè)務代碼反而沒怎么動。
所以到現(xiàn)在為止,三年多的程序員生涯,我還沒嘗試過 TDD 開發(fā)。

雖然環(huán)境如此艱難,但有條件的情況下還是應該試一下 TDD 的。例如在你自己負責一個項目又不忙的時候,可以采用此方法編寫測試用例。

測試框架推薦

我常用的測試框架是 jest,好處是有中文文檔,API 清晰明了,一看就知道是干什么用的。

部署

在沒有學會自動部署前,我是這樣部署項目的:

  1. 執(zhí)行測試 npm run test。
  2. 推送代碼 git push
  3. 構建項目 npm run build。
  4. 將打包好的文件放到靜態(tài)服務器。

一次兩次還行,如果天天都這樣,就會把很多時間浪費在重復的操作上。所以我們要學會自動部署,徹底解放雙手。

自動部署(又叫持續(xù)部署 Continuous Deployment,英文縮寫 CD)一般有兩種觸發(fā)方式:

  1. 輪詢。
  2. 監(jiān)聽 webhook 事件。

輪詢

輪詢,就是構建軟件每隔一段時間自動執(zhí)行打包、部署操作。

這種方式不太好,很有可能軟件剛部署完我就改代碼了。為了看到新的頁面效果,不得不等到下一次構建開始。

另外還有一個副作用,假如我一天都沒更改代碼,構建軟件還是會不停的執(zhí)行打包、部署操作,白白的浪費資源。

所以現(xiàn)在的構建軟件基本采用監(jiān)聽 webhook 事件的方式來進行部署。

監(jiān)聽 webhook 事件

webhook 鉤子函數(shù),就是在你的構建軟件上進行設置,監(jiān)聽某一個事件(一般是監(jiān)聽 push 事件),當事件觸發(fā)時,自動執(zhí)行定義好的腳本。

例如 Github Actions,就有這個功能。

image

對于新人來說,僅看我這一段講解是不可能學會自動部署的。為此我特地寫了一篇自動化部署教程,不需要你提前學習自動化部署的知識,只要照著指引做,就能實現(xiàn)前端項目自動化部署。

前端項目自動化部署——超詳細教程(Jenkins、Github Actions),教程已經奉上,各位大佬看完后要是覺得有用,不要忘了點贊,感激不盡。

監(jiān)控

監(jiān)控,又分性能監(jiān)控和錯誤監(jiān)控,它的作用是預警和追蹤定位問題。

性能監(jiān)控

性能監(jiān)控一般利用 window.performance 來進行數(shù)據(jù)采集。

Performance 接口可以獲取到當前頁面中與性能相關的信息,它是 High Resolution Time API 的一部分,同時也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

這個 API 的屬性 timing,包含了頁面加載各個階段的起始及結束時間。

在這里插入圖片描述

在這里插入圖片描述

為了方便大家理解 timing 各個屬性的意義,我在知乎找到一位網(wǎng)友對于 timing 寫的簡介(忘了姓名,后來找不到了,見諒),在此轉載一下。

timing: {
        // 同一個瀏覽器上一個頁面卸載(unload)結束時的時間戳。如果沒有上一個頁面,這個值會和fetchStart相同。
    navigationStart: 1543806782096,

    // 上一個頁面unload事件拋出時的時間戳。如果沒有上一個頁面,這個值會返回0。
    unloadEventStart: 1543806782523,

    // 和 unloadEventStart 相對應,unload事件處理完成時的時間戳。如果沒有上一個頁面,這個值會返回0。
    unloadEventEnd: 1543806782523,

    // 第一個HTTP重定向開始時的時間戳。如果沒有重定向,或者重定向中的一個不同源,這個值會返回0。
    redirectStart: 0,

    // 最后一個HTTP重定向完成時(也就是說是HTTP響應的最后一個比特直接被收到的時間)的時間戳。
    // 如果沒有重定向,或者重定向中的一個不同源,這個值會返回0. 
    redirectEnd: 0,

    // 瀏覽器準備好使用HTTP請求來獲取(fetch)文檔的時間戳。這個時間點會在檢查任何應用緩存之前。
    fetchStart: 1543806782096,

    // DNS 域名查詢開始的UNIX時間戳。
        //如果使用了持續(xù)連接(persistent connection),或者這個信息存儲到了緩存或者本地資源上,這個值將和fetchStart一致。
    domainLookupStart: 1543806782096,

    // DNS 域名查詢完成的時間.
    //如果使用了本地緩存(即無 DNS 查詢)或持久連接,則與 fetchStart 值相等
    domainLookupEnd: 1543806782096,

    // HTTP(TCP) 域名查詢結束的時間戳。
        //如果使用了持續(xù)連接(persistent connection),或者這個信息存儲到了緩存或者本地資源上,這個值將和 fetchStart一致。
    connectStart: 1543806782099,

    // HTTP(TCP) 返回瀏覽器與服務器之間的連接建立時的時間戳。
        // 如果建立的是持久連接,則返回值等同于fetchStart屬性的值。連接建立指的是所有握手和認證過程全部結束。
    connectEnd: 1543806782227,

    // HTTPS 返回瀏覽器與服務器開始安全鏈接的握手時的時間戳。如果當前網(wǎng)頁不要求安全連接,則返回0。
    secureConnectionStart: 1543806782162,

    // 返回瀏覽器向服務器發(fā)出HTTP請求時(或開始讀取本地緩存時)的時間戳。
    requestStart: 1543806782241,

    // 返回瀏覽器從服務器收到(或從本地緩存讀?。┑谝粋€字節(jié)時的時間戳。
        //如果傳輸層在開始請求之后失敗并且連接被重開,該屬性將會被數(shù)制成新的請求的相對應的發(fā)起時間。
    responseStart: 1543806782516,

    // 返回瀏覽器從服務器收到(或從本地緩存讀取,或從本地資源讀?。┳詈笠粋€字節(jié)時
        //(如果在此之前HTTP連接已經關閉,則返回關閉時)的時間戳。
    responseEnd: 1543806782537,

    // 當前網(wǎng)頁DOM結構開始解析時(即Document.readyState屬性變?yōu)椤發(fā)oading”、相應的 readystatechange事件觸發(fā)時)的時間戳。
    domLoading: 1543806782573,

    // 當前網(wǎng)頁DOM結構結束解析、開始加載內嵌資源時(即Document.readyState屬性變?yōu)椤癷nteractive”、相應的readystatechange事件觸發(fā)時)的時間戳。
    domInteractive: 1543806783203,

    // 當解析器發(fā)送DOMContentLoaded 事件,即所有需要被執(zhí)行的腳本已經被解析時的時間戳。
    domContentLoadedEventStart: 1543806783203,

    // 當所有需要立即執(zhí)行的腳本已經被執(zhí)行(不論執(zhí)行順序)時的時間戳。
    domContentLoadedEventEnd: 1543806783216,

    // 當前文檔解析完成,即Document.readyState 變?yōu)?'complete'且相對應的readystatechange 被觸發(fā)時的時間戳
    domComplete: 1543806783796,

    // load事件被發(fā)送時的時間戳。如果這個事件還未被發(fā)送,它的值將會是0。
    loadEventStart: 1543806783796,

    // 當load事件結束,即加載事件完成時的時間戳。如果這個事件還未被發(fā)送,或者尚未完成,它的值將會是0.
    loadEventEnd: 1543806783802
}

通過以上數(shù)據(jù),我們可以得到幾個有用的時間

// 重定向耗時
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗時
dom: timing.domComplete - timing.domLoading,
// 頁面加載耗時
load: timing.loadEventEnd - timing.navigationStart,
// 頁面卸載耗時
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 請求耗時
request: timing.responseEnd - timing.requestStart,
// 獲取性能信息時當前時間
time: new Date().getTime(),

還有一個比較重要的時間就是白屏時間,它指從輸入網(wǎng)址,到頁面開始顯示內容的時間。

將以下腳本放在 </head> 前面就能獲取白屏時間。

<script> whiteScreen = new Date() - performance.timing.navigationStart </script>

通過這幾個時間,就可以得知頁面首屏加載性能如何了。

另外,通過 window.performance.getEntriesByType('resource') 這個方法,我們還可以獲取相關資源(js、css、img...)的加載時間,它會返回頁面當前所加載的所有資源。

在這里插入圖片描述

它一般包括以下幾個類型

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我們只需用到以下幾個信息

// 資源的名稱
name: item.name,
// 資源加載耗時
duration: item.duration.toFixed(2),
// 資源大小
size: item.transferSize,
// 資源所用協(xié)議
protocol: item.nextHopProtocol,

現(xiàn)在,寫幾行代碼來收集這些數(shù)據(jù)。

// 收集性能信息
const getPerformance = () => {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗時
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏時間
        whiteScreen: whiteScreen,
        // DOM 渲染耗時
        dom: timing.domComplete - timing.domLoading,
        // 頁面加載耗時
        load: timing.loadEventEnd - timing.navigationStart,
        // 頁面卸載耗時
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 請求耗時
        request: timing.responseEnd - timing.requestStart,
        // 獲取性能信息時當前時間
        time: new Date().getTime(),
    }

    return performance
}

// 獲取資源信息
const getResources = () => {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 獲取資源信息時當前時間
        time: new Date().getTime(),
    }

    data.forEach(item => {
        const arry = resource[item.initiatorType]
        arry && arry.push({
            // 資源的名稱
            name: item.name,
            // 資源加載耗時
            duration: item.duration.toFixed(2),
            // 資源大小
            size: item.transferSize,
            // 資源所用協(xié)議
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小結

通過對性能及資源信息的解讀,我們可以判斷出頁面加載慢有以下幾個原因:

  1. 資源過多
  2. 網(wǎng)速過慢
  3. DOM元素過多

除了用戶網(wǎng)速過慢,我們沒辦法之外,其他兩個原因都是有辦法解決的,性能優(yōu)化將在下一節(jié)《性能優(yōu)化》中會講到。

錯誤監(jiān)控

現(xiàn)在能捕捉的錯誤有三種。

  1. 資源加載錯誤,通過 addEventListener('error', callback, true) 在捕獲階段捕捉資源加載失敗錯誤。
  2. js 執(zhí)行錯誤,通過 window.onerror 捕捉 js 錯誤。
  3. promise 錯誤,通過 addEventListener('unhandledrejection', callback)捕捉 promise 錯誤,但是沒有發(fā)生錯誤的行數(shù),列數(shù)等信息,只能手動拋出相關錯誤信息。

我們可以建一個錯誤數(shù)組變量 errors 在錯誤發(fā)生時,將錯誤的相關信息添加到數(shù)組,然后在某個階段統(tǒng)一上報,具體如何操作請看代碼

// 捕獲資源加載失敗錯誤 js css img...
addEventListener('error', e => {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 錯誤發(fā)生的時間
            time: new Date().getTime(),
        })
    }
}, true)

// 監(jiān)聽 js 錯誤
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 錯誤發(fā)生的時間
        time: new Date().getTime(),
    })
}

// 監(jiān)聽 promise 錯誤 缺點是獲取不到行數(shù)數(shù)據(jù)
addEventListener('unhandledrejection', e => {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason && e.reason.msg) || e.reason || '',
        // 錯誤發(fā)生的時間
        time: new Date().getTime(),
    })
})

小結

通過錯誤收集,可以了解到網(wǎng)站錯誤發(fā)生的類型及數(shù)量,從而可以做相應的調整,以減少錯誤發(fā)生。
完整代碼和 DEMO 請看我另一篇文章前端性能和錯誤監(jiān)控的末尾,大家可以復制代碼(HTML文件)在本地測試一下。

數(shù)據(jù)上報

性能數(shù)據(jù)上報

性能數(shù)據(jù)可以在頁面加載完之后上報,盡量不要對頁面性能造成影響。

window.onload = () => {
    // 在瀏覽器空閑時間獲取性能及資源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}

當然,你也可以設一個定時器,循環(huán)上報。不過每次上報最好做一下對比去重再上報,避免同樣的數(shù)據(jù)重復上報。

錯誤數(shù)據(jù)上報

我在DEMO里提供的代碼,是用一個 errors 數(shù)組收集所有的錯誤,再在某一階段統(tǒng)一上報(延時上報)。
其實,也可以改成在錯誤發(fā)生時上報(即時上報)。這樣可以避免在收集完錯誤延時上報還沒觸發(fā),用戶卻已經關掉網(wǎng)頁導致錯誤數(shù)據(jù)丟失的問題。

// 監(jiān)聽 js 錯誤
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 錯誤發(fā)生的時間
        time: new Date().getTime(),
    }
    
    // 即時上報
    axios.post({ url: 'xxx', data, })
}

SPA

window.performance API 是有缺點的,在 SPA 切換路由時,window.performance.timing 的數(shù)據(jù)不會更新。
所以我們需要另想辦法來統(tǒng)計切換路由到加載完成的時間。
拿 Vue 舉例,一個可行的辦法就是切換路由時,在路由的全局前置守衛(wèi) beforeEach 里獲取開始時間,在組件的 mounted 鉤子里執(zhí)行 vm.$nextTick 函數(shù)來獲取組件的渲染完畢時間。

router.beforeEach((to, from, next) => {
    store.commit('setPageLoadedStartTime', new Date())
})
mounted() {
    this.$nextTick(() => {
        this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
    })
}

除了性能和錯誤監(jiān)控,其實我們還可以做得更多。

用戶信息收集

navigator

使用 window.navigator 可以收集到用戶的設備信息,操作系統(tǒng),瀏覽器信息...

UV(Unique visitor)

是指通過互聯(lián)網(wǎng)訪問、瀏覽這個網(wǎng)頁的自然人。訪問您網(wǎng)站的一臺電腦客戶端為一個訪客。00:00-24:00內相同的客戶端只被計算一次。一天內同個訪客多次訪問僅計算一個UV。
在用戶訪問網(wǎng)站時,可以生成一個隨機字符串+時間日期,保存在本地。在網(wǎng)頁發(fā)生請求時(如果超過當天24小時,則重新生成),把這些參數(shù)傳到后端,后端利用這些信息生成 UV 統(tǒng)計報告。

PV(Page View)

即頁面瀏覽量或點擊量,用戶每1次對網(wǎng)站中的每個網(wǎng)頁訪問均被記錄1個PV。用戶對同一頁面的多次訪問,訪問量累計,用以衡量網(wǎng)站用戶訪問的網(wǎng)頁數(shù)量。

頁面停留時間

傳統(tǒng)網(wǎng)站
用戶在進入 A 頁面時,通過后臺請求把用戶進入頁面的時間捎上。過了 10 分鐘,用戶進入 B 頁面,這時后臺可以通過接口捎帶的參數(shù)可以判斷出用戶在 A 頁面停留了 10 分鐘。
SPA
可以利用 router 來獲取用戶停留時間,拿 Vue 舉例,通過 router.beforeEach destroyed 這兩個鉤子函數(shù)來獲取用戶停留該路由組件的時間。

瀏覽深度

通過 document.documentElement.scrollTop 屬性以及屏幕高度,可以判斷用戶是否瀏覽完網(wǎng)站內容。

頁面跳轉來源

通過 document.referrer 屬性,可以知道用戶是從哪個網(wǎng)站跳轉而來。

小結

通過分析用戶數(shù)據(jù),我們可以了解到用戶的瀏覽習慣、愛好等等信息,想想真是恐怖,毫無隱私可言。

前端監(jiān)控部署教程

前面說的都是監(jiān)控原理,但要實現(xiàn)還是得自己動手寫代碼。為了避免麻煩,我們可以用現(xiàn)有的工具 sentry 去做這件事。

sentry 是一個用 python 寫的性能和錯誤監(jiān)控工具,你可以使用 sentry 提供的服務(免費功能少),也可以自己部署服務?,F(xiàn)在來看一下如何使用 sentry 提供的服務實現(xiàn)監(jiān)控。

注冊賬號

打開 https://sentry.io/signup/ 網(wǎng)站,進行注冊。

image
image

選擇項目,我選的 Vue。

image

安裝 sentry 依賴

選完項目,下面會有具體的 sentry 依賴安裝指南。

image

根據(jù)提示,在你的 Vue 項目執(zhí)行這段代碼 npm install --save @sentry/browser @sentry/integrations @sentry/tracing,安裝 sentry 所需的依賴。

再將下面的代碼拷到你的 main.js,放在 new Vue() 之前。

import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 這里是你的 dsn 地址,注冊完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});

然后點擊第一步中的 skip this onboarding,進入控制臺頁面。

如果忘了自己的 DSN,請點擊左邊的菜單欄選擇 Settings -> Projects -> 點擊自己的項目 -> Client Keys(DSN)。

創(chuàng)建第一個錯誤

在你的 Vue 項目執(zhí)行一個打印語句 console.log(b)。

這時點開 sentry 主頁的 issues 一項,可以發(fā)現(xiàn)有一個報錯信息 b is not defined

image

這個報錯信息包含了錯誤的具體信息,還有你的 IP、瀏覽器信息等等。

但奇怪的是,我們的瀏覽器控制臺并沒有輸出報錯信息。

這是因為被 sentry 屏蔽了,所以我們需要加上一個選項 logErrors: true。

image

然后再查看頁面,發(fā)現(xiàn)控制臺也有報錯信息了:

image

上傳 sourcemap

一般打包后的代碼都是經過壓縮的,如果沒有 sourcemap,即使有報錯信息,你也很難根據(jù)提示找到對應的源碼在哪。

下面來看一下如何上傳 sourcemap。

首先創(chuàng)建 auth token。

image
image
image
image

這個生成的 token 一會要用到。

安裝 sentry-cli@sentry/webpack-plugin

npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin

安裝完上面兩個插件后,在項目根目錄創(chuàng)建一個 .sentryclirc 文件(不要忘了在 .gitignore 把這個文件添加上,以免暴露 token),內容如下:

[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c

把 xxx 替換成剛才生成的 token。

org 是你的組織名稱。

image

project 是你的項目名稱,根據(jù)下面的提示可以找到。

image
image

在項目下新建 vue.config.js 文件,把下面的內容填進去:

const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目錄
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生產環(huán)境下上傳 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}

填完以后,執(zhí)行 npm run build,就可以看到 sourcemap 的上傳結果了。

image

我們再來看一下沒上傳 sourcemap 和上傳之后的報錯信息對比。

未上傳 sourcemap

image
image

已上傳 sourcemap

image
image

可以看到,上傳 sourcemap 后的報錯信息更加準確。

切換中文環(huán)境和時區(qū)

image
image

選完刷新即可。

性能監(jiān)控

image

打開 performance 選項,就能看到你每個項目的運行情況。具體的參數(shù)解釋請看文檔 Performance Monitoring

性能優(yōu)化

性能優(yōu)化主要分為兩類:

  1. 加載時優(yōu)化
  2. 運行時優(yōu)化

例如壓縮文件、使用 CDN 就屬于加載時優(yōu)化;減少 DOM 操作,使用事件委托屬于運行時優(yōu)化。

在解決問題之前,必須先找出問題,否則無從下手。所以在做性能優(yōu)化之前,最好先調查一下網(wǎng)站的加載性能和運行性能。

手動檢查

檢查加載性能

一個網(wǎng)站加載性能如何主要看白屏時間和首屏時間。

  • 白屏時間:指從輸入網(wǎng)址,到頁面開始顯示內容的時間。
  • 首屏時間:指從輸入網(wǎng)址,到頁面完全渲染的時間。

將以下腳本放在 </head> 前面就能獲取白屏時間。

<script> new Date() - performance.timing.navigationStart </script>

首屏時間比較復雜,得考慮有圖片和沒有圖片的情況。

如果沒有圖片,則在 window.onload 事件里執(zhí)行 new Date() - performance.timing.navigationStart 即可獲取首屏時間。

如果有圖片,則要在最后一個在首屏渲染的圖片的 onload 事件里執(zhí)行 new Date() - performance.timing.navigationStart 獲取首屏時間,實施起來比較復雜,在這里限于篇幅就不說了。

檢查運行性能

配合 chrome 的開發(fā)者工具,我們可以查看網(wǎng)站在運行時的性能。

打開網(wǎng)站,按 F12 選擇 performance,點擊左上角的灰色圓點,變成紅色就代表開始記錄了。這時可以模仿用戶使用網(wǎng)站,在使用完畢后,點擊 stop,然后你就能看到網(wǎng)站運行期間的性能報告。如果有紅色的塊,代表有掉幀的情況;如果是綠色,則代表 FPS 很好。

另外,在 performance 標簽下,按 ESC 會彈出來一個小框。點擊小框左邊的三個點,把 rendering 勾出來。

image
image

這兩個選項,第一個是高亮重繪區(qū)域,另一個是顯示幀渲染信息。把這兩個選項勾上,然后瀏覽網(wǎng)頁,可以實時的看到你網(wǎng)頁渲染變化。

利用工具檢查

監(jiān)控工具

可以部署一個前端監(jiān)控系統(tǒng)來監(jiān)控網(wǎng)站性能,上一節(jié)中講到的 sentry 就屬于這一類。

chrome 工具 Lighthouse

如果你安裝了 Chrome 52+ 版本,請按 F12 打開開發(fā)者工具。


image
image

它不僅會對你網(wǎng)站的性能打分,還會對 SEO 打分。

image

使用 Lighthouse 審查網(wǎng)絡應用

如何做性能優(yōu)化

網(wǎng)上關于性能優(yōu)化的文章和書籍多不勝數(shù),但有很多優(yōu)化規(guī)則已經過時了。所以我寫了一篇性能優(yōu)化文章前端性能優(yōu)化 24 條建議(2020),分析總結出了 24 條性能優(yōu)化建議,強烈推薦。

重構

《重構2》一書中對重構進行了定義:

所謂重構(refactoring)是這樣一個過程:在不改變代碼外在行為的前提下,對代碼做出修改,以改進程序的內部結構。重構是一種經千錘百煉形成的有條不紊的程序整理方法,可以最大限度地減小整理過程中引入錯誤的概率。本質上說,重構就是在代碼寫好之后改進它的設計。

重構和性能優(yōu)化有相同點,也有不同點。

相同的地方是它們都在不改變程序功能的情況下修改代碼;不同的地方是重構為了讓代碼變得更加易讀、理解,性能優(yōu)化則是為了讓程序運行得更快。

重構可以一邊寫代碼一邊重構,也可以在程序寫完后,拿出一段時間專門去做重構。沒有說哪個方式更好,視個人情況而定。

如果你專門拿一段時間來做重構,建議你在重構一段代碼后,立即進行測試。這樣可以避免修改代碼太多,在出錯時找不到錯誤點。

重構的原則

  1. 事不過三,三則重構。即不能重復寫同樣的代碼,在這種情況下要去重構。
  2. 如果一段代碼讓人很難看懂,那就該考慮重構了。
  3. 如果已經理解了代碼,但是非常繁瑣或者不夠好,也可以重構。
  4. 過長的函數(shù),需要重構。
  5. 一個函數(shù)最好對應一個功能,如果一個函數(shù)被塞入多個功能,那就要對它進行重構了。

重構手法

《重構2》這本書中,介紹了多達上百個重構手法。但我覺得有兩個是比較常用的:

  1. 提取重復代碼,封裝成函數(shù)
  2. 拆分太長或功能太多的函數(shù)

提取重復代碼,封裝成函數(shù)

假設有一個查詢數(shù)據(jù)的接口 /getUserData?age=17&city=beijing。現(xiàn)在需要做的是把用戶數(shù)據(jù):{ age: 17, city: 'beijing' } 轉成 URL 參數(shù)的形式:

let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key => {
    result += '&' + key + '=' + data[key]
})

result.substr(1) // age=17&city=beijing

如果只有這一個接口需要轉換,不封裝成函數(shù)是沒問題的。但如果有多個接口都有這種需求,那就得把它封裝成函數(shù)了:

function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key => {
        result += '&' + key + '=' + data[key]
    })

    return result.substr(1)
}

拆分太長或功能太多的函數(shù)

假設現(xiàn)在有一個注冊功能,用偽代碼表示:

function register(data) {
    // 1. 驗證用戶數(shù)據(jù)是否合法
    /**
     * 驗證賬號
     * 驗證密碼
     * 驗證短信驗證碼
     * 驗證身份證
     * 驗證郵箱
     */

    // 2. 如果用戶上傳了頭像,則將用戶頭像轉成 base64 碼保存
    /**
     * 新建 FileReader 對象
     * 將圖片轉換成 base64 碼
     */

    // 3. 調用注冊接口
    // ...
}

這個函數(shù)包含了三個功能,驗證、轉換、注冊。其中驗證和轉換功能是可以提取出來單獨封裝成函數(shù)的:

function register(data) {
    // 1. 驗證用戶數(shù)據(jù)是否合法
    // verify()

    // 2. 如果用戶上傳了頭像,則將用戶頭像轉成 base64 碼保存
    // tobase64()

    // 3. 調用注冊接口
    // ...
}

如果你對重構有興趣,強烈推薦你閱讀《重構2》這本書。

參考資料:

總結

寫這篇文章主要是為了對我這一年多工作經驗作總結,因為我基本上都在研究前端工程化以及如何提升團隊的開發(fā)效率。希望這篇文章能幫助一些對前端工程化沒有經驗的新手,通過這篇文章入門前端工程化。

如果這篇文章對你有幫助,請點一下贊,感激不盡。

求職啟事

本人具有三年+前端工作經驗,32歲,高中學歷,現(xiàn)尋求天津、北京地區(qū)的前端工作機會。

下面是我掌握的一些技能:

  1. 熟練掌握 HTML、CSS、JavaScript。
  2. 熟練掌握 Vue 全家桶并研究過 Vue1.0 源碼及 Vue3.0 部分源碼。
  3. 使用 nodejs 寫過腳本和個人博客,沒有開發(fā)過企業(yè)應用。
  4. 學習計算機原理并實現(xiàn)一個簡單的 cpu 和內存模塊運行在模擬器上(github 項目地址)。
  5. 學習操作系統(tǒng)并做實驗實現(xiàn)了一個簡單的內核(github 項目地址)。
  6. 學習編譯原理寫過一個簡單編譯器(github 項目地址)。
  7. 對計算機網(wǎng)絡應用層和傳輸層的知識比較了解。
  8. 數(shù)據(jù)結構與算法有學習過,還刷了 300+ 道 leetcode,但效果不是很好。

社交網(wǎng)站

如果您覺得我的條件還可以,可以私信我或在評論區(qū)留言,謝謝。

相關文章

  1. 4W字從0到部署上線,用 TS 帶你徹底掌握前端工程化

  2. 到底什么是前端工程師?聊聊前端工作的現(xiàn)在與未來

  3. 簡單談論下組件化思想

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容