解读vue-server-renderer源码并在react中的实现
前言
在博客开发的过程中,有这样一个需求想解决,就是在SSR开发环境中,服务端的代码是是直接通过webpack打包成文件(因为里面包含同构的代码,就是服务端与客户端共享前端的组件代码),写到磁盘里,然后在启动打包好的入口文件来启动服务。但是我不想在开发环境把文件打包到磁盘中,想直接打包在内存中,这样不仅能优化速度,还不会因开发环境产生多余文件。还有就是webpack对require的处理,会导致路径映射的问题,包括对require变量的问题。所以我就想只有组件相关的代码进行webpack编译,别的无关的服务端代码不进行webpack编译处理。
但是这中间有个问题一直悬而不决,就是如何引入内存中的文件。包括在引入这个文件后,如何把关联的文件一起引入,如通过require(module)
引入的模块,于是我想到以前在给vue做ssr的时候用到的vue-server-renderer
这个库,这个是没有直接打出文件,而是把文件打入了内存中。但是他却能获取到文件,并执行文件获取到结果。于是就开启了这次的研究之旅。
实现
先讲下项目这块的实现流程,然后在讲下vue-server-renderer
这个包是如何解决这个问题的,以此在react中的实现。
|-- webpack
| |-- webpack.client.js // entry => clilent-main.js
| |-- webpack.server.js // entry => server-main.js
|-- client // 客户端代码
| |-- app.js
| |-- client-main.js // 客户端打包入口
| |-- server-main.js // server端打包代码入口
|-- server // server端代码
| |-- ssr.js // ssr启动入口
client-main.js
, 客户端打包一份代码,就是正常的打包, 打包出对应的文件。import React, { useEffect, useState } from 'react'
import ReactDom from 'react-dom'
import App from './app' loadableReady(() => {
ReactDom.hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('app')
)
})
server-main.js
,因为是SSR,所以在服务端也需要打包一份对应的js文件,用于ssr渲染。我这里是打算在这块直接处理完组件相关的数据,返回html,到时候服务端直接引入这个文件,获取html返回给前端就行。这是我的项目的处理,vue官方demo会有点区别,他是直接返回的app实例(new Vue(...)
, 然后在vue-server-renderer
库中解析这个实例,最后同样也是返回解析好的html字符串。这里会有点区别,原理还是一样。// 返回一个函数,这样可以传入一些参数,用来传入服务端的一些数据
import { renderToString } from 'react-dom/server'
export default async (context: IContext, options: RendererOptions = {}) => {
// 获取组件数据
... // 获取当前url对应的组件dom信息
const appHtml = renderToString(
extractor.collectChunks(
<Provider store={store}>
<StaticRouter location={context.url} context={context as any}>
<HelmetProvider context={helmetContext}>
<App />
</HelmetProvider>
</StaticRouter>
</Provider>
)
) // 渲染模板
const html = renderToString(
<HTML>{appHtml}</HTML>
)
context.store = store
return html
}
3. `ssr.js`, 因为这些文件我都是打在内存中的。所以我需要解析内存中的文件,来获取`server-main.js`中的函数,执行他,返回html给前端。
```typescript
// start方法是执行webpack的node端代码,用于把编译的文件打入内存中。
import { start } from '@root/scripts/setup'
// 执行他,createBundleRenderer方法就是用来解析在server端打包的代码
start(app, ({ loadableStats, serverManifest, inputFileSystem }) => {
renderer = createBundleRenderer({
loadableStats,
serverManifest,
inputFileSystem
})
})
// 执行server-main.js中的函数并获取html
const html = await renderer.renderToString(context)
ctx.body = html
客户端的好说,通过创建html模板,然后把当前路由对应的资源(js, css,..)引入,访问的时候,浏览器直接拉取资源就行(这块是通过@loadable/webpack-plugin
、@loadable/server
、@loadable/component
来进行资源的加载与获取,此处不做过多介绍,此文重点不在这个)。
这块的重点就是如何在内存中解析server-main.js
这个被打包出来的需要在服务端引用的代码。
我们来看vue ssr的官方代码: vue-hackernews-2.0
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/server-main.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
plugins: [
new VueSSRServerPlugin()
]
})
上面用到了一个vue-server-renderer/server-plugin
, 这个插件的主要功能是干嘛呢,其实就是对webpack中的资源做了下处理,把其中的js资源全部打在了一个json文件中。
源码如下:
// webpack上自定义了一个vue-server-plugin插件
compiler.hooks.emit.tapAsync('vue-server-plugin', (compilation, cb) => {
// 获取所有资源
var stats = compilation.getStats().toJson();,
var entryName = Object.keys(stats.entrypoints)[0];
var entryInfo = stats.entrypoints[entryName];
// 不存在入口文件
if (!entryInfo) {
return cb()
}
var entryAssets = entryInfo.assets.filter(isJS);
// 入口具有多个js文件,只需一个就行: entry: './src/entry-server.js'
if (entryAssets.length > 1) {
throw new Error(
"Server-side bundle should have one single entry file. " +
"Avoid using CommonsChunkPlugin in the server config."
)
}
var entry = entryAssets[0];
if (!entry || typeof entry !== 'string') {
throw new Error(
("Entry \"" + entryName + "\" not found. Did you specify the correct entry option?")
)
}
var bundle = {
entry: entry,
files: {},
maps: {}
};
// 遍历所有资源
stats.assets.forEach(function (asset) {
// 是js资源,就存入bundle.files字段中。
if (isJS(asset.name)) {
bundle.files[asset.name] = compilation.assets[asset.name].source();
} else if (asset.name.match(/\.js\.map$/)) { // sourceMap文件,存入maps字段中,用来追踪错误
bundle.maps[asset.name.replace(/\.map$/, '')] = JSON.parse(compilation.assets[asset.name].source());
}
// 删除资源,因为js跟js.map已经存到bundle中了,需要的资源已经存起来了,别的没必要打包出来了。
delete compilation.assets[asset.name];
});
var json = JSON.stringify(bundle, null, 2);
var filename = this$1.options.filename; // => vue-ssr-server-bundle.json
// 把bundle存入assets中,那样assets中就只有vue-ssr-server-bundle.json这个json文件了,
/*
vue-ssr-server-bundle.json
{
entry: 'server-bundle.js',
files: [
'server-bundle.js': '...',
'1.server-bundle.js': '...',
],
maps: [
'server-bundle.js.map': '...',
'1.server-bundle.js.map': '...',
]
}
*/
compilation.assets[filename] = {
source: function () { return json; },
size: function () { return json.length; }
};
cb();
});
这个插件的处理也及其简单,就是拦截了资源,对其重新做了下处理。生成一个json文件,到时候方便直接进行解析处理。
然后我们来看node服务的入口文件,来看如何获取html,并进行解析的
const { createBundleRenderer } = require('vue-server-renderer')
// bundle: 读取vue-ssr-server-bundle.json中的数据,
/*
bundle => vue-ssr-server-bundle.json
{
entry: 'server-bundle.js',
files: [
'server-bundle.js': '...',
'1.server-bundle.js': '...',
],
maps: [
'server-bundle.js.map': '...',
'1.server-bundle.js.map': '...',
]
}
*/
renderer = createBundleRenderer(bundle, {
template: fs.readFileSync(templatePath, 'utf-8'), // html模板
// client端json文件,也存在于内存中,也是对webpack资源的拦截处理,这里不做多介绍,原理差不多。读取对应的资源放入html模板中,在client端进行二次渲染,绑定vue事件等等
clientManifest: readFile(devMiddleware.fileSystem, 'vue-ssr-client-manifest.json'),
runInNewContext: false // 在node沙盒中共用global对象,不创建新的
}))
const context = {
title: 'Vue HN 2.0', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.send(html)
})
通过查看上面server端项目启动的入口文件,里面用createBundleRenderer
中的renderToString
来直接返回html,所以来到vue-server-renderer
这个库来看看这个里面到底做了什么
function createRenderer(ref) {
return {
renderToString: (app, context, cb) => {
// 解析app: app => new Vue(...),就是vue实例对象
// 这块就是对vue组件的编译解析,最后获取对应的html string
// 重点不在这,此处也不做过多介绍
const htmlString = new RenderContext({app, ...})
return cb(null, htmlString)
}
}
}
function createRenderer$1(options) {
return createRenderer({...options, ...rest})
}
function createBundleRendererCreator(createRenderer) {
return function createBundleRenderer(bundle, rendererOptions) {
entry = bundle.entry;
// 关联的js资源内容
files = bundle.files;
// sourceMap内容
// createSourceMapConsumers方法作用便是通过require('source-map')模块来追踪错误文件。因为我们都进行了资源拦截,所以这块也需要自己实现对错误的正确路径映射。
maps = createSourceMapConsumers(bundle.maps);
// 调用createRenderer方法获取renderer对象
var renderer = createRenderer(rendererOptions);
// 这块就是处理内存文件中的代码了,
// {files: ['entry.js': 'module.exports = a']}, 就是我读取entry.js文件中的内容,他是字符串, 然后node如何处理的,处理完之后得到结果。
// 下面这个方法进行详细说明
var run = createBundleRunner(
entry,
files,
basedir,
rendererOptions.runInNewContext
);
return {
renderToString: (context, cb) => {
// 执行run方法,就能获取我在server-main.js入口文件里面 返回的new Vue实例
run(context).then(app => {
renderer.renderToString(app, context, function (err, res) {
// 打印错误映射的正确文件路径
rewriteErrorTrace(err, maps);
// res: 解析好的html字符串
cb(err, res);
});
})
}
}
}
}
var createBundleRenderer = createBundleRendererCreator(createRenderer$1);
exports.createBundleRenderer = createBundleRenderer;
- 上面逻辑也比较清晰明了,通过
createBundleRunner
方法来解析入口文件的字符串代码,vueserver-main.js
入口文件返回是一个Promise函数,Promise返回的是new Vue()
,所以解析出来的结果就new Vue
实例。 - 通过
RenderContext
等实例解析返回的new Vue
实例,获取到对应的html字符串。 - 通过
source-map
模块对错误进行正确的文件路径映射。
这样就实现了在内存中执行文件中的代码,返回html,达到ssr的效果。这次文章的重点是如何执行那段入口文件的 字符串 代码。
我们来到createBundleRunner
方法,来看看里面到底是如何实现的。
function createBundleRunner (entry, files, basedir, runInNewContext) {
var evaluate = compileModule(files, basedir, runInNewContext);
if (runInNewContext !== false && runInNewContext !== 'once') {
// 这块runInNewContext不传false 跟 once这两个选项的话,每次都会生成一个新的上下文环境,我们共用一个上下文global就行。所以这块就不考虑
} else {
var runner;
var initialContext;
return function (userContext) {
// void 0 === undefined, 因为undefined可被重新定义,void没法重新定义,所以用void 0 肯定是undefined
if ( userContext === void 0 ) userContext = {};
return new Promise(function (resolve) {
if (!runner) {
// runInNewContext: false, 所以这里上下文就是指的global
var sandbox = runInNewContext === 'once'
? createSandbox()
: global;
// 通过调用evaluate方法返回入口文件的函数。代码实现: evaluate = compileModule(files, basedir, runInNewContext)
// 去到compileModule方法看里面是如何实现的
/*
vue官方demo的server-main.js文件,返回的时一个Promise函数,所以runner就是这个函数。
export default context => {
return new Promise((resolve) => {
const { app } = createApp()
resolve(app)
})
}
*/
// 传入入口文件名,返回入口函数。
runner = evaluate(entry, sandbox);
}
// 执行promise返回 app,至此app就得到了。
resolve(runner(userContext));
});
}
}
}
// 这个方法返回了evaluateModule方法,也就是上面evaluate方法
// evaluate = function evaluateModule(filename, sandbox, evaluatedFiles) {}
function compileModule (files, basedir, runInNewContext) {
var compiledScripts = {};
// filename: 依赖的文件名,例如 server.bundle.js 或 server.bundle.js依赖的 1.server.bundle.js 文件
// 在通过vue-ssr-server-bundle.json中的files字段获取这个文件名对应的文件内容 类似:"module.exports = 10"字符串
// 通过node的module模块来包裹这段代码,代码其实很简单粗暴,封装成了一个函数,传入我们熟知的commonjs规范中的require、exports等等变量
/*
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname, process, global) { ',
'\n});'
];
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
结果:
function (exports, require, module, __filename, __dirname, process, global) {
module.exports = 10
}
*/
// 通过vm模块创建沙盒环境,来执行这段js代码。
function getCompiledScript (filename) {
if (compiledScripts[filename]) {
return compiledScripts[filename]
}
var code = files[filename];
var wrapper = require('module').wrap(code);
var script = new require('vm').Script(wrapper, {
filename: filename,
displayErrors: true
});
compiledScripts[filename] = script;
return script
}
function evaluateModule (filename, sandbox, evaluatedFiles) {
if ( evaluatedFiles === void 0 ) evaluatedFiles = {};
if (evaluatedFiles[filename]) {
return evaluatedFiles[filename]
}
// 获取这个执行这段代码的沙盒环境
var script = getCompiledScript(filename);
// 沙盒环境使用的上下文 runInThisContext => global
var compiledWrapper = runInNewContext === false
? script.runInThisContext()
: script.runInNewContext(sandbox);
var m = { exports: {}};
var r = function (file) {
file = path$1.posix.join('.', file);
// 当前js依赖的打包文件,存在,继续创建沙盒环境执行
if (files[file]) {
return evaluateModule(file, sandbox, evaluatedFiles)
} else {
return require(file)
}
};
// 执行函数代码。注意webpack要打包成commonjs规范的,不然这里就对不上了。
compiledWrapper.call(m.exports, m.exports, r, m);
// 获取返回值
var res = Object.prototype.hasOwnProperty.call(m.exports, 'default')
? m.exports.default
: m.exports;
evaluatedFiles[filename] = res;
// 返回结果
return res
}
return evaluateModule
}
createBundleRunner
函数里的实现其实也不多。就是创建一个沙盒环境来执行获取到的代码
整个逻辑核心思路如下
- 通过拦截webpack assets 生成一个json文件,包含所有js文件数据
- 通过入口文件到生成好的json文件里面取出来那段字符串代码。
- 通过
require('module').wrap
把字符串代码转换成函数形式的字符串代码,commonjs规范 - 通过
require('vm')
创建沙盒环境来执行这段代码,返回结果。 - 如果入口文件有依赖别的文件,再次执行 2 - 4步骤,把入口文件换成依赖的文件就好,例如,路由一般都是懒加载的,所以在访问指定路由时,webpack打包出来也会获取这个对应的路由文件,依赖到入口文件里面。
- 通过沙盒环境执行获取到的返回结果,在vue-hackernews-2.0项目中是
new Vue
实例对象。 - 解析这个
vue
实例,获取到对应的html字符串,放入html模板中,最后返回给前端。
这样就实现了读取内存文件,得到对应的html数据。主要就是通过 vm
模块跟module
模块来执行这些代码的。其实这块的整个代码也还是比较简单的。并没有什么复杂的逻辑。
因为项目是基于react
和webpack5
的,所以在代码的处理上会有些不同,但是实现方案基本还是一致的。
其实说到执行代码,js里面还有一个方法可以执行代码,就是eval
方法。但是eval
方法在require
的时候都是在本地模块中进行查找,存在于内存中的文件我发现没法去进行require
查找。所以还是用的vm
模块来执行的代码,毕竟可以重写require方法
项目完整代码:GitHub 仓库
我自己新创建了一个相互学习的群,无论你是准备入坑的小白,还是半路入行的同学,希望我们能一起分享与交流。
QQ群:810018802, 点击加入
QQ群 | 公众号 |
---|---|
前端打杂群 |
冬瓜书屋 |
解读vue-server-renderer源码并在react中的实现的更多相关文章
- Go Revel - server.go 源码分析
之前介绍了 Go Revel - main函数分析 http://www.cnblogs.com/hangxin1940/p/3263775.html 最后会调用 `revel.Run(*port)` ...
- 从vue.js的源码分析,input和textarea上的v-model指令到底做了什么
v-model是 vue.js 中用于在表单表单元素上创建双向数据绑定,它的本质只是一个语法糖,在单向数据绑定的基础上,增加了监听用户输入事件并更新数据的功能:对,它本质上只是一个语法糖,但到底是一个 ...
- 一张思维导图辅助你深入了解 Vue | Vue-Router | Vuex 源码架构
1.前言 本文内容讲解的内容:一张思维导图辅助你深入了解 Vue | Vue-Router | Vuex 源码架构. 项目地址:https://github.com/biaochenxuying/vu ...
- 安卓图表引擎AChartEngine(四) - 源码示例 嵌入Acitivity中的折线图
前面几篇博客中都是调用ChartFactory.get***Intent()方法,本节讲的内容调用ChartFactory.get***View()方法,这个方法调用的结果可以嵌入到任何一个Activ ...
- Spring5源码解析-Spring框架中的单例和原型bean
Spring5源码解析-Spring框架中的单例和原型bean 最近一直有问我单例和原型bean的一些原理性问题,这里就开一篇来说说的 通过Spring中的依赖注入极大方便了我们的开发.在xml通过& ...
- angular源码分析:angular中脏活累活的承担者之$interpolate
一.首先抛出两个问题 问题一:在angular中我们绑定数据最基本的方式是用两个大括号将$scope的变量包裹起来,那么如果想将大括号换成其他什么符号,比如换成[{与}],可不可以呢,如果可以在哪里配 ...
- angular源码分析:angular中入境检察官$sce
一.ng-bing-html指令问题 需求:我需要将一个变量$scope.x = '<a href="http://www.cnblogs.com/web2-developer/&qu ...
- angular源码分析:angular中各种常用函数,比较省代码的各种小技巧
angular的工具函数 在angular的API文档中,在最前面就是讲的就是angular的工具函数,下面列出来 angular.bind //用户将函数和对象绑定在一起,返回一个新的函数 angu ...
- angular源码分析:angular中的依赖注入式如何实现的
一.准备 angular的源码一份,我这里使用的是v1.4.7.源码的获取,请参考我另一篇博文:angular源码分析:angular源代码的获取与编译环境安装 二.什么是依赖注入 据我所知,依赖注入 ...
随机推荐
- c语言链表从本地文件中读取和写入数据
1 typedef struct Data{ 2 40 char *name; 3 41 char *IDCARD; 4 42 char *job_id; 5 43 char *length; 6 4 ...
- Linux系统(Centos7)最新版本Docker简易(yum)安装步骤
Docker从1.13版本之后采用时间线的方式作为版本号,分为社区版CE和企业版EE. 社区版是免费提供给个人开发者和小型团体使用的,企业版会提供额外的收费服务,比如经过官方测试认证过的基础设施.容器 ...
- 060_Cookie/Session
目录 会话 有状态会话 保存会话的两种技术 cookie session 常见场景 Cookie cookie细节 删除cookie Session 什么是session session使用场景 se ...
- x64dbg 条件断点相关文档
输入 字符格式 条件断点 Input When using x64dbg you can often use various things as input. Commands Commands ha ...
- [源码解析] 并行分布式框架 Celery 之 worker 启动 (1)
[源码解析] 并行分布式框架 Celery 之 worker 启动 (1) 目录 [源码解析] 并行分布式框架 Celery 之 worker 启动 (1) 0x00 摘要 0x01 Celery的架 ...
- java例题_03 水仙花数
1 /*3 [程序 3 水仙花数] 2 题目:打印出所有的"水仙花数",所谓"水仙花数"是指一个三位数,其各位数字立方和等于该数本身. 3 例如:153 是一个 ...
- Android Studio 之 制作 Nine-Patch 图片(.9图片)
•引言 9.png 可以保证图片在合适的位置进行局部拉伸,避免了图片全局缩放造成的图片变形问题. 但是由于Android Studio对于.9图片的检查更加严格,所以不符合AS要求的.9图片会带来很多 ...
- [Fundamental of Power Electronics]-PART II-9. 控制器设计-9.5 控制器的设计
9.5 控制器设计 现在让我们来考虑如何设计控制器系统,来满足有关抑制扰动,瞬态响应以及稳定性的规范或者说设计目标.典型的直流控制器设计可以用以下规范定义: 1.负载电流变化对输出电压调节的影响.当负 ...
- 通过Dapr实现一个简单的基于.net的微服务电商系统(五)——一步一步教你如何撸Dapr之状态管理
状态管理和上一章的订阅发布都算是Dapr相较于其他服务网格框架来讲提供的比较特异性的内容,今天我们来讲讲状态管理. 目录:一.通过Dapr实现一个简单的基于.net的微服务电商系统 二.通过Dapr实 ...
- 自动化kolla-ansible部署ubuntu20.04+openstack-victoria之vmware设置-02
自动化kolla-ansible部署ubuntu20.04+openstack-victoria之vmware设置-02 欢迎加QQ群:1026880196 进行讨论 1. vmwae版本 2. 网 ...