带你五步学会Vue SSR - 前端学习 - SegmentFault 思否

标签: | 发表时间:2020-05-03 08:42 | 作者:
出处:https://segmentfault.com

前言

SSR大家肯定都不陌生,通过服务端渲染,可以优化SEO抓取,提升首页加载速度等,我在学习SSR的时候,看过很多文章,有些对我有很大的启发作用,有些就只是照搬官网文档。通过几天的学习,我对SSR有了一些了解,也从头开始完整的配置出了SSR的开发环境,所以想通过这篇文章,总结一些经验,同时希望能够对学习SSR的朋友起到一点帮助。

我会通过五个步骤,一步步带你完成SSR的配置:

  1. 纯浏览器渲染
  2. 服务端渲染,不包含 Ajax初始化数据
  3. 服务端渲染,包含 Ajax初始化数据
  4. 服务端渲染,使用 serverBundleclientManifest进行优化
  5. 一个完整的基于 Vue + VueRouter + Vuex的SSR工程

如果你现在对于我上面说的还不太了解,没有关系,跟着我一步步向下走,最终你也可以独立配置一个SSR开发项目, 所有源码我会放到 github上,大家可以作为参考

正文

1. 纯浏览器渲染

这个配置相信大家都会,就是基于 weback + vue的一个常规开发配置,这里我会放一些关键代码,完整代码可以去 github查看。

目录结构
    - node_modules
- components  
    - Bar.vue
    - Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
app.js
    import Vue from 'vue';
import App from './App.vue';

let app = new Vue({
  el: '#app',
  render: h => h(App)
});
App.vue
    <template>
  <div>
    <Foo></Foo>
    <Bar></Bar>
  </div>
</template>

<script>
import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';

export default {
  components: {
    Foo, Bar
  }
}
</script>
index.html
    <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>纯浏览器渲染</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>
components/Foo.vue
    <template>
  <div class="foo">
    <h1>Foo Component</h1>
  </div>
</template>

<style>
.foo {
  background: yellowgreen;
}
</style>
components/Bar.vue
    <template>
  <div class="bar">
    <h1>Bar Component</h1>
  </div>
</template>

<style>
.bar {
  background: bisque;
}
</style>
webpack.config.js
    const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  mode: 'development',

  entry: './app.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
        // 如果需要单独抽出CSS文件,用下面这个配置
        // use: ExtractTextPlugin.extract({
        //   fallback: 'vue-style-loader',
        //   use: [
        //     'css-loader',
        //     'postcss-loader'
        //   ]
        // })
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: './index.html'
    }),
    // 如果需要单独抽出CSS文件,用下面这个配置
    // new ExtractTextPlugin("styles.css")
  ]
};
postcss.config.js
    module.exports = {
  plugins: [
    require('autoprefixer')
  ]
};
.babelrc
    {
  "presets": [
    "@babel/preset-env"
  ],
  "plugins": [
    // 让其支持动态路由的写法 const Foo = () => import('../components/Foo.vue')
    "dynamic-import-webpack"    
  ]
}
package.json
    {
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build": "webpack"
  },
  "dependencies": {
    "vue": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "babel-plugin-dynamic-import-webpack": "^1.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9"
  }
}
命令
启动开发环境
    yarn start
构建生产环境
    yarn run build

最终效果截图:

纯浏览器渲染

完整代码查看 github

2. 服务端渲染,不包含 Ajax初始化数据

服务端渲染SSR,类似于同构,最终要让一份代码既可以在服务端运行,也可以在客户端运行。如果说在SSR的过程中出现问题,还可以回滚到纯浏览器渲染,保证用户正常看到页面。

那么,顺着这个思路,肯定就会有两个 webpack的入口文件,一个用于浏览器端渲染 weboack.client.config.js,一个用于服务端渲染 webpack.server.config.js,将它们的公有部分抽出来作为 webpack.base.cofig.js,后续通过 webpack-merge进行合并。同时,也要有一个 server来提供 http服务,我这里用的是 koa

我们来看一下新的目录结构:

    - node_modules
- config    // 新增
    - webpack.base.config.js
    - webpack.client.config.js
    - webpack.server.config.js
