Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue SSR深度剖析 - 知乎 #39

Open
zepang opened this issue Mar 5, 2022 · 0 comments
Open

Vue SSR深度剖析 - 知乎 #39

zepang opened this issue Mar 5, 2022 · 0 comments

Comments

@zepang
Copy link
Owner

zepang commented Mar 5, 2022

介绍

vue-ssr 相信大部分前端开发都听说过,或许自己也尝试搭建过一些小 demo,但真正运用到项目中的不多。本文将从什么是 ssr、ssr 是如何运作的以及 ssr 项目的优化方向等这几个方面给大家详细介绍下 vue-ssr。

阅读此文章需要对 vue、vue-ssr 有一定基础,并且默认读者使用 webpack 对 vue 应用打包。

本文会涉及到vue-server-renderervue-loader的相关源码解析,建议阅读的同时对照库的源码,以便更容易理解。

什么是 vue-ssr

ssr 是 Server-Side Rendering 的简写,即由服务端负责渲染页面直出,亦即同构应用。程序的大部分代码都可以在服务端和客户端运行。在服务端 vue 组件渲染为 html 字符串,在客户端生成 dom 和操作 dom。

能在服务端渲染为 html 字符串得益于 vue 组件结构是基于 vnode 的。vnode 是 dom 的抽象表达,它不是真实的 dom,它是由 js 对象组成的树,每个节点代表了一个 dom。因为 vnode 所以在服务端 vue 可以把 js 对象解析为 html 字符串。同样在客户端 vnode 因为是存在内存之中的,操作内存总比操作 dom 快的多,每次数据变化需要更新 dom 时,新旧 vnode 树经过 diff 算法,计算出最小变化集,大大提高了性能。

ssr 的主要优势在于更好的 SEO 和更快的到达时间,服务端返回的内容是具有信息内容的 html 文档,这对搜索引擎的爬虫是友好的。用户在弱网情况下也无需等待 js 加载完成才能开始渲染页面,可以更加快速的看到完整的内容。

当然 ssr 也有他的问题,开发 ssr 的项目需要更好的区分哪些代码能在服务端运行,哪些代码只能在客户端运行,比如:window、document 这些就不能出现在初始化代码和服务端的一些钩子函数中,我们需要写出更加通用的代码以保证在两端都可以正常的解析和运行。另外 ssr 项目在 node 中渲染页面显然要比大部分动态网站要消耗更多的 cpu 资源,如果项目是需要在高流量环境中使用,则需要准备更多的服务器负载和更好的缓存策略。

祭出官方提供的架构图 :

SSR 是如何运作的

根据应用的触发时机我们分成以下几个步骤详细讲解:

编译阶段

vue-ssr 是同构框架,即我们开发的同一份代码会被运行在服务端和客户端两个环境中。所以我们的代码需要更加偏向于通用,但毕竟环境的差异导致很多特定代码无法兼容,比如:vue 的 dom 挂载、一些运行于客户端的第三方库等等。vue-ssr 提供的方式是配置两个入口文件 (entry-client.js、entry-server.js),通过 webpack 把你的代码编译成两个 bundle。

两个入口的编译方式可以很方便的做两个环境的差异化代码抹平:

  1. 在客户端入口中 vue 实例化之后执行挂载 dom 的代码,服务端入口的 vue 则只需要生成 vue 对象即可 。
  2. 一些不兼容 ssr 的第三方库或者代码片段,我们可以只在客户端入口中加载 。
  3. 即使通用代码我们也可以通过打包工具做到两个运行环境的差异化。比如最常见的在应用中发起请求时,在客户端我们经常使用 axios 来发起请求,在服务端虽然也兼容 axios,但是服务端发起的请求并不需要和客户端一样走外网请求,服务端的接口网关或者鉴权方式和客户端也不一定相同。这种情况我们可以通过 webpack 的 resolve.alias 配置实现两个环境引用不同模块。
  4. 在服务端的代码我们不需要做 code split,甚至我们项目中所有引入的依赖库,也并不需要打包到 bundle 中。因为在 node 运行环境中,我们的依赖库都可以通过 require 在运行时加载进来。

通过 webpack 打包生成的 bundle 示例:

Server Bundle

vue-ssr-server-bundle.json:

{ 
  "entry": "static/js/app.80f0e94fe005dfb1b2d7.js", 
  "files": { 
    "static/js/app.80f0e94fe005dfb1b2d7.js": "module.exports=function(t...", 
    "static/js/xxx.29dba471385af57c280c.js": "module.exports=function(t..." 
  } 
}

Client Bundle

许多静态资源...

vue-ssr-client-manifest.json 文件:

{ 
  "publicPath": "//cdn.xxx.cn/xxx/", 
  "all": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js", 
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "initial": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js",
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "async": [ 
    "static/js/xxx.29dba471385af57c280c.js" 
  ], 
  "modules": { 
    "00f0587d": [ 0, 1 ] 
    ... 
    } 
}

Server Bundle 中包含了所有要在服务端运行的代码列表,和一个入口文件名。

Client Bundle 包含了所有需要在客户端运行的脚本和静态资源,如:js、css 图片、字体等。还有一份 clientManifest 文件清单,清单中initial数组中的 js 将会在 ssr 输出时插入到 html 字符串中作为 preload 和 script 脚本引用。asyncmodules将配合检索出异步组件和异步依赖库的 js 文件的引入,在输出阶段我们会详细解读。

初始化阶段

ssr 应用会在 node 启动时初始化一个 renderer 单例对象,renderer 对象由vue-server-renderer库的createBundleRenderer函数创建,函数接受两个参数,serverBundle 内容和 options 配置

在 options 中我们需要传入 clientManifest 内容,其他的参数我们会在后续阶段讲解。

bundleRenderer = createBundleRenderer(serverBundle, { 
  runInNewContext: false, 
  clientManifest, 
  inject: false 
});

初始化完成,当用户发起请求时,renderer.renderToString或者renderer.renderToStream函数将完成 vue 组件到 html 的过程。

