Browser API 调用分析
代码文件中的外部依赖,不论是通过 npm 安装的第三方依赖,还是大型项目中基于 Webpack 等构建工具建立起 alias 关系的项目依赖,都是通过 Import 方式引入的。对于通过 Import 引入的 API,分析工具需要对 AST 进行两轮遍历:第一轮是针对代码文件中的 Import 节点进行遍历分析,获取从目标依赖中导出的 API 信息,第二轮针对导入的 API 进行调用判定、用途分析。
举个例子:
// 示例代码
import { app } from 'framework'; // 项目依赖,通过webpack alia配置指向目标仓库
import { clone } from 'loadsh'; // 第三方依赖,一般安装在node_modules下
function cloneInfo (info: string) {
clone(info);
}
function getInfos (info: string) {
const result = app.get(info);
return result;
}针对上述示例代码,我们分析的目标是搞清楚从 framework 导入的 app 这个API 在代码中真实的调用情况。
但前端项目中还存在一些特殊的依赖,它们不需要通过 Import 引入,也不需要显示声明,以全局变量形式在代码中直接调用。例如 Bom API、Dom API 等这些依托于浏览器环境的 API,我们可以称它们为 Browser API。
// Browser API 示例代码
function back(){
history.back(); // history API
}
function goto(){
location.href = 'https://iceman.well.com'; // location API
}
function getDom(){
return document.getElementByld('idxxx'); // document API
}
function timeOut(name: string){
window.setTimeout(()=>{ // window API
console.log(name);
}, 500);
}这节课,我们就来学习如何分析 Browser API。
Browser API 分析意义
微前端架构下的巨型项目,子应用如果滥用 Browser API 可能会对其它应用或基础框架造成严重影响,沙箱方案只是针对此类问题的解决方案,但并不表示子应用代码就可以无所顾忌地使用 Browser API 了,了解代码真实的依赖调用可以主动发现、预防此类问题。
举个例子,A 应用代码:
// 封装setCookie
function setCookie(key, value, day) {
let d = new Date();
d.setDate(d.getDate() + day);
document.cookie = key + "=" + value + ";expires=" + d;
}
// 封装getCookie
function getCookie(key) {
let str = document.cookie;
let arr = str.split("; ");
for (let i = 0; i < arr.length; i++) {
let item = arr[i].split("=");
if (item[0] == key) {
return item[1];
}
}
return ";"
}B 应用代码:
//创建cookie
function creatCookie(name,value,expiresDay) {
var oDay = new Date();
oDay.setDate(oDay.getDate() + expiresDay);
document.cookie = name + ' = ' + value + '; expires = ' + expiresDay;
}A、B 两个应用各自都封装了 Cookie 处理的函数,当 A 应用执行自己的 setCookie 方法时传入的 key 为 ‘iceman’,然后当用户切换到 B 应用后,调用 creatCookie 方法时传入的 name 也为 ‘iceman’。这个时候,B 应用已经把 A 应用设置的 cookie 给覆盖了,当用户再次切回 A 应用时,调用 getCookie 传入 'iceman' 时获取的值就是错误的。
正常情况下,基础框架层会封装具有命名空间隔离特性的 cookie API 给到 A、B 应用。但正如我们第 1 篇程中所讲,API 文档只能告诉开发者如何使用 API,但并不代表他们真的会按规则去使用。
因此我们需要分析这类特殊的 API,及早发现和规避潜在风险。
Browser API 特征分析
老样子,既然要分析 Browser API,那我们先将下面示例代码放入 TypeScript AST Viewer,观察一下 Browser API 调用场景中 AST 的结构及特征:
// 示例代码
function back(){
window.history.back();
}
function getDom(){
return document.getElementById('idxxx');
}我们发现 Browser API 与 Import API 的调用特征是一样的,包括链式调用在内,并没有什么不同。所以我们也可以通过判断 Identifier 类型节点的名称与 Browser API 名称是否一致来判定 Browser API 是否被调用,而且因为没有 Import 节点的干扰,我们只需要排除局部同名变量的影响即可。
对于 Import API,我们通过对比 AST 节点的 Symbol 与 Import API 节点的 Symbol 指向是否一致来排除局部同名节点的干扰,但 Browser API 不存在显示声明,所以它的 Symbol 信息比较特别。大家仔细观察一下上面两张图中右下角红框内的 pos、end 属性值就会发现,它们的值远大于我们整个代码字符串流的 pos、end 值,即它们的值在代码字符总长度之外。
什么意思呢?上面 AST 对应的字符串总长度为 111,正常 AST 节点 Symbol 指向的声明节点 pos、end 属性值肯定在 0-111 之间。但 Browser API 因为不存在声明,所以 pos 和 end 属性值都是大于 111 的 ,我们可以通过这个特性来排除局部同名变量的干扰。
下面是判定 Browser API 的代码实现,需要注意的一点是,Browser API 因为不存在显示声明,所以不需要针对 Import 节点进行 AST 遍历分析,需要分析哪些 API 直接在配置文件中的 browserApis 配置项中配置即可。
完整代码可以参考 lib/analysis.js :
// AST分析
_dealAST(importItems, ast, checker, filePath, projectName, httpRepo, baseLine = 0) {
const that = this;
......
// 遍历AST
function walk(node) {
// console.log(node);
tsCompiler.forEachChild(node, walk);
// 获取节点代码行信息
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + baseLine + 1;
// Browser API Analysis,命中Browser Api Item Name
if(tsCompiler.isIdentifier(node)
&& node.escapedText
&& that._browserApis.length>0
&& that._browserApis.includes(node.escapedText)) {
const symbol = checker.getSymbolAtLocation(node);
// console.log(symbol);
if(symbol && symbol.declarations){
// 在AST中找不到声明上下文信息,判定该API是 Browser API 调用
if(symbol.declarations.length>1
|| ( symbol.declarations.length==1 && symbol.declarations[0].pos >ast.end)){
const { baseNode, depth, apiName } = that._checkPropertyAccess(node);
// 执行插件记录Browser API调用信息
// that._runBrowserPlugins(tsCompiler, baseNode, depth, apiName, filePath, projectName, httpRepo, line);
}
}
}
}
walk(ast);
......
}上面的 _browserApis 数组由 browserApis 配置项(可选配置)初始化而来,比如 [ 'window','history','document' ] 表示工具需要分析的 Browser API 有 window.xxx,history.xxx,document.xxx,空数组则表示不需要分析 Browser API。
如果按照上面的分析逻辑,那么在实际执行后我们会得到类似下面这样的分析结果(示例):
"browserMap": {
"document.getElementById": {
"callNum": 1,
"callOrigin": null,
"callFiles": {
"Order&src/components/scrollBar/index.vue": {
"projectName": "Order",
"httpRepo": "",
"lines": [
32
]
}
}
},
"window.history.back": {
"callNum": 1,
"callOrigin": null,
"callFiles": {
"Order&src/api/people.ts": {
"projectName": "Order",
"httpRepo": "",
"lines": [
13
]
}
}
},
"history.back": {
"callNum": 1,
"callOrigin": null,
"callFiles": {
"Order&src/api/people.ts": {
"projectName": "Order",
"httpRepo": "",
"lines": [
13
]
}
}
}
}我们看到 history.back 与 window.history.back 被当成不同的 API 调用被统计了,但这样的结果并不符合我们的预期,我们希望 window.history.back 这种调用只会命中 window 这个 API,而 history.back 这种调用只会命中 history 这个 API。
所以分析逻辑需要区分 window.history 与 history 这两种调用方式,为了避免类似场景对分析结果的影响,我们添加一个过滤逻辑,完整代码如下:
// AST分析
_dealAST(importItems, ast, checker, filePath, projectName, httpRepo, baseLine = 0) {
const that = this;
......
// 遍历AST
function walk(node) {
// console.log(node);
tsCompiler.forEachChild(node, walk);
const line = ast.getLineAndCharacterOfPosition(node.getStart()).line + baseLine + 1;
// Browser API Analysis,命中Browser Api Item Name
if(tsCompiler.isIdentifier(node)
&& node.escapedText
&& that._browserApis.length>0
&& that._browserApis.includes(node.escapedText)) {
const symbol = checker.getSymbolAtLocation(node);
// console.log(symbol);
if(symbol && symbol.declarations){
// 在AST中找不到声明上下文信息,判定该API是 Browser API 调用
if(symbol.declarations.length>1
|| ( symbol.declarations.length==1 && symbol.declarations[0].pos >ast.end)){
const { baseNode, depth, apiName } = that._checkPropertyAccess(node);
// 排除 window.xxx 此类场景对于统计的干扰
if(!(depth>0
&& node.parent.name
&& node.parent.name.pos ==node.pos
&& node.parent.name.end ==node.end)){
// 执行插件记录Browser API调用信息
// that._runBrowserPlugins(tsCompiler, baseNode, depth, apiName, filePath, projectName, httpRepo, line);
}
}
}
}
}
walk(ast);
......
}Browser API 分析插件
与 Import API 类似,在判定代码中存在 Browser API 调用后,也需要记录调用信息,所以我们内置了 browserPlugin 分析插件,完整源码请参考 plugins/browserPlugin.js :
exports.browserPlugin = function (analysisContext) {
const mapName = 'browserMap';
// 在分析实例上下文挂载副作用
analysisContext[mapName] = {};
function isBrowserCheck (context, tsCompiler, node, depth, apiName, filePath, projectName, httpRepo, line) {
try{
if (!context[mapName][apiName]) {
context[mapName][apiName] = {};
context[mapName][apiName].callNum = 1;
context[mapName][apiName].callOrigin = null;
context[mapName][apiName].callFiles = {};
context[mapName][apiName].callFiles[filePath] = {};
context[mapName][apiName].callFiles[filePath].projectName = projectName;
context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
context[mapName][apiName].callFiles[filePath].lines = [];
context[mapName][apiName].callFiles[filePath].lines.push(line);
} else {
context[mapName][apiName].callNum++;
if (!Object.keys(context[mapName][apiName].callFiles).includes(filePath)) {
context[mapName][apiName].callFiles[filePath] = {};
context[mapName][apiName].callFiles[filePath].projectName = projectName;
context[mapName][apiName].callFiles[filePath].httpRepo = httpRepo;
context[mapName][apiName].callFiles[filePath].lines = [];
context[mapName][apiName].callFiles[filePath].lines.push(line);
}else{
context[mapName][apiName].callFiles[filePath].lines.push(line);
}
}
return true; // true: 命中规则, 终止执行后序插件
}catch(e){
// console.log(e);
const info = {
projectName: projectName,
apiName: apiName,
httpRepo: httpRepo + filePath.split('&')[1] + '#L' + line,
file: filePath.split('&')[1],
line: line,
stack: e.stack
};
context.addDiagnosisInfo(info);
return false; // false: 插件执行报错, 继续执行后序插件
}
}
// 返回分析Node节点的函数
return {
mapName: mapName,
checkFun: isBrowserCheck,
afterHook: null
};
}BrowserPlugin 的安装,执行有自己单独的插件队列,它与分析 Import API 的插件队列属于同级关系,不会干扰 Import API 的分析流程,配置文件中如果没有配置 browserApis ,那么 codeAnalysis 实例不会去安装 BrowserPlugin,也不会去分析 Browser API。
下面是 CodeAnalysis 中关于 BrowserPlugin 插件管理的简化代码,完整代码请参考 lib/analysis.js :
class CodeAnalysis {
constructor(options) {
......
// 私有属性
this._scanSource = options.scanSource; // 扫描源配置信息
this._analysisTarget = options.analysisTarget; // 要分析的目标依赖配置
this._browserApis = options.browserApis || []; // 需要分析的BrowserApi配置
// 公共属性
this.browserQueue = []; // Browser分析插件队列
......
}
// 注册插件
_installPlugins(plugins) {
......
if(this._browserApis.length>0){
this.browserQueue.push(browserPlugin(this)); // install browserPlugin
}
......
}
// 执行Browser分析插件队列中的检测函数
_runBrowserPlugins(tsCompiler, baseNode, depth, apiName, filePath, projectName, httpRepo, line) {
if(this.browserQueue.length>0){
for(let i=0; i<this.browserQueue.length; i++){
const checkFun = this.browserQueue[i].checkFun;
if(checkFun(this, tsCompiler, baseNode, depth, apiName, filePath, projectName, httpRepo, line)){
break;
}
}
}
}
// AST分析
_dealAST(ast, ...) {
......
const that = this;
// 遍历AST
function walk(node) {
......
tsCompiler.forEachChild(node, walk);
// 获取基础分析节点信息
const { baseNode, depth, apiName } = that._checkPropertyAccess(node);
// 执行分析插件
that._runBrowserPlugins(tsCompiler, baseNode, depth, apiName, filePath, projectName, httpRepo, line);
......
}
walk(ast);
......
}
// 扫描文件,分析代码
_scanCode() {
......
this._dealAST();
......
}
// 入口函数
analysis() {
......
// 注册插件
this._installPlugins();
// 扫描分析TS
this._scanCode();
......
}
}小节
这一小节我们学习了如何分析 Browser API,需要大家掌握以下知识点:
- 微前端架构下滥用
Browser API可能会对其它应用或基础框架造成严重影响,因此我们需要分析这类特殊的 API,及早发现和规避潜在风险。 Browser API的调用特征与Import API是一样的。但Browser API不存在显示声明,所以 Symbol 信息比较特别,它指向的声明节点的 pos、end 属性值都远大于代码字符串总长度,我们可以通过这个特征来排除局部同名变量的干扰。BrowserPlugin的安装,执行有单独的插件队列来管理,它与分析 Import API 的插件队列属于同级关系,彼此互不干扰,是否分析Browser API可以通过配置文件来控制。