- src
    - components  
        - Bar.vue
        - Foo.vue
    - App.vue
    - app.js
    - entry-client.js   // 新增
    - entry-server.js   // 新增
    - index.html
    - index.ssr.html    // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

在纯客户端应用程序(client-only app)中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)。

所以,我们要对 app.js做修改,将其包装为一个工厂函数,每次调用都会生成一个全新的根组件。

app.js

    import Vue from 'vue';
import App from './App.vue';

export function createApp() {
  const app = new Vue({
    render: h => h(App)
  });

  return { app };
}

在浏览器端,我们直接新建一个根组件,然后将其挂载就可以了。

entry-client.js

    import { createApp } from './app.js';

const { app } = createApp();

app.$mount('#app');

在服务器端,我们就要返回一个函数,该函数的作用是接收一个 context参数,同时每次都返回一个新的根组件。这个 context在这里我们还不会用到,后续的步骤会用到它。

entry-server.js

    import { createApp } from './app.js';

export default context => {
  const { app } = createApp();

  return app;
}

然后再来看一下 index.ssr.html

index.ssr.html

    <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>服务端渲染</title>
</head>
<body>
  <!--vue-ssr-outlet-->

  <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>

<!--vue-ssr-outlet-->的作用是作为一个占位符,后续通过 vue-server-renderer插件,将服务器解析出的组件 html字符串插入到这里。

<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>是为了将 webpack通过 webpack.client.config.js打包出的文件放到这里(这里是为了简单演示,后续会有别的办法来做这个事情)。

因为服务端吐出来的就是一个 html字符串,后续的 Vue相关的响应式、事件响应等等,都需要浏览器端来接管,所以就需要将为浏览器端渲染打包的文件在这里引入。

用官方的词来说,叫 客户端激活(client-side hydration)

所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:

    // 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性:

    <div id="app" data-server-rendered="true">

Vue在浏览器端就依靠这个属性将服务器吐出来的 html进行激活,我们一会自己构建一下就可以看到了。

接下来我们看一下 webpack相关的配置:

webpack.base.config.js

    const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  mode: 'development',

  resolve: {
    extensions: ['.js', '.vue']
  },

  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'
      },
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin()
  ]
};

webpack.client.config.js

    const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});

注意,这里的入口文件变成了 entry-client.js,将其打包出的 client.bundle.js插入到 index.html中。

webpack.server.config.js

    const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});

这里有几个点需要注意一下:

  1. 入口文件是 entry-server.js
  2. 因为是打包服务器端依赖的代码,所以 target要设为 node,同时, outputlibraryTarget要设为 commonjs2

这里关于 HtmlWebpackPlugin配置的意思是,不要在 index.ssr.html中引入打包出的 server.bundle.js,要引为浏览器打包的 client.bundle.js,原因前面说过了,是为了让 Vue可以将服务器吐出来的 html进行激活,从而接管后续响应。

那么打包出的 server.bundle.js在哪用呢?接着往下看就知道啦~~

package.json

    {
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build:client": "webpack --config config/webpack.client.config.js",
    "build:server": "webpack --config config/webpack.server.config.js"
  },
  "dependencies": {
    "koa": "^2.5.3",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0",
    "vue": "^2.5.17",
    "vue-server-renderer": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "style-loader": "^0.23.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9",
    "webpack-merge": "^4.1.4"
  }
}

接下来我们看 server端关于 http服务的代码:

server/server.js

    const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

// 后端Server
backendRouter.get('/index', (ctx, next) => {
  // 这里用 renderToString 的 promise 返回的 html 有问题,没有样式
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
  });
});

backendApp.use(serve(path.resolve(__dirname, '../dist')));

backendApp
  .use(backendRouter.routes())
  .use(backendRouter.allowedMethods());

backendApp.listen(3000, () => {
  console.log('服务器端渲染地址: http://localhost:3000');
});


// 前端Server
frontendRouter.get('/index', (ctx, next) => {
  let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
  ctx.type = 'html';
  ctx.status = 200;
  ctx.body = html;
});

frontendApp.use(serve(path.resolve(__dirname, '../dist')));

frontendApp
  .use(frontendRouter.routes())
  .use(frontendRouter.allowedMethods());

frontendApp.listen(3001, () => {
  console.log('浏览器端渲染地址: http://localhost:3001');
});

