likes
comments
collection
share

前端进阶 - 闭包详解与实战

作者站长头像
站长
· 阅读数 15

理解闭包

百度百科中对闭包有如下定义:

闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

应用闭包的意义就在于可以在函数运行完成后,继续维持函数内局部变量的生命周期,从而能在函数外部使用函数内部的局部变量。

简单的闭包

const useCount = () => {
  let count = 1
  
  const increment = () => {
    count++
  }
  
  const getCount = () => {
    return count
  }
  
  return {
    increment,
    getCount
  }
}

const { increment, getCount } = useCount()
console.log(getCount()) // 1
increment()
console.log(getCount()) // 2

useCount 中的 incrementgetCount 两个函数就是典型的闭包。如果没有定义这两个函数或者只是定义了这两个函数而没把它们作为返回值,那么在 useCount 函数执行完毕时,其内部定义的局部变量 count 的生命周期就会结束,它所占的内存空间就会被系统回收。由于闭包的存在,count 的生命周期延续到了 useCount 函数执行完毕之后,从而可在函数外部被操作和访问。

原理剖析

在程序中,变量存放的位置有全局空间(堆结构)和局部空间(栈结构),在 C++ 中,默认情况下定义的变量都存放在局部空间中,在函数执行完毕后自动释放其内存空间。而通过 malloc 函数或 new 关键字定义的变量会放到全局空间,会在程序运行之时一直存在,只能通过 free 函数或 delete 关键字来手动释放其内存空间。

而在 javascript 中,所有变量都是通过同样的关键字来定义,在程序在执行时,通过检测变量是否被闭包所引用来决定变量是存放到全局空间还是局部空间。被闭包引用的变量就会被存放到全局空间,从而能在函数执行完毕后继续存在。

C++ 相比,javascript 屏蔽了很多底层实现细节,这也正是为什么 javascript 被称为入门容易精通难的语言,无论变量最终存放到哪里,开发者都只需要 let count = 1 这一句代码来定义,降低了开发门槛,但是当开发者定义了一个变量后,可能并不知道变量存到到全局空间还是局部空间。对于经验不足的开发者来说可能会无意间滥用闭包,从而使程序过度占用大量内存,而一直无法释放,最终导致程序崩溃。

深入探讨

场景一:

window.onload = () => {
  const root = document.querySelector('#app')
  // 使用var关键字
  for (var i = 1; i <= 5; ++i) {
    const button = document.createElement('button')
    button.innerText = `button ${i}`
    button.addEventListener('click', () => {
      console.log(i)
    })
    root.appendChild(button)
  }
}

场景二:

window.onload = () => {
  const root = document.querySelector('#app')
  // 使用let关键字
  for (let i = 1; i <= 5; ++i) {
    const button = document.createElement('button')
    button.innerText = `button ${i}`
    button.addEventListener('click', () => {
      console.log(i)
    })
    root.appendChild(button)
  }
}

在场景一中,当我们点击按钮时,5 个按钮都会打印出 6,而在场景二中会分别打出 1 ~ 5。

造成这种差异的原因在于 varlet 定义的变量有着不同的作用域,var 定义的变量作用域为整个 onload 函数,在 for 循环中用 let 定义的变量,每次循环都有其独立的作用域。闭包将其所使用的变量存放到全局空间的时机并不是定义变量的时候,而是变量在其生命周期即将结束的时候。 var 定义的变量 ionload 函数执行完毕时值为 6,所以闭包(按钮点击事件)中 i 的值也为 6,而 let 定义的变量 i 在每次循环结束时的值分别为 1 ~ 5,所以闭包中 i 的值也分别为 1 ~ 5。

基于以上原理,将场景一做个小改动:

window.onload = () => {
  const root = document.querySelector('#app')
  // 使用var关键字
  for (var i = 1; i <= 5; ++i) {
    const button = document.createElement('button')
    button.innerText = `button ${i}`
    // 使用立即执行函数
    button.addEventListener(
      'click',
      (function (i) {
        return () => console.log(i)
      })(i)
    )
    root.appendChild(button)
  }
}

此时点击按钮,就能正确地打印 1 ~ 5,因为此时闭包使用变量 i 已经不再是 for 循环中 var 定义的变量 i 了,而是匿名函数(立即执行函数)中的参数 i,参数 i 在匿名函数执行完时的值分别为 1 ~ 5,所以闭包中 i 的值也分别为 1 ~ 5。

再对场景二做个小改动:

window.onload = () => {
  const root = document.querySelector('#app')
  // 将i的定义提到for循环以外
  let i
  for (i = 1; i <= 5; ++i) {
    const button = document.createElement('button')
    button.innerText = `button ${i}`
    button.addEventListener('click', () => {
      console.log(i)
    })
    root.appendChild(button)
  }
}

let 定义变量提到 for 循环以外,这种写法基本等同于场景一中在 for 循环中用 var 来定义变量。所以此时 i 生命周期结束是在 onload 函数执行完毕时,闭包中 i 的值为 6。

闭包实战

下面是一个 vue 的例子:

// useCount.ts
export const useCount = (initialCount: number = 0) => {
  const count = ref(initialCount)

  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
}

// Root.tsx
export default defineComponent({
  name: 'Root',
  setup() {
    const { count, increment } = useCount(10)
    return () => {
      return (
        <div>
          <button onClick={increment}>点击</button>
          <div>count: {count.value}</div>
        </div>
      )
    }
  }
})

假如我们要在多个组件中共用同一个 count,就可以在 useCount 之外再封装一个辅助函数,将其返回的对象赋给辅助函数的局部变量,再利用闭包将这个局部变量返回出来供外部使用。这样就能在所有组件中共用同一个变量。

参考 createSharedComposable | VueUse

// createSharedState.ts
type AnyFn = (...args: any) => any

export default function createSharedState<Fn extends AnyFn>(
  hook: Fn
): Fn {
  let store: any = null
  const func = (...args: any) => {
    // 只有第一次使用的时候初始化
    if (!store) {
      store = hook(...args)
    }
    return store
  }
  return func as Fn
}

// useCount.ts
export const useCount = (initialCount: number = 0) => {
  const count = ref(initialCount)

  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
}

export const useSharedCount = createSharedStore(useCount)

// Root.tsx
export default defineComponent({
  name: 'Root',
  setup() {
    const { count, increment } = useSharedCount(10)
    return () => {
      return (
        <div>
          <button onClick={increment}>点击</button>
          <div>count: {count.value}</div>
          <Child />
        </div>
      )
    }
  }
})

// Child.tsx
export default defineComponent({
  name: 'Child',
  setup() {
    const { doubleCount } = useSharedCount()
    return () => {
      return (
        <div>count: {doubleCount.value}</div>
      )
    }
  }
})

现在 Root 组件和 Child 组件就可以共享同一个 hook 函数中的数据了。

正确使用闭包

在上述例子中,虽然利用闭包解决了数据共享的问题,但是闭包占用的内存空间会一直得不到释放,如果在网站中大量使用 createSharedStore 函数来创建共享型 hook 函数,有可能造成性能问题。下面有两种思路来解决内存回收的问题。

手动释放

利用 createSharedStore 函数创建共享型 hook 函数时,为返回对象添加一个释放内存的方法,从而能在合适的时机手动释放内存。

// createSharedState.ts
type WithFreeFunc<P extends any[], R> = (...args: P) => R & {
  free: () => void
}

export default function createSharedState<P extends any[], R>(
  hook: (...args: P) => R
): WithFreeFunc<P, R> {
  let store: any = null
  const func = (...args: P) => {
    if (!store) {
      store = hook(...args)
    }
    return {
      ...store,
      // 添加手动释放内存方法
      free() {
        store = null
      }
    } as ReturnType<WithFreeFunc<P, R>>
  }
  return func
}

// Root.tsx
export default defineComponent({
  name: 'Root',
  setup() {
    const { count, increment, free } = useSharedCount(10)
    
    // Root组件销毁时手动释放内存
     onUnmounted(() => {
      free()
    })
    
    return () => {
      return (
        <div>
          <button onClick={increment}>点击</button>
          <div>count: {count.value}</div>
          <Child />
        </div>
      )
    }
  }
})

局部共享

利用 vue 中的 provideinject 函数来创建一个局部共享的数据,只有执行 provide 的组件的子孙组件才能访问该数据,在 provide 根组件销毁时自动释放内存。

参考 createInjectionState | VueUse

// createInjectionState.ts
export default function createInjectionState<Arguments extends any[], Return>(
  hook: (...argd: Arguments) => Return
): readonly [(...args: Arguments) => Return, () => Return | undefined] {
  let store: any = null
  // 生成唯一ID
  const key = nanoid()
  const provideFunc = (...args: Arguments) => {
    store = composable(...args)
    provide(key, store)

    // provide的根组件销毁时自动释放内存
    onUnmounted(() => {
      store = null
    })

    return store as Return
  }
  
  const injectFunc = () => {
    return inject<Return>(key)
  }
  
  return [provideFunc, injectFunc]
}

// useCount.ts
export const useCount = (initialCount: number = 0) => {
  const count = ref(initialCount)

  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
}

export const [useProvideCount, useInjectCount] = createInjectionState(useCount)

// Root.tsx
export default defineComponent({
  name: 'Root',
  setup() {
    const { count, increment } = useProvideCount(10)
    return () => {
      return (
        <div>
          <button onClick={increment}>点击</button>
          <div>count: {count.value}</div>
          <Child />
        </div>
      )
    }
  }
})

// Child.tsx
export default defineComponent({
  name: 'Child',
  setup() {
    const { doubleCount } = useInjectCount()
    return () => {
      return (
        <div>count: {doubleCount.value}</div>
      )
    }
  }
})