兼容 IE 的换肤利器——Varx

1,091 阅读5分钟

梦想和现实只有一步之遥!

前言

最近公司项目需要实现动态换肤功能,脑子里面瞬间出现 css var 方案,使用简单,易上手,紧接着需要兼容 ie 的需求出现 css var 方案宣告破产!

通过调研发现当前社区主流的方案都是通过 css 预处理器来实现换肤,对于动态换肤也是上 css var 的,ε(┬┬﹏┬┬)3。于是,我有了一个大胆的想法,自己造轮子,不就是适配 ie 吗,有何难!

6EBC7A73.gif

相关链接

TODO

  • 使用简单,与 css var 尽量不冲突
  • ie 支持 css var 功能
  • vue2vue3react 等主流框架打通

初步调研

说干就干,当即调研了一下需要在 ie 实现 css var 的难点:

  • css var 语法不支持,行内使用会被解析时过滤掉
  • 存在于 style 标签内的样式虽然会存在,但是通过 styleSheet 获取样式规则仍然会被过滤
  • 如何实现按需更新且保证样式不会错乱

解决思路

js 中存在低版本兼容的 pollyfill,但是 css 并不存在类似的工具,所以决定开发一个用 js 来提供对 css varpollyfill 工具。

思路实现

存储中心

创建存储中心,通过使用提供的编码函数来收集所用到的 css var 变量。灵感来自于脏检测机制,通过固定的使用方式来感知 css var 的变更情况,基于插件机制暴露部分钩子函数,类似于 vue 的组件生命周期,即存储中心只为存储变量及提供相关函数,内部不具备其他功能,仅支持通过插件扩展。

class Varx {
  private static _instanceCount = 0
  /**
   * varx instance count
   */
  static get instanceCount() {
    return this._instanceCount
  }

  private _isVarx: boolean
  /**
   * Indicate whether it is currently a varx instance.
   */
  get isVarx() {
    return this._isVarx
  }

  private _id: number
  /**
   * varx instance id, it's only.
   */
  get id() {
    return this._id
  }

  private _namespace: string
  /**
   * namespace for varx
   */
  get namespace() {
    return this._namespace
  }

  private _strict: boolean
  /**
   * strict mode for varx
   */
  get strict() {
    return this._strict
  }

  private _keyFormatFn: KeyFormatFn | null
  /**
   * format function fro varx key
   */
  get keyFormatFn() {
    return this._keyFormatFn
  }

  /**
   * namespace and key join string
   */
  get joinStr() {
    return this.namespace ? '__' : ''
  }

  /**
   * namespace processed with join string
   */
  get processNamespace() {
    return this.namespace + this.joinStr
  }

  private _state: VarState
  /**
   * varx var storage center
   */
  get state() {
    return this._state
  }

  private _updateLocked: boolean

  private _onBeforeUpdateCallbacks: HookFunc[]

  private _onUpdatedCallbacks: HookFunc[]

  private _destroyLocked: boolean

  private _onBeforeDestroyCallbacks: HookFunc[]

  private _onDestroyedCallbacks: HookFunc[]

  private _accessLocked: boolean

  private _onAccessStateCallbacks: HookFunc[]

  constructor(options?: VarxInitOptions) {
    const { plugins = [] } = options ?? {}

    this._state = Object.create(null)
    this._namespace = safeValue(options?.namespace, 'string', '')
    this._strict = safeValue(options?.strict, 'boolean', false)
    this._keyFormatFn = safeValue(options?.keyFormatFn, 'function', null)
    this._updateLocked = false
    this._onBeforeUpdateCallbacks = []
    this._onUpdatedCallbacks = []
    this._destroyLocked = false
    this._onBeforeDestroyCallbacks = []
    this._onDestroyedCallbacks = []
    this._accessLocked = false
    this._onAccessStateCallbacks = []
    this._isVarx = true
    this._id = Varx._instanceCount++

    const { accessState, _onUpdateState, _onDestroy } = this
    const that = this

    // bind this
    this.accessState = function (...args: Parameters<typeof accessState>) {
      accessState.apply(that, args)
    }
    this._onUpdateState = function (...args: Parameters<typeof _onUpdateState>) {
      _onUpdateState.apply(that, args)
    }
    this._onDestroy = function (...args: Parameters<typeof _onDestroy>) {
      _onDestroy.apply(that, args)
    }

    Promise.resolve().then(() => plugins.forEach((plugin) => plugin(this)))
  }

