vue2 手写一个简易的服务端渲染 - vue ssr(含vuex+vue-router)

1,121 阅读3分钟

前言

  • 为什么服务端渲染
  • 客户端不利于SEO搜索引擎优化
  • 服务端渲染是可以被爬虫抓到的, 客户端很难被抓取到
  • SSR直接将HTML字符串传递给浏览器 大大加快了首屏加载时间
  • 但同时SSR占用更多的CPU和内存资源
  • 一些常用的浏览器API可能无法使用
  • 只支持vue的beforeCreate和created两个生命周期
  • 本文服务用的是node
  • Vue SSR 指南
图片替换文本

示例

src/app.vue

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

src/Bar.vue

<template>
  <div>
    <!-- lisi -->
    {{ $store.state.name }}  
  </div>
</template>

<script>
  export default {
      /**
       * @description 在服务端执行的方法 这个方法在后端执行
       */
      asyncData(store){
          return store.dispatch('changeName')
      },

      /**
       * @description 服务端也会执行 beforeCreate 和 created
       */
      beforeCreate() {
        console.log('服务端会调用beforeCreate')
      },
      created() {
        console.log('服务端会调用created')
      },

      /**
       * @description 浏览器执行 后端忽略
       */
      mounted() {
        console.log('服务端不会调用mounted')
      }

  }
</script>

<style scoped="true">
    div {
      width: 100%;
      height: 50px;
      line-height: 50px;
      background: goldenrod;
    }
</style>


src/components/Foo.vue

<template>
    <div @click="show">foo</div>
</template>
<script>
export default {
    methods:{
        show(){
            alert('前端逻辑操作, 与服务端渲染无关')
        }
    }
}
</script>

效果

服务端掉用vue的钩子 只支持beforeCreate 和 create, 在bar.vue文件中

图片替换文本

基本流程

  • 将导出的vue实例分成两份 一份是客户端要打包的 一份是服务端要打包的 (都是webpack打包)
  • 客户端打包的是针对客端使用的(比如一些事件 视图操作 路由跳转等)
  • 服务端打包的是node server要执行的函数
  • 在node server中通过VueServerRenderer中的createBundleRenderer方法 去调用函数(server-entry.js导出的函数), 获取实例
  • 将要执行的函数结合createBundleRenderer选项中添加的html模板(模板要加入<!--vue-ssr-outlet-->)
  • 通过.renderToString根据实例生成一个字符串 传给浏览器

有vue-router流程

  • 这里用到模式是history(问题刷新时返回404)
  • 用户输入url, 服务端会将路径传给render函数
  • 在函数中向让路由跳转完毕router.push(url)
  • 跳转完毕后通过router.onReady方法(这可以有效确保服务端渲染时服务端和客户端输出的一致)
  • 接着router.getMatchedComponents()获取当前路由的所有组件(返回数组)
  • 如果length等于0 没有匹配路由 服务端返回not found(前端路由也可做处理, 具体看router.js文件)

有vuex流程

  • 服务端渲染的数据 只针对路由可以访问的组件
  • 组件有一个函数asyncData 专为服务端调用
  • 当用户输入完路径 组件中有asyncData 并调用
  • context.state = store.state 将其挂载到window.__INITIAL_STATE__
  • 接着浏览器 开始渲染 将服务端加载好的数据替换掉

目录结构

├── config
│   ├── webpack.base.js
│   ├── webpack.client.js
│   └── webpack.server.js
├── dist
│   ├── client.bundle.js
│   ├── index.html
│   ├── index.ssr.html
│   └── server.bundle.js
├── public
│   ├── index.html
│   └── index.ssr.html
├── src
│   ├── App.vue
│   ├── components
│   │   ├── Bar.vue
│   │   └── Foo.vue
│   ├── entry-client.js
│   ├── entry-server.js
│   ├── router.js
│   ├── store.js
│   └── app.js
├── server.js
└── package.json

