JavaScript—ES6 超程式設計(5)

前端啟航發表於2018-10-22

幾年前 ES6 剛出來的時候接觸過 超程式設計(Metaprogramming)的概念,不過當時還沒有深究。在應用和學習中不斷接觸到這概念,比如 mobx 5 中就用到了 Proxy 重寫了 Observable 物件,覺得有必要梳理總結一下。

本文並非是一篇傳統意義上的教程,總結的是自己學習 ES6 超程式設計相關知識(Symbols & Proxy & Reflect)的理解、教程文件 和 程式碼片段。

1、理解超程式設計 Symbol、Reflect 和 Proxy 是屬於 ES6 超程式設計範疇的,能“介入”的物件底層操作進行的過程中,並加以影響。超程式設計中的 元 的概念可以理解為 程式 本身。

”超程式設計能讓你擁有可以擴充套件程式自身能力“。這句話還是很抽象,初學者該怎麼理解呢?

我也理解了半天,想到了下面的例子:

就好比你原本是公司的部門的大主管,雖然你能力很強,但也必須按照規章制度做事,比如早上 8 點必須到公司,否則你就要扣績效;而後來公司基本規定靈活了,每個部門可以自己制定打卡制度,此時身為主管的你,依據公司該基本規定,制定自己部門的考勤制度,本部門的職工可以 9 點來公司,還可以不打卡!(當然還可以制定其他規定)

在這個例子中:

”整個公司“就相當於 JS 引擎 ”公司的基本規章制度“就相當於 JS 執行機制和語法,員工辦事最低要求就是遵照公司的規章制度 ”在此基礎上,你擁有管理部門的權力,負責開發並維護一些產品“,這種行為就相當於平時普通的程式設計; 公司的基本規定變靈活之後,你除了擁有以前管理員工的權力之外,”還擁有更改制度(針對該部門)的能力,這樣就可以從制度層面影響員工的最低要求行為“,這裡更改規章制度就相當於 超程式設計 了; 這裡的例子不一定準確,是我個人的理解,權做參考,也可以去看看知乎上 怎麼理解超程式設計? 的問答。

藉助這個例子理解超程式設計,我們能感知在沒有超程式設計能力的時候,就算你程式設計能力很厲害,但終究“孫悟空翻不出五指山”;而掌握了超程式設計能力之後,就差上天了,“給你一個支點,你就能撬動地球”,能力大大擴增。

簡言之,超程式設計讓你具備一定程度上改變現有的程式規則層面的能力。或者說,超程式設計可以讓你以某種形式去影響或更改程式執行所依賴的基礎功能,以此獲得一些維護性、效率上的好處。

Javascript 中,eval、new Function()便是兩個可以用來進行超程式設計的特性。不過因為效能和可維護的角度上,這兩個特性還是不要用為妙。

在 ES6 之後,標準引入了 Proxy & Reflect & Symbols,從而提供比較完善的超程式設計能力。

2、學習 ES6 超程式設計的資源 我原本也想仔細講講 ES6 中 Symbol、Proxy 和 Reflect 的基本概念和使用的,但網上這方面的文章不要太多,覺得重複碼字也沒有太必要。這裡著重推薦幾篇,分為教程類和手冊類,通讀完之後應該就掌握差不多了。

超程式設計在 ES6 體現最為突出的是 Proxy 的應用,目前我所找的文章也多偏向 Proxy。

原理教程類:

深入淺出ES6(十二):代理 Proxies:ES6 深入淺出系列,個人推薦認真讀完該文章。本文的作者實現了 ES6 的 Reflect 特性,所以他對 ES6 這兩個特性理解是最為深刻的,他的文章自然要深度閱讀。 ES6 Proxies in Depth:和其他教程相比,該文章篇幅稍微短一些,能較為快速得掌握概念和一些實際應用。 Metaprogramming with proxies:來自 《Exploring ES6》書籍摘選,基礎入門。 Chapter 7: Meta Programming:經典的 《You Don't Know JS》系列文章,深入淺出,文章夠長,需要一些耐心。 Metaprogramming in ES6: Symbols and why they're awesome:本篇就是基於 Symbols、Reflect、Proxy 等實現超程式設計的教程系列教程,內容也足夠詳實。 ES6學習筆記: 代理和反射:非常詳實地整理了 Proxy 和 Reflect 相關的知識點,只是閱讀起來略微枯燥。 應用教程類:

ES6 Features - 10 Use Cases for Proxy:收集了 10 個 proxy 的具體應用場景,具體的程式碼放在 jsProxy 倉庫中 從ES6重新認識JavaScript設計模式(五): 代理模式和Proxy:本文從設計模式上去理解 Proxy 的應用 使用 Javascript 原生的 Proxy 優化應用 :文章涉及到 Proxy 的基本用法、如何使用 Proxy 建立代理模式,以及如何對應用進行優化。 手冊類:

MDN - Proxy:MDN 上的 Proxy 官方文件 MDN - Reflect:MDN 上的 Reflect 官方文件 MDN - 超程式設計:MDN 官方文件教程,介紹了超程式設計的概念,應該算是比較抽象的,當手冊翻翻不錯; ECMAScript 6 入門 - Proxy:阮一峰翻譯的 《ECMAScript 6 入門》 教程 ES6 自定義JavaScript語言行為的 Proxy 物件:算是簡明版的中文版的 API 手冊 在沒充分理解超程式設計之前翻手冊還是挺枯燥的,建議平時使用的時候再從這裡補漏 隨著時間的推移,上面收集的文章可能會顯得陳舊,又有可能出現新的好文章,推薦在搜尋引擎中使用 js Metaprogramming 或者 es6 proxy 進行搜尋相關文章;

3、程式碼片段 下面摘抄一些程式碼片段,方便自己後續在應用 JS 超程式設計的時候快速 "借鑑"。你們如果也有覺得不錯的程式碼片段,歡迎在 issue 中回覆,我將不定期更新到這兒。

目錄 Schema 校驗 自動填充物件 進位制轉換 快取代理 實現私有屬性 函式節流 圖片懶載入 監聽屬性更改 實現單例模式 Python 那樣擷取陣列

Schema 校驗 ↑ 示例來自 ES6 Proxies in Depth 場景:person 是一個普通物件,包含一個 age 屬性,當我們給它賦值的時候確保是大於零的數值,否則賦值失敗並丟擲異常。

var person = { age: 27 };

複製程式碼

思路:通過設定 set trap,其中包含了對 age 欄位的校驗邏輯。

程式碼:


var validator = {
  set (target, key, value) {
    if (key === 'age') {
      if (typeof value !== 'number' || Number.isNaN(value)) {
        throw new TypeError('Age must be a number')
      }
      if (value <= 0) {
        throw new TypeError('Age must be a positive number')
      }
    }
    return true
  }
}
var proxy = new Proxy(person, validator)
proxy.age = 'foo'
// <- TypeError: Age must be a number
proxy.age = NaN
// <- TypeError: Age must be a number
proxy.age = 0
// <- TypeError: Age must be a positive number
proxy.age = 28
console.log(person.age)
// <- 28
複製程式碼

自動填充物件 ↑

場景:建立一個Tree()函式來實現以下特性,當我們需要時,所有中間物件 branch1、branch2 和 branch3 都可以自動建立。


var tree = Tree();
tree
//    { }
tree.branch1.branch2.twig = "green";
// { branch1: { branch2: { twig: "green" } } }

tree.branch1.branch3.twig = "yellow";

// { branch1: { branch2: { twig: "green" },
//                 branch3: { twig: "yellow" }}}
複製程式碼

思路:Tree 返回的就是一個 proxy 例項,通過 get trap ,當不存在屬性的時候自動建立一個子樹。

程式碼:


  function Tree() {
    return new Proxy({}, handler);
  }
  var handler = {
    get: function (target, key, receiver) {
      if (!(key in target)) {
        target[key] = Tree();  // 自動建立一個子樹
      }
      return Reflect.get(target, key, receiver);
    }
  };
複製程式碼

進位制轉換 ↑ 場景:比如將 2 進位制轉換成 16 進位制或者 8 進位制,反之也能轉換。

思路:由於大部分的功能是相同的,我們通過函式名字將變數提取出來,然後通過 get trap 完成進位制轉換。

程式碼:


const baseConvertor = new Proxy({}, {
  get: function baseConvert(object, methodName) {
    var methodParts = methodName.match(/base(\d+)toBase(\d+)/);
    var fromBase = methodParts && methodParts[1];
    var toBase = methodParts && methodParts[2];
    if (!methodParts || fromBase > 36 || toBase > 36 || fromBase < 2 || toBase < 2) {
      throw new Error('TypeError: baseConvertor' + methodName + ' is not a function');
    }
    return function (fromString) {
      return parseInt(fromString, fromBase).toString(toBase);
    }
  }
});

baseConvertor.base16toBase2('deadbeef') === '11011110101011011011111011101111';
baseConvertor.base2toBase16('11011110101011011011111011101111') === 'deadbeef';
複製程式碼

快取代理 ↑

場景:以沒有經過任何優化的計算斐波那契數列的函式來假設為開銷很大的方法,這種遞迴呼叫在計算 40 以上的斐波那契項時就能明顯的感到延遲感。希望通過快取來改善。

const getFib = (number) => {
  if (number <= 2) {
    return 1;
  } else {
    return getFib(number - 1) + getFib(number - 2);
  }
}
複製程式碼

注:這只是演示快取的寫法,遞迴呼叫本身就有問題,容易導致記憶體洩露,在實際應用中需要改寫上述的 getFib 函式。

思路:因為是函式呼叫,所以需使用 apply trap,利用 Map 或者普通物件儲存每次計算的結果,在執行運算前先去 Map 查詢計算值是否被快取。(相當於以空間換時間,獲得效能提升)

程式碼:


const getCacheProxy = (fn, cache = new Map()) => {
  return new Proxy(fn, {
    apply(target, context, args) {
      const argsString = args.join(' ');
      if (cache.has(argsString)) {
        // 如果有快取,直接返回快取資料
        console.log(`輸出${args}的快取結果: ${cache.get(argsString)}`);
        
        return cache.get(argsString);
      }
      const result = Reflect.apply(target, undefined, args);
      cache.set(argsString, result);

      return result;
    }
  })
}

const getFibProxy = getCacheProxy(getFib);
getFibProxy(40); // 102334155
getFibProxy(40); // 輸出40的快取結果: 102334155
複製程式碼

在實際應用中資料量越大、計算過程越複雜,優化效果越好,否則有可能會得不償失。

實現私有屬性 ↑ 場景:眾所周知,JavaScript是沒有私有屬性這一個概念的,私有屬性一般是以 _ 下劃線開頭,請通過 Proxy 限制以 _ 開頭的屬性的訪問。

const myObj = {
  public: 'hello',
  _private: 'secret',
  method: function () {
    console.log(this._private);
  }
},
複製程式碼

思路:看上去比較簡單,貌似使用 get、set 這兩個 trap 就可以,但實際上並不是。實際上還需要實現 has, ownKeys , getOwnPropertyDescriptor 這些 trap,這樣就能最大限度的限制私有屬性的訪問。

程式碼:

function getPrivateProps(obj, filterFunc) {
  return new Proxy(obj, {
    get(obj, prop) {
      if (!filterFunc(prop)) {
        let value = Reflect.get(obj, prop);
        // 如果是方法, 將this指向修改原物件
        if (typeof value === 'function') {
          value = value.bind(obj);
        }
        return value;
      }
    },
    set(obj, prop, value) {
      if (filterFunc(prop)) {
        throw new TypeError(`Can't set property "${prop}"`);
      }
      return Reflect.set(obj, prop, value);
    },
    has(obj, prop) {
      return filterFunc(prop) ? false : Reflect.has(obj, prop);
    },
    ownKeys(obj) {
      return Reflect.ownKeys(obj).filter(prop => !filterFunc(prop));
    },
    getOwnPropertyDescriptor(obj, prop) {
      return filterFunc(prop) ? undefined : Reflect.getOwnPropertyDescriptor(obj, prop);
    }
  });
}

function propFilter(prop) {
  return prop.indexOf('_') === 0;
}

myProxy = getPrivateProps(myObj, propFilter);

console.log(JSON.stringify(myProxy)); // {"public":"hello"}
console.log(myProxy._private); // undefined
console.log('_private' in myProxy); // false
console.log(Object.keys(myProxy)); // ["public", "method"]
for (let prop in myProxy) { console.log(prop); }    // public  method
myProxy._private = 1; // Uncaught TypeError: Can't set property "_private"
複製程式碼

注意:其中在 get 方法的內部,我們有個判斷,如果訪問的是物件方法使將 this 指向被代理物件,這是在使用 Proxy 需要十分注意的,如果不這麼做方法內部的 this 會指向 Proxy 代理。

一般來講, set trap 都會預設觸發 getOwnPropertyDescriptor 和 defineProperty

函式節流 ↑

場景:控制函式呼叫的頻率.

const handler = () => console.log('Do something...');
document.addEventListener('scroll', handler);
複製程式碼

思路:涉及到函式的呼叫,所以使用 apply trap 即可。

程式碼:

const createThrottleProxy = (fn, rate) => {
  let lastClick = Date.now() - rate;
  return new Proxy(fn, {
    apply(target, context, args) {
      if (Date.now() - lastClick >= rate) {
        fn.bind(target)(args);
        lastClick = Date.now();
      }
    }
  });
};

const handler = () => console.log('Do something...');
const handlerProxy = createThrottleProxy(handler, 1000);
document.addEventListener('scroll', handlerProxy);
複製程式碼

同樣需要注意使用 bind 繫結上下文,不過這裡的示例使用了箭頭函式,不用 bind 也沒啥問題。

圖片懶載入 ↑

場景:為了更好的使用者體驗,在載入圖片的時候,使用 loading 佔點陣圖,等真正圖片載入完畢之後再顯示出來。原始的寫法如下:

const img = new Image();
img.src = '/some/big/size/image.jpg';
document.body.appendChild(img);
複製程式碼

思路:載入圖片的時候,會讀取 img.src 屬性,我們使用 constructor trap 控制在建立的時候預設使用 loading 圖,等載入完畢再將真實地址賦給 img;

程式碼:


 const IMG_LOAD = 'https://img.alicdn.com/tfs/TB11rDdclLoK1RjSZFuXXXn0XXa-300-300.png';

  const imageProxy = (loadingImg) => {
      return new Proxy(Image, {
          construct(target, args){
              const instance = Reflect.construct(target, args);
              instance.src = loadingImg;
              return instance;
          }
      });
  };

  const ImageProxy = imageProxy(IMG_LOAD);

  const createImageProxy = (realImg) =>{
      const img = new ImageProxy();
      const virtualImg = new Image();
      virtualImg.src = realImg;
      virtualImg.onload = () => {
          hasLoaded = true;
          img.src = realImg;
      };
      return img;
  }
  var img = createImageProxy('https://cdn.dribbble.com/users/329207/screenshots/5289734/bemocs_db_dribbble_03_gold_leaf.jpg');
  document.body.appendChild(img);
複製程式碼

監聽屬性更改 ↑

場景:當普通物件屬性更改後,觸發所繫結的 onChange 回撥;

思路:能更改屬性的有 set 和 deleteProperty 這兩個 trap,在其中呼叫 onChange 方法即可


function trackChange(obj, onChange) {
    const handler = {
        set (obj, prop, value) {
            const oldVal = obj[prop];
            Reflect.set(obj, prop, value);
            onChange(obj, prop, oldVal, value);
        },
        deleteProperty (obj, prop) {
            const oldVal = obj[prop];
            Reflect.deleteProperty(obj, prop);
            onChange(obj, prop, oldVal, undefined);
        }
    };
    return new Proxy(obj, handler);
}

// 應用在物件上
let myObj = trackChange({a: 1, b: 2}, function (obj, prop, oldVal, newVal) {
    console.log(`myObj.${prop} changed from ${oldVal} to ${newVal}`);
});

myObj.a = 5;     // myObj.a changed from 1 to 5
delete myObj.b;  // myObj.b changed from 2 to undefined
myObj.c = 6;     // myObj.c changed from undefined to 6

// 應用在陣列上
let myArr = trackChange([1,2,3], function (obj, prop, oldVal, newVal) {
    let propFormat = isNaN(parseInt(prop)) ? `.${prop}` : `[${prop}]`,
        arraySum = myArr.reduce((a,b) => a + b);
    console.log(`myArr${propFormat} changed from ${oldVal} to ${newVal}`);
    console.log(`  sum [${myArr}] = ${arraySum}`);
});

myArr[0] = 4;      // myArr[0] changed from 1 to 4         
                   //   sum [4,2,3] = 9
delete myArr[2];   // myArr[2] changed from 3 to undefined                
                   //   sum [4,2,] = 6
myArr.length = 1;  // myArr.length changed from 3 to 1                
                   //   sum [4] = 4
複製程式碼

實現單例模式 ↑

場景:實現單例設計模式;

思路:和建立有關的,是 construct 這個 trap,每次我們返回相同的例項即可。

程式碼:


// makes a singleton proxy for a constructor function
function makeSingleton(func) {
    let instance,
        handler = {
            construct: function (target, args) {
                if (!instance) {
                    instance = new func();
                }
                return instance;
            }
        };
    return new Proxy(func, handler);
}


// 以這個為 constructor 為例
function Test() {
    this.value = 0;
}

// 普通建立例項
const t1 = new Test(),
    t2 = new Test();
t1.value = 123;
console.log('Normal:', t2.value);  // 0 - 因為 t1、t2 是不同的例項

// 使用 Proxy 來 trap 建構函式, 完成單例模式
const TestSingleton = makeSingleton(Test),
    s1 = new TestSingleton(),
    s2 = new TestSingleton();
s1.value = 123;
console.log('Singleton:', s2.value);  // 123 - 現在 s1、s2 是相同的例項。
複製程式碼

像 Python 那樣擷取陣列 ↑

場景:在 python 中,你可以使用 list[10:20:3] 來獲取 10 到 20 索性中每隔 3 個的元素組成的陣列(也支援負數索引)。

思路:由於在 JS 中,陣列方括號語法中不支援冒號,只能曲線救國,使用這樣 list["10:20:3"] 的形式。只需要實現 get trap 即可。


// Python-like array slicing

function pythonIndex(array) {

    function parse(value, defaultValue, resolveNegative) {
        if (value === undefined || isNaN(value)) {
            value = defaultValue;
        } else if (resolveNegative && value < 0) {
            value += array.length;
        }
        return value;
    }
    
    function slice(prop) {
        if (typeof prop === 'string' && prop.match(/^[+-\d:]+$/)) {
            // no ':', return a single item
            if (prop.indexOf(':') === -1) {
                let index = parse(parseInt(prop, 10), 0, true);
                console.log(prop, '\t\t', array[index]);
                return array[index];
            }                
            // otherwise: parse the slice string
            let [start, end, step] = prop.split(':').map(part => parseInt(part, 10));
            step = parse(step, 1, false);
            if (step === 0) {
                throw new RangeError('Step can\'t be zero');
            }
            if (step > 0) {
                start = parse(start, 0, true);
                end = parse(end, array.length, true);
            } else {
                start = parse(start, array.length - 1, true);
                end = parse(end, -1, true);
            }
            // slicing
            let result = [];
            for (let i = start; start <= end ? i < end : i > end; i += step) {
                result.push(array[i]);
            }
            console.log(prop, '\t', JSON.stringify(result));
            return result;
        }
    }

    const handler = {
        get (arr, prop) {
            return slice(prop) || Reflect.get(array, prop);
        }
    };
    return new Proxy(array, handler);
}


// try it out
let values = [0,1,2,3,4,5,6,7,8,9],
    pyValues = pythonIndex(values);

console.log(JSON.stringify(values));

pyValues['-1'];      // 9
pyValues['0:3'];     // [0,1,2]    
pyValues['8:5:-1'];  // [8,7,6]
pyValues['-8::-1'];  // [2,1,0]
pyValues['::-1'];    // [9,8,7,6,5,4,3,2,1,0]
pyValues['4::2'];    // [4,6,8]

// 不影響正常的索引
pyValues[3];         // 3
複製程式碼

這裡推薦一下我的前端學習交流群:784783012,裡面都是學習前端的,如果你想製作酷炫的網頁,想學習程式設計。自己整理了一份2018最全面前端學習資料,從最基礎的HTML+CSS+JS【炫酷特效,遊戲,外掛封裝,設計模式】到移動端HTML5的專案實戰的學習資料都有整理,送給每一位前端小夥伴,有想學習web前端的,或是轉行,或是大學生,還有工作中想提升自己能力的,正在學習的小夥伴歡迎加入學習。

相關文章