Search code examples
javascriptvue.jsmemory-managementgarbage-collection

How to avoid memory leaks when adding css to <head>


So I wrote this vue hook to inject global css programmatically

    const injectedStyles = new Set<string>()
    
    function useGlobalStyles(cssStyles: string) {
        let styleEl: HTMLStyleElement
    
        onBeforeMount(() => {
            if (injectedStyles.has(cssStyles)) {
                return
            } else {
                injectedStyles.add(cssStyles)
            }
    
            styleEl = document.createElement('style')
            styleEl.textContent = cssStyles
            document.head.appendChild(styleEl)
        })
    }

It works, but it will keep style tag in head even if component is unmounted. Fixing this is the difficult part for me. I think it would required keeping track of all component instances that depend on these particular cssStyles, but then wouldn't it prevent garbage collector from disposing of them?


Solution

  • One solution is a counter that tracks the number of consumers of a particular style:

    1. When a component using this hook mounts:
      • add the style to <head>
      • increment the counter
    2. When that component unmounts:
      • decrement the counter
      • if the resulting count is zero, remove the style from <head>

    Note this solution doesn't require tracking component instances, so there's no possibility of a memory leak here.

    import { onBeforeMount, onUnmounted } from 'vue'
    
    // map of injected CSS styles and their number of consumers
    const injectedStyles = {} as Record<string, number>
    
    function useGlobalStyles(cssStyles: string) {
        let styleEl: HTMLStyleElement
    
        onBeforeMount(() => {
            if (injectedStyles[cssStyles] === undefined) {
              injectedStyles[cssStyles] = 1
            } else {
              injectedStyles[cssStyles]++
    
              // style already injected, no need to do anything
              return
            }
    
            styleEl = document.createElement('style')
            styleEl.textContent = cssStyles
            document.head.appendChild(styleEl)
        })
    
        onUnmounted(() => {
            injectedStyles[cssStyles]--
            if (injectedStyles[cssStyles] <= 0) {
                delete injectedStyles[cssStyles]
                styleEl?.remove()
                styleEl = undefined
            }
        })
    }
    

    As a memory optimization, consider storing a hash of the styles in memory instead of the style strings themselves:

    import { onBeforeMount, onUnmounted } from 'vue'
    
    // https://stackoverflow.com/a/7616484/6277151
    const hashCode = (str: string) => {
        let hash = 0
        if (str.length === 0) return hash
        for (let i = 0; i < str.length; i++) {
            let chr = str.charCodeAt(i)
            hash = ((hash << 5) - hash) + chr
            hash |= 0 // Convert to 32bit integer
        }
        return hash
    }
    
    // map of injected CSS style hashes and their number of consumers
    const injectedStyles = {} as Record<string, number>
    
    function useGlobalStyles(cssStyles: string) {
        let styleEl: HTMLStyleElement
    
        const styleHash = hashCode(cssStyles)
    
        onBeforeMount(() => {
            if (injectedStyles[styleHash] === undefined) {
              injectedStyles[styleHash] = 1
            } else {
              injectedStyles[styleHash]++
    
              // style already injected, no need to do anything
              return
            }
    
            styleEl = document.createElement('style')
            styleEl.textContent = cssStyles
            document.head.appendChild(styleEl)
        })
    
        onUnmounted(() => {
            injectedStyles[styleHash]--
            if (injectedStyles[styleHash] <= 0) {
                delete injectedStyles[styleHash]
                styleEl?.remove()
                styleEl = undefined
            }
        })
    }