关于前端:解读vueserverrenderer源码并在react中的实现

35次阅读

共计 10363 个字符,预计需要花费 26 分钟才能阅读完成。

前言

​ 在博客开发的过程中,有这样一个需要想解决,就是在 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 中的函数并获取 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;
  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 群 公众号
前端打杂群
冬瓜书屋

正文完
 0