Garfish 微前端實現原理

語言: CN / TW / HK

近期有落地一些微前端業務場景,也遇到一些問題,看了下他們的實現發現目前無論是garfish還是qiankun對於這一塊的實現都在不斷的完善中,但是qiankun我也看了一下他們的實現,在一些case的處理上較garfish存在一定不足。所以本次是針對garfish的實現分析。下面會從資源載入入口,資源解析,沙箱環境,程式碼執行四大塊進行分析,瞭解微前端的主要實現邏輯。

下文中對比qiankun的版本為2.4.0,如有不正確的還請評論指正。

文中涉及大量的程式碼分析,希望能夠從實現層面更加直接的看出實現的邏輯,而不是通過幾張圖來解釋概念。

如何解析資源(html入口)

獲取資源內容

根據提供的url作為入口檔案載入資源。載入的實現很簡單,通過fetch拿到資源內容,如果是html資源入口會進行標籤的序列化和相關處理,這個後面會看到。如果是js檔案則會直接例項化一個js資源類,目的是儲存載入到資源的型別,大小,程式碼字串等基本資訊。並會嘗試快取載入的資源。

下面是載入各類資源的實現,比如獲取html檔案、js檔案、css檔案。在整個流程中,這個方法會被多次使用來載入資源。

``` // 載入任意的資源,但是都是會轉為 string

load(url: string, config?: RequestInit) {

// 移除了部分程式碼只保留說明性的部分

  config = { mode: 'cors', ...config, ...requestConfig };

  this.loadings[url] = fetch(url, config)

    .then((res) => {

      // 響應碼大於 400 的當做錯誤

      if (res.status >= 400) {

        error(`load failed with status "${res.status}"`);

      }

      const type = res.headers.get('content-type');

      return res.text().then((code) => ({ code, type, res }));

    })

    .then(({ code, type, res }) => {

      let manager;

      const blob = new Blob([code]);

      const size = Number(blob.size);

      const ft = parseContentType(type);

      // 對載入的資源進行分類處理

      // 下方new 的幾個例項的目的都是儲存程式碼塊字串和資源型別等一些基本資訊

      if (isJs(ft) || /.js/.test(res.url)) {

        manager = new JsResource({ url, code, size, attributes: [] });

      } else if (isHtml(ft) || /.html/.test(res.url)) {

        manager = new HtmlResource({ url, code, size });

      } else if (isCss(ft) || /.css/.test(res.url)) {

        manager = new CssResource({ url, code, size });

      } else {

        error(`Invalid resource type "${type}"`);

      }



    // 所有的請求會存在一個promise map來維護,載入完成後清空

      this.loadings[url] = null;

      currentSize += isNaN(size) ? 0 : size;

      if (!isOverCapacity(currentSize) || this.forceCaches.has(url)) {

        // 嘗試快取載入的資源

        this.caches[url] = manager;

      }

      return manager;

    })

    .catch((e) => {

      const message = e instanceof Error ? e.message : String(e);

      error(`${message}, url: "${url}"`);

    });

  return this.loadings[url];

}

} ```

在html入口被載入的時候,這個方法便幫助我們獲取到了入口html檔案內容,接下載需要解析這個html檔案。

序列化DOM樹

因為html入口比較特殊,下面單獨對這部分進行分析。如何解析並處理html檔案的呢。首先我們在上一步獲得了資源的檔案內容。下一步是對載入的html資源進行ast解析,結構化dom,以便提取不同型別的標籤內容。這裡使用到了 himalaya 這個輔助庫。線上嘗試地址https://jew.ski/himalaya/, 解析內容格式如下,將dom文字解析文json結構。

結構化後進行深度優先遍歷把link,style,script標籤提取出來

``` // 呼叫方式

this.queryVNodesByTagNames(['link', 'style', 'script'])

// 具體實現

// 實現程式碼擷取 其中this.ast就是上面演示的parse的結果

private queryVNodesByTagNames(tagNames: Array) {

const res: Record<string, Array<VNode>> = {};

for (const tagName of tagNames) {

  res[tagName] = [];

}

const traverse = (vnode: VNode | VText) => {

  if (vnode.type === 'element') {

    const { tagName, children } = vnode;

    if (tagNames.indexOf(tagName) > -1) {

      res[tagName].push(vnode);

    }

    children.forEach((vnode) => traverse(vnode));

  }

};

this.ast.forEach((vnode) => traverse(vnode));

return res;

} ```

由於當前各個框架的實現基本都是有js生成dom並掛載到指定的元素上,因此這裡只要把這三種載入資源的標籤提取出來基本就完成了頁面的載入。當然還需要配合微前端的載入方式改造下子系統入口,讓掛載函式指向主應用提供的dom。至此我們完成了基本資源的提取。

構建執行環境

接下來就是例項化當前子應用了。我們需要子應用的執行時獨立的環境不影響主應用的程式碼。因此子應用需要在指定的沙箱內執行,這也是微前端實現的核心部分。首先看下例項化子應用的程式碼

``` // 每個子引用都會通過這個方法來例項化

private createApp(

appInfo: AppInfo,

opts: LoadAppOptions,

manager: HtmlResource,

isHtmlMode: boolean,

) {

const run = (resources: ResourceModules) => {

  // 這裡是獲取沙箱環境

  let AppCtor = opts.sandbox.snapshot ? SnapshotApp : App;

  if (!window.Proxy) {

    warn(

      'Since proxy is not supported, the sandbox is downgraded to snapshot sandbox',

    );

    AppCtor = SnapshotApp;

  }

  // 將app在沙箱內例項化以保證獨立執行

  const app = new AppCtor(

    this.context,

    appInfo,

    opts,

    manager,

    resources, // 提供的html入口

    isHtmlMode,

  );

  this.context.emit(CREATE_APP, app);

  return app;

};



// 如果是 html, 就需要載入用到的資源

const mjs = Promise.all(this.takeJsResources(manager as HtmlResource));

const mlink = Promise.all(this.takeLinkResources(manager as HtmlResource));

return Promise.all([mjs, mlink]).then(([js, link]) => run({ js, link }));

} ```

這裡只需要大致看一下一個子應用的大致建立和載入流程,基本就是一個上下文,一些資源資訊。具體細節後續可以看看原始碼串下整體流程。接下來看下應用的執行上下文——沙箱的實現

程式碼的執行

在獲取資源內容一節我們已經對script資源的獲取進行了解析。但是這個部分程式碼具體是如何在沙箱環境執行的呢,在例項化app時會有一個方法execScript,實現如下,其中的code引數就是我們script獲取的程式碼字串。

``` execScript(

code: string,

url?: string,

options?: { async?: boolean; noEntry?: boolean },

) {

try {

  (this.sandbox as Sandbox).execScript(code, url, options);

} catch (e) {

  this.context.emit(ERROR_COMPILE_APP, this, e);

  throw e;

}

} ```

可以看到這部分的實現呼叫了沙箱中的execScript,這裡先說下前置知識,基本所有的沙箱環境的程式碼執行都會使用with這個語法來處理程式碼的執行上下文,並且有著天然的優勢。在vue中處理模板中訪問變數this關鍵字的方式也採用了這個方式。

接下來看下具體的實現。

``` execScript(code: string, url = '', options?: ExecScriptOptions) {

// 省略一些次要程式碼,保留核心邏輯

// 這裡的context就是我們上面建立的代理window

const context = this.context;

const refs = { url, code, context };



// 這一步是建立一個script標籤如果url存在,src為給定的url,否則code放到標籤體內

// 返回值為清空這個script 元素的引用函式

const revertCurrentScript = setDocCurrentScript(this, code, url, async);



try {

  const sourceUrl = url ? `//# sourceURL=${url}\n` : '';

  let code = `${refs.code}\n${sourceUrl}`;



  if (this.options.openSandbox) {

    // 如果是非嚴格模式則需要with包裹保證內部程式碼執行的上下文為代理後的window

    code = !this.options.useStrict

      ? `with(window) {;${this.attachedCode + code}}`

      : code;

    // 這個函式構造了程式碼執行環境

    evalWithEnv(code, {

      window: refs.context,

      ...this.overrideContext.overrides,

      unstable_sandbox: this,

    });

  }

}



revertCurrentScript();



if (noEntry) {

  refs.context.module = this.overrideContext.overrides.module;

  refs.context.exports = context.module.exports;

}

} ```

接下來看下evalWithEnv的實現邏輯,這個函式的執行邏輯也很簡單,就是把我們的程式碼內容放到一個構造出來的上下文中執行,上下文中的window,document等物件都是我們重寫和代理過的,因此保證了環境的隔離。

``` export function internFunc(internalizeString) {

const temporaryOb = {};

temporaryOb[internalizeString] = true;

return Object.keys(temporaryOb)[0];

}

export function evalWithEnv(code: string, params: Record) {

const keys = Object.keys(params);

// 不可使用隨機值,否則無法作為常量字串複用

// 將我們代理過的全域性變數掛到一個指定屬性下

const randomValKey = 'garfish__exec_temporary';

const vals = keys.map((k) => window.${randomValKey}.${k});

try {

rawWindow[randomValKey] = params;

// 陣列首尾元素中間就是我們程式碼實際執行的位置

// 可以看到首先繫結代理過的window作為上下文,然後引數指定了我們代理和重寫的物件,

// 這樣程式碼內獲取注入document物件時其實已經是代理過的了

const evalInfo = [

  `;(function(${keys.join(',')}){`,

  `\n}).call(${vals[0]},${vals.join(',')});`,

];

const internalizeString = internFunc(evalInfo[0] + code + evalInfo[1]);

// (0, eval) 這個表示式會讓 eval 在全域性作用域下執行

(0, eval)(internalizeString);

} finally {

delete rawWindow[randomValKey];

}

} ```

到這裡我們知道程式碼的執行環境使我們代理的window和重寫的方法構造的,配合上面的with語句的特性則可以解決變數提升相關的問題。到這裡我們完成了程式碼從載入到執行的路徑分析。

結語

上面的分析大多為了講解基本思路,闡述微前端的基本實現思想,在實際的執行過程中會有很多其他邏輯的判斷以及載入優化,如果有興趣的可以參考原始碼實現。目前garfish也在不斷的完善過程中,因為很多場景需要使用者驗證,開發能考慮到的業務case畢竟有限,在寫這篇文章的時候每天都會有近百個commit提交更新過來。可以看到優化場景還是挺多的。總的來說微前端確實很大程度上解決了專案遷移難,技術升級慢和難維護專案的問題。如果有上述痛點是可以嘗試一下的。

Garfish 開源連結:https://github.com/modern-js-dev/garfish