整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          手把手教你寫一個簡易的微前端框架

          手把手教你寫一個簡易的微前端框架

          近看了幾個微前端框架的源碼(single-spa[1]qiankun[2]micro-app[3]),感覺收獲良多。所以打算造一個迷你版的輪子,來加深自己對所學知識的了解。

          這個輪子將分為五個版本,逐步的實現一個最小可用的微前端框架:

          1.支持不同框架的子應用(v1[4] 分支)2.支持子應用 HTML 入口(v2[5] 分支)3.支持沙箱功能,子應用 window 作用域隔離、元素隔離(v3[6] 分支)4.支持子應用樣式隔離(v4[7] 分支)5.支持各應用之間的數據通信(main[8] 分支)

          每一個版本的代碼都是在上一個版本的基礎上修改的,所以 V5 版本的代碼是最終代碼。

          Github 項目地址:https://github.com/woai3c/mini-single-spa

          V1 版本

          V1 版本打算實現一個最簡單的微前端框架,只要它能夠正常加載、卸載子應用就行。如果將 V1 版本細分一下的話,它主要由以下兩個功能組成:

          1.監聽頁面 URL 變化,切換子應用2.根據當前 URL、子應用的觸發規則來判斷是否要加載、卸載子應用

          監聽頁面 URL 變化,切換子應用

          一個 SPA 應用必不可少的功能就是監聽頁面 URL 的變化,然后根據不同的路由規則來渲染不同的路由組件。因此,微前端框架也可以根據頁面 URL 的變化,來切換到不同的子應用:

          // 當 location.pathname 以 /vue 為前綴時切換到 vue 子應用
          https://www.example.com/vue/xxx
          // 當 location.pathname 以 /react 為前綴時切換到 react 子應用
          https://www.example.com/react/xxx

          這可以通過重寫兩個 API 和監聽兩個事件來完成:

          1.重寫 window.history.pushState()[9]2.重寫 window.history.replaceState()[10]3.監聽 popstate[11] 事件4.監聽 hashchange[12] 事件

          其中 pushState()replaceState() 方法可以修改瀏覽器的歷史記錄棧,所以我們可以重寫這兩個 API。當這兩個 API 被 SPA 應用調用時,說明 URL 發生了變化,這時就可以根據當前已改變的 URL 判斷是否要加載、卸載子應用。

          // 執行下面代碼后,瀏覽器的 URL 將從 https://www.xxx.com 變為 https://www.xxx.com/vue
          window.history.pushState(null, '', '/vue')

          當用戶手動點擊瀏覽器上的前進后退按鈕時,會觸發 popstate 事件,所以需要對這個事件進行監聽。同理,也需要監聽 hashchange 事件。

          這一段邏輯的代碼如下所示:

          import { loadApps } from '../application/apps'
          
          
          const originalPushState=window.history.pushState
          const originalReplaceState=window.history.replaceState
          
          
          export default function overwriteEventsAndHistory() {
              window.history.pushState=function (state: any, title: string, url: string) {
                  const result=originalPushState.call(this, state, title, url)
                  // 根據當前 url 加載或卸載 app
                  loadApps()
                  return result
              }
          
          
              window.history.replaceState=function (state: any, title: string, url: string) {
                  const result=originalReplaceState.call(this, state, title, url)
                  loadApps()
                  return result
              }
          
          
              window.addEventListener('popstate', ()=> {
                  loadApps()
              }, true)
          
          
              window.addEventListener('hashchange', ()=> {
                  loadApps()
              }, true)
          }

          從上面的代碼可以看出來,每次 URL 改變時,都會調用 loadApps() 方法,這個方法的作用就是根據當前的 URL、子應用的觸發規則去切換子應用的狀態:

          export async function loadApps() {
              // 先卸載所有失活的子應用
              const toUnMountApp=getAppsWithStatus(AppStatus.MOUNTED)
              await Promise.all(toUnMountApp.map(unMountApp))
          
          
              // 初始化所有剛注冊的子應用
              const toLoadApp=getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)
              await Promise.all(toLoadApp.map(bootstrapApp))
          
          
              const toMountApp=[
                  ...getAppsWithStatus(AppStatus.BOOTSTRAPPED),
                  ...getAppsWithStatus(AppStatus.UNMOUNTED),
              ]
              // 加載所有符合條件的子應用
              await toMountApp.map(mountApp)
          }

          這段代碼的邏輯也比較簡單:

          1.卸載所有已失活的子應用2.初始化所有剛注冊的子應用3.加載所有符合條件的子應用

          根據當前 URL、子應用的觸發規則來判斷是否要加載、卸載子應用

          為了支持不同框架的子應用,所以規定了子應用必須向外暴露 bootstrap() mount() unmount() 這三個方法。bootstrap() 方法在第一次加載子應用時觸發,并且只會觸發一次,另外兩個方法在每次加載、卸載子應用時都會觸發。

          不管注冊的是什么子應用,在 URL 符合加載條件時就調用子應用的 mount() 方法,能不能正常渲染交給子應用負責。在符合卸載條件時則調用子應用的 unmount() 方法。

          registerApplication({
              name: 'vue',
              // 初始化子應用時執行該方法
              loadApp() { 
                  return {
                      mount() {                
                          // 這里進行掛載子應用的操作
                          app.mount('#app')
                      },
                      unmount() {
                          // 這里進行卸載子應用的操作 
                          app.unmount()
                      },
                  }
              },
              // 如果傳入一個字符串會被轉為一個參數為 location 的函數
              // activeRule: '/vue' 會被轉為 (location)=> location.pathname==='/vue'
              activeRule: (location)=> location.hash==='#/vue'
          })

          上面是一個簡單的子應用注冊示例,其中 activeRule() 方法用來判斷該子應用是否激活(返回 true 表示激活)。每當頁面 URL 發生變化,微前端框架就會調用 loadApps() 判斷每個子應用是否激活,然后觸發加載、卸載子應用的操作。

          何時加載、卸載子應用

          首先我們將子應用的狀態分為三種:

          • bootstrap,調用 registerApplication() 注冊一個子應用后,它的狀態默認為 bootstrap,下一個轉換狀態為 mount
          • mount,子應用掛載成功后的狀態,它的下一個轉換狀態為 unmount
          • unmount,子應用卸載成功后的狀態,它的下一個轉換狀態為 mount,即卸載后的應用可再次加載。

          現在我們來看看什么時候會加載一個子應用,當頁面 URL 改變后,如果子應用滿足以下兩個條件,則需要加載該子應用:

          1. activeRule() 的返回值為 true,例如 URL 從 / 變為 /vue,這時子應用 vue 為激活狀態(假設它的激活規則為 /vue)。
          2. 子應用狀態必須為 bootstrapunmount,這樣才能向 mount 狀態轉換。如果已經處于 mount 狀態并且 activeRule() 返回值為 true,則不作任何處理。

          如果頁面的 URL 改變后,子應用滿足以下兩個條件,則需要卸載該子應用:

          1. activeRule() 的返回值為 false,例如 URL 從 /vue 變為 /,這時子應用 vue 為失活狀態(假設它的激活規則為 /vue)。
          2. 子應用狀態必須為 mount,也就是當前子應用必須處于加載狀態(如果是其他狀態,則不作任何處理)。然后 URL 改變導致失活了,所以需要卸載它,狀態也從 mount 變為 unmount

          API 介紹

          V1 版本主要向外暴露了兩個 API:

          1. registerApplication(),注冊子應用。
          2. start(),注冊完所有的子應用后調用,在它的內部會執行 loadApps() 去加載子應用。

          registerApplication(Application) 接收的參數如下:

          interface Application {
              // 子應用名稱
              name: string
          
          
              /**
               * 激活規則,例如傳入 /vue,當 url 的路徑變為 /vue 時,激活當前子應用。
               * 如果 activeRule 為函數,則會傳入 location 作為參數,activeRule(location) 返回 true 時,激活當前子應用。
               */
              activeRule: Function | string
          
          
              // 傳給子應用的自定義參數
              props: AnyObject
          
          
              /**
               * loadApp() 必須返回一個 Promise,resolve() 后得到一個對象:
               * {
               *   bootstrap: ()=> Promise<any>
               *   mount: (props: AnyObject)=> Promise<any>
               *   unmount: (props: AnyObject)=> Promise<any>
               * }
               */
              loadApp: ()=> Promise<any>
          }

          一個完整的示例

          現在我們來看一個比較完整的示例(代碼在 V1 分支的 examples 目錄):

          let vueApp
          registerApplication({
              name: 'vue',
              loadApp() {
                  return Promise.resolve({
                      bootstrap() {
                          console.log('vue bootstrap')
                      },
                      mount() {
                          console.log('vue mount')
                          vueApp=Vue.createApp({
                              data() {
                                  return {
                                      text: 'Vue App'
                                  }
                              },
                              render() {
                                  return Vue.h(
                                      'div',     // 標簽名稱
                                      this.text  // 標簽內容
                                  )
                              },
                          })
          
          
                          vueApp.mount('#app')
                      },
                      unmount() {
                          console.log('vue unmount')
                          vueApp.unmount()
                      },
                  })
              },
              activeRule:(location)=> location.hash==='#/vue',
          })
          
          
          registerApplication({
              name: 'react',
              loadApp() { 
                  return Promise.resolve({
                      bootstrap() {
                          console.log('react bootstrap')
                      },
                      mount() {
                          console.log('react mount')
                          ReactDOM.render(
                              React.createElement(LikeButton),
                              $('#app')
                          );
                      },
                      unmount() {
                          console.log('react unmount')
                          ReactDOM.unmountComponentAtNode($('#app'));
                      },
                  })
              },
              activeRule: (location)=> location.hash==='#/react'
          })
          
          
          start()

          演示效果如下:

          小結

          V1 版本的代碼打包后才 100 多行,如果只是想了解微前端的最核心原理,只看 V1 版本的源碼就可以了。

          V2 版本

          V1 版本的實現還是非常簡陋的,能夠適用的業務場景有限。從 V1 版本的示例可以看出,它要求子應用提前把資源都加載好(或者把整個子應用打包成一個 NPM 包,直接引入),這樣才能在執行子應用的 mount() 方法時,能夠正常渲染。

          舉個例子,假設我們在開發環境啟動了一個 vue 應用。那么如何在主應用引入這個 vue 子應用的資源呢?首先排除掉 NPM 包的形式,因為每次修改代碼都得打包,不現實。第二種方式就是手動在主應用引入子應用的資源。例如 vue 子應用的入口資源為:

          那么我們可以在注冊子應用時這樣引入:

          registerApplication({
              name: 'vue',
              loadApp() { 
                  return Promise.resolve({
                      bootstrap() {
                          import('http://localhost:8001/js/chunk-vendors.js')
                          import('http://localhost:8001/js/app.js')
                      },
                      mount() {
                          // ...            
                      },
                      unmount() {
                          // ...            
                      },
                  })
              },
              activeRule: (location)=> location.hash==='#/vue'
          })

          這種方式也不靠譜,每次子應用的入口資源文件變了,主應用的代碼也得跟著變。還好,我們有第三種方式,那就是在注冊子應用的時候,把子應用的入口 URL 寫上,由微前端來負責加載資源文件。

          registerApplication({
              // 子應用入口 URL
              pageEntry: 'http://localhost:8081'
              // ...
          })

          “自動”加載資源文件

          現在我們來看一下如何自動加載子應用的入口文件(只在第一次加載子應用時執行):

          export default function parseHTMLandLoadSources(app: Application) {
              return new Promise<void>(async (resolve, reject)=> {
                  const pageEntry=app.pageEntry    
                  // load html        
                  const html=await loadSourceText(pageEntry)
                  const domparser=new DOMParser()
                  const doc=domparser.parseFromString(html, 'text/html')
                  const { scripts, styles }=extractScriptsAndStyles(doc as unknown as Element, app)
          
          
                  // 提取了 script style 后剩下的 body 部分的 html 內容
                  app.pageBody=doc.body.innerHTML
          
          
                  let isStylesDone=false, isScriptsDone=false
                  // 加載 style script 的內容
                  Promise.all(loadStyles(styles))
                  .then(data=> {
                      isStylesDone=true
                      // 將 style 樣式添加到 document.head 標簽
                      addStyles(data as string[])
                      if (isScriptsDone && isStylesDone) resolve()
                  })
                  .catch(err=> reject(err))
          
          
                  Promise.all(loadScripts(scripts))
                  .then(data=> {
                      isScriptsDone=true
                      // 執行 script 內容
                      executeScripts(data as string[])
                      if (isScriptsDone && isStylesDone) resolve()
                  })
                  .catch(err=> reject(err))
              })
          }

          上面代碼的邏輯:

          1.利用 ajax 請求子應用入口 URL 的內容,得到子應用的 HTML2.提取 HTML 中 script style 的內容或 URL,如果是 URL,則再次使用 ajax 拉取內容。最后得到入口頁面所有的 script style 的內容3.將所有 style 添加到 document.head 下,script 代碼直接執行4.將剩下的 body 部分的 HTML 內容賦值給子應用要掛載的 DOM 下。

          下面再詳細描述一下這四步是怎么做的。

          一、拉取 HTML 內容

          export function loadSourceText(url: string) {
              return new Promise<string>((resolve, reject)=> {
                  const xhr=new XMLHttpRequest()
                  xhr.onload=(res: any)=> {
                      resolve(res.target.response)
                  }
          
          
                  xhr.onerror=reject
                  xhr.onabort=reject
                  xhr.open('get', url)
                  xhr.send()
              })
          }

          代碼邏輯很簡單,使用 ajax 發起一個請求,得到 HTML 內容。

          上圖就是一個 vue 子應用的 HTML 內容,箭頭所指的是要提取的資源,方框標記的內容要賦值給子應用所掛載的 DOM。

          二、解析 HTML 并提取 style script 標簽內容

          這需要使用一個 API DOMParser[13],它可以直接解析一個 HTML 字符串,并且不需要掛到 document 對象上。

          const domparser=new DOMParser()
          const doc=domparser.parseFromString(html, 'text/html')

          提取標簽的函數 extractScriptsAndStyles(node: Element, app: Application) 代碼比較多,這里就不貼代碼了。這個函數主要的功能就是遞歸遍歷上面生成的 DOM 樹,提取里面所有的 style script 標簽。

          三、添加 style 標簽,執行 script 腳本內容

          這一步比較簡單,將所有提取的 style 標簽添加到 document.head 下:

          export function addStyles(styles: string[] | HTMLStyleElement[]) {
              styles.forEach(item=> {
                  if (typeof item==='string') {
                      const node=createElement('style', {
                          type: 'text/css',
                          textContent: item,
                      })
          
          
                      head.appendChild(node)
                  } else {
                      head.appendChild(item)
                  }
              })
          }

          js 腳本代碼則直接包在一個匿名函數內執行:

          export function executeScripts(scripts: string[]) {
              try {
                  scripts.forEach(code=> {
                      new Function('window', code).call(window, window)
                  })
              } catch (error) {
                  throw error
              }
          }

          四、將剩下的 body 部分的 HTML 內容賦值給子應用要掛載的 DOM 下

          為了保證子應用正常執行,需要將這部分的內容保存起來。然后每次在子應用 mount() 前,賦值到所掛載的 DOM 下。

          // 保存 HTML 代碼
          app.pageBody=doc.body.innerHTML
          
          
          // 加載子應用前賦值給掛載的 DOM
          app.container.innerHTML=app.pageBody
          app.mount()

          現在我們已經可以非常方便的加載子應用了,但是子應用還有一些東西需要修改一下。

          子應用需要做的事情

          在 V1 版本里,注冊子應用的時候有一個 loadApp() 方法。微前端框架在第一次加載子應用時會執行這個方法,從而拿到子應用暴露的三個方法。現在實現了 pageEntry 功能,我們就不用把這個方法寫在主應用里了,因為不再需要在主應用里引入子應用。

          但是又得讓微前端框架拿到子應用暴露出來的方法,所以我們可以換一種方式暴露子應用的方法:

          // 每個子應用都需要這樣暴露三個 API,該屬性格式為 `mini-single-spa-${appName}`
          window['mini-single-spa-vue']={
              bootstrap,
              mount,
              unmount
          }

          這樣微前端也能拿到每個子應用暴露的方法,從而實現加載、卸載子應用的功能。

          另外,子應用還得做兩件事:

          1.配置 cors,防止出現跨域問題(由于主應用和子應用的域名不同,會出現跨域問題)2.配置資源發布路徑

          如果子應用是基于 webpack 進行開發的,可以這樣配置:

          module.exports={
              devServer: {
                  port: 8001, // 子應用訪問端口
                  headers: {
                      'Access-Control-Allow-Origin': '*'
                  }
              },
              publicPath: "//localhost:8001/",
          }

          一個完整的示例

          示例代碼在 examples 目錄。

          registerApplication({
              name: 'vue',
              pageEntry: 'http://localhost:8001',
              activeRule: pathPrefix('/vue'),
              container: $('#subapp-viewport')
          })
          
          
          registerApplication({
              name: 'react',
              pageEntry: 'http://localhost:8002',
              activeRule:pathPrefix('/react'),
              container: $('#subapp-viewport')
          })
          
          
          start()

          V3 版本

          V3 版本主要添加以下兩個功能:

          1. 隔離子應用 window 作用域
          2. 隔離子應用元素作用域

          隔離子應用 window 作用域

          在 V2 版本下,主應用及所有的子應用都共用一個 window 對象,這就導致了互相覆蓋數據的問題:

          // 先加載 a 子應用
          window.name='a'
          // 后加載 b 子應用
          window.name='b'
          // 這時再切換回 a 子應用,讀取 window.name 得到的值卻是 b
          console.log(window.name) // b

          為了避免這種情況發生,我們可以使用 Proxy[14] 來代理對子應用 window 對象的訪問:

          app.window=new Proxy({}, {
              get(target, key) {
                  if (Reflect.has(target, key)) {
                      return Reflect.get(target, key)
                  }
          
          
                  const result=originalWindow[key]
                  // window 原生方法的 this 指向必須綁在 window 上運行,否則會報錯 "TypeError: Illegal invocation"
                  // e.g: const obj={}; obj.alert=alert;  obj.alert();
                  return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result
              },
          
          
              set: (target, key, value)=> {
                  this.injectKeySet.add(key)
                  return Reflect.set(target, key, value)
              }
          })

          從上述代碼可以看出,用 Proxy 對一個空對象做了代理,然后把這個代理對象作為子應用的 window 對象:

          1. 當子應用里的代碼訪問 window.xxx 屬性時,就會被這個代理對象攔截。它會先看看子應用的代理 window 對象有沒有這個屬性,如果找不到,就會從父應用里找,也就是在真正的 window 對象里找。
          2. 當子應用里的代碼修改 window 屬性時,會直接在子應用的代理 window 對象上修改。

          那么問題來了,怎么讓子應用里的代碼讀取/修改 window 時候,讓它們訪問的是子應用的代理 window 對象?

          剛才 V2 版本介紹過,微前端框架會代替子應用拉取 js 資源,然后直接執行。我們可以在執行代碼的時候使用 with[15] 語句將代碼包一下,讓子應用的 window 指向代理對象:

          export function executeScripts(scripts: string[], app: Application) {
              try {
                  scripts.forEach(code=> {            
                      // ts 使用 with 會報錯,所以需要這樣包一下
                      // 將子應用的 js 代碼全局 window 環境指向代理環境 proxyWindow
                      const warpCode=`
                          ;(function(proxyWindow){
                              with (proxyWindow) {
                                  (function(window){${code}\n}).call(proxyWindow, proxyWindow)
                              }
                          })(this);
                      `
          
          
                      new Function(warpCode).call(app.sandbox.proxyWindow)
                  })
              } catch (error) {
                  throw error
              }
          }

          卸載時清除子應用 window 作用域

          當子應用卸載時,需要對它的 window 代理對象進行清除。否則下一次子應用重新加載時,它的 window 代理對象會存有上一次加載的數據。剛才創建 Proxy 的代碼中有一行代碼 this.injectKeySet.add(key),這個 injectKeySet 是一個 Set 對象,存著每一個 window 代理對象的新增屬性。所以在卸載時只需要遍歷這個 Set,將 window 代理對象上對應的 key 刪除即可:

          for (const key of injectKeySet) {
              Reflect.deleteProperty(microAppWindow, key as (string | symbol))
          }

          記錄綁定的全局事件、定時器,卸載時清除

          通常情況下,一個子應用除了會修改 window 上的屬性,還會在 window 上綁定一些全局事件。所以我們要把這些事件記錄起來,在卸載子應用時清除這些事件。同理,各種定時器也一樣,卸載時需要清除未執行的定時器。

          下面的代碼是記錄事件、定時器的部分關鍵代碼:

          // 部分關鍵代碼
          microAppWindow.setTimeout=function setTimeout(callback: Function, timeout?: number | undefined, ...args: any[]): number {
              const timer=originalWindow.setTimeout(callback, timeout, ...args)
              timeoutSet.add(timer)
              return timer
          }
          
          
          microAppWindow.clearTimeout=function clearTimeout(timer?: number): void {
              if (timer===undefined) return
              originalWindow.clearTimeout(timer)
              timeoutSet.delete(timer)
          }
          microAppWindow.addEventListener=function addEventListener(
              type: string, 
              listener: EventListenerOrEventListenerObject, 
              options?: boolean | AddEventListenerOptions | undefined,
          ) {
              if (!windowEventMap.get(type)) {
                  windowEventMap.set(type, [])
              }
          
          
              windowEventMap.get(type)?.push({ listener, options })
              return originalWindowAddEventListener.call(originalWindow, type, listener, options)
          }
          
          
          microAppWindow.removeEventListener=function removeEventListener(
              type: string, 
              listener: EventListenerOrEventListenerObject, 
              options?: boolean | AddEventListenerOptions | undefined,
          ) {
              const arr=windowEventMap.get(type) || []
              for (let i=0, len=arr.length; i < len; i++) {
                  if (arr[i].listener===listener) {
                      arr.splice(i, 1)
                      break
                  }
              }
          
          
              return originalWindowRemoveEventListener.call(originalWindow, type, listener, options)
          }

          下面這段是清除事件、定時器的關鍵代碼:

          for (const timer of timeoutSet) {
              originalWindow.clearTimeout(timer)
          }
          
          
          for (const [type, arr] of windowEventMap) {
              for (const item of arr) {
                  originalWindowRemoveEventListener.call(originalWindow, type as string, item.listener, item.options)
              }
          }

          緩存子應用快照

          之前提到過子應用每次加載的時候會都執行 mount() 方法,由于每個 js 文件只會執行一次,所以在執行 mount() 方法之前的代碼在下一次重新加載時不會再次執行。

          舉個例子:

          window.name='test'
          
          
          function bootstrap() { // ... }
          function mount() { // ... }
          function unmount() { // ... }

          上面是子應用入口文件的代碼,在第一次執行 js 代碼時,子應用可以讀取 window.name 這個屬性的值。但是子應用卸載時會把 name 這個屬性清除掉。所以子應用下一次加載的時候,就讀取不到這個屬性了。

          為了解決這個問題,我們可以在子應用初始化時(拉取了所有入口 js 文件并執行后)將當前的子應用 window 代理對象的屬性、事件緩存起來,生成快照。下一次子應用重新加載時,將快照恢復回子應用上。

          生成快照的部分代碼:

          const { windowSnapshot, microAppWindow }=this
          const recordAttrs=windowSnapshot.get('attrs')!
          const recordWindowEvents=windowSnapshot.get('windowEvents')!
          
          
          // 緩存 window 屬性
          this.injectKeySet.forEach(key=> {
              recordAttrs.set(key, deepCopy(microAppWindow[key]))
          })
          
          
          // 緩存 window 事件
          this.windowEventMap.forEach((arr, type)=> {
              recordWindowEvents.set(type, deepCopy(arr))
          })

          恢復快照的部分代碼:

          const { 
              windowSnapshot, 
              injectKeySet, 
              microAppWindow, 
              windowEventMap, 
              onWindowEventMap,
          }=this
          const recordAttrs=windowSnapshot.get('attrs')!
          const recordWindowEvents=windowSnapshot.get('windowEvents')!
          
          
          recordAttrs.forEach((value, key)=> {
              injectKeySet.add(key)
              microAppWindow[key]=deepCopy(value)
          })
          
          
          recordWindowEvents.forEach((arr, type)=> {
              windowEventMap.set(type, deepCopy(arr))
              for (const item of arr) {
                  originalWindowAddEventListener.call(originalWindow, type as string, item.listener, item.options)
              }
          })

          隔離子應用元素作用域

          我們在使用 document.querySelector() 或者其他查詢 DOM 的 API 時,都會在整個頁面的 document 對象上查詢。如果在子應用上也這樣查詢,很有可能會查詢到子應用范圍外的 DOM 元素。為了解決這個問題,我們需要重寫一下查詢類的 DOM API:

          // 將所有查詢 dom 的范圍限制在子應用掛載的 dom 容器上
          Document.prototype.querySelector=function querySelector(this: Document, selector: string) {
              const app=getCurrentApp()
              if (!app || !selector || isUniqueElement(selector)) {
                  return originalQuerySelector.call(this, selector)
              }
              // 將查詢范圍限定在子應用掛載容器的 DOM 下
              return app.container.querySelector(selector)
          }
          
          
          Document.prototype.getElementById=function getElementById(id: string) {
              // ...
          }

          將查詢范圍限定在子應用掛載容器的 DOM 下。另外,子應用卸載時也需要恢復重寫的 API:

          Document.prototype.querySelector=originalQuerySelector
          Document.prototype.querySelectorAll=originalQuerySelectorAll
          // ...

          除了查詢 DOM 要限制子應用的范圍,樣式也要限制范圍。假設在 vue 應用上有這樣一個樣式:

          body {
              color: red;
          }

          當它作為一個子應用被加載時,這個樣式需要被修改為:

          /* body 被替換為子應用掛載 DOM 的 id 選擇符 */
          #app {
              color: red;
          }

          實現代碼也比較簡單,需要遍歷每一條 css 規則,然后替換里面的 bodyhtml 字符串:

          const re=/^(\s|,)?(body|html)\b/g
          // 將 body html 標簽替換為子應用掛載容器的 id
          cssText.replace(re, `#${app.container.id}`)

          V4 版本

          V3 版本實現了 window 作用域隔離、元素隔離,在 V4 版本上我們將實現子應用樣式隔離。

          第一版

          我們都知道創建 DOM 元素時使用的是 document.createElement() API,所以我們可以在創建 DOM 元素時,把當前子應用的名稱當成屬性寫到 DOM 上:

          Document.prototype.createElement=function createElement(
              tagName: string,
              options?: ElementCreationOptions,
          ): HTMLElement {
              const appName=getCurrentAppName()
              const element=originalCreateElement.call(this, tagName, options)
              appName && element.setAttribute('single-spa-name', appName)
              return element
          }

          這樣所有的 style 標簽在創建時都會有當前子應用的名稱屬性。我們可以在子應用卸載時將當前子應用所有的 style 標簽進行移除,再次掛載時將這些標簽重新添加到 document.head 下。這樣就實現了不同子應用之間的樣式隔離。

          移除子應用所有 style 標簽的代碼:

          export function removeStyles(name: string) {
              const styles=document.querySelectorAll(`style[single-spa-name=${name}]`)
              styles.forEach(style=> {
                  removeNode(style)
              })
          
          
              return styles as unknown as HTMLStyleElement[]
          }

          第一版的樣式作用域隔離完成后,它只能對每次只加載一個子應用的場景有效。例如先加載 a 子應用,卸載后再加載 b 子應用這種場景。在卸載 a 子應用時會把它的樣式也卸載。如果同時加載多個子應用,第一版的樣式隔離就不起作用了。

          第二版

          由于每個子應用下的 DOM 元素都有以自己名稱作為值的 single-spa-name 屬性(如果不知道這個名稱是哪來的,請往上翻一下第一版的描述)。

          所以我們可以給子應用的每個樣式加上子應用名稱,也就是將這樣的樣式:

          div {
              color: red;
          }

          改成:

          div[single-spa-name=vue] {
              color: red;
          }

          這樣一來,就把樣式作用域范圍限制在對應的子應用所掛載的 DOM 下。

          給樣式添加作用域范圍

          現在我們來看看具體要怎么添加作用域:

          /**
           * 給每一條 css 選擇符添加對應的子應用作用域
           * 1. a {} -> a[single-spa-name=${app.name}] {}
           * 2. a b c {} -> a[single-spa-name=${app.name}] b c {}
           * 3. a, b {} -> a[single-spa-name=${app.name}], b[single-spa-name=${app.name}] {}
           * 4. body {} -> #${子應用掛載容器的 id}[single-spa-name=${app.name}] {}
           * 5. @media @supports 特殊處理,其他規則直接返回 cssText
           */

          主要有以上五種情況。

          通常情況下,每一條 css 選擇符都是一個 css 規則,這可以通過 style.sheet.cssRules 獲取:

          拿到了每一條 css 規則之后,我們就可以對它們進行重寫,然后再把它們重寫掛載到 document.head 下:

          function handleCSSRules(cssRules: CSSRuleList, app: Application) {
              let result=''
              Array.from(cssRules).forEach(cssRule=> {
                  const cssText=cssRule.cssText
                  const selectorText=(cssRule as CSSStyleRule).selectorText
                  result +=cssRule.cssText.replace(
                      selectorText, 
                      getNewSelectorText(selectorText, app),
                  )
              })
          
          
              return result
          }
          
          
          let count=0
          const re=/^(\s|,)?(body|html)\b/g
          function getNewSelectorText(selectorText: string, app: Application) {
              const arr=selectorText.split(',').map(text=> {
                  const items=text.trim().split(' ')
                  items[0]=`${items[0]}[single-spa-name=${app.name}]`
                  return items.join(' ')
              })
          
          
              // 如果子應用掛載的容器沒有 id,則隨機生成一個 id
              let id=app.container.id
              if (!id) {
                  id='single-spa-id-' + count++
                  app.container.id=id
              }
          
          
              // 將 body html 標簽替換為子應用掛載容器的 id
              return arr.join(',').replace(re, `#${id}`)
          }

          核心代碼在 getNewSelectorText() 上,這個函數給每一個 css 規則都加上了 [single-spa-name=${app.name}]。這樣就把樣式作用域限制在了對應的子應用內了。

          效果演示

          大家可以對比一下下面的兩張圖,這個示例同時加載了 vue、react 兩個子應用。第一張圖里的 vue 子應用部分字體被 react 子應用的樣式影響了。第二張圖是添加了樣式作用域隔離的效果圖,可以看到 vue 子應用的樣式是正常的,沒有被影響。

          V5 版本

          V5 版本主要添加了一個全局數據通信的功能,設計思路如下:

          1.所有應用共享一個全局對象 window.spaGlobalState,所有應用都可以對這個全局對象進行監聽,每當有應用對它進行修改時,會觸發 change 事件。2.可以使用這個全局對象進行事件訂閱/發布,各應用之間可以自由的收發事件。

          下面是實現了第一點要求的部分關鍵代碼:

          export default class GlobalState extends EventBus {
              private state: AnyObject={}
              private stateChangeCallbacksMap: Map<string, Array<Callback>>=new Map()
          
          
              set(key: string, value: any) {
                  this.state[key]=value
                  this.emitChange('set', key)
              }
          
          
              get(key: string) {
                  return this.state[key]
              }
          
          
              onChange(callback: Callback) {
                  const appName=getCurrentAppName()
                  if (!appName) return
          
          
                  const { stateChangeCallbacksMap }=this
                  if (!stateChangeCallbacksMap.get(appName)) {
                      stateChangeCallbacksMap.set(appName, [])
                  }
          
          
                  stateChangeCallbacksMap.get(appName)?.push(callback)
              }
          
          
              emitChange(operator: string, key?: string) {
                  this.stateChangeCallbacksMap.forEach((callbacks, appName)=> {
                      /**
                       * 如果是點擊其他子應用或父應用觸發全局數據變更,則當前打開的子應用獲取到的 app 為 null
                       * 所以需要改成用 activeRule 來判斷當前子應用是否運行
                       */
                      const app=getApp(appName) as Application
                      if (!(isActive(app) && app.status===AppStatus.MOUNTED)) return
                      callbacks.forEach(callback=> callback(this.state, operator, key))
                  })
              }
          }

          下面是實現了第二點要求的部分關鍵代碼:

          export default class EventBus {
              private eventsMap: Map<string, Record<string, Array<Callback>>>=new Map()
          
          
              on(event: string, callback: Callback) {
                  if (!isFunction(callback)) {
                      throw Error(`The second param ${typeof callback} is not a function`)
                  }
          
          
                  const appName=getCurrentAppName() || 'parent'
          
          
                  const { eventsMap }=this
                  if (!eventsMap.get(appName)) {
                      eventsMap.set(appName, {})
                  }
          
          
                  const events=eventsMap.get(appName)!
                  if (!events[event]) {
                      events[event]=[] 
                  }
          
          
                  events[event].push(callback)
              }
          
          
              emit(event: string, ...args: any) {
                  this.eventsMap.forEach((events, appName)=> {
                      /**
                       * 如果是點擊其他子應用或父應用觸發全局數據變更,則當前打開的子應用獲取到的 app 為 null
                       * 所以需要改成用 activeRule 來判斷當前子應用是否運行
                       */
                      const app=getApp(appName) as Application
                      if (appName==='parent' || (isActive(app) && app.status===AppStatus.MOUNTED)) {
                          if (events[event]?.length) {
                              for (const callback of events[event]) {
                                  callback.call(this, ...args)
                              }
                          }
                      }
                  })
              }
          }

          以上兩段代碼都有一個相同的地方,就是在保存監聽回調函數的時候需要和對應的子應用關聯起來。當某個子應用卸載時,需要把它關聯的回調函數也清除掉。

          全局數據修改示例代碼

          // 父應用
          window.spaGlobalState.set('msg', '父應用在 spa 全局狀態上新增了一個 msg 屬性')
          // 子應用
          window.spaGlobalState.onChange((state, operator, key)=> {
              alert(`vue 子應用監聽到 spa 全局狀態發生了變化: ${JSON.stringify(state)},操作: ${operator},變化的屬性: ${key}`)
          })

          全局事件示例代碼

          // 父應用
          window.spaGlobalState.emit('testEvent', '父應用發送了一個全局事件: testEvent')
          // 子應用
          window.spaGlobalState.on('testEvent', ()=> alert('vue 子應用監聽到父應用發送了一個全局事件: testEvent'))

          總結

          至此,一個簡易微前端框架的技術要點已經講解完畢。強烈建議大家在看文檔的同時,把 demo 運行起來跑一跑,這樣能幫助你更好的理解代碼。

          如果你覺得我的文章寫得不錯,也可以看看我的其他一些技術文章或項目:

          • 帶你入門前端工程[16]
          • 可視化拖拽組件庫一些技術要點原理分析[17]
          • 前端性能優化 24 條建議(2020)[18]
          • 前端監控 SDK 的一些技術要點原理分析[19]
          • 手把手教你寫一個腳手架 [20]
          • 計算機系統要素-從零開始構建現代計算機[21]

          References

          [1] single-spa: https://github.com/single-spa/single-spa
          [2] qiankun:
          https://github.com/umijs/qiankun
          [3] micro-app:
          https://github.com/micro-zoe/micro-app
          [4] v1:
          https://github.com/woai3c/mini-single-spa/tree/v1
          [5] v2:
          https://github.com/woai3c/mini-single-spa/tree/v2
          [6] v3:
          https://github.com/woai3c/mini-single-spa/tree/v3
          [7] v4:
          https://github.com/woai3c/mini-single-spa/tree/v4
          [8] main:
          https://github.com/woai3c/mini-single-spa
          [9] window.history.pushState():
          https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState
          [10] window.history.replaceState():
          https://developer.mozilla.org/zh-CN/docs/Web/API/History/replaceState
          [11] popstate:
          https://developer.mozilla.org/zh-CN/docs/Web/API/Window/popstate_event
          [12] hashchange:
          https://developer.mozilla.org/zh-CN/docs/Web/API/Window/hashchange_event
          [13] DOMParser:
          https://developer.mozilla.org/zh-CN/docs/Web/API/DOMParser
          [14] Proxy:
          https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
          [15] with:
          https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/with
          [16] 帶你入門前端工程:
          https://woai3c.gitee.io/introduction-to-front-end-engineering/
          [17] 可視化拖拽組件庫一些技術要點原理分析:
          https://github.com/woai3c/Front-end-articles/issues/19
          [18] 前端性能優化 24 條建議(2020):
          https://github.com/woai3c/Front-end-articles/blob/master/performance.md
          [19] 前端監控 SDK 的一些技術要點原理分析:
          https://github.com/woai3c/Front-end-articles/issues/26
          [20] 手把手教你寫一個腳手架 :
          https://github.com/woai3c/Front-end-articles/issues/22
          [21] 計算機系統要素-從零開始構建現代計算機:
          https://github.com/woai3c/nand2tetris

          在 javascript 語言中, 有一個奇奇怪怪的 "關鍵字" 叫做 this

          ● 為什么說它是 奇奇怪怪 呢, 是因為你寫出 100 個 this, 可能有 100 個解釋, 完全不挨邊

          ● 但是, 在你的學習過程中, 搞清楚了 this 這個玩意, 那么會對你的開發生涯有很大幫助的

          ● 接下來咱們就開始一點一點的認識一下 this


          this 初認識

          ● 看到 this, 先給他翻譯過來 "這個"

          ● 到底啥意思呢 ?

          ○ 飯桌上, 你媽和你說, 你多吃點的這個

          ○ 商店里, 你媳婦和你說, 這個包 這個包 這個包 我都要

          ○ 宴會上, 你爸和人介紹說, 這個傻小子是我兒子

          ● 你看, 每一句話上都有 "這個", 但是每個 "這個" 都是一個意思嗎 ? 并不

          ● 就像我們 js 內的 this 一樣, 每一個 this 的意思都不一樣

          ● 但是我們會發現

          ○ 在說話的過程中, "這個" 是和我們說話的手勢有關系

          ● 在 js 內一個道理

          ○ this 的意思是和代碼的 "手勢" 有關系

          ● 例子 :

          ○ 當你媳婦手指著一個 LV 包的時候, 說的 "這個" 指代的就是 LV包`

          ○ 當你媽指著魚香肉絲的時候說 "這個" 指代的就是 魚香肉絲

          ○ 所以在 javascript 內的 this 是要看 "說這句話的代碼手指向哪里了"

          ● 看看下面一段代碼

          var box=document.querySelector('#box')
          
          box.onclick=function () {
              console.log(this)
          }

          ● 當你點擊 box 這個元素的時候, 會觸發后面的函數

          ● 然后函數一執行, 就會在控制臺打印一下 this

          ● 這里的 this 就是 box 這個元素

          ● 這就是一個非常簡單的 this 指向的例子了

          ● 接下來我們就開始詳細學習一下 this


          給你個概念

          ● this , 是一個指針形變量, 它動態的指向當前函數的運行環境

          ● "什么鬼東西, 我聽不懂啊"

          ● 給一個私人的解釋 : "根據 this 所在的函數是如何被調用的來決定 this 是什么"

          ● 舉個栗子來看一下

          function fn() {
              console.log(this)
          }
          fn()
          
          // this 就是 window

          ● 因為 this 是在 fn 函數內, 所以 fn 函數的調用方式就決定了這個 this 是什么

          function a() {
              function b() {
                  console.log(this)
              }
              b()
          }
          a()
          
          // this 就是 window

          ● 因為 this 是在 b 函數內, 所以 b 函數的調用方式決定了 this 是什么, 和 a 函數沒關系

          ● 就是這個意思

          ● 最后, 根據這些年的經驗總結給出一個私人的概念, 要牢記

          ○ 函數的 this

          ○ 和函數定義在哪沒關系

          ○ 和函數怎么定義沒關系

          ○ 只看這個函數的調用方式

          ○ 箭頭函數除外


          對象調用

          ● 對象調用, 就是利用一個對象作為宿主來調用函數

          ● 最簡單的方式就是把函數寫在一個對象內, 利用對象來調用

          // 對象內寫一個函數
          const obj={
              fn: function () { console.log(this) }
          }
          
          // 調用這個函數
          obj.fn()

          ● 這時候, 我們調用了和這個對象內的 fn 函數

          ● 調用方式就是利用對象調用的函數, 所以在這個函數內的 this 就是 obj 這個對象

          ● 換句話說, 只要在這個函數內, 只要出現 this 就是這個對象


          全局調用

          ● 顧名思義, 全局調用就是直接調用一個全局函數

          function fn() {
              console.log(this)
          }
          
          fn()

          ● 此時這個函數內的 this 就是 window

          ● 可能有的小伙伴覺得瘋了

          ● 但是我們仔細思考一下, 你會發現

          ● 其實 fn 因為是在全局上的, 那么其實調用的完整寫法可以寫成 window.fn()

          ● 此時就回到了之前對象調用那條路上, 這樣就通順了


          奇怪的調用

          ● 這個時候, 有的小伙伴可能會想到一個問題, 如果這個函數不放在全局呢 ?

          const obj={
              fn: function () {
                  function fun() {
                      console.log(this)
                  }
          
                  fun()
              }
          }
          
          obj.fn()

          ● 此時的 this 應該是什么呢 ?

          ● 按照之前的思路思考

          ○ obj.fn() 確實調用了函數, 但是 this 不是在 obj.fn 函數內, 是在 fun 函數內

          ○ fun() 確實也調用了函數, 但是我沒有辦法寫成 window.fun()

          ○ 那么 this 到底是不是 window 呢, 還是應該是 obj 內

          ● 答案確實是 window, 這又是為什么呢 ?


          捋一下思路

          ● 說道這里, 我們會發現

          ● this 真的是好奇怪哦 o(* ̄︶ ̄*)o 搞不定了

          ● 要是按照這個方式, 我來回來去的得記多少種, 誰會記得下來呢

          this 的個人經驗

          ● 首先, this 在各種不同的情況下會不一樣

          ● 那么從現在開始我把我總結的內容毫無保留的傳授給你


          經驗一 :

          ● 在 js 的非嚴格模式下適用

          ● 在非箭頭函數中適用

          ● 不管函數定義在哪, 不管函數怎么定義, 只看函數的調用方式

          ○ 只要我想知道 this 是誰

          ○ 就看這個 this 是寫在哪個函數里面

          ○ 這個函數是怎么被調用的


          觀察 this 在哪個函數內

          function fn() {
              console.log(this)
          }
          
          // this 在函數 fn 內, 就看 fn 函數是怎么被調用的就能知道 this 是誰
          const obj={
              fn: function () {
                  console.log(this)
              }
          }
          
          // this 在 obj.fn 函數內, 就看這個函數怎么被調用的就能知道 this 是誰
          const obj={
              fn: function () {
                  function fun() {
                      console.log(this)
                  }
              }
          }
          
          // 這個 this 是在 fun 函數內
          // 如果你想知道這個 this 是誰
          // 和 obj.fn 函數沒有關系, 只要知道 fun 函數是怎么被調用的就可以了

          ● 一定要注意 : 你想知道的 this 在哪個函數內, 就去觀察哪個函數的調用方式就好了


          一些常見的函數調用方式

          1.普通調用

          ● 調用方式 : 函數名()

          ● this 是 window

          ● 只要你書寫 "函數名()" 調用了一個函數, 那么這個函數內的 this 就是 window

          function fn() {
              console.log(this)
          }
          fn()
          // 這里就是 fn() 調用了一個函數, 那么 fn 內的 this 就是 window
          const obj={
              fn: function () {
                  function fun() {
                      console.log(this)
                  }
                  fun()
              }
          }
          obj.fn()
          // 這里的 this 因為是在 fun 函數內
          // fun() 就調用了這個 fun 函數
          // 所以不用管 fun 函數寫在了哪里
          // 這個 fun 函數內的 this 就是 window

          2.對象調用

          ● 調用方式:

          ○ 對象.函數名()

          ○ 對象['函數名']()

          ● this 就是這個對象, 對象叫啥, 函數內的 this 就叫啥

          const obj={
              fn: function () {
                  console.log(this)
              }
          }
          obj.fn()
          // 因為 obj.fn() 調用了這個函數, 所以 obj.fn 函數內的 this 就是 obj
          const xhl={
              fn: function () {
                  console.log(this)
              }
          }
          xhl.fn()
          // 因為 obj.fn() 調用了這個函數, 所以 xhl.fn 函數內的 this 就是 xhl
          function fn() {
              const xhl={
                  fn: function () {
                      console.log(this)
                  }
              }
              xhl.fn()
          }
          
          fn()
          // 因為我們要觀察的 this 是在 xhl.fn 這個函數內
          // 所以只需要關注這個函數是如何被調用的即可
          // 因為是 xhl.fn 調用了和這個函數, 所以函數內的 this 就是 xhl

          3.定時器調用

          ● 調用方式

          ○ setTimeout(function () {}, 1000)

          ○ setInterval(function () {}, 1000)

          ● this 就是 window

          ● 一個函數不管是怎么定義的, 只要被當做定時器處理函數使用, this 就是 widnow

          setTimeout(function () {
              console.log(this)
          }, 1000)
          // 這里的 this 就是 window
          setInterval(function () {
              console.log(this)
          }, 1000)
          // 這里的 this 就是 window
          const xhl={
              fn: function () {
                  console.log(this)
              }
          }
          
          setTimeout(xhl.fn, 1000)
          // 這里的 xhl.fn 函數不是直接書寫 xhl.fn() 調用的
          // 而是給到了 setTimeout 定時器處理函數
          // 所以這里的 this 就是 window

          4.事件處理函數

          ● 調用方式

          ○ 事件源.on事件類型=事件處理函數

          ○ 事件源.addEventListener(事件類型, 事件處理函數)

          ● this 就是 事件源

          ● 只要是作為事件處理函數使用, 那么該函數內的 this 就是 事件源

          奧,對了,事件就是:在事件中,當前操作的那個元素就是事件源

          box.onclick=function () {
              console.log(this)
          }
          // 這里的 this 就是 box
          box.addEventListener('click', function () {
              console.log(this)
          })
          // 這里的 this 就是 box
          const xhl={
              fn: function () {
                  console.log(this)
              }
          }
          
          box.addEventListener('click', xhl.fn)
          // 這里的 xhl.fn 函數不是直接書寫 xhl.fn() 調用的
          // 而是給到了 事件, 被當做了事件處理函數使用
          // 所以這里的 this 就是 事件源box
          const xhl={
              fn: function () {
                  console.log(this)
              }
          }
          
          box.onclick=xhl.fn
          // 這里的 xhl.fn 函數不是直接書寫 xhl.fn() 調用的
          // 而是給到了 事件, 被當做了事件處理函數使用
          // 所以這里的 this 就是 事件源box

          5.構造函數調用

          ● 調用方式

          ○ new 函數名()

          ● this 就是該構造函數的當前實例

          ● 只要和 new 關鍵字調用了, this 就是實例對象

          function fn() {
              console.log(this)
          }
          
          const f=new fn()
          // 這里的因為 fn 函數和 new 關鍵字在一起了
          // 所以這里的 this 就是 fn 函數的實例對象
          // 也就是 f
          const xhl={
              fn: function () {
                  console.log(this)
              }
          }
          
          const x=new xhl.fn()
          // 這里的 xhl.fn 也是因為和 new 關鍵字在一起了
          // 所以這里的 this 就是 xhl.fn 函數的實例對象
          // 也就是 x

          記清楚原則 :

          不管函數在哪定義

          不管函數怎么定義

          只看函數的調用方式

          經驗二 :

          ● 在嚴格模式下適用

          ● 其實只有一個

          ○ 全局函數沒有 this, 是 undefined

          ○ 其他的照搬經驗一就可以了


          1. 非嚴格模式

          // 非嚴格模式
          function fn() {
              console.log(this)
          }
          fn()
          // 因為是在非嚴格模式下, 這里的 this 就是 window

          2. 嚴格模式

          // 嚴格模式
          'use strict'
          function fn() {
              console.log(this)
          }
          fn()
          // 因為是在嚴格模式下, 這里的 this 就是 undefined

          記清楚原則 :

          嚴格模式下

          全局函數沒有 this

          是個 undefiend

          經驗三 :

          ● 專門來說一下箭頭函數

          ● 其實也只有一條

          ○ 推翻之前的所有內容

          ○ 箭頭函數內沒有自己的 this

          ○ 箭頭函數內的 this 就是外部作用域的 this

          ● 換句話說, 當你需要判斷箭頭函數內的 this 的時候

          ○ 和函數怎么調用沒有關系了

          ○ 要看函數定義在什么位置

          // 非箭頭函數
          const xhl={
              fn: function () {
                  console.log(this)
              }
          }
          xhl.fn()
          // 因為是 非箭頭函數, 所以這里的 this 就是 xhl
          
          //==========================================================// 箭頭函數
          const xhl={
              fn: ()=> {
                  console.log(this)
              }
          }
          xhl.fn()
          // 因為是 箭頭函數, 之前的經驗不適用了
          // 這個函數外部其實就是全局了, 所以這里的 this 就是 window
          // 非箭頭函數
          box.onclick=function () {
              console.log(this)
          }
          // 因為是 非箭頭函數, 這里的 this 就是 box
          
          //==========================================================// 箭頭函數
          box.onclick=()=> {
              console.log(this)
          }
          // 因為是 箭頭函數
          // 這個函數外部就是全局了, 所以這里的 this 就是 window
          // 非箭頭函數
          const obj={
              fn: function () {
                  function fun() {
                      console.log(this)
                  }
                  fun()
              }
          }
          obj.fn()
          // 因為是 非箭頭函數, 所以 fun 函數內的 this 就是 window
          
          //==========================================================// 箭頭函數
          const obj={
              fn: function () {
                  const fun=()=> {
                      console.log(this)
                  }
                  fun()
              }
          }
          obj.fn()
          // 因為是 箭頭函數
          // 那么這個 fun 外面其實就是 obj.fn 函數
          // 所以只要知道了 obj.fn 函數內的 this 是誰, 那么 fun 函數內的 this 就出來了
          // 又因為 obj.fn 函數內的 this 是 obj
          // 所以 fun 函數內的 this 就是 obj

          記清楚原則 :

          只要是箭頭函數

          不管函數怎么調用

          就看這個函數定義在了哪里


          最后

          ● 好了

          ● 按照以上三個經驗, 記清楚原則

          ● 那么在看到 this 就不慌了

          家好,很高興又見面了,我是姜茶的編程筆記,我們一起學習前端相關領域技術,共同進步,也歡迎大家關注、點贊、收藏、轉發,您的支持是我不斷創作的動力

          我們來聊聊箭頭函數(就是下面這個東西)!箭頭函數的語法比傳統的函數表達式更簡潔,而且它們沒有自己的 thisargumentssupernew.target。它們非常適合用在需要匿名函數的地方,同時不能作為構造函數使用。

          // 當只有一個參數時,圓括號不是必須的
          (singleParam)=> { statements }
          singleParam=> { statements }
          
          // 沒有參數的函數必須加圓括號
          ()=> { statements }
          

          箭頭函數有兩個主要優點:

          1?? 語法更簡潔

          2?? 不會綁定 this

          切入正題【特點】

          沒有自己的 this

          箭頭函數不會創建自己的 this,它只會繼承外層作用域的 this

          function Person() {
           this.age=0;
          
           setInterval(()=> {
            // this 正確地指向 p 實例
            console.log(this===p); // true
            this.age++;
           }, 1000);
          }
          
          var p=new Person();
          

          與嚴格模式的關系

          由于 this 是詞法綁定的,嚴格模式中與 this 相關的規則將被忽略。

          var f=()=> { 'use strict'; return this; };
          f()===window; // 或 global
          

          通過 call、apply 或 bind 調用

          因為箭頭函數沒有自己的 this,使用這些方法調用時只能傳遞參數,它們的第一個參數 this 會被忽略。

          let adder={
           base: 1,
           add: function (a) {
            console.log(this===adder); // true
            let f=(v)=> v + this.base;
            return f(a);
           },
           addThruCall: function (a) {
            let f=(v)=> {
             console.log(this===adder); // true
             console.log(`v 的值是 ${v},this.base 的值是 ${this.base}`); // 'v 的值是 1,this.base 的值是 1'
             return v + this.base;
            };
            let b={ base: 3 };
            // call() 方法不能綁定 this 為 b 對象,第一個參數 b 被忽略了
            return f.call(b, a);
           }
          };
          
          console.log(adder.add(1)); // 輸出 2
          console.log(adder.addThruCall(1)); // 輸出 2
          

          使用箭頭函數作為方法

          箭頭函數沒有 this 綁定。

          "use strict";
          var obj={
           i: 10,
           b: ()=> console.log(this.i, this), // undefined, Window{...}
           c: function () {
            console.log(this.i, this); // 10, Object {...}
           }
          };
          obj.b();
          obj.c();
          

          使用 new 操作符

          箭頭函數不能用作構造函數,用 new 調用會拋出錯誤。

          var Foo=()=> {};
          var foo=new Foo(); // TypeError: Foo is not a constructor
          

          作為匿名函數

          ES6 的箭頭函數表達式是匿名函數的一種簡寫方式:

          // 匿名函數
          let show=function () {
              console.log("匿名函數")
          };
          show(); // "匿名函數"
          
          let show1=()=> console.log("匿名函數");
          show1(); // "匿名函數"
          

          不過,箭頭函數和傳統匿名函數在實際操作中還是有一些區別的。

          最后

          如果你有任何問題或建議,歡迎在評論區留言交流!祝你編程愉快!


          主站蜘蛛池模板: 国产婷婷一区二区三区| 无码av不卡一区二区三区| 亚洲天堂一区二区三区| 日本免费一区尤物| 无码乱人伦一区二区亚洲| 日韩在线视频不卡一区二区三区 | 日本一区午夜艳熟免费| eeuss鲁片一区二区三区| 91成人爽a毛片一区二区| 亚洲色精品aⅴ一区区三区| 天天看高清无码一区二区三区| 中文字幕视频一区| 无码一区二区三区在线观看| 成人国产一区二区三区| 日韩精品一区二区三区中文字幕| 日本一区二区三区免费高清| 亚洲国产视频一区| 精品人伦一区二区三区潘金莲| 国产av天堂一区二区三区| 亚洲一区无码中文字幕| 中文字幕精品一区| 久久婷婷久久一区二区三区| 国产精品免费一区二区三区四区| 久久精品一区二区三区中文字幕| 亚洲第一区在线观看| 久久无码一区二区三区少妇| 成人区人妻精品一区二区不卡视频 | 日韩一区二区三区视频久久| 在线观看日本亚洲一区| 伊人久久一区二区三区无码| 久久99精品一区二区三区| 久久精品国产亚洲一区二区| 一区二区三区在线|欧| 午夜影院一区二区| 成人无号精品一区二区三区| 精品一区二区三区视频在线观看 | 无码一区二区三区在线观看| 精品人伦一区二区三区潘金莲| 一区二区三区在线观看| 日韩精品视频一区二区三区 | 国产女人乱人伦精品一区二区|