整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

手把手教你写一个简易的微前端框架

手把手教你写一个简易的微前端框架

近看了几个微前端框架的源码(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(); // "匿名函数"

不过,箭头函数和传统匿名函数在实际操作中还是有一些区别的。

最后

如果你有任何问题或建议,欢迎在评论区留言交流!祝你编程愉快!