首頁>技術>

大家好,我是Echa哥,這一次,讓我們來以React為例,把服務端渲染(Server Side Render,簡稱“SSR”)學個明明白白。

這裡附上這個專案的github地址: https://github.com/sanyuan0704/react-ssr

part1:實現一個基礎的React元件SSR

這一部分來簡要實現一個React元件的SSR。

一. SSR vs CSR

什麼是服務端渲染?

廢話不多說,直接起一個express伺服器。

var express = require('express')var app = express()app.get('/', (req, res) => { res.send( ` <html> <head> <title>hello</title> </head> <body> <h1>hello</h1> <p>world</p> </body> </html> ` )})app.listen(3001, () => { console.log('listen:3001')})複製程式碼

啟動之後開啟localhost:3001可以看到頁面顯示了hello world。而且開啟網頁原始碼:

也能夠完成顯示。

這就是服務端渲染。其實非常好理解,就是伺服器返回一堆html字串,然後讓瀏覽器顯示。

與服務端渲染相對的是客戶端渲染(Client Side Render)。那什麼是客戶端渲染? 現在建立一個新的React專案,用腳手架生成專案,然後run起來。 這裡你可以看到React腳手架自動生成的首頁。

然而開啟網頁原始碼。

body中除了相容處理的noscript標籤之外,只有一個id為root的標籤。那首頁的內容是從哪來的呢?很明顯,是下面的script中拉取的JS程式碼控制的。

因此,CSR和SSR最大的區別在於前者的頁面渲染是JS負責進行的,而後者是伺服器端直接返回HTML讓瀏覽器直接渲染。

為什麼要使用服務端渲染呢?

傳統CSR的弊端:

由於頁面顯示過程要進行JS檔案拉取和React程式碼執行,首屏載入時間會比較慢。對於SEO(Search Engine Optimazition,即搜尋引擎優化),完全無能為力,因為搜尋引擎爬蟲只認識html結構的內容,而不能識別JS程式碼內容。

SSR的出現,就是為了解決這些傳統CSR的弊端。

二、實現React元件的服務端渲染

剛剛起的express服務返回的只是一個普通的html字串,但我們討論的是如何進行React的服務端渲染,那麼怎麼做呢? 首先寫一個簡單的React元件:

// containers/Home.jsimport React from 'react';const Home = () => { return ( <div> <div>This is sanyuan</div> </div> )}export default Home複製程式碼

現在的任務就是將它轉換為html程式碼返回給瀏覽器。 眾所周知,JSX中的標籤其實是基於虛擬DOM的,最終要通過一定的方法將其轉換為真實DOM。虛擬DOM也就是JS物件,可以看出整個服務端的渲染流程就是通過虛擬DOM的編譯來完成的,因此虛擬DOM巨大的表達力也可見一斑了。

而react-dom這個庫中剛好實現了編譯虛擬DOM的方法。做法如下:

// server/index.jsimport express from 'express';import { renderToString } from 'react-dom/server';import Home from './containers/Home';const app = express();const content = renderToString(<Home />);app.get('/', function (req, res) { res.send( ` <html> <head> <title>ssr</title> </head> <body> <div id="root">${content}</div> </body> </html> ` );})app.listen(3001, () => { console.log('listen:3001')})複製程式碼

啟動express服務,再瀏覽器上開啟對應埠,頁面顯示出"this is sanyuan"。 到此,就初步實現了一個React元件是服務端渲染。 當然,這只是一個非常簡陋的SSR,事實上對於複雜的專案而言是無能為力的,在之後會一步步完善,打造出一個功能完整的React的SSR框架。

part2: 初識同構一.引入同構

其實前面的SSR是不完整的,平時在開發的過程中難免會有一些事件繫結,比如加一個button:

// containers/Home.jsimport React from 'react';const Home = () => { return ( <div> <div>This is sanyuan</div> <button onClick={() => {alert('666')}}>click</button> </div> )}export default Home複製程式碼

再試一下,你會驚奇的發現,事件繫結無效!那這是為什麼呢?原因很簡單,react-dom/server下的renderToString並沒有做事件相關的處理,因此返回給瀏覽器的內容不會有事件繫結。

那怎麼解決這個問題呢?