bundleRenderer.renderToString(context, (err, html) => { 
    //...
})

createBundleRenderer函数在初始化阶段主要做了 3 件事情:

1. 创建将 vue 对象解析为 html 的渲染函数的单例对象

var renderer = createRenderer(rendererOptions);

在 createRenderer 函数中创建了两个对象:rendertemplateRenderer,他们分别负责 vue 组件的渲染和 html 的组装,在之后的阶段我们详细讲解。

var render = createRenderFunction(modules, directives, isUnaryTag, cache);
var templateRenderer = new TemplateRenderer({
  template: template,
  inject: inject,
  shouldPreload: shouldPreload,
  shouldPrefetch: shouldPrefetch,
  clientManifest: clientManifest,
  serializer: serializer
});

2. 创建 nodejs 的 vm 沙盒,并返回了 run 函数作为每次实例化 vue 组件的入口函数

var run = createBundleRunner( 
  entry, 
  files, 
  basedir, 
  rendererOptions.runInNewContext 
);

这里的 entry 和 files 参数是 vue-ssr-server-bundle.json 中的 entry 和 files 字段,分别是应用的入口文件名和打包的文件内容集合。

runInNewContext 是可选的沙盒运行配置:

  1. true,每次创建 vue 实例时都创建一个全新的 v8 上下文环境并重新执行 bundle 代码,好处是每次渲染的环境状态是隔离的,不存在状态单例问题,也不存在状态污染问题。但是,缺点是每次创建 v8 上下文的性能代价很高。
  2. false,创建在当前 global 运行上下文中运行的 bundle 代码环境,bundle 代码将可以获取到当前运行环境的 global 对象,运行环境是单例的
  3. once ,会在初始化时单例创建与 global 隔离的运行上下文

当 runInNewContext 设置为 false 或者 once 时,在初始化之后的用户每次请求将会在同一个沙盒环境中运行,所以在实例化 vue 实例或者一些状态存储必须通过闭包创建独立的作用域才不会被不同请求产生的数据相互污染,举个例子:

export function createApp(context) {
  const app = new Vue({
    render: h => h(App)
  });

  return {app};
}

在 createBundleRunner 函数中有非常重要的两个函数 getCompiledScript 和 evaluateModule

function getCompiledScript (filename) {
  if (compiledScripts[filename]) {
    return compiledScripts[filename]
  }
  var code = files[filename];
  var wrapper = NativeModule.wrap(code);
  var script = new 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);
  var compiledWrapper = runInNewContext === false
  ? script.runInThisContext()
  : script.runInNewContext(sandbox);
  var m = { exports: {}};
  var r = function (file) {
    file = path$1.posix.join('.', file);
    if (files[file]) {
      return evaluateModule(file, sandbox, evaluatedFiles)
    } else if (basedir) {
      return require(
        resolvedModules[file] ||
        (resolvedModules[file] = resolve.sync(file, { basedir: basedir }))
      )
    } else {
      return require(file)
    }
  };
  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
}

createBundleRunner 执行时调用 evaluateModule 并传入 serverBundle 中的应用入口文件名 entry 和沙盒执行上下文。

之后调用 getCompiledScript,通过入口文件名在 files 文件内容集合中找到入口文件内容的 code,code 内容大致如下,内容是由 webpack 打包编译生成:

module.exports = (function(modules) {
  //...
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  return __webpack_require__(__webpack_require__.s = "ry7I");
})({
  "ry7I": (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__.default = function(context) {
      return new Promise((resolve, reject) => {
        const {app} = createApp(context);
        resolve(app);
      });
    }
  }),
  "+ooV": (function (module, __webpack_exports__, __webpack_require__) {
    //...
  })
})

上面代码是把你一个立即执行函数赋值给 module.exports,而立即执行函数的结果返回了入口模块:

return __webpack_require__(__webpack_require__.s = "ry7I");

这里的ry7I是 webpack 打包时模块的moduleId,根据ry7I我们可以找到:

{
  "ry7I": (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__.default = function(context) {
      return new Promise((resolve, reject) => {
        const {app} = createApp(context);
        resolve(app);
      });
    }
  })
}

这里的入口模块就是我们服务端 entry-server.js 的内容。为了方便理解我们可以把入口文件简单理解为以下内容:

module.exports = {
  default: function(context) {
    return new Promise((resolve, reject) => {
      const {app} = createApp(context);
      //...
      resolve(app);
    });
  }
  ...
}

这只是一段赋值代码,如果在 vm 执行它的话并没有任何返回值,我们也拿不到入口函数,所以在 vm 中执行前,我们需要把这段代码内容用NativeModule.wrap(code)包裹一下,NativeModule就是 nodejs 的module模块,wrap函数只做了一次简单的包裹。

module.wrap 源码:

let wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

包裹之后入口文件的 code:

(function (exports, require, module, __filename, __dirname) {
  module.exports = {
    default: function(context) {
      return new Promise((resolve, reject) => {
        const {app} = createApp(context);
        //...
        resolve(app);
      });
    }
  }
});

回到 getCompiledScript 函数,通过new vm.Script编译 wrapper 之后并返回给 evaluateModule,接下来根据 runInNewContext 的配置来决定是在当前上下文中执行还是在单独的上下文中执行,并将执行结果返回(我看上面的 code,执行结果其实就是返回了一个函数)。

接下来执行compiledWrapper.call(m.exports, m.exports, r, m);,传入的参数分别对应上面函数中:exports、require、module,这样我们就通过传入的 m 对象引用拿到了入口函数。另外传入 r 函数是为了替代原生 require 用来解析 bundle 中通过 require 函数引用的其他模块。

这一步通过 createBundleRunner 函数创建的 run,在用户发起请求时每次调用都会通过入口函数实例化一个完整的 vue 对象。

3. 返回renderToStringrenderToStream函数

return {
  renderToString: function (context, cb) {
    var assign;

    if (typeof context === 'function') {
      cb = context;
      context = {};
    }

    var promise;
    if (!cb) {
      ((assign = createPromiseCallback(), promise = assign.promise, cb = assign.cb));
    }

    run(context).catch(function (err) {
      rewriteErrorTrace(err, maps);
      cb(err);
    }).then(function (app) {
      if (app) {
        renderer.renderToString(app, context, function (err, res) {
          rewriteErrorTrace(err, maps);
          cb(err, res);
        });
      }
    });

    return promise
  },
  renderToStream: function (context, cb) {
    //...
    run(context).then(function (app) {
      var renderStream = renderer.renderToStream(app, context);

      renderStream.pipe(res);
    });
    //...
  }
}

虽然 vue 的文档没有提到,但是根据这部分的代码,renderToString如果在没执行 cb 回调函数的情况下是返回一个 Promise 对象的,这里很巧妙的利用 createPromiseCallback 创建了一个 promise 并导出了它的 resolve 和 reject 实现了和 cb 回调的兼容逻辑,所以我们同样也可以这样使用renderToString:

try {
    const html = await bundleRenderer.renderToString(context);
    //...
} catch (error) {
    //error handler
}

小结:

  1. 获取到 serverBundle 的入口文件代码并解析为入口函数,每次执行实例化 vue 对象
  2. 实例化了 render 和 templateRenderer 对象,负责渲染 vue 组件和组装 html

渲染阶段

当用户请求达到 node 端时,调用 bundleRenderer.renderToString 函数并传入用户上下文 context,context 对象可以包含一些服务端的信息,比如:url、ua 等等,也可以包含一些用户信息,context 对象内容 (除了 context.state 和模板中的占位字段) 并不会被输出到前端:

bundleRenderer.renderToString(context, (err, html) => {
  return res.send(html);
});

上一个阶段在 createBundleRenderer 函数中创建了 renderer 和 run,执行bundleRenderer.renderToString时会先调用 run 创建 vue 的对象实例,然后调用把 vue 实例传给renderer.renderToString函数。

这个时候如果使用了 vue-router 库,则在创建 vue 实例时,调用 router.push(url) 后 router 开始导航,router 负责根据 url 匹配对应的 vue 组件并实例化他们,最后在 router.onReady 回调函数中返回整个 vue 实例。

我们接下来看下在这个函数中做了哪些事情。

function renderToString (
  component,
  context,
  cb
) {
  var assign;

  if (typeof context === 'function') {
    cb = context;
    context = {};
  }
  if (context) {
    templateRenderer.bindRenderFns(context);
  }

  // no callback, return Promise
  var promise;
  if (!cb) {
    ((assign = createPromiseCallback(), promise = assign.promise, cb = assign.cb));
  }

  var result = '';
  var write = createWriteFunction(function (text) {
    result += text;
    return false
  }, cb);
  try {
    render(component, write, context, function (err) {
      if (err) {
        return cb(err)
      }
      if (context && context.rendered) {
        context.rendered(context);
      }
      if (template) {
        try {
          var res = templateRenderer.render(result, context);
          if (typeof res !== 'string') {
            // function template returning promise
            res
              .then(function (html) { return cb(null, html); })
              .catch(cb);
        } else {
          cb(null, res);
        }
        } catch (e) {
          cb(e);
        }
      } else {
        cb(null, result);
      }
    });
  } catch (e) {
    cb(e);
  }

  return promise
}

在初始化阶段第一步创建的templateRenderer,它负责 html 的组装,它的主要原型方法有:bindRenderFnsrenderrenderStylesrenderResourceHintsrenderStaterenderScripts

其中renderStylesrenderResourceHintsrenderStaterenderScripts分别是生成页面需要加载的样式、preload 和 prefetch 资源、页面 state(比如 vuex 的状态 state,需要在服务端给 context.state 赋值才能输出)、脚本文件引用的内容。

在上面代码中执行的templateRenderer.bindRenderFns则是把这四个 render 函数绑定到用户上下文 context 中,以便用户可以拿到这些内容做自定义的组装或者渲染。

接下来创建了var write = createWriteFunction写函数,主要负责每个组件渲染完成之后返回 html 内容时的拼接。

之后调用了createRenderFunction 创建的render函数,传入 vue 对象实例、写函数、用户上下文 context 和渲染完成之后的 done 回调。

render 函数:

function render (
  component,
  write,
  userContext,
  done
) {
  warned = Object.create(null);
  var context = new RenderContext({
    activeInstance: component,
    userContext: userContext,
    write: write, done: done, renderNode: renderNode,
    isUnaryTag: isUnaryTag, modules: modules, directives: directives,
    cache: cache
  });
  installSSRHelpers(component);
  normalizeRender(component);

  var resolve = function () {
    renderNode(component._render(), true, context);
  };
  waitForServerPrefetch(component, resolve, done);
}

在这个函数中组件将被按照从父到子的递归顺序,把 vue 组件渲染为 html。

**第一步,**创建RenderContext 渲染上下文对象,这个对象将贯穿整个递归过程,它主要负责在递归过程中闭合组件标签和渲染可缓存组件的存储工作。

第二步,执行installSSRHelpersnormalizeRender这两行主要是针对在组件中使用字符串 template 模板的组件的编译工作,在执行normalizeRender时 vue 会将字符串模板解析语法树,然后转成 render 函数。而installSSRHelpers是在解析之前安装一些在 ssr 中生成 vnode 的帮助函数,一个简单的 template 解析为 render 的例子:

template:

<span><div>{{value}}</div></span>

render:

with(this){return _c('span',[_ssrNode("<div>"+_ssrEscape(_s(value))+"</div>")])}

虽然 vue 在解析 html 时已经做了很多优化,比如:上面的__ssrNode 函数,它不再生成 vnode 而是生成 StringNode 这样的简单节点,在后续渲染时直接拼接字符串即可。但是毕竟还是要解析一次 html 的语法树,所以我们通常开发 vue 项目时使用 vue-loader 把 template 解析为 render 函数或者直接用 jsx 语法,甚至 createElement 函数。而在vue-server-renderer库中缺有大量只是针对 template 字符串模板的解析和优化的代码,所以尽量避免使用 template 字符串模板。

