基於React服務器渲染搭建一個仿Cnode社區WebAPP

 

 

 

 

一.什麼是服務器渲染

1.1不同於客戶端渲染,以之前的React開發的小項目爲例,使用客戶端渲染SPA應用時,在輸入url後,dns解析成ip,瀏覽器發送http請求到對應ip的指定端口下,服務器接收到http請求,返回的是一個打包好的bundle.js,瀏覽器解析js,動態創建Dom,我們用的ReactDOM.render也就是這樣的一個方法

1.2服務器渲染返回是有內容的html,我們啓動client和server,分別在8888端口和3333端口,我們訪問3333端口,看到

 

 

我們看到在response中,html中有head,裏面包含了一些meta信息,title信息,這樣可以用於SEO,也包含了CSS信息,在body中,我們掛載了一個root根div。然後其中是有完整的DOM結構的,瀏覽器會先解析html文件,解析DOM樹,解析CSS,然後合併成渲染樹,然後我們看到在下方的script標籤中,有一個js文件,瀏覽器解析js,爲DOM綁定事件。這不就是我們的Web頁面了嗎~

1.3服務器渲染使用的React基本上和客戶端渲染沒有什麼不同,只是有幾個包使用的Api不同,我們還是正常的用React開發頁面

1.4服務器渲染使用的是Webpack進行打包,然後這裏用的是Mobx做React的數據管理,沒有使用redux,Mobx是一個更簡單上手的數據管理庫,它不具備redux的時間回溯功能,也不具備redux的嚴格數據流,除了用action改變state,也可以直接訪問state進行改變,redux只能通過嚴格的定義action,dispatch不同的aciton來通知reducer改變state,還可以使用中間件對action進行攔截和包裝(網絡數據請求),扯遠了~

1.5爲什麼要渲染一次client還要渲染一次server呢,因爲server我們是在node環境中開發的,node環境下,我們沒法調用可以被node識別的瀏覽器的事件綁定等api,我們需要起一個webpack-dev-server(開發模式下,生產模式直接打包放到cdn上)來生成一個app.js,這個app.js是客戶端渲染打包的代碼,我們在server渲染的時候,需要用React提供的服務器渲染的api,用bundle,templete進行渲染,這個templete就是客戶端生成的ejs HTML模板,這個模板中的script標籤引入了我們在客戶端渲染的時候生成的app.js,vendor.js manfeist.js,這樣,一個http請求發送到服務器上,我們返回的html的body和head部分是我們server渲染出的string在瀏覽器直接解析的,script標籤中則引入了client渲染的app.js等js文件,瀏覽器解析js,然後進行混合渲染標記,爲DOM綁定事件,完成同構。

1.6首屏渲染好了以後,接下來每個數據獲取的http請求都被服務器代理,也避免的瀏覽器的跨域問題,這裏解析出http的req的url中的path req.path再拼接一個baseURL,這裏就是cnode的公開api接口,然後用axios庫發送過去,返回一個Promise我們解析它並且

1.7路由同構問題https://blog.csdn.net/sinat_17775997/article/details/83151136

1.8在這個小項目裏,我們使用了mobx而不是redux,mobx和redux的區別。mobx不是單向的,

mobx定義不同的state類,然後使用provider給app注入state

mobx推薦使用action修飾的函數來改變state,使用的時候,用inject給React組件注入store就可以再React組件中的props中拿到了

export class TopicStore {
  @observable topics
  @observable details
  @observable createdTopics
  @observable syncing = false
  @observable tab = undefined

  constructor(
    { syncing = false, topics = [], tab = null, details = [] } = {},
  ) {
    this.syncing = syncing
    this.topics = topics.map(topic => new Topic(createTopic(topic)))
    this.details = details.map(detail => new Topic(createTopic(detail)))
    this.tab = tab
  }

  @computed get topicMap() {
    return this.topics.reduce((result, topic) => {
      result[topic.id] = topic
      return result
    }, {})
  }

  @computed get detailsMap() {
    return this.details.reduce((result, topic) => {
      result[topic.id] = topic
      return result
    }, {})
  }

  @action addTopic(topic) {
    this.topics.push(new Topic(createTopic(topic)))
  }


  
  @action fetchTopics(tab) {
    return new Promise((resolve, reject) => {
      if (tab === this.tab && this.topics.length > 0) {
        resolve()
      } else {
        this.tab = tab
        this.topics = []
        this.syncing = true
        get('/topics', {
          mdrender: false,
          tab,
        }).then(resp => {
          if (resp.success) {
            const topics = resp.data.map(topic => {
              return new Topic(createTopic(topic))
            })
            this.topics = topics
            this.syncing = false
            resolve()
          } else {
            this.syncing = false
            reject()
          }
        }).catch(err => {
          reject(err)
        })
      }
    })
  }

  @action createTopic(title, tab, content) {
    return new Promise((resolve, reject) => {
      post('/topics', {
        title, tab, content,
      })
        .then(data => {
          if (data.success) {
            const topic = {
              title,
              tab,
              content,
              id: data.topic_id,
              create_at: Date.now(),
            }
            this.createdTopics.push(new Topic(createTopic(topic)))
            resolve(topic)
          } else {
            reject(new Error(data.error_msg || '未知錯誤'))
          }
        })
        .catch((err) => {
          if (err.response) {
            reject(new Error(err.response.data.error_msg || '未知錯誤'))
          } else {
            reject(new Error('未知錯誤'))
          }
        })
    })
  }

  @action getTopicDetail(id) {
    console.log('get topic id:', id) // eslint-disable-line
    return new Promise((resolve, reject) => {
      if (this.detailsMap[id]) {
        resolve(this.detailsMap[id])
      } else {
        get(`/topic/${id}`, {
          mdrender: false,
        }).then(resp => {
          if (resp.success) {
            const topic = new Topic(createTopic(resp.data), true)
            this.details.push(topic)
            resolve(topic)
          } else {
            reject()
          }
        }).catch(err => {
          reject(err)
        })
      }
    })
  }

  toJson() {
    return {
      page: this.page,
      topics: toJS(this.topics),
      syncing: toJS(this.syncing),
      details: toJS(this.details),
      tab: this.tab,
    }
  }
}

二.項目流程

 

這個小項目我想分成幾個不同的部分總結下:

1.如何使用Webpack配置服務器渲染的環境

都在代碼裏註釋了,這裏就不做過多詳解。列舉幾個常見的問題

分別介紹bundle,chunk,module是什麼

bundle:是由webpack打包出來的文件,
chunk:代碼塊,一個chunk由多個模塊組合而成,用於代碼的合併和分割。
module:是開發中的單個模塊,在webpack的世界,一切皆模塊,一個模塊對應一個文件,webpack會從配置的entry中遞歸開始找出所有依賴的模塊。

分別介紹什麼是loader?什麼是plugin?

loader:模塊轉換器,用於將模塊的原內容按照需要轉成你想要的內容
plugin:在webpack構建流程中的特定時機注入擴展邏輯,來改變構建結果,是用來自定義webpack打包過程的方式,一個插件是含有apply方法的一個對象,通過這個方法可以參與到整個webpack打包的各個流程(生命週期)。

什麼是模塊化,不同的模塊化標準有哪些

https://segmentfault.com/a/1190000015991869?utm_source=tag-newest

2.如何搭建服務器渲染的node代理服務器

我們先看一下server.js也就是服務器的啓動入口文件:

是一個express服務器,調動常見的bodyparser中間件和session中間件,使用app.use('/api/user' ,require('./util/user-api')),傳入對應的http函數(req,res,next) => {},開發和生產模式我們用兩套配置,通過判斷cross-env提供的NODE_ENV定義來通過process.env這個node全局環境變量來判斷

const express = require('express')
const app = express()
const fs = require('fs')
const path = require('path')
const bodyParser = require('body-parser')
const session = require('express-session')
const favicon = require('serve-favicon')
const serverRender = require('./util/server-render')

const isDev = process.env.NODE_ENV === 'development'

app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: false }))

app.use(session({
  maxAge: 10 * 60 * 1000,
  name: 'tid',
  resave: false,
  saveUninitialized: false,
  secret: 'I will teach you'
}))

app.use('/api/user', require('./util/user-api'))
app.use('/api', require('./util/inject-token'))

app.use(favicon(path.join(__dirname, '../favicon.ico')))

if (!isDev) {
  app.use('/public', express.static(path.join(__dirname, '../dist')))
  const serverEntry = require('../dist/server-entry')
  const template = fs.readFileSync(path.join(__dirname, '../dist/server.ejs'), 'utf8')
  app.get('*', function (req, res, next) {
    serverRender(serverEntry, template, req, res).catch(next)
  })
} else {
  const devStatic = require('./util/dev-static')
  devStatic(app)
}

app.use(function (error, req, res, next) {
  console.error(error)
})

const host = process.env.HOST || '0.0.0.0'
const port = process.env.PORT || 3333

app.listen(port, host, function () {
  console.log(`server is listening on ${host}:${port}`)
})
const axios = require('axios')
const path = require('path')
const MemoryFileSystem = require('memory-fs')

const proxy = require('http-proxy-middleware')
const serverRender = require('./server-render')

const webpack = require('webpack')
const webpackServerConfig = require('../../build/webpack.config.server')

const mfs = new MemoryFileSystem()
const serverCompiler = webpack(webpackServerConfig)
//nodejs module模塊
const NativeModule = require('module')
//
const vm = require('vm')

let serverBundle
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
  if (err) throw err
  stats = stats.toJson()
  stats.errors.forEach(err => console.error(err))
  stats.warnings.forEach(warn => console.warn(warn))

  const bundlePath = path.join(
    webpackServerConfig.output.path,
    webpackServerConfig.output.filename
  )

  // mobx會有多個模塊存在的問題,所以把mobx作爲exteneral使用
  // 讓bundle引用mobx的時候從node_modules下面引入
  // 保持mobx實例在運行環境中只存在一份
  //node的vm模塊,
  const m = { exports: {} }
  try {

    //自己
    const bundle = mfs.readFileSync(bundlePath, 'utf-8')
    const wrapper = NativeModule.wrap(bundle)
    const script = new vm.Script(wrapper, {
      filename: 'server-bundle.js',
      displayErrors: true
    })
    const result = script.runInThisContext()
    result.call(m.exports, m.exports, require, m)
    serverBundle = m.exports

    
  } catch (err) {
    console.log(err.stack)
  }
})

const getTemplate = () => {
  return new Promise((resolve, reject) => {
    axios.get('http://localhost:8888/public/server.ejs')
      .then(res => {
        resolve(res.data)
      })
      .catch(err => {
        console.error('get template error', err)
      })
  })

}

const getStoreState = (stores) => {
  return Object.keys(stores).reduce((result, storeName) => {
    result[storeName] = stores[storeName].toJson()
    return result
  }, {})
}

module.exports = function handleDevSSR(app) {

  app.use('/public', proxy({
    target: 'http://127.0.0.1:8888'
  }))

  app.get('*', function (req, res, next) {
    if (!serverBundle) {
      return res.send('waiting for compile')
    }
    getTemplate().then(template => {
      return serverRender(serverBundle, template, req, res)
    }).catch(err => {
      next(err)
    })
  })
}

我們以開發模式爲例,我們需要一個自定的模塊,打包我們生成的服務端渲染所需的代碼,也就是serverbundle.js,

然後把serverBundle和templete HTML模板作爲參數,我們調用我們打包的serverbundle對象的


const Helmet = require('react-helmet').default
const ReactDomServer = require('react-dom/server')
const ejs = require('ejs')
const serialize = require('serialize-javascript')
const SheetsRegistry = require('react-jss').SheetsRegistry
const colors = require('material-ui/colors')
const createMuiTheme = require('material-ui/styles').createMuiTheme
const create = require('jss').create
const preset = require('jss-preset-default').default
const asyncBootstrapper = require('react-async-bootstrapper').default


//store數組化
const getStoreState = (stores) => {
  return Object.keys(stores).reduce((result, storeName) => {
    result[storeName] = stores[storeName].toJson()
    return result
  }, {})
}

module.exports = (bundle, template, req, res) => {
  const user = req.session.user
  const createApp = bundle.default
  const createStoreMap = bundle.createStoreMap
  const routerContext = {}
  const stores = createStoreMap()
  //如果已經加載了user,之前加載了會放到session中,這裏直接const user = req.seesion.user,如果存在,直接賦值
  //if(user) store.appState.user.info = user 
  if (user) {
    stores.appState.user.isLogin = true
    stores.appState.user.info = user
  }

  const theme = createMuiTheme({
    palette: {
      primary: colors.pink,
      accent: colors.lightBlue,
      type: 'light',
    },
  })

  //jss是可以用js寫css的meterialui的方式,知道就行
  const sheetsRegistry = new SheetsRegistry()
  const jss = create(preset())

  const app = createApp(stores, routerContext, sheetsRegistry, jss, theme, req.url)
  return new Promise((resolve, reject) => {
    asyncBootstrapper(app).then(() => {
      if (routerContext.url) {
        res.status(302).setHeader('Location', routerContext.url)
        res.end()
        return
      }
      const appString = ReactDomServer.renderToString(app)
      const helmet = Helmet.rewind()
      //首屏SEO,用helmet組件
      const html = ejs.render(template, {
        meta: helmet.meta.toString(),
        link: helmet.link.toString(),
        style: helmet.style.toString(),
        title: helmet.title.toString(),
        appString,
        initalState: serialize(getStoreState(stores)),
        materialCss: sheetsRegistry.toString()
      })
      res.send(html)
      resolve()
    }).catch(reject)
  })
}

關於路由前端路由重定向,如果前端路由有Redirect組件,那麼通過服務端的StaticRouter,會把url參數放到routerContext中,我們在後端可以通過判斷routerContext中是否有url屬性來跳轉頁面,要用StaticRouter包裹一下App

3.業務邏輯

業務部分包括首頁,和詳情頁和新建頁和個人中心頁,佈局用的是meterialUi,這也石第一次用UI庫,需要用JSSPRoveder包裹一下,css也是定義成js對象然後解析,webpack打包的時候會解析它們,這裏webpack在打包的時候,會從server-entry開始解析依賴,並且在同構的時候,app.js會給這個入口注入它所需的這幾個參數,數據獲取老生常談了,在componentDidMount的時候調用fetchData,通過服務器代理髮送到cnode,在fetchData中,return的promise也不需要resovle,只需要傳給封裝了axios的get請求所需的url參數,然後從返回的promise對象的then方法把數據存儲到state中,這裏用的mobx。

  
  @action fetchTopics(tab) {
    return new Promise((resolve, reject) => {
      if (tab === this.tab && this.topics.length > 0) {
        resolve()
      } else {
        this.tab = tab
        this.topics = []
        this.syncing = true
        get('/topics', {
          mdrender: false,
          tab,
        }).then(resp => {
          if (resp.success) {
            const topics = resp.data.map(topic => {
              return new Topic(createTopic(topic))
            })
            this.topics = topics
            this.syncing = false
            resolve()
          } else {
            this.syncing = false
            reject()
          }
        }).catch(err => {
          reject(err)
        })
      }
    })
  }
export default (stores, routerContext, sheetsRegistry, jss, theme, url) => {
  jss.options.createGenerateClassName = createGenerateClassName
  return (
    <Provider {...stores}>
      <StaticRouter context={routerContext} location={url}>
        <JssProvider registry={sheetsRegistry} jss={jss}>
          <MuiThemeProvider theme={theme} sheetsManager={new Map()}>
            <App />
          </MuiThemeProvider>
        </JssProvider>
      </StaticRouter>
    </Provider>
  )
}

4.項目上線的其他配置

1.瞭解一下cdn是什麼,可以提高下載的速度

把client打包的代碼放到cdn上,從cdn請求資源會更快,

2.總結一下webpack的優化:

(1)第三方包不要打包到app.js中,單獨打包到vendor中,可以儘可能利用緩存

(2)使用CommonsChunkPlugin對webpacl每次打包都會自動變更的一部分單獨打包,不讓它在app中

(3)使用UglifyJsPlugin壓縮Js代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章