从零搭建Vue服务端渲染

什么是服务端渲染

服务端渲染(Server Side Render)简称ssr,是一种直接从服务端返回渲染内容到客户端的页面渲染方式。

服务端渲染的好处

  • 首屏渲染速度快
  • 有利于SEO

服务端渲染的过程

  1. 客户端发起请求时,服务端通过读取文件模板以及获取异步数据,结合生成客户端的首屏渲染内容,返回到客户端。
  2. 客户端收到返回内容后,直接渲染返回结果,然后再激活客户端渲染。以Vue为例,即重新$mount渲染Vue实例,由于Vue实例已经挂载,所以不用重新渲染DOM,只会在客户端生成Vue实例,并将数据变为响应式的数据。

服务端渲染的构建过程

按照服务端渲染的过程,服务端渲染的构建会有两个入口,客户端渲染的入口以及服务端渲染的入口。打包之后也会对应生成客户端和服务端的入口bundle.js,触发渲染时,后执行对应的bundle.js。具体构建过程如下图:
在这里插入图片描述

从零开始搭建

服务端渲染的基本结构

  1. 在一个空的文件夹中初始化package.json,创建src文件目录用于存放源码文件。

  2. 安装依赖

    1. 生产依赖:
      1. vue:vue.js
      2. vue-server-renderer:vue服务端渲染器
      3. express:帮助生成web服务器
      4. cross-env:跨平台设置全局环境
    2. 开发依赖:
      1. webpack打包依赖:webpack、webpack-cli、webpack-merge(合并webpack配置)、webpack-node-externals(排除wbepack中的node模块)、friendly-errors-webpack-plugin(打包日志友好输出)。
      2. babel转换相关:babel-loader、@babel/core、@babel/plugin-transform-runtime、@babel/preset-env。
      3. vue转换相关:vue-loader、vue-template-compiler
      4. css及文件转换:css-loader、filer-loader、url-loader。
  3. 在src中创建Vue实例模板App.vue以及入口模板文件app.js。

    /* App.vue */
    <template>
      <div id="app">
    
      </div>
    </template>
    
    <script>
    export default {
    
    }
    </script>
    
    <style>
    
    </style>
    
    /* app.js */
    /**
     * 生成Vue实例
     */
    import Vue from 'vue'
    import App from './App'
    
    export function createApp() {
      const app = new Vue({
        render: h => h(App)
      })
      return {
        app
      }
    }
    
  4. 根目录下创建server.js,用于启动web服务,并将服务端渲染的内容发送到客户端。

    // server.js
    // 创建Vue实例
    const Vue = require('vue')
    const app = new Vue({
      template: `<div id="app">{{ title }}</div>`,
      data() {
        return {
          title: '吴绍清'
        }
      }
    })
    
    // 创建渲染器
    const renderer = require('vue-server-renderer').createRenderer()
    
    // 创建服务器对象
    const server = require('express')()
    server.get('/', (req, res) => {
      res.setHeader('Content-Type', 'text/html;charset=utf-8') // 设置返回内容的编码格式,反之乱码
      // 将Vue实例渲染为 HTML 字符串
      renderer.renderToString(app, (err, html) => {
        if (err) return res.status(500).end(err)
        console.log(html);
        res.end(html)
      })
    })
    
    server.listen(8000)
    
  5. 通过node server.js来启动web服务器,访问http://localhost:8000/即可看到服务端渲染的内容。此时是直接返回模板的html字符串内容。
    在这里插入图片描述

  6. 为渲染器设置模板。需要创建一个模板文件index.template.html。然后在创建渲染器时读取模板,并传入。并在renderToString时将数据对象作为第二个参数传入。

    <!-- index.template.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <!-- 三花括号进行 HTML 不转义差值 -->
      {{{ meta }}}
      <!-- 双花括号进行 HTML 转义差值 -->
      <title>{{ title }}</title>
    </head>
    <body>
      <!-- 固定写法,服务度渲染的出口,Vue实例将替换下面的注释标签 -->
      <!--vue-ssr-outlet-->
    </body>
    </html>
    
    //server.js修改
    // 创建渲染器
    const renderer = require('vue-server-renderer').createRenderer({
      template: require('fs').readFileSync('./index.template.html', 'utf-8')
    })
    
    // 创建服务器对象
    const server = require('express')()
    server.get('/', (req, res) => {
      res.setHeader('Content-Type', 'text/html;charset=utf-8') // 设置返回内容的编码格式,反之乱码
      // 将Vue实例渲染为 HTML 字符串
      renderer.renderToString(app, {
        title: '模板插值',
        meta: `<meta http-equiv="X-UA-Compatible" content="IE=edge" />`
      }, (err, html) => {
        if (err) return res.status(500).end(err)
        res.end(html)
      })
    })
    
  7. 启动服务器之后,访问http://localhost:8000/,将返回模板与数据的结合结果
    在这里插入图片描述