有哪些包

* vue vuex vue-router

# node 服务端
* koa
* koa-router                监听路由
* koa-static                前端静态文件、图片等静态资源处理模块。配置静态资源目录后,将不会出现静态资源not found错误
* vue-server-renderer       node服务渲染vue

# webpack 打包部分
* webpack
* webpack-cli       
* webpack-merge             合并
* babel-loader              webpack和babel的一个桥梁
* @babel/core               babel的核心模块(默认直接调用)
* @babel/preset-env         把es6+转换成低级语法
* vue-loader                解析.vue
* vue-template-compiler     编译模板
* css-loader                解析css样式
* vue-style-loader          解析的css文件插入到style标签中(vue-style-loader支持服务端渲染)

* concurrently              可以同时执行两个脚本命令
* nodemon                   修改node服务端直接更新, 无需重新启动

package.json

"scripts": {
    "client:dev": "webpack serve --config scripts/webpack.client.js",
    "client:build": "webpack --config scripts/webpack.client.js --watch",
    "server:build": "webpack --config scripts/webpack.server.js --watch",
    "run:all": "concurrently \"npm  run client:build\" \"npm run server:build\""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "concurrently": "^5.3.0",
    "koa": "^2.13.1",
    "koa-router": "^10.0.0",
    "koa-static": "^5.0.0",
    "vue": "^2.6.12",
    "vue-router": "^3.4.9",
    "vue-server-renderer": "^2.6.12",
    "vuex": "^3.6.0",
    "webpack-merge": "^5.7.3"
  },
  "devDependencies": {
    "@babel/core": "^7.12.10",
    "@babel/preset-env": "^7.12.11",
    "babel-loader": "^8.2.2",
    "css-loader": "^5.0.1",
    "html-webpack-plugin": "^4.5.1",
    "vue-loader": "^15.9.6",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.12",
    "webpack": "^5.13.0",
    "webpack-cli": "^4.3.1"
  }

正题

src/app.js

导出的vue实例

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

/**
 * @description 入口改装成了函数 目的是服务端渲染时 每次访问的适合都可以通过这个工厂函数返回一个全新的实例
 * @description 保证每个人访问都可以拿到一个自己的实例
 */
export default () => {
    const app = new Vue({
        render: h => h(App)
    })
    return { app }
}

src/client-entry.js

webpack打包 客户端入口文件

/** 客户端 */
import createApp from './app.js'
let {app} = createApp()
app.$mount('#app')

src/server-entry.js

webpack打包 服务端入口文件

/** 服务端入口 */
import createApp from './app.js'

/**
 * @description 服务端渲染可以返回一个函数
 * @description 每次都能产生一个新的应用
 * @param {context} 调用方法时 服务端会传入url
 */
export default (context)=>{
    const { url } = context
    
    /** 路由是异步组件 promise等待路由加载完毕 */
    return new Promise((resolve, reject) => {
        let { app, router, store } = createApp()

        // 要跳转的url路径
        router.push(url)

        // 路由跳转完毕后 组件触发
        router.onReady(() => {
            const matchComponents = router.getMatchedComponents()

            // 没有匹配到
            if (matchComponents.length == 0) {
                return reject({ code: 404 })

            } else {

                // matchComponents 指的是当前路径下的所有组件
                // 服务端在渲染的时候 默认会找当前路径下的所有组间中的asyncData
                // 并且在服务端也会创建一个vuex 传递给asyncData(store)
                Promise.all(matchComponents.map(component => {
                    if (component.asyncData) {
                        return component.asyncData(store)
                    }
                })).then(() => {
                    // 会默认在window下生成一个变量 内部默认就这样做的
                    // window.__INITIAL_STATE__ = {"name":"jiangwen"}
                    // 服务器执行完毕后 最新的状态保存在store.state上
                    context.state = store.state

                    // app 是已经获取到数据的实例
                    resolve(app)
                })

            }

        })

    })

}


config/webpack.base.js

webpack 公共部分

/** 打包的公告配置文件 */

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

module.exports = {
    mode: 'development', // 开发模式
    output: {
        filename: '[name].bundle.js',
        path: path.resolve(__dirname,'../dist')
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                use: 'vue-loader'
            },
            {
                test: /\.js$/,
                use: {
                    loader: 'babel-loader',     // babel-loader自动调用 @babel/core -> preset-env
                    options: {
                        presets: ['@babel/preset-env'],
                    }
                },
                exclude: /node_modules/ // node_modules文件排除
            },
            {
                test: /\.css$/,
                /** 执行顺序 从右向左执行 从下向上执行 */
                use: [
                    'vue-style-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            esModule: false, // 注意为了配套使用vue-style-loader 置为false 不然样式出不来
                        }
                    }
                ]
            }
        ]
    },

    plugins: [
        /** 固定的 */
        new VueLoaderPlugin()
    ]
}

config/webpack.client.js

webpack 客户端

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

/**
 * @description 客户端打包入口
 */
module.exports = merge(base,{
    entry: {
        client: path.resolve(__dirname, '../src/client-entry.js')
    },

    plugins:[
        new HtmlWebpackPlugin({ // html模板
            template: path.resolve(__dirname, '../public/index.html'),
            filename:'client.html' // 修改导出后的默认名(index.html)
        }),
    ]

})

config/webpack.server.js

webpack 服务端

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

/** 服务端打包入口 */
module.exports = merge(base,{
    target: 'node', // node 使用
    entry: {
        server: path.resolve(__dirname, '../src/server-entry.js')
    },
    output:{
        libraryTarget: "commonjs2" // export.modules
    },
    plugins:[
        new HtmlWebpackPlugin({ /** html 模板 */
            template: path.resolve(__dirname, '../public/index.ssr.html'),
            filename: 'server.html',
            excludeChunks: ['server'], // 不让server文件注入
            minify: false, // 不压缩
            client:'/client.bundle.js' // 加个参数 方便注入 index.ssr.html模板
        }),
    ]
})

public/index.ssr.html 模板

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <!--vue-ssr-outlet-->
    <script src="/client.bundle.js"></script>

    <!-- ejs模板 -->
    <script src="<%=htmlWebpackPlugin.options.client%>"></script>
</body>
</html>

server.js 服务端文件

启动时用命令 nodemon server.js

const Koa = require('koa')
const app = new Koa()
const Router = require('koa-router')
const router = new Router()
const VueServerRenderer = require('vue-server-renderer')
const static = require('koa-static')

// 读取文件
const fs = require('fs')
const path = require('path')
const serverBundle = fs.readFileSync(path.resolve(__dirname, 'dist/server.bundle.js'), 'utf8')
const template = fs.readFileSync(path.resolve(__dirname, 'dist/server.html'), 'utf8')

// 根据实例
const render = VueServerRenderer.createBundleRenderer(serverBundle, {
    template
})

router.get('/', async (ctx) => {
    ctx.body = await new Promise((resolve, reject) => {
        render.renderToString({url: ctx.url}, (err, html) => { // 如果想让css生效 只能使用回调的方式
            if (err) reject(err);
            resolve(html)
        })
    })
})

// 只要用户刷新就会像服务器发请求
router.get('/(.*)',async (ctx)=>{
    ctx.body = await new Promise((resolve, reject) => {
        // 通过服务端渲染 渲染后返回
        render.renderToString({url: ctx.url}, (err, html) => {
            if (err && err.code == 404) resolve(`not found`)
            
            console.log("🚀 ~ file: server.js ~ line 36 ~ render.renderToString ~ html", html)
            resolve(html)

        })
    })
})

app.use(static(path.resolve(__dirname, 'dist')))
app.use(router.routes())

app.listen(3000)