js熱更新簡單分析

三日晴發表於2019-04-06

熱更新

當然這裡更多是菜狗子的一家之言,片面不全,居然還有一堆謬誤。歡迎斧正。

前端熱更新

既然說到熱更新,我們不妨擴充套件下,補充下前端自動更新的實現。 個人才疏學淺,見過的方式大致分兩種

  • 直接重新整理介面,大致就是bowersync的方式,直接reload,簡單粗暴,規避了許多問題
  • 增量更新webpack-dev-serverHMR

簡單討論下webpack-hot-middleware究竟是怎麼實現了熱更新。

這裡我們不討論如何替換和覆蓋之前執行的結果

個人理解:其實就是一個簡單的事件機制

服務端

本質就是一個連線往前端發資料

// server 端
...
publish: function(payload) {
  /** 
  * erveryClient 就是對每個連線都寫入。
  * 連結報頭,確保連結不會被斷開
  *   Content-Type: 'text/event-stream;charset=utf-8',
  *   Connection: 'keep-alive', 這個只會在http1開啟
  */
  everyClient(function(client) {
    client.write('data: ' + JSON.stringify(payload) + '\n\n');
  });
},
...

複製程式碼

客戶端

對接受到資料進行處理。當然這裡的資料其實約定過格式。由於這部分程式碼是執行在前端,剩下的就是基礎的dom操作云云了。

/**
* @see https://github.com/webpack-contrib/webpack-hot-middleware/blob/master/client.js
*/
function processMessage(obj) {
  switch (obj.action) {
    case 'building':
      if (options.log) {
        console.log(
          '[HMR] bundle ' +
            (obj.name ? "'" + obj.name + "' " : '') +
            'rebuilding'
        );
      }
      break;
    // ...
    default:
      if (customHandler) {
        customHandler(obj);
      }
  }

  if (subscribeAllHandler) {
    subscribeAllHandler(obj);
  }
}
複製程式碼

之前採用 socket.io(目前webpack-dev-server你就能找到類似實現的方式),現在改用EventSource方式。

檢活機制

順帶一提,裡面實現的一套提高穩定性的機制。

// client
// 每次來資訊更新最新活動時間,定時檢查是否超出超時時間
function handleOnline() {
    if (options.log) console.log('[HMR] connected');
    lastActivity = new Date();
}

function handleMessage(event) {
    lastActivity = new Date();
    for (var i = 0; i < listeners.length; i++) {
      listeners[i](event);
    }
}
var timer = setInterval(function() {
    if (new Date() - lastActivity > options.timeout) {
      handleDisconnect();
    }
}, options.timeout / 2);

// server
// 定時給前端發資訊,用於更新前端的活動時間
...
var interval = setInterval(function heartbeatTick() {
    everyClient(function(client) {
      client.write('data: \uD83D\uDC93\n\n');
    });
}, heartbeat).unref();
 ...
複製程式碼

node 熱更新

簡單討論完前端的熱更新基礎之後,我們來了解下node層面的熱更新。

如何更新模組程式碼

首先,我們遇到第一個問題,如何將程式碼更新到執行中的程式中(emm這種表述怪怪的)

  • 第一步明確我們日出來的程式碼。也就是一個個字串集合(一堆二進位制數啥的就算了。強行字串集合好吧) 把字元編譯執行,我找到如下的法子
    • evel 執行檔案
    • Function建構函式,建立函式
    • vm模組執行程式碼
小小補充

我試驗的時候發現,其實有如下的一個小問題

const vm = require("vm");
var a = 1;
var b = 1;
var c = 1;
d = 1;

vm.runInNewContext(
  `
    console.log('vm',d)
    console.log('vm',typeof a);
    a = 2;
    console.log('vm',a);`,
  global
);
console.log("a", a);
console.log("-------");
eval(`
    console.log('eval',typeof b);
    b = 2;
    console.log('eval',b)
`);
console.log("b", b);

console.log("-------");
const test = Function(
  `
    console.log('function',d)
    console.log('function',typeof c);
    c = 2;
    console.log('function',c)`
);
test();
console.log("c", c);

/**
* 輸出結果
    vm 1
    vm undefined
    vm 2
    a 1
    -------
    eval number
    eval 2
    b 2
    -------
    function 1
    function undefined
    function 2
    c 1
*/
複製程式碼

