• micro-app-1-渲染篇


    本篇文章将告诉你如何实现子项目在基底下运行。

    官方文档地址

    https://github.com/micro-zoe/micro-app/issues/17

    自己实现地址

    https://github.com/wangyunteng/create-micro-app.git

    让你的头发我的头发转一转,转一个同心圆。
    让我们摸一摸永不凋谢的头发,开始出发
    
    • 1
    • 2
    微前端是什么,要做什么,它是怎么运行的,你总要先有个了解。
    实现原理:简单来说,它就是通过接口,将子项目的代码全部请求过来,在基座下面执行。
    此时就会引出几个问题
    1. 父子中的变量和监听函数在同一个window下,所以大家就会相互影响,此时应该如何分离,那就是代理,沙箱隔离
    2. 父子的css 元素也会被相互影响,此时你也要进行隔离处理。
    3. 父子如何进行通信
    这些问题,都需要一一解决,会在后续陆续解决。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    写的时候遇见问题

    程序员不是在创建bug,就是在修改bug的路上,最后蓦然回首bug原因,如此简单:

    1.发现项目一直在刷新

    请求到子项目,再次请求的时候使用的基座的地址,因为子项目的是相对路径,此时要改为绝对路径,或者使用src去拼接

    解决:

    1. 可以在基座应用上把publicPath改成 你的子应用的地址
    2. 在获取资源的时候,手动拼接url,将app.url和你的资源url拼接到一起再进行获取
    url = app.url.endsWith('/') ? app.url.substring(0,app.url.length - 1): app.url{url};
    
    • 1

    2.子项目直接把父项目给冲掉

    兄弟或者父子相同框架下,他们的模板代码相同,此时他们就会使用同一个元素开始项目启动。这样就会造成子项目将父亲项目给全盘替换的问题。

    解决:

    此时就需要微前端框架进行内部进行处理了,将相同框架造成的影响剔除掉。

    搭建基本框架,实现的基本功能点

    创建容器

    微前端的渲染是将子应用的js、css等静态资源加载到基座应用中执行,所以基座应用和子应用本质是同一个页面。这不同于iframe,iframe则是创建一个新的窗口,由于每次加载都要初始化整个窗口信息,所以iframe的性能不高。

    如同每个前端框架在渲染时都要指定一个根元素,微前端渲染时也需要指定一个根元素作为容器,这个根元素可以是一个div或其它元素。

    这里我们使用的是通过customElements创建的自定义元素,因为它不仅提供一个元素容器,还自带了生命周期函数,我们可以在这些钩子函数中进行加载渲染等操作,从而简化步骤。

    // /src/element.js
    
    // 自定义元素
    class MyElement extends HTMLElement {
      // 声明需要监听的属性名,只有这些属性变化时才会触发attributeChangedCallback
      static get observedAttributes () {
        return ['name', 'url']
      }
    
      constructor() {
        super();
      }
    
      connectedCallback() {
        // 元素被插入到DOM时执行,此时去加载子应用的静态资源并渲染
        console.log('micro-app is connected')
      }
    
      disconnectedCallback () {
        // 元素从DOM中删除时执行,此时进行一些卸载操作
        console.log('micro-app has disconnected')
      }
    
      attributeChangedCallback (attr, oldVal, newVal) {
        // 元素属性发生变化时执行,可以获取name、url等属性的值
        console.log(`attribute ${attrName}: ${newVal}`)
      }
    }
    
    /**
     * 注册元素
     * 注册后,就可以像普通元素一样使用micro-app,当micro-app元素被插入或删除DOM时即可触发相应的生命周期函数。
     */
    window.customElements.define('micro-app', MyElement)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    micro-app元素可能存在重复定义的情况,所以我们加一层判断,并放入函数中。

    // /src/element.js
    
    export function defineElement () {
      // 如果已经定义过,则忽略
      if (!window.customElements.get('micro-app')) {
        window.customElements.define('micro-app', MyElement)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    /src/index.js中定义默认对象SimpleMicroApp,引入并执行defineElement函数。

    // /src/index.js
    
    import { defineElement } from './element'
    
    const SimpleMicroApp = {
      start () {
        defineElement()
      }
    }
    
    export default SimpleMicroApp
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    引入simple-micro-app

    在vue2项目的main.js中引入simple-micro-app,执行start函数进行初始化。

    // vue2/src/main.js
    
    import SimpleMicroApp from 'simple-micro-app'
    
    SimpleMicroApp.start()
    
    • 1
    • 2
    • 3
    • 4
    • 5

    然后就可以在vue2项目中的任何位置使用micro-app标签。

    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    插入micro-app标签后,就可以看到控制台打印的钩子信息。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uqUCgZk5-1667875578230)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c89029ba97244332940483c4783eb0b1~tplv-k3u1fbpfcp-zoom-1.image)]

    以上我们就完成了容器元素的初始化,子应用的所有元素都会放入到这个容器中。接下来我们就需要完成子应用的静态资源加载及渲染。

    创建微应用实例

    很显然,初始化的操作要放在connectedCallback 中执行。我们声明一个类,它的每一个实例都对应一个微应用,用于控制微应用的资源加载、渲染、卸载等。

    // /src/app.js
    
    // 创建微应用
    export default class CreateApp {
      constructor () {}
    
      status = 'created' // 组件状态,包括 created/loading/mount/unmount
    
      // 存放应用的静态资源
      source = { 
        links: new Map(), // link元素对应的静态资源
        scripts: new Map(), // script元素对应的静态资源
      }
    
      // 资源加载完时执行
      onLoad () {}
    
      /**
       * 资源加载完成后进行渲染
       */
      mount () {}
    
      /**
       * 卸载应用
       * 执行关闭沙箱,清空缓存等操作
       */
      unmount () {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    我们在connectedCallback函数中初始化实例,将name、url及元素自身作为参数传入,在CreateApp的constructor中记录这些值,并根据url地址请求html。

    // /src/element.js
    import CreateApp, { appInstanceMap } from './app'
    
    ...
    connectedCallback () {
      // 创建微应用实例
      const app = new CreateApp({
        name: this.name,
        url: this.url,
        container: this,
      })
    
      // 记入缓存,用于后续功能
      appInstanceMap.set(this.name, app)
    }
    
    attributeChangedCallback (attrName, oldVal, newVal) {
      // 分别记录name及url的值
      if (attrName === 'name' && !this.name && newVal) {
        this.name = newVal
      } else if (attrName === 'url' && !this.url && newVal) {
        this.url = newVal
      }
    }
    ...
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    在初始化实例时,根据传入的参数请求静态资源。

    // /src/app.js
    import loadHtml from './source'
    
    // 创建微应用
    export default class CreateApp {
      constructor ({ name, url, container }) {
        this.name = name // 应用名称
        this.url = url  // url地址
        this.container = container // micro-app元素
        this.status = 'loading'
        loadHtml(this)
      }
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    请求html

    我们使用fetch请求静态资源,好处是浏览器自带且支持promise,但这也要求子应用的静态资源支持跨域访问。

    // src/source.js
    
    export default function loadHtml (app) {
      fetch(app.url).then((res) => {
        return res.text()
      }).then((html) => {
        console.log('html:', html)
      }).catch((e) => {
        console.error('加载html出错', e)
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    因为请求js、css等都需要使用到fetch,所以我们将它提取出来作为公共方法。

    // /src/utils.js
    
    /**
     * 获取静态资源
     * @param {string} url 静态资源地址
     */
    export function fetchSource (url) {
      return fetch(url).then((res) => {
        return res.text()
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    重新使用封装后的方法,并对获取到到html进行处理。

    // src/source.js
    import { fetchSource } from './utils'
    
    export default function loadHtml (app) {
      fetchSource(app.url).then((html) => {
        html = html
          .replace(/]*>[\s\S]*?/i, (match) => {
            // 将head标签替换为micro-app-head,因为web页面只允许有一个head标签
            return match
              .replace(//i, '')
          })
          .replace(/]*>[\s\S]*?/i, (match) => {
            // 将body标签替换为micro-app-body,防止与基座应用的body标签重复导致的问题。
            return match
              .replace(//i, '')
          })
    
        // 将html字符串转化为DOM结构
        const htmlDom = document.createElement('div')
        htmlDom.innerHTML = html
        console.log('html:', htmlDom)
    
        // 进一步提取和处理js、css等静态资源
        extractSourceDom(htmlDom, app)
      }).catch((e) => {
        console.error('加载html出错', e)
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    html格式化后,我们就可以得到一个DOM结构。从下图可以看到,这个DOM结构包含link、style、script等标签,接下来就需要对这个DOM做进一步处理。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RhSt9MHQ-1667875578232)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1fe4cefd9ec244778590b3cfb4cb1ed7~tplv-k3u1fbpfcp-zoom-1.image)]

    提取js、css等静态资源地址

    我们在extractSourceDom方法中循环递归处理每一个DOM节点,查询到所有link、style、script标签,提取静态资源地址并格式化标签。

    // src/source.js
    
    /**
     * 递归处理每一个子元素
     * @param parent 父元素
     * @param app 应用实例
     */
    function extractSourceDom(parent, app) {
      const children = Array.from(parent.children)
      
      // 递归每一个子元素
      children.length && children.forEach((child) => {
        extractSourceDom(child, app)
      })
    
      for (const dom of children) {
        if (dom instanceof HTMLLinkElement) {
          // 提取css地址
          const href = dom.getAttribute('href')
          if (dom.getAttribute('rel') === 'stylesheet' && href) {
            // 计入source缓存中
            app.source.links.set(href, {
              code: '', // 代码内容
            })
          }
          // 删除原有元素
          parent.removeChild(dom)
        } else if (dom instanceof HTMLScriptElement) {
          // 并提取js地址
          const src = dom.getAttribute('src')
          if (src) { // 远程script
            app.source.scripts.set(src, {
              code: '', // 代码内容
              isExternal: true, // 是否远程script
            })
          } else if (dom.textContent) { // 内联script
            const nonceStr = Math.random().toString(36).substr(2, 15)
            app.source.scripts.set(nonceStr, {
              code: dom.textContent, // 代码内容
              isExternal: false, // 是否远程script
            })
          }
    
          parent.removeChild(dom)
        } else if (dom instanceof HTMLStyleElement) {
          // 进行样式隔离
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49

    请求静态资源

    上面已经拿到了html中的css、js等静态资源的地址,接下来就是请求这些地址,拿到资源的内容。

    接着完善loadHtml,在extractSourceDom下面添加请求资源的方法。

    // src/source.js
    ...
    export default function loadHtml (app) {
      ...
      // 进一步提取和处理js、css等静态资源
      extractSourceDom(htmlDom, app)
    
      // 获取micro-app-head元素
      const microAppHead = htmlDom.querySelector('micro-app-head')
      // 如果有远程css资源,则通过fetch请求
      if (app.source.links.size) {
        fetchLinksFromHtml(app, microAppHead, htmlDom)
      } else {
        app.onLoad(htmlDom)
      }
    
      // 如果有远程js资源,则通过fetch请求
      if (app.source.scripts.size) {
        fetchScriptsFromHtml(app, htmlDom)
      } else {
        app.onLoad(htmlDom)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    fetchLinksFromHtmlfetchScriptsFromHtml分别请求css和js资源,请求资源后的处理方式不同,css资源会转化为style标签插入DOM中,而js不会立即执行,我们会在应用的mount方法中执行js。

    两个方法的具体实现方式如下:

    // src/source.js
    /**
     * 获取link远程资源
     * @param app 应用实例
     * @param microAppHead micro-app-head
     * @param htmlDom html DOM结构
     */
     export function fetchLinksFromHtml (app, microAppHead, htmlDom) {
      const linkEntries = Array.from(app.source.links.entries())
      // 通过fetch请求所有css资源
      const fetchLinkPromise = []
      for (const [url] of linkEntries) {
        fetchLinkPromise.push(fetchSource(url))
      }
    
      Promise.all(fetchLinkPromise).then((res) => {
        for (let i = 0; i < res.length; i++) {
          const code = res[i]
          // 拿到css资源后放入style元素并插入到micro-app-head中
          const link2Style = document.createElement('style')
          link2Style.textContent = code
          microAppHead.appendChild(link2Style)
    
          // 将代码放入缓存,再次渲染时可以从缓存中获取
          linkEntries[i][1].code = code
        }
    
        // 处理完成后执行onLoad方法
        app.onLoad(htmlDom)
      }).catch((e) => {
        console.error('加载css出错', e)
      })
    }
    
    /**
     * 获取js远程资源
     * @param app 应用实例
     * @param htmlDom html DOM结构
     */
     export function fetchScriptsFromHtml (app, htmlDom) {
      const scriptEntries = Array.from(app.source.scripts.entries())
      // 通过fetch请求所有js资源
      const fetchScriptPromise = []
      for (const [url, info] of scriptEntries) {
        // 如果是内联script,则不需要请求资源
        fetchScriptPromise.push(info.code ? Promise.resolve(info.code) :  fetchSource(url))
      }
    
      Promise.all(fetchScriptPromise).then((res) => {
        for (let i = 0; i < res.length; i++) {
          const code = res[i]
          // 将代码放入缓存,再次渲染时可以从缓存中获取
          scriptEntries[i][1].code = code
        }
    
        // 处理完成后执行onLoad方法
        app.onLoad(htmlDom)
      }).catch((e) => {
        console.error('加载js出错', e)
      })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    上面可以看到,css和js加载完成后都执行了onLoad方法,所以onLoad方法被执行了两次,接下来我们就要完善onLoad方法并渲染微应用。

    渲染

    因为onLoad被执行了两次,所以我们进行标记,当第二次执行时说明所有资源都加载完成,然后进行渲染操作。

    // /src/app.js
    
    // 创建微应用
    export default class CreateApp {
      ...
      // 资源加载完时执行
      onLoad (htmlDom) {
        this.loadCount = this.loadCount ? this.loadCount + 1 : 1
        // 第二次执行且组件未卸载时执行渲染
        if (this.loadCount === 2 && this.status !== 'unmount') {
          // 记录DOM结构用于后续操作
          this.source.html = htmlDom
          // 执行mount方法
          this.mount()
        }
      }
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    mount方法中将DOM结构插入文档中,然后执行js文件进行渲染操作,此时微应用即可完成基本的渲染。

    // /src/app.js
    
    // 创建微应用
    export default class CreateApp {
      ...
      /**
       * 资源加载完成后进行渲染
       */
      mount () {
        // 克隆DOM节点
        const cloneHtml = this.source.html.cloneNode(true)
        // 创建一个fragment节点作为模版,这样不会产生冗余的元素
        const fragment = document.createDocumentFragment()
        Array.from(cloneHtml.childNodes).forEach((node) => {
          fragment.appendChild(node)
        })
    
        // 将格式化后的DOM结构插入到容器中
        this.container.appendChild(fragment)
    
        // 执行js
        this.source.scripts.forEach((info) => {
          (0, eval)(info.code)
        })
    
        // 标记应用为已渲染
        this.status = 'mounted'
      }
      ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    以上步骤完成了微前端的基本渲染操作,我们看一下效果。

    卸载

    当micro-app元素被删除时会自动执行生命周期函数disconnectedCallback,我们在此处执行卸载相关操作。

    // /src/element.js
    
    class MyElement extends HTMLElement {
      ...
      disconnectedCallback () {
        // 获取应用实例
        const app = appInstanceMap.get(this.name)
        // 如果有属性destory,则完全卸载应用包括缓存的文件
        app.unmount(this.hasAttribute('destory'))
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    接下来完善应用的unmount方法:

    // /src/app.js
    
    export default class CreateApp {
      ...
      /**
       * 卸载应用
       * @param destory 是否完全销毁,删除缓存资源
       */
      unmount (destory) {
        // 更新状态
        this.status = 'unmount'
        // 清空容器
        this.container = null
        // destory为true,则删除应用
        if (destory) {
          appInstanceMap.delete(this.name)
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    当destory为true时,删除应用的实例,此时所有静态资源失去了引用,自动被浏览器回收。

  • 相关阅读:
    6.5 XSS 获取 Cookie 攻击
    添砖Java之路(其六)——通过集合制作的学生信息管理系统
    leetcode 二分查找·系统掌握 寻找旋转排序数组中的最小值II
    java学习--day18(TreeSet底层&内部类)
    Halcon WPF 开发学习笔记(3):WPF+Halcon初步开发
    vue框架的基础语法之方法和事件的绑定,样式绑定,循环渲染,条件渲染
    java考点之程序看界面响应
    .NET微信网页开发相关文章教程
    TiDB Dashboard 慢查询页面
    Imazing2023免费版苹果手机iOS数据管理软件
  • 原文地址:https://blog.csdn.net/weixin_43856422/article/details/127746385