第三步,执行 waitForServerPrefetch,在 waitForServerPrefetch 函数中,会检查组件是否定义了 serverPrefetch 钩子 (vue@2.6.0 + 新增 api,代替了以前 asyncData 的兼容方案),如果定义了,则等待钩子执行完毕后才继续 resolve 回调。

在回调中component._render返回的是该 vue 组件的 vnode,传递给 renderNode 函数递归解析。(ps. 大家可以看到,虽然 serverPrefetch 这个 api 在官方文档中说明是一个返回 promise 的 function 类型,但根据源码看,它也可以被定义为一个返回 promise 的 function 类型的数组)

function waitForServerPrefetch (vm, resolve, reject) {
  var handlers = vm.$options.serverPrefetch;
  if (isDef(handlers)) {
    if (!Array.isArray(handlers)) { handlers = [handlers]; }
    try {
      var promises = [];
      for (var i = 0, j = handlers.length; i < j; i++) {
        var result = handlers[i].call(vm, vm);
        if (result && typeof result.then === 'function') {
          promises.push(result);
        }
      }
      Promise.all(promises).then(resolve).catch(reject);
      return
    } catch (e) {
      reject(e);
    }
  }
  resolve();
}

第四步,这一步开始执行renderNode,根据不同的 vnode 类型执行不同的 render 函数,六种不同类型的节点渲染方法,我们主要对renderStringNode$1renderComponentrenderElementrenderAsyncComponent这四个主要渲染函数做个分析:

function renderNode (node, isRoot, context) {
  if (node.isString) {
    renderStringNode$1(node, context);
  } else if (isDef(node.componentOptions)) {
    renderComponent(node, isRoot, context);
  } else if (isDef(node.tag)) {
    renderElement(node, isRoot, context);
  } else if (isTrue(node.isComment)) {
    if (isDef(node.asyncFactory)) {
      // async component
      renderAsyncComponent(node, isRoot, context);
    } else {
      context.write(("<!--" + (node.text) + "-->"), context.next);
    }
  } else {
    console.log(node.tag, ' is text', node.text)
    context.write(
      node.raw ? node.text : escape(String(node.text)),
      context.next
    );
  }
}

renderStringNode$1,负责处理通过 vue 编译 template 字符串模板生成的 StringNode 简单节点的渲染工作,如果没有子节点则直接调用写函数,其中 el.open 和 el.close 是节点开始和闭合标签。如果有子节点则把子节点添加到渲染上下文的 renderStates 数组中,写入开始标签并传入渲染上下文的 next 函数,写函数在拼接完成后调用 next,在渲染上下文的 next 函数中继续解析该节点的子节点,并且在解析这个节点树之后写入闭合标签:

function renderStringNode$1 (el, context) {
  var write = context.write;
  var next = context.next;
  if (isUndef(el.children) || el.children.length === 0) {
    write(el.open + (el.close || ''), next);
  } else {
    var children = el.children;
    context.renderStates.push({
      type: 'Element',
      children: children,
      rendered: 0,
      total: children.length,
      endTag: el.close
    });
    write(el.open, next);
  }
}

renderComponent,负责处理 vue 的组件类型的节点,如果组件设置了 serverCacheKey 并且缓存中存在该 key 的渲染结果,则直接写入缓存的 html 结果。在写入 html 之前我们看到代码中循环调用了 res.components 并且传入了用户上下文 userContext,循环调用的函数其实是在 vue-loader 注入的一个 hook。这个 hook 会在执行时把当前这个组件的moduleIdentifier(webpack 中编译时生成的模块标识) 添加到用户上下文 userContext 的_registeredComponents数组中,vue 会通过这个数组查找组件的引用资源文件。

如果没有命中缓存或者根本就没有缓存,则分别执行:renderComponentWithCacherenderComponentInner,这两个函数的区别是renderComponentWithCache会在组件渲染完成时,通过渲染上下文把结果写入缓存。

function renderComponent (node, isRoot, context) {
  var write = context.write;
  var next = context.next;
  var userContext = context.userContext;

  // check cache hit
  var Ctor = node.componentOptions.Ctor;
  var getKey = Ctor.options.serverCacheKey;
  var name = Ctor.options.name;
  var cache = context.cache;
  var registerComponent = registerComponentForCache(Ctor.options, write);

  if (isDef(getKey) && isDef(cache) && isDef(name)) {
    var rawKey = getKey(node.componentOptions.propsData);
    if (rawKey === false) {
      renderComponentInner(node, isRoot, context);
      return
    }
    var key = name + '::' + rawKey;
    var has = context.has;
    var get = context.get;
    if (isDef(has)) {
      has(key, function (hit) {
        if (hit === true && isDef(get)) {
          get(key, function (res) {
            if (isDef(registerComponent)) {
              registerComponent(userContext);
            }
            res.components.forEach(function (register) { return register(userContext); });
            write(res.html, next);
          });
        } else {
          renderComponentWithCache(node, isRoot, key, context);
        }
      });
    } else if (isDef(get)) {
      get(key, function (res) {
        if (isDef(res)) {
          if (isDef(registerComponent)) {
            registerComponent(userContext);
          }
          res.components.forEach(function (register) { return register(userContext); });
          write(res.html, next);
        } else {
          renderComponentWithCache(node, isRoot, key, context);
        }
      });
    }
  } else {
    renderComponentInner(node, isRoot, context);
  }
}

在 renderComponentInner 函数中通过 vnode 创建组件对象,等待组件的 serverPrefetch 钩子执行完成之后,调用组件对象的_render 生成子节点的 vnode 后再渲染。(ps. 这里我们可以看出,serverPrefetch 钩子中获取的数据只会被渲染到当前组件或者子组件中,因为在执行这个组件的 serverPrefetch 之前父组件已经被渲染完成了。)

function renderComponentInner (node, isRoot, context) {
  var prevActive = context.activeInstance;
  // expose userContext on vnode
  node.ssrContext = context.userContext;
  var child = context.activeInstance = createComponentInstanceForVnode(
    node,
    context.activeInstance
  );
  normalizeRender(child);

  var resolve = function () {
    var childNode = child._render();
    childNode.parent = node;
    context.renderStates.push({
      type: 'Component',
      prevActive: prevActive
    });
    renderNode(childNode, isRoot, context);
  };

  var reject = context.done;

  waitForServerPrefetch(child, resolve, reject);
}

renderElement渲染函数,负责渲染 dom 组件。函数内部调用了renderStartingTag,这个函数处理自定义指令、show 指令和组件的 scoped CSS ID 生成还有给标签加上 data-server-rendered 属性(表示这是经过服务端渲染的标签),最后组装好 dom 的开始标签 startTag。

如果组件是自闭合标签或者没有子节点,则直接写入标签节点内容。否则通过渲染上下文在渲染子节点后再写入结束标签。

function renderElement (el, isRoot, context) {
  var write = context.write;
  var next = context.next;

  if (isTrue(isRoot)) {
    if (!el.data) { el.data = {}; }
    if (!el.data.attrs) { el.data.attrs = {}; }
    el.data.attrs[SSR_ATTR] = 'true';
  }

  if (el.fnOptions) {
    registerComponentForCache(el.fnOptions, write);
  }

  var startTag = renderStartingTag(el, context);
  var endTag = "</" + (el.tag) + ">";
  if (context.isUnaryTag(el.tag)) {
    write(startTag, next);
  } else if (isUndef(el.children) || el.children.length === 0) {
    write(startTag + endTag, next);
  } else {
    var children = el.children;
    context.renderStates.push({
      type: 'Element',
      children: children,
      rendered: 0,
      total: children.length,
      endTag: endTag
    });
    write(startTag, next);
  }
}

renderAsyncComponent负责针对异步函数的加载和解析,vnode 的 asyncFactory 是加载函数,因为我们的 serverBundle 已经包含所有脚本包含异步脚本了,所以在这一步的 asyncFactory 几乎就相当于一次 Promise.resolve 返回异步模块,不发起任何请求。拿到组件内容后创建 vnode 节点,调用 renderComponent、renderNode。如果函数式组件的话可能返回多个 vnode,直接通过渲染上下文渲染。

function renderAsyncComponent (node, isRoot, context) {
  var factory = node.asyncFactory;

  var resolve = function (comp) {
    if (comp.__esModule && comp.default) {
      comp = comp.default;
    }
    var ref = node.asyncMeta;
    var data = ref.data;
    var children = ref.children;
    var tag = ref.tag;
    var nodeContext = node.asyncMeta.context;
    var resolvedNode = createComponent(
      comp,
      data,
      nodeContext,
      children,
      tag
    );
    if (resolvedNode) {
      if (resolvedNode.componentOptions) {
        // normal component
        renderComponent(resolvedNode, isRoot, context);
      } else if (!Array.isArray(resolvedNode)) {
        // single return node from functional component
        renderNode(resolvedNode, isRoot, context);
      } else {
        // multiple return nodes from functional component
        context.renderStates.push({
          type: 'Fragment',
          children: resolvedNode,
          rendered: 0,
          total: resolvedNode.length
        });
        context.next();
      }
    } else {
      // invalid component, but this does not throw on the client
      // so render empty comment node
      context.write("<!---->", context.next);
    }
  };

  if (factory.resolved) {
    resolve(factory.resolved);
    return
  }

  var reject = context.done;
  var res;
  try {
    res = factory(resolve, reject);
  } catch (e) {
    reject(e);
  }
  if (res) {
    if (typeof res.then === 'function') {
      res.then(resolve, reject).catch(reject);
    } else {
      // new syntax in 2.3
      var comp = res.component;
      if (comp && typeof comp.then === 'function') {
        comp.then(resolve, reject).catch(reject);
      }
    }
  }
}

渲染函数已经介绍完毕,所有 vnode 都要经历这些函数渲染,当最后一个组件调用写函数,并执行渲染上下文的 next 时结束渲染工作,调用渲染上下文的 done 函数,也就是回到下面的回调函数。

如果用户上下文 context 定义了 rendered 钩子的话,触发这个钩子 (这个钩子在 vue@2.6.0 新增的)。

result 变量就是不断通过调用写函数拼接的组件渲染结果。

render(component, write, context, function (err) {
  if (err) {
    return cb(err)
  }
  if (context && context.rendered) {
    context.rendered(context);
  }
  if (template) {
    try {
      var res = templateRenderer.render(result, context);
      if (typeof res !== 'string') {
        // function template returning promise
        res
          .then(function (html) { return cb(null, html); })
          .catch(cb);
      } else {
        cb(null, res);
      }
    } catch (e) {
      cb(e);
    }
  } else {
    cb(null, result);
  }
});

如果没有定义 tempate 则 vue 在服务端的工作已经结束了。我们将在下一阶段分析当定义了 template 时 templateRenderer 对象在输出阶段如何拼接 html 和找到组件所依赖的脚本文件。

小结:

  1. 用户发起请求时,通过执行 serverBundle 后得到的应用入口函数,实例化 vue 对象。
  2. renderer 对象负责把 vue 对象递归转为 vnode,并把 vnode 根据不同 node 类型调用不同渲染函数最终组装为 html。
  3. 在渲染组件的过程中如果组件定义了 serverPrefetch 钩子,则等待 serverPrefetch 执行完成之后再渲染页面 (serverPrefetch 生成的数据不会应用于父组件)

内容输出阶段

在上一个阶段我们已经拿到了 vue 组件渲染结果,它是一个 html 字符串,在浏览器中展示页面我们还需要 css、js 等依赖资源的引入标签和我们在服务端的渲染数据,这些最终组装成一个完整的 html 报文输出到浏览器中。

这里 vue 提供了两种选项:

没有定义 template 模板,在上面代码中我们看到,如果用户没有配置 template 的情况下,渲染结果会被直接返回给renderToString的回调函数,而页面所需要的脚本依赖我们通过用户上下文 context 的renderStylesrenderResourceHintsrenderStaterenderScripts这些函数分别获得(因为 context 在开始渲染之前就已经被templateRenderer.bindRenderFns(context)注入这些函数了)。

接下来我们可以用我们自己熟悉的模板引擎来渲染出最终的 html 报文,这里用 hbs 举个例子:

renderer.renderToString(context, (err, html) => {
  if (err) {
    return handlerError(err, req, res, next);
  }
  const styles = context.renderStyles();
  const scripts = context.renderScripts();
  const resources = context.renderResourceHints();
  const states = context.renderState();

  const result = template({
    html,
    styles,
    scripts,
    resources,
    states
  });

  return res.send(result);
});

handlerbars:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
    {{{resources}}}
    {{{styles}}}
</head>
<body>
    {{{html}}}
    {{{states}}}
    {{{scripts}}}
</body>
</html>

定义了 template 模板 ,在定义了 template 情况下,在创建 TemplateRenderer 实例的构造函数中,会对提供的 template 字符串做一个解析。解析的规则很简单,把模板分为三个部分:html 文档开头到标签是head部分,head 之后到内容占位符是neck部分,最后tail部分是内容占位符到最后。

function parseTemplate (
  template,
  contentPlaceholder
) {
  if ( contentPlaceholder === void 0 ) contentPlaceholder = '<!--vue-ssr-outlet-->';

  if (typeof template === 'object') {
    return template
  }

  var i = template.indexOf('</head>');
  var j = template.indexOf(contentPlaceholder);

  if (j < 0) {
    throw new Error("Content placeholder not found in template.")
  }

  if (i < 0) {
    i = template.indexOf('<body>');
    if (i < 0) {
      i = j;
    }
  }

  return {
    head: compile$1(template.slice(0, i), compileOptions),
    neck: compile$1(template.slice(i, j), compileOptions),
    tail: compile$1(template.slice(j + contentPlaceholder.length), compileOptions)
  }
}

compile$1 函数是 lodash.template 的引用,利用 lodash.template 函数将这三个字符串包装为一个渲染函数,我们可以在 template 模板中自定义一些占位符,然后通过用户上下文 context 上面的数据渲染。

var compile$1 = require('lodash.template');
var compileOptions = {
  escape: /{{([^{][\s\S]+?[^}])}}/g,
  interpolate: /{{{([\s\S]+?)}}}/g
};

vue 在官方文档中 Head 管理 (https://ssr.vuejs.org/zh/guide/head.html) 中介绍了,如何通过将数据绑定到用户上下文 context 上,然后在模板中将这些数据渲染。其实不仅在 head 中支持自定义渲染,同样necttail部分都支持这么做。

接下来我们看 TemplateRenderer 如何帮我们做 html 组装的,this.parsedTemplate就是在构造函数中通过上面的解析函数得到的包含三个部分的 compile 对象,接下来只需要把准备好的各个部分按照顺序拼接就好了,如果设置了 inject 为 false,则 preload、style、state、script 的引用都需要自己在模板中自行渲染。

TemplateRenderer.prototype.render = function render (content, context) {
  var template = this.parsedTemplate;
  if (!template) {
    throw new Error('render cannot be called without a template.')
  }
  context = context || {};

  if (typeof template === 'function') {
    return template(content, context)
  }

  if (this.inject) {
    return (
      template.head(context) +
      (context.head || '') +
      this.renderResourceHints(context) +
      this.renderStyles(context) +
      template.neck(context) +
      content +
      this.renderState(context) +
      this.renderScripts(context) +
      template.tail(context)
    )
  } else {
    return (
      template.head(context) +
      template.neck(context) +
      content +
      template.tail(context)
    )
  }
};

输出 html 的流程已经讲完,但是还是有很多人疑惑,如果我的项目是做了 code splits 代码是分割的,甚至还有一些异步组件,vue 执行的 serverBundle 代码是如何通过 clientManifest 找到页面依赖的 js 和 css 呢?

在文档开头的编译阶段我们介绍了 clientManifest 文件结构,其中:

all 数组是编译工具打包的所有文件的集合

initial 数组是入口文件和在入口文件中引用的其他非异步依赖模块的文件集合

async 则是所有异步文件的集合。

modules 对象是 moduleIdentifier 和和 all 数组中文件的映射关系(modules 对象是我们查找文件引用的重要数据)。

要生成 clientManifest 文件需要在 webpack 配置的 plugins 中加入插件:

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
// ...
plugins: [
  new VueSSRClientPlugin({
    filename: '../../manifest.json'
  })
]
// ...

假设我们现在有一个简单的 vue 应用,其中有一个 app.vue 文件,并引用了一个异步组件,生成了下面的 clientManifest 文件:

{ 
  "publicPath": "//cdn.xxx.cn/xxx/", 
  "all": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js", 
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "initial": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js",
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "async": [ 
    "static/js/async.29dba471385af57c280c.js" 
  ], 
  "modules": { 
    "00f0587d": [ 0, 1 ] 
    ... 
    } 
}

通过配置的 plugin 我们知道 clientmanifest 是由 vue-server-renderer/client-plugin 生成的,我们来看下它在编译时做了哪些事情,我们可以下下面的代码:

在 webpack 中,编译时 compilation 对象可以获得打包资源模块和文件 (关于 webpack 详细解读可以参考这篇文章: https://segmentfault.com/a/1190000015088834)。

all、initial、async 都可以通过 stats.assets 和 stats.entrypoints 获得。

modules 通过 stats.modules 获得,modules 的 key 是根据 identifier 生成的,对应的依赖文件列表则可以通过 states.modules.chunks 获得。

VueSSRClientPlugin.prototype.apply = function apply(compiler) {
  var this$1 = this;

  onEmit(compiler, 'vue-client-plugin', function (compilation, cb) {
    var stats = compilation.getStats().toJson();

    var allFiles = uniq(stats.assets
      .map(function (a) { return a.name; }));

    var initialFiles = uniq(Object.keys(stats.entrypoints)
      .map(function (name) { return stats.entrypoints[name].assets; })
      .reduce(function (assets, all) { return all.concat(assets); }, [])
      .filter(function (file) { return isJS(file) || isCSS(file); }));

    var asyncFiles = allFiles
      .filter(function (file) { return isJS(file) || isCSS(file); })
      .filter(function (file) { return initialFiles.indexOf(file) < 0; });

    var manifest = {
      publicPath: stats.publicPath,
      all: allFiles,
      initial: initialFiles,
      async: asyncFiles,
      modules: { /* [identifier: string]: Array<index: number> */ }
    };
    var assetModules = stats.modules.filter(function (m) { return m.assets.length; });
    var fileToIndex = function (file) { return manifest.all.indexOf(file); };
    stats.modules.forEach(function (m) {
      // ignore modules duplicated in multiple chunks
      if (m.chunks.length === 1) {
        var cid = m.chunks[0];
        var chunk = stats.chunks.find(function (c) { return c.id === cid; });
        if (!chunk || !chunk.files) {
          return
        }
        var id = m.identifier.replace(/\s\w+$/, ''); // remove appended hash
        var files = manifest.modules[hash(id)] = chunk.files.map(fileToIndex);
        // find all asset modules associated with the same chunk
        assetModules.forEach(function (m) {
          if (m.chunks.some(function (id) { return id === cid; })) {
            files.push.apply(files, m.assets.map(fileToIndex));
          }
        });
      }
    });

    var json = JSON.stringify(manifest, null, 2);
    compilation.assets[this$1.options.filename] = {
      source: function () { return json; },
      size: function () { return json.length; }
    };
    cb();
  });
};

ps. webpack 的 identifier 通常是需要编译的模块路径,比如:

/Users/chenfeng/Documents/source/yoho/yoho-community-web/node_modules/vue-loader/lib/index.js??vue-loader-options!/Users/chenfeng/Documents/source/yoho/yoho-community-web/apps/app.vue

我们通过 vue-server-renderer/client-plugin 插件生成了 clientManifest,接下来我们还需要知道,vue 在渲染时是如何和这个数据关联起来的?

我们来看 vue 文件在经过 vue-loader 编译过程中做了哪些事情。下面是 app.vue 文件经过 vue-loader 处理过后的生成内容,有一串字符串引起了我们的注意:00f0587d。这个好像也出现在了 clientManifest 文件的 modules 对象中!那这个字符串是怎么来的呢?

import { render, staticRenderFns } from "./app.vue?vue&type=template&id=3546f492&"
import script from "./app.vue?vue&type=script&lang=js&"
export * from "./app.vue?vue&type=script&lang=js&"
function injectStyles (context) {

  var style0 = require("./app.vue?vue&type=style&index=0&lang=scss&")
if (style0.__inject__) style0.__inject__(context)

}

/* normalize component */
import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  injectStyles,
  null,
  "00f0587d"

)

component.options.__file = "apps/app.vue"
export default component.exports

上面代码内容都是由 vue-loader 生成的,我们继续来分析生成上面代码的代码:

let code = `
${templateImport}
${scriptImport}
${stylesCode}

/* normalize component */
import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
  script,
  render,
  staticRenderFns,
  ${hasFunctional ? `true` : `false`},
  ${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
  ${hasScoped ? JSON.stringify(id) : `null`},
  ${isServer ? JSON.stringify(hash(request)) : `null`}
  ${isShadow ? `,true` : ``}
)
  `.trim() + `\n`

其中 normalizer 函数的第七个参数就是我们要找的内容,这里的 request 是 webpack 是需要编译的模块路径,比如:

/Users/chenfeng/Documents/source/yoho/yoho-community-web/node_modules/vue-loader/lib/index.js??vue-loader-options!/Users/chenfeng/Documents/source/yoho/yoho-community-web/apps/app.vue

这个字段和上面 plugin 中得到的 identifier 字段是相同含义。

我们接下来看normalizer接收到moduleIdentifier(在 plugins 生成的 identifier 和上面的 request 经过 hash 之后我们都把他们叫做 moduleIdentifier)后做了哪些事情:

export default function normalizeComponent (
  scriptExports,
  render,
  staticRenderFns,
  functionalTemplate,
  injectStyles,
  scopeId,
  moduleIdentifier, /* server only */
  shadowMode /* vue-cli only */
) {
  // Vue.extend constructor export interop
  var options = typeof scriptExports === 'function'
    ? scriptExports.options
    : scriptExports

  // render functions
  if (render) {
    options.render = render
    options.staticRenderFns = staticRenderFns
    options._compiled = true
  }

  // functional template
  if (functionalTemplate) {
    options.functional = true
  }

  // scopedId
  if (scopeId) {
    options._scopeId = 'data-v-' + scopeId
  }

  var hook
  if (moduleIdentifier) { // server build
    hook = function (context) {
      // 2.3 injection
      context =
        context || // cached call
        (this.$vnode && this.$vnode.ssrContext) || // stateful
        (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext) // functional
      // 2.2 with runInNewContext: true
      if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') {
        context = __VUE_SSR_CONTEXT__
      }
      // inject component styles
      if (injectStyles) {
        injectStyles.call(this, context)
      }
      // register component module identifier for async chunk inferrence
      if (context && context._registeredComponents) {
        context._registeredComponents.add(moduleIdentifier)
      }
    }
    // used by ssr in case component is cached and beforeCreate
    // never gets called
    options._ssrRegister = hook
  } else if (injectStyles) {
    hook = shadowMode
      ? function () { injectStyles.call(this, this.$root.$options.shadowRoot) }
      : injectStyles
  }

  if (hook) {
    if (options.functional) {
      // for template-only hot-reload because in that case the render fn doesn't
      // go through the normalizer
      options._injectStyles = hook
      // register for functioal component in vue file
      var originalRender = options.render
      options.render = function renderWithStyleInjection (h, context) {
        hook.call(context)
        return originalRender(h, context)
      }
    } else {
      // inject component registration as beforeCreate hook
      var existing = options.beforeCreate
      options.beforeCreate = existing
        ? [].concat(existing, hook)
        : [hook]
    }
  }

  return {
    exports: scriptExports,
    options: options
  }
}