以上Function執行結果我發現與瀏覽器端並不一致,細究原因: 1. Function只能讀取全域性變數與其內部的變數 2. node載入執行都會包裹一層函式,而我們在瀏覽器中直接在全域性宣告一個變數相當於為當前全域性物件附加一個屬性,值為變數值。這裡看變數d就能比較清楚的知道。

小結

  1. Function 取不到當前作用域的變數,只能獲取全域性變數的,這點會讓我們熱載入的程式碼很難作為一個獨立安全的模組執行(我能想到只有把變數都放在全域性維護。這樣會帶來很多問題)
  2. eval,Function 不容易除錯。用除錯工具無法打斷點除錯,所以麻煩的東西也是不推薦使用。
  3. eval,function執行更慢。這個沒有測試下,其實上述方式都是載入字串,好像都沒有辦法預編譯處理把

JavaScript 為什麼不推薦使用 eval?

node 模組載入方式

回到正題,其實我們都知道關於載入模組,其實node中本身就提供了一個超級好的方法,可以引入一個檔案(js,node等)基於這個我們似乎就能很簡單就可以實現拍黃片那樣的熱更新了~我們不妨先一起看下node中是怎麼實現的require

Talk is cheap ,show me the code. 先扔兩個原始碼地址

node 模組載入與執行

現在我們深入下,看下node是怎麼處理js的模組的,估計我寫的也不咋地,大家可以先看下阮一峰大佬的講解 不過好像他的版本有點點老TAT

node模組載入部分的一些原始碼,簡單講下整個程式的執行,其實就是程式引入初始化一個main module之後,require的時候每個引用檔案在新建module,同時快取,記錄關係,最後形成一個樹形結構。整個過程可以從這裡起步開始看。

