Vue3 SSR源码解读和实现一个mini-ssr
总结
先来总结一下读完这篇文章会收获到什么吧。
- Vue3框架的整体分包思想
- 了解compiler阶段包的作用以及runtimer阶段包的作用
- 了解 compiler-ssr、server-render主要代码实现
- 实现一个mini-ssr服务端渲染应用
ok,以上就是读完这篇文章的收获。
Vue3分包
在面试中经常会被问到vue2
和vue3
的区别是什么,通常都会有人这样回答:
- vue3有组合式API
- vue3用Proxy实现的响应时代里
- vue3新增了许多组件
- vue3的diff优化
等等,这样千篇一律的回答。我觉得这样的回答其实打动不了面试官并且面试官还可以能认为你没有看过源码。我觉得vue3
最好的优化就是它的分包,可以让vue
更加易于扩展以及和其他项目更好的结合。如果我们只需要使用vue
的响应式,我们只需要使用@vue/reactivity
这个包就可以。先来看一下vue3
所有包有哪些。
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);
comiler-dom
编译出的ast
两个包所编译出来的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);
接下来就来看一下compiler-ssr
是如何从vue
代码编译成这样的代码的。
- 第一步通过
compilerDOM
的baseParse
方法将源代码转换成js
认识的ast
- 第二步通过
transfrom
方法,添加一些服务端渲染插件来优化ast
使其展示更多的内容
nodeTransform
顾名思义就是对元素节点的处理,directieTransForm
就是对vue指令
的处理。
我们就先看一个插件吧,其他插件都是差不多的功能,先看一下nodeTransform
中的ssrTransformIf
插件做了什么吧。
const ssrTransformIf = compilerDom.createStructuralDirectiveTransform(
/^(if|else|else-if)$/,
compilerDom.processIf
);
代码是这样的,主要的逻辑就是在createStructuralDirectiveTransform
函数了。
这里插入新的ast
其实就是compiler-core
生成的ast,因为使用compiler-core
生成的ast不依赖于平台,它生成的ast
里面有source
属性这个属性就是源代码,这也是我们ssr
所需要的东西。
没有使用ssrTransformIf
插件所编译出来的ast
使用之后
接下来就到了重头戏了,也是最主要的部分。
接下来会调用ssrCodegenTransform
方法。
先看一个createSSRTransformContext
方法
然后processChildren
方法.这个方法就是根据节点类型,来调用pushStringPart
往elements
中添加。
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整体内容就是这样了。
复制下来,用记事本看一下。
很明显,这就是整个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
方法来做转义字符
最后就会生成如下的代码:
最终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
包中的ssrRenderAttr
和ssrInterpolate
这两个方法,那么我就来看这两个方法做了啥事情。
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.vue
和views/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-ssr
和vue-router
的整合,能看到这里的我相信都是热爱前端的伙伴,在这里也祝愿大家可以在前端的道路上越走越远。
转载自:https://juejin.cn/post/7394789388142346249