{T}

Browser API 调用分析

代码文件中的外部依赖,不论是通过 npm 安装的第三方依赖,还是大型项目中基于 Webpack 等构建工具建立起 alias 关系的项目依赖,都是通过 Import 方式引入的。对于通过 Import 引入的 API,分析工具需要对 AST 进行两轮遍历:第一轮是针对代码文件中的 Import 节点进行遍历分析,获取从目标依赖中导出的 API 信息,第二轮针对导入的 API 进行调用判定、用途分析。

举个例子:

typescript
// 示例代码
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

typescript
// 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 应用代码:

typescript
// 封装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 应用代码:

typescript
//创建cookie
function creatCookie(name,value,expiresDay) {
    var oDay = new Date();
    oDay.setDate(oDay.getDate() + expiresDay);
    document.cookie = name + ' = ' + value + '; expires = ' + expiresDay;
}

AB 两个应用各自都封装了 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 的结构及特征:

typescript
// 示例代码
function back(){
    window.history.back();
}
function getDom(){
    return document.getElementById('idxxx');
}

我们发现 Browser APIImport API 的调用特征是一样的,包括链式调用在内,并没有什么不同。所以我们也可以通过判断 Identifier 类型节点的名称与 Browser API 名称是否一致来判定 Browser API 是否被调用,而且因为没有 Import 节点的干扰,我们只需要排除局部同名变量的影响即可。

对于 Import API,我们通过对比 AST 节点的 Symbol 与 Import API 节点的 Symbol 指向是否一致来排除局部同名节点的干扰,但 Browser API 不存在显示声明,所以它的 Symbol 信息比较特别。大家仔细观察一下上面两张图中右下角红框内的 posend 属性值就会发现,它们的值远大于我们整个代码字符串流的 posend 值,即它们的值在代码字符总长度之外。

什么意思呢?上面 AST 对应的字符串总长度为 111,正常 AST 节点 Symbol 指向的声明节点 posend 属性值肯定在 0-111 之间。但 Browser API 因为不存在声明,所以 posend 属性值都是大于 111 的 ,我们可以通过这个特性来排除局部同名变量的干扰。

下面是判定 Browser API 的代码实现,需要注意的一点是,Browser API 因为不存在显示声明,所以不需要针对 Import 节点进行 AST 遍历分析,需要分析哪些 API 直接在配置文件中的 browserApis 配置项中配置即可。

完整代码可以参考 lib/analysis.js

typescript
// 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。

如果按照上面的分析逻辑,那么在实际执行后我们会得到类似下面这样的分析结果(示例):

json
"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.backwindow.history.back 被当成不同的 API 调用被统计了,但这样的结果并不符合我们的预期,我们希望 window.history.back 这种调用只会命中 window 这个 API,而 history.back 这种调用只会命中 history 这个 API。

所以分析逻辑需要区分 window.history 与 history 这两种调用方式,为了避免类似场景对分析结果的影响,我们添加一个过滤逻辑,完整代码如下:

typescript
// 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 :

typescript
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

typescript
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,需要大家掌握以下知识点:

  1. 微前端架构下滥用 Browser API 可能会对其它应用或基础框架造成严重影响,因此我们需要分析这类特殊的 API,及早发现和规避潜在风险。
  2. Browser API 的调用特征与 Import API 是一样的。但 Browser API 不存在显示声明,所以 Symbol 信息比较特别,它指向的声明节点的 pos、end 属性值都远大于代码字符串总长度,我们可以通过这个特征来排除局部同名变量的干扰。
  3. BrowserPlugin 的安装,执行有单独的插件队列来管理,它与分析 Import API 的插件队列属于同级关系,彼此互不干扰,是否分析 Browser API 可以通过配置文件来控制。