基于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.youkuaiyun.com/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代码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值