内容简介:本来在上周就想写下这篇文章,但是在学习的过程中,越来越觉得之前的很多思路需要修改,所以就下定决心,等我重构完这个项目之后再写第二篇教程。先上代码仓库看过我第一篇文章的朋友们应该已经大致了解了
本来在上周就想写下这篇文章,但是在学习的过程中,越来越觉得之前的很多思路需要修改,所以就下定决心,等我重构完这个项目之后再写第二篇教程。
先上代码仓库 github
看过我第一篇文章的朋友们应该已经大致了解了 react ssr 的基本思路了,如果没有第一篇文章的同学建议先看教程一,但是只是掌握这些还是远远不够的。
首先梳理下上篇教程所带来的问题
- 路由配置了两次,并且还是保持 react-router 和 koa-router 路径配置保持一致。
- 同样的请求,需要编写两次。
- 即使客户端资源完成打包,服务端代码依旧还是依赖了客户端的源代码。
- 没办法写 css module 。
- 开发环境不友好,需要启动两个服务,并且热更新支持很差。
非常幸运,以上的问题在 v2 中都已解决。下面就跟着我依次解决上述问题,由于考虑文章篇幅,这次我不会贴出太多的源码,只叙述我的思路以及部分核心代码,强烈建议掘友们自己动手 码一码 。
重构路由以及请求
在上次的文章中我分别采用了 react-router 和 koa-router 来构建项目的路由,并且手动保持两端路由的一致性,这样的好处是更加的灵活以及解耦,但缺点是是编写很多重复的代码,考虑实际上我们实际开发中,对于输出 html 的路由前后端基本是一致的,并且数据处理出入不大,则我们在 koa-router 的 html 路由部分可以完全采用 react-router 的配置。
首先我们 npm i react-router-config -S
,这个包在后面会发挥至关重要的作用。
重构路由配置如下
import React from 'react'; import Home from './pages/home' import Detail from './pages/detail' export default [ { path: '/', component: Home, exact: true, }, { path: '/detail/:id', component: Detail, exact: true, }, ] 复制代码
koa-router 修改如下
router.get('/api/flash', HomeControl.flash); router.get('/api/column', HomeControl.column); router.get('/api/detail', DetailControl.detail); router.get('*', async (ctx, next) => { await render(ctx, template); next(); }) 复制代码
这样我们所有直出 html 的路由部分走同一个控制器,想知道 render 干了什么事?
其实和之前一样,通过 renderToString 输出对应路由的 html ,然后填充数据,返回最终的 html ,简单看下
import { renderRoutes } from 'react-router-config'; function templating(template) { return props => template.replace(/<!--([\s\S]*?)-->/g, (_, key) => props[key.trim()]); } function(ctx, template) { try { const render = templating(template); const html = renderToString( <Provider store={store}> <StaticRouter location={ctx.url} context={ctx}> { renderRoutes(routerConfig) } // 这里的routerConfig就是上面配置的路由信息 </StaticRouter> </Provider> ); const body = render({ html, store: `<script>window.__STORE__ = ${JSON.stringify(ctx.store.getState())}</script>`, }); ctx.body = body; ctx.type = 'text/html'; } catch (err) { console.error(err.message); ctx.body = err.message; ctx.type = 'text/html'; } } 复制代码
在模板中使用注释当做占位符,抛弃了花括号,这样前后端就可以共用一个模板了。
但是上面的 store 部分我们怎么去获取到呢?在之前我们是在每个路由渲染之前请求数据然后将数据传递给 render 函数,现在我们路由走的是同一个控制器,应该如何处理 store ?
现在我们就来重构下 store
首先在每一个路由组件上面编写一个静态方法 asyncData
function mapDispatchToProps(dispatch) { return { fetchHome: (id) => dispatch(homeActions.fetchHome(id)), fetchColumn: (page) => dispatch(homeActions.fetchColumn(page)), } } class Home extends React.Component { state = { tabs: [ { title: '科技新闻', index: 0 }, { title: '24h快讯', index: 1 } ], columnPage: this.props.column.length > 0 ? 1 : 0, } static asyncData(store) { const { fetchHome, fetchColumn } = mapDispatchToProps(store.dispatch); // 这里必须return Promise 并且这里发起请求走的是node环境,api路径必须写绝对路径。 return Promise.all([ fetchHome(), fetchColumn(), ]) } } 复制代码
然后在我们的 render 函数中去调用对应组件的 asyncData 去初始化 store
import { renderRoutes, matchRoutes } from 'react-router-config'; import createStore from '../createStore.js' function templating(template) { return props => template.replace(/<!--([\s\S]*?)-->/g, (_, key) => props[key.trim()]); } function(ctx, template) { try { // 初始化store const store = createStore(); // 先获取所有匹配上的路由信息 const routes = matchRoutes(routerConfig, ctx.url); // 如果没有匹配上路由则返回404 if (routes.length <= 0) { return reject({ code: 404, message: 'Not Page' }); } // 等所有数据请求回来之后在render, 注意这里不能用ctx上的路由信息,要使用前端的路由信息 const promises = routes .filter(item => item.route.component.asyncData) // 过滤掉没有asyncData的组件 .map(item => item.route.component.asyncData(store, item.match)); // 调用组件内部的asyncData,这里就修改了store Promise.all(promises).then(() => { ....同上 }) } catch (err) { ....同上 } } 复制代码
现在 store 的初始化完全都由 action 控制,不需要我们手动的通过初始值去初始化 store 。不懂的看下图
好的,到这里我们路由和数据处理以及重构完成。
重构koa代码
在上篇教程中,由于我们的服务端代码中充斥着 jsx 代码,所以我们在运行之前需要使用 babel 编译下源代码,可是 jsx 代码就那么一小部分,为了这一小部分,而且编译整个服务端代码,这是非常错误的决定,所以现在我们来重构下 koa 的代码
既想不编译 koa 代码,又想让 node 识别 jsx ,那我们应该怎么处理呢?非常的简单,只要我们把包含 jsx 代码的这部分抽取到一个单独的文件,然后我们只编译这个文件,这样不就行了?
其实上面的思路就是编写一个服务端入口文件。现在我们既有客户端入口,也有服务端入口,并且他们都依赖 React React-router Redux ,则我们先编写一个公共文件,导出这部分的代码。
// createApp.js import routerConfig from './router'; import createStore from './redux/store/createStore'; import { renderRoutes } from 'react-router-config'; export default function(store = {}) { return { router: renderRoutes(routerConfig), store: createStore(store), routerConfig, } } 复制代码
然后编写 server-entry.js 返回一个 controller
import ReactDom from 'react-dom'; import { StaticRouter } from 'react-router-dom'; import React from 'react'; import { Provider } from 'react-redux'; import { matchRoutes } from 'react-router-config'; import createApp from './createApp'; export default ctx => { return new Promise((resolve, reject) => { const { router, store, routerConfig } = createApp(); const routes = matchRoutes(routerConfig, ctx.url); // 如果没有匹配上路由则返回404 if (routes.length <= 0) { return reject({ code: 404, message: 'Not Page' }); } // 等所有数据请求回来之后在render, 注意这里不能用ctx上的路由信息,要使用前端的路由信息 const promises = routes .filter(item => item.route.component.asyncData) .map(item => item.route.component.asyncData(store, item.match)); Promise.all(promises).then(() => { ctx.store = store; // 挂载到ctx上,方便渲染到页面上 resolve( <Provider store={store}> <StaticRouter location={ctx.url} context={ctx}> { router } </StaticRouter> </Provider> ) }).catch(reject); }) } 复制代码
现在我们只需要编写一个服务端打包的 webpack 配置文件, 将服务端入口打包成 node 可以识别的文件,然后在node端引入这个编译后的 controller 即可。
const merge = require('webpack-merge'); const webpack = require('webpack'); const baseConfig = require('./webpack.base.config'); const config = require('./config')[process.env.NODE_ENV]; const nodeExternals = require('webpack-node-externals'); const { resolve } = require('./utils'); module.exports = merge(baseConfig(config), { target: 'node', devtool: config.devtool, entry: resolve('app/server-entry.js'), output: { filename: 'js/server-bundle.js', libraryTarget: 'commonjs2' // 使用commonjs模块化 }, // 服务端打包的时候忽略外部的npm包 externals: nodeExternals({ // 当然外部的css还是可以打进来的 whitelist: /\.css$/ }), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(config.env), 'process.env.VUE_ENV': '"server"' }), ] }) 复制代码
具体的请看 github ,值得说明的是,千万不要吧 css 打进这个包, node 是不识别 css 的,所以需要抽离 css 代码。
现在我们服务端代码可以舒舒服服的写代码了,无需编译即可运行,并且我们不在依赖前端的源代码,也可以开心的使用 css module 了
开启 css module 很简单, css-loader 就自带这个功能。
{ loader: 'css-loader', options: { modules: true, // 开启css module localIdentName: '[path][local]-[hash:base64:5]' // css module 命名规则 }, }, 复制代码
最后我们只需要 npm build
打包客户端资源和服务端资源,就可以直接 npm start
启动服务了。
由于我们启动的服务需要依赖打包后的文件,生产环境没问题,但是开发环境我总不能每次修改了代码就要重新打包一次吧,这样会严重影响效率。下面我们来说下开发环境如何处理这个问题呢?
开发环境构建
起初我准备和上次一样,开启两个服务,客户端使用 webpack-dev-server 服务端做一层转发,将静态资源转发到 dev-server 服务,但是这样做在开发环境就不能实现 ssr ,所以我决定合并这两个服务,由 koa 实现 dev-server 的功能。
编写 dev-server.js
const fs = require('fs') const path = require('path') const MFS = require('memory-fs') const webpack = require('webpack') const chokidar = require('chokidar') const clientConfig = require('./webpack.client.config') const serverConfig = require('./webpack.server.config') const readFile = (fs, file) => { return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8') } module.exports = function(app, templatePath) { let bundle let template let clientHtml // 这里其实就是吧resolve单独拿出来了,其实你也可以直接吧下面的代码写在promise里面,这样的好处就是减少代码嵌套。 let ready const readyPromise = new Promise(r => { ready = r }) // 更新触发的函数 const update = () => { if (bundle && clientHtml) { ready({ bundle, clientHtml }); } } // 监听模版文件 template = fs.readFileSync(templatePath, 'utf-8') chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8') console.log('index.html template updated.') update() }) // 添加热更新的入口 clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] clientConfig.output.filename = '[name].js' clientConfig.plugins.push( new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ) // 创建dev服务 const clientCompiler = webpack(clientConfig) const devMiddleware = require('koa-webpack-dev-middleware')(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true }); app.use(devMiddleware) clientCompiler.hooks.done.tap('DevPlugin', stats => { stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return // 获取dev内存中入口html clientHtml = readFile( devMiddleware.fileSystem, 'server.tpl.html', ) update() }) // 开启热更新 app.use(require('koa-webpack-hot-middleware')(clientCompiler)) // 监听并且更新server入口文件 const serverCompiler = webpack(serverConfig) // 创建一个内存文件系统 const mfs = new MFS() serverCompiler.outputFileSystem = mfs serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() if (stats.errors.length) return // 获取内存中的server-bundle,并用eval函数执行,返回controller bundle = eval(readFile(mfs, 'js/server-bundle.js')).default; update() }) return readyPromise } 复制代码
最后在 koa 中区分下两个环境
if (isPro) { // 生成环境直接使用打包好的资源 serverBundle = require('../dist/js/server-bundle').default; template = fs.readFileSync(resolve('../dist/server.tpl.html'), 'utf-8'); } else { // 开发环境创建一个服务 readyPromise = require('../build/dev-server')(app, resolve('../app/index.html')); } router.get('*', async (ctx, next) => { if (isPro) { await render(ctx, serverBundle, template); } else { // 等待内存中文件获取到之后再渲染。 const { bundle, clientHtml } = await readyPromise; await render(ctx, bundle, clientHtml); } next(); }) 复制代码
好了,本篇教程到这里就结束了,如果帮助到你了,那么请不要吝啬你的赞和 start 有问题可以在下面评论或者在 github 上留言。最后各位看官给我的 github 点个 start ,小编感激不尽啊。
以上所述就是小编给大家介绍的《使用 React + Koa 从零开始一步一步的带你开发一个 36kr SSR 案例(二)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 从头开始编写任何机器学习算法的6个步骤:感知器案例研究
- iOS混合开发库(GICXMLLayout)布局案例分析(1)今日头条案例
- 17个云计算开源案例入围第三届中国优秀云计算开源案例评选
- Spring Boot 2.0 基础案例(十二):基于转账案例,演示事务管理操作
- 基于MNIST数据集实现2层神经网络案例实战-大数据ML样本集案例实战
- Nginx相关实战案例
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Google 广告优化与工具
宫鑫 / 电子工业出版社 / 2010-7 / 60.00元
《Google 广告优化与工具》全面地阐述了Google AdWords这个高效广告投放平台的各方面内容,包括Google广告的渠道组成、质量得分、关键词和广告语策略、后期跟踪机制以及各种辅助工具的使用等。引导读者一步步建立一个强大的Google搜索引擎营销投放策略。本书案例丰富、知识面广且层次清晰,适用于各个层次的搜索引擎营销优化人员,不论是电商企业的领导人、工作在账户优化第一线的具体操作者、营......一起来看看 《Google 广告优化与工具》 这本书的介绍吧!