

egg 静态资源方案源代码解析
source link: https://zhuanlan.zhihu.com/p/136031675
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

egg 静态资源方案源代码解析
在前端的发展史中,自从前后端分离开始算起,静态资源方案经历了以下几个阶段:
- 青铜时代:前端构建好 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:负责在开发阶段执行代码构建,整个应用会等待它构建完成并且启动后,才触发启动完成
Recommend
-
174
前言对于页面中静态资源(html/js/css/img/webfont),理想中的效果:页面以最快的速度获取到所有必须静态资源,渲染飞快;服务器上静态资源未更新时再次访问不请求服务器;服务器上静态资源更新时请求服务器最新资源,加载又飞快。总结下来也就是2个指标
-
121
转转hybrid app web静态资源离线系统实践 Original...
-
50
-
65
前端静态资源缓存最优解以及max-age的陷阱
-
41
当我们使用 SpringMVC 框架时,静态资源会被拦截,需要添加额外配置,之前老有小伙伴在微信上问松哥Spring Boot 中的静态资源加载问题:“松哥,我的HTML页面好像没有样式?”,今天我就通过一篇文章,来和大伙仔细聊一聊这个问题。
-
40
1MB - 免费且便捷的静态资源部署网站 - NEXT
-
55
前言 在写 cdn 和 对象存储文章的时候,看到了一些跟静态资源有关的问题,这里就来做一些整理。 什么是静态资源 不根据访问的条件变化的资源就是静态资...
-
26
@ SpringBoot中的SpringMVC配置功能都是在 WebMvcAutoConfiguration 类中, xxxxAutoConfiguration 就是帮我们给容器中自动配置组件的;idea全局搜索的快捷键是两次 shift ,...
-
21
By 超神经 内容概要: 土地分类是遥感影像的重要应用场景之一,本文介绍了土地分类的几个常用方法,并利用开源语义分割代码,打造了一个土地分类模型...
-
22
rust-unofficial/awesome-rust:Rust开源代码和资源的精选列表。 Rust 代码和资源的精选列表。点击标题。...
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK