做者:威威(沪江前端开发工程师)
本文原创,转载请注明做者及出处。javascript
最近, 产品同窗一如往常笑嘻嘻的递来需求文档, 纵使心里万般拒绝, 身体却是很诚实。 接过需求,好在需求不复杂, 简单构思 后决定用Vue, 驾轻就熟。 切好图, 挽起袖子准备撸代码的时候, SEO同窗不知什么时候已经站到了背后。css
"据说你要用Vue?" "恩..." "SEO考虑了吗?整个SPA出来,网页的SEO咋办?" "奥..."
换之前, 估计只能无奈的换个实现方式, 可是Vue 2.0时代的到来, 给你多了一种可能。 你能够对SEO工程师说:用Vue没问题!html
想必,不少前端同窗都有相似这样的经历, 为了SEO,只能放弃驾轻就熟的框架。 SEO(Search Engine Optimization)顾名思义就是一系列为了提升 网站收录排名,吸引精准用户的方案。 这么看来,SEO确实是有举足轻重的做用。 不过,好消息是,Vue2.0的发布为SEO提供了可能, 这就是SSR(serve side render)。前端
提及SSR,其实早在SPA (Single Page Application) 出现以前,网页就是在服务端渲染的。服务器接收到客户端请求后,将数据和模板拼接成完整的页面响应到客户端。 客户端直接渲染, 此时用户但愿浏览新的页面,就必须重复这个过程, 刷新页面. 这种体验在Web技术发展的当下是几乎不能被接受的,因而愈来愈多的技术方案涌现,力求 实现无页面刷新或者局部刷新来达到优秀的交互体验。 好比Vue:vue
- 在客户端管理路由,用户切换路由,无需向服务器从新请求页面和静态资源,只须要使用 ajax 获取数据在客户端完成渲染,这样能够减小了不少没必要要的网络传输,缩短了响应时间。 - 声明式渲染(告诉 vue 你要作什么,让它帮你作),把咱们从烦人的DOM操做中解放出来,集中处理业务逻辑。 - 组件化视图,不管是功能组件仍是UI组件均可以进行抽象,写一次处处用。 - 先后端并行开发,只须要与后端定好数据格式,前期用模拟数据,就能够与后端并行开发了。 - 对复杂项目的各个组件之间的数据传递 vue - Vuex 状态管理模式
缺点你们天然猜到了, 对,主要的一点就是不利于SEO,或者说对SEO不友好。 来看下面两张图;java
SPA页面的源代码node
下图SSR页面的源代码webpack
上面两张图就是使用了传统单页应用和SSR的页面源代码, 第一张图中,很明显页面的数据都是经过Ajax异步获取,然而搜索引擎度娘家的爬虫看到这样空旷的源码并不会丝毫留恋. 相反,经过服务端渲染的页面,就有不少对于爬虫来说有效的链接. 毕竟度娘一家独大,看来服务端渲染确实有探究的必要了。ios
先看一张Vue官网的服务端渲染示意图git
从图上能够看出,ssr 有两个入口文件,client.js 和 server.js, 都包含了应用代码,webpack 经过两个入口文件分别打包成给服务端用的 server bundle 和给客户端用的 client bundle. 当服务器接收到了来自客户端的请求以后,会建立一个渲染器 bundleRenderer,这个 bundleRenderer 会读取上面生成的 server bundle 文件,而且执行它的代码, 而后发送一个生成好的 html 到浏览器,等到客户端加载了 client bundle 以后,会和服务端生成的DOM 进行 Hydration(判断这个DOM 和本身即将生成的DOM 是否相同,若是相同就将客户端的vue实例挂载到这个DOM上, 不然会提示警告)。
知道了Vue服务端渲染的大体流程,那怎么用代码来实现呢?
1. 建立一个 vue 实例 2. 配置路由,以及相应的视图组件 3. 使用 vuex 管理数据 4. 建立服务端入口文件 5. 建立客户端入口文件 6. 配置 webpack,分服务端打包配置和客户端打包配置 7. 建立服务器端的渲染器,将vue实例渲染成html
// 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 }
和咱们之前写的vue实例差异不大,可是咱们不会在这里将app mount到DOM上,由于这个实例也会在服务端去运行,这里直接将 app 暴露出去。
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 } ] });
注意这里的 base,在服务端传递 path 给 vue-router 的时候要注意去掉前面的 '/c/',不然会匹配不到。
<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>
这里咱们暴露一个 fetchServerData 方法用来在服务端渲染时作数据的预加载,具体在哪调用,下面会讲到。 beforeMount 是vue的生命周期钩子函数,当应用在客户端切换到这个视图的时候会在特定的时候去执行,用于在客户端获取数据。
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;
state 使咱们应用层的数据,至关于一个仓库,整个应用层的数据都存在这里,与不使用vuex的vue应用有两点不一样:
- Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地获得高效更新。 - Vuex 不容许咱们直接对 store 中的数据进行操做。改变 store 中的状态的惟一途径就是显式地提交(commit) mutations。这样使得咱们能够方便地跟踪每个状态的变化,从而让咱们可以实现一些工具帮助咱们更好地了解咱们的应用。 action 响应在view上的用户输入致使的状态变化,并不直接操做数据,异步的逻辑都封装在这里执行,它最终的目的是提交 mutation 来操做数据。 mutation vuex 中修改store 数据的惟一方法,使用 commit 来提交。
// 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; }) }
server.js 返回一个函数,该函数接受一个从服务端传递过来的 context 的参数,将 vue 实例经过 promise 返回。 context 通常包含 当前页面的url,首先咱们调用 vue-router 的 router.push(url) 切换到到对应的路由, 而后调用 getMatchedComponents 方法返回对应要渲染的组件, 这里会检查组件是否有 fetchServerData 方法,若是有就会执行它。
下面这行代码将服务端获取到的数据挂载到 context 对象上,后面会把这些数据直接发送到浏览器端与客户端的vue 实例进行数据(状态)同步。
context.initialState = store.state
建立客户端入口文件 client-entry.js
// client-entry.js import { app, store } from './app'; import './main.scss'; store.replaceState(window.__INITIAL_STATE__); app.$mount('#app');
客户端入口文件很简单,同步服务端发送过来的数据,而后把 vue 实例挂载到服务端渲染的 DOM 上。
// 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"' }) ] })
注意这里添加了 target: 'node' 和 libraryTarget: 'commonjs2',而后入口文件改为咱们的 server-entry.js, 客户端的 webpack 和之前同样,这里就不贴了。
由于有两个 webpack 配置文件,执行 webpack 时候就须要指定 --config 参数来编译不一样的 bundle。 咱们能够配置两个 npm script
"packclient": "webpack --config webpack.client.config.js", "packserver": "webpack --config webpack.server.config.js"
而后在命令行运行
npm run packclient npm run packserver
就会生成两个文件 client-bundle.js 和 server-bundle.js
// 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); }
在 node 的 views 模板文件中只须要将上面的 html 输出就能够了
// ssr.html {% extends 'layout.html' %} {% block body %} {{ html | safe }} {% endblock %} <script src="/public/client.js"></script>
这样,一个简单的服务端渲染就结束了。
限于篇幅,详细的代码请参考 Github代码库:https://github.com/ikcamp/vue...
整个demo包含了:
没有涉及:
对Vue的服务端渲染有更深一步的认识,实际在生产环境中的应用可能还须要考虑不少因素。
选择Vue的服务端渲染方案,是情理之中的选择,不是对新技术的盲目追捧,而是一切为了须要。 Vue 2.0的SSR方案只是提供了一种可能,多了一种选择,框架自己在于服务开发者,根据不一样的场景选择不一样的方案,才会事半功倍。
文章仅表明我的观点,有不稳当地方烦请你们指出,共同进步!
iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。