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


本文將分成以下 7 個小節:

  1. 技術選型
  2. 統一規范
  3. 測試
  4. 部署
  5. 監控
  6. 性能優化
  7. 重構

部分小節提供了非常詳細的實戰教程,讓大家動手實踐。

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

技術選型

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

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

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

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

統一規范

代碼規范

先來看看統一代碼規范的好處:

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

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

如何制訂代碼規范

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

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

css 代碼規范也有不少,例如:

如何檢查代碼規范

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

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

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

為了改善以上缺點,我們可以使用 VSCode。使用它並加上適當的配置可以在每次保存代碼的時候,自動驗證代碼並進行格式化,省去了動手的麻煩。

css 檢查代碼規范則使用 stylelint 插件。

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

在這里插入圖片描述

git 規范

git 規范包括兩點:分支管理規范、git commit 規范。

分支管理規范

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

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

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

git commit 規范

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

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

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

type: commit 的類型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代碼,以提高性能
  • refactor: 代碼重構(重構,在不影響代碼內部行為、功能下的代碼修改)
  • docs: 文檔修改
  • style: 代碼格式修改, 注意不是 css 修改(例如分號修改)
  • test: 測試用例新增、修改
  • build: 影響項目構建或依賴項修改
  • revert: 恢復上一次提交
  • ci: 持續集成相關文件修改
  • chore: 其他修改(不在上述類型中的修改)
  • release: 發布新版本
  • 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: 添加網站主頁靜態頁面

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

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

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

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

驗證 git commit 規范

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

下載輔助工具

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 提交規范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

現在來解釋下各個鈎子的含義:

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

項目規范

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

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

├─public
├─src
├─test

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

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

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

UI 規范

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

制定 UI 規范的好處:

  • 統一頁面 UI 標准,節省 UI 設計時間
  • 提高前端開發效率

測試

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

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

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

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

單元測試

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

它應該怎么寫呢?

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

對一個函數做測試

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

對一個類做測試

假設有這樣一個類:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

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

對一個組件做測試

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

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

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

  1. 點擊 <input type="file" />,選擇圖片上傳。
  2. 觸發 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)
    })
}

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

// 重寫 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() 函數
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()
        })
    })
}

// 執行測試
test()

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

TDD 測試驅動開發

TDD 就是根據需求提前把測試代碼寫好,然后根據測試代碼實現功能。

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

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

測試框架推薦

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

部署

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

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

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

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

  1. 輪詢。
  2. 監聽 webhook 事件。

輪詢

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

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

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

所以現在的構建軟件基本采用監聽 webhook 事件的方式來進行部署。

監聽 webhook 事件

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

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

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

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

監控

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

性能監控

性能監控一般利用 window.performance 來進行數據采集。

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

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

在這里插入圖片描述
在這里插入圖片描述

為了方便大家理解 timing 各個屬性的意義,我在知乎找到一位網友對於 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時間戳。
        //如果使用了持續連接(persistent connection),或者這個信息存儲到了緩存或者本地資源上,這個值將和fetchStart一致。
	domainLookupStart: 1543806782096,

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

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

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

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

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

	// 返回瀏覽器從服務器收到(或從本地緩存讀取)第一個字節時的時間戳。
        //如果傳輸層在開始請求之后失敗並且連接被重開,該屬性將會被數制成新的請求的相對應的發起時間。
	responseStart: 1543806782516,

	// 返回瀏覽器從服務器收到(或從本地緩存讀取,或從本地資源讀取)最后一個字節時
        //(如果在此之前HTTP連接已經關閉,則返回關閉時)的時間戳。
	responseEnd: 1543806782537,

	// 當前網頁DOM結構開始解析時(即Document.readyState屬性變為“loading”、相應的 readystatechange事件觸發時)的時間戳。
	domLoading: 1543806782573,

	// 當前網頁DOM結構結束解析、開始加載內嵌資源時(即Document.readyState屬性變為“interactive”、相應的readystatechange事件觸發時)的時間戳。
	domInteractive: 1543806783203,

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

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

	// 當前文檔解析完成,即Document.readyState 變為 'complete'且相對應的readystatechange 被觸發時的時間戳
	domComplete: 1543806783796,

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

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

通過以上數據,我們可以得到幾個有用的時間

// 重定向耗時
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(),

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

將以下腳本放在 </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,
// 資源所用協議
protocol: item.nextHopProtocol,

現在,寫幾行代碼來收集這些數據。

// 收集性能信息
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,
            // 資源所用協議
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小結

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

  1. 資源過多
  2. 網速過慢
  3. DOM元素過多

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

錯誤監控

現在能捕捉的錯誤有三種。

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

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

// 捕獲資源加載失敗錯誤 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',
            // 錯誤發生的時間
            time: new Date().getTime(),
        })
    }
}, true)

// 監聽 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,
        // 錯誤發生的時間
        time: new Date().getTime(),
    })
}

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

小結

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

數據上報

性能數據上報

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

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)
    }
}

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

錯誤數據上報

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

// 監聽 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,
        // 錯誤發生的時間
        time: new Date().getTime(),
    }
    
    // 即時上報
    axios.post({ url: 'xxx', data, })
}

SPA

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

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

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

用戶信息收集

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

UV(Unique visitor)

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

PV(Page View)

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

頁面停留時間

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

瀏覽深度

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

頁面跳轉來源

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

小結

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

前端監控部署教程

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

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

注冊賬號

打開 https://sentry.io/signup/ 網站,進行注冊。

選擇項目,我選的 Vue。

安裝 sentry 依賴

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

根據提示,在你的 Vue 項目執行這段代碼 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)

創建第一個錯誤

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

這時點開 sentry 主頁的 issues 一項,可以發現有一個報錯信息 b is not defined

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

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

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

然后再查看頁面,發現控制台也有報錯信息了:

上傳 sourcemap

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

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

首先創建 auth token。

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

安裝 sentry-cli@sentry/webpack-plugin

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

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

[auth]
token=xxx

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

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

org 是你的組織名稱。

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

在項目下新建 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'],
            }),
        ],
    },
}

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

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

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

未上傳 sourcemap

已上傳 sourcemap

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

切換中文環境和時區

選完刷新即可。

性能監控

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

性能優化

性能優化主要分為兩類:

  1. 加載時優化
  2. 運行時優化

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

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

手動檢查

檢查加載性能

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

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

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

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

window.onload 事件里執行 new Date() - performance.timing.navigationStart 即可獲取首屏時間。

檢查運行性能

配合 chrome 的開發者工具,我們可以查看網站在運行時的性能。

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

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

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

利用工具檢查

監控工具

可以部署一個前端監控系統來監控網站性能,上一節中講到的 sentry 就屬於這一類。

chrome 工具 Lighthouse

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

它不僅會對你網站的性能打分,還會對 SEO 打分。

使用 Lighthouse 審查網絡應用

如何做性能優化

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

重構

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

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

重構和性能優化有相同點,也有不同點。

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

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

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

重構的原則

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

重構手法

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

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

提取重復代碼,封裝成函數

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

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

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

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

    return result.substr(1)
}

拆分太長或功能太多的函數

假設現在有一個注冊功能,用偽代碼表示:

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

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

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

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

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

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

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

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

參考資料:

總結

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM