在博客开发的过程当中,有这样一个需求想解决,就是在SSR开发环境中,服务端的代码是是直接经过webpack打包成文件(由于里面包含同构的代码,就是服务端与客户端共享前端的组件代码),写到磁盘里,而后在启动打包好的入口文件来启动服务。可是我不想在开发环境把文件打包到磁盘中,想直接打包在内存中,这样不只能优化速度,还不会因开发环境产生多余文件。还有就是webpack对require的处理,会致使路径映射的问题,包括对require变量的问题。因此我就想只有组件相关的代码进行webpack编译,别的无关的服务端代码不进行webpack编译处理。javascript
可是这中间有个问题一直悬而不决,就是如何引入内存中的文件。包括在引入这个文件后,如何把关联的文件一块儿引入,如经过require(module)
引入的模块,因而我想到之前在给vue作ssr的时候用到的vue-server-renderer
这个库,这个是没有直接打出文件,而是把文件打入了内存中。可是他却能获取到文件,并执行文件获取到结果。因而就开启了此次的研究之旅。css
先讲下项目这块的实现流程,而后在讲下vue-server-renderer
这个包是如何解决这个问题的,以此在react中的实现。html
|-- 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字符串。这里会有点区别,原理仍是同样。vue
// 返回一个函数,这样能够传入一些参数,用来传入服务端的一些数据 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 }
ssr.js
, 由于这些文件我都是打在内存中的。因此我须要解析内存中的文件,来获取server-main.js
中的函数,执行他,返回html给前端。java
// 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
这个被打包出来的须要在服务端引用的代码。node
咱们来看vue ssr的官方代码: vue-hackernews-2.0react
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
源码以下:git
// 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
方法来解析入口文件的字符串代码,vue server-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
函数里的实现其实也很少。就是建立一个沙盒环境来执行获取到的代码
整个逻辑核心思路以下
require('module').wrap
把字符串代码转换成函数形式的字符串代码,commonjs规范require('vm')
建立沙盒环境来执行这段代码,返回结果。new Vue
实例对象。vue
实例,获取到对应的html字符串,放入html模板中,最后返回给前端。这样就实现了读取内存文件,获得对应的html数据。主要就是经过 vm
模块跟module
模块来执行这些代码的。其实这块的整个代码也仍是比较简单的。并无什么复杂的逻辑。
由于项目是基于react
和webpack5
的,因此在代码的处理上会有些不一样,可是实现方案基本仍是一致的。
其实说到执行代码,js里面还有一个方法能够执行代码,就是eval
方法。可是eval
方法在require
的时候都是在本地模块中进行查找,存在于内存中的文件我发现无法去进行require
查找。因此仍是用的vm
模块来执行的代码,毕竟能够重写require方法
项目完整代码:GitHub 仓库
我本身新建立了一个相互学习的群,不管你是准备入坑的小白,仍是半路入行的同窗,但愿咱们能一块儿分享与交流。
QQ群:810018802, 点击加入
QQ群 | 公众号 |
---|---|
前端打杂群![]() |
冬瓜书屋![]() |