自從 react
vue
angular
等 mvvm
前端框架問世以後,先後端分離使得分工更加明確,開發效率顯著提升。 由之前的後端渲染數據吐頁面變成了前端請求數據,渲染頁面,因此在客戶端渲染中必須先下載服務器的 js css 文件再進行渲染。這須要必定的時間,中間的白屏對用戶來講也不是很友好,並且爬蟲抓取到的頁面是一個無內容的空頁面,也不利於 seo
。所以在前端框架基礎上的 ssr
也成了剛需。ssr
的好處也十分明顯javascript
1. 利於 seo
2. 加快首屏加載,解決首屏的白屏問題
...
複製代碼
網上關於 react ssr
的文章成千上萬,雖然原理相同,但每一個人的實現方式風格迥異,並且不少都有着複雜的配置和代碼邏輯,能把 ssr
解釋清楚的少之又少,因此我認真研究了一下react ssr
的實現,在同事的啓發下搭了一個本身的 react ssr & csr
同構框架,只有一個目的,那就是爭取把 ssr 講得誰都能看懂。css
前面有兩張示意圖,爲了方便,我直接用了豆瓣和掘金的 api 來作數據展現,logo 直接用了豆瓣(不要在乎這些細節😂)。html
🚀 github
,項目地址在這裏前端
下面正式開始介紹本項目中 SSR 的實現vue
SSR
原理本項目的客戶端服務端同構大概是這樣一個流程,總的來講就是在服務器和客戶端之間加一成 node,這一層的做用是接收到客戶端的請求以後,在這一層請求數據,而後把數據塞到 html 模版頁面中,最後吐出一個靜態頁面,全部耗時的操做都在服務端完成。固然,服務端返回的只是一個靜態的 html,想要交互、dom 操做的話必須運行一遍客戶端的js,所以在吐頁面的時候要把cdn上的js也插入進來,剩下的就交給客戶端本身去處理了。這樣纔算完成同構。在開始以前,我先拋出幾個新人容易困惑的問題(實際上是我以前困惑的幾個問題)java
1. 服務端如何將客戶端的異步請求劫持並完成請求,渲染頁面呢?
2. 服務端請求回來的數據,在運行客戶端js的時候會不會被覆蓋呢?
3. 服務端返回一個沒有樣式的 html 的話會影響體驗,如何在服務端就插入樣式呢?
...
複製代碼
帶着這些問題,咱們開始研究下ssr的實現node
首先介紹下項目結構react
如上圖,這是我嘗試了多種結構以後肯定下來的,我一直很看重項目結構的設計,一個清晰的框架能讓本身的思路更加清晰。client 和 server 分別是客戶端和服務端的內容,client 中有咱們的 pages 頁面,components 共用組件,utils 是經常使用工具函數。若是項目不須要 ssr 的話,client 端也能單獨跑起來,這就是客戶端渲染了,本項目的服務端和客戶端分別跑在 8987,8988 端口。lib 文件夾下是全局的一些配置和服務, 包括 webpack 等。webpack
項目開發或者打包的時候,會啓動server
和client
兩條編譯流水線ios
打包的文件也在build
下的server
和client
文件夾下,怎麼樣,這個結構是否是灰常清晰易懂。
路由等配置就不貼源碼了,感興趣的能夠看一下源碼。
🚀打包的過程是重點,一個webpack
配置文件通用,配置的部分參數須要根據客戶端仍是服務端、開發仍是生產環境來區分。
'use strict'
const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const StartServerPlugin = require('start-server-webpack-plugin')
const OpenBrowserPlugin = require('open-browser-webpack-plugin')
const nodeExternals = require('webpack-node-externals')
const ManifestPlugin = require('webpack-manifest-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const WebpackBar = require('webpackbar')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// const postcss = require('../postcss')
const paths = require('./paths')
const createAlias = require('./alias')
const config = require('../package.json')
function createEntry(termimal) {
const isServer = termimal === 'server'
const mainEntry = isServer ? paths.appServer : paths.appClient
return isServer ? {
main: mainEntry
} : Object.assign({}, {main: mainEntry}, {
// 通用庫抽出 vendor
vendor: [
'react',
'react-dom',
'react-router-dom',
'axios'
]
})
}
function createWebpackConfig (termimal) {
const isProd = process.env.NODE_ENV === 'production'
const isDev = !isProd
const isServer = termimal === 'server'
const isClient = termimal === 'client'
const isDevServer = isDev && isServer
const isProdClient = isProd && isClient
const isProdServer = isProd && isServer
const target = isServer ? 'node' : 'web'
return {
bail: isProd,
mode: isProd ? 'production' : 'development',
target: isServer ? 'node' : 'web',
entry: createEntry(termimal),
output: {
filename: `[name]${isProdClient ? '.[chunkhash]' : ''}.js`,
// filename: `[name].[chunkhash].js`,
path: isServer ? paths.buildServer : paths.buildClient,
publicPath: '',
libraryTarget: isServer ? 'commonjs2' : 'var',
},
node: {
__dirname: true,
__filename: true
},
resolve: {
// alias 配置
alias: createAlias()
},
module: {
strictExportPresence: true,
noParse (file) {
return !/\/cjs\/|react-hot-loader/.test(file) && /\.min\.js/.test(file)
},
rules: [
{
oneOf: [
{
test: /\.(js|jsx)?$/,
use: [
{
loader: 'babel-loader',
options: {
babelrc: false,
cacheDirectory: true,
compact: isProdClient,
highlightCode: true,
presets: [
// babel 單獨配置
path.resolve(__dirname, './babel'),
{}
]
}
}
]
},
{
test: /\.css$/,
use: [
isClient && (isProd ? MiniCssExtractPlugin.loader : 'style-loader'),
isDevServer && 'isomorphic-style-loader',
{
loader: 'css-loader',
options: {
modules: true,
localIdentName: '[name]-[local]-[hash:base58:5]',
importLoaders: 1,
exportOnlyLocals: isProdServer
}
}
].filter(Boolean)
},
{
test: /\.(png|jpg|jpeg|gif|image.svg)$/,
loader: 'file-loader',
options: {
name: `${isDev ? '' : '/'}[name].[hash:base58:8].[ext]`,
emitFile: isClient
}
},
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
svgProps: {
height: '{props.size || props.height}',
width: '{props.size || props.width}',
fill: '{props.fill || "currentColor"}'
},
svgo: false
}
}
]
}
]
}
]
},
plugins: [
// server 端由 StartServerPlugin 啓動
isDevServer && new StartServerPlugin({
name: 'main.js',
keyboard: true,
signal: true
}),
isClient && new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isProd
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
isDev && new webpack.HotModuleReplacementPlugin(),
new WebpackBar({
color: isClient ? '#ff2124' : '#1151fe',
name: isClient ? 'client' : 'server'
}),
isProd && new MiniCssExtractPlugin({
filename: `${isDev ? '' : '/'}[name].[contenthash].css`
}),
isClient && new ManifestPlugin({
writeToFileEmit: true,
fileName: `manifest.json`
})
].filter(Boolean),
// server 端配置
externals: [isServer && nodeExternals()].filter(Boolean),
optimization: {
minimize: isProdClient,
minimizer: [
new TerserPlugin({
cache: true,
parallel: 2,
sourceMap: true,
terserOptions: {
keep_fnames: /^[A-Z]\w+Error$/,
safari10: true
}
})
],
concatenateModules: isProdClient,
splitChunks: {
maxAsyncRequests: 1,
cacheGroups: isClient ? {
vendors: {
test: /node_modules/,
name: 'vendors',
}
} : undefined
}
},
devServer: {
allowedHosts: [".localhost"],
disableHostCheck: false,
compress: true,
port: config.project.devServer.port,
headers: {
'access-control-allow-origin': '*'
},
hot: false,
publicPath: '',
historyApiFallback: true
}
}
}
module.exports = createWebpackConfig
複製代碼
📦以上就是項目的 webpack 配置,爲了能在全局像
import Avatar from 'components/Avatar'
複製代碼
這樣引用組件,咱們須要配置 alias:
'use strict'
const path = require('path')
const base = require('app-root-dir')
module.exports = function createAlias () {
return Object.assign(
{},
{
'base': base.get(),
'client': path.resolve(base.get(), 'client'),
'server': path.resolve(base.get(), 'server'),
'lib': path.resolve(base.get(), 'lib'),
'config': path.resolve(base.get(), 'client/config'),
'utils': path.resolve(base.get(), 'client/utils'),
'hocs': path.resolve(base.get(), 'client/hocs'),
'router': path.resolve(base.get(), 'client/router'),
'components': path.resolve(base.get(), 'client/components'),
'pages': path.resolve(base.get(), 'client/pages'),
}
)
}
複製代碼
運行 run dev 以後會啓動客戶端和服務端的編譯:
const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
const open = require('open')
const path = require('path')
const webpackConfig = require(path.resolve('lib/webpackConfig'))
const config = require(path.resolve(__dirname, '../package.json'))
// 客戶端編譯
const clientConfig = webpackConfig('client')
const clientCompiler = webpack(clientConfig)
const clientDevServer = new WebpackDevServer(
clientCompiler,
clientConfig.devServer
)
clientDevServer.listen(config.project.devServer.port)
// 服務端編譯
const serverConfig = webpackConfig('server')
const serverCompiler = webpack(serverConfig)
serverCompiler.watch({
quiet: true,
stats: 'none'
})
複製代碼
下面是個人服務端處理,因爲引入了 babel,因此我在服務端可使用 es6 模塊
import Koa from 'koa'
import path from 'path'
import debug from 'debug'
import Router from 'koa-router'
import koaStatic from 'koa-static'
import bodyParser from 'koa-bodyparser'
import favic from 'koa-favicon'
import packageJson from '../package.json'
import ReactServer from './App'
import {routes} from 'client/pages'
const server = new ReactServer()
const log = (target, port) => debug(`dev:${target} The ${target} side rendering is running at http://localhost:${port}`)
const app = new Koa()
const router = new Router()
app.use(bodyParser({
jsonLimit: '8mb'
}))
// 對因此的路由都返回這個頁面了
router.get('*', async ctx => {
// 匹配頁面的實際路由
const currentRoute = routes.find(r => r.path === ctx.request.url)
const currentComponent = currentRoute && currentRoute.component
// 把頁面中的請求劫持過來在服務端發
const { fetchId, getInitialProps } = currentComponent || {}
const currentProps = getInitialProps && await getInitialProps()
// 服務端請求到的數據
const contextProps = {
[fetchId]: {
data: currentProps,
pending: false,
error: null
}
}
ctx.body = server.renderApp(ctx, contextProps)
})
// 靜態
app.use(koaStatic(path.join(__dirname, '../build')))
app.use(
favic(path.resolve(__dirname, '../public/favicon.ico'), {
maxAge: 1000 * 60 * 10
})
);
app.use(router.routes())
// 處理 server hot reload
if (module.hot) {
process.once('SIGUSR2', () => {
log('Got HMR signal from webpack StartServerPlugin.')
})
module.hot.accept()
module.hot.dispose(() => server.close())
}
app.listen(packageJson.project.port, () => {
log('server', packageJson.project.port)('')
log('client', packageJson.project.devServer.port)('')
})
複製代碼
因而在頁面中配置異步請求:
const fetchId = 'highRateMovie'
class HighRateMovie extends React.Component {
......
}
HighRateMovie.fetchId = fetchId
// 該組件下綁定的異步邏輯,供服務端抓取
HighRateMovie.getInitialProps = () => fetch(addQuery('https://movie.douban.com/j/search_subjects', {
type: 'movie',
tag: '豆瓣高分',
sort: 'recommend',
page_limit: 40,
page_start: 0
}))
export default HighRateMovie
複製代碼
這裏的 fetchId 是做爲全局 context 對象的鍵來用的,不能重複,最後頁面中的數據結構會是:
{
movies: [],
music: {},
heroList: []
...
}
複製代碼
這裏的fetchId 就成了惟一標識。
上面在服務端拿到的 contextProps 又是怎麼傳遞到咱們的頁面中的呢,這就是 React 16.3 推出的新的 context api 了,熟悉的人應該一眼就能看懂,不太熟悉 context api 建議看一下相關文檔 context api ,也十分簡單。爲何我這裏不用 redux 或者 mobx 呢,這就純粹是我的喜愛了,redux 相對來講比較重,並且開發工程中須要配置 action 和 reducer,寫起來比較繁瑣,mobx 相對來講較輕。這裏採用了 contextApi,由於它相對來講更加簡潔,且易於配置。
// 建立上下文
const AppContext = React.createContext('')
// 由 Provider 提供 props
<AppContext.Provider value={this.state}>
{this.props.children}
</AppContext.Provider>
// 由 Consumer 接收 props
<AppContext.Consumer>
{this.props.children}
</AppContext.Consumer>
複製代碼
上面是 context 大體的工做原理,基於此,項目中抽出了一個統一的 app 生成器:
import React from 'react'
import Pages from 'client/pages'
import AppContextProvider from 'hocs/withAppContext'
// 這裏由 client 和 server 端共享,context 由外部傳入,這裏就有了全局的 props 了。
export const renderBaseApp = context => {
return (
<AppContextProvider appContext={context}>
<Pages />
</AppContextProvider>
)
}
export default renderBaseApp
複製代碼
服務端渲染的時候就能抓取到這個請求,並把請求回來的數據塞進 context 中,經過 Provider 提供給全部的組件。
dangdangdang 重點在下面,所謂同構,就是服務端吐一個 html
頁面,可是頁面綁定的點擊等事件如何執行呢,服務端是沒有 dom
這個概念的,所以最最重要的同構就是吐出來的 html
仍然要加載客戶端打包的 js
完成相關事件的綁定
import React from 'react'
import path from 'path'
import fs from 'fs'
import Mustache from 'mustache'
import {StaticRouter} from 'react-router-dom'
import {renderToString} from 'react-dom/server'
import { getBuildFile, getAssetPath } from './utils'
import template from './template'
import renderBaseApp from 'lib/baseApp'
let ssrStyles = []
// 建立一個 ReactServer 類供服務端調用,這個類處理與 html 模版相關的一切東西
class ReactServer {
constructor(props) {
Object.assign(this, props)
}
// 獲取客戶端全部的打包的文件
get buildFiles() {
return getBuildFile()()
}
// 獲取須要的打包文件,這裏只須要js文件
get vendorFiles() {
return Object.keys(this.buildFiles).filter(key => {
const item = this.buildFiles[key]
return path.extname(item) === '.js'
})
}
// 拼接 script 標籤字符串,接收 context 參數存儲數據
getScripts(ctx) {
return this.vendorFiles
.filter(item => path.extname(item) === '.js')
.map(item => `<script type="text/javascript" src='${getAssetPath()}${item}'></script>`)
.reduce((a, b) => a + b, `<script type="text/javascript">window._INIT_CONTEXT_ = ${JSON.stringify(ctx)}</script>`)
}
// 服務端渲染初期就把 css 文件添加進來, 因爲 isomorphic-style-loader提供給咱們了
_getCss()這個方法,所以能夠將 css 文件在服務端拼接成 style 標籤,獲得的頁面最開始就有了樣式
getCss() {
// 讀取初始化樣式文件
const cssFile = fs.readFileSync(path.resolve(__dirname, '../client/index.css'), 'utf-8')
const initStyles = `<style type="text/css">${cssFile}</style>`
const innerStyles = `<style type="text/css">${ssrStyles.reduceRight((a, b) => a + b, '')}</style>`
// 服務端 css 包含兩部分,一個是初始化樣式文件,一個是 css modules 生成的樣式文件,都在這裏插進來
return initStyles + innerStyles
}
// 這個方法提供給 withStyle hoc 使用,目的是把頁面中的樣式都提取出來
addStyles(css) {
const styles = typeof css._getCss === 'function' ? css._getCss() : ''
if(!ssrStyles.includes(styles)) {
ssrStyles.push(css._getCss())
}
}
renderTemplate = props => {
return Mustache.render(template(props))
}
renderApp(ctx, context) {
const html = renderToString((
<StaticRouter location={ctx.url} context={context}>
// 這裏統一下發一個 addStyles 函數供 withStyle hoc 使用,能夠理解爲下發一個爪子,把組件中的樣式都抓回來
{renderBaseApp({...context, addStyles: this.addStyles, ssrStyles: this.ssrStyles})}
</StaticRouter>
))
return this.renderTemplate({
title: '豆瓣',
html,
scripts: this.getScripts(context),
css: this.getCss()
})
}
}
export default ReactServer
複製代碼
上面這個 getCss 鉤子是如何抓取我頁面中的樣式的呢,這得益於 withStyle hoc:
/**
* 目前僅供開發環境下提取 CSS
*/
import React from 'react'
import hoistNonReactStatics from 'hoist-non-react-statics'
import { withAppContext } from './withAppContext'
function devWithStyle (css) {
if (typeof window !== 'undefined') {
return x => x
}
return function devWithStyleInner (Component) {
const componentName = Component.displayName || Component.name
class CSSWrapper extends React.Component {
render () {
if (typeof this.props.addStyles === 'function') {
this.props.addStyles(css)
}
return <Component {...this.props} css={css} />
}
}
hoistNonReactStatics(CSSWrapper, Component)
CSSWrapper.displayName = `withStyle(${componentName})`
return withAppContext('addStyles')(CSSWrapper)
}
}
function prodwithStyle () {
return x => x
}
const withStyle = process.env.NODE_ENV === 'production' ? prodwithStyle : devWithStyle
export default withStyle
複製代碼
而後在頁面中引入:
import React from 'react'
import withStyle from 'hocs/withStyle'
import JumpLink from './JumpLink'
import css from './MovieCell.css'
class MovieCell extends React.Component {
render() {
const {data = {}} = this.props
return (
<JumpLink href={data.url} blank className={css.root}>
<img src={data.cover || 'https://img3.doubanio.com/view/photo/s_ratio_poster/public/p480747492.jpg'} className={css.cover} />
<div className={css.title}>{data.title}</div>
<div className={css.rate}>{data.rate} 分</div>
</JumpLink>
)
}
}
export default withStyle(css)(MovieCell)
複製代碼
在每一個用到樣式的插入這個 hoc 把樣式抓到服務端處理,這就是 css 的處理。
你們可能注意到了,我在插入客戶端打包後的腳本時,還插入了這樣一個腳本
<script type="text/javascript">window._INIT_CONTEXT_ = ${JSON.stringify(ctx)}</script>
複製代碼
這是由於同構以前客戶端和服務端是兩個服務,數據沒法共享,我在服務端把數據下發以後,在執行客戶端的js過程當中又被客戶端初始化清空了,但是我數據明明都已經有了啊,這一清空前面不都白作了嗎,啊摔...
爲了解決這個問題,就在這裏多插入一個腳本,存咱們初始化的數據,在客戶端渲染的過程當中,初始的context 直接從window中獲取就能夠了
class App extends React.Component {
render() {
return (
<BrowserRouter>
{renderBaseApp(window._INIT_CONTEXT_)}
</BrowserRouter>
)
}
}
export default App
複製代碼
到如今咱們的服務端渲染基本已經完成了,啓動服務以後看頁面,
這裏咱們能夠看到服務端確實把渲染好的頁面直接吐出來了,而客戶端渲染卻只獲得一個空的html文件,再下載js去加載頁面內容,並且因爲我用的豆瓣和掘金api,在客戶端請求跨域,只有在服務端能拿到數據,這裏又發現ssr的另外一個好處了~~~並且因爲請求是在服務端發的,在頁面中是看不到請求的api的。
到這裏咱們基本已經完成了 基於 context api 的服務端渲染了,可是還有一個遺留的問題,若是我在服務端請求失敗,吐出來頁面也沒有數據該怎麼辦呢?
因此要針對這種狀況作一些特殊的處理。
這裏增長了一個 clientFetch 的 hoc,對有異步請求的頁面都套上這個 hoc,這個 hoc 的做用是客戶端渲染的過程當中發現若是沒有想要的數據,斷定爲請求失敗,在客戶端從新請求一次。
/**
* 服務端請求失敗時 client 端的發請求邏輯
*/
import hoistNonReactStatics from 'hoist-non-react-statics'
import {pick} from 'lodash'
import { withAppContext } from 'hocs/withAppContext'
const defaultOptions = {
// 在瀏覽器端 didMount 和 didUpdate 時默認觸發
client: true,
// 自動注入獲取到的數據至 props 中 ([fetchId], error, pending),指定一個 id
fetchId: null
}
export default function clientFetch (options = {}) {
options = Object.assign({}, defaultOptions, options)
const { client: shouldFetch, fetchId } = options
return function clientFetchInner (Component) {
if (!Component.prototype.getInitialProps) {
throw new Error(`getInitialProps must be defined`)
}
// 這裏繼承的是傳入的 Component
class clientFetchWrapper extends Component {
constructor(props) {
super(props)
this.getInitialProps = this.getInitialProps.bind(this)
}
static defaultProps = {
[fetchId]: {}
}
shouldGetInitialProps() {
return this.props[fetchId].pending === undefined
}
componentDidMount () {
if (typeof super.componentDidMount === 'function') {
super.componentDidMount()
}
this.fetchAtClient()
}
componentDidUpdate (...args) {
if (typeof super.componentDidUpdate === 'function') {
super.componentDidUpdate(...args)
}
this.fetchAtClient()
}
// 客戶端同構請求
fetchAtClient () {
if (!shouldFetch) {
return
}
if (typeof this.shouldGetInitialProps === 'function') {
if (this.shouldGetInitialProps() && typeof this.getInitialProps === 'function') {
this.fetch()
}
}
}
// client 的實際請求發送邏輯
fetch () {
this.setContextProps({ pending: true })
return this.getInitialProps()
.then(data => {
this.setContextProps({ pending: false, data, error: null })
}, error => {
this.setContextProps({ pending: false, data: {}, error })
})
}
// connect 場景下,注入數據到 appContext
setContextProps (x) {
if (!fetchId) {
return
}
this.props.setAppContext(appContext => {
const oldVal = appContext[fetchId] || {}
const newVal = {[fetchId]: { ...oldVal, ...x }}
return newVal
})
}
render () {
return super.render()
}
}
hoistNonReactStatics(clientFetchWrapper, Component)
return withAppContext(
function (appContext) {
const con = pick(appContext, ['setAppContext'])
return Object.assign(con, (appContext || {})[fetchId])
}
)(clientFetchWrapper)
}
}
複製代碼
這個 hoc 有兩個做用,一是服務端請求失敗發二次請求,保證頁面的有效性,第二是當我不作服務端渲染時,依然能夠將客戶端打包文件部署到線上,默認都會走這個 hoc 的發請求邏輯。這樣至關於給上了一層保險。到這裏纔算真正作到客戶端服務端同構了,項目還須要持續優化~~
喜歡的小夥伴點個star吧~~
若是有任何問題,歡迎留言或者提issue,項目有任何須要改進的地方,也歡迎指正~~
另外在用豆瓣和掘金的api
的時候突發奇想,簡單設計了一個本身的 koa
爬蟲框架,抓取靜態頁面或者動態api
,感興趣的小夥伴也能夠瞄一眼~~