Nuxt 深度实践

4,220 阅读11分钟

Nuxt 深度实践

Vue 和 React 给我们提供了很好的底层 MV* 框架,在这之上,我们需要考虑一些更进一步的问题:

  1. 如何同构(SSR,CSR)?
  2. 如何提升业务迭代速度?
  3. 如何更好地进行前端模块化和工程化?
  4. 如何降低部署成本和发布成本?

为什么要使用 Nuxt

很多面试官在面试的时候,都喜欢问项目的技术选型,为什么要用这样一个框架来实现你的需求,假设有人这么问我,我会这样说:

  • Progressive,其实是我们考虑的重点。作为一个互联网产品,各种日常活动以及不定期的运营活动是必然的。很多重复的逻辑都可以抽离成模块来进行管理。Nuxt 的 module 为我们提供了便捷的“即插即用”的模块管理方式。目前,我们的项目中已经拥有了 10 个左右的大型模块,以及多个业务插件,这些东西帮我们解决了很多代码复用的问题。
  • Vue,基于团队技术栈考虑,当然,如果你的团队的主要技术栈是 React,也可以选择 Nuxt 的亲兄弟 Nest。
  • Deployment,由于我们团队的 Node 服务都是基于 k8s + docker 进行部署的,那么,基本没有 server 端语言依赖的 Nuxt 当然是首选。如果你的服务更多的部署在物理机上,为了更好地利用多核性能,使用 egg 或者 PM2 + Koa/Express + Nuxt 也是可以的选择。
  • Static,Nuxt 对于生成静态页面也有很好的支持,由于很多活动在过期之后,仍旧需要保存一份快照,Nuxt 提供的这个功能就很实用了。可以很好地减少我们的快照生成的成本。 基于上面的这些原因,我们19年底开始迭代我们的 Nuxt 模块,到现在已经有总共上千 QPS 流量的 H5 已经接入到 Nuxt 中了。 诚然,在业务迭代的时候,我们遇到了很多问题,多次的 whiteboard discussion 帮助我们更好地完成了基于我们业务场景的功能迭代,逐渐理解这些渐进式框架的设计思路。

什么功能可以用 module 实现

在最开始的时候,我们基于 express 和 Nuxt 搭建了一个基本上裸奔的项目框架,只是进行简单的 SSR。我们开始考虑,如何将现有的项目接入进来。

迁移旧的

我们以前的 SSR 项目都是基于内部一个非常古老的框架实现的。

有多古老呢?没有 async,没有 ES6 语法,基于 svn 进行代码管理。很多 node 模块都是通过 node-gyp 编译的 C++ 模块,C++ 源码已经丢失了。node 版本永远停留在 node 0.12.x,甚至于我们在这个项目上 docker 的时候,需要自己构建 node image 来作为业务的基础镜像。 如此硬核的技术债,就需要一步一步来还了。 我们拆分了以前的代码所有的功能模块(其实大部分已经废弃),在里面找到了一些可以在新的项目中提前抽离出来的部分。比如 platformDetect,auth,toast,JSBridge 等。 说到这里,旧代码如何进行重构,是一件非常考验技术积累的工作。俗话说,步子太大会扯到x。保证现有项目的稳定,又可以在业务中逐步迭代新框架,保证业务进度和快速迁移迭代,是在着手之前就需要进行思考的。 基于上面的四个旧的功能模块,我们考虑了必要性和可移植性,就规划了一期方案:

  1. 针对 platformDetect 这一类的通用逻辑模块,俗称 utils,可以整理成一个单独的 module,提供 server/client 的 plugin 给业务方(也是我们自己,小团队的悲催)调用,这部分可以先使用旧的代码。
  2. Auth,是一个非常基础,并且非常重要的模块,整个页面能否正常打开和展示数据,都需要这个模块来进行校验。而且由于我们的登录逻辑算是比较复杂的,这里也经过了多次踩坑和重新设计,最终达到一个可以 release 的程度。
  3. Toast,严格意义上来说,这是一个 UI 模块,但是考虑到这个模块我们可以通过插件的方式注入进行,并且基本上所有的 H5 都会用到这个功能,所以我们也将这个模块的优先级提高,并且进行重写。
  4. JSBridge,也是一个非常基础,但是泛用面很大的模块,但是这个模块是通过 SDK 的方式引入的,并且只需要在 client 端调用,以前的代码可以直接使用,毕竟 JavaScript 是可以向下兼容的,所以我们直接将这个 SDK 抽离出来,作为一个单独的模块进行注入,方便其他项目进行引用。

创造新的

大多数项目,需要迁移的旧的模块一般都是一些必要的,并且偏业务侧的模块。而将 H5 业务从物理机部署迁移到 docker 部署,将服务整体进行微服务化。我们需要增加很多模块,来提供一些在 k8s 中需要使用的功能。

监控

无论你使用的是什么服务架构,到位的监控可以让业务运行在稳定、安全的环境中。基于 k8s 的环境,Prometheus 是一个通用的方案。当然,其他的监控方案也可以用类似的思路来进行实现。 我们考虑的监控主要基于几个维度:

  • 响应时间
  • upstream 请求状态
  • 流量
  • 业务错误 响应时间其实非常简单,基于 express 的中间件模型,我们只需要实现一个中间件,在请求进入的时候记录一个 startTime,并且在请求结束的时候,用当前时间 - 开始时间,就可以得到整个请求的响应时间了。动手吧~
// monitorMiddleware.js
// 任何监控上报的 sdk
const promClient = require(‘prom-client’);
const monitorReport = serviceName => {
	return async (req, res, next) => {
	  const { path } = req;
	  const startTime = Date.now();
    await next();
    try {
		const responseTime = Date.now() - startTime;
      promClient.report(serviceName, path, responseTime);
	  }
	}
}

module.exports = monitorReport;

// server.js
const app = express();
app.use(monitorMiddleware(‘demo’));

run。。 理想很丰满,现实是渣渣。我们并没有收到任何监控上报。 省略掉了无数 debug 过程,我们发现,请求在进入 node 服务之后,中间的前半部分可以正常执行,但是在 await next() 之后的逻辑却无法执行。 在查找了源码和文档之后,发现 Nuxt 其实会接管所有进入到 Nuxt 渲染流程的业务,然后由 Nuxt 来进行返回。下面这段代码是 SSR 的时候调用 Nuxt 的方式:

// server.js
app.use((req: Express.Request, res: Express.Response) => {
  nuxt.render(req, res);
});

Nuxt 会将自己渲染的结果直接吐回到客户端。 所幸,Nuxt 提供了 hooks,来让我们介入 Nuxt 的生命周期。 我们使用了 render:routeDone 这个生命周期来进行 SSR 的服务监控。根据官方文档,这个生命周期会在

Every time a route is server-rendered. Called after the response has been sent to the browser.

也就是在 SSR 渲染结束,并且响应返回到客户端之后进行调用。我们可以在这个 hook 上进行响应时间的上报(事实上,大部分的上报都是在这个阶段进行的)。 我们将 hook 和 middleware 整理成一个 module,来让所以业务可以一键注入依赖,接入到整个监控体系当中。

// module.js
// 这里对于模块的代码进行了简化,当然,在真正实现模块的时候,还是需要对文件目录进行结构化的
const rendererHook = require('./hooks/render');
module.exports = function prometheus(options) {
	const { serviceName } = options;
  this.addServerMiddleware({
    path: '/metrics',
    handler: metrics,
  });
  this.addServerMiddleware({
    path: '/',
    handler: monitorMiddleware(‘demo’),
  });
  this.nuxt.hook('render:routeDone', rendererHook.routeDone);
}

// hooks/render.js
const onRenderRouteDoneHook = (url, result, context) => {
  try {
    const { path } = context.req;
    const { statusCode } = context.res;
    const resSize = JSON.stringify(result).length;
    const renderTime = Date.now() - context.res.startTime;
    const isError = statusCode === 200 ? 0 : 1;
    // 这个函数里面可以根据你的需求,上报想要的信息,比如上面得到的
	// resSize:流量
	// renderTime:渲染时间
    // isError:渲染错误
    prom.report()
  }
}

告警

由于公司内部有已经搭建好的 sentry 平台,能够有可以直接利用的资源当然是最好的,Nuxt 社区也提供了很多非常好的模块,其中就有 nuxt-sentry 模块。 将模块搞下来之后,发现有些地方需要修改一下来适配公司的环境,不过在大部分场景下都是可以直接使用的。

数据请求

同样的 Nuxt 社区提供了 nuxt-axios 模块来帮助我们更方便地进行数据请求。但是在不同环境下,我们仍然要对 axios 进行一些修改来适配环境,比如:

  • 在 development 和 production 请求不同的接口,如果你们的后台也有测试和线上环境,并且又有跨域限制的话,是不可避免要做这样的区分的。
  • 在请求超时或者失败的情况下重放请求。
  • 设置请求过期时间,并且在超时之后返回一个自定义的错误码。
  • 统一为所有请求注入一些自定义信息等等。 Nuxt-axios 提供了单例方式的 axios 实例来让我们调用,我们可以直接对于 nuxt-axios 进行一次上层封装而不需要动到内部逻辑,只需要在引入模块的时候,同时引入这两个模块即可。 假定我们将这个模块命名为 nuxt-request,我们可以得到这样一份模块入口文件。
// request.js
module.exports = function request(moduleOptions) {
  const { timeout = 3000 } = moduleOptions;
  this.addPlugin({
    src: path.resolve(__dirname, './plugins/request.server.js'),
    fileName: 'request.server.js',
    mode: 'server',
    options: {
      timeout,
    },
  });
  this.addPlugin({
    src: path.resolve(__dirname, './plugins/request.client.js'),
    fileName: 'request.client.js',
    mode: 'client',
    options: {
      timeout,
    },
  });
}

区分客户端和服务端,我们注入两个不同的插件,而 Nuxt 会根据代码所运行的环境,来引入不同的插件在 runtime 执行,options 参数也是 Nuxt 很有趣的一个地方,他允许我们在编译的时候,将一些变量动态的注入到插件中(ejs 模板语言的方式),从而让业务方在调用模块或者插件的时候,可以通过在 nuxt.config.js 中配置的方式,来动态注入信息到插件中。比如上面我们就动态注入了 axios 请求的超时时间到插件中。

// request.server.js
export default function axios(context, inject) {
  const { $axios, req } = context;
  // 这里假设我们将 userVid 通过请求 cookie 的方式带下来
  const { userVid } = req.cookies.userVid; 
  $axios.onRequest(config => {
    const { url } = config;
    // 通过 interceptor 的方式,我们可以做到很多事情,比如注入请求 IP
    if (url.startsWith('http') || url.startsWith('https')) {
      config.baseURL = 'xxxx';
    }
    // 比如注入用户信息
    config.params && config.params.userVid = userVid;
    // 比如注入请求超时时间
    config.timeout = <%= options.timeout %>;
  });
}

Nuxt.config.js 为我们提供了配置的途径,而插件里面开放出来的 template plugin 功能则可以实现很多基于配置方式的扩展。比如上面的超时时间的设置。 并且,由于 context 中包含每次请求的上下文,我们还可以针对每个不同的服务端 request,进行单次请求的注入,不用每次在调用的时候都手动传递请求上下文到 upstream。 当然,得益于 Nuxt 可以分两端来注入插件,客户端我们可以使用和服务端不同的处理方式,来对请求进行拦截并且处理。 假设对于客户端的请求,我们需要 reject 掉部分请求,防止一些敏感数据的接口暴露到外网。下面是客户端使用的插件:

// request.client.js
export default function axios(context, inject) {
  const { $axios, req } = context;
  const wrapper = fn => {
    if (Object.prototype.toString.call(fn) !== '[object Function]') {
      throw new Error('fn must be a function, but got: ', typeof fn);
    }
    return (url, ...args) => {
      if (url.startsWith('/user/secret')) {
        return Promise.reject('request forbidden');
      }
      return fn(url, ...args);
    }
  }
  const get = wrapper($axios.get);
  const post = wrapper($axios.post);
  inject('axios', {
    get,
    post,
  });
}

然后,我们在模块的入口分别引入这两个插件,并且在 nuxt.config.js 中配置即可。 Nuxt 提供的模块、插件、中间件以及 hooks 可以让我们在框架的基础上,做到即插即用的业务扩展,当一个功能模块被多个业务使用的时候,就需要考虑将其抽出来做成一个模块发布出去,在业务繁多的团队中,这种方式能够极大的提高开发效率。 推荐使用 GitHub - lerna/lerna: A tool for managing JavaScript projects with multiple packages.来管理这些 Nuxt 模块,lerna 的多 packages 管理和 release log 可以让模块发布更加方便和清晰,将所有 Nuxt 依赖都收敛在一起。

Nuxt 和k8s 的结合

现在的前端开发环境已经和几年前变化了很多,我们最早是通过自研的 node.js 框架,将服务直接部署在物理机上来提供服务,这样会存在很多问题。

  1. 当异常流量进入服务之后,会出现机器负载飙升,但是有没有非常成熟的方式来限制 node 占用的资源,如果机器资源被吃满(尤其是内存),会导致很多请求阻塞,形成雪崩效应;
  2. 横向扩容成本较高,并且不够灵活以及弹性;
  3. 搭建服务监控体系的成本也很高,很多东西都需要自己“造火箭”;
  4. CI / CD 流程架构的不够好的话,会导致部署成本较高,回滚也不是非常方便。

由于上面的问题,以及线上服务遇到过的很多问题。我们将所有的服务全部迁移到了 k8s 上面,基于 docker 进行业务部署。 这里就先不详细说明基于 k8s 的一系列监控,CI / CD 等流程了,因为可能需要了解到 k8s 相关的知识,如果大家有兴趣的话后面可以单独开一篇前端和 k8s 之间的结合。 所以我们从 k8s 得到了什么?

  • 动态扩容,当服务的响应时间指标到了一个瓶颈之后,自动对服务进行扩容;
  • 基于 Prometheus 的服务质量监控;
  • 结合 k8s 实现的一套 CI / CD 流程,简化从代码提交到发布上线的流程和时间;
  • 保证服务的粒度,活动类服务可以需要时上线,不需要可以直接全部下线,更好的节约硬件成本。

Nuxt 使用的一些 trick

在使用 Nuxt 实现业务的时候,有一些特殊场景的需求需要解决,在这之间发现了一些使用的技巧,这块会在我们持续迭代的过程中进行持续更新~

一些隐藏的 hook

在兼容 kindle 浏览器的时候,我们发现无论怎么降低 babel 的 target 版本,都不能够正常执行 JavaScript,但是页面渲染是正常的,在经过一系列 debug 之后,我们发现了问题所在。 Kindle 内置浏览器的版本是非常低的,大概是十几年前的 safari 浏览器,这个浏览器甚至不支持 IIFE,而 babel 编译出来的 JavaScript 脚本,基本都是包含 IIFE 的,甚至于最外层就是 IIFE。所以,我们需要将 SSR 出来的页面的 JavaScript 脚本重定向到另外一个脚本,那个脚本用低版本的原生 JavaScript 实现,来保证在 kindle 上能够绑定事件监听等。 那么如何进行替换呢,在 SSR 的时候,Nuxt 会接管进入到自己路由的服务,直接渲染成 HTML 字符串吐回给前端。 我们想到的第一个点就是 hooks,但是令人失望的是,我们在文档中似乎没有找到能够拿到渲染结果的 hook。在翻阅源码的时候,我们发现了一个内置的 hook,这个 hook 没有在文档中表明,猜测可能是 Nuxt 自己使用的一个 hook 或者是 vue 的 hook。

// @nuxt/vue-renderer/dist/vue-renderer.js
    // Template params
    const templateParams = {
      HTML_ATTRS: meta ? meta.htmlAttrs.text(true /* addSrrAttribute */) : '',
      HEAD_ATTRS: meta ? meta.headAttrs.text() : '',
      BODY_ATTRS: meta ? meta.bodyAttrs.text() : '',
      HEAD,
      APP,
      ENV: this.options.env
    };

    // Call ssr:templateParams hook
    await this.serverContext.nuxt.callHook('vue-renderer:ssr:templateParams', templateParams);

    // Render with SSR template
    const html = this.renderTemplate(this.serverContext.resources.ssrTemplate, templateParams);

    let preloadFiles;
    if (this.options.render.http2.push) {
      preloadFiles = this.getPreloadFiles(renderContext);
    }

这个 hook 是 vue-renderer:ssr:templateParams,这个 hook 提供一个参数 templateParams,这个参数的提供了几个属性,都是 SSR 的渲染结果:

HTML_ATTRS: 'data-n-head-ssr',
HEAD_ATTRS: '',
BODY_ATTRS: '',
HEAD: '<title>nuxt-demo</title>',
APP: '<div data-server-rendered="true" id="__nuxt"><!----><div id="__layout"><div><div class="demo"><div class="demo_center"></div></div></div><script src="/_nuxt/runtime.js" defer></script><script src="/_nuxt/commons.app.js" defer></script><script src="/_nuxt/vendors.app.js" defer></script><script src="/_nuxt/app.js" defer></script>'
ENV: { baseUrl: 'https://localhost:3000/demo' } }

可以看到, JavaScript 脚本都被插入到 APP 属性中,我们尝试了通过正则表达式,将 APP 属性中的脚本,替换成我们自己的脚本 URL,发现在 client 端拉到的脚本就是我们自己的低配脚本了。 当然,使用一个并未暴露出来的接口来解决问题可能并不是最好的解决方案,但是还没有发现更好的解决方案之前,这个方法虽然简陋但是可以实现我们的功能(如果评论区各位大佬有更合理的实现方式,可以留言告诉作者~)

