解读vue-server-renderer源码并在react中的实现

更新日期: 2021-05-18阅读: 1.5k标签: 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 方法来解析入口文件的字符串代码,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 函数里的实现其实也不多。就是创建一个沙盒环境来执行获取到的代码

整个逻辑核心思路如下

通过拦截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 仓库
博客原文地址


链接: https://fly63.com/article/detial/10298

如何优雅的设计 React 组件

如今的 Web 前端已被 React、Vue 和 Angular 三分天下,尽管现在的 jQuery 已不再那么流行,但 jQuery 的设计思想还是非常值得致敬和学习的,特别是 jQuery 的插件化。

React深度编程:受控组件与非受控组件

受控组件与非受控组件在官网与国内网上的资料都不多,有些人觉得它可有可不有,也不在意。这恰恰显示React的威力,满足不同规模大小的工程需求。

React框架学习_关于React两种构建应用方式选择

一般在传统模式下,我们构建前端项目很简单。就是下载各种js文件,如JQuery、Echart等,直接放置在html静态文件。Webpack则是JavaScript中比较知名的打包工具。这两个构建工具构成了React应用快速搭建的基础。

Gatsby.js_一款基于React.js静态站点生成工具

Gatsby能快速的使用 React 生态系统来生成静态网站,可以结合React Component、Markdown 和服务端渲染来完成静态网站生成让他更强大。

React创建组件的三种方式及其区别

React推出后,出于不同的原因先后出现三种定义react组件的方式,殊途同归;具体的三种方式:函数式定义的无状态组件、es5原生方式React.createClass定义的组件、es6形式的extends React.Component定义的组件

react生命周期详解_深入理解React生命周期

React主要思想是通过构建可复用组件来构建用户界面,每个组件都有自己的生命周期,它规定了组件的状态和方法需要在哪个阶段改变和执行。所谓组件就是有限状态机,,表示有限个状态以及在这些状态之间的转移和动作行为的模型。

React + Webpack 构建打包优化

React 相关的优化:使用 babel-react-optimize 对 React 代码进行优化,检查没有使用的库,去除 import 引用,按需打包所用的类库,比如 lodash 、echarts 等.Webpack 构建打包存在的问题两个方面:构建速度慢,打包后的文件体积过大

react router中页面传值的三种方法

这篇文章主要介绍React Router定义路由之后如何传值,有关React和React Router 。react router中页面传值的三种方法:props.params、query、state

react 高阶组件的 理解和应用

react 高阶组件简单的理解是:一个包装了另一个基础组件的组件。高阶组件的两种形式:属性代理(Props Proxy)、反向继承 (Inheritance Inversion)

react中的refs属性的使用方法

React 支持一种非常特殊的属性 Ref ,你可以用来绑定到 render() 输出的任何组件上。这个特殊的属性允许你引用 render() 返回的相应的支撑实例( backing instance )。这样就可以确保在任何时间总是拿到正确的实例

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!