egg 静态资源方案源代码解析

2,063 阅读6分钟
原文链接: zhuanlan.zhihu.com

前言

在前端的发展史中,自从前后端分离开始算起,静态资源方案经历了以下几个阶段:

  • 青铜时代:前端构建好 HTML,发布到后端服务器上,对外获取 HTML 的接口由业务后端服务器提供
  • 白银时代:同样是前端构建好 HTML,但不同的是构建完成后,发布到特定的托管服务上
  • 黄金时代:随着 Node.js的发展和页面逻辑愈发复杂,前端也开始拥有自己的独立应用(即所谓 BFF 模式),会同时提供数据接口,以及 HTML 静态资源服务

在这种背景下,对于静态资源方案,存在两个要求:首先 server/client 代码文件会放在同一个目录下面,服务端通过模板引擎进行渲染,并且引入客户端代码,其次客户端代码文件会先经历构建打包过程(打包的工具还多种多样),才能被作为静态资源提供。因此需要有一套方案来整合这种“渲染 + 构建”模式。

egg 提供了 egg-view-assets 作为静态资源方案,具体使用可以参考这篇文章:Egg 最新的静态资源方案。而本文则负责解释这套静态资源方案的实现细节。

核心链路

ctx.render 开始

让我们从一个最简单的 API 开始,跳过各种实现细节,理清在一次 ctx.render 中到底发生了什么。

PS:这次案例的代码使用了 assets-with-roadhog

// app/controller/home.js
module.exports = class HomeController extends Controller {
  async render() {
    await this.ctx.render('index.js');
  }
}

render 方法由 egg-view 对 context 进行扩展提供,它内部最终会把调用转发给 ContextView 实例上的 render 方法。

// egg-view/app/extend/context.js

const ContextView = require('../../lib/context_view');
const VIEW = Symbol('Context#view');

module.exports = {
  render(...args) {
    return this.renderView(...args).then(body => {
      this.body = body;
    });
  },
  // render调用了 renderView
  renderView(...args) {
    return this.view.render(...args);
  },
  // 在每一次请求中都会生成的 ContextView 对象
  get view() {
    if (!this[VIEW]) {
      this[VIEW] = new ContextView(this);
    }
    return this[VIEW];
  },
}

而在 ContextView 实例上的 render 方法,最终都集中指向了它自己的 [RENDER] 方法,这一步的作用是通过后缀名来查找 ViewEngine 实例,并且交付给它进行渲染执行

// egg-view/app/lib/context-view.js
class ContextView {
  async [RENDER](name, locals, options = {}) {
    // 省略关系不大的代码

    // 根据后缀名匹配 ViewEngine
    const ext = path.extname(filename);
    viewEngineName = this.viewManager.extMap.get(ext);

    const view = this[GET_VIEW_ENGINE](viewEngineName);

    // 使用 viewEngine 的实例执行 render
    return await view.render(filename, this[SET_LOCALS](locals), options);
  },
  [GET_VIEW_ENGINE](name) {
    // 获取 ViewEngine类并且实例化,并且交付给调用者
    const ViewEngine = this.viewManager.get(name);
    const engine = new ViewEngine(this.ctx);
    return engine;
  }
}

ViewEngine 查找

这里值得注意的是,extMap 保存的是 ViewEngine 的类,那么这种从后缀名 -> ViewEngine 实例是怎么做到的呢?其实,egg-view 在它的文档里已经写明了:How to write a view plugin。这里不多介绍,只是简单解释一下,做到三步就可以了:

第一,实现一个 ViewEngine 类,提供 render/renderString 方法,

module.exports = class AssetsView {
  async render(name, locals, options) {

  }
  async renderString() {
    throw new Error('assets engine don\'t support renderString');
  }
};

第二,通过注册,给你的 ViewEngine 提供一个名字,这段代码要在插件的 app.js 里写,这样 egg 在启动的时候才会去加载执行

// app.js
module.exports = app => {
  app.view.use('assets', AssetsView);
};

第三,egg-view 会要求你在配置中提供映射关系,这样就可以知道,在 ctx.render 的时候,哪个后缀名用哪个插件了。

// config/config.default.js
module.exports = appInfo => {

  config.view = {
    root: path.join(appInfo.baseDir, 'app/assets'),
    mapping: {
      '.js': 'assets',
    },
  };
}

所以,对于 egg-view-assets 而言,mapping 配置是从 js -> assets,也就是说,如果我执行 ctx.render,最终是交给了 AssetsView 实例执行 render 方法.

AssetsView#render

在这个例子里,由于 templateViewEngine 和 templatePath 都没有被指定,所以 render 方法就跳过了 readFileWithCache 函数,直接执行了 renderDefault 函数

async render(name, locals, options) {
  const templateViewEngine = options.templateViewEngine || this.config.templateViewEngine;
  const templatePath = options.templatePath || this.config.templatePath;

  const assets = this.ctx.helper.assets;

  // setEntry 的作用是指定入口文件
  // 这里的 entry 不是指 webpack 打包的入口文件,反而是 webpack 打包之后的结果文件,要作为页面的入口文件插入
  assets.setEntry(options.name);

  // 设置注入到模板引擎中的上下文
  assets.setContext(options.locals);

  if (templateViewEngine && templatePath) {
    // Do sth.
  }

  return renderDefault(assets);
}

renderDefault 也是比较简单的,就是执行了函数,返回 HTML 字符串,其中 assets 发挥了注入的作用:

  • getStyle 和 getScript:根据环境来返回相应的资源文件,例如是本地开发的话,可能就是类似 [http://127.0.0.1:8000/index.css]这样的结构
  • getContext:写一段内联 script,把希望注入的变量,挂载到 window 上面,以供获取
'use strict';

module.exports = assets => {
  return `
  <!doctype html>
  <html>
    <head>
      ${assets.getStyle()}
    </head>
    <body>
      <div id="root"></div>
      ${assets.getContext()}
      ${assets.getScript()}
    </body>
  </html>
  `;
};

入口文件何处寻

那么问题来了,页面的入口级 js 是怎么拿到的?这就要看 getScript 的代码了,它来自于 egg-view-assets 对 helper 的扩展

const AssetsContext = require('../../lib/assets_context');

module.exports = {
  get assets() {
    // 省略一系列缓存,可以看到指向了 AssetsContext 实例
        this[ASSETS] = new AssetsContext(this.ctx);
    return this[ASSETS];
  },
};

在 AssetsContext 中,我们先简单考虑,以本地开发环境为例。

class Assets {

  constructor(ctx) {
    // 本地开发环境下,publicPath 是 / 符号
    this.publicPath = this.isLocalOrUnittest ? '/' : normalizePublicPath(this.config.publicPath);
  }

  getScript(entry) {
    // 这里虽然没有 entry 传入进来
    // 但是 this.entry 在之前的 setEntry 中被设置成了 index.js,也就是 ctx.render 的第一个参数
    entry = entry || this.entry;

    let script = '';

    // 这一段负责把 entry 转换成实际的路径
    // 比如:<script src="http://127.0.0.1:8000/index.js"></script>
    script += scriptTpl({
      url: this.getURL(entry),
      crossorigin: this.crossorigin,
    });
    return script;
  }
}

那么问题来了,凭什么 [http://127.0.0.1:8000/index.js]会有这个文件呢?这是在 egg-view-assets 在启动的时候实现的,对 assets 插件(这是 egg-view-assets 作为 egg 插件的名字)的配置进行了动态扩展

module.exports = app => {
  const assetsConfig = app.config.assets;

  // 本地开发环境下,这个判断为 true
  if (assetsConfig.devServer.enable && assetsConfig.isLocalOrUnittest) {

    let port = assetsConfig.devServer.port;

    // 如果是自动端口处理,就从文件里去读
    if (assetsConfig.devServer.autoPort === true) {
      try {
        port = fs.readFileSync(assetsConfig.devServer.portPath, 'utf8');
        assetsConfig.devServer.port = Number(port);
      } catch (err) {
        // istanbul ignore next
        throw new Error('check autoPort fail');
      }
    }
    const protocol = app.options.https ? 'https' : 'http';

    assetsConfig.url = `${protocol}://127.0.0.1:${port}`;
  }
}

然后在调用 getScript 的时候,会自动把这个 url 作为 host,加上 publicPath 和 entry,得到最后的入口文件

结论

正像贯高在 Egg 最新的静态资源方案 中所说的

assets 模板引擎并非服务端渲染,而是以一个静态资源文件作为入口,使用基础模板渲染出 html,并将这个文件插入到 html 的一种方式

egg-view-assets 这套方法,就是通过先打包好JS/CSS -> Egg 获取请求时查找JS/CSS后插入页面的方式,让服务端和客户端逻辑在同一个项目仓库里实现,成为了可能,而如果只是看核心页面在何处渲染,那本质上确实还是客户端渲染。

核心流程介绍完了,接下来开始介绍,egg-view/egg-view-assets 体系

egg-view 详解

egg-view 本身是不提供任何视图渲染能力的,可以认为它是一个转发器,把特定后缀的文件,映射到一个具体的模板引擎去执行。

提供两个模块

  • ViewManager:模版引擎管理,提供文件缓存、后缀名映射、引擎注册(use方法)等能力
  • ContextView:渲染,提供运行时引擎映射、render/renderString方法提供、变量注入等功能

ViewManager

注册能力就是通过 view_manager 这个文件实现的,它提供了一个Map类

class ViewManager extends Map {
    use(name, viewEngine) {
    this.set(name, viewEngine);
  }
}

只要执行use方法,就会把模版引擎保存下来。

同时,通常传给 ctx.render 的路径都是 index.js、index.html 这样的字符串,那么怎么找到对应的路径呢?view_manager 提供了 resolve 方法

async resolve(name) {
  const config = this.config;

  // fileMap 是一个 Map 实例,进行缓存控制
  let filename = this.fileMap.get(name);
  if (config.cache && filename) return filename;

  // root 目录可以在 view 插件的配置中被指定,例如放到 app/assets 目录下面
  // defaultExtension 也是可以被指定的,例如默认的是 html 作为模板

  // resolvePath 的逻辑比较简单,就是在 root 列表下面进行遍历,然后找到对应的文件
  // 至于为什么 这里的 root 是个列表?因为在 view_manager 的 constructor 中,对 view 插件中配置的 root 进行了分割处理,就变成数组了
  filename = await resolvePath([ name, name + config.defaultExtension ], config.root);
  assert(filename, `Can't find ${name} from ${config.root.join(',')}`);

  // set cache
  this.fileMap.set(name, filename);
  return filename;
}

动态映射

前面提到过,当我们在执行类似 ctx.render('user.html') 的时候,其实我们就是在执行egg-view/app/extend/context上面的render方法

render(...args) {
  return this[RENDER](...args);
}
async [RENDER](name, locals, options = {}) {
  // 一是,根据传入的xxx.html和配置根目录(比如app/view),查找对应的文件
  // 这里使用了前面 view_manager 的resolve能力
  const filename = await this.viewManager.resolve(name);

  // 二是,看是否传入 options.viewEngineName,没传就根据文件的后缀名,映射到模版引擎,再没有就用配置默认的
  let viewEngineName = options.viewEngine;
  if (!viewEngineName) {
    const ext = path.extname(filename);
    viewEngineName = this.viewManager.extMap.get(ext);
  }
  // use the default view engine that is configured if no matching above
  if (!viewEngineName) {
    viewEngineName = this.config.defaultViewEngine;
  }

  // 比如,现在拿到了一个叫 nunjunks 的名字,作为 viewEngineName 变量的值

  // 通过这个名字获取,并且实例化 viewEngine
  const view = this[GET_VIEW_ENGINE](viewEngineName);

  // SET_LOCALS 的作用是扩展注入到模板引擎中的变量
  return await view.render(filename, this[SET_LOCALS](locals), options);
}

[GET_VIEW_ENGINE](name) {
  // 取出从viewManager这个Map中保存好的引擎,// 实例化
  const ViewEngine = this.viewManager.get(name);
  const engine = new ViewEngine(this.ctx);

  if (engine.render) engine.render = this.app.toAsyncFunction(engine.render);
  if (engine.renderString) engine.renderString = this.app.toAsyncFunction(engine.renderString);
  return engine;
}

这里的 toAsyncFunction 函数很有意思,它能够把一个 Generator 函数保证转换成 AsyncFunction,这样做的目的是为了兼容早期使用 Generator 函数作为 render 方法的插件。toAsyncFunction 来自于 egg-core,代码也是比较简单的,就是用 co 库包了一下,最终会让它返回一个 Promise

toAsyncFunction(fn) {
  if (!is.generatorFunction(fn)) return fn;
  fn = co.wrap(fn);
  return async function(...args) {
    return fn.apply(this, args);
  };
}

变量注入

有时候我们会想要把一些变量注入到模版当中,这就是所谓 local,而 egg-view 通过Object.assign 的方式,默认注入了 ctx、request、helper

[SET_LOCALS](locals) {
  return Object.assign({
    ctx: this.ctx,
    request: this.ctx.request,
    helper: this.ctx.helper,
  }, this.ctx.locals, locals);
}

关于依赖反转的思考

egg-view 对于 ViewEngine 管理机制的设计,充分体现了依赖反转的设计思想。

依赖反转定义:在面向对象编程领域中,依赖反转原则(Dependency inversion principle,DIP)是指一种特定的解耦(传统的依赖关系建立在高层次上,而具体的策略设置则应用在低层次的模块上)形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。

在 egg-view 的例子中,对外只会提供一个 render 方法,内部通过定义好 ViewEngine 的标准 Interface(一个包含render/renderString方法的 Class),并且向它暴露 name、locals、options 等变量,让具体的 ViewEngine 去完成渲染。

可以看出,处在上层的 render 方法,不需要自顶向下地关心每个功能的编码判断,只要基于一组约定提供API,剩下的事情交给各个 ViewEngine 下层功能去完成就好了。

egg-view-assets 详解

egg-view-assets 的核心模块

  • assets_context:会被挂载到 helper.assets 上,为模版引擎渲染提供 script 路径、css 路径映射服务
  • assets_view:会以 assets 为 name 被注册,主要是对 egg-view 能力的定制化操作
  • dev_server:负责在开发阶段执行代码构建。以及,如果你还开起了 waitStart 选项的话,整个应用会等待它构建完成并且启动后,才触发启动完成

有关 assets_context 的作用和 assets_view 的作用,在核心链路章节已经介绍过了,就不再做展开了。重点介绍一下 dev_server。

Devserver

在开发环境,我们可以启动一个 Devserver 来构建文件。

在 Cluster 模式下,通常会以 Agent 作为统一处理资源文件的对象,因为如果多个 App 来做这件事,就乱套了。而在 Cluster 机制下, 文件目录下的 agent 会先得到执行。

// 正式环境不需要打开
if (!assetsConfig.isLocalOrUnittest) return;
if (!assetsConfig.devServer.enable) return;

// 启动
const server = new DevServer(agent);

// 处理一些ready、关闭响应等逻辑
server.ready(err => {
  if (err) agent.coreLogger.error('[egg-view-assets]', err.message);
});

因此,启动 DevServer,是 Agent 的事情。而DevServer的核心可以分为三个部分:

第一,自动端口检查。在 init 函数当中:

  • 端口:通过 detectPort 去做自动端口检查,同时写入到 portPath 当中,方便之后各个 App 来读取。
  • 后续:执行 startAsync 和 waitListen
class DevServer extends Base {
  async init() {
    const { devServer } = this.app.config.assets;

    // 自动端口号检查
    if (devServer.autoPort) {
      devServer.port = await detectPort(10000);
      await mkdirp(path.dirname(devServer.portPath));

      // portPath 是用来存放端口号的
      // 之前在“入口文件何处寻”小节中提到过,在自动端口号模式下会从这里取文件
      await fs.writeFile(devServer.portPath, devServer.port);
    } else {
      // check whether the port is using
      if (await this.checkPortExist()) {
        throw new Error(`port ${this.app.config.assets.devServer.port} has been used`);
      }
    }

    // start dev server asynchronously
    this.startAsync();
    await this.waitListen();
  }
}

第二,启动命令,即 startAsync。既然 egg-view-assets 是渲染模板 + 注入入口JS 的模式,那在开发阶段就需要和构建工具整合。而每个开发者都可能有自己的习惯。因此 通过 command 配置的方式来实现了构建工具自由化。

举个配置的例子

config.assets = {
  devServer: {
    debug: true,
    command: 'umi dev',
    port: 8000,
  },
};
// egg-view-assets
startAsync() {
  const { devServer } = this.app.config.assets;
  // 这行的作用就是把类似 'xxx dev --port={port}' 中的port 换成 config.assets.devServer.port 指定的值
  devServer.command = this.replacePort(devServer.command);
  const [ command, ...args ] = devServer.command.split(/\s+/);

  // 省略环境变量代码
  const opt = {
    // 默认输出是被禁用的
    stdio: [ 'inherit', 'ignore', 'inherit' ],
    env,
  };
  if (devServer.cwd) opt.cwd = devServer.cwd;

  // 开启 debug 的情况下,构建命令的输出会被 pipe 向 stdio(这样我们就可以在 Terminal 之类的工具中看到了)
  if (devServer.debug) opt.stdio[1] = 'inherit';

  // 新起一个进程去跑命令
  const proc = this.proc = spawn(command, args, opt);
  proc.once('error', err => this.exit(err));
  proc.once('exit', code => this.exit(code));
}

第三,启动超时检查。通过 waitListen 函数实现,本质上就是启动了一个 while 循环,用 detect 模块检查端口是否被使用,如果被使用就算成功了。

async waitListen() {

  // 从配置里拿到 timeout 超时
  const { devServer } = this.app.config.assets;
  let timeout = devServer.timeout / 1000;

  let isSuccess = false;
  while (timeout > 0) {

    // 如果是关掉的话,就不用做什么了
    if (this.isClosed) {
      return;
    }

    // 这个函数的本质是执行 detect-port 库进行端口号嗅探
    // 如果说 detect-port 库返回的端口号,和指定的端口号不同,那么就说明指定的端口号已经被使用了,即成功启动
    if (await this.checkPortExist()) {
      // 成功启动
      isSuccess = true;
      break;
    }
    timeout--;
    // 每一秒执行一次 while 循环,避免CPU压力过大
    await sleep(1000);
  }

  if (isSuccess) return;
  const err = new Error(`Run "${devServer.command}" failed after ${devServer.timeout / 1000}s`);
  throw err;
}

至此就启动完成了,应用也可以从指定的目录拿到文件

总结

至此,egg-view/egg-view-assets 体系介绍完毕,再次总结一下

egg-view 提供两个模块

  • ViewManager:会被挂载到 app.view 变量上,核心是模版引擎管理,即注册和查找
  • ContextView:会被挂载到 ctx.view,核心方法是 [RENDER],负责找到特定的 ViewEngine 去执行渲染

egg-view-assets 的核心模块

  • assets_context:会被挂载到 helper.assets 上,为模版引擎渲染提供 script 路径、css 路径映射服务
  • assets_view:会以 assets 为 name 被注册,主要是对 egg-view 能力的定制化操作
  • dev_server:负责在开发阶段执行代码构建,整个应用会等待它构建完成并且启动后,才触发启动完成