前言

在博客开发的过程中,有这样一个需要想解决,就是在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启动入口
  1. 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')  )})
  2. 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给前端。

    // start办法是执行webpack的node端代码,用于把编译的文件打入内存中。import { start } from '@root/scripts/setup'// 执行他,createBundleRenderer办法就是用来解析在server端打包的代码start(app, ({ loadableStats, serverManifest, inputFileSystem }) => { renderer = createBundleRenderer({ loadableStats, serverManifest, inputFileSystem })})// 执行server-main.js中的函数并获取htmlconst 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;
  1. 下面逻辑也比拟清晰明了,通过createBundleRunner办法来解析入口文件的字符串代码,vue server-main.js入口文件返回是一个Promise函数,Promise返回的是new Vue(),所以解析进去的后果就new Vue实例。
  2. 通过RenderContext等实例解析返回的new Vue实例,获取到对应的html字符串。
  3. 通过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函数里的实现其实也不多。就是创立一个沙盒环境来执行获取到的代码

整个逻辑外围思路如下

  1. 通过拦挡webpack assets 生成一个json文件,蕴含所有js文件数据
  2. 通过入口文件到生成好的json文件外面取出来那段字符串代码。
  3. 通过require('module').wrap把字符串代码转换成函数模式的字符串代码,commonjs标准
  4. 通过require('vm')创立沙盒环境来执行这段代码,返回后果。
  5. 如果入口文件有依赖别的文件,再次执行 2 - 4步骤,把入口文件换成依赖的文件就好,例如,路由个别都是懒加载的,所以在拜访指定路由时,webpack打包进去也会获取这个对应的路由文件,依赖到入口文件外面。
  6. 通过沙盒环境执行获取到的返回后果,在vue-hackernews-2.0我的项目中是 new Vue实例对象。
  7. 解析这个vue实例,获取到对应的html字符串,放入html模板中,最初返回给前端。

这样就实现了读取内存文件,失去对应的html数据。次要就是通过 vm模块跟module模块来执行这些代码的。其实这块的整个代码也还是比较简单的。并没有什么简单的逻辑。

因为我的项目是基于reactwebpack5的,所以在代码的解决上会有些不同,然而实现计划根本还是统一的。

其实说到执行代码,js外面还有一个办法能够执行代码,就是eval办法。然而eval办法在require的时候都是在本地模块中进行查找,存在于内存中的文件我发现没法去进行require查找。所以还是用的vm模块来执行的代码,毕竟能够重写require办法

我的项目残缺代码:GitHub 仓库

博客原文地址

我本人新创建了一个互相学习的群,无论你是筹备入坑的小白,还是半路入行的同学,心愿咱们能一起分享与交换。
QQ群:810018802, 点击退出

QQ群公众号
前端打杂群
冬瓜书屋