这里对两个端口进行监听,3000端口是服务端渲染,3001端口是直接输出 index.html,然后会在浏览器端走 Vue的那一套,主要是为了和服务端渲染做对比使用。

这里的关键代码是如何在服务端去输出 html`字符串。

    const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

可以看到, server.bundle.js在这里被使用啦,因为它的入口是一个函数,接收 context作为参数(非必传),输出一个根组件 app

这里我们用到了 vue-server-renderer插件,它有两个方法可以做渲染,一个是 createRenderer,另一个是 createBundleRenderer

    const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 选项 */ })
    const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })

createRenderer无法接收为服务端打包出的 server.bundle.js文件,所以这里只能用 createBundleRenderer

serverBundle参数可以是以下之一:

  • 绝对路径,指向一个已经构建好的 bundle文件( .js.json)。必须以 /开头才会被识别为文件路径。
  • webpack + vue-server-renderer/server-plugin生成的 bundle对象。
  • JavaScript代码字符串(不推荐)。

这里我们引入的是.js文件,后续会介绍如何使用.json文件以及有什么好处。

    renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
});

使用 createRenderercreateBundleRenderer返回的 renderer函数包含两个方法 renderToStringrenderToStream,我们这里用的是 renderToString成功后直接返回一个完整的字符串, renderToStream返回的是一个 Node流。

renderToString支持 Promise,但是我在使用 Prmoise形式的时候样式会渲染不出来,暂时还不知道原因,如果大家知道的话可以给我留言啊。

配置基本就完成了,来看一下如何运行。

    yarn run build:client       // 打包浏览器端需要bundle
yarn run build:server       // 打包SSR需要bundle

yarn start      // 其实就是 node server/server.js,提供http服务

最终效果展示:

访问 http://localhost:3000/index

SSR没有ajax数据

我们看到了前面提过的 data-server-rendered="true"属性,同时会加载 client.bundle.js文件,为了让 Vue在浏览器端做后续接管。

访问 http://localhost:3001/index还和第一步实现的效果一样,纯浏览器渲染,这里就不放截图了。

完整代码查看 github

3. 服务端渲染,包含Ajax初始化数据

如果SSR需要初始化一些异步数据,那么流程就会变得复杂一些。

我们先提出几个问题:

  1. 服务端拿异步数据的步骤在哪做?
  2. 如何确定哪些组件需要获取异步数据?
  3. 获取到异步数据之后要如何塞回到组件内?

带着问题我们向下走,希望看完这篇文章的时候上面的问题你都找到了答案。

服务器端渲染和浏览器端渲染组件经过的生命周期是有区别的, 在服务器端,只会经历 beforeCreatecreated两个生命周期。因为SSR服务器直接吐出 html字符串就好了,不会渲染DOM结构,所以不存在 beforeMountmounted的,也不会对其进行更新,所以也就不存在 beforeUpdateupdated等。

我们先来想一下,在纯浏览器渲染的 Vue项目中,我们是怎么获取异步数据并渲染到组件中的?一般是在 created或者 mounted生命周期里发起异步请求,然后在成功回调里执行 this.data = xxxVue监听到数据发生改变,走后面的 Dom Diff,打 patch,做 DOM更新。

那么服务端渲染可不可以也这么做呢? 答案是不行的

  1. mounted里肯定不行,因为 SSR都没有 mounted生命周期,所以在这里肯定不行。
  2. beforeCreate里发起异步请求是否可以呢,也是不行的。因为请求是异步的,可能还没有等接口返回,服务端就已经把 html字符串拼接出来了。

所以,参考一下 官方文档,我们可以得到以下思路:

  1. 在渲染前,要预先获取所有需要的异步数据,然后存到 Vuexstore中。
  2. 在后端渲染时,通过 Vuex将获取到的数据注入到相应组件中。
  3. store中的数据设置到 window.__INITIAL_STATE__属性中。
  4. 在浏览器环境中,通过 Vuexwindow.__INITIAL_STATE__里面的数据注入到相应组件中。

正常情况下,通过这几个步骤,服务端吐出来的 html字符串相应组件的数据都是最新的,所以第4步并不会引起 DOM更新,但如果出了某些问题,吐出来的 html字符串没有相应数据, Vue也可以在浏览器端通过 `Vuex注入数据,进行 DOM更新。

更新后的目录结构:

    - node_modules
- config
   - webpack.base.config.js
   - webpack.client.config.js
   - webpack.server.config.js
- src
   - components  
       - Bar.vue
       - Foo.vue
   - store             // 新增
       store.js
   - App.vue
   - app.js
   - entry-client.js
   - entry-server.js   
   - index.html
   - index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

先来看一下 store.js:

store/store.js

    import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const fetchBar = function() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('bar 组件返回 ajax 数据');
    }, 1000);
  });
};

function createStore() {
  const store = new Vuex.Store({
    state: {
      bar: ''
    },

    mutations: {
      'SET_BAR'(state, data) {
        state.bar = data;
      }
    },

    actions: {
      fetchBar({ commit }) {
        return fetchBar().then((data) => {
          commit('SET_BAR', data);
        }).catch((err) => {
          console.error(err);
        })
      }
    }
  });

  if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
  }
  
  return store;
}

export default createStore;

typeof window

如果不太了解 Vuex,可以去 Vuex官网先看一些基本概念。

vuex

这里 fetchBar可以看成是一个异步请求,这里用 setTimeout模拟。在成功回调中 commit相应的 mutation进行状态修改。

这里有一段关键代码:

    if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
}

因为 store.js同样也会被打包到服务器运行的 server.bundle.js中,所以运行环境不一定是浏览器,这里需要对 window做判断,防止报错,同时如果有 window.__INITIAL_STATE__属性,说明服务器已经把所有初始化需要的异步数据都获取完成了,要对 store中的状态做一个替换,保证统一。

components/Bar.vue

    <template>
  <div class="bar">
    <h1 @click="onHandleClick">Bar Component</h1>
    <h2>异步Ajax数据:</h2>
    <span>{{ msg }}</span>
  </div>
</template>

<script>
  const fetchInitialData = ({ store }) => {
    store.dispatch('fetchBar');
  };

  export default {
    asyncData: fetchInitialData,

    methods: {
      onHandleClick() {
        alert('bar');
      }
    },

    mounted() {
      // 因为服务端渲染只有 beforeCreate 和 created 两个生命周期,不会走这里
      // 所以把调用 Ajax 初始化数据也写在这里,是为了供单独浏览器渲染使用
      let store = this.$store;
      fetchInitialData({ store });
    },

    computed: {
      msg() {
        return this.$store.state.bar;
      }
    }
  }
</script>

<style>
.bar {
  background: bisque;
}
</style>

这里在 Bar组件的默认导出对象中增加了一个方法 asyncData,在该方法中会 dispatch相应的 action,进行异步数据获取。

需要注意的是,我在 mounted中也写了获取数据的代码,这是为什么呢?因为想要做到同构,代码单独在浏览器端运行,也应该是没有问题的,又由于服务器没有 mounted生命周期,所以我写在这里就可以解决单独在浏览器环境使用也可以发起同样的异步请求去初始化数据。

components/Foo.vue

    <template>
  <div class="foo">
    <h1 @click="onHandleClick">Foo Component</h1>
  </div>
</template>

<script>
export default {
  methods: {
    onHandleClick() {
      alert('foo');
    }
  },
}
</script>

<style>
.foo {
  background: yellowgreen;
}
</style>

这里我对两个组件都添加了一个点击事件,为的是证明在服务器吐出首页 html后,后续的步骤都会被浏览器端的 Vue接管,可以正常执行后面的操作。

app.js

    import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';

export function createApp() {
  const store = createStore();

  const app = new Vue({
    store,
    render: h => h(App)
  });

  return { app, store, App };
}

在建立根组件的时候,要把 Vuex的store传进去,同时要返回,后续会用到。

最后来看一下 entry-server.js,关键步骤在这里:

entry-server.js

    import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app, store, App } = createApp();

    let components = App.components;
    let asyncDataPromiseFns = [];
  
    Object.values(components).forEach(component => {
      if (component.asyncData) {
        asyncDataPromiseFns.push(component.asyncData({ store }));
      }
    });
  
    Promise.all(asyncDataPromiseFns).then((result) => {
      // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
      context.state = store.state;
  
      console.log(222);
      console.log(store.state);
      console.log(context.state);
      console.log(context);
  
      resolve(app);
    }, reject);
  });
}

我们通过导出的 App拿到了所有它下面的 components,然后遍历,找出哪些 componentasyncData方法,有的话调用并传入 store,该方法会返回一个 Promise,我们使用 Promise.all等所有的异步方法都成功返回,才 resolve(app)

context.state = store.state作用是,当使用 createBundleRenderer时,如果设置了 template选项,那么会把 context.state的值作为 window.__INITIAL_STATE__自动插入到模板 html中。

这里需要大家多思考一下,弄清楚整个服务端渲染的逻辑。

如何运行:

    yarn run build:client
yarn run build:server

yarn start

最终效果截图:

服务端渲染:打开 http://localhost:3000/index

server-remder-ajax

可以看到 window.__INITIAL_STATE__被自动插入了。

我们来对比一下 SSR到底对加载性能有什么影响吧。

服务端渲染时 performance截图

sercer_render_ajax_performance

纯浏览器端渲染时 performance截图

client_render_ajax_performance

同样都是在 fast 3G网络模式下,纯浏览器端渲染首屏加载花费时间 2.9s,因为 client.js加载就花费了 2.27s,因为没有 client.js就没有 Vue,也就没有后面的东西了。

服务端渲染首屏时间花费 0.8s,虽然 client.js加载扔花费 2.27s,但是首屏已经不需要它了,它是为了让 Vue在浏览器端进行后续接管。

从这我们可以真正的看到,服务端渲染对于提升首屏的响应速度是很有作用的。

当然有的同学可能会问,在服务端渲染获取初始 ajax数据时,我们还延时了1s,在这个时间用户也是看不到页面的。没错,接口的时间我们无法避免,就算是纯浏览器渲染,首页该调接口还是得调,如果接口响应慢,那么纯浏览器渲染看到完整页面的时间会更慢。

完整代码查看 github

4. 使用serverBundle和clientManifest进行优化

前面我们创建服务端 renderer的方法是:

    const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

serverBundle我们用的是打包出的 server.bundle.js文件。这样做的话,在每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map。

vue-server-renderer提供一个名为 createBundleRenderer的 API,用于处理此问题,通过使用 webpack的自定义插件, server bundle将生成为可传递到 bundle renderer的特殊 JSON文件。所创建的 bundle renderer,用法和普通 renderer相同,但是 bundle renderer提供以下优点:

  • 内置的 source map支持(在 webpack配置中使用 devtool: 'source-map'
  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer实例)
  • 关键 CSS(critical CSS)注入(在使用 *.vue文件时):自动内联在渲染过程中用到的组件所需的 CSS。更多细节请查看 CSS章节。
  • 使用 clientManifest进行资源注入:自动推断出最佳的预加载( preload)和预取( prefetch)指令,以及初始渲染所需的代码分割 chunk

preloadprefetch有不了解的话可以自行查一下它们的作用哈。

那么我们来修改 webpack配置:

webpack.client.config.js

    const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new VueSSRClientPlugin(),   // 新增
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});

webpack.server.config.js

    const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
   // 对 bundle renderer 提供 source map 支持
  devtool: '#source-map',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  externals: [nodeExternals()],     // 新增
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new VueSSRServerPlugin(),   // 这个要放到第一个写,否则 CopyWebpackPlugin 不起作用,原因还没查清楚
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});

因为是服务端引用模块,所以不需要打包 node_modules中的依赖,直接在代码中 require引用就好,所以配置 externals: [nodeExternals()]

两个配置文件会分别生成 vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json。作为 createBundleRenderer的参数。

来看 server.js

server.js

    const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
});

效果和第三步就是一样的啦,就不截图了,完整代码查看 github

5. 配置一个完整的基于Vue + VueRouter + Vuex的SSR

这里和第四步不一样的是引入了 vue-router,更接近于实际开发项目。

src下新增 router目录。

router/index.js

    import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';

Vue.use(Router);

function createRouter() {
  const routes = [
    {
      path: '/bar',
      component: Bar
    },
    {
      path: '/foo',
      component: () => import('../components/Foo.vue')   // 异步路由
    }
  ];

  const router = new Router({
    mode: 'history',
    routes
  });

  return router;
}

export default createRouter;