// server.js
async function start () {
  // Init Nuxt.js
  const nuxt = new Nuxt(config);
  const { host, port } = nuxt.options.server;

  nuxt.hook('vue-renderer:ssr:templateParams', (templateParams: TemplateParams) => {
    console.log(templateParams);
    const APP = templateParams.APP;
    const removedScriptApp = APP.replace(/<script\b((?!utils|global|localStorageUtil|collectHtml|login|shelf|reader|>).)*><\/script>/g, '');
    templateParams.APP = removedScriptApp;
    return templateParams;
  });

  // Build only in dev mode
  if (config.dev) {
    const builder = new Builder(nuxt);
    await builder.build();
  } else {
    await nuxt.ready();
  }

  // Give nuxt middleware to express
  app.use((req: Express.Request, res: Express.Response) => {
    nuxt.render(req, res);
  });

  // Listen the server
  app.listen(port, host);
  consola.ready({
    message: `Server listening on http://${host}:${port}`,
    badge: true
  });
}
start();

SSR 同时支持 Express API

在业务开发的过程中,我们发现很多 API 在服务端渲染和客户端渲染的时候都要使用,这些 API 并不是后台提供的,而是我们需要自己将后台的一些数据拼接,并且串行或者并行一系列请求,进行数据组装。 由于在服务端渲染的时候,调用的是 Nuxt 的 asyncData 方法进行请求,而在客户端的时候,是直接请求后台 API,如果我们直接通过 Express 的中间件方式实现,那么 asyncData 就需要请求本地 localhost 来拿到这个请求结果,这种方式不是非常合理,因为这样会占用 node 服务的流量,浪费一次 TCP 连接。 我们摸索的比较好的实现方式有几种:

  1. 将 API 和 Nuxt 分离,这些 API 被聚合在其他服务当中。这样也存在问题,一个业务开发的时候,需要启动两个项目来做,API 服务还是不能够很好的 serverless;
  2. 抽出数据拼接的 transform,将数据请求分别写在 asyncData 和 Express middleware 中。我们目前采用的是这种方式,在 API 是并行请求的时候,这样的方式能够很好地工作,transform 实现两端同构其实是非常容易的。但是,在串行请求的过程中,仍然需要多个 transform 来处理,在后续的业务需求中就遇到了这样的问题,所以我们考虑了第三种方案。
  3. 将 API 抽离成为一个单独的模块:service。service 负责进行 API 请求和数据聚合的封装。每个 API 可以封装成一个函数,区分不同环境,注入不同的 request 对象,对于每个 service 模块来说,它们不需要关心自己是哪个环境或者使用哪个请求对象来获取数据,而是通过注入的统一的 axios 实例来进行请求。
// userService.js
module.exports = async function user (context, ...args) {
  const { $axios } = context.app;
  const { userVid } = context.req.query;
  const userData = await $axios.get('/user/profile?userVid=' + userVid);
  const { userName, gender } = userData;
  const friendData = await $axios.post('/user/friend?userVid=' + userVid + '&gender=' + gender);
  return {
    ...userData,
    ...friendData,
  };
}
// service.server.js  作为 server plugin 提供给 asyncData
module.exports = function service (context, inject) {
  const services = getAllService();		// 通过目录文件读取的方式,读取所有的 service
  const serviceMap = new Map();
  services.forEach(service => {
    const fn = require(service);
    serviceMap.set(service, (...args) => {
      fn(context, ...args);
    });
  });

  inject('service', {
    request: (serviceName, ...args) => {
      return serviceMap.get(serviceName)(...args);
    },
  });
}
// service.js 作为 SDK 提供给 serverMiddleware
const services = getAllService();
// 由于 serviceMiddleware 中拿不到 Nuxt 的 axios,所以需要单独注入一个进去
const axios = require(axios);
const serviceMap = new Map();
services.forEach(service => {
  const fn = require(service);
  serviceMap.set(service, (req) => {
	  return (...args) => {
      return fn({
        app: { $axios: axios },
        req: { ...req },
      }, ...args);
    };
  });
});
module.exports = function service (serviceName, req) => {
	return serviceMap.get(serviceName)(req);
}

这里的实现为了脱敏,所以写的比较简单,不过实现上的思路大致已经比较明确,核心是通过注入函数参数的方式,来抹平 Nuxt 中与 Express 中的环境差别。

写在最后

Nuxt 为我们容器化的 SSR 开发提供了很多思路,也为我们的前端工程化提供了很多解决方案,不过 Nuxt 本身还是比较底层的,只是对于 Vue SSR 的功能进行了一定的扩展,留下了很多开发的空间。 在我们实践的过程中,还有很多问题没有解决,当然每个公司的整体业务架构和业务场景不同,这篇文章也会跟着业务迭代慢慢更新(最近刚接完 typescript,有精力了再继续补充进去)。