vue|分享vue项目的服务端渲染学习过程

最近抽出了点时间,弄了下vue ssr项目,至于ssr的优点就不多提了。学习路线参照了官方实例,有兴趣的同学可以去看下。
我的项目地址,主要使用了ssr+typescript+vuex+vue-cli 2.0,有兴趣的同学,欢迎start。
那么就先讲下前期的打包配置吧,本地开发,也就是所谓的dev,需要热更新等一系列便于调试的插件,所以需要区分webpack的配置。代码就不多提了,可以看下官方配置,也可以看下我的配置。
如果是使用js+vue-cli 2.0的同学,那么官方实例可以完美支持,一点都不需要动。我用的是ts+vue-cli 2.0写的,webpack 4.0以上才支持ts,所以需要升级webpack版本,但是4.0以后,有很多插件都弃用了,坑的一批。比如压缩css的插件ExtractTextPlugin,需要替换成MiniCssExtractPlugin,但是坑比的是服务端渲染还不能用(document is no defined),这里也是需要注意的点,千万不要在服务端和客户端都能触发的钩子中操作dom,比如created,asyncData。所以不能像之前那样写在base.config里面了,也就是服务端不能使用,如果你也碰到了这个问题,可以看下我的这篇从webpack 3.0升级到4.0的经历。
server.js
项目的起始点就是server.js文件,看下package.json,script命令就可以看出来,其实启动项目就是运行node server.js。官方实例用的那些缓存插件就不多提了,其实有些缓存配置可以配在nginx里面的。细心的同学一看app.all()就知道了,其实这就是开了个node服务器而已,我们前端跳转的路由,就相当于一个get请求,服务器接到这个请求,会根据vue提供的ssr插件,把页面渲染好之后再发送到客户端。在渲染的同时,会有个上下文对象context,记录这你想要往客户都传输的信息,什么都可以传,比如页面渲染时间,语言版本等等。

function render (req, res) { const s = Date.now()res.setHeader("Content-Type", "text/html"); res.setHeader("Server", serverInfo); // 往响应头里添加一些服务端信息const handleError = err => { if (err.url) { res.redirect(err.url) } else if(err.code === 404) { res.status(404).send('404 | Page Not Found') } else { res.status(500).send('500 | Internal Server Error') console.error(`error during render : ${req.url}`) console.error(err.stack) } }const context = { title: 'Confession-Wall', url: req.url }renderer.renderToString(context, (err, html) => { if (err) { return handleError(err) } res.send(html); if (!isProd) { console.log(`页面渲染耗时: ${Date.now() - s}ms`); } }) }app.all(`${config.BasePath}*`, isProd ? render : (req, res) => { if (req.method !== 'GET') return next(); readyPromise.then(() => render(req, res)) })const port = process.env.PORT || 3000 app.listen(port, () => { console.log(`server started at localhost:${port}`); })


main.ts
下图为main.ts的代码,由于为了每个用户从服务端拿到的是新的没有污染的代码,所以store,router,vue,每次都要new一个新的实例。
// main.ts import Vue from 'vue'; import App from './App.vue'; import LocalStore from './store/index' import LocalRouter from './router/index' // 在服务器端渲染时把当前的路由信息,同步进store中,也就相当于vuex store中多了个route module import { sync } from 'vuex-router-sync'; Vue.directive('focus', { inserted: function (el) { el.focus(); } }); export function createApp () { const store = new LocalStore(); const router = new LocalRouter(); sync(store, router); const app = new Vue({ router, store, render: h => h(App) }) return { app, router, store }; }

vuex-router-sync
至于使用了vuex-router-sync插件的效果如何,我们可以在浏览器的console里可以打印出来,因为服务端向客户端同步数据,是通过向window全局中注入一个对象,用来记录服务端的store信息。如下图所示,你会看到store里面会多了个route的module,不是必须的,你也可以不是使用,或者通过你自己的方式实现。
vue|分享vue项目的服务端渲染学习过程
文章图片

当然从服务端能带过来的不仅是store,也不仅仅往store里添加route信息,只要你需要的任何骚操作信息都可以,下面会讲到。
entry-server.ts
接下来应该到了entry-server.ts文件了,注释里写了我在学习时对其的理解。
// entry-server import { createApp } from './main'; export interface Context { title: string; url: string; state: any }// context由server.js中注入 export default (context: Context) => { return new Promise((resolve, reject) => { const { app, router, store } = createApp()const { url } = context const { fullPath } = router.resolve(url).route// 判断req里的请求地址是否等于当前路由 if (fullPath !== url) { return reject({ url: fullPath }) }// 如果等于,则把当前url,push进router中,便于客户端接管 router.push(url)router.onReady(() => { const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) { return reject({ code: 404 }) } // 如果路由匹配,则触发服务器端asyncData钩子,此钩子便是你组件定义的钩子函数, // 默认写在与methods同级,所以取的是其options,其实可以自行定义其位置,和实现方法 // 可以在这里对钩子重写,使之拥有更多功能 Promise.all(matchedComponents.map((Component:any) => { if (Component.options.asyncData) { return Component.options.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 把服务端请求到的数据,注入windows中的__INITIAL_STATE__中,便于客户端接管vuex store context.state = store.state; resolve(app); }).catch(reject); }, reject) }) }

需要注意的地方是这里暴露出来的方法,返回的是一个promise,之前写的时候不注意,踩了个大坑。Component.options.asyncData,这里其实可以自由发挥的,按官方那个实例来看,一般asyncData钩子是与methods同级,所以这里你去拿组件上的asyncData就可以了。至于叫不叫asyncData,你可以自行发挥,你也可以放在methods里面,怎么样的行。只要在这里能取到相应地方的相应方法就可以了。传入的参数你也可以自行发挥,比如传如isServer: true,用以区分是服务端渲染还是客户端渲染触发了这个钩子,以及重定向方法之类的。context.state就是向客户端注入的内容,可以自行添加东西。比如:
context.state ={ store: store.state, text: '我是服务端注入的内容' }

【vue|分享vue项目的服务端渲染学习过程】此时客户端接受的window.__INITIAL_STATE__就如图所示,这是你客户端同步状态的时候就要取对store了。
vue|分享vue项目的服务端渲染学习过程
文章图片

entry-client.ts
接下来应该就是entry-client.ts文件了。
import Vue from 'vue'; import 'es6-promise/auto'; import { createApp }from './main'; import { Route } from 'vue-router'; /** * 当组件复用时,触发asyncData钩子,重新请求数据 */ Vue.mixin({ beforeRouteUpdate (to: any, from: any, next: any) { const { asyncData } = (this as any).$options if (asyncData) { asyncData({ store: (this as any).$store, route: to }).then(next).catch(next) } else { next() } } })const { app, router, store } = createApp()// 获取服务端渲染时,注入的__INITIAL_STATE__信息,并同步到客户端的vuex store中 if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) }router.onReady(() => { router.beforeResolve( async (to: Route, from: Route, next: any) => { const matched = router.getMatchedComponents(to) const prevMatched = router.getMatchedComponents(from) let diffed = false // 校验to的路由地址和from的路由地址是否相等,如果不相等则在客户端触发asyncData钩子 const activated = matched.filter((c: any, i: any) => { return diffed || (diffed = (prevMatched[i] !== c)) }) const asyncDataHooks = activated.map((c:any) => c.options.asyncData).filter((_: any) => _) if (!asyncDataHooks.length) { return next() } await Promise.all(asyncDataHooks.map( async (hook: any) => await hook({ store, route: to }))) .then(() => { next() }) .catch(next) }) app.$mount('#app'); // 挂在到app上 })// 如果浏览器支持serviceWorker则注册 if (navigator.serviceWorker) { navigator.serviceWorker.register('/service-worker.js').then((registration) => { console.log('serviceWorker注册成功') }).catch(() => { console.log('serviceWorker注册失败') }) } // 向window type中插入__INITIAL_STATE__以至于ts不报错 declare global { interface Window { __INITIAL_STATE__: any } }

index.template.html
跟官方实例一样,也是可以改造的,你可以在server.js的context中注入你任何想注入的类容,像下面{{ title }}一样注入到你渲染后的模板中,比如页面构建时间,当前语言版本,等一系列操作。body中没东西,就会默认在window中注入名为__INITIAL_STATE__的对象,当然你也可以自定义,比如使用名字为__INIT_STATE__的对象,可以在body中加入这段代码
{{{ renderState({ windowKey: '__INIT_STATE__', contextKey: 'state', }) }}} {{{ renderScripts() }}}
{{ title }} - 锐客网

.vue文件
如果你前面拿的是与methods同级的属性,那么就写在同级就行了,钩子函数的名字和参数与你前面entry-client.ts里保持一致,跟客户端渲染的created钩子差不多,里面放一些请求,和改变vuex的东西进行数据预取。至于vuex的形式,以及用不用vuex做状态管理都无所谓。
async asyncData({store, route}:any) { const id = route.query.id; let params: Detail.ArticDetail.RequestParams = { id: id }; store.commit('detail/articDetail/$assignParams', params); await store.dispatch('detail/articDetail/getArticDetail'); }

拿之前的老项目重构的,由于vue-cli 2.0用起来不太好,以及当初自己摸索的vuex写法比较恶心,就只搭个实例了,项目地址,有兴趣的,觉得写了这么多废话有点用的同学欢迎start。
有兴趣的同学,可以一起讨论,无时不在。

    推荐阅读