-
Notifications
You must be signed in to change notification settings - Fork 0
/
1-4-3.html
755 lines (744 loc) · 34.4 KB
/
1-4-3.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
<!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-3">1-4-3 架構與效能: React</h2>
<h3>架構的效能到底指什麼</h3>
<p>
大部分應用的複雜度並不會對效能和產品體驗組成挑戰。現代化的架構憑藉高效的虛擬 DOM diff
演算法、響應式理念及架構內部引擎,已經做得較為完美了,一般專案需求不會對效能產生太大的壓力。
</p>
<p>
但是對於一些極其複雜的需求,效能最佳化問題是無法回避的。如果是圖形處理應用、DNA檢測實驗應用、豐富文字編輯或功能豐用的表單型應用,則很容易觸碰到效能瓶頸。同樣,作為架構的使用者,也需要對效能最佳化有所了解,這對了解架構本身也有很大的幫助。
</p>
<p>
前端開發自然離不開瀏覽器,而效能最佳化大都在和瀏覽器進行處理,頁面每一幀的變化都是由瀏覽器繪製出來的,並且這個繪製額示器的更新頻率受限於顯示器的更新頻率,因此一個重要的效能資料指標是每秒
60 幀的繪製頻率。這樣進行簡單的換算之後,每一幀只有 16.6ms 繪製時間。
</p>
<p>
一個應用對使用者的互動回應處理過慢,則需要花費很長的時間來計算更新資料,這就會造成應用緩慢、效能不佳的問題,使得使用者體驗極差。對架構來說,以
react 為例,開發者不需要額外關注 DOM 層面的操作,因為 React 透過維護虛擬 DOM 並使用其高效的
diff 演算法,就可以決策出每次更新的最小化 DOM 合併操作。實際上,使用 React
能做到的效能最佳化,使用純原生的 JavaScript 也能做到,甚至做得更好。只不過透過 React
進行統一處理後,可以大幅節省開發成本,同時降低應用效能對開發者最佳化技能的依賴。
</p>
<p>
因此,對於現代架構在效能方的最佳化,除了可以想辦法縮減本身的套件體積,主要在於架構本身執行時期對
DOM操作的合理性及本身引擎計算的高效性等方面的最佳化。
</p>
<h3>React 的虛擬 DOM diff</h3>
<p>React 主要透過以下幾種方式來確保虛擬 DOM diff 演算法更新都能夠高效。</p>
<ul>
<li>使用高效的 diff 演算法。</li>
<li>進行 batch 操作。</li>
<li>摒棄髒檢測更新方式。</li>
</ul>
<p>
一個元件使用 setState 方法時, React
都會認為該元件變「髒」了,進而觸發元件本身的重新繪製(re-render)時,因為 React
始終維護兩套虛擬 DOM,其中一套是更新後的虛擬 DOM,另一套是前一個狀態的虛擬
DOM,所以可以透過對這兩套虛擬 DOM 執行 diff
演算法,找到需要變化的最小單元集,然後把這個最小單元集應用在真實的 DOM 中。
</p>
<p>
透過 diff 演算法找到這個最小單元集後,React 採用啟發式的想法進行了一些假設,將兩棵 DOM
樹之間的差異尋找成本由 O(n³) 縮減到 O(n)。
</p>
<p>說到這裡,你一定很想知道 React 進行了哪些大膽假設,下兩點便是。</p>
<ul>
<li>對 DOM 節點跨層級移動的情況忽略不計。</li>
<li>
擁有相同類型的兩個元件產生相似的樹狀結構,擁有不同類型的兩個元件產生不同的樹狀結構。
</li>
</ul>
<p>根據這些假設, React 採取的策略如下。</p>
<ul>
<li>React 對元件樹進行分層比較,兩棵樹只會對同一層级的節進行比較。</li>
<li>
當對同一層級的節點進行比較時,對於不同的元件類型,直接將整個元件取代為更新後的元件。對於下圖所示的元件結構,如果子元件
B 和 H 類型同時發生化,那麼當查到B 元件時,直接將其取代為元件可以減少不必要的資源消耗。
</li>
</ul>
<ul>
<li>
當對同一層級的節進行比較時,對於相同的元件類型,如果元件的 state或props
發生變化,直接重新繪製元件本身。開發者可以嘗試使用 shouldComponentUpdate
生命週期函數來避開不必要的繪製。
</li>
<li>當對同一層級節點進行比較時,開發者可以使用 key 屬性來宣吿同一層級節點的更新方式。</li>
</ul>
<img src="./assets/image/1-4-3/1.jpg" alt="" />
<p>
另外,setState 方法會引發「蝴蝶效應」,並透過創新的diff
演算法找到需要更新的最小單元集,但是這些變更並不一定立即同步生效。實際上, React 會執行
setState
的合併操作,通俗地講就是「積攢歸併」一批變化後,再統一進行更新。顯然,這是出於對效能的考慮。
</p>
<h3>提升 React 應用效能的建議</h3>
<p>React 繪製真實的 DOM 節點的過程由兩個主要過程組成。</p>
<ul>
<li>對 React 內部維的虛擬 DOM 進行更新。</li>
<li>比較前後兩個虛擬 DOM,並將 diff 所得結果應用於真實的 DOM 中。</li>
</ul>
<p>這兩步極其關鍵。設想一下,如果虛擬 DOM ,那麼重新 繪製勢必會很耗時。</p>
<h4>大幅地減少重新繪製</h4>
<p>
為了提升 React
應用效能,首先想到的就是大幅地避開不必要的重新繪製。但是,當狀態發生變化時,重新繪製是 react
內部的預設行為,如何確保不必要的繪製呢?
</p>
<p>
最先想到的解決方案一定是使用 shouldComponentUpdate
生命週期函數,它旨在比較前後狀態(state/props)是否出現了變更,根據是否變更來決定元件是否需要重新繪製。
</p>
<p>實際上,開發者可以透過很多方式給 react 送「不需要繪製」的訊號。</p>
<p>
舉例來說,對於無狀態元件傳回同一個 element 實例的情况,如果每次執行 render 方法都傳回相同的
element 實例,React 會認為元件並沒有發生變化,程式如下所示。
</p>
<pre><code class="language-js">
class MyComponent extends Component {
text = '';
renderedElement = null;
_render() {
return <div>{this.props.text}</div>
}
render() {
if (!this.renderedElement || this.props.text !== this.text) {
this.text = this.props.text;
this.renderedElement = _render();
}
return this.renderedElement;
}
// ...
}
</code></pre>
<p>lodash 函式庫中的 memoize 該函數可以簡化上述程式,如下。</p>
<pre><code class="language-js">
import memoize from 'lodash/memoize'
class MyComponent extends Component{
_render = memoize((text) => <div>{text}</div>)
render() {
return _render(this.props.text)
}
}
</code></pre>
<p>不妨在之前介的高階元件的基礎上設想這樣一種高階元件:</p>
<p>
顆粒較細地控制元件的繪製行為。舉例來說,某個元件僅在某一項 props
變化時才會觸發重新繪製。這樣一來,開發者就可以完全掌控元件繪製時機,更有針對性地進行繪製最佳化。
</p>
<p>
在社群中,優秀的 recompose 函數庫剛好可以滿足需求。舉例來說,以下是利用了 recompose
函數庫的onlyUpdateForKeys 修飾器的程式。
</p>
<pre><code class="language-js">
@onlyUpdateForKeys(['prop1', 'prop2'])
class MyComponent2 extends Component {
render () {
// ...
}
}
</code></pre>
<p>
在使用 onlyUpdateForKeys 修飾器的情況下, MyComponent2 元件只在 prop2
發生變化時才進行繪製,其他 props 無論發生什麼變化,都不會觸發重新繪製。
</p>
<p>
onlyUpdateForKeys 背後的原理是在高階元件中呼叫 shouldComponentUpdate
方法,並在該方法中比較物件由完整的 props 轉為傳入的指定 props。可以翻閱 recompose
源碼進行原理了解。
</p>
<h4>避開 inline function 反模式</h4>
<p>
需要注意一個「反模式」。當使用 render 方法時,要留意 render
方法內建立的函數或陣列等,它們可能是顯性產生的,也可能是隱式產生的。這些新產生的函數或陣列在達到一定數量時會造成一定的效能負擔。同時
render
方法經常被反覆執行多次,也就是說總有新的函數或陣列被建立,這樣會造成記憶體無意義負擔。
</p>
<p>對效能比較人性化的做法通常是,只建立一次自訂函數,而非每次繪製時都建立一次,程式如下。</p>
<pre><code class="language-js">
render() {
return <MyInput onChange={this.props.update.bind(this)} />;
}
</code></pre>
<p>或可以使用以下操作。</p>
<pre><code class="language-js">
render() {
return <MyInput onChange={()=> this.props.update()} />;
}
</code></pre>
<p>
對於在 render 方法內產生新的陣列或其他類類型資料的情况,也存在相似的問題,例如下面的程式。
</p>
<pre><code class="language-js">
render() {
return <SubComponent items={this.props.items || [] }/>
}
</code></pre>
<p>這樣做會在每次繪製 this.props.items 不存在時建立一個空陣列。對此,更好的做法如下。</p>
<pre><code class="language-js">
const EMPTY_ARRAY = []
render() {
return <SubComponent items={this.props.items || EMPTY_ARRAY}/>
}
</code></pre>
<p>
這些副作用對效能的影響都微乎其微,它們並不是效能最佳化的元兇。但是了解這些內容對於撰寫高品質的程式還是有幫助,後續會針對這種情況進行架構層面上的啟發式探索。
</p>
<h4>使用 PureComponent 確保開發性能</h4>
<p>
PureComponent 大致與 Component 相同,唯一不同的地方是 PureComponent
會自動幫助開發者使用shouldComponentUpdate 生命週期方法,也就是說, 當元件 state 或 props
發生化時,正常的 Component 都會自動進行重新繪製,在這種情況下 shouldComponentUpdate 會預傳回
true。但是, Component 會先進行比較,即比較前後的 state 和 props
是否相等。需要注意這種比較是淺比較。如何了解所謂的淺比較呢?以下是一段典型淺比較程式。
</p>
<pre><code class="language-js">
function shallowEqual (objA: mixed, objB: mixed) {
if (is(objA, objB)) {
return true;
}
if(typeof objA !== 'object' l1 objA === null || typeof objB !== 'object' II objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if(keysA.length !== keysB.length) {
return false;
}
for (let i = 0; i < keysA.length; itt) {
if (!hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])){
return false;
}
}
return true;
}
</code></pre>
<p>基於以上程式,歸納出使用 PureComponent 時需要注意的細節,如下。</p>
<ul>
<li>
既然是淺比較,也就是說在比較 props 和 state 時,如果比較物件是 Javascript
基本類型,則會對其值是否相等進行判斷;如果比較物件是 JavaScript 參考類型,如 object 或
array ,則會判斷其參考是否相同,而不會對值進行比較。
</li>
<li>開發者需要避免共用(mutate)帶來的問題。</li>
</ul>
<p>
如果在一個父元件中對 object 進行了 mutate 操作,且子元件依賴此資料,並採用了 PureComponent
宣告,那子元件將無法進行更新。儘管 props 中的某一項值
發生了變化,但是由於它的參考並沒有發生變化,PureComponent 的 shouldComponentUpdate 會傳回
false。更好的做法是在更新 props 或 state 時,傳回一個新的物件或陣列。
</p>
<h4>分析一個真實案例</h4>
<p>
設想一下,如果應用元件非常複雜,含有一個具有很長列表的元件,且只是其中一個子元件發生了變化,那麼使用
PureComponent 進行比較,有選擇性地進行繪製,一定比對所有清單項目都重新繪製划算很多。
</p>
<p>
看一個案例:簡單實現一個採用 PureComponent 和不採用 PureComponent
的效能差別比較試驗。假如在頁面中需要繪製非常多的使用者資訊,所有的使用者資訊都被維護在一個
user 陣列中,陣列的每一項為一個 JavaScript 物件,表示一個使用者的基本資訊,那麼可以使用 User
元件繪製每一個使用者的資訊內容,範例如下。
</p>
<pre><code class="language-js">
import User from './User'
const Users = ({users}) =><div>{Users.map(user => <User {...user} />)}</div>
</code></pre>
<p>
這樣做存在的問題是 users 陣列作為 Users 元件的 props 如果 users 陣列的第K項發變化, users
陣列便產生變化, Users 元件重新繪製會導致所有的 User 元件都進行繪製。對於某個 User
元件,如果第K項並沒有發生變化,這個 User 元件就不需要重新繪製,但也不得不進行必要的繪製。
</p>
<p>在測試中,繪製了一個有 200 項資料的陣列,程式如下。</p>
<pre><code class="language-js">
const arraySize = 200;
const getUsers = () =>
Array(arraySize)
.fill(1)
.map((_, index) => ({
name: 'John Doe',
hobby: 'Painting',
age: index === 0 ? Math.random() * 100 : 50
}))
</code></pre>
<p>
注意,這裡在 getUsers 方法中對 age 屬性進行了判斷,確保每次呼叫時, getUser
傳回的陣列只有第一項的 age 屬性值不同,其餘的全部為50。在測試元件的componentDidUpdate
中確保陣列將觸發 400 次重新繪製,並且每次只改變陣列的第一項 age
屬性,其他的均保持不變,程式如下。
</p>
<pre><code class="language-js">
const repeats = 400;
componentDidUpdate (){
++this.renderCount;
this.dt += performance.now() - this.startTime;
if (this.renderCount repeats === 0) {
if (this.componentUnderTestIndex > -1) {
this.dts[componentsToTest [this.componentUnderTestIndex]] = this.dt;
console.log('dt',
componentsToTest [this.componentUnderTest Index] ,
this.dt);
}
++this.componentUnderTestIndex;
this.dt = 0;
this.componentUnderTest= componentsToTest[this.componentUnderTestIndex];
}
if(this.componentUnderTest) {
setTimeout(() => {
this.startTime = performance.now();
this.setState({ users: getUsers()})
}, 0);
} else {
alert(`
Bender Performance ArraySize: ${arraySize} Repeats: ${repeats}
Functional: ${Math.round(this.dts.Functional)} ms
pureComponent: ${Math.round(this.dts.PureComponent)} ms
Component: ${Math.round(this.dts.Component)} ms
`);
}
}
</code></pre>
<p>下面對3種元件宣告方式進行比較。</p>
<p>1.函數式方式,程式如下。</p>
<pre><code class="language-js">
export const Functional = ({ name, age, hobby }) => (
<div>
<span>{name}</span>
<span>{age}</span>
<span>{hobby}</span>
</div>
)
</code></pre>
<p>2.PureComponent 方式,程式如下。</p>
<pre><code class="language-js">
export class PureComponent extends React. PureComponent {
render() {
const { name, age, hobby } = this.props;
return (
<div>
<span>{name}</span>
<span>{age}</span>
<span>{hobby}</span>
</div>
)
}
}
</code></pre>
<p>3.經典 class 方式,程式如下。</p>
<pre><code class="language-js">
export class Component extends React.Component{
render() {
const { name, age, hobby } = this.props;
return (
<div>
<span>{name}</span>
<span>{age}</span>
<span>{hobby}</span>
</div>
)
}
}
</code></pre>
<p>
使用 PureComponent 宣的元件會自動在觸發繪製前後進行 {name , age, hobby}
物件值比較。如果物件值沒有發生變化,則 shouldComponentUpdate 回傳
false,以避開不必要的繪製。因此,使用 PureComponent
宣吿的元件效能明顯優於其他方式。在不同的瀏覽器環境下,結論如下。
</p>
<ul>
<li>在FirefoxPureComponent繪製效率高出 30%</li>
<li>在SafariT,PureComponent 繪製效率高出 6%</li>
<li>在Chrome,PureComponent 繪製效率高出15%</li>
</ul>
<p>
實際上是透過定義 changedItems 來表示變化陣列的專案,透過 array
表示所需繪製的陣列的。changedItems.length/array.length
的比值越小,表示陣列中變化的元素越少,React.PureComponent
有關的效能最佳化也就越有必要實施,因為React.PureComponent
透過淺比較避開了不必要的更新過程,而淺比較本身的計算成本一般都不值一提。
</p>
<p>
當然 PureComponent
也不是萬能的,尤其是使用它進行淺比較時,需要格外注意。因此在特定情況下,開發者根據需求自己實現
shouldComponentUpdate 中的比較邏輯將是更高效的選擇。
</p>
<h3>React 效能設計亮點</h3>
<p>
React 的效能設計亮點非常多,除了老生常談的虛擬
DOM,還有很多不為人知的細節,例如事件機制(合成和池化)、React Fiber 設計。
</p>
<h4>React 效能設計亮點之事件</h4>
<ul>
<li>將所有事件掛載到 document 節點上,利用事件代理實現最佳化。</li>
<li>採用合成事件,在原生事件的基礎上包裝合成事件,並結合池化想法實現記憶體保護。</li>
</ul>
<h4>React 效能設計亮點之 setState</h4>
<p>
其非同步
(或叫合併)設計也是出於效能的考慮。這種最佳化想法已經被很多框架所參考,Vue中也有類似的設計。
</p>
<h4>React 能設計亮點之 React Fiber</h4>
<p>
在瀏覽器主執行緒中,JavaScript 程式在呼叫堆 疊即時執行,可能會呼叫瀏覽器的 API 對 DOM
操作;也可能執行一些非同步任務,這些非同步任務如果是以回呼的方式處理的,常常會被增加到 event
queue 中;如果是以 Promise 的方式處理的,就會被先放到 job queue
中。這種操作有關巨任務和微任務,這些非同步任務和繪製任務將在下一個時序中由呼叫堆疊處理執行。
</p>
<p>
如果呼叫堆疊執行一個很耗時的指令稿,例如解析一個圖片,則呼叫堆疊就會像上下班高峰期的幹道入口一樣,被這個複雜任務堵塞。主執行緒中的其他任務都要排隊等待處理,進而阻塞UI回應。這時,使用者點擊、輸入、頁面動畫等都沒有了回應。
</p>
<p>
一般,有兩種方案可以用來突破上瓶頸,其中之一就是將耗時高、成本高、易阻塞的長任務進行切片,分成子任務,並非同步執行它們。
</p>
<p>
這樣一來,這些子任務就會在不同的執行堆疊的執行週期執行,進而使主執行緒可以在子任務間隙中執行
UI 更新操作。設想一個常見的場景:如果需要繪製一個由10萬資料組成的列表,就可以將資料分段,使用
setTimeout
進行分步處理,這樣建置繪製列表的工作就被分了不同的子任務並在瀏覽器中依次執行。在這些子任務執行的間隙,瀏覽器就可以處理UI更新。
</p>
<p>React 在 JavaScript 執行層面花費的時間較多,因為這一過程十分複雜:</p>
<ul>
<li>建置 Virtual DOM→計算 DOM diff→產生 render patch</li>
</ul>
<p>
也就是說,在某種程度上,React著名的排程策略 -- stack reconciler(堆疊協調),是 React
的效能瓶頸。因為該策略會深度優先檢查所有的 Virtual DOM 節點 ,計算完整棵 Virtual DOM 樹的
diff 後,才會使任務移出堆疊並釋放執行緒,所以瀏覽器主執行緒在被 React
更新任務佔據時,使用者與瀏覽器進行的任何互動都不會獲得回饋,只有等到任結束後,才能獲得瀏覽器的回應。
</p>
<h3>Vue3.0 動靜結合的 Dom diff</h3>
<p>
Vue 3.0 之所能夠做出動靜結合 DOM diff ,或把這個
問題放得更大,之所能夠做到預先編譯最佳化,是因為 Vue
可靜態解析範本,在解析範本時,整個解析過程是:利用正規表示法順序解析範本,當解析到開始標籤、閉合標籤和文字時就會分別執行對應的回呼函數,來達到建置
AST的目的。
</p>
<p>上述過程可以用下圖來概括。</p>
<p>將這個過程用程式進行表述,如下所示</p>
<img src="./assets/image/1-4-3/2.jpg" alt="" />
<pre><code class="language-js">
const ast = parse(template, options)
optimize(ast, options)
const code = generate(ast, option)
</code></pre>
<p>
借助預先編譯過程 Vue
可以大幅地做到預先編譯最佳化。舉例來說,在預先編譯時標記出範本中可能變化的元件節點,再次進行
diff
操作時就可以跳過「永遠不會變化的節點」,而只需要比較『可能變化的動態節點」。這也就是動靜結合的
DOM diff 將 diff 成本從與範本大小正相關最佳化到與動態節點正相關最佳化的理論依據。
</p>
<p>
也可標記出一些快速通道(fast path)。舉例說,對於某個複雜元件中的 className
發生變化的場景(這個場景很常見,例如透過根據變數更改 className
來應用不同的樣式),就可以在預先編譯階段進行特定的標記,在重新繪製 diff 時只需要更新新的
className 即可。
</p>
<p>從預先編譯最佳化的本質上來看,React是否可像Vue 那樣進行預先編譯最佳化呢?</p>
<p>
在進行預先編譯最佳化時,Vue需要做資料雙向綁定,進行資料攔截或代理,所以需要在預先編譯階段靜態分析範本,分析出視圖依賴了哪些資料進行響應式處理;
而 React 只需進行局部重新繪製,它所負責的就是一堆遞迴 React. createElement
的執行呼叫,無法從範本層面進行靜態分析。
</p>
<p>以下這樣的JSX</p>
<pre><code class="language-js">
<div>
<p>
<span> This is a test </span>
</p>
</div>
</code></pre>
<p>將被編譯為以下內容。</p>
<pre><code class="language-js">
React.createElement (
"div", null,
React.createElement (
"p", null,
React.createElement (
"span", null, "This is a test"
)
)
)
</code></pre>
<p>
因此,React JSX 過度的靈活性會導致執行時期可以用於最佳化的資訊不足。但是,在
React架構之外,作為開發者因為能夠接觸到 JSX 編譯成 React.createElement
的整個過程,所以還是可以透過專案化方法達到類似目的。開發者在專案中開發 Babel
外掛程式,以便將 JSX 編譯成 React.createElement, 那麼最佳化方法就是從撰寫 Babel
外掛程式開始的。
</p>
<p>下圖為 JSX 編譯過程</p>
<img src="./assets/image/1-4-3/3.jpg" alt="" />
<p>那麼,開發者到底應該怎麼做才能實現預先編譯最佳化呢?</p>
<p>
為此,以下列舉一些具有代表性的案例,這些案例都是開發者在開發 Babel
外掛程式的情況下實現的預先編譯最佳化。
</p>
<h4>靜態元素提升</h4>
<p>
將靜態不變的節點在預先編譯階段就抽象成函數或靜態變數,這和在 Vue
架構內所做的一樣,不過需要開發者自己實現,這樣一來就不需要在每次重新繪製時產生多餘的實例,只需要呼叫
_ref 變數即可,程式如下。
</p>
<pre><code class="language-js">
const ref = <span>Hello World</span>
class MyComponent extends React.Component{
render() {
return (
<div className={this.props.className}>
{ _ref}
</div>
)
}
}
</code></pre>
<h4>在執行時期程式中刪除 propTypes</h4>
<p>
propTypes 供了許多驗證工具,用來幫助確定 React 元件中 props 資料的有效性。但是,React 在
v15.5 後就移除了 propTypes, 現在使用 prop-types 函數庫來代替它。
</p>
<p>
propTypes 對於業務開發非常有用,幫助彌補了 JavaScript
資料類型檢查的不足。但是,線上程式中的propTypes 是多餘的。因此,在行時期程式中刪除
propTypes 就變得比較有必要了。
</p>
<h4>在執行時期程式中去除內聯函數</h4>
<p>
第三個最佳化場景是這樣的:當元件記憶體在函數宣告的邏輯(舉例來說,使用箭頭函數或使用 bind
宣告函數)
或閉包變數時,元件每更新一次,都產生一個新的函數或閉包變數。將這種不必要的函數稱為內聯函數。
</p>
<p>
舉例來說,在下面這段程式中, transformedData 和onClick
對應的匿名函數都會隨著元件繪製重新產生一個全新的參考。
</p>
<pre><code class="language-js">
export default ({ data, sortComparator, filterPredicate, history }) => {
const transformedData = data
.filter(filterPredicate)
.sort(sortComparator)
return (
<div>
<button
className="back-btn"
onClick={()=> history.pop()}
/>
<ul className="data-list">
{transformedData.map(({ id, value }) => (
<Item value={value}>
))}
</ul>
</div>
)
}
</code></pre>
<p>
反覆產生這些內聯函數或資料,對 React
執行時期效能或多或少會有一點影響,也給垃圾回收帶來了壓力。
</p>
<p>
專案中,可以透過外掛程式對內聯函數或變數進行記憶體持久化處理來減少以上影響。最後,經過預先編譯最佳化的程式如下。
</p>
<pre><code class="language-js">
let _anonymousFnComponent
export default ({ data, sortComparator, filterPredicate, history }) => {
const transformedData = React.useMemo(
()=> data.filter(filterPredicate).sort(sortComparator),
[data, data.filter, filterPredicate, sortComparator]
)
return React.createElement( _anonymousFnComponent = _anonymousFnComponent ||
(()=> {
const onClick2 = React.useCallback(
()=>history.pop(),
[history, history.pop]
)
return (
<div>
<button className="back-btn" onClick={_onClick2} />
<ul className="data-list">
{transformedData.map(({ id, value }) =>
React.createElement (
// ...
)
)}
</ul>
</div>
)
}), null)
}
</code></pre>
<p>
範例使用了 React 的新特性 useMemo 和 useCallback 將這些變數包裹。useMemo 和 useCallback
都在元件第一次繪製時執行,之後會在其依賴的變數,也就是 useMemo 和 useCallback
的第二個參數陣列內的數值發生變化時再次執行;這兩個 hook 都會傳回快取的值, useMemo
傳回快取的變數, useCallback 傳回快取的函數。
</p>
<p>
transformedData 在資料來源 data、data.filter、filterPredicate、sortComparator
發生變化時才會更新,並重新產生一份 transformedData, 函數繪製時,只要依賴的
data、data.filter、filterPredicate、sortComparator 不變,就不會重新產生
transformedData,而是使用快取的值。 onClick 也使用了 useCallback 將函數參考持久化儲存,和
useMemo的道理一樣。
</p>
<h4>使用無狀態元件</h4>
<p>
函數式元件未來比 class 元件別元件有更好的效能,並且 不管是從效能、可組合性還是 TS
契合度上,函數都優於 class。
</p>
<p>下面的實例中,會使符合條件的 class 宣告元件在預先編譯段自動轉化為函數式元件。</p>
<p>目標是將一個典型的 class 元件改寫為一個函數式元件,程式如下。</p>
<pre><code class="language-js">
class MyComponent extends React.Component (
static propTypes = {
className: React.PropTypes.string.isRequired
}
render() {
return (
<div className={this.props.className}>
<span>Hello World</span>
</div>
)
}
)
</code></pre>
<p>在預先編譯階段,以上程式會被最佳化為以下內容。</p>
<pre><code class="language-js">
const MyComponent = props =>
<div className={props.className}>
<span>Hello World</span>
</div>
MyComponent.propTypes = {
className: React.PropTypes.string.isRequired
}
</code></pre>
<p>這裡詳細實現一下 Babel 外掛程式,程式如下。其中會有一些 AST 的內容。</p>
<pre><code class="language-js">
module.exports = function({ types: t }) {
return {
visitor: {
Class (path) {
const state = {
renderMethod: null,
properties:[],
thisProps: [],
isPure: true
}
path.traverse(bodyVisitor, state)
let replacement = []
state.thisProps.forEach(function(thisProp){
thisProp.replaceWith(t.identifier('props'))
thisProp.replaceWith(t.identifier('props'))
})
replacement.push(
t.functionDeclaration (
id,
[t.identifier('props')],
state, renderMethod.node.body
)
)
state.properties,forEach (prop => {
replacement.push(t.expressionStatement (
t.assignmentExpression ('=' ,
t.MemberExpression(id, prop.node.key),
prop.node.value
)
))
})
if (t.isExpression(path.node)) {
replacement.push(t.returnStatement(id))
replacement = t.callExpression(
t.functionExpression(null, [],
t.blockStatement(replacement)
),
[]
)
}
path.replaceWithMultiple (
replacement
)
}
}
}
const bodyVisitor = {
ClassMethod(path) {
if (path.node.key.name === 'render') {
this.renderMethod = path
} else {
this.isPure = false
path.stop()
}
},
ClassProperty(path) {
const name = path.node.key.name
if (path.node.static &&
name === 'propTypes' ||
name === 'defaultProps'
) {
this.properties.push(path)
} else {
this.isPure = false
this.isPure = false
}
},
MemberExpression(path) {
this.thisProps.push(path)
},
JSXIdentifier(path) {
if (path.node.name === 'ref') {
this.isPure = false
path.stop()
}
}
}
}
</code></pre>
<p>對於以上程式,需要先明確,什麼樣的 class 元件具備轉換成函數式元件的條件?</p>
<p>
元件不能具有 this.state 參考,元件中不能出任何生命週期方法,也不能出現 createRef
,因為這些特性在函數式元件中不存在。
</p>
<p>
滿足這樣的條件後,再在 JSX 轉換過程中進行元件取代:首先透過 AST
進行檢查,在檢查過程中找到符合條件的class 元件,用 isPure
來標記是否符合條件,同時在檢查時將每一個符合條件的 class 元件的 render 方法儲存,作
為轉換函數式元件的傳回值;儲存 propType 和 defaultProps 靜態屬性,以便
之後掛載到函數元件的函數屬性上;同時將 this.props 用法轉為 props,props
會作為函數式元件的參數出現,最後再按照上述規則修改 AST,新的 AST 相關
元件節點就會產生函數式元件。
</p>
</article>
</main>
</body>
<script type="module" src="./js/main.js"></script>
</html>