  accessState() {
    if (this._accessLocked) return

    Promise.resolve().then(() => {
      invokeCallbacks(this._onAccessStateCallbacks, this)

      this._accessLocked = false
    })
  }

  /**
   * Trigger callback event when access state
   *
   * @param fn callback function for access state
   */
  onAccessState(fn: HookFunc) {
    if (typeof fn !== 'function' || this._onAccessStateCallbacks.includes(fn)) return

    this._onAccessStateCallbacks.push(fn)
  }

  /**
   * destroy resources
   */
  destroy() {
    // on destroy locked
    if (this._destroyLocked) return

    // lock destroy
    this._destroyLocked = true

    // Start a micro task execute update queues.
    // Prevent block main process.
    Promise.resolve().then(() => {
      const beforeDestroyCallbacks = this._onBeforeDestroyCallbacks.splice(0)
      const destroyedCallbacks = this._onDestroyedCallbacks.splice(0)

      // before destroy
      invokeCallbacks(beforeDestroyCallbacks, this)

      // destroying
      invokeCallbacks([this._onDestroy])

      // after destroy
      invokeCallbacks(destroyedCallbacks, this)

      // unlock destroy
      this._destroyLocked = false
    })
  }

  /**
   * Trigger callback event when before destroy
   *
   * @param fn callback function for before destroy
   */
  onBeforeDestroy(fn: HookFunc) {
    if (typeof fn !== 'function' || this._onBeforeDestroyCallbacks.includes(fn)) return

    this._onBeforeDestroyCallbacks.push(fn)
  }

  private _onDestroy() {
    this._namespace = ''
    this._state = Object.create(null)
    this._keyFormatFn = null
    this._onBeforeUpdateCallbacks = []
    this._onUpdatedCallbacks = []
    this._onBeforeDestroyCallbacks = []
    this._onDestroyedCallbacks = []
  }

  /**
   * Trigger callback event when destroyed
   *
   * @param fn callback function for destroyed
   */
  onDestroyed(fn: HookFunc) {
    if (typeof fn !== 'function' || this._onDestroyedCallbacks.includes(fn)) return

    this._onDestroyedCallbacks.push(fn)
  }

  /**
   * Remove before destroy callback
   *
   * @param fn callback function for destroyed
   */
  removeBeforeDestroyCallback(fn: HookFunc) {
    if (typeof fn !== 'function') return

    remove(this._onBeforeDestroyCallbacks, fn)
  }

  /**
   * Remove destroyed callback
   *
   * @param fn callback function for destroyed
   */
  removeDestroyedCallback(fn: HookFunc) {
    if (typeof fn !== 'function') return

    remove(this._onDestroyedCallbacks, fn)
  }

  /**
   *  update state
   *
   * @param state state for update
   * @param eventType trigger update event type
   */
  updateState(state: VarStateParameter, eventType: StateEventType = 'update') {
    // On update locked, start a micro task execute updateState function.
    if (this._updateLocked) return

    logger.assert(
      isObject(state),
      'The state of parameter for updateState function must be an object.'
    )

    // lock updateState
    this._updateLocked = true

    // Start a micro task execute update queues
    // Prevent block main process
    Promise.resolve().then(() => {
      // before update
      invokeCallbacks(this._onBeforeUpdateCallbacks, this)

      // updating
      invokeCallbacks([this._onUpdateState], state, eventType)

      // after update
      invokeCallbacks(this._onUpdatedCallbacks, this)

      // unlock updateState
      this._updateLocked = false
    })
  }

  /**
   * Trigger callback event when before update
   *
   * @param fn callback function for before update
   */
  onBeforeUpdateState(fn: HookFunc) {
    if (typeof fn !== 'function' || this._onBeforeUpdateCallbacks.includes(fn)) return

    this._onBeforeUpdateCallbacks.push(fn)
  }

  private _onUpdateState(updateState: VarStateParameter, eventType: StateEventType = 'update') {
    objectEach(updateState, (v, k) => {
      // get current state value
      const oldValue = this._state[k]?.value
      const value = this.safeValue(v, k, this._state[k])

      // only on update set to state
      if (eventType === 'update') {
        this._state[k] = value
      }

      // Only on key not in state or value not equal oldValue emit onUpdate.
      if (value.value !== oldValue) {
        value.onUpdate?.(value.value, k, this, eventType)
      }
    })
  }

  /**
   * Trigger callback event when updated
   *
   * @param fn callback function for updated
   */
  onUpdatedState(fn: HookFunc) {
    if (typeof fn !== 'function' || this._onUpdatedCallbacks.includes(fn)) return

    this._onUpdatedCallbacks.push(fn)
  }

  /**
   * Remove before update callback
   *
   * @param fn callback function for before update
   */
  removeBeforeUpdateStateCallback(fn: HookFunc) {
    if (typeof fn !== 'function') return

    remove(this._onBeforeUpdateCallbacks, fn)
  }

  /**
   * Remove updated callback
   *
   * @param fn callback function for updated
   */
  removeUpdatedStateCallback(fn: HookFunc) {
    if (typeof fn !== 'function') return

    remove(this._onUpdatedCallbacks, fn)
  }

  /**
   * encode key
   *
   * @param key will be encoded key
   *
   * @returns encoded key
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.encodeKey('bg') // demo__bg
   * ```
   */
  encodeKey(key: string = '') {
    logger.assert(
      typeof key === 'string',
      'The key of parameter for encodeKey function must be a string.'
    )

    const _key = this._strict ? key : this.decodeKey(key)
    return this.processNamespace + (this._keyFormatFn?.(_key) ?? _key)
  }

  /**
   * decode key
   *
   * @param key will be decoded key
   *
   * @returns decoded key
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.decodeKey('demo__bg') // bg
   * ```
   */
  decodeKey(key: string = '') {
    logger.assert(
      typeof key === 'string',
      'The key of parameter for decodeKey function must be a string.'
    )

    return key.startsWith(this.processNamespace) ? key.slice(this.processNamespace.length) : key
  }

  /**
   * encode var key
   *
   * @param key will be encoded var key
   *
   * @returns encoded var key
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.encodeVarKey('bg') // --demo__bg
   * ```
   */
  encodeVarKey(key: string = '') {
    logger.assert(
      typeof key === 'string',
      'The key of parameter for encodeVarKey function must be a string.'
    )

    return `--${this.encodeKey(this.decodeVarKey(key))}`
  }

  /**
   * decode var key
   *
   * @param key will be decoded var key
   *
   * @returns decoded var key
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.decodeVarKey('--demo__bg') // bg
   * ```
   */
  decodeVarKey(key: string = '') {
    logger.assert(
      typeof key === 'string',
      'The key of parameter for decodeVarKey function must be a string.'
    )

    const _namespace = `--${this.processNamespace}`
    return key.startsWith(_namespace) ? key.slice(_namespace.length) : key
  }

  /**
   * encode var obj
   *
   * @param key will be encoded var obj
   * @param emitUpdate emit update event when encoded var obj
   *
   * @returns encoded var obj
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.encodeVarObj({ bg: 'red' }) // { '--demo__bg': 'red' }
   * ```
   */
  encodeVarObj(obj: VarStateParameter, emitUpdate = true) {
    logger.assert(
      isObject(obj),
      'The obj of parameter for encodeVarObj function must be an object.'
    )

    const varObj: Record<string, VarStateValueType> = {}
    const tempVarState: VarState = {}
    objectEach(obj, (v, k) => {
      const key = this.encodeVarKey(k)
      const value = this.safeValue(v, key)

      varObj[key] = value.value
      tempVarState[key] = value
    })

    if (emitUpdate) {
      this.updateState(tempVarState)
    } else {
      this._onUpdateState(tempVarState, 'update')
    }

    return varObj
  }

  /**
   * decode var obj
   *
   * @param key will be decoded var obj
   *
   * @returns decoded var obj
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.decodeVarObj({ '--demo__bg': 'red' }) // { bg: 'red' }
   * ```
   */
  decodeVarObj(varObj: VarStateParameter) {
    logger.assert(
      isObject(varObj),
      'The varObj of parameter for decodeVarObj function must be an object.'
    )

    const obj: Record<string, VarStateValueType> = {}
    objectEach(varObj, (v, k) => {
      obj[this.decodeVarKey(k)] = this.safeValue(v, k).value
    })

    return obj
  }

  /**
   * encode var tuple
   *
   * @param key will be encoded var tuple
   * @param emitUpdate emit update event when encoded var tuple
   *
   * @returns encoded var tuple
   *
   * @example
   * ```js
   * const varx = new Varx({ namespace: 'demo' })
   * varx.encodeVarTuple('bg', 'red') // ['--demo__bg', 'red', 'var(--demo__bg)']
   * ```
   */
  encodeVarTuple(key: string, value: VarStateParameter, emitUpdate = true): VarTuple {
    const _key = this.encodeVarKey(key)
    const _value = this.safeValue(value, _key)
    const tempState = { [_key]: _value }

    if (emitUpdate) {
      this.updateState(tempState)
    } else {
      this._onUpdateState(tempState)
    }

    return [_key, _value.value, `var(${_key})`]
  }

  /**
   * delete var for varx state
   *
   * @param key needs delete var key
   * @param emitUpdate whether emit update event when deleted
   */
  deleteVar(key: string | string[], emitUpdate = true) {
    if (typeof key === 'string') key = [key]

    logger.assert(
      Array.isArray(key),
      'The key of parameter for deleteVar function must be a string or an array.'
    )

    const state: VarState = {}
    for (let k of key) {
      const _var = this.getVar(k)
      if (isUndef(_var)) continue

      if (!(k in this._state)) k = this.encodeVarKey(k)
      delete this._state[k]

      state[k] = _var!
    }

    if (emitUpdate) {
      this.updateState(state, 'delete')
    } else {
      this._onUpdateState(state, 'delete')
    }
  }

  /**
   * get state var by key
   *
   * ***Calling the function will trigger the access event.***
   *
   * @param key key for varx state
   *
   * @returns varx state var
   */
  getVar(key: string = '') {
    logger.assert(
      typeof key === 'string',
      'The key of parameter for getVar function must be a string.'
    )

    const _var = this.state[this._strict ? key : this.encodeVarKey(key)]
    if (isUndef(_var)) return

    this.accessState()

    return deepCopy(_var)
  }

  /**
   * get state var value by key
   *
   * ***Calling the function will trigger the access event.***
   *
   * @param key key for varx state
   *
   * @returns varx state var value
   */
  getVarValue(key: string = '') {
    logger.assert(
      typeof key === 'string',
      'The key of parameter for getVarValue function must be a string.'
    )

    return this.getVar(key)?.value ?? ''
  }

  /**
   * simple function for get var
   */
  g(...args: Parameters<Varx['getVar']>) {
    return this.getVar.apply(this, args)
  }

  /**
   * simple function for getVarValue
   */
  gv(...args: Parameters<Varx['getVarValue']>) {
    return this.getVarValue.apply(this, args)
  }

