使用 babel 外掛來打造真正的“私有”屬性

語言: CN / TW / HK

大家都知道 JavaScript 的物件屬性預設是可以被從外部訪問和修改的,也就是說,JavaScript 本身不存在完全“私有”的物件屬性。例如:

class Point{
    constructor(x, y){
        this._x = x;
        this._y = y;
    }
    get length(){
        const {_x, _y} = this;
        return Math.sqrt(_x * _x + _y * _y);
    }
}

let p = new Point(3, 4);
console.log(p._x, p._y, p.length); //3, 4, 5
複製程式碼

在上面的程式碼裡,我們約定俗成地用下劃線開頭來表示私有變數。我們希望 _x、_y 不被外部訪問,然而,這只是我們一廂情願,使用者還是可以訪問到這兩個變數。

在這裡,我們不討論 ES 的 private 標準提案,而是討論如何使用工具來將約定變成真正的私有。

使用 Symbol 來構造私有資料

ES6 提供了一個新的資料型別叫做 Symbol,Symbol 有許多用途,其中一個用途是可以用來生成唯一 key,用作屬性標識,我們利用它可以實現真正的私有屬性:

const [_x, _y] = [Symbol('_x'), Symbol('_y')];
class Point{
    constructor(x, y){
        this[_x] = x;
        this[_y] = y;
    }
    get length(){
        const x = this[_x], 
              y = this[_y];

        return Math.sqrt(x * x + y * y);
    }
}

let p = new Point(3, 4);
console.log(p._x, p._y, p.length); //undefined, undefined, 5
複製程式碼

我們改寫上一版的程式碼,用 Symbol 的 _x、_y 代替字串來作為 key,這樣,外部 p 訪問 _x、_y 屬性就訪問不到了,這樣我們就真正實現了物件資料的私有。

上面這種用法並不複雜,但是,如果我們每次定義物件都這麼去寫還是顯得麻煩。因此,我們可以考慮讓編譯器去做這件事情,自動將下劃線開頭的屬性編譯成私有屬性。

使用 Babel 外掛來實現屬性的預設私有

在這裡,我們可以開發 Babel 的外掛來實現。Babel 的原理在部落格之前的文章中有介紹。還有使用 Babel 外掛來進行測試覆蓋度檢查的例子。如果對於 Babel 不熟悉的同學,可以回顧一下之前的文章。

首先,我們分析一下要處理的 AST 部分。ES6 的 class 有兩種 node 型別,一種是 ClassDeclaration,另一種是 ClassExpression。它們比較類似,但是在一些細節上又有區別。比如 ReturnStatement 之後可以跟 ClassExpression 但是不能跟 ClassDeclaration。

ClassDeclaration 與 ClassExpression

//ClassDeclaration
class Foo{
 //...
}

//classExpression
const Bar = class MyClass{
  //...
}
複製程式碼

對這兩種 node,如果其中有下劃線開頭的屬性,可以分別編譯成如下形式:

const Foo = function(){
  [...fields] = [...Symbol(...)]
  class Foo {
    //...
  }
  return Foo;
}();

const Bar = function(){
  [...fields] = [...Symbol(...)]
  return class MyClass{
    //...
  }
}();
複製程式碼

此外,還需要考慮 ES Modules 的情況:

export class Foo{
  //...
}
複製程式碼

對應為:

export const Foo = function(){
  //...
}();
複製程式碼

上面的形式沒有問題。但是如果:

export default class Foo{
  //...
}
複製程式碼

對應為:

export default const Foo = function(){
  //...
}();
複製程式碼

編譯會報錯。因此要進行修改,對應成:

const Foo = function(){
  //...
}();
export default Foo;
複製程式碼

由於 Class 允許存在巢狀,因此,我們需要使用堆疊結構,在 AST 的 enter 的時候建立存放當前 Class 的 scope 下的私有屬性列表。堆疊還有一個作用,就是如果堆疊為空,那麼當前作用域不在 Class 內部,不用進行編譯轉換。

    ClassDeclaration: {
      exit(path){
        let expr = transformWrapClass(path.node);
        if(!expr) return;

        if(path.parentPath.node.type === 'ExportDefaultDeclaration'){
          //處理 export default 的特殊情況
          path.parentPath.insertAfter(t.exportDefaultDeclaration(
            t.identifier(path.node.id.name)
          ));
          path.parentPath.replaceWith(expr);
        }else{
          //替換掉當前 path
          path.replaceWith(expr);
        }

        path.skip();
      },
      enter(path, state){
           //建立存放私有變數識別符號的堆疊
        stack.push({
          variables: new Set()
        });
      }
    },
    ClassExpression: {
      exit(path){
        let expr = transformWrapClass(path.node);
        if(!expr) return;

        //ClassExpression 可以直接 export default
        path.replaceWith(expr);

        path.skip();
      },      
      enter(path, state){
        stack.push({
          variables: new Set()
        });
      }    
    }
