目前市面上有許多成熟的前端監控系統,但我們沒有選擇成品,而是自己動手研發。這里面包括多個原因:
* 填補H5日志的空白
* 節約公司費用支出
* 可靈活地根據業務自定義監控
* 回溯時間能更長久
* 反哺運營和產品,從而優化產品質量
* 一次難得的練兵機會
  前端監控地基本目的:了解當前項目實際使用的情況,有哪些異常,在追蹤到后,對其進行分析,并提供合適的解決方案。
  其實也可以說是防患于未來,因為根據海恩法則可知:
  每一起嚴重事故的背后,必然有 29 次輕微事故和 300 起未遂先兆以及 1000 起事故隱患。
  前端監控地終極目標: 1 分鐘感知、5 分鐘定位、10 分鐘恢復。目前是初版,離該目標還比較遙遠。
  SDK(采用ES5語法)取名為 [shin.js](https://github.com/pwstrick/shin-admin/blob/main/public/shin.js),其作用就是將數據通過 JavaScript 采集起來,統一發送到后臺,采集的方式包括監聽或劫持原始方法,獲取需要上報的數據,并通過 gif 傳遞數據。
  整個系統大致的運行流程如下:
:-: 
  2023-01-16 經過 TypeScript 整理重寫后,正式將監控系統的腳本開源,命名為?[shin-monitor](https://github.com/pwstrick/shin-monitor)。
## 一、異常捕獲
  異常包括運行時錯誤、Promise錯誤、框架錯誤等。
  2022-08-10 錯誤日志在提交時附帶版本號,版本號是在初始化時手動輸入的,格式是年月日時分加上名字。
~~~
shin.setParam({
version: '202208091830-strick',
});
~~~
  版本號的作用是為了在發生錯誤時,能追蹤到什么時刻上線的代碼。有時候可能因為上了新代碼發生了錯誤,那就得找出新代碼的位置。
~~~
/**
* 上報錯誤
* @param {Object} errorLog 錯誤日志
*/
function handleError(errorLog) {
// 推送版本號
shin.param.version && (errorLog.version = shin.param.version);
shin.send({
category: ACTION_ERROR,
data: errorLog
});
}
~~~
  有了版本號大致能知道代碼提交的時間范圍,如果要與 Git 提交操作做精準關聯的話,可以將此版本號作為 commit 的備注添加進來。
**1)error事件**
  為 window 注冊[error](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/error_event)事件,捕獲全局錯誤,過濾掉與業務無關的錯誤,例如“Script error.”、JSBridge告警等,還需統一資源載入和運行時錯誤的數據格式。
~~~
// 定義的錯誤類型碼
var ERROR_RUNTIME = "runtime";
var ERROR_SCRIPT = "script";
var ERROR_STYLE = "style";
var ERROR_IMAGE = "image";
var ERROR_AUDIO = "audio";
var ERROR_VIDEO = "video";
var ERROR_PROMISE = "promise";
var ERROR_VUE = "vue";
var ERROR_REACT = "react";
var LOAD_ERROR_TYPE = {
SCRIPT: ERROR_SCRIPT,
LINK: ERROR_STYLE,
IMG: ERROR_IMAGE,
AUDIO: ERROR_AUDIO,
VIDEO: ERROR_VIDEO
};
/**
* 監控腳本運行時的異常
*/
window.addEventListener(
"error",
function (event) {
var errorTarget = event.target;
// 過濾掉與業務無關的錯誤
if (event.message === "Script error.") {
return;
}
if (
errorTarget !== window &&
errorTarget.nodeName &&
LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()]
) {
handleError(formatLoadError(errorTarget));
} else {
// 過濾無效錯誤
event.message && handleError(
formatRuntimerError(
event.message,
event.filename,
event.lineno,
event.colno,
event.error
)
);
}
},
true //捕獲
);
/**
* 生成 runtime 錯誤日志
* @param {String} message 錯誤信息
* @param {String} filename 出錯文件的URL
* @param {Long} lineno 出錯代碼的行號
* @param {Long} colno 出錯代碼的列號
* @param {Object} error 錯誤信息Object
* https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error
*/
function formatRuntimerError(message, filename, lineno, colno, error) {
return {
type: ERROR_RUNTIME,
lineno,
colno,
desc: {
prompt: message + " at " + filename + ":" + lineno + ":" + colno,
url: location.href
}
// stack: error && (error.stack ? error.stack : "no stack") // IE <9, has no error stack
};
}
/**
* 生成 load 錯誤日志
* 需要加載資源的元素
*/
function formatLoadError(errorTarget) {
return {
type: LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()],
desc: {
url: errorTarget.baseURI,
src: errorTarget.src || errorTarget.href
}
};
}
~~~
  2022-12-14 在 error 事件中,調用 formatRuntimerError() 前,增加 event.message 的判斷,用于過濾一些無效的錯誤,例如 undefined at undefined:undefined:undefined。
  2022-12-16 為 formatRuntimerError() 中的 desc 增加當前出錯頁面的地址,便于日志檢索。
