Skip to content
On this page

Scope作用域详解

前言

这篇文章让我们来学习下 BabelScope 的一些相关知识,这样在还原混淆代码的时候能够更快速清晰的知道某个变量在哪里被引用了,这个变量在哪里定义的了。

首先要了解什么是 作用域,具体可以自行百度,简单来说一个函数就是一个作用域,那么函数里面有几个变量,可以说这几个变量的作用域就是这个函数。

Scope 的用法

scope 提供了一些对象属性和方法,可以让我们方便地查找标识符的作用域,获取并修改标识符的所有引用,以及判断标识符是否为参数或常量。如果不是常量,也可以知道在哪里修改了它。

  • scope.block
  • scope.dump()
  • scope.getBinding()
  • scppe.getOwnBinding()
  • scope.traverse()
  • scope.rename()
  • scope.hasBinding()
  • scope.hasOwnBinding()
  • scope.getAllBindings()
  • scope.hasReference()
  • scope.getBindingIdentifier()

文章中的示例代码如下:

js
const a = 1000;  
let b = 2000;  
let obj = {  
name: 'mankvis',  
add: function (a) {  
a = 400;  
b = 300;  
let e = 700;  
  
function demo() {  
let d = 600;  
}  
  
demo();  
return a + a + b + 1000 + obj.name;  
},  
}  
obj.add(100);  

scope.block

scope.block 属性可以用来获取标识符的作用域,返回 Node 对象,使用方法分为两种情况:变量函数。以下为标识符是 变量 的情况下,示例代码如下:

js
const ast = parser.parse(jsCode);  
  
traverse(ast, {  
Identifier(path) {  
if (path.node.name === 'e') {  
console.log(generator(path.scope.block).code)  
}  
}  
})  
/*  
function (a) {  
a = 400;  
b = 300;  
let e = 700;  
  
function demo() {  
let d = 600;  
}  
  
demo();  
return a + a + b + 1000 + obj.name;  
}  
*/  

上文说到即然 path.scope.block 返回的是 Node 对象,那么就可以使用 generator 来生成 Node 对象的代码。上述代码遍历所有的 Identifier,当名字为 e 时,把当前节点的作用域转换为代码。变量 e 是定义在 obj.add 内部的,所以整个作用域是整个 add 函数。 但是如果遍历的是一个 函数,那么它的作用域就会有些特别,来看下面这个例子:

js
traverse(ast, {  
FunctionDeclaration(path) {  
console.log(generator(path.scope.block).code)  
}  
})  
  
/*  
function demo() {  
let d = 600;  
}  
*/  

上述代码遍历 FunctionDeclaration,在原始代码中只有 demo() 这个函数符合要求,但是 demo 的作用域实际上应该是整个 add 的范围,因此输出与实际不符,这时需要去获取父级作用域。修改代码如下:

js
traverse(ast, {  
FunctionDeclaration(path) {  
console.log(generator(path.scope.parent.block).code)  
}  
})  
  
/*  
function (a) {  
a = 400;  
b = 300;  
let e = 700;  
  
function demo() {  
let d = 600;  
}  
  
demo();  
return a + a + b + 1000 + obj.name;  
}  
*/  

scope.dump

scope.dump() 会得到自底向上的作用域与变量信息,示例代码如下:

js
traverse(ast, {  
FunctionDeclaration(path) {  
console.log('\n\n这里是函数', path.node.id.name + '()');  
path.scope.dump();  
}  
})  
  
/*  
这里是函数 demo()------------------------------------------------------------  
# FunctionDeclaration  
- d { constant: true, references: 0, violations: 0, kind: 'let' }  
# FunctionExpression  
- a { constant: false, references: 2, violations: 1, kind: 'param' }  
- e { constant: true, references: 0, violations: 0, kind: 'let' }  
- demo { constant: true, references: 1, violations: 0, kind: 'hoisted' }  
# Program  
- a { constant: true, references: 0, violations: 0, kind: 'const' }  
- b { constant: false, references: 1, violations: 1, kind: 'let' }  
- obj { constant: true, references: 2, violations: 0, kind: 'let' }  
------------------------------------------------------------  
*/  
  

上面的内容是使用 scope.dump() 的输出内容,看着比较多,这里说下怎么看

  • # 开头的是每一个作用域,上面有三个作用域,分别是 FunctionDeclarationFunctionExpressionProgram
  • - 开头的是每一个绑定(binding),每一个 binding 都会包含几个关键信息,分别是:constantreferencesviolationskind
  • constant 表示是否为常量,为布尔值
  • references 表示被引用的次数
  • violations 表示被重新定义的次数
  • kind 表示声明类型,param 参数、hoisted 提升、var 变量、local 内部。

以上的的这些属性并不是全部,后面介绍 Binding 对象的时候再以补充。

对于scope.dump()单次输出都是自底向上的,先输出当前作用域,再输出父级作用域,再输出父级的父级的作用域...直到顶级作用域

scope.getBinding

scope.getBinding 接收一个类型为字符串的参数,用来指定要获取某个标识符的绑定。为了更直观的说明绑定的含义,先看下面这段代码,遍历 FunctionDeclaration,符合要求的只有 demo,然后获取当前节点下的绑定 a,直接输出绑定信息:

js
traverse(ast, {  
FunctionDeclaration(path) {  
let bindingA = path.scope.getBinding('a');  
console.log(bindingA);  
},  
})  
  
/*  
Binding {  
identifier: Node {type: 'Identifier', ..., name: 'a'},  
scope: Scope {  
...,  
block: Node {type: 'FunctionExpression', ...}  
},  
path: NodePath {...},  
kind: 'param',  
constantViolations: [...],  
constant: false,  
referencePaths: [...],  
referenced: true,  
references: 2  
}  
}  
*/  

getBinding中传入的值必须是当前节点能够引用到的标识符名,如果传入一个不存在的 g ,这个标识符并不存在,或者说当前节点引用不到,那么 getBinding 会返回 undefined

可是 FunctionDeclaration 的作用域只是 demo 本身,为什么能找到 a 呢,这是因为 demo 本身的作用域是 add 这个函数,所以是可以直接找到 a 的

以上输出内容中的属性 constantreferenceskind 之前已经介绍过了,其他属性介绍如下:

  • identifiera 标识符的 Node 对象
  • patha 表示符的 Path 对象
  • kind 表示这是一个参数,但是并不代表就是当前 demo 的参数。实际上原始代码中,aadd 的参数(当函数中局部变量与全局变量重名时,使用的是局部变量)
  • referencePaths 假设标识符被引用,referencePaths 中会存放所有引用该标识符的节点的 Path 对象
  • constantViolations 假如标识符被修改,那么 constantViolations 中会存放所有修改该标识符的节点的 Path 对象

另外可以看出,Binding 中也有 scope,因为获取的是 aBinding,所以是 ascope。将其中的 block 节点转换为代码后,可以看出它的作用域范围就是 add。值得一提的是,加入获取的是 demoBinding,将其中的 block 节点转换为代码后,输出的也是 add。因此获取函数作用域也可以用如下方式:

js
traverse(ast, {  
FunctionExpression(path) {  
let bindingA = path.scope.getBinding('a');  
let bindingDemo = path.scope.getBinding('demo');  
console.log(bindingA.referenced);  
console.log(bindingA.references);  
console.log(generator(bindingA.scope.block).code);  
console.log(generator(bindingDemo.scope.block).code);  
},  
})  
  
/*  
true  
2  
下面代码输出2次  
function (a) {  
a = 400;  
b = 300;  
let e = 700;  
  
function demo() {  
let d = 600;  
}  
  
demo();  
return a + a + b + 1000 + obj.name;  
}  
*/  

scope.getOwnBinding

scope.getOwnBinding 该函数用于获取当前节点自己的绑定,也就是不包含父级作用域中定义的标识符的绑定,但是该函数会得到子函数中定义的标识符的绑定,看下面的例子:

js
function TestOwnBinding(path) {  
path.traverse({  
Identifier(p) {  
let name = p.node.name;  
console.log(name, !!p.scope.getOwnBinding(name));  
}  
});  
}  
  
traverse(ast, {  
FunctionExpression(path) {  
TestOwnBinding(path);  
},  
})  
  
/*  
a true  
a true  
b false  
e true  
demo false  
d true  
demo true  
a true  
a true  
b false  
obj false  
name false  
*/  

上述代码遍历 FunctionExpression 节点,当前案例中符合要求的只有 add 函数,然后遍历该函数下所有的 Identifier,输出标识符名和 getOwnBinding 的结果,查看输出结果,可以发现子函数 demo 中定义的 d 变量,该变量也可以通过 getOwnBinding 得到,也就是说,如果只想获取当前节点下定义的标识符,而不设计子函数的话,还需要进一步判断,可以通过判断标识符作用域是否与当前函数一致来确定,如下:

js
function TestOwnBinding(path) {  
path.traverse({  
Identifier(p) {  
let name = p.node.name;  
let binding = p.scope.getBinding(name);  
binding && console.log(name, generator(binding.scope.block).code === path.toString());  
}  
});  
}  
  
traverse(ast, {  
FunctionExpression(path) {  
TestOwnBinding(path);  
},  
})  
  
/*  
a true  
a true  
b false  
e true  
demo true  
d false  
demo true  
a true  
a true  
b false  
obj false  
*/  

上述代码通过 binding.scope.block 获取标识符作用域,转换代码后,在与当前节点代码进行比较,就可以确定是否是当前函数中定义的标识符。因为子函数中定义的标识符,作用域范围是子函数本身,添加判断作用域代码后,使用 getBindinggetOwnBinding 的结果是一样的。

scope.traverse

scope.traverse 方法可以用来遍历作用域中的节点。可以使用 Path 对象中的 scope,也可以使用 Binding 中的 scope,笔者推荐使用后者,下面来看下例子:

js
traverse(ast, {  
FunctionDeclaration(path) {  
let binding = path.scope.getBinding('a');  
binding.scope.traverse(binding.scope.block, {  
AssignmentExpression(p) {  
if (p.node.left.name === 'a') {  
p.node.right = types.numericLiteral(500);  
}  
},  
})  
}  
})  
  
/*  
const a = 1000;  
let b = 2000;  
let obj = {  
name: 'mankvis',  
add: function (a) {  
a = 500;  
b = 300;  
let e = 700;  
  
function demo() {  
let d = 600;  
}  
  
demo();  
return a + a + b + 1000 + obj.name;  
}  
};  
obj.add(100);  
*/  

原始代码中, a = 400,上述代码的作用是将它改为 a = 500,假如是从 demo 这个函数入手,那么只要获取 demo 函数中的 aBinding,然后遍历 binding.scope.block(也就是 a 的作用域),找到赋值表达式是 lefta的,将对应的 right 改掉。

scope.rename

可以使用 scope.rename 将标识符进行重命名,这个方法会同时修改所有引用该标识符的地方,例如将 add 函数中的 b 变量重命名为 x,代码如下:

js
traverse(ast, {  
FunctionExpression(path) {  
let binding = path.scope.getBinding('b');  
binding.scope.rename('b', 'x');  
},  
})  
  
/*  
const a = 1000;  
let x = 2000;  
let obj = {  
name: 'mankvis',  
add: function (a) {  
a = 400;  
x = 300;  
let e = 700;  
  
function demo() {  
let d = 600;  
}  
  
demo();  
return a + a + x + 1000 + obj.name;  
}  
};  
obj.add(100);  
*/  

上述方法很方便的就把 b 改为了 x,但是如果随便指定一个变量名,可能会与现有标识符发生命名冲突,这时可以使用 scope.generateUidIdentifier 来生成一个标识符,生成的标识符不会与任何本地的标识符相冲突,代码如下:

js
traverse(ast, {  
FunctionExpression(path) {  
path.scope.generateUidIdentifier('uid');  
// Node {type: "Identifier", name: "_uid"}  
path.scope.generateUidIdentifier('_uid2');  
// Node {type: "Identifier", name: "_uid2"}  
},  
})  

使用这两种方法,就可以实现一个简单的标识符混淆的案例,代码如下:

js
traverse(ast, {  
  
Identifier(path) {  
path.scope.rename(  
path.node.name,  
path.scope.generateUidIdentifier('_0x2ba6ea').name  
);  
},  
})  
  
/*  
const _0x2ba6ea = 1000;  
let _0x2ba6ea15 = 2000;  
let _0x2ba6ea18 = {  
name: 'mankvis',  
add: function (_0x2ba6ea14) {  
_0x2ba6ea14 = 400;  
_0x2ba6ea15 = 300;  
let _0x2ba6ea9 = 700;  
  
function _0x2ba6ea12() {  
let _0x2ba6ea11 = 600;  
}  
  
_0x2ba6ea12();  
  
return _0x2ba6ea14 + _0x2ba6ea14 + _0x2ba6ea15 + 1000 + _0x2ba6ea18.name;  
}  
};  
  
_0x2ba6ea18.add(100);  
*/  

实际上标识符混淆方案还可以做的更复杂,例如,上述代码中,如果在多定义一些函数,会发现各函数之间的局部变量名是不重复的。加入把各个函数之间的局部变量定义成重复的,甚至还可以让函数中的局部变量跟当前函数中没有引用到的全局变量重名,原始代码中的全局变量 aadd 中的 a 参数。

scope.hasBinding

该方法查询某标识符是否有绑定,返回 true 或者 false。可以用 scope.getBinding("a") 代替,scope.getBinding("a") 返回 undefined,等同于 scope.hasBinding("a") 返回 false

scope.hasOwnBinding

该方法查询当前节点中是否有自己的绑定,返回布尔值,例如,对于 demo 函数,OwnBinding 只有一个 d,函数名 demo 虽然也是标识符,但不属于 demo 函数的 OwnBinding范畴,是属于它的父级作用域中的,如:

js
traverse(ast, {  
FunctionDeclaration(path) {  
console.log(path.scope.parent.hasOwnBinding('demo'));  
},  
})  
  
/*  
true  
*/  

同样可以使用 scope.getOwnBinding("a") 代替它,scope.getOwnBinding("a") 返回 undefined,等同于 scope.hasOwnBinding("a") 返回 false。原始代码中 scope.hasOwnBinding("a") 也是通过 scope.getOwnBinding("a") 来实现的。

scope.getAllBindings

该方法获取当前节点的所有绑定,会返回一个对象。该对象以标识符名为属性名,对应的 Binding 为属性值,代码如下:

js
traverse(ast, {  
FunctionDeclaration(path) {  
console.log(path.scope.getAllBindings());  
}  
})  
  
/*  
[Object: null prototype] {  
d: Binding {...},  
a: Binding {...},  
demo: Binding {...},  
e: Binding {...},  
b: Binding {...},  
obj: Binding {...},  
}  
*/  

遍历每一个 Binding,代码如下:

js
traverse(ast, {  
BlockStatement(path) {  
console.log('\n此块节点源码: \n', path.toString());  
let bindings = path.scope.bindings;  
console.log('作用域内被绑定数量:', Object.keys(bindings).length);  
for (const bindingsKey in bindings) {  
console.log('名字', bindingsKey);  
let binding_ = bindings[bindingsKey];  
console.log('类型:', binding_.kind);  
console.log('定义:', binding_.identifier);  
console.log('是否常量:', binding_.constant);  
console.log('被修改信息记录:', binding_.constantViolations);  
console.log('是否被引用:', binding_.referenced);  
console.log('被引用次数:', binding_.references);  
console.log('被引用信息NodePath记录', binding_.referencePaths);  
}  
console.log('-------------------------------------');  
},  
})  

scope.hasReference

scope.hasReference("a") 表示查询当前节点中是否有 a 标识符的引用,返回布尔值。

scope.getBindingIdentifier

scope.getBindingIdentifier("a") 表示获取当前节点中绑定的 a 的标识符,返回 IdentifierNode 对象。同样,这个方法也有 Own 版本,为 scope.getOwnBindingIdentifier("a")

Released under the MIT License.