Vue 服务端渲染业务入门实践
keuo9813
8年前
<h2><strong>背景</strong></h2> <p>最近, 产品同学一如往常笑嘻嘻的递来需求文档, 纵使内心万般拒绝, 身体倒是很诚实。 接过需求,好在需求不复杂, 简单构思 后决定用Vue, 得心应手。 切好图, 挽起袖子准备撸代码的时候, SEO同学不知何时已经站到了背后。</p> <p>"听说你要用Vue?"</p> <p>"恩..."</p> <p>"SEO考虑了吗?整个SPA出来,网页的SEO咋办?"</p> <p>"奥..."</p> <p>换以前, 估计只能无奈的换个实现方式, 但是Vue 2.0时代的到来, 给你多了一种可能。 你可以对SEO工程师说:用Vue没问题!</p> <p>想必,很多前端同学都有类似这样的经历, 为了SEO,只能放弃得心应手的框架。 SEO(Search Engine Optimization)顾名思义就是一系列为了提高 网站收录排名,吸引精准用户的方案。 这么看来,SEO确实是有举足轻重的作用。 不过,好消息是,Vue2.0的发布为SEO提供了可能, 这就是SSR(serve side render)。</p> <p>说起SSR,其实早在SPA (Single Page Application) 出现之前,网页就是在服务端渲染的。服务器接收到客户端请求后,将数据和模板拼接成完整的页面响应到客户端。 客户端直接渲染, 此时用户希望浏览新的页面,就必须重复这个过程, 刷新页面. 这种体验在Web技术发展的当下是几乎不能被接受的,于是越来越多的技术方案涌现,力求 实现无页面刷新或者局部刷新来达到优秀的交互体验。 比如Vue:</p> <p>- 在客户端管理路由,用户切换路由,无需向服务器重新请求页面和静态资源,只需要使用 ajax 获取数据在客户端完成渲染,这样可以减少了很多不必要的网络传输,缩短了响应时间。</p> <p>- 声明式渲染(告诉 vue 你要做什么,让它帮你做),把我们从烦人的DOM操作中解放出来,集中处理业务逻辑。</p> <p>- 组件化视图,无论是功能组件还是UI组件都可以进行抽象,写一次到处用。</p> <p>- 前后端并行开发,只需要与后端定好数据格式,前期用模拟数据,就可以与后端并行开发了。</p> <p>- 对复杂项目的各个组件之间的数据传递 vue - Vuex 状态管理模式</p> <p>缺点大家自然猜到了, 对,主要的一点就是不利于SEO,或者说对SEO不友好。 来看下面两张图;</p> <p>SPA页面的源代码</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/8a4b632d6d04ad21890c3f869c588a71.jpg"></p> <p>下图SSR页面的源代码</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/2ae0f1f445ec3f55a97ee803686800a8.jpg"></p> <p>上面两张图就是使用了传统单页应用和SSR的页面源代码, 第一张图中,很明显页面的数据都是通过Ajax异步获取,然而搜索引擎度娘家的爬虫看到这样空旷的源码并不会丝毫留恋. 相反,通过服务端渲染的页面,就有很多对于爬虫来讲有效的连接. 毕竟度娘一家独大,看来服务端渲染确实有探究的必要了。</p> <p>vue 的服务端渲染是怎么回事?</p> <p>先看一张Vue官网的服务端渲染示意图</p> <p><img src="https://simg.open-open.com/show/588c42bee019f81be0beae263ce2d915.jpg"></p> <p>从图上可以看出,ssr 有两个入口文件,client.js 和 server.js, 都包含了应用代码,webpack 通过两个入口文件分别打包成给服务端用的 server bundle 和给客户端用的 client bundle. 当服务器接收到了来自客户端的请求之后,会创建一个渲染器 bundleRenderer,这个 bundleRenderer 会读取上面生成的 server bundle 文件,并且执行它的代码, 然后发送一个生成好的 html 到浏览器,等到客户端加载了 client bundle 之后,会和服务端生成的DOM 进行 Hydration(判断这个DOM 和自己即将生成的DOM 是否相同,如果相同就将客户端的vue实例挂载到这个DOM上, 否则会提示警告)。</p> <h2>怎么实现?</h2> <p>知道了Vue服务端渲染的大致流程,那怎么用代码来实现呢?</p> <p>1. 创建一个 vue 实例</p> <p>2. 配置路由,以及相应的视图组件</p> <p>3. 使用 vuex 管理数据</p> <p>4. 创建服务端入口文件</p> <p>5. 创建客户端入口文件</p> <p>6. 配置 webpack,分服务端打包配置和客户端打包配置</p> <p>7. 创建服务器端的渲染器,将vue实例渲染成html</p> <ul> <li> <p>首先我们来创建一个 vue 实例</p> </li> </ul> <pre> <code class="language-javascript">// app.js import Vue from 'vue'; import router from './router'; import store from './store'; import App from './components/app'; let app = new Vue({ template: '<app></app>', base: '/c/', components: { App }, router, store }); export { app, router, store }</code></pre> <p>和我们以前写的vue实例差别不大,但是我们不会在这里将app mount到DOM上,因为这个实例也会在服务端去运行,这里直接将 app 暴露出去。</p> <ul> <li> <p>配置 vue 路由</p> </li> </ul> <pre> <code class="language-javascript"> import Vue from 'vue'; import VueRouter from 'vue-router'; import IndexView from '../views/indexView'; import ArticleItems from '../views/articleItems'; Vue.use(VueRouter); const router = new VueRouter({ mode: 'history', base: '/c/', routes: [ { path: '/:alias', component: IndexView }, { path: '/:alias/list', component: ArticleItems } ] });</code></pre> <p>注意这里的 base,在服务端传递 path 给 vue-router 的时候要注意去掉前面的 '/c/',否则会匹配不到。</p> <ul> <li> <p>创建视图组件,这里我们使用单文件组件,下面是 indexView.vue 文件的实例代码</p> </li> </ul> <pre> <code class="language-javascript"><template> <div class="content"> <course-cover :class-data="classData[0]"></course-cover> <article-items :article-items="articleItems"></article-items> </div> </template> <script> import courseCover from '../components/courseCover.vue'; import articleItems from '../components/articleItems'; export default { computed: { classData() { return this.$store.state.courseListItems; }, articleItems() { return this.$store.state.articleItems; } }, components: { courseCover, articleItems }, // 服务端获取数据 fetchServerData ({ state, dispatch, commit }) { let alias = state.route.params.alias; return Promise.all([ dispatch('FETCH_ZT', { alias }), dispatch('FETCH_COURSE_ITEMS'), dispatch('FETCH_ARTICLE_ITEMS') ]) }, // 客户端获取数据 beforeMount() { return this.$store.dispatch('FETCH_COURSE_ITEMS'); } } </script></code></pre> <p>这里我们暴露一个 fetchServerData 方法用来在服务端渲染时做数据的预加载,具体在哪调用,下面会讲到。 beforeMount 是vue的生命周期钩子函数,当应用在客户端切换到这个视图的时候会在特定的时候去执行,用于在客户端获取数据。</p> <ul> <li> <p>使用 vuex 管理数据,vue2.0 的服务端官方推荐使用 STORE 来管理数据,和1.0相比 api 有一些调整</p> </li> </ul> <pre> <code class="language-javascript"> import Vue from 'vue'; import Vuex from 'vuex'; import axios from 'axios'; Vue.use(Vuex); let apiHost = 'http://localhost:3000'; const store = new Vuex.Store({ state: { alias: '', ztData: {}, courseListItems: [], articleItems: [] }, actions: { FETCH_ZT: ({ commit, dispatch, state }, { alias }) = { commit('SET_ALIAS', { alias }); return axios.get(`${apiHost}/api/zt`) .then(response => { let data = response.data || {}; commit('SET_ZT_DATA', data); }) }, FETCH_COURSE_ITEMS: ({ commit, dispatch, state }) => { return axios.get(`${apiHost}/api/course_items`).then(response => { let data = response.data; commit('SET_COURSE_ITEMS', data); }); }, FETCH_ARTICLE_ITEMS: ({ commit, dispatch, state }) => { return axios.get(`${apiHost}/api/article_items`) .then(response => { let data = response.data; commit('SET_ARTICLE_ITEMS', data); }) } }, mutations: { SET_COURSE_ITEMS: (state, data) => { state.courseListItems = data; }, SET_ALIAS: (state, { alias }) => { state.alias = alias; }, SET_ZT_DATA: (state, { ztData }) => { state.ztData = ztData; }, SET_ARTICLE_ITEMS: (state, items) => { state.articleItems = items; } } }) export default store;</code></pre> <p>state 使我们应用层的数据,相当于一个仓库,整个应用层的数据都存在这里,与不使用vuex的vue应用有两点不同:</p> <p>- Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。</p> <p>- Vuex 不允许我们直接对 store 中的数据进行操作。改变 store 中的状态的唯一途径就是显式地提交(commit) mutations。这样使得我们可以方便地跟踪每一个状态的变化,从而让我们能够实现一些工具帮助我们更好地了解我们的应用。</p> <p>action 响应在view上的用户输入导致的状态变化,并不直接操作数据,异步的逻辑都封装在这里执行,它最终的目的是提交 mutation 来操作数据。 mutation vuex 中修改store 数据的唯一方法,使用 commit 来提交。</p> <ul> <li> <p>创建服务端的入口文件 server-entry.js</p> </li> </ul> <pre> <code class="language-javascript">// server-entry.js import {app, router, store} from './app'; export default context => { const s = Date.now(); router.push(context.url); const matchedComponents = router.getMatchedComponents(); if(!matchedComponents) { return Promise.reject({ code: '404' }); } return Promise.all( matchedComponents.map(component => { if(component.fetchServerData) { return component.fetchServerData(store); } }) ).then(() => { context.initialState = store.state; return app; }) }</code></pre> <p>server.js 返回一个函数,该函数接受一个从服务端传递过来的 context 的参数,将 vue 实例通过 promise 返回。 context 一般包含 当前页面的url,首先我们调用 vue-router 的 router.push(url) 切换到到对应的路由, 然后调用 getMatchedComponents 方法返回对应要渲染的组件, 这里会检查组件是否有 fetchServerData 方法,如果有就会执行它。</p> <p>下面这行代码将服务端获取到的数据挂载到 context 对象上,后面会把这些数据直接发送到浏览器端与客户端的vue 实例进行数据(状态)同步。</p> <pre> <code class="language-javascript">context.initialState = store.state`</code></pre> <p>创建客户端入口文件 client-entry.js</p> <pre> <code class="language-javascript">// client-entry.js import { app, store } from './app'; import './main.scss'; store.replaceState(window.__INITIAL_STATE__); app.$mount('#app');</code></pre> <p>客户端入口文件很简单,同步服务端发送过来的数据,然后把 vue 实例挂载到服务端渲染的 DOM 上。</p> <ul> <li> <p>配置 webpack</p> </li> </ul> <pre> <code class="language-javascript">// webpack.server.config.js const base = require('./webpack.base.config'); // webpack 的通用配置 module.exports = Object.assign({}, base, { target: 'node', entry: './src/server-entry.js', output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }, externals: Object.keys(require('../package.json').dependencies), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"' }) ] })</code></pre> <p>注意这里添加了 target: 'node' 和 libraryTarget: 'commonjs2',然后入口文件改成我们的 server-entry.js, 客户端的 webpack 和以前一样,这里就不贴了。</p> <ul> <li> <p>分别打包服务端代码和客户端代码</p> </li> </ul> <p>因为有两个 webpack 配置文件,执行 webpack 时候就需要指定 --config 参数来编译不同的 bundle。 我们可以配置两个 npm script</p> <pre> <code class="language-javascript"> "packclient": "webpack --config webpack.client.config.js", "packserver": "webpack --config webpack.server.config.js"</code></pre> <p>然后在命令行运行</p> <pre> <code class="language-javascript"> npm run packclient npm run packserver</code></pre> <p>就会生成两个文件 client-bundle.js 和 server-bundle.js</p> <ul> <li> <p>创建服务端渲染器</p> </li> </ul> <pre> <code class="language-javascript">// controller.js const serialize = require('serialize-javascript'); // 因为我们在vue-router 的配置里面使用了 `base: '/c'`,这里需要去掉请求path中的 '/c' let url = this.url.replace(/\/c/, ''); let context = { url: this.url }; // 创建渲染器 let bundleRenderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'), 'utf-8')) let html = yield new Promise((resolve, reject) => { // 将vue实例编译成一个字符串 bundleRenderer.renderToString( context, // 传递context 给 server-bundle.js 使用 (err, html) => { if(err) { console.error('server render error', err); resolve(''); } /** * 还记得在 server-entry.js 里面 `context.initialState = store.state` 这行代码么? * 这里就直接把数据发送到浏览器端啦 **/ html += `<script> // 将服务器获取到的数据作为首屏数据发送到浏览器 window.__INITIAL_STATE__ = ${serialize(context.initialState, { isJSON: true })} </script>`; resolve(html); } ) }) yield this.render('ssr', html); // 创建渲染器函数 function createRenderer(code) { return require('vue-server-renderer').createBundleRenderer(code); }</code></pre> <p>在 node 的 views 模板文件中只需要将上面的 html 输出就可以了</p> <pre> <code class="language-javascript">// ssr.html {% extends 'layout.html' %} {% block body %} {{ html | safe }} {% endblock %} <script src="/public/client.js"></script></code></pre> <p>这样,一个简单的服务端渲染就结束了,限于篇幅,详细的代码请 参考 Github代码库 。</p> <p>https://github.com/pangz1/vue-ssr</p> <h2>小结</h2> <p>整个demo包含了:</p> <p>- vue + vue-router + vuex 的使用</p> <p>- 服务端数据获取</p> <p>- 客户端数据同步以及DOM hydration。</p> <p>没有涉及:</p> <p>- 流式渲染</p> <p>- 组件缓存</p> <p>对Vue的服务端渲染有更深一步的认识,实际在生产环境中的应用可能还需要考虑很多因素。</p> <p>选择Vue的服务端渲染方案,是情理之中的选择,不是对新技术的盲目追捧,而是一切为了需要。 Vue 2.0的SSR方案只是提供了一种可能,多了一种选择,框架本身在于服务开发者,根据不同的场景选择不同的方案,才会事半功倍。</p> <p> </p> <p> </p> <p>来自:http://mp.weixin.qq.com/s/rClP45Eng4vlI887wY5fdw</p> <p> </p>