~~  得用[performance.getEntriesByType("resource")](https://developer.mozilla.org/zh-CN/docs/Web/API/Performance/getEntriesByType)讀取到資源列表(由[PerformanceResourceTiming](https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceResourceTiming)組成),然后循環列表。
  當數據項的decodedBodySize屬性為0時,就可判斷無法讀取這個資源;或者沒有該屬性,可認為當前資源緩存在瀏覽器中。
  這種判斷的條件不夠全,也不夠精確,后面就用比較簡單粗暴的方式來做判斷依據,那就是 duration 大于20秒,就認為請求超時了。
  在日志中會將各個階段的時間參數都保存,便于后期的校驗。~~
  2023-06-27 媒體資源在出現錯誤時,會在 errorTarget 包含一個錯誤對象,包括 code 和 message。?
  不過 message 我讀取時都是空字符串,下面是一段來自于[shin-monitor](https://github.com/pwstrick/shin-monitor)的 TS 代碼,修改了 formatLoadError 中的邏輯。
~~~
private formatLoadError(errorTarget: TypeEventTarget): TypeErrorData {
const desc: TypeResourceDesc = {
url: errorTarget.baseURI,
src: errorTarget.src || errorTarget.href
};
/**
* 對于媒體資源 errorTarget 會包含 error 屬性,其 code 包含 4 個值
* MEDIA_ERR_ABORTED:表示由于用戶取消操作而引發的錯誤(數值為 1)
* MEDIA_ERR_NETWORK:表示由于網絡錯誤而引發的錯誤(數值為 2)
* MEDIA_ERR_DECODE:表示由于解碼錯誤而引發的錯誤(數值為 3)
* MEDIA_ERR_SRC_NOT_SUPPORTED:表示由于不支持媒體資源格式而引發的錯誤(數值為 4)
*/
if(errorTarget.error) {
const MEDIA_ERR = {
1: '用戶取消操作',
2: '網絡錯誤',
3: '解碼錯誤',
4: '不支持的媒體資源格式'
};
const { code } = errorTarget.error;
code && (desc.message = MEDIA_ERR[code]);
}
return {
type: CONSTANT.LOAD_ERROR_TYPE[errorTarget.nodeName.toUpperCase()],
desc
// stack: "no stack"
};
}
~~~
  2022-12-13 在將 error 事件處理程序中的第一個 if 判斷條件中的 !event.filename 去掉后,就能監控到腳本、樣式和圖像等靜態資源的異常了。
  所以下面這段代碼現在可以全部注釋掉了,但是這段代碼可以提供一些對異常資源處理的思路,例如根據參數計算出資源是 404 還是超時等問題。
~~~
/**
* 監控資源異常,即無法響應的資源
*/
window.addEventListener(
"load",
function () {
// 羅列資源列表,PerformanceResourceTiming類型
var resources = performance.getEntriesByType("resource");
// 映射initiatorType和錯誤類型
var hashError = {
script: ERROR_SCRIPT,
link: ERROR_STYLE
// img: ERROR_IMAGE
};
resources &&
resources.forEach(function (value) {
var type = hashError[value.initiatorType];
/**
* 非監控資源、響應時間在20秒內、監控資源是ma.gif或shin.js,則結束當前循環
*/
if (
!type || // 非監控資源
value.duration < 20000 || // 20秒內
value.name.indexOf("ma.gif") >= 0 ||
value.name.indexOf("shin.js") >= 0
) {
return;
}
/**
* 若是CSS文件,則過濾腳本文件
* 但是Vue會通過link元素預請求腳本
*/
// if (type === ERROR_STYLE
// && value.name.indexOf('.js') >= 0) {
// return;
// }
handleError({
type,
desc: handleNumber(value.toJSON())
});
});
},
false
);
~~~
  其實主要是為了監控腳本文本的響應,因為有時候會由于腳本沒響應而導致頁面空白,直接影響到業務,業務人員也不可能一直盯著頁面的,為了避免這種情況,就需要實時監控資源的響應狀態。
  在監控到圖像的異常后,就發現有兩三千個 404 請求,一部分是因為默認給 img 元素的 src 屬性賦空字符串導致的。
  另一部分是真的請求不到資源,那么當圖像請求不到時,為了能有更好的體驗,可以將裂圖替換成某一張默認圖。
  2022-12-15 在優化后,仍然有三千多個圖像錯誤的記錄,于是做了進一步的分析。
  發現這些圖像都是用戶的頭像,雖然在活動頁中無法訪問,但是在管理后臺卻能訪問。
  經過比對發現,是域名的差異,一個域名可以訪問,而另一個不能訪問,所以需要將域名替換掉,而路徑可以保持不變。
  由于之前頭像使用了統一的組件,所以只需在一處做替換即可。
  2023-06-19 對一個常規活動做了一次優化,但是上線后收到大量的 webp 圖像錯誤,并且清一色都來自于 iOS 系統。
  經過搜索了解到 iOS14 以上才支持 webp 格式,因此需要對此類系統做兼容處理。
:-: 
  2022-12-30 突然發現 shin.js 腳本內部報的錯誤,并不會上報到后臺,將線上文件通過 Charles 映射到本地,發現報的錯誤是 Script error。
  Script error 是一種跨域錯誤,而我的 shin.js 與當前網頁是不同域名,因此腳本中的錯誤就都變成了 Script error。
  因此需要為 shin.js 的請求加上跨域首部:Access-Control-Allow-Origin: \*,并且為 script 增加[crossorigin](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin)屬性。
~~~html
<script src="https://www.xxx.com/js/shin.js" crossorigin="anonymous"></script>
~~~
  還有一種辦法就是重寫 addEventListener,但只能捕獲事件回調程序中的錯誤。在調試的時候,出現了死循環,調查發現和 React 有關,[參考此文](https://ost.51cto.com/posts/33)。
~~~
const originAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
const wrappedListener = function (...args) {
try {
return listener.apply(this, args);
}
catch (err) {
throw err;
}
}
return originAddEventListener.call(this, type, wrappedListener, options);
}
~~~
**2)unhandledrejection事件**
  為 window 注冊[unhandledrejection](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/unhandledrejection_event)事件,捕獲未處理的 Promise 錯誤,當 Promise 被 reject 且沒有 reject 處理器時觸發。
~~~
window.addEventListener(
"unhandledrejection",
function (event) {
//處理響應數據,只抽取重要信息
var response = event.reason.response || response.status;
//若無響應,則不監控
if (!response) {
return;
}
var desc = response.request.ajax;
desc.status = event.reason.status;
handleError({
type: ERROR_PROMISE,
desc: desc
});
},
true
);
~~~
  Promise 常用于異步通信,例如[axios](https://github.com/axios/axios)庫,當響應異常通信時,就能借助該事件將其捕獲,得到的結果如下。
~~~
{
"type": "promise",
"desc": {
"response": {
"data": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic",
"status": 504,
"statusText": "Gateway Timeout",
"headers": {
"connection": "keep-alive",
"date": "Wed, 24 Mar 2021 07:53:25 GMT",
"transfer-encoding": "chunked",
"x-powered-by": "Express"
},
"config": {
"transformRequest": {},
"transformResponse": {},
"timeout": 0,
"xsrfCookieName": "XSRF-TOKEN",
"xsrfHeaderName": "X-XSRF-TOKEN",
"maxContentLength": -1,
"headers": {
"Accept": "application/json, text/plain, */*",
},
"method": "get",
"url": "/api/monitor/performance/statistic"
},
"request": {
"ajax": {
"type": "GET",
"url": "/api/monitor/performance/statistic",
"status": 504,
"endBytes": 0,
"interval": "13.15ms",
"network": {
"bandwidth": 0,
"type": "4G"
},
"response": "Error occured while trying to proxy to: localhost:8000/monitor/performance/statistic"
}
}
},
"status": 504
},
"stack": "Error: Gateway Timeout
at handleError (http://localhost:8000/umi.js:18813:15)"
}
~~~
  這樣就能分析出 500、502、504 等響應碼所占通信的比例,當高于日常數量時,就得引起注意,查看是否在哪塊邏輯出現了問題。
  500 是代碼報錯,502 是沒有找到對應的服務,504 是服務響應過慢導致超時,例如超過 60 秒沒有得到服務的響應就會報 504。
  有一點需要注意,上面的結構中包含響應信息,這是需要對[Error](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Error)做些額外擴展的,如下所示。
~~~
import fetch from 'axios';
function handleError(errorObj) {
const { response } = errorObj;
if (!response) {
const error = new Error('你的網絡有點問題');
error.response = errorObj;
error.status = 504;
throw error;
}
const error = new Error(response.statusText);
error.response = response;
error.status = response.status;
throw error;
}
export default function request(url, options) {
return fetch(url, options)
.catch(handleError)
.then((response) => {
return { data: response.data };
});
}
~~~
  2022-11-22 最近發現前端監控中的 504 數量,要比 Nginx 中的 504 日志數量多。
  經過排查發現是因為有些接口的 response 是空字符串,在 handleError() 函數中,就會給 error.status 賦值 504。
  經查,這些接口中都不會包含 req-id 的記錄,也就是說在服務端的日志中,沒有留下記錄,服務器沒有對其進行處理。
  這類接口占比很少(0.001% 左右),懷疑是 Nginx 因為某種原因沒有轉發到對應的服務中,需要運維配合調查。
  為了能區分是服務器返回的 504,還是因為空字符串定義的 504,對 handleError() 函數做改造。
  將 status 聲明成 512,之所以選這個狀態碼,是因為在它之前的狀態碼都有明確的含義。
~~~
function handleError(errorObj) {
// ...
if (!response) {
// ...
error.status = 512; // 自定義response為空時的錯誤狀態碼
throw error;
}
// ...
}
~~~
  2022-11-08 在?unhandledrejection?事件中,可以增加一些需要過濾的通信異常,例如登錄信息超時、埋點請求等,如下所示。
  如果做的通用點,還可以在此處預留一個可配置的鉤子函數。
~~~
if(desc.status == 401 || // 過濾管理后臺登錄信息超時的異常
desc.url.indexOf('reports/logs') >= 0 // 過濾埋點異常
) {
return;
}
~~~
  公司中有一套項目依賴的是 jQuery 庫,因此要監控此處的異常通信,需要做點改造。
  好在所有的通信都會請求一個通用函數,那么只要修改此函數的邏輯,就能覆蓋到項目中的所有頁面。
  搜索了API資料,以及研讀了 jQuery 中通信的源碼后,得出需要聲明一個 xhr() 函數,在函數中初始化 XMLHttpRequest 對象,從而才能監控它的實例。
  并且在 error 方法中需要手動觸發?unhandledrejection 事件。
~~~
$.ajax({
url,
method,
data,
success: (res) => {
success(res);
},
xhr: function () {
this.current = new XMLHttpRequest();
return this.current;
},
error: function (res) {
error(res);
Promise.reject({
status: res.status,
response: {
request: {
ajax: this.current.ajax
}
}
}).catch((error) => {
throw error;
});
}
});
~~~
  2023-09-25 在我當前公司中,Nginx 也可以監控 HTTP 的異常請求(例如 5XX 的請求),實踐下來比較精確,而網頁監控到的錯誤數量與 Nginx 監控的數量往往不同。
  有時候網頁多,有時候網頁少,很有可能受外部環境的影響,例如網絡波動、瀏覽器關閉等,導致兩邊統計的不同。
  其實還有一類響應異常,這類接口的請求是 200,但是由于業務邏輯異常,在返回響應時,會給一個異常的狀態碼,例如下面是一段正常的 JSON 響應。
~~~
{ code: 0, msg: "", data: {} }
~~~
  當 code 為非 0 時,就認為是一次異常的請求。如果公司對 JSON 響應的格式做了標準化,那就比較容易做區分。
  像我當前公司,沒有統一,存在多種格式,例如有些接口中 status 為 1 是正常的,或者 code 為 200 是正常的。
  所以不能簡單的來做甄別,為此,將響應狀態碼特地存儲在數據庫表的 message_code 字段中,方便自己分析。
**3)框架錯誤**
  框架是指目前流行的React、Vue等,我只對公司目前使用的這兩個框架做了監控。
  React 需要在項目中創建一個[ErrorBoundary](https://react.docschina.org/docs/error-boundaries.html)類,捕獲錯誤。
~~~
import React from 'react';
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
componentDidCatch(error, info) {
this.setState({ hasError: true });
// 將component中的報錯發送到后臺
shin && shin.reactError(error, info);
}
render() {
if (this.state.hasError) {
return null
// 也可以在出錯的component處展示出錯信息
// return <h1>出錯了!</h1>;
}
return this.props.children;
}
}
~~~
  其中 reactError() 方法在組裝錯誤信息。
~~~
/**
* 處理 React 錯誤(對外)
*/
shin.reactError = function (err, info) {
handleError({
type: ERROR_REACT,
desc: {
prompt: err.toString(), // 描述
url: location.href
},
stack: info.componentStack
});
};
~~~
  如果要對 Vue 進行錯誤捕獲,那么就得重寫[Vue.config.errorHandler()](https://cn.vuejs.org/v2/api/index.html#errorHandler),其參數就是 Vue 對象。
~~~
/**
* Vue.js 錯誤劫持(對外)
*/
shin.vueError = function (vue) {
var _vueConfigErrorHandler = vue.config.errorHandler;
vue.config.errorHandler = function (err, vm, info) {
handleError({
type: ERROR_VUE,
desc: {
prompt: err.toString(), // 描述
url: location.href
}
stack: err.stack //堆棧
});
// 控制臺打印錯誤
if (
typeof console !== "undefined" &&
typeof console.error !== "undefined"
) {
console.error(err);
}
// 執行原始的錯誤處理程序
if (typeof _vueConfigErrorHandler === "function") {
_vueConfigErrorHandler.call(err, vm, info);
}
};
};
~~~
  如果 Vue 是被模塊化引入的,那么就得在模塊的某個位置調用該方法,因為此時 Vue 不會綁定到 window 中,即不是全局變量。
  2022-12-16 為 React 和 Vue 在上報的 desc 中增加當前頁面的地址,在日志查詢時就能通過 URL 路徑來查了。
**4)難點**
  雖然把錯誤都搜集起來了,但是現代化的前端開發,都會做一次代碼合并壓縮混淆,也就是說,無法定位錯誤的真正位置。
  為了能轉換成源碼,就需要引入自動堆棧映射([SourceMap](https://github.com/mozilla/source-map)),[webpack](https://www.webpackjs.com/configuration/devtool/)默認就帶了此功能,只要聲明相應地關鍵字開啟即可。
  我選擇了 devtool: "hidden-source-map",生成完成的原始代碼,并在腳本中隱藏Source Map路徑。
~~~
//# sourceMappingURL=index.bundle.js.map
~~~
  在生成映射文件后,就需要讓運維配合,編寫一個腳本(在發完代碼后觸發),將這些文件按年月日小時分鐘的格式命名(例如 202103041826.js.map),并遷移到指定目錄中,用于后期的映射。
  之所以沒有到秒是因為沒必要,在執行發代碼的操作時,發布按鈕會被鎖定,其他人無法再發。
  映射的邏輯是用 Node.js 實現的,會在后文中詳細講解。注意,必須要有列號,才能完成代碼還原。
## 二、行為搜集
  將行為分成:用戶行為、瀏覽器行為、控制臺打印行為。監控這些主要是為了在排查錯誤時,能還原用戶當時的各個動作,從而能更好的找出問題出錯的原因。
**1)用戶行為**
  2022-12-29 觀察了下公司網頁的布局,里面充斥著 div 元素,沒有實現語義化的布局,并且經常拿 div 當按鈕使用。
  之前也沒約定樣式規則,例如按鈕樣式加 btn- 前綴,所以現在也無法區分哪些 div 是按鈕,哪些是容器。
  一般的話,a、button、li 等元素大部分都會綁定交互事件或默認行為,所以它們是有意義。
  而隨意的點下背景圖、列表等位置,在本處是不需要記錄的,因為這里不是用作埋點的功能,可理解為記錄關鍵的影響布局的行為。
  為了避免大量無意義的點擊上報,就需要過濾出綁定點擊事件的元素,想到一個方法是讀取 node.onclick 屬性。
  但是發現,并不都能返回值,因此這種判斷方式不準確。并且當點擊的元素并
  然后試圖找到一個方法,可以讀取元素綁定的事件名稱,網上有個非標準的方法 getEventListeners(),但是有兼容性問題。
  又想到一個方法,那就是記錄 HTML 的結構,比對兩次是否相同,不同時就記錄。
  但是實際操作時,發現很多情況下都會改變 HTML 結構,選中一個文本框、點擊表格的一列等,都不是有記錄意義的點擊。
  現在還沒想到比較好的辦法,就只能先通過特征慢慢完善需要記錄的判斷條件了,例如 CSS 類中包含 tabs 字符串的,可認為是一個菜單欄,點擊是有意義的。
  在下面的代碼中 window.onclick 支持 IE9+,若要支持 IE8 瀏覽器,可以改成 document.onclick。
~~~
/**
* 全局監聽事件
*/
function _eventHandle(eventType, detect) {
return function (e) {
if (!detect(e)) {
return;
}
handleAction(ACTION_EVENT, {
type: eventType,
desc: _removeQuote(e.target.outerHTML) // 去除雙引號
});
};
}
/**
* 監聽點擊事件
* window.onclick 支持 IE9+,若要支持 IE8 瀏覽器,可以改成 document.onclick
*/
window.addEventListener("click", _eventHandle("click", function (e) {
var node = e.target;
var nodeName = node.nodeName.toLowerCase();
// 若是 body 元素,則不記錄
if (nodeName === "body") {
return false;
}
// 白名單
if (
nodeName !== "a" &&
nodeName !== "button" &&
nodeName !== "li" &&
// 先判斷是否包含 indexOf 方法,再根據樣式特征判斷,例如菜單欄樣式
(node.className.indexOf && node.className.indexOf('tabs') === -1)) {
return false;
}
return true;
}),
false
);
~~~
  2024-03-01 handleAction() 方法用于將數據整理好后,發送到后臺。handleNumber() 會遞歸的將數字四舍五入小數點后兩位,性能參數計算后會有大量小數,在此處可以統一處理。
~~~
private handleAction(type: string, data: any): void {
this.http.send({ category: type, data: this.handleNumber(data) });
}
/**
* 遞歸的將數字四舍五入小數點后兩位
*/
private handleNumber(obj: any): any {
const type = typeof obj;
// 若 obj 是 null,則 typeof null 也是 object
if (type === 'object' && obj !== null) {
for (const key in obj) {
// 讀取屬性狀態
const des = Object.getOwnPropertyDescriptor(obj, key);
// 當key是只讀屬性時,就不能直接賦值了
if(des && des.writable) {
obj[key] = this.handleNumber(obj[key]);
}
}
}
if (type === 'number') {
return rounded(obj, 2);
}
return obj;
}
~~~
  在為 obj 對象賦值時,需要判斷屬性是否可寫,對于只讀屬性會報錯。
~~~
Cannot assign to read only property 'size' of object '#<Blob>'
~~~
**2)瀏覽器行為**
  監控異步通信,重寫 XMLHttpRequest 對象,并通過[Navigator.connection](https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/connection)讀取當前的網絡環境,例如4G、3G等。
  其實還想獲取當前用戶環境的網速,不過還沒有較準確的獲取方式,因此并沒有添加進來。
~~~
var _XMLHttpRequest = window.XMLHttpRequest; // 保存原生的XMLHttpRequest
// 覆蓋XMLHttpRequest
window.XMLHttpRequest = function (flags) {
var req = new _XMLHttpRequest(flags); // 調用原生的XMLHttpRequest
monitorXHR(req); // 埋入我們的“間諜”
return req;
};
var monitorXHR = function (req) {
req.ajax = {};
// var _change = req.onreadystatechange;
req.addEventListener(
"readystatechange",
function () {
if (this.readyState == 4) {
// 只上報文本和JSON格式的響應數據
if (
req.responseType &&
(req.responseType != "text" || req.responseType != "json")
) {
return;
}
var end = shin.now(); // 結束時間
req.ajax.status = req.status; // 狀態碼
if ((req.status >= 200 && req.status < 300) || req.status == 304) {
// 請求成功
req.ajax.endBytes = `${_kb(req.responseText.length * 2)}KB`; // KB
// console.log('響應數據:'+ req.ajax.endBytes); //響應數據大小
} else {
// 請求失敗
req.ajax.endBytes = 0;
}
// 為監控的響應頭添加 req-id 字段
var reqId = req.getResponseHeader("req-id");
if (reqId) {
req.ajax.header
? (req.ajax.header["req-id"] = reqId)
: (req.ajax.header = { "req-id": reqId });
}
// req.ajax.header
req.ajax.interval = `${_rounded(end - start, 2)}ms`; // 單位毫秒
req.ajax.network = shin.network();
// 只記錄6000個字符以內的響應限制,以便讓MySQL表中的message字段能成功存儲
req.responseText.length <= 6000 &&
(req.ajax.response = req.responseText);
// req.ajax.response = req.responseText;
if (
req.status < 500 && // 只傳送500以內的通信
req.ajax.url !== "/api/user" && // 不需要監控后臺身份通信
) {
handleAction(ACTION_AJAX, req.ajax);
}
// console.log('ajax響應時間:'+req.ajax.interval);
}
},
false
);
// “間諜”又對open方法埋入了間諜
var _open = req.open;
req.open = function (type, url, async) {
req.ajax.type = type; // 埋點
req.ajax.url = url; // 埋點
return _open.apply(req, arguments);
};
// 設置請求首部
var _setRequestHeader = req.setRequestHeader;
req.setRequestHeader = function (header, value) {
if (header === "Authorization") {
// 監控身份狀態
req.ajax.header = {
[header]: value
};
}
return _setRequestHeader.apply(req, arguments);
};
// 發送請求
var _send = req.send;
var start; // 請求開始時間
req.send = function (data) {
start = shin.now(); // 埋點
// var bytes = 0; //發送數據大小
if (data) {
req.ajax.startBytes = `${_kb(JSON.stringify(data).length * 2)}KB`;
req.ajax.data = data; // 傳遞的參數
}
return _send.apply(req, arguments);
};
};
~~~
  2022-05-10 在正式調用 handleAction() 方法發送監控信息到后臺之前,可以過濾一些不需要或不影響業務的接口。
  例如上述代碼中的 api/user,第三方的一些日志接口等,這些過濾條件可以暴露給外部作為鉤子函數。
  2022-07-29 在響應頭添加 req-id 字段,為了能與服務端中的日志關聯,詳見后面的第 4 小節。
  在所有的日志中,通信占的比例是最高的,大概在 90% 以上。
  2022-06-18 剛開始會將所有搜集到的內容上報到后臺服務,但是有些數據會比較大,超出 1M 后,后臺就會報 500 的錯誤,計算下來居然占到了總錯誤的 94%。
  于是就控制了ajax響應的大小,并且在上報前也會驗證內容尺寸,因為有的打印內容也會有幾兆,例如打印 base64 格式的圖片。
  瀏覽器的行為還包括跳轉,當前非常流行 SPA,所以在記錄跳轉地址時,只需監聽[onpopstate](https://developer.mozilla.org/zh-CN/docs/Web/API/WindowEventHandlers/onpopstate)事件即可,其中上一頁地址也會被記錄。
~~~
/**
* 在路由中注入自定義邏輯
*/
function injectRouter() {
var href = location.href;
handleAction(ACTION_REDIRECT, {
refer: shin.refer,
current: href
});
shin.refer = href;
}
/**
* 全局監聽跳轉
* 點擊后退、前進按鈕或者調用 history.back()、history.forward()、history.go() 方法才會觸發 popstate 事件
* 點擊 <a href=/xx/yy#anchor>hash</a> 按鈕也會觸發 popstate 事件
*/
var _onPopState = window.onpopstate;
window.onpopstate = function (args) {
injectRouter();
_onPopState && _onPopState.apply(this, args);
};
~~~
  2023-01-01 注意,popstate 事件只會在瀏覽器某些行為下觸發, 比如點擊后退、前進按鈕或者調用 history.back()、history.forward()、history.go() 方法。
  現在流行的路由庫:[react-router](https://reactrouter.com/en/main)和[vue-router](https://router.vuejs.org/zh/),底層都是通過 history.pushState() 和 history.replaceState() 來實現路由的變化。
  因此,如果要監聽這兩個方法,就得采用注入的方式,在事件中添加自定義邏輯。
~~~
/**
* 監聽 pushState() 和 replaceState() 兩個方法
*/
var bindEventListener = function (type) {
var historyEvent = history[type];
return function () {
// 觸發 history 的原始事件
var newEvent = historyEvent.apply(this, arguments);
injectRouter();
return newEvent;
};
};
history.pushState = bindEventListener("pushState");
history.replaceState = bindEventListener("replaceState");
~~~
**3)控制臺打印行為**
  其實就是重寫 console 中的方法,目前只對 log() 做了處理。在實際使用中發現了兩個問題。
  第一個是在項目調試階段,將數據打印在控制臺時,顯示的文件和行數都是 SDK 的名稱和位置,無法得知真正的位置,很是別扭。
  并且在 SDK 的某些位置調用 console.log() 會形成死循環。后面就加了個 isDebug 開關,在調試時就關閉監控,省心。
~~~
function injectConsole(isDebug) {
!isDebug &&
["log"].forEach(function (level) {
var _oldConsole = console[level];
console[level] = function () {
var params = [].slice.call(arguments); // 參數轉換成數組
_oldConsole.apply(this, params); // 執行原先的 console 方法
var seen = [];
handleAction(ACTION_PRINT, {
level: level,
// 避免循環引用
desc: JSON.stringify(params, function (key, value) {
if (typeof value === "object" && value !== null) {
if (seen.indexOf(value) >= 0) {
return;
}
seen.push(value);
}
return value;
})
});
};
});
}
~~~
  第二個就是某些要打印的變量包含循環引用,這樣在調用[JSON.stringify()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)時就會報錯。
  2022-11-22 發現有些打印其實是可以忽略的,例如公司最近引入了一個第三方的 SDK 庫。
  而在該庫中包含大量的打印代碼,這些都應該被忽略掉,忽略后的打印數量從最高的 77W 降低至 3W。
  過濾打印的那段邏輯可以封裝成一個鉤子函數,暴露給外部。
~~~
function injectConsole(isDebug) {
!isDebug && ["log"].forEach(function (level) {
console[level] = function () {
// ...
var desc = JSON.stringify(params, function (key, value) {
//...
});
// 過濾SDK的打印信息
if (desc && desc.indexOf("SDK") >= 0) {
return;
}
handleAction(ACTION_PRINT, {
type: level,
desc: desc
});
};
});
}
~~~
  2022-11-08 發現當短時間內(例如 1 秒內)有大量的打印(例如幾百次),并且將請求發送給后臺時,會讓電腦 CPU 暴漲,在項目上線后需要注意打印時機和數量。
  2023-06-30 增加對自定義異常 console.error 的監控,在之前的 ["log", "error"] 數組中增加一項。
  2023-07-03 發現如果打印 new Error(),那么字符串序列化的結果是一個空對象({}),看不到真實的錯誤信息。
  需要對 Error 實例做一次適配(如下所示),[getOwnPropertyNames()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames)靜態方法會返回一個由自有屬性組成的數組。
~~~
function errorToJSON(error) {
const errorObj = {};
Object.getOwnPropertyNames(error).forEach(key => {
errorObj[key] = error[key];
});
return errorObj;
}
~~~
  注意,這么做后有可能會丟失一些錯誤對象的特定行為和方法,但可以保證得到序列化后的錯誤信息。
**4)全鏈路日志查詢**
  2022-07-29 新增全鏈路日志查詢的 ID。在前端監控中,會記錄通信的請求、響應等信息。
  而這些接口基本都是 Node 服務提供的,它們也會有日志,包括 MySQL語句、埋點、內部接口調用等,為了能將通信日志和服務日志關聯,就需要一個標識符。
  我們所有的接口都會由一個統一的 Nginx 網關做轉發,在轉發時,Nginx 會自動生成一個用于識別通信的字符串標識:X-Request-Id。
  這個標識會作為通信上下文的一個 reqId 屬性存在,只要在響應時,給響應頭加上這個屬性就能實現關聯。如下所示,一段 Node.js 的代碼,使用的框架是 KOA。
~~~
ctx.set('Access-Control-Expose-Headers', 'req-id');
ctx.set('req-id', ctx.reqId);
~~~
  在 SDK 中,得到響應頭后,就能讀取 req-id,并記錄到監控日志表中,由此就能實現兩端日志的關聯。
~~~
var reqId = req.getResponseHeader('req-id');
if(reqId) {
req.ajax.header ? (req.ajax.header['req-id'] = reqId) : (req.ajax.header = { 'req-id':reqId });
}
~~~
  2023-11-09 若響應中不存在 req-id,那么在 Chrome 控制臺會報錯,如下所示。
~~~
Refused to get unsafe header "req-id"
~~~
  這不是 JavaScript 錯誤,所以代碼執行也不會停止,僅僅是一個提示。
  若要消除此錯誤,需要在調用 getResponseHeader() 方法之前先判斷指定的響應頭是否存在,如下所示。
~~~
if(req.getAllResponseHeaders().indexOf('req-id') >= 0)
reqId = req.getResponseHeader('req-id');
~~~
## 三、其他
**1)環境信息**
  通過解析請求中的 UA 信息,可以得到操作系統、瀏覽器名稱版本、CPU等信息。
~~~
{
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36",
"browser": {
"name": "Chrome",
"version": "89.0.4389.82",
"major": "89"
},
"engine": {
"name": "Blink",
"version": "89.0.4389.82"
},
"os": {
"name": "Mac OS",
"version": "10.14.6"
},
"device": {},
"cpu": {}
}
~~~
  圖省事,就用了一個開源庫,叫做[UAParser.js](https://github.com/faisalman/ua-parser-js),在 Node.js 中引用了此庫。
**2)上報**
  上報選擇了 Gif 的方式,即把參數拼接到一張 Gif 地址后,傳送到后臺。
~~~
/**
* 組裝監控變量
*/
function _paramify(obj) {
obj.token = shin.param.token;
obj.subdir = shin.param.subdir;
obj.identity = getIdentity();
return encodeURIComponent(JSON.stringify(obj));
}
/**
* 推送監控信息
*/
shin.send = function (data) {
var ts = new Date().getTime().toString();
var img = new Image(0, 0);
img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;
};
~~~
  用這種方式有幾個優勢:
* 兼容性高,所有的瀏覽器都支持。
* 不存在跨域問題。
* 不會攜帶當前域名中的 cookie。
* 不會阻塞頁面加載。
* 相比于其他類型的圖片格式(BMP、PNG等),能節約更多的網絡資源。
  不過這種方式也有一個問題,那就是采用 GET 的請求后,瀏覽器會限制 URL 的長度,也就是不能攜帶太多的數據,否則會報 431 錯誤。
  在之前記錄 Ajax 響應數據時就有一個判斷,只記錄300個字符以內的響應數據,其實就是為了規避此限制而加了這段代碼。
  不過在正式使用中發現,由于做了字符判斷,因此有時候會缺失查詢列表的信息,而這些信息都很關鍵,對排查起到決定性作用,因為后面就改成了普通的POST提交,這樣就不會有數據量的限制了。
  但存儲量一下子就暴增,從原先每個月100G增加到半個月250G,保存6個月的話就要存儲3T的數據,經濟成本上也增加了不少。
  讓數據組的同事將比較占內存的通信記錄列出,由于不太會用到,因此我單獨做了過濾。
**3)錯誤回放**
  2022-12-21 在搜集到報錯時,能夠回放錯誤發生之前的頁面動作。之前曾寫過一篇《[純JavaScript實現頁面行為的錄制](https://www.cnblogs.com/strick/p/12206766.html)》分析了回放原理。
  簡單的說,就是將 DOM 映射成指定的 JSON 結構,每次 DOM 發生變化時,記錄 DOM 信息(即記錄用戶行為),回放就是將 JSON 結構反解析成 DOM 元素。
  其中要監控 DOM 的變化,就需要使用[MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver),它會在 DOM 操作結束后才觸發,是一種異步接口,比起同步的[MutationEvent](https://developer.mozilla.org/en-US/docs/Web/API/MutationEvent),性能要更高。
  雖然知道了原理,但是要實現一個比較完善的回放功能,是一件比較復雜的事情。所以本次將會使用一個開源庫:[rrweb](https://github.com/rrweb-io/rrweb)。
  原先計劃是在發生錯誤時,存儲前面 2 分鐘內的行為記錄,但是發現容量非常大,可能要好多 M。為了節省空間,暫時先存儲 10~20 秒之間的行為,控制在 500KB 以內。
  有兩種方式引入該庫,一種是在頁面源碼中加入,另一種是通過 JavaScript 動態添加。如下所示,為了減少內存占用,在頁面中只保留 3 段行為記錄。
~~~
var recordEventsMatrix = [[]];
function recordPage(isRecord) {
if (!isRecord) {
return;
}
var script = document.createElement("script");
script.src = "//cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js";
// 開始監控頁面行為
script.onload = function () {
rrweb.record({
emit(event, isCheckout) {
// isCheckout 是一個標識,告訴你重新制作了快照
if (isCheckout) {
// 最多保留 3 段行為記錄
var deleteCount = recordEventsMatrix.length - 2;
deleteCount > 0 && recordEventsMatrix.splice(0, deleteCount);
recordEventsMatrix.push([]);
}
var lastEvents = recordEventsMatrix[recordEventsMatrix.length - 1];
lastEvents.push(event);
},
checkoutEveryNms: 10 * 1000 // 每 10 秒重新制作快照
});
};
document.head.append(script);
}
~~~
  為了防止數據庫容量增加過快,目前就只有當頁面出現白屏時,才上報到后臺中。
~~~
/**
* 讀取最近 20 秒的行為記錄
*/
function getRecentRecord() {
var len = recordEventsMatrix.length;
if (len === 0) return "";
var events;
if (len.length >= 2) {
events = recordEventsMatrix[len - 2].concat(recordEventsMatrix[len - 1]);
} else {
events = recordEventsMatrix[len - 1];
}
return JSON.stringify(events);
}
/**
* 推送監控信息
* 改成POST請求
*/
shin.send = function (data) {
// var ts = new Date().getTime().toString();
// var img = new Image(0, 0);
// img.src = shin.param.src + "?m=" + _paramify(data) + "&ts=" + ts;
var m = _paramify(data);
// 大于8000的長度,就不在上報,廢棄掉
if (m.length >= 8000) {
return;
}
var body = { m: m };
var record;
// 當前是一條錯誤日志,并且描述的是奔潰
if (data.category === ACTION_ERROR && data.data.type === ERROR_CRASH) {
// 讀取行為記錄
record = getRecentRecord();
// 只有當有內容時,才發送行為記錄
record.length > 0 && (body.r = record);
}
// 如果修改headers,就會多一次OPTIONS預檢請求
fetch(shin.param.src, {
method: "POST",
// headers: {
// 'Content-Type': 'application/json',
// },
body: JSON.stringify(body)
});
};
~~~
  在將此功能上線后,馬上就發現了之前一個困擾我的白屏問題(在《[監控頁面奔潰](https://www.cnblogs.com/strick/p/14986378.html)》一文中,有具體的監控原理)。就是 body 里明明有內容,但是卻上報為白屏。
  查看回放,的確是白屏,于是在控制臺中查看頁面元素,果然發現了端倪,如下圖所示。
:-: 
  html 元素的字體大小是 0,從而導致頁面中涉及到 rem 的計算都為 0,元素的尺寸也就是 0 了。
  經過排查,應該是[flexible.js](https://github.com/beipiaoyu2011/flexible/blob/master/public/js/frame/flexible.debug.js)中的計算問題,因為 getBoundingClientRect() 得到的寬度是 0,從而讓 fontSize 也成為了 0。
~~~
function refreshRem() {
var width = document.documentElement.getBoundingClientRect().width;
if (width / dpr > 540) {
width = 540 * dpr;
}
var rem = width / 10;
docEl.style.fontSize = rem + "px";
flexible.rem = win.rem = rem;
}
~~~
  至于為什么是 0,還沒有準確答案,可能是在讀取時,DOM 元素還不存在。
  解決辦法就是當 width 是 0 時,就給個默認值,例如可讀取視口寬度的 window.innerWidth。
  2022-12-26 雖然加了 window.innerWidth,但還是會出現白屏的情況。
  那是因為 flexible.js 被放在了 shin.js 的后面請求,這就有可能在調用 document.body.clientHeight 取到的值是 0,馬上替換兩者的位置。
**4)身份標識**
  每次進入頁面都會生成一個唯一的標識,存儲在[sessionStorage](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/sessionStorage)中。
  在查詢日志時,可通過該標識過濾出此用戶的上下文日志,消除與他不相干的日志,也就是展示他的行為軌跡。
~~~
function getIdentity() {
var key = "shin-monitor-identity";
//頁面級的緩存而非全站緩存
var identity = sessionStorage.getItem(key);
if (!identity) {
//生成標識
identity = Number(
Math.random().toString().substr(3, 3) + Date.now()
).toString(36);
sessionStorage.setItem(key, identity);
}
return identity;
}
~~~
  2022-11-21 新增可指定的身份信息標識,在實際使用監控的過程中發現,客服在收到用戶的問題反饋后,會提供給我們 userId 和遇到問題的時間范圍。
  目前我們只能根據時間范圍來排查記錄,然后通過上面的?identity 來縮小范圍,經過上下文的記錄分析后,才能大致知道這條記錄是否是問題用戶的。
  為了能更準確的知道相關記錄是否就是問題用戶的,又增加了一個身份參數,從外面傳進來,或者自動通過 JSBridge 等方式獲取。
~~~
var defaults = {
param: {
...
identity: "" //可自定義的身份信息字段
}
};
/**
* 在客戶端中埋入可識別的身份信息,例如userId
*/
function injectIdentity(identity) {
// 若不是APP或已經指定身份信息,則返回
if (!isApp() || identity) return;
// 在JSBridge成功調用后的回調函數
window.getMonitorUserSuccess = function (result) {
try {
var json = JSON.parse(result);
shin.param.identity = json.data.userId;
} catch (e) {
// console.error(e.message);
}
};
// 通過JSBridge讀取用戶信息
var iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = "xxx://xxx.yyy/getInfo?callback=getMonitorUserSuccess";
/**
* 需要加個定時器,因為調用document.body時,DOM還未存在
* Uncaught TypeError: Cannot read property 'appendChild' of null
*/
setTimeout(() => {
document.body && document.body.appendChild(iframe);
}, 500);
}
~~~
  在 getIdentity() 函數中,增加一句合并自定義身份字段的代碼。
  2022-12-19 將自定義身份放在默認身份之前,因為這樣便于使用 ES 的前綴查詢。
~~~
function getIdentity() {
//...
if (!identity) {
// 生成標識
identity = Number(
Math.random().toString().substring(3, 6) + Date.now()
).toString(36);
// 與自定義的身份字段合并,自定義字段在前,便于使用 ES 的前綴查詢
shin.param.identity && (identity = shin.param.identity + '-' + identity);
//...
}
return identity;
}
~~~
  上述針對的是客戶端中的頁面,而在管理后臺中,對身份的處理又略有不同,以我當前公司為例。
  由于所有的接口都會在后臺校驗身份,因此會自帶 JWT 加密過的身份信息(Authorization字段),那么只要將此字段也一并保存,需要時將其[解密](https://jwt.io/),就能知道操作人是誰了。
~~~
{
"type": "GET",
"url": "/api/xxx/yyy",
"header": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
"req-id": "52d768494e664885353"
},
"status": 200,
"endBytes": "13.88KB",
"interval": "382.2ms",
"network": {
"bandwidth": 0,
"type": "4G"
}
}
~~~
**5)Canvas 指紋**
  2023-07-10 增加瀏覽器指紋,用于計算 UV 數據,在上報時會附帶此信息。
  首先利用 Canvas 在不同終端中繪制會有細微差別的特點,對生成的圖像進行運算,得到一個指紋,如下所示。
~~~
/**
* ASCII字符串轉換成十六進制
*/
export function bin2hex(s: string): string {
let o = '';
s += '';
for (let i = 0, l = s.length; i < l; i++) {
const n = s.charCodeAt(i).toString(16);
o += n.length < 2 ? '0' + n : n;
}
return o;
}
/**
* Canvas 指紋
* 注意,同型號的手機,其 Canvas 指紋是相同的
*/
private getFingerprint(): string {
const key = "shin-monitor-fingerprint";
const fingerprint = localStorage.getItem(key);
if (fingerprint) return fingerprint;
// 繪制 Canvas
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const txt = "fingerprint";
ctx.textBaseline = "top";
ctx.font = "16px Arial";
ctx.fillStyle = "#F60";
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069";
ctx.fillText(txt, 2, 15);
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
ctx.fillText(txt, 4, 17);
var b64 = canvas.toDataURL().replace("data:image/png;base64,", "");
// window.atob 用于解碼使用 base64 編碼的字符串
const bin = window.atob(b64);
// 必須調用 slice() 否則無法轉換
const result = bin2hex(bin.slice(-16, -12));
// 緩存到本地
localStorage.setItem(key, result);
return result;
}
~~~
  為了提升性能,會將生成的字符串存儲在 localStorage 中,只進行一次指紋計算。
  經過測試發現,即使將客戶端殺掉,再次進入頁面,存儲的值仍然存在,除非進到應用管理,清空數據。
  注意,同型號的手機,其 Canvas 指紋是相同的,因此在服務端接收到參數后,需要再和 UA 和 IP 進行合并。
  最終 MD5 加密得到一個指紋,當然,這個指紋也會出現重復的概率。
**6)未來展望**
  目前,SDK 的所有邏輯都是寫在一個文件中的,未來體積極有可能膨脹,那么到時候會影響加載時間。
  可以將各個監控部分以插件的形式分離,例如 打印一個模塊、通信一個模塊,想要什么功能就單獨組合。
  還可以添加生命周期,在各個階段增加回調,引入特殊場景的特殊邏輯,保持高擴展性。
*****
> 原文出處:
[博客園-從零開始搞系列](https://www.cnblogs.com/strick/category/1928903.html)
已建立一個微信前端交流群,如要進群,請先加微信號freedom20180706或掃描下面的二維碼,請求中需注明“看云加群”,在通過請求后就會把你拉進來。還搜集整理了一套[面試資料](https://github.com/pwstrick/daily),歡迎閱讀。

推薦一款前端監控腳本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不僅能監控前端的錯誤、通信、打印等行為,還能計算各類性能參數,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、擴展運算符和剩余參數
- 3、解構
- 4、模板字面量
- 5、對象字面量的擴展
- 6、Symbol
- 7、代碼模塊化
- 8、數字
- 9、字符串
- 10、正則表達式
- 11、對象
- 12、數組
- 13、類型化數組
- 14、函數
- 15、箭頭函數和尾調用優化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、類
- 21、類的繼承
- 22、Promise
- 23、Promise的靜態方法和應用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基礎實踐
- 3、WebRTC視頻通話
- 4、Web音視頻基礎
- CSS進階
- 1、CSS基礎拾遺
- 2、偽類和偽元素
- 3、CSS屬性拾遺
- 4、浮動形狀
- 5、漸變
- 6、濾鏡
- 7、合成
- 8、裁剪和遮罩
- 9、網格布局
- 10、CSS方法論
- 11、管理后臺響應式改造
- React
- 1、函數式編程
- 2、JSX
- 3、組件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表單
- 8、樣式
- 9、組件通信
- 10、高階組件
- 11、Redux基礎
- 12、Redux中間件
- 13、React Router
- 14、測試框架
- 15、React Hooks
- 16、React源碼分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基礎
- 4、webpack進階
- 5、Git
- 6、Fiddler
- 7、自制腳手架
- 8、VSCode插件研發
- 9、WebView中的頁面調試方法
- Vue.js
- 1、數據綁定
- 2、指令
- 3、樣式和表單
- 4、組件
- 5、組件通信
- 6、內容分發
- 7、渲染函數和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、數據類型
- 2、接口
- 3、類
- 4、泛型
- 5、類型兼容性
- 6、高級類型
- 7、命名空間
- 8、裝飾器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系統和網絡
- 3、命令行工具
- 4、自建前端監控系統
- 5、定時任務的調試
- 6、自制短鏈系統
- 7、定時任務的進化史
- 8、通用接口
- 9、微前端實踐
- 10、接口日志查詢
- 11、E2E測試
- 12、BFF
- 13、MySQL歸檔
- 14、壓力測試
- 15、活動規則引擎
- 16、活動配置化
- 17、UmiJS版本升級
- 18、半吊子的可視化搭建系統
- 19、KOA源碼分析(上)
- 20、KOA源碼分析(下)
- 21、花10分鐘入門Node.js
- 22、Node環境升級日志
- 23、Worker threads
- 24、低代碼
- 25、Web自動化測試
- 26、接口攔截和頁面回放實驗
- 27、接口管理
- 28、Cypress自動化測試實踐
- 29、基于Electron的開播助手
- Node.js精進
- 1、模塊化
- 2、異步編程
- 3、流
- 4、事件觸發器
- 5、HTTP
- 6、文件
- 7、日志
- 8、錯誤處理
- 9、性能監控(上)
- 10、性能監控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 監控系統
- 1、SDK
- 2、存儲和分析
- 3、性能監控
- 4、內存泄漏
- 5、小程序
- 6、較長的白屏時間
- 7、頁面奔潰
- 8、shin-monitor源碼分析
- 前端性能精進
- 1、優化方法論之測量
- 2、優化方法論之分析
- 3、瀏覽器之圖像
- 4、瀏覽器之呈現
- 5、瀏覽器之JavaScript
- 6、網絡
- 7、構建
- 前端體驗優化
- 1、概述
- 2、基建
- 3、后端
- 4、數據
- 5、后臺
- Web優化
- 1、CSS優化
- 2、JavaScript優化
- 3、圖像和網絡
- 4、用戶體驗和工具
- 5、網站優化
- 6、優化閉環實踐
- 數據結構與算法
- 1、鏈表
- 2、棧、隊列、散列表和位運算
- 3、二叉樹
- 4、二分查找
- 5、回溯算法
- 6、貪心算法
- 7、分治算法
- 8、動態規劃
- 程序員之路
- 大學
- 2011年
- 2012年
- 2013年
- 2014年
- 項目反思
- 前端基礎學習分享
- 2015年
- 再一次項目反思
- 然并卵
- PC網站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端學習之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 2024年
- 日志
- 2020