传入moduleIdentifier后,定义了 hook 函数,hook 函数的内容很简单,接收用户上下文 context 参数,最终把moduleIdentifier添加到用户上下文的_registeredComponents 数组中。这个 hook 我们在上面中也提到过,在渲染缓存组件时需要把组件从缓存中取出来,手动调用一次这个 hook,因为缓存组件没有普通组件的生命周期钩子。

之后的代码中判断组件是否是函数式组件,如果是函数式组件同样没有生命周期钩子,所以在这里重写了组件的 render 函数,执行 render 时先调用 hook 钩子。

如果是普通组件,则把 hook 钩子添加到组件的 beforeCreated 生命周期钩子中。

小结:

  1. 在编译阶段通过插件生成应用的文件和模块moduleIdentifier
  2. 在同一次编译过程中通过 vue-loader 把moduleIdentifier注入到每个模块的 hook 钩子中
  3. 在渲染阶段创建组件时调用 hook 钩子,把每个模块的moduleIdentifier添加到用户上下文 context 的_registeredComponents 数组中
  4. TemplateRenderer 在获取依赖文件时读取_registeredComponents 根据moduleIdentifier在 clientManifest 文件的映射关系找到,页面所需要引入的文件。

客户端阶段

当客户端发起了请求,服务端返回渲染结果和 css 加载完毕后,用户就已经可以看到页面渲染结果了,不用等待 js 加载和执行。服务端输出的数据有两种,一个是服务端渲染的页面结果,还有一个在服务端需要输出到浏览器的数据状态。

这里的数据状态可能是在服务端组件 serverPrefetch 钩子产生的数据,也可能是组件创建过程中产生的数据,这些数据需要同步给浏览器,否则会造成两端组件状态不一致。我们一般会使用 vuex 来存储这些数据状态,并在渲染完成后把 vuex 的 state 复制给用户上下文的 context.state。

在组装 html 阶段可以通过 renderState 生成输出内容,例子:

<script>window.__INITIAL_STATE__={"data": 'xxx'}</script>

当客户端开始执行 js 时,我们可以通过 window 全局变量读取到这里的数据状态,并替换到自己的数据状态,这里我们依然用 vuex 举例:

store.replaceState(window.__INITIAL_STATE__);

之后在我们调用 $mount 挂载 vue 对象时,vue 会判断 mount 的 dom 是否含有 data-server-rendered 属性,如果有表示该组件已经经过服务端渲染了,并会跳过客户端的渲染阶段,开始执行之后的组件生命周期钩子函数。

之后所有的交互和 vue-router 不同页面之间的跳转将全部在浏览器端运行。

SSR 的几点优化

我认为 ssr 最棒的一点就是使用一套前端技术开发的同时又解决纯前端开发页面的首屏时间问题。

很多人担心的一点是 ssr 在服务端跑 vue 代码,是不是很慢?我想说 vue-ssr 很快,但它毕竟不是常规的渲染引擎拼接字符串或者静态页面的输出。所以 ssr 的页面在访问流量比较大时要好好利用缓存 (并且尽量使用外部缓存),我相信即使不是 ssr 的页面如果页面流量大时是不是依然还是需要做缓存?

所以,对于 ssr 页面优化程度最大的一种方案就是合理利用缓存

当我们的页面内容比较长时我们建议在服务端只渲染首屏的内容,尽量减少不必要的运算。比如列表的场景,我们一页的内容可能是十条,但是用户在一屏的大小中最多只能看到五条,那我们在服务端只渲染五条内容,剩下的内容可以在浏览器端异步渲染。

不要让 ssr 在服务端执行一些密集 cpu 的运算,这条同样适用于任何 nodejs 应用,任何密集 cpu 的运算都会拖慢整个应用的响应速度。

在服务端调用后端接口或者查询数据库时,尽量把请求超时时间控制在一个合理的范围,因为一旦后端服务大量出现超时异常,减少我们请求的超时时间,及时断开请求将避免服务资源被快速沾满。

合理利用 dns-prefetch、preload 和 prefetch 加速页面资源下载速度 ,preload 和 prefetch 在我们配置了 template 和 inject 时 vue 会帮我们自动插入。页面需要引用的资源我们都可以在 head 中加入:

<link rel="preload[prefetch|dns-prefetch]" href="xxx.js" as="script[style]">

preload:告知浏览器该资源会在当前页面用到,浏览器会在解析 dom 树的同时非阻塞的下载该资源,在页面解析完成请求该资源时立即返回,并且通过标签的 as 属性浏览器会给不同类型的资源标识不同的加载优先级,比如 css 相关的资源会比 js 和图片的优先级更高。

prefetch:告知浏览器该资源可能会在页面上用到,浏览器会在空闲时机预下载,并不保证一定能预下载。

dns-prefetch:告知浏览器这些域名请帮我们开始 dns 的解析工作,待页面解析完成加载这些域名的资源时不用再执行 dns 解析。

更多详情,可以参考: http://www.alloyteam.com/2015/10/prefetching-preloading-prebrowsing/

非阻塞式的脚本加载 这个在我们配置了 template 和 inject 后 vue 也会自动帮我们的 script 加载脚本加上 defer 属性,script 有两种属性 defer 和 async:

无属性:在 dom 解析阶段开始下载,并且阻塞 dom 解析,下载完成之后再恢复 dom 解析。

defer:在 dom 解析阶段开始下载 js,不阻塞 dom 解析,并在 dom 解析渲染完成之后再执行。

async:在 dom 解析阶段开始下载 js,不阻塞 dom 解析,在下载完成之后立即执行,如果 dom 正在解析则阻塞住。

显然 defer 会让页面更快的呈现。

具体可参考:https://www.growingwiththeweb.com/2014/02/async-vs-defer-attributes.html

合理定义组件边界不要定义不必要的组件,组件的粒度要把控好,太细粒度的组件定义没有意义。
https://zhuanlan.zhihu.com/p/61348429

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant