New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue SSR Demo #112

Open
youngwind opened this Issue Jan 10, 2018 · 13 comments

Comments

Projects
None yet
@youngwind
Owner

youngwind commented Jan 10, 2018

前言

最近接手一个老项目,典型的 Vue 组件化前端渲染,后续业务优化可能会朝 SSR 方向走,因此,就先做些技术储备。如果对 Vue SSR 完全不了解,请先阅读官方文档

思路

Vue 提供了一个官方 Demo,该 Demo 优点是功能大而全,缺点是对新手不友好,容易让人看蒙。因此,今天我们来写一个更加容易上手的 Demo。总共分三步走,循序渐进

  1. 写一个简单的前端渲染 Demo(不包含 Ajax 数据);
  2. 将前端渲染改成后端渲染(仍然不包含 Ajax 数据);
  3. 在后端渲染的基础上,加上 Ajax 数据的处理;

第一步:前端渲染 Demo

这部分比较简单,就是一个页面中包含两个组件:Foo 和 Bar。

<!-- index.html -->
<body>
<div id="app">
    <app></app>
</div>
<script src="./dist/web.js"></script>  <!--这是 app.js 打包出来的 JS 文件 -->
</body>
// app.js,也是 webpack 打包入口
import Vue from 'vue';
import App from './App.vue';
var app = new Vue({
    el: '#app',
    components: {
        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>
// Foo.vue
<template>
    <div class='foo'>
        <h1>Foo</h1>
        <p>Component </p>
    </div>
</template>
<style>
    .foo{
        background: yellow;
    }
</style>
// Bar.vue
<template>
    <div class='bar'>
        <h1>Bar</h1>
        <p>Component </p>
    </div>
</template>
<style>
    .bar{
        background: blue;
    }
</style>

最终渲染结果如下图所示,源码请参考这里
image

第二步:后端渲染(不包含 Ajax 数据)

第一步的 Demo 虽不包含任何 Ajax 数据,但即便如此,要把它改造成后端渲染,亦非易事。该从哪几个方面着手呢?

  1. 拆分 JS 入口;
  2. 拆分 Webpack 打包配置;
  3. 编写服务端渲染主体逻辑。

1. 拆分 JS 入口

在前端渲染的时候,只需要一个入口 app.js。现在要做后端渲染,就得有两个 JS 文件:entry-client.jsentry-server.js 分别作为浏览器和服务器的入口。
先看 entry-client.js,它跟第一步的 app.js 有什么区别吗? → 没有区别,只是换了个名字而已,内容都一样。
再看 entry-server.js,它只需返回 App.vue 的实例。

// entry-server.js
export default function createApp() {
    const app = new Vue({
        render: h => h(App)
    });
    return app;  
};

entry-server.jsentry-client.js 这两个入口主要区别如下:

  1. entry-client.js 在浏览器端执行,所以需要指定 el 并且显式调用 $mount 方法,以启动浏览器的渲染。
  2. entry-server.js 在服务端被调用,因此需要导出为一个函数。

2. 拆分 Webpack 打包配置

在第一步中,由于只有 app.js 一个入口,只需要一份 Webpack 配置文件。现在有两个入口了,自然就需要两份 Webpack 配置文件:webpack.server.conf.jswebpack.client.conf.js,它们的公共部分抽象成 webpack.base.conf.js
关于 webpack.server.conf.js,有两个注意点:

  1. libraryTarget: 'commonjs2' → 因为服务器是 Node,所以必须按照 commonjs 规范打包才能被服务器调用。
  2. target: 'node' → 指定 Node 环境,避免非 Node 环境特定 API 报错,如 document 等。

3. 编写服务端渲染主体逻辑

Vue SSR 依赖于包 vue-server-render,它的调用支持两种入口格式:createRenderer 和 createBundleRenderer,前者以 Vue 组件为入口,后者以打包后的 JS 文件为入口,本文采取后者。

// server.js 服务端渲染主体逻辑
// dist/server.js 就是以 entry-server.js 为入口打包出来的 JS 
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.get('/index', (req, res) => {
    renderer.renderToString((err, html) => {
        if (err) {
            console.error(err);
            res.status(500).end('服务器内部错误');
            return;
        }
        res.end(html);
    })
});

server.listen(8002, () => {
    console.log('后端渲染服务器启动,端口号为:8002');
});

这一步的最终渲染效果如下图所示,从图中我们可以看到,组件已经被后端成功渲染了。源码请参考这里
image

第三步:后端渲染(预获取 Ajax 数据)

这是关键的一步,也是最难的一步。
假如第二步的组件各自都需要请求 Ajax 数据的话,该怎么处理呢?官方文档给我们指出了思路,我简要概括如下:

  1. 在开始渲染之前,预先获取所有需要的 Ajax 数据(然后存在 Vuex 的 Store 中);
  2. 后端渲染的时候,通过 Vuex 将获取到的 Ajax 数据分别注入到各个组件中;
  3. 把全部 Ajax 数据埋在 window.INITIAL_STATE 中,通过 HTML 传递到浏览器端;
  4. 浏览器端通过 Vuex 将 window.INITIAL_STATE 里面的 Ajax 数据分别注入到各个组件中。

下面谈几个重点。

我们知道,在常规的 Vue 前端渲染中,组件请求 Ajax 一般是这么写的:“在 mounted 中调用 this.fetchData,然后在回调里面把返回数据写到实例的 data 中,这就 ok 了。”
在 SSR 中,这是不行的,因为服务器并不会执行 mounted 周期。那么我们是否可以把 this.fetchData
提前到 created 或者 beforeCreate 这两个生命周期中执行?同样不行。原因是:this.fetchData 是异步请求,请求发出去之后,没等数据返回呢,后端就已经渲染完了,无法把 Ajax 返回的数据也一并渲染出来。
所以,我们得提前知道都有哪些组件有 Ajax 请求,等把这些 Ajax 请求都返回了数据之后,才开始组件的渲染。

// store.js
function fetchBar() {
    return new Promise(function (resolve, reject) {
        resolve('bar ajax 返回数据');
    });
}

export default function createStore() {
    return new Vuex.Store({
        state: {
            bar: '',
        },
        actions: {
            fetchBar({commit}) {
                return fetchBar().then(msg => {
                    commit('setBar', {msg})
                })
            }
        },
        mutations:{
            setBar(state, {msg}) {
                Vue.set(state, 'bar', msg);
            }
        }
    })
}
// Bar.uve
asyncData({store}) {
    return store.dispatch('fetchBar');
},
computed: {
    bar() {
        return this.$store.state.bar;
    }
}

组件的 asyncData 方法已经定义好了,但是怎么索引到这个 asyncData 方法呢?先看我的根组件 App.vue 是怎么写的。

// App.vue
<template>
    <div>
        <h1>App.vue</h1>
        <p>vue with vue </p>
        <hr>
        <foo1 ref="foo_ref"></foo1>
        <bar1 ref="bar_ref"></bar1>
        <bar2 ref="bar_ref2"></bar2>
    </div>
</template>
<script>
    import Foo from './components/Foo.vue';
    import Bar from './components/Bar.vue';

    export default {
        components: {
            foo1: Foo,
            bar1: Bar,
            bar2: Bar
        }
    }
</script>

从根组件 App.vue 我们可以看到,只需要解析其 components 字段,便能依次找到各个组件的 asyncData 方法了。

// entry-server.js 
export default function (context) {
    // context 是 vue-server-render 注入的参数
    const store = createStore();
    let app = new Vue({
        store,
        render: h => h(App)
    });

    // 找到所有 asyncData 方法
    let components = App.components;
    let prefetchFns = [];
    for (let key in components) {
        if (!components.hasOwnProperty(key)) continue;
        let component = components[key];
        if(component.asyncData) {
            prefetchFns.push(component.asyncData({
                store
            }))
        }
    }

    return Promise.all(prefetchFns).then((res) => {
        // 在所有组件的 Ajax 都返回之后,才最终返回 app 进行渲染
        context.state = store.state;
        // context.state 赋值成什么,window.__INITIAL_STATE__ 就是什么
        return app;
    });
};

还有几个问题比较有意思:

  1. 是否必须使用 vue-router?→ 不是。虽然官方给出的 Demo 里面用到了 vue-router,那只不过是因为官方 Demo 是包含多个页面的 SPA 罢了。当然,一般的多页面SPA,是需要用 vue-router 的,因为不同路由对应不同的组件,并非每次都把所有组件的 asyncData 都执行的。但是有例外,比如我的这个老项目,就只有一个页面(一个页面中包含很多的组件),所以根本不需要用到 vue-router,也照样能做 SSR。主要的区别就是如何找到那些该被执行的 asyncData 方法:官方 Demo 通过 vue-router,而我通过直接解析 components 字段,仅此而已。
  2. 是否必须使用 Vuex? → 是,但也不是,请看尤大的回答。为什么必须要有类似 Vuex 的存在?我们来分析一下。
    2.1. 当预先获取到的 Ajax 数据返回之后,Vue 组件还没开始渲染。所以,我们无法把 Ajax 数据直接挂载到组件实例上,只能把 Ajax 数据 先放在单独的某个地方。
    2.2. 当 Vue 组件开始渲染的时候,还得把 Ajax 数据拿出来,正确地传递给各个组件。
    2.3. 在浏览器渲染的时候,需要正确解析 window.INITIAL_STATE ,并传递给各个组件。
    因此,我们得有这么一个独立于视图以外的地方,用来存储、管理和传递数据,这就是 Vuex 存在的理由。
  3. 后端已经把 Ajax 数据转化为 HTML 了,为什么还需要把 Ajax 数据通过 window.INITIAL_STATE 传递到前端? → 因为前端渲染的时候仍然需要知道这些数据。举个例子,你写了一个组件,给它绑定了一个点击事件,点击的时候打印出 this.msg 字段值。现在后端是把组件 HTML 渲染出来了,但是事件的绑定和触发肯定得由浏览器来完成啊,如果浏览器拿不到跟服务器端同样的数据的话,在触发组件点击事件的时候,又上哪儿去找 msg 字段呢?

至此,我们已经完成了带 Ajax 数据的后端渲染了。这一步最为复杂,也最为关键,需要反复思考和尝试。具体渲染效果图如下所示,源码请参考这里
image

首屏渲染速度对比

大功告成了吗?还没。人们都说 SSR 能提升首屏渲染速度,下面我们真实对比一下,看看到底是不是真的。(同样在 Fast 3G 网络条件下)。
image
image

官方思路的变形

行文至此,关于 Vue SSR Demo 便已经结束了。后面是我结合自身项目特点的一些变形,不感兴趣的读者可以不看。
第三步官方思路有什么缺点吗?我认为是有的:对老项目来说,改造成本比较大。需要显式的引入 vuex,就得走 action、mutations 那一套,无论是代码改动量还是新人学习成本,都不低。
有什么办法能减少对旧项目的改动量的吗?我是这么做的。

// store.js
// action,mutations 那些都不需要了,只定义一个空 state
export default function createStore() {
    return new Vuex.Store({
        state: {}
    })
}
// Bar.vue
// tagName 是组件实例的名字,比如 bar1、bar2、foo1 等,由 entry-server.js 注入
export default {
    prefetchData: function (tagName) {
        return new Promise((resolve, reject) => {
            resolve({
                tagName,
                data: 'Bar ajax 数据'
            });
        })
    }
}
// entry-server.js
return Promise.all(prefetchFns).then((res) => {
    // 拿到 Ajax 数据之后,手动将数据写入 state,不通过 action,mutation 那一套
    // state 内部区分的 key 值就是 tagName,比如 bar1、bar2、foo1 等
    res.forEach((item, key) => {
        Vue.set(store.state, `${item.tagName}`, item.data);
    });
    context.state = store.state;
    return app;
});
// ssrmixin.js
// 将每个组件都需要的 computed 抽象成一个 mixin,然后注入
export default {
    computed: {
        prefetchData () {
            let componentTag = this.$options._componentTag;    // bar1、bar2、foo1
            return this.$store.state[componentTag];
        }
    }
}

至此,我们就便得到了 Vue SSR 的一种变形。对于组件开发者而言,只需要把原来的 this.fetchData 方法抽象到 prefetchData 方法,然后就可以在 DOM 中使用 {{prefetchData}} 拿到到数据了。这部分的代码请参考这里

总结

Vue SSR 确实是个有趣的东西,关键在于灵活运用。此 Demo 还有一个遗留问题没有解决:当把 Ajax 抽象到 prefetchData,做成 SSR 之后,原先的前端渲染就失效了。能不能同一份代码同时支持前端渲染和后端渲染呢?这样当后端渲染出问题的时候,我就可以随时切回前端渲染,也算是兜底的方案。

参考资料

  1. 从零开始搭建vue-ssr系列之一:写在前面的话, By 会说话的鱼
  2. vue SSR 服务端渲染记录, By echo_numb
  3. Vue SSR 官方文档实践·一:从零到粗暴混合前后端, By songlairui
  4. Vue 2 服务端渲染初探, By 题叶
  5. Vue SSR踩坑, By ghosert
  6. 详解 NodeJS 的 VM 模块, By dorsywang

----- 完 ------

@youngwind youngwind added the Vue label Jan 10, 2018

@keminu

This comment has been minimized.

keminu commented Jan 11, 2018

很收益

@fengyinchao

This comment has been minimized.

fengyinchao commented Feb 3, 2018

理解很透彻!

@renjie1996

This comment has been minimized.

renjie1996 commented Feb 28, 2018

thx

@destiny12341

This comment has been minimized.

destiny12341 commented Feb 28, 2018

@269378737

This comment has been minimized.

269378737 commented Mar 2, 2018

不错的Vue SSR入门级指南

@gavinwyf

This comment has been minimized.

gavinwyf commented Mar 9, 2018

@stevennzhou

This comment has been minimized.

stevennzhou commented May 3, 2018

@youngwind 想请教一下作者,我在你的例子上加入了vue-router,使用lazy router的时候,webpack会进行代码分割。但是实际上生成的文件,node跑server.js的时候,会报组件js文件未找到,请问这是什么原因?

@cuiyongjian

This comment has been minimized.

cuiyongjian commented Jul 1, 2018

我想咨询下,首屏的概念是怎么定义的,是人眼看到的部分还是首次请求的完整页面?

@youngwind

This comment has been minimized.

Owner

youngwind commented Jul 1, 2018

首屏的定义这两种都有可能,得结合上下文判断。本文的首屏指的是“首次请求的完整页面” @cuiyongjian

@Thinking80s

This comment has been minimized.

Thinking80s commented Jul 3, 2018

mark

@william-xue

This comment has been minimized.

william-xue commented Aug 6, 2018

棒棒哒

@dddw001

This comment has been minimized.

dddw001 commented Sep 30, 2018

第一步不是前端渲染吗?怎么就有client.conf和server.conf了?

@pampang

This comment has been minimized.

pampang commented Oct 23, 2018

请问作者有遇到 ios webview(客户端、safari)下无法展示 SSR 内容,需要等到 js 加载完才出现视图的问题吗?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment