侧边栏

JavaScript模拟获取函数执行上下文

发布于 | 分类于 编程语言/JavaScript

在代码开发中,函数嵌套调用时很常见的,有时候需要根据函数的执行上下文做一些事情。

比如a调用了b,b调用了c,c调用了d,最后希望d可以获取是从a调用时的一些信息,一般是通过参数透传等方式来实现,在写法上比较繁琐。

本文将研究在前端js代码实现中,是否有更简单的写法。

函数执行上下文

JavaScript函数执行上下文是JavaScript引擎在执行函数时创建的一个内部环境,它包含了函数执行所需的所有信息。每当一个函数被调用时,JavaScript引擎会为该函数创建一个新的执行上下文,并将其推入执行上下文栈中。

执行上下文主要包含三个部分:

  1. 变量环境(Variable Environment):存储变量和函数声明的绑定
  2. 词法环境(Lexical Environment):存储标识符和其值的映射关系
  3. this绑定:确定函数执行时this的值

在执行上下文内部,还有一个调用栈(Call Stack)的概念,它记录了函数的调用顺序。当函数被调用时,会创建一个新的执行上下文并压入栈顶;当函数执行完毕后,该执行上下文会从栈中弹出。

理想情况下,如果有什么API可以直接访问或者操作函数执行上下文,就可以实现文章开头想要的功能。

但这些都是 规范级别的抽象结构(定义在 ECMAScript 规范中),并不是 JavaScript 运行时可访问的对象。

JavaScript本身并没有直接提供操作函数执行上下文的API,所以没有类似 getExecutionContext() 或 setLexicalEnv() 这样的 API

获取调用栈

假设我们的场景是希望d函数在运行时,识别到时在a函数调用的,然后执行一些特殊逻辑,这就要求在运行的时候需要获取调用栈

使用 Error.stack

Error.stack 是 JavaScript 中获取函数调用栈最常用的方法。它会返回一个字符串,包含了从错误发生位置开始追溯到脚本初始调用的完整调用路径。

js
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

这个方法的弊端是在异步函数无法获取完整的调用栈信息

js
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的写法

js
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,但是非组件的函数中,只能通过参数透传的方式获取调用栈信息。

js
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对象,可以向这个对象添加一些属性,实现传递参数

js
// 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();
});

但本质上是参数透传,只是在已有参数上做的扩展而已

另外一种思路是借助全局变量

js
let id;

function a(){
  id = 1
  b()
}
function b(){
  c()
}
function c(){
  console.log(id)
}

这个方案的问题在于全局变量很容易被其他函数影响,比如另外一个函数a1也修改了id,同时调用链路存在异步函数,就会导致最终c运行时得到的id值不确定。

如果将所有函数都定义在一个大函数作用域里面,就可以在底层函数的参数控制对应的id

js
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 的工作原理是基于执行上下文的概念。当进入一个异步操作时,它会保存当前的存储状态,并在异步操作完成时恢复这个状态。

js
// 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 的核心概念包括:

  1. Zone:表示一个执行上下文环境
  2. Task:表示一个异步任务
  3. Interceptor:用于拦截异步操作

使用 Zone.js,我们可以创建一个带有特定属性的 zone,并在任何异步操作中访问这些属性:

html
<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 的优势:

  1. 可以跨越异步操作保持上下文信息
  2. 提供了统一的异步任务跟踪机制
  3. 支持多种异步操作(setTimeout, Promise, event listeners 等)
  4. 可以用于性能监控和错误追踪

不过需要注意的是,Zone.js 会 monkey-patch 浏览器的原生 API,可能会带来一些性能开销和兼容性问题。

小结

目前看起来,如果是在前端项目开发中,期望实现类似于通过函数执行上下文来管理一批函数调用的功能,Zone.js是少数可以work的方案,但是这种对底层定时器、Promise 等API的mock 操作,可能会带来一些意想不到的问题,技术选型时需要好好斟酌一番。

如果有其他更好的方案,欢迎留言~

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。