打包构建设置

  1. src目录下创建entry-client.js和entry-server.js文件,作为客户端和服务端打包的入口文件。

    // entry-client.js
    /**
     * 客户端渲染入口
     */
    import createApp from './app'
    
    const { app } = createApp()
    
    // 在id为app的div中渲染Vue实例
    app.$mount('#app')
    
    // entry-server.js
    import createApp from './app'
    
    export default context => {
      const { app } = createApp()
    
      return app
    }
    
  2. 创建打包配置文件webpack.base.config.js作为公共打包配置,webpack.client.config.js作为客户端打包配置,webpack.server.config.js作为服务端打包配置。

    // webpack.base.config.js
    const path = require('path')
    const VueLoaderPlugin = require('vue-loader/lib/plugin')
    const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
    
    const resolve = file => path.resolve(__dirname, file)
    const isProd = process.env.NODE_ENV === 'production'
    
    const devToolOption = {}
    
    if (!isProd) {
      devToolOption.devtool = 'cheap-module-eval-source-map'
    }
    
    module.exports = {
      mode: isProd ? 'production' : 'development',
      output: {
        path: resolve('../dist/'),
        publicPath: '/dist/',
        filename: '[name].[chunkhash].js'
      },
      resolve: {
        alias: {
          '@': resolve('../src/')
        },
        extensions: ['.js', '.vue', '.json']
      },
      ...devToolOption,
      module: {
        rules: [
          // 处理图片资源
          {
            test: /\.(png|jpg|gif)$/i,
            use: {
              loader: 'url-loader',
              options: {
                limit: 8192
              }
            }
          },
          // 处理字体资源 
          { 
            test: /\.(woff|woff2|eot|ttf|otf)$/, 
            use: [ 'file-loader', ], 
          },
          // 处理 .vue 资源 
          { 
            test: /\.vue$/, 
            loader: 'vue-loader' 
          },
          // 处理 CSS 资源 
          // 它会应用到普通的 `.css` 文件 
          // 以及 `.vue` 文件中的 `<style>` 块 
          { 
            test: /\.css$/, 
            use: [ 'vue-style-loader', 'css-loader' ] 
          }
        ]
      },
      plugins: [ 
        new VueLoaderPlugin(), 
        new FriendlyErrorsWebpackPlugin() 
      ]
    }
    
    // webpack.client.config.js
    const { merge } = require('webpack-merge')
    const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
    
    const common = require('./webpack.base.config')
    
    module.exports = merge(common, {
      entry: {
        app: './src/entry-client.js' // 客户端打包入口
      },
      module: {
        rules: [
          {
            test: /\.m?js$/,
            exclude: /node_modules/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-env'],
                cacheDirectory: true,
                plugins: ['@babel/plugin-transform-runtime']
              }
            }
          }
        ]
      },
      // 打包优化:将 webpack 运行时分离到一个引导 chunk 中, 
      // 以便可以在之后正确注入异步 chunk。 
      optimization: {
        splitChunks: {
          name: 'manifest',
          minChunks: Infinity
        }
      },
      plugins: [
        // 在输出目录中生成 vue-ssr-client-manifest.json
        new VueSSRClientPlugin()
      ]
    })
    
    // webpack.server.config.js
    const { merge } = require('webpack-merge')
    const nodeExternals = require('webpack-node-externals') 
    const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
    const common = require('./webpack.base.config')
    
    const serverConfig = merge(common, {
      entry: './src/entry-server.js', // 服务端打包入口
      // 这允许 webpack 以 Node 适用方式处理模块加载 
      // 并且还会在编译 Vue 组件时, 
      // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
      target: 'node',
      output: {
        filename: 'server-bundle.js',
        // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) 
        libraryTarget: 'commonjs2'
      },
      // 不打包 node_modules 第三方包,而是保留 require 方式直接加载 
      externals: [
        nodeExternals({ 
          // 白名单中的资源依然正常打包 
          allowlist: [/\.css$/] 
        })
      ], 
      plugins: [ 
        // 这是将服务器的整个输出构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json`
        new VueSSRServerPlugin() 
      ]
    })
    
    module.exports = serverConfig
    
  3. 修改server.js,通过createBundleRenderer来生成渲染器,该渲染器负责完成输出服务器渲染结果,并在客户端激活客户端渲染。

    // 服务端入口打包之后的文件
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    // 模板文件
    const template = require('fs').readFileSync('./index.template.html', 'utf-8')
    // 客户端入口打包之后的文件
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    // 创建渲染器
    const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
    
  4. package.json中配置命令

    "scripts": {
      "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
       "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
       "build": "rimraf dist && yarn build:client && yarn build:server",
       "start": "cross-env NODE_ENV=production node server.js",
       "dev": "node server.js"
    },
    

开发模式构建优化

开发模式的构建目的是为了开发时方便的一些功能,比如热更新、SourceMap等。ssr要实现热更新需要在文件更新之后,重新执行ssr的渲染流程。

  1. 首先要区分开发模式还是生产模式,对开发模式下的服务进行特殊处理。

  2. 开发模式下需要监视文件修改,然后重新打包生成template、serverBundle、clientMannifest,之后再调用createBundleRenderer生成新的renderer。然后在通过renderer返回服务端渲染结果时,需要等待renderer生成之后才能使用。

    1. 考虑是将重新打包到生成renderer的过程封装为一个Promise,通过Promise的状态来确定整个过程是否完成。
    // server.js修改部分
    // 创建服务器对象
    const express = require('express')
    const server = express()
    
    const { createBundleRenderer } = require('vue-server-renderer')
    
    const setupDevServer = require('./build/setup-dev-server')
    const isProd = process.env.NODE_ENV === 'production'
    let devServerReady
    let renderer
    
    if (isProd) {
      // 服务端入口打包之后的文件
      const serverBundle = require('./dist/vue-ssr-server-bundle.json')
      // 模板文件
      const template = require('fs').readFileSync('./index.template.html', 'utf-8')
      // 客户端入口打包之后的文件
      const clientManifest = require('./dist/vue-ssr-client-manifest.json')
      // 创建渲染器
      renderer = createBundleRenderer(serverBundle, {
        template,
        clientManifest
      })
    } else {
       // 开发模式下 监视文件修改 -> 重新打包生成文件 -> 再读取文件生成renderer
      // setupDevServer 返回一个Promise,这样在外部就能通过Promise拿到其状态
      devServerReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
        // 创建渲染器
        renderer = createBundleRenderer(serverBundle, {
          template,
          clientManifest
        })
      })
    }
    
    const render = async (req, res) => {
      // 处理物理磁盘的静态资源访问
      server.use('/dist', express.static('./dist'))
      try {
        const html = await renderer.renderToString({
          title: '伍绍清',
          meta: `<meta http-equiv="X-UA-Compatible" content="IE=edge" />`,
        })
        res.setHeader('Content-Type', 'text/html;charset=utf-8') // 设置返回内容的编码格式,反之乱码
        res.end(html)
      } catch(e) {
        return res.status(500).end(e)
      }
    }
    
    
    server.get('*', isProd ? render : async (req, res) => {
      await devServerReady // 开发环境需要等待renderer生成之后再调用render
      render(req, res)
    })
    
    // setupDevServer
    const fs = require('fs')
    const path = require('path')
    const chokidar = require('chokidar')
    const webpack = require('webpack')
    const webpackDevMiddleware = require('webpack-dev-middleware')
    const hotMiddleware = require('webpack-hot-middleware')
    
    const resolve = file => path.resolve(__dirname, file)
    
    module.exports = function setupDevServer(server, cb) {
      let ready
      const p = new Promise(res => ready = res)
    
      let serverBundle
      let template
      let clientManifest
    
      const update = () => {
        // 三个文件都生成好之后才能重新生成renderer
        if (serverBundle && template && clientManifest) {
          ready() // 调用resolve方法改变Promise状态
          cb(serverBundle, template, clientManifest)
        }
      }
    
      // 监视template 文件变化,并重新构建template -> 调用update更新renderer
      // 读取template文件,构建template,文件更新之后重新读取template文件,构建template
      const templatePath = resolve('../index.template.html')
      template = fs.readFileSync(templatePath, 'utf-8')
      // 监视资源变化,使用chokidar,基于fs.watch fs.watchFile
      chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8')
        update()
      })
    
      // 监视serverBundle 文件变化,并重新构建serverBundle -> 调用update更新renderer
      // 构建serverBundle需要使用webpack打包
      const serverConfig = require('./webpack.server.config')
      const serverCompiler = webpack(serverConfig)
      // webpackDevMiddleware会将打包生成的文件存放在内存中,自动以监视模式运行,不用手动通过watch监视文件变化
      const serverDevMiddlerware = webpackDevMiddleware(serverCompiler)
      // 注册一个插件,在每次执行完构建之后,读取内存中的构建结果,生成serverBundle
      serverCompiler.hooks.done.tap('server', () => {
        // 从内存中读取打包之后的结果需要借助webpackDevMiddleware的返回值.serverDevMiddleware.context.outputFileSystem就和node文件系统的fs模块是一样的,读取的文件地址都不用改变
        const serverCompilerResult = serverDevMiddlerware.context.outputFileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
        // 读取的结果时字符串,需要转换从js代码
        serverBundle = JSON.parse(serverCompilerResult)
        update()
      })
    
      // 监视clientManifest 文件变化,并重新构建clientManifest -> 调用update更新renderer
      // clientManifest的打包和serverBundle的打包类似,都需要经过webpack打包
      const clientConfig = require('./webpack.client.config')
      // 增加热更新配置\
      // 1.添加HotModuleReplacementPlugin插件
      clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
      // 2.修改入口,将 webpack-hot-middleware/client 添加到入口数组最前面
      clientConfig.entry.app = [
        'webpack-hot-middleware/client?quiet=true&reload=true',
        clientConfig.entry.app
      ]
      // 3.去掉output.filename中的chunk,webpack-hot-middleware需要保证每次输出的文件名字一致。
      clientConfig.output.filename = '[name].js'
      const clientComparer = webpack(clientConfig)
      // 使用webpack-dev-middleware监视clientManifest打包
      const clientDevMiddleware = webpackDevMiddleware(clientComparer, {
        publicPath: clientConfig.output.publicPath
      })
      // 注册插件,在client文件打包之后执行回调
      clientComparer.hooks.done.tap('client', () => {
        // 读取clientManifest打包之后的结果并重新构建,然后调用update
        clientManifest = JSON.parse(
          clientDevMiddleware.context.outputFileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
        )
        update()
      })
    
      // 3.在server中挂载插件
      server.use(hotMiddleware(clientComparer, {
        log: false ,// 关闭hotMiddleware本身的日志输出
      }))
    
      // 将clientDevMiddleware挂载到Express服务中,提供对其内部内存中数据的访问
      server.use(clientDevMiddleware)
    
      return p
    }
    
  3. 运行命令行yarn dev即可进入开发模式,文件修改之后将重新打包编译。

SSR路由处理

使用vue-router进行路由处理

  1. 安装vue-router依赖,创建router/index.js文件,并导出一个创建router的函数。

    // router/index.js
    import Vue from 'vue'
    import Router from 'vue-router'
    import Home from '@/pages/home.vue'
    
    Vue.use(Router)
    
    export default function createRouter () {
      return new Router({
        mode: 'history', // 服务端大多不支持hash模式的路由,history模式的路由前后端支持更好
        routes: [
          {
            name: 'home',
            path: '/',
            component: Home
          },
          {
            name: 'about',
            path: '/about',
            component: () => import('@/pages/about.vue')
          },
          {
            name: 'post',
            path: '/post',
            component: () => import('@/pages/post.vue')
          },
          {
            name: '404',
            path: '*',
            component: () => import('@/pages/404.vue')
          }
        ]
      })
    }
    
  2. 在src/app.js中通过函数创建router实例,并将router实例注入到根Vue实例中。

    // src/app.js
    export default function createApp() {
      const router = createRouter()
      const app = new Vue({
        router,
        render: h => h(App)
      })
      return {
        router,
        app
      }
    }
    
  3. 修改server.js,在执行render.renderToString时将req.url传入,后续server端入口文件需要根据请求的url来设置router的路由。

    const html = await renderer.renderToString({
      title: '伍绍清',
      meta: `<meta http-equiv="X-UA-Compatible" content="IE=edge" />`,
      url: req.url
    })
    
  4. 修改entry-server.js,等待router将可能的异步组件和钩子解析执行完,再返回vue实例

    // entry-server.js
    import createApp from './app'
    
    export default async context => {
      const { app, router } = createApp()
    
      // 设置服务器端的 router 位置
      router.push(context.url)
    
      // 等待 router 将可能的异步组件和钩子解析完
      await new Promise(router.onReady.bind(router))
    
      return app
    }
    
  5. 修改entry-client.js,等待路由准备完之后渲染 Vue实例到 id为 app 的元素内

    // entry-client.js
    /**
     * 客户端渲染入口
     */
    import createApp from './app'
    
    const { app, router } = createApp()
    
    // 等待路由准备完之后渲染 Vue实例到 id为 app 的元素内
    router.onReady(() => {
      // 在id为app的div中渲染Vue实例
      app.$mount('#app')
    })
    

SSR页面Head 管理

页面Head管理即管理页面中head标签中的内容。在服务端可以通过this.$ssrContext来访问或者设置。在客户端则通过document来设置。先来看一种官方推荐做法,利用mixin来实现。

  1. 首先创建src/mixins/title-mixin.js文件,导出一个titleMixin,根据渲染端的不同利用不同的方法设置title。

    // title-mixin.js
    function getTitle(vm) {
      // 组件可以提供一个 `title` 选项
      // 此选项可以是一个字符串或函数
      const { title } = vm.$options
      if (title) {
        return typeof title === 'function' ? title.call(vm) : title
      }
    }
    
    const serverTitleMixin = {
      created() {
        const title = getTitle(this)
        if (title) {
          this.$ssrContext.title = title
        }
      }
    }
    
    const clientTitleMixin = {
      mounted() {
        const title = getTitle(this)
        if (title) {
          document.title = title
        }
      }
    }
    
    export default process.env.VUE_ENV === 'server' ? serverTitleMixin : clientTitleMixin
    
  2. 修改package.json中的命令行,设置全局的VUE_ENV环境变量。

    "scripts": {
        "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
        "build:server": "cross-env NODE_ENV=production VUE_ENV=server webpack --config build/webpack.server.config.js",
        "build": "rimraf dist && yarn build:client && yarn build:server",
        "start": "cross-env NODE_ENV=production node server.js",
        "dev": "nodemon server.js"
      },
    
  3. 在具体的页面组件内添加title属性。

    <script>
    export default {
      title: 'Home',
      name: 'HomePage'
    }
    </script>
    
  4. 启动项目,访问页面,浏览器标签栏将出现页面组件设置的title。
    在这里插入图片描述