这里我们把 Foo组件作为一个异步组件引入,做成按需加载。

app.js中引入 router,并导出:

app.js

    import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';

export function createApp() {
  const store = createStore();
  const router = createRouter();

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });

  return { app, store, router, App };
}

修改 App.vue引入路由组件:

App.vue

    <template>
  <div id="app">
    <router-link to="/bar">Goto Bar</router-link> 
    <router-link to="/foo">Goto Foo</router-link> 
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  beforeCreate() {
    console.log('App.vue beforeCreate');
  },

  created() {
    console.log('App.vue created');
  },

  beforeMount() {
    console.log('App.vue beforeMount');
  },

  mounted() {
    console.log('App.vue mounted');
  }
}
</script>

最重要的修改在 entry-server.js中,

entry-server.js

    import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app, store, router, App } = createApp();

    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      console.log(context.url)
      console.log(matchedComponents)

      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      Promise.all(matchedComponents.map(component => {
        if (component.asyncData) {
          return component.asyncData({ store });
        }
      })).then(() => {
        // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
        context.state = store.state;

        // 返回根组件
        resolve(app);
      });
    }, reject);
  });
}

这里前面提到的 context就起了大作用,它将用户访问的 url地址传进来,供 vue-router使用。因为有异步组件,所以在 router.onReady的成功回调中,去找该 url路由所匹配到的组件,获取异步数据那一套还和前面的一样。

于是,我们就完成了一个基本完整的基于 Vue + VueRouter + VuexSSR配置,完成代码查看 github

最终效果演示:

访问 http://localhost:3000/bar

ssr-router

完整代码查看 github

后续

上面我们通过五个步骤,完成了从纯浏览器渲染到完整服务端渲染的同构,代码既可以运行在浏览器端,也可以运行在服务器端。那么,回过头来我们在看一下是否有优化的空间,又或者有哪些扩展的思考。

1. 优化
  • 我们目前是使用 renderToString方法,完全生成 html后,才会向客户端返回,如果使用 renderToStream,应用 bigpipe技术可以向浏览器持续不断的返回一个流,那么文件的加载浏览器可以尽早的显示一些东西出来。
    const stream = renderer.renderToStream(context)

返回的值是 Node.js stream

    let html = ''

stream.on('data', data => {
  html += data.toString()
})

stream.on('end', () => {
  console.log(html) // 渲染完成
})

stream.on('error', err => {
  // handle error...
})

在流式渲染模式下,当 renderer遍历虚拟 DOM树( virtual DOM tree)时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端。

然而,当第一个数据 chunk被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文( render context),当流( stream)启动时,这些数据将不可用。这是因为,大量上下文信息( context information)(如头信息( head information)或内联关键 CSS(inline critical CSS))需要在应用程序标记( markup)之前出现,我们基本上必须等待流( stream)完成后,才能开始使用这些上下文数据。

因此,如果你依赖由组件生命周期钩子函数填充的上下文数据,则不建议使用流式传输模式。

  • webpack优化

webpack优化又是一个大的话题了,这里不展开讨论,感兴趣的同学可以自行查找一些资料,后续我也可能会专门写一篇文章来讲 webpack优化。

2. 思考
  • 是否必须使用 vuex

答案是不用。 Vuex只是为了帮助你实现一套数据存储、更新、获取的机制,入股你不用 Vuex,那么你就必须自己想一套方案可以将异步获取到的数据存起来,并且在适当的时机将它注入到组件内,有一些文章提出了一些方案,我会放到参考文章里,大家可以阅读一下。

  • 是否使用 SSR就一定好?

这个也是不一定的,任何技术都有使用场景。 SSR可以帮助你提升首页加载速度,优化搜索引擎 SEO,但同时由于它需要在 node中渲染整套 Vue的模板,会占用服务器负载,同时只会执行 beforeCreatecreated两个生命周期,对于一些外部扩展库需要做一定处理才可以在 SSR中运行等等。

结语

本文通过五个步骤,从纯浏览器端渲染开始,到配置一个完整的基于 Vue + vue-router + Vuex的SSR环境,介绍了很多新的概念,也许你看完一遍不太理解,那么结合着源码,去自己手敲几遍,然后再来看几遍文章,相信你一定可以掌握 SSR

