前言
在前端的发展史中,自从前后端分离开始算起,静态资源方案经历了以下几个阶段:
- 青铜时代:前端构建好 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:负责在开发阶段执行代码构建,整个应用会等待它构建完成并且启动后,才触发启动完成