這就需要進行同構了。所謂同構,通俗的講,就是一套React程式碼在伺服器上執行一遍,到達瀏覽器又執行一遍。服務端渲染完成頁面結構,瀏覽器端渲染完成事件繫結。

那如何進行瀏覽器端的事件繫結呢?

唯一的方式就是讓瀏覽器去拉取JS檔案執行,讓JS程式碼來控制。於是服務端返回的程式碼變成了這樣:

有沒有發現和之前的區別?區別就是多了一個script標籤。而它拉取的JS程式碼就是來完成同構的。

那麼這個index.js我們如何生產出來呢?

在這裡,要用到react-dom。具體做法其實就很簡單了:

//client/index. jsimport React from 'react';import ReactDom from 'react-dom';import Home from '../containers/Home';ReactDom.hydrate(<Home />, document.getElementById('root'))複製程式碼

然後用webpack將其編譯打包成index.js:

//webpack.client.jsconst path = require('path');const merge = require('webpack-merge');const config = require('./webpack.base');const clientConfig = { mode: 'development', entry: './src/client/index.js', output: { filename: 'index.js', path: path.resolve(__dirname, 'public') },}module.exports = merge(config, clientConfig);//webpack.base.jsmodule.exports = { module: { rules: [{ test: /\\.js$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/preset-react', ['@babel/preset-env', { targets: { browsers: ['last 2 versions'] } }]] } }] }}//package.json的script部分 "scripts": { "dev": "npm-run-all --parallel dev:**", "dev:start": "nodemon --watch build --exec node \\"./build/bundle.js\\"", "dev:build:server": "webpack --config webpack.server.js --watch", "dev:build:client": "webpack --config webpack.client.js --watch" },複製程式碼

在這裡需要開啟express的靜態檔案服務:

const app = express();app.use(express.static('public'));複製程式碼

現在前端的script就能拿到控制瀏覽器的JS程式碼啦。

繫結事件完成!

現在來初步總結一下同構程式碼執行的流程:

二.同構中的路由問題

現在寫一個路由的配置檔案:

// Routes.jsimport React from 'react';import {Route} from 'react-router-dom'import Home from './containers/Home';import Login from './containers/Login'export default ( <div> <Route path='/' exact component={Home}></Route> <Route path='/login' exact component={Login}></Route> </div>)複製程式碼

在客戶端的控制程式碼,也就是上面寫過的client/index.js中,要做相應的更改:

import React from 'react';import ReactDom from 'react-dom';import { BrowserRouter } from 'react-router-dom'import Routes from '../Routes'const App = () => { return ( <BrowserRouter> {Routes} </BrowserRouter> )}ReactDom.hydrate(<App />, document.getElementById('root'))複製程式碼

這時候控制檯會報錯,

因為在Routes.js中,每個Route元件外面包裹著一層div,但服務端返回的程式碼中並沒有這個div,所以報錯。如何去解決這個問題?需要將服務端的路由邏輯執行一遍。

// server/index.jsimport express from 'express';import {render} from './utils';const app = express();app.use(express.static('public'));//注意這裡要換成*來匹配app.get('*', function (req, res) { res.send(render(req));}); app.listen(3001, () => { console.log('listen:3001')});複製程式碼// server/utils.jsimport Routes from '../Routes'import { renderToString } from 'react-dom/server';//重要是要用到StaticRouterimport { StaticRouter } from 'react-router-dom'; import React from 'react'export const render = (req) => { //構建服務端的路由 const content = renderToString( <StaticRouter location={req.path} > {Routes} </StaticRouter> ); return ` <html> <head> <title>ssr</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `}複製程式碼

現在路由的跳轉就沒有任何問題啦。 注意,這裡僅僅是一級路由的跳轉,多級路由的渲染在之後的系列中會用react-router-config中renderRoutes來處理。

part3: 同構專案中引入Redux

這一節主要是講述Redux如何被引入到同構專案中以及其中需要注意的問題。

重新回顧一下redux的運作流程:

再回顧一下同構的概念,即在React程式碼客戶端和伺服器端各自執行一遍。

一、建立全域性store

現在開始建立store。 在專案根目錄的store資料夾(總的store)下:

import {createStore, applyMiddleware, combineReducers} from 'redux';import thunk from 'redux-thunk';import { reducer as homeReducer } from '../containers/Home/store';//合併專案元件中store的reducerconst reducer = combineReducers({ home: homeReducer})//建立store,並引入中介軟體thunk進行非同步操作的管理const store = createStore(reducer, applyMiddleware(thunk));//匯出建立的storeexport default store複製程式碼二、元件內action和reducer的構建

Home資料夾下的工程檔案結構如下:

在Home的store目錄下的各個檔案程式碼示例:

//constants.jsexport const CHANGE_LIST = 'HOME/CHANGE_LIST';複製程式碼//actions.jsimport axios from 'axios';import { CHANGE_LIST } from "./constants";//普通actionconst changeList = list => ({ type: CHANGE_LIST, list});//非同步操作的action(採用thunk中介軟體)export const getHomeList = () => { return (dispatch) => { return axios.get('xxx') .then((res) => { const list = res.data.data; console.log(list) dispatch(changeList(list)) }); };}複製程式碼//reducer.jsimport { CHANGE_LIST } from "./constants";const defaultState = { name: 'sanyuan', list: []}export default (state = defaultState, action) => { switch(action.type) { default: return state; }}複製程式碼//index.jsimport reducer from "./reducer";//這麼做是為了匯出reducer讓全域性的store來進行合併//那麼在全域性的store下的index.js中只需引入Home/store而不需要Home/store/reducer.js//因為腳手架會自動識別資料夾下的index檔案export {reducer}複製程式碼三、元件連線全域性store

下面是Home元件的編寫示例。

import React, { Component } from 'react';import { connect } from 'react-redux';import { getHomeList } from './store/actions'class Home extends Component { render() { const { list } = this.props return list.map(item => <div key={item.id}>{item.title}</div>) }}const mapStateToProps = state => ({ list: state.home.newsList,})const mapDispatchToProps = dispatch => ({ getHomeList() { dispatch(getHomeList()); }})//連線storeexport default connect(mapStateToProps, mapDispatchToProps)(Home);複製程式碼

對於store的連線操作,在同構專案中分兩個部分,一個是與客戶端store的連線,另一部分是與服務端store的連線。都是通過react-redux中的Provider來傳遞store的。

客戶端:

//src/client/index.jsimport React from 'react';import ReactDom from 'react-dom';import {BrowserRouter, Route} from 'react-router-dom';import { Provider } from 'react-redux';import store from '../store'import routes from '../routes.js'const App = () => { return ( <Provider store={store}> <BrowserRouter> {routes} </BrowserRouter> </Provider> )}ReactDom.hydrate(<App />, document.getElementById('root'))複製程式碼

服務端:

//src/server/index.js的內容保持不變//下面是src/server/utils.jsimport Routes from '../Routes'import { renderToString } from 'react-dom/server';import { StaticRouter } from 'react-router-dom'; import { Provider } from 'react-redux';import React from 'react'export const render = (req) => { const content = renderToString( <Provider store={store}> <StaticRouter location={req.path} > {Routes} </StaticRouter> </Provider> ); return ` <html> <head> <title>ssr</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `}複製程式碼四、潛在的坑

其實上面這樣的store建立方式是存在問題的,什麼原因呢?

上面的store是一個單例,當這個單例匯出去後,所有的使用者用的是同一份store,這是不應該的。那麼這麼解這個問題呢?

在全域性的store/index.js下修改如下:

//匯出部分修改export default () => { return createStore(reducer, applyMiddleware(thunk))}複製程式碼

這樣在客戶端和服務端的js檔案引入時其實引入了一個函式,把這個函式執行就會拿到一個新的store,這樣就能保證每個使用者訪問時都是用的一份新的store。

part4: 非同步資料的服務端渲染方案(資料注水與脫水)一、問題引入

在平常客戶端的React開發中,我們一般在元件的componentDidMount生命週期函式進行非同步資料的獲取。但是,在服務端渲染中卻出現了問題。

現在我在componentDidMount鉤子函式中進行Ajax請求:

