-
Notifications
You must be signed in to change notification settings - Fork 0
/
1-4-1.html
1048 lines (1025 loc) · 46.7 KB
/
1-4-1.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="./public/favicon.ico" />
<meta http-equiv="cache-control" content="no-cache" />
<title></title>
<link rel="stylesheet" href=" https://necolas.github.io/normalize.css/8.0.1/normalize.css" />
<link rel="stylesheet" href="./hightlight/default.min.css" />
<link rel="stylesheet" href="./css/main.css" />
<link rel="stylesheet" href="./css/copybutton.css" />
<link rel="stylesheet" href="./css/hightlight.css" />
<script src="./hightlight/hightlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.11/clipboard.min.js"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-BEVZJDBC7Z"></script>
<script src="./js/gtag.js"></script>
</head>
<body>
<header>
<nav>
<h1>
<span id="toggle-menu"></span>
<a href="index.html"></a>
</h1>
</nav>
</header>
<main>
<aside>
<nav></nav>
</aside>
<article>
<h2 id="1-4-1">1-4-1 效能監控和錯誤收集與回報</h2>
<h3>效能監控指標</h3>
<p>
既然是效能監控,那麼首先就需要明確衡量指標。一般來說,業界認可的常用指撐有第一次繪製(FP)時間、第一次有內容繪製(FCP)時間、第一次有意義繪製(FMP)時間、初次載入畫面時間、使用者可互動(TTI)時間。接下來分別看一看每個指標的含義。
</p>
<ul>
<li>
<strong>第一次繪製時間(FP):</strong>
對於應用頁面,第一次出現視覺上不同於跳羅之前內容的時間點,或說是頁面發生第一次繪製的時間點。
</li>
<li>
<strong>第一次有內容繪製時間(FCP):</strong>
指瀏覽器完成繪製 DOM
中第一部分內容(可能是文字、影像或其他任何元素)的時間點,此時使用者應該在視覺上有直觀的感受。
</li>
<li>
<strong>第一次有意義繪製時間(FMP):</strong>
指頁面關鍵元素的繪製時間。這個概念並沒有標準化定義,因為關鍵元素可以由開發者自行定義—究竟什麼是「有意義」的內容,只有開發者或產品經理自己了解。
</li>
<li>
<strong>初次載入畫面時間(TTI):</strong>
對所有網頁應用,這是一個非常重要的指標。用白話來説,就是進入頁面之後,應用繪製完成整個手機螢幕(未捲動之前)內容的時間。需要注意的是,業界對這個指標其實沒有確切的定論,舉例來說,這個時間是否包含手機螢幕內圖片的繪製完成時間。
</li>
<li>
<strong>使用者可互動時間:</strong>
顧名思義,就是使用者可以與應用進行互動的時間。一般來講,我們認為是DOMReady
的時間,因為我們通常會在這時綁定事件操作。如果頁面中有關互動的指令稿沒有下載完成,那麼當然沒有到達所謂的使用者可互動時間。那麼,如何定義
DOMReady 時間呢?我推薦參考司徒正美的文章《何謂DOMReady》。
</li>
</ul>
<div>圖1</div>
<img src="./assets/image/1-4-1/1.jpg" alt="" />
<p>
圖1是造訪 Medium 行動網站分析獲得的時序圖,可根據網頁載入的不同時段體會個時間節點變化。
</p>
<div>圖2</div>
<img src="./assets/image/1-4-1/2.jpg" alt="" />
<p>Google Lighthouse 對網站的分析結果如 圖2 所示。</p>
<p>
請注意,First Meaningful Paint、First Contentful Paint 及 Time to
Interactive(可互動時間)都包含在結果中。
</p>
<p>
這裡先對這些時間節點及資料有一個初步的認識,後面將逐步學習如何統計這些時間,並做出如 圖2
所示的分析系統。接下來,繼續了解兩個概念。
</p>
<ul>
<li>
<strong>總下載時間:</strong>
頁面所有資源載入完成所需要的時間。一般可以統計 window.onload
時間,這樣可以統計出同步載入的資源全部載入完的耗時。如果頁面中存在較多非同步繪製,那麼可以將非同步繪製全部完成的時間作為總下載時間。
</li>
<li>
<strong>目訂指標:</strong>
由於應用特點不同,所以可以根據需求自訂時間。舉例來說,一個類似 Instagram
的頁面由圖片瀑布流組成,那麼可能非常關心螢幕中第一排圖片繪製完成的時間。
</li>
</ul>
<p>
這裡提一下,DOMContentLoaded 與 load
事件的區別。其實從這兩個事件的命名中就能體會到,DOMContentLoaded 指的是文件中 DOM
內容載入完畢的時間,也就是說 HTML
結構已經是完整的了。但是,很多頁面都包含圖片、特珠字型、視訊、音訊等其他資源,由於這些資源由網路請求取得,需要額外的網路請求,因此
DOM 內容載入完畢時,這些資源還沒有請求或繪製完成。當頁面上所有資源載入完成後,Load
事件才會被觸發。因此,在時間除上,Load 事件常常會落後於 DOMContentLoaded 事件,如 圖3
所示。
</p>
<div>圖3</div>
<img src="./assets/image/1-4-1/3.jpg" alt="" />
<h3>FMP 的智慧獲取演算法</h3>
<p>
這裡結合自訂指標和第一次有意義繪製(FMP)時間,稍微延伸一點內容:
由於第一次有意義繪製比較主觀,開發者可以自行指定究竟哪些屬於有意義
的繪製元素,因此可以透過FMP的智慧獲取演算法來自訂FMP時間・該演 算法的實現過程如下。
</p>
<p>首先,取得有意義的繪製元素。一般認為,具備以下幾個條件的元素更像是有意義的元素。</p>
<ul>
<li>體積百分比比較大。</li>
<li>螢幕內可見百分比大。</li>
<li>屬於資源載入元素(img、svg、video、object、embed、canvas)。</li>
<li>由具備以上特徵的多種元素共同組成的元素。</li>
</ul>
<p>根據元素對頁面視覺的貢獻對元素特點的權重進行劃分,實際權重值如下所示。</p>
<pre><code class="language-js">
const weightMap = {
SVG: 2,
IMG: 2,
CANVAS: 3,
OBJECT: 3,
EMBED: 3,
VIDEO: 3,
OTHER: 1
}
</code></pre>
<p>
接著,對整個頁面進行深度優先檢查搜尋,之後對每一個元素進行分數計算,實際透過
element.getBoundingClientRect 取得元素的位置和大小,然後透過計算所有元素的「width * height
* weight * 元素在 viewport
中的面積百分比」的乘積,確定元素的最後得分。將該元素的子元素得分之和與其得分進行比較,取較大值,記錄在候選集合中。這個集合是可視區域內得分最高的元素的集合,對這個集合的得分取平均值,然後過濾出在平均分之上的元素集合,進行時間計算。這就獲得了一個智慧的
FMP 時間。最後,程式由 qbright 實現。
</p>
<h3>效能資料取得</h3>
<p>了解了上述效能指標,下面來分析一下這些效能指標的資料究竟該如何計算並取得。</p>
<h4>window.performance:強大但有缺點</h4>
<p>
目前最為流行和可靠的方案是採用 window.performance API
計算效能指標資料,該API非常強大,不僅能計算出與頁面效能相關的資料,還能計算出與頁面資源載入和非同步請求相關的資料。
呼叫 window.performance timing 會傳回一個物件,這個物件包含各種頁面載入
和繪製的時間節點,如圖4所示。
</p>
<div>圖4</div>
<img src="./assets/image/1-4-1/4.jpg" alt="" />
<p>可以透過對上圖中的資訊進行計算獲得以下程式。</p>
<pre><code class="language-js">
const window.performance = {
memory: {
usedJSHeapSize,
totalJSHeapSize,
jsHeapSizeLimit
},
navigation: {
// 透過頁面重新導向跳躍到目前頁面的次數
redirectCount,
// 以哪種方式進入頁面
// 0 正常跳躍進入
// 1 透過 window.location.reload() 重新更新進入
// 2 透過瀏覽器歷史記錄及瀏覽器中的前進後退按鈕進入
// 255 透過其他方式進入
type,
},
timing: {
// 等於前一個頁面的 unLoad 時間,如果沒有前一個頁面,則等於 fetchStart 時間 navigationStart
// 前一個頁面的 unLoad 時間,如果沒有前一個頁面或前一個頁面與目前頁面在不同域中,則值為0
unloadEventStart,
// 前一個頁面中 unLoad 事件綁定的回呼函數執行完畢的時間
unloadEventEnd,
redirectStart,
redirectEnd,
// 檢查快取前,準備請求第一個資源的時間
fetchStart,
// 域名查詢開始的時間
domainLookupStart,
// 域名查詢結束的時間
domainLookupEnd,
// HTTP(TCP)開始建立連接的時間 connectStart,
// HTTP (TCP)建立連接結束的時間
connectEnd,
secureConnectionStart,
// 連接建立完成後,請求文件開始的時間 requestStart,
// 連接建立完成後,文件開始傳回並收到內容的時間
responseStart,
// 最後一個位元組傳回並收到內容的時間
responseEnd,
// Document.readyState 值為 loading 的時間
domLoading,
// Document.readyState值為interactive的時間
domInteractive,
// DOMContentLoaded事件開始的時間
domContentLoadedEventStart,
// DOMContentLoaded事件結束的時間
domContentLoadedEventEnd,
// Document.readyState值為 complete 的時間
domComplete,
// load事件開始的時間
loadEventStart,
// load事件結束的時間
loadEventEnd
}
}
</code></pre>
<p>根據這些時間節點選擇對應的時間兩兩做差,便可以計算出一些典型指標,實際如下。</p>
<pre><code class="language-js">
const calcTime = ()=> {
let times = {}
let t = window.performance.timing
// 重新導向時間
times.redirectTime = t.redirectEnd - t.redirectStart
// DNS 查詢耗時
times.dnsTime = t.domainLookupEnd - t.domainLookupStart
// TCP 建立連接完成驗證的時間
connect = t.connectEnd - t.connectStart
// TTFB 讀取頁面第一個位元組的時間
times. ttfbTime = t.responseStart - t.navigationStart
// DNS 快取時間
times.appcacheTime = t.domainLookupStart - t.fetchStart
// 移除頁面的時間
times.unloadTime = t.unloadEventEnd - t.unloadEventStart
// TCP 連接耗時
times.tcpTime = t.connectEnd - t.connectStart
// request 請求耗時
times.regTime = t.responseEnd - t.responseStart
// 解析 DOM 樹耗時
times.analysisTime = t.domComplete - t.domInteractive
// 空閒時間
times.blankTime = t.domLoading - t.fetchStart
// domReadyTime 即使用者可互動時間
times.domReadyTime = t.domContentLoadedEventEnd - t.fetchStart
// 使用者等待頁面完全可用的時間
times.loadPage = t.loadEventEnd - t.navigationStart
return times
}
</code></pre>
<p>
這個 API 的功能非常強大,但是並不適用於所有場景。舉例來說,如果在單頁應用中改變 URL
但不更新頁面(單頁應用的典型路由方案),那麼使用 window.performance.timing
所取得的資料是不會更新的還需要開發者重新設計統計方案。同時,window.performance.timing
可能無法滿足一些自訂的資料。下面來分析一下部分無法直接取得的效能指標的計算方法。
</p>
<h4>自訂時間計算</h4>
<p>
初次載入畫面時間的計算方式不盡相同,開發者可以根據自己的需求來確定初次載入畫面時間的計算方式。下面列舉幾個典型的方案。
</p>
<p>
對網頁高度小於螢幕的網站來說,統計初次載入畫面繪製耗時非常簡單,只要在頁面底部加上指令稿,記錄執行該指令稿的時間,並將這個時間與
window.performance.timing.navigationStart 時間做差,即獲得初次載入畫面繪製耗時。
</p>
<p>
但網頁高度小於螢幕的網站畢竟是少數,對網頁高度大於一頁的頁面來說,需要先估算出接近於一螢幕的最後一個元素的位置,然後在該位置插入下面的指令稿。
</p>
<pre><code class="language-js">
var time = +new Date() - window.performance. timing.navigationStart
</code></pre>
<p>上述方案顯然是比較理想化的,很難透過自動化工具或一段集中管理的程式對時間進行統計。</p>
<p>
開發者直接在頁面 DOM
中插入時間統計,不僅使程式侵入性太強,而且花費的成本很高。同時,這樣的計算方式其實並沒有考慮初次載入畫面圖片載入的情況,也就是說對於初次載入畫面圖片未載人完的情況,會被認為載入已經完成。如果要考慮初次載入畫面圖片的載入。建議使用透過集中化指令稿統計初次載入畫面時間的方法:使用計時器不斷檢測
img
節點,判斷圖片是否在初次載入畫面且載入完成,找到初次載入畫面載入最慢的圖片載入完成的時間,進一步計算出初次載入畫面時間;如果初次載入畫面沒有圖片,那就使用
DOMReady 時間。計算初次載入畫面繪製耗時的相關程式如下。
</p>
<pre><code class="language-js">
const win = window
const firstScreenHeight = win.screen.height
let firstScreenImgs = []
let isFindLastImg = false
let allImgLoaded = false
let collect = []
const t = setInterval(() => {
let i, img
if (isFindLastImg) {
if (firstScreenImgs.length){
for (i = 0; i < firstScreenImgs.length; i++) {
img = firstScreenImgs[i]
if (!ing.complete) {
allImgLoaded = false
break
} else {
allImgLoaded = true
}
}
}else {
allImgLoaded = true
}
if (allImgLoaded) {
collect.push({
firstScreenLoaded: startTime - Date.now()
})
clearInterval(t)
}
} else {
var imgs = body.querySelector('img')
for (i = 0;i < imgs.length; i++){
img = imgs[i]
let imgOffsetTop = getOffsetTop(img)
if (imgOffsetTop > firstScreenHeight) {
isFindLastImg = true
break
} else if (imgOffsetTop <= firstScreenHeight && ! img.hasPushed) {
img.hasPushed = 1
firstScreenImgs.push(img)
}
}
}
},0)
const doc = document
doc.addEventListener('DOMcontentLoaded',()=>{
const imgs = body.querySelector('img')
if (!imgs.length) {
isFindLastImg = true
}
})
win.addEventListener('load',()=>{
allImgLoaded = true
isFindLastImg = true
if (t) {
clearInterval(t)
}
})
</code></pre>
<p>
另外一種方式是不使用計時器,且預設影響初次載入畫面時間的主要因素是圖片的載入,如果沒有圖片,那麼純粹繪製文字是很快的,因此,可以透過統計初次載入畫面內圖片的載入時間取得初次載入畫面繪製完成的時間,程式如下。
</p>
<pre><code class="language-js">
(function logFirstScreen () {
let images = document.getElementsByTagName('img')
let iLen = images.length
let curMax = 0
let inScreenLen = 0
// 圖片的載入回呼
function imageBack () {
this.removeEventListener && this.removeEventListener('load',imageBack,!1)
if (++curMax === inScreenLen) {
// 所有在初次載入畫面的圖片均已載入完成的話,發送記錄檔
log()
}
}
//對所有位於指定區域的圖片綁定回呼事件
for (var s = 0; s < iLen;s++){
var img = images[s]
var offset = {
top: 0
}
var curImg = img
while(curImg.offsetParent) {
offset.top += curImg.offsetTop
curImg = curImg.offsetParent
}
// 判斷圖片在不在初次載入畫面
if (document. documentElement. clientHeight < offset.top) {
continue
}
// 圖片還沒有載入完成的話
if (!img.complete) {
inScreenLent+
img.addEventListener ('load', imageBack, !1)
}
}
//如果初次載入畫面沒有圖片,則直接發送記錄檔
if (inScreenLen ===0) {
log()
}
// 對發送記錄檔進行統計
function log () {
window.loginfo.firstScreen = +new Date()- window.performance.timing.navigationStart
console.log('初次載入畫面時間:',+new Date()- window.performance.timing.navigationStart)
}
})()
</code></pre>
<p>
可見,除了可以使用教科書般強大的 Performance
API,我們也完全擁有自主權來統計各種頁面效能資料。這需要開發者根據實際場景和業務需求,結合社區已有方案,找到完全適合自己的統計擷取方式。
</p>
<h3>錯誤訊息收集</h3>
<p>
錯誤訊息收集方式首先會想到兩種:透過ty catch 補捉錯誤,以及透過 window.onerror 進行監聽。
</p>
<h4>try catch 方案</h4>
<p>先來看一下 try catch 方案,程式如下。</p>
<pre><code class="language-js">
try{
//程式區塊
} catch (e){
//錯誤處理
//這裡可以將錯誤訊息發送給伺服器端
}finally{
// 額外處理
}
</code></pre>
<p>
這種方式需要開發者對預估存在錯誤風險的程式進行包裹,這個套件包裹過程可以手動增加,也可以透過自動化工具或類別庫完成。自動化方案是
AST 技術實現為基礎的,舉例來說,UglifyJS 透過提供操作 AST 的API,使得可以對每個函數增加
try catch,社區上的 foio 實現就是一個很好的實例。為函數自動包裹 try catch 的實現程式如下。
</p>
<pre><code class="language-js">
const fs = require('fs')
const = require('lodash')
const UglifyJS = require('uglify-js')
const isASTFunctionNode = node => node instanceof UglifyJS.AST_Defun || node instanceof UglifyJS.AST _Function
const globalFuncTryCatch = (source, errorHandler) => {
if (!_.isFunction(errorHandler)) {
throw 'errorHandler should be a valid function'
}
const errorHandlerSource = errorHandler.toString()
const errorHandlerAST = UglifyJS.parse('(' + errorHandlerSource + ')(error) ;')
var tryCatchAST = UglifyJS.parse('try{}catch(error){}')
const sourceAST = UglifyJS.parse(source)
var topFuncScope = []
tryCatchAST.body[0].catch.body[0] = errorHandlerAST
const walker = new UglifyJS.TreeWalker(function(node){
if(isASTFunctionNode(node)) {
topFuncScope.push(node)
}
})
sourceAST.walk(walker)
sourceAST.transform(transfer)
const transfer = new UglifyJS.TreeTransformer(null,
node => {
if (isASTFunctionNode(node) && _.includes(topFuncScope, node)) {
var stream = UglifyJS.OutputStream()
for (var i = 0; i < node. body. length; i++) {
node.body[i].print(stream)
}
var innerFuncCode = stream.toString()
tryCatchAST.body[0].body.splice(0, tryCatchAST.body[0].body.length)
var innerTyrCatchNode = UglifyJS.parse(innerFuncCode,{toplevel: tryCatchAST.body[0]})
node,body.splice(0, node.body.length)
return UglifyJS.parse(innerTyrCatchNode.print_to_string(),{toplevel: node});
}
})
const outputCode = sourceAST.print_to_string({beautify: true})
return outputCode
}
module.exports.globalFuncTryCatch = globalFuncTryCatch
</code></pre>
<p>
從 globalFuncTryCatch 函數的第一個參數中獲得目標程式 source,將其轉為 AST 的程式如下。
</p>
<pre><code class="language-js">
const sourceAST = UglifyJS.parse(source)
</code></pre>
<p>
globalFuncTryCatch
函數的第二個參數為開發者定義的出現錯誤時的回應函數,將錯誤回應函數字字元字串化並轉為 AST
插入 catch 區塊中,程式如下。
</p>
<pre><code class="language-js">
var tryCatchAST = UglifyJS.parse('try{}catch(error){}')
const errorHandlerSource = errorHandler.toString()
const errorHandlerAST = UglifyJS.parse('(' +errorHandlerSource+')(error);')
tryCatchAST.body[O].catch.body[0] = errorHandlerAST
</code></pre>
<p>
這樣,借助於 globalFuncTryCatch 便可以對每個函數增加 try catch 敘述,並根據
globalFuncTryCatch
的第二個參數傳入自訂的錯誤處理函數(可以在該函數中進行錯誤回報),程式如下。
</p>
<pre><code class="language-js">
globalFuncTryCatch(inputCode, function(error) {
// 此處是異常處理程式,可以回報並記錄記錄檔
})
</code></pre>
<p>這裡的關鍵在於要使用 UglifyJS 來對 AST 進行檢查,並在其中加入標記的內容,程式如下。</p>
<pre><code class="language-js">
const walker = new UglifyJS.TreeWalker(function (node) {
if (isASTFunctionNode(node)) {
topFuncScope.push(node)
}
})
sourceAST.walk(walker)
sourceAST.transform(transfer)
</code></pre>
<p>最後傳回經過處理後的程式,如下所示。</p>
<pre><code class="language-js">
const outputCode = sourceAST.print_to_string({beautify: true})
return outputCode
</code></pre>
<p>使用 try catch 可以確保頁面不當機,並對錯誤進行完整處理,這是一個非常好的習慣。</p>
<h4>try catch 方案的限制</h4>
<p>
try catch
處理異常的能力有限,對於處理執行時期同步錯誤是沒有問題的。但卻無法處理語法錯誤和非同步錯誤。下面來看一個處理執行時期同步錯誤的範例,程式如下。
</p>
<pre><code class="language-js">
try {
a //未定義變數
} catch(e){
console.log(e)
}
</code></pre>
<p>
上面程式中的錯誤可以被 try catch處理。但是,將上述程式改為語法錯誤後 (程式以下),try
catch 就無法捕捉錯誤了。
</p>
<pre><code class="language-js">
try {
var a =\'a'
} catch (e) {
console.log(e) ;
}
// X VM79:2 Uncaught SyntaxError: Invalid or unexpected token
</code></pre>
<p>再來看一下處理非同步錯誤的情況,程式如下。</p>
<pre><code class="language-js">
try {
setTimeout(()=>{ a })
} catch (e) {
console.log(e)
}
// X VM83:2 Uncaught ReferenceError: a is not defined at <anonymous>:2:20
</code></pre>
<p>歸納一下就是,try catch 能力有限,且對於程式的侵入性較強。</p>
<h3>認識 window.onerror</h3>
<p>
下面再看一下 window.onerror 對錯誤進行處理的方案。開發者只需要給 window 境加 onerror
事件監聽:同時注意將 window.onerror
放在所有指令搞之前,便能對語法異常和執行異常進行處理,程式如下。
</p>
<div class="controls">
<button id="script-error" type="button">Generate script error</button>
<img class="bad-img" />
</div>
<div class="event-log">
<label for="eventLog">Event log:</label>
<textarea readonly class="event-log-contents" rows="8" cols="30" id="eventLog"></textarea>
</div>
<pre><code class="language-js">
const log = document.querySelector(".event-log-contents");
window.addEventListener("error", (event) => {
log.textContent = `${log.textContent}${event.type}: ${event.message}\n`;
console.log(event);
});
const scriptError = document.querySelector("#script-error");
scriptError.addEventListener("click", () => {
const badCode = "const s;";
eval(badCode);
});
</code></pre>
<p>這裡的參數較為重要,包含稍後需要上傳的資訊,實際解釋如下。</p>
<ul>
<li>message 為錯誤訊息提示。</li>
<li>source 為錯誤指令稿位址。</li>
<li>lineno 為錯誤程式所在的行號。</li>
<li>colno 為錯誤程式所在的列號。</li>
<li>error 為錯誤的物件資訊,舉例來説,error.stack 會取得錯誤的堆疊資訊。</li>
</ul>
<p>
window.onerror 這種方式對程式侵入性較小,因比不必透過 AST 向程式中自動插入指令稿。onerror
除了對語法錯誤和網路錯誤(因為網路請求異常不會發生事件上浮)無能為力,對非同步和同步錯誤都能捕捉到執行時錯誤。
</p>
<p>
但是需要注意的是,如果想使用 window.onerror 函數消化錯誤,則需要顯示傳回
true,以確保錯誤不會向上拋出,主控台上也不會出現一堆錯誤訊息資訊。
</p>
<h3>跨域指令稿的錯誤處理</h3>
<p>
干萬不要以為掌握了以上內容就萬事大吉了,現實中的場景有很多種,有一些場景有載入不同域的
Javascript
指令稿的需求,這樣的場景較為常見。舉例來說,載入協力廠商內容以展示廣告,進行效能測試、錯誤統計,使用協力廠商服務,等等。
</p>
<p>
對不同域的 JavaScript 檔案。window.onerror
不能保證取得到有效資訊:出於安全原因,不同瀏覽器傳回的錯誤訊息參數可能並不一致。舉例來說。跨域之後:window.onerror
在很多瀏覽器中是無法捕捉異常資訊的,要統一傳回指令稿錯誤(script error),就需要對 script
指令稿進行以下設定。
</p>
<p>令稿錯誤(script error)就需要對 script 指令稿進行下設定</p>
<pre><code class="language-js">
crossorigin="anonymous"
</code></pre>
<p>要在伺服器端增加 Access-Control-Allow-Origin-header 內容以指定允許哪些域的請求存取。</p>
<h3>使用 source map 進行錯誤還原</h3>
<p>
目前為止,已經學習了取得錯訊息的方法但令稿是經過壓縮的,那麼也無用武之地,因為這樣捕捉到的錯誤訊息的位置(行列)會出現較大偏差,錯誤經過壓縮而難以辨認。這時就需要啟用
sourcemap 了。很多建置工具都支援 source map,舉例來說,對利用 webpack
包裝壓縮產生的一份對應指令稿的 map 檔案進行追蹤時,就在 webpack 中開 source map
功能,程式如下所示。
</p>
<pre><code class="language-js">
module.exports = {
// ...
devtool: '#source-map',
// ...
}
</code></pre>
<h3>針對 Promise 錯誤收集與處理</h3>
<p>
建議養成寫 Promise 的時候候最後寫上 catch 函數的習慣,不過 ESLint 外掛程式
eslint-plugin-promise 可以幫助完成這項工作,其會透過 catch-or-return 來確保程式中所有
Promise (被顯性傳回的除外)都有的 catch 處理,而以下寫法是無法透過程式檢查的。
</p>
<pre><code class="language-js">
var p = new Promise()
p.then(fn1)
p.then(fn1, fn2)
function fn1() {
p.then(doSomething)
}
</code></pre>
<p>這種 ESLint 掛程式是以 AST 實現為基礎的,其實現方式如下:</p>
<pre><code class="language-js">
module.exports = {
meta: {
docs: {
//...
},
messages: {
// ...
}
},
create(context) {
const options = context.options[0] || {}
const allowThen = options.allowThen
let terminationMethod = options.terminationMethod || 'catch'
if (typeof terminationMethod === 'string') {
terminationMethod = [terminationMethod]
}
return {
ExpressionStatement(node) {
if (!isPromise(node.expression)) {
return
}
if (
(allowThen && node,
expression.type === 'CallExpression' &&
node.expression.callee.type === 'MemberExpression' &&
node.expression.callee.property.name === 'then' &&
node.expression.arguments.length === 2)
) {
return
}
if (
(node.expression.type === 'CallExpression' && node,
expression.callee.type === 'MemberExpression' && terminationMethod,
indexOf(node.expression.callee.property.name) !== -1)
) {
return
}
if (
node.expression.type === 'CallExpression' &&
node.expression.callee.type === 'MemberExpression' &&
node.expression.callee.property.type === 'Literal' &&
node.expression.callee.property.value === 'catch'
) {
return
}
context.report({
node,
messageId: 'terminationMethod',
data: { terminationMethod }
})
}
}
}
}
</code></pre>
<p>
上述程式依然是以 AST 為基礎的,對業務程式中含有Promise 邏輯述進行了判别,實際判斷規則是看
Promise 實例 then 方法後是否連線了catch 方法,如果沒有連線 catch 方法,則顯示出錯。
</p>
<p>
Promise 實例的 then 方法中的第二個 onRejected 函數也能處理錯誤,這和上面提到的 catch
差異請看下列程式。
</p>
<pre><code class="language-js">
new Promise((resolve, reject) => {
throw new Error()
}).then(()=> {
console.log('resolved')
}, err => {
console.log('rejected')
throw err
}).catch(err => {
console.log(err, 'catch')
})
</code></pre>
<p>
上述程式執行後,將輸出 rejected,在有 onRejected 的情況下, onRejected 就會發揮作用,catch
不會被呼叫。而執行下面程式時,輸出 M705:10 Error at Promise.then (<anonymous>:4:9)
"catch"。此時, onRejected 並不能 捕捉 then 方法第一個參數onResolved 中的錯誤。
</p>
<pre><code class="language-js">
new Promise((resolve, reject) => {
resolve()
}).then(() => {
throw new Error()
console.log('resolved')
},(err) => {
console.log('rejected')
throw err
}).catch((err) => {
console.log(err, 'catch')
})
</code></pre>
<p>
透過比較可以看出 catch
也許是進行錯誤處時最好的選擇。但是,這兩種方式各有特點,需要進步認識才能正確使用。
</p>
<p>
除此之外,在對 Promise 進行錯誤處理時,還可以捕捉事件 unhandlerejection,
並在此事件中集中進行錯誤收集,程式如下。
</p>
<pre><code class="language-js">
window.addEventListener("unhandledrejection", e=> {
e.preventDefault()
console.log(e.reason)
return true
})
</code></pre>
<h3>處理網路載入錯誤</h3>
<p>
前面提到的處理方式都是在瀏覽器端的指令稿邏輯產生錯誤時進行,下面設想用 script 標籤、link
標籤進行令稿或其他資源載入程式,此時會由於某種原因(可能服器錯誤,也可能網路不穩定)導致指令搞請求失敗,網路載入錯誤。
</p>
<pre><code class="language-html">
<script src="***.js"></script>
<link ref="stylesheet" href="***.css">
</code></pre>
<p>為了捕捉這些載入異常,可以進行以下操作。</p>
<pre><code class="language-js">
<script src="***.js" onerror="errorHandler(this)"></script>
<link rel-"stylesheet" href-"***.css" onerror="errorHandler(this)">
</code></pre>
<p>
除此之外,也可以使用 window.addEventListener 方式對載入異常進行處理。注意,這時無法使用
window.onerror 進行處理,因為 window.onerror 事件是透過事件冒泡取得 error
資訊的,而網路載入錯誤是不會進行事件冒泡。
</p>
<p>
不支援冒泡的事件還有滑鼠(聚焦focus/失焦blur)
與滑鼠移動相關的事件(mouseleave/mouseenter),一些 UI事件(如 scroll、resize)
</p>
<p>進一步對網路資源的載入異常進行處理的,程式如下。</p>
<pre><code class="language-js">
window.addEventListener('error',error => {
console.log(error)
}, true)
</code></pre>
<p>
怎麼區分網路資源載入錯誤和其他一般錯誤呢? 這裡有個小技巧,普通錯誤的 error 物件中會有一個
error.message ,表示錯訊息,而載入錯誤對應的 error 物件沒有,因此可以根據以下程式進行區分。
</p>
<pre><code class="language-js">
window.addEventListener('error', error => {
if (!error.message) {
//網路資源載入錯誤
console.1og(error)
}
}, true)
</code></pre>
<p>
但是,正因為沒有 error.message
屬性,也就沒有額外資訊取得實際的錯誤細節,現階段也無法實區分載入的錯誤類別,舉例來說,因為資源不存在產生的
404 服務端錯誤等,只能配合後端記錄檔進行排除。
</p>
<p>
window.onerror 需要進行函數設定值,舉例來說,window.onerror = function(){ //...
},因此重複宣告後會被取代,後續設定值會覆蓋之前的值,這是一個弊端。 如下面範例:
</p>
<pre><code class="language-js">
window.onerror = function() {console.log(1)}
window.onerror = function() {console.log(2)}
window.dispatchEvent(new Event('error'))
// 2
// true
</code></pre>
<p>window.addEventListener('error') 可以綁定多個回呼函數,按照綁定順序依次執行,如下範例。</p>
<pre><code class="language-js">
window.addEventListener('error',()=>{console.log(1)})
window.addEventListener('error',()=>{console.log(2)})
window.dispatchEvent(new Event('error'))
// 1
// 2
// true
</code></pre>
<h3>頁面當機收集和處理</h3>
<p>
成熟的系統還需要收集當機和卡頓資訊,為此以監聽 window 物件的 load 和 beforeunload
事件,並結合sessionStorage 對網頁當機實施監控。
</p>
<pre><code class="language-js">
window.addEventListener('load', ()=>{
sessionStorage.setItem('good exit', 'pending')
})
window,addEventListener('beforeunload', ()=>{
sessionStorage.setItem('good exit', 'true')
})
if(sessionStorage.getItem('good exit') && sessionStorage.getItem('good exit'!== 'true')){
// 捕捉到頁面當機
}
</code></pre>
<p>
這段程式想法是首先在網頁 load 事件的回呼裡利用 sessionStorage 將 good_exit 值記錄為
pending ; 接下來,在頁面無異常退出前,即在 beforeunload 事件回呼中將 sessionStorage 記錄的
good_exit 值修改為 true。
</p>
<p>
因此,如果頁面沒有當機的話 good_exit 值會在離開前被設定為 true,否則程式就可以透過
sessionStorage.getItem('good_exit')&& sessionStorage.getItem('good_exit')!=='true'
判斷出頁面當機,並進行處理。
</p>
<p>
如果應用中部署了 PWA ,那麼便可享受 service worker帶來的福利! 這裡可以透過 service worker
來完成網頁當機的處理工作。基本原理就是,service worker
和網頁的主執行緒相互獨立,因此即使網頁發生了當機現象,也不會影響 service worker
所在執行緒的工作。在監控網頁的狀態時,是透過
navigator.serviceWorker.controller.postMessage API 來進行資訊的取得和記錄的。
</p>
<h3>架構的錯誤處理</h3>
<p>
對架構來說,React 在16之前的版本是使用 unstable_handleError 來處理捕捉錯誤的; 16
之後的版本使用了著名的 componentDidCatch 來處理錯誤。Vue 中提供了 Vue.config.errorHandle
來處理捕到捉到的錯誤,如果開發者沒有設定 Vue.config.errorHandler,那捕捉到的錯誤會以
console.error 的方式輸出。
</p>
<p>
架構會透過 console.error 的方式拋出,因此可綁架 console.error
來捕捉架構中的錯誤並做出處理。
</p>
<pre><code class="language-js">
const nativeConsoleError = window.console.error
window.console,error = (...args) => nativeConsoleError.apply(this, [`I got ${args}`])
</code></pre>
<p>執行被綁架後的 console.error 邏輯,輸出如下圖所示:</p>
<div>圖5</div>
<img src="./assets/image/1-4-1/5.jpg" alt="" />
<p>最後歸納一下處理的錯誤或異常,如下所示。</p>
<ul>
<li>JavaScript 語法錯誤、程式異常</li>
<li>AJAX 請求異常(xhr.addEventListener('error',function(e){//...}))</li>
<li>靜態資源載入異常</li>
<li>Promise 異常</li>
<li>跨域指令稿錯誤</li>
<li>頁面當機</li>
<li>架構錯誤</li>
</ul>
<p>
在真實生產境中,錯誤和異常有很多種,需要開發者格外留心,並注意覆蓋每一種情況。另外,除了效能和錯誤訊息,一些額外資訊,如頁面停留時間、長任務處理耗時等常常對分析網頁表現非常重要。對於錯誤訊息擷取和
處理的介紹就到此為止,接下來看一下資料的回報和系統設計。
</p>
<h2>效能資料和錯誤訊息回報</h2>
<p>
資料都有了,那麼該如何回報呢?可能有的開發者想:「不就是一個 AJAX 請求嗎?」
實際上還真沒有這麼簡單,有一些細節需要考慮。
</p>
<h4>1.回報採用單獨域名是否更好?</h4>
<p>成熟的網站資料回報的域名常常與業務域名並不相同。這樣做的好處主要有以下兩點。</p>
<ul>
<li>
使用單獨域名,可以防止主業務伺服器造成壓力,能夠避免記錄檔相關處理邏輯和資料在主業務伺服器上的堆積。
</li>
<li>
另外,很多瀏覽器對同一個域名的請求量有平行處理數的限制,單獨域名能夠充分利用現代瀏覽器的平行處理設定。
</li>
</ul>
<h4>2.獨立域名的跨域問題</h4>
<p>
對於單獨的記錄檔域名,一定有關跨域問題。經常發現頁面使用建置空的 Image
物件的方式(如下所示)進行資料回報。原因是請求圖片並不涉及跨域問題。
</p>
<pre><code class="language-js">
let url = 'xxx'
let img = new Image()
img.src = url
</code></pre>
<p>可以將資料進行序列化,作為 URL 參數進行傳遞,程式如下。</p>
<pre><code class="language-js">
let url = 'xxx?data=' + JSON.stringify(data)
let img = new Image()
img.src = url
</code></pre>
<h4>3.何時回報資料</h4>
<p>頁面載入效能資料可以在頁面穩定後進行回報。</p>
<p>
一次回報就是一次存取,對於其他錯誤和異常資料的回報,假設我們的應用記錄檔量很大,那麼就有必要將記錄檔合併,在同一時間統一回報。那麼,在什麼場景下回報效能資料呢?一般有以下4種場景。
</p>
<ul>
<li>頁面載入和重新更新</li>
<li>頁面切換路由</li>
<li>頁面所在的 Tab 標籤重新變得可見</li>
<li>頁面關閉</li>
</ul>
<p>但是,對越來越多的單頁應用來說,需要格外注意資料回報時機。</p>
<p>
如果切換路由是透過改變 hash 值實現的,那麼只需要監聽 hashchange 事件;如果是透過 history
API 改變 URL,那需使用 pushState 和 replaceState 事件。當然,一勞永逸的做法是進行系統更新
(monkey patch),並結合發佈/訂閱模式為相關事件的觸發增加處理。
</p>
<pre><code class="language-js">
const patchMethod = type =>
() => {
const result = history[type].apply(this, arguments)
const event = new Event(type)
event.arguments = arguments
window.dispatchEvent (event)
return result
}
history.pushState = patchMethod('pushState')
history.replaceState = patchMethod('replaceState')
</code></pre>
<p>
以上程式透過重新定義 history.pushState 和 history.replaceState 方法,增加並觸發 pushState
和 replaceState 事件,可以在history.pushState 和 history.replaceState
事件觸發時增加訂閱函數並進行回報。
</p>
<pre><code class="language-js">
window.addEventListener('replaceState', e => {
// report...
})
window.addEventListener('pushState', e =>{
// report...
})
</code></pre>
<p>對於非單頁面應用,該何時回報,以及如何回報呢?</p>
<p>
如果是在頁面離開時進行資料發送的,那麼在頁面移除期間是否能夠安全地發送完資料就是一個難題,因為在頁面跳轉進入下一個頁面時,
難以保證非同步資料的安全發送。如果使用同步 AJAX 方法,則程式如下所示。
</p>
<pre><code class="language-js">
window.addEventListener('unload', logData, false)
const logData = () => {
var client = new XMLHttpRequest() ,open("POST","/log",false) //第三個參數表明 XHR 是同步的
client.setRequestHeader("Content-Type", "text/plain;charset=UTF-8")
client.send(data)
}
</code></pre>
<p>上述程式可以完成資料的回報,但是會對頁面跳躍的流暢程度和使用者體驗造成影響。</p>
<p>推薦一個 sendBeacon 方法,其程式如下。</p>
<pre><code class="language-js">
window.addEventListener('unload', logData, false)
const logData = () => {
navigator.sendBeacon("/log", data)
}
</code></pre>
<p>
navigator.sendBeacon
天生是解決頁跳躍時的請求發送問題的。他的幾個特點決定了對應問題的解決方案。
</p>
<ul>
<li>
它的行為是非同步的,也就是說請求的發送不會阻塞跳躍到下一個頁面,因此可以確保跳躍的流暢度。
</li>
<li>它在沒有極端資料量和佇列總數的限制下,優先傳回 true 以確保請求成功發送。</li>
</ul>
<p>
目前,Google Analytics 使用 navigator.sendBeacon 回報資料,透過動態建立 img 標籤,以及在
img.src 中連接 URL 的方式發送請求,不存在跨域限制。如果 URL 太長,就採用 sendBeacon
的方式傳送請求;如果瀏覽器不支援 sendBeacon 方法,發送 AJAX post 同步請求。對URL
長度進行判斷的程式如下所示。
</p>
<pre><code class="language-js">
const reportData = url => {
// ...
if (urlLength < 2083) {
imgReport(url, times)
} else if (navigator.sendBeacon){
sendBeacon(url, times)
} else {
xmlLoadData(url, times)
}
}
</code></pre>
<p>
如果網頁存取量很大,那麼因為一個錯誤所發送的資訊就會非常多,我們可以給錯誤訊息的回報設定一個擷取率,如下所示。擷取率可以根據實際情況來設定,設定方法有很多種。
</p>
<pre><code class="language-js">
const reportData = url => {
// 只擷取30%
if (Math.random() < 0.3) {
send(data)
}
}
</code></pre>
<h2>無侵入和效能人性化的方案設計</h2>
<p>根據這些基礎知識,如何設計一個好的系統方案呢?</p>
<p>首先,這樣的系統大致可分為4個段,如下圖所示:</p>