基於babel的埋點工具簡單實現及思考

語言: CN / TW / HK

相關知識點

  1. 什麼是AST抽象語法樹
    1. 程式的編譯過程
  2. AST的用途
  3. Babel的原理
  4. 個人實現的基於babel的埋點例項及思考

什麼是AST抽象語法樹

程式的編譯過程

什麼是程式的編譯呢?我們都知道,在傳統的編譯語言流程中,程式中的一段程式碼在它被執行之前都會經歷三個步驟,這個步驟的執行過程也就是程式的編譯過程。

  1. 分詞(詞法分析)

詞法分析的過程也就是第一步,我們寫的程式碼本質上就是一串串字串,而詞法分析這個過程則會把這些由字元組成的字串去分解成有意義的程式碼塊。比如:

 let a = 1
 // let   a   =   1
複製程式碼

在這個程式中就會把 let、 a、 =、 1、 拆分開來,對於某些特殊佔位符(如空格)是否需要拆分則會取決於這個佔位符是否有實際的意義。

  1. 解析(語法分析)

語法分析的過程就是將詞法分析後的結果按照一定的規則進行組合,將雜湊的程式碼塊進行關聯並形成一個代表程式語法結構的樹,也被稱為是抽象語法樹(AST)。抽象語法樹是原始碼的抽象語法結構的樹狀表示,樹上的每個節點都表示原始碼中的一種結構,之所以說是抽象的,抽象表示把js程式碼進行了結構化的轉化,轉化為一種資料結構。這種資料結構其實就是一個大的json物件,json我們都熟悉,他就像一顆枝繁葉茂的樹。有樹根,有樹幹,有樹枝,有樹葉,無論多小多大,都是一棵完整的樹。簡單理解,就是把我們寫的程式碼按照一定的規則轉換成一種樹形結構如:

具體的ast內容大家也可以通過這裡自行去輸入檢視,另外,對於這個工具來說 分別有選擇語言以及拆解工具的地方,大家也可以根據自己的語言去選擇相應的環境

  1. 程式碼生成

程式碼生成環節也是編譯過程的最後一節,它會將語法分析階段的AST抽象語法樹轉換為可執行的程式碼,然後在交換個我們。至於生成什麼樣的程式碼,也是可以由我們自己去決定的,理論上符合語言的規則就可以。

AST的用途

在瞭解了什麼是AST之後,關於AST的用途有哪些,想必我們心中都有了一定的答案。AST的作用不僅僅是用來在JavaScript引擎的編譯上,我們在實際的開發過程中也是經常使用的,比如我們常用的babel外掛將 ES6轉化成ES5、使用 UglifyJS來壓縮程式碼 、css前處理器、開發WebPack外掛、Vue-cli前端自動化工具等等,這些底層原理都是基於AST來實現的,AST能力十分強大, 能夠幫助開發者理解JavaScript這門語言的精髓。有了這些,我們可以準確的操控程式碼的執行時以及編譯時的相關處理。

例如:大傻之前在逛GIthub時候偶然發現了關於Vue3.x的issue。 具體情況是這樣的,在使用Vue3.0時候,猛然間發現瞭如果使用jsx寫法會導致有些例如v-once這些不支援,不支援怎麼辦呢?百度谷歌搜起來,搜完後覺得還沒明白就來到了issue,這裡有個思路,因為是jsx語法,所以我們去的肯定不是Vue的issue,肯定是轉換工具的,在這裡我們用的是 babel-plugin-jsx,並且發現瞭如下issue

在一番激烈的狡(交)辯(流)後,大傻輸了,靜下心發現了在程式碼編譯的這兩處(12)並沒有對v-once等一些指令做相應的處理。

這個小例子,也說明了AST扮演的角色,比如我們在某些報錯後懷疑是某個庫或者框架的錯誤,其實也有可能是在編譯階段由於規則不一致或者沒提供暴露的錯誤。如果我們能準確分析出來錯誤原因,那麼媽媽再也不用擔心我亂提issue了。

Babel的原理

隨著前端工程化的興起,讓我們接觸了更多的語言工具,babel在這就是一種特有的工具。

我們通常對babel的理解就是它可以幫助我們去處理相容性,也就是有些JavaScript的新特性,可能我們想去使用,但對於某些瀏覽器來說還並未支援,此時我們就可以通過babel 將我們的程式碼降級處理為瀏覽器相容的執行版本從而達到開發和生產環境兩套程式碼,一次操作的便捷開發。

  • Babel外掛就是作用於抽象語法樹
  • Babel三個主要的處理步驟就是解析(parse)轉換(transform)生成(generate)
  1. 解析

解析就相當於我們的編譯過程中的詞法分析和語法分析的結合版,將程式碼解析成抽象語法樹(AST),每個js引擎(比如Chrome瀏覽器中的V8引擎)都有自己的AST解析器,而Babel是通過Babylon實現的。解析過程有兩個階段:詞法分析和語法分析,詞法分析階段把字串形式的程式碼轉換為令牌(tokens)流,令牌類似於AST中節點;而語法分析階段則會把一個令牌流轉換成 AST的形式,同時這個階段會把令牌中的資訊轉換成AST的表述結構。

  1. 轉換

轉換這個步驟一般來說就是暴漏給我們的處理步驟,將在此階段對節點進行新增、更新以及移除操作。通過traverse進行深度優先遍歷,維護AST樹的整體狀態,並且可完成對其的替換、刪除或者增加節點。返回的結果就是我們處理後的AST。

  1. 生成

生成階段就是將我們二階段的最終AST進行轉換,轉換成我們的字串形式的程式碼,並且建立程式碼對映,也就是source-map。程式碼生成就是,先對整個AST進行深度遍歷,再通過generate轉換為可以表示轉換後代碼的字串。

個人實現的基於babel的埋點例項及思考

我們一般埋點時候都是通過函式的形式,傳入指定引數進而實現埋點,那麼我們在開發過程中如果對需要埋點的地方給一些特殊標識(在這我用的是console.log),那麼當我們程式碼在執行前是不是可以通過工具去批量化的處理這些埋點的地方進而實現統一埋點.整個流程建議大家參考前面的ast生成器網站去邊看邊寫

首先是tacker.js,這個檔案主要就是對我們原始碼生成AST後的AST進行處理,主要兩個方面

  • 在此模組中匯入我們的埋點函式
  • 遍歷查詢我們的標識區域進行替換操作
const { declare } = require('@babel/helper-plugin-utils');
const importModule = require('@babel/helper-module-imports');
const {default: template} = require("@babel/template");

const autoTrackPlugin = declare((api, options, dirname) => {
  api.assertVersion(7); // 表示是版本7

  return {
    visitor: {
      Program: {
        enter (path, state) {
          path.traverse({
            ImportDeclaration (curPath) {
              const requirePath = curPath.get('source').node.value;
              if (requirePath === options.trackerPath) {
                const specifierPath = curPath.get('specifiers.0');
                if (specifierPath.isImportSpecifier()) {
                  state.trackerImportId = specifierPath.toString();
                } else if(specifierPath.isImportNamespaceSpecifier()) {
                  state.trackerImportId = specifierPath.get('local').toString();
                }
                path.stop();
              }
            }
          });
          if (!state.trackerImportId) {
            state.trackerImportId  = importModule.addDefault(path, 'tracker',{
              nameHint: path.scope.generateUid('tracker')
            }).name;
          }
        }
      },
      'ClassMethod|ArrowFunctionExpression|FunctionExpression|FunctionDeclaration'(path, state) //關於這塊知識 大家可以看下官方文件把 比較多 這是為了找符合函式的AST節點{
        const targetCalleeName = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`);
        // TODO 找子節點
        const bodyPath = path.get('body');
        if (bodyPath.isBlockStatement()) {// 先去找塊級的作用域
          const bodyPath2 = bodyPath.get('body.0') // 目前找的是第一個塊級的 body內容
          console.log(bodyPath.get('body').type)
          if(bodyPath2.isExpressionStatement()){// 這個找的是console對應的ast語句 
            const calleeName = bodyPath2.get('expression').get('callee').toString()//
            const bodyPath3 = bodyPath2.get('expression')
              if (targetCalleeName.includes(calleeName)) {
                let arg = []
                bodyPath3.node.arguments.forEach((item,index,array)=>{
                  if(array[0].value==='tracker'){
                  //  如果我們console的第一個值為tracker時候 說明是埋點 否則就是我們普通的一個console.log
                    if(index>0){
                      let ret = item.value || item.name
                      arg.push(ret)
                    }
                  }
                })
                if(arg.length>0){
                  state.trackerAST = template.expression(`${state.trackerImportId}(${arg.join(',')})`)();
                  bodyPath3.remove()// 移除原來的console程式碼
                  bodyPath.node.body.unshift(state.trackerAST);// 插入最新的我們自己的程式碼
                }
              }

          }
        }
      }
    }
  }
});
module.exports = autoTrackPlugin;

複製程式碼

然後是我們的startTracker.js,這個檔案就是我們的入口函式,我們在本地測試時候可以通過 node startTracker.js指令去執行這段程式碼,它的主要作用就是,轉化為AST交給我們tracker函式去處理AST,拿到處理後的AST並且生成新的程式碼

const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const autoTrackPlugin = require('./tracker');
const fs = require('fs');
const path = require('path');

const sourceCode = fs.readFileSync(path.join(__dirname, './code.js'), {
  encoding: 'utf-8'
});

const ast = parser.parse(sourceCode, {
  sourceType: 'unambiguous'
});

const { code } = transformFromAstSync(ast, sourceCode, {
  plugins: [[autoTrackPlugin, {
    trackerPath: 'tracker'
  }]]
  //
  /*
  * 呼叫函式轉化
  * 1 傳入ast 內容
  * 2 傳入map ast錯誤問題對映到map檔案中
  * 3 一個物件 是配置相關
  *   1 傳入plugins 是一個數組  陣列是不同的外掛 也可以用陣列標識
  *     外掛的陣列
  *       第一個是用的外掛
  *       第二個是對這個外掛提供的配置 可以自定義的常量 一併放進接收的options中
  * */
});

console.log(code);
複製程式碼

最後就是我們的測試用的Code程式碼(目前只模擬做了一個塊級作用域的內容,多個塊級作用域並沒有去寫,大家可以看著AST自己完善下)

const obj={
  a:111
}
function a () {
  console.log(obj);
}

class B {
  bb() {
    console.log('tracker',232)
    return 'bbb';
  }
}

const c = () => 'ccc';

const d = function () {
  console.log('tracker','1818',11);
}
複製程式碼

最後 通過執行我們可以看到輸出後的結果以及和原始碼的對比結果.怎麼樣?是不是感覺很有意思. 希望大家在看過文章後有一個初步的瞭解,也可以找一些資料來鞏固下學習下.並做一些自己的小工具來增加印象, 最後祝大家新的一年裡,工作生活都虎虎生威!!! 

 

最後

如果你覺得此文對你有一丁點幫助,點個贊。或者可以加入我的開發交流群:1025263163相互學習,我們會有專業的技術答疑解惑

如果你覺得這篇文章對你有點用的話,麻煩請給我們的開源專案點點star:http://github.crmeb.net/u/defu不勝感激 !

PHP學習手冊:http://doc.crmeb.com
技術交流論壇:http://q.crmeb.com