解讀vue-server-renderer原始碼並在react中的實現

dongzi發表於2021-05-18

前言

​ 在部落格開發的過程中,有這樣一個需求想解決,就是在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給前端。

```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;
  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群 公眾號
前端打雜群
QQ群:810018802
冬瓜書屋
公眾號:冬瓜書屋

相關文章