JavaScript模拟获取函数执行上下文
在代码开发中,函数嵌套调用时很常见的,有时候需要根据函数的执行上下文做一些事情。
比如a调用了b,b调用了c,c调用了d,最后希望d可以获取是从a调用时的一些信息,一般是通过参数透传等方式来实现,在写法上比较繁琐。
本文将研究在前端js代码实现中,是否有更简单的写法。
函数执行上下文
JavaScript函数执行上下文是JavaScript引擎在执行函数时创建的一个内部环境,它包含了函数执行所需的所有信息。每当一个函数被调用时,JavaScript引擎会为该函数创建一个新的执行上下文,并将其推入执行上下文栈中。
执行上下文主要包含三个部分:
- 变量环境(Variable Environment):存储变量和函数声明的绑定
- 词法环境(Lexical Environment):存储标识符和其值的映射关系
- this绑定:确定函数执行时this的值
在执行上下文内部,还有一个调用栈(Call Stack)的概念,它记录了函数的调用顺序。当函数被调用时,会创建一个新的执行上下文并压入栈顶;当函数执行完毕后,该执行上下文会从栈中弹出。
理想情况下,如果有什么API可以直接访问或者操作函数执行上下文,就可以实现文章开头想要的功能。
但这些都是 规范级别的抽象结构(定义在 ECMAScript 规范中),并不是 JavaScript 运行时可访问的对象。
JavaScript本身并没有直接提供操作函数执行上下文的API,所以没有类似 getExecutionContext() 或 setLexicalEnv() 这样的 API
获取调用栈
假设我们的场景是希望d函数在运行时,识别到时在a函数调用的,然后执行一些特殊逻辑,这就要求在运行的时候需要获取调用栈
使用 Error.stack
Error.stack
是 JavaScript 中获取函数调用栈最常用的方法。它会返回一个字符串,包含了从错误发生位置开始追溯到脚本初始调用的完整调用路径。
function a() {
b();
}
function b() {
c();
}
function c() {
// 创建一个Error对象来获取调用栈
const stack = new Error().stack;
console.log(stack);
}
a();
会得到如下形式的字符串,就可以解析得到函数调用栈
Error
at c (1.html:135:27)
at b (1.html:130:13)
at a (1.html:126:13)
at 1.html:140:9
这个方法的弊端是在异步函数无法获取完整的调用栈信息
function c() {
setTimeout(function(){
const stack = new Error().stack;
console.log(stack);
})
}
只会得到下面的内容
Error
at 1.html:136:23
需要注意Error.stack
不是标准的一部分,但在大多数现代浏览器中都支持
console.trace()
console.trace()
可以得到完整的调用栈,这是比较理想的调用栈输出
(anonymous) @ 1.html:138
setTimeout
c @ 1.html:135
b @ 1.html:130
a @ 1.html:126
(anonymous) @ 1.html:143
但他只能在控制台打印内容,无法通过js获取到具体的字符串值
使用 arguments.callee
还有一种arguments的写法
function a() {
b();
}
function b() {
c();
}
function c() {
const caller = arguments.callee.caller.caller
console.log(caller.name); // 获取调用者
}
a() // a
arguments.callee 已被弃用,不推荐使用,在严格模式下这会报错
小结
JS本身看起来并没有提供的官方的获取调用栈方式,上面这些骚操作写法,并不推荐在生产环境使用
参数透传
另外一种场景是参数透传,比如某个路由跳转的逻辑,由于react router没有提供全局的history对象,只能使用useHistory,但是非组件的函数中,只能通过参数透传的方式获取调用栈信息。
function a() {
const context = { userId: 123, requestId: 'req-1' };
b(context);
}
function b(context) {
// 传递context给下一个函数
c(context);
}
function c(context) {
// 继续传递context
d(context);
}
function d(context) {
// 最终函数可以访问到最初传递下来的上下文信息
console.log('User ID:', context.userId);
console.log('Request ID:', context.requestId);
}
a();
从代码维护和测试的成本看,通过参数注入的变量,实际比import的外部依赖更好,但是链条长了很不优雅,尤其是在已有代码做添加或扩展的时候
koa、express等后端框架采用了一种取巧的手段,在中间件的第一个参数是一个context对象,可以向这个对象添加一些属性,实现传递参数
// Koa 示例
app.use(async (ctx, next) => {
ctx.userId = 123; // 在上下文对象中添加信息
await next();
});
app.use(async (ctx, next) => {
// 中间件可以访问前面设置的userId
console.log('User ID:', ctx.userId);
await next();
});
// Express 示例
app.use((req, res, next) => {
req.userId = 123; // 在请求对象中添加信息
next();
});
app.use((req, res, next) => {
// 中间件可以访问前面设置的userId
console.log('User ID:', req.userId);
next();
});
但本质上是参数透传,只是在已有参数上做的扩展而已
另外一种思路是借助全局变量
let id;
function a(){
id = 1
b()
}
function b(){
c()
}
function c(){
console.log(id)
}
这个方案的问题在于全局变量很容易被其他函数影响,比如另外一个函数a1也修改了id,同时调用链路存在异步函数,就会导致最终c运行时得到的id值不确定。
如果将所有函数都定义在一个大函数作用域里面,就可以在底层函数的参数控制对应的id
function call(id){
function a(){
b()
}
function b(){
c()
}
function c(){
console.log(id)
}
}
call(1)
call(2)
但实际开发中,尤其是已有代码的迭代,b和c很有可能是独立写在不同的作用域里面的。
实际上如果有某种机制,在起始函数调用时,保存的id,在这个函数后续调用的其他函数(包含同步和异步函数)可以拿到同一个id,这个id不会被其他函数修改,那么这个方案就比较理想了。
换言之,如果能够有一个类似于作用域之类的函数调用域的东西,就可以实现这个效果,这个看起来需要编程语言底层支持。
AsyncLocalStorage
AsyncLocalStorage 是 Node.js 提供的一个 API,用于在异步操作中存储和传递上下文信息。它是内部模块async_hooks
模块的一部分,专门设计用来解决在异步操作中保持上下文的难题。
AsyncLocalStorage 的工作原理是基于执行上下文的概念。当进入一个异步操作时,它会保存当前的存储状态,并在异步操作完成时恢复这个状态。
// Node.js 示例
const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();
function withRequestId(fn) {
const id = Math.random().toString(36).slice(2);
asyncLocalStorage.run(id, fn);
}
function logWithId(msg) {
const id = asyncLocalStorage.getStore();
console.log(`[${id}]`, msg);
}
withRequestId(async () => {
logWithId("start");
await new Promise(r => setTimeout(r, 500));
logWithId("end");
});
通过这个API,我们可以在异步操作中自动传递上下文信息,而不需要手动传递。
AsyncLocalStorage 特别适用于以下场景:
- 请求追踪和日志记录
- 用户身份验证信息传递
- 数据库事务上下文管理
- 性能监控和指标收集
Zone.js
AsyncLocalStorage只能在Node环境下使用,浏览器中并不支持。
Zone.js
是 Angular 团队开发的一个库,它可以用来拦截和跟踪浏览器中的异步操作。Zone.js 可以帮助我们在异步操作中保持上下文信息,解决了传统 JavaScript 在异步操作中丢失调用栈信息的问题。
Zone.js 的核心概念包括:
- Zone:表示一个执行上下文环境
- Task:表示一个异步任务
- Interceptor:用于拦截异步操作
使用 Zone.js,我们可以创建一个带有特定属性的 zone,并在任何异步操作中访问这些属性:
<script src="https://unpkg.com/zone.js/bundles/zone.umd.js"></script>
<script>
const myZone = Zone.current.fork({
name: 'requestZone',
properties: { requestId: 'myzone' }
});
async function update() {
console.log("other fn:", Zone.current.get('requestId'));
}
myZone.run(async () => {
console.log("start:", Zone.current.get('requestId'));
setTimeout(() => {
console.log("timeout:", Zone.current.get('requestId'));
}, 1000);
Promise.resolve().then(() => {
console.log("promise:", Zone.current.get('requestId'));
});
await update()
});
const myZone2 = Zone.current.fork({
name: 'requestZone2',
properties: { requestId: 'myzone2' }
});
myZone2.run(async () => {
console.log("start:", Zone.current.get('requestId'));
await update()
})
</script>
Zone.js 的优势:
- 可以跨越异步操作保持上下文信息
- 提供了统一的异步任务跟踪机制
- 支持多种异步操作(setTimeout, Promise, event listeners 等)
- 可以用于性能监控和错误追踪
不过需要注意的是,Zone.js 会 monkey-patch 浏览器的原生 API,可能会带来一些性能开销和兼容性问题。
小结
目前看起来,如果是在前端项目开发中,期望实现类似于通过函数执行上下文来管理一批函数调用的功能,Zone.js
是少数可以work的方案,但是这种对底层定时器、Promise 等API的mock 操作,可能会带来一些意想不到的问题,技术选型时需要好好斟酌一番。
如果有其他更好的方案,欢迎留言~
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