处理上面的方法外还可以使用第三方插件vue-meta来实现。

  1. 安装vue-meta,并在src/app.js中注册插件,然后定义一个全局的mixin,为所有的Vue实例设置一个metaInfo.titleTemplate属性来设置title的模板。

    import VueMeta from 'vue-meta';
    // 注册插件
    Vue.use(VueMeta)
    Vue.mixin({
      metaInfo: {
        titleTemplate: '%s - wsq'
      }
    })
    
  2. 在页面组件中设置metaInfo.title属性,title属性的值将替换模板中的%s,最终显示在标签栏中。

    export default {
      // title: 'About',
      metaInfo: {
        title: 'About'
      },
      name: 'AboutPage'
    }
    

SSR数据预取和状态管理

服务端和客户端的数据状态要一致,不然会混合失败,导致渲染结果错误。
在这里插入图片描述
通过Vuex来实现服务端和客户端数据状态的一致。

  1. 安装Vuex,并创建store/index.js文件,注册插件,并导出一个创建Store实例的函数。

    // store/index.js
    import Vuex from 'vuex'
    import axios from 'axios'
    import Vue from 'vue'
    
    Vue.use(Vuex)
    
    export default function createStore() {
      return new Vuex.Store({
        state: {
          posts: []
        },
        mutations: {
          savePosts(state, payload) {
            state.posts = payload
          }
        },
        actions: {
          async getPosts({ commit }) {
            const { data } = await axios({
              url: 'https://cnodejs.org/api/v1/topics',
              method: 'GET'
            })
            commit('savePosts', data.data)
          }
        }
      })
    }
    
  2. 修改src/app.js,在根Vue实例中注入Store实例

    // src/app.js
    export default function createApp() {
      const router = createRouter()
      const store = createStore()
      const app = new Vue({
        router,
        store,
        render: h => h(App)
      })
      return {
        store,
        router,
        app
      }
    }
    
  3. 修改entry-server.js,定义context.rendered方法,该方法会在务端渲染完毕之后调用,在方法中将store.state保存到context.state。context.state 数据将被内联到页面模板中。最终发送给客户端的页面中会包含一段脚本: window.__INITIAL_STATE__ = context.state。客户端将 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中。

    // 会在服务端渲染完毕之后被调用,可以在函数内部拿到服务端渲染好的状态数据
    context.rendered = () => {
      // rendered 会把 context.state 数据对象内联到页面模板中
      // 最终发送给客户端的页面中会包含一段脚本: window.__INITIAL_STATE__ = context.state
      // 客户端将 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
      context.state = store.state
    }
    
  4. 修改entry-client.js,将 window.__INITIAL_STATE__ 填充到store容器中。

    // 同步从entry-server中设置的数据,保持两次渲染的状态一致
    if(window.__INITIAL_STATE__) {
      store.replaceState(window.__INITIAL_STATE__)
    }
    
  5. 创建src/pages/post.vue来使用store中的数据。Vue提供为服务端渲染提供了一个特殊的声明周期钩子serverPrefetch,会在服务端渲染之前调用,必须返回Promise。可以在该生命周期中调用action中异步获取数据。

    <template>
      <div>
        <ul>
          <li v-for="p in posts" :key="p.id">{{ p.title }}</li>
        </ul>
      </div>
    </template>
    
    <script>
    import { mapState, mapActions } from 'vuex'
    
    export default {
      metaInfo: {
        title: 'Post'
      },
      name: 'PostPage',
      data() {
        return {}
      },
      computed: {
        ...mapState(['posts'])
      },
      methods: {
        ...mapActions(['getPosts'])
      },
      // 服务端渲染特有的生命周期钩子, 会在渲染之前调用,必须返回Promise
      serverPrefetch() {
        return this.getPosts()
      }
    }
    </script>
    
    <style>
    
    </style>
    

总结

服务端渲染基本构成就是服务端负责处理并返回首屏渲染的内容,之后客户端在接管页面的渲染,最后通过第三方的插件来管理路由以及数据状态,保证两端的路由和数据状态的同步。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值