-
Notifications
You must be signed in to change notification settings - Fork 0
/
1-6-5.html
616 lines (592 loc) · 24.4 KB
/
1-6-5.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
<!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-6-5">1-6-5 同構應用</h2>
<p>
同構應用能夠實現的基礎是虛擬 DOM,以虛擬
DOM為基礎,我們可以產生真實的DOM,並由瀏覽器繪製;也可以呼叫不同架構的不同API,將虛擬DOM
產生字串,由伺服器端傳輸給客戶端。
</p>
<h3>包裝環境區分</h3>
<p>
同構應用實現了客戶端程式和伺服器端程式的基本統一,只需要選寫一種元件,就能產生適用於伺服器端和客戶端的元件。但伺服器端程式和客戶端程式在大多數情況下還是需要單獨處理的。舉例來說,以下差別就決定了客戶端和伺服器端程式完全重複使用的難易程度。
</p>
<h4>1.路由程式差別</h4>
<p>伺服器端需要根據請求路徑比對頁面元件,客戶端需要透過瀏覽器中的位址比對頁面元件。</p>
<p>對於路由程式的差別化處理,可以參考如下所示的客戶端程式。</p>
<pre><code class="language-js">
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<div>
<Route path='/' component={Home}>
<Route path='/product' component={Product}>
</div>
</BrowserRouter>
</Provider>
)
}
ReactDom.render(<App/>, document.querySelector('#root'))
</code></pre>
<p>
BrowserRouter 元件根據 window.location 及 history API
實現頁面切換,而伺服器端一定是無法取得 window.location 的,伺服器端的程式如下。
</p>
<pre><code class="language-js">
const App = () => {
return
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<div>
<Route path='/' component={Home}>
</div>
</StaticRouter>
</Provider>
}
Return ReactDom.renderToString(<App/>)
</code></pre>
<p>
伺服器端需要使用 StaticRouter 元件,並將請求位址和上下文資訊作為 location 和 context 傳入
StaticRouter 。
</p>
<h4>2.程式打包的差別</h4>
<p>
伺服器端程式如果需要依賴 Node.js
核心模組或協力廠商模組,就不再需要把這些模組程式包裝到最後程式中了。因為環境已經安裝了這些依賴,伺服器端程式可以直接參考。這樣一來,就需要在
webpack 中設定 target:node,並借助 webpack-node-externals
外掛程式解決協力廠商依賴包裝的問題。
</p>
<p>
對於圖片等靜態資源,url-loader
會在伺服器端程式和客户端程式包裝過程中分別參考它們,因此在資原原始目錄中會產生重複的檔案。當然,後包裝出來的檔案會因為名稱重複而覆蓋前一次包裝出來的結果,但並不影響專案的執行,只是整個建置過程並不優雅。
</p>
<p>
由於路由程式在伺服器端和客戶端之間存在差別,因此 webpack 設定檔的 entry
也會不同,其程式如下。
</p>
<pre><code class="language-js">
{
entry: './src/client/index.js',
}
{
entry: './src/server/index.js',
}
</code></pre>
<h3>注水和脫水</h3>
<p>
什麼叫作注水和脱水呢?這和同構應用中的資料取得有關:在伺服器端繪製時,伺服器端會請求介面取得資料,並處理準備好的資料狀態(如果使用
Redux,就是進行 store
的更新),為了減少用戶端的請求,我們需要保留這個狀態。保留這個狀態的一般做法是在伺服器端傳回
HTML 字串的時候,將資料 JSON.stringify
一併傳回,這個過程叫作脱水(dehydrate);用戶端不需要請求取得資料,因為它可以直接使用伺服器端下發的資料,這個過程叫作注水(hydrate)。
</p>
<p>上述過程可以用程式來表示。伺服器端的程式如下。</p>
<pre><code class="language-js">
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<script>
window.context={
initialState:${JSON.stringify(store.getState())}
}
</script>
<div id="app">
// ...
</div>
</body>
</html>
`
</code></pre>
<p>同時,用戶端的程式如下。</p>
<pre><code class="language-js">
export const getClientStore = ()=>{
const defaultState = JSON.parse(window.context.state)
return createStore(reducer, defaultState, applyMiddleware(thunk))
}
</code></pre>
<p>
這一系列操作非常典型,有幾個細節非常值得探討:在伺服器端繪製時,伺服器端如何能夠請求所有的
API,確保資料已經全部被請求了呢?
</p>
<p>
這個問題一般可以用兩種方法來解決。第一種方法是設定路由 route-config,結合
matchRoutes,找到頁面上相關元件所需的請求介面的方法並執行請求。這種方法要求開發者透過路由設定資訊,顯性地告知伺服器端他們要請求的內容。
</p>
<p>首先來設定路由,程式如下。</p>
<pre><code class="language-js">
const routes = [
{
path: "/",
component: Root,
loadData: () => getSomeData()
},
// etc.
]
import { routes } from "/routes"
function App() {
return (
<Switch>
{routes.map(route =>(<Route {...route}/>))}
</Switch>
)
}
</code></pre>
<p>在伺服器端程式中,根據靜態設定來請求資料,程式如下。</p>
<pre><code class="language-js">
import { matchPath } from "react-router-dom"
const promises = []
routes.some(route =>{
const match = matchPath(reg.path, route)
if(match) promises.push(route.loadData(match))
return match
})
Promise.all(promises).then(data => {
putTheDataSomewhereTheClientCanFindIt(data)
})
</code></pre>
<p>
另外一種方法的設計想法與 Next.js 的設計想法類似,需要在 React
元件上定義靜態方法。舉例來說,定義靜態 loadData
方法,在伺服器端繪製時,我們可以檢查所有元件的 loadData
方法,取得需要請求的介面。這樣的方式參考了早期 react-apollo 的解決方案。以下為Facebook
團隊的開放原始碼專案 react-apollo 貢獻的程式,其功能就是檢查元件,取得請求介面。
</p>
<pre><code class="language-js">
function getPromisesFromTree({
rootElement,
rootContext = {},
}: PromiseTreeArgument): PromiseTreeResult[] {
const promises: PromiseTreeResult[] = [];
walkTree(rootElement, rootContext, (_, instance, context, childContext) => {
if (instance && hasFetchDataFunction(instance)) {
const promise = instance.fetchData();
if (isPromise<Object>(promise)) {
promises.push({ promise, context: childContext || context, instance });
return false;
}
}
});
return promises;
}
// Recurse a React Element tree, running visitor on each element.
// If visitor returns `false`, don't call the element's render function
// or recurse into its child elements.
export function walkTree(
element: React.ReactNode,
context: Context,
visitor: (
element: React.ReactNode,
instance: React.Component<any> | null,
context: Context,
childContext?: Context,
) => boolean | void,
) {
if (Array.isArray(element)) {
element.forEach(item => walkTree(item, context, visitor));
return;
}
if (!element) {
return;
}
// A stateless functional component or a class
if (isReactElement(element)) {
if (typeof element.type === 'function') {
const Comp = element.type;
const props = Object.assign({}, Comp.defaultProps, getProps(element));
let childContext = context;
let child;
// Are we are a react class?
if (isComponentClass(Comp)) {
const instance = new Comp(props, context);
// In case the user doesn't pass these to super in the constructor.
// Note: `Component.props` are now readonly in `@types/react`, so
// we're using `defineProperty` as a workaround (for now).
Object.defineProperty(instance, 'props', {
value: instance.props || props,
});
instance.context = instance.context || context;
// Set the instance state to null (not undefined) if not set, to match React behaviour
instance.state = instance.state || null;
// Override setState to just change the state, not queue up an update
// (we can't do the default React thing as we aren't mounted
// "properly", however we don't need to re-render as we only support
// setState in componentWillMount, which happens *before* render).
instance.setState = newState => {
if (typeof newState === 'function') {
// React's TS type definitions don't contain context as a third parameter for
// setState's updater function.
// Remove this cast to `any` when that is fixed.
newState = (newState as any)(instance.state, instance.props, instance.context);
}
instance.state = Object.assign({}, instance.state, newState);
};
if (Comp.getDerivedStateFromProps) {
const result = Comp.getDerivedStateFromProps(instance.props, instance.state);
if (result !== null) {
instance.state = Object.assign({}, instance.state, result);
}
} else if (instance.UNSAFE_componentWillMount) {
instance.UNSAFE_componentWillMount();
} else if (instance.componentWillMount) {
instance.componentWillMount();
}
if (providesChildContext(instance)) {
childContext = Object.assign({}, context, instance.getChildContext());
}
if (visitor(element, instance, context, childContext) === false) {
return;
}
child = instance.render();
} else {
// Just a stateless functional
if (visitor(element, null, context) === false) {
return;
}
child = Comp(props, context);
}
if (child) {
if (Array.isArray(child)) {
child.forEach(item => walkTree(item, childContext, visitor));
} else {
walkTree(child, childContext, visitor);
}
}
} else if ((element.type as any)._context || (element.type as any).Consumer) {
// A React context provider or consumer
if (visitor(element, null, context) === false) {
return;
}
let child;
if ((element.type as any)._context) {
// A provider - sets the context value before rendering children
((element.type as any)._context as any)._currentValue = element.props.value;
child = element.props.children;
} else {
// A consumer
child = element.props.children((element.type as any)._currentValue);
}
if (child) {
if (Array.isArray(child)) {
child.forEach(item => walkTree(item, context, visitor));
} else {
walkTree(child, context, visitor);
}
}
} else {
// A basic string or dom element, just get children
if (visitor(element, null, context) === false) {
return;
}
if (element.props && element.props.children) {
React.Children.forEach(element.props.children, (child: any) => {
if (child) {
walkTree(child, context, visitor);
}
});
}
}
} else if (typeof element === 'string' || typeof element === 'number') {
// Just visit these, they are leaves so we don't keep traversing.
visitor(element, null, context);
}
}
</code></pre>
<p>此外,注水和脱水也是同構應用中最為核心和關鍵的細節。</p>
<h3>請求認證處理</h3>
<p>
上面講到伺服器端預先請求資料,那麼請思考以下場景中的問題:某個請求成類cookie所表明的使用者資訊,如請求「我的學習計畫清單」。這種情況下伺服器端發生的請求是不同於用戶端的,伺服器端發出的請求不會自動帶有瀏覽器增加的cookie,也不含有瀏覽器附帶的
header(訊息表頭)資訊,因此在伺服器端發送這個請求後,一定不會獲得預期的結果。
</p>
<p>對於這個問題,下面來看一下以 react-apollo 為基礎的解決方案。</p>
<pre><code class="language-js">
import { ApolloProvider } from 'react-apollo'
import { ApolloClient } from 'apollo-client'
import { createHttpLink } from 'apollo-link-http'
import Express from 'express'
import { StaticRouter } from 'react-router'
import { InMemoryCache } from "apollo-cache-inmemory"
import Layout from './routes/Layout'
const app = new Express () ;
app.use((reg, res) => {
const client = new ApolloClient ({
ssrMode: true,
link: createHttpLink ({
uri: 'http://localhost: 3010',
credentials: 'same-origin',
headers: {
cookie: reg.header ('Cookie'),
},
}),
cache: new InMemoryCache()
});
const context = {}
const App =(
<ApolloProvider client={client}>
<StaticRouter location={reg.url} context={context}>
<Layout />
</ StaticRouter>
</ApolloProvider>
);
})
</code></pre>
<p>
該解決方案非常簡單,其原理是這樣的:伺服器端發出請求時需要保留用戶端頁面請求的資訊,並在
API 請求取得資訊時攜帶並透傳這個資訊。在上述程式中,呼叫 createHttpLink
方法時,以下設定項目是關鍵。它使伺服器端發出的請求完整地還原了用戶端資訊,因此驗證類別介面也不會再出現問題。
</p>
<pre><code class="language-js">
headers: {
cookie:req.header('Cookie'),
}
</code></pre>
<p>
事實上,在早期 React 中,很多伺服器端繪製的輪子都參考了 react-apollo
的優秀思想,所以可以抽空去了解一下 react-apollo。
</p>
<h3>樣式問題處理</h3>
<p>
同構應用的樣式處理容易被開發者所忽視,而一旦忽視,就會掉到坑裡。舉例來說,正常的伺服器端繪製只是傳回了
HTML 字串,樣式需要瀏覽器載入完 CSS
後才會加上,而這個樣式增加的過程就會造成頁面的閃動。再舉例來說,我們不能再使用
style-loader 了,因為這個 webpack loader 會在編譯時將樣式模組載入 HTML header
中。但是在伺服器端繪製環境下,沒有 window 物件,style-loader 就會顯示出錯。我們一般會將
style-loader 換為 isomorphic-style-loader 來實現樣式處理,實際如下。
</p>
<pre><code class="language-js">
{
test: /\.css$/,
use: [
'isomorphic-style-loader',
'css-loader',
'postcss-loader'
],
}
</code></pre>
<p>
此外,使用 'isomorphic-style-loader'
還可以解決頁面樣式閃動的問題。它的實現原理也不難了解;在伺服器端輸出 HTML
字串的同時,可以將樣式插入 HTML 字串中,將結果一同傳送到用戶端。
</p>
<p>
isomorphic-style-loader 的原理:對 webpack 來說,所有的資源都是模組,webpack loader
在編譯過程中可以將匯入的 CSS 檔案轉換成物件,拿到樣式資訊,因此,isomorphic-style-loader
可以取得頁面中的所有元件樣式。為了使程式實現更加通用化,isomorphic-style-loader 可以利用
context API 在繪製頁面元件時取得所有 React 元件的樣式資訊,最後插入 HTML 字串中。
</p>
<p>在伺服器端繪製時,需要加入以下邏輯。</p>
<pre><code class="language-js">
import express from 'express'
import React from 'react'
import ReactDOM from 'react-dom'
import StyleContext from 'isomorphic-style-loader/StyleContext'
import App from './App.js'
const server = express ()
const port = process.env.PORT || 3000
server.get('*',(reg,res,next)=>{
const css = new Set() // CSS for all rendered React components
const insertCss = (...styles) => styles.forBach(style = css.add(style._getCss()))
const body = ReactDOM.renderToString(
<StyleContext.Provider value={{insertCss}}>
<App />
</StyleContext.Provider>
)
const html = `<!doctype html>
<html>
<head>
<script src="client.js" defer></script>
<style>${[...css].join ('')}</style>
</head>
<body>
<div id="root">${body}</div>
<body>
</html>`
res.status(200).send(html)
})
server.listen(port, () => {
console.log(`Node.js app is running at http://localhost:${port}/`)
})
</code></pre>
<p>
這裡定義了 css Set 類型來儲存真面所有的樣式,並定義了 insertCss 方法,透過 context
可以將該方法傳遞給每個 React 元件。這樣一來,每個 React 元件就都可以呼叫 insertCss
方法了,並在呼叫該方法時,將元件樣式加入 css Set 函數中。
</p>
<p>最後,用 [..css].join('') 就可以取得真面的所有樣式字串了</p>
<h3>meta tags 繪製</h3>
<p>React 應用的程式骨架常常如下所示。</p>
<pre><code class="language-js">
React 應用的程式骨架常常如下所示。
const App =()=>{
return (
<div<
<Component1 /<
<Component2 /<
</div<
)
}
ReactDom.render(<App/<, document.querySelector('#root'))
</code></pre>
<p>
將 App 元件嵌入 document.querySelector('#root') 節點中的時候 React 元件一般是不包含 head
標籤內容的。但是,單頁面應用在切換路由時可能也會需要動態修改 head 標籤資訊:如 title
內容。也就是說,雖然單頁面應用在切換頁面時不會經過伺服器端繪製,但是仍然需要更改 document
中 title 的內容。
</p>
<p>
那麼,伺服器端如何繪製 meta tags 的 head
標籤就是一個常被忽略但是非常重要的話題。一般情況下,常常使用 react-helmet
函數庫來解決該問題。
</p>
<p>舉例來說,在 Home 元件中加入 react-helmet 函數庫所提供的 Helmet 元件的程式如下。</p>
<pre><code class="language-js">
import Helmet from "react-helmet";
<div>
<Helmet>
<title>Home page</title>
<meta name="description" content="Home page description" />
</Helmet>
<h1>Home component</h1>
</code></pre>
<p>在 Users 元件中加入 react-helmet 函數庫所提供的 Helmet 元件的程式如下。</p>
<pre><code class="language-js">
<Helmet>
<title>Users page</title>
<meta name="description" content="Users page description" />
</Helmet>
</code></pre>
<p>
react-helmet這個函數庫會在 Home 元件和 Users 元件繪製時檢測到
Helmet,並自動執行副作用邏輯。
</p>
<h3>404處理</h3>
<p>
當進行伺服器端繪製時,還需要留心對出現 404 的情況進行處理。假設 layout.js
檔案中的內容如下。
</p>
<pre><code class="language-js">
<Switch>
<Route path="/" exact component={Home} />
<Route path="/users" exact component={Users}/>
</Switch>
</code></pre>
<p>當存取:/home時,會獲得一個空白頁面,瀏覽器也沒有獲得 404 的狀態碼。</p>
<p>為了處理這種情況,可以加入以下內容。</p>
<pre><code class="language-js">
<Switch>
<Route path="/" exact component={Home} />
<Route path="/users" exact component={Users} />
<Route component={NotFound} />
</Switch>
</code></pre>
<p>同時建立 NotFound.js 檔案,檔案中的內容如下所示。</p>
<pre><code class="language-js">
import React from 'react'
export default function NotFound({ staticContext }){
if (staticContext) {
staticContext.notFound = true
}
return (
<div>Not found</div>
)
}
</code></pre>
<p>
當存取一個不存在的位址時,要傳回 404 狀態碼。一般來說,React router
類別庫已經對此進行了較好的封裝,Static Router 會在元件中植入一個 context prop,並將
context.notFound 設定值為 true,所以我們只需在 server/index.js 中加入以下內容即可。
</p>
<pre><code class="language-js">
const context = {}
const html = renderer(data, req.path, context);
if(context.notFound){
res.status(404)
}
res.send(html)
</code></pre>
<h3>安全問題</h3>
<p>
伺服器端繪製時,開發者要格外小心。這裡要特別說明一點是,前面提到了注水和脫水的過程,其中的過程非常容易遭受
XSS 攻擊,JSON.stringify(如下所示)可能會造成 script 植入。
</p>
<pre><code class="language-js">
ctx.body =`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<script>
window.context = {
initialState: ${JSON.stringify(store.getState())}
}
</script>
<div id="app">
//...
</div>
</body>
<html>
`
</code></pre>
<p>
因此,需要嚴格清洗 JSON 字串中的 HTML 標籤和其他危險的字元。推薦使用 serialize-JavaScript
函數庫進行處理,這也是同構應用中最容易被忽視的細節。
</p>
<p>React dangerouslySetInnerHTML API 也有類似的風險。</p>
<h3>效能最佳化</h3>
<p>
將資料請求移到了伺服器端,但是依然要格外重視效能最佳化。目前,業界對此的普遍做法包含以下幾點。
</p>
<ul>
<li>
使用快取:伺服器端最佳化的最重要的方法就是快取,不同於傳統伺服器端的快取措施,目前的措施可以實現元件級快取,業界的
Walmart Labs 在這方面進行的費作非常多,使效能有了較大的提升。
</li>
<li>
採用 HSF 代替 HTTP:HSF 是 High-Speed Service Framework
的縮寫,可譯為分散式的遠端服務呼叫架構。在對外提供服務上,HSF 的效能遠超過 HTTP。
</li>
<li>升級Node.js</li>
<li>升級 React</li>
</ul>
</article>
</main>
</body>
<script type="module" src="./js/main.js"></script>
</html>