  /**
   * get safe var value
   *
   * @param value var value
   * @param oldValue var old value
   *
   * @returns a safe var value
   */
  safeValue(value: VarStateValueParameter, key?: string, oldValue?: VarStateValue): VarStateValue {
    if (!isObject(value)) {
      value = { value, key }
    } else if (value !== null && typeof key === 'string' && key.length > 0) {
      value.key = key
    }
    if (isUndef(value.value)) {
      value.value = ''
    } else if (isObject(value.value)) {
      value.value = String(value.value)
    }
    if (typeof value.key !== 'string') {
      value.key = isUndef(value.key) ? '' : String(value.key)
    }
    if ('onUpdate' in value && typeof value.onUpdate !== 'function') {
      logger.warn(
        'The onUpdate of parameter for safeValue must be a function. Otherwise it will be deleted.'
      )
      delete value.onUpdate
    }
    return Object.assign({}, oldValue, value)
  }
}

低浏览器插件(替代扫描器)

提供在变量访问、更新时执行扫描任务,扫描当前样式规则,通过解析器解析并替换无效变量。

function lowBrowser(config?: LowBrowserConfig): VarxPlugin {
  return (varx) => {
    const lowBrowserMeta: LowBrowserMeta = {
      coverStyles: [],
      scanLink: config?.scanLink ?? false,
      parser: null,
      onlyLowBrowser: config?.onlyLowBrowser ?? false
    }

    // 为 varx 实例对象绑定当前插件元信息
    varx.lowBrowserMeta = lowBrowserMeta

    // 处理 css link 的覆盖样式标签
    function flushCoverStyles() {
      // 倒序循环避免移除元素导致索引错乱
      for (let i = lowBrowserMeta.coverStyles.length - 1; i >= 0; i--) {
        const element = lowBrowserMeta.coverStyles[i]
        const originElement = element.__coverInfo.origin
        
        // 如果来源 css link 的父级元素不存在,则认为标签已被移除,同步删除覆盖样式标签
        // 否则验证覆盖样式标签是否与来源标签为兄弟关系,验证失败则移动标签至来源标签的下一级
        // 用于避免不同样式标签内的同类名样式覆盖不生效
        if (originElement.parentElement) {
          if (originElement.nextElementSibling !== element) {
            insertAfter(originElement, element)
          }
        } else {
          lowBrowserMeta.coverStyles.splice(i, 1)
          element.parentElement?.removeChild(element)
        }
      }
    }

    // 为 css link 标签打补丁
    function patchCssLink(el: HTMLLinkElement) {
      // 如果已经存在元素对应的覆盖样式则跳过处理
      if (lowBrowserMeta.coverStyles.findIndex((style) => style.__coverInfo.origin === el) > -1)
        return

      const parser = lowBrowserMeta.parser!

      // 通过解析器解析样式内容并生成新的 style 标签进行追加
      parser.parseValidStyleText(el).then((text) => {
        // 判断是否有用到 css var
        if (!parser.hasValidCssVar(text)) return

        const styleElement = document.createElement('style') as CoverStyleElement

        // 为 style 标签记录覆盖信息
        styleElement.__coverInfo = {
          origin: el,
          rawText: text
        }
        styleElement.textContent = parser.replaceValidCssVar(text, varx.state)

        insertAfter(el, styleElement)
        lowBrowserMeta.coverStyles.push(styleElement)
      })
    }

    // 为 style 标签打补丁
    function patchStyle(el: HTMLStyleElement & Partial<CoverInfo>) {
      const parser = lowBrowserMeta.parser!

      // 通过解析器解析 style 标签样式并替换 css var
      parser.parseValidStyleText(el).then((text) => {
        const rawText = el.__coverInfo ? el.__coverInfo.rawText : text

        if (!parser.hasValidCssVar(rawText)) return

        if (!el.__coverInfo) {
          el.__coverInfo = {
            origin: el,
            rawText
          }
        }

        const newText = parser.replaceValidCssVar(rawText, varx.state)
        // 如果新的样式与旧的样式相同时直接跳过赋值
        if (newText !== text) {
          el.textContent = newText
        }
      })
    }

    // 执行扫描任务
    function scan() {
      // 如果仅在低版本浏览器(ie)中使用时,则会检测当前浏览器是否支持 css var ,若支持则结束任务,否则继续
      if (lowBrowserMeta.onlyLowBrowser && checkSupportNativeCssVar()) return

      // 创建解析器
      if (!lowBrowserMeta.parser) {
        lowBrowserMeta.parser = new Parser(varx.processNamespace, config?.parserHttpFn)
      }

      // 先处理旧的 css link 的覆盖样式
      flushCoverStyles()

      // 扫描获取有效的样式标签(style、link)
      const styleElements = scanValidStyleElements()

      // 开启微任务防止阻塞主线程
      Promise.resolve().then(() => {
        styleElements.forEach((element) => {
          // 为对应的标签打补丁
          isCssLink(element) ? patchCssLink(element) : patchStyle(element)
        })
      })
    }

    // 扫描有效的样式标签
    function scanValidStyleElements() {
      const elements: (HTMLStyleElement | HTMLLinkElement)[] = [].concat(
        Array.from(document.getElementsByTagName('style') as any),
        lowBrowserMeta.scanLink ? Array.from(document.getElementsByTagName('link') as any) : []
      )
      return elements
    }

    // 为 varx 注册事件回调
    varx.onAccessState(() => {
      scan()
    })
    varx.onUpdatedState(() => {
      scan()
    })
    varx.onDestroyed(() => {
      lowBrowserMeta.coverStyles.splice(0).forEach((element) => {
        removeElement(element, element.parentElement ?? document.head)
      })

      delete varx.lowBrowserMeta
    })
  }
}

解析器(低浏览器插件附属)

创建解析器,对扫描器扫描到的样式标签(stylelink)内容进行解析,提取出使用了 css var 的样式规则,并根据存储中心的样式信息进行替换。

class Parser {
  private static _baseHttpFn: HttpFnParameter = null
  /**
   * 获取 css link 文件内容的异步函数
   *
   * @description
   * 默认会采用 XMLHttpRequest 实例对象进行获取。
   *
   * ***若存在需要认证域名或 token 的情况则推荐设置该函数,接收 href 地址,并返回文件内容。***
   *
   * ***优先级:httpFn > baseHttpFn > xhr***
   *
   * @default null
   *
   * @example
   * ```js
   * Parser.setBaseHttpFn((href) => data)
   * ```
   */
  static get baseHttpFn() {
    return this._baseHttpFn
  }

  private static _linkMap: LinkMap = {}
  /**
   * css link 缓存集合
   *
   * @description 以 css link href 为缓存 key ,对解析到的结果进行缓存保留,避免二次解析。
   *
   * @default {}
   *
   * @example
   * ```js
   * Parser.setLinkMap('./xxx.css', '.xxx { xxx }')
   * ```
   */
  static get linkMap() {
    return Object.freeze(deepCopy(this._linkMap))
  }

  private _namespace = ''
  /**
   * varx 实例的命名空间
   *
   * @description 对解析功能提供帮助。
   */
  get namespace() {
    return this._namespace
  }

  private _httpFn: HttpFnParameter = null
  /**
   * 获取 css link 文件内容的异步函数
   *
   * @description
   * 默认会采用 XMLHttpRequest 实例对象或 baseHttpFn (若存在)进行获取。
   *
   * ***若存在需要认证域名或 token 的情况则推荐设置该函数,接收 href 地址,并返回文件内容。***
   *
   * ***优先级:httpFn > baseHttpFn > xhr***
   *
   * @default null
   *
   * @example
   * ```js
   * Parser.setHttpFn((href) => data)
   * ```
   */
  get httpFn() {
    return this._httpFn ?? Parser.baseHttpFn
  }

  /**
   * @param namespace varx 命名空间
   * @param httpFn 获取 css link 文件内容的异步函数
   */
  constructor(namespace: string, httpFn?: HttpFnParameter) {
    this._namespace = safeValue(namespace, 'string', '')

    this.setHttpFn(httpFn)
  }

  /**
   * 设置缓存 link href 数据
   *
   * @param key 需要缓存的 link href
   * @param value 需要缓存的 link href 数据
   */
  static setLinkMap(key: string, value: string) {
    if ([key, value].some((v) => typeof v !== 'string')) return

    this._linkMap[key] = value
  }

  /**
   * 设置解析器的基异步函数
   *
   * @param fn 获取 css link 文件内容的异步函数
   */
  static setBaseHttpFn(fn: HttpFnParameter = null) {
    if (typeof fn !== 'function') {
      this._baseHttpFn = null
    } else {
      this._baseHttpFn = (href) => Promise.resolve().then(() => fn(href))
    }
  }

  /**
   * 设置解析器的异步函数
   *
   * @param fn 获取 css link 文件内容的异步函数
   */
  setHttpFn(fn: HttpFnParameter = null) {
    if (typeof fn !== 'function') {
      this._httpFn = null
    } else {
      this._httpFn = (href) => Promise.resolve().then(() => fn(href))
    }
  }

  /**
   * 解析 css var key
   *
   * @param cssVar 需要解析的 css var
   *
   * @returns 解析后的 css var key
   *
   * @example
   * ```js
   * const parser = new Parser('demo')
   * parser.parseVarKey('var(--demo__bg)') // --demo__bg
   * parser.parseVarKey('var(--demo__bg, red)') // --demo__bg
   * ```
   */
  parseVarKey(cssVar = '') {
    const frontVar = cssVar.split(')')[0] ?? ''
    return frontVar.replace(/(var\(|\s|\r)/g, '').split(',')[0] ?? ''
  }

  /**
   * 解析有效的样式
   *
   * @param el 需要解析的样式标签
   *
   * @returns 解析后的样式
   *
   * @example
   * ```js
   * (async function() {
   *  const parser = new Parser()
   *  const styleText = await parser.parseValidStyleText(document.getElementsByTagName('style')[0])
   *  const linkText = await parser.parseValidStyleText(document.getElementsByTagName('link')[0])
   *
   *  console.log(styleText) // .xxx { xxx } ...
   *  console.log(linkText) // .xxx { xxx } ...
   * })()
   * ```
   */
  async parseValidStyleText(el: HTMLStyleElement | HTMLLinkElement) {
    let styleText = ''

    if (isCssLink(el)) {
      if (el.href in Parser.linkMap) {
        styleText = Parser.linkMap[el.href]
      } else {
        const { error, data, message } = await this.getFile(el.href)
        if (error) {
          // 若当前接口调用失败,但其他接口成功后尝试从缓存中重新获取
          styleText = await Promise.resolve(Parser.linkMap[el.href] ?? '')
          !styleText && logger.error(`href 地址:${el.href} 解析错误!原因:${message}`)
        } else {
          styleText = data
          Parser.setLinkMap(el.href, data)
        }
      }
    } else if (isStyle(el)) {
      styleText = el.textContent ?? ''
    }

    return styleText
  }

  /**
   * 获取 css link 文件内容
   *
   * @description 异步优先级:httpFn > baseHttpFn > xhr
   *
   * @param href css link href
   *
   * @returns 获取 css link 的文件内容
   *
   * @example
   * ```js
   * (async function() {
   *  const parser = new Parser()
   *  await parser.getFile('./xxx.css') // .xxx { xxx } ...
   * })()
   * ```
   */
  async getFile(href: string) {
    return new Promise<{ error: boolean; data: any; message: string }>((resolve) => {
      if (!href) return resolve({ error: true, data: '', message: '参数 href 必填!' })

      // 如果设置了 http 函数,则采用设置函数获取文件内容
      if (typeof this.httpFn === 'function') {
        return this.httpFn(href)
          .then((data) =>
            resolve({
              error: false,
              data: typeof data === 'string' ? data : '',
              message: ''
            })
          )
          .catch((e) =>
            resolve({
              error: true,
              data: null,
              message: e?.message ?? e.toString()
            })
          )
      }

      const xhr = new XMLHttpRequest()

      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          const error = xhr.status !== 200

          resolve({
            error,
            data: error ? null : xhr.response,
            message: error ? xhr.statusText : ''
          })
        }
      }

      xhr.open('GET', href)
      xhr.send()
    })
  }

  /**
   * 替换有效的 css var 样式
   *
   * @param cssText css 样式
   * @param varMap varx 的 varMap 集合
   *
   * @returns 替换 css var 后的样式
   */
  replaceValidCssVar(cssText: string = '', varState: VarState = {}) {
    return cssText.replace(
      new RegExp(`var\\(--${this._namespace}[^)]+\\)`, 'g'),
      (cssVar) => String(varState[this.parseVarKey(cssVar)]?.value) ?? ''
    )
  }

  /**
   * 判断是否包含有效的 css var
   *
   * @param text 需要验证的样式
   *
   * @returns 当前样式是否包含有效的 css var
   *
   * @example
   * ```js
   * const parser = new Parser('demo')
   * parser.hasValidCssVar('.xxx { background: var(--background); }') // false
   * parser.hasValidCssVar('.xxx { background: var(--demo__background); }') // true
   * ```
   */
  hasValidCssVar(text: string = '') {
    return text.includes(`var(--${this._namespace}`)
  }
}