/**
* 刪除了debug的一些輸出具體,具體要看可以看下原始碼 
* @link https://github.com/nodejs/node/blob/0817840f775032169ddd70c85ac059f18ffcc81c/lib/internal/modules/cjs/loader.js#L874:33
*/ 
Module._load = function(request, parent, isMain) {
  const filename = Module._resolveFilename(request, parent, isMain);
  /**
  * 會快取一次執行結果,所以每個模組引入只會被執行一次。
  * 如果想再次執行需要清楚掉
  * 這個cache 會被代理到require上
  * 在reqire定義那可以看到:`require.cache = Module._cache;`
  */ 
  const cachedModule = Module._cache[filename];
  if (cachedModule) {
    // 這裡會記錄一次模組的引用關係,對gc回收的引用有影響。
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  const mod = NativeModule.map.get(filename);
  if (mod && mod.canBeRequiredByUsers) {
    return mod.compileForPublicLoader(experimentalModules);
  }

  /*
  * Don't call updateChildren(), Module constructor already does.
  * 上面這個是原註釋。就不翻譯了
  */ 
  const module = new Module(filename, parent);

  if (isMain) {
     // 要專案的引用結構其實可以mainModule,遞迴列印
    process.mainModule = module;
    module.id = '.';
  }
  Module._cache[filename] = module;
  
  /**
  * 有興趣可以看下這個函式
  * 這裡我只大概梳理下載入過程
  * 1. 設定引用可能的資料夾(在module的paths裡你可以很清楚的看到結果)
  *     + 所在目錄
  *     + 所在目錄的node_modules
  *     + /node_modules 目錄
  * 2. 獲取檔案字尾(最後一個“.”開始的內容,tips:以“.”不算哈,會被排除)
  * 3. 根據檔案字尾呼叫預設的函式解析。
  *
  * 其實這個過程會有一堆的嘗試操作。這裡不做過多贅述。
  * 自行檢視'tryPackage,tryFile,tryExtensions'
  * 所以為了效能能好一丟丟,大家可以把引用盡量寫的清晰。
  * 省去程式猜你引用的路徑,還要讀package.json云云
  *****
  *****
  * emmm 這裡其實他還約定了一個experimentalModules 
  * @link https://github.com/nodejs/node-eps/blob/master/002-es-modules.md
  */
  tryModuleLoad(module, filename);

  return module.exports;
};
複製程式碼

這裡在簡單補充下js檔案的解析過程

首先我們的檔案其實都是一些文字資訊,首先會被包裹在一個函式裡。
tips:(在字串前加上 `(function (exports, require, module, __filename, __dirname) { `尾部加上後`\n}`);
然後依據是否修改過包裹方式呼叫`compileFunction` 或者vm模組的`runInThisContext`方法,

emmm這兩個方法都是c++那邊實現了,就不是很看得懂了TAT
@link https://github.com/nodejs/node/blob/5f8ccecaa2e44c4a04db95ccd278a7078c14dd77/src/node_contextify.h。
複製程式碼

emmmm 到這裡我們大概能知道node裡模組是怎麼載入的。大概都忘記標題了吧,回顧下,我們目前要解決的是如何把內容載入程式序。也順帶知道了這些__filename這些常量是怎麼來的。

如何刪除舊的引用

【FBI WARNING】這個其實很麻煩,我的做法一定不會很全很完美,但是做的不好就很容易導致記憶體消耗越來越高

舊的不去新的不來,我們現在已經有法子引來新的,那接著來了解下node中如何去掉舊的。簡單瞭解下gc機制。

我只是菜狗子,看不懂c++原始碼。webkit技術內幕啥的也沒耐著性子看完,只看了一些文章和深入淺出nodejs,以下內容來自於歸納,主要來源於深入nodejs第五章。

裂牆安利【深入淺出nodejs】

  • 在V8中,主要將記憶體分為新生代和老生代兩代,
  • 新生代中的物件為存活時間較短的物件,老生代中的物件為存活時間較長或常駐記憶體的物件。
  • V8堆的整體大小就是新生代所用記憶體空間加上老生代的記憶體空間。不會動態的擴充套件!預設64位系統大概約1.4 GB和32位系統約0.7 GB
  • 通過 --max-old-space-size--max-new-space-size設定最大值
  • 新生代中的物件主要通過Scavenge演算法進行垃圾回收
    • Scavenge演算法主要採用Cheney演算法,採用複製的方式實現的垃圾回收演算法
    • 將堆記憶體分為兩個semispace,一個使用,一個閒置
    • 分配物件時,分配在from中,回收時檢查存活則複製到to,釋放非存活,最後交換空間
  • 在新生代被Scavenge搞過多次(一些文章指出時兩次)仍舊存活,升級進入老生代
  • 老生代用Mark-Sweep與Mark-Compact結合的方式進行回收
    • Mark-Sweep在標記階段遍歷堆中的所有物件,並標記活著的物件,清除 沒有被標記的物件
    • Mark-Compact用於解決前面Mark-Sweep導致的記憶體不連續的問題,物件在標記為死亡後,在整理的 過程中,將活著的物件往一端移動。
  • 因為gc清理必須讓程式暫停下來(否則記憶體和物件對不上,筍乾爆炸),所以其實gc會導致node暫時不執行其他內容,所以大佬們還引入了 Incremental Marking
    • 將原本要一口氣停頓完成 的動作改為增量標記(incremental marking)
    • V8後續還引入了延遲清理(lazy sweeping)與增量式整理(incremental compaction)
  • 其實還有個點在一些文章看到的,如果判斷是否存活
    • 最開始大家把這個命題轉化成是否有引用,但是引入了一個問題,遞迴引用就砸了。也容易有外部引用導致的記憶體洩漏,大概在ie6 7 還能復現
    • 後面改善了一種方式,從根開始往下找,找不到的算是沒有死了。就完美解決上述的問題了。
  • 還有個點,就是大物件,對gc其實時很不友好的,每次要判斷這個物件真的超級累,救救gc孩子吧
小結

經過上面那一串逼逼,我們大致明白了,要刪掉舊的,就刪除引用就玩球。所以我們只需要

  • 每次require之後刪除require.cache上的內容
  • 刪除引用鏈(ps:前面require 原始碼裡updateChildren 記錄的)的引用
  • 自己七七八八的引用就好了
  • 完結撒花

總結

所以針對node熱更新(熱部署)我個人給以下方法

  • 主動
    • 監聽檔案變化(設定介面)總之找個法子把你更新檔案這事偷偷告訴程式,觸發新內容載入
    • 搞死node那些快取機制,刪除require.cache等等,還有主要是你的引用!
  • 被動
    • 搞死node那些快取機制,刪除require.cache等等,還有主要是你的引用!
    • 被呼叫的時候再去require

完結撒花

相關文章