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

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/theoneEmperor/article/details/86102567

      最近抽出了点时间,弄了下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,不是必须的,你也可以不是使用,或者通过你自己的方式实现。

当然从服务端能带过来的不仅是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: '我是服务端注入的内容'
}

此时客户端接受的window.__INITIAL_STATE__就如图所示,这是你客户端同步状态的时候就要取对store了。

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() }}}

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>{{ title }}</title>
    <meta charset="utf-8">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="default">
    <link rel="apple-touch-icon" sizes="120x120" href="./public/logo-120.png">
    <meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
    <link rel="shortcut icon" sizes="48x48" href="./public/logo-48.png">
    <meta name="theme-color" content="#f60">
    <link rel="manifest" href="./manifest.json">
  </head>
  <body>
  <!--vue-ssr-outlet-->
  </body>
</html>

.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。

有兴趣的同学,可以一起讨论,无时不在。

猜你喜欢

转载自blog.csdn.net/theoneEmperor/article/details/86102567
今日推荐