InVue 插件(Vue 集成)

由于作者主要使用 vue 相关技术栈开发,所以优先考虑了 vue 的功能集成。

通过 vue-demi 抹平 vue2vue3 之间的差异,提供使用一致性,为存储中心实例增加响应式属性及获取响应式变量的函数,解决了行内不能使用的问题,应尽可能的采用函数来获取响应式的变量,避免造成对原存储中心数据的冲击以及提升代码的维护性。

function createRState(): RVarx['_rState'] {
  return isVue2 ? Vue2.observable({ value: {} }) : ref({})
}

function setRState(rState: RVarx['_rState'], state: VarState = {}) {
  return isVue2 ? Vue2.set(rState, 'value', state) : (rState.value = state)
}

function inVue(varx: VarxCtor | RVarx): RVarx {
  logger.assert(varx?.isVarx, 'The varx must be a varx instance.')

  if (varx._rState !== undefined) {
    return varx as RVarx
  }

  const rState = createRState()

  varx._rState = rState
  varx.gr = gr.bind(varx)
  varx.grv = grv.bind(varx)

  varx.onUpdatedState(() => {
    setRState(rState, deepCopy(varx.state))
  })

  varx.onDestroyed(() => {
    delete varx._rState
    delete varx.gr
    delete varx.grv
  })

  function gr(key: string) {
    return rState.value[varx.g(key)?.key!]
  }

  function grv(key: string) {
    return rState.value[varx.g(key)?.key!]?.value ?? ''
  }

  return varx as RVarx
}

生成根样式插件

当存储变量被改变时自动生成新的 :root 样式并挂载在 head 底部,为支持 css var 的浏览器提供样式注入。

function generateRootStyle(): VarxPlugin {
  return (varx) => {
    if (!checkSupportNativeCssVar()) return

    const rootStyleMeta: RootStyleMeta = {
      id: `varx-root-style__${varx.id}`,
      el: null
    }

    varx.rootStyleMeta = rootStyleMeta
    rootStyleMeta.el = getElement(rootStyleMeta.id, 'style') as HTMLStyleElement

    function genRootStyle() {
      const vars: string[] = []
      objectEach(varx.state, (v, k) => {
        vars.push(`${k}:${v.value};`)
      })

      setStyleText(rootStyleMeta.id, `:root{${vars.join('')}}`)
    }

    genRootStyle()

    varx.onUpdatedState(() => {
      genRootStyle()
    })
    varx.onDestroyed(() => {
      removeElement(rootStyleMeta.id)

      rootStyleMeta.el = null
      delete varx.rootStyleMeta
    })
  }
}

目前该插件已发布与 npm,可以通过 npm 安装,原创不易,希望能留下各位看官的痕迹 ❥,Thanks♪(・ω・)ノ!

713F9006.jpg