在博客開發的過程當中,有這樣一個需求想解決,就是在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羣 | 公衆號 |
---|---|
前端打雜羣 |
冬瓜書屋 |