likes
comments
collection
share

Vue3 SSR源码解读和实现一个mini-ssr

作者站长头像
站长
· 阅读数 53

总结

先来总结一下读完这篇文章会收获到什么吧。

  • Vue3框架的整体分包思想
  • 了解compiler阶段包的作用以及runtimer阶段包的作用
  • 了解 compiler-ssr、server-render主要代码实现
  • 实现一个mini-ssr服务端渲染应用

ok,以上就是读完这篇文章的收获。

Vue3分包

在面试中经常会被问到vue2vue3的区别是什么,通常都会有人这样回答:

  • vue3有组合式API
  • vue3用Proxy实现的响应时代里
  • vue3新增了许多组件
  • vue3的diff优化

等等,这样千篇一律的回答。我觉得这样的回答其实打动不了面试官并且面试官还可以能认为你没有看过源码。我觉得vue3最好的优化就是它的分包,可以让vue更加易于扩展以及和其他项目更好的结合。如果我们只需要使用vue的响应式,我们只需要使用@vue/reactivity这个包就可以。先来看一下vue3所有包有哪些。

Vue3 SSR源码解读和实现一个mini-ssr

Vue3那么多包只有三个比较重要的包compiler阶段下的所有包runtime阶段下的所有包响应式系统的包

先来看一下compiler阶段下的四个包的区别:

  • compiler-core 不依赖任何环境将vue代码转换成ast
  • compiler-dom 依赖于DOM元素生成ast
  • compiler-sfc 对单文件组件生成可执行的js代码
  • compiler-ssr 用于服务端渲染的

先看一下compiler-core编译出的ast

import { baseCompile } from '@vue/compiler-core'
const code = `
<script>
import { ref } from 'vue'
export default {
  setup() {
    const msg = ref('Hello World!');
    return { msg }
  }
}
</script>

<template>
  <h1>{{ msg }}</h1>
  <input v-model="msg" />
</template>
`
const result = baseCompile(code);

console.log(result);

Vue3 SSR源码解读和实现一个mini-ssr

comiler-dom编译出的ast

Vue3 SSR源码解读和实现一个mini-ssr

两个包所编译出来的ast差别还是挺大的。

compiler-ssr 源码解读

先来看一下compiler-ssr最终会编译成什么

import { compile } from '@vue/compiler-ssr'

const code = `
<script>
import { ref } from 'vue'
export default {
  setup() {
    const msg = ref('Hello World!');
    return { msg }
  }
}
</script>

<template>
  <h1>{{ msg }}</h1>
  <input v-model="msg" />
</template>
`

const result = compile(code);

Vue3 SSR源码解读和实现一个mini-ssr

接下来就来看一下compiler-ssr是如何从vue代码编译成这样的代码的。

  • 第一步通过compilerDOMbaseParse方法将源代码转换成js认识的ast
  • 第二步通过transfrom方法,添加一些服务端渲染插件来优化ast使其展示更多的内容

Vue3 SSR源码解读和实现一个mini-ssr

nodeTransform顾名思义就是对元素节点的处理,directieTransForm就是对vue指令的处理。

我们就先看一个插件吧,其他插件都是差不多的功能,先看一下nodeTransform中的ssrTransformIf插件做了什么吧。

const ssrTransformIf = compilerDom.createStructuralDirectiveTransform(
  /^(if|else|else-if)$/,
  compilerDom.processIf
);

代码是这样的,主要的逻辑就是在createStructuralDirectiveTransform函数了。

Vue3 SSR源码解读和实现一个mini-ssr

这里插入新的ast其实就是compiler-core生成的ast,因为使用compiler-core生成的ast不依赖于平台,它生成的ast里面有source属性这个属性就是源代码,这也是我们ssr所需要的东西。

没有使用ssrTransformIf插件所编译出来的ast

Vue3 SSR源码解读和实现一个mini-ssr

使用之后

Vue3 SSR源码解读和实现一个mini-ssr

接下来就到了重头戏了,也是最主要的部分。

接下来会调用ssrCodegenTransform方法。

Vue3 SSR源码解读和实现一个mini-ssr

先看一个createSSRTransformContext方法

Vue3 SSR源码解读和实现一个mini-ssr

然后processChildren方法.这个方法就是根据节点类型,来调用pushStringPartelements中添加。

function processChildren(parent, context, asFragment = false, disableNestedFragments = false, disableCommentAsIfAlternate = false) {
  if (asFragment) {
    context.pushStringPart(`<!--[-->`);
  }
  const { children } = parent;
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    switch (child.type) {
      case 1:
        switch (child.tagType) {
          case 0:
            ssrProcessElement(child, context);
            break;
          case 1:
            ssrProcessComponent(child, context, parent);
            break;
          case 2:
            ssrProcessSlotOutlet(child, context);
            break;
          case 3:
            break;
          default:
            context.onError(
              createSSRCompilerError(
                67,
                child.loc
              )
            );
            const exhaustiveCheck2 = child;
            return exhaustiveCheck2;
        }
        break;
      case 2:
        context.pushStringPart(shared.escapeHtml(child.content));
        break;
      case 3:
        context.pushStringPart(`<!--${child.content}-->`);
        break;
      case 5:
        context.pushStringPart(
          compilerDom.createCallExpression(context.helper(SSR_INTERPOLATE), [
            child.content
          ])
        );
        break;
      case 9:
        ssrProcessIf(
          child,
          context,
          disableNestedFragments,
          disableCommentAsIfAlternate
        );
        break;
      case 11:
        ssrProcessFor(child, context, disableNestedFragments);
        break;
      case 10:
        break;
      case 12:
      case 8:
        break;
      default:
        context.onError(
          createSSRCompilerError(
            67,
            child.loc
          )
        );
        const exhaustiveCheck = child;
        return exhaustiveCheck;
    }
  }
  if (asFragment) {
    context.pushStringPart(`<!--]-->`);
  }
}

elements整体内容就是这样了。

Vue3 SSR源码解读和实现一个mini-ssr

复制下来,用记事本看一下。

Vue3 SSR源码解读和实现一个mini-ssr

很明显,这就是整个vue文件解析出来的,在template模块中会出现一下动态数据,那么vue会识别这些数据,以对象的方式保存在elements中,这里的callee也是非常重要的。在生成最后的结果时会使用这些函数,从而实现动态数据的展示。

接下来就是最后一个方法generate方法。

function generate(ast){
  const functionName = ssr ? `ssrRender` : `render`;
  const args = ssr ? ["_ctx", "_push", "_parent", "_attrs"] : ["_ctx", "_cache"];
  
   push(`function ${functionName}(${signature}) {`);
   
  if (ast.codegenNode) {  // 这里的codegennNode就是我们ssrCodeTransform的context内容
    genNode(ast.codegenNode, context);
  }
}

genNode方法

function genNode(node, context) {
  if (shared.isString(node)) {
    context.push(node, -3 /* Unknown */);
    return;
  }
  if (shared.isSymbol(node)) {
    context.push(context.helper(node));
    return;
  }
  switch (node.type) {
    case 1:
    case 9:
    case 11:
      assert(
        node.codegenNode != null,
        `Codegen node is missing for element/if/for node. Apply appropriate transforms first.`
      );
      genNode(node.codegenNode, context);
      break;
    case 2:
      genText(node, context);
      break;
    case 4:
      genExpression(node, context);
      break;
    case 5:
      genInterpolation(node, context);
      break;
    case 12:
      genNode(node.codegenNode, context);
      break;
    case 8:
      genCompoundExpression(node, context);
      break;
    case 3:
      genComment(node, context);
      break;
    case 13:
      genVNodeCall(node, context);
      break;
    case 14:
      genCallExpression(node, context);
      break;
    case 15:
      genObjectExpression(node, context);
      break;
    case 17:
      genArrayExpression(node, context);
      break;
    case 18:
      genFunctionExpression(node, context);
      break;
    case 19:
      genConditionalExpression(node, context);
      break;
    case 20:
      genCacheExpression(node, context);
      break;
    case 21:
      genNodeList(node.body, context, true, false);
      break;
    case 22:
      genTemplateLiteral(node, context);
      break;
    case 23:
      genIfStatement(node, context);
      break;
    case 24:
      genAssignmentExpression(node, context);
      break;
    case 25:
      genSequenceExpression(node, context);
      break;
    case 26:
      genReturnStatement(node, context);
      break;
    case 10:
      break;
    default:
      {
        assert(false, `unhandled codegen node type: ${node.type}`);
        const exhaustiveCheck = node;
        return exhaustiveCheck;
      }
  }
}

那么多处理的,我们就不一一来看了,找一个比较典型了吧比如genCallExpression,是对表达式进行处理的。 它会通过我们上面提到的callee这个属性所指的函数进行包裹这个变量。

function genCallExpression(node, context) {
  const { push, helper, pure } = context;
  const callee = shared.isString(node.callee) ? node.callee : helper(node.callee);
  if (pure) {
    push(PURE_ANNOTATION);
  }
  push(callee + `(`, -2 /* None */, node);
  genNodeList(node.arguments, context);
  push(`)`);
}

调用escapHtml方法来做转义字符

Vue3 SSR源码解读和实现一个mini-ssr

最后就会生成如下的代码:

Vue3 SSR源码解读和实现一个mini-ssr

最终compiler-ssr的主要部分就解读完毕了,这里做一下总结

  • 通过compiler-dom包下的baseParse方法将源代码转换成ast语法树,并且这个ast语法树是针对DOM环境的
  • 通过transform方法并调用一些节点插件和指令插件来优化ast语法树,这里我们提到了一个ssrTransformIf的插件,会将compiler-core生成的ast里面的source拿过来。这正是ssr所需要的内容。
  • 然后又调用了ssrCodegenTransform方法,会的ast里面的source解析,返回elements变量。
  • 最后就是调用generate方法,对elements里面的内容解析。

接下来我们就来看看runtime阶段server-render包做了啥事情。通过compiler阶段生成的源代码就可以看出它使用了server-render包中的ssrRenderAttrssrInterpolate这两个方法,那么我就来看这两个方法做了啥事情。

function ssrRenderAttr(key, value) {
  if (!shared.isRenderableAttrValue(value)) {
    return ``;
  }
  return ` ${key}="${shared.escapeHtml(value)}"`;
}

ssrRenderAttr就非常简单返回的就是value=xxx。

function ssrInterpolate(value) {
  return shared.escapeHtml(shared.toDisplayString(value));
}

ssrRenderAttr也非常简单,就是返回内容,但是它做了一些处理。

const toDisplayString = (val) => {
  return isString(val) ? val : val == null ? "" : 
  isArray(val) || isObject(val) && (val.toString === objectToString || 
    !isFunction(val.toString)) ? isRef(val) ? 
    toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val);
};

它对对象类型的做了JSON.stringify的处理,对函数做了toString的处理,对Ref类型了做了.value的处理。

致辞整个SSR阶段就已经完成了。

实现一个mini-ssr

首先我们先把环境创建好,这里我们使用了一些第三方库。

  • babel-loader
  • webpack
  • webpack-cli
  • vue-loader
  • webpack-node-externals
  • vue
  • express
  • vue-router

我们就使用到了这些第三方库。

新建一个App.vue文件

<script setup>
import { ref } from "vue";

const count = ref(0);
const add = () => {
  count.value++;
};
</script>

<template>
  <div>
    {{ count }}
    <button @click="add">add</button>
  </div>
</template>

新建一个app.js文件

import { createSSRApp } from "vue";
import App from './App.vue'

export default function myCreateApp(){
  const app = createSSRApp(App);
  return app;
}

创建一个server.js

const express = require('express');
const { renderToString } = require('@vue/server-renderer');
const { default: myCreateApp } = require('./app');
const { createMemoryHistory } = require('vue-router')
const { default: createRouter } = require('./router')


const server = express();

server.use(express.static('build'));

server.get('/*', async (req, res) => {
  const app = myCreateApp();
  const html = await renderToString(app);
  res.send(`
    <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
      </head>
      <body>
        <div id="app">
          ${html}
        </div>
      </body>
      <script src="/client/client_bundle.js"></script>
      </html>
    `)
})

server.listen(3000, () => {
  console.log('start server http://localhost:3000')
})

这里使用了的@vue/server-render这个包,renderToString这个方法主要是获取Vue实例的html代码。

到这里我们就基本完成了服务端渲染的逻辑,但是我们直接用node命令来跑这个express服务肯定是跑不起来的,因为浏览器或者node不认识.vue文件,所以这里我们需要借助webpack工具以及vue-loader工具来解析.vue文件,然后使用node命令运行构建后的js文件。

我们来编写一个server.config.js文件,它用来打包我们的服务端

const path = require('path');
const nodeExternals = require('webpack-node-externals');
const { VueLoaderPlugin } = require('vue-loader')

/**
 * @type {import('webpack').Configuration}
 */

module.exports = {
  target: 'node', // 指定打包的是node
  mode: 'development',
  entry: './server.js',
  output: {
    filename: 'server_bundle.js',
    path: path.resolve(__dirname, 'build/server')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ],
  },
  plugins: [new VueLoaderPlugin()],
  externals: [nodeExternals()],
  watch: true
}

修改package.json文件,添加一个命令

"build:server": "webpack --config ./server.config.js",

这里我们将服务端代码打包到了build/server/server_bunld.js这个文件了。

我们修改package.json文件,添加一个start启动命令

 "start": "nodemon ./build/server/server_bundle.js"

使用npm run start启动,启动成功访问localhost:3000这个地址,我们就可以看到页面也出来了,我们点击查看网页源码,显示的不只是id=app了。

但是,当我们点击add按钮时,没有反应,这是为什么呢?

因为renderToString方法只是返回html代码,并没有js相关的逻辑代码,我们要怎么做才可以有js的功能呢?

这里我们需要使用createApp来创建一个vue实例,然后在打包,将生成的产物用script方式引入,这样就有了js的功能,我们来试一下。

创建一个client.js文件

import { createApp } from "vue";
import App from './App.vue';

const app = createApp(App);
app.mount('#app');

创建一个client.config.js用于打包client.js

const path = require('path');
const { VueLoaderPlugin } = require('vue-loader');

/**
 * @type {import('webpack').Configuration}
 */

module.exports = {
  target: 'web',
  mode: 'development',
  entry: './client.js',
  output: {
    filename: 'client_bundle.js',
    path: path.resolve(__dirname, 'build/client')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [new VueLoaderPlugin()],
  watch: true
}

修改package.json文件,添加一个命令

"build:client": "webpack --config ./client.config.js",

运行这个webpack配置文件,会得到一个client_bundle.js文件。

然后我们修改服务端代码,在返回的html内容里添加这个js文件。

res.send(`
    <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
      </head>
      <body>
        <div id="app">
          ${html}
        </div>
      </body>
      <script src="/client/client_bundle.js"></script> // 新增
      </html>
    `)

这时我们再启动这个服务,点击按钮这个功能没有问题了。

其实原理就是创建了两个相同的js文件,一个使用了createSSRApp创建应用,这个应用只能返回html代码,通过renderToString方法来返回html代码并没有js的代码。

我们想一下为什么不返回js的代码,我的理解是vue的视图更新使利用到了VDOM所以真实的DOM它完成不了更新的操作。

然后我们就需要使用createApp来再次创建一个应用,使用相同的Vue代码,然后打包。这里我们就使用它的js功能了,其实不止是使用了它的js功能,就连渲染出来的html也是VDOM

我们试一下,在App.vue文件中打印一下Math.random()你会发现vscode终端打印了一次浏览器控制台也打印了一次,因为在执行server_boundle.js会执行打印一次,然后引入的client_boundle.js又会打印一次。

这也就理解了为什么我们在Nuxt SSR框架中打印,控制台会有警告说服务端值和客户端的值不一致。

到这里我们实现了一个简单的mini-ssr的功能。那我们如何结合路由呢?

这里我们会使用vue-router包中的createMemoryHistory创建路由的方法,从英文翻译也可以理解这个方法的作用创建内存历史记录,它这个路由并不是基于浏览器的history的路由,就是一个模拟路由效果的方法,主要用于SSR服务端渲染功能。

修改App.vue文件,在template中添加router-view组件

<router-view></router-view>

创建两个路由页面views/about.vueviews/home.vue

// views/about.vue
<template>
  <div>about page</div>
</template>
//views/home.vue
<template>
  <div>home page</div>
</template>

创建路由配置文件router/index.js

import { createRouter } from "vue-router";

const routes = [
  {
    path: '/',
    component: () => import('../views/home.vue')
  },
  {
    path: '/about',
    component: () => import('../views/about.vue')
  }
]

export default function (history) {
  return createRouter({
    history,
    routes
  })
}

client.js修改成如下:

import { createApp } from "vue";
import App from './App.vue';
import { createWebHistory } from "vue-router";
import createRouter from "./router/index";

const app = createApp(App);
const router = createRouter(createWebHistory());
app.use(router);
router.isReady().then(() => {
  app.mount('#app');
})

到这里应该都ok吧,平常我们使用spa应用,路由也是这样配置的。

接下来我们来修改服务端的代码server.js,我们在renterToString方法之前添加以下代码。这里使用了createMemoryHistory就是为了创建一个虚拟的路由系统。

const router = createRouter(createMemoryHistory());
  app.use(router);
  await router.push(req.url || '/');
  await router.isReady();

重新打包并重新启动,然后在浏览器输入不同的路由地址,页面也展示了对应的内容。

如果大家理解困难的话也可以看看官网的文档:cn.vuejs.org/guide/scali…

到这里就完成了mini-ssrvue-router的整合,能看到这里的我相信都是热爱前端的伙伴,在这里也祝愿大家可以在前端的道路上越走越远。

转载自:https://juejin.cn/post/7394789388142346249
评论
请登录