import { getHomeList } from './store/actions' //...... componentDidMount() { this.props.getList(); } //...... const mapDispatchToProps = dispatch => ({ getList() { dispatch(getHomeList()); }})複製程式碼//actions.jsimport { CHANGE_LIST } from "./constants";import axios from 'axios'const changeList = list => ({ type: CHANGE_LIST, list})export const getHomeList = () => { return dispatch => { //另外起的本地的後端服務 return axiosInstance.get('localhost:4000/api/news.json') .then((res) => { const list = res.data.data; dispatch(changeList(list)) }) }}//reducer.jsimport { CHANGE_LIST } from "./constants";const defaultState = { name: 'sanyuan', list: []}export default (state = defaultState, action) => { switch(action.type) { case CHANGE_LIST: const newState = { ...state, list: action.list } return newState default: return state; }}複製程式碼

好,現在啟動服務。

現在頁面能夠正常渲染,但是開啟網頁原始碼。

原始碼裡面並沒有這些列表資料啊!那這是為什麼呢?

讓我們來分析一下客戶端和服務端的執行流程,當瀏覽器傳送請求時,伺服器接受到請求,這時候伺服器和客戶端的store都是空的,緊接著客戶端執行componentDidMount生命週期中的函式,獲取到資料並渲染到頁面,然而伺服器端始終不會執行componentDidMount,因此不會拿到資料,這也導致伺服器端的store始終是空的。換而言之,關於非同步資料的操作始終只是客戶端渲染。

現在的工作就是讓服務端將獲得資料的操作執行一遍,以達到真正的服務端渲染的效果。

二、改造路由

在完成這個方案之前需要改造一下原有的路由,也就是routes.js

import Home from './containers/Home';import Login from './containers/Login';export default [{ path: "/", component: Home, exact: true, loadData: Home.loadData,//服務端獲取非同步資料的函式 key: 'home'},{ path: '/login', component: Login, exact: true, key: 'login'}}];複製程式碼

此時客戶端和服務端中編寫的JSX程式碼也發生了相應變化

//客戶端//以下的routes變數均指routes.js匯出的陣列<Provider store={store}> <BrowserRouter> <div> { routers.map(route => { <Route {...route} /> }) } </div> </BrowserRouter></Provider>複製程式碼//服務端<Provider store={store}> <StaticRouter> <div> { routers.map(route => { <Route {...route} /> }) } </div> </StaticRouter></Provider>複製程式碼

其中配置了一個loadData引數,這個引數代表了服務端獲取資料的函式。每次渲染一個元件獲取非同步資料時,都會呼叫相應元件的這個函式。因此,在編寫這個函式具體的程式碼之前,我們有必要想清楚如何來針對不同的路由來匹配不同的loadData函式。

在server/utils.js中加入以下邏輯

import { matchRoutes } from 'react-router-config'; //呼叫matchRoutes用來匹配當前路由(支援多級路由) const matchedRoutes = matchRoutes(routes, req.path) //promise物件陣列 const promises = []; matchedRoutes.forEach(item => { //如果這個路由對應的元件有loadData方法 if (item.route.loadData) { //那麼就執行一次,並將store傳進去 //注意loadData函式呼叫後需要返回Promise物件 promises.push(item.route.loadData(store)) } }) Promise.all(promises).then(() => { //此時該有的資料都已經到store裡面去了 //執行渲染的過程(res.send操作) } )複製程式碼

現在就可以安心的寫我們的loadData函式,其實前面的鋪墊工作做好後,這個函式是相當容易的。

import { getHomeList } from './store/actions'Home.loadData = (store) => { return store.dispatch(getHomeList())}複製程式碼//actions.jsexport const getHomeList = () => { return dispatch => { return axios.get('xxxx') .then((res) => { const list = res.data.data; dispatch(changeList(list)) }) }}複製程式碼

根據這個思路,服務端渲染中非同步資料的獲取功能就完成啦。

三、資料的注水和脫水

其實目前做了這裡還是存在一些細節問題的。比如當我將生命週期鉤子裡面的非同步請求函式註釋,現在頁面中不會有任何的資料,但是開啟網頁原始碼,卻發現:

資料已經掛載到了服務端返回的HTML程式碼中。那這就說明服務端和客戶端的store不同步的問題。

其實也很好理解。當服務端拿到store並獲取資料後,客戶端的js程式碼又執行一遍,在客戶端程式碼執行的時候又建立了一個空的store,兩個store的資料不能同步。

那如何才能讓這兩個store的資料同步變化呢?

首先,在服務端獲取獲取之後,在返回的html程式碼中加入這樣一個script標籤:

<script> window.context = { state: ${JSON.stringify(store.getState())} }</script>複製程式碼

這叫做資料的“注水”操作,即把服務端的store資料注入到window全域性環境中。 接下來是“脫水”處理,換句話說也就是把window上繫結的資料給到客戶端的store,可以在客戶端store產生的源頭進行,即在全域性的store/index.js中進行。

//store/index.jsimport {createStore, applyMiddleware, combineReducers} from 'redux';import thunk from 'redux-thunk';import { reducer as homeReducer } from '../containers/Home/store';const reducer = combineReducers({ home: homeReducer})//服務端的store建立函式export const getStore = () => { return createStore(reducer, applyMiddleware(thunk));}//客戶端的store建立函式export const getClientStore = () => { const defaultState = window.context ? window.context.state : {}; return createStore(reducer, defaultState, applyMiddleware(thunk));}複製程式碼

至此,資料的脫水和注水操作完成。但是還是有一些瑕疵,其實當服務端獲取資料之後,客戶端並不需要再發送Ajax請求了,而客戶端的React程式碼仍然存在這樣的浪費效能的程式碼。怎麼辦呢?

還是在Home元件中,做如下的修改:

componentDidMount() { //判斷當前的資料是否已經從服務端獲取 //要知道,如果是首次渲染的時候就渲染了這個元件,則不會重複發請求 //若首次渲染頁面的時候未將這個元件渲染出來,則一定要執行非同步請求的程式碼 //這兩種情況對於同一組件是都是有可能發生的 if (!this.props.list.length) { this.props.getHomeList() }}複製程式碼

一路做下來,非同步資料的服務端渲染還是比較複雜的,但是難度並不是很大,需要耐心地理清思路。

至此一個比較完整的SSR框架就搭建的差不多了,但是還有一些內容需要補充,之後會繼續更新的。加油吧!

part5: node作中間層及請求程式碼優化一、為什麼要引入node中間層?

其實任何技術都是與它的應用場景息息相關的。這裡我們反覆談的SSR,其實不到萬不得已我們是用不著它的,SSR所解決的最大的痛點在於SEO,但它同時帶來了更昂貴的成本。不僅因為服務端渲染需要更加複雜的處理邏輯,還因為同構的過程需要服務端和客戶端都執行一遍程式碼,這雖然對於客戶端並沒有什麼大礙,但對於服務端卻是巨大的壓力,因為數量龐大的訪問量,對於每一次訪問都要另外在伺服器端執行一遍程式碼進行計算和編譯,大大地消耗了伺服器端的效能,成本隨之增加。如果訪問量足夠大的時候,以前不用SSR的時候一臺伺服器能夠承受的壓力現在或許要增加到10臺才能抗住。痛點在於SEO,但如果實際上對SEO要求並不高的時候,那使用SSR就大可不必了。

那同樣地,為什麼要引入node作為中間層呢?它是處在哪兩者的中間?又是解決了什麼場景下的問題?

在不用中間層的前後端分離開發模式下,前端一般直接請求後端的介面。但真實場景下,後端所給的資料格式並不是前端想要的,但處於效能原因或者其他的因素介面格式不能更改,這時候需要在前端做一些額外的資料處理操作。前端來操作資料本身無可厚非,但是當資料量變得龐大起來,那麼在客戶端就是產生巨大的效能損耗,甚至影響到使用者體驗。在這個時候,node中間層的概念便應運而生。

它最終解決的前後端協作的問題。

一般的中間層工作流是這樣的:前端每次傳送請求都是去請求node層的介面,然後node對於相應的前端請求做轉發,用node去請求真正的後端介面獲取資料,獲取後再由node層做對應的資料計算等處理操作,然後返回給前端。這就相當於讓node層替前端接管了對資料的操作。

二、SSR框架中引入中間層

在之前搭建的SSR框架中,服務端和客戶端請求利用的是同一套請求後端介面的程式碼,但這是不科學的。

對客戶端而言,最好通過node中間層。而對於這個SSR專案而言,node開啟的伺服器本來就是一箇中間層的角色,因而對於伺服器端執行資料請求而言,就可以直接請求真正的後端介面啦。

//actions.js//引數server表示當前請求是否發生在node服務端const getUrl = (server) => { return server ? 'xxxx(後端介面地址)' : '/api/sanyuan.json(node介面)';}//這個server引數是Home元件裡面傳過來的,//在componentDidMount中呼叫這個action時傳入false,//在loadData函式中呼叫時傳入true, 這裡就不貼元件程式碼了export const getHomeList = (server) => { return dispatch => { return axios.get(getUrl(server)) .then((res) => { const list = res.data.data; dispatch(changeList(list)) }) }}複製程式碼

在server/index.js應拿到前端的請求做轉發,這裡是直接用proxy形式來做,也可以用node單獨向後端傳送一次HTTP請求。

//增加如下程式碼import proxy from 'express-http-proxy';//相當於攔截到了前端請求地址中的/api部分,然後換成另一個地址app.use('/api', proxy('http://xxxxxx(服務端地址)', { proxyReqPathResolver: function(req) { return '/api'+req.url; }}));複製程式碼三、請求程式碼優化

其實請求的程式碼還是有優化的餘地的,仔細想想,上面的server引數其實是不用傳遞的。

現在我們利用axios的instance和thunk裡面的withExtraArgument來做一些封裝。

//新建server/request.jsimport axios from 'axios'const instance = axios.create({ baseURL: 'http://xxxxxx(服務端地址)'})export default instance//新建client/request.jsimport axios from 'axios'const instance = axios.create({ //即當前路徑的node服務 baseURL: '/'})export default instance複製程式碼

然後對全域性下store的程式碼做一個微調:

import {createStore, applyMiddleware, combineReducers} from 'redux';import thunk from 'redux-thunk';import { reducer as homeReducer } from '../containers/Home/store';import clientAxios from '../client/request';import serverAxios from '../server/request';const reducer = combineReducers({ home: homeReducer})export const getStore = () => { //讓thunk中介軟體帶上serverAxios return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));}export const getClientStore = () => { const defaultState = window.context ? window.context.state : {}; //讓thunk中介軟體帶上clientAxios return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));}複製程式碼

現在Home元件中請求資料的action無需傳參,actions.js中的請求程式碼如下:

export const getHomeList = () => { //返回函式中的預設第三個引數是withExtraArgument傳進來的axios例項 return (dispatch, getState, axiosInstance) => { return axiosInstance.get('/api/sanyuan.json') .then((res) => { const list = res.data.data; console.log(res) dispatch(changeList(list)) }) }}複製程式碼

至此,程式碼優化就做的差不多了,這種程式碼封裝的技巧其實可以用在其他的專案當中,其實還是比較優雅的。

part6: 多級路由渲染(renderRoutes)

現在將routes.js的內容改變如下:

import Home from './containers/Home';import Login from './containers/Login';import App from './App'//這裡出現了多級路由export default [{ path: '/', component: App, routes: [ { path: "/", component: Home, exact: true, loadData: Home.loadData, key: 'home', }, { path: '/login', component: Login, exact: true, key: 'login', } ]}]複製程式碼

現在的需求是讓頁面公用一個Header元件,App元件編寫如下:

import React from 'react';import Header from './components/Header';const App = (props) => { console.log(props.route) return ( <div> <Header></Header> </div> )}export default App;複製程式碼

對於多級路由的渲染,需要服務端和客戶端各執行一次。 因此編寫的JSX程式碼都應有所實現:

//routes是指routes.js中返回的陣列//服務端:<Provider store={store}> <StaticRouter location={req.path} > <div> {renderRoutes(routes)} </div> </StaticRouter></Provider>//客戶端:<Provider store={getClientStore()}> <BrowserRouter> <div> {renderRoutes(routes)} </div> </BrowserRouter></Provider>複製程式碼

這裡都用到了renderRoutes方法,其實它的工作非常簡單,就是根據url渲染一層路由的元件(這裡渲染的是App元件),然後將下一層的路由通過props傳給目前的App元件,依次迴圈。

那麼,在App元件就能通過props.route.routes拿到下一層路由進行渲染:

part7: CSS的服務端渲染思路(context鉤子變數)一、客戶端專案中引入CSS

還是以Home元件為例

//Home/style.cssbody { background: gray;}複製程式碼

現在,在Home元件程式碼中引入:

import styles from './style.css';複製程式碼

要知道這樣的引入CSS程式碼的方式在一般環境下是執行不起來的,需要在webpack中做相應的配置。 首先安裝相應的外掛。

npm install style-loader css-loader --D複製程式碼//webpack.client.jsconst path = require('path');const merge = require('webpack-merge');const config = require('./webpack.base');const clientConfig = { mode: 'development', entry: './src/client/index.js', module: { rules: [{ test: /\\.css?$/, use: ['style-loader', { loader: 'css-loader', options: { modules: true } }] }] }, output: { filename: 'index.js', path: path.resolve(__dirname, 'public') },}module.exports = merge(config, clientConfig);複製程式碼//webpack.base.js程式碼,回顧一下,配置了ES語法相關的內容module.exports = { module: { rules: [{ test: /\\.js$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/preset-react', ['@babel/preset-env', { targets: { browsers: ['last 2 versions'] } }]] } }] }}複製程式碼

好,現在在客戶端CSS已經產生了效果。

可是開啟網頁原始碼:

咦?裡面並沒有出現任何有關CSS樣式的程式碼啊!那這是什麼原因呢?很簡單,其實我們的服務端的CSS載入還沒有做。接下來我們來完成CSS程式碼的服務端的處理。

二、服務端CSS的引入

首先,來安裝一個webpack的外掛,

npm install -D isomorphic-style-loader複製程式碼

然後再webpack.server.js中做好相應的css配置:

//webpack.server.jsconst path = require('path');const nodeExternals = require('webpack-node-externals');const merge = require('webpack-merge');const config = require('./webpack.base');const serverConfig = {  target: 'node',  mode: 'development',  entry: './src/server/index.js',  externals: [nodeExternals()],  module: {    rules: [{      test: /\\.css?$/,      use: ['isomorphic-style-loader', {        loader: 'css-loader',        options: {          modules: true        }      }]    }]  },  output: {    filename: 'bundle.js',    path: path.resolve(__dirname, 'build')  }}module.exports = merge(config, serverConfig);複製程式碼

它做了些什麼事情?

再看看這行程式碼:

import styles from './style.css';複製程式碼

引入css檔案時,這個isomorphic-style-loader幫我們在styles中掛了三個函式。輸出styles看看:

現在我們的目標是拿到CSS程式碼,直接通過styles._getCss即可獲得。

那我們拿到CSS程式碼後放到哪裡呢?其實react-router-dom中的StaticRouter中已經幫我們準備了一個鉤子變數context。如下

//context從外界傳入<StaticRouter location={req.path} context={context}>    <div>        {renderRoutes(routes)}    </div></StaticRouter>複製程式碼

這就意味著在路由配置物件routes中的元件都能在服務端渲染的過程中拿到這個context,而且這個context對於元件來說,就相當於元件中的props.staticContext。並且,這個props.staticContext只會在服務端渲染的過程中存在,而客戶端渲染的時候不會被定義。這就讓我們能夠通過這個變數來區分兩種渲染環境啦。

現在,我們需要在服務端的render函式執行之前,初始化context變數的值:

let context = { css: [] }複製程式碼

我們只需要在元件的componentWillMount生命週期中編寫相應的邏輯即可:

componentWillMount() {  //判斷是否為服務端渲染環境  if (this.props.staticContext) {    this.props.staticContext.css.push(styles._getCss())  }}複製程式碼

服務端的renderToString執行完成後,context的CSS現在已經是一個有內容的陣列,讓我們來獲取其中的CSS程式碼:

//拼接程式碼const cssStr = context.css.length ? context.css.join('\\n') : '';複製程式碼

現在掛載到頁面:

//放到返回的html字串裡的header裡面<style>${cssStr}</style>複製程式碼

網頁原始碼中看到了CSS程式碼,效果也沒有問題。CSS渲染完成!

三、利用高階元件優化程式碼

也許你已經發現,對於每一個含有樣式的元件,都需要在componentWillMount生命週期中執行完全相同的邏輯,對於這些邏輯我們是否能夠把它封裝起來,不用反覆出現呢?

其實是可以實現的。利用高階元件就可以完成:

//根目錄下建立withStyle.js檔案import React, { Component } from 'react';//函式返回元件//需要傳入的第一個引數是需要裝飾的元件//第二個引數是styles物件export default (DecoratedComponent, styles) => {  return class NewComponent extends Component {    componentWillMount() {      //判斷是否為服務端渲染過程      if (this.props.staticContext) {        this.props.staticContext.css.push(styles._getCss())      }    }    render() {      return <DecoratedComponent {...this.props} />    }  }}複製程式碼

然後讓這個匯出的函式包裹我們的Home元件。

import WithStyle from '../../withStyle';//......const exportHome = connect(mapStateToProps, mapDispatchToProps)(withStyle(Home, styles));export default exportHome;複製程式碼

這樣是不是簡潔很多了呢?將來對於越來越多的元件,採用這種方式也是完全可以的。

part8: 做好SEO的一些技巧,引入react-helmet

這一節我們來簡單的聊一點SEO相關的內容。

一、SEO技巧分享

所謂SEO(Search Engine Optimization),指的是利用搜索引擎的規則提高網站在有關搜尋引擎內的自然排名。現在的搜尋引擎爬蟲一般是全文分析的模式,分析內容涵蓋了一個網站主要3個部分的內容:文字、多媒體(主要是圖片)和外部連結,通過這些來判斷網站的型別和主題。因此,在做SEO優化的時候,可以圍繞這三個角度來展開。

對於文字來說,儘量不要抄襲已經存在的文章,以寫技術部落格為例,東拼西湊抄來的文章排名一般不會高,如果需要引用別人的文章要記得宣告出處,不過最好是原創,這樣排名效果會比較好。多媒體包含了視訊、圖片等檔案形式,現在比較權威的搜尋引擎爬蟲比如Google做到對圖片的分析是基本沒有問題的,因此高品質的圖片也是加分項。另外是外部連結,也就是網站中a標籤的指向,最好也是和當前網站相關的一些連結,更容易讓爬蟲分析。

當然,做好網站的門面,也就是標題和描述也是至關重要的。如:

網站標題中不僅僅包含了關鍵詞,而且有比較詳細和靠譜的描述,這讓使用者一看到就覺得非常親切和可靠,有一種想要點選的衝動,這就表明網站的轉化率比較高。

二、引入react-helmet

而React專案中,開發的是單頁面的應用,頁面始終只有一份title和description,如何根據不同的元件顯示來對應不同的網站標題和描述呢?

其實是可以做到的。

npm install react-helmet --save複製程式碼

元件程式碼:(還是以Home元件為例)

其實也非常簡單:

//server/utils.jsimport { renderToString } from 'react-dom/server';import {  StaticRouter } from 'react-router-dom'; import React from 'react';import { Provider } from "react-redux";import { renderRoutes } from 'react-router-config';import { Helmet } from 'react-helmet';export const render = (store, routes, req, context) => {  const content = renderToString(    <Provider store={store}>      <StaticRouter location={req.path} context={context}>        <div>          {renderRoutes(routes)}        </div>      </StaticRouter>    </Provider>  );  //拿到helmet物件,然後在html字串中引入  const helmet = Helmet.renderStatic();  const cssStr = context.css.length ? context.css.join('\\n') : '';  return  `    <html>      <head>        <style>${cssStr}</style>        ${helmet.title.toString()}        ${helmet.meta.toString()}      </head>      <body>        <div id="root">${content}</div>        <script>          window.context = {            state: ${JSON.stringify(store.getState())}          }        </script>        <script src="/index.js"></script>      </body>    </html>  `};複製程式碼

現在來看看效果:

網頁原始碼中顯示出對應的title和description, 客戶端的顯示也沒有任何問題,大功告成!

關於React的服務端渲染原理,就先分享到這裡,內容還是比較複雜的,對於前端的綜合能力要求也比較高,但是堅持跟著學下來,一定會大有裨益的。相信你看了這一系列之後也有能力造出自己的SSR輪子,更加深刻地理解這一方面的技術。

原連結:https://juejin.im/post/5d1fe6be51882579db031a6d

最新評論
  • 1 #

    對於非同步獲取資料這塊,原則上是寫在express邏輯裡,用node去獲取資料,然後在constructor將資料作為初始狀態寫到視圖裡

  • 2 #

    為啥要服務端渲染呢

  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 嵌入式開發入門-從STM32CudeMX、FreeRtos、Proteu模擬開始