複製程式碼

接下來,我們處理具體的 Identifier:

    Identifier(path, state) {
      if(stack.length <= 0) return; //不在 class 作用域內,直接返回 
      if(/^__.*__$/.test(path.node.name)) return; //系統保留屬性,比如 __proto__

      let node = path.node,
          parentNode = path.parentPath.node,
          meta = stack[stack.length - 1];

      let regExp = new RegExp(state.opts.pattern || '^_');

      //給屬性名增加字首字尾,避免內部使用時出現重名
      //比如應當允許 let _x = this._x;
      let symbolName = '$' + node.name + '$'; 

      if(parentNode 
         && parentNode.type === 'MemberExpression' 
         && parentNode.object.type === 'ThisExpression'
         && !parentNode.computed
         && regExp.test(node.name)){ //private

        //對於私有屬性的讀寫 this._x,直接替換成 this[_x]
        //並且記錄下當前變數識別符號,新增到棧頂的 Set 中去
        node.name = symbolName;
        meta.variables.add(node.name);
        parentNode.computed = true;
      }else if(parentNode 
         && parentNode.type === 'MemberExpression' 
         && parentNode.object.type === 'Super'
         && !parentNode.computed
         && regExp.test(node.name)){

        //使用 super._x 訪問父元素的屬性,進行一個變換
        node.name = symbolName;
        parentNode.computed = true;
        let expr = transformPropertyToSymbol(node.name);
        path.replaceWith(expr);
        path.skip();
      }else if(parentNode 
         && parentNode.type === 'ClassMethod' 
         && regExp.test(node.name)){

        //處理 class 的方法和 getter、setter 名帶下劃線的情況。
        node.name = symbolName;
        meta.variables.add(node.name);
        parentNode.computed = true;
      }
    },
複製程式碼

Protected 的屬性和 super._x 操作

對於物件方法帶下劃線的情況,和 this 帶下劃線不同,我們是可以使用 super.屬性名 來訪問的。比如:

class Foo{
    constructor(x) {
        this._x = x;
    }
    //這是一個 protected 的屬性,在派生類中可以通過 super._X 訪問
    get _X(){
        return this._x;
    }
}

class Bar extends Foo{
  constructor(x, y){
      super(x);
      this._y = y;
  }
  get XY(){
      return [super._X, this._y];
  }
}

let bar = new Bar(3, 4);
console.log(bar.XY); //[3, 4]
複製程式碼

在這裡,我們需要對 super._X 進行處理,如果直接編譯:

const Foo = function(){
    const [$_x$, $_X$] = [Symbol('$_x$'), Symbol('$_X$')];

    class Foo{
        constructor(x) {
            this[$_x$] = x;
        }
        //這是一個 protected 的屬性,在派生類中可以通過 super._X 訪問
        get [$_X$](){
            return this[$_x$];
        }
    }
    return Foo;
}();

const Bar = function(){
    const [$_y$, $_X$] = [Symbol('$_y$'), Symbol('$_X$')];
    class Bar extends Foo{
      constructor(x, y){
          super(x);
          this[$_y$] = y;
      }
      get XY(){
          return [super[$_X$], this[$_y$]];
      }
    }
    return Bar;
}();

let bar = new Bar(3, 4);
console.log(bar.XY); //[undefined, 4]
複製程式碼

由於每個 Symbol 都是唯一的,所以 Bar 的 Symbol('X_X') 和 Foo 的並不相同,這樣也就獲取不到 super[X_X] 實際的值了。

因此,在這裡,我們編譯的時候不能直接這樣轉成 Symbol,而是要通過反射機制去處理:

const Foo = function(){
    const [$_x$, $_X$] = [Symbol('$_x$'), Symbol('$_X$')];

    class Foo{
        constructor(x) {
            this[$_x$] = x;
        }
        //這是一個 protected 的屬性,在派生類中可以通過 super._X 訪問
        get [$_X$](){
            return this[$_x$];
        }
    }
    return Foo;
}();

const Bar = function(){
    const [$_y$] = [Symbol('$_y$')];
    class Bar extends Foo{
      constructor(x, y){
          super(x);
          this[$_y$] = y;
      }
      get XY(){
          return [super[Object.getOwnPropertySymbols(this.__proto__.__proto__).filter(s => String(s) === "Symbol($_X$)")[0]], this[$_y$]];
      }
    }
    return Bar;
}();

let bar = new Bar(3, 4);
console.log(bar.XY); //[3, 4]
複製程式碼

上面的 super 裡的 key 有一大串,是:

Object.getOwnPropertySymbols(this.__proto__.__proto__)
    .filter(s => String(s) === "Symbol($_X$)")[0]
複製程式碼

這裡通過 Object.getOwnPropertySymbols(this.__proto__.__proto__) 反射出父類的 Symbol,然後通過字串匹配到對應的 key。

於是,我們確定了轉換方法,那麼接下來就只是實現具體的轉換細節了:

  function transformCreateSymbols(){
    let meta = stack.pop(),
        variableNames = Array.from(meta.variables);

    //no private variables
    if(variableNames.length <= 0) return;

    let identifiers = variableNames.map(id => t.identifier(id));

    let pattern = t.arrayPattern(identifiers);

    let symbols = variableNames.map(id =>  
      t.callExpression(t.identifier('Symbol'), [t.stringLiteral(id)]));

    symbols = t.arrayExpression(symbols);

    return t.variableDeclaration(
      'const',
      [t.variableDeclarator(pattern, symbols)]
    );  
  }

  function transformWrapClass(cls){
    let symbols = transformCreateSymbols();
    if(!symbols) return;

    if(cls.type === 'ClassDeclaration'){
      let expr = t.callExpression(
        t.functionExpression(null, [], 
          t.blockStatement(
            [symbols,
             cls,
             t.returnStatement(
               t.identifier(cls.id.name)
             )]
          )
        ), []
      );

      return t.variableDeclaration(
        'const',
        [t.variableDeclarator(
          t.identifier(cls.id.name),
          expr
        )]
      );
    }else if(cls.type === 'ClassExpression'){
      return t.callExpression(
        t.functionExpression(null, [], 
          t.blockStatement(
            [symbols,
             t.returnStatement(
               cls
             )]
          )
        ), []
      );
    }
  }
複製程式碼

上面的方法將 ClassDeclaration 和 ClassExpression 處理完成。接下來是處理 super 屬性的部分:

  function transformPropertyToSymbol(name){
    let expr = t.callExpression(
      t.memberExpression(
        t.identifier('Object'),
        t.identifier('getOwnPropertySymbols')
      ), [
        t.memberExpression(
          t.memberExpression(
            t.thisExpression(),
            t.identifier('__proto__')
          ),
          t.identifier('__proto__')
        )
      ]
    );

    expr = t.callExpression(
      t.memberExpression(
        expr,
        t.identifier('filter')
      ),
      [
        t.arrowFunctionExpression(
          [t.identifier('s')],
          t.binaryExpression(
            '===',
            t.callExpression(
              t.identifier('String'),
              [t.identifier('s')]
            ),
            t.stringLiteral(`Symbol(${name})`)
          )
        )
      ]
    );

    expr = t.memberExpression(
      expr,
      t.numericLiteral(0),
      true
    );

    return expr;
  }
複製程式碼

上面程式碼雖然繁瑣,但都並不複雜,只是 AST 樹的構建而已。最終,我們形成完整的外掛程式碼。有興趣的同學可以關注這個 GitHub repo

要使用的話,直接安裝:

npm i babel-plugin-transform-private --save-dev
複製程式碼

然後配置一下:

{
  "plugins": [
    ["transform-private", {
      "pattern": "^_"
    }],
  ]
}
複製程式碼

其中配置的 pattern 引數可以修改私有變數的匹配正則表示式,預設是 `"^_" 也就是以下劃線開頭,可以改成別的模式。

以上就是今天的全部內容,程式碼比較多,但是關鍵點就這些,其他就是構建 AST 樹的過程。如有任何問題,歡迎討論。