你只會(huì)用前端數(shù)據(jù)埋點(diǎn) SDK 嗎?
前言
相信不少人因?yàn)轫?xiàng)目中沒有接觸過數(shù)據(jù)埋點(diǎn)相關(guān)的內(nèi)容,而沒有花時(shí)間去了解它,總覺得這又是一個(gè)自己還不能涉及的方面,然而數(shù)據(jù)埋點(diǎn)本身并不難理解,只是很難做得好,本文會(huì)從 認(rèn)識(shí)數(shù)據(jù)埋點(diǎn) SDK、設(shè)計(jì)前端數(shù)據(jù)埋點(diǎn) SDK 兩個(gè)核心方面來展開,聊聊前端數(shù)據(jù)埋點(diǎn)的那些事。
認(rèn)識(shí)數(shù)據(jù)埋點(diǎn) SDK
SDK 全稱是 Software Development Kit 即 軟件開發(fā)工具包,一般都是一些軟件工程師為特定的軟件包、軟件框架、硬件平臺(tái)、操作系統(tǒng)等建立應(yīng)用軟件時(shí)的開發(fā)工具的集合。
為什么需要前端數(shù)據(jù)埋點(diǎn)?
對(duì)產(chǎn)品本身而言,我們需要關(guān)注內(nèi)容包括如下幾個(gè)方面:
用戶在產(chǎn)品里 主要做什么操作、停留多久、訪問幾次
用戶點(diǎn)擊率占比如何,會(huì)不會(huì)出現(xiàn)某些功能設(shè)計(jì)對(duì)于用戶而言是無效的
用戶在核心使用流程上是否順暢,頁面反饋是否正常友好
可能有哪些潛在的用戶的功能需要更新
總的來說,數(shù)據(jù)埋點(diǎn) 核心是為了 收集數(shù)據(jù)(有了數(shù)據(jù)就可以為所欲為),只有通過分析數(shù)據(jù),才能更好的評(píng)估出整個(gè)項(xiàng)目的質(zhì)量和重要性(數(shù)據(jù)為王),并且能夠?yàn)楫a(chǎn)品優(yōu)化指明方向(數(shù)據(jù)驅(qū)動(dòng)產(chǎn)品)。
前端數(shù)據(jù)埋點(diǎn)要考慮哪些方面?
數(shù)據(jù)埋點(diǎn)的核心是數(shù)據(jù)收集,而與數(shù)據(jù)相關(guān)的內(nèi)容不外乎如下幾個(gè)內(nèi)容:
數(shù)據(jù)又是基于應(yīng)用產(chǎn)生的,因?yàn)闆]有應(yīng)用就不會(huì)有相關(guān)的數(shù)據(jù)
應(yīng)用本身要提供展示、收集、操作內(nèi)容,而這是基于平臺(tái)的,比如網(wǎng)站就是基于瀏覽器平臺(tái)
有應(yīng)用、有平臺(tái)就得有用戶,因?yàn)閼?yīng)用本身就是為了給用戶提供好用的功能去解決某些存在的問題
針對(duì)開發(fā)者而言,應(yīng)用就是代碼,代碼運(yùn)行的質(zhì)量也能決定應(yīng)用的質(zhì)量,而顯式質(zhì)量體現(xiàn)在錯(cuò)誤或警告上
總結(jié)下來,數(shù)據(jù)埋點(diǎn)其實(shí)要考慮的就是 用戶行為、錯(cuò)誤警告、頁面性能 三個(gè)核心方面。
用戶行為
用戶行為就是在網(wǎng)頁應(yīng)用中進(jìn)行的一系列操作,但用戶的操作有很多種,都需要記錄下來是不可能的,一般需要記錄用戶的以下幾種行為:
用戶瀏覽頁面次數(shù),PV(Page View)
用戶每次訪問網(wǎng)站中的一個(gè)頁面就被記錄為 1 個(gè) PV,多次訪問同一個(gè)頁面,訪問量就會(huì)累計(jì)
頁面瀏覽用戶數(shù),UV(Unique visitor)
通過網(wǎng)絡(luò)正常訪問頁面的使用者,通常一臺(tái)電腦客戶端或一個(gè)用戶賬號(hào)為一個(gè)訪客,一般同一個(gè)客戶端或用戶賬號(hào)在 24h 內(nèi)多次訪問只會(huì)被記錄為 1 個(gè) UV,計(jì)算策略視具體情況而定
用戶點(diǎn)擊按鈕次數(shù)
以上兩種可以認(rèn)為是 **自動(dòng)式觸發(fā)埋點(diǎn)**,而點(diǎn)擊按鈕次數(shù)就屬于是 **互動(dòng)式觸發(fā)埋點(diǎn)**,便于去了解這個(gè)功能按鈕的使用情況
錯(cuò)誤警告
頁面中代碼運(yùn)行產(chǎn)生的錯(cuò)誤,可能會(huì)導(dǎo)致用戶核心操作流程被中斷,為了避免大量用戶受到影響,我們需要獲取 生產(chǎn)環(huán)境的錯(cuò)誤數(shù)據(jù),這樣才能便于開發(fā)者及時(shí)進(jìn)行修復(fù)。
通常來講代碼中的錯(cuò)誤會(huì)包含以下幾大類:
全局錯(cuò)誤,即未被捕獲的錯(cuò)誤
局部錯(cuò)誤,即通過 try...catch、promise.then、promise.catch 等捕獲的錯(cuò)誤
接口請(qǐng)求錯(cuò)誤,即在二次封裝請(qǐng)求 API 中進(jìn)行請(qǐng)求和接收響應(yīng)時(shí)的錯(cuò)誤
組件級(jí)錯(cuò)誤,即使用 Vue/React 組件時(shí)發(fā)生的錯(cuò)誤
頁面性能
頁面性能其實(shí)也是前端性能優(yōu)化中一個(gè)需要考慮和優(yōu)化的點(diǎn),畢竟如果一個(gè)網(wǎng)站老是發(fā)生 白屏、交互卡頓、頁面資源加載時(shí)間長(zhǎng) 等問題,肯定是沒辦法留住用戶的,特別是用戶的真實(shí)環(huán)境各不相同,如 Windows x、MACOS、Android、iOS 等,更加需要統(tǒng)計(jì)和收集相關(guān)數(shù)據(jù),便于進(jìn)行集中優(yōu)化處理,提升用戶體驗(yàn)。
與頁面性能指標(biāo)相關(guān)的內(nèi)容,在之前的 前端性能優(yōu)化到底該怎么做(上)— 開門見山 一文中有提到,這里大致總結(jié)下:
首次繪制(First Paint,F(xiàn)P)
在渲染進(jìn)程確認(rèn)要渲染當(dāng)前響應(yīng)資源后,渲染進(jìn)程會(huì)先創(chuàng)建一個(gè)空白頁面,通常把創(chuàng)建空白頁面的這個(gè)時(shí)間點(diǎn)稱為 First Paint,簡(jiǎn)稱 FP
所謂的 白屏?xí)r間 其實(shí)指的就是創(chuàng)建這個(gè)空白頁面到瀏覽器開始渲染非空白內(nèi)容的時(shí)間,比如頁面背景發(fā)生變化等
首次內(nèi)容繪制(First Contentful Paint,F(xiàn)CP)
當(dāng)用戶看見一些 "內(nèi)容" 元素被繪制在頁面上的時(shí)間點(diǎn),和白屏是不一樣,它可以是 文本 首次繪制,或 SVG 首次出現(xiàn),或 Canvas 首次繪制等,即當(dāng)頁面中繪制了第一個(gè) 像素 時(shí),這個(gè)時(shí)間點(diǎn)稱為 First Content Paint,簡(jiǎn)稱 FCP
首屏?xí)r間 / 最大內(nèi)容繪制(Largest Contentful Paint, LCP)
LCP 是一種新的性能度量標(biāo)準(zhǔn),LCP 側(cè)重于用戶體驗(yàn)的性能度量標(biāo)準(zhǔn),與現(xiàn)有度量標(biāo)準(zhǔn)相比,更容易理解與推理,當(dāng)首屏內(nèi)容完全繪制完成時(shí),這個(gè)時(shí)間點(diǎn)稱為 Largest Content Paint,簡(jiǎn)稱 LCP
最大內(nèi)容繪制應(yīng)在 2.5s 內(nèi)完成
首次輸入延遲(First Input Delay, FID)
FID 測(cè)量的是當(dāng)用戶第一次在頁面上交互的時(shí)候(點(diǎn)擊鏈接、點(diǎn)擊按鈕 或 自定義基于 js 的事件),到瀏覽器實(shí)際開始處理這個(gè)事件的時(shí)間
首次輸入延遲應(yīng)在 100ms 內(nèi)完成
累積布局偏移(Cumulative Layout Shift, CLS)
CLS 是為了測(cè)量 視覺穩(wěn)定性,以便提供良好的用戶體驗(yàn)
累積布局偏移應(yīng)保持在 0.1 或更少
首字節(jié)達(dá)到時(shí)間(Time to First Byte,TTFB)
指的是瀏覽器開始收到服務(wù)器響應(yīng)數(shù)據(jù)的時(shí)間(后臺(tái)處理時(shí)間 + 重定向時(shí)間),是反映服務(wù)端響應(yīng)速度的重要指標(biāo)
TTFB 時(shí)間如果超過 500ms,用戶在打開網(wǎng)頁的時(shí)就會(huì)感覺到明顯的等待
理解了 為什么要做前端數(shù)據(jù)埋點(diǎn) 和 前端數(shù)據(jù)埋點(diǎn)所需要統(tǒng)計(jì)數(shù)據(jù)的方方面面,接下來我們就需要設(shè)計(jì)一個(gè)自己的 前端數(shù)據(jù)埋點(diǎn) SDK 了。
設(shè)計(jì)前端數(shù)據(jù)埋點(diǎn) SDK
這里只我們考慮數(shù)據(jù)埋點(diǎn)的核心內(nèi)容,因此不會(huì)涉及得肯定沒有那么全面,而一開始也不可能設(shè)計(jì)得全面,只要保證核心功能,那么在基于核心進(jìn)行擴(kuò)展即可。
確定 options 和 data 內(nèi)容
應(yīng)用的唯一標(biāo)識(shí) — options.AppId
數(shù)據(jù)埋點(diǎn) SDK 作為一個(gè)通用的工具集,是可供多個(gè)系統(tǒng)進(jìn)行使用的,而這就意味著需要去保證每個(gè)應(yīng)用的唯一性,一般來講,在初始化 SDK 的時(shí)候是需要接入方提供的當(dāng)前應(yīng)用的 ID。
那這個(gè) ID 從何而來?隨便生成嗎?一般來說需要經(jīng)過如下步驟:
在對(duì)應(yīng)監(jiān)控系統(tǒng)上為當(dāng)前應(yīng)用生成唯一的 AppId
在對(duì)應(yīng)應(yīng)用接入 SDK 時(shí)作為配置項(xiàng)之一傳入
其實(shí)還會(huì)涉及到請(qǐng)求 url 內(nèi)容,主要用于發(fā)送給對(duì)應(yīng)的監(jiān)控系統(tǒng),因此 options 核心內(nèi)容簡(jiǎn)單設(shè)計(jì)如下:
{
appId: '', // 當(dāng)前應(yīng)用唯一標(biāo)識(shí)
baseUrl: '', // 數(shù)據(jù)發(fā)送的地址
}
數(shù)據(jù)發(fā)送格式 — data
由于需要收集的數(shù)據(jù)類型包含多種,最好能夠定義一種比較通用的數(shù)據(jù)格式,便于更友好地進(jìn)行數(shù)據(jù)收集。
這里簡(jiǎn)單定義一下數(shù)據(jù)格式,大致如下,格式隨需求場(chǎng)景產(chǎn)生差異:
{
appId: '', // 當(dāng)前應(yīng)用唯一標(biāo)識(shí)
type: 'action' | 'performance'| 'network' | 'error', // 不同數(shù)據(jù)類型
pageUrl: '', // 頁面地址
apiUrl: '', // 接口地址
userId: '', // 當(dāng)前用戶 id
userName: '', // 當(dāng)前用戶 name
time: '',// 觸發(fā)記錄的時(shí)間
data: {}, // 接口響應(yīng)結(jié)果 | 性能指標(biāo) | 錯(cuò)誤對(duì)象 | 用戶操作相關(guān)信息
}
確定數(shù)據(jù)發(fā)送方式
如果要問前端埋點(diǎn)最基本要實(shí)現(xiàn)的功能是什么,那必然是 數(shù)據(jù)發(fā)送 的能力,否則即便有應(yīng)用、有用戶、有數(shù)據(jù)也只能保存在本地沒法發(fā)送給相應(yīng)的監(jiān)控系統(tǒng),意味就沒法進(jìn)行收集和統(tǒng)計(jì)(數(shù)據(jù)等于白給)。
那么數(shù)據(jù)發(fā)送都有什么方式呢?針對(duì)這個(gè)問題把 數(shù)據(jù)發(fā)送 翻譯成 請(qǐng)求發(fā)送 就容易多了,轉(zhuǎn)而問題就變成了 請(qǐng)求發(fā)送方式都有哪些?
一般會(huì)包括如下幾種(包括但不限于):
XMLHttpRequest
fetch
form 表單的 action
基于元素 src 屬性的請(qǐng)求
img 標(biāo)簽的 src
script 標(biāo)簽的 src
Navigator.sendBeacon()
這里選擇的是最后一種,因?yàn)?Navigator.sendBeacon() 就是專門用于通過 HTTP POST 將統(tǒng)計(jì)數(shù)據(jù) 異步 發(fā)送到 Web 服務(wù)器上,同時(shí)能避免傳統(tǒng)技術(shù)發(fā)送分析數(shù)據(jù)的一些問題。
傳統(tǒng)技術(shù)發(fā)送統(tǒng)計(jì)數(shù)據(jù)的一些問題,可以直接通過 *傳送門* 查看,由于文章篇幅有限不在額外解釋。
SDK 核心代碼
這里我們只考慮極簡(jiǎn)情況,設(shè)計(jì)好的 SDK 代碼內(nèi)容比較簡(jiǎn)單,直接上代碼:
let SDK = null // EasyAgentSDK 實(shí)例對(duì)象
const QUEUE = [] // 任務(wù)隊(duì)列
cosnt NOOP = (v) => v
// 通過 web-vitals 頁面性能指標(biāo)
const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry) // 布局偏移量
getFID(onPerfEntry) // 首次輸入延遲時(shí)間
getFCP(onPerfEntry) // 首次內(nèi)容渲染時(shí)間
getLCP(onPerfEntry) // 首次最大內(nèi)容渲染時(shí)間
getTTFB(onPerfEntry) // 首個(gè)字節(jié)到達(dá)時(shí)間
})
}
}
export default class EasyAgentSDK {
appId = ''
baseUrl = ''
timeOnPage = 0
config = {}
onPageShow = null
onPagesHide = null
constructor(options = {}) {
if (SDK) return
SDK = this
this.appId = options.appId
this.baseUrl = options.baseUrl || window.location.origin
this.onPageShow = options.onPageShow || NOOP
this.onPagesHide = options.onPagesHide || NOOP
// 初始化監(jiān)聽頁面變化
this.listenPage()
}
// 設(shè)置 config
setConfig(congfig){
this.config = congfig
}
// 刷新任務(wù)隊(duì)列
flushQueue() {
Promise.resolve().then(() => {
QUEUE.forEach((fn) => fn())
QUEUE.length = 0;
})
}
// 監(jiān)聽頁面變化
listenPage() {
let pageShowTime = 0
window.addEventListener('pageshow', () => {
pageShowTime = performance.now()
// 頁面性能指標(biāo)上報(bào)
reportWebVitals((data) => {
this.performanceReport({ data })
})
// 執(zhí)行 onPageShow
this.onPageShow();
})
window.addEventListener('pagehide', () => {
// 記錄用戶在頁面停留時(shí)間
this.timeOnPage = performance.now() - pageShowTime
// 刷新隊(duì)列前執(zhí)行 onPagesHide
this.onPagesHide();
// 刷新任務(wù)隊(duì)列
this.flushQueue()
})
}
// Json 轉(zhuǎn) FormData
json2FormData(data){
const formData = new FormData()
Object.keys(data).forEach(key => {
formData.append(key, data[key])
});
return formData
}
// 自定義上報(bào)類型
report(config) {
QUEUE.push(() => {
const formData = json2FormData({
...this.config,
...config,
time: new Date().toLocaleString(),
appId: this.appId,
pageUrl: window.location.href,
});
navigator.sendBeacon(`${this.baseUrl}${config.url || ''}`, formData)
})
}
// 用戶行為上報(bào)
actionReport(config) {
this.report({
...config,
type: 'action',
})
}
// 網(wǎng)絡(luò)狀況上報(bào)
networkReport(config) {
this.report({
...config,
type: 'network',
})
}
// 頁面性能指標(biāo)上報(bào)
performanceReport(config) {
this.report({
...config,
type: 'performance',
})
}
// 錯(cuò)誤警告上報(bào)
errorReport(config) {
this.report({
...config,
type: 'error',
})
}
}
上報(bào)用戶行為
統(tǒng)計(jì) PV 和 UV — 自動(dòng)觸發(fā)埋點(diǎn)
關(guān)于 PV 和 UV 在上述已經(jīng)做過介紹了,本質(zhì)上這兩個(gè)數(shù)據(jù)統(tǒng)計(jì)都可在一個(gè)上報(bào)類型為 action 數(shù)據(jù)發(fā)送中獲得,主要看監(jiān)控系統(tǒng)是按照怎樣的規(guī)則對(duì)數(shù)據(jù)進(jìn)行分析和統(tǒng)計(jì),這里在 SDK 內(nèi)部監(jiān)聽了頁面的 pageshow / pagehide 兩個(gè)事件:
在
pageshow
中可以上報(bào)與
PV / UV
相關(guān)的數(shù)據(jù) 和 頁面性能相關(guān)的數(shù)據(jù)
window.SDK = new EasyAgentSDK({
appId: 'application_id',
baseUrl: '//aegis.example.com/collect',
onPageShow() {
window.SDK.actionReport({
data: {} // 其他必要傳遞的信息
})
}
});
window.SDK.setConfig({
userId: UserInfo.userId, // 當(dāng)前用戶 id
userName: UserInfo.userName, // 當(dāng)前用戶 name
});
在 pagehide 中主要用于計(jì)算用戶停留在頁面上的時(shí)間 timeOnPage 和 刷新任務(wù)隊(duì)列
統(tǒng)計(jì)用戶點(diǎn)擊按鈕 — 交互式觸發(fā)埋點(diǎn)
假設(shè)我們希望記錄某些按鈕的使用次數(shù)的數(shù)據(jù),可以在 document 上監(jiān)聽 click 事件,目的利用事件冒泡以便于不需要侵入不同按鈕的 click 事件,比如:
const TargetElementFilter = ['export_btn']
const findTarget = (filters) => {
return filters.find((filter) => TargetElementFilter.find((v) => filter === v)));
}
document.addEventListener('click', (e) => {
const { id, className, outerHTML } = e.target
const isTarget = findTarget([id, className])
if (isTarget) {
SDK.actionReport({
data: {
id,
className,
outerHTML
}, // 其他必要傳遞的信息
})
}
})
上報(bào)頁面性能
和頁面性能相關(guān)的內(nèi)容,屬于 SDK 自動(dòng)觸發(fā)埋點(diǎn),不應(yīng)該讓使用者在手動(dòng)接入,在上面的實(shí)現(xiàn)中,我們?cè)?pageshow 事件中通 reportWebVitals 和 performanceReport 進(jìn)行數(shù)據(jù)上報(bào),并且這里選擇了 Google 推出的 web-vitals 來獲取和頁面性能指標(biāo)相關(guān)的具體數(shù)據(jù),對(duì)應(yīng)代碼為:
// 通過 web-vitals 頁面性能指標(biāo)
const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry) // 布局偏移量
getFID(onPerfEntry) // 首次輸入延遲時(shí)間
getFCP(onPerfEntry) // 首次內(nèi)容渲染時(shí)間
getLCP(onPerfEntry) // 首次最大內(nèi)容渲染時(shí)間
getTTFB(onPerfEntry) // 首個(gè)字節(jié)到達(dá)時(shí)間
})
}
}
獲取得到的數(shù)據(jù)大致如下:
上報(bào)錯(cuò)誤警告
全局錯(cuò)誤
全局錯(cuò)誤,即未被捕獲的錯(cuò)誤,可以通過 window.onerror 事件來捕獲,然后進(jìn)行錯(cuò)誤數(shù)據(jù)上報(bào),大致如下:
window.addEventListener('error', (reason) => {
const { filename, message, error } = reason;
window.SDK.errorReport({
data: {
filename,
message,
error
}
});
})
局部錯(cuò)誤
局部錯(cuò)誤,即通過 try...catch、promise.then、promise.catch 等捕獲的錯(cuò)誤,大致使用如下:
try {
throw new Error('error for test')
} catch(error) {
window.SDK.errorReport({
data: {
error,
},
})
}
Promise.reject(new Error('Promise reject for test'))
.then(
() => {},
(reason) => {
window.SDK.errorReport({
data: {
error: reason
}
});
},
)
Promise.reject(new Error('Promise reject for test'))
.catch(
(reason) => {
window.SDK.errorReport({
data: {
error: reason
}
});
},
)
接口請(qǐng)求錯(cuò)誤
接口請(qǐng)求錯(cuò)誤,即在二次封裝請(qǐng)求 API 中進(jìn)行請(qǐng)求和接收響應(yīng)時(shí)的錯(cuò)誤,為了方便這里以 axios 來舉例子,我們可以在它的 請(qǐng)求攔截 和 響應(yīng)攔截 的第二個(gè)回調(diào)參數(shù)中去上報(bào)對(duì)應(yīng)的錯(cuò)誤數(shù)據(jù)信息,大致如下:
// 創(chuàng)建axios實(shí)例
const service = axios.create({
baseURL, // api 的 base_url
timeout: 60000, // 請(qǐng)求超時(shí)時(shí)間
responseType: reqConf.responseType,
});
// 請(qǐng)求攔截
service.interceptors.request.use(
(config) => {
...
return config;
},
(error) => {
window.SDK.errorReport({
apiUrl: config.url,
data: {
error,
},
})
},
);
// 響應(yīng)攔截
service.interceptors.response.use(
(config: any) => {
...
return config;
},
(error: any) => {
window.SDK.errorReport({
apiUrl: config.url,
data: {
error,
},
})
return error.response.data;
},
);
組件級(jí)錯(cuò)誤
組件級(jí)錯(cuò)誤,即使用 Vue / React 框架組件時(shí)發(fā)生的錯(cuò)誤,完全可以使用它們?cè)诠俜轿臋n中提到的錯(cuò)誤捕獲方式來捕獲并上報(bào)錯(cuò)誤。
Vue
中的
errorHandler
就是用于為應(yīng)用內(nèi)拋出的未捕獲錯(cuò)誤指定一個(gè)全局處理函:
// App.vue
onMounted(()=>{
throw new Error('error in onMounted')
});
// main.ts
const app = createApp(App)
app.config.errorHandler = (error, instance, info) => {
window.SDK.errorReport({
data: {
instance,
info,
error
}
});
}
React
中的
ErrorBoundary
錯(cuò)誤邊界相關(guān)的
getDerivedStateFromError
和
componentDidCatch
鉤子
// 定義錯(cuò)誤邊界組件
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能夠顯示降級(jí)后的 UI
return { hasError: true };
}
componentDidCatch(error, info) {
// 可以將錯(cuò)誤日志上報(bào)給服務(wù)器
window.SDK.errorReport({
data: {
info,
error
}
});
}
render() {
if (this.state.hasError) {
// 自定義降級(jí)后的 UI 并渲染 、
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
// 使用錯(cuò)誤邊界組件
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
最后
現(xiàn)在我們了解了 前端數(shù)據(jù)埋點(diǎn) SDK 的二三事,通過上面的例子可能讓你覺得看起來比較簡(jiǎn)單,但是真的要做好數(shù)據(jù)埋點(diǎn)也必然沒有那么容易,比如好需要考慮你的 SDK 數(shù)據(jù)發(fā)送的時(shí)間、發(fā)送的次數(shù)、需不需要將某些數(shù)據(jù)信息整合在一起只發(fā)送一次、怎么避免網(wǎng)絡(luò)擁塞等等問題。
作者:熊的貓
原文:https://juejin.cn/post/7163046672874864676
作者:熊的貓
歡迎關(guān)注微信公眾號(hào) :深圳灣碼農(nóng)