最后,本文所有源代码都放在我的 github上,如果对你有帮助的话,就来点一个赞吧~~

欢迎关注我的公众号

微信公众号

参考链接

相关 [vue ssr 前端] 推荐:

带你五步学会Vue SSR - 前端学习 - SegmentFault 思否

- -
SSR大家肯定都不陌生,通过服务端渲染,可以优化SEO抓取,提升首页加载速度等,我在学习SSR的时候,看过很多文章,有些对我有很大的启发作用,有些就只是照搬官网文档. 通过几天的学习,我对SSR有了一些了解,也从头开始完整的配置出了SSR的开发环境,所以想通过这篇文章,总结一些经验,同时希望能够对学习SSR的朋友起到一点帮助.

ssr vuejs/vue-hackernews-2.0: HackerNews clone built with Vue 2.0, vue-router & vuex, with server-side rendering

- -
This is a demo primarily aimed at explaining how to build a server-side rendered Vue app, as a companion to our SSR documentation. #install dependenciesnpm install#or yarn#serve in dev mode, with hot reload at localhost:8080npm run dev#build for productionnpm run build#serve in production modenpm start.

微前端及Vue + qiankun 实现案例 - 简书

- -
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略. 微前端的概念出现于2016年末,其将微服务的概念引入前端世界. 用以解决在需求、人员、技术栈等因素不断更迭下前端工程演变成巨石应用(Frontend Monolith)**而不可维护的问题. 这类问题尤其常见于企业级Web项目中.

适合前端Vue开发童鞋的跨平台Weex - SegmentFault 思否

- -
基于 Vue 技术栈的你如果需要选用一种移动端跨平台框架,是 Weex. 无疑,相对于后两者,因为你现在已有比较熟练的 Vue 基础,如果在其他条件一致的情况,Weex 无疑是最佳选择;但是 Weex 真的适合在实际项目中进行移动端跨平台开发吗. Weex 的开发效率、Weex 的质量是否满足需求.

【SSR】漫谈服务端渲染

- - 掘金 前端
大家好,我是Laffery,本文同步发表在我的个人博客「 Kuqiochi | 谈谈服务端渲染「SSR」」. SSR(服务端渲染,Server Side Render),顾名思义就是在服务端渲染出页面. 与之相对应的是CSR(客户端渲染,Client Side Render),即在浏览器上渲染完整的页面.

Vue 移动端框架

- - IT瘾-jianshu
vonic 一个基于 vue.js 和 ionic 样式的 UI 框架,用于快速构建移动端单页应用,很简约. 中文文档| github地址| 在线预览. vux 基于WeUI和Vue(2.x)开发的移动端UI组件库. 基于webpack+vue-loader+vux可以快速开发移动端页面,配合vux-loader方便你在WeUI的基础上定制需要的样式.

vue路由权限校验

- - 掘金前端
做后台系统的时候,难免会有用户权限的判断. admin可以查看全部菜单,user只能查看部分菜单. 一开始接触这个需求的时候,完全是纯前端做的. 在配置路由的时候,加一个roles的属性,通过判断用户的roles是否与路由的roles属性相匹配来作为显示隐藏的依据. // 过滤路由 menuList-菜单 roles-用户角色 const checkMenuList = (menuList, roles) => { for (let i = 0; i < menuList.length; i++) {.

当 SSR 遇上 Serverless,轻松实现页面瞬开

- -

从0开始微操SSR之nextjs项目实践

- - 掘金前端
这是一个电商类的网站,对于这种电商类的网站,我们一般会想要的效果是:. 1、用户从百度或者一些搜索引擎直接搜索关键字,就能够导流到我的网站上,这就是SEO(Search Engine Optimization). SEO(Search Engine Optimization):汉译为搜索引擎优化. 是一种方式:利用搜索引擎的规则提高网站在有关搜索引擎内的自然排名.

助力ssr,使用concent为nextjs应用加点料

- - SegmentFault 最新的文章
开源不易,感谢你的支持, ❤ star concent^_^. 这里我们将使用 create-next-app命令来安装一个基础的next示例应用. 执行完毕后,可以看到一个如下的目录结构. |____public |____pages | |____ _app.js // next应用默认的根组件 | |____index.js // 默认首页 | |____api.