Coder Social home page Coder Social logo

blog's Introduction

blog's People

Contributors

liyongning avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

Vue 源码解读(11)—— render helper

Vue 源码解读(11)—— render helper

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 Vue 源码解读(10)—— 编译器 之 生成渲染函数 最后讲到组件更新时,需要先执行编译器生成的渲染函数得到组件的 vnode。

渲染函数之所以能生成 vnode 是通过其中的 _c、_l、、_v、_s 等方法实现的。比如:

  • 普通的节点被编译成了可执行 _c 函数

  • v-for 节点被编译成了可执行的 _l 函数

  • ...

但是到目前为止我们都不清楚这些方法的原理,它们是如何生成 vnode 的?只知道它们是 Vue 实例方法,今天我们就从源码中找答案。

目标

在 Vue 编译器的基础上,进一步深入理解一个组件是如何通过这些运行时的工具方法(render helper)生成 VNode 的

源码解读

入口

我们知道这些方法是 Vue 实例方法,按照之前对源码的了解,实例方法一般都放在 /src/core/instance 目录下。其实之前在 Vue 源码解读(6)—— 实例方法 阅读中见到过 render helper,在文章的最后。

/src/core/instance/render.js

export function renderMixin (Vue: Class<Component>) {
  // install runtime convenience helpers
  // 在组件实例上挂载一些运行时需要用到的工具方法
  installRenderHelpers(Vue.prototype)
  
  // ...
}

installRenderHelpers

/src/core/instance/render-helpers/index.js

/**
 * 在实例上挂载简写的渲染工具函数,这些都是运行时代码
 * 这些工具函数在编译器生成的渲染函数中被使用到了
 * @param {*} target Vue 实例
 */
export function installRenderHelpers(target: any) {
  /**
   * v-once 指令的运行时帮助程序,为 VNode 加上打上静态标记
   * 有点多余,因为含有 v-once 指令的节点都被当作静态节点处理了,所以也不会走这儿
   */
  target._o = markOnce
  // 将值转换为数字
  target._n = toNumber
  /**
   * 将值转换为字符串形式,普通值 => String(val),对象 => JSON.stringify(val)
   */
  target._s = toString
  /**
   * 运行时渲染 v-for 列表的帮助函数,循环遍历 val 值,依次为每一项执行 render 方法生成 VNode,最终返回一个 VNode 数组
   */
  target._l = renderList
  target._t = renderSlot
  /**
   * 判断两个值是否相等
   */
  target._q = looseEqual
  /**
   * 相当于 indexOf 方法
   */
  target._i = looseIndexOf
  /**
   * 运行时负责生成静态树的 VNode 的帮助程序,完成了以下两件事
   *   1、执行 staticRenderFns 数组中指定下标的渲染函数,生成静态树的 VNode 并缓存,下次在渲染时从缓存中直接读取(isInFor 必须为 true)
   *   2、为静态树的 VNode 打静态标记
   */
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  /**
   * 为文本节点创建 VNode
   */
  target._v = createTextVNode
  /**
   * 为空节点创建 VNode
   */
  target._e = createEmptyVNode
}

_o = markOnce

/src/core/instance/render-helpers/render-static.js

/**
 * Runtime helper for v-once.
 * Effectively it means marking the node as static with a unique key.
 * v-once 指令的运行时帮助程序,为 VNode 加上打上静态标记
 * 有点多余,因为含有 v-once 指令的节点都被当作静态节点处理了,所以也不会走这儿
 */
export function markOnce (
  tree: VNode | Array<VNode>,
  index: number,
  key: string
) {
  markStatic(tree, `__once__${index}${key ? `_${key}` : ``}`, true)
  return tree
}

markStatic

/src/core/instance/render-helpers/render-static.js

/**
 * 为 VNode 打静态标记,在 VNode 上添加三个属性:
 * { isStatick: true, key: xx, isOnce: true or false } 
 */
function markStatic (
  tree: VNode | Array<VNode>,
  key: string,
  isOnce: boolean
) {
  if (Array.isArray(tree)) {
    // tree 为 VNode 数组,循环遍历其中的每个 VNode,为每个 VNode 做静态标记
    for (let i = 0; i < tree.length; i++) {
      if (tree[i] && typeof tree[i] !== 'string') {
        markStaticNode(tree[i], `${key}_${i}`, isOnce)
      }
    }
  } else {
    markStaticNode(tree, key, isOnce)
  }
}

markStaticNode

/src/core/instance/render-helpers/render-static.js

/**
 * 标记静态 VNode
 */
function markStaticNode (node, key, isOnce) {
  node.isStatic = true
  node.key = key
  node.isOnce = isOnce
}

_l = renderList

/src/core/instance/render-helpers/render-list.js

/**
 * Runtime helper for rendering v-for lists.
 * 运行时渲染 v-for 列表的帮助函数,循环遍历 val 值,依次为每一项执行 render 方法生成 VNode,最终返回一个 VNode 数组
 */
export function renderList (
  val: any,
  render: (
    val: any,
    keyOrIndex: string | number,
    index?: number
  ) => VNode
): ?Array<VNode> {
  let ret: ?Array<VNode>, i, l, keys, key
  if (Array.isArray(val) || typeof val === 'string') {
    // val 为数组或者字符串
    ret = new Array(val.length)
    for (i = 0, l = val.length; i < l; i++) {
      ret[i] = render(val[i], i)
    }
  } else if (typeof val === 'number') {
    // val 为一个数值,则遍历 0 - val 的所有数字
    ret = new Array(val)
    for (i = 0; i < val; i++) {
      ret[i] = render(i + 1, i)
    }
  } else if (isObject(val)) {
    // val 为一个对象,遍历对象
    if (hasSymbol && val[Symbol.iterator]) {
      // val 为一个可迭代对象
      ret = []
      const iterator: Iterator<any> = val[Symbol.iterator]()
      let result = iterator.next()
      while (!result.done) {
        ret.push(render(result.value, ret.length))
        result = iterator.next()
      }
    } else {
      // val 为一个普通对象
      keys = Object.keys(val)
      ret = new Array(keys.length)
      for (i = 0, l = keys.length; i < l; i++) {
        key = keys[i]
        ret[i] = render(val[key], key, i)
      }
    }
  }
  if (!isDef(ret)) {
    ret = []
  }
  // 返回 VNode 数组
  (ret: any)._isVList = true
  return ret
}

_m = renderStatic

/src/core/instance/render-helpers/render-static.js

/**
 * Runtime helper for rendering static trees.
 * 运行时负责生成静态树的 VNode 的帮助程序,完成了以下两件事
 *   1、执行 staticRenderFns 数组中指定下标的渲染函数,生成静态树的 VNode 并缓存,下次在渲染时从缓存中直接读取(isInFor 必须为 true)
 *   2、为静态树的 VNode 打静态标记
 * @param { number} index 表示当前静态节点的渲染函数在 staticRenderFns 数组中的下标索引
 * @param { boolean} isInFor 表示当前静态节点是否被包裹在含有 v-for 指令的节点内部
 */
 export function renderStatic (
  index: number,
  isInFor: boolean
): VNode | Array<VNode> {
  // 缓存,静态节点第二次被渲染时就从缓存中直接获取已缓存的 VNode
  const cached = this._staticTrees || (this._staticTrees = [])
  let tree = cached[index]
  // if has already-rendered static tree and not inside v-for,
  // we can reuse the same tree.
  // 如果当前静态树已经被渲染过一次(即有缓存)而且没有被包裹在 v-for 指令所在节点的内部,则直接返回缓存的 VNode
  if (tree && !isInFor) {
    return tree
  }
  // 执行 staticRenderFns 数组中指定元素(静态树的渲染函数)生成该静态树的 VNode,并缓存
  // otherwise, render a fresh tree.
  tree = cached[index] = this.$options.staticRenderFns[index].call(
    this._renderProxy,
    null,
    this // for render fns generated for functional component templates
  )
  // 静态标记,为静态树的 VNode 打标记,即添加 { isStatic: true, key: `__static__${index}`, isOnce: false }
  markStatic(tree, `__static__${index}`, false)
  return tree
}

_c

/src/core/instance/render.js

/**
 * 定义 _c,它是 createElement 的一个柯里化方法
 * @param {*} a 标签名
 * @param {*} b 属性的 JSON 字符串
 * @param {*} c 子节点数组
 * @param {*} d 节点的规范化类型
 * @returns VNode or Array<VNode>
 */
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

createElement

/src/core/vdom/create-element.js

/**
 * 生成组件或普通标签的 vnode,一个包装函数,不用管
 * wrapper function for providing a more flexible interface
 * without getting yelled at by flow
 */
export function createElement(
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  // 执行 _createElement 方法创建组件的 VNode
  return _createElement(context, tag, data, children, normalizationType)
}

_createElement

/src/core/vdom/create-element.js

/**
 * 生成 vnode,
 *   1、平台保留标签和未知元素执行 new Vnode() 生成 vnode
 *   2、组件执行 createComponent 生成 vnode
 *     2.1 函数式组件执行自己的 render 函数生成 VNode
 *     2.2 普通组件则实例化一个 VNode,并且在其 data.hook 对象上设置 4 个方法,在组件的 patch 阶段会被调用,
 *         从而进入子组件的实例化、挂载阶段,直至完成渲染
 * @param {*} context 上下文
 * @param {*} tag 标签
 * @param {*} data 属性 JSON 字符串
 * @param {*} children 子节点数组
 * @param {*} normalizationType 节点规范化类型
 * @returns VNode or Array<VNode>
 */
export function _createElement(
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  if (isDef(data) && isDef((data: any).__ob__)) {
    // 属性不能是一个响应式对象
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    // 如果属性是一个响应式对象,则返回一个空节点的 VNode
    return createEmptyVNode()
  }
  // object syntax in v-bind
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  if (!tag) {
    // 动态组件的 is 属性是一个假值时 tag 为 false,则返回一个空节点的 VNode
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  // 检测唯一键 key,只能是字符串或者数字
  // warn against non-primitive key
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }

  // 子节点数组中只有一个函数时,将它当作默认插槽,然后清空子节点列表
  // support single function children as default scoped slot
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  // 将子元素进行标准化处理
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }

   /**
   * 这里开始才是重点,前面的都不需要关注,基本上是一些异常处理或者优化等
   */

  let vnode, ns
  if (typeof tag === 'string') {
    // 标签是字符串时,该标签有三种可能:
    //   1、平台保留标签
    //   2、自定义组件
    //   3、不知名标签
    let Ctor
    // 命名空间
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // tag 是平台原生标签
      // platform built-in elements
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        // v-on 指令的 .native 只在组件上生效
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      // 实例化一个 VNode
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // tag 是一个自定义组件
      // 在 this.$options.components 对象中找到指定标签名称的组件构造函数
      // 创建组件的 VNode,函数式组件直接执行其 render 函数生成 VNode,
      // 普通组件则实例化一个 VNode,并且在其 data.hook 对象上设置了 4 个方法,在组件的 patch 阶段会被调用,
      // 从而进入子组件的实例化、挂载阶段,直至完成渲染
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 不知名的一个标签,但也生成 VNode,因为考虑到在运行时可能会给一个合适的名字空间
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
     // tag 为非字符串,比如可能是一个组件的配置对象或者是一个组件的构造函数
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  // 返回组件的 VNode
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

createComponent

/src/core/vdom/create-component.js

/**
 * 创建组件的 VNode,
 *   1、函数式组件通过执行其 render 方法生成组件的 VNode
 *   2、普通组件通过 new VNode() 生成其 VNode,但是普通组件有一个重要操作是在 data.hook 对象上设置了四个钩子函数,
 *      分别是 init、prepatch、insert、destroy,在组件的 patch 阶段会被调用,
 *      比如 init 方法,调用时会进入子组件实例的创建挂载阶段,直到完成渲染
 * @param {*} Ctor 组件构造函数
 * @param {*} data 属性组成的 JSON 字符串
 * @param {*} context 上下文
 * @param {*} children 子节点数组
 * @param {*} tag 标签名
 * @returns VNode or Array<VNode>
 */
export function createComponent(
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // 组件构造函数不存在,直接结束
  if (isUndef(Ctor)) {
    return
  }

  // Vue.extend
  const baseCtor = context.$options._base

  // 当 Ctor 为配置对象时,通过 Vue.extend 将其转为构造函数
  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  // 如果到这个为止,Ctor 仍然不是一个函数,则表示这是一个无效的组件定义
  // if at this stage it's not a constructor or an async component factory,
  // reject.
  if (typeof Ctor !== 'function') {
    if (process.env.NODE_ENV !== 'production') {
      warn(`Invalid Component definition: ${String(Ctor)}`, context)
    }
    return
  }

  // 异步组件
  // async component
  let asyncFactory
  if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
    if (Ctor === undefined) {
      // 为异步组件返回一个占位符节点,组件被渲染为注释节点,但保留了节点的所有原始信息,这些信息将用于异步服务器渲染 和 hydration
      return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
      )
    }
  }

  // 节点的属性 JSON 字符串
  data = data || {}

  // 这里其实就是组件做选项合并的地方,即编译器将组件编译为渲染函数,渲染时执行 render 函数,然后执行其中的 _c,就会走到这里了
  // 解析构造函数选项,并合基类选项,以防止在组件构造函数创建后应用全局混入
  // resolve constructor options in case global mixins are applied after
  // component constructor creation
  resolveConstructorOptions(Ctor)

  // 将组件的 v-model 的信息(值和回调)转换为 data.attrs 对象的属性、值和 data.on 对象上的事件、回调
  // transform component v-model data into props & events
  if (isDef(data.model)) {
    transformModel(Ctor.options, data)
  }

  // 提取 props 数据,得到 propsData 对象,propsData[key] = val
  // 以组件 props 配置中的属性为 key,父组件中对应的数据为 value
  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)

  // 函数式组件
  // functional component
  if (isTrue(Ctor.options.functional)) {
    /**
     * 执行函数式组件的 render 函数生成组件的 VNode,做了以下 3 件事:
     *   1、设置组件的 props 对象
     *   2、设置函数式组件的渲染上下文,传递给函数式组件的 render 函数
     *   3、调用函数式组件的 render 函数生成 vnode
     */
    return createFunctionalComponent(Ctor, propsData, data, context, children)
  }

  // 获取事件监听器对象 data.on,因为这些监听器需要作为子组件监听器处理,而不是 DOM 监听器
  // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  const listeners = data.on
  // 将带有 .native 修饰符的事件对象赋值给 data.on
  // replace with listeners with .native modifier
  // so it gets processed during parent component patch.
  data.on = data.nativeOn

  if (isTrue(Ctor.options.abstract)) {
    // 如果是抽象组件,则值保留 props、listeners 和 slot
    // abstract components do not keep anything
    // other than props & listeners & slot

    // work around flow
    const slot = data.slot
    data = {}
    if (slot) {
      data.slot = slot
    }
  }

  /**
   * 在组件的 data 对象上设置 hook 对象,
   * hook 对象增加四个属性,init、prepatch、insert、destroy,
   * 负责组件的创建、更新、销毁,这些方法在组件的 patch 阶段会被调用
   * install component management hooks onto the placeholder node
   */
  installComponentHooks(data)

  const name = Ctor.options.name || tag
  // 实例化组件的 VNode,对于普通组件的标签名会比较特殊,vue-component-${cid}-${name}
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

  // Weex specific: invoke recycle-list optimized @render function for
  // extracting cell-slot template.
  // https://github.com/Hanks10100/weex-native-directive/tree/master/component
  /* istanbul ignore if */
  if (__WEEX__ && isRecyclableComponent(vnode)) {
    return renderRecyclableComponentTemplate(vnode)
  }

  return vnode
}

resolveConstructorOptions

/src/core/instance/init.js

/**
 * 从构造函数上解析配置项
 */
export function resolveConstructorOptions (Ctor: Class<Component>) {
  // 从实例构造函数上获取选项
  let options = Ctor.options
  if (Ctor.super) {
    const superOptions = resolveConstructorOptions(Ctor.super)
    // 缓存
    const cachedSuperOptions = Ctor.superOptions
    if (superOptions !== cachedSuperOptions) {
      // 说明基类的配置项发生了更改
      // super option changed,
      // need to resolve new options.
      Ctor.superOptions = superOptions
      // check if there are any late-modified/attached options (#4976)
      // 找到更改的选项
      const modifiedOptions = resolveModifiedOptions(Ctor)
      // update base extend options
      if (modifiedOptions) {
        // 将更改的选项和 extend 选项合并
        extend(Ctor.extendOptions, modifiedOptions)
      }
      // 将新的选项赋值给 options
      options = Ctor.options = mergeOptions(superOptions, Ctor.extendOptions)
      if (options.name) {
        options.components[options.name] = Ctor
      }
    }
  }
  return options
}

resolveModifiedOptions

/src/core/instance/init.js

/**
 * 解析构造函数选项中后续被修改或者增加的选项
 */
function resolveModifiedOptions (Ctor: Class<Component>): ?Object {
  let modified
  // 构造函数选项
  const latest = Ctor.options
  // 密封的构造函数选项,备份
  const sealed = Ctor.sealedOptions
  // 对比两个选项,记录不一致的选项
  for (const key in latest) {
    if (latest[key] !== sealed[key]) {
      if (!modified) modified = {}
      modified[key] = latest[key]
    }
  }
  return modified
}

transformModel

src/core/vdom/create-component.js

/**
 * 将组件的 v-model 的信息(值和回调)转换为 data.attrs 对象的属性、值和 data.on 对象上的事件、回调
 * transform component v-model info (value and callback) into
 * prop and event handler respectively.
 */
function transformModel(options, data: any) {
  // model 的属性和事件,默认为 value 和 input
  const prop = (options.model && options.model.prop) || 'value'
  const event = (options.model && options.model.event) || 'input'
    // 在 data.attrs 对象上存储 v-model 的值
    ; (data.attrs || (data.attrs = {}))[prop] = data.model.value
  // 在 data.on 对象上存储 v-model 的事件
  const on = data.on || (data.on = {})
  // 已存在的事件回调函数
  const existing = on[event]
  // v-model 中事件对应的回调函数
  const callback = data.model.callback
  // 合并回调函数
  if (isDef(existing)) {
    if (
      Array.isArray(existing)
        ? existing.indexOf(callback) === -1
        : existing !== callback
    ) {
      on[event] = [callback].concat(existing)
    }
  } else {
    on[event] = callback
  }
}

extractPropsFromVNodeData

/src/core/vdom/helpers/extract-props.js

/**
 * <comp :msg="hello vue"></comp>
 * 
 * 提取 props,得到 res[key] = val 
 * 
 * 以 props 配置中的属性为 key,父组件中对应的的数据为 value
 * 当父组件中数据更新时,触发响应式更新,重新执行 render,生成新的 vnode,又走到这里
 * 这样子组件中相应的数据就会被更新 
 */
export function extractPropsFromVNodeData (
  data: VNodeData, // { msg: 'hello vue' }
  Ctor: Class<Component>, // 组件构造函数
  tag?: string // 组件标签名
): ?Object {
  // 组件的 props 选项,{ props: { msg: { type: String, default: xx } } }
  
  // 这里只提取原始值,验证和默认值在子组件中处理
  // we are only extracting raw values here.
  // validation and default values are handled in the child
  // component itself.
  const propOptions = Ctor.options.props
  if (isUndef(propOptions)) {
    // 未定义 props 直接返回
    return
  }
  // 以组件 props 配置中的属性为 key,父组件传递下来的值为 value
  // 当父组件中数据更新时,触发响应式更新,重新执行 render,生成新的 vnode,又走到这里
  // 这样子组件中相应的数据就会被更新
  const res = {}
  const { attrs, props } = data
  if (isDef(attrs) || isDef(props)) {
    // 遍历 propsOptions
    for (const key in propOptions) {
      // 将小驼峰形式的 key 转换为 连字符 形式
      const altKey = hyphenate(key)
      // 提示,如果声明的 props 为小驼峰形式(testProps),但由于 html 不区分大小写,所以在 html 模版中应该使用 test-props 代替 testProps
      if (process.env.NODE_ENV !== 'production') {
        const keyInLowerCase = key.toLowerCase()
        if (
          key !== keyInLowerCase &&
          attrs && hasOwn(attrs, keyInLowerCase)
        ) {
          tip(
            `Prop "${keyInLowerCase}" is passed to component ` +
            `${formatComponentName(tag || Ctor)}, but the declared prop name is` +
            ` "${key}". ` +
            `Note that HTML attributes are case-insensitive and camelCased ` +
            `props need to use their kebab-case equivalents when using in-DOM ` +
            `templates. You should probably use "${altKey}" instead of "${key}".`
          )
        }
      }
      checkProp(res, props, key, altKey, true) ||
      checkProp(res, attrs, key, altKey, false)
    }
  }
  return res
}

checkProp

/src/core/vdom/helpers/extract-props.js

/**
 * 得到 res[key] = val
 */
function checkProp (
  res: Object,
  hash: ?Object,
  key: string,
  altKey: string,
  preserve: boolean
): boolean {
  if (isDef(hash)) {
    // 判断 hash(props/attrs)对象中是否存在 key 或 altKey
    // 存在则设置给 res => res[key] = hash[key]
    if (hasOwn(hash, key)) {
      res[key] = hash[key]
      if (!preserve) {
        delete hash[key]
      }
      return true
    } else if (hasOwn(hash, altKey)) {
      res[key] = hash[altKey]
      if (!preserve) {
        delete hash[altKey]
      }
      return true
    }
  }
  return false
}

createFunctionalComponent

/src/core/vdom/create-functional-component.js

installRenderHelpers(FunctionalRenderContext.prototype)

/**
 * 执行函数式组件的 render 函数生成组件的 VNode,做了以下 3 件事:
 *   1、设置组件的 props 对象
 *   2、设置函数式组件的渲染上下文,传递给函数式组件的 render 函数
 *   3、调用函数式组件的 render 函数生成 vnode
 * 
 * @param {*} Ctor 组件的构造函数 
 * @param {*} propsData 额外的 props 对象
 * @param {*} data 节点属性组成的 JSON 字符串
 * @param {*} contextVm 上下文
 * @param {*} children 子节点数组
 * @returns Vnode or Array<VNode>
 */
export function createFunctionalComponent (
  Ctor: Class<Component>,
  propsData: ?Object,
  data: VNodeData,
  contextVm: Component,
  children: ?Array<VNode>
): VNode | Array<VNode> | void {
  // 组件配置项
  const options = Ctor.options
  // 获取 props 对象
  const props = {}
  // 组件本身的 props 选项
  const propOptions = options.props
  // 设置函数式组件的 props 对象
  if (isDef(propOptions)) {
    // 说明该函数式组件本身提供了 props 选项,则将 props.key 的值设置为组件上传递下来的对应 key 的值
    for (const key in propOptions) {
      props[key] = validateProp(key, propOptions, propsData || emptyObject)
    }
  } else {
    // 当前函数式组件没有提供 props 选项,则将组件上的 attribute 自动解析为 props
    if (isDef(data.attrs)) mergeProps(props, data.attrs)
    if (isDef(data.props)) mergeProps(props, data.props)
  }

  // 实例化函数式组件的渲染上下文
  const renderContext = new FunctionalRenderContext(
    data,
    props,
    children,
    contextVm,
    Ctor
  )

  // 调用 render 函数,生成 vnode,并给 render 函数传递 _c 和 渲染上下文
  const vnode = options.render.call(null, renderContext._c, renderContext)

  // 在最后生成的 VNode 对象上加一些标记,表示该 VNode 是一个函数式组件生成的,最后返回 VNode
  if (vnode instanceof VNode) {
    return cloneAndMarkFunctionalResult(vnode, data, renderContext.parent, options, renderContext)
  } else if (Array.isArray(vnode)) {
    const vnodes = normalizeChildren(vnode) || []
    const res = new Array(vnodes.length)
    for (let i = 0; i < vnodes.length; i++) {
      res[i] = cloneAndMarkFunctionalResult(vnodes[i], data, renderContext.parent, options, renderContext)
    }
    return res
  }
}

installComponentHooks

/src/core/vdom/create-component.js

const hooksToMerge = Object.keys(componentVNodeHooks)
/**
 * 在组件的 data 对象上设置 hook 对象,
 * hook 对象增加四个属性,init、prepatch、insert、destroy,
 * 负责组件的创建、更新、销毁
 */
 function installComponentHooks(data: VNodeData) {
  const hooks = data.hook || (data.hook = {})
  // 遍历 hooksToMerge 数组,hooksToMerge = ['init', 'prepatch', 'insert' 'destroy']
  for (let i = 0; i < hooksToMerge.length; i++) {
    // 比如 key = init
    const key = hooksToMerge[i]
    // 从 data.hook 对象中获取 key 对应的方法
    const existing = hooks[key]
    // componentVNodeHooks 对象中 key 对象的方法
    const toMerge = componentVNodeHooks[key]
    // 合并用户传递的 hook 方法和框架自带的 hook 方法,其实就是分别执行两个方法
    if (existing !== toMerge && !(existing && existing._merged)) {
      hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge
    }
  }
}

function mergeHook(f1: any, f2: any): Function {
  const merged = (a, b) => {
    // flow complains about extra args which is why we use any
    f1(a, b)
    f2(a, b)
  }
  merged._merged = true
  return merged
}

componentVNodeHooks

/src/core/vdom/create-component.js

// patch 期间在组件 vnode 上调用内联钩子
// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
  // 初始化
  init(vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // 被 keep-alive 包裹的组件
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      // 创建组件实例,即 new vnode.componentOptions.Ctor(options) => 得到 Vue 组件实例
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      // 执行组件的 $mount 方法,进入挂载阶段,接下来就是通过编译器得到 render 函数,接着走挂载、patch 这条路,直到组件渲染到页面
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  },

  // 更新 VNode,用新的 VNode 配置更新旧的 VNode 上的各种属性
  prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    // 新 VNode 的组件配置项
    const options = vnode.componentOptions
    // 老 VNode 的组件实例
    const child = vnode.componentInstance = oldVnode.componentInstance
    // 用 vnode 上的属性更新 child 上的各种属性
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  },

  // 执行组件的 mounted 声明周期钩子
  insert(vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    // 如果组件未挂载,则调用 mounted 声明周期钩子
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    // 处理 keep-alive 组件的异常情况
    if (vnode.data.keepAlive) {
      if (context._isMounted) {
        // vue-router#1212
        // During updates, a kept-alive component's child components may
        // change, so directly walking the tree here may call activated hooks
        // on incorrect children. Instead we push them into a queue which will
        // be processed after the whole patch process ended.
        queueActivatedComponent(componentInstance)
      } else {
        activateChildComponent(componentInstance, true /* direct */)
      }
    }
  },

  /**
   * 销毁组件
   *   1、如果组件被 keep-alive 组件包裹,则使组件失活,不销毁组件实例,从而缓存组件的状态
   *   2、如果组件没有被 keep-alive 包裹,则直接调用实例的 $destroy 方法销毁组件
   */
  destroy (vnode: MountedComponentVNode) {
    // 从 vnode 上获取组件实例
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      // 如果组件实例没有被销毁
      if (!vnode.data.keepAlive) {
        // 组件没有被 keep-alive 组件包裹,则直接调用 $destroy 方法销毁组件
        componentInstance.$destroy()
      } else {
        // 负责让组件失活,不销毁组件实例,从而缓存组件的状态
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
}

createComponentInstanceForVnode

/src/core/vdom/create-component.js

/**
 * new vnode.componentOptions.Ctor(options) => 得到 Vue 组件实例 
 */
export function createComponentInstanceForVnode(
  // we know it's MountedComponentVNode but flow doesn't
  vnode: any,
  // activeInstance in lifecycle state
  parent: any
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  // 检查内联模版渲染函数
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  // new VueComponent(options) => Vue 实例
  return new vnode.componentOptions.Ctor(options)
}

总结

面试官 问:一个组件是如何变成 VNode?

  • 组件实例初始化,最后执行 $mount 进入挂载阶段

  • 如果是只包含运行时的 vue.js,只直接进入挂载阶段,因为这时候的组件已经变成了渲染函数,编译过程通过模块打包器 + vue-loader + vue-template-compiler 完成的

  • 如果没有使用预编译,则必须使用全量的 vue.js

  • 挂载时如果发现组件配置项上没有 render 选项,则进入编译阶段

  • 将模版字符串编译成 AST 语法树,其实就是一个普通的 JS 对象

  • 然后优化 AST,遍历 AST 对象,标记每一个节点是否为静态静态;然后再进一步标记出静态根节点,在组件后续更新时会跳过这些静态节点的更新,以提高性能

  • 接下来从 AST 生成渲染函数,生成的渲染函数有两部分组成:

    • 负责生成动态节点 VNode 的 render 函数

    • 还有一个 staticRenderFns 数组,里面每一个元素都是一个生成静态节点 VNode 的函数,这些函数会作为 render 函数的组成部分,负责生成静态节点的 VNode

  • 接下来将渲染函数放到组件的配置对象上,进入挂载阶段,即执行 mountComponent 方法

  • 最终负责渲染组件和更新组件的是一个叫 updateComponent 方法,该方法每次执行前首先需要执行 vm._render 函数,该函数负责执行编译器生成的 render,得到组件的 VNode

  • 将一个组件生成 VNode 的具体工作是由 render 函数中的 _c、_o、_l、_m 等方法完成的,这些方法都被挂载到 Vue 实例上面,负责在运行时生成组件 VNode

提示:到这里首先要明白什么是 VNode,一句话描述就是 —— 组件模版的 JS 对象表现形式,它就是一个普通的 JS 对象,详细描述了组件中各节点的信息

下面说的有点多,其实记住一句就可以了,设置组件配置信息,然后通过 new VNode(组件信息) 生成组件的 VNode

  • _c,负责生成组件或 HTML 元素的 VNode,_c 是所有 render helper 方法中最复杂,也是最核心的一个方法,其它的 _xx 都是它的组成部分

    • 接收标签、属性 JSON 字符串、子节点数组、节点规范化类型作为参数

    • 如果标签是平台保留标签或者一个未知的元素,则直接 new VNode(标签信息) 得到 VNode

    • 如果标签是一个组件,则执行 createComponent 方法生成 VNode

      • 函数式组件执行自己的 render 函数生成 VNode

      • 普通组件则实例化一个 VNode,并且在在 data.hook 对象上设置 4 个方法,在组件的 patch 阶段会被调用,从而进入子组件的实例化、挂载阶段,然后进行编译生成渲染函数,直至完成渲染

      • 当然生成 VNode 之前会进行一些配置处理比如:

        • 子组件选项合并,合并全局配置项到组件配置项上

        • 处理自定义组件的 v-model

        • 处理组件的 props,提取组件的 props 数据,以组件的 props 配置中的属性为 key,父组件中对应的数据为 value 生成一个 propsData 对象;当组件更新时生成新的 VNode,又会进行这一步,这就是 props 响应式的原理

        • 处理其它数据,比如监听器

        • 安装内置的 init、prepatch、insert、destroy 钩子到 data.hooks 对象上,组件 patch 阶段会用到这些钩子方法

  • _l,运行时渲染 v-for 列表的帮助函数,循环遍历 val 值,依次为每一项执行 render 方法生成 VNode,最终返回一个 VNode 数组

  • _m,负责生成静态节点的 VNode,即执行 staticRenderFns 数组中指定下标的函数

简单总结 render helper 的作用就是:在 Vue 实例上挂载一些运行时的工具方法,这些方法用在编译器生成的渲染函数中,用于生成组件的 VNode。

好了,到这里,一个组件从初始化开始到最终怎么变成 VNode 就讲完了,最后剩下的就是 patch 阶段了,下一篇文章将讲述如何将组件的 VNode 渲染到页面上。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

按需加载原理分析

按需加载原理分析

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

简介

了解 Babel 插件基本知识,理解按需加载的内部原理,再也不怕面试官问我按需加载的实现原理了。


import { Button } from 'element-ui'

怎么就变成了

var Button = require('element-ui/lib/button.js')
require('element-ui/lib/theme-chalk/button.css')

为了找到答案,分两步来进行,这也是自己学习的过程:

  1. babel 插件入门,编写 babel-plugin-lyn 插件

  2. 解读 babel-plugin-component 源码,从源码中找到答案

babel 插件入门

这一步我们去编写一个babel-plugin-lyn插件,这一步要达到的目的是:

  • 理解babel插件做了什么

  • 学会分析AST语法树

  • 学会使用基本的API

  • 能编写一个简单的插件,做基本的代码转换

有了以上基础我们就可以尝试去阅读babel-plugin-component源码,从源码中找到我们想要的答案

简单介绍

Babel是一个JavaScript编译器,是一个从源码到源码的转换编译器,你为Babel提供一些JavaScript代码,Babel按照要求更改这些代码,然后返回给你新生成的代码。

代码转换(更改)的过程中是借助AST (抽象语法树)来完成的,通过改变AST节点信息来达到转换代码的目的,到这里其实也就可以简单回答出我们在目标中提到的代码转化是怎么完成的 ?,其实就是Babel读取我们的源代码,将其转换为AST,分析AST,更改AST的某些节点信息,然后生成新的代码,就完成了转换过程,而具体是怎么更改节点信息,就需要去babel-plugin-component源码中找答案了

Babel的世界中,我们要更改某个节点的时候,就需要去访问(拦截)该节点,这里采用了访问者模式访问者是一个用于AST遍历的跨语言的模式,加单的说就是定义了一个对象,用于在树状结构获取具体节点的的方法,这些节点其实就是AST节点,可以在 AST Explorer 中查看代码的AST信息,这个我们在编写代码的时候会多次用到

babel-plugin-lyn

接下来编写一个自己的插件

初始化项目目录

mkdir babel-plugin && cd babel-plugin && npm init -y

新建插件目录

在项目的node_modules目录新建一个文件夹,作为自己的插件目录

mkdir -p node_modules/babel-plugin-lyn

在插件目录新建 index.js

touch index.js

创建需要被处理的 JS 代码

在项目根目录下创建 index.js,编写如下代码

let a = 1
let b = 1

很简单吧,我们需要将其转换为:

const aa = 1
const bb = 1

接下来进行插件编写

babel-plugin-lyn/index.js

基本结构
// 函数会有一个 babelTypes 参数,我们结构出里面的 types
// 代码中需要用到它的一些方法,方法具体什么意思可以参考 
// https://babeljs.io/docs/en/next/babel-types.html
module.exports = function ({ types: bts }) {
  // 返回一个有 visitor 的对象,这是规定,然后在 visitor 中编写获取各个节点的方法
  return {
    visitor: {
        ...
    }
  }
}
分析源代码

有了插件的基本结构之后,接下来我们需要分析我们的代码,它在AST中长什么样

AST Explorer

如下图所示:

用鼠标点击需要更改的地方,比如我们要改变量名,则点击以后会看到右侧的AST tree展开并高亮了一部分,高亮的这部分就是我们要改的变量aAST节点,我们知道它是一个Identifier类型的节点,所以我们就在visitor中编写一个Identifier方法

module.exports = function ({ types: bts }) {
    return {
        visitor: {
            /**
             * 负责处理所有节点类型为 Identifier 的 AST 节点
             * @param {*} path AST 节点的路径信息,可以简单理解为里面放了 AST 节点的各种信息
             * @param {*} state 有一个很重要的 state.opts,是 .babelrc 中的配置项
            */
            Identifier (path, state) {
                // 节点信息
                const node = path.node
                // 从节点信息中拿到 name 属性,即 a 和 b
                const name = node.name
                // 如果配置项中存在 name 属性,则将 path.node.name 的值替换为配置项中的值
                if (state.opts[name]) {
                    path.node.name = state.opts[name]
                }
            }
        }
    }
}

这里我们用到了插件的配置信息,接下来我们在.babelrc中编写插件的配置信息

.babelrc
{
  "plugins": [
    [
      "lyn",
      {
        "a": "aa",
        "b": "bb"
      }
    ]
  ]
}

这个配置项是不是很熟悉?和babel-plugin-component的及其相似,lyn表示 babel 插件的名称,后面的对象就是我们的配置项

输出结果
首先安装 babel-cli

这里有一点需要注意,在安装 babel-cli 之前,把我们编写的插件备份,不然执行下面的安装时,我们的插件目录会被删除,原因没有深究,应该是我们的插件不是一个有效的 npm 包,所以会被清除掉

npm i babel-cli -D
编译
npx babel index.js

得到如下输出:

let aa = 1;
let bb = 1;

说明我们的插件已经生效,且刚才的思路是没问题的,转译代码其实就是通过更改 AST 节点的信息即可

let -> const

我们刚才已经完成了变量的转译,接下来再把let关键字变成const

按照刚才的方法,我们需要更改关键字let,将光标移动到let上,发现AST Tree高亮部分变了,可以看到letAST节点类型为VariableDeclaration,且我们要改的就是kind属性,好了,开始写代码

module.exports = function ({ types: bts }) {
    return {
        visitor: {
            Identifier (path, state) {
                ...
            },
            // 处理变量声明关键字
            VariableDeclaration (path, state) {
                // 这次就没从配置文件读了,来个简单的,直接改
                path.node.kind = 'const'
            }
        }
    }
}
编译
npx babel index.js

得到如下输出:

const aa = 1;
const bb = 1;

到这里我们第一阶段的入门就结束了,是不是感觉很简单??是的,这个入门示例真的很简单,但是真的编写一个可用于业务Babel插件以及其中的涉及到的AST编译原理是非常复杂的。但是这个入门示例已经可以支持我们去分析babel-plugin-component插件的源码原理了。

完整代码
// 函数会有一个 babelTypes 参数,我们结构出里面的 types
// 代码中需要用到它的一些方法,方法具体什么意思可以参考 
// https://babeljs.io/docs/en/next/babel-types.html
module.exports = function ({ types: bts }) {
  // 返回一个有 visitor 的对象,这是规定,然后在 visitor 中编写获取各个节点的方法
  return {
    visitor: {
      /**
       * 负责处理所有节点类型为 Identifier 的 AST 节点
       * @param {*} path AST 节点的路径信息,可以简单理解为里面放了 AST 节点的各种信息
       * @param {*} state 有一个很重要的 state.opts,是 .babelrc 中的配置项
       */
      Identifier (path, state) {
        // 节点信息
        const node = path.node
        // 从节点信息中拿到 name 属性,即 a 和 b
        const name = node.name
        // 如果配置项中存在 name 属性,则将 path.node.name 的值替换为配置项中的值
        if (state.opts[name]) {
          path.node.name = state.opts[name]
        }
      },
      // 处理变量声明关键字
      VariableDeclaration (path, state) {
        // 这次就没从配置文件读了,来个简单的,直接改
        path.node.kind = 'const'
      }
    }
  }
}

babel-plugin-component 源码分析

目标分析

在进行源码阅读之前我们先分析一下我们的目标,带着目标去阅读,效果会更好

源代码

// 全局引入
import ElementUI from 'element-ui'
Vue.use(ElementUI)
// 按需引入
import { Button, Checkbox } from 'element-ui'
Vue.use(Button)
Vue.component(Checkbox.name, Checkbox)

上面就是我们使用element-ui组件库的两种方式,全局引入和按需引入

目标代码

// 全局引入
var ElementUI = require('element-ui/lib')
require('element-ui/lib/theme-chalk/index.css')
Vue.use(ElementUI)
// 按需引入
var Button = require('element-ui/lib/button.js')
require('element-ui/lib/theme-chalk/button.css')
var Checkbox = require('element-ui/lib/checkbox.js')
require('element-ui/lib/theme-chalk/checkbox.css')
Vue.use(Button)
Vue.component(Checkbox.name, Checkbox)

以上就是源代码和转译后的目标代码,我们可以将他们分别复制到 AST Explorer 中查看 AST Tree的信息,进行分析

全局引入

从上图中可以看出,这两条语句总共是由两种类型的节点组成,import对应的ImportDeclaration的节点,Vue.use(ElementUI)对应于ExpressionStatement类型的节点

可以看到import ElementUI from 'element-ui'对应到AST中,from后面的element-ui对应于source.value,且节点类型为StringLiteral

import ElementUI from 'element-ui'中的ElementUI对应于ImportDefaultSpecifier类型的节点,是个默认导入,变量对应于Indentifier节点的name属性

6

Vue.use(ElementUI)是个声明式的语句,对应于ExpressionStatement的节点,可以看到参数ElementUI放到了arguments部分

按需引入

可以看到body有三个子节点,一个ImportDeclaration,两个ExpressionStatement,和我们的代码一一对应

import语句中对于from后面的部分上面的全局是一样的,都是在source中,是个Literal类型的节点

可以看到import后面的内容变了,上面的全局引入是一个ImportDefaultDeclaration类型的节点,这里的按需加载是一个ImportDeclaration节点,且引入的内容放在specifiers对象中,每个组件(Button、Checkbox)是一个ImportSpecifier,里面定义了importedlocalIdentifier,而我们的变量名称(Button、Checkbox)放在name属性上

剩下的Vue.use(Button)Vue.component(Checkbox.name, Checkbox)和上面全局引入类似,有一点区别是Vue.component(Checkbox.name, Checkbox)arguments有两个元素

经过刚开始的基础入门以及上面对于AST的一通分析,我们其实已经大概可以猜出来从源代码目标代码这个转换过程中发生了些什么,其实就是在visitor对象上设置响应的方法(节点类型),然后去处理符合要求的节点,将节点上对应的属性更改为目标代码上响应的值,把源代码目标代码都复制到 AST Explorer 中查看,就会发现,相应节点之间的差异(改动)就是babel-plugin-component做的事情,接下来我们进入源码寻找答案。

源码分析

直接在刚才的项目中执行

npm i babel-plugin-component -D

安装 babel-plugin-component,安装完成,在node_modules目录找babel-plugin-component目录

image-20220207175041085

看代码是随时对照AST Explorer和打log确认

.babelrc

{
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

入口,index.js

// 默认就是用于element-ui组件库的按需加载插件
module.exports = require('./core')('element-ui');

核心,core.js

源码阅读提示

  • 清楚读源码的目的是什么,为了解决什么样的问题
  • 一定要有相关的基础知识,比如上面的 babel 入门,知道入口位置在 visitor,以及在 visitor 中找那些方法去读
  • 读过程中一定要勤动手,写注释,打 log,这样有助于提高思路
  • 阅读这篇源码,一定要会用 AST Explorer 分析和对比我们的源代码 和 目标代码
  • 下面的源代码几乎每行都加了注释,大家按照步骤自己下一套源码,可以对比着看,一遍看不懂,看两遍,书读三遍其义自现,真的,当然,读的过程中有不懂的地方需要查一查
/**
 * 判断 obj 的类型
 * @param {*} obj 
 */
function _typeof(obj) { 
  if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { 
    _typeof = function _typeof(obj) { 
      return typeof obj; 
    }; 
  } else { 
    _typeof = function _typeof(obj) { 
      return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; 
    }; 
  } 
  return _typeof(obj); 
}

// 提供了一些方法,负责生成 import 节点
var _require = require('@babel/helper-module-imports'),
  addSideEffect = _require.addSideEffect,
  addDefault = _require.addDefault;

// node.js 的内置模块,处理 路径信息
var resolve = require('path').resolve;

// node.js 内置模块,判断文件是否存在
var isExist = require('fs').existsSync;

// 缓存变量, cache[libraryName] = 1 or 2
var cache = {};
// 缓存样式库的样式路径,cachePath[libraryName] = ''
var cachePath = {};
// importAll['element-ui/lib'] = true,说明存在默认导入
var importAll = {};

module.exports = function core(defaultLibraryName) {
  return function (_ref) {
    // babelTypes,提供了一系列方法供使用,官网地址:https://babeljs.io/docs/en/next/babel-types.html
    var types = _ref.types;
    // 存储所有的 ImportSpecifier,即按需引入的组件,specified = { Button: 'Button', Checkbox: 'Checkbox' }
    var specified;
    // 存储所有全局引入的库,libraryObjs = { ElementUI: 'element-ui' }
    var libraryObjs;
    // 存储已经引入(处理)的方法(组件),
    // selectedMethods = {
    //   ElementUI: { type: 'Identifier', name: '_ElementUI' },
    //   Button: { type: 'Identifier', name: '_Button' },
    //   Checkbox: { type: 'Identifier', name: '_Checkbox' }
    // }
    var selectedMethods;
    // 引入的模块和库之间的对应关系,moduleArr = { Button: 'element-ui', Checkbox: 'element-ui' }
    var moduleArr;

    // 将驼峰命名转换为连字符命名
    function parseName(_str, camel2Dash) {
      if (!camel2Dash) {
        return _str;
      }

      var str = _str[0].toLowerCase() + _str.substr(1);

      return str.replace(/([A-Z])/g, function ($1) {
        return "-".concat($1.toLowerCase());
      });
    }

    /**
     * 该方法负责生成一些 AST 节点,这些节点的信息是根据一堆配置项来的,这对配置项就是在告诉 AST 节点每个组件的路径信息,
     * 比如 'element-ui/lib/button.js' 和 'element-ui/lib/theme-chalk/button.css'
     * @param {*} methodName Button、element-ui
     * @param {*} file 一拖不想看的对象信息
     * @param {*} opts .babelrc 配置项
     */
    function importMethod(methodName, file, opts) {
      // 如果 selectedMethods 中没有 Butotn、element-ui 则进入 if ,否则直接 return selectedMethods[methodName],说明该方法(组件)已经被处理过了
      if (!selectedMethods[methodName]) {
        var options;
        var path;

        // 不用管
        if (Array.isArray(opts)) {
          options = opts.find(function (option) {
            return moduleArr[methodName] === option.libraryName || libraryObjs[methodName] === option.libraryName;
          }); // eslint-disable-line
        }

        /**
         * 以下是一堆配置项
         */
        // 传递进来的配置
        options = options || opts;
        var _options = options,
          // 配置的 libDir
          _options$libDir = _options.libDir,
          // 没有配置,就默认为 lib, /element-ui/lib/button.js 中的 lib 就是这么来的
          libDir = _options$libDir === void 0 ? 'lib' : _options$libDir,
          // 组件库,element-ui
          _options$libraryName = _options.libraryName,
          // 组件库名称
          libraryName = _options$libraryName === void 0 ? defaultLibraryName : _options$libraryName,
          // 样式,boolean 类型,这里是 undefined
          _options$style = _options.style,
          // style 默认是 true,也可以由用户提供,在用户没有提供 styleLibraryName 选项是起作用
          style = _options$style === void 0 ? true : _options$style,
          // undefiend
          styleLibrary = _options.styleLibrary,
          // undefined
          _options$root = _options.root,
          // ''
          root = _options$root === void 0 ? '' : _options$root,
          _options$camel2Dash = _options.camel2Dash,
          camel2Dash = _options$camel2Dash === void 0 ? true : _options$camel2Dash;
        // 配置项中的,'theme-chalk'
        var styleLibraryName = options.styleLibraryName;
        // ''
        var _root = root;
        var isBaseStyle = true;
        var modulePathTpl;
        var styleRoot;
        var mixin = false;
        // 后缀 xx.css
        var ext = options.ext || '.css';

        if (root) {
          _root = "/".concat(root);
        }

        if (libraryObjs[methodName]) {
          // 默认导入 ElementUI, path = 'element-ui/lib'
          path = "".concat(libraryName, "/").concat(libDir).concat(_root);

          if (!_root) {
            // 默认导入的情况下,记录在 importAll 中标记 path 为 true
            importAll[path] = true;
          }
        } else {
          // 按需引入,path = 'element-ui/lib/button'
          path = "".concat(libraryName, "/").concat(libDir, "/").concat(parseName(methodName, camel2Dash));
        }

        // 'element-ui/lib/button'
        var _path = path;
        /**
         * selectedMethods['Button'] = { type: Identifier, name: '_Button' }
         * addDefault 就负责添加刚才在 visitor.CallExpreesion 那说的那堆东西,
         * 这里主要负责 var Button = require('element-ui/lib/button.js'),
         * 这是猜的,主要是没找到这方面的文档介绍
         */
        selectedMethods[methodName] = addDefault(file.path, path, {
          nameHint: methodName
        });

        /**
         * 接下来是处理样式
         */
        if (styleLibrary && _typeof(styleLibrary) === 'object') {
          styleLibraryName = styleLibrary.name;
          isBaseStyle = styleLibrary.base;
          modulePathTpl = styleLibrary.path;
          mixin = styleLibrary.mixin;
          styleRoot = styleLibrary.root;
        }

        // styleLibraryName = 'theme-chalk',如果配置该选项,就采用默认的方式,进入 else 查看
        if (styleLibraryName) {
          // 缓存样式库路径
          if (!cachePath[libraryName]) {
            var themeName = styleLibraryName.replace(/^~/, '');
            // cachePath['element-ui'] = 'element-ui/lib/theme-chalk'
            cachePath[libraryName] = styleLibraryName.indexOf('~') === 0 ? resolve(process.cwd(), themeName) : "".concat(libraryName, "/").concat(libDir, "/").concat(themeName);
          }

          if (libraryObjs[methodName]) {
            // 默认导入
            /* istanbul ingore next */
            if (cache[libraryName] === 2) {
              // 提示信息,意思是说如果你项目既存在默认导入,又存在按需加载,则要保证默认导入在按需加载的前面
              throw Error('[babel-plugin-component] If you are using both' + 'on-demand and importing all, make sure to invoke the' + ' importing all first.');
            }

            // 默认导出的样式库路径:path = 'element-ui/lib/theme-chalk/index.css'
            if (styleRoot) {
              path = "".concat(cachePath[libraryName]).concat(styleRoot).concat(ext);
            } else {
              path = "".concat(cachePath[libraryName]).concat(_root || '/index').concat(ext);
            }

            cache[libraryName] = 1;
          } else {
            // 按需引入,这里不等于 1 就是存在默认导入 + 按需引入的情况,基本上没人会这么用
            if (cache[libraryName] !== 1) {
              /* if set styleLibrary.path(format: [module]/module.css) */
              var parsedMethodName = parseName(methodName, camel2Dash);

              if (modulePathTpl) {
                var modulePath = modulePathTpl.replace(/\[module]/ig, parsedMethodName);
                path = "".concat(cachePath[libraryName], "/").concat(modulePath);
              } else {
                path = "".concat(cachePath[libraryName], "/").concat(parsedMethodName).concat(ext);
              }

              if (mixin && !isExist(path)) {
                path = style === true ? "".concat(_path, "/style").concat(ext) : "".concat(_path, "/").concat(style);
              }

              if (isBaseStyle) {
                addSideEffect(file.path, "".concat(cachePath[libraryName], "/base").concat(ext));
              }

              cache[libraryName] = 2;
            }
          }

          // 添加样式导入,require('elememt-ui/lib/theme-chalk/button.css'),这里也是猜的,说实话,addDefault 方法看的有点懵,要是有文档就好了
          addDefault(file.path, path, {
            nameHint: methodName
          });
        } else {
          if (style === true) {
            // '/element-ui/style.css,这里是默认的,ext 可以由用户提供,也是用默认的
            addSideEffect(file.path, "".concat(path, "/style").concat(ext));
          } else if (style) {
            // 'element-ui/xxx,这里的 style 是用户提供的 
            addSideEffect(file.path, "".concat(path, "/").concat(style));
          }
        }
      }

      return selectedMethods[methodName];
    }

    function buildExpressionHandler(node, props, path, state) {
      var file = path && path.hub && path.hub.file || state && state.file;
      props.forEach(function (prop) {
        if (!types.isIdentifier(node[prop])) return;

        if (specified[node[prop].name]) {
          node[prop] = importMethod(node[prop].name, file, state.opts); // eslint-disable-line
        }
      });
    }

    function buildDeclaratorHandler(node, prop, path, state) {
      var file = path && path.hub && path.hub.file || state && state.file;
      if (!types.isIdentifier(node[prop])) return;

      if (specified[node[prop].name]) {
        node[prop] = importMethod(node[prop].name, file, state.opts); // eslint-disable-line
      }
    }

    return {
      // 程序的整个入口,熟悉的 visitor
      visitor: {
        // 负责处理 AST 中 Program 类型的节点
        Program: function Program() {
          // 将之前定义的几个变量初始化为没有原型链的对象
          specified = Object.create(null);
          libraryObjs = Object.create(null);
          selectedMethods = Object.create(null);
          moduleArr = Object.create(null);
        },
        // 处理 ImportDeclaration 节点
        ImportDeclaration: function ImportDeclaration(path, _ref2) {
          // .babelrc 中的插件配置项
          var opts = _ref2.opts;
          // import xx from 'xx', ImportDeclaration 节点
          var node = path.node;
          // import xx from 'element-ui',这里的 node.source.value 存储的就是 库名称
          var value = node.source.value;
          var result = {};

          // 可以不用管,如果配置项是个数组,从数组中找到该库的配置项
          if (Array.isArray(opts)) {
            result = opts.find(function (option) {
              return option.libraryName === value;
            }) || {};
          }

          // 库名称,比如 element-ui
          var libraryName = result.libraryName || opts.libraryName || defaultLibraryName;

          // 如果当前 import 的库就是我们需要处理的库,则进入
          if (value === libraryName) {
            // 遍历node.specifiers,里面放了多个ImportSpecifier,每个都是我们要引入的组件(方法)
            node.specifiers.forEach(function (spec) {
              // ImportSpecifer 是按需引入,还有另外的一个默认导入,ImportDefaultSpecifier,比如:ElementUI
              if (types.isImportSpecifier(spec)) {
                // 设置按需引入的组件, 比如specfied['Button'] = 'Button'
                specified[spec.local.name] = spec.imported.name;
                // 记录当前组件是从哪个库引入的,比如 moduleArr['Button'] = 'element-ui'
                moduleArr[spec.imported.name] = value;
              } else {
                // 默认导入,libraryObjs['ElementUI'] = 'element-ui'
                libraryObjs[spec.local.name] = value;
              }
            });

            // 不是全局引入就删掉该节点,意思是删掉所有的按需引入,这个会在 importMethod 方法中设置
            if (!importAll[value]) {
              path.remove();
            }
          }
        },
        /**
         * 这里很重要,我们会发现在使用按需加载时,如果你只是import引入,但是没有使用,比如Vue.use(Button),则一样不会打包,所以这里就是来
         * 处理这种情况的,只有你引入的包实际使用了,才会真的import,要不然刚才删了就没有然后了,就不会在 node 上添加各种 arguments 了,比如:
         * {
         *   type: 'CallExpression',
         *   callee: { type: 'Identifier', name: 'require' },
         *   arguments: [ { type: 'StringLiteral', value: 'element-ui/lib' } ]
         * }
         * {
         *   type: 'CallExpression',
         *   callee: { type: 'Identifier', name: 'require' },
         *   arguments: [
         *    {
         *      type: 'StringLiteral',
         *      value: 'element-ui/lib/chalk-theme/index.css'
         *    }
         *   ]
         * }
         * {
         *    type: 'CallExpression',
         *    callee: { type: 'Identifier', name: 'require' },
         *    arguments: [ { type: 'StringLiteral', value: 'element-ui/lib/button' } ]
         * }
         * 以上这些通过打log可以查看,这个格式很重要,因为有了这部分数据,我们就知道:
         * import {Button} from 'element-ui' 为什么能
         * 得到 var Button = require('element-ui/lib/button.js')
         * 以及 require('element-ui/lib/theme-chalk/button.css')
         *
         * @param {*} path 
         * @param {*} state 
         */
        CallExpression: function CallExpression(path, state) {
          // Vue.use(Button),CallExpression 节点
          var node = path.node;
          // 很大的一拖对象,不想看(不用看,费头发)
          var file = path && path.hub && path.hub.file || state && state.file;
          // callee 的 name 属性,我们这里不涉及该属性,类似ElementUI(ok)这种语法会有该属性,node.callee.name 就是 ElementUI
          var name = node.callee.name;

          console.log('import method 处理前的 node:', node)
          // 判断 node.callee 是否属于 Identifier,我们这里不是,我们的是一个 MemberExpression
          if (types.isIdentifier(node.callee)) {
            if (specified[name]) {
              node.callee = importMethod(specified[name], file, state.opts);
            }
          } else {
            // 解析 node.arguments 数组,每个元素都是一个 Identifier,Vue.use或者Vue.component的参数
            node.arguments = node.arguments.map(function (arg) {
              // 参数名称
              var argName = arg.name;

              // 1、这里会生成一个新的 Identifier,并更改 AST节点的属性值
              // 2、按需引入还是默认导入是在 ImportDeclaration 中决定的
              if (specified[argName]) {
                // 按需引入,比如:{ type: "Identifier", name: "_Button" },这是 AST 结构的 JSON 对象表示形式
                return importMethod(specified[argName], file, state.opts);
              } else if (libraryObjs[argName]) {
                // 默认导入,{ type: "Identifier", name: "_ElementUI" }
                return importMethod(argName, file, state.opts);
              }

              return arg;
            });
          }
          console.log('import method 处理后的 node:', node)
        },
        /**
         * 后面几个不用太关注,在这里不涉及,看字面量就可以明白在做什么 
         */
        // 处理 MemberExpression,更改 node.object 对象
        MemberExpression: function MemberExpression(path, state) {
          var node = path.node;
          var file = path && path.hub && path.hub.file || state && state.file;

          if (libraryObjs[node.object.name] || specified[node.object.name]) {
            node.object = importMethod(node.object.name, file, state.opts);
          }
        },
        // 处理赋值表达式
        AssignmentExpression: function AssignmentExpression(path, _ref3) {
          var opts = _ref3.opts;

          if (!path.hub) {
            return;
          }

          var node = path.node;
          var file = path.hub.file;
          if (node.operator !== '=') return;

          if (libraryObjs[node.right.name] || specified[node.right.name]) {
            node.right = importMethod(node.right.name, file, opts);
          }
        },
        // 数组表达式
        ArrayExpression: function ArrayExpression(path, _ref4) {
          var opts = _ref4.opts;

          if (!path.hub) {
            return;
          }

          var elements = path.node.elements;
          var file = path.hub.file;
          elements.forEach(function (item, key) {
            if (item && (libraryObjs[item.name] || specified[item.name])) {
              elements[key] = importMethod(item.name, file, opts);
            }
          });
        },
        // 属性
        Property: function Property(path, state) {
          var node = path.node;
          buildDeclaratorHandler(node, 'value', path, state);
        },
        // 变量声明
        VariableDeclarator: function VariableDeclarator(path, state) {
          var node = path.node;
          buildDeclaratorHandler(node, 'init', path, state);
        },
        // 逻辑表达式
        LogicalExpression: function LogicalExpression(path, state) {
          var node = path.node;
          buildExpressionHandler(node, ['left', 'right'], path, state);
        },
        // 条件表达式
        ConditionalExpression: function ConditionalExpression(path, state) {
          var node = path.node;
          buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
        },
        // if 语句
        IfStatement: function IfStatement(path, state) {
          var node = path.node;
          buildExpressionHandler(node, ['test'], path, state);
          buildExpressionHandler(node.test, ['left', 'right'], path, state);
        }
      }
    };
  };
};

总结

通过阅读源码以及打log的方式,我们得到了如下信息:

{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'require' },
    arguments: [ { type: 'StringLiteral', value: 'element-ui/lib' } ]
}
{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'require' },
    arguments: [
        {
          type: 'StringLiteral',
          value: 'element-ui/lib/chalk-theme/index.css'
        }
    ]
}
{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'require' },
    arguments: [ { type: 'StringLiteral', value: 'element-ui/lib/button' } ]
}

这其实就是经过变化后的AST的部分信息,通过对比目标代码在AST Tree中的显示会发现,结果是一致的,也就是说通过以上AST信息就可以生成我们需要的目标代码

目标代码中的require关键字就是calleerequire函数中的参数就是arguments数组

以上就是 按需加载原理分析 的所有内容。

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

PDF 生成(1)— 开篇

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

封面.png

简介

本系列旨在介绍纯前端技术方案下的 PDF 生成最佳实践。内容涵盖业务背景、选型思路和实践历程,从简单的 PDF 文件生成到复杂的配置化与服务化。

整个实践过程以技术为驱动,同时也展示了如何打造技术产品的过程。是一份适合任何人实践的教程。

背景

需求来自业务对公司战略的拆解 — 安全运维托管服务,为用户提供全日制的数字化资产安全运维、监控、告警、专家分析等服务。一句话总结就是,用户付钱找我们为用户提供全方位的资产运维服务。

在这个服务中我们为用户做了很多事情,我们需要让用户看到我们的价值,所以会以日报、周报的形式为用户推送运维报告,而这份报告就是以 PDF 文件的形式呈现。

所以,这份报告承载了产品能力和价值的传递,业务对 PDF 文件内容的展现提出了明确的要求:需要呈现出色彩鲜明、精美的设计,简单描述就是好看 + 酷炫

于是,设计同学的设计稿就来了

本系列出现的所有和托管服务相关的配图版权均归 360 企业安全云所有

image (3) (1).png

看到设计稿的瞬间,就在想,这效果用 PDF 能呈现?最后会不会是这结果?

image.png

因此,业务需求可以归纳为一份出色、惊艳的 PDF 文件

技术调研

讲了业务背景,接下来就该技术调研了,经过调研,PDF 文件生成可以总结为两大类:原生方案和转化方案。

原生方案

利用开源工具库直接操作 PDF 文件,在文件内绘制内容,比如 iText、PDFKit、pdf-lib。

  • 优点,性能高,适用于内容简单的场景
  • 缺点,难以处理具有复杂排版和样式的场景

转化方案

将内容通过中间媒介转化成 PDF 文件,主要包括:Word 转 PDF、HTML/CSS 转 PDF。

Word 转 PDF 的缺点和原生方案一样,在复杂排版和样式场景上有心无力。大概原理是通过 Word 提供的 API 操作编写 Word 文档,然后 Word 转换成 PDF 文件。

HTML/CSS 转 PDF,主要有如下三种方案:

  • 模版引擎,利用模版引擎生成 HTML/CSS,然后结合下面的两个方案生成 PDF 文件,一般后端同学会用这个方案
  • Canvas,前端常用的方案,例如 html2canvas + jsPDF,但在 PDF 分页、内容截断问题上难以解决,PDF 目录页不支持页面跳转和展示页码
  • 浏览器打印系统,利用浏览器的布局、渲染、打印能力,通过 DevTools 协议控制 Chrome/Chromiun,实现 PDF 文件的打印,即 chrome 浏览器右键 -> 打印的自动化版本

技术决策

经过调研和众多方案的分析,最终我们选择了浏览器打印系统方案,具体的实现上我们选择了 Puppeteer 框架,它是一个 Node.js 库,提供高级 API 控制 Chrome/Chromiun 浏览器,我们在浏览器中手动执行的大多数操作它都可以完成,例如执行 page.pdf 方法即可将当前渲染的页面打印成 PDF 文件,简单易用。

为什么选择基于浏览器打印系统的 puppeteer 方案?

  • 经过方案调研之后的综合对比,基于浏览器打印系统的方案更符合业务的诉求
  • 我们是前端团队,这套方案更符合团队的技术栈
  • 人力和时间成本,其他几个方案基本上就是只能服务端同学自己做,前端很难参与进去,对服务端团队的研发资源造成压力,影响部分业务的吞吐率

这套方案前后端同学各司其职、通力合作,分别做自己擅长的事。服务端同学开发页面接口供前端同学调用,前端同学负责开发酷炫的页面,PDF 生成服务将前后端同学开发的页面转成 PDF 文件

image.png

于是,产品和设计同学就可以在这张静态的 A4 纸上尽情发挥,不受技术限制。

技术架构

image.png

方案的技术架构,分为三大块,分别是:

  • 接入方,即 PDF 生成服务的调用方,就是一个普通的 Web 项目(前端 + 后端)
  • PDF 生成服务,对外暴露 API,一次 API 调用,产出一份 PDF 文件的下载地址
  • 配置服务,维护接入方的信息,为 PDF 生成服务提供必要的配置信息,比如接入方 Web 项目的页面地址,PDF 生成服务会负责将这些页面生成 PDF 文件

整体执行流程如下:

image.png

  • 接入方,带着分配的 APP ID 和 其它参数调用生成 PDF 服务的 API 接口,其它参数是接入方前后端自己需要用到的参数,调用时提供的所有参数会原封不动的通过 URL 查询参数带到接入方的前端页面地址上
  • PDF 生成服务
    • 接收到请求后,将请求放入队列
    • 监听到队列有内容进入,通知生成 PDF 文件的模块,启动 PDF 生成任务
    • 任务拿着 APP ID 请求配置服务,获取到对应的配置信息
    • 任务将配置信息中指定的所有页面打印成 PDF 文件
    • 将 PDF 文件上传到智慧云(S3)上,并将 PDF 文件的下载地址通过回调接口回传给接入方

总结

到这里本文就结束了,本文主要讲了如下内容:

  • 业务背景,要求技术能够产出一份漂亮 + 酷炫的 PDF 文件
  • 技术调研,主要分为原生方案和转化方案
  • 技术决策,结合业务诉求、各个方案的优缺点、团队技术栈和部门人力、时间成本,最终选择基于浏览器打印系统的 puppeteer 方案
  • 整个方案的技术架构设计

一个完善的技术架构是随着业务持续迭代而产生的,接下来我们将从零开始逐步实现整套架构,因此这是一份适合任何人实践的教程

链接

  • PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

如何快速为团队打造自己的组件库(下)—— 基于 element-ui 为团队打造自己的组件库

如何快速为团队打造自己的组件库(下)—— 基于 element-ui 为团队打造自己的组件库

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

element-ui

简介

在了解 Element 源码架构 的基础上,接下来我们基于 element-ui 为团队打造自己的组件库。

主题配置

基础组件库在 UI 结构上差异很小,一般只是在主题色上会有较大差异,毕竟每个团队都有了 UI 风格。比如,我们团队的 UI 设计稿其实是基于 Ant Design 来出的,而组件库是基于 Element-UI 来开发,即使是这种情况,对组件本身的改动也很少。所以,基于开源库打造团队自己的组件库时,主题配置就很重要了。

element-ui 的一大特色就是支持自定义主题,它通过在线主题编辑器、Chrome 插件或命令行主题工具这三种方式来定制 element-ui 所有组件的样式。那么 element-ui 是怎么做到这一点的呢?

因为 element-ui 组件样式中的颜色、字体、线条等样式都是通过变量的方式引入的,在 packages/theme-chalk/src/common/var.scss 中可以看到这些变量的定义,这就为自定义主题提供了方便,因为我们只需要修改这些变量,就可以实现组件主题的改变。

在线主题编辑器和 Chrome 插件支持实时预览。并且可以下载定制的样式包,然后使用。在线主题编辑器和 Chrome 插件的优点是可视化,简洁明了,但是有个最大的缺点就是,最后下载出来的是一个将所有组件样式打包到一起的样式包,没办法支持按需加载,不推荐使用。这里我们使用命令行主题工具来定制样式。

命令行主题工具

  • 初始化项目目录并安装主题生成工具(element-theme)

    mkdir theme && cd theme && npm init -y && npm i element-theme -D
  • 安装白垩主题

    npm i element-theme-chalk -D
  • 初始化变量文件

    node_modules/.bin/et -i

    命令执行以后可能会得到如下报错信息

    image-20220213193714430

    原因是 element-theme 包中依赖了低版本的 graceful-fs,低版本 graceful-fs 在高版本的 node.js 中不兼容,最简单的方案是升级 graceful-fs。

    在项目根目录下创建 npm-shrinkwrap.json 文件,并添加如下内容:

    {
       "dependencies": {
           "graceful-fs": {
               "version": "4.2.2"
           }
       }
    }

    运行 npm install 重新安装依赖即可解决,然后重新执行 node_modules/.bin/et -i,执行完以后会在当前目录生成 element-variables.scss 文件。

  • 修改变量

    直接编辑 element-variables.scss 文件,例如修改主题色为红色,将文件中的 $--color-primary 的值修改为 red$--color-primary: red !default;

    文件中写了很好的注释,并且样式代码也是按照组件来分割组织的,所以大家可以对照设计团队给到的设计稿来一一修改相关的变量。如果实在觉得看代码比较懵,可以参照在线主题编辑器,两边的变量名是一致的。

    题外话:element-ui 还提供了两个资源包,供设计团队使用,所以最理想的是,让设计团队根据 element-ui 的资源包出设计稿,这样两边就可以做到统一,研发团队的工作量也会降低不少。比如我们团队就不是这样,设计团队给到的设计稿是基于 Ant Design 出的,研发组件库时改动的工作量和难度就会相对比较大。所以研发、设计、产品一定要进行很好的沟通。

  • 编译主题

    修改完以后,保存文件,然后执行以下命令编译主题,会产生一个 theme 目录。生产出来都是 CSS 样式文件,文件名和组件名一一对应,支持按需引入(指定组件的样式文件)和全量引入(index.css)。

    • 生产未压缩的样式文件

      node_modules/.bin/et --out theme-chalk
    • 生产经过压缩的样式文件

      node_modules/.bin/et --minimize --out theme-chalk
    • 帮助命令

      node_modules/.bin/et --help
    • 启用 watch 模式,实时编译主题

      node_modules/.bin/et --watch --out theme-chalk
  • 使用自定义主题

    • 用新生成的主题目录(theme-chalk)替换掉框架中的 packages/theme-chalk 目录。重命名老的 theme-chalk 为 theme-chalk.bak,不要删掉,后面需要用

      建议将生成主题时用到的 element-variables.scss 文件保存在项目中,因为以后你可能会需要重新生成主题

    • 修改 /examples/entry.js/examples/play.js/examples/extension/src/app.js 中引入的组件库的样式

      // 用新的样式替换旧的默认样式
      // import 'packages/theme-chalk/src/index.scss
      import 'packages/theme-chalk/index.css
    • 修改 /build/bin/iconInit.js 中引入的图标样式文件

      // var fontFile = fs.readFileSync(path.resolve(__dirname, '../../packages/theme-chalk/src/icon.scss'), 'utf8');
      var fontFile = fs.readFileSync(path.resolve(__dirname, '../../packages/theme-chalk/icon.css'), 'utf8');
    • 修改 /examples/docs/{四种语言}/custom-theme.md

      // @import "~element-ui/packages/theme-chalk/src/index";
      @import "~element-ui/packages/theme-chalk/index";
      
    • 执行 make dev 启动开发环境,查看效果

    到这一步,主题配置就结束了,你会发现,element-ui 官网的组件样式基本上和设计稿上的一致。但是仔细对比后,会发现有一些组件的样式和设计稿有差异,这时候就需要对这些组件的样式进行深度定制,覆写不一致的样式。

    其实这块儿漏掉了 /build/bin/new.js 中涉及的样式目录,这块儿的改动会放到后面

样式深度定制

上一步的主题配置,只能解决主题相关的样式,但是有些组件的有些样式不属于主题样式,如果这部分样式刚好又和设计稿不一致的话,那就需要重写这部分样式去覆盖上一步的样式。

以下配置还支持为自定义组件添加样式

样式目录

  • 主题配置 步骤中备份的 /packages/theme-chalk.bak 重命名为 /packages/theme-lyn,作为覆写组件和自定义组件的样式目录

  • 删掉 /packages/theme-lyn/src 目录的所有文件

  • 你会写 scss ?

    • 忽略掉下一步,然后后续步骤你只需将对应的 less 操作换成 sass 即可
  • 你不会写 scss,扩展其它语法,假设你会写 less

    • 在项目根目录执行以下命令,然后删掉 gulp-sass

      npm i less less-loader gulp-less -D && npm uninstall gulp-sass -D

      如果一会儿启动开发环境以后,报错 “TypeError: this.getOptions is not a function”,则降级 less-loader 版本,比如我的版本是:[email protected][email protected]

    • /packages/theme-lyn 目录下执行以下命令,然后删掉 gulp-sass

      npm i gulp-less -D && npm uninstall gulp-sass -D
    • /packages/theme-lyn/gulpfile.js 更改为以下内容

      'use strict';
      
      /**
       *  将 ./src/*.less 文件编译成 css 文件输出到 ./lib 目录
       *  将 ./src/fonts/中的所有字体文件输出到 ./lib/fonts 中,如果你没有覆写字体样式的需要,则删掉拷贝字体样式部分
       */
      const { series, src, dest } = require('gulp');
      const less = require('gulp-less');
      const autoprefixer = require('gulp-autoprefixer');
      const cssmin = require('gulp-cssmin');
      const path = require('path')
      
      function compile() {
        return src('./src/*.less')
          .pipe(less({
            paths: [ path.join(__dirname, './src') ]
          }))
          .pipe(autoprefixer({
            browsers: ['ie > 9', 'last 2 versions'],
            cascade: false
          }))
          .pipe(cssmin())
          .pipe(dest('./lib'));
      }
      
      function copyfont() {
        return src('./src/fonts/**')
          .pipe(cssmin())
          .pipe(dest('./lib/fonts'));
      }
      
      // 也可以在这里扩展其它功能,比如拷贝静态资源
      
      exports.build = series(compile, copyfont);
    • build/webpack.demo.js 中增加解析 less 文件的规则

      {
        test: /\.less$/,
        use: [
          isProd ? MiniCssExtractPlugin.loader : 'style-loader',
          'css-loader',
          'less-loader'
        ]
      }
  • 假如你要覆写 button 组件的部分样式

    • /packages/theme-lyn/src 目录下新建 button.less 文件,编写覆写样式时请遵循如下规则

      • 组件样式的覆写,最好遵循 BEM 风格,目的是提供良好的命名空间隔离,避免样式打包以后发生意料之外的覆盖
      • 只覆写已有的样式,可以在组件上新增类名,但不要删除,目的是兼容线上代码
      // 这里我要把主要按钮的字号改大有些,只是为了演示效果
      .el-button--primary {
        font-size: 24px;
      }
  • 改造 build/bin/gen-cssfile.js 脚本

    /**
     * 将各个覆写的样式文件在 packages/theme-lyn/src/index.less 文件中自动引入
     */
    
    var fs = require('fs');
    var path = require('path');
    
    // 生成 theme-lyn/src 中的 index.less 文件
    function genIndexLessFile(dir) {
      // 文件列表
      const files = fs.readdirSync(dir);
      /**
       * @import 'x1.less';
       * @import 'x2.less;
       */
      let importStr = "/* Automatically generated by './build/bin/gen-cssfile.js' */\n";
    
      // 需要排除的文件
      const excludeFile = ['assets', 'font', 'index.less', 'base.less', 'variable.less'];
    
      files.forEach(item => {
        if (excludeFile.includes(item) || !/\.less$/.test(item)) return;
    
        // 只处理非 excludeFile 中的 less 文件
        importStr += `@import "./${item}";\n`;
      });
    
      // 在 packages/theme-lyn/src/index.less 文件中写入 @import "xx.less",即在 index.less 中引入所有的样式文件
      fs.writeFileSync(path.resolve(dir, 'index.less'), importStr);
    }
    
    genIndexLessFile(path.resolve(__dirname, '../../packages/theme-lyn/src/'));
  • 在项目根目录下执行以下命令

    npm i shelljs -D
  • 新建 /build/bin/compose-css-file.js

    /**
     * 负责将打包后的两个 css 目录(lib/theme-chalk、lib/theme-lyn)合并
     * lib/theme-chalk 目录下的样式文件是通过主题配置自动生成的
     * lib/theme-lyn 是扩展组件的样式(覆写默认样式和自定义组件的样式)
     * 最后将样式都合并到 lib/theme-chalk 目录下
     */
    const fs = require('fs');
    const fileSave = require('file-save');
    const { resolve: pathResolve } = require('path');
    const shelljs = require('shelljs');
    
    const themeChalkPath = pathResolve(__dirname, '../../lib/theme-chalk');
    const themeStsUIPath = pathResolve(__dirname, '../../lib/theme-lyn');
    
    // 判断样式目录是否存在
    let themeChalk = null;
    let themeStsUI = null;
    try {
      themeChalk = fs.readdirSync(themeChalkPath);
    } catch (err) {
      console.error('/lib/theme-chalk 不存在');
      process.exit(1);
    }
    try {
      themeStsUI = fs.readdirSync(themeStsUIPath);
    } catch (err) {
      console.error('/lib/theme-lyn 不存在');
      process.exit(1);
    }
    
    /**
     * 遍历两个样式目录,合并相同文件,将 theme-lyn 的中样式追加到 theme-chalk 中对应样式文件的末尾
     * 如果 theme-lyn 中的文件在 theme-chalk 中不存在(比如扩展的新组件),则直接将文件拷贝到 theme-chalk
     */
    const excludeFiles = ['element-variables.css', 'variable.css'];
    for (let i = 0, themeStsUILen = themeStsUI.length; i < themeStsUILen; i++) {
      if (excludeFiles.includes(themeStsUI[i])) continue;
    
      if (themeStsUI[i] === 'fonts') {
        shelljs.cp('-R', pathResolve(themeStsUIPath, 'fonts/*'), pathResolve(themeChalkPath, 'fonts'));
        continue;
      }
    
      if (themeStsUI[i] === 'assets') {
        shelljs.cp('-R', pathResolve(themeStsUIPath, 'assets'), themeChalkPath);
        continue;
      }
    
      if (themeChalk.includes(themeStsUI[i])) {
        // 说明当前样式文件是覆写 element-ui 中的样式
        const oldFileContent = fs.readFileSync(pathResolve(themeChalkPath, themeStsUI[i]), { encoding: 'utf-8' });
        fileSave(pathResolve(themeChalkPath, themeStsUI[i])).write(oldFileContent).write(fs.readFileSync(pathResolve(themeStsUIPath, themeStsUI[i])), 'utf-8').end();
      } else {
        // 说明当前样式文件是扩展的新组件的样式文件
        // fs.writeFileSync(pathResolve(themeChalkPath, themeStsUI[i]), fs.readFileSync(pathResolve(themeStsUIPath, themeStsUI[i])));
        shelljs.cp(pathResolve(themeStsUIPath, themeStsUI[i]), themeChalkPath);
      }
    }
    
    // 删除 lib/theme-lyn
    shelljs.rm('-rf', themeStsUIPath);
  • 改造 package.json 中的 scripts

    {
      "gen-cssfile:comment": "在 /packages/theme-lyn/src/index.less 中自动引入各个组件的覆写样式文件",
      "gen-cssfile": "node build/bin/gen-cssfile",
      "build:theme:comment": "构建主题样式:在 index.less 中自动引入各个组件的覆写样式文件 && 通过 gulp 将 less 文件编译成 css 并输出到 lib 目录 && 拷贝基础样式 theme-chalk 到 lib/theme-chalk && 拷贝 编译后的 theme-lyn/lib/* 目录到 lib/theme-lyn && 合并 theme-chalk 和 theme-lyn",
      "build:theme": "npm run gen-cssfile && gulp build --gulpfile packages/theme-lyn/gulpfile.js && cp-cli packages/theme-lyn/lib lib/theme-lyn && cp-cli packages/theme-chalk lib/theme-chalk && node build/bin/compose-css-file.js",
    }
  • 执行以下命令

    npm run gen-cssfile
  • 改造 /examples/entry.js/examples/play.js

    // 用新的样式替换旧的默认样式
    // import 'packages/theme-chalk/src/index.scss
    import 'packages/theme-chalk/index.css	// 在这行下面引入自定义样式
    // 引入自定义样式
    import 'packages/theme-lyn/src/index.less'
  • 访问官网,查看 button 组件的覆写样式是否生效

自定义组件

组件库在后续的开发和迭代中,需要两种自定义组件的方式:

  • 增加新的 element-ui 组件

    element-ui 官网可能在某个时间点增加一个你需要的基础组件,这时你需要将其集成进来

  • 增加业务组件

    基础组件就绪以后,团队就会开始推动业务组件的建设,这时候就会向组件库中增加新的组件

新的 element-ui 组件

element-ui 提供了增加新组件的脚本,执行 make new <component-name> [中文名] 即可生成新组件所需的所有文件以及配置,比如:make new button 按钮,有了该脚本可以让你专注于组件的编写,不需要管任何配置。

/build/bin/new.js

但是由于我们调整了框架主题库的结构,所以脚本文件也需要做相应的调整。需要将 /build/bin/new.js 文件中处理样式的代码删掉,样式文件不再需要脚本自动生成,而是通过重新生成主题的方式实现。

// /build/bin/new.js 删掉以下代码
{
    filename: path.join('../../packages/theme-chalk/src', `${componentname}.scss`),
    content: `@import "mixins/mixins";
@import "common/var";

@include b(${componentname}) {
}`
},
  
// 添加到 index.scss
const sassPath = path.join(__dirname, '../../packages/theme-chalk/src/index.scss');
const sassImportText = `${fs.readFileSync(sassPath)}@import "./${componentname}.scss";`;
fileSave(sassPath)
  .write(sassImportText, 'utf8')
  .end('\n');

Makefile

改造 Makefile 文件,在 new 配置后面增加 && npm run build:file 命令,重新生成组件库入口文件,不然不会引入新增加的组件。

new:
    node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS)) && npm run build:file

增加新组件

完成上述改动以后,只需两步即可完成新 element-ui 组件的创建:

  • 执行 make new <component-name> [组件中文名] 命令新建新的 element-ui 组件

    这一步会生成众多文件,你只需要从新的 element-ui 源码中将该组件对应的代码复制过来填充到对应的文件即可

  • 重新生成主题,然后覆盖现在的 /packages/theme-chalk

业务组件

新增的业务组件就不要以 el 开头了,避免和 element 组件重名或造成误会。需要模拟 /build/bin/new.js 脚本写一个新建业务组件的脚本 /build/bin/new-lyn-ui.js,大家可以基于该脚本去扩展。

/build/bin/new-lyn-ui.js

'use strict';

/**
 * 新建组件脚本,以 lyn-city 组件为例
 * 1、在 packages 目录下新建组件目录,并完成目录结构的基本创建
 * 2、创建组件文档
 * 3、组件单元测试文件
 * 4、组件样式文件
 * 5、组件类型声明文件
 * 6、并将上述新建的相关资源自动添加的相应的文件,比如组件组件注册到 components.json 文件、样式文件在 index.less 中自动引入等
 * 总之你只需要专注于编写你的组件代码即可,其它一概不用管
 */

console.log();
process.on('exit', () => {
  console.log();
});

if (!process.argv[2]) {
  console.error('[组件名]必填 - Please enter new component name');
  process.exit(1);
}

const path = require('path');
const fs = require('fs');
const fileSave = require('file-save');
const uppercamelcase = require('uppercamelcase');
// 组件名称 city
const componentname = process.argv[2];
// 组件中文名 城市列表
const chineseName = process.argv[3] || componentname;
// 组件大驼峰命名 City
const ComponentName = uppercamelcase(componentname);
// 组件路径:/packages/city
const PackagePath = path.resolve(__dirname, '../../packages', componentname);
const Files = [
  // packages/city/index.js 的内容
  {
    filename: 'index.js',
    content: `import ${ComponentName} from './src/main';

/* istanbul ignore next */
${ComponentName}.install = function(Vue) {
  Vue.component(${ComponentName}.name, ${ComponentName});
};

export default ${ComponentName};`
  },
  // packages/city/src/main.vue 组件定义
  {
    filename: 'src/main.vue',
    content: `<template>
  <div class="lyn-${componentname}"></div>
</template>

<script>
export default {
  name: 'Lyn${ComponentName}'
};
</script>`
  },
  // 组件中文文档
  {
    filename: path.join('../../examples/docs/zh-CN', `${componentname}.md`),
    content: `## ${ComponentName} ${chineseName}`
  },
  // 组件单元测试文件
  {
    filename: path.join('../../test/unit/specs', `${componentname}.spec.js`),
    content: `import { createTest, destroyVM } from '../util';
import ${ComponentName} from 'packages/${componentname}';

describe('${ComponentName}', () => {
  let vm;
  afterEach(() => {
    destroyVM(vm);
  });

  it('create', () => {
    vm = createTest(${ComponentName}, true);
    expect(vm.$el).to.exist;
  });
});
`
  },
  // 组件样式文件
  {
    filename: path.join(
      '../../packages/theme-lyn/src',
      `${componentname}.less`
    ),
    content: `@import "./base.less";\n\n.lyn-${componentname} {
}`
  },
  // 组件类型声明文件
  {
    filename: path.join('../../types', `${componentname}.d.ts`),
    content: `import { LynUIComponent } from './component'

/** ${ComponentName} Component */
export declare class Lyn${ComponentName} extends LynUIComponent {
}`
  }
];

// 将新组件添加到 components.json
const componentsFile = require('../../components.json');
if (componentsFile[componentname]) {
  console.error(`${componentname} 已存在.`);
  process.exit(1);
}
componentsFile[componentname] = `./packages/${componentname}/index.js`;
fileSave(path.join(__dirname, '../../components.json'))
  .write(JSON.stringify(componentsFile, null, '  '), 'utf8')
  .end('\n');

// 在 index.less 中引入新组件的样式文件
const lessPath = path.join(
  __dirname,
  '../../packages/theme-lyn/src/index.less'
);
const lessImportText = `${fs.readFileSync(
  lessPath
)}@import "./${componentname}.less";`;
fileSave(lessPath).write(lessImportText, 'utf8').end('\n');

// 添加到 element-ui.d.ts
const elementTsPath = path.join(__dirname, '../../types/element-ui.d.ts');

let elementTsText = `${fs.readFileSync(elementTsPath)}
/** ${ComponentName} Component */
export class ${ComponentName} extends Lyn${ComponentName} {}`;

const index = elementTsText.indexOf('export') - 1;
const importString = `import { Lyn${ComponentName} } from './${componentname}'`;

elementTsText =
  elementTsText.slice(0, index) +
  importString +
  '\n' +
  elementTsText.slice(index);

fileSave(elementTsPath).write(elementTsText, 'utf8').end('\n');

// 新建刚才声明的所有文件
Files.forEach(file => {
  fileSave(path.join(PackagePath, file.filename))
    .write(file.content, 'utf8')
    .end('\n');
});

// 将新组建添加到 nav.config.json
const navConfigFile = require('../../examples/nav.config.json');

Object.keys(navConfigFile).forEach(lang => {
  const groups = navConfigFile[lang].find(item => Array.isArray(item.groups))
    .groups;
  groups[groups.length - 1].list.push({
    path: `/${componentname}`,
    title:
      lang === 'zh-CN' && componentname !== chineseName
        ? `${ComponentName} ${chineseName}`
        : ComponentName
  });
});

fileSave(path.join(__dirname, '../../examples/nav.config.json'))
  .write(JSON.stringify(navConfigFile, null, '  '), 'utf8')
  .end('\n');

console.log('DONE!');

Makefile

Makefile 中增加如下配置:

new-lyn-ui:
    node build/bin/new-lyn-ui.js $(filter-out $@,$(MAKECMDGOALS)) && npm run build:file
	
help:
    @echo "   \033[35mmake new-lyn-ui <component-name> [中文名]\033[0m\t---  创建新的 LynUI 组件 package. 例如 'make new-lyn-ui city 城市选择'"

icon 图标

element-ui 虽然提供了大量的 icon,但往往不能满足团队的业务需求,所有就需要往组件库中增加业务 icon,这里以 Iconfont 为例。不建议直接使用设计给的图片或者 svg,太占资源了。

  • 打开 iconfont

  • 登陆 -> 资源管理 -> 我的项目 -> 新建项目

    注意,这里为 icon 设置前缀时不要使用 el-icon-,避免和 element-ui 中的 icon 重复。这个项目就作为团队项目使用了,以后团队所有的业务图标都上传到该项目,所以最好注册一个团队账号。

  • 新建成功后,点击 上传图标至项目 ,选择 上传图标 ,上传设计给的 svg(必须是 svg),根据需要选择 保留颜色或不保留并提交

  • 上传完毕,编辑、检查没问题后,点击 下载至本地

  • 复制其中的 iconfont.ttficonfont.woff/packages/theme-lyn/src/fonts 目录下

  • 新建 /packages/theme-lyn/src/icon.less 文件,并添加如下内容

    @font-face {
      font-family: 'iconfont';
      src: url('./fonts/iconfont.woff') format('woff'), url('./fonts/iconfont.ttf') format('truetype');
      font-weight: normal;
      font-display: auto;
      font-style: normal;
    }
    
    [class^="lyn-icon-"], [class*=" lyn-icon-"] {
      font-family: 'iconfont' !important;
      font-style: normal;
      font-weight: normal;
      font-variant: normal;
      text-transform: none;
      line-height: 1;
      vertical-align: baseline;
      display: inline-block;
    
      /* Better Font Rendering =========== */
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale
    }
    
    /**
     * 示例:
     * .lyn-icon-iconName:before {
     *   content: "\unicode 16进制码" 
     * }
     * .lyn-icon-add:before {
     *   content: "\e606"
     * }
     */
  • 执行 npm run gen-cssfile

  • 更新 /build/bin/iconInit.js 文件为以下内容

    'use strict';
    
    var postcss = require('postcss');
    var fs = require('fs');
    var path = require('path');
    
    /**
     * 从指定的 icon 样式文件(entry)中按照给定正则表达式(regExp)解析出 icon 名称,然后输出到指定位置(output)
     * @param {*} entry 被解析的文件相对于当前文件的路径,比如:../../packages/theme-chalk/icon.css
     * @param {*} regExp 被解析的正则表达式,比如:/\.el-icon-([^:]+):before/
     * @param {*} output 解析后的资源输出到相对于当前文件的指定位置,比如:../../examples/icon.json
     */
    function parseIconName(entry, regExp, output) {
      // 读取样式文件
      var fontFile = fs.readFileSync(path.resolve(__dirname, entry), 'utf8');
      // 将样式内容解析为样式节点
      var nodes = postcss.parse(fontFile).nodes;
      var classList = [];
    
      // 遍历样式节点
      nodes.forEach((node) => {
        // 从样式选择器中根据给定匹配规则匹配出 icon 名称
        var selector = node.selector || '';
        var reg = new RegExp(regExp);
        var arr = selector.match(reg);
    
        // 将匹配到的 icon 名称放入 classList
        if (arr && arr[1]) {
          classList.push(arr[1]);
        }
      });
    
      classList.reverse(); // 希望按 css 文件顺序倒序排列
    
      // 将 icon 名称数组输出到指定 json 文件中
      fs.writeFile(path.resolve(__dirname, output), JSON.stringify(classList), () => { });
    }
    
    // 根据 icon.css 文件生成所有的 icon 图标名
    parseIconName('../../packages/theme-chalk/icon.css', /\.el-icon-([^:]+):before/, '../../examples/icon.json')
    
    // 根据 icon.less 文件生成所有的 sts icon 图标名
    parseIconName('../../packages/theme-lyn/src/icon.less', /\.lyn-icon-([^:]+):before/, '../../examples/lyn-icon.json')
  • 执行 npm run build:file,会看到在 /examples 目录下生成了一个 lyn-icon.json 文件

  • /examples/entry.js 中增加如下内容

    import lynIcon from './lyn-icon.json';
    Vue.prototype.$lynIcon = lynIcon; // StsIcon 列表页用
  • /examples/nav.config.json 中业务配置部分增加 lyn-icon 路由配置

    {
      "groupName": "LynUI",
      "list": [
        {
          "path": "/lyn-icon",
          "title": "icon 图标"
        }
      ]
    }
  • 增加文档 /examples/docs/{语言}/lyn-icon.md,添加如下内容

  • 查看官网 看图标是否生效

  • 后续如需扩展新的 icon

    • 在前面新建的 iconfont 项目中上传新的图标,然后点击 下载至本地,将其中的 iconfont.ttficonfont.woff 复制 /packages/theme-lyn/src/fonts 目录下即可(替换已有的文件)

    • /packages/theme-lyn/src/icon.less 中设置新的 icon 样式声明

    • 执行 npm run build:file

    • 查看官网 看图标添加是否成功

升级 Vue 版本

element-ui 本身依赖的是 vue@^2.5.x,该版本的 vue 不支持最新的 v-slot 插槽语法(v-slot 是在 2.6.0 中新增的),组件的 markdown 文档中使用 v-slot 语法不生效且会报错,所以需要升级 vue 版本。涉及三个包:vue@^2.6.12、@vue/component-compiler-utils@^3.2.0、vue-template-compiler@^2.6.12。执行以下命令即可完成更新:

  • 删除旧包

    npm uninstall vue @vue/component-compiler-utils vue-template-compiler -D
  • 安装新包

    npm install vue@^2.6.12 @vue/component-compiler-utils@^3.2.0 vue-template-compiler@^2.6.12 -D
  • 更新 package.json 中的 peerDependencies

    {
      "peerDependencies": {
        "vue": "^2.6.12"
      }
    }

扩展

到这里,组件库的架构调整其实已经完成了,接下来只需组织团队成员对照设计稿进行组件开发就可以了。但是对于一些有洁癖的开发者来说,其实还差点。

比如:

  • 团队的组件库不想叫 element-ui,有自己的名称,甚至整个组件库的代码都不想出现 element 字样

  • element-ui 的某些功能团队不需要,比如:官网项目(examples)中的主题、资源模块、chrome 插件(extension)、国际化相关(只保留中文即可)

  • 静态资源,element-ui 将所有的静态资源都上传到自己的 CDN 上了,我们去访问其实优点慢,可以将相关资源挪到团队自己的 CDN 上

  • 工程代码质量问题,element-ui 本身提供了 eslint,做了一点代码质量的控制,但是做的不够,比如格式限制、自动格式化等,可以参考 搭建自己的 typescript 项目 + 开发自己的脚手架工具 ts-cli 中的 代码质量 部分去配置

  • 替换官网 logo、渲染信息等

  • element-ui 样式库的优化,其实 element-ui 的样式存在重复加载的问题

    虽然它通过 webpack 打包已经解决了一部分问题,但是某些情况还是会出现重复加载,比如 table 组件中使用 checkbox 组件,就会加载两次 checkbox 组件的样式代码。有精力的同学可以去研究研究

  • 你的业务只需要 element-ui 的部分基础组件,把不需要的删掉,可以降低组件库的体积,提升加载速度

  • ...

这些工作有一些是对官网项目(examples)的裁剪,有一些是项目整体优化,还有一些是洁癖,不过,相信凡是进行到这一步的同学,都已经为团队构建出了自己的组件库,解决以上列出的那些问题完全不再话下,这里就不一一列出方法了。

链接

  • Element 源码架构 思维导图版
  • 组件库专栏
    • 如何快速为团队打造自己的组件库(上)—— Element 源码架构
    • Element 源码架构 视频版,关注微信公众号,回复: "Element 源码架构视频版" 获取

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

手写 Vue2 系列 之 初始渲染

手写 Vue2 系列 之 初始渲染

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 手写 Vue2 系列 之 编译器 中完成了从模版字符串到 render 函数的工作。当我们得到 render 函数之后,接下来就该进入到真正的挂载阶段了:

挂载 -> 实例化渲染 Watcher -> 执行 updateComponent 方法 -> 执行 render 函数生成 VNode -> 执行 patch 进行首次渲染 -> 递归遍历 VNode 创建各个节点并处理节点上的普通属性和指令 -> 如果节点是自定义组件则创建组件实例 -> 进行组件的初始化、挂载 -> 最终所有 VNode 变成真实的 DOM 节点并替换掉页面上的模版内容 -> 完成初始渲染

目标

所以,本篇文章目标就是实现上面描述的整个过成,完成初始渲染。整个过程中涉及如下知识点:

  • render helper

  • VNode

  • patch 初始渲染

  • 指令(v-model、v-bind、v-on)的处理

  • 实例化子组件

  • 插槽的处理

实现

接下来就正式进入代码实现过程,一步步实现上述所有内容,完成页面的初始渲染。

mount

/src/compiler/index.js

/**
 * 编译器
 */
export default function mount(vm) {
  if (!vm.$options.render) { // 没有提供 render 选项,则编译生成 render 函数
    // ...
  }
  mountComponent(vm)
}

mountComponent

/src/compiler/mountComponent.js

/**
 * @param {*} vm Vue 实例
 */
export default function mountComponent(vm) {
  // 更新组件的的函数
  const updateComponent = () => {
    vm._update(vm._render())
  }

  // 实例化一个渲染 Watcher,当响应式数据更新时,这个更新函数会被执行
  new Watcher(updateComponent)
}

vm._render

/src/compiler/mountComponent.js

/**
 * 负责执行 vm.$options.render 函数
 */
Vue.prototype._render = function () {
  // 给 render 函数绑定 this 上下文为 Vue 实例
  return this.$options.render.apply(this)
}

render helper

/src/compiler/renderHelper.js

/**
 * 在 Vue 实例上安装运行时的渲染帮助函数,比如 _c、_v,这些函数会生成 Vnode
 * @param {VueContructor} target Vue 实例
 */
export default function renderHelper(target) {
  target._c = createElement
  target._v = createTextNode
}

createElement

/src/compiler/renderHelper.js

/**
 * 根据标签信息创建 Vnode
 * @param {string} tag 标签名 
 * @param {Map} attr 标签的属性 Map 对象
 * @param {Array<Render>} children 所有的子节点的渲染函数
 */
function createElement(tag, attr, children) {
  return VNode(tag, attr, children, this)
}

createTextNode

/src/compiler/renderHelper.js

/**
 * 生成文本节点的 VNode
 * @param {*} textAst 文本节点的 AST 对象
 */
function createTextNode(textAst) {
  return VNode(null, null, null, this, textAst)
}

VNode

/src/compiler/vnode.js

/**
 * VNode
 * @param {*} tag 标签名
 * @param {*} attr 属性 Map 对象
 * @param {*} children 子节点组成的 VNode
 * @param {*} text 文本节点的 ast 对象
 * @param {*} context Vue 实例
 * @returns VNode
 */
export default function VNode(tag, attr, children, context, text = null) {
  return {
    // 标签
    tag,
    // 属性 Map 对象
    attr,
    // 父节点
    parent: null,
    // 子节点组成的 Vnode 数组
    children,
    // 文本节点的 Ast 对象
    text,
    // Vnode 的真实节点
    elm: null,
    // Vue 实例
    context
  }
}

vm._update

/src/compiler/mountComponent.js

Vue.prototype._update = function (vnode) {
  // 老的 VNode
  const prevVNode = this._vnode
  // 新的 VNode
  this._vnode = vnode
  if (!prevVNode) {
    // 老的 VNode 不存在,则说明时首次渲染根组件
    this.$el = this.__patch__(this.$el, vnode)
  } else {
    // 后续更新组件或者首次渲染子组件,都会走这里
    this.$el = this.__patch__(prevVNode, vnode)
  }
}

安装 __patch__、render helper

/src/index.js

/**
 * 初始化配置对象
 * @param {*} options 
 */
Vue.prototype._init = function (options) {
  // ...
  initData(this)
  // 安装运行时的渲染工具函数
  renderHelper(this)
  // 在实例上安装 patch 函数
  this.__patch__ = patch
  // 如果存在 el 配置项,则调用 $mount 方法编译模版
  if (this.$options.el) {
    this.$mount()
  }
}

patch

/src/compiler/patch.js

/**
 * 初始渲染和后续更新的入口
 * @param {VNode} oldVnode 老的 VNode
 * @param {VNode} vnode 新的 VNode
 * @returns VNode 的真实 DOM 节点
 */
export default function patch(oldVnode, vnode) {
  if (oldVnode && !vnode) {
    // 老节点存在,新节点不存在,则销毁组件
    return
  }

  if (!oldVnode) { // oldVnode 不存在,说明是子组件首次渲染
    createElm(vnode)
  } else {
    if (oldVnode.nodeType) { // 真实节点,则表示首次渲染根组件
      // 父节点,即 body
      const parent = oldVnode.parentNode
      // 参考节点,即老的 vnode 的下一个节点 —— script,新节点要插在 script 的前面
      const referNode = oldVnode.nextSibling
      // 创建元素
      createElm(vnode, parent, referNode)
      // 移除老的 vnode
      parent.removeChild(oldVnode)
    } else {
      console.log('update')
    }
  }
  return vnode.elm
}

createElm

/src/compiler/patch.js

/**
 * 创建元素
 * @param {*} vnode VNode
 * @param {*} parent VNode 的父节点,真实节点
 * @returns 
 */
function createElm(vnode, parent, referNode) {
  // 记录节点的父节点
  vnode.parent = parent
  // 创建自定义组件,如果是非组件,则会继续后面的流程
  if (createComponent(vnode)) return

  const { attr, children, text } = vnode
  if (text) { // 文本节点
    // 创建文本节点,并插入到父节点内
    vnode.elm = createTextNode(vnode)
  } else { // 元素节点
    // 创建元素,在 vnode 上记录对应的 dom 节点
    vnode.elm = document.createElement(vnode.tag)
    // 给元素设置属性
    setAttribute(attr, vnode)
    // 递归创建子节点
    for (let i = 0, len = children.length; i < len; i++) {
      createElm(children[i], vnode.elm)
    }
  }
  // 如果存在 parent,则将创建的节点插入到父节点内
  if (parent) {
    const elm = vnode.elm
    if (referNode) {
      parent.insertBefore(elm, referNode)
    } else {
      parent.appendChild(elm)
    }
  }
}

createTextNode

/src/compiler/patch.js

/**
 * 创建文本节点
 * @param {*} textVNode 文本节点的 VNode
 */
function createTextNode(textVNode) {
  let { text } = textVNode, textNode = null
  if (text.expression) {
    // 存在表达式,这个表达式的值是一个响应式数据
    const value = textVNode.context[text.expression]
    textNode = document.createTextNode(typeof value === 'object' ? JSON.stringify(value) : String(value))
  } else {
    // 纯文本
    textNode = document.createTextNode(text.text)
  }
  return textNode
}

setAttribute

/src/compiler/patch.js

/**
 * 给节点设置属性
 * @param {*} attr 属性 Map 对象
 * @param {*} vnode
 */
function setAttribute(attr, vnode) {
  // 遍历属性,如果是普通属性,直接设置,如果是指令,则特殊处理
  for (let name in attr) {
    if (name === 'vModel') {
      // v-model 指令
      const { tag, value } = attr.vModel
      setVModel(tag, value, vnode)
    } else if (name === 'vBind') {
      // v-bind 指令
      setVBind(vnode)
    } else if (name === 'vOn') {
      // v-on 指令
      setVOn(vnode)
    } else {
      // 普通属性
      vnode.elm.setAttribute(name, attr[name])
    }
  }
}
setVModel

/src/compiler/patch.js

/**
 * v-model 的原理
 * @param {*} tag 节点的标签名
 * @param {*} value 属性值
 * @param {*} node 节点
 */
function setVModel(tag, value, vnode) {
  const { context: vm, elm } = vnode
  if (tag === 'select') {
    // 下拉框,<select></select>
    Promise.resolve().then(() => {
      // 利用 promise 延迟设置,直接设置不行,
      // 因为这会儿 option 元素还没创建
      elm.value = vm[value]
    })
    elm.addEventListener('change', function () {
      vm[value] = elm.value
    })
  } else if (tag === 'input' && vnode.elm.type === 'text') {
    // 文本框,<input type="text" />
    elm.value = vm[value]
    elm.addEventListener('input', function () {
      vm[value] = elm.value
    })
  } else if (tag === 'input' && vnode.elm.type === 'checkbox') {
    // 选择框,<input type="checkbox" />
    elm.checked = vm[value]
    elm.addEventListener('change', function () {
      vm[value] = elm.checked
    })
  }
}
setVBind

/src/compiler/patch.js

/**
 * v-bind 原理
 * @param {*} vnode
 */
function setVBind(vnode) {
  const { attr: { vBind }, elm, context: vm } = vnode
  for (let attrName in vBind) {
    elm.setAttribute(attrName, vm[vBind[attrName]])
    elm.removeAttribute(`v-bind:${attrName}`)
  }
}
setVOn

/src/compiler/patch.js

/**
 * v-on 原理
 * @param {*} vnode 
 */
function setVOn(vnode) {
  const { attr: { vOn }, elm, context: vm } = vnode
  for (let eventName in vOn) {
    elm.addEventListener(eventName, function (...args) {
      vm.$options.methods[vOn[eventName]].apply(vm, args)
    })
  }
}

createComponent

/src/compiler/patch.js

/**
 * 创建自定义组件
 * @param {*} vnode
 */
function createComponent(vnode) {
  if (vnode.tag && !isReserveTag(vnode.tag)) { // 非保留节点,则说明是组件
    // 获取组件配置信息
    const { tag, context: { $options: { components } } } = vnode
    const compOptions = components[tag]
    const compIns = new Vue(compOptions)
    // 将父组件的 VNode 放到子组件的实例上
    compIns._parentVnode = vnode
    // 挂载子组件
    compIns.$mount()
    // 记录子组件 vnode 的父节点信息
    compIns._vnode.parent = vnode.parent
    // 将子组件添加到父节点内
    vnode.parent.appendChild(compIns._vnode.elm)
    return true
  }
}

isReserveTag

/src/utils.js

/**
 * 是否为平台保留节点
 */
export function isReserveTag(tagName) {
  const reserveTag = ['div', 'h3', 'span', 'input', 'select', 'option', 'p', 'button', 'template']
  return reserveTag.includes(tagName)
}

插槽原理

以下示例是插槽的常用方式。插槽的原理其实很简单,只是实现起来稍微有些麻烦罢了。

  • 解析

    如果组件标签有子节点,在解析的时候将这些子节点,解析成一个特定的数据结构,该结构中包含了插槽的全部信息,然后将该数据结构放到父节点的属性上,其实就是找个地方存放这些信息,然后在 renderSlot 中使用时取出来。当然这个解析过程是发生在父组件的解析过程中的。

  • 生成渲染函数

    在生成子组件的渲染函数阶段,如果碰到 slot 标签,则返回一个 _t 的渲染函数,函数接收两个参数:属性的 JSON 字符串形式,slot 标签的所有子节点的渲染函数组成的 children 数组。

  • render helper

    在执行子组件的渲染函数时,如果执行到 vm._t,就会调用 renderSlot 方法,该方法会返回插槽的 VNode,然后进入子组件的 patch 阶段,将这些 VNode 变成真实的 DOM 并渲染到页面上。

以上就是插槽的原理,然后接下来实现的时候,在某些地方可能会稍微有点绕,多多少少是因为整体架构存在一些问题,所以里面会有一些修补性质的代码,这些代码你可以理解为为了实现插槽功能,而写的一点业务代码。你只需要把住插槽的本质即可。

示例

<!-- comp -->
<template>
  <div>
    <div>
      <slot name="slot1">
        <span>插槽默认内容</span>
      </slot>
    </div>
      <slot name="slot2" v-bind:test="xx">
        <span>插槽默认内容</span>
      </slot>
    <div>
    </div>
  </div>
</template>
<comp></comp>
<comp>
  <template v-slot:slot2="xx">
    <div>作用域插槽,通过插槽从父组件给子组件传递内容</div>
  </template>
<comp>

parse

/src/compiler/parse.js

function processElement() {
    // ...

    // 处理插槽内容
    processSlotContent(curEle)

    // 节点处理完以后让其和父节点产生关系
    if (stackLen) {
      stack[stackLen - 1].children.push(curEle)
      curEle.parent = stack[stackLen - 1]
      // 如果节点存在 slotName,则说明该节点是组件传递给插槽的内容
      // 将插槽信息放到组件节点的 rawAttr.scopedSlots 对象上
      // 而这些信息在生成组件插槽的 VNode 时(renderSlot)会用到
      if (curEle.slotName) {
        const { parent, slotName, scopeSlot, children } = curEle
        // 这里关于 children 的操作,只是单纯为了避开 JSON.stringify 的循环引用问题
        // 因为生成渲染函数时需要对 attr 执行 JSON.stringify 方法
        const slotInfo = {
          slotName, scopeSlot, children: children.map(item => {
            delete item.parent
            return item
          })
        }
        if (parent.rawAttr.scopedSlots) {
          parent.rawAttr.scopedSlots[curEle.slotName] = slotInfo
        } else {
          parent.rawAttr.scopedSlots = { [curEle.slotName]: slotInfo }
        }
      }
    }
  }

processSlotContent

/src/compiler/parse.js

/**
 * 处理插槽
 * <scope-slot>
 *   <template v-slot:default="scopeSlot">
 *     <div>{{ scopeSlot }}</div>
 *   </template>
 * </scope-slot>
 * @param { AST } el 节点的 AST 对象
 */
function processSlotContent(el) {
  // 注意,具有 v-slot:xx 属性的 template 只能是组件的根元素,这里不做判断
  if (el.tag === 'template') { // 获取插槽信息
    // 属性 map 对象
    const attrMap = el.rawAttr
    // 遍历属性 map 对象,找出其中的 v-slot 指令信息
    for (let key in attrMap) {
      if (key.match(/v-slot:(.*)/)) { // 说明 template 标签上 v-slot 指令
        // 获取指令后的插槽名称和值,比如: v-slot:default=xx
        // default
        const slotName = el.slotName = RegExp.$1
        // xx
        el.scopeSlot = attrMap[`v-slot:${slotName}`]
        // 直接 return,因为该标签上只可能有一个 v-slot 指令
        return
      }
    }
  }
}

generate

/src/compiler/generate.js

/**
 * 解析 ast 生成 渲染函数
 * @param {*} ast 语法树 
 * @returns {string} 渲染函数的字符串形式
 */
function genElement(ast) {
  // ...

  // 处理子节点,得到一个所有子节点渲染函数组成的数组
  const children = genChildren(ast)

  if (tag === 'slot') {
    // 生成插槽的处理函数
    return `_t(${JSON.stringify(attrs)}, [${children}])`
  }

  // 生成 VNode 的可执行方法
  return `_c('${tag}', ${JSON.stringify(attrs)}, [${children}])`
}
renderHelper

/src/compiler/renderHelper.js

/**
 * 在 Vue 实例上安装运行时的渲染帮助函数,比如 _c、_v,这些函数会生成 Vnode
 * @param {VueContructor} target Vue 实例
 */
export default function renderHelper(target) {
  // ...
  target._t = renderSlot
}
renderSlot

/src/compiler/renderHelper.js

/**
 * 插槽的原理其实很简单,难点在于实现
 * 其原理就是生成 VNode,难点在于生成 VNode 之前的各种解析,也就是数据准备阶段
 * 生成插槽的的 VNode
 * @param {*} attrs 插槽的属性
 * @param {*} children 插槽所有子节点的 ast 组成的数组
 */
function renderSlot(attrs, children) {
  // 父组件 VNode 的 attr 信息
  const parentAttr = this._parentVnode.attr
  let vnode = null
  if (parentAttr.scopedSlots) { // 说明给当前组件的插槽传递了内容
    // 获取插槽信息
    const slotName = attrs.name
    const slotInfo = parentAttr.scopedSlots[slotName]
    // 这里的逻辑稍微有点绕,建议打开调试,查看一下数据结构,理清对应的思路
    // 这里比较绕的逻辑完全是为了实现插槽这个功能,和插槽本身的原理没关系
    this[slotInfo.scopeSlot] = this[Object.keys(attrs.vBind)[0]]
    vnode = genVNode(slotInfo.children, this)
  } else { // 插槽默认内容
    // 将 children 变成 vnode 数组
    vnode = genVNode(children, this)
  }

  // 如果 children 长度为 1,则说明插槽只有一个子节点
  if (children.length === 1) return vnode[0]
  return createElement.call(this, 'div', {}, vnode)
}
genVNode

/src/compiler/renderHelper.js

/**
 * 将一批 ast 节点(数组)转换成 vnode 数组
 * @param {Array<Ast>} childs 节点数组
 * @param {*} vm 组件实例
 * @returns vnode 数组
 */
function genVNode(childs, vm) {
  const vnode = []
  for (let i = 0, len = childs.length; i < len; i++) {
    const { tag, attr, children, text } = childs[i]
    if (text) { // 文本节点
      if (typeof text === 'string') { // text 为字符串
        // 构造文本节点的 AST 对象
        const textAst = {
          type: 3,
          text,
        }
        if (text.match(/{{(.*)}}/)) {
          // 说明是表达式
          textAst.expression = RegExp.$1.trim()
        }
        vnode.push(createTextNode.call(vm, textAst))
      } else { // text 为文本节点的 ast 对象
        vnode.push(createTextNode.call(vm, text))
      }
    } else { // 元素节点
      vnode.push(createElement.call(vm, tag, attr, genVNode(children, vm)))
    }
  }
  return vnode
}

结果

好了,到这里,模版的初始渲染就已经完成了,如果你能看到如下效果图,则说明一切正常。因为整个过程涉及的内容还是比较多的,如果觉得某些地方不太清楚,建议再看看,仔细梳理下整个流程。

动图链接:https://gitee.com/liyongning/typora-image-bed/raw/master/202203141833484.image

Jun-18-2021 07-35-17.gif

可以看到,原始标签、自定义组件、插槽都已经完整的渲染到了页面上,完成了初始渲染之后,接下来就该去实现后续的更新过程了,也就是下一篇 手写 Vue2 系列 之 patch —— diff

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

微前端框架 之 qiankun 从入门到源码分析

微前端框架 之 qiankun 从入门到源码分析

当学习成为了习惯,知识也就变成了常识。感谢各位的 StarWatch评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

封面

简介

从 single-spa 的缺陷讲起 -> qiankun 是如何从框架层面解决 single-spa 存在的问题 -> qiankun 源码解读,带你全方位刨析 qiankun 框架。

介绍

qiankun 是基于 single-spa 做了二次封装的微前端框架,通过解决了 single-spa 的一些弊端和不足,来帮助大家实现更简单、无痛的构建一个生产可用的微前端架构系统。

微前端框架 之 single-spa 从入门到精通 通过从 基本使用 -> 部署 -> 框架源码分析 -> 手写框架,带你全方位刨析 single-spa 框架。

因为 qiankun 是基于 single-spa 做的二次封装,主要解决了 single-spa 的一些痛点和不足,所以最好对 single-spa 有一个全面的了解和认识,明白其原理、了解它的不足和缺陷,然后带着问题和目的去阅读 qiankun 源码,可以达到事半功倍的效果,整个阅读过程的思路也会更加清晰明了。

为什么不是 single-spa

如果你很了解 single-spa 或者阅读过 微前端框架 之 single-spa 从入门到精通 ,你会发现 single-spa 就做了两件事,加载微应用(加载方法还是用户自己提供的)、维护微应用状态(初始化、挂载、卸载)。了解多了会发现 single-spa 虽好,但是却存在一些比较严重的问题

  1. 对微应用的侵入性太强

    single-spa 采用 JS Entry 的方式接入微应用。微应用改造一般分为三步:

    • 微应用路由改造,添加一个特定的前缀
    • 微应用入口改造,挂载点变更和生命周期函数导出
    • 打包工具配置更改

    侵入型强其实说的就是第三点,更改打包工具的配置,使用 single-spa 接入微应用需要将微应用整个打包成一个 JS 文件,发布到静态资源服务器,然后在主应用中配置该 JS 文件的地址告诉 single-spa 去这个地址加载微应用。

    不说其它的,就现在这个改动就存在很大的问题,将整个微应用打包成一个 JS 文件,常见的打包优化基本上都没了,比如:按需加载、首屏资源加载优化、css 独立打包等优化措施。

    项目发布以后出现了 bug ,修复之后需要更新上线,为了清除浏览器缓存带来的影响,一般文件名会带上 chunkcontent,微应用发布之后文件名都会发生变化,这时候还需要更新主应用中微应用配置,然后重新编译主应用然后发布,这套操作简直是不能忍受的,这也是 微前端框架 之 single-spa 从入门到精通 这篇文章中示例项目中微应用发布时的环境配置选择 development 的原因。

  2. 样式隔离问题

    single-spa 没有做这部分的工作。一个大型的系统会有很的微应用组成,怎么保证这些微应用之间的样式互不影响?微应用和主应用之间的样式互不影响?这时只能通过约定命名规范来实现,比如应用样式以自己的应用名称开头,以应用名构造一个独立的命名空间,这个方式新系统还好说,如果是一个已有的系统,这个改造工作量可不小。

  3. JS 隔离

    这部分工作 single-spa 也没有做。 JS 全局对象污染是一个很常见的现象,比如:微应用 A 在全局对象上添加了一个自己特有的属性,window.A,这时候切换到微应用 B,这时候如何保证 window 对象是干净的呢?

  4. 资源预加载

    这部分的工作 single-spa 更没做了,毕竟将微应用整个打包成一个 js 文件。现在有个需求,比如为了提高系统的用户体验,在第一个微应用挂载完成后,需要让浏览器在后台悄悄的加载其它微应用的静态资源,这个怎么实现呢?

  5. 应用间通信

    这部分工作 single-spa 没做,它只在注册微应用时给微应用注入一些状态信息,后续就不管了,没有任何通信的手段,只能用户自己去实现

以上 5 个问题中第 2、3、5 还好说,可以通过一些方式来解决,比如采用命名空间的方式解决样式隔离问题, 通过备份全局对象,每次微应用切换时初始化全局对象的方式来解决 JS 隔离的问题,通信问题可以通过传递一些通信方法,这点依赖了 JS 对象本身的特性(传递的是引用)来实现;但是第一个和第四个就不好解决了,这是 JS Entry 方式带来的问题,要解决这个问题,难度相对就会大很多,工作量也会更大。况且这些通用的脏活累活就不应该由用户(框架使用者)来解决,而是由框架来解决。

为什么是 qiankun

上面说到,通用的脏活累活应该在框架层面去做,qiankun 基于 single-spa 做了二次封装,很好的解决了上面提到的几个问题。

  1. HTML Entry

    qiankun 通过 HTML Entry 的方式来解决 JS Entry 带来的问题,让你接入微应用像使用 iframe 一样简单。

  2. 样式隔离

    qiankun 实现了两种样式隔离

    • 严格的样式隔离模式,为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
    • 实验性的方式,通过动态改写 css 选择器来实现,可以理解为 css scoped 的方式
  3. 运行时沙箱

    qiankun 的运行时沙箱分为 JS 沙箱和 样式沙箱

    JS 沙箱 为每个微应用生成单独的 window proxy 对象,配合 HTML Entry 提供的 JS 脚本执行器 (execScripts) 来实现 JS 隔离;

    样式沙箱 通过重写 DOM 操作方法,来劫持动态样式和 JS 脚本的添加,让样式和脚本添加到正确的地方,即主应用的插入到主应用模版内,微应用的插入到微应用模版,并且为劫持的动态样式做了 scoped css 的处理,为劫持的脚本做了 JS 隔离的处理,更加具体的内容可继续往下阅读或者直接阅读 微前端专栏 中的 qiankun 2.x 运行时沙箱 源码分析

  4. 资源预加载

    qiankun 实现预加载的思路有两种,一种是当主应用执行 start 方法启动 qiankun 以后立即去预加载微应用的静态资源,另一种是在第一个微应用挂载以后预加载其它微应用的静态资源,这个是利用 single-spa 提供的 single-spa:first-mount 事件来实现的

  5. 应用间通信

    qiankun 通过发布订阅模式来实现应用间通信,状态由框架来统一维护,每个应用在初始化时由框架生成一套通信方法,应用通过这些方法来更改全局状态和注册回调函数,全局状态发生改变时触发各个应用注册的回调函数执行,将新旧状态传递到所有应用

说明

文章基于 qiankun 2.0.26 版本做了完整的源码分析,目前网上好像还没有 qiankun 2.x 版本的完整源码分析,简单搜了下好像都是 1.x 版本的

由于框架代码比较多的,博客有字数限制,所以将全部内容拆成了三篇文章,每一篇都可独立阅读:

  • 微前端框架 之 qiankun 从入门到精通

    ,文章由以下三部分组成

    • 为什么不是 single-spa,详细介绍了 single-spa 存在的问题
    • 为什么是 qiankun,详细介绍了 qiankun 是怎么从框架层面解决 single-spa 存在的问题的
    • 源码解读,完整解读了 qiankun 2.x 版本的源码
  • qiankun 2.x 运行时沙箱 源码分析,详细解读了 qiankun 2.x 版本的沙箱实现

  • HTML Entry 源码分析,详细解读了 HTML Entry 的原理以及在 qiankun 中的应用

源码解读

这里没有单独编写示例代码,因为 qiankun 源码中提供了完整的示例项目,这也是 qiankun 做的很好的一个地方,提供完整的示例,避免大家在使用时重复踩坑。

微前端实现和改造时面临的第一个困难就是主应用的设置、微应用的接入,single-spa 官方没有提供一个很好的示例项目,所以大家在使用 single-spa 接入微应用时还是需要踩不少坑的,甚至有些问题需要去阅读源码才能解决

框架目录结构

github 克隆项目以后,执行一下命令:

  • 安装 qiankun 框架所需的包

    yarn install
  • 安装示例项目的包

    yarn examples:install

以上命令执行结束以后:

image-20220202220056482

有料的 package.json

  • npm-run-all

    一个 CLI 工具,用于并行或顺序执行多个 npm 脚本

  • father-build

    基于 rollup 的库构建工具,father 更加强大

  • 多项目的目录组织以及 scripts 部分的编写

  • main 和 module 字段

    标识组件库的入口,当两者同时存在时,module 字段的优先级高于 main

示例项目中的主应用

这里需要更改一下示例项目中主应用的 webpack 配置

{
  ...
  devServer: {
    // 从 package.json 中可以看出,启动示例项目时,主应用执行了两条命令,其实就是启动了两个主应用,但是却只配置了一个端口,浏览器打开 localhost:7099 和你预想的有一些出入,这时显示的是 loadMicroApp(手动加载微应用) 方式的主应用,基于路由配置的主应用没起来,因为端口被占用了
    // port: '7099'
		// 这样配置,手动加载微应用的主应用在 7099 端口,基于路由配置的主应用在 7088 端口
    port: process.env.MODE === 'multiple' ? '7099' : '7088'
  }
  ...
}

启动示例项目

yarn examples:start

命令执行结束以后,访问 localhost:7099localhost:7088 两个地址,可以看到如下内容:

image-20220202220258551

image-20220202220401608

到这一步,就证明项目正式跑起来了,所有准备工作就绪

示例项目

官方为我们准备了两种主应用的实现方式,五种微应用的接入示例,覆盖面可以说是比较广了,足以满足大家的普遍需要了

主应用

主应用在 examples/main 目录下,提供了两种实现方式,基于路由配置的 registerMicroApps 和 手动加载微应用的 loadMicroApp。主应用很简单,就是一个从 0 通过 webpack 配置的一个同时支持 react 和 vue 的项目,至于为什么同时支持 react 和 vue,继续往下看

webpack.config.js

就是一个普通的 webpack 配置,配置了一个开发服务器 devServer、两个 loader (babel-loader、css loader)、一个插件 HtmlWebpackPlugin (告诉 webpack html 模版文件是哪个)

通过 webpack 配置文件的 entry 字段得知入口文件分别为 index.jsmultiple.js

基于路由配置

通用将微应用关联到一些 url 规则的方式,实现当浏览器 url 发生变化时,自动加载相应的微应用的功能

index.js
// qiankun api 引入
import { registerMicroApps, runAfterFirstMounted, setDefaultMountApp, start, initGlobalState } from '../../es';
// 全局样式
import './index.less';

// 专门针对 angular 微应用引入的一个库
import 'zone.js';

/**
 * 主应用可以使用任何技术栈,这里提供了 react 和 vue 两种,可以随意切换
 * 最终都导出了一个 render 函数,负责渲染主应用
 */
// import render from './render/ReactRender';
import render from './render/VueRender';

// 初始化主应用,其实就是渲染主应用
render({ loading: true });

// 定义 loader 函数,切换微应用时由 qiankun 框架负责调用显示一个 loading 状态
const loader = loading => render({ loading });

// 注册微应用
registerMicroApps(
  // 微应用配置列表
  [
    {
      // 应用名称
      name: 'react16',
      // 应用的入口地址
      entry: '//localhost:7100',
      // 应用的挂载点,这个挂载点在上面渲染函数中的模版里面提供的
      container: '#subapp-viewport',
      // 微应用切换时调用的方法,显示一个 loading 状态
      loader,
      // 当路由前缀为 /react16 时激活当前应用
      activeRule: '/react16',
    },
    {
      name: 'react15',
      entry: '//localhost:7102',
      container: '#subapp-viewport',
      loader,
      activeRule: '/react15',
    },
    {
      name: 'vue',
      entry: '//localhost:7101',
      container: '#subapp-viewport',
      loader,
      activeRule: '/vue',
    },
    {
      name: 'angular9',
      entry: '//localhost:7103',
      container: '#subapp-viewport',
      loader,
      activeRule: '/angular9',
    },
    {
      name: 'purehtml',
      entry: '//localhost:7104',
      container: '#subapp-viewport',
      loader,
      activeRule: '/purehtml',
    },
  ],
  // 全局生命周期钩子,切换微应用时框架负责调用
  {
    beforeLoad: [
      app => {
        // 这个打印日志的方法可以学习一下,第三个参数会替换掉第一个参数中的 %c%s,并且第三个参数的颜色由第二个参数决定
        console.log('[LifeCycle] before load %c%s', 'color: green;', app.name);
      },
    ],
    beforeMount: [
      app => {
        console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
      },
    ],
    afterUnmount: [
      app => {
        console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
      },
    ],
  },
);

// 定义全局状态,并返回两个通信方法
const { onGlobalStateChange, setGlobalState } = initGlobalState({
  user: 'qiankun',
});

// 监听全局状态的更改,当状态发生改变时执行回调函数
onGlobalStateChange((value, prev) => console.log('[onGlobalStateChange - master]:', value, prev));

// 设置新的全局状态,只能设置一级属性,微应用只能修改已存在的一级属性
setGlobalState({
  ignore: 'master',
  user: {
    name: 'master',
  },
});

// 设置默认进入的子应用,当主应用启动以后默认进入指定微应用
setDefaultMountApp('/react16');

// 启动应用
start();

// 当第一个微应用挂载以后,执行回调函数,在这里可以做一些特殊的事情,比如开启一监控或者买点脚本
runAfterFirstMounted(() => {
  console.log('[MainApp] first app mounted');
});
VueRender.js
/**
 * 导出一个由 vue 实现的渲染函数,渲染了一个模版,模版里面包含一个 loading 状态节点和微应用容器节点
 */
import Vue from 'vue/dist/vue.esm';

// 返回一个 vue 实例
function vueRender({ loading }) {
  return new Vue({
    template: `
      <div id="subapp-container">
        <h4 v-if="loading" class="subapp-loading">Loading...</h4>
        <div id="subapp-viewport"></div>
      </div>
    `,
    el: '#subapp-container',
    data() {
      return {
        loading,
      };
    },
  });
}

// vue 实例
let app = null;

// 渲染函数
export default function render({ loading }) {
  // 单例,如果 vue 实例不存在则实例化主应用,存在则说明主应用已经渲染,需要更新主营应用的 loading 状态
  if (!app) {
    app = vueRender({ loading });
  } else {
    app.loading = loading;
  }
}
ReactRender.js
/**
 * 同 vue 实现的渲染函数,这里通过 react 实现了一个一样的渲染函数
 */
import React from 'react';
import ReactDOM from 'react-dom';

// 渲染主应用
function Render(props) {
  const { loading } = props;

  return (
    <>
      {loading && <h4 className="subapp-loading">Loading...</h4>}
      <div id="subapp-viewport" />
    </>
  );
}

// 将主应用渲染到指定节点下
export default function render({ loading }) {
  const container = document.getElementById('subapp-container');
  ReactDOM.render(<Render loading={loading} />, container);
}
手动加载微应用

通常这种场景下的微应用是一个不带路由的可独立运行的业务组件,这种使用方式的情况比较少见

multiple.js
/**
 * 调用 loadMicroApp 方法注册了两个微应用
 */
import { loadMicroApp } from '../../es';

const app1 = loadMicroApp(
  // 应用配置,名称、入口地址、容器节点
  { name: 'react15', entry: '//localhost:7102', container: '#react15' },
  // 可以添加一些其它的配置,比如:沙箱、样式隔离等
  {
    sandbox: {
      // strictStyleIsolation: true,
    },
  },
);

const app2 = loadMicroApp(
  { name: 'vue', entry: '//localhost:7101', container: '#vue' },
  {
    sandbox: {
      // strictStyleIsolation: true,
    },
  },
);

vue

vue 微应用在 examples/vue 目录下,就是一个通过 vue-cli 创建的 vue demo 应用,然后对 vue.config.jsmain.js 做了一些更改

vue.config.js

一个普通的 webpack 配置,需要注意的地方就三点

{
  ...
  // publicPath 没在这里设置,是通过 webpack 提供的全局变量 __webpack_public_path__ 来即时设置的,webpackjs.com/guides/public-path/
  devServer: {
    ...
    // 设置跨域,因为主应用需要通过 fetch 去获取微应用引入的静态资源的,所以必须要求这些静态资源支持跨域
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  output: {
    // 把子应用打包成 umd 库格式
    library: `${name}-[name]`,	// 库名称,唯一
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${name}`,
  }
  ...
}
main.js
// 动态设置 __webpack_public_path__
import './public-path';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
// 路由配置
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

Vue.use(ElementUI);

let router = null;
let instance = null;

// 应用渲染函数
function render(props = {}) {
  const { container } = props;
  // 实例化 router,根据应用运行环境设置路由前缀
  router = new VueRouter({
    // 作为微应用运行,则设置 /vue 为前缀,否则设置 /
    base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
    mode: 'history',
    routes,
  });

  // 实例化 vue 实例
  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 支持应用独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * 从 props 中获取通信方法,监听全局状态的更改和设置全局状态,只能操作一级属性
 * @param {*} props 
 */
function storeTest(props) {
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
      true,
    );
  props.setGlobalState &&
    props.setGlobalState({
      ignore: props.name,
      user: {
        name: props.name,
      },
    });
}

/**
 * 导出的三个生命周期函数
 */
// 初始化
export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

// 挂载微应用
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  storeTest(props);
  render(props);
}

// 卸载、销毁微应用
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
public-path.js
/**
 * 在入口文件中使用 ES6 模块导入,则在导入后对 __webpack_public_path__ 进行赋值。
 * 在这种情况下,必须将公共路径(public path)赋值移至专属模块,然后将其在最前面导入
 */

// qiankun 设置的全局变量,表示应用作为微应用在运行
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

jQuery

这是一个使用了 jQuery 的项目,在 examples/purehtml 目录下,展示了如何接入使用 jQuery 开发的应用

package.json

为了达到演示效果,使用 http-server 在起了一个本地服务器,并且支持跨域

{
  ...
  "scripts": {
    "start": "cross-env PORT=7104 http-server . --cors",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  ...
}
entry.js
// 渲染函数
const render = $ => {
  $('#purehtml-container').html('Hello, render with jQuery');
  return Promise.resolve();
};

// 在全局对象上导出三个生命周期函数
(global => {
  global['purehtml'] = {
    bootstrap: () => {
      console.log('purehtml bootstrap');
      return Promise.resolve();
    },
    mount: () => {
      console.log('purehtml mount');
      // 调用渲染函数
      return render($);
    },
    unmount: () => {
      console.log('purehtml unmount');
      return Promise.resolve();
    },
  };
})(window);
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Purehtml Example</title>
  <script src="//cdn.bootcss.com/jquery/3.4.1/jquery.min.js">
  </script>
</head>
<body>
  <div style="display: flex; justify-content: center; align-items: center; height: 200px;">
    Purehtml Example
  </div>
  <div id="purehtml-container" style="text-align:center"></div>
  <!-- 引入 entry.js,相当于 vue 项目的 publicPath 配置 -->
  <script src="//localhost:7104/entry.js" entry></script>
</body>
</html>

angular 9、react 15、react 16

这三个实例项目就不一一分析了,和 vue 项目类似,都是配置打包工具将微应用打包成一个 umd 格式,然后配置应用入口文件 和 路由前缀

小结

好了,读到这里,系统改造(可以开始干活了)基本上就已经可以顺利进行了,从主应用的开发到微应用接入,应该是不会有什么问题了。

当然如果你想继续深入了解,比如:

  • 上面用到那些 API 的原理是什么?
  • qiankun 是怎么解决我们之前提到的 single-spa 未解决的问题的?
  • ...

接下来就带着我们的疑问和目的去全面深入的了解 qiankun 框架的内部实现

框架源码

整个框架的源码目录是 src,入口文件是 src/index.ts

入口 src/index.ts

/**
 * 在示例或者官网提到的所有 API 都在这里统一导出
 */
// 最关键的三个,手动加载微应用、基于路由配置、启动 qiankun
export { loadMicroApp, registerMicroApps, start } from './apis';
// 全局状态
export { initGlobalState } from './globalState';
// 全局的未捕获异常处理器
export * from './errorHandler';
// setDefaultMountApp 设置主应用启动后默认进入哪个微应用、runAfterFirstMounted 设置当第一个微应用挂载以后需要调用的一些方法
export * from './effects';
// 类型定义
export * from './interfaces';
// prefetch
export { prefetchImmediately as prefetchApps } from './prefetch';

registerMicroApps

/**
 * 注册微应用,基于路由配置
 * @param apps = [
 *  {
 *    name: 'react16',
 *    entry: '//localhost:7100',
 *    container: '#subapp-viewport',
 *    loader,
 *    activeRule: '/react16'
 *  },
 *  ...
 * ]
 * @param lifeCycles = { ...各个生命周期方法对象 }
 */
export function registerMicroApps<T extends object = {}>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 防止微应用重复注册,得到所有没有被注册的微应用列表
  const unregisteredApps = apps.filter(app => !microApps.some(registeredApp => registeredApp.name === app.name));

  // 所有的微应用 = 已注册 + 未注册的(将要被注册的)
  microApps = [...microApps, ...unregisteredApps];

  // 注册每一个微应用
  unregisteredApps.forEach(app => {
    // 注册时提供的微应用基本信息
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    // 调用 single-spa 的 registerApplication 方法注册微应用
    registerApplication({
      // 微应用名称
      name,
      // 微应用的加载方法,Promise<生命周期方法组成的对象>
      app: async () => {
        // 加载微应用时主应用显示 loading 状态
        loader(true);
        // 这句可以忽略,目的是在 single-spa 执行这个加载方法时让出线程,让其它微应用的加载方法都开始执行
        await frameworkStartedDefer.promise;

        // 核心、精髓、难点所在,负责加载微应用,然后一大堆处理,返回 bootstrap、mount、unmount、update 这个几个生命周期
        const { mount, ...otherMicroAppConfigs } = await loadApp(
          // 微应用的配置信息
          { name, props, ...appConfig },
          // start 方法执行时设置的配置对象
          frameworkConfiguration,
          // 注册微应用时提供的全局生命周期对象
          lifeCycles,
        );

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      // 微应用的激活条件
      activeWhen: activeRule,
      // 传递给微应用的 props
      customProps: props,
    });
  });
}

start

/**
 * 启动 qiankun
 * @param opts start 方法的配置对象 
 */
export function start(opts: FrameworkConfiguration = {}) {
  // qiankun 框架默认开启预加载、单例模式、样式沙箱
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  // 从这里可以看出 start 方法支持的参数不止官网文档说的那些,比如 urlRerouteOnly,这个是 single-spa 的 start 方法支持的
  const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;

  // 预加载
  if (prefetch) {
    // 执行预加载策略,参数分别为微应用列表、预加载策略、{ fetch、getPublicPath、getTemplate }
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }

  // 样式沙箱
  if (sandbox) {
    if (!window.Proxy) {
      console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
      // 快照沙箱不支持非 singular 模式
      if (!singular) {
        console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
        // 如果开启沙箱,会强制使用单例模式
        frameworkConfiguration.singular = true;
      }
    }
  }

  // 执行 single-spa 的 start 方法,启动 single-spa
  startSingleSpa({ urlRerouteOnly });

  frameworkStartedDefer.resolve();
}

预加载 - doPrefetchStrategy

/**
 * 执行预加载策略,qiankun 支持四种
 * @param apps 所有的微应用 
 * @param prefetchStrategy 预加载策略,四种 =》 
 *  1、true,第一个微应用挂载以后加载其它微应用的静态资源,利用的是 single-spa 提供的 single-spa:first-mount 事件来实现的
 *  2、string[],微应用名称数组,在第一个微应用挂载以后加载指定的微应用的静态资源
 *  3、all,主应用执行 start 以后就直接开始预加载所有微应用的静态资源
 *  4、自定义函数,返回两个微应用组成的数组,一个是关键微应用组成的数组,需要马上就执行预加载的微应用,一个是普通的微应用组成的数组,在第一个微应用挂载以后预加载这些微应用的静态资源
 * @param importEntryOpts = { fetch, getPublicPath, getTemplate }
 */
export function doPrefetchStrategy(
  apps: AppMetadata[],
  prefetchStrategy: PrefetchStrategy,
  importEntryOpts?: ImportEntryOpts,
) {
  // 定义函数,函数接收一个微应用名称组成的数组,然后从微应用列表中返回这些名称所对应的微应用,最后得到一个数组[{name, entry}, ...]
  const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter(app => names.includes(app.name));

  if (Array.isArray(prefetchStrategy)) {
    // 说明加载策略是一个数组,当第一个微应用挂载之后开始加载数组内由用户指定的微应用资源,数组内的每一项表示一个微应用的名称
    prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
  } else if (isFunction(prefetchStrategy)) {
    // 加载策略是一个自定义的函数,可完全自定义应用资源的加载时机(首屏应用、次屏应用)
    (async () => {
      // critical rendering apps would be prefetch as earlier as possible,关键的应用程序应该尽可能早的预取
      // 执行加载策略函数,函数会返回两个数组,一个关键的应用程序数组,会立即执行预加载动作,另一个是在第一个微应用挂载以后执行微应用静态资源的预加载
      const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
      // 立即预加载这些关键微应用程序的静态资源
      prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
      // 当第一个微应用挂载以后预加载这些微应用的静态资源
      prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
    })();
  } else {
    // 加载策略是默认的 true 或者 all
    switch (prefetchStrategy) {
      case true:
        // 第一个微应用挂载之后开始加载其它微应用的静态资源
        prefetchAfterFirstMounted(apps, importEntryOpts);
        break;

      case 'all':
        // 在主应用执行 start 以后就开始加载所有微应用的静态资源
        prefetchImmediately(apps, importEntryOpts);
        break;

      default:
        break;
    }
  }
}

// 判断是否为弱网环境
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /(2|3)g/.test(navigator.connection.effectiveType))
  : false;

/**
 * prefetch assets, do nothing while in mobile network
 * 预加载静态资源,在移动网络下什么都不做
 * @param entry
 * @param opts
 */
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  // 弱网环境下不执行预加载
  if (!navigator.onLine || isSlowNetwork) {
    // Don't prefetch if in a slow network or offline
    return;
  }

  // 通过时间切片的方式去加载静态资源,在浏览器空闲时去执行回调函数,避免浏览器卡顿
  requestIdleCallback(async () => {
    // 得到加载静态资源的函数
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    // 样式
    requestIdleCallback(getExternalStyleSheets);
    // js 脚本
    requestIdleCallback(getExternalScripts);
  });
}

/**
 * 在第一个微应用挂载之后开始加载 apps 中指定的微应用的静态资源
 * 通过监听 single-spa 提供的 single-spa:first-mount 事件来实现,该事件在第一个微应用挂载以后会被触发
 * @param apps 需要被预加载静态资源的微应用列表,[{ name, entry }, ...]
 * @param opts = { fetch , getPublicPath, getTemplate }
 */
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // 监听 single-spa:first-mount 事件
  window.addEventListener('single-spa:first-mount', function listener() {
    // 已挂载的微应用
    const mountedApps = getMountedApps();
    // 从预加载的微应用列表中过滤出未挂载的微应用
    const notMountedApps = apps.filter(app => mountedApps.indexOf(app.name) === -1);

    // 开发环境打印日志,已挂载的微应用和未挂载的微应用分别有哪些
    if (process.env.NODE_ENV === 'development') {
      console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notMountedApps);
    }

    // 循环加载微应用的静态资源
    notMountedApps.forEach(({ entry }) => prefetch(entry, opts));

    // 移除 single-spa:first-mount 事件
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

/**
 * 在执行 start 启动 qiankun 之后立即预加载所有微应用的静态资源
 * @param apps 需要被预加载静态资源的微应用列表,[{ name, entry }, ...]
 * @param opts = { fetch , getPublicPath, getTemplate }
 */
export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  // 开发环境打印日志
  if (process.env.NODE_ENV === 'development') {
    console.log('[qiankun] prefetch starting for apps...', apps);
  }

  // 加载所有微应用的静态资源
  apps.forEach(({ entry }) => prefetch(entry, opts));
}

应用间通信 initGlobalState

// 触发全局监听,执行所有应用注册的回调函数
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
  // 循环遍历,执行所有应用注册的回调函数
  Object.keys(deps).forEach((id: string) => {
    if (deps[id] instanceof Function) {
      deps[id](cloneDeep(state), cloneDeep(prevState));
    }
  });
}

/**
 * 定义全局状态,并返回通信方法,一般由主应用调用,微应用通过 props 获取通信方法。 
 * @param state 全局状态,{ key: value }
 */
export function initGlobalState(state: Record<string, any> = {}) {
  if (state === globalState) {
    console.warn('[qiankun] state has not changed!');
  } else {
    // 方法有可能被重复调用,将已有的全局状态克隆一份,为空则是第一次调用 initGlobalState 方法,不为空则非第一次次调用
    const prevGlobalState = cloneDeep(globalState);
    // 将传递的状态克隆一份赋值为 globalState
    globalState = cloneDeep(state);
    // 触发全局监听,当然在这个位置调用,正常情况下没啥反应,因为现在还没有应用注册回调函数
    emitGlobal(globalState, prevGlobalState);
  }
  // 返回通信方法,参数表示应用 id,true 表示自己是主应用调用
  return getMicroAppStateActions(`global-${+new Date()}`, true);
}

/**
 * 返回通信方法 
 * @param id 应用 id
 * @param isMaster 表明调用的应用是否为主应用,在主应用初始化全局状态时,initGlobalState 内部调用该方法时会传递 true,其它都为 false
 */
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
  return {
    /**
     * 全局依赖监听,为指定应用(id = 应用id)注册回调函数
     * 依赖数据结构为:
     * {
     *   {id}: callback
     * }
     *
     * @param callback 注册的回调函数
     * @param fireImmediately 是否立即执行回调
     */
    onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      // 回调函数必须为 function
      if (!(callback instanceof Function)) {
        console.error('[qiankun] callback must be function!');
        return;
      }
      // 如果回调函数已经存在,重复注册时给出覆盖提示信息
      if (deps[id]) {
        console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
      }
      // id 为一个应用 id,一个应用对应一个回调
      deps[id] = callback;
      // 克隆全局状态
      const cloneState = cloneDeep(globalState);
      // 如果需要,立即出发回调执行
      if (fireImmediately) {
        callback(cloneState, cloneState);
      }
    },

    /**
     * setGlobalState 更新 store 数据
     *
     * 1. 对新输入 state 的第一层属性做校验,如果是主应用则可以添加新的一级属性进来,也可以更新已存在的一级属性,
     *    如果是微应用,则只能更新已存在的一级属性,不可以新增一级属性
     * 2. 触发全局监听,执行所有应用注册的回调函数,以达到应用间通信的目的
     *
     * @param state 新的全局状态
     */
    setGlobalState(state: Record<string, any> = {}) {
      if (state === globalState) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }

      // 记录旧的全局状态中被改变的 key
      const changeKeys: string[] = [];
      // 旧的全局状态
      const prevGlobalState = cloneDeep(globalState);
      globalState = cloneDeep(
        // 循环遍历新状态中的所有 key
        Object.keys(state).reduce((_globalState, changeKey) => {
          if (isMaster || _globalState.hasOwnProperty(changeKey)) {
            // 主应用 或者 旧的全局状态存在该 key 时才进来,说明只有主应用才可以新增属性,微应用只可以更新已存在的属性值,且不论主应用微应用只能更新一级属性
            // 记录被改变的key
            changeKeys.push(changeKey);
            // 更新旧状态中对应的 key value
            return Object.assign(_globalState, { [changeKey]: state[changeKey] });
          }
          console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
          return _globalState;
        }, globalState),
      );
      if (changeKeys.length === 0) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      // 触发全局监听
      emitGlobal(globalState, prevGlobalState);
      return true;
    },

    // 注销该应用下的依赖
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

全局未捕获异常处理器

/**
 * 整个文件的逻辑一眼明了,整个框架提供了两种全局异常捕获,一个是 single-spa 提供的,另一个是 qiankun 自己的,你只需提供相应的回调函数即可
 */

// single-spa 的异常捕获
export { addErrorHandler, removeErrorHandler } from 'single-spa';

// qiankun 的异常捕获
// 监听了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
  window.addEventListener('error', errorHandler);
  window.addEventListener('unhandledrejection', errorHandler);
}

// 移除 error 和 unhandlerejection 事件监听
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
  window.removeEventListener('error', errorHandler);
  window.removeEventListener('unhandledrejection', errorHandler);
}

setDefaultMountApp

/**
 * 设置主应用启动后默认进入的微应用,其实是规定了第一个微应用挂载完成后决定默认进入哪个微应用
 * 利用的是 single-spa 的 single-spa:no-app-change 事件,该事件在所有微应用状态改变结束后(即发生路由切换且新的微应用已经被挂载完成)触发
 * @param defaultAppLink 微应用的链接,比如 /react16
 */
export function setDefaultMountApp(defaultAppLink: string) {
  // 当事件触发时就说明微应用已经挂载完成,但这里只监听了一次,因为事件被触发以后就移除了监听,所以说是主应用启动后默认进入的微应用,且只执行了一次的原因
  window.addEventListener('single-spa:no-app-change', function listener() {
    // 说明微应用已经挂载完成,获取挂载的微应用列表,再次确认确实有微应用挂载了,其实这个确认没啥必要
    const mountedApps = getMountedApps();
    if (!mountedApps.length) {
      // 这个是 single-spa 提供的一个 api,通过触发 window.location.hash 或者 pushState 更改路由,切换微应用
      navigateToUrl(defaultAppLink);
    }

    // 触发一次以后,就移除该事件的监听函数,后续的路由切换(事件触发)时就不再响应
    window.removeEventListener('single-spa:no-app-change', listener);
  });
}

// 这个 api 和 setDefaultMountApp 作用一致,官网也提到,兼容老版本的一个 api
export function runDefaultMountEffects(defaultAppLink: string) {
  console.warn(
    '[qiankun] runDefaultMountEffects will be removed in next version, please use setDefaultMountApp instead',
  );
  setDefaultMountApp(defaultAppLink);
}

runAfterFirstMounted

/**
 * 第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本
 * 同样利用的 single-spa 的 single-spa:first-mount 事件,当第一个微应用挂载以后会触发
 * @param effect 回调函数,当第一个微应用挂载以后要做的事情
 */
export function runAfterFirstMounted(effect: () => void) {
  // can not use addEventListener once option for ie support
  window.addEventListener('single-spa:first-mount', function listener() {
    if (process.env.NODE_ENV === 'development') {
      console.timeEnd(firstMountLogLabel);
    }

    effect();

    // 这里不移除也没事,因为这个事件后续不会再被触发了
    window.removeEventListener('single-spa:first-mount', listener);
  });
}

手动加载微应用 loadMicroApp

/**
 * 手动加载一个微应用,是通过 single-spa 的 mountRootParcel api 实现的,返回微应用实例
 * @param app = { name, entry, container, props }
 * @param configuration 配置对象
 * @param lifeCycles 还支持一个全局生命周期配置对象,这个参数官方文档没提到
 */
export function loadMicroApp<T extends object = {}>(
  app: LoadableApp<T>,
  configuration?: FrameworkConfiguration,
  lifeCycles?: FrameworkLifeCycles<T>,
): MicroApp {
  const { props } = app;
  // single-spa 的 mountRootParcel api
  return mountRootParcel(() => loadApp(app, configuration ?? frameworkConfiguration, lifeCycles), {
    domElement: document.createElement('div'),
    ...props,
  });
}

qiankun 的核心 loadApp

接下来介绍 loadApp 方法,个人认为 qiankun 的核心代码可以说大部分都在这里,当然这也是整个框架的精髓和难点所在

/**
 * 完成了以下几件事:
 *  1、通过 HTML Entry 的方式远程加载微应用,得到微应用的 html 模版(首屏内容)、JS 脚本执行器、静态经资源路径
 *  2、样式隔离,shadow DOM 或者 scoped css 两种方式
 *  3、渲染微应用
 *  4、运行时沙箱,JS 沙箱、样式沙箱
 *  5、合并沙箱传递出来的 生命周期方法、用户传递的生命周期方法、框架内置的生命周期方法,将这些生命周期方法统一整理,导出一个生命周期对象,
 * 供 single-spa 的 registerApplication 方法使用,这个对象就相当于使用 single-spa 时你的微应用导出的那些生命周期方法,只不过 qiankun
 * 额外填了一些生命周期方法,做了一些事情
 *  6、给微应用注册通信方法并返回通信方法,然后会将通信方法通过 props 注入到微应用
 * @param app 微应用配置对象
 * @param configuration start 方法执行时设置的配置对象 
 * @param lifeCycles 注册微应用时提供的全局生命周期对象
 */
export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObject> {
  // 微应用的入口和名称
  const { entry, name: appName } = app;
  // 实例 id
  const appInstanceId = `${appName}_${+new Date()}_${Math.floor(Math.random() * 1000)}`;

  // 下面这个不用管,就是生成一个标记名称,然后使用该名称在浏览器性能缓冲器中设置一个时间戳,可以用来度量程序的执行时间,performance.mark、performance.measure
  const markName = `[qiankun] App ${appInstanceId} Loading`;
  if (process.env.NODE_ENV === 'development') {
    performanceMark(markName);
  }

  // 配置信息
  const { singular = false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration;

  /**
   * 获取微应用的入口 html 内容和脚本执行器
   * template 是 link 替换为 style 后的 template
   * execScript 是 让 JS 代码(scripts)在指定 上下文 中运行
   * assetPublicPath 是静态资源地址
   */
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // single-spa 的限制,加载、初始化和卸载不能同时进行,必须等卸载完成以后才可以进行加载,这个 promise 会在微应用卸载完成后被 resolve,在后面可以看到
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }

  // --------------- 样式隔离 ---------------
  // 是否严格样式隔离
  const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
  // 实验性的样式隔离,后面就叫 scoped css,和严格样式隔离不能同时开启,如果开启了严格样式隔离,则 scoped css 就为 false,强制关闭
  const enableScopedCSS = isEnableScopedCSS(configuration);

  // 用一个容器元素包裹微应用入口 html 模版, appContent = `<div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>`
  const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
  // 将 appContent 有字符串模版转换为 html dom 元素,如果需要开启样式严格隔离,则将 appContent 的子元素即微应用入口模版用 shadow dom 包裹起来,以达到样式严格隔离的目的
  let element: HTMLElement | null = createElement(appContent, strictStyleIsolation);
  // 通过 scoped css 的方式隔离样式,从这里也就能看出官方为什么说:
  // 在目前的阶段,该功能还不支持动态的、使用 <link />标签来插入外联的样式,但考虑在未来支持这部分场景
  // 在现阶段只处理 style 这种内联标签的情况 
  if (element && isEnableScopedCSS(configuration)) {
    const styleNodes = element.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(element!, stylesheetElement, appName);
    });
  }

  // --------------- 渲染微应用 ---------------
  // 主应用装载微应用的容器节点
  const container = 'container' in app ? app.container : undefined;
  // 这个是 1.x 版本遗留下来的实现,如果提供了 render 函数,当微应用需要被激活时就执行 render 函数渲染微应用,新版本用的 container,弃了 render
  // 而且 legacyRender 和 strictStyleIsolation、scoped css 不兼容
  const legacyRender = 'render' in app ? app.render : undefined;

  // 返回一个 render 函数,这个 render 函数要不使用用户传递的 render 函数,要不将 element 插入到 container
  const render = getRender(appName, appContent, container, legacyRender);

  // 渲染微应用到容器节点,并显示 loading 状态
  render({ element, loading: true }, 'loading');

  // 得到一个 getter 函数,通过该函数可以获取 <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
  const containerGetter = getAppWrapperGetter(
    appName,
    appInstanceId,
    !!legacyRender,
    strictStyleIsolation,
    enableScopedCSS,
    () => element,
  );

  // --------------- 运行时沙箱 ---------------
  // 保证每一个微应用运行在一个干净的环境中(JS 执行上下文独立、应用间不会发生样式污染)
  let global = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  if (sandbox) {
    /**
     * 生成运行时沙箱,这个沙箱其实由两部分组成 => JS 沙箱(执行上下文)、样式沙箱
     * 
     * 沙箱返回 window 的代理对象 proxy 和 mount、unmount 两个方法
     * unmount 方法会让微应用失活,恢复被增强的原生方法,并记录一堆 rebuild 函数,这个函数是微应用卸载时希望自己被重新挂载时要做的一些事情,比如动态样式表重建(卸载时会缓存)
     * mount 方法会执行一些一些 patch 动作,恢复原生方法的增强功能,并执行 rebuild 函数,将微应用恢复到卸载时的状态,当然从初始化状态进入挂载状态就没有恢复一说了
     */
    const sandboxInstance = createSandbox(
      appName,
      containerGetter,
      Boolean(singular),
      enableScopedCSS,
      excludeAssetFilter,
    );
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxInstance.proxy as typeof window;
    mountSandbox = sandboxInstance.mount;
    unmountSandbox = sandboxInstance.unmount;
  }

  // 合并用户传递的生命周期对象和 qiankun 框架内置的生命周期对象
  const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
    {},
    // 返回内置生命周期对象,global.__POWERED_BY_QIANKUN__ 和 global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ 的设置就是在内置的生命周期对象中设置的
    getAddOns(global, assetPublicPath),
    lifeCycles,
    (v1, v2) => concat(v1 ?? [], v2 ?? []),
  );

  await execHooksChain(toArray(beforeLoad), app, global);

  // get the lifecycle hooks from module exports,获取微应用暴露出来的生命周期函数
  const scriptExports: any = await execScripts(global, !singular);
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global);

  // 给微应用注册通信方法并返回通信方法,然后会将通信方法通过 props 注入到微应用
  const {
    onGlobalStateChange,
    setGlobalState,
    offGlobalStateChange,
  }: Record<string, Function> = getMicroAppStateActions(appInstanceId);

  const parcelConfig: ParcelConfigObject = {
    name: appInstanceId,
    bootstrap,
    // 挂载阶段需要执行的一系列方法
    mount: [
      // 性能度量,不用管
      async () => {
        if (process.env.NODE_ENV === 'development') {
          const marks = performance.getEntriesByName(markName, 'mark');
          // mark length is zero means the app is remounting
          if (!marks.length) {
            performanceMark(markName);
          }
        }
      },
      // 单例模式需要等微应用卸载完成以后才能执行挂载任务,promise 会在微应用卸载完以后 resolve
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          return prevAppUnmountedDeferred.promise;
        }

        return undefined;
      },
      // 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
      async () => {
        // element would be destroyed after unmounted, we need to recreate it if it not exist
        // unmount 阶段会置空,这里重新生成
        element = element || createElement(appContent, strictStyleIsolation);
        // 渲染微应用到容器节点,并显示 loading 状态
        render({ element, loading: true }, 'mounting');
      },
      // 运行时沙箱导出的 mount
      mountSandbox,
      // exec the chain after rendering to keep the behavior with beforeLoad
      async () => execHooksChain(toArray(beforeMount), app, global),
      // 向微应用的 mount 生命周期函数传递参数,比如微应用中使用的 props.onGlobalStateChange 方法
      async props => mount({ ...props, container: containerGetter(), setGlobalState, onGlobalStateChange }),
      // 应用 mount 完成后结束 loading
      async () => render({ element, loading: false }, 'mounted'),
      async () => execHooksChain(toArray(afterMount), app, global),
      // initialize the unmount defer after app mounted and resolve the defer after it unmounted
      // 微应用挂载完成以后初始化这个 promise,并且在微应用卸载以后 resolve 这个 promise
      async () => {
        if (await validateSingularMode(singular, app)) {
          prevAppUnmountedDeferred = new Deferred<void>();
        }
      },
      // 性能度量,不用管
      async () => {
        if (process.env.NODE_ENV === 'development') {
          const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
          performanceMeasure(measureName, markName);
        }
      },
    ],
    // 卸载微应用
    unmount: [
      async () => execHooksChain(toArray(beforeUnmount), app, global),
      // 执行微应用的 unmount 生命周期函数
      async props => unmount({ ...props, container: containerGetter() }),
      // 沙箱导出的 unmount 方法
      unmountSandbox,
      async () => execHooksChain(toArray(afterUnmount), app, global),
      // 显示 loading 状态、移除微应用的状态监听、置空 element
      async () => {
        render({ element: null, loading: false }, 'unmounted');
        offGlobalStateChange(appInstanceId);
        // for gc
        element = null;
      },
      // 微应用卸载以后 resolve 这个 promise,框架就可以进行后续的工作,比如加载或者挂载其它微应用
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          prevAppUnmountedDeferred.resolve();
        }
      },
    ],
  };

  // 微应用有可能定义 update 方法
  if (typeof update === 'function') {
    parcelConfig.update = update;
  }

  return parcelConfig;
}

样式隔离

qiankun 的样式隔离有两种方式,一种是严格样式隔离,通过 shadow dom 来实现,另一种是实验性的样式隔离,就是 scoped css,两种方式不可共存

严格样式隔离

qiankun 中的严格样式隔离,就是在这个 createElement 方法中做的,通过 shadow dom 来实现, shadow dom 是浏览器原生提供的一种能力,在过去的很长一段时间里,浏览器用它来封装一些元素的内部结构。以一个有着默认播放控制按钮的 <video> 元素为例,实际上,在它的 Shadow DOM 中,包含来一系列的按钮和其他控制器。Shadow DOM 标准允许你为你自己的元素(custom element)维护一组 Shadow DOM。具体内容可查看 shadow DOM

/**
 * 做了两件事
 *  1、将 appContent 由字符串模版转换成 html dom 元素
 *  2、如果需要开启严格样式隔离,则将 appContent 的子元素即微应用的入口模版用 shadow dom 包裹起来,达到样式严格隔离的目的
 * @param appContent = `<div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>`
 * @param strictStyleIsolation 是否开启严格样式隔离
 */
function createElement(appContent: string, strictStyleIsolation: boolean): HTMLElement {
  // 创建一个 div 元素
  const containerElement = document.createElement('div');
  // 将字符串模版 appContent 设置为 div 的子与阿苏
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div,appContent 由模版字符串变成了 DOM 元素
  const appElement = containerElement.firstChild as HTMLElement;
  // 如果开启了严格的样式隔离,则将 appContent 的子元素(微应用的入口模版)用 shadow dom 包裹,以达到微应用之间样式严格隔离的目的
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      if (appElement.attachShadow) {
        shadow = appElement.attachShadow({ mode: 'open' });
      } else {
        // createShadowRoot was proposed in initial spec, which has then been deprecated
        shadow = (appElement as any).createShadowRoot();
      }
      shadow.innerHTML = innerHTML;
    }
  }

  return appElement;
}
实验性样式隔离

实验性样式的隔离方式其实就是 scoped cssqiankun 会通过动态改写一个特殊的选择器约束来限制 css 的生效范围,应用的样式会按照如下模式改写:

// 假设应用名是 react16
.app-main {
  font-size: 14px;
}
div[data-qiankun-react16] .app-main {
  font-size: 14px;
}
process
/**
 * 做了两件事:
 *  实例化 processor = new ScopedCss(),真正处理样式选择器的地方
 *  生成样式前缀 `div[data-qiankun]=${appName}`
 * @param appWrapper = <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
 * @param stylesheetElement = <style>xx</style>
 * @param appName 微应用名称
 */
export const process = (
  appWrapper: HTMLElement,
  stylesheetElement: HTMLStyleElement | HTMLLinkElement,
  appName: string,
) => {
  // lazy singleton pattern,单例模式
  if (!processor) {
    processor = new ScopedCSS();
  }

  // 目前支持 style 标签
  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }

  // 微应用模版
  const mountDOM = appWrapper;
  if (!mountDOM) {
    return;
  }

  // div
  const tag = (mountDOM.tagName || '').toLowerCase();

  if (tag && stylesheetElement.tagName === 'STYLE') {
    // 生成前缀 `div[data-qiankun]=${appName}`
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
     /**
     * 实际处理样式的地方
     * 拿到样式节点中的所有样式规则,然后重写样式选择器
     *  含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
     *  普通选择器:将前缀插到第一个选择器的后面
     */
    processor.process(stylesheetElement, prefix);
  }
}

export const QiankunCSSRewriteAttr = 'data-qiankun';
ScopedCSS
// https://developer.mozilla.org/en-US/docs/Web/API/CSSRule
enum RuleType {
  // type: rule will be rewrote
  STYLE = 1,
  MEDIA = 4,
  SUPPORTS = 12,

  // type: value will be kept
  IMPORT = 3,
  FONT_FACE = 5,
  PAGE = 6,
  KEYFRAMES = 7,
  KEYFRAME = 8,
}

const arrayify = <T>(list: CSSRuleList | any[]) => {
  return [].slice.call(list, 0) as T[];
};

export class ScopedCSS {
  private static ModifiedTag = 'Symbol(style-modified-qiankun)';

  private sheet: StyleSheet;

  private swapNode: HTMLStyleElement;

  constructor() {
    const styleNode = document.createElement('style');
    document.body.appendChild(styleNode);

    this.swapNode = styleNode;
    this.sheet = styleNode.sheet!;
    this.sheet.disabled = true;
  }

  /**
   * 拿到样式节点中的所有样式规则,然后重写样式选择器
   *  含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
   *  普通选择器:将前缀插到第一个选择器的后面
   * 
   * 如果发现一个样式节点为空,则该节点的样式内容可能会被动态插入,qiankun 监控了该动态插入的样式,并做了同样的处理
   * 
   * @param styleNode 样式节点
   * @param prefix 前缀 `div[data-qiankun]=${appName}`
   */
  process(styleNode: HTMLStyleElement, prefix: string = '') {
    // 样式节点不为空,即 <style>xx</style>
    if (styleNode.textContent !== '') {
      // 创建一个文本节点,内容为 style 节点内的样式内容
      const textNode = document.createTextNode(styleNode.textContent || '');
      // swapNode 是 ScopedCss 类实例化时创建的一个空 style 节点,将样式内容添加到这个节点下
      this.swapNode.appendChild(textNode);
      /**
       * {
       *  cssRules: CSSRuleList {0: CSSStyleRule, 1: CSSStyleRule, 2: CSSStyleRule, 3: CSSStyleRule, length: 4}
       *  disabled: false
       *  href: null
       *  media: MediaList {length: 0, mediaText: ""}
       *  ownerNode: style
       *  ownerRule: null
       *  parentStyleSheet: null
       *  rules: CSSRuleList {0: CSSStyleRule, 1: CSSStyleRule, 2: CSSStyleRule, 3: CSSStyleRule, length: 4}
       *  title: null
       *  type: "text/css"
       * }
       */
      const sheet = this.swapNode.sheet as any; // type is missing
      /**
       * 得到所有的样式规则,比如
       * [
       *  {selectorText: "body", style: CSSStyleDeclaration, styleMap: StylePropertyMap, type: 1, cssText: "body { background: rgb(255, 255, 255); margin: 0px; }", …}
       *  {selectorText: "#oneGoogleBar", style: CSSStyleDeclaration, styleMap: StylePropertyMap, type: 1, cssText: "#oneGoogleBar { height: 56px; }", …}
       *  {selectorText: "#backgroundImage", style: CSSStyleDeclaration, styleMap: StylePropertyMap, type: 1, cssText: "#backgroundImage { border: none; height: 100%; poi…xed; top: 0px; visibility: hidden; width: 100%; }", …}
       *  {selectorText: "[show-background-image] #backgroundImage {xx}"
       * ]
       */
      const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
      /**
       * 重写样式选择器
       *  含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
       *  普通选择器:将前缀插到第一个选择器的后面
       */
      const css = this.rewrite(rules, prefix);
      // 用重写后的样式替换原来的样式
      // eslint-disable-next-line no-param-reassign
      styleNode.textContent = css;

      // cleanup
      this.swapNode.removeChild(textNode);
      return;
    }

    /**
     * 
     * 走到这里说明样式节点为空
     */

    // 创建并返回一个新的 MutationObserver 它会在指定的DOM发生变化时被调用
    const mutator = new MutationObserver(mutations => {
      for (let i = 0; i < mutations.length; i += 1) {
        const mutation = mutations[i];

        // 表示该节点已经被 qiankun 处理过,后面就不会再被重复处理
        if (ScopedCSS.ModifiedTag in styleNode) {
          return;
        }

        // 如果是子节点列表发生变化
        if (mutation.type === 'childList') {
          // 拿到 styleNode 下的所有样式规则,并重写其样式选择器,然后用重写后的样式替换原有样式
          const sheet = styleNode.sheet as any;
          const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
          const css = this.rewrite(rules, prefix);

          // eslint-disable-next-line no-param-reassign
          styleNode.textContent = css;
          // 给 styleNode 添加一个 ScopedCss.ModifiedTag 属性,表示已经被 qiankun 处理过,后面就不会再被处理了
          // eslint-disable-next-line no-param-reassign
          (styleNode as any)[ScopedCSS.ModifiedTag] = true;
        }
      }
    });

    // since observer will be deleted when node be removed
    // we dont need create a cleanup function manually
    // see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/disconnect
    // 观察 styleNode 节点,当其子节点发生变化时调用 callback 即 实例化时传递的函数
    mutator.observe(styleNode, { childList: true });
  }

  /**
   * 重写样式选择器,都是在 ruleStyle 中处理的:
   *  含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
   *  普通选择器:将前缀插到第一个选择器的后面
   * 
   * @param rules 样式规则
   * @param prefix 前缀 `div[data-qiankun]=${appName}`
   */
  private rewrite(rules: CSSRule[], prefix: string = '') {
    let css = '';

    rules.forEach(rule => {
      // 几种类型的样式规则,所有类型查看 https://developer.mozilla.org/zh-CN/docs/Web/API/CSSRule#%E7%B1%BB%E5%9E%8B%E5%B8%B8%E9%87%8F
      switch (rule.type) {
        // 最常见的 selector { prop: val }
        case RuleType.STYLE:
          /**
           * 含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
           * 普通选择器:将前缀插到第一个选择器的后面
           */
          css += this.ruleStyle(rule as CSSStyleRule, prefix);
          break;
        // 媒体 @media screen and (max-width: 300px) { prop: val }
        case RuleType.MEDIA:
          // 拿到其中的具体样式规则,然后调用 rewrite 通过 ruleStyle 去处理
          css += this.ruleMedia(rule as CSSMediaRule, prefix);
          break;
        // @supports (display: grid) {}
        case RuleType.SUPPORTS:
          // 拿到其中的具体样式规则,然后调用 rewrite 通过 ruleStyle 去处理
          css += this.ruleSupport(rule as CSSSupportsRule, prefix);
          break;
        // 其它,直接返回样式内容
        default:
          css += `${rule.cssText}`;
          break;
      }
    });

    return css;
  }

  /**
   * 普通的根选择器用前缀代替
   * 根组合选择器置空,忽略非标准形式的兄弟选择器,比如 html + body {...}
   * 针对普通选择器则是在第一个选择器后面插入前缀,比如 .xx 变成 .xxprefix
   * 
   * 总结就是:
   *  含有根元素选择器的情况:用前缀替换掉选择器中的根元素选择器部分,
   *  普通选择器:将前缀插到第一个选择器的后面
   * 
   * handle case:
   * .app-main {}
   * html, body {}
   * 
   * @param rule 比如:.app-main {} 或者 html, body {}
   * @param prefix `div[data-qiankun]=${appName}`
   */
  // eslint-disable-next-line class-methods-use-this
  private ruleStyle(rule: CSSStyleRule, prefix: string) {
    // 根选择,比如 html、body、:root
    const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
    // 根组合选择器,比如 html body {...} 、 html > body {...}
    const rootCombinationRE = /(html[^\w{[]+)/gm;

    // 选择器
    const selector = rule.selectorText.trim();

    // 样式文本
    let { cssText } = rule;

    // 如果选择器为根选择器,则直接用前缀将根选择器替换掉
    // handle html { ... }
    // handle body { ... }
    // handle :root { ... }
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }

    // 根组合选择器
    // handle html body { ... }
    // handle html > body { ... }
    if (rootCombinationRE.test(rule.selectorText)) {
      // 兄弟选择器 html + body,非标准选择器,无效,转换时忽略
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;

      // since html + body is a non-standard rule for html
      // transformer will ignore it
      if (!siblingSelectorRE.test(rule.selectorText)) {
        // 说明时 html + body 这种非标准形式,则将根组合器置空
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }

    // 其它一般选择器,比如 类选择器、id 选择器、元素选择器、组合选择器等
    // handle grouping selector, a,span,p,div { ... }
    cssText = cssText.replace(/^[\s\S]+{/, selectors =>
      // item 是匹配的字串,p 是第一个分组匹配的内容,s 是第二个分组匹配的内容
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
        // handle div,body,span { ... }
        if (rootSelectorRE.test(item)) {
          // 说明选择器中含有根元素选择器
          return item.replace(rootSelectorRE, m => {
            // do not discard valid previous character, such as body,html or *:not(:root)
            const whitePrevChars = [',', '('];

            // 将其中的根元素替换为前缀
            if (m && whitePrevChars.includes(m[0])) {
              return `${m[0]}${prefix}`;
            }

            // replace root selector with prefix
            return prefix;
          });
        }

        // selector1 selector2 =》 selector1prefix selector2
        return `${p}${prefix} ${s.replace(/^ */, '')}`;
      }),
    );

    return cssText;
  }

  // 拿到其中的具体样式规则,然后调用 rewrite 通过 ruleStyle 去处理
  // handle case:
  // @media screen and (max-width: 300px) {}
  private ruleMedia(rule: CSSMediaRule, prefix: string) {
    const css = this.rewrite(arrayify(rule.cssRules), prefix);
    return `@media ${rule.conditionText} {${css}}`;
  }

  // 拿到其中的具体样式规则,然后调用 rewrite 通过 ruleStyle 去处理
  // handle case:
  // @supports (display: grid) {}
  private ruleSupport(rule: CSSSupportsRule, prefix: string) {
    const css = this.rewrite(arrayify(rule.cssRules), prefix);
    return `@supports ${rule.conditionText} {${css}}`;
  }
}

结语

以上内容就是对 qiankun 框架的完整解读了,相信你在阅读完这篇文章以后会有不错的收获,源码在 github

阅读 qiankun 时的感受就是 书读百变其义自现,qiankun 框架有些地方实现还是比较难理解的,相信大家阅读源码时也会有这个感受,那就多读几遍吧,当然也可以来评论区交流,共同学习,共同进步!!

链接

感谢各位的 StarWatch评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 StarWatch评论

李永宁lyn

Vue 源码解读(8)—— 编译器 之 解析(下)

Vue 源码解读(8)—— 编译器 之 解析(下)

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

特殊说明

由于文章篇幅限制,所以将 Vue 源码解读(8)—— 编译器 之 解析 拆成了两篇文章,本篇是对 Vue 源码解读(8)—— 编译器 之 解析(上) 的一个补充,所以在阅读时请同时打开 Vue 源码解读(8)—— 编译器 之 解析(上) 一起阅读。

processAttrs

/src/compiler/parser/index.js

/**
 * 处理元素上的所有属性:
 * v-bind 指令变成:el.attrs 或 el.dynamicAttrs = [{ name, value, start, end, dynamic }, ...],
 *                或者是必须使用 props 的属性,变成了 el.props = [{ name, value, start, end, dynamic }, ...]
 * v-on 指令变成:el.events 或 el.nativeEvents = { name: [{ value, start, end, modifiers, dynamic }, ...] }
 * 其它指令:el.directives = [{name, rawName, value, arg, isDynamicArg, modifier, start, end }, ...]
 * 原生属性:el.attrs = [{ name, value, start, end }],或者一些必须使用 props 的属性,变成了:
 *         el.props = [{ name, value: true, start, end, dynamic }]
 */
function processAttrs(el) {
  // list = [{ name, value, start, end }, ...]
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, syncGen, isDynamic
  for (i = 0, l = list.length; i < l; i++) {
    // 属性名
    name = rawName = list[i].name
    // 属性值
    value = list[i].value
    if (dirRE.test(name)) {
      // 说明该属性是一个指令

      // 元素上存在指令,将元素标记动态元素
      // mark element as dynamic
      el.hasBindings = true
      // modifiers,在属性名上解析修饰符,比如 xx.lazy
      modifiers = parseModifiers(name.replace(dirRE, ''))
      // support .foo shorthand syntax for the .prop modifier
      if (process.env.VBIND_PROP_SHORTHAND && propBindRE.test(name)) {
        // 为 .props 修饰符支持 .foo 速记写法
        (modifiers || (modifiers = {})).prop = true
        name = `.` + name.slice(1).replace(modifierRE, '')
      } else if (modifiers) {
        // 属性中的修饰符去掉,得到一个干净的属性名
        name = name.replace(modifierRE, '')
      }
      if (bindRE.test(name)) { // v-bind, <div :id="test"></div>
        // 处理 v-bind 指令属性,最后得到 el.attrs 或者 el.dynamicAttrs = [{ name, value, start, end, dynamic }, ...]

        // 属性名,比如:id
        name = name.replace(bindRE, '')
        // 属性值,比如:test
        value = parseFilters(value)
        // 是否为动态属性 <div :[id]="test"></div>
        isDynamic = dynamicArgRE.test(name)
        if (isDynamic) {
          // 如果是动态属性,则去掉属性两侧的方括号 []
          name = name.slice(1, -1)
        }
        // 提示,动态属性值不能为空字符串
        if (
          process.env.NODE_ENV !== 'production' &&
          value.trim().length === 0
        ) {
          warn(
            `The value for a v-bind expression cannot be empty. Found in "v-bind:${name}"`
          )
        }
        // 存在修饰符
        if (modifiers) {
          if (modifiers.prop && !isDynamic) {
            name = camelize(name)
            if (name === 'innerHtml') name = 'innerHTML'
          }
          if (modifiers.camel && !isDynamic) {
            name = camelize(name)
          }
          // 处理 sync 修饰符
          if (modifiers.sync) {
            syncGen = genAssignmentCode(value, `$event`)
            if (!isDynamic) {
              addHandler(
                el,
                `update:${camelize(name)}`,
                syncGen,
                null,
                false,
                warn,
                list[i]
              )
              if (hyphenate(name) !== camelize(name)) {
                addHandler(
                  el,
                  `update:${hyphenate(name)}`,
                  syncGen,
                  null,
                  false,
                  warn,
                  list[i]
                )
              }
            } else {
              // handler w/ dynamic event name
              addHandler(
                el,
                `"update:"+(${name})`,
                syncGen,
                null,
                false,
                warn,
                list[i],
                true // dynamic
              )
            }
          }
        }
        if ((modifiers && modifiers.prop) || (
          !el.component && platformMustUseProp(el.tag, el.attrsMap.type, name)
        )) {
          // 将属性对象添加到 el.props 数组中,表示这些属性必须通过 props 设置
          // el.props = [{ name, value, start, end, dynamic }, ...]
          addProp(el, name, value, list[i], isDynamic)
        } else {
          // 将属性添加到 el.attrs 数组或者 el.dynamicAttrs 数组
          addAttr(el, name, value, list[i], isDynamic)
        }
      } else if (onRE.test(name)) { // v-on, 处理事件,<div @click="test"></div>
        // 属性名,即事件名
        name = name.replace(onRE, '')
        // 是否为动态属性
        isDynamic = dynamicArgRE.test(name)
        if (isDynamic) {
          // 动态属性,则获取 [] 中的属性名
          name = name.slice(1, -1)
        }
        // 处理事件属性,将属性的信息添加到 el.events 或者 el.nativeEvents 对象上,格式:
        // el.events = [{ value, start, end, modifiers, dynamic }, ...]
        addHandler(el, name, value, modifiers, false, warn, list[i], isDynamic)
      } else { // normal directives,其它的普通指令
        // 得到 el.directives = [{name, rawName, value, arg, isDynamicArg, modifier, start, end }, ...]
        name = name.replace(dirRE, '')
        // parse arg
        const argMatch = name.match(argRE)
        let arg = argMatch && argMatch[1]
        isDynamic = false
        if (arg) {
          name = name.slice(0, -(arg.length + 1))
          if (dynamicArgRE.test(arg)) {
            arg = arg.slice(1, -1)
            isDynamic = true
          }
        }
        addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i])
        if (process.env.NODE_ENV !== 'production' && name === 'model') {
          checkForAliasModel(el, value)
        }
      }
    } else {
      // 当前属性不是指令
      // literal attribute
      if (process.env.NODE_ENV !== 'production') {
        const res = parseText(value, delimiters)
        if (res) {
          warn(
            `${name}="${value}": ` +
            'Interpolation inside attributes has been removed. ' +
            'Use v-bind or the colon shorthand instead. For example, ' +
            'instead of <div id="{{ val }}">, use <div :id="val">.',
            list[i]
          )
        }
      }
      // 将属性对象放到 el.attrs 数组中,el.attrs = [{ name, value, start, end }]
      addAttr(el, name, JSON.stringify(value), list[i])
      // #6887 firefox doesn't update muted state if set via attribute
      // even immediately after element creation
      if (!el.component &&
        name === 'muted' &&
        platformMustUseProp(el.tag, el.attrsMap.type, name)) {
        addProp(el, name, 'true', list[i])
      }
    }
  }
}

addHandler

/src/compiler/helpers.js

/**
 * 处理事件属性,将事件属性添加到 el.events 对象或者 el.nativeEvents 对象中,格式:
 * el.events[name] = [{ value, start, end, modifiers, dynamic }, ...]
 * 其中用了大量的篇幅在处理 name 属性带修饰符 (modifier) 的情况
 * @param {*} el ast 对象
 * @param {*} name 属性名,即事件名
 * @param {*} value 属性值,即事件回调函数名
 * @param {*} modifiers 修饰符
 * @param {*} important 
 * @param {*} warn 日志
 * @param {*} range 
 * @param {*} dynamic 属性名是否为动态属性
 */
export function addHandler (
  el: ASTElement,
  name: string,
  value: string,
  modifiers: ?ASTModifiers,
  important?: boolean,
  warn?: ?Function,
  range?: Range,
  dynamic?: boolean
) {
  // modifiers 是一个对象,如果传递的参数为空,则给一个冻结的空对象
  modifiers = modifiers || emptyObject
  // 提示:prevent 和 passive 修饰符不能一起使用
  // warn prevent and passive modifier
  /* istanbul ignore if */
  if (
    process.env.NODE_ENV !== 'production' && warn &&
    modifiers.prevent && modifiers.passive
  ) {
    warn(
      'passive and prevent can\'t be used together. ' +
      'Passive handler can\'t prevent default event.',
      range
    )
  }

  // 标准化 click.right 和 click.middle,它们实际上不会被真正的触发,从技术讲他们是它们
  // 是特定于浏览器的,但至少目前位置只有浏览器才具有右键和中间键的点击
  // normalize click.right and click.middle since they don't actually fire
  // this is technically browser-specific, but at least for now browsers are
  // the only target envs that have right/middle clicks.
  if (modifiers.right) {
    // 右键
    if (dynamic) {
      // 动态属性
      name = `(${name})==='click'?'contextmenu':(${name})`
    } else if (name === 'click') {
      // 非动态属性,name = contextmenu
      name = 'contextmenu'
      // 删除修饰符中的 right 属性
      delete modifiers.right
    }
  } else if (modifiers.middle) {
    // 中间键
    if (dynamic) {
      // 动态属性,name => mouseup 或者 ${name}
      name = `(${name})==='click'?'mouseup':(${name})`
    } else if (name === 'click') {
      // 非动态属性,mouseup
      name = 'mouseup'
    }
  }

  /**
   * 处理 capture、once、passive 这三个修饰符,通过给 name 添加不同的标记来标记这些修饰符
   */
  // check capture modifier
  if (modifiers.capture) {
    delete modifiers.capture
    // 给带有 capture 修饰符的属性,加上 ! 标记
    name = prependModifierMarker('!', name, dynamic)
  }
  if (modifiers.once) {
    delete modifiers.once
    // once 修饰符加 ~ 标记
    name = prependModifierMarker('~', name, dynamic)
  }
  /* istanbul ignore if */
  if (modifiers.passive) {
    delete modifiers.passive
    // passive 修饰符加 & 标记
    name = prependModifierMarker('&', name, dynamic)
  }

  let events
  if (modifiers.native) {
    // native 修饰符, 监听组件根元素的原生事件,将事件信息存放到 el.nativeEvents 对象中
    delete modifiers.native
    events = el.nativeEvents || (el.nativeEvents = {})
  } else {
    events = el.events || (el.events = {})
  }

  const newHandler: any = rangeSetItem({ value: value.trim(), dynamic }, range)
  if (modifiers !== emptyObject) {
    // 说明有修饰符,将修饰符对象放到 newHandler 对象上
    // { value, dynamic, start, end, modifiers }
    newHandler.modifiers = modifiers
  }

  // 将配置对象放到 events[name] = [newHander, handler, ...]
  const handlers = events[name]
  /* istanbul ignore if */
  if (Array.isArray(handlers)) {
    important ? handlers.unshift(newHandler) : handlers.push(newHandler)
  } else if (handlers) {
    events[name] = important ? [newHandler, handlers] : [handlers, newHandler]
  } else {
    events[name] = newHandler
  }

  el.plain = false
}

addIfCondition

/src/compiler/parser/index.js

/**
 * 将传递进来的条件对象放进 el.ifConditions 数组中
 */
export function addIfCondition(el: ASTElement, condition: ASTIfCondition) {
  if (!el.ifConditions) {
    el.ifConditions = []
  }
  el.ifConditions.push(condition)
}

processPre

/src/compiler/parser/index.js

/**
 * 如果元素上存在 v-pre 指令,则设置 el.pre = true 
 */
function processPre(el) {
  if (getAndRemoveAttr(el, 'v-pre') != null) {
    el.pre = true
  }
}

processRawAttrs

/src/compiler/parser/index.js

/**
 * 设置 el.attrs 数组对象,每个元素都是一个属性对象 { name: attrName, value: attrVal, start, end }
 */
function processRawAttrs(el) {
  const list = el.attrsList
  const len = list.length
  if (len) {
    const attrs: Array<ASTAttr> = el.attrs = new Array(len)
    for (let i = 0; i < len; i++) {
      attrs[i] = {
        name: list[i].name,
        value: JSON.stringify(list[i].value)
      }
      if (list[i].start != null) {
        attrs[i].start = list[i].start
        attrs[i].end = list[i].end
      }
    }
  } else if (!el.pre) {
    // non root node in pre blocks with no attributes
    el.plain = true
  }
}

processIf

/src/compiler/parser/index.js

/**
 * 处理 v-if、v-else-if、v-else
 * 得到 el.if = "exp",el.elseif = exp, el.else = true
 * v-if 属性会额外在 el.ifConditions 数组中添加 { exp, block } 对象
 */
function processIf(el) {
  // 获取 v-if 属性的值,比如 <div v-if="test"></div>
  const exp = getAndRemoveAttr(el, 'v-if')
  if (exp) {
    // el.if = "test"
    el.if = exp
    // 在 el.ifConditions 数组中添加 { exp, block }
    addIfCondition(el, {
      exp: exp,
      block: el
    })
  } else {
    // 处理 v-else,得到 el.else = true
    if (getAndRemoveAttr(el, 'v-else') != null) {
      el.else = true
    }
    // 处理 v-else-if,得到 el.elseif = exp
    const elseif = getAndRemoveAttr(el, 'v-else-if')
    if (elseif) {
      el.elseif = elseif
    }
  }
}

processOnce

/src/compiler/parser/index.js

/**
 * 处理 v-once 指令,得到 el.once = true
 * @param {*} el 
 */
function processOnce(el) {
  const once = getAndRemoveAttr(el, 'v-once')
  if (once != null) {
    el.once = true
  }
}

checkRootConstraints

/src/compiler/parser/index.js

/**
 * 检查根元素:
 *   不能使用 slot 和 template 标签作为组件的根元素
 *   不能在有状态组件的 根元素 上使用 v-for 指令,因为它会渲染出多个元素
 * @param {*} el 
 */
function checkRootConstraints(el) {
  // 不能使用 slot 和 template 标签作为组件的根元素
  if (el.tag === 'slot' || el.tag === 'template') {
    warnOnce(
      `Cannot use <${el.tag}> as component root element because it may ` +
      'contain multiple nodes.',
      { start: el.start }
    )
  }
  // 不能在有状态组件的 根元素 上使用 v-for,因为它会渲染出多个元素
  if (el.attrsMap.hasOwnProperty('v-for')) {
    warnOnce(
      'Cannot use v-for on stateful component root element because ' +
      'it renders multiple elements.',
      el.rawAttrsMap['v-for']
    )
  }
}

closeElement

/src/compiler/parser/index.js

/**
 * 主要做了 3 件事:
 *   1、如果元素没有被处理过,即 el.processed 为 false,则调用 processElement 方法处理节点上的众多属性
 *   2、让自己和父元素产生关系,将自己放到父元素的 children 数组中,并设置自己的 parent 属性为 currentParent
 *   3、设置自己的子元素,将自己所有非插槽的子元素放到自己的 children 数组中
 */
function closeElement(element) {
  // 移除节点末尾的空格,当前 pre 标签内�的元素除外
  trimEndingWhitespace(element)
  // 当前元素不再 pre 节点内,并且也没有被处理过
  if (!inVPre && !element.processed) {
    // 分别处理元素节点的 key、ref、插槽、自闭合的 slot 标签、动态组件、class、style、v-bind、v-on、其它指令和一些原生属性 
    element = processElement(element, options)
  }
  // 处理根节点上存在 v-if、v-else-if、v-else 指令的情况
  // 如果根节点存在 v-if 指令,则必须还提供一个具有 v-else-if 或者 v-else 的同级别节点,防止根元素不存在
  // tree management
  if (!stack.length && element !== root) {
    // allow root elements with v-if, v-else-if and v-else
    if (root.if && (element.elseif || element.else)) {
      if (process.env.NODE_ENV !== 'production') {
        // 检查根元素
        checkRootConstraints(element)
      }
      // 给根元素设置 ifConditions 属性,root.ifConditions = [{ exp: element.elseif, block: element }, ...]
      addIfCondition(root, {
        exp: element.elseif,
        block: element
      })
    } else if (process.env.NODE_ENV !== 'production') {
      // 提示,表示不应该在 根元素 上只使用 v-if,应该将 v-if、v-else-if 一起使用,保证组件只有一个根元素
      warnOnce(
        `Component template should contain exactly one root element. ` +
        `If you are using v-if on multiple elements, ` +
        `use v-else-if to chain them instead.`,
        { start: element.start }
      )
    }
  }
  // 让自己和父元素产生关系
  // 将自己放到父元素的 children 数组中,然后设置自己的 parent 属性为 currentParent
  if (currentParent && !element.forbidden) {
    if (element.elseif || element.else) {
      processIfConditions(element, currentParent)
    } else {
      if (element.slotScope) {
        // scoped slot
        // keep it in the children list so that v-else(-if) conditions can
        // find it as the prev node.
        const name = element.slotTarget || '"default"'
          ; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
      }
      currentParent.children.push(element)
      element.parent = currentParent
    }
  }

  // 设置自己的子元素
  // 将自己的所有非插槽的子元素设置到 element.children 数组中
  // final children cleanup
  // filter out scoped slots
  element.children = element.children.filter(c => !(c: any).slotScope)
  // remove trailing whitespace node again
  trimEndingWhitespace(element)

  // check pre state
  if (element.pre) {
    inVPre = false
  }
  if (platformIsPreTag(element.tag)) {
    inPre = false
  }
  // 分别为 element 执行 model、class、style 三个模块的 postTransform 方法
  // 但是 web 平台没有提供该方法
  // apply post-transforms
  for (let i = 0; i < postTransforms.length; i++) {
    postTransforms[i](element, options)
  }
}

trimEndingWhitespace

/src/compiler/parser/index.js

/**
 * 删除元素中空白的文本节点,比如:<div> </div>,删除 div 元素中的空白节点,将其从元素的 children 属性中移出去
 */
function trimEndingWhitespace(el) {
  if (!inPre) {
    let lastNode
    while (
      (lastNode = el.children[el.children.length - 1]) &&
      lastNode.type === 3 &&
      lastNode.text === ' '
    ) {
      el.children.pop()
    }
  }
}

processIfConditions

/src/compiler/parser/index.js

function processIfConditions(el, parent) {
  // 找到 parent.children 中的最后一个元素节点
  const prev = findPrevElement(parent.children)
  if (prev && prev.if) {
    addIfCondition(prev, {
      exp: el.elseif,
      block: el
    })
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `v-${el.elseif ? ('else-if="' + el.elseif + '"') : 'else'} ` +
      `used on element <${el.tag}> without corresponding v-if.`,
      el.rawAttrsMap[el.elseif ? 'v-else-if' : 'v-else']
    )
  }
}

findPrevElement

/src/compiler/parser/index.js

/**
 * 找到 children 中的最后一个元素节点 
 */
function findPrevElement(children: Array<any>): ASTElement | void {
  let i = children.length
  while (i--) {
    if (children[i].type === 1) {
      return children[i]
    } else {
      if (process.env.NODE_ENV !== 'production' && children[i].text !== ' ') {
        warn(
          `text "${children[i].text.trim()}" between v-if and v-else(-if) ` +
          `will be ignored.`,
          children[i]
        )
      }
      children.pop()
    }
  }
}

帮助

到这里编译器的解析部分就结束了,相信很多人看的是云里雾里的,即使多看几遍可能也没有那么清晰。

不要着急,这个很正常,编译器这块儿的代码量确实是比较大。但是内容本身其实不复杂,复杂的是它要处理东西实在是太多了,这才导致这部分的代码量巨大,相对应的,就会产生比较难的感觉。确实不简单,至少我觉得它是整个框架最复杂最难的地方了。

对照着视频和文章大家可以多看几遍,不明白的地方写一些示例代码辅助调试,编写详细的注释。还是那句话,书读百遍,其义自现。

阅读的过程中,大家需要抓住编译器解析部分的本质:将类 HTML 字符串模版解析成 AST 对象。

所以这么多代码都在做一件事情,就是解析字符串模版,将整个模版用 AST 对象来表示和记录。所以,大家阅读的时候,可以将解析过程中生成的 AST 对象记录下来,帮助阅读和理解,这样在读完以后不至于那么迷茫,也有助于大家理解。

这是我在阅读的时候的一个简单记录:

const element = {
  type: 1,
  tag,
  attrsList: [{ name: attrName, value: attrVal, start, end }],
  attrsMap: { attrName: attrVal, },
  rawAttrsMap: { attrName: attrVal, type: checkbox },
  // v-if
  ifConditions: [{ exp, block }],
  // v-for
  for: iterator,
  alias: 别名,
  // :key
  key: xx,
  // ref
  ref: xx,
  refInFor: boolean,
  // 插槽
  slotTarget: slotName,
  slotTargetDynamic: boolean,
  slotScope: 作用域插槽的表达式,
  scopeSlot: {
    name: {
      slotTarget: slotName,
      slotTargetDynamic: boolean,
      children: {
        parent: container,
        otherProperty,
      }
    },
    slotScope: 作用域插槽的表达式,
  },
  slotName: xx,
  // 动态组件
  component: compName,
  inlineTemplate: boolean,
  // class
  staticClass: className,
  classBinding: xx,
  // style
  staticStyle: xx,
  styleBinding: xx,
  // attr
  hasBindings: boolean,
  nativeEvents: { evetns},
  events: {
    name: [{ value, dynamic, start, end, modifiers }]
  },
  props: [{ name, value, dynamic, start, end }],
  dynamicAttrs: [ attrs],
  attrs: [{ name, value, dynamic, start, end }],
  directives: [{ name, rawName, value, arg, isDynamicArg, modifiers, start, end }],
  // v-pre
  pre: true,
  // v-once
  once: true,
  parent,
  children: [],
  plain: boolean,
}

总结

  • 面试官 问:简单说一下 Vue 的编译器都做了什么?

    Vue 的编译器做了三件事情:

    • 将组件的 html 模版解析成 AST 对象

    • 优化,遍历 AST,为每个节点做静态标记,标记其是否为静态节点,然后进一步标记出静态根节点,这样在后续更新的过程中就可以跳过这些静态节点了;标记静态根用于生成渲染函数阶段,生成静态根节点的渲染函数

    • 从 AST 生成运行时的渲染函数,即大家说的 render,其实还有一个,就是 staticRenderFns 数组,里面存放了所有的静态节点的渲染函数


  • 面试官 问:详细说一说编译器的解析过程,它是怎么将 html 字符串模版变成 AST 对象的?

    • 遍历 HTML 模版字符串,通过正则表达式匹配 "<"

    • 跳过某些不需要处理的标签,比如:注释标签、条件注释标签、Doctype。

      备注:整个解析过程的核心是处理开始标签和结束标签

    • 解析开始标签

      • 得到一个对象,包括 标签名(tagName)、所有的属性(attrs)、标签在 html 模版字符串中的索引位置

      • 进一步处理上一步得到的 attrs 属性,将其变成 [{ name: attrName, value: attrVal, start: xx, end: xx }, ...] 的形式

      • 通过标签名、属性对象和当前元素的父元素生成 AST 对象,其实就是一个 普通的 JS 对象,通过 key、value 的形式记录了该元素的一些信息

      • 接下来进一步处理开始标签上的一些指令,比如 v-pre、v-for、v-if、v-once,并将处理结果放到 AST 对象上

      • 处理结束将 ast 对象存放到 stack 数组

      • 处理完成后会截断 html 字符串,将已经处理掉的字符串截掉

    • 解析闭合标签

      • 如果匹配到结束标签,就从 stack 数组中拿出最后一个元素,它和当前匹配到的结束标签是一对。

      • 再次处理开始标签上的属性,这些属性和前面处理的不一样,比如:key、ref、scopedSlot、样式等,并将处理结果放到元素的 AST 对象上

        备注 视频中说这块儿有误,回头看了下,没有问题,不需要改,确实是这样

      • 然后将当前元素和父元素产生联系,给当前元素的 ast 对象设置 parent 属性,然后将自己放到父元素的 ast 对象的 children 数组中

    • 最后遍历完整个 html 模版字符串以后,返回 ast 对象

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

手写 Vue2 系列 之 异步更新队列

手写 Vue2 系列 之 异步更新队列

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 手写 Vue 系列 之 computed 实现了 Vue 的 computed 计算属性。

目标

本篇文章是 手写 Vue 系列 的最后一篇,实现 Vue 的异步更新队列。

读过源码,相信大家都知道 Vue 异步更新的大概流程:依赖收集结束之后,当响应式数据发生变化 -> 触发 setter 执行 dep.notify -> 让 dep 通知 自己收集的所有 watcher 执行 update 方法 -> watch.update 调用 queueWatcher 将自己放到 watcher 队列 -> 接下来调用 nextTick 方法将刷新 watcher 队列的方法放到 callbacks 数组 -> 然后将刷新 callbacks 数组的方法放到浏览器的异步任务队列 -> 待将来执行时最终触发 watcher.run 方法,执行 watcher.get 方法。

实现

接下来会完整实现 Vue 的异步更新队列,让你彻底理解 Vue 的异步更新过程都发生了什么。

Watcher

/src/watcher.js

// 用来标记 watcher
let uid = 0

**
 * @param {*} cb 回调函数,负责更新 DOM 的回调函数
 * @param {*} options watcher 的配置项
 */
export default function Watcher(cb, options = {}, vm = null) {
  // 标识 watcher
  this.uid = uid++
  // ...
}

watcher.update

/src/watcher.js

/**
 * 响应式数据更新时,dep 通知 watcher 执行 update 方法,
 * 让 update 方法执行 this._cb 函数更新 DOM
 */
Watcher.prototype.update = function () {
  if (this.options.lazy) { // 懒执行,比如 computed 计算属性
    // 将 dirty 置为 true,当页面重新渲染获取计算属性时就可以执行 evalute 方法获取最新的值了
    this.dirty = true
  } else {
    // 将 watcher 放入异步 watcher 队列
    queueWatcher(this)
  }
}

watcher.run

/src/watcher.js

/**
 * 由刷新 watcher 队列的函数调用,负责执行 watcher.get 方法
 */
Watcher.prototype.run = function () {
  this.get()
}

异步更新队列

/src/asyncUpdateQueue.js

/**
 * 异步更新队列
 */

// 存储本次更新的所有 watcher
const queue = []

// 标识现在是否正在刷新 watcher 队列
let flushing = false
// 标识,保证 callbacks 数组中只会有一个刷新 watcher 队列的函数
let waiting = false
// 存放刷新 watcher 队列的函数,或者用户调用 Vue.nextTick 方法传递的回调函数
const callbacks = []
// 标识浏览器当前任务队列中是否存在刷新 callbacks 数组的函数
let pending = false

queueWatcher

/src/asyncUpdateQueue.js

/**
 * 将 watcher 放入队列
 * @param {*} watcher 待会儿需要被执行的 watcher,包括渲染 watcher、用户 watcher、computed
 */
export function queueWatcher(watcher) {
  if (!queue.includes(watcher)) { // 防止重复入队
    if (!flushing) { // 现在没有在刷新 watcher 队列
      queue.push(watcher)
    } else { // 正在刷新 watcher 队列,比如用户 watcher 的回调函数中更改了某个响应式数据
      // 标记当前 watcher 在 for 中是否已经完成入队操作
      let flag = false
      // 这时的 watcher 队列时有序的(uid 由小到大),需要保证当前 watcher 插入进去后仍然有序
      for (let i = queue.length - 1; i >= 0; i--) {
        if (queue[i].uid < watcher.uid) { // 找到了刚好比当前 watcher.uid 小的那个 watcher 的位置
          // 将当前 watcher 插入到该位置的后面
          queue.splice(i + 1, 0, watcher)
          flag = true
          break;
        }
      }
      if (!flag) { // 说明上面的 for 循环在队列中没找到比当前 watcher.uid 小的 watcher
        // 将当前 watcher 插入到队首 
        queue.unshift(watcher)
      }
    }
    if (!waiting) { // 表示当前 callbacks 数组中还没有刷新 watcher 队列的函数
      // 保证 callbacks 数组中只会有一个刷新 watcher 队列的函数
      // 因为如果有多个,没有任何意义,第二个执行的时候 watcher 队列已经为空了
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

flushSchedulerQueue

/src/asyncUpdateQueue.js

/**
 * 负责刷新 watcher 队列的函数,由 flushCallbacks 函数调用
 */
function flushSchedulerQueue() {
  // 表示正在刷新 watcher 队列
  flushing = true
  // 给 watcher 队列排序,根据 uid 由小到大排序
  queue.sort((a, b) => a.uid - b.uid)
  // 遍历队列,依次执行其中每个 watcher 的 run 方法
  while (queue.length) {
    // 取出队首的 watcher
    const watcher = queue.shift()
    // 执行 run 方法
    watcher.run()
  }
  // 到这里 watcher 队列刷新完毕
  flushing = waiting = false
}

nextTick

/src/asyncUpdateQueue.js

/**
 * 将刷新 watcher 队列的函数或者用户调用 Vue.nextTick 方法传递的回调函数放入 callbacks 数组
 * 如果当前的浏览器任务队列中没有刷新 callbacks 的函数,则将 flushCallbacks 函数放入任务队列
 */
function nextTick(cb) {
  callbacks.push(cb)
  if (!pending) { // 表明浏览器当前任务队列中没有刷新 callbacks 数组的函数
    // 将 flushCallbacks 函数放入浏览器的微任务队列
    Promise.resolve().then(flushCallbacks)
    // 标识浏览器的微任务队列中已经存在 刷新 callbacks 数组的函数了
    pending = true
  }
}

flushCallbacks

/src/asyncUpdateQueue.js

/**
 * 负责刷新 callbacks 数组的函数,执行 callbacks 数组中的所有函数
 */
function flushCallbacks() {
  // 表示浏览器任务队列中的 flushCallbacks 函数已经被拿到执行栈执行了
  // 新的 flushCallbacks 函数可以进入浏览器的任务队列了
  pending = false
  while(callbacks.length) {
    // 拿出最头上的回调函数
    const cb = callbacks.shift()
    // 执行回调函数
    cb()
  }
}

总结

到这里 精通 Vue 系列 就要结束了,现在我们再回头看下整个系列:从 Vue 源码解读 开始到现在的 手写 Vue,总共 20 篇文章。如果你是从头到尾跟下来的,相信我们最初定的目标早已实现,这会儿你是否可以在自己的简历上写上:精通 Vue 源码原理。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

从 0 到 1 搭建组件库

从 0 到 1 搭建组件库

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

简介

从实现项目基本架构 -> 支持多规范打包 -> 实现按需加载 -> 发布 npm 包,带你从 0 到 1 搭建组件库。

搭建项目

  • 初始化项目目录
mkdir lyn-comp-lib && cd lyn-comp-lib && npm init -y
  • 新建 packages 目录

packages 目录为组件目录,组件一个文件夹为单位,一个文件夹为一个组件

mkdir packages
  • 新建 /src/index.js

/src/index.js 作为 commonjs 规范的打包入口

mkdir src && cd src && touch index.js
  • 新建 webpack.common.js

commonjs 规范的 webpack 配置文件

touch webpack.common.js
  • 新建 webpack.umd.js

umd 规范的 webpack 配置文件

touch webpack.umd.js
  • 新建 publish.sh

负责构建项目 和 发布 npm 包

touch publish.sh
  • 安装 webpack、webpack-cli
npm i webpack webpack-cli -D

项目目录结构

开始编码

目前我们只是为了验证架构设计,所以只会写一些简单的 demo

组件

在 packages 目录中新建两个目录,作为组件目录

其实这个目录结构参考了 element-ui 组件库,为支持 按需加载 做准备

  • /packages/hello/src/index.js
// hello function
export default function hello (msg) {
    console.log('hello ', msg)
}
  • /packages/log/src/index.js
// log function
export default function log (str) {
    console.log('log: ', str)
}

引入并导出组件

在 /src/index.js 中统一引入项目中的组件并导出

// 当组件变得庞大时这部分可自动生成,element-ui 就是采用自动生成的方式
import hello from '../packages/hello/src/index'
import log from '../packages/log/src/index'

export default {
    hello,
    log
}

编写 webpack 配置文件

  • /webpack.common.js
const path = require('path')

module.exports = {
    entry: './src/index.js',
    // 使用 开发者 模式,目的是为了一会儿的调试,实际开发中可改为 production
    mode: 'development',
    output: {
        path: path.join(__dirname, './lib'),
        filename: 'lyn-comp-lib.common.js',
        // commonjs2 规范
        libraryTarget: 'commonjs2',
        // 将 bundle 中的 window 对象替换为 this,不然会报 window is not defined
        globalObject: 'this',
        // 没有该配置项,组件会挂载到 default 属性下,需要 comp.default.xxx 这样使用,不方便
        libraryExport: 'default'
    }
}
  • /webpack.umd.js
const path = require('path')

module.exports = {
    // 实际开发时这部分可以自动生成,可采用 element-ui 的方式
    // 按需加载 需要将入口配置为多入口模式,一个组件 一个入口
    entry: {
        log: './packages/log/src/index.js',
        hello: './packages/hello/src/index.js'
    },
    mode: 'development',
    output: {
        path: path.join(__dirname, './lib'),
        filename: '[name].js',
        // umd 规范
        libraryTarget: 'umd',
        globalObject: 'this',
        // 组件库暴露出来的 全局变量,比如 通过 script 方式引入 bundle 时就可以使用
        library: 'lyn-comp-lib',
        libraryExport: 'default'
    }
}

package.json

{
    "name": "@liyongning/lyn-comp-lib",
    "version": "1.0.0",
    "description": "从 0 到 1 搭建组件库",
    "main": "lib/lyn-comp-lib.common.js",
    "scripts": {
        "build:commonjs2": "webpack --config webpack.common.js",
        "build:umd": "webpack --config webpack.umd.js",
        "build": "npm run build:commonjs2 && npm run build:umd"
    },
    "keywords": ["组件库", "0 到 1"],
    "author": "Li Yong Ning",
    "files": [
      "lib",
      "package.json"
    ],
    "repository": {
      "type": "git",
      "url": "https://github.com/liyongning/lyn-comp-lib.git"
    },
    ...
}

解释

  • name

    在 包 名称前加自己的 npm 账户名,采用 npm scope 的方式,包目录的组织方式和普通包不一样,而且可以有效的避免和他人的包名冲突

  • main

    告诉使用程序 ( import hello from '@liyongning/lyn-comp-lib' ) 去哪里加载组件库

  • script

    构建命令

  • files

    发布 npm 包时告诉发布程序只将 files 中指定的 文件 和 目录 上传到 npm 服务器

  • repository

    代码仓库地址,选项不强制,可以没有,不过一般都会提供,和他人共享

构建发布脚本 publish.sh

shell 脚本,负责构建组件库和发布组件库到 npm

#!/bin/bash

echo '开始构建组件库'

npm run build

echo '组件库构建完成,现在发布'

npm publish --access public

README.md

一个项目必可少的文件,readme.md,负责告诉别人,如何使用我们的组件库

构建、发布

到这一步,不出意外,开篇定的目标就要完成了,接下来执行脚本,构建和发布组件库,当然发布之前你应该有一个自己的 npm 账户

sh publish.sh

执行脚本过程中没有报错,并最后出现以下内容,则表示发布 npm 包成功,也可以去 npm 官网 查看

...
npm notice total files:   5                                       
npm notice 
+ @liyongning/[email protected]

测试

接下来我们新建一个测试项目去实际使用刚才发布的组件库,去验证其是否可用以及是否达到我们的预期目标

新建项目

  • 初始化项目目录
mkdir test && cd test && npm init -y && npm i webpack webpack-cli -D && npm i @liyongning/lyn-comp-lib -S

查看 日志 或者 package.json 会发现 组件库 已经安装成功,接下来就是使用了

  • 新建 /src/index.js
import { hello } from '@liyongning/lyn-comp-lib'
console.log(hello('lyn comp lib'))
  • 构建
npx webpack-cli --mode development

在 /dist 目录会生成打包后的文件 mian.js,然后在 /dist 目录新建 index.html 文件并引入 main.js,然后在浏览器打开,打开控制台,会发现输出如下内容:

  • 是否按需加载

我们在 /src/index.js 中只引入和使用了 hello 方法,在 main.js 中搜索 hello functionlog function 会发现都能搜到,说明现在是全量引入,接下来根据 使用文档(README.md) 配置按需加载

从这张图上也能看出,引入是 commonjs 的包,而不是 "./node_modules/@liyongning/lyn-comp-lib/lib/hello.js

  • 根据组件库的使用文档配置按需加载

安装 babel-plugin-component

安装 babel-loader、@babel/core

npm install --save-dev babel-loader @babel/core
// webpack.config.js
const path = require('path')

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'main.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader'
      }
    ]
  }
}

安装 @babel/preset-env

{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "component",
      {
        "libraryName": "@liyongning/lyn-comp-lib",
        "style": false
      }
    ]
  ]
}
  • 配置 package.json 的 script
```json
{
  ...
  scripts: {
    "build": "webpack --config webpack.config.js"
  }
  ...
}
  • 执行构建命令
npm run build
  • 重复上面的第 4 步,会发现打包后的文件只有 hello function,没有 log function

而且实际的包体积也小了

OK,目标完成!!如有疑问欢迎提问,共同进步

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

如何使用 axios 实现前端并发限制和重试机制

如何使用 axios 实现前端并发限制和重试机制

在 Web 开发中,我们经常需要向后端发送多个异步请求以获取数据,然而过多的请求可能会对服务器造成过大的压力,影响系统的性能。因此,我们需要对并发请求进行限制。同时,由于网络环境的不稳定性,发送请求时也需要考虑添加重试机制,以提高请求的成功率和可靠性。

本篇博客将介绍如何使用 axios 实现前端并发限制和重试机制。axios 是一款基于 Promise 的 HTTP 客户端,可以用于浏览器和 Node.js 环境下发送 HTTP 请求。

前端并发限制的实现

我们可以使用一个请求队列来限制并发请求的数量,每次发送请求时,将请求加入到队列中,并检查当前队列的长度是否小于最大并发请求数量,如果是,就从队列中取出一个请求并发送;如果不是,就等待前面的请求完成后再发送下一个请求。

以下是一个使用 axios 实现前端并发限制的示例代码:

const axios = require('axios');

// 最大并发请求数
const MAX_CONCURRENT_REQUESTS = 5;

// 请求队列
const requestQueue = [];
// 当前正在进行中的请求数
let activeRequests = 0;

/**
 * 处理请求队列中的请求
 */
function processQueue() {
  // 如果当前进行中的请求数没达到最大并发数 && 请求队列不为空
  if (activeRequests < MAX_CONCURRENT_REQUESTS && requestQueue.length > 0) {
    // 从请求队列中取出队头的请求
    const { url, config, resolve, reject } = requestQueue.shift();
    // 进行中的请求数 +1
    activeRequests++;
    // 通过 Axios 发送请求
    axios(url, config)
      .then((response) => {
        // 请求成功,将 外层 Promise 的状态更新为 fulfilled,并返回请求结果
        resolve(response);
      })
      .catch((error) => {
        // 请求失败,将 外层 Promise 的状态更新为 rejected,并返回错误信息
        reject(error);
      })
      .finally(() => {
        // 不论成功还是失败都会执行 finally,表示本次请求结束,将进行中的请求数 -1
        activeRequests--;
        // 再处理请求队列中的下一个请求
        processQueue();
      });
  }
}

/**
 * 并发请求方法
 * @param { string } url 请求的 url
 * @param { AxiosRequestConfig } config Axios 的请求配置
 */
function limitConcurrentRequests(url, config) { 
  // 这里很关键,将用户发起的每个请求都变成一个 Promise,而 Promise 的状态会在 processQueue 中根据 Axios 的执行结果来更新
  return new Promise((resolve, reject) => {
    // 将请求的配置信息 和 更新 Promise 状态的两个方法变成一个对象推入请求队列中
    requestQueue.push({ url, config, resolve, reject });
    // 执行 processQueue 方法处理请求队列中的每个请求
    processQueue();
  });
}

module.exports = { limitConcurrentRequests };

在以上代码中,我们设置了一个 MAX_CONCURRENT_REQUESTS 常量,表示最大并发请求数量,同时维护了一个请求队列 requestQueue 和一个变量 activeRequests,分别用于存储请求队列和正在处理的请求数量。我们通过定义 processQueue 方法来处理请求队列中的请求,它会检查当前正在处理的请求数量是否小于最大并发请求数量,并从队列中取出一个请求并发送。在发送请求的过程中,我们通过 Promise 的 thencatch 方法来处理成功和失败的情况,并在最后通过 finally 方法将正在处理的请求数量减一,并再次调用 processQueue 方法,以处理下一个请求。

使用时,我们可以通过以下代码来进行测试和梳理上述逻辑:

const { limitConcurrentRequests } = require('./concurrency');

// 定义了 20个 URL
const urls = ['https://jsonplaceholder.typicode.com/posts/1', 'https://jsonplaceholder.typicode.com/posts/2', 'https://jsonplaceholder.typicode.com/posts/3', 'https://jsonplaceholder.typicode.com/posts/4', 'https://jsonplaceholder.typicode.com/posts/5', 'https://jsonplaceholder.typicode.com/posts/6', 'https://jsonplaceholder.typicode.com/posts/7', 'https://jsonplaceholder.typicode.com/posts/8', 'https://jsonplaceholder.typicode.com/posts/9', 'https://jsonplaceholder.typicode.com/posts/10', 'https://jsonplaceholder.typicode.com/posts/11', 'https://jsonplaceholder.typicode.com/posts/12', 'https://jsonplaceholder.typicode.com/posts/13', 'https://jsonplaceholder.typicode.com/posts/14', 'https://jsonplaceholder.typicode.com/posts/15', 'https://jsonplaceholder.typicode.com/posts/16', 'https://jsonplaceholder.typicode.com/posts/17', 'https://jsonplaceholder.typicode.com/posts/18', 'https://jsonplaceholder.typicode.com/posts/19', 'https://jsonplaceholder.typicode.com/posts/20'];

// 通过循环,同时发起 20 个请求
urls.forEach(url => limitConcurrentRequests(url)
  .then(responses => console.log(responses.data))
  .catch(error => console.error(error)));

在以上代码中,我们定义了一个包含 20 个 URL 的数组 urls,通过循环同时发送 20个请求。最后,我们通过 thencatch 方法分别处理请求成功和失败的情况,并打印出结果。

前端重试机制的实现

有时,由于网络环境的不稳定性,发送的请求可能会失败,因此我们需要对请求添加重试机制。在实现重试机制时,我们需要注意以下几点:

  • 需要限制重试的次数,避免无限重试;
  • 在重试过程中,需要添加一定的延迟时间,以避免过于频繁地发送请求;
  • 重试时需要保证请求的幂等性,即多次重试的结果应该与单次请求的结果一致。

以下是一个使用 axios 实现前端重试机制的示例代码:

const axios = require('axios');

// 最大重试次数,避免无限重试
const MAX_RETRY_TIMES = 3;
// 重试延时,避免频繁发送请求
const RETRY_DELAY = 1000;

/**
 * 支持重试机制的请求方法,整体方案是通过 Promise 包裹 Axios【这点和并发请求 limitConcurrentRequests 思路一样】 + 递归的逻辑来实现
 * @param { string } url 请求地址
 * @param { AxiosRequestConfig } config Axios 的请求配置
 * @param { number } retryTimes 请求最大重试次数
 * @returns Promise
 */
function retryableRequest(url, config, retryTimes = MAX_RETRY_TIMES) {
  return new Promise((resolve, reject) => {
    // 通过 Axios 发送请求
    axios(url, config)
      .then((response) => {
        // 请求成功,直接将 Promise 状态变为 fulfilled
        resolve(response);
      })
      .catch((error) => {
        // 请求失败
        if (retryTimes === 0) {
          // 剩余重试次数为 0,表示本次请求失败,将 Promise 状态从 pending 更新为 rejected
          reject(error);
        } else {
          // 还能继续重试,RETRY_DELAY 秒之后,递归调用 retryableRequest 方法,重新发送请求
          setTimeout(() => {
            // 递归逻辑,通过递归来实现重试,每次递归重试次数 -1;根据下层 retryableRequest 方法的 Promise 结果更新当前 Promise 的状态
            retryableRequest(url, config, retryTimes - 1)
              .then((response) => {
                // 请求成功,将 Promise 状态从 pending 更新为 fulfilled
                resolve(response);
              })
              .catch((error) => {
                // 请求失败,表示本次请求失败,将 Promise 状态从 pending 更新为 rejected
                reject(error);
              });
          }, RETRY_DELAY);
        }
      });
  });
}

module.exports = { retryableRequest };

在以上代码中,我们设置了一个 MAX_RETRY_TIMES 常量,表示最大重试次数,同时定义了一个 RETRY_DELAY 常量,表示重试的延迟时间。我们通过定义 retryableRequest 方法来实现重试机制,它会通过递归调用自身来重试请求,同时在每次重试前会添加一定的延迟时间以避免频繁发送请求。如果重试次数超过了最大重试次数,就会抛出错误。

以下是一个使用重试机制的示例代码:

const { retryableRequest } = require('./retry');

retryableRequest('https://jsonplaceholder.typicode.com/posts/1', { method: 'get' })
  .then((response) => {
    console.log(response.data);
  })
  .catch((error) => {
    console.error(error);
  });

在以上代码中,我们使用了 retryableRequest 方法来发送请求,并在 then 和 catch 方法中处理请求成功和失败的情况。如果请求失败,重试机制会自动尝试重新发送请求,直到达到最大重试次数或请求成功为止。

并发限制 + 请求重试

上面分别讲述了 前端并发限制前端重试机制 的实现,但两者逻辑独立,接下来会将两者结合,整体思路是在 并发限制 的基础上增加 请求重试。

const axios = require('axios');

// 最大并发请求数
const MAX_CONCURRENT_REQUESTS = 5;

// 请求队列
const requestQueue = [];
// 当前正在进行中的请求数
let activeRequests = 0;

/**
 * 处理请求队列中的请求
 */
function processQueue() {
  // 如果当前进行中的请求数没达到最大并发数 && 请求队列不为空
  if (activeRequests < MAX_CONCURRENT_REQUESTS && requestQueue.length > 0) {
    // 从请求队列中取出队头的请求
    const { url, config, retryTimes, retryDelay, resolve, reject } = requestQueue.shift();
    // 进行中的请求数 +1
    activeRequests++;
    // 通过 Axios 发送请求
    axios(url, config)
      .then((response) => {
        // 请求成功,将 外层 Promise 的状态更新为 fulfilled,并返回请求结果
        resolve(response);
      })
      .catch((error) => {
        // 请求失败
        if (retryTimes === 0) {
          // 剩余重试次数为 0,表示本次请求失败,将 外层 Promise 的状态更新为 rejected,并返回错误信息
          reject(error);
        } else {
          // 还能继续重试,将请求重新入队
          setTimeout(() => {
            requestQueue.push({ url, config, retryTimes: retryTimes - 1, retryDelay, resolve, reject });
          }, retryDelay);
        }
      })
      .finally(() => {
        // 不论成功还是失败都会执行 finally,表示本次请求结束,将进行中的请求数 -1
        activeRequests--;
        // 再处理请求队列中的下一个请求
        processQueue();
      });
  }
}

/**
 * 请求方法
 * @param { string } url 请求的 url
 * @param { AxiosRequestConfig } config Axios 的请求配置
 * @param { number } retryTimes 最大重试次数,避免无限重试
 * @param { number } retryDelay 试延时,避免频繁发送请求
 */
function request(url, config, retryTimes = 3, retryDelay = 1000) { 
  // 这里很关键,将用户发起的每个请求都变成一个 Promise,而 Promise 的状态会在 processQueue 中根据 Axios 的执行结果来更新
  return new Promise((resolve, reject) => {
    // 将请求的配置信息 和 更新 Promise 状态的两个方法变成一个对象推入请求队列中
    requestQueue.push({ url, config, retryTimes, retryDelay, resolve, reject });
    // 执行 processQueue 方法处理请求队列中的每个请求
    processQueue();
  });
}

module.exports = { request };

总结

并发控制 + 请求重试整体思路还是比较简单的,Promise + 队列是关键。大家可以基于文中的代码扩充自己的业务逻辑。

这套思路可使用的场景有很多,比如 通过 refreshToken 刷新 token、URL 携带 ticket 实现免登录 等。

通过使用并发限制和重试机制,我们可以更好地控制前端请求的发送和处理。在实际开发中,我们需要根据具体的业务场景来选择合适的并发限制和重试机制,以确保请求的成功率和性能。

Vue 源码解读(8)—— 编译器 之 解析(上)

Vue 源码解读(8)—— 编译器 之 解析(上)

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

特殊说明

由于文章篇幅限制,所以将 Vue 源码解读(8)—— 编译器 之 解析 拆成了上下两篇,所以在阅读本篇文章时请同时打开 Vue 源码解读(8)—— 编译器 之 解析(下)一起阅读。

前言

Vue 源码解读(4)—— 异步更新 最后说到刷新 watcher 队列,执行每个 watcher.run 方法,由 watcher.run 调用 watcher.get,从而执行 watcher.getter 方法,进入实际的更新阶段。这个流程如果不熟悉,建议大家再去读一下这篇文章。

当更新一个渲染 watcher 时,执行的是 updateComponent 方法:

// /src/core/instance/lifecycle.js
const updateComponent = () => {
  // 执行 vm._render() 函数,得到 虚拟 DOM,并将 vnode 传递给 _update 方法,接下来就该到 patch 阶段了
  vm._update(vm._render(), hydrating)
}

可以看到每次更新前都需要先执行一下 vm._render() 方法,vm._render 就是大家经常听到的 render 函数,由两种方式得到:

  • 用户自己提供,在编写组件时,用 render 选项代替模版

  • 由编译器编译组件模版生成 render 选项

今天我们就来深入研究编译器,看看它是怎么将我们平时编写的类 html 模版编译成 render 函数的。

编译器的核心由三部分组成:

  • 解析,将类 html 模版转换为 AST 对象

  • 优化,也叫静态标记,遍历 AST 对象,标记每个节点是否为静态节点,以及标记出静态根节点

  • 生成渲染函数,将 AST 对象生成渲染函数

由于编译器这块儿的代码量太大,所以,将这部分知识拆成三部分来讲,第一部分就是:解析。

目标

深入理解 Vue 编译器的解析过程,理解如何将类 html 模版字符串转换成 AST 对象。

源码解读

接下来我们去源码中找答案。

阅读建议

由于解析过程代码量巨大,所以建议大家抓住主线:“解析类 HTML 字符串模版,生成 AST 对象”,而这个 AST 对象就是我们最终要得到的结果,所以大家在阅读的过程中,要动手记录这个 AST 对象,这样有助于理解,也让你不那么容易迷失。

也可以先阅读 下篇帮助 部分,有个提前的准备和心理预期。

入口 - $mount

编译器的入口位置在 /src/platforms/web/entry-runtime-with-compiler.js,有两种方式找到这个入口

/src/platforms/web/entry-runtime-with-compiler.js

/**
 * 编译器的入口
 * 运行时的 Vue.js 包就没有这部分的代码,通过 打包器 结合 vue-loader + vue-compiler-utils 进行预编译,将模版编译成 render 函数
 * 
 * 就做了一件事情,得到组件的渲染函数,将其设置到 this.$options 上
 */
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 挂载点
  el = el && query(el)

  // 挂载点不能是 body 或者 html
  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  // 配置项
  const options = this.$options
  // resolve template/el and convert to render function
  /**
   * 如果用户提供了 render 配置项,则直接跳过编译阶段,否则进入编译阶段
   *   解析 template 和 el,并转换为 render 函数
   *   优先级:render > template > el
   */
  if (!options.render) {
    let template = options.template
    if (template) {
      // 处理 template 选项
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          // { template: '#app' },template 是一个 id 选择器,则获取该元素的 innerHtml 作为模版
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        // template 是一个正常的元素,获取其 innerHtml 作为模版
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      // 设置了 el 选项,获取 el 选择器的 outerHtml 作为模版
      template = getOuterHTML(el)
    }
    if (template) {
      // 模版就绪,进入编译阶段
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      // 编译模版,得到 动态渲染函数和静态渲染函数
      const { render, staticRenderFns } = compileToFunctions(template, {
        // 在非生产环境下,编译时记录标签属性在模版字符串中开始和结束的位置索引
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        // 界定符,默认 {{}}
        delimiters: options.delimiters,
        // 是否保留注释
        comments: options.comments
      }, this)
      // 将两个渲染函数放到 this.$options 上
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  // 执行挂载
  return mount.call(this, el, hydrating)
}

compileToFunctions

/src/compiler/to-function.js

/**
 * 1、执行编译函数,得到编译结果 -> compiled
 * 2、处理编译期间产生的 error 和 tip,分别输出到控制台
 * 3、将编译得到的字符串代码通过 new Function(codeStr) 转换成可执行的函数
 * 4、缓存编译结果
 * @param { string } template 字符串模版
 * @param { CompilerOptions } options 编译选项
 * @param { Component } vm 组件实例
 * @return { render, staticRenderFns }
 */
return function compileToFunctions(
  template: string,
  options?: CompilerOptions,
  vm?: Component
): CompiledFunctionResult {
  // 传递进来的编译选项
  options = extend({}, options)
  // 日志
  const warn = options.warn || baseWarn
  delete options.warn

  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production') {
    // 检测可能的 CSP 限制
    try {
      new Function('return 1')
    } catch (e) {
      if (e.toString().match(/unsafe-eval|CSP/)) {
        // 看起来你在一个 CSP 不安全的环境中使用完整版的 Vue.js,模版编译器不能工作在这样的环境中。
        // 考虑放宽策略限制或者预编译你的 template 为 render 函数
        warn(
          'It seems you are using the standalone build of Vue.js in an ' +
          'environment with Content Security Policy that prohibits unsafe-eval. ' +
          'The template compiler cannot work in this environment. Consider ' +
          'relaxing the policy to allow unsafe-eval or pre-compiling your ' +
          'templates into render functions.'
        )
      }
    }
  }

  // 如果有缓存,则跳过编译,直接从缓存中获取上次编译的结果
  const key = options.delimiters
    ? String(options.delimiters) + template
    : template
  if (cache[key]) {
    return cache[key]
  }

  // 执行编译函数,得到编译结果
  const compiled = compile(template, options)

  // 检查编译期间产生的 error 和 tip,分别输出到控制台
  if (process.env.NODE_ENV !== 'production') {
    if (compiled.errors && compiled.errors.length) {
      if (options.outputSourceRange) {
        compiled.errors.forEach(e => {
          warn(
            `Error compiling template:\n\n${e.msg}\n\n` +
            generateCodeFrame(template, e.start, e.end),
            vm
          )
        })
      } else {
        warn(
          `Error compiling template:\n\n${template}\n\n` +
          compiled.errors.map(e => `- ${e}`).join('\n') + '\n',
          vm
        )
      }
    }
    if (compiled.tips && compiled.tips.length) {
      if (options.outputSourceRange) {
        compiled.tips.forEach(e => tip(e.msg, vm))
      } else {
        compiled.tips.forEach(msg => tip(msg, vm))
      }
    }
  }

  // 转换编译得到的字符串代码为函数,通过 new Function(code) 实现
  // turn code into functions
  const res = {}
  const fnGenErrors = []
  res.render = createFunction(compiled.render, fnGenErrors)
  res.staticRenderFns = compiled.staticRenderFns.map(code => {
    return createFunction(code, fnGenErrors)
  })

  // 处理上面代码转换过程中出现的错误,这一步一般不会报错,除非编译器本身出错了
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production') {
    if ((!compiled.errors || !compiled.errors.length) && fnGenErrors.length) {
      warn(
        `Failed to generate render function:\n\n` +
        fnGenErrors.map(({ err, code }) => `${err.toString()} in\n\n${code}\n`).join('\n'),
        vm
      )
    }
  }

  // 缓存编译结果
  return (cache[key] = res)
}

compile

/src/compiler/create-compiler.js

/**
 * 编译函数,做了两件事:
 *   1、选项合并,将 options 配置项 合并到 finalOptions(baseOptions) 中,得到最终的编译配置对象
 *   2、调用核心编译器 baseCompile 得到编译结果
 *   3、将编译期间产生的 error 和 tip 挂载到编译结果上,返回编译结果
 * @param {*} template 模版
 * @param {*} options 配置项
 * @returns 
 */
function compile(
  template: string,
  options?: CompilerOptions
): CompiledResult {
  // 以平台特有的编译配置为原型创建编译选项对象
  const finalOptions = Object.create(baseOptions)
  const errors = []
  const tips = []

  // 日志,负责记录将 error 和 tip
  let warn = (msg, range, tip) => {
    (tip ? tips : errors).push(msg)
  }

  // 如果存在编译选项,合并 options 和 baseOptions
  if (options) {
    // 开发环境走
    if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
      // $flow-disable-line
      const leadingSpaceLength = template.match(/^\s*/)[0].length

      // 增强 日志 方法
      warn = (msg, range, tip) => {
        const data: WarningMessage = { msg }
        if (range) {
          if (range.start != null) {
            data.start = range.start + leadingSpaceLength
          }
          if (range.end != null) {
            data.end = range.end + leadingSpaceLength
          }
        }
        (tip ? tips : errors).push(data)
      }
    }

    /**
     * 将 options 中的配置项合并到 finalOptions
     */

    // 合并自定义 module
    if (options.modules) {
      finalOptions.modules =
        (baseOptions.modules || []).concat(options.modules)
    }
    // 合并自定义指令
    if (options.directives) {
      finalOptions.directives = extend(
        Object.create(baseOptions.directives || null),
        options.directives
      )
    }
    // 拷贝其它配置项
    for (const key in options) {
      if (key !== 'modules' && key !== 'directives') {
        finalOptions[key] = options[key]
      }
    }
  }

  // 日志
  finalOptions.warn = warn

  // 到这里为止终于到重点了,调用核心编译函数,传递模版字符串和最终的编译选项,得到编译结果
  // 前面做的所有事情都是为了构建平台最终的编译选项
  const compiled = baseCompile(template.trim(), finalOptions)
  if (process.env.NODE_ENV !== 'production') {
    detectErrors(compiled.ast, warn)
  }
  // 将编译期间产生的错误和提示挂载到编译结果上
  compiled.errors = errors
  compiled.tips = tips
  return compiled
}

baseOptions

/src/platforms/web/compiler/options.js

export const baseOptions: CompilerOptions = {
  expectHTML: true,
  // 处理 class、style、v-model
  modules,
  // 处理指令
  // 是否是 pre 标签
  isPreTag,
  // 是否是自闭合标签
  isUnaryTag,
  // 规定了一些应该使用 props 进行绑定的属性
  mustUseProp,
  // 可以只写开始标签的标签,结束标签浏览器会自动补全
  canBeLeftOpenTag,
  // 是否是保留标签(html + svg)
  isReservedTag,
  // 获取标签的命名空间
  getTagNamespace,
  staticKeys: genStaticKeys(modules)
}

baseCompile

/src/compiler/index.js

/**
 * 在这之前做的所有的事情,只有一个目的,就是为了构建平台特有的编译选项(options),比如 web 平台
 * 
 * 1、将 html 模版解析成 ast
 * 2、对 ast 树进行静态标记
 * 3、将 ast 生成渲染函数
 *    静态渲染函数放到  code.staticRenderFns 数组中
 *    code.render 为动态渲染函数
 *    在将来渲染时执行渲染函数得到 vnode
 */
function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 将模版解析为 AST,每个节点的 ast 对象上都设置了元素的所有信息,比如,标签信息、属性信息、插槽信息、父节点、子节点等。
  // 具体有那些属性,查看 start 和 end 这两个处理开始和结束标签的方法
  const ast = parse(template.trim(), options)
  // 优化,遍历 AST,为每个节点做静态标记
  // 标记每个节点是否为静态节点,然后进一步标记出静态根节点
  // 这样在后续更新中就可以跳过这些静态节点了
  // 标记静态根,用于生成渲染函数阶段,生成静态根节点的渲染函数
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  // 从 AST 生成渲染函数,生成像这样的代码,比如:code.render = "_c('div',{attrs:{"id":"app"}},_l((arr),function(item){return _c('div',{key:item},[_v(_s(item))])}),0)"
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

parse

注意:由于这部分的代码量太大,于是将代码在结构上做了一些调整,方便大家阅读和理解。

/src/compiler/parser/index.js

/**
 * 
 * 将 HTML 字符串转换为 AST
 * @param {*} template HTML 模版
 * @param {*} options 平台特有的编译选项
 * @returns root
 */
export function parse(
  template: s tring,
  options: CompilerOptions
): ASTElement | void {
  // 日志
  warn = options.warn || baseWarn

  // 是否为 pre 标签
  platformIsPreTag = options.isPreTag || no
  // 必须使用 props 进行绑定的属性
  platformMustUseProp = options.mustUseProp || no
  // 获取标签的命名空间
  platformGetTagNamespace = options.getTagNamespace || no
  // 是否是保留标签(html + svg)
  const isReservedTag = options.isReservedTag || no
  // 判断一个元素是否为一个组件
  maybeComponent = (el: ASTElement) => !!el.component || !isReservedTag(el.tag)

  // 分别获取 options.modules 下的 class、model、style 三个模块中的 transformNode、preTransformNode、postTransformNode 方法
  // 负责处理元素节点上的 class、style、v-model
  transforms = pluckModuleFunction(options.modules, 'transformNode')
  preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')
  postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')

  // 界定符,比如: {{}}
  delimiters = options.delimiters

  const stack = []
  // 空格选项
  const preserveWhitespace = options.preserveWhitespace !== false
  const whitespaceOption = options.whitespace
  // 根节点,以 root 为根,处理后的节点都会按照层级挂载到 root 下,最后 return 的就是 root,一个 ast 语法树
  let root
  // 当前元素的父元素
  let currentParent
  let inVPre = false
  let inPre = false
  let warned = false
  
  // 解析 html 模版字符串,处理所有标签以及标签上的属性
  // 这里的 parseHTMLOptions 在后面处理过程中用到,再进一步解析
  // 提前解析的话容易让大家岔开思路
  parseHTML(template, parseHtmlOptions)
  
  // 返回生成的 ast 对象
  return root

parseHTML

/src/compiler/parser/html-parser.js

/**
 * 通过循环遍历 html 模版字符串,依次处理其中的各个标签,以及标签上的属性
 * @param {*} html html 模版
 * @param {*} options 配置项
 */
export function parseHTML(html, options) {
  const stack = []
  const expectHTML = options.expectHTML
  // 是否是自闭合标签
  const isUnaryTag = options.isUnaryTag || no
  // 是否可以只有开始标签
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  // 记录当前在原始 html 字符串中的开始位置
  let index = 0
  let last, lastTag
  while (html) {
    last = html
    // 确保不是在 script、style、textarea 这样的纯文本元素中
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 找第一个 < 字符
      let textEnd = html.indexOf('<')
      // textEnd === 0 说明在开头找到了
      // 分别处理可能找到的注释标签、条件注释标签、Doctype、开始标签、结束标签
      // 每处理完一种情况,就会截断(continue)循环,并且重置 html 字符串,将处理过的标签截掉,下一次循环处理剩余的 html 字符串模版
      if (textEnd === 0) {
        // 处理注释标签 <!-- xx -->
        if (comment.test(html)) {
          // 注释标签的结束索引
          const commentEnd = html.indexOf('-->')

          if (commentEnd >= 0) {
            // 是否应该保留 注释
            if (options.shouldKeepComment) {
              // 得到:注释内容、注释的开始索引、结束索引
              options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            }
            // 调整 html 和 index 变量
            advance(commentEnd + 3)
            continue
          }
        }

        // 处理条件注释标签:<!--[if IE]>
        // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
        if (conditionalComment.test(html)) {
          // 找到结束位置
          const conditionalEnd = html.indexOf(']>')

          if (conditionalEnd >= 0) {
            // 调整 html 和 index 变量
            advance(conditionalEnd + 2)
            continue
          }
        }

        // 处理 Doctype,<!DOCTYPE html>
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          advance(doctypeMatch[0].length)
          continue
        }

        /**
         * 处理开始标签和结束标签是这整个函数中的核型部分,其它的不用管
         * 这两部分就是在构造 element ast
         */

        // 处理结束标签,比如 </div>
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          // 处理结束标签
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // 处理开始标签,比如 <div id="app">,startTagMatch = { tagName: 'div', attrs: [[xx], ...], start: index }
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // 进一步处理上一步得到结果,并最后调用 options.start 方法
          // 真正的解析工作都是在这个 start 方法中做的
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }

      let text, rest, next
      if (textEnd >= 0) {
        // 能走到这儿,说明虽然在 html 中匹配到到了 <xx,但是这不属于上述几种情况,
        // 它就只是一个普通的一段文本:<我是文本
        // 于是从 html 中找到下一个 <,直到 <xx 是上述几种情况的标签,则结束,
        // 在这整个过程中一直在调整 textEnd 的值,作为 html 中下一个有效标签的开始位置

        // 截取 html 模版字符串中 textEnd 之后的内容,rest = <xx
        rest = html.slice(textEnd)
        // 这个 while 循环就是处理 <xx 之后的纯文本情况
        // 截取文本内容,并找到有效标签的开始位置(textEnd)
        while (
          !endTag.test(rest) &&
          !startTagOpen.test(rest) &&
          !comment.test(rest) &&
          !conditionalComment.test(rest)
        ) {
          // 则认为 < 后面的内容为纯文本,然后在这些纯文本中再次找 <
          next = rest.indexOf('<', 1)
          // 如果没找到 <,则直接结束循环
          if (next < 0) break
          // 走到这儿说明在后续的字符串中找到了 <,索引位置为 textEnd
          textEnd += next
          // 截取 html 字符串模版 textEnd 之后的内容赋值给 rest,继续判断之后的字符串是否存在标签
          rest = html.slice(textEnd)
        }
        // 走到这里,说明遍历结束,有两种情况,一种是 < 之后就是一段纯文本,要不就是在后面找到了有效标签,截取文本
        text = html.substring(0, textEnd)
      }

      // 如果 textEnd < 0,说明 html 中就没找到 <,那说明 html 就是一段文本
      if (textEnd < 0) {
        text = html
      }

      // 将文本内容从 html 模版字符串上截取掉
      if (text) {
        advance(text.length)
      }

      // 处理文本
      // 基于文本生成 ast 对象,然后将该 ast 放到它的父元素的肚子里,
      // 即 currentParent.children 数组中
      if (options.chars && text) {
        options.chars(text, index - text.length, index)
      }
    } else {
      // 处理 script、style、textarea 标签的闭合标签
      let endTagLength = 0
      // 开始标签的小写形式
      const stackedTag = lastTag.toLowerCase()
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
      // 匹配并处理开始标签和结束标签之间的所有文本,比如 <script>xx</script>
      const rest = html.replace(reStackedTag, function (all, text, endTag) {
        endTagLength = endTag.length
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1)
        }
        if (options.chars) {
          options.chars(text)
        }
        return ''
      })
      index += html.length - rest.length
      html = rest
      parseEndTag(stackedTag, index - endTagLength, index)
    }

    // 到这里就处理结束,如果 stack 数组中还有内容,则说明有标签没有被闭合,给出提示信息
    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`, { start: index + html.length })
      }
      break
    }
  }

  // Clean up any remaining tags
  parseEndTag()
}

advance

/src/compiler/parser/html-parser.js

/**
 * 重置 html,html = 从索引 n 位置开始的向后的所有字符
 * index 为 html 在 原始的 模版字符串 中的的开始索引,也是下一次该处理的字符的开始位置
 * @param {*} n 索引
 */
function advance(n) {
  index += n
  html = html.substring(n)
}

parseStartTag

/src/compiler/parser/html-parser.js

/**
 * 解析开始标签,比如:<div id="app">
 * @returns { tagName: 'div', attrs: [[xx], ...], start: index }
 */
function parseStartTag() {
  const start = html.match(startTagOpen)
  if (start) {
    // 处理结果
    const match = {
      // 标签名
      tagName: start[1],
      // 属性,占位符
      attrs: [],
      // 标签的开始位置
      start: index
    }
    /**
     * 调整 html 和 index,比如:
     *   html = ' id="app">'
     *   index = 此时的索引
     *   start[0] = '<div'
     */
    advance(start[0].length)
    let end, attr
    // 处理 开始标签 内的各个属性,并将这些属性放到 match.attrs 数组中
    while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
      attr.start = index
      advance(attr[0].length)
      attr.end = index
      match.attrs.push(attr)
    }
    // 开始标签的结束,end = '>' 或 end = ' />'
    if (end) {
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

handleStartTag

/src/compiler/parser/html-parser.js

/**
 * 进一步处理开始标签的解析结果 ——— match 对象
 *  处理属性 match.attrs,如果不是自闭合标签,则将标签信息放到 stack 数组,待将来处理到它的闭合标签时再将其弹出 stack,表示该标签处理完毕,这时标签的所有信息都在 element ast 对象上了
 *  接下来调用 options.start 方法处理标签,并根据标签信息生成 element ast,
 *  以及处理开始标签上的属性和指令,最后将 element ast 放入 stack 数组
 * 
 * @param {*} match { tagName: 'div', attrs: [[xx], ...], start: index }
 */
function handleStartTag(match) {
  const tagName = match.tagName
  // />
  const unarySlash = match.unarySlash

  if (expectHTML) {
    if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
      parseEndTag(lastTag)
    }
    if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
      parseEndTag(tagName)
    }
  }

  // 一元标签,比如 <hr />
  const unary = isUnaryTag(tagName) || !!unarySlash

  // 处理 match.attrs,得到 attrs = [{ name: attrName, value: attrVal, start: xx, end: xx }, ...]
  // 比如 attrs = [{ name: 'id', value: 'app', start: xx, end: xx }, ...]
  const l = match.attrs.length
  const attrs = new Array(l)
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // 比如:args[3] => 'id',args[4] => '=',args[5] => 'app'
    const value = args[3] || args[4] || args[5] || ''
    const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
      ? options.shouldDecodeNewlinesForHref
      : options.shouldDecodeNewlines
    // attrs[i] = { id: 'app' }
    attrs[i] = {
      name: args[1],
      value: decodeAttr(value, shouldDecodeNewlines)
    }
    // 非生产环境,记录属性的开始和结束索引
    if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
      attrs[i].start = args.start + args[0].match(/^\s*/).length
      attrs[i].end = args.end
    }
  }

  // 如果不是自闭合标签,则将标签信息放到 stack 数组中,待将来处理到它的闭合标签时再将其弹出 stack
  // 如果是自闭合标签,则标签信息就没必要进入 stack 了,直接处理众多属性,将他们都设置到 element ast 对象上,就没有处理 结束标签的那一步了,这一步在处理开始标签的过程中就进行了
  if (!unary) {
    // 将标签信息放到 stack 数组中,{ tag, lowerCasedTag, attrs, start, end }
    stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
    // 标识当前标签的结束标签为 tagName
    lastTag = tagName
  }

  /**
   * 调用 start 方法,主要做了以下 6 件事情:
   *   1、创建 AST 对象
   *   2、处理存在 v-model 指令的 input 标签,分别处理 input 为 checkbox、radio、其它的情况
   *   3、处理标签上的众多指令,比如 v-pre、v-for、v-if、v-once
   *   4、如果根节点 root 不存在则设置当前元素为根节点
   *   5、如果当前元素为非自闭合标签则将自己 push 到 stack 数组,并记录 currentParent,在接下来处理子元素时用来告诉子元素自己的父节点是谁
   *   6、如果当前元素为自闭合标签,则表示该标签要处理结束了,让自己和父元素产生关系,以及设置自己的子元素
   */
  if (options.start) {
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

parseEndTag

/src/compiler/parser/html-parser.js

/**
 * 解析结束标签,比如:</div>
 * 最主要的事就是:
 *   1、处理 stack 数组,从 stack 数组中找到当前结束标签对应的开始标签,然后调用 options.end 方法
 *   2、处理完结束标签之后调整 stack 数组,保证在正常情况下 stack 数组中的最后一个元素就是下一个结束标签对应的开始标签
 *   3、处理一些异常情况,比如 stack 数组最后一个元素不是当前结束标签对应的开始标签,还有就是
 *      br 和 p 标签单独处理
 * @param {*} tagName 标签名,比如 div
 * @param {*} start 结束标签的开始索引
 * @param {*} end 结束标签的结束索引
 */
function parseEndTag(tagName, start, end) {
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  // 倒序遍历 stack 数组,找到第一个和当前结束标签相同的标签,该标签就是结束标签对应的开始标签的描述对象
  // 理论上,不出异常,stack 数组中的最后一个元素就是当前结束标签的开始标签的描述对象
  // Find the closest opened tag of the same type
  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    // If no tag name is provided, clean shop
    pos = 0
  }

  // 如果在 stack 中一直没有找到相同的标签名,则 pos 就会 < 0,进行后面的 else 分支

  if (pos >= 0) {
    // 这个 for 循环负责关闭 stack 数组中索引 >= pos 的所有标签
    // 为什么要用一个循环,上面说到正常情况下 stack 数组的最后一个元素就是我们要找的开始标签,
    // 但是有些异常情况,就是有些元素没有给提供结束标签,比如:
    // stack = ['span', 'div', 'span', 'h1'],当前处理的结束标签 tagName = div
    // 匹配到 div,pos = 1,那索引为 2 和 3 的两个标签(span、h1)说明就没提供结束标签
    // 这个 for 循环就负责关闭 div、span 和 h1 这三个标签,
    // 并在开发环境为 span 和 h1 这两个标签给出 ”未匹配到结束标签的提示”
    // Close all the open elements, up the stack
    for (let i = stack.length - 1; i >= pos; i--) {
      if (process.env.NODE_ENV !== 'production' &&
        (i > pos || !tagName) &&
        options.warn
      ) {
        options.warn(
          `tag <${stack[i].tag}> has no matching end tag.`,
          { start: stack[i].start, end: stack[i].end }
        )
      }
      if (options.end) {
        // 走到这里,说明上面的异常情况都处理完了,调用 options.end 处理正常的结束标签
        options.end(stack[i].tag, start, end)
      }
    }

    // 将刚才处理的那些标签从数组中移除,保证数组的最后一个元素就是下一个结束标签对应的开始标签
    // Remove the open elements from the stack
    stack.length = pos
    // lastTag 记录 stack 数组中未处理的最后一个开始标签
    lastTag = pos && stack[pos - 1].tag
  } else if (lowerCasedTagName === 'br') {
    // 当前处理的标签为 <br /> 标签
    if (options.start) {
      options.start(tagName, [], true, start, end)
    }
  } else if (lowerCasedTagName === 'p') {
    // p 标签
    if (options.start) {
      // 处理 <p> 标签
      options.start(tagName, [], false, start, end)
    }
    if (options.end) {
      // 处理 </p> 标签
      options.end(tagName, start, end)
    }
  }
}

parseHtmlOptions

src/compiler/parser/index.js

定义如何处理开始标签、结束标签、文本节点和注释节点。

start

/**
 * 主要做了以下 6 件事情:
 *   1、创建 AST 对象
 *   2、处理存在 v-model 指令的 input 标签,分别处理 input 为 checkbox、radio、其它的情况
 *   3、处理标签上的众多指令,比如 v-pre、v-for、v-if、v-once
 *   4、如果根节点 root 不存在则设置当前元素为根节点
 *   5、如果当前元素为非自闭合标签则将自己 push 到 stack 数组,并记录 currentParent,在接下来处理子元素时用来告诉子元素自己的父节点是谁
 *   6、如果当前元素为自闭合标签,则表示该标签要处理结束了,让自己和父元素产生关系,以及设置自己的子元素
 * @param {*} tag 标签名
 * @param {*} attrs [{ name: attrName, value: attrVal, start, end }, ...] 形式的属性数组
 * @param {*} unary 自闭合标签
 * @param {*} start 标签在 html 字符串中的开始索引
 * @param {*} end 标签在 html 字符串中的结束索引
 */
function start(tag, attrs, unary, start, end) {
  // 检查命名空间,如果存在,则继承父命名空间
  // check namespace.
  // inherit parent ns if there is one
  const ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag)

  // handle IE svg bug
  /* istanbul ignore if */
  if (isIE && ns === 'svg') {
    attrs = guardIESVGBug(attrs)
  }

  // 创建当前标签的 AST 对象
  let element: ASTElement = createASTElement(tag, attrs, currentParent)
  // 设置命名空间
  if (ns) {
    element.ns = ns
  }

  // 这段在非生产环境下会走,在 ast 对象上添加 一些 属性,比如 start、end
  if (process.env.NODE_ENV !== 'production') {
    if (options.outputSourceRange) {
      element.start = start
      element.end = end
      // 将属性数组解析成 { attrName: { name: attrName, value: attrVal, start, end }, ... } 形式的对象
      element.rawAttrsMap = element.attrsList.reduce((cumulated, attr) => {
        cumulated[attr.name] = attr
        return cumulated
      }, {})
    }
    // 验证属性是否有效,比如属性名不能包含: spaces, quotes, <, >, / or =.
    attrs.forEach(attr => {
      if (invalidAttributeRE.test(attr.name)) {
        warn(
          `Invalid dynamic argument expression: attribute names cannot contain ` +
          `spaces, quotes, <, >, / or =.`,
          {
            start: attr.start + attr.name.indexOf(`[`),
            end: attr.start + attr.name.length
          }
        )
      }
    })
  }

  // 非服务端渲染的情况下,模版中不应该出现 style、script 标签
  if (isForbiddenTag(element) && !isServerRendering()) {
    element.forbidden = true
    process.env.NODE_ENV !== 'production' && warn(
      'Templates should only be responsible for mapping the state to the ' +
      'UI. Avoid placing tags with side-effects in your templates, such as ' +
      `<${tag}>` + ', as they will not be parsed.',
      { start: element.start }
    )
  }

  /**
   * 为 element 对象分别执行 class、style、model 模块中的 preTransforms 方法
   * 不过 web 平台只有 model 模块有 preTransforms 方法
   * 用来处理存在 v-model 的 input 标签,但没处理 v-model 属性
   * 分别处理了 input 为 checkbox、radio 和 其它的情况
   * input 具体是哪种情况由 el.ifConditions 中的条件来判断
   * <input v-mode="test" :type="checkbox or radio or other(比如 text)" />
   */
  // apply pre-transforms
  for (let i = 0; i < preTransforms.length; i++) {
    element = preTransforms[i](element, options) || element
  }

  if (!inVPre) {
    // 表示 element 是否存在 v-pre 指令,存在则设置 element.pre = true
    processPre(element)
    if (element.pre) {
      // 存在 v-pre 指令,则设置 inVPre 为 true
      inVPre = true
    }
  }
  // 如果 pre 标签,则设置 inPre 为 true
  if (platformIsPreTag(element.tag)) {
    inPre = true
  }

  if (inVPre) {
    // 说明标签上存在 v-pre 指令,这样的节点只会渲染一次,将节点上的属性都设置到 el.attrs 数组对象中,作为静态属性,数据更新时不会渲染这部分内容
    // 设置 el.attrs 数组对象,每个元素都是一个属性对象 { name: attrName, value: attrVal, start, end }
    processRawAttrs(element)
  } else if (!element.processed) {
    // structural directives
    // 处理 v-for 属性,得到 element.for = 可迭代对象 element.alias = 别名
    processFor(element)
    /**
     * 处理 v-if、v-else-if、v-else
     * 得到 element.if = "exp",element.elseif = exp, element.else = true
     * v-if 属性会额外在 element.ifConditions 数组中添加 { exp, block } 对象
     */
    processIf(element)
    // 处理 v-once 指令,得到 element.once = true 
    processOnce(element)
  }

  // 如果 root 不存在,则表示当前处理的元素为第一个元素,即组件的 根 元素
  if (!root) {
    root = element
    if (process.env.NODE_ENV !== 'production') {
      // 检查根元素,对根元素有一些限制,比如:不能使用 slot 和 template 作为根元素,也不能在有状态组件的根元素上使用 v-for 指令
      checkRootConstraints(root)
    }
  }

  if (!unary) {
    // 非自闭合标签,通过 currentParent 记录当前元素,下一个元素在处理的时候,就知道自己的父元素是谁
    currentParent = element
    // 然后将 element push 到 stack 数组,将来处理到当前元素的闭合标签时再拿出来
    // 将当前标签的 ast 对象 push 到 stack 数组中,这里需要注意,在调用 options.start 方法
    // 之前也发生过一次 push 操作,那个 push 进来的是当前标签的一个基本配置信息
    stack.push(element)
  } else {
    /**
     * 说明当前元素为自闭合标签,主要做了 3 件事:
     *   1、如果元素没有被处理过,即 el.processed 为 false,则调用 processElement 方法处理节点上的众多属性
     *   2、让自己和父元素产生关系,将自己放到父元素的 children 数组中,并设置自己的 parent 属性为 currentParent
     *   3、设置自己的子元素,将自己所有非插槽的子元素放到自己的 children 数组中
     */
    closeElement(element)
  }
}

end

/**
 * 处理结束标签
 * @param {*} tag 结束标签的名称
 * @param {*} start 结束标签的开始索引
 * @param {*} end 结束标签的结束索引
 */
function end(tag, start, end) {
  // 结束标签对应的开始标签的 ast 对象
  const element = stack[stack.length - 1]
  // pop stack
  stack.length -= 1
  // 这块儿有点不太理解,因为上一个元素有可能是当前元素的兄弟节点
  currentParent = stack[stack.length - 1]
  if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
    element.end = end
  }
  /**
   * 主要做了 3 件事:
   *   1、如果元素没有被处理过,即 el.processed 为 false,则调用 processElement 方法处理节点上的众多属性
   *   2、让自己和父元素产生关系,将自己放到父元素的 children 数组中,并设置自己的 parent 属性为 currentParent
   *   3、设置自己的子元素,将自己所有非插槽的子元素放到自己的 children 数组中
   */
  closeElement(element)
}

chars

/**
 * 处理文本,基于文本生成 ast 对象,然后将该 ast 放到它的父元素的肚子里,即 currentParent.children 数组中 
 */
function chars(text: string, start: number, end: number) {
  // 异常处理,currentParent 不存在说明这段文本没有父元素
  if (!currentParent) {
    if (process.env.NODE_ENV !== 'production') {
      if (text === template) { // 文本不能作为组件的根元素
        warnOnce(
          'Component template requires a root element, rather than just text.',
          { start }
        )
      } else if ((text = text.trim())) { // 放在根元素之外的文本会被忽略
        warnOnce(
          `text "${text}" outside root element will be ignored.`,
          { start }
        )
      }
    }
    return
  }
  // IE textarea placeholder bug
  /* istanbul ignore if */
  if (isIE &&
    currentParent.tag === 'textarea' &&
    currentParent.attrsMap.placeholder === text
  ) {
    return
  }
  // 当前父元素的所有孩子节点
  const children = currentParent.children
  // 对 text 进行一系列的处理,比如删除空白字符,或者存在 whitespaceOptions 选项,则 text 直接置为空或者空格
  if (inPre || text.trim()) {
    // 文本在 pre 标签内 或者 text.trim() 不为空
    text = isTextTag(currentParent) ? text : decodeHTMLCached(text)
  } else if (!children.length) {
    // 说明文本不在 pre 标签内而且 text.trim() 为空,而且当前父元素也没有孩子节点,
    // 则将 text 置为空
    // remove the whitespace-only node right after an opening tag
    text = ''
  } else if (whitespaceOption) {
    // 压缩处理
    if (whitespaceOption === 'condense') {
      // in condense mode, remove the whitespace node if it contains
      // line break, otherwise condense to a single space
      text = lineBreakRE.test(text) ? '' : ' '
    } else {
      text = ' '
    }
  } else {
    text = preserveWhitespace ? ' ' : ''
  }
  // 如果经过处理后 text 还存在
  if (text) {
    if (!inPre && whitespaceOption === 'condense') {
      // 不在 pre 节点中,并且配置选项中存在压缩选项,则将多个连续空格压缩为单个
      // condense consecutive whitespaces into single space
      text = text.replace(whitespaceRE, ' ')
    }
    let res
    // 基于 text 生成 AST 对象
    let child: ?ASTNode
    if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
      // 文本中存在表达式(即有界定符)
      child = {
        type: 2,
        // 表达式
        expression: res.expression,
        tokens: res.tokens,
        // 文本
        text
      }
    } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
      // 纯文本节点
      child = {
        type: 3,
        text
      }
    }
    // child 存在,则将 child 放到父元素的肚子里,即 currentParent.children 数组中
    if (child) {
      if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
        child.start = start
        child.end = end
      }
      children.push(child)
    }
  }
},

comment

/**
 * 处理注释节点
 */
function comment(text: string, start, end) {
  // adding anything as a sibling to the root node is forbidden
  // comments should still be allowed, but ignored
  // 禁止将任何内容作为 root 的节点的同级进行添加,注释应该被允许,但是会被忽略
  // 如果 currentParent 不存在,说明注释和 root 为同级,忽略
  if (currentParent) {
    // 注释节点的 ast
    const child: ASTText = {
      // 节点类型
      type: 3,
      // 注释内容
      text,
      // 是否为注释
      isComment: true
    }
    if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
      // 记录节点的开始索引和结束索引
      child.start = start
      child.end = end
    }
    // 将当前注释节点放到父元素的 children 属性中
    currentParent.children.push(child)
  }
}

createASTElement

/src/compiler/parser/index.js

/**
 * 为指定元素创建 AST 对象
 * @param {*} tag 标签名
 * @param {*} attrs 属性数组,[{ name: attrName, value: attrVal, start, end }, ...]
 * @param {*} parent 父元素
 * @returns { type: 1, tag, attrsList, attrsMap: makeAttrsMap(attrs), rawAttrsMap: {}, parent, children: []}
 */
export function createASTElement(
  tag: string,
  attrs: Array<ASTAttr>,
  parent: ASTElement | void
): ASTElement {
  return {
    // 节点类型
    type: 1,
    // 标签名
    tag,
    // 标签的属性数组
    attrsList: attrs,
    // 标签的属性对象 { attrName: attrVal, ... }
    attrsMap: makeAttrsMap(attrs),
    // 原始属性对象
    rawAttrsMap: {},
    // 父节点
    parent,
    // 孩子节点
    children: []
  }
}

preTransformNode

/src/platforms/web/compiler/modules/model.js

/**
 * 处理存在 v-model 的 input 标签,但没处理 v-model 属性
 * 分别处理了 input 为 checkbox、radio 和 其它的情况
 * input 具体是哪种情况由 el.ifConditions 中的条件来判断
 * <input v-mode="test" :type="checkbox or radio or other(比如 text)" />
 * @param {*} el 
 * @param {*} options 
 * @returns branch0
 */
function preTransformNode (el: ASTElement, options: CompilerOptions) {
  if (el.tag === 'input') {
    const map = el.attrsMap
    // 不存在 v-model 属性,直接结束
    if (!map['v-model']) {
      return
    }

    // 获取 :type 的值
    let typeBinding
    if (map[':type'] || map['v-bind:type']) {
      typeBinding = getBindingAttr(el, 'type')
    }
    if (!map.type && !typeBinding && map['v-bind']) {
      typeBinding = `(${map['v-bind']}).type`
    }

    // 如果存在 type 属性
    if (typeBinding) {
      // 获取 v-if 的值,比如: <input v-model="test" :type="checkbox" v-if="test" />
      const ifCondition = getAndRemoveAttr(el, 'v-if', true)
      // &&test
      const ifConditionExtra = ifCondition ? `&&(${ifCondition})` : ``
      // 是否存在 v-else 属性,<input v-else />
      const hasElse = getAndRemoveAttr(el, 'v-else', true) != null
      // 获取 v-else-if 属性的值 <inpu v-else-if="test" />
      const elseIfCondition = getAndRemoveAttr(el, 'v-else-if', true)
      // 克隆一个新的 el 对象,分别处理 input 为 chekbox、radio 或 其它的情况
      // 具体是哪种情况,通过 el.ifConditins 条件来判断
      // 1. checkbox
      const branch0 = cloneASTElement(el)
      // process for on the main node
      // <input v-for="item in arr" :key="item" />
      // 处理 v-for 表达式,得到 branch0.for = arr, branch0.alias = item
      processFor(branch0)
      // 在 branch0.attrsMap 和 branch0.attrsList 对象中添加 type 属性
      addRawAttr(branch0, 'type', 'checkbox')
      // 分别处理元素节点的 key、ref、插槽、自闭合的 slot 标签、动态组件、class、style、v-bind、v-on、其它指令和一些原生属性 
      processElement(branch0, options)
      // 标记当前对象已经被处理过了
      branch0.processed = true // prevent it from double-processed
      // 得到 true&&test or false&&test,标记当前 input 是否为 checkbox
      branch0.if = `(${typeBinding})==='checkbox'` + ifConditionExtra
      // 在 branch0.ifConfitions 数组中放入 { exp, block } 对象
      addIfCondition(branch0, {
        exp: branch0.if,
        block: branch0
      })
      // 克隆一个新的 ast 对象
      // 2. add radio else-if condition
      const branch1 = cloneASTElement(el)
      // 获取 v-for 属性值
      getAndRemoveAttr(branch1, 'v-for', true)
      // 在 branch1.attrsMap 和 branch1.attrsList 对象中添加 type 属性
      addRawAttr(branch1, 'type', 'radio')
      // 分别处理元素节点的 key、ref、插槽、自闭合的 slot 标签、动态组件、class、style、v-bind、v-on、其它指令和一些原生属性 
      processElement(branch1, options)
      // 在 branch0.ifConfitions 数组中放入 { exp, block } 对象
      addIfCondition(branch0, {
        // 标记当前 input 是否为 radio
        exp: `(${typeBinding})==='radio'` + ifConditionExtra,
        block: branch1
      })
      // 3. other,input 为其它的情况
      const branch2 = cloneASTElement(el)
      getAndRemoveAttr(branch2, 'v-for', true)
      addRawAttr(branch2, ':type', typeBinding)
      processElement(branch2, options)
      addIfCondition(branch0, {
        exp: ifCondition,
        block: branch2
      })

      // 给 branch0 设置 else 或 elseif 条件
      if (hasElse) {
        branch0.else = true
      } else if (elseIfCondition) {
        branch0.elseif = elseIfCondition
      }

      return branch0
    }
  }
}

getBindingAttr

/src/compiler/helpers.js

/**
 * 获取 el 对象上执行属性 name 的值 
 */
export function getBindingAttr (
  el: ASTElement,
  name: string,
  getStatic?: boolean
): ?string {
  // 获取指定属性的值
  const dynamicValue =
    getAndRemoveAttr(el, ':' + name) ||
    getAndRemoveAttr(el, 'v-bind:' + name)
  if (dynamicValue != null) {
    return parseFilters(dynamicValue)
  } else if (getStatic !== false) {
    const staticValue = getAndRemoveAttr(el, name)
    if (staticValue != null) {
      return JSON.stringify(staticValue)
    }
  }
}

getAndRemoveAttr

/src/compiler/helpers.js

/**
 * 从 el.attrsList 中删除指定的属性 name
 * 如果 removeFromMap 为 true,则同样删除 el.attrsMap 对象中的该属性,
 *   比如 v-if、v-else-if、v-else 等属性就会被移除,
 *   不过一般不会删除该对象上的属性,因为从 ast 生成 代码 期间还需要使用该对象
 * 返回指定属性的值
 */
// note: this only removes the attr from the Array (attrsList) so that it
// doesn't get processed by processAttrs.
// By default it does NOT remove it from the map (attrsMap) because the map is
// needed during codegen.
export function getAndRemoveAttr (
  el: ASTElement,
  name: string,
  removeFromMap?: boolean
): ?string {
  let val
  // 将执行属性 name 从 el.attrsList 中移除
  if ((val = el.attrsMap[name]) != null) {
    const list = el.attrsList
    for (let i = 0, l = list.length; i < l; i++) {
      if (list[i].name === name) {
        list.splice(i, 1)
        break
      }
    }
  }
  // 如果 removeFromMap 为 true,则从 el.attrsMap 中移除指定的属性 name
  // 不过一般不会移除 el.attsMap 中的数据,因为从 ast 生成 代码 期间还需要使用该对象
  if (removeFromMap) {
    delete el.attrsMap[name]
  }
  // 返回执行属性的值
  return val
}

processFor

/src/compiler/parser/index.js

/**
 * 处理 v-for,将结果设置到 el 对象上,得到:
 *   el.for = 可迭代对象,比如 arr
 *   el.alias = 别名,比如 item
 * @param {*} el 元素的 ast 对象
 */
export function processFor(el: ASTElement) {
  let exp
  // 获取 el 上的 v-for 属性的值
  if ((exp = getAndRemoveAttr(el, 'v-for'))) {
    // 解析 v-for 的表达式,得到 { for: 可迭代对象, alias: 别名 },比如 { for: arr, alias: item }
    const res = parseFor(exp)
    if (res) {
      // 将 res 对象上的属性拷贝到 el 对象上
      extend(el, res)
    } else if (process.env.NODE_ENV !== 'production') {
      warn(
        `Invalid v-for expression: ${exp}`,
        el.rawAttrsMap['v-for']
      )
    }
  }
}

addRawAttr

/src/compiler/helpers.js

// 在 el.attrsMap 和 el.attrsList 中添加指定属性 name
// add a raw attr (use this in preTransforms)
export function addRawAttr (el: ASTElement, name: string, value: any, range?: Range) {
  el.attrsMap[name] = value
  el.attrsList.push(rangeSetItem({ name, value }, range))
}

processElement

/src/compiler/parser/index.js

/**
 * 分别处理元素节点的 key、ref、插槽、自闭合的 slot 标签、动态组件、class、style、v-bind、v-on、其它指令和一些原生属性 
 * 然后在 el 对象上添加如下属性:
 * el.key、ref、refInFor、scopedSlot、slotName、component、inlineTemplate、staticClass
 * el.bindingClass、staticStyle、bindingStyle、attrs
 * @param {*} element 被处理元素的 ast 对象
 * @param {*} options 配置项
 * @returns 
 */
export function processElement(
  element: ASTElement,
  options: CompilerOptions
) {
  // el.key = val
  processKey(element)

  // 确定 element 是否为一个普通元素
  // determine whether this is a plain element after
  // removing structural attributes
  element.plain = (
    !element.key &&
    !element.scopedSlots &&
    !element.attrsList.length
  )

  // el.ref = val, el.refInFor = boolean
  processRef(element)
  // 处理作为插槽传递给组件的内容,得到  插槽名称、是否为动态插槽、作用域插槽的值,以及插槽中的所有子元素,子元素放到插槽对象的 children 属性中
  processSlotContent(element)
  // 处理自闭合的 slot 标签,得到插槽名称 => el.slotName = xx
  processSlotOutlet(element)
  // 处理动态组件,<component :is="compoName"></component>得到 el.component = compName,
  // 以及标记是否存在内联模版,el.inlineTemplate = true of false
  processComponent(element)
  // 为 element 对象分别执行 class、style、model 模块中的 transformNode 方法
  // 不过 web 平台只有 class、style 模块有 transformNode 方法,分别用来处理 class 属性和 style 属性
  // 得到 el.staticStyle、 el.styleBinding、el.staticClass、el.classBinding
  // 分别存放静态 style 属性的值、动态 style 属性的值,以及静态 class 属性的值和动态 class 属性的值
  for (let i = 0; i < transforms.length; i++) {
    element = transforms[i](element, options) || element
  }
  /**
   * 处理元素上的所有属性:
   * v-bind 指令变成:el.attrs 或 el.dynamicAttrs = [{ name, value, start, end, dynamic }, ...],
   *                或者是必须使用 props 的属性,变成了 el.props = [{ name, value, start, end, dynamic }, ...]
   * v-on 指令变成:el.events 或 el.nativeEvents = { name: [{ value, start, end, modifiers, dynamic }, ...] }
   * 其它指令:el.directives = [{name, rawName, value, arg, isDynamicArg, modifier, start, end }, ...]
   * 原生属性:el.attrs = [{ name, value, start, end }],或者一些必须使用 props 的属性,变成了:
   *         el.props = [{ name, value: true, start, end, dynamic }]
   */
  processAttrs(element)
  return element
}

processKey

/src/compiler/parser/index.js

/**
 * 处理元素上的 key 属性,设置 el.key = val
 * @param {*} el 
 */
function processKey(el) {
  // 拿到 key 的属性值
  const exp = getBindingAttr(el, 'key')
  if (exp) {
    // 关于 key 使用上的异常处理
    if (process.env.NODE_ENV !== 'production') {
      // template 标签不允许设置 key
      if (el.tag === 'template') {
        warn(
          `<template> cannot be keyed. Place the key on real elements instead.`,
          getRawBindingAttr(el, 'key')
        )
      }
      // 不要在 <transition=group> 的子元素上使用 v-for 的 index 作为 key,这和没用 key 没什么区别
      if (el.for) {
        const iterator = el.iterator2 || el.iterator1
        const parent = el.parent
        if (iterator && iterator === exp && parent && parent.tag === 'transition-group') {
          warn(
            `Do not use v-for index as key on <transition-group> children, ` +
            `this is the same as not using keys.`,
            getRawBindingAttr(el, 'key'),
            true /* tip */
          )
        }
      }
    }
    // 设置 el.key = exp
    el.key = exp
  }
}

processRef

/src/compiler/parser/index.js

/**
 * 处理元素上的 ref 属性
 *  el.ref = refVal
 *  el.refInFor = boolean
 * @param {*} el 
 */
function processRef(el) {
  const ref = getBindingAttr(el, 'ref')
  if (ref) {
    el.ref = ref
    // 判断包含 ref 属性的元素是否包含在具有 v-for 指令的元素内或后代元素中
    // 如果是,则 ref 指向的则是包含 DOM 节点或组件实例的数组
    el.refInFor = checkInFor(el)
  }
}

processSlotContent

/src/compiler/parser/index.js

/**
 * 处理作为插槽传递给组件的内容,得到:
 *  slotTarget => 插槽名
 *  slotTargetDynamic => 是否为动态插槽
 *  slotScope => 作用域插槽的值
 *  直接在 <comp> 标签上使用 v-slot 语法时,将上述属性放到 el.scopedSlots 对象上,其它情况直接放到 el 对象上
 * handle content being passed to a component as slot,
 * e.g. <template slot="xxx">, <div slot-scope="xxx">
 */
function processSlotContent(el) {
  let slotScope
  if (el.tag === 'template') {
    // template 标签上使用 scope 属性的提示
    // scope 已经弃用,并在 2.5 之后使用 slot-scope 代替
    // slot-scope 即可以用在 template 标签也可以用在普通标签上
    slotScope = getAndRemoveAttr(el, 'scope')
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && slotScope) {
      warn(
        `the "scope" attribute for scoped slots have been deprecated and ` +
        `replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
        `can also be used on plain elements in addition to <template> to ` +
        `denote scoped slots.`,
        el.rawAttrsMap['scope'],
        true
      )
    }
    // el.slotScope = val
    el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')
  } else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && el.attrsMap['v-for']) {
      // 元素不能同时使用 slot-scope 和 v-for,v-for 具有更高的优先级
      // 应该用 template 标签作为容器,将 slot-scope 放到 template 标签上 
      warn(
        `Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
        `(v-for takes higher priority). Use a wrapper <template> for the ` +
        `scoped slot to make it clearer.`,
        el.rawAttrsMap['slot-scope'],
        true
      )
    }
    el.slotScope = val
    el.slotScope = slotScope
  }

  // 获取 slot 属性的值
  // slot="xxx",老旧的具名插槽的写法
  const slotTarget = getBindingAttr(el, 'slot')
  if (slotTarget) {
    // el.slotTarget = 插槽名(具名插槽)
    el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
    // 动态插槽名
    el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])
    // preserve slot as an attribute for native shadow DOM compat
    // only for non-scoped slots.
    if (el.tag !== 'template' && !el.slotScope) {
      addAttr(el, 'slot', slotTarget, getRawBindingAttr(el, 'slot'))
    }
  }

  // 2.6 v-slot syntax
  if (process.env.NEW_SLOT_SYNTAX) {
    if (el.tag === 'template') {
      // v-slot 在 tempalte 标签上,得到 v-slot 的值
      // v-slot on <template>
      const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
      if (slotBinding) {
        // 异常提示
        if (process.env.NODE_ENV !== 'production') {
          if (el.slotTarget || el.slotScope) {
            // 不同插槽语法禁止混合使用
            warn(
              `Unexpected mixed usage of different slot syntaxes.`,
              el
            )
          }
          if (el.parent && !maybeComponent(el.parent)) {
            // <template v-slot> 只能出现在组件的根位置,比如:
            // <comp>
            //   <template v-slot>xx</template>
            // </comp>
            // 而不能是
            // <comp>
            //   <div>
            //     <template v-slot>xxx</template>
            //   </div>
            // </comp>
            warn(
              `<template v-slot> can only appear at the root level inside ` +
              `the receiving component`,
              el
            )
          }
        }
        // 得到插槽名称
        const { name, dynamic } = getSlotName(slotBinding)
        // 插槽名
        el.slotTarget = name
        // 是否为动态插槽
        el.slotTargetDynamic = dynamic
        // 作用域插槽的值
        el.slotScope = slotBinding.value || emptySlotScopeToken // force it into a scoped slot for perf
      }
    } else {
      // 处理组件上的 v-slot,<comp v-slot:header />
      // slotBinding = { name: "v-slot:header", value: "", start, end}
      // v-slot on component, denotes default slot
      const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
      if (slotBinding) {
        // 异常提示
        if (process.env.NODE_ENV !== 'production') {
          // el 不是组件的话,提示,v-slot 只能出现在组件上或 template 标签上
          if (!maybeComponent(el)) {
            warn(
              `v-slot can only be used on components or <template>.`,
              slotBinding
            )
          }
          // 语法混用
          if (el.slotScope || el.slotTarget) {
            warn(
              `Unexpected mixed usage of different slot syntaxes.`,
              el
            )
          }
          // 为了避免作用域歧义,当存在其他命名槽时,默认槽也应该使用<template>语法
          if (el.scopedSlots) {
            warn(
              `To avoid scope ambiguity, the default slot should also use ` +
              `<template> syntax when there are other named slots.`,
              slotBinding
            )
          }
        }
        // 将组件的孩子添加到它的默认插槽内
        // add the component's children to its default slot
        const slots = el.scopedSlots || (el.scopedSlots = {})
        // 获取插槽名称以及是否为动态插槽
        const { name, dynamic } = getSlotName(slotBinding)
        // 创建一个 template 标签的 ast 对象,用于容纳插槽内容,父级是 el
        const slotContainer = slots[name] = createASTElement('template', [], el)
        // 插槽名
        slotContainer.slotTarget = name
        // 是否为动态插槽
        slotContainer.slotTargetDynamic = dynamic
        // 所有的孩子,将每一个孩子的 parent 属性都设置为 slotContainer
        slotContainer.children = el.children.filter((c: any) => {
          if (!c.slotScope) {
            // 给插槽内元素设置 parent 属性为 slotContainer,也就是 template 元素
            c.parent = slotContainer
            return true
          }
        })
        slotContainer.slotScope = slotBinding.value || emptySlotScopeToken
        // remove children as they are returned from scopedSlots now
        el.children = []
        // mark el non-plain so data gets generated
        el.plain = false
      }
    }
  }
}

getSlotName

/src/compiler/parser/index.js

/**
 * 解析 binding,得到插槽名称以及是否为动态插槽
 * @returns { name: 插槽名称, dynamic: 是否为动态插槽 }
 */
function getSlotName(binding) {
  let name = binding.name.replace(slotRE, '')
  if (!name) {
    if (binding.name[0] !== '#') {
      name = 'default'
    } else if (process.env.NODE_ENV !== 'production') {
      warn(
        `v-slot shorthand syntax requires a slot name.`,
        binding
      )
    }
  }
  return dynamicArgRE.test(name)
    // dynamic [name]
    ? { name: name.slice(1, -1), dynamic: true }
    // static name
    : { name: `"${name}"`, dynamic: false }
}

processSlotOutlet

/src/compiler/parser/index.js

// handle <slot/> outlets,处理自闭合 slot 标签
// 得到插槽名称,el.slotName
function processSlotOutlet(el) {
  if (el.tag === 'slot') {
    // 得到插槽名称
    el.slotName = getBindingAttr(el, 'name')
    // 提示信息,不要在 slot 标签上使用 key 属性
    if (process.env.NODE_ENV !== 'production' && el.key) {
      warn(
        `\`key\` does not work on <slot> because slots are abstract outlets ` +
        `and can possibly expand into multiple elements. ` +
        `Use the key on a wrapping element instead.`,
        getRawBindingAttr(el, 'key')
      )
    }
  }
}

processComponent

/src/compiler/parser/index.js

/**
 * 处理动态组件,<component :is="compName"></component>
 * 得到 el.component = compName
 */
function processComponent(el) {
  let binding
  // 解析 is 属性,得到属性值,即组件名称,el.component = compName
  if ((binding = getBindingAttr(el, 'is'))) {
    el.component = binding
  }
  // <component :is="compName" inline-template>xx</component>
  // 组件上存在 inline-template 属性,进行标记:el.inlineTemplate = true
  // 表示组件开始和结束标签内的内容作为组件模版出现,而不是作为插槽别分发,方便定义组件模版
  if (getAndRemoveAttr(el, 'inline-template') != null) {
    el.inlineTemplate = true
  }
}

transformNode

/src/platforms/web/compiler/modules/class.js

/**
 * 处理元素上的 class 属性
 * 静态的 class 属性值赋值给 el.staticClass 属性
 * 动态的 class 属性值赋值给 el.classBinding 属性
 */
function transformNode (el: ASTElement, options: CompilerOptions) {
  // 日志
  const warn = options.warn || baseWarn
  // 获取元素上静态 class 属性的值 xx,<div class="xx"></div>
  const staticClass = getAndRemoveAttr(el, 'class')
  if (process.env.NODE_ENV !== 'production' && staticClass) {
    const res = parseText(staticClass, options.delimiters)
    // 提示,同 style 的提示一样,不能使用 <div class="{{ val}}"></div>,请用
    // <div :class="val"></div> 代替
    if (res) {
      warn(
        `class="${staticClass}": ` +
        'Interpolation inside attributes has been removed. ' +
        'Use v-bind or the colon shorthand instead. For example, ' +
        'instead of <div class="{{ val }}">, use <div :class="val">.',
        el.rawAttrsMap['class']
      )
    }
  }
  // 静态 class 属性值赋值给 el.staticClass
  if (staticClass) {
    el.staticClass = JSON.stringify(staticClass)
  }
  // 获取动态绑定的 class 属性值,并赋值给 el.classBinding
  const classBinding = getBindingAttr(el, 'class', false /* getStatic */)
  if (classBinding) {
    el.classBinding = classBinding
  }
}

transformNode

/src/platforms/web/compiler/modules/style.js

/**
 * 从 el 上解析出静态的 style 属性和动态绑定的 style 属性,分别赋值给:
 * el.staticStyle 和 el.styleBinding
 * @param {*} el 
 * @param {*} options 
 */
function transformNode(el: ASTElement, options: CompilerOptions) {
  // 日志
  const warn = options.warn || baseWarn
  // <div style="xx"></div>
  // 获取 style 属性
  const staticStyle = getAndRemoveAttr(el, 'style')
  if (staticStyle) {
    // 提示,如果从 xx 中解析到了界定符,说明是一个动态的 style,
    // 比如 <div style="{{ val }}"></div>则给出提示:
    // 动态的 style 请使用 <div :style="val"></div>
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production') {
      const res = parseText(staticStyle, options.delimiters)
      if (res) {
        warn(
          `style="${staticStyle}": ` +
          'Interpolation inside attributes has been removed. ' +
          'Use v-bind or the colon shorthand instead. For example, ' +
          'instead of <div style="{{ val }}">, use <div :style="val">.',
          el.rawAttrsMap['style']
        )
      }
    }
    // 将静态的 style 样式赋值给 el.staticStyle
    el.staticStyle = JSON.stringify(parseStyleText(staticStyle))
  }

  // 获取动态绑定的 style 属性,比如 <div :style="{{ val }}"></div>
  const styleBinding = getBindingAttr(el, 'style', false /* getStatic */)
  if (styleBinding) {
    // 赋值给 el.styleBinding
    el.styleBinding = styleBinding
  }
}

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(1)—— 前言

Vue 源码解读(1)—— 前言

当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

简介

专栏的第一篇,主要介绍专栏的目的、规划、适用人群,以及准备工作和扫盲的基础知识。

前言

最近在准备一些 Vue 系列的文章和视频,之前 Vue 的源码也读过好几遍,但是一直没写相关的文章,所以最近就计划写一写。

目标

精通 Vue 技术栈的源码原理,这是这系列的文章最终目的。

首先会从 Vue 源码解读开,会产出一系列的文章和视频,从详细刨析源码,再到 手写 Vue 1.0Vue 2.0。之后会产出周边生态相关库的源码分析和手写系列,比如:vuex、vue-router、vue-cli 等。

相信经过这一系列的认真学习,大家都可以在自己的简历上写上这么一条:精通 Vue 技术栈的源码原理

适合人群

  • 熟练使用 Vue 技术栈进行日常开发(增删改查)

  • 想深入了解框架实现原理

  • 想跳槽 或 跟老板提涨薪的同学(增删改查不值钱)

如何学习

对于系列文章,顺序学习自然最好,但如果你本身对源码有一些了解或者对某一部分特别感兴趣,也可以直接看相应对应的文章。

很多人习惯利用碎片化时间去学习,对于快餐类的文章当然没有问题,但是如果你想深入学习,还是建议坐在电脑前用整块的时间对照着文章亲自动手去学。

记住:光看不练假把式,所以在学习过程中一定要勤动手,不动笔墨不读书,像笔记、思维导图、示例代码、为源码编写注释、debug 调试等,该上就上,绝对不能偷懒。

如果你觉得该系列文章对你有帮助,欢迎大家 点赞关注,也欢迎将它分享给你身边的小伙伴。

准备

现在最新的 Vue 2 的版本号是 2.6.12,所以我就以当前版本的代码进行分析和学习。

下载 Vue 源码

  • git 命令
git clone https://github.com/vuejs/vue.git
  • github 手动下载然后解压

装包

执行 npm i 安装依赖,待装到端到端测试工具时可直接 ctrl + c 掉,不影响后续源码的研读。

source map

在 package.json -> scripts 中的 dev 命令中添加 --sourcemap,这样就可以在浏览器中调试源码时查看当前代码在源码中的位置。

{
  "scripts": {
    "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
  }
}

开发调试

执行以下命令,启动开发环境:

npm run dev

看到如下效果,并在 dist 目录下生成 vue.js.map 文件,则表示成功。到这里所有的准备工作均已完成,但是不要将当前命令行 ctrl + c 掉,因为你在阅读源码时会需要向源码中添加注释,甚至改动源码,当前命令可以监测源码的改动,如果发现改动会自动进行打包;如果关闭当前命令行,你会发现,随着你注释代码的编写,在浏览器中调试源码时会出现和源码映射的偏差。所以为了更好的调试体验就别关闭它。

扫盲

执行 npm run build 命令之后会发现在 dist 目录下生成一堆特殊命名的 vue.*.js 文件,这些特殊的命名分别是什么意思呢?

构建文件分类

UMD CommonJS ES Module
Full vue.js vue.common.js vue.esm.js
Runtime-only vue.runtime.js vue.runtime.common.js vue.runtime.esm.js
Full (production) vue.min.js vue.common.prod.js
Runtime-only (production) vue.runtime.min.js vue.runtime.common.prod.js

名词解释

  • Full:这是一个全量的包,包含编译器(compiler)和运行时(runtime)。

  • Compiler:编译器,负责将模版字符串(即你编写的类 html 语法的模版代码)编译为 JavaScript 语法的 render 函数。

  • Runtime:负责创建 Vue 实例、渲染函数、patch 虚拟 DOM 等代码,基本上除了编译器之外的代码都属于运行时代码。

  • UMD:兼容 CommonJS 和 AMD 规范,通过 CDN 引入的 vue.js 就是 UMD 规范的代码,包含编译器和运行时。

  • CommonJS:典型的应用比如 nodeJS,CommonsJS 规范的包是为了给 browserify 和 webpack 1 这样旧的打包器使用的。他们默认的入口文件为 vue.runtime.common.js

  • ES Module:现代 JavaScript 规范,ES Module 规范的包是给像 webpack 2 和 rollup 这样的现代打包器使用的。这些打包器默认使用仅包含运行时的 vue.runtime.esm.js 文件。

运行时(Runtime)+ 编译器(Compiler) vs. 只包含运行时(Runtime-only)

如果你需要动态编译模版(比如:将字符串模版传递给 template 选项,或者通过提供一个挂载元素的方式编写 html 模版),你将需要编译器,因此需要一个完整的构建包。

当你使用 vue-loader 或者 vueify 时,*.vue 文件中的模版在构建时会被编译为 JavaScript 的渲染函数。因此你不需要包含编译器的全量包,只需使用只包含运行时的包即可。

只包含运行时的包体积要比全量包的体积小 30%。因此尽量使用只包含运行时的包,如果你需要使用全量包,那么你需要进行如下配置:

webpack

module.exports = {
  // ...
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    }
  }
}

Rollup

const alias = require('rollup-plugin-alias')

rollup({
  // ...
  plugins: [
    alias({
      'vue': 'vue/dist/vue.esm.js'
    })
  ]
})

Browserify

Add to your project's package.json:

{
  // ...
  "browser": {
    "vue": "vue/dist/vue.common.js"
  }
}

源码目录结构

通过目录结构的阅读,对源码有一个大致的了解,知道哪些东西需要去哪看。

├── benchmarks                  性能、基准测试
├── dist                        构建打包的输出目录
├── examples                    案例目录
├── flow                        flow 语法的类型声明
├── packages                    一些额外的包,比如:负责服务端渲染的包 vue-server-renderer、配合 vue-loader 使用的的 vue-template-compiler,还有 weex 相关的
│   ├── vue-server-renderer
│   ├── vue-template-compiler
│   ├── weex-template-compiler
│   └── weex-vue-framework
├── scripts                     所有的配置文件的存放位置,比如 rollup 的配置文件
├── src                         vue 源码目录
│   ├── compiler                编译器
│   ├── core                    运行时的核心包
│   │   ├── components          全局组件,比如 keep-alive
│   │   ├── config.js           一些默认配置项
│   │   ├── global-api          全局 API,比如熟悉的:Vue.use()、Vue.component() 等
│   │   ├── instance            Vue 实例相关的,比如 Vue 构造函数就在这个目录下
│   │   ├── observer            响应式原理
│   │   ├── util                工具方法
│   │   └── vdom                虚拟 DOM 相关,比如熟悉的 patch 算法就在这儿
│   ├── platforms               平台相关的编译器代码
│   │   ├── web
│   │   └── weex
│   ├── server                  服务端渲染相关
├── test                        测试目录
├── types                       TS 类型声明

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

在线主题切换

在线主题切换

在线主题切换的本质就是通过 JS 替换主题 link 标签的 href 属性,加载对应主题的样式包。

样式包可以是多套 CSS 样式,也可以是由 CSS 变量组成的主题包。

多套 CSS 样式

优点 是简单、易于理解,缺点 也很明显,可维护性差、扩展性差、开发工作量大(需要研发同学为系统开发多套样式)。可阅读下面的示例代码感受一下

index.html

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>multi theme</title>
    <!-- 主题包,通过 JS 动态切换 link 的 href 值 -->
	<link rel="stylesheet" class="theme" href="./blue.css">
</head>

<body>
	<div class="theme div-ele">
		multi theme
	</div>
	<button onclick="toggleRedTheme()">red theme</button>
	<button onclick="toggleBlueTheme()">blue theme</button>

	<script>
        // 切换红色主题
		function toggleRedTheme() {
			document.querySelector('.theme').setAttribute('href', './red.css')
		}

        // 切换蓝色主题
		function toggleBlueTheme() {
			document.querySelector('.theme').setAttribute('href', './blue.css')
		}
	</script>
</body>
</html>

blue.css

/* 蓝色主题 */
.div-ele {
	width: 200px;
	height: 200px;
	line-height: 200px;
	text-align: center;
	color: #fff;
	margin-bottom: 20px;
	background-color: blue;
}

red.css

/* 红色主题 */
.theme {
	width: 200px;
	height: 200px;
	line-height: 200px;
	text-align: center;
	color: #fff;
	margin-bottom: 20px;
	background-color: red;
}

当有一天你接手了类似的一个老旧系统,产品过来跟你说,我们现在需要给系统新增一套样式,全新的 UI 设计稿已经出来了,你抽时间做一下吧。这里大家要搞清楚的是,这不是一个抽时间就能完成的简单需求,这意味着你需要为系统重写一套新的样式,比如叫 yellow.css

首先你需要复制已有样式,然后在浏览器中对照设计稿挨个去修改相关样式代码,并将修改同步到代码文件中,对于一个大型系统来说,这个工作量真的是......

针对上面的问题,有没有什么优化办法呢?一个呼之欲出的答案就是样式抽离。

当你新增或修改已有 UI 样式时,其实修改的只是部分样式,比如背景色、字体颜色、边框色等,这部分经常被修改的样式我们称为主题样式,你需要将这些样式找出来。这里难在寻找样式,就像大海捞针,那能否把相关样式抽出来呢?就像写代码一样,将公共逻辑抽离,然后在各个地方复用。

Sass 变量

这里我们需要借助 CSS 预编译语言去实现,比如 Sass。将多套 CSS 样式中的公共样式(主题样式)抽离,通过 Sass 变量维护公共样式,每次新增或修改主题时,只需要修改主题变量文件,然后重新编译生成新的 CSS 样式。也就是说这里的样式需要通过 Sass 或 Less 语言编写,然后编译成 CSS,因为浏览器只认识 CSS。

这样就进一步提升了系统的可维护性和扩展性,也降低了主题样式的开发工作量。

index.html

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>multi theme</title>
    <!-- 主题包,通过 JS 动态切换 link 的 href 值 -->
	<link rel="stylesheet" href="./blue.css">
</head>

<body>
	<div class="div-ele">
		multi theme
	</div>
	<button onclick="toggleRedTheme()">red theme</button>
	<button onclick="toggleBlueTheme()">blue theme</button>

	<script>
        // 切换红色主题
		function toggleRedTheme() {
			document.querySelector('.theme').setAttribute('href', './red.css')
		}

        // 切换蓝色主题
		function toggleBlueTheme() {
			document.querySelector('.theme').setAttribute('href', './blue.css')
		}
	</script>
</body>

</html>

var.scss

// blue theme
$backgroundColor: blue;

// red theme
// $backgroundColor: red;

index.scss

@import './var.scss';

.div-ele {
	width: 200px;
	height: 200px;
	line-height: 200px;
	text-align: center;
	color: #fff;
	margin-bottom: 20px;
	background-color: $backgroundColor
}

index.scss 编译后生成如下两套样式

blue.css

/* 蓝色主题 */
.div-ele {
  width: 200px;
  height: 200px;
  line-height: 200px;
  text-align: center;
  color: #fff;
  margin-bottom: 20px;
  background-color: blue;
}

red.css

/* 红色主题 */
.div-ele {
  width: 200px;
  height: 200px;
  line-height: 200px;
  text-align: center;
  color: #fff;
  margin-bottom: 20px;
  background-color: red;
}

这时候当产品说新增一套黄色样式的时候,我只需要在 var.scss 文件中修改对应的主题样式,然后编译生成 yellow.css 样式文件即可。

像 ElementUI、Ant Design 就是这样的思路,样式包中内置主题样式变量文件,比如 var.scss,文件中维护了大量的样式变量,如果你需要定制自己的主题样式,只需要修改这个变量文件,然后重新编译组件库,发版就可以了。

当然了,这里抛开技术之外,有一个跨团队合作的问题(研发、设计、产品),你需要协调三个团队的资源去完成这件事,让产品和设计同学合作,根据业务和产品特点为团队出一套 UI 规范,研发同学根据 UI 规范完成多主题样式的研发。

但这里存在一个问题,组件库都支持按需打包,只打包使用到的组件和组件的样式。但是当业务系统需要支持多主题时,组件库就没办法再提供样式的按需打包了。

首先,组件库多主题需要配置不同的样式变量(var.scss)文件,然后编译生成多套样式,将样式包独立发布。

业务系统在使用组件库时,手动引入样式包,不能再使用组件库的样式按需打包能力,因为业务系统切换主题样式是发生在运行时,而按需打包是发生在编译时。

运行时切换主题的方式和 多套 CSS 样式 一样,也是通过 JavaScript 操作 link 标签,完成样式的替换,所以该方案算是第一个方案的一个优化。

CSS 变量

Sass 变量的方案虽然提升了可维护性和可扩展性,但是却导致另外一个问题,组件库丢失了样式按需打包的能力。

而丢失的原因是因为主题切换发生在运行时,但是组件库的样式却需要在编译期将 Sass 编译为 CSS,两者具有不同的运行时段,所以结合起来使用就导致无法使用组件库样式按需打包的能力。

这时候就需要想有没有什么办法能让两者发生在同一时刻,比如都发生在运行时或编译时,可惜编译时暂时还没什么好的方案,但是运行时可以使用 CSS 变量的方式。

CSS 变量是 CSS 的新功能,可以简单理解为原生支持像 Sass、Less 语言的变量能力,从 2017 年 3 月份之后,所有主要浏览器已经都支持了 CSS 变量。

所以这里的方案就是 CSS 变量,将所有主题样式抽离到独立的主题样式文件中,然后在运行时通过 JavaScript 动态替换 link 标签。

CSS 变量基本使用

/* 最佳实践是将样式变量定义在根伪类 :root 下,这样就可以在 HTML 文档的任何地方访问到定义的样式变量了,相当于全局作用域 */
:root {
	--backgroundColor: red;
}

.div-ele {
	/* 通过 var 函数来获取指定变量的值 */
	background-color: var(--backgroundColor);
}

index.html

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>multi theme</title>
    <!-- 主题包,通过 JS 动态切换 link 的 href 值 -->
	<link rel="stylesheet" class="theme" href="./blue.css">
    <!-- 系统样式,其中的主题样式使用 CSS 变量 -->
	<link rel="stylesheet" href="./index.css">
</head>

<body>
	<div class="div-ele">
		multi theme
	</div>
	<button onclick="toggleRedTheme()">red theme</button>
	<button onclick="toggleBlueTheme()">blue theme</button>

	<script>
        // 切换红色主题
		function toggleRedTheme() {
			document.querySelector('.theme').setAttribute('href', './red.css')
		}

        // 切换蓝色主题
		function toggleBlueTheme() {
			document.querySelector('.theme').setAttribute('href', './blue.css')
		}
	</script>
</body>

</html>

index.css

/* 系统样式,其中的主题样式使用 CSS 变量 */
.div-ele {
	width: 200px;
	height: 200px;
	line-height: 200px;
	text-align: center;
	color: #fff;
	margin-bottom: 20px;
    /* 使用 CSS 变量声明的主题样式 */
	background-color: var(--backgroundColor);
}

red.css

/* 红色主题 */
:root {
	--backgroundColor: red;
}

blue.css

/* 蓝色主题 */
:root {
	--backgroundColor: blue;
}

所以,当产品需要为系统新增一套黄色主题时,只需要增加一个 yellow.css 文件即可

yellow.css

/* 黄色主题 */
:root {
	--backgroundColor: yellow;
}

总结

以上就是常见的在线主题切换方案:开发多套 CSS 样式、基于 Sass 变量的多套 CSS 样式、CSS 变量。其本质就是在切换主题时通过 JS 替换主题 link 标签的 href 属性,加载对应主题的样式包。

  • 多套 CSS 样式
    • **优点:**简单、易理解,就是写多套主题
    • **缺点:**开发工作量大、维护难度大、扩展性差
  • 基于 Sass 变量优化后的多套 CSS 样式
    • **优点:**通过主题样式的抽离,开发工作量小,维护难度中等,扩展性好
    • **缺点:**组件库样式丢失了按需打包的能力,因为在线切换的整个主题包,维护难度中等,后期每次新增和修改主题样式都需要重新编译生成对应的主题样式
  • CSS 变量
    • **优点:**开发工作量小、易维护、扩展性好,浏览器原生支持
    • **缺点:**虽然主流浏览器都支持了,单相对上面两个方案来说是劣势,性能稍微优点没那么优秀

所以如果你的业务复杂度没那么高(一个页面有上万个 DOM 节点),浏览器兼容性也还好,CSS 变量可以成为你的首选方案,结合 Sass 等预编译语言去实现在线主题切换。

拓展

虽然现在有了多主题方案,但是在团队内如何很好的落地呢?

看到这个问题,你可能会想这还不简单?主题肯定是内置到组件库啊。嗯,没问题,这是一种应用场景,但这只是冰山一角。

大家要知道 CSS 主题样式的应用场景不止是组件库(基础 UI 库、业务组件库、物料库),更多的其实是在你的业务代码中,可以仔细想想,你平时开发时是不是需要写很多 CSS 代码,这些 CSS 代码中也会包含很多主题相关的样式。

你如果将相关样式直接写死成设计稿上给定的数据,在主题切换时,这部分写死的样式就无法被切换,这是切换的只是组件库中相关 UI 的样式。你的业务代码怎么办?

  1. 原始方案,将主题变量全部通过文档记录,要求每个开发同学熟记这些主题变量,并在业务代码中使用。这个方法一听就很变态(变量那么多)
  2. vscode 插件,将主题变量封装成 vscode 插件,或者代码片段,拿代码片段举例来说,比如背景主题色,输入 background-color 直接生成 background-color: var(--backgroundColor);。这里只给大家提供一个思路,具体实现可以自己探索探索,有好的实现可以在评论区和大家的分享分享

链接

多主题切换的示例代码:liyongning/multi-theme


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

思维导图 + 文字 = 让你一次性学会正则表达式

思维导图 + 文字 = 让你一次性学会正则表达式

为什么要学正则表达式

为什么要学习正则表达式?相信很多人没有思考过这个问题,每一次学习正则表达式都是因为 “需要”,这个需要可能是各种各样的原因,比如:日常工作、看源码、面试

  • 日常工作

    其实很多人认为不会正则也不影响日常工作,确实,很多需要正则的地方用普通的代码也可以实现,比如:将 2021-10-04 替换成 2021/10/04

    • 正则方式
    const str = '2021-10-04'
    const formatStr = str.replace(/-/g, '/')
    // 2021/10/04
    console.log(formatStr)
    • 普通代码
    const str = '2021-10-04'
    const strArr = str.split('-')
    const formatStr = `${strArr[0]}/${strArr[1]}/${strArr[2]}`
    // 2021/10/04
    console.log(formatStr)

    两种方式都能实现我们的需要,但是可以看到这个简单的例子用普通代码实现明显代码量大并且复杂;况且这只是一个最简单的例子,稍微复杂一点的需求,代码量和复杂度会有更明显的区别。

  • 看源码

    这里的源码不止是某个框架的源码,比如工作中你接手的某个业务项目,其中有一些正则表达式,你要理解代码在做什么,这时候你就需要去百度、google 现查、现学正则表达式的各种语法。

    看框架、库的源码,比如 Vue 框架的编译器的 解析 部分就有大量的正则表达式,如果看不懂这些正则表达式,那看这部分的源码就会非常难,基本可以说是看不懂。

  • 面试

    这个可以说是刚需,只要大家参加过一些面试,相信肯定被问到过不止一次,或是直接问,或通过一个问题来变相考察,当你通过普通代码实现了需求,面试官会问:你能通过正则表达式来实现吗?你瞬间就懵逼了,只能说不好意思,我不会,这时候面试评价就会降低。

所以不论是上面哪种情况,都值得大家花一些时间将正则表达式一次性彻底搞定,毕竟每次需要的时候现查、现学实在是太累、太浪费时间了,也会在一定程度上打击你的积极性。

导读

明白了为什么要学正则表达式,接下来就是花时间来学习了。本文通过 思维导图 + 文字 的形式让你一次性学会正则表达式,既有系统性学习知识的作用,也有字典的作用,所以大家学完可以收藏,将来有需要的时候可以通过文章或思维导图速查相关知识点。

文章主要分为两大部分:

  • 基础知识

    • 字符匹配

    • 位置匹配

    • 括号

  • 实战

    • 解读和书写正则表达式

    • 示例

基础知识

image.png

字符匹配

正则表达式是匹配模式,要不字符匹配,要不位置匹配,就这两种。

精确匹配

image.png

精确匹配很简单,就是匹配特定字符或字符串,比如/abc/就是匹配字符串abc,在react-router中就有精确匹配模式,指的就是匹配特定路由。

模糊匹配

image.png

模糊匹配和精确匹配相对,分为横向模糊匹配和纵向模糊匹配。

横向模糊匹配

比如 /ab{1,3}c/,匹配的字符串中可以含有 1 到 3 个 b,这里的横向可以简单理解为字符串变长了,可以匹配 abc、abbc、abbbc。

纵向模糊匹配

纵向模糊匹配借助了字符组([])的能力来实现,比如/a[123]b/,表示在第二个位置可以有 1、2、3 这三个的任意一个值,简单理解就是字符串长度不变,但是在指定位置可以有多种情况,可视化形式为:

image.png

字符组

image.png

定义

字符组是用[]包裹起来的一组字符,表示指定位置可以是这组字符中的任意一个字符,比如/a[123]b/第二个字符可以是 1,也可以是 2 亦或者是 3,也就是说可以匹配 a1b、a2b、a3b 这三个字符串。

范围表示

字符组可以是枚举形式,比如 /[abcdefg]/,也可以是简写形式/[a-g]/;如果枚举的是一个范围,则一般用简写形式的范围表示方法,常见表示如下:

image.png

排除字符组

如果字符组的第一个字符是^,则表示不匹配字符组中指定的所有字符,比如/[^a-c1-3]/,表示不匹配 a、b、c、1、2、3 这 6 个字符。

常见简写形式

我们在正则表达式中经常会看到一些特殊字符,这些特殊字符叫元字符。

学习正则表达式,元字符一定要记熟,否则你的正则表达式的能力永远停留在现用现查的阶段,或者至少做到看到元字符知道它是什么意思。熟能生巧。

image.png

量词

image.png

定义

量词也称重复,即重复匹配量词前面的表达式,比如横向模糊匹配中的/ab{1,3}c/,表示重复匹配 b 一到三次。

简写形式

常见的简写形式如下图所示,和上面提到的元字符一样,也要熟练使用。

image.png

贪婪 or 惰性匹配

贪婪匹配只要满足匹配条件,会尽可能的匹配多的数据,比如:

const ret = '123 1234 12345 123456'.match(/\d{2,5}/g)
// ['123', '1234', '12345', '12345']
console.log(ret)

惰性匹配则是满足匹配条件,只需匹配最少量的数据就行,比如:

const ret = '123 1234 12345 123456'.match(/\d{2,5}?/g)
// ['12', '12', '34', '12', '34', '12', '34', '56']
console.log(ret)

如果理解有困难,请认真分析和对比两个示例的结果。从示例中可以看到,量词后面加?就可以开启惰性匹配模式。

多选分支

image.png

定义

多选分支需要借助括号来实现,多个子模式满足任意一个就算匹配成功,且会停止匹配,就像运算一样,所以它默认就是惰性匹配模式,使用|(管道符)分隔各个模式。

示例
// 示例一,从 goodbye 中匹配 good
const ret1 = 'goodbye'.match(/good|goodbye/g)
// ['good']
console.log(ret)

// 示例二,从 goodbye 中匹配 goodbye
const ret2 = 'goodbye'.match(/goodbye|good/g)
// ['goodbye']
console.log(ret)

位置匹配

正则表达式是匹配模式,要不字符匹配,要不位置匹配,就这两种。

image.png

开头结尾

  • ^匹配字符串的开头位置,多行模式(m)下匹配每一行的开始位置

  • $匹配字符串的结束位置,多行模式(m)下匹配每一行的换行符(\n)前面的位置

边界

  • \b匹配单词边界,具体描述就是\w\W之间的位置,也包括\w^之间的位置,也包括\w$之间的位置,比如:

    const ret = "[JS] Lesson_01.mp4".replace(/\b/g, '#')
    // "[#JS#] #Lesson_01#.#mp4#"
    console.log(ret)
  • \B非单词边界,即\b之外的位置都是\B能匹配到的位置,比如:

    const ret = "[JS] Lesson_01.mp4".replace(/\B/g, '#')
    // "#[J#S]# L#e#s#s#o#n#_#0#1.m#p#4"
    console.log(ret)

查找

image.png

前向查找
  • /(?=p)/p 是一个子模式,该正则表示取 p 前面的位置,比如:

    const ret = 'hello'.replace(/(?=l)/g, '#')
    // 'he#l#lo
    console.log(ret)
  • /(?!p)//(?=p)/的反义词,匹配非 p 前面的位置,比如:

    const ret = 'hello'.replace(/(?!l)/g, '#')
    // '#h#ell#o'
    console.log(ret)
后向查找
  • /(?<=p)/p是一个子模式,该正则表示取 p 后面的位置,比如:

    const ret = 'hello'.replace(/(?<=l)/g, '#')
    // 'hel#l#o
    console.log(ret)
  • /(?<!p)//(?<=p)/的反义词,匹配非 p 后面的位置,比如:

    const ret = 'hello'.replace(/(?<!l)/g, '#')
    // '#h#e#llo#'
    console.log(ret)

括号

image.png

括号在正则表达式中的作用就是提供分组以便引用分组数据的能力;也可以理解为数学中括号的作用,将一组数据括起来,统一处理。

有两种引用数据的方式:

  • API 引用,比如RegExp.$1-99

  • 正则表达式引用,比如\1\99,当然只能引用前面已经出现的分组,所以又叫反向引用

分组结构

比如/a+/,只能匹配连续的 1 到 n 个 a,如果我要匹配连续的 1 到 n 个 ab 呢?这时候就需要用到括号的分组能力,将 ab 括起来看作一组,然后加上量词(统一处理),比如/(ab)+/

分支结构

分支结构其实就是前面讲到的多选分支,形如/(p1|p2)/,p1 和 p2 是两个正则表达式。比如/My name is (abc|cdef)/g 可以匹配 'My name is abc' 和 'My name is cdef'。

引用分组

有两种引用分组数据的方式,API 引用和反向引用。

使用 API 引用分组数据

这里以 Javascript 为例

  • match

    const reg = /(\d{4})-(\d{2})-(\d{2})/
    const str = "2021-10-04"
    // ['2021-10-04', '2021', '10', '04', index: 0, input: '2021-10-04', groups: undefined]
    console.log(str.match(reg))
  • exec

    const reg = /(\d{4})-(\d{2})-(\d{2})/
    const str = "2021-10-04"
    // ['2021-10-04', '2021', '10', '04', index: 0, input: '2021-10-04', groups: undefined]
    console.log(reg.exec(str))
  • $1~$99

    const reg = /(\d{4})-(\d{2})-(\d{2})/
    const str = "2021-10-04"
    // 正则操作都行,比如 match、exec、test
    reg.test(str)
    // 2021
    console.log(RegExp.$1)
    // 10
    console.log(RegExp.$2)
    // 04
    console.log(RegExp.$3)
    const reg = /(\d{4})-(\d{2})-(\d{2})/g
    // 2021-10-04 => 2021/10/04
    const ret = '2021-10-04'.replace(reg, '$1/$2/$3')
    // 2021/10/04
    console.log(ret)
反向引用

在正则表达式中直接引用前面出现的分组。

注意:如果反向引用的分组不存在,比如 /(\d)\2/,这里的 \2 会被转义然后匹配转义后的字符

编写一个正则表达式可同时匹配如下字符串:2021-10-042021/10/042021.10.04

// 注意其中的 \1,它就是反向引用,在第二个分隔符位置通过反向引用来使用第一个分隔符
const reg = /\d{4}(-|\/|\.)\d{2}\1\d{2}/
// true
console.log(reg.test('2021-10-04'))
// true
console.log(reg.test('2021/10/04'))
// true
console.log(reg.test('2021.10.04'))

非捕获分组

之前的使用方式都会捕获匹配到的分组数据,并在之后使用,所以也叫捕获型分组。

假如只想使用括号的原始功能 —— 分组,并不想在 API 或者正则的反向引用中使用捕获到的数据,则可以使用非捕获分组。

非捕获分组的优点是提高性能、减少内存占用。

语法/(?:p)/,比如:/(?:ab)+/g

const reg = /(?:ab)+/g
reg.test('abc')
// 空
console.log(RegExp.$1)
// $1c,分组不存在,$1 被原样匹配了
console.log('abc'.replace(reg, '$1'))

实战

任何知识只学不练,没任何意义,所以接下来就进入实战部分。

衡量对知识的掌握程度有两个角度:读和写。不仅要能读懂已有的解决方案,需要的时候也能写出自己的解决方案。正则表达式也是一样,要能看懂别人写的正则,需要的时候自己也能写出合理的正则表达式。

解读正则表达式

解读正则表达式和解读数学中的算数表达式很相似,都需要根据操作符的优先级将表达式分成一个个的独立块。

正则表达式的操作符都体现在结构中,即由特殊字符和普通字符所代表的一个个特殊整体。根据基础知识部分我们知道正则表达式有:字符字面量、字符组、量词、锚字符(位置)、分组、选择分支、反向引用(\1-99)这 7 种结构。

其中涉及的操作符有(优先级从上到下由高到低):

  • 转义符\

  • 括号和方括号(...)(?:...)(?=...)(?!...)[...]

  • 量词{m}{m,n}{m,}?*+

  • 位置和序列^$\元字符一般字符

  • 分支结构|(管道符)

/ab?(c|de*)+fg/

接下来我们分析一个正则表达式,作为实战

  • 阅读正则,根据操作符优先级,发现有括号,所以 (c|de*) 是一个整体

    • (c|de*)中,有量词*,所以e*是一个整体

    • 又由于管道符|的优先级最低,所以cd各为一个整体

  • 正则表达式就变成了这样:ab?(整体)+fg,经分析,发现这些字符中量词优先级最高,所以b?(整体)+分别各为一个整体

  • 剩下的普通字符afg各为整体

可视化表示方式

image.png

所以这个正则表达式的意思就是:匹配a (0或1个b) (c或(d 任意多个e))1到n个 fg,阅读时请忽略其中的空格,空格只是为了方便阅读理解。

比如该表达式可以匹配:abcfg、abdefg、acfg、adefg、adfg、accfg、adedefg 等。

所以阅读一个正则表达式最重要的就是要将一个复杂的正则表达式根据操作符优先级将其拆成一个个简单的整体

书写正则表达式

上面讲了“读”,接下来讲“写”。针对一个正则问题,如果构建一个合理的正则表达式呢?要遵循如下准则:

  • 前提

    • 能否使用正则:有些字符串根本无法用正则匹配

    • 是否有必要使用正则:能用字符串 API 解决的问题,就别用正则了,比如字符搜索

    • 是否有必要构建一个复杂的正则:比如常见的密码复杂度要求,长度 6-12 位,并且有数字、小写字母、大写字母组成,而且至少包括 2 种字符,这个问题如果用一个正则表达式来写是非常复杂的,但是如果将其拆成多个小的正则并辅以一定的代码来实现就很简单了

  • 平衡法则

    • 准确性,匹配预期字符,不匹配非预期字符

    • 可读性和可维护性

    • 效率

      正则匹配的过程为:

      1. 编译
      2. 设定起始位置
      3. 尝试匹配
      4. 匹配失败的话,从下一位开始继续第 3 步,这里会有一个回溯的过程
      5. 最终结果,匹配成功或失败
      • 使用具体的字符或字符组代替通配符.,来消除回溯

      • 如果不使用分组引用和反向引用时,使用非捕获型分组,因为捕获的分组数据需要额外的内存来存储

      • 提取分支公共部分,比如/^abc|^def/可以修改为/^(?:abc|def)/

      • 减少分支数量,比如/red|read/可以修改为/rea?d/

      会发现有些优化之后可读性会下降,所以这里就需要有个权衡

上述法则都是八股文,我觉得要写一个正则的关键在于分析、读懂需求,如果连需求都看不明白,又何谈写呢?更别说优化了,而且日常中需要优化的正则场景很少会碰见,除非说你写的正则,明显拖慢了程序的运行速度。

验证给定字符串是否为合法的固定电话

需求分析

常见的固定电话有如下格式:

  • 三位区号

    • 七位电话号码

      • 0108888888

      • 010-8888888

      • (010)8888888

    • 八位电话号码

      • 01088888888

      • 010-88888888

      • (010)88888888

  • 四位区号

    • 七位电话号码

      • 05518888888

      • 0551-8888888

      • (0551)8888888

    • 八位电话号码

      • 055188888888

      • 0551-88888888

      • (0551)88888888

上面列出了所有固定电话的格式(不考虑分机号和+86的情形)。经分析发现该需求可以用正则实现,而且也很有必要,因为单用字符串 API 匹配的话代码太过复杂了。

  • 经分析,发现区域以 0 开头,后面跟两位或三位数字,所以:/^0\d{2,3}/

  • 后面的电话则是以 7 位或 8 位数字结尾,所以:/\d{7,8}$/

  • 接下来将上面两个正则根据多种情况组合起来,这时候不要考虑平衡法则,先写出来再说

    • 区号和电话之间没有字符,比如: 0108888888,正则为:/^0\d{2,3}\d{7,8}$/

    • 区号和电话之间是连字符,比如:010-8888888,正则为:/^0\d{2,3}-\d{7,8}$/

    • 区号用括号括起来,比如:(010)8888888,正则位:/^\(0\d{2,3}\)\d{7,8}$/

    • 组合,三者之间为或的关系,所以用分支结构:

      /(^0\d{2,3}\d{7,8}$|^0\d{2,3}-\d{7,8}$|^\(0\d{2,3}\)\d{7,8}$)/

    • 可视化图

      image.png

  • 验证表达式是否正确

    const reg = /(^0\d{2,3}\d{7,8}$|^0\d{2,3}-\d{7,8}$|^\(0\d{2,3}\)\d{7,8}$)/
    // true
    console.log(reg.test('0108888888'))
    // true
    console.log(reg.test('01088888888'))
    // true
    console.log(reg.test('010-8888888'))
    // true
    console.log(reg.test('010-88888888'))
    // true
    console.log(reg.test('(010)8888888'))
    // true
    console.log(reg.test('(010)88888888'))
    // true
    console.log(reg.test('05518888888'))
    // true
    console.log(reg.test('055188888888'))
    // true
    console.log(reg.test('0551-8888888'))
    // true
    console.log(reg.test('0551-88888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // false
    console.log(reg.test('(010)8888'))
    // false
    console.log(reg.test('(0108888)'))
    // false
    console.log(reg.test('010-8888'))
  • 验证通过,说明正则写的没问题,接下来对正则表达式进行优化

    提取公共部分,减少冗余。分析会发现

    • 区号有两种情况:

      • 不带括号的🈶️可能会有-
      • 带括号的没有-
    • 区号后面的电话就一种情况,所以经过优化,上面很啰嗦的正则就变成了如下正则:

    • /^(0\d{2,3}-?|\(0\d{2,3}\))\d{7,8}$/

    • 优化后可视化图,看图说话,效果不是一般的好

      image.png

  • 验证优化之后的正则,防止优化出错

    const reg = /^(0\d{2,3}-?|\(0\d{2,3}\))\d{7,8}$/
    // true
    console.log(reg.test('0108888888'))
    // true
    console.log(reg.test('01088888888'))
    // true
    console.log(reg.test('010-8888888'))
    // true
    console.log(reg.test('010-88888888'))
    // true
    console.log(reg.test('(010)8888888'))
    // true
    console.log(reg.test('(010)88888888'))
    // true
    console.log(reg.test('05518888888'))
    // true
    console.log(reg.test('055188888888'))
    // true
    console.log(reg.test('0551-8888888'))
    // true
    console.log(reg.test('0551-88888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // true
    console.log(reg.test('(0551)8888888'))
    // false
    console.log(reg.test('(010)8888'))
    // false
    console.log(reg.test('(0108888)'))
    // false
    console.log(reg.test('010-8888'))
  • 验证通过,这个需求就完成了

这就是写一个正则表达式的过程,会发现需求分析阶段非常重要,这跟我们做项目一样,需求分析是第一阶段,只有这个阶段做好了,后面的过程才会顺,切记不要想着一步到位(看到问题直接就想答案,头疼)。

总结

到这里所有内容就结束了。正则表达式有太多的抽象符号,让人难以记忆,所以很多程序员的正则能力一直停留在现用现查的阶段,始终得不到精进,互联网上的资料也是五花八门。

本文也是自己学习正则的一个记录,通过思维导图 + 文章的形式加深理解和记忆,将来也可以作为一个字典,有哪个地方忘了可以快速查询。

如果文章有帮助到大家,欢迎点赞、收藏、关注。

本文内容参考自 JS 正则迷你书

思维导图,欢迎点赞


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注、 点赞、收藏和评论。

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

手写 Vue 系列 之 从 Vue1 升级到 Vue2

手写 Vue 系列 之 从 Vue1 升级到 Vue2

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 手写 Vue 系列 之 Vue1.x 带大家从零开始实现了 Vue1 的核心原理,包括如下功能:

  • 数据响应式拦截

    • 普通对象

    • 数组

  • 数据响应式更新

    • 依赖收集

      • Dep

      • Watcher

    • 编译器

      • 文本节点

      • v-on:click

      • v-bind

      • v-model

在最后也详细讲解了 Vue1 的诞生以及存在的问题:Vue1.x 在中小型系统中性能会很好,定向更新 DOM 节点,但是大型系统由于 Watcher 太多,导致资源占用过多,性能下降。于是 Vue2 中通过引入 VNode 和 Diff 的来解决这个问题,

所以接下来的系列内容就是升级上一篇文章编写的 lyn-vue 框架,将它从 Vue1 升级到 Vue2。所以建议整个系列大家按顺序去阅读学习,如若强行阅读,可能会产生云里雾里的感觉,事倍功半。

另外欢迎 关注 以防迷路,同时系列文章都会收录到 精通 Vue 技术栈的源码原理 专栏,也欢迎关注该专栏。

目标

升级后的框架需要将如下示例代码跑起来

示例

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Lyn Vue2.0</title>
</head>

<body>
  <div id="app">
    <h3>数据响应式更新 原理</h3>
    <div>{{ t }}</div>
    <div>{{ t1 }}</div>
    <div>{{ arr }}</div>
    <h3>methods + computed + 异步更新队列 原理</h3>
    <div>
      <p>{{ counter }}</p>
      <div>{{ doubleCounter }}</div>
      <div>{{ doubleCounter }}</div>
      <div>{{ doubleCounter }}</div>
      <button v-on:click="handleAdd"> Add </button>
      <button v-on:click="handleMinus"> Minus </button>
    </div>
    <h3>v-bind</h3>
    <span v-bind:title="title">右键审查元素查看我的 title 属性</span>
    <h3>v-model 原理</h3>
    <div>
      <input type="text" v-model="inputVal" />
      <div>{{ inputVal }}</div>
    </div>
    <div>
      <input type="checkbox" v-model="isChecked" />
      <div>{{ isChecked }}</div>
    </div>
    <div>
      <select v-model="selectValue">
        <option value="1">1</option>
        <option value="2">2</option>
        <option value="3">3</option>
      </select>
      <div>{{ selectValue }}</div>
    </div>
    <h3>组件 原理</h3>
    <comp></comp>
    <h3>插槽 原理</h3>
    <scope-slot></scope-slot>
    <scope-slot>
      <template v-slot:default="scopeSlot">
        <div>{{ scopeSlot }}</div>
      </template>
    </scope-slot>
  </div>
  <script type="module">
    import Vue from './src/index.js'
    const ins = new Vue({
      el: '#app',
      data() {
        return {
          // 原始值和对象的响应式原理
          t: 't value',
          t1: {
            tt1: 'tt1 value'
          },
          // 数组的响应式原理
          arr: [1, 2, 3],
          // 响应式更新
          counter: 0,
          // v-bind
          title: "I am title",
          // v-model
          inputVal: 'test',
          isChecked: true,
          selectValue: 2,
        }
      },
      // methods + 事件 + 数据响应式更新 原理
      methods: {
        handleAdd() {
          this.counter++
        },
        handleMinus() {
          this.counter--
        }
      },
      // computed + 异步更新队列 的原理
      computed: {
        doubleCounter() {
          console.log('evalute doubleCounter')
          return this.counter * 2
        }
      },
      // 组件
      components: {
        // 子组件
        'comp': {
          template: `
            <div>
              <p>{{ compCounter }}</p>
              <p>{{ doubleCompCounter }}</p>
              <p>{{ doubleCompCounter }}</p>
              <p>{{ doubleCompCounter }}</p>
              <button v-on:click="handleCompAdd"> comp add </button>
              <button v-on:click="handleCompMinus"> comp minus </button>
            </div>`,
          data() {
            return {
              compCounter: 0
            }
          },
          methods: {
            handleCompAdd() {
              this.compCounter++
            },
            handleCompMinus() {
              this.compCounter--
            }
          },
          computed: {
            doubleCompCounter() {
              console.log('evalute doubleCompCounter')
              return this.compCounter * 2
            }
          }
        },
        // 插槽
        'scope-slot': {
          template: `
            <div>
              <slot name="default" v-bind:slotKey="slotKey">{{ slotKey }}</slot>
            </div>
          `,
          data() {
            return {
              slotKey: 'scope slot content'
            }
          }
        }
      }
    })
    // 数据响应式拦截
    setTimeout(() => {
      console.log('********** 属性值为原始值时的 getter、setter ************')
      console.log(ins.t)
      ins.t = 'change t value'
      console.log(ins.t)
    }, 1000)

    setTimeout(() => {
      console.log('********** 属性的新值为对象的情况 ************')
      ins.t = {
        tt: 'tt value'
      }
      console.log(ins.t.tt)
    }, 2000)

    setTimeout(() => {
      console.log('********** 验证对深层属性的 getter、setter 拦截 ************')
      ins.t1.tt1 = 'change tt1 value'
      console.log(ins.t1.tt1)
    }, 3000)

    setTimeout(() => {
      console.log('********** 将值为对象的属性更新为原始值 ************')
      console.log(ins.t1)
      ins.t1 = 't1 value'
      console.log(ins.t1)
    }, 4000)

    setTimeout(() => {
      console.log('********** 数组操作方法的拦截 ************')
      console.log(ins.arr)
      ins.arr.push(4)
      console.log(ins.arr)
    }, 5000)
  </script>
</body>

</html>

知识点

示例代码涉及的知识点包括:

  • 基于模版解析的编译器

    • 解析模版得到 AST

    • 基于 AST 生成渲染函数

    • render helper

      • _c,创建指定标签的 VNode

      • _v,创建文本节点的 VNode

      • _t,创建插槽节点的 VNode

    • VNode

  • patch

    • 原生标签和组件的初始渲染

      • v-model

      • v-bind

      • v-on

    • diff

  • 插槽原理

  • computed

  • 异步更新队列

效果

示例代码最终的运行效果如下:

Jun-13-2021 14-12-43.gif

说明

该框架只为讲解 Vue 的核心原理,没有什么健壮性可言,说不定你换个示例代码可能就会报错、跑不起来,但是用来学习是完全足够了,基本上把 Vue 的核心原理(知识点)都实现了一遍。

所以接下来就开始正式的学习之旅吧,加油!!

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(10)—— 编译器 之 生成渲染函数

Vue 源码解读(10)—— 编译器 之 生成渲染函数

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

这篇文章是 Vue 编译器的最后一部分,前两部分分别是:Vue 源码解读(8)—— 编译器 之 解析Vue 源码解读(9)—— 编译器 之 优化

从 HTML 模版字符串开始,解析所有标签以及标签上的各个属性,得到 AST 语法树,然后基于 AST 语法树进行静态标记,首先标记每个节点是否为静态静态,然后进一步标记出静态根节点。这样在后续的更新中就可以跳过这些静态根节点的更新,从而提高性能。

这最后一部分讲的是如何从 AST 生成渲染函数。

目标

深入理解渲染函数的生成过程,理解编译器是如何将 AST 变成运行时的代码,也就是我们写的类 html 模版最终变成了什么?

源码解读

入口

/src/compiler/index.js

/**
 * 在这之前做的所有的事情,只有一个目的,就是为了构建平台特有的编译选项(options),比如 web 平台
 * 
 * 1、将 html 模版解析成 ast
 * 2、对 ast 树进行静态标记
 * 3、将 ast 生成渲染函数
 *    静态渲染函数放到  code.staticRenderFns 数组中
 *    code.render 为动态渲染函数
 *    在将来渲染时执行渲染函数得到 vnode
 */
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 将模版解析为 AST,每个节点的 ast 对象上都设置了元素的所有信息,比如,标签信息、属性信息、插槽信息、父节点、子节点等。
  // 具体有那些属性,查看 options.start 和 options.end 这两个处理开始和结束标签的方法
  const ast = parse(template.trim(), options)
  // 优化,遍历 AST,为每个节点做静态标记
  // 标记每个节点是否为静态节点,然后进一步标记出静态根节点
  // 这样在后续更新中就可以跳过这些静态节点了
  // 标记静态根,用于生成渲染函数阶段,生成静态根节点的渲染函数
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  // 代码生成,将 ast 转换成可执行的 render 函数的字符串形式
  // code = {
  //   render: `with(this){return ${_c(tag, data, children, normalizationType)}}`,
  //   staticRenderFns: [_c(tag, data, children, normalizationType), ...]
  // }
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

generate

/src/compiler/codegen/index.js

/**
 * 从 AST 生成渲染函数
 * @returns {
 *   render: `with(this){return _c(tag, data, children)}`,
 *   staticRenderFns: state.staticRenderFns
 * } 
 */
export function generate(
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  // 实例化 CodegenState 对象,生成代码的时候需要用到其中的一些东西
  const state = new CodegenState(options)
  // 生成字符串格式的代码,比如:'_c(tag, data, children, normalizationType)'
  // data 为节点上的属性组成 JSON 字符串,比如 '{ key: xx, ref: xx, ... }'
  // children 为所有子节点的字符串格式的代码组成的字符串数组,格式:
  //     `['_c(tag, data, children)', ...],normalizationType`,
  //     最后的 normalization 是 _c 的第四个参数,
  //     表示节点的规范化类型,不是重点,不需要关注
  // 当然 code 并不一定就是 _c,也有可能是其它的,比如整个组件都是静态的,则结果就为 _m(0)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

genElement

/src/compiler/codegen/index.js

阅读建议

先读最后的 else 模块生成 code 的语句部分,即处理自定义组件和原生标签的 else 分支,理解最终生成的数据格式是什么样的;然后再回头阅读 genChildrengenData,先读 genChildren,代码量少,彻底理解最终生成的数据结构,最后再从上到下去阅读其它的分支。

在阅读以下代码时,请把 Vue 源码解读(8)—— 编译器 之 解析(下) 最后得到的 AST 对象放旁边辅助阅读,因为生成渲染函数的过程就是在处理该对象上众多的属性的过程。

export function genElement(el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre
  }

  if (el.staticRoot && !el.staticProcessed) {
    /**
     * 处理静态根节点,生成节点的渲染函数
     *   1、将当前静态节点的渲染函数放到 staticRenderFns 数组中
     *   2、返回一个可执行函数 _m(idx, true or '') 
     */
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    /**
     * 处理带有 v-once 指令的节点,结果会有三种:
     *   1、当前节点存在 v-if 指令,得到一个三元表达式,condition ? render1 : render2
     *   2、当前节点是一个包含在 v-for 指令内部的静态节点,得到 `_o(_c(tag, data, children), number, key)`
     *   3、当前节点就是一个单纯的 v-once 节点,得到 `_m(idx, true of '')`
     */
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) {
    /**
     * 处理节点上的 v-for 指令  
     * 得到 `_l(exp, function(alias, iterator1, iterator2){return _c(tag, data, children)})`
     */
    return genFor(el, state)
  } else if (el.if && !el.ifProcessed) {
    /**
     * 处理带有 v-if 指令的节点,最终得到一个三元表达式:condition ? render1 : render2
     */
    return genIf(el, state)
  } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
    /**
     * 当前节点不是 template 标签也不是插槽和带有 v-pre 指令的节点时走这里
     * 生成所有子节点的渲染函数,返回一个数组,格式如:
     * [_c(tag, data, children, normalizationType), ...] 
     */
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    /**
     * 生成插槽的渲染函数,得到
     * _t(slotName, children, attrs, bind)
     */
    return genSlot(el, state)
  } else {
    // component or element
    // 处理动态组件和普通元素(自定义组件、原生标签)
    let code
    if (el.component) {
      /**
       * 处理动态组件,生成动态组件的渲染函数
       * 得到 `_c(compName, data, children)`
       */
      code = genComponent(el.component, el, state)
    } else {
      // 自定义组件和原生标签走这里
      let data
      if (!el.plain || (el.pre && state.maybeComponent(el))) {
        // 非普通元素或者带有 v-pre 指令的组件走这里,处理节点的所有属性,返回一个 JSON 字符串,
        // 比如 '{ key: xx, ref: xx, ... }'
        data = genData(el, state)
      }

      // 处理子节点,得到所有子节点字符串格式的代码组成的数组,格式:
      // `['_c(tag, data, children)', ...],normalizationType`,
      // 最后的 normalization 表示节点的规范化类型,不是重点,不需要关注
      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      // 得到最终的字符串格式的代码,格式:
      // '_c(tag, data, children, normalizationType)'
      code = `_c('${el.tag}'${data ? `,${data}` : '' // data
        }${children ? `,${children}` : '' // children
        })`
    }
    // 如果提供了 transformCode 方法, 
    // 则最终的 code 会经过各个模块(module)的该方法处理,
    // 不过框架没提供这个方法,不过即使处理了,最终的格式也是 _c(tag, data, children)
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}

genChildren

/src/compiler/codegen/index.js

/**
 * 生成所有子节点的渲染函数,返回一个数组,格式如:
 * [_c(tag, data, children, normalizationType), ...] 
 */
export function genChildren(
  el: ASTElement,
  state: CodegenState,
  checkSkip?: boolean,
  altGenElement?: Function,
  altGenNode?: Function
): string | void {
  // 所有子节点
  const children = el.children
  if (children.length) {
    // 第一个子节点
    const el: any = children[0]
    // optimize single v-for
    if (children.length === 1 &&
      el.for &&
      el.tag !== 'template' &&
      el.tag !== 'slot'
    ) {
      // 优化,只有一个子节点 && 子节点的上有 v-for 指令 && 子节点的标签不为 template 或者 slot
      // 优化的方式是直接调用 genElement 生成该节点的渲染函数,不需要走下面的循环然后调用 genCode 最后得到渲染函数
      const normalizationType = checkSkip
        ? state.maybeComponent(el) ? `,1` : `,0`
        : ``
      return `${(altGenElement || genElement)(el, state)}${normalizationType}`
    }
    // 获取节点规范化类型,返回一个 number 0、1、2,不是重点, 不重要
    const normalizationType = checkSkip
      ? getNormalizationType(children, state.maybeComponent)
      : 0
    // 函数,生成代码的一个函数
    const gen = altGenNode || genNode
    // 返回一个数组,数组的每个元素都是一个子节点的渲染函数,
    // 格式:['_c(tag, data, children, normalizationType)', ...]
    return `[${children.map(c => gen(c, state)).join(',')}]${normalizationType ? `,${normalizationType}` : ''
      }`
  }
}

genNode

/src/compiler/codegen/index.js

function genNode(node: ASTNode, state: CodegenState): string {
  if (node.type === 1) {
    return genElement(node, state)
  } else if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {
    return genText(node)
  }
}

genText

/src/compiler/codegen/index.js

export function genText(text: ASTText | ASTExpression): string {
  return `_v(${text.type === 2
    ? text.expression // no need for () because already wrapped in _s()
    : transformSpecialNewlines(JSON.stringify(text.text))
    })`
}

genComment

/src/compiler/codegen/index.js

export function genComment(comment: ASTText): string {
  return `_e(${JSON.stringify(comment.text)})`
}

genData

/src/compiler/codegen/index.js

/**
 * 处理节点上的众多属性,最后生成这些属性组成的 JSON 字符串,比如 data = { key: xx, ref: xx, ... } 
 */
export function genData(el: ASTElement, state: CodegenState): string {
  // 节点的属性组成的 JSON 字符串
  let data = '{'

  // 首先先处理指令,因为指令可能在生成其它属性之前改变这些属性
  // 执行指令编译方法,比如 web 平台的 v-text、v-html、v-model,然后在 el 对象上添加相应的属性,
  // 比如 v-text: el.textContent = _s(value, dir)
  //     v-html:el.innerHTML = _s(value, dir)
  // 当指令在运行时还有任务时,比如 v-model,则返回 directives: [{ name, rawName, value, arg, modifiers }, ...}] 
  // directives first.
  // directives may mutate the el's other properties before they are generated.
  const dirs = genDirectives(el, state)
  if (dirs) data += dirs + ','

  // key,data = { key: xx }
  if (el.key) {
    data += `key:${el.key},`
  }
  // ref,data = { ref: xx }
  if (el.ref) {
    data += `ref:${el.ref},`
  }
  // 带有 ref 属性的节点在带有 v-for 指令的节点的内部, data = { refInFor: true }
  if (el.refInFor) {
    data += `refInFor:true,`
  }
  // pre,v-pre 指令,data = { pre: true }
  if (el.pre) {
    data += `pre:true,`
  }
  // 动态组件,data = { tag: 'component' }
  // record original tag name for components using "is" attribute
  if (el.component) {
    data += `tag:"${el.tag}",`
  }
  // 为节点执行模块(class、style)的 genData 方法,
  // 得到 data = { staticClass: xx, class: xx, staticStyle: xx, style: xx }
  // module data generation functions
  for (let i = 0; i < state.dataGenFns.length; i++) {
    data += state.dataGenFns[i](el)
  }
  // 其它属性,得到 data = { attrs: 静态属性字符串 } 或者 
  // data = { attrs: '_d(静态属性字符串, 动态属性字符串)' }
  // attributes
  if (el.attrs) {
    data += `attrs:${genProps(el.attrs)},`
  }
  // DOM props,结果同 el.attrs
  if (el.props) {
    data += `domProps:${genProps(el.props)},`
  }
  // 自定义事件,data = { `on${eventName}:handleCode` } 或者 { `on_d(${eventName}:handleCode`, `${eventName},handleCode`) }
  // event handlers
  if (el.events) {
    data += `${genHandlers(el.events, false)},`
  }
  // 带 .native 修饰符的事件,
  // data = { `nativeOn${eventName}:handleCode` } 或者 { `nativeOn_d(${eventName}:handleCode`, `${eventName},handleCode`) }
  if (el.nativeEvents) {
    data += `${genHandlers(el.nativeEvents, true)},`
  }
  // 非作用域插槽,得到 data = { slot: slotName }
  // slot target
  // only for non-scoped slots
  if (el.slotTarget && !el.slotScope) {
    data += `slot:${el.slotTarget},`
  }
  // scoped slots,作用域插槽,data = { scopedSlots: '_u(xxx)' }
  if (el.scopedSlots) {
    data += `${genScopedSlots(el, el.scopedSlots, state)},`
  }
  // 处理 v-model 属性,得到
  // data = { model: { value, callback, expression } }
  // component v-model
  if (el.model) {
    data += `model:{value:${el.model.value
      },callback:${el.model.callback
      },expression:${el.model.expression
      }},`
  }
  // inline-template,处理内联模版,得到
  // data = { inlineTemplate: { render: function() { render 函数 }, staticRenderFns: [ function() {}, ... ] } }
  if (el.inlineTemplate) {
    const inlineTemplate = genInlineTemplate(el, state)
    if (inlineTemplate) {
      data += `${inlineTemplate},`
    }
  }
  // 删掉 JSON 字符串最后的 逗号,然后加上闭合括号 }
  data = data.replace(/,$/, '') + '}'
  // v-bind dynamic argument wrap
  // v-bind with dynamic arguments must be applied using the same v-bind object
  // merge helper so that class/style/mustUseProp attrs are handled correctly.
  if (el.dynamicAttrs) {
    // 存在动态属性,data = `_b(data, tag, 静态属性字符串或者_d(静态属性字符串, 动态属性字符串))`
    data = `_b(${data},"${el.tag}",${genProps(el.dynamicAttrs)})`
  }
  // v-bind data wrap
  if (el.wrapData) {
    data = el.wrapData(data)
  }
  // v-on data wrap
  if (el.wrapListeners) {
    data = el.wrapListeners(data)
  }
  return data
}

genDirectives

/src/compiler/codegen/index.js

阅读建议:这部分内容也可以放到其它方法后面去读,比如你想深究 v-model 的实现原理

/**
 * 运行指令的编译方法,如果指令存在运行时任务,则返回 directives: [{ name, rawName, value, arg, modifiers }, ...}] 
 */
function genDirectives(el: ASTElement, state: CodegenState): string | void {
  // 获取指令数组
  const dirs = el.directives
  // 没有指令则直接结束
  if (!dirs) return
  // 指令的处理结果
  let res = 'directives:['
  // 标记,用于标记指令是否需要在运行时完成的任务,比如 v-model 的 input 事件
  let hasRuntime = false
  let i, l, dir, needRuntime
  // 遍历指令数组
  for (i = 0, l = dirs.length; i < l; i++) {
    dir = dirs[i]
    needRuntime = true
    // 获取节点当前指令的处理方法,比如 web 平台的 v-html、v-text、v-model
    const gen: DirectiveFunction = state.directives[dir.name]
    if (gen) {
      // 执行指令的编译方法,如果指令还需要运行时完成一部分任务,则返回 true,比如 v-model
      // compile-time directive that manipulates AST.
      // returns true if it also needs a runtime counterpart.
      needRuntime = !!gen(el, dir, state.warn)
    }
    if (needRuntime) {
      // 表示该指令在运行时还有任务
      hasRuntime = true
      // res = directives:[{ name, rawName, value, arg, modifiers }, ...]
      res += `{name:"${dir.name}",rawName:"${dir.rawName}"${dir.value ? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}` : ''
        }${dir.arg ? `,arg:${dir.isDynamicArg ? dir.arg : `"${dir.arg}"`}` : ''
        }${dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ''
        }},`
    }
  }
  if (hasRuntime) {
    // 也就是说,只有指令存在运行时任务时,才会返回 res
    return res.slice(0, -1) + ']'
  }
}

genProps

/src/compiler/codegen/index.js

/**
 * 遍历属性数组 props,得到所有属性组成的字符串
 * 如果不存在动态属性,则返回:
 *   'attrName,attrVal,...'
 * 如果存在动态属性,则返回:
 *   '_d(静态属性字符串, 动态属性字符串)' 
 */
function genProps(props: Array<ASTAttr>): string {
  // 静态属性
  let staticProps = ``
  // 动态属性
  let dynamicProps = ``
  // 遍历属性数组
  for (let i = 0; i < props.length; i++) {
    // 属性
    const prop = props[i]
    // 属性值
    const value = __WEEX__
      ? generateValue(prop.value)
      : transformSpecialNewlines(prop.value)
    if (prop.dynamic) {
      // 动态属性,`dAttrName,dAttrVal,...`
      dynamicProps += `${prop.name},${value},`
    } else {
      // 静态属性,'attrName,attrVal,...'
      staticProps += `"${prop.name}":${value},`
    }
  }
  // 去掉静态属性最后的逗号
  staticProps = `{${staticProps.slice(0, -1)}}`
  if (dynamicProps) {
    // 如果存在动态属性则返回:
    // _d(静态属性字符串,动态属性字符串)
    return `_d(${staticProps},[${dynamicProps.slice(0, -1)}])`
  } else {
    // 说明属性数组中不存在动态属性,直接返回静态属性字符串
    return staticProps
  }
}

genHandlers

/src/compiler/codegen/events.js

/**
 * 生成自定义事件的代码
 * 动态:'nativeOn|on_d(staticHandlers, [dynamicHandlers])'
 * 静态:`nativeOn|on${staticHandlers}`
 */
 export function genHandlers (
  events: ASTElementHandlers,
  isNative: boolean
): string {
  // 原生:nativeOn,否则为 on
  const prefix = isNative ? 'nativeOn:' : 'on:'
  // 静态
  let staticHandlers = ``
  // 动态
  let dynamicHandlers = ``
  // 遍历 events 数组
  // events = [{ name: { value: 回调函数名, ... } }]
  for (const name in events) {
    // 获取指定事件的回调函数名,即 this.methodName 或者 [this.methodName1, ...]
    const handlerCode = genHandler(events[name])
    if (events[name] && events[name].dynamic) {
      // 动态,dynamicHandles = `eventName,handleCode,...,`
      dynamicHandlers += `${name},${handlerCode},`
    } else {
      // 静态,staticHandles = `"eventName":handleCode,`
      staticHandlers += `"${name}":${handlerCode},`
    }
  }
  // 去掉末尾的逗号
  staticHandlers = `{${staticHandlers.slice(0, -1)}}`
  if (dynamicHandlers) {
    // 动态,on_d(statickHandles, [dynamicHandlers])
    return prefix + `_d(${staticHandlers},[${dynamicHandlers.slice(0, -1)}])`
  } else {
    // 静态,`on${staticHandlers}`
    return prefix + staticHandlers
  }
}

genStatic

/src/compiler/codegen/index.js

/**
 * 生成静态节点的渲染函数
 *   1、将当前静态节点的渲染函数放到 staticRenderFns 数组中
 *   2、返回一个可执行函数 _m(idx, true or '') 
 */
// hoist static sub-trees out
function genStatic(el: ASTElement, state: CodegenState): string {
  // 标记当前静态节点已经被处理过了
  el.staticProcessed = true
  // Some elements (templates) need to behave differently inside of a v-pre
  // node.  All pre nodes are static roots, so we can use this as a location to
  // wrap a state change and reset it upon exiting the pre node.
  const originalPreState = state.pre
  if (el.pre) {
    state.pre = el.pre
  }
  // 将静态根节点的渲染函数 push 到 staticRenderFns 数组中,比如:
  // [`with(this){return _c(tag, data, children)}`]
  state.staticRenderFns.push(`with(this){return ${genElement(el, state)}}`)
  state.pre = originalPreState
  // 返回一个可执行函数:_m(idx, true or '')
  // idx = 当前静态节点的渲染函数在 staticRenderFns 数组中下标
  return `_m(${state.staticRenderFns.length - 1
    }${el.staticInFor ? ',true' : ''
    })`
}

genOnce

/src/compiler/codegen/index.js

/**
 * 处理带有 v-once 指令的节点,结果会有三种:
 *   1、当前节点存在 v-if 指令,得到一个三元表达式,condition ? render1 : render2
 *   2、当前节点是一个包含在 v-for 指令内部的静态节点,得到 `_o(_c(tag, data, children), number, key)`
 *   3、当前节点就是一个单纯的 v-once 节点,得到 `_m(idx, true of '')`
 */
function genOnce(el: ASTElement, state: CodegenState): string {
  // 标记当前节点的 v-once 指令已经被处理过了
  el.onceProcessed = true
  if (el.if && !el.ifProcessed) {
    // 如果含有 v-if 指令 && if 指令没有被处理过,则走这里
    // 处理带有 v-if 指令的节点,最终得到一个三元表达式,condition ? render1 : render2 
    return genIf(el, state)
  } else if (el.staticInFor) {
    // 说明当前节点是被包裹在还有 v-for 指令节点内部的静态节点
    // 获取 v-for 指令的 key
    let key = ''
    let parent = el.parent
    while (parent) {
      if (parent.for) {
        key = parent.key
        break
      }
      parent = parent.parent
    }
    // key 不存在则给出提示,v-once 节点只能用于带有 key 的 v-for 节点内部
    if (!key) {
      process.env.NODE_ENV !== 'production' && state.warn(
        `v-once can only be used inside v-for that is keyed. `,
        el.rawAttrsMap['v-once']
      )
      return genElement(el, state)
    }
    // 生成 `_o(_c(tag, data, children), number, key)`
    return `_o(${genElement(el, state)},${state.onceId++},${key})`
  } else {
    // 上面几种情况都不符合,说明就是一个简单的静态节点,和处理静态根节点时的操作一样,
    // 得到 _m(idx, true or '')
    return genStatic(el, state)
  }
}

genFor

/src/compiler/codegen/index.js

/**
 * 处理节点上的 v-for 指令  
 * 得到 `_l(exp, function(alias, iterator1, iterator2){return _c(tag, data, children)})`
 */
export function genFor(
  el: any,
  state: CodegenState,
  altGen?: Function,
  altHelper?: string
): string {
  // v-for 的迭代器,比如 一个数组
  const exp = el.for
  // 迭代时的别名
  const alias = el.alias
  // iterator 为 v-for = "(item ,idx) in obj" 时会有,比如 iterator1 = idx
  const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
  const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''

  // 提示,v-for 指令在组件上时必须使用 key
  if (process.env.NODE_ENV !== 'production' &&
    state.maybeComponent(el) &&
    el.tag !== 'slot' &&
    el.tag !== 'template' &&
    !el.key
  ) {
    state.warn(
      `<${el.tag} v-for="${alias} in ${exp}">: component lists rendered with ` +
      `v-for should have explicit keys. ` +
      `See https://vuejs.org/guide/list.html#key for more info.`,
      el.rawAttrsMap['v-for'],
      true /* tip */
    )
  }

  // 标记当前节点上的 v-for 指令已经被处理过了
  el.forProcessed = true // avoid r
  // 得到 `_l(exp, function(alias, iterator1, iterator2){return _c(tag, data, children)})`
  return `${altHelper || '_l'}((${exp}),` +
    `function(${alias}${iterator1}${iterator2}){` +
    `return ${(altGen || genElement)(el, state)}` +
    '})'
}

genIf

/src/compiler/codegen/index.js

/**
 * 处理带有 v-if 指令的节点,最终得到一个三元表达式,condition ? render1 : render2 
 */
export function genIf(
  el: any,
  state: CodegenState,
  altGen?: Function,
  altEmpty?: string
): string {
  // 标记当前节点的 v-if 指令已经被处理过了,避免无效的递归
  el.ifProcessed = true // avoid recursion
  // 得到三元表达式,condition ? render1 : render2
  return genIfConditions(el.ifConditions.slice(), state, altGen, altEmpty)
}

function genIfConditions(
  conditions: ASTIfConditions,
  state: CodegenState,
  altGen?: Function,
  altEmpty?: string
): string {
  // 长度若为空,则直接返回一个空节点渲染函数
  if (!conditions.length) {
    return altEmpty || '_e()'
  }

  // 从 conditions 数组中拿出第一个条件对象 { exp, block }
  const condition = conditions.shift()
  // 返回结果是一个三元表达式字符串,condition ? 渲染函数1 : 渲染函数2
  if (condition.exp) {
    // 如果 condition.exp 条件成立,则得到一个三元表达式,
    // 如果条件不成立,则通过递归的方式找 conditions 数组中下一个元素,
    // 直到找到条件成立的元素,然后返回一个三元表达式
    return `(${condition.exp})?${genTernaryExp(condition.block)
      }:${genIfConditions(conditions, state, altGen, altEmpty)
      }`
  } else {
    return `${genTernaryExp(condition.block)}`
  }

  // v-if with v-once should generate code like (a)?_m(0):_m(1)
  function genTernaryExp(el) {
    return altGen
      ? altGen(el, state)
      : el.once
        ? genOnce(el, state)
        : genElement(el, state)
  }
}

genSlot

/src/compiler/codegen/index.js

/**
 * 生成插槽的渲染函数,得到
 * _t(slotName, children, attrs, bind)
 */
function genSlot(el: ASTElement, state: CodegenState): string {
  // 插槽名称
  const slotName = el.slotName || '"default"'
  // 生成所有的子节点
  const children = genChildren(el, state)
  // 结果字符串,_t(slotName, children, attrs, bind)
  let res = `_t(${slotName}${children ? `,${children}` : ''}`
  const attrs = el.attrs || el.dynamicAttrs
    ? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(attr => ({
      // slot props are camelized
      name: camelize(attr.name),
      value: attr.value,
      dynamic: attr.dynamic
    })))
    : null
  const bind = el.attrsMap['v-bind']
  if ((attrs || bind) && !children) {
    res += `,null`
  }
  if (attrs) {
    res += `,${attrs}`
  }
  if (bind) {
    res += `${attrs ? '' : ',null'},${bind}`
  }
  return res + ')'
}

genComponent

/src/compiler/codegen/index.js

// componentName is el.component, take it as argument to shun flow's pessimistic refinement
/**
 * 生成动态组件的渲染函数
 * 返回 `_c(compName, data, children)`
 */
function genComponent(
  componentName: string,
  el: ASTElement,
  state: CodegenState
): string {
  // 所有的子节点
  const children = el.inlineTemplate ? null : genChildren(el, state, true)
  // 返回 `_c(compName, data, children)`
  // compName 是 is 属性的值
  return `_c(${componentName},${genData(el, state)}${children ? `,${children}` : ''
    })`
}

总结

  • 面试官 问:简单说一下 Vue 的编译器都做了什么?

    Vue 的编译器做了三件事情:

    • 将组件的 html 模版解析成 AST 对象

    • 优化,遍历 AST,为每个节点做静态标记,标记其是否为静态节点,然后进一步标记出静态根节点,这样在后续更新的过程中就可以跳过这些静态节点了;标记静态根用于生成渲染函数阶段,生成静态根节点的渲染函数

    • 从 AST 生成运行渲染函数,即大家说的 render,其实还有一个,就是 staticRenderFns 数组,里面存放了所有的静态节点的渲染函数


  • 面试官:详细说一下渲染函数的生成过程

    大家一说到渲染函数,基本上说的就是 render 函数,其实编译器生成的渲染有两类:

    • 第一类就是一个 render 函数,负责生成动态节点的 vnode

    • 第二类是放在一个叫 staticRenderFns 数组中的静态渲染函数,这些函数负责生成静态节点的 vnode

    渲染函数生成的过程,其实就是在遍历 AST 节点,通过递归的方式,处理每个节点,最后生成形如:_c(tag, attr, children, normalizationType) 的结果。tag 是标签名,attr 是属性对象,children 是子节点组成的数组,其中每个元素的格式都是 _c(tag, attr, children, normalizationTYpe) 的形式,normalization 表示节点的规范化类型,是一个数字 0、1、2,不重要。

    在处理 AST 节点过程中需要大家重点关注也是面试中常见的问题有:

    • 静态节点是怎么处理的

      静态节点的处理分为两步:

      • 将生成静态节点 vnode 函数放到 staticRenderFns 数组中

      • 返回一个 _m(idx) 的可执行函数,意思是执行 staticRenderFns 数组中下标为 idx 的函数,生成静态节点的 vnode

    • v-once、v-if、v-for、组件 等都是怎么处理的

      • 单纯的 v-once 节点处理方式和静态节点一致

      • v-if 节点的处理结果是一个三元表达式

      • v-for 节点的处理结果是可执行的 _l 函数,该函数负责生成 v-for 节点的 vnode

      • 组件的处理结果和普通元素一样,得到的是形如 _c(compName) 的可执行代码,生成组件的 vnode


到这里,Vue 编译器 的源码解读就结束了。相信大家在阅读的过程中不免会产生云里雾里的感觉。这个没什么,编译器这块儿确实是比较复杂,可以说是整个框架最难理解也是代码量最大的一部分了。一定要静下心来多读几遍,遇到无法理解的地方,一定要勤动手,通过示例代码加断点调试的方式帮助自己理解。

当你读完几遍以后,这时候情况可能就会好一些,但是有些地方可能还会有些晕,这没事,正常。毕竟这是一个框架的编译器,要处理的东西太多太多了,你只需要理解其核心**(模版解析、静态标记、代码生成)就可以了。后面会有 手写 Vue 系列,编译器这部分会有一个简版的实现,帮助加深对这部分知识的理解。

编译器读完以后,会发现有个不明白的地方:编译器最后生成的代码都是经过 with 包裹的,比如:

<div id="app">
  <div v-for="item in arr" :key="item">{{ item }}</div>
</div>

经过编译后生成:

with (this) {
  return _c(
    'div',
    {
      attrs:
      {
        "id": "app"
      }
    },
    _l(
      (arr),
      function (item) {
        return _c(
          'div',
          {
            key: item
          },
          [_v(_s(item))]
        )
      }
    ),
    0
  )
}

都知道,with 语句可以扩展作用域链,所以生成的代码中的 _c、_l、_v、_s 都是 this 上一些方法,也就是说在运行时执行这些方法可以生成各个节点的 vnode。

所以联系前面的知识,响应式数据更新的整个执行过程就是:

  • 响应式拦截到数据的更新

  • dep 通知 watcher 进行异步更新

  • watcher 更新时执行组件更新函数 updateComponent

  • 首先执行 vm._render 生成组件的 vnode,这时就会执行编译器生成的函数

  • 问题

    • 渲染函数中的 _c、_l、、_v、_s 等方法是什么?

    • 它们是如何生成 vnode 的?

下一篇文章 Vue 源码解读(11)—— render helper 将会带来这部分知识的详细解读,也是面试经常被问题的:比如:v-for 的原理是什么?

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

PDF 生成(2)— 生成 PDF 文件

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

封面.png

回顾

前面我们在 PDF 生成(1)— 开篇 讲了业务背景、技术调研、技术决策和整个方案的技术架构设计。知道了为什么做,也知道了最后的成果,接下来我们就进入实操阶段,带大家从零开始逐步实现整套架构。

简介

本文我们以 百度新闻 为例,讲解如何通过 puppeteer 将百度新闻页打印成一份完整的 PDF 文件。

构建项目

  • 执行 mkdir generate-pdf && cd generate-pdf && npm init -y 命令,初始化项目,然后用 vscode 打开创建项目目录。
  • 执行 npm i puppeteer 安装 puppeteer
  • 分别创建 /server/fe 两个目录来存放 Node 和 前端代码,项目目录结构如下:

生成 PDF 文件

创建 /server/index.mjs 文件,进行代码编写,这里我们以 百度新闻 为例,生成一份 PDF 文件。代码主要意思是:

  • 以界面化模式打开一个浏览器(browser)
  • 浏览器上新开一个 Tab 页(page)
  • 当前 Tab 页打开 https://news.baidu.com 链接
  • 调用 page.pdf 方法将当前页打印成 PDF 文件
  • 关闭浏览器

代码如下:

import puppeteer from "puppeteer";

/**
 * 生成 PDF 文件
 */
async function generatePDF() {
  // 启动浏览器。为了演示效果,暂时关闭无头模式,以浏览器界面形式运行
  const browser = await puppeteer.launch({ headless: false })
  // 打开一个新的 Tab 页
  const page = await browser.newPage()
  // 在当前 Tab 页上打开 “百度新闻” 页。第二个配置参数,意思是当页面触发 load 事件,并且 500ms 内没有新的网络连接,则继续往下执行
  await page.goto('https://news.baidu.com', { waitUntil: ['load', 'networkidle0']})
  // 将当前页打印成 PDF 文件
  await page.pdf({
    // PDF 文件的存储路径,如果不设置则会以二进制的形式放到内存中
    path: './news.pdf',
    // 以 A4 纸的尺寸来打印 PDF
    format: 'A4',
    // 设置 PDF 文件的页边距,避免内容完全贴边
    margin: {
      top: 40,
      right: 40,
      bottom: 40,
      left: 40
    },
    // 打印的时候打印背景色
    printBackground: true,
  })
  // 关闭浏览器
  await browser.close()
}

generatePDF()

PDF 效果如下:

image.png
image.png

短短的 10行 代码就能将一个现成的网页打印成一份 PDF 文件,是不是很简单。

仔细观察,会发现 PDF 文件的内容比网页的实际内容要少,这因为网页随着滚动会再动态加载一些内容(懒加载)

打印完整网页(网页滚动 — 懒加载场景)

这里我们只处理有限滚动场景,无限滚动虽然原理一样,但处理没有尽头,这块儿可以根据业务需要自行特殊处理,比如打印前 10屏。
这里用 代码来模拟滚动,让浏览器加载完整内容,核心代码如下:

image.png

生成的 PDF 文件效果如下(为了节省篇幅,只截取了 开始和结尾 两页)

image.png
image.png

完整代码:

import puppeteer from "puppeteer";

/**
 * 生成 PDF 文件
 */
async function generatePDF() {
  // 启动浏览器。为了演示效果,暂时关闭无头模式,以浏览器界面形式运行
  const browser = await puppeteer.launch({ headless: false })
  // 打开一个新的 Tab 页
  const page = await browser.newPage()
  // 在当前 Tab 页上打开 “百度新闻” 页。第二个配置参数,意思是当页面触发 load 事件,并且 500ms 内没有新的网络连接,则继续往下执行
  await page.goto('https://news.baidu.com', { waitUntil: ['load', 'networkidle0'] })
  // 滚动页面,加载完整内容。evaluate 的回调函数会在浏览器中执行,evalaute 方法的返回值是回调函数的返回值
  await page.evaluate(function () {
    return new Promise(resolve => {
      // 通过递归来滚动页面
      function scrollPage() {
        // { 浏览器窗口可视区域的高度,页面的总高度,已滚动的高度 }
        const { clientHeight, scrollHeight, scrollTop } = document.documentElement
        // 如果滚动高度 + 视口高度 < 总高度,则继续滚动,否则就任务滚动到底部了
        if (scrollTop + clientHeight < scrollHeight) {
          document.documentElement.scrollTo(0, scrollTop + clientHeight)
          // 加一个 setTimeout 来保证滚动的稳定性
          setTimeout(() => {
            scrollPage()
          }, 500)
        } else {
          resolve()
        }
      }
      scrollPage()
    })
  })
  // 将当前页打印成 PDF 文件
  await page.pdf({
    // PDF 文件的存储路径,如果不设置则会以二进制的形式放到内存中
    path: './news.pdf',
    // 以 A4 纸的尺寸来打印 PDF
    format: 'A4',
    // 设置 PDF 文件的页边距,避免内容完全贴边
    margin: {
      top: 40,
      right: 40,
      bottom: 40,
      left: 40
    },
    // 打印的时候打印背景色
    printBackground: true,
  })
  // 关闭浏览器
  await browser.close()
}

generatePDF()

页眉、页脚

我们经常能在 PDF 文件中看到页眉、页脚。页眉、页脚可以展示文件的作者、日期、版权、页码等信息,对于读者了解和阅读 PDF 文件有很大的帮助。那在当前技术方案下该如何为打印的 PDF 文件设置页眉、页脚呢?

puppeteer 的 page.pdf 方法提供了相应的配置参数。只需要一个 displayHeaderFooter: true 的配置项就可以

image.png

效果如下:

image.png

可以看到,页眉的左边是 PDF 文件生成的时间,中间位置是页面的 title,页脚的左边是当前页面的 URL,右边是当前页码/总页码。说实话,展示的效果还是不错的,但它的能力不止于此。

puppeteer 还提供了两个配置项,分别是 headerTemplatefooterTemplate,可以让使用者通过有效的 HTML 字符串来自定义页眉、页脚,并且其中还内置了一些特殊的变量,比如 date、title、url、pageNumber、totalPages,分别对应默认的页眉、页脚信息。

接下来我们实现如下效果的页眉、页脚:

image.png

核心代码如下:

image.png
image.png

在实现页眉页脚时,需要注意如下内容:

  • 所有内容都需要放在模版字符串中,不能从外部引入,比如 CSS、图片,可以看到 img 的 src 值是 base64 之后的内容
  • 页眉天生会有 20px 的上边距,需要处理掉。如果不知道的话,会发现无法很难做到垂直居中,甚至看到页眉页脚空白
  • 页脚天生会有 18px 的下边距,需要处理掉

完整代码:

import puppeteer from "puppeteer";
import { footerTemplate, headerTemplate } from "./header-footer-template.mjs";

/**
 * 生成 PDF 文件
 */
async function generatePDF() {
  // 启动浏览器。为了演示效果,暂时关闭无头模式,以浏览器界面形式运行
  const browser = await puppeteer.launch({ headless: false })
  // 打开一个新的 Tab 页
  const page = await browser.newPage()
  // 在当前 Tab 页上打开 “百度新闻” 页。第二个配置参数,意思是当页面触发 load 事件,并且 500ms 内没有新的网络连接,则继续往下执行
  await page.goto('https://news.baidu.com', { waitUntil: ['load', 'networkidle0'] })
  // 滚动页面,加载完整内容。evaluate 的回调函数会在浏览器中执行,evalaute 方法的返回值是回调函数的返回值
  await page.evaluate(function () {
    return new Promise(resolve => {
      // 通过递归来滚动页面
      function scrollPage() {
        // { 浏览器窗口可视区域的高度,页面的总高度,已滚动的高度 }
        const { clientHeight, scrollHeight, scrollTop } = document.documentElement
        // 如果滚动高度 + 视口高度 < 总高度,则继续滚动,否则就任务滚动到底部了
        if (scrollTop + clientHeight < scrollHeight) {
          document.documentElement.scrollTo(0, scrollTop + clientHeight)
          // 加一个 setTimeout 来保证滚动的稳定性
          setTimeout(() => {
            scrollPage()
          }, 500)
        } else {
          resolve()
        }
      }
      scrollPage()
    })
  })
  // 将当前页打印成 PDF 文件
  await page.pdf({
    // PDF 文件的存储路径,如果不设置则会以二进制的形式放到内存中
    path: './news.pdf',
    // 以 A4 纸的尺寸来打印 PDF
    format: 'A4',
    // 设置 PDF 文件的页边距,避免内容完全贴边
    margin: {
      top: 40,
      right: 40,
      bottom: 40,
      left: 40
    },
    // 开启页眉、页脚
    displayHeaderFooter: true,
    // 通过 HTML 模版字符串自定义页眉、页脚
    headerTemplate: headerTemplate(),
    footerTemplate: footerTemplate(),
    // 打印的时候打印背景色
    printBackground: true,
  })
  // 关闭浏览器
  await browser.close()
}

generatePDF()

新建 /server/header-footer-template.mjs 文件

/**
 * 页眉页脚
 * 需要注意的点:
 *    1. 所有内容都需要放在模版字符串中,不能从外部引入,比如 CSS、图片,可以看到 img 的 src 值是 base64 之后的内容
 *    2. 页眉天生会有 20px 的上边距,需要处理掉。如果不知道的话,会发现无法很难做到垂直居中,甚至看到页眉页脚空白
 *    3. 页脚天生会有 18px 的下边距,需要处理掉
 */
import crypto from 'crypto'

// 页眉
export function headerTemplate() {
  return `<div style="box-sizing: border-box; width: 100%; height: 40px; text-align: right; margin-right: 40px; margin-top: -20px; display: flex; justify-content: flex-end; align-items: center;">
    <img style="width: 83px; height: 16px;" src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAWAAAABACAYAAAAkuq3OAAAAAXNSR0IArs4c6QAAAARzQklUCAgICHwIZIgAABhNSURBVHic7Z17lBxVnce/t/oxr56Z7pkJYSBCTTiAApKJKIILTkfZVckig1FgXWUmR2EXXDeTLCviwWWCGPTIkgkPBcVDI8hTzITsuohoJhAgKI8J8jAESIeEDCSZmZ5H5tGPe/ePququqq7qrld3T8L9QKW6Xvf+qqb627/63d+9RVBiHnhq/wlvjzR0vviOEDo6nPp7xkjT5KzQNp2CMJMivho/o9VBZBpr6FSaYiaZEv5UX0NfPfVo+t5pLfF7Tj755GSpbeRwOJxKQLwukDFGfvTo1PK/vec/qzZA/2FoXDjaTXlHh+l7PoJ1Ygt9YtXSuue9spPD4XAqjWcCfOcfp4995m10h4L493dGhCavylWzIExfo8R3521dgXWEEFqKOjgcDqdcuBbgXz+RWLh1T+D7+yaFrpmk4LlHbUSoiiSaQnTNzZdU3UgIYeWok8PhcLzGsWAyxsh37pu6ZdewcPlMmgheGmWV+mpMfP6j6RVfPzt0VyXq53A4HDc4EuCbNo6d/urewIP7JwXRY3scccpRdP0/n7rzYt5gx+FwDiVsC/C1D43/92tDgRUzKfhKYZBT6qsxeXpb8pyepY3PVdoWDofDsYJlAWaMkSt+OfXb3aNCp9k+4RqGUJABIAj4gIDAEKoRMHqQIU2BqRSQygDjM6UJFdcEGBVbyOqffK3mupJUwOFwOB5iSQkvu2O8Ze+Y74+UkVPV66v9DMcfAXR8hODIRuCYFgH1tVKRhBAQkv/54Azw6m6K8Wngf15MYWhMEmYv6TiR3X5VZ93l3pbK4XA43lJUgMfHx1v+434ysGdEOFlZd0Q9w7mnAktOJgjVEFOxVT6rl9VzABgaoXj5nQzW/zmFd0a8O7G2eSz202+GlntXIofD4XhLQQFmjJFLfza5afco6QCA+fUM5y6yLryFPhvx1Gsp3PtkCrtGWNY4dY6Z3eWF83Djzy4L/Wehc+RwOJxKUVCAv3Hb+CO7R8iXqgPA0nbg/I8ThKqNPVu34qvmd88n8Ys/pTCVdJ/i+8WP4aJ/W1r/kOuCOBwOx2NMFbH7lvEb3h3BdxtrgW9/juC0hflhhEKhBrPPVhkaoVi7cQYv7cpojLXrETeFkPrYsfRTV305zLsxczg5wvKkJl4BOz7QGCri9Q8dOH3rDv+zDTVEWHOxDy0N8s4OPV473q+eB5+cxX3PJHFw1rk3XF/N3ll/VWMb7758yNIuT07pB5DwyJZCdFuoNwpAVC0PylO5iQLYpFqOA2irgB1A/nWzQhzAgKdWVAC/0cq/vOl7QmwhwjVf8iFUo92mFlK7n51w0aercMICH65/eAqJKQYpyQ225uNTOGbFL0ZvA8AzIw49RADroRUtu3QBWOKFMUXQ98gcQL4Ad0ErOKtRGQGeK/QCuNbhscsBxDyzpALkCfClNx+4JhIi9avO04pvIYH1MvRgxOKFftx6aQirH5jC9qF0NsxgZ77jPXLpxudGbz/vk5FtroyRhKAUXZ/HAKhzrPsALCpBPQCwGdKNr7DJZD8vWAnnAhOGZJvo0oYopOvZ47KcuYabJwNRtxyGM09UwclTRi+ciy8ArIW7J4i74O6cnbABqu+5RoC/devwyc2N5OorPu/P83wBbz1bu7Q2Cbj24lrcsnEKT2+3nzh8cBq+za8IdwL4hEtTOiF9ob0mrlteVKJ6AOBu3XKp6gHy44x2uAvuxVdhBaRr3OdReXOBTrgTMDVhuHMsBmBPgHvh3nblB3oJ7Iuw2x8cp3TIdScAnQDPCws/+FpHoNYo7OAm/mvExDTDlleSqKkiiJ4atGR5a5OAqy+sw9WxSWyL2xfhrdszH/9ebPjCNd3NbrIiulwcOxdIQPJW5jq90D4RAJKAxm2U0Q7tD4DiMQ3YtCUM6YdAmccdlGEHEdKPYlxejuPwaiDrRb74JmBdRKOqz4oIL4a9a2Tao7fEhOW6Y4BKgPft23dkmvmXNtQpgpnf6OVV/PeRLTO4dcMkJqelOk5Y4McPlzdAnF98eIn6GoIbukP4yW8O4o/bZorur2bRQj+uXua/eE03nAqwCHeNQXOBcjVIuaEH+V/QOCRPJ26jnCjywyvrYf/LGpWPU9tSygYrEVpvdBCSzYcDRn9bQIrnWnUMenVlqD3huMUyKiXAgOTExQCVAPt8ga/WVvlkVzQnvvoMBquxYDPe2JPWiK+y7oYHJnHHikZL1tfXEFz39RAaagk2PjeNZKp4hkQwQLDq/Br4/eyCiYmJj9TX179uqbJ8vGrMaYfkkVklBil265YBC/ushHORXgt3YYco8q9LAvbFF5DOdaWuPLXHZPUcy/2DJTqs344X6Zaog2O6YXzP2xFfQBLgY6ENIYiwJ8I9mANtAioB9n1bkTFZRqHWUbtertn6F3akNOKr8OKOJPYOUxzVbH1o4SuX1WFBsw+/eGwSk9PmGWaNdQKuvySEY+YRMAYwxlYCuMxyRTniqNyj4GaUr8W3H87P81o4F2ARxnHI5S7s6ZPLXaGrZz2s/5jq6xYd2mIVp9evH9K1Kgd280I7Yfy3XQ1n97Vynt2qdSKsi3Cx7WVBAID9+0eW+vx+MbeaZOdush+MeGNP2nzbu+bbzLg4Wo17v9OEiz5dh0jIh0gdQUMNUFsFROoEXL60Dr/sacDi46TwBmMMAL7IGCtvKyKnGCKMMx5Ww33Mugf5nn8U7hrk3Hj5dsuOl7CuctAOc/HtdVGuUYaNCOk+KuXfxzP8ABCoCuTHQywIb/4hldG01iYBq5bVYdWyOryxJ4OJGYZQNdAaIairBiilYIxlJ5/PN394ePiLkFJCOHMDo4yHdXD3BVVzAYCXdHVYzYyIG6zLtmSXAL14jJnsNwit9+hFiMoqMd2y2bVoh7EguhVfpc4lcvnqthkROU94Trd3+AGAMPJZzVpCQCBrMAGUcISRN1woRlwJTliQ83QV4VWWlc8A4Pf7PwkuwHOFXuTHFAfhbYxO+bK+BGeZEQndcaX0sPSNIWYi0g/t00E3tKLWD+9iwj3QnvNqFPfMRUihnlKIr4L67yqq1ivCP6dF2D80NCoSQtqyXccAgAHKAzrR9VYuVY83L1F7u2YTpfRcAN+rtK0cw5SkOCSP1QpR3fHbYC7cSrlOMiPKKcCiQd1W6IL2hywO7wR4BbR2DaDw9RJhHFLy8qlGQe0Jq+tTGrrLFRe3jT9QjSgIUekvUf7PiS8hmlEjnGQ/lBO1t6v3fJVlQsgixpjAx4eoKEYdCexmPIiw1yI/APPMiEL16kVQtFGnWwoJsIjc+et/FDo8tEFfdqdct1leeTfynyp2wXvxVYhD+vsZpbh1QmujPj+8EiQADPp9EBYpIycwBkhveSfZJs6sCDMiN9nJ6+eI2OrRe7pG6xhjEAQBQ0NDiwG8UFmLP7CI8D7jwSqFMiPM8m3L+Rgr6pbjBfa9FuY9uroLbHOL+totQb7Y9uo+dyDnFZeD1TAPK7kdW8QLEgAifp9POF1aZuaebdYr1saC55r3a+btFtjWDi7AlSIK6XFUTRzl66XXI9en94TaYfzYHtctV9qDUohW2gBINgwU2N6F8gveNhjbFEXlxReQ7p+on1L6ISIQbbiB5ZL85MXcWLsMpg1y+xIUA9tmMXbQPEWwUKrZH16YwY4CaWqtTT50LAqivsZc7K3Ef5UpGAzOlS/RB5GYyfow8mO4vSWyYa6moYm65bjJflGDfStBF8z/RlFUxsYuGLcFzKWhBDr9IEQa+SGb8SApbL5DK+9gEgt+ZMsMbnpkAkkXL9h8/IVZALMF92n9nYCbvxXO67Zs5u2aecXyuo87t5ZTIsLIj+P1VsAOPfpUsGNLVI8dYW+H1svTxzbj8C6coy97ENqwjGhSV6UELwxjz5wYrNOjP9dS9TAU/YzSBuITsl4vkV3d7GcNSnw4lxtBCEH8/Qxu3TDpSnytMjRCsW79JNb+qzZTx47nq2qIMxwP2SJhuPOC7B7bBe8aVcrZq+5woVwxYKP7Im6ybx+0XvwmaEMSTnuZGbETWttWwlq39rthPz+5HdoYc0Kuzy5Gf7PuIseEAYzq1q1Eib4vfiIIuvEftF6uFnVuRNZlxis704bdi0vFs68lMTHNsqEIO56ven8UOFMLrEV5h7OLelxezOPy5goi7P1dBmHNu4kb1FMK9AI8Z3NYLTLg4Jgo8gU45oEtVtB3Sitp3ZIHyBiY3PlCEiciZUMwQBFkxhS/l4GpUtUAIEPLJ74AUF8jaMTXaDLbpu6cAfv92dVE3ZwDp2SIsDeu7Wo4e7wsVQz4cBPgQw19yKSkjcICGE1KOsSArDDJcyUDjeUyIaT1Oe+XMeCMjwQRKtAw5jVnnaIdP9hMdI22qXGRuRHF3Gj84JSPuG6ZC/Dhh4h8x6qkvWX9TOlsLPd+y3aGkz1hRXcZcjsQRdDkjfMjAm68rBFX/nys5KGIM08K4tJz67I2Fgo/FPKO5f0OODSjEg0L/ZBSa5zQAe6xu0UvhFyADz+iuuU4SuwB+wkwAcaaNNFQTQucIliCZrOCEqk47fgAHr2uGS/sSGFoJAMzHn9+Fq+YvM3i/E9V47ijzNvFPioGcIqY217M29XHgPVQ6riFeDncd2+Mwl5S+gY4j0X14oMjwHHYiztaDT+US4BF3bLZQDwc79E7VgMOy+mFtsF8A0xSHv0AdgKsiTFVri8hIErSL0juXwL1IBFy7DjXLFdfU/z1Qm/sTpsK8FmnVFl+PZFVb9fM+wUAn4+8bakyzqFEHKXp+2/kiZZyRDQFfYv8oUoPrL9kNqJbDsNeXH8d7Mf1ReQ7KXfbLEONuqx2mAkwIaSfMXZatrtFtreFOi+CQKXHucY6RajL3AnOrudrJL4AQCl9qzwWcw4DyiXA+vziXR6XXyn0g/nYIQx7mS1jsD+SXlS3HIdzDziG/FcmRY3KEyilk8oCYxRStFf6T1oJuXGOZedMThRmjLlKI3CD3ewHo+NfaWl5udx2cw5pyhGG0Ht/bvDKPrflRFHeRmsnbTRehR8AY/HWp7cBkAK7mjgkY7LQqoUXLJsgkdusEugyYqXhTb9sJMAAXlxCiP1XcHA+yJRDgPVjAcddlLUWUgeKu2C/DUDpEr5JLkN0YUe5G60Vj9MqosH+bsIPQH7nE8Nr4I9EItsSicR+APOkVbK0Mgp1wxsh0vpserASMSau0rls49bzVZVzf7ls5hw2qAU4jtIIcLxAnU4QkRsVLQFJGPphPJ5vVJ68zprZDHuhlGOhDTkkkD9wk5fovdM43HnAgBTzLRqGkN6IQci9THpRpYTSIgdVupkiZuqOcoyU1QG24/nqP+vJZDK/L4PJnMOLJfK8lA1v6gwb0WZdxTImwgDOlydAEoMNcj1dKF1mR8zm/lHkC3CvN6YY4mX4QSEhlxNVrevUl+0HAJ/P95d0Wvs0zhjDCzuRem1ISL87wmbTlFUfFSHTR4VJ5HOnIlUV9AUK9lr2GCtCa9UDpoy9deSRR/61TKZzDh/KnZMbt7l/J6QvfCckL7a9yP5RWPd0lQFplHTIwyU/WUT+dXIbflDYAO317YKucdAPAKFQ6P5EInELgGYAODBBcPMT/sxb+0gADAEQUgMGvLYX1WAMD/8Zgcs+kx4960R/xOezJ8EnLPADz5lsO7r42Dhu8n4VfILwoA2TOZxDiQHkvCwRuVc2iQ7Li0ESJP3oZ4cLpQg/KMSQ/+aVqLp8teKtBXD9/gmC6x/1Y/8EfNA3tMnzqSTQ9xgi/zuYTv7wwgANBlBt1aIzTwoi+CjyRk4786QgjmoWDI8x8mxT6czMK7uZ8PI7lM2mcZAAgdYIE848jmWq/bTBTIAZAEbpvVbt5XAOUaKQxOV8uGtA65aPvxvF3wN3KLJCt+xl1+OiYYisADc2Nv40MTb+/Zsf95H947DUG2LHEAuuvCc1cdMlQipULdRbOUac78MdKyK48udjGB6nCAaA9oVBrFoWKnicWnx/91J6+IFnUT0xg2q5obBK2kvALweAc07KjH/l9BRqg2jQl0OAh5pbWl63YiuHcwgRRi700Anr8dyEhX2jyInIILQNeYcy7cj/cYp5XIdRGKIX8tNEVoAJIaM39o/f8+b7+Kad3LI9w6x+1a9mJ350cWC4qcHfbOWYU0Q/HlvTjMG3Ujgi7DP1fAGt8KYzdObq+1Nk+xAK1vOHV4WGl3cHZ688N3mwtRF12bIAZAj5odVzM0G50d0i2tzfjSfTUXwXDT1w/rhppyGnG/nnZHR8b4Ey9L2rxCL7F6If+T2oRDgfdtToOurjjR2wZm8C+b2plA4K58Ne1kIC0rlugCSiyqNxl4Vy2pEbrzeOXEOe0ZgJUZt2Keg7o4Th/G8ag7nX3m2wrhPefL8V9PdzGKqB9DUB3M7e0fBsILATBGHNe4j0c6Na6rD/x18N1H5oXrDOeA9nKMNHUkpxQ38SW3cU3l9tZkMNDt70T7O0JgjZOycPNTc3XeTSpG7Y6xZplTiANtXyJpRu/IYYtN11S5nMsgTmnpLb/FKviSG/G3MPtHG8SpGAdH+oBb0Xxm8BNkJpQBtAYc/VqSfdhnyhewnFGwJLzWqYi3el7r8Y5PtM43r290YSlGE1pQyUMZjODaaRSTbv8jtnM8/+bfY9r6xUe79PvZ5OPPOGbEOBicnzuiDD6QszdSMHiSo0wq7zwCwvfx3VlLOBYy4M8BLF3BJfwFhwyt2JwAy7T15K/HElJHFcDEmIBiwcF4MkEBFIP6DrYD/2K6Ly4guY//2iqNz9l60379n/99eE+sAwwGQxszOl0mjofTh55P+9NPM+pdSVheqsBkop7tuS9jFqbcCdT7RR3HJJEsvPTuPoiCozAnjiwIEDH3ZhlpJHWQpKOu6ojpIOsaciDvMv/FwRNjXK46GCiLkhIgrFrpkinhdAEt0lkMIWcRd1DkB6CmiTJ6uvIrI7FkOpEGH8JFnJ+y+b5maY90Uz6ZUMwiYCEmbyG+C0c+VRX78szW96NDn/+R3p3VddUFsbDAiW4sJ69g6np3773Gxy1342+/b7rG5iBpYa+ZZ9guLCM3LiPzYFvPkepo6dh0S4ljX5fWTL8PBwR3Nz86sOzBJRmteTbDYodwO8b3Eeg3Hjib5ur+oyHAFKZleJ6nWLqPscq4gVxhg9ucSRy1AYKHH9ceTeQ6cPVehRfgzmKpW6/zTfddMk3nN6x3oYcxf7OqYZk9//cuCtttYaq8PQgVKK+5+cef+ugfR8u/W1hoG+S9IgAJ7eTvDQcwKGEtpTPPN47P1GNP23tgXNn7VbPoczxyjHcJicElKwF8Vn/mu0D4zo8+Rss+wM37tf66huaKjzF/Vif/PM9L6fPZY8wkk9K79A8akTGX69haD/efPMCkKADGPLB34QiTmph8PhcLygaDe26DWjm4gHrfGROrL/Xz5XdfCcRcGQIAgtRvts/uvseO+D0w1EHnrCbG7GHd+k2LKd4J6nLPbOI1gycH1kwMHpcDgcjmvM3UQFPy6gFIPFsg+KTcMTdN6a30yLX1g9NnPPnyZfHJtMT+irenwwmWRytkWhudlEGfCrJ228JSNjOYWHw+FwPMfyQA4d3x29i4F1u68y9wqNx3pD6ZqqgB8AxqfSB8/7wUSdceKxtfnfnUjw9Ha1i1wgcVlGEITFA2sidl9fwuFwOK4p7gHLbP5RZDlldB2jVE47o3LObfE51cylzwQUVQFfNgvjzb2ZQP6+9uZPvU6h9bz1y/lTJpWZS2lGHA7nA4RlAQaALT9u6aGUraSUJTIFhDCjmxuJcE2QaAZyT2dY0FF4I2NhXmDKsIzo9UXlcDgcK9gSYAB4+sZ5fZl0ajHNsHhGETHd3MqUmMhgeFwag5gxhrYjiOVjNZMVES4wpdO2357K4XA4nmBbgAFga19rfOvaI9rAMssZY3FNwxa1MJenJ/+azB4XqffhtON8tnvfuZ0EUC7AHA6nIjgSYIWtfa0xArqEUbqaZljcrkd6/8BUdrAdALjw7KCrTAvDGG/BOWJb+1rjXlxIDofDsYtnbxQ6o2dITCYRJYSsAJjlhq2uc6pxxXn1WSH+6cZJ3LspWfxAk2QIxpQu08rq/K7U8mnHKcWSwdu5AHM4nMpQkle6tV+xp12gvhWMsHaw4mJ8yTnVuPwfpVEsKaW4b9M0bts4Y6muoslpxhvigMDFl8PhVJSSv1OzvWdnGDP+dlDSDuJbBMJEAABjIhjCINLwfx+aJ+ArZ1fhjA8HEAkBE1MZPPzULHYfYNj2NsXEtMshaxkSIGQQjG5AbSo22NfG+9BzOJyK8v8SRY0Ddt7HNAAAAABJRU5ErkJggg=='></img>
  </div>`
}

// 页脚
export function footerTemplate() {
  return `<div style="box-sizing: border-box; width: 100%; height: 40px; display: flex; justify-content: space-between; align-items: center; margin-bottom: -18px; padding: 0 40px; font-family: PingFangSC-Regular; font-size: 12px;">
    <div style="color: #fafafa;">${crypto.randomUUID()}</div>
    <div style="display: flex; justify-content: space-between; align-items: center; width: 70px; color: #666666;">
      <div><span>共</span> <span class="totalPages"></span> <span>页</span></div>
      <div class="pageNumber" style="font-family: PingFangSC-Semibold; font-weight: bold; color: #BFBFBF;"></div>
    </div>
  </div>`
}

效果如下:

image.png

总结

本文我们以百度新闻页为例为大家展示了 puppeteer 的基本使用:

  • 通过短短的 10行 代码将百度新闻页打印成一份 PDF 文件
  • 通过 puppeteer 的 page.evaluate 方法为浏览器注入一段 JS 代码,用代码来模拟页面滚动,以解决懒加载的问题,从而保证 PDF 文件内容的完整性
  • 通过自定义页眉、页脚的方式讲解了 puppeteer 中关于页眉、页脚相关选项的基本使用和其中的

随着本文的结束,基于 puppeteer 的 PDF 文件生成基本架子就搭起来了,而 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,但现有内容在我们的技术架构中只是九牛一毛,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点

链接

  • PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(7)—— Hook Event

Vue 源码解读(7)—— Hook Event

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

Hook Event(钩子事件)相信很多 Vue 开发者都没有使用过,甚至没听过,毕竟 Vue 官方文档中也没有提及。

Vue 提供了一些生命周期钩子函数,供开发者在特定的逻辑点添加额外的处理逻辑,比如:在组件挂载阶段提供了 beforeMountmounted 两个生命周期钩子,供开发者在组件挂载阶段执行额外的逻辑处理,比如为组件准备渲染所需的数据。

那这个 Hook Event —— 钩子事件,其中也有钩子的意思,和 Vue 的生命周期钩子函数有什么关系呢?它又有什么用呢?这就是这边文章要解答的问题。

目标

  • 理解什么是 Hook Event ?明白其使用场景

  • 深入理解 Hook Event 的实现原理

什么是 Hook Event ?

Hook Event 是 Vue 的自定义事件结合生命周期钩子实现的一种从组件外部为组件注入额外生命周期方法的功能。

使用场景

假设现在有这么一个第三方的业务组件,逻辑很简单,就在 mounted 生命周期中调用接口获取数据,然后将数据渲染到页面上。

<template>
  <div class="wrapper">
    <ul>
      <li v-for="item in arr" :key="JSON.stringify(item)">
        {{ item }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data() {
    return {
      arr: []
    }
  },
  async mounted() {
    // 调用接口获取组件渲染的数据
    const { data: { data } } = await this.$axios.get('/api/getList')
    this.arr.push(...data)
  }
}
</script>

然后在使用的发现这个组件有些瑕疵,比如最简单的,接口等待时间可能比较长,我想在 mounted 生命周期开始执行的时候在控制台输出一个 loading ... 字符串,增强用户体验。

这个需求该怎么实现呢?

有两个办法:第一个比较麻烦,修改源码;而第二种方式则简单多了,就是我们今天介绍的 Hook Event,从组件外面为组件注入额外的生命周期方法。

<template>
  <div class="wrapper">
    <comp @hook:mounted="hookMounted" />
  </div>
</template>

<script>
// 这就是上面的那个第三方业务组件
import Comp from '@/components/Comp.vue'

export default {
  components: {
    Comp
  },
  methods: {
    hookMounted() {
      console.log('loading ...')
    }
  }
}
</script>

这时候你再刷新页面就会发现业务组件在请求数据的时候,会在控制台输出一个 loading ... 字符串。

作用

Hook Event 有什么作用?

通过 Hook Event 可以从组件外部为组件注入额外的生命周期方法。

实现原理

知道了 Hook Event 的使用场景和作用,接下来就从源码去找它的实现原理,做到 “知其然,亦知其所以然”。

前面说过,Hook Event 是 Vue 的自定义事件结合生命周期钩子函数实现的一种功能,所以我们就去看生命周期相关的代码,比如:我们知道,Vue 的生命周期函数是通过一个叫 callHook 的方法来执行的

callHook

/src/core/instance/lifecycle.js

/**
 * callHook(vm, 'mounted')
 * 执行实例指定的生命周期钩子函数
 * 如果实例设置有对应的 Hook Event,比如:<comp @hook:mounted="method" />,执行完生命周期函数之后,触发该事件的执行
 * @param {*} vm 组件实例
 * @param {*} hook 生命周期钩子函数
 */
export function callHook (vm: Component, hook: string) {
  // 在执行生命周期钩子函数期间禁止依赖收集
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  // 从实例配置对象中获取指定钩子函数,比如 mounted
  const handlers = vm.$options[hook]
  // mounted hook
  const info = `${hook} hook`
  if (handlers) {
    // 通过 invokeWithErrorHandler 执行生命周期钩子
    for (let i = 0, j = handlers.length; i < j; i++) {
      invokeWithErrorHandling(handlers[i], vm, null, vm, info)
    }
  }
  // Hook Event,如果设置了 Hook Event,比如 <comp @hook:mounted="method" />,则通过 $emit 触发该事件
  // vm._hasHookEvent 标识组件是否有 hook event,这是在 vm.$on 中处理组件自定义事件时设置的
  if (vm._hasHookEvent) {
    // vm.$emit('hook:mounted')
    vm.$emit('hook:' + hook)
  }
  // 关闭依赖收集
  popTarget()
}

invokeWithErrorHandling

/src/core/util/error.js

/**
 * 通用函数,执行指定函数 handler
 * 传递进来的函数会被用 try catch 包裹,进行异常捕获处理
 */
export function invokeWithErrorHandling (
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res
  try {
    // 执行传递进来的函数 handler,并将执行结果返回
    res = args ? handler.apply(context, args) : handler.call(context)
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true
    }
  } catch (e) {
    handleError(e, vm, info)
  }
  return res
}

vm.$on

/src/core/instance/events.js

/**
 * 监听实例上的自定义事件,vm._event = { eventName: [fn1, ...], ... }
 * @param {*} event 单个的事件名称或者有多个事件名组成的数组
 * @param {*} fn 当 event 被触发时执行的回调函数
 * @returns 
 */
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  const vm: Component = this
  if (Array.isArray(event)) {
    // event 是有多个事件名组成的数组,则遍历这些事件,依次递归调用 $on
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$on(event[i], fn)
    }
  } else {
    // 将注册的事件和回调以键值对的形式存储到 vm._event 对象中 vm._event = { eventName: [fn1, ...] }
    (vm._events[event] || (vm._events[event] = [])).push(fn)
    // hookEvent,提供从外部为组件实例注入声明周期方法的机会
    // 比如从组件外部为组件的 mounted 方法注入额外的逻辑
    // 该能力是结合 callhook 方法实现的
    if (hookRE.test(event)) {
      vm._hasHookEvent = true
    }
  }
  return vm
}

总结

  • 面试官 问:什么是 Hook Event?

    Hook Event 是 Vue 的自定义事件结合生命周期钩子实现的一种从组件外部为组件注入额外生命周期方法的功能。


  • 面试官 问:Hook Event 是如果实现的?

    <comp @hook:lifecycleMethod="method" />
    • 处理组件自定义事件的时候(vm.$on) 如果发现组件有 hook:xx 格式的事件(xx 为 Vue 的生命周期函数),则将 vm._hasHookEvent 置为 true,表示该组件有 Hook Event

    • 在组件生命周期方法被触发的时候,内部会通过 callHook 方法来执行这些生命周期函数,在生命周期函数执行之后,如果发现 vm._hasHookEvent 为 true,则表示当前组件有 Hook Event,通过 vm.$emit('hook:xx') 触发 Hook Event 的执行

    这就是 Hook Event 的实现原理。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

uni-app、Vue3 + ucharts 图表 H5 无法渲染

uni-app、Vue3 + ucharts 图表 H5 无法渲染

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

logo (14)

简介

从问题定位开始,到给框架(uni-app)提 issue、出解决方案(PR),再到最后的思考,详细记录了整个过程。

前序

当你在业务中不幸踩了开源框架的某些坑,这是你的不幸,但这同时也是你的幸运,因为这是你给自己简历中增加亮点的绝佳机会。

而给开源社区贡献 PR 是你证明自己技术侧拥有 P7 实力的绝佳方式,P7 的评判标准无非是业务和技术,业务上有收益,技术上有深度和广度(别人有的你能做的更好,别人没有的你能有)。

这次整个过程历时 3-4 天,在此之前我也没读过 uni-app 和 ucharts 的源码,所以这里把整个过程分享出来也是给大家一个解决问题的思路。

环境

  • uni-app cli 版本 3.0.0-alpha-3030820220114011
  • hbuilder 版本 3.3.8.20220114-alpha
  • ucharts 版本 uni-modules 2.3.7-20220122

现象

uni-app、vue3 + ucharts 绘制图表,开发环境正常,但是打包上线后,H5 无法绘制图表,也不报任何错误。

开发 线上
APP 正常 正常
H5 正常 无法绘制

问题定位

给 ucharts 的社区提 issue,经过交流,维护者 “怀疑“ 是 uni-app 的 vue3 的 renderjs 有问题,但是他也给不了一个肯定的答复,让去 uni-app 的社区提 issue 而且示例中不能用 ucharts。个人对于该回答持怀疑态度,于是决定自己去定位问题。

怀疑是 ucharts 的 bug

  • ucharts 视图部分的关键代码
<view ...其它属性 :prop="uchartsOpts" :change:prop="rdcharts.ucinit">
  <canvas ...属性 />
</view>

这里有一个知识点需要补充:当 prop 发生改变,change:prop 的回调会被调用,这是 uni-app 框架提供的能力,但官方文档没有提及,从源码中可以看到。

  • 看了 ucharts 的源码,绘制图表时的代码执行过程如下:

可是打包后的 H5 线上环境,当执行 this.uchartsOpts = newConfig 之后却没有触发 change:prop 事件,所以这看起来似乎是 uni-app 的 view 组件有问题

感谢 ucharts 官方,在定位问题过程中,和社区进行交流后,ucharts 免费赠送了一个永久超级会员,感谢 🙏 🙏 !!

view 组件的 prop 和 change:prop

提供如下示例:

<template>
  <view>
    <view :prop="counter" :change:prop="changeProp"></view>
		<view>{{ msg }}</view>
  </view>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";

const counter = ref(1)
const msg = ref('hello')

function changeProp() {
	msg.value = 'hello' + counter.value
}

// @ts-ignore
let timer = null
onMounted(() => {
	timer = setInterval(() => {
		counter.value += 1
	}, 1000)
})

onBeforeUnmount(() => {
	// @ts-ignore
	clearInterval(timer)
})
</script>

<style>
</style>
H5 开发环境 H5 打包后
vue2 正常 正常
vue3 正常 change:prop 未执行

因为开发环境没有问题,所以在开发环境中通过在 change:prop 方法中打断点,查看调用栈,找到触发 change:prop 回调的方法,再一步步往上看,终于发现了 uni-app 重写渲染器(render 函数)的地方,在 @dcloudio/uni-h5-vue/dist/vue.runtime.esm.js 中。​

通过阅读 uni-app 的源码,得到如下内容:

响应式数据发生变化,触发 vue 的响应式更新。比如你的响应式数据作为元素的 prop 属性传递,则在 patch 阶段会触发 patchProps 方法, 触发该方法后,方法内判断新老 props 是否发生改变,如果变了,则遍历新的 props 对象,将其中的每个属性、值和老的对比,如果不相等 或者 props 的 key 为 change:xx 则直接调用 patchProp 方法,如果 __UNI_FEATURE_WXS__为真并且 props 的 key 为 change: 开头,则调用 patchWxs,patchWxs 方法最终会通过 nextTick 调用 change:prop 的回调方法。

以下为上述执行过程的流程图:

最终定位到问题就出在 __UNI_FEATURE_WXS__上,发现开发环境中它是 true,但是打包后就变成了 false。

__UNI_FEATURE_WXS__

__UNI_FEATURE_WXS__是一个全局变量,所以肯定是通过 vite 的 define 选项进行设置的。

于是接下来的目的就是需要找到 __UNI_FEATURE_WXS__是在什么地方进行设置的。可以全局搜该变量,然后找到在 @dcloudio/uni-cli-shared 包中找到一个叫 initFeatures 的方法,该方法中声明了一个 features 对象:

const {
  wx,
  wxs,
  // ...其它变量
} = extend(
  initManifestFeature(options),
  // ... 其它方法
)

const features = {
  // vue
  __VUE_OPTIONS_API__: vueOptionsApi, // enable/disable Options API support, default: true
  __VUE_PROD_DEVTOOLS__: vueProdDevTools, // enable/disable devtools support in production, default: false
  // uni
  __UNI_FEATURE_WX__: wx, // 是否启用小程序的组件实例 API,如:selectComponent 等(uni-core/src/service/plugin/appConfig)
  __UNI_FEATURE_WXS__: wxs, // 是否启用 wxs 支持,如:getComponentDescriptor 等(uni-core/src/view/plugin/appConfig)
  // ... 其它属性
}

看了该对象的设置没什么问题,wxs在开发和生产环境下都是 true。那接下来就需要找到谁调用了 initFeatures 方法,而且可能调用完了以后通过判断当前命令,比如:执行 build 时,将 __UNI_FEATURE_WXS__设置为了 false。

刚开始想正向推导。vite-plugin-uni 是 uni-app 提供给 vite 的一个插件框架,uni-app 中的 vite 配置都来自于这里。

插件当中的 uni 插件提供了 config 选项,config 选项的值是调用 createConfig 方法返回的函数,该函数会返回一个对象,该对象会和 vite 的配置做深度合并;该对象有 define 选项,该选项的值为 createDefine 函数的返回值,该返回值是一个对象,其中调用了 initDefine,再往下看发现不对,然后路 走死了。

发现上面正向推导的方式走不通以后,于是开始反向推导,即全局搜索,都有哪些地方调用了 initFeatures,然后一步步的往下推,得到如下正确的流程图:

经过最终的调试,发现 启动开发环境和打包时最终的调用路径是:uniH5Plugin -> createConfig -> configDefine -> initFeatures。
而最终的问题也就是出在了 initFeatures 方法调用的 initManifestFeature 方法中。

答案

最终定位到出问题的地方在 @dcloudio/uni-cli-shared/src/vite/features.ts 文件的 initManifestFeature 方法中。有如下对比:

  • github 仓库的最新代码,版本号:3.0.0-alpha-3030820220114011
if (command === 'build') {
    // TODO 需要预编译一遍?
    // features.wxs = false
    // features.longpress = false
  }
  • 已发版的代码,最高版本号:3.0.0-alpha-3031120220208001
if (command === 'build') {
    // TODO 需要预编译一遍?
    features.wxs = false;
    features.longpress = false;
}

已发版的版本居然高于仓库内的最新版本号。查看 npm 上的发布版本信息:

发现版本号发生了回退。这几次回退的版本号都是不符合规范的版本号,而且其中可能携带了 bug,比如上面提到的最高版本。

发版出现版本号不符合规范的情况是由于项目还没有一个规范的发版流程导致的,但是已经是 alpha 版本了,这种低级错误还是应该避免的。

更致命的操作是,回退版本号。uni-app 目前每次升级都是升级的最小版本号后面的数值,而业务项目的 package.json 都是 "@dcloudio/uni-app": "^xxx" 的形式,这就意味着,你每次重新装包(比如自动化部署时)或者升级包时,都会更新到这个存在 bug 的高版本,这就会导致线上系统报 bug。

解决方案

所以这里正确的处理方式是重新发一个更高版本的包,而不是回退版本。因为该操作会导致用户线上的系统出 bug,即以下代码无法正常执行:

<view :prop="msg" :change:prop="cb"></view>

当正常情况下,当 msg 改变后,change:prop 的回调会执行。但是这个携带 bug 的高版本包,在打包时(npm run build)将 __UNI_FEATURE_WXS__设置为了 false,导致 change:prop 的回调不会被调用。

总结

代码可以回退,但是版本号不要回退,应该基于当前稳定版本,重新发一版版本号更高的版本。

于是就给官方提了 issue 和 解决方案

结果

官方已采纳该解决方案,基于当前稳定版重新发布一版版本号更高的版本。

思考

针对 uni-app 这种处于 alpha 版本的框架,项目内部也确实不应该继续使用 ^ 符号,还是应该将版本号写死为最新的 tag 版本,因为总跟随 alpha 的最新版,确实可能会踩坑。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

qiankun 2.x 运行时沙箱 源码分析

qiankun 2.x 运行时沙箱 源码分析

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

简介

从源码层面详细讲解了 qiankun 框架中的 JS 沙箱 和 样式沙箱的实现原理。

序言

沙箱 这个词想必大家应该不陌生,即使陌生,读完这篇文章也就不那么陌生了

沙箱 (Sandboxie) ,又叫沙盘,即是一个虚拟系统程序,允许你在沙盘环境中运行浏览器或其他程序,因此运行所产生的变化可以随后删除。它创造了一个类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响。 在网络安全中,沙箱指在隔离环境中,用以测试不受信任的文件或应用程序等行为的工具

而今天要说的沙箱来自 qiankun 的实现,是为了解决微前端方案中的隔离问题,qiankun 目前可以说的最好的 微前端 实现方案吧,它基于 single-spa 做了二次封装,解决了 single-spa 遗留的众多问题, 运行时沙箱 就是其中之一

为什么需要它

single-spa 虽好,但却存在一些需要在框架层面解决但却没有解决的问题,比如为每个微应用提供一个干净、独立的运行环境

JS 全局对象污染是一个很常见的现象,比如:微应用 A 在全局对象上添加了一个自己特有的属性 window.A,这时候切换到微应用 B,这时候如何保证 window对象是干净的呢?答案就是 qiankun 实现的 运行时沙箱

总结

先上总结,运行时沙箱分为 JS 沙箱样式沙箱

JS 沙箱

JS 沙箱,通过 proxy 代理 window 对象,记录 window 对象上属性的增删改查

  • 单例模式

    直接代理了原生 window 对象,记录原生 window 对象的增删改查,当 window 对象激活时恢复 window 对象到上次即将失活时的状态,失活时恢复 window 对象到初始初始状态

  • 多例模式

    代理了一个全新的对象,这个对象是复制的 window 对象的一部分不可配置属性,所有的更改都是基于这个 fakeWindow 对象,从而保证多个实例之间属性互不影响

将这个 proxy 作为微应用的全局对象,所有的操作都在这个 proxy 对象上,这就是 JS 沙箱的原理

样式沙箱

通过增强多例模式下的 createElement 方法,负责创建元素并劫持 script、link、style 三个标签的创建动作

增强 appendChild、insertBefore 方法,负责添加元素,并劫持 script、link、style 三个标签的添加动作,根据是否是主应用调用决定标签是插入到主应用还是微应用,并且将 proxy 对象传递给微应用,作为其全局对象,以达到 JS 隔离的目的

初始化完成后返回一个 free 函数,会在微应用卸载时被调用,负责清除 patch、缓存动态添加的样式(因为微应用被卸载后所有的相关DOM元素都会被删掉)

free 函数执行完成后返回 rebuild 函数,在微应用重新挂载时会被调用,负责向微应用添加刚才缓存的动态样式

其实严格来说这个样式沙箱有点名不副实,真正的样式隔离是 严格样式隔离模式 和 scoped css模式 提供的,当然如果开启了 scoped css,样式沙箱中动态添加的样式也会经过 scoped css 的处理

回到正题,样式沙箱实际做的事情其实很简单,就是将动态添加的 script、link、style 这三个元素插入到对的位置,属于主应用的插入主应用,属于微应用的插入到对应的微应用中,方便微应用卸载的时候一起删除,

当然样式沙箱还额外做了两件事:

  • 在卸载之前为动态添加样式做缓存,在微应用重新挂载时再插入到微应用内
  • 将 proxy 对象传递给 execScripts 函数,将其设置为微应用的执行上下文

以上内容就是对运行时沙箱的一个总结,更加详细的实现过程,可继续阅读下面的源码分析部分

源码分析

接下来就进入令人头昏脑胀的源码分析部分,说实话,运行时沙箱这段代码还是有一些难度的,我在阅读 qiankun 源码的时候,这部分内容反反复复阅读了好几遍,github

入口位置 - createSandbox

/**
 * 生成运行时沙箱,这个沙箱其实由两部分组成 => JS 沙箱(执行上下文)、样式沙箱
 *
 * @param appName 微应用名称
 * @param elementGetter getter 函数,通过该函数可以获取 <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
 * @param singular 是否单例模式
 * @param scopedCSS
 * @param excludeAssetFilter 指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理
 */
export function createSandbox(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  singular: boolean,
  scopedCSS: boolean,
  excludeAssetFilter?: (url: string) => boolean,
) {
  /**
   * JS 沙箱,通过 proxy 代理 window 对象,记录 window 对象上属性的增删改查,区别在于:
   *  单例模式直接代理了原生 window 对象,记录原生 window 对象的增删改查,当 window 对象激活时恢复 window 对象到上次即将失活时的状态,
   * 失活时恢复 window 对象到初始初始状态
   *  多例模式代理了一个全新的对象,这个对象是复制的 window 对象的一部分不可配置属性,所有的更改都是基于这个 fakeWindow 对象,从而保证多个实例
   * 之间属性互不影响
   * 后面会将 sandbox.proxy 作为微应用的全局对象,所有的操作都在这个 proxy 对象上,这就是 JS 沙箱的原理
   */
  let sandbox: SandBox;
  if (window.Proxy) {
    sandbox = singular ? new LegacySandbox(appName) : new ProxySandbox(appName);
  } else {
    // 不支持 proxy 的浏览器,通过 diff 方式实现的沙箱
    sandbox = new SnapshotSandbox(appName);
  }

  /**
   * 样式沙箱
   * 
   * 增强多例模式下的 createElement 方法,负责创建元素并劫持 script、link、style 三个标签的创建动作
   * 增强 appendChild、insertBefore 方法,负责添加元素,并劫持 script、link、style 三个标签的添加动作,做一些特殊的处理 => 
   * 根据是否是主应用调用决定标签是插入到主应用还是微应用,并且将 proxy 对象传递给微应用,作为其全局对象,以达到 JS 隔离的目的
   * 初始化完成后返回 free 函数,会在微应用卸载时被调用,负责清除 patch、缓存动态添加的样式(因为微应用被卸载后所有的相关DOM元素都会被删掉)
   * free 函数执行完成后返回 rebuild 函数,在微应用重新挂载时会被调用,负责向微应用添加刚才缓存的动态样式
   * 
   * 其实严格来说这个样式沙箱有点名不副实,真正的样式隔离是之前说的 严格样式隔离模式 和 scoped css模式 提供的,当然如果开启了 scoped css,
   * 样式沙箱中动态添加的样式也会经过 scoped css 的处理;回到正题,样式沙箱实际做的事情其实很简单,将动态添加的 script、link、style 
   * 这三个元素插入到对的位置,属于主应用的插入主应用,属于微应用的插入到对应的微应用中,方便微应用卸载的时候一起删除,
   * 当然样式沙箱还额外做了两件事:一、在卸载之前为动态添加样式做缓存,在微应用重新挂载时再插入到微应用内,二、将 proxy 对象传递给 execScripts
   * 函数,将其设置为微应用的执行上下文
   */
  const bootstrappingFreers = patchAtBootstrapping(
    appName,
    elementGetter,
    sandbox,
    singular,
    scopedCSS,
    excludeAssetFilter,
  );
  // mounting freers are one-off and should be re-init at every mounting time
  // mounting freers 是一次性的,应该在每次挂载时重新初始化
  let mountingFreers: Freer[] = [];

  let sideEffectsRebuilders: Rebuilder[] = [];

  return {
    proxy: sandbox.proxy,

    /**
     * 沙箱被 mount
     * 可能是从 bootstrap 状态进入的 mount
     * 也可能是从 unmount 之后再次唤醒进入 mount
     * mount 时重建副作用(rebuild 函数),即微应用在被卸载时希望重新挂载时做的一些事情,比如重建缓存的动态样式
     */
    async mount() {
      /* ------------------------------------------ 因为有上下文依赖(window),以下代码执行顺序不能变 ------------------------------------------ */

      /* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
      sandbox.active();

      const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
      const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length);

      // must rebuild the side effects which added at bootstrapping firstly to recovery to nature state
      if (sideEffectsRebuildersAtBootstrapping.length) {
        // 微应用再次挂载时重建刚才缓存的动态样式
        sideEffectsRebuildersAtBootstrapping.forEach(rebuild => rebuild());
      }

      /* ------------------------------------------ 2. 开启全局变量补丁 ------------------------------------------*/
      // render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
      mountingFreers = patchAtMounting(appName, elementGetter, sandbox, singular, scopedCSS, excludeAssetFilter);

      /* ------------------------------------------ 3. 重置一些初始化时的副作用 ------------------------------------------*/
      // 存在 rebuilder 则表明有些副作用需要重建
      // 现在只看到针对 umi 的那个 patchHistoryListener 有 rebuild 操作
      if (sideEffectsRebuildersAtMounting.length) {
        sideEffectsRebuildersAtMounting.forEach(rebuild => rebuild());
      }

      // clean up rebuilders,卸载时会再填充回来
      sideEffectsRebuilders = [];
    },

    /**
     * 恢复 global 状态,使其能回到应用加载之前的状态
     */
    // 撤销初始化和挂载阶段打的 patch;缓存微应用希望自己再次被挂载时需要做的一些事情(rebuild),比如重建动态样式表;失活微应用
    async unmount() {
      // record the rebuilders of window side effects (event listeners or timers)
      // note that the frees of mounting phase are one-off as it will be re-init at next mounting
      // 卸载时,执行 free 函数,释放初始化和挂载时打的 patch,存储所有的 rebuild 函数,在微应用再次挂载时重建通过 patch 做的事情(副作用)
      sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map(free => free());

      sandbox.inactive();
    },
  };
}

JS 沙箱

SingularProxySandbox 单例 JS 沙箱
/**
 * 基于 Proxy 实现的单例模式下的沙箱,直接操作原生 window 对象,并记录 window 对象的增删改查,在每次微应用切换时初始化 window 对象;
 * 激活时:将 window 对象恢复到上次即将失活时的状态
 * 失活时:将 window 对象恢复为初始状态
 * 
 * TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
 */
export default class SingularProxySandbox implements SandBox {
  // 沙箱期间新增的全局变量
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();

  // 沙箱期间更新的全局变量,key 为被更新的属性,value 为被更新的值
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

  // 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

  name: string;

  proxy: WindowProxy;

  type: SandBoxType;

  sandboxRunning = true;

  // 激活沙箱
  active() {
    // 如果沙箱由失活 -> 激活,则恢复 window 对象到上次失活时的状态
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    // 切换沙箱状态为 激活
    this.sandboxRunning = true;
  }

  // 失活沙箱
  inactive() {
    // 开发环境,打印被改变的全局属性
    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
        ...this.addedPropsMapInSandbox.keys(),
        ...this.modifiedPropsOriginalValueMapInSandbox.keys(),
      ]);
    }

    // restore global props to initial snapshot
    // 将被更改的全局属性再改回去
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    // 新增的属性删掉
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

    // 切换沙箱状态为 失活
    this.sandboxRunning = false;
  }

  constructor(name: string) {
    this.name = name;
    this.type = SandBoxType.LegacyProxy;
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;

    const self = this;
    const rawWindow = window;
    const fakeWindow = Object.create(null) as Window;

    const proxy = new Proxy(fakeWindow, {
      set(_: Window, p: PropertyKey, value: any): boolean {
        if (self.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            // 属性不存在,则添加
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值,说明是更改已存在的属性
            const originalValue = (rawWindow as any)[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }

          currentUpdatedPropsValueMap.set(p, value);
          // 直接设置原生 window 对象,因为是单例模式,不会有其它的影响
          // eslint-disable-next-line no-param-reassign
          (rawWindow as any)[p] = value;

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // or use window.top to check if an iframe context
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }

        // 直接从原生 window 对象拿数据
        const value = (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(_: Window, p: string | number | symbol): boolean {
        return p in rawWindow;
      },
    });

    this.proxy = proxy;
  }
}

/**
 * 在 window 对象上设置 key value 或 删除指定属性(key)
 * @param prop key
 * @param value value
 * @param toDelete 是否删除
 */
function setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
  if (value === undefined && toDelete) {
    // 删除 window[key]
    delete (window as any)[prop];
  } else if (isPropConfigurable(window, prop) && typeof prop !== 'symbol') {
    // window[key] = value
    Object.defineProperty(window, prop, { writable: true, configurable: true });
    (window as any)[prop] = value;
  }
}
ProxySandbox 多例 JS 沙箱
// 记录被激活的沙箱的数量
let activeSandboxCount = 0;

/**
 * 基于 Proxy 实现的多例模式下的沙箱
 * 通过 proxy 代理 fakeWindow 对象,所有的更改都是基于 fakeWindow,这点和单例不一样(很重要),
 * 从而保证每个 ProxySandbox 实例之间属性互不影响
 */
export default class ProxySandbox implements SandBox {
  /** window 值变更记录 */
  private updatedValueSet = new Set<PropertyKey>();

  name: string;

  type: SandBoxType;

  proxy: WindowProxy;

  sandboxRunning = true;

  // 激活
  active() {
    // 被激活的沙箱数 + 1
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }

  // 失活
  inactive() {
    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
        ...this.updatedValueSet.keys(),
      ]);
    }

    // 被激活的沙箱数 - 1
    clearSystemJsProps(this.proxy, --activeSandboxCount === 0);

    this.sandboxRunning = false;
  }

  constructor(name: string) {
    this.name = name;
    this.type = SandBoxType.Proxy;
    const { updatedValueSet } = this;

    const self = this;
    const rawWindow = window;
    // 全局对象上所有不可配置属性都在 fakeWindow 中,且其中具有 getter 属性的属性还存在 propertesWithGetter map 中,value 为 true
    const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);

    const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
    // 判断全局对象是否存在指定属性
    const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);

    const proxy = new Proxy(fakeWindow, {
      set(target: FakeWindow, p: PropertyKey, value: any): boolean {
        // 如果沙箱在运行,则更新属性值并记录被更改的属性
        if (self.sandboxRunning) {
          // 设置属性值
          // @ts-ignore
          target[p] = value;
          // 记录被更改的属性
          updatedValueSet.add(p);

          // 不用管,和 systemJs 有关
          interceptSystemJsProps(p, value);

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      // 获取执行属性的值
      get(target: FakeWindow, p: PropertyKey): any {
        if (p === Symbol.unscopables) return unscopables;

        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        if (
          p === 'top' ||
          p === 'parent' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          // if your master app in an iframe context, allow these props escape the sandbox
          if (rawWindow === rawWindow.parent) {
            return proxy;
          }
          return (rawWindow as any)[p];
        }

        // proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty
        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

        // mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher
        if (p === 'document') {
          document[attachDocProxySymbol] = proxy;
          // remove the mark in next tick, thus we can identify whether it in micro app or not
          // this approach is just a workaround, it could not cover all the complex scenarios, such as the micro app runs in the same task context with master in som case
          // fixme if you have any other good ideas
          nextTick(() => delete document[attachDocProxySymbol]);
          return document;
        }
        // 以上内容都是一些特殊属性的处理

        // 获取特定属性,如果属性具有 getter,说明是原生对象的那几个属性,否则是 fakeWindow 对象上的属性(原生的或者用户设置的)
        // eslint-disable-next-line no-bitwise
        const value = propertiesWithGetter.has(p) ? (rawWindow as any)[p] : (target as any)[p] || (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

      // 判断是否存在指定属性
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(target: FakeWindow, p: string | number | symbol): boolean {
        return p in unscopables || p in target || p in rawWindow;
      },

      getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
        /*
         as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
         > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
         */
        if (target.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(target, p);
          descriptorTargetMap.set(p, 'target');
          return descriptor;
        }

        if (rawWindow.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
          descriptorTargetMap.set(p, 'rawWindow');
          // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
          if (descriptor && !descriptor.configurable) {
            descriptor.configurable = true;
          }
          return descriptor;
        }

        return undefined;
      },

      // trap to support iterator with sandbox
      ownKeys(target: FakeWindow): PropertyKey[] {
        return uniq(Reflect.ownKeys(rawWindow).concat(Reflect.ownKeys(target)));
      },

      defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
        const from = descriptorTargetMap.get(p);
        /*
         Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
         otherwise it would cause a TypeError with illegal invocation.
         */
        switch (from) {
          case 'rawWindow':
            return Reflect.defineProperty(rawWindow, p, attributes);
          default:
            return Reflect.defineProperty(target, p, attributes);
        }
      },

      deleteProperty(target: FakeWindow, p: string | number | symbol): boolean {
        if (target.hasOwnProperty(p)) {
          // @ts-ignore
          delete target[p];
          updatedValueSet.delete(p);

          return true;
        }

        return true;
      },
    });

    this.proxy = proxy;
  }
}
createFakeWindow
/**
 * 拷贝全局对象上所有不可配置属性到 fakeWindow 对象,并将这些属性的属性描述符改为可配置的然后冻结
 * 将启动具有 getter 属性的属性再存入 propertiesWithGetter map 中
 * @param global 全局对象 => window
 */
function createFakeWindow(global: Window) {
  // 记录 window 对象上的 getter 属性,原生的有:window、document、location、top,比如:Object.getOwnPropertyDescriptor(window, 'window') => {set: undefined, enumerable: true, configurable: false, get: ƒ}
  // propertiesWithGetter = {"window" => true, "document" => true, "location" => true, "top" => true, "__VUE_DEVTOOLS_GLOBAL_HOOK__" => true}
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  // 存储 window 对象中所有不可配置的属性和值
  const fakeWindow = {} as FakeWindow;

  /*
   copy the non-configurable property of global to fakeWindow
   see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
   > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
   */
  Object.getOwnPropertyNames(global)
    // 遍历出 window 对象所有不可配置属性
    .filter(p => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      return !descriptor?.configurable;
    })
    .forEach(p => {
      // 得到属性描述符
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        // 获取其 get 属性
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        /*
         make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
         > The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
         */
        if (
          p === 'top' ||
          p === 'parent' ||
          p === 'self' ||
          p === 'window' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          // 将 top、parent、self、window 这几个属性由不可配置改为可配置
          descriptor.configurable = true;
          /*
           The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
           Example:
            Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
            Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
           */
          if (!hasGetter) {
            // 如果这几个属性没有 getter,则说明由 writeable 属性,将其设置为可写
            descriptor.writable = true;
          }
        }

        // 如果存在 getter,则以该属性为 key,true 为 value 存入 propertiesWithGetter map
        if (hasGetter) propertiesWithGetter.set(p, true);

        // 将属性和描述设置到 fakeWindow 对象,并且冻结属性描述符,不然有可能会被更改,比如 zone.js
        // freeze the descriptor to avoid being modified by zone.js
        // see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}
            
SnapshotSandbox
function iter(obj: object, callbackFn: (prop: any) => void) {
  // eslint-disable-next-line guard-for-in, no-restricted-syntax
  for (const prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      callbackFn(prop);
    }
  }
}

/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;

  name: string;

  type: SandBoxType;

  sandboxRunning = true;

  private windowSnapshot!: Window;

  private modifyPropsMap: Record<any, any> = {};

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot;
  }

  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, prop => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });

    this.sandboxRunning = true;
  }

  inactive() {
    this.modifyPropsMap = {};

    iter(window, prop => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });

    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
    }

    this.sandboxRunning = false;
  }
}

样式沙箱

patchAtBootstrapping
/**
 * 初始化阶段给 createElement、appendChild、insertBefore 三个方法打一个 patch
 * @param appName 
 * @param elementGetter 
 * @param sandbox 
 * @param singular 
 * @param scopedCSS 
 * @param excludeAssetFilter 
 */
export function patchAtBootstrapping(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  sandbox: SandBox,
  singular: boolean,
  scopedCSS: boolean,
  excludeAssetFilter?: Function,
): Freer[] {
  // 基础 patch,增强 createElement、appendChild、insertBefore 三个方法
  const basePatchers = [
    () => patchDynamicAppend(appName, elementGetter, sandbox.proxy, false, singular, scopedCSS, excludeAssetFilter),
  ];

  // 每一种沙箱都需要打基础 patch
  const patchersInSandbox = {
    [SandBoxType.LegacyProxy]: basePatchers,
    [SandBoxType.Proxy]: basePatchers,
    [SandBoxType.Snapshot]: basePatchers,
  };

  // 返回一个数组,数组元素是 patch 的执行结果 => free 函数
  return patchersInSandbox[sandbox.type]?.map(patch => patch());
}
patch
/**
 * 增强多例模式下的 createElement 方法,负责创建元素并劫持 script、link、style 三个标签的创建动作
 * 增强 appendChild、insertBefore 方法,负责添加元素,并劫持 script、link、style 三个标签的添加动作,做一些特殊的处理 => 
 * 根据是否是主应用调用决定标签是插入到主应用还是微应用,并且将 proxy 对象传递给微应用,作为其全局对象,以打包 JS 隔离的目的
 * 初始化完成后返回 free 函数,负责清除 patch、缓存动态添加的样式(因为微应用被卸载后所有的相关DOM元素都会被删掉)
 * free 函数执行完成后返回 rebuild 函数,rebuild 函数在微应用重新挂载时向微应用添加刚才缓存的动态样式
 * 
 * Just hijack dynamic head append, that could avoid accidentally hijacking the insertion of elements except in head.
 * Such a case: ReactDOM.createPortal(<style>.test{color:blue}</style>, container),
 * this could made we append the style element into app wrapper but it will cause an error while the react portal unmounting, as ReactDOM could not find the style in body children list.
 * @param appName 微应用名称
 * @param appWrapperGetter getter 函数,通过该函数可以获取 <div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>
 * @param proxy window 代理
 * @param mounting 是否为 mounting 阶段
 * @param singular 是否为单例
 * @param scopedCSS 是否弃用 scoped css
 * @param excludeAssetFilter 指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理
 */
export default function patch(
  appName: string,
  appWrapperGetter: () => HTMLElement | ShadowRoot,
  proxy: Window,
  mounting = true,
  singular = true,
  scopedCSS = false,
  excludeAssetFilter?: CallableFunction,
): Freer {
  // 动态样式表,存储所有动态添加的样式
  let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];

  // 在多例模式下增强 createElement 方法,让其除了可以创建元素,还可以了劫持创建 script、link、style 元素的情况
  const unpatchDocumentCreate = patchDocumentCreateElement(
    appName,
    appWrapperGetter,
    singular,
    proxy,
    dynamicStyleSheetElements,
  );

  // 增强 appendChild、insertBefore、removeChild 三个元素;除了本职工作之外,appendChild 和 insertBefore 还可以额外处理 script、style、link
  // 三个标签的插入,可以根据情况决定元素被插入到微应用模版空间中还是主应用模版空间,removeChild 也是可以根据情况移除主应用的元素还是移除微应用中这三个元素
  const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
    appName,
    appWrapperGetter,
    proxy,
    singular,
    scopedCSS,
    dynamicStyleSheetElements,
    excludeAssetFilter,
  );

  // 记录初始化的次数
  if (!mounting) bootstrappingPatchCount++;
  // 记录挂载的次数
  if (mounting) mountingPatchCount++;

  // 初始化完成后返回 free 函数,负责清除 patch、缓存动态添加的样式、返回 rebuild 函数,rebuild 函数在微应用重新挂载时向微应用添加刚才缓存的动态样式
  return function free() {
    // bootstrap patch just called once but its freer will be called multiple times
    if (!mounting && bootstrappingPatchCount !== 0) bootstrappingPatchCount--;
    if (mounting) mountingPatchCount--;

    // 判断所有微应用是否都被卸载了
    const allMicroAppUnmounted = mountingPatchCount === 0 && bootstrappingPatchCount === 0;
    // 微应用都卸载以后移除 patch, release the overwrite prototype after all the micro apps unmounted
    unpatchDynamicAppendPrototypeFunctions(allMicroAppUnmounted);
    unpatchDocumentCreate(allMicroAppUnmounted);

    // 因为微应用被卸载的时候会删除掉刚才动态添加的样式,这里缓存了动态添加的样式内容,在微应用卸载后重新挂载时就可以用了
    dynamicStyleSheetElements.forEach(stylesheetElement => {
      if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
        if (stylesheetElement.sheet) {
          // record the original css rules of the style element for restore
          setCachedRules(stylesheetElement, (stylesheetElement.sheet as CSSStyleSheet).cssRules);
        }
      }
    });

    // 返回一个 rebuild 函数,微应用重新挂载时调用
    return function rebuild() {
      // 遍历动态样式表
      dynamicStyleSheetElements.forEach(stylesheetElement => {
        // 像微应用容器中添加样式节点
        document.head.appendChild.call(appWrapperGetter(), stylesheetElement);

        // 添加样式内容到样式节点,这个样式内容从刚才的缓存中找
        if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
          const cssRules = getCachedRules(stylesheetElement);
          if (cssRules) {
            // eslint-disable-next-line no-plusplus
            for (let i = 0; i < cssRules.length; i++) {
              const cssRule = cssRules[i];
              (stylesheetElement.sheet as CSSStyleSheet).insertRule(cssRule.cssText);
            }
          }
        }
      });

      // As the hijacker will be invoked every mounting phase, we could release the cache for gc after rebuilding
      if (mounting) {
        dynamicStyleSheetElements = [];
      }
    };
  };
}
patchDocumentCreateElement
/**
 * 多例模式下增强 createElement 方法,让其除了具有创建元素功能之外,还可以劫持创建 script、link、style 这三个元素的情况
 * @param appName 微应用名称
 * @param appWrapperGetter 
 * @param singular 
 * @param proxy 
 * @param dynamicStyleSheetElements 
 */
function patchDocumentCreateElement(
  appName: string,
  appWrapperGetter: () => HTMLElement | ShadowRoot,
  singular: boolean,
  proxy: Window,
  dynamicStyleSheetElements: HTMLStyleElement[],
) {
  // 如果是单例模式直接 return
  if (singular) {
    return noop;
  }

  // 以微应用运行时的 proxy 为 key,存储该微应用的一些信息,比如 名称、proxy、微应用模版、自定义样式表等
  proxyContainerInfoMapper.set(proxy, { appName, proxy, appWrapperGetter, dynamicStyleSheetElements, singular });

  // 第一个微应用初始化时会执行这段,增强 createElement 方法,让其除了可以创建元素之外,还可以劫持 script、link、style 三个标签的创建动作
  if (Document.prototype.createElement === rawDocumentCreateElement) {
    Document.prototype.createElement = function createElement<K extends keyof HTMLElementTagNameMap>(
      this: Document,
      tagName: K,
      options?: ElementCreationOptions,
    ): HTMLElement {
      // 创建元素
      const element = rawDocumentCreateElement.call(this, tagName, options);
      // 劫持 script、link、style 三种标签
      if (isHijackingTag(tagName)) {
        // 下面这段似乎没啥用,因为没发现有哪个地方执行设置,proxyContainerInfoMapper.set(this[attachDocProxySysbol])
        // 获取这个东西的值,然后将该值添加到 element 对象上,以 attachElementContainerSymbol 为 key
        const proxyContainerInfo = proxyContainerInfoMapper.get(this[attachDocProxySymbol]);
        if (proxyContainerInfo) {
          Object.defineProperty(element, attachElementContainerSymbol, {
            value: proxyContainerInfo,
            enumerable: false,
          });
        }
      }

      // 返回创建的元素
      return element;
    };
  }

  // 后续的微应用初始化时直接返回该函数,负责还原 createElement 方法
  return function unpatch(recoverPrototype: boolean) {
    proxyContainerInfoMapper.delete(proxy);
    if (recoverPrototype) {
      Document.prototype.createElement = rawDocumentCreateElement;
    }
  };
}
patchTHMLDynamicAppendPrototypeFunctions
// 增强 appendChild、insertBefore、removeChild 方法,返回 unpatch 方法,解除增强
function patchHTMLDynamicAppendPrototypeFunctions(
  appName: string,
  appWrapperGetter: () => HTMLElement | ShadowRoot,
  proxy: Window,
  singular = true,
  scopedCSS = false,
  dynamicStyleSheetElements: HTMLStyleElement[],
  excludeAssetFilter?: CallableFunction,
) {
  // Just overwrite it while it have not been overwrite
  if (
    HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
    HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
    HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
  ) {
    // 增强 appendChild 方法
    HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadAppendChild,
      appName,
      appWrapperGetter,
      proxy,
      singular,
      dynamicStyleSheetElements,
      scopedCSS,
      excludeAssetFilter,
    }) as typeof rawHeadAppendChild;
    HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawBodyAppendChild,
      appName,
      appWrapperGetter,
      proxy,
      singular,
      dynamicStyleSheetElements,
      scopedCSS,
      excludeAssetFilter,
    }) as typeof rawBodyAppendChild;

    HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
      appName,
      appWrapperGetter,
      proxy,
      singular,
      dynamicStyleSheetElements,
      scopedCSS,
      excludeAssetFilter,
    }) as typeof rawHeadInsertBefore;
  }

  // Just overwrite it while it have not been overwrite
  if (
    HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
    HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
  ) {
    HTMLHeadElement.prototype.removeChild = getNewRemoveChild({
      appWrapperGetter,
      headOrBodyRemoveChild: rawHeadRemoveChild,
    });
    HTMLBodyElement.prototype.removeChild = getNewRemoveChild({
      appWrapperGetter,
      headOrBodyRemoveChild: rawBodyRemoveChild,
    });
  }

  return function unpatch(recoverPrototype: boolean) {
    if (recoverPrototype) {
      HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
      HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
      HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
      HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;

      HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
    }
  };
}
getOverwrittenAppendChildOrInsertBefore
/**
 * 增强 appendChild 和 insertBefore 方法,让其除了具有添加元素的功能之外,还具有一些其它的逻辑,比如:
 * 根据是否是微应用或者特殊元素决定 link、style、script 元素的插入位置是在主应用还是微应用
 * 劫持 script 标签的添加,支持远程加载脚本和设置脚本的执行上下文(proxy)
 * @param opts 
 */
function getOverwrittenAppendChildOrInsertBefore(opts: {
  appName: string;
  proxy: WindowProxy;
  singular: boolean;
  dynamicStyleSheetElements: HTMLStyleElement[];
  appWrapperGetter: CallableFunction;
  rawDOMAppendOrInsertBefore: <T extends Node>(newChild: T, refChild?: Node | null) => T;
  scopedCSS: boolean;
  excludeAssetFilter?: CallableFunction;
}) {
  return function appendChildOrInsertBefore<T extends Node>(
    this: HTMLHeadElement | HTMLBodyElement,
    newChild: T,
    refChild?: Node | null,
  ) {
    // 要插入的元素
    let element = newChild as any;
    // 原始方法
    const { rawDOMAppendOrInsertBefore } = opts;
    if (element.tagName) {
      // 解析参数
      // eslint-disable-next-line prefer-const
      let { appName, appWrapperGetter, proxy, singular, dynamicStyleSheetElements } = opts;
      const { scopedCSS, excludeAssetFilter } = opts;

      // 多例模式会走的一段逻辑
      const storedContainerInfo = element[attachElementContainerSymbol];
      if (storedContainerInfo) {
        // eslint-disable-next-line prefer-destructuring
        appName = storedContainerInfo.appName;
        // eslint-disable-next-line prefer-destructuring
        singular = storedContainerInfo.singular;
        // eslint-disable-next-line prefer-destructuring
        appWrapperGetter = storedContainerInfo.appWrapperGetter;
        // eslint-disable-next-line prefer-destructuring
        dynamicStyleSheetElements = storedContainerInfo.dynamicStyleSheetElements;
        // eslint-disable-next-line prefer-destructuring
        proxy = storedContainerInfo.proxy;
      }

      const invokedByMicroApp = singular
        ? // check if the currently specified application is active
          // While we switch page from qiankun app to a normal react routing page, the normal one may load stylesheet dynamically while page rendering,
          // but the url change listener must to wait until the current call stack is flushed.
          // This scenario may cause we record the stylesheet from react routing page dynamic injection,
          // and remove them after the url change triggered and qiankun app is unmouting
          // see https://github.com/ReactTraining/history/blob/master/modules/createHashHistory.js#L222-L230
          checkActivityFunctions(window.location).some(name => name === appName)
        : // have storedContainerInfo means it invoked by a micro app in multiply mode
          !!storedContainerInfo;

      switch (element.tagName) {
        // link 和 style
        case LINK_TAG_NAME:
        case STYLE_TAG_NAME: {
          // 断言,newChild 为 style 或者 link 标签
          const stylesheetElement: HTMLLinkElement | HTMLStyleElement = newChild as any;
          // href 属性
          const { href } = stylesheetElement as HTMLLinkElement;
          if (!invokedByMicroApp || (excludeAssetFilter && href && excludeAssetFilter(href))) {
            // 进来则说明,这个创建元素的动作不是微应用调用的,或者是一个特殊指定不希望被 qiankun 劫持的 link 标签
            // 将其创建到主应用的下
            return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
          }

          // 微应用容器 DOM
          const mountDOM = appWrapperGetter();

          // scoped css
          if (scopedCSS) {
            css.process(mountDOM, stylesheetElement, appName);
          }

          // 将该元素存到样式表中
          // eslint-disable-next-line no-shadow
          dynamicStyleSheetElements.push(stylesheetElement);
          // 参考元素
          const referenceNode = mountDOM.contains(refChild) ? refChild : null;
          // 将该元素在微应用的空间中创建,这样卸载微应用的时候就可以直接一起删除了
          return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
        }

        // script 标签
        case SCRIPT_TAG_NAME: {
          // 链接和文本
          const { src, text } = element as HTMLScriptElement;
          // some script like jsonp maybe not support cors which should't use execScripts
          if (!invokedByMicroApp || (excludeAssetFilter && src && excludeAssetFilter(src))) {
            // 同理,将该标签创建到主应用下
            return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
          }

          // 微应用容器 DOM
          const mountDOM = appWrapperGetter();
          // 用户提供的 fetch 方法
          const { fetch } = frameworkConfiguration;
          // 参考节点
          const referenceNode = mountDOM.contains(refChild) ? refChild : null;

          // 如果 src 存在,则说明是一个外联脚本
          if (src) {
            // 执行远程加载,将 proxy 设置为脚本的全局对象,来达到 JS 隔离的目的
            execScripts(null, [src], proxy, {
              fetch,
              strictGlobal: !singular,
              beforeExec: () => {
                Object.defineProperty(document, 'currentScript', {
                  get(): any {
                    return element;
                  },
                  configurable: true,
                });
              },
              success: () => {
                // we need to invoke the onload event manually to notify the event listener that the script was completed
                // here are the two typical ways of dynamic script loading
                // 1. element.onload callback way, which webpack and loadjs used, see https://github.com/muicss/loadjs/blob/master/src/loadjs.js#L138
                // 2. addEventListener way, which toast-loader used, see https://github.com/pyrsmk/toast/blob/master/src/Toast.ts#L64
                const loadEvent = new CustomEvent('load');
                if (isFunction(element.onload)) {
                  element.onload(patchCustomEvent(loadEvent, () => element));
                } else {
                  element.dispatchEvent(loadEvent);
                }

                element = null;
              },
              error: () => {
                const errorEvent = new CustomEvent('error');
                if (isFunction(element.onerror)) {
                  element.onerror(patchCustomEvent(errorEvent, () => element));
                } else {
                  element.dispatchEvent(errorEvent);
                }

                element = null;
              },
            });

            // 创建一个注释元素,表示该 script 标签被 qiankun 劫持处理了
            const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
            return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode);
          }

          // 说明该 script 是一个内联脚本
          execScripts(null, [`<script>${text}</script>`], proxy, {
            strictGlobal: !singular,
            success: element.onload,
            error: element.onerror,
          });
          // 创建一个注释元素,表示该 script 标签被 qiankun 劫持处理了
          const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
          return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
        }

        default:
          break;
      }
    }

    // 调用原始方法,插入元素
    return rawDOMAppendOrInsertBefore.call(this, element, refChild);
  };
}
getNewRemoveChild
/**
 * 增强 removeChild,让其可以根据情况决定是从主应用中移除指定元素,还是从微应用中移除 script、style、link 元素
 * 如果是被劫持元素,则从微应用中移除,否则从主应用中移除
 * @param opts 
 */
function getNewRemoveChild(opts: {
  appWrapperGetter: CallableFunction;
  headOrBodyRemoveChild: typeof HTMLElement.prototype.removeChild;
}) {
  return function removeChild<T extends Node>(this: HTMLHeadElement | HTMLBodyElement, child: T) {
    // 原始的 removeChild
    const { headOrBodyRemoveChild } = opts;
    try {
      const { tagName } = child as any;
      // 当移除的元素是 script、link、style 之一时特殊处理
      if (isHijackingTag(tagName)) {
        // 微应用容器空间
        let { appWrapperGetter } = opts;

        // 新建时设置的,storedContainerInfo 包含了微应用的一些信息,不过 storedContainerInfo 应该是始终为 undefeind,因为设置位置的代码似乎永远不会被执行
        const storedContainerInfo = (child as any)[attachElementContainerSymbol];
        if (storedContainerInfo) {
          // eslint-disable-next-line prefer-destructuring
          // 微应用的包裹元素,也可以说微应用模版
          appWrapperGetter = storedContainerInfo.appWrapperGetter;
        }

        // 从微应用容器空间中移除该元素
        // container may had been removed while app unmounting if the removeChild action was async
        const container = appWrapperGetter();
        if (container.contains(child)) {
          return rawRemoveChild.call(container, child) as T;
        }
      }
    } catch (e) {
      console.warn(e);
    }

    // 从主应用中移除元素
    return headOrBodyRemoveChild.call(this, child) as T;
  };
}

patchAtMounting

在微应用的挂载阶段会被调用,主要负责给各个全局变量(方法)打 patch

export function patchAtMounting(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  sandbox: SandBox,
  singular: boolean,
  scopedCSS: boolean,
  excludeAssetFilter?: Function,
): Freer[] {
  const basePatchers = [
    // 定时器 patch
    () => patchInterval(sandbox.proxy),
    // 事件监听 patch
    () => patchWindowListener(sandbox.proxy),
    // fix umi bug
    () => patchHistoryListener(),
    // 初始化阶段时的那个 patch
    () => patchDynamicAppend(appName, elementGetter, sandbox.proxy, true, singular, scopedCSS, excludeAssetFilter),
  ];

  const patchersInSandbox = {
    [SandBoxType.LegacyProxy]: [...basePatchers],
    [SandBoxType.Proxy]: [...basePatchers],
    [SandBoxType.Snapshot]: basePatchers,
  };

  return patchersInSandbox[sandbox.type]?.map(patch => patch());
}
patch => patchInterval
/**
 * 定时器 patch,设置定时器时自动记录定时器 id,清除定时器时自动删除已清除的定时器 id,释放 patch 时自动清除所有未被清除的定时器,并恢复定时器方法
 * @param global = windowProxy
 */
export default function patch(global: Window) {
  let intervals: number[] = [];

  // 清除定时器,并从 intervals 中清除已经清除的 定时器 id
  global.clearInterval = (intervalId: number) => {
    intervals = intervals.filter(id => id !== intervalId);
    return rawWindowClearInterval(intervalId);
  };

  // 设置定时器,并记录定时器的 id
  global.setInterval = (handler: Function, timeout?: number, ...args: any[]) => {
    const intervalId = rawWindowInterval(handler, timeout, ...args);
    intervals = [...intervals, intervalId];
    return intervalId;
  };

  // 清除所有的定时器,并恢复定时器方法
  return function free() {
    intervals.forEach(id => global.clearInterval(id));
    global.setInterval = rawWindowInterval;
    global.clearInterval = rawWindowClearInterval;

    return noop;
  };
}
patch => patchWindowListener
/**
 * 监听器 patch,添加事件监听时自动记录事件的回调函数,移除时自动删除事件的回调函数,释放 patch 时自动删除所有的事件监听,并恢复监听函数
 * @param global windowProxy
 */
export default function patch(global: WindowProxy) {
  // 记录各个事件的回调函数
  const listenerMap = new Map<string, EventListenerOrEventListenerObject[]>();

  // 设置监听器
  global.addEventListener = (
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ) => {
    // 从 listenerMap 中获取已经存在的该事件的回调函数
    const listeners = listenerMap.get(type) || [];
    // 保存该事件的所有回调函数
    listenerMap.set(type, [...listeners, listener]);
    // 设置监听
    return rawAddEventListener.call(window, type, listener, options);
  };

  // 移除监听器
  global.removeEventListener = (
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ) => {
    // 从 listenerMap 中移除该事件的指定回调函数
    const storedTypeListeners = listenerMap.get(type);
    if (storedTypeListeners && storedTypeListeners.length && storedTypeListeners.indexOf(listener) !== -1) {
      storedTypeListeners.splice(storedTypeListeners.indexOf(listener), 1);
    }
    // 移除事件监听
    return rawRemoveEventListener.call(window, type, listener, options);
  };

  // 释放 patch,移除所有的事件监听
  return function free() {
    // 移除所有的事件监听
    listenerMap.forEach((listeners, type) =>
      [...listeners].forEach(listener => global.removeEventListener(type, listener)),
    );
    // 恢复监听函数
    global.addEventListener = rawAddEventListener;
    global.removeEventListener = rawRemoveEventListener;

    return noop;
  };
}

链接

  • 微前端专栏

    • 微前端框架 之 qiankun 从入门到源码分析

    • HTML Entry 源码分析

    • 微前端框架 之 single-spa 从入门到精通

  • github

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

手写 Vue2 系列 之 patch —— diff

手写 Vue2 系列 之 patch —— diff

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 手写 Vue2 系列 之 初始渲染 中完成了原始标签、自定义组件、插槽的的初始渲染,当然其中也涉及到 v-bind、v-model、v-on 指令的原理。完成首次渲染之后,接下来就该进行后续的更新了:

响应式数据发生更新 -> setter 拦截到更新操作 -> dep 通知 watcher 执行 update 方法 -> 进而执行 updateComponent 方法更新组件 -> 执行 render 生成新的 vnode -> 将 vnode 传递给 vm._update 方法 -> 调用 patch 方法 -> 执行 patchVnode 进行 DOM diff 操作 -> 完成更新

目标

所以,本篇的目标就是实现 DOM diff,完成后续更新。涉及知识点只有一个:DOM diff。

实现

接下来就开始实现 DOM diff,完成响应式数据的后续更新。

patch

/src/compiler/patch.js

/**
 * 负责组件的首次渲染和后续更新
 * @param {VNode} oldVnode 老的 VNode
 * @param {VNode} vnode 新的 VNode
 */
export default function patch(oldVnode, vnode) {
  if (oldVnode && !vnode) {
    // 老节点存在,新节点不存在,则销毁组件
    return
  }

  if (!oldVnode) { // oldVnode 不存在,说明是子组件首次渲染
  } else {
    if (oldVnode.nodeType) { // 真实节点,则表示首次渲染根组件
    } else {
      // 后续的更新
      patchVnode(oldVnode, vnode)
    }
  }
}

patchVnode

/src/compiler/patch.js

/**
 * 对比新老节点,找出其中的不同,然后更新老节点
 * @param {*} oldVnode 老节点的 vnode
 * @param {*} vnode 新节点的 vnode
 */
function patchVnode(oldVnode, vnode) {
  // 如果新老节点相同,则直接结束
  if (oldVnode === vnode) return

  // 将老 vnode 上的真实节点同步到新的 vnode 上,否则,后续更新的时候会出现 vnode.elm 为空的现象
  vnode.elm = oldVnode.elm

  // 走到这里说明新老节点不一样,则获取它们的孩子节点,比较孩子节点
  const ch = vnode.children
  const oldCh = oldVnode.children

  if (!vnode.text) { // 新节点不存在文本节点
    if (ch && oldCh) { // 说明新老节点都有孩子
      // diff
      updateChildren(ch, oldCh)
    } else if (ch) { // 老节点没孩子,新节点有孩子
      // 增加孩子节点
    } else { // 新节点没孩子,老节点有孩子
      // 删除这些孩子节点
    }
  } else { // 新节点存在文本节点
    if (vnode.text.expression) { // 说明存在表达式
      // 获取表达式的新值
      const value = JSON.stringify(vnode.context[vnode.text.expression])
      // 旧值
      try {
        const oldValue = oldVnode.elm.textContent
        if (value !== oldValue) { // 新老值不一样,则更新
          oldVnode.elm.textContent = value
        }
      } catch {
        // 防止更新时遇到插槽,导致报错
        // 目前不处理插槽数据的响应式更新
      }
    }
  }
}

updateChildren

/src/compiler/patch.js

/**
 * diff,比对孩子节点,找出不同点,然后将不同点更新到老节点上
 * @param {*} ch 新 vnode 的所有孩子节点
 * @param {*} oldCh 老 vnode 的所有孩子节点
 */
function updateChildren(ch, oldCh) {
  // 四个游标
  // 新孩子节点的开始索引,叫 新开始
  let newStartIdx = 0
  // 新结束
  let newEndIdx = ch.length - 1
  // 老开始
  let oldStartIdx = 0
  // 老结束
  let oldEndIdx = oldCh.length - 1
  // 循环遍历新老节点,找出节点中不一样的地方,然后更新
  while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) { // 根为 web 中的 DOM 操作特点,做了四种假设,降低时间复杂度
    // 新开始节点
    const newStartNode = ch[newStartIdx]
    // 新结束节点
    const newEndNode = ch[newEndIdx]
    // 老开始节点
    const oldStartNode = oldCh[oldStartIdx]
    // 老结束节点
    const oldEndNode = oldCh[oldEndIdx]
    if (sameVNode(newStartNode, oldStartNode)) { // 假设新开始和老开始是同一个节点
      // 对比这两个节点,找出不同然后更新
      patchVnode(oldStartNode, newStartNode)
      // 移动游标
      oldStartIdx++
      newStartIdx++
    } else if (sameVNode(newStartNode, oldEndNode)) { // 假设新开始和老结束是同一个节点
      patchVnode(oldEndNode, newStartNode)
      // 将老结束移动到新开始的位置
      oldEndNode.elm.parentNode.insertBefore(oldEndNode.elm, oldCh[newStartIdx].elm)
      // 移动游标
      newStartIdx++
      oldEndIdx--
    } else if (sameVNode(newEndNode, oldStartNode)) { // 假设新结束和老开始是同一个节点
      patchVnode(oldStartNode, newEndNode)
      // 将老开始移动到新结束的位置
      oldStartNode.elm.parentNode.insertBefore(oldStartNode.elm, oldCh[newEndIdx].elm.nextSibling)
      // 移动游标
      newEndIdx--
      oldStartIdx++
    } else if (sameVNode(newEndNode, oldEndNode)) { // 假设新结束和老结束是同一个节点
      patchVnode(oldEndNode, newEndNode)
      // 移动游标
      newEndIdx--
      oldEndIdx--
    } else {
      // 上面几种假设都没命中,则老老实的遍历,找到那个相同元素
    }
  }
  // 跳出循环,说明有一个节点首先遍历结束了
  if (newStartIdx < newEndIdx) { // 说明老节点先遍历结束,则将剩余的新节点添加到 DOM 中

  }
  if (oldStartIdx < oldEndIdx) { // 说明新节点先遍历结束,则将剩余的这些老节点从 DOM 中删掉

  }
}

sameVNode

/src/compiler/patch.js

/**
 * 判断两个节点是否相同
 * 这里的判读比较简单,只做了 key 和 标签的比较
 */
function sameVNode(n1, n2) {
  return n1.key == n2.key && n1.tag === n2.tag
}

结果

好了,到这里,虚拟 DOM 的 diff 过程就完成了,如果你能看到如下效果图,则说明一切正常。

动图地址:https://gitee.com/liyongning/typora-image-bed/raw/master/202203151929235.image

Jun-18-2021 09-11-18.gif

可以看到,页面已经完全做到响应式数据的初始渲染和后续更新。其中关于 Computed 计算属性的内容仍然没有正确的显示出来,这很正常,因为还没实现这个功能,所以接下来就会去实现 conputed 计算属性,也就是下一篇内容 手写 Vue2 系列 之 computed

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

让你的网站加载更快 —— Prefetch 和 Preload 技术详解

让你的网站加载更快 —— Prefetch 和 Preload 技术详解

prefetch 和 preload 是浏览器提供的两种资源预加载技术,它们可以预先请求浏览器可能需要的资源,并将这些资源缓存到本地,以便在页面需要时能够更快地获取,从而显著提高网站性能,优化用户体验。这些资源可以是文本文件、图像、音频或视频等各种类型的文件。

这里提到的 缓存 和 服务器设置的资源缓存不一样,比如 expires、cache-control 等,这里提到的缓存只是提前加载资源缓存到本地,

这里提到的 缓存 只是通过预加载技术将资源提前缓存到本地,只是将资源提前加载回来了,至于具体的缓存策略还是有服务器决定的,比如 nginx 设置 expires 或 cache control。

prefetch 和 preload 之间的主要区别在于:

  • prefetch 利用浏览器的空闲时间,预加载将来可能会被用户访问到的资源,由于是利用浏览器的空闲时间,所以它不会影响当前页的加载性能,当然也不保证预加载的资源一定会被提前缓存,假如浏览器一直很忙
  • preload 用于预加载即将被使用的资源,被标记为 preload 的资源会被优先加载,也就是说它会保证预加载的资源在使用前一定会被提前缓存到本地,所以,如果使用不当,它会影响当前页的加载性能

prefetch

prefetch 可以帮助浏览器在页面加载之前预取用户可能需要的资源,以加快网站的整体加载速度。这些资源包括但不限于图像、脚本和样式表。

prefetch 可以使用 HTM L的 标签实现。例如,下面的代码将预取一个名为 “example.js” 的脚本文件:

<link rel="prefetch" href="example.js" as="script">

prefetch 还可以通过 HTTP 头来实现。例如,下面的代码将使用HTTP头预取一个名为 “example.js” 的脚本文件:

Link: <example.js>; rel="prefetch"

当浏览器遇到这些标签 或 HTTP 头时,它会预取指定的资源,并将它们存储在本地缓存中。这样,当用户浏览网站时,这些资源将能够更快地加载。

值得注意的是,prefetch 并不保证资源的加载顺序或加载时间,也不保证在需要使用之前资源一定会被缓存,因为 prefetch 是在浏览器空闲时间工作,所以如果浏览器一直忙,prefetch 的资源就没机会被加载。

preload

preload 是一种更为复杂的资源预加载技术,它可以在页面加载时预取即将被使用的资源,以加快页面的渲染速度。这些资源包括但不限于图像、脚本和样式表。

preload 可以使用 HTML 的 标签实现。例如,下面的代码将预取一个名为 “example.css” 的样式表文件:

<link rel="preload" href="example.css" as="style">

preload 还可以通过 HTTP 头来实现。例如,下面的代码将使用HTTP头预取一个名为 “example.js” 的脚本文件:

Link: <example.js>; rel="preload"; as="script"

与 prefetch 不同,preload 可以确保资源的加载顺序和时间,并且这些资源在使用前一定会被缓存。但是,preload 也需要谨慎使用,因为标有 preload 的资源会被优先加载,因此它可能会影响页面的加载性能。如果您的网站中有大量资源需要预加载,可能会影响页面的渲染速度。

as 属性

在使用 标签时,as 属性用于指定预加载资源的类型。它告诉浏览器如何处理预加载的资源,并在加载过程中进行优化。以下是一些常见的as属性值:

  • as="script",预加载JavaScript文件
  • as="style",预加载CSS文件
  • as="font",预加载字体文件
  • as="image",预加载图片文件
  • as="audio",预加载音频文件
  • as="video",预加载视频文件
  • as="fetch",预加载数据文件(例如JSON、XML等)。

使用正确的 as 属性可以帮助浏览器更好地优化预加载的资源,并在加载过程中提高性能。例如,如果您预加载的是 CSS 文件,则应将as属性设置为 "style",这将使浏览器在预加载 CSS 文件时执行一些优化,例如提前解析样式并缓存它们。同样,如果您预加载的是字体文件,则应将 as 属性设置为 "font",这将使浏览器在预加载字体文件时执行一些优化,例如提前解码字体并进行缓存。

需要注意的是,使用 标签进行预加载和预取时,必须指定 as 属性来告知浏览器需要预加载或预取的资源类型。如果不指定 as 属性,浏览器将根据文件扩展名来猜测资源类型,这可能会导致预加载和预取失败,所以为了获得最佳的性能和预加载效果,建议始终使用正确的 as 属性。

经过实际测试,发现 preload 不使用 as 属性,观看 network 面板中资源的加载顺序,看起来 preload 像失效了,而且有时候浏览器的 console 会给出告警。

实战

下面我们将通过一个示例来演示 prefetch 和 preload 的相关知识点:

<!DOCTYPE html>
<html lang="en">
<head>
  <style>
    * {
      padding: 0;
      margin: 0;
    }
    body {
      display: flex;
    }
    img {
      width: 500px;
      height: 500px;
    }
  </style>
  <link rel="prefetch" href="https://p5.ssl.qhimg.com/d/inn/a382649bc56a/russian-girl.png" as="image" >
  <link rel="preload" href="https://p4.ssl.qhimg.com/d/inn/19dd63a16a5a/dog.png" as="image" >
</head>
<body style="display: flex;">
  <img src="https://p3.ssl.qhimg.com/d/inn/ab18573862d6/cat.png">
  <img src="https://p4.ssl.qhimg.com/d/inn/19dd63a16a5a/dog.png">
</body>
</html>

代码中有三张图片,这三张图片在代码中由上而下分别是:

  • prefetch 的 russian-girl
  • preload 的 dog
  • img 标签的 cat
  • img 标签的 dog。

再看下图中三张照片的加载顺序:

  • 代码中最上面的 prefetch russian-girl 反而是最后被加载,并且在另外两张图片加载就绪之前始终处于 pending 状态,等另外两张图片加载完成后才加载,表明优先级最低,并且不会占用页面资源
  • 处于最后的 img dog 反而是最先被加载的,然后 img cat 次之,因为 dog.png 通过 preload 做了预加载,表明 preload 的资源会优先被加载

备注: 为了观看加载效果,所以故意把网络调成了 fast 3G,所以图片加载时间比较长。

image

总结

prefetch 和 preload 是两种非常有用的资源预加载技术,可以显著提高网站性能并优化用户体验。使用 prefetch 可以帮助浏览器预取将来可能会被用户访问到的资源,而使用 preload 可以预加载即将被使用的资源。在使用这些技术时,我们需要注意谨慎使用,确保只预加载可能会被用户使用的资源,从而并避免过度预加载导致性能问题。

Vue 源码解读(3)—— 响应式原理

Vue 源码解读(3)—— 响应式原理

当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 Vue 源码解读(2)—— Vue 初始化过程 详细讲解了 Vue 的初始化过程,明白了 new Vue(options) 都做了什么,其中关于 数据响应式 的实现用一句话简单的带过,而这篇文章则会详细讲解 Vue 数据响应式的实现原理。

目标

  • 深入理解 Vue 数据响应式原理。

  • methods、computed 和 watch 有什么区别?

源码解读

经过上一篇文章的学习,相信关于 响应式原理 源码阅读的入口位置大家都已经知道了,就是初始化过程中处理数据响应式这一步,即调用 initState 方法,在 /src/core/instance/init.js 文件中。

initState

/src/core/instance/state.js

/**
 * 两件事:
 *   数据响应式的入口:分别处理 props、methods、data、computed、watch
 *   优先级:props、methods、data、computed 对象中的属性不能出现重复,优先级和列出顺序一致
 *         其中 computed 中的 key 不能和 props、data 中的 key 重复,methods 不影响
 */
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  // 处理 props 对象,为 props 对象的每个属性设置响应式,并将其代理到 vm 实例上
  if (opts.props) initProps(vm, opts.props)
  // 处理 methos 对象,校验每个属性的值是否为函数、和 props 属性比对进行判重处理,最后得到 vm[key] = methods[key]
  if (opts.methods) initMethods(vm, opts.methods)
  /**
   * 做了三件事
   *   1、判重处理,data 对象上的属性不能和 props、methods 对象上的属性相同
   *   2、代理 data 对象上的属性到 vm 实例
   *   3、为 data 对象的上数据设置响应式 
   */
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  /**
   * 三件事:
   *   1、为 computed[key] 创建 watcher 实例,默认是懒执行
   *   2、代理 computed[key] 到 vm 实例
   *   3、判重,computed 中的 key 不能和 data、props 中的属性重复
   */
  if (opts.computed) initComputed(vm, opts.computed)
  /**
   * 三件事:
   *   1、处理 watch 对象
   *   2、为 每个 watch.key 创建 watcher 实例,key 和 watcher 实例可能是 一对多 的关系
   *   3、如果设置了 immediate,则立即执行 回调函数
   */
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
    
  /**
   * 其实到这里也能看出,computed 和 watch 在本质是没有区别的,都是通过 watcher 去实现的响应式
   * 非要说有区别,那也只是在使用方式上的区别,简单来说:
   *   1、watch:适用于当数据变化时执行异步或者开销较大的操作时使用,即需要长时间等待的操作可以放在 watch 中
   *   2、computed:其中可以使用异步方法,但是没有任何意义。所以 computed 更适合做一些同步计算
   */
}

initProps

src/core/instance/state.js

// 处理 props 对象,为 props 对象的每个属性设置响应式,并将其代理到 vm 实例上
function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // 缓存 props 的每个 key,性能优化
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  // 遍历 props 对象
  for (const key in propsOptions) {
    // 缓存 key
    keys.push(key)
    // 获取 props[key] 的默认值
    const value = validateProp(key, propsOptions, propsData, vm)
    // 为 props 的每个 key 是设置数据响应式
    defineReactive(props, key, value)
    // static props are already proxied on the component's prototype
    // during Vue.extend(). We only need to proxy props defined at
    // instantiation here.
    if (!(key in vm)) {
      // 代理 key 到 vm 对象上
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

proxy

/src/core/instance/state.js

// 设置代理,将 key 代理到 target 上
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

initMethods

/src/core/instance/state.js

/**
 * 做了以下三件事,其实最关键的就是第三件事情
 *   1、校验 methoss[key],必须是一个函数
 *   2、判重
 *         methods 中的 key 不能和 props 中的 key 相同
 *         methos 中的 key 与 Vue 实例上已有的方法重叠,一般是一些内置方法,比如以 $ 和 _ 开头的方法
 *   3、将 methods[key] 放到 vm 实例上,得到 vm[key] = methods[key]
 */
function initMethods (vm: Component, methods: Object) {
  // 获取 props 配置项
  const props = vm.$options.props
  // 遍历 methods 对象
  for (const key in methods) {
    if (process.env.NODE_ENV !== 'production') {
      if (typeof methods[key] !== 'function') {
        warn(
          `Method "${key}" has type "${typeof methods[key]}" in the component definition. ` +
          `Did you reference the function correctly?`,
          vm
        )
      }
      if (props && hasOwn(props, key)) {
        warn(
          `Method "${key}" has already been defined as a prop.`,
          vm
        )
      }
      if ((key in vm) && isReserved(key)) {
        warn(
          `Method "${key}" conflicts with an existing Vue instance method. ` +
          `Avoid defining component methods that start with _ or $.`
        )
      }
    }
    vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
  }
}

initData

src/core/instance/state.js

/**
 * 做了三件事
 *   1、判重处理,data 对象上的属性不能和 props、methods 对象上的属性相同
 *   2、代理 data 对象上的属性到 vm 实例
 *   3、为 data 对象的上数据设置响应式 
 */
function initData (vm: Component) {
  // 得到 data 对象
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  /**
   * 两件事
   *   1、判重处理,data 对象上的属性不能和 props、methods 对象上的属性相同
   *   2、代理 data 对象上的属性到 vm 实例
   */
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // 为 data 对象上的数据设置响应式
  observe(data, true /* asRootData */)
}

export function getData (data: Function, vm: Component): any {
  // #7573 disable dep collection when invoking data getters
  pushTarget()
  try {
    return data.call(vm, vm)
  } catch (e) {
    handleError(e, vm, `data()`)
    return {}
  } finally {
    popTarget()
  }
}

initComputed

/src/core/instance/state.js

const computedWatcherOptions = { lazy: true }

/**
 * 三件事:
 *   1、为 computed[key] 创建 watcher 实例,默认是懒执行
 *   2、代理 computed[key] 到 vm 实例
 *   3、判重,computed 中的 key 不能和 data、props 中的属性重复
 * @param {*} computed = {
 *   key1: function() { return xx },
 *   key2: {
 *     get: function() { return xx },
 *     set: function(val) {}
 *   }
 * }
 */
function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()

  // 遍历 computed 对象
  for (const key in computed) {
    // 获取 key 对应的值,即 getter 函数
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }

    if (!isSSR) {
      // 为 computed 属性创建 watcher 实例
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        // 配置项,computed 默认是懒执行
        computedWatcherOptions
      )
    }

    if (!(key in vm)) {
      // 代理 computed 对象中的属性到 vm 实例
      // 这样就可以使用 vm.computedKey 访问计算属性了
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      // 非生产环境有一个判重处理,computed 对象中的属性不能和 data、props 中的属性相同
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      }
    }
  }
}

/**
 * 代理 computed 对象中的 key 到 target(vm)上
 */
export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  // 构造属性描述符(get、set)
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef)
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop
    sharedPropertyDefinition.set = userDef.set || noop
  }
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  // 拦截对 target.key 的访问和设置
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

/**
 * @returns 返回一个函数,这个函数在访问 vm.computedProperty 时会被执行,然后返回执行结果
 */
function createComputedGetter (key) {
  // computed 属性值会缓存的原理也是在这里结合 watcher.dirty、watcher.evalaute、watcher.update 实现的
  return function computedGetter () {
    // 得到当前 key 对应的 watcher
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      // 计算 key 对应的值,通过执行 computed.key 的回调函数来得到
      // watcher.dirty 属性就是大家常说的 computed 计算结果会缓存的原理
      // <template>
      //   <div>{{ computedProperty }}</div>
      //   <div>{{ computedProperty }}</div>
      // </template>
      // 像这种情况下,在页面的一次渲染中,两个 dom 中的 computedProperty 只有第一个
      // 会执行 computed.computedProperty 的回调函数计算实际的值,
      // 即执行 watcher.evalaute,而第二个就不走计算过程了,
      // 因为上一次执行 watcher.evalute 时把 watcher.dirty 置为了 false,
      // 待页面更新后,wathcer.update 方法会将 watcher.dirty 重新置为 true,
      // 供下次页面更新时重新计算 computed.key 的结果
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

/**
 * 功能同 createComputedGetter 一样
 */
function createGetterInvoker(fn) {
  return function computedGetter () {
    return fn.call(this, this)
  }
}

initWatch

/src/core/instance/state.js

/**
 * 处理 watch 对象的入口,做了两件事:
 *   1、遍历 watch 对象
 *   2、调用 createWatcher 函数
 * @param {*} watch = {
 *   'key1': function(val, oldVal) {},
 *   'key2': 'this.methodName',
 *   'key3': {
 *     handler: function(val, oldVal) {},
 *     deep: true
 *   },
 *   'key4': [
 *     'this.methodNanme',
 *     function handler1() {},
 *     {
 *       handler: function() {},
 *       immediate: true
 *     }
 *   ],
 *   'key.key5' { ... }
 * }
 */
function initWatch (vm: Component, watch: Object) {
  // 遍历 watch 对象
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      // handler 为数组,遍历数组,获取其中的每一项,然后调用 createWatcher
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

/**
 * 两件事:
 *   1、兼容性处理,保证 handler 肯定是一个函数
 *   2、调用 $watch 
 * @returns 
 */
function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  // 如果 handler 为对象,则获取其中的 handler 选项的值
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  // 如果 hander 为字符串,则说明是一个 methods 方法,获取 vm[handler]
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

/**
 * 创建 watcher,返回 unwatch,共完成如下 5 件事:
 *   1、兼容性处理,保证最后 new Watcher 时的 cb 为函数
 *   2、标示用户 watcher
 *   3、创建 watcher 实例
 *   4、如果设置了 immediate,则立即执行一次 cb
 *   5、返回 unwatch
 * @param {*} expOrFn key
 * @param {*} cb 回调函数
 * @param {*} options 配置项,用户直接调用 this.$watch 时可能会传递一个 配置项
 * @returns 返回 unwatch 函数,用于取消 watch 监听
 */
Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  // 兼容性处理,因为用户调用 vm.$watch 时设置的 cb 可能是对象
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  // options.user 表示用户 watcher,还有渲染 watcher,即 updateComponent 方法中实例化的 watcher
  options = options || {}
  options.user = true
  // 创建 watcher
  const watcher = new Watcher(vm, expOrFn, cb, options)
  // 如果用户设置了 immediate 为 true,则立即执行一次回调函数
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value)
    } catch (error) {
      handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
    }
  }
  // 返回一个 unwatch 函数,用于解除监听
  return function unwatchFn () {
    watcher.teardown()
  }
}

observe

/src/core/observer/index.js

/**
 * 响应式处理的真正入口
 * 为对象创建观察者实例,如果对象已经被观察过,则返回已有的观察者实例,否则创建新的观察者实例
 * @param {*} value 对象 => {}
 */
export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 非对象和 VNode 实例不做响应式处理
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    // 如果 value 对象上存在 __ob__ 属性,则表示已经做过观察了,直接返回 __ob__ 属性
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    // 创建观察者实例
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

Observer

/src/core/observer/index.js

/**
 * 观察者类,会被附加到每个被观察的对象上,value.__ob__ = this
 * 而对象的各个属性则会被转换成 getter/setter,并收集依赖和通知更新
 */
export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    // 实例话一个 dep
    this.dep = new Dep()
    this.vmCount = 0
    // 在 value 对象上设置 __ob__ 属性
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      /**
       * value 为数组
       * hasProto = '__proto__' in {}
       * 用于判断对象是否存在 __proto__ 属性,通过 obj.__proto__ 可以访问对象的原型链
       * 但由于 __proto__ 不是标准属性,所以有些浏览器不支持,比如 IE6-10,Opera10.1
       * 为什么要判断,是因为一会儿要通过 __proto__ 操作数据的原型链
       * 覆盖数组默认的七个原型方法,以实现数组响应式
       */
      if (hasProto) {
        // 有 __proto__
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      // value 为对象,为对象的每个属性(包括嵌套对象)设置响应式
      this.walk(value)
    }
  }

  /**
   * 遍历对象上的每个 key,为每个 key 设置响应式
   * 仅当值为对象时才会走这里
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * 遍历数组,为数组的每一项设置观察,处理数组元素为对象的情况
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

defineReactive

/src/core/observer/index.js

/**
 * 拦截 obj[key] 的读取和设置操作:
 *   1、在第一次读取时收集依赖,比如执行 render 函数生成虚拟 DOM 时会有读取操作
 *   2、在更新时设置新值并通知依赖更新
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 实例化 dep,一个 key 一个 dep
  const dep = new Dep()

  // 获取 obj[key] 的属性描述符,发现它是不可配置对象的话直接 return
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 记录 getter 和 setter,获取 val 值
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  // 递归调用,处理 val 即 obj[key] 的值为对象的情况,保证对象中的所有 key 都被观察
  let childOb = !shallow && observe(val)
  // 响应式核心
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // get 拦截对 obj[key] 的读取操作
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      /**
       * Dep.target 为 Dep 类的一个静态属性,值为 watcher,在实例化 Watcher 时会被设置
       * 实例化 Watcher 时会执行 new Watcher 时传递的回调函数(computed 除外,因为它懒执行)
       * 而回调函数中如果有 vm.key 的读取行为,则会触发这里的 读取 拦截,进行依赖收集
       * 回调函数执行完以后又会将 Dep.target 设置为 null,避免这里重复收集依赖
       */
      if (Dep.target) {
        // 依赖收集,在 dep 中添加 watcher,也在 watcher 中添加 dep
        dep.depend()
        // childOb 表示对象中嵌套对象的观察者对象,如果存在也对其进行依赖收集
        if (childOb) {
          // 这就是 this.key.chidlKey 被更新时能触发响应式更新的原因
          childOb.dep.depend()
          // 如果是 obj[key] 是 数组,则触发数组响应式
          if (Array.isArray(value)) {
            // 为数组项为对象的项添加依赖
            dependArray(value)
          }
        }
      }
      return value
    },
    // set 拦截对 obj[key] 的设置操作
    set: function reactiveSetter (newVal) {
      // 旧的 obj[key]
      const value = getter ? getter.call(obj) : val
      // 如果新老值一样,则直接 return,不跟新更不触发响应式更新过程
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // setter 不存在说明该属性是一个只读属性,直接 return
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      // 设置新值
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 对新值进行观察,让新值也是响应式的
      childOb = !shallow && observe(newVal)
      // 依赖通知更新
      dep.notify()
    }
  })
}

dependArray

/src/core/observer/index.js

/**
 * 遍历每个数组元素,递归处理数组项为对象的情况,为其添加依赖
 * 因为前面的递归阶段无法为数组中的对象元素添加依赖
 */
function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob__.dep.depend()
    if (Array.isArray(e)) {
      dependArray(e)
    }
  }
}

数组响应式

src/core/observer/array.js

/**
 * 定义 arrayMethods 对象,用于增强 Array.prototype
 * 当访问 arrayMethods 对象上的那七个方法时会被拦截,以实现数组响应式
 */
import { def } from '../util/index'

// 备份 数组 原型对象
const arrayProto = Array.prototype
// 通过继承的方式创建新的 arrayMethods
export const arrayMethods = Object.create(arrayProto)

// 操作数组的七个方法,这七个方法可以改变数组自身
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * 拦截变异方法并触发事件
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  // 缓存原生方法,比如 push
  const original = arrayProto[method]
  // def 就是 Object.defineProperty,拦截 arrayMethods.method 的访问
  def(arrayMethods, method, function mutator (...args) {
    // 先执行原生方法,比如 push.apply(this, args)
    const result = original.apply(this, args)
    const ob = this.__ob__
    // 如果 method 是以下三个之一,说明是新插入了元素
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // 对新插入的元素做响应式处理
    if (inserted) ob.observeArray(inserted)
    // 通知更新
    ob.dep.notify()
    return result
  })
})

def

/src/core/util/lang.js

/**
 * Define a property.
 */
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

protoAugment

/src/core/observer/index.js

/**
 * 设置 target.__proto__ 的原型对象为 src
 * 比如 数组对象,arr.__proto__ = arrayMethods
 */
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

copyAugment

/src/core/observer/index.js

/**
 * 在目标对象上定义指定属性
 * 比如数组:为数组对象定义那七个方法
 */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

Dep

/src/core/observer/dep.js

import type Watcher from './watcher'
import { remove } from '../util/index'
import config from '../config'

let uid = 0

/**
 * 一个 dep 对应一个 obj.key
 * 在读取响应式数据时,负责收集依赖,每个 dep(或者说 obj.key)依赖的 watcher 有哪些
 * 在响应式数据更新时,负责通知 dep 中那些 watcher 去执行 update 方法
 */
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  // 在 dep 中添加 watcher
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  // 像 watcher 中添加 dep
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  /**
   * 通知 dep 中的所有 watcher,执行 watcher.update() 方法
   */
  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    // 遍历 dep 中存储的 watcher,执行 watcher.update()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

/**
 * 当前正在执行的 watcher,同一时间只会有一个 watcher 在执行
 * Dep.target = 当前正在执行的 watcher
 * 通过调用 pushTarget 方法完成赋值,调用 popTarget 方法完成重置(null)
 */
Dep.target = null
const targetStack = []

// 在需要进行依赖收集的时候调用,设置 Dep.target = watcher
export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

// 依赖收集结束调用,设置 Dep.target = null
export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

Watcher

/src/core/observer/watcher.js

/**
 * 一个组件一个 watcher(渲染 watcher)或者一个表达式一个 watcher(用户watcher)
 * 当数据更新时 watcher 会被触发,访问 this.computedProperty 时也会触发 watcher
 */
export default class Watcher {
  vm: Component;
  expression: string;
  cb: Function;
  id: number;
  deep: boolean;
  user: boolean;
  lazy: boolean;
  sync: boolean;
  dirty: boolean;
  active: boolean;
  deps: Array<Dep>;
  newDeps: Array<Dep>;
  depIds: SimpleSet;
  newDepIds: SimpleSet;
  before: ?Function;
  getter: Function;
  value: any;

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    // options
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      // this.getter = function() { return this.xx }
      // 在 this.get 中执行 this.getter 时会触发依赖收集
      // 待后续 this.xx 更新时就会触发响应式
      this.getter = parsePath(expOrFn)
      if (!this.getter) {
        this.getter = noop
        process.env.NODE_ENV !== 'production' && warn(
          `Failed watching path: "${expOrFn}" ` +
          'Watcher only accepts simple dot-delimited paths. ' +
          'For full control, use a function instead.',
          vm
        )
      }
    }
    this.value = this.lazy
      ? undefined
      : this.get()
  }

  /**
   * 执行 this.getter,并重新收集依赖
   * this.getter 是实例化 watcher 时传递的第二个参数,一个函数或者字符串,比如:updateComponent 或者 parsePath 返回的读取 this.xx 属性值的函数
   * 为什么要重新收集依赖?
   *   因为触发更新说明有响应式数据被更新了,但是被更新的数据虽然已经经过 observe 观察了,但是却没有进行依赖收集,
   *   所以,在更新页面时,会重新执行一次 render 函数,执行期间会触发读取操作,这时候进行依赖收集
   */
  get () {
    // 打开 Dep.target,Dep.target = this
    pushTarget(this)
    // value 为回调函数执行的结果
    let value
    const vm = this.vm
    try {
      // 执行回调函数,比如 updateComponent,进入 patch 阶段
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      // 关闭 Dep.target,Dep.target = null
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

  /**
   * Add a dependency to this directive.
   * 两件事:
   *   1、添加 dep 给自己(watcher)
   *   2、添加自己(watcher)到 dep
   */
  addDep (dep: Dep) {
    // 判重,如果 dep 已经存在则不重复添加
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      // 缓存 dep.id,用于判重
      this.newDepIds.add(id)
      // 添加 dep
      this.newDeps.push(dep)
      // 避免在 dep 中重复添加 watcher,this.depIds 的设置在 cleanupDeps 方法中
      if (!this.depIds.has(id)) {
        // 添加 watcher 自己到 dep
        dep.addSub(this)
      }
    }
  }

  /**
   * Clean up for dependency collection.
   */
  cleanupDeps () {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    let tmp = this.depIds
    this.depIds = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.clear()
    tmp = this.deps
    this.deps = this.newDeps
    this.newDeps = tmp
    this.newDeps.length = 0
  }

  /**
   * 根据 watcher 配置项,决定接下来怎么走,一般是 queueWatcher
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      // 懒执行时走这里,比如 computed

      // 将 dirty 置为 true,可以让 computedGetter 执行时重新计算 computed 回调函数的执行结果
      this.dirty = true
    } else if (this.sync) {
      // 同步执行,在使用 vm.$watch 或者 watch 选项时可以传一个 sync 选项,
      // 当为 true 时在数据更新时该 watcher 就不走异步更新队列,直接执行 this.run 
      // 方法进行更新
      // 这个属性在官方文档中没有出现
      this.run()
    } else {
      // 更新时一般都这里,将 watcher 放入 watcher 队列
      queueWatcher(this)
    }
  }

  /**
   * 由 刷新队列函数 flushSchedulerQueue 调用,完成如下几件事:
   *   1、执行实例化 watcher 传递的第二个参数,updateComponent 或者 获取 this.xx 的一个函数(parsePath 返回的函数)
   *   2、更新旧值为新值
   *   3、执行实例化 watcher 时传递的第三个参数,比如用户 watcher 的回调函数
   */
  run () {
    if (this.active) {
      // 调用 this.get 方法
      const value = this.get()
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // 更新旧值为新值
        const oldValue = this.value
        this.value = value

        if (this.user) {
          // 如果是用户 watcher,则执行用户传递的第三个参数 —— 回调函数,参数为 val 和 oldVal
          try {
            this.cb.call(this.vm, value, oldValue)
          } catch (e) {
            handleError(e, this.vm, `callback for watcher "${this.expression}"`)
          }
        } else {
          // 渲染 watcher,this.cb = noop,一个空函数
          this.cb.call(this.vm, value, oldValue)
        }
      }
    }
  }

  /**
   * 懒执行的 watcher 会调用该方法
   *   比如:computed,在获取 vm.computedProperty 的值时会调用该方法
   * 然后执行 this.get,即 watcher 的回调函数,得到返回值
   * this.dirty 被置为 false,作用是页面在本次渲染中只会一次 computed.key 的回调函数,
   *   这也是大家常说的 computed 和 methods 区别之一是 computed 有缓存的原理所在
   * 而页面更新后会 this.dirty 会被重新置为 true,这一步是在 this.update 方法中完成的
   */
  evaluate () {
    this.value = this.get()
    this.dirty = false
  }

  /**
   * Depend on all deps collected by this watcher.
   */
  depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }

  /**
   * Remove self from all dependencies' subscriber list.
   */
  teardown () {
    if (this.active) {
      // remove self from vm's watcher list
      // this is a somewhat expensive operation so we skip it
      // if the vm is being destroyed.
      if (!this.vm._isBeingDestroyed) {
        remove(this.vm._watchers, this)
      }
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)
      }
      this.active = false
    }
  }
}

总结

面试官 问:Vue 响应式原理是怎么实现的?

  • 响应式的核心是通过 Object.defineProperty 拦截对数据的访问和设置

  • 响应式的数据分为两类:

    • 对象,循环遍历对象的所有属性,为每个属性设置 getter、setter,以达到拦截访问和设置的目的,如果属性值依旧为对象,则递归为属性值上的每个 key 设置 getter、setter

      • 访问数据时(obj.key)进行依赖收集,在 dep 中存储相关的 watcher

      • 设置数据时由 dep 通知相关的 watcher 去更新

    • 数组,增强数组的那 7 个可以更改自身的原型方法,然后拦截对这些方法的操作

      • 添加新数据时进行响应式处理,然后由 dep 通知 watcher 去更新

      • 删除数据时,也要由 dep 通知 watcher 去更新

面试官 问:methods、computed 和 watch 有什么区别?

<!DOCTYPE html>
<html lang="en">

<head>
  <title>methods、computed、watch 有什么区别</title>
</head>

<body>
  <div id="app">
    <!-- methods -->
    <div>{{ returnMsg() }}</div>
    <div>{{ returnMsg() }}</div>
    <!-- computed -->
    <div>{{ getMsg }}</div>
    <div>{{ getMsg }}</div>
  </div>
  <script src="../../dist/vue.js"></script>
  <script>
    new Vue({
    el: '#app',
    data: {
      msg: 'test'
    },
    mounted() {
      setTimeout(() => {
        this.msg = 'msg is changed'
      }, 1000)
    },
    methods: {
      returnMsg() {
        console.log('methods: returnMsg')
        return this.msg
      }
    },
    computed: {
      getMsg() {
        console.log('computed: getMsg')
        return this.msg + ' hello computed'
      }
    },
    watch: {
      msg: function(val, oldVal) {
        console.log('watch: msg')
        new Promise(resolve => {
          setTimeout(() => {
            this.msg = 'msg is changed by watch'
          }, 1000)
        })
      }
    }
  })
  </script>
</body>

</html>

点击查看动图演示,动图地址:https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9c957654bb484ae7ba4ace1b912cff03~tplv-k3u1fbpfcp-watermark.awebp

示例其实就是答案了

  • 使用场景

    • methods 一般用于封装一些较为复杂的处理逻辑(同步、异步)

    • computed 一般用于封装一些简单的同步逻辑,将经过处理的数据返回,然后显示在模版中,以减轻模版的重量

    • watch 一般用于当需要在数据变化时执行异步或开销较大的操作

  • 区别

    • methods VS computed

      通过示例会发现,如果在一次渲染中,有多个地方使用了同一个 methods 或 computed 属性,methods 会被执行多次,而 computed 的回调函数则只会被执行一次。

      通过阅读源码我们知道,在一次渲染中,多次访问 computedProperty,只会在第一次执行 computed 属性的回调函数,后续的其它访问,则直接使用第一次的执行结果(watcher.value),而这一切的实现原理则是通过对 watcher.dirty 属性的控制实现的。而 methods,每一次的访问则是简单的方法调用(this.xxMethods)。

    • computed VS watch

      通过阅读源码我们知道,computed 和 watch 的本质是一样的,内部都是通过 Watcher 来实现的,其实没什么区别,非要说区别的化就两点:1、使用场景上的区别,2、computed 默认是懒执行的,切不可更改。

    • methods VS watch

      methods 和 watch 之间其实没什么可比的,完全是两个东西,不过在使用上可以把 watch 中一些逻辑抽到 methods 中,提高代码的可读性。

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

PDF 生成(6)— 服务化、配置化

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

封面.png

回顾

前面我们分别通过 PDF 生成(1)— 开篇PDF 生成(2)— 生成 PDF 文件PDF 生成(3)— 封面、尾页PDF 生成(4)— 目录页PDF 生成(5)— 内容页支持由多页面组成 五篇来讲解 PDF 生成的整个方案,到目前为止,整套方案基本完成了:

  • 我们通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分
  • 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑

至此,PDF 生成的能力齐了,但怎么给用户使用呢?这就是本文要解决的问题了。

简介

前面我们花了大量的精力来完善整个 PDF 生成方案,现在从 PDF 生成角度来说,能力已经齐备,但整个服务以及相关配置都运行在本地,没办法直接给用户使用。

所以本文我们就将 PDF 生成能力通过服务化暴露给用户,相关资源配置化来适配不同的用户。

服务化

通过为项目引入 Koa 框架来对外提供服务。

  • 安装 koa 和 @koa/router,npm i koa @koa/router
  • 新建/server/koa-server.mjs文件

/server/koa-server.mjs:

import Koa from 'koa'
import KoaRouter from '@koa/router'
import { generatePDF } from './index.mjs'

const app = new Koa()
const router = new KoaRouter()

// 当用户请求 http://localhost:3000 时,触发 generatePDF() 函数生成 PDF 文件
router.get('/', function(ctx) {
  generatePDF()

  ctx.body = {
    errno: 0,
    data: [],
    msg: '正在生成 PDF 文件'
  }
})

app.use(router.routes())

app.listen(3000, () => {
  console.log('koa-server start at 3000 port')
})

/server/index.mjs 导出 generatePDF 方法

image.png

通过 node 或 nodemon 执行 /server/koa-server.mjs,然后在浏览器直接访问http://localhost:3000会看到 PDF 生成服务开始运行,并生成 PDF 文件。这样,我们的 PDF 生成能力就实现了对外的服务化

配置化

目前可以发现,PDF 文件的目录页配置、前端页面的 URL 等信息都是写死在代码中的,我们需要将这些信息以接入方为维度进行统一维护,并以服务的形式暴露给 PDF 生成服务。

  • 安装 axios,用来请求配置服务npm i axios
  • 分别对/server/koa-server.mjs/server/index.mjs进行如下改造

/server/koa-server.mjs

import Koa from 'koa'
import KoaRouter from '@koa/router'
import { generatePDF } from './index.mjs'
import axios from 'axios'

const app = new Koa()
const router = new KoaRouter()

// 当用户请求 http://localhost:3000 时,触发 generatePDF() 函数生成 PDF 文件
router.get('/', async function (ctx) {
  const appId = ctx.query.appId
  const { data: configData } = await axios.get(`http://localhost:3000/get-pdf-config?appId=${appId}`)
  // 异常情况
  if (configData.errno) {
    ctx.body = configData
    return
  }

  const { data } = configData
  generatePDF(data)

  ctx.body = {
    errno: 0,
    data: [],
    msg: '正在生成 PDF 文件'
  }
})

// 获取指定 appId 所对应的配置信息
router.get('/get-pdf-config', function (ctx) {
  const pdfConfig = {
    // 为接入方分配唯一的 uuid
    '59edaf80-ca75-8699-7ca7-b8121d01d136': {
      name: 'PDF 生成服务测试',
      // 目录页配置
      dir: [
        { title: '锚点 1', id: 'anchor1' },
        { title: '锚点 2', id: 'anchor2' },
        { title: '第二个内容页 —— 锚点 1', id: 'second-content-page-anchor1' },
        { title: '第二个内容页 —— 锚点 2', id: 'second-content-page-anchor2' },
      ],
      // 接入方的前端页面链接
      pageInfo: {
        // 封面
        "cover": "file:///Users/liyongning/studyspace/generate-pdf/fe/cover.html",
        // 内容页
        "content": [
          "file:///Users/liyongning/studyspace/generate-pdf/fe/exact-page-num.html",
          "file:///Users/liyongning/studyspace/generate-pdf/fe/second-content-page.html"
        ],
        // 尾页
        "lastPage": "file:///Users/liyongning/studyspace/generate-pdf/fe/last-page.html"
      },
      // ... 还可以增加其他配置
    }
  }

  const appId = ctx.query.appId || ''
  if (!pdfConfig[appId]) {
    ctx.body = {
      errno: 100,
      data: [],
      msg: '无效的 appId,请联系服务提供方申请接入'
    }
    return
  }

  ctx.body = {
    errno: 0,
    data: pdfConfig[appId],
    msg: 'success'
  }
})

app.use(router.routes())

app.listen(3000, () => {
  console.log('koa-server start at 3000 port')
})

增加了配置服务/get-pdf-config,并在 PDF 生成服务中调用,获取配置内容,并将配置内容传递给了generatePDF方法。

/server/index.mjs

image.png
image.png
image.png

PDF 的目录页配置、封面、内容页、尾页均改成了使用配置服务传递过来的数据,我们在浏览器访问http://localhost:3000/?appId=59edaf80-ca75-8699-7ca7-b8121d01d136即可生成 PDF 文件,如果访问时没有传 appId 会收到异常提示:

image.png

好了,配置化就讲到这里了,就像代码中提到的一样,所有和配置相关的信息都可以通过配置服务来维护,可根据自己的需求来进行扩充。

并发控制(队列)

现在我们的 PDF 生成能力以服务的形式对外提供,并通过配置服务来维护接入方信息。经过一段时间的推广后,接入的用户越来越多,服务的调用量越来越大,这时候就会遇到服务稳定性的问题。

每个请求我们都会启动一个浏览器,一台 2核 4G内存的机器,三四个并发基本上就超负荷运行了,如果同时有更多的请求过来,直接就宕机了。所以,我们需要为服务增加一个并发控制。思路如下:

  • 给服务增加一个任务队列,这个队列可以通过 kafka 实现,也可以通过 redis 来实现,最差也可以程序自己维护一个单机版的内存队列(不推荐)
  • 每个请求进来时,先入队
  • 当队列中监听到有任务存在时,从队列中取出一个任务然后执行,这个取任务的频率可以由程序自己控制

这样,程序就不会因为请求量过大,而导致机器宕机。基于队列我们也可以做任务失败重试。

任务分类

服务又稳定的运行了一段时间,有天又收到了一个接入申请,这个接入方的使用场景是不定期的生成几千几万份报告,然后将这些报告打包发给销售,让销售进一步跟进用户。

这个需求很合理,但是会对我们现有的服务造成影响,试想,如果这个任务一旦启动,短时间就会在队列中堆积几万个待执行的任务,要消费完这些任务可能需要好几个小时甚至一整天,这会影响其他任务的执行,后入队的任务一直排在队尾,迟迟得不到执行。

这时候,我们就需要对任务进行分类,将任务分为实时和非实时,实时任务进入实时队列,非实时任务进入非实时队列,程序有优先消费实时队列中的任务,当实时队列为空时去消费非实时队列的任务,当两个队列都为空时,程序停止。

其他优化

本系列的重点是演示 PDF 生成的核心思路和逻辑,所以有些地方的代码写的比较简单,比如没有做很好的模块化拆分、异常处理等,但这些完全不影响对整体架构的理解。

技术架构中我们还有一些能力没有实现,比如:

  • PDF 文件上传 S3,并将下载地址回传给接入方
  • 服务的安全校验,可以设置复杂的校验,也可以通过简单的参数签名来做,根据使用场景来决定
  • 告警推送,比如 PDF 文件生成异常告警、PDF 文件下载链接推送入群或发给个人等

剩下的这些功能都依赖一些内网服务,所以这里就没有一一演示了,只提供一些思路,大家可以根据自己的实际情况有选择性的学习和迭代。

部署问题

项目开发结束后,一般都需要部署到服务器上,这时候你可能会遇到一些困难,比如:

  • 启动项目后,会发现有如下报错,其原因是服务器上缺少相关安装包,具体可查看 鼓掌排除 下的 Chrome 无法在 linux 上启动

img

  • 如果遇到如下错误,是因为 nss 库版本过低,可通过 rpm -q nss 命令查看已安装的库信息,然后使用 yum update nss 进行升级

  • 这会儿,服务应该起来了,但执行的时候发现又报错了,这时候需要禁用沙箱,可以查看 鼓掌排除设置 Chrome Linux 沙箱

image.png

  • 这时候 PDF 终于生成了,但可能会发现 — 乱码了,这是字符集问题,即服务器上没有对应的字体库,具体操作参考下面的字体库章节

img

字体库

如果生成的 PDF 文件出现了乱码问题,是因为服务器缺少字体库文件,我们需要为服务器增加相应的字体。比如我们使用的是 PingFang 和思源黑体,去找设计同学要一份字体文件,然后拷贝到 /usr/share/fonts 目录下,其中涉及到如下命令:

  • fc-list | grep 'PingFang SC' 查看是否有该字体库
  • 字体库配置文件 /etc/fonts/fonts.config,打开后发现,这就是为什么把新增字体文件放 /usr/share/fonts 目录的原因

img

  • 新增字体文件后执行 fc-cache -f -v 清空字体缓存,并会生成新的字体缓存

总结

到这里,本文就结束了,我们来简单总结一些:

  • 我们通过 Koa 框架,将 PDF 生成能力以服务的形式对外暴露
  • 通过配置化服务来维护接入方的一些信息,比如业务名称、目录页的配置、PDF 文件封面、内容页、尾页对应的 URL 等,配置化服务配置的内容有很多,根据场景自行扩充
  • 通过队列来做并发控制,保证服务的稳定性
  • 通过对任务进行分类(实时和非实时),来保证实时任务的及时消费,非实时任务的稳定消费
  • 最后给大家提了一些其他可迭代的点,比如文件上传、下载地址回传、服务安全校验、告警推送等

系列总结

如果你完整的阅读了整个系列,那么首先应该为自己鼓掌,毕竟又是成长的一段时间,另外一定要进行实操,光看不实践,学习效果还是会打一定的折扣。接下来我们就对本系列进行一个简单的回顾总结:

  • 首先我们在 PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

感谢大家花时间阅读,希望大家能从本系列学到对自己有用的知识,不论是 PDF 生成本身,还是整个思考迭代过程,亦或者是其中的某些点。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

手写 Vue2 系列 之 编译器

手写 Vue2 系列 之 编译器

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

接下来就要正式进入手写 Vue2 系列了。这里不会从零开始,会基于 lyn-vue 直接进行升级,所以如果你没有阅读过 手写 Vue 系列 之 Vue1.x,请先从这篇文章开始,按照顺序进行学习。

都知道,Vue1 存在的问题就是在大型应用中 Watcher 太多,如果不清楚其原理请查看 手写 Vue 系列 之 Vue1.x

所以在 Vue2 中通过引入了 VNode 和 diff 算法来解决该问题。通过降低 Watcher 的粒度,一个组件对应一个 Watcher(渲染 Watcher),这样就不会出现大型页面 Watcher 太多导致性能下降的问题。

在 Vue1 中,Watcher 和 页面中的响应式数据一一对应,当响应式数据发生改变,Dep 通知 Watcher 完成对应的 DOM 更新。但是在 Vue2 中一个组件对应一个 Watcher,当响应式数据发生改变时,Watcher 并不知道这个响应式数据在组件中的什么位置,那又该如何完成更新呢?

阅读过之前的 源码系列,大家肯定都知道,Vue2 引入了 VNode 和 diff 算法,将组件 编译 成 VNode,每次响应式数据发生变化时,会生成新的 VNode,通过 diff 算法对比新旧 VNode,找出其中发生改变的地方,然后执行对应的 DOM 操作完成更新。

所以,到这里大家也能明白,Vue1 和 Vue2 在核心的数据响应式部分其实没什么变化,主要的变动在编译器部分。

目标

完成 Vue2 编译器的一个简版实现,从字符串模版解析开始,到最终得到 render 函数。

编译器

在手写 Vue1 时,编译器时通过 DOM API 来遍历模版的 DOM 结构来完成的,在 Vue2 中不再使用这种方式,而是和官方一样,直接编译组件的模版字符串,生成 AST,然后从 AST 生成渲染函数。

首先将 Vue1 的 compiler 目录备份,然后新建一个 compiler 目录,作为 Vue2 的编译器目录

mv compiler compiler-vue1 && mkdir compiler

mount

/src/compiler/index.js

/**
 * 编译器
 */
export default function mount(vm) {
  if (!vm.$options.render) { // 没有提供 render 选项,则编译生成 render 函数
    // 获取模版
    let template = ''

    if (vm.$options.template) {
      // 模版存在
      template = vm.$options.template
    } else if (vm.$options.el) {
      // 存在挂载点
      template = document.querySelector(vm.$options.el).outerHTML
      // 在实例上记录挂载点,this._update 中会用到
      vm.$el = document.querySelector(vm.$options.el)
    }

    // 生成渲染函数
    const render = compileToFunction(template)
    // 将渲染函数挂载到 $options 上
    vm.$options.render = render
  }
}

compileToFunction

/src/compiler/compileToFunction.js

/**
 * 解析模版字符串,得到 AST 语法树
 * 将 AST 语法树生成渲染函数
 * @param { String } template 模版字符串
 * @returns 渲染函数
 */
export default function compileToFunction(template) {
  // 解析模版,生成 ast
  const ast = parse(template)
  // 将 ast 生成渲染函数
  const render = generate(ast)
  return render
}

parse

/src/compiler/parse.js

/**
 * 解析模版字符串,生成 AST 语法树
 * @param {*} template 模版字符串
 * @returns {AST} root ast 语法树
 */
export default function parse(template) {
  // 存放所有的未配对的开始标签的 AST 对象
  const stack = []
  // 最终的 AST 语法树
  let root = null

  let html = template
  while (html.trim()) {
    // 过滤注释标签
    if (html.indexOf('<!--') === 0) {
      // 说明开始位置是一个注释标签,忽略掉
      html = html.slice(html.indexOf('-->') + 3)
      continue
    }
    // 匹配开始标签
    const startIdx = html.indexOf('<')
    if (startIdx === 0) {
      if (html.indexOf('</') === 0) {
        // 说明是闭合标签
        parseEnd()
      } else {
        // 处理开始标签
        parseStartTag()
      }
    } else if (startIdx > 0) {
      // 说明在开始标签之间有一段文本内容,在 html 中找到下一个标签的开始位置
      const nextStartIdx = html.indexOf('<')
      // 如果栈为空,则说明这段文本不属于任何一个元素,直接丢掉,不做处理
      if (stack.length) {
        // 走到这里说说明栈不为空,则处理这段文本,并将其放到栈顶元素的肚子里
        processChars(html.slice(0, nextStartIdx))
      }
      html = html.slice(nextStartIdx)
    } else {
      // 说明没有匹配到开始标签,整个 html 就是一段文本
    }
  }
  return root
  
  // parseStartTag 函数的声明
  // ...
  // processElement 函数的声明
}

// processVModel 函数的声明
// ...
// processVOn 函数的声明

parseStartTag

/src/compiler/parse.js

/**
 * 解析开始标签
 * 比如: <div id="app">...</div>
 */
function parseStartTag() {
  // 先找到开始标签的结束位置 >
  const end = html.indexOf('>')
  // 解析开始标签里的内容 <内容>,标签名 + 属性,比如: div id="app"
  const content = html.slice(1, end)
  // 截断 html,将上面解析的内容从 html 字符串中删除
  html = html.slice(end + 1)
  // 找到 第一个空格位置
  const firstSpaceIdx = content.indexOf(' ')
  // 标签名和属性字符串
  let tagName = '', attrsStr = ''
  if (firstSpaceIdx === -1) {
    // 没有空格,则认为 content 就是标签名,比如 <h3></h3> 这种情况,content = h3
    tagName = content
    // 没有属性
    attrsStr = ''
  } else {
    tagName = content.slice(0, firstSpaceIdx)
    // content 的剩下的内容就都是属性了,比如 id="app" xx=xx
    attrsStr = content.slice(firstSpaceIdx + 1)
  }
  // 得到属性数组,[id="app", xx=xx]
  const attrs = attrsStr ? attrsStr.split(' ') : []
  // 进一步解析属性数组,得到一个 Map 对象
  const attrMap = parseAttrs(attrs)
  // 生成 AST 对象
  const elementAst = generateAST(tagName, attrMap)
  // 如果根节点不存在,说明当前节点为整个模版的第一个节点
  if (!root) {
    root = elementAst
  }
  // 将 ast 对象 push 到栈中,当遇到结束标签的时候就将栈顶的 ast 对象 pop 出来,它两就是一对儿
  stack.push(elementAst)

  // 自闭合标签,则直接调用 end 方法,进入闭合标签的处理截断,就不入栈了
  if (isUnaryTag(tagName)) {
    processElement()
  }
}

parseEnd

/src/compiler/parse.js

/**
 * 处理结束标签,比如: <div id="app">...</div>
 */
function parseEnd() {
  // 将结束标签从 html 字符串中截掉
  html = html.slice(html.indexOf('>') + 1)
  // 处理栈顶元素
  processElement()
}

parseAttrs

/src/compiler/parse.js

/**
 * 解析属性数组,得到一个属性 和 值组成的 Map 对象
 * @param {*} attrs 属性数组,[id="app", xx="xx"]
 */
function parseAttrs(attrs) {
  const attrMap = {}
  for (let i = 0, len = attrs.length; i < len; i++) {
    const attr = attrs[i]
    const [attrName, attrValue] = attr.split('=')
    attrMap[attrName] = attrValue.replace(/"/g, '')
  }
  return attrMap
}

generateAST

/src/compiler/parse.js

/**
 * 生成 AST 对象
 * @param {*} tagName 标签名
 * @param {*} attrMap 标签组成的属性 map 对象
 */
function generateAST(tagName, attrMap) {
  return {
    // 元素节点
    type: 1,
    // 标签
    tag: tagName,
    // 原始属性 map 对象,后续还需要进一步处理
    rawAttr: attrMap,
    // 子节点
    children: [],
  }
}

processChars

/src/compiler/parse.js

/**
 * 处理文本
 * @param {string} text 
 */
function processChars(text) {
  // 去除空字符或者换行符的情况
  if (!text.trim()) return

  // 构造文本节点的 AST 对象
  const textAst = {
    type: 3,
    text,
  }
  if (text.match(/{{(.*)}}/)) {
    // 说明是表达式
    textAst.expression = RegExp.$1.trim()
  }
  // 将 ast 放到栈顶元素的肚子里
  stack[stack.length - 1].children.push(textAst)
}

processElement

/src/compiler/parse.js

/**
 * 处理元素的闭合标签时会调用该方法
 * 进一步处理元素上的各个属性,将处理结果放到 attr 属性上
 */
function processElement() {
  // 弹出栈顶元素,进一步处理该元素
  const curEle = stack.pop()
  const stackLen = stack.length
  // 进一步处理 AST 对象中的 rawAttr 对象 { attrName: attrValue, ... }
  const { tag, rawAttr } = curEle
  // 处理结果都放到 attr 对象上,并删掉 rawAttr 对象中相应的属性
  curEle.attr = {}
  // 属性对象的 key 组成的数组
  const propertyArr = Object.keys(rawAttr)

  if (propertyArr.includes('v-model')) {
    // 处理 v-model 指令
    processVModel(curEle)
  } else if (propertyArr.find(item => item.match(/^v-bind:(.*)/))) {
    // 处理 v-bind 指令,比如 <span v-bind:test="xx" />
    processVBind(curEle, RegExp.$1, rawAttr[`v-bind:${RegExp.$1}`])
  } else if (propertyArr.find(item => item.match(/^v-on:(.*)/))) {
    // 处理 v-on 指令,比如 <button v-on:click="add"> add </button>
    processVOn(curEle, RegExp.$1, rawAttr[`v-on:${RegExp.$1}`])
  }

  // 节点处理完以后让其和父节点产生关系
  if (stackLen) {
    stack[stackLen - 1].children.push(curEle)
    curEle.parent = stack[stackLen - 1]
  }
}

processVModel

/src/compiler/parse.js

/**
 * 处理 v-model 指令,将处理结果直接放到 curEle 对象身上
 * @param {*} curEle 
 */
function processVModel(curEle) {
  const { tag, rawAttr, attr } = curEle
  const { type, 'v-model': vModelVal } = rawAttr

  if (tag === 'input') {
    if (/text/.test(type)) {
      // <input type="text" v-model="inputVal" />
      attr.vModel = { tag, type: 'text', value: vModelVal }
    } else if (/checkbox/.test(type)) {
      // <input type="checkbox" v-model="isChecked" />
      attr.vModel = { tag, type: 'checkbox', value: vModelVal }
    }
  } else if (tag === 'textarea') {
    // <textarea v-model="test" />
    attr.vModel = { tag, value: vModelVal }
  } else if (tag === 'select') {
    // <select v-model="selectedValue">...</select>
    attr.vModel = { tag, value: vModelVal }
  }
}

processVBind

/src/compiler/parse.js

/**
 * 处理 v-bind 指令
 * @param {*} curEle 当前正在处理的 AST 对象
 * @param {*} bindKey v-bind:key 中的 key
 * @param {*} bindVal v-bind:key = val 中的 val
 */
function processVBind(curEle, bindKey, bindVal) {
  curEle.attr.vBind = { [bindKey]: bindVal }
}

processVOn

/src/compiler/parse.js

/**
 * 处理 v-on 指令
 * @param {*} curEle 当前被处理的 AST 对象
 * @param {*} vOnKey v-on:key 中的 key
 * @param {*} vOnVal v-on:key="val" 中的 val
 */
function processVOn(curEle, vOnKey, vOnVal) {
  curEle.attr.vOn = { [vOnKey]: vOnVal }
}

isUnaryTag

/src/utils.js

/**
 * 是否为自闭合标签,内置一些自闭合标签,为了处理简单
 */
export function isUnaryTag(tagName) {
  const unaryTag = ['input']
  return unaryTag.includes(tagName)
}

generate

/src/compiler/generate.js

/**
 * 从 ast 生成渲染函数
 * @param {*} ast ast 语法树
 * @returns 渲染函数
 */
export default function generate(ast) {
  // 渲染函数字符串形式
  const renderStr = genElement(ast)
  // 通过 new Function 将字符串形式的函数变成可执行函数,并用 with 为渲染函数扩展作用域链
  return new Function(`with(this) { return ${renderStr} }`)
}

genElement

/src/compiler/generate.js

/**
 * 解析 ast 生成 渲染函数
 * @param {*} ast 语法树 
 * @returns {string} 渲染函数的字符串形式
 */
function genElement(ast) {
  const { tag, rawAttr, attr } = ast

  // 生成属性 Map 对象,静态属性 + 动态属性
  const attrs = { ...rawAttr, ...attr }

  // 处理子节点,得到一个所有子节点渲染函数组成的数组
  const children = genChildren(ast)

  // 生成 VNode 的可执行方法
  return `_c('${tag}', ${JSON.stringify(attrs)}, [${children}])`
}

genChildren

/src/compiler/generate.js

/**
 * 处理 ast 节点的子节点,将子节点变成渲染函数
 * @param {*} ast 节点的 ast 对象 
 * @returns [childNodeRender1, ....]
 */
function genChildren(ast) {
  const ret = [], { children } = ast
  // 遍历所有的子节点
  for (let i = 0, len = children.length; i < len; i++) {
    const child = children[i]
    if (child.type === 3) {
      // 文本节点
      ret.push(`_v(${JSON.stringify(child)})`)
    } else if (child.type === 1) {
      // 元素节点
      ret.push(genElement(child))
    }
  }
  return ret
}

结果

mount 方法中加一句 console.log(vm.$options.render),打开控制台,刷新页面,看到如下内容,说明编译器就完成了

image.png

接下来就会进入正式的挂载阶段,完成页面的初始渲染。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(9)—— 编译器 之 优化

Vue 源码解读(9)—— 编译器 之 优化

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 Vue 源码解读(8)—— 编译器 之 解析 详细详解了编译器的第一部分,如何将 html 模版字符串编译成 AST。今天带来编译器的第二部分,优化 AST,也是大家常说的静态标记。

目标

深入理解编译器的静态标记过程

源码解读

入口

/src/compiler/index.js

/**
 * 在这之前做的所有的事情,只有一个目的,就是为了构建平台特有的编译选项(options),比如 web 平台
 * 
 * 1、将 html 模版解析成 ast
 * 2、对 ast 树进行静态标记
 * 3、将 ast 生成渲染函数
 *    静态渲染函数放到  code.staticRenderFns 数组中
 *    code.render 为动态渲染函数
 *    在将来渲染时执行渲染函数得到 vnode
 */
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 将模版解析为 AST,每个节点的 ast 对象上都设置了元素的所有信息,比如,标签信息、属性信息、插槽信息、父节点、子节点等。
  // 具体有那些属性,查看 start 和 end 这两个处理开始和结束标签的方法
  const ast = parse(template.trim(), options)
  // 优化,遍历 AST,为每个节点做静态标记
  // 标记每个节点是否为静态节点,然后进一步标记出静态根节点
  // 这样在后续更新的过程中就可以跳过这些静态节点了
  // 标记静态根,用于生成渲染函数阶段,生成静态根节点的渲染函数
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  // 从 AST 生成渲染函数,生成像这样的代码,比如:code.render = "_c('div',{attrs:{"id":"app"}},_l((arr),function(item){return _c('div',{key:item},[_v(_s(item))])}),0)"
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

optimize

/src/compiler/optimizer.js

/**
 * 优化:
 *   遍历 AST,标记每个节点是静态节点还是动态节点,然后标记静态根节点
 *   这样在后续更新的过程中就不需要再关注这些节点
 */
export function optimize(root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  /**
   * options.staticKeys = 'staticClass,staticStyle'
   * isStaticKey = function(val) { return map[val] }
   */
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  // 平台保留标签
  isPlatformReservedTag = options.isReservedTag || no
  // 遍历所有节点,给每个节点设置 static 属性,标识其是否为静态节点
  markStatic(root)
  // 进一步标记静态根,一个节点要成为静态根节点,需要具体以下条件:
  // 节点本身是静态节点,而且有子节点,而且子节点不只是一个文本节点,则标记为静态根
  // 静态根节点不能只有静态文本的子节点,因为这样收益太低,这种情况下始终更新它就好了
  markStaticRoots(root, false)
}

markStatic

/src/compiler/optimizer.js

/**
 * 在所有节点上设置 static 属性,用来标识是否为静态节点
 * 注意:如果有子节点为动态节点,则父节点也被认为是动态节点
 * @param {*} node 
 * @returns 
 */
function markStatic(node: ASTNode) {
  // 通过 node.static 来标识节点是否为 静态节点
  node.static = isStatic(node)
  if (node.type === 1) {
    /**
     * 不要将组件的插槽内容设置为静态节点,这样可以避免:
     *   1、组件不能改变插槽节点
     *   2、静态插槽内容在热重载时失败
     */
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      // 递归终止条件,如果节点不是平台保留标签  && 也不是 slot 标签 && 也不是内联模版,则直接结束
      return
    }
    // 遍历子节点,递归调用 markStatic 来标记这些子节点的 static 属性
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      // 如果子节点是非静态节点,则将父节点更新为非静态节点
      if (!child.static) {
        node.static = false
      }
    }
    // 如果节点存在 v-if、v-else-if、v-else 这些指令,则依次标记 block 中节点的 static
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}

isStatic

/src/compiler/optimizer.js

/**
 * 判断节点是否为静态节点:
 *  通过自定义的 node.type 来判断,2: 表达式 => 动态,3: 文本 => 静态
 *  凡是有 v-bind、v-if、v-for 等指令的都属于动态节点
 *  组件为动态节点
 *  父节点为含有 v-for 指令的 template 标签,则为动态节点
 * @param {*} node 
 * @returns boolean
 */
function isStatic(node: ASTNode): boolean {
  if (node.type === 2) { // expression
    // 比如:{{ msg }}
    return false
  }
  if (node.type === 3) { // text
    return true
  }
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) &&
    Object.keys(node).every(isStaticKey)
  ))
}

markStaticRoots

/src/compiler/optimizer.js

/**
 * 进一步标记静态根,一个节点要成为静态根节点,需要具体以下条件:
 * 节点本身是静态节点,而且有子节点,而且子节点不只是一个文本节点,则标记为静态根
 * 静态根节点不能只有静态文本的子节点,因为这样收益太低,这种情况下始终更新它就好了
 * 
 * @param { ASTElement } node 当前节点
 * @param { boolean } isInFor 当前节点是否被包裹在 v-for 指令所在的节点内
 */
function markStaticRoots(node: ASTNode, isInFor: boolean) {
  if (node.type === 1) {
    if (node.static || node.once) {
      // 节点是静态的 或者 节点上有 v-once 指令,标记 node.staticInFor = true or false
      node.staticInFor = isInFor
    }

    if (node.static && node.children.length && !(
      node.children.length === 1 &&
      node.children[0].type === 3
    )) {
      // 节点本身是静态节点,而且有子节点,而且子节点不只是一个文本节点,则标记为静态根 => node.staticRoot = true,否则为非静态根
      node.staticRoot = true
      return
    } else {
      node.staticRoot = false
    }
    // 当前节点不是静态根节点的时候,递归遍历其子节点,标记静态根
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for)
      }
    }
    // 如果节点存在 v-if、v-else-if、v-else 指令,则为 block 节点标记静态根
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block, isInFor)
      }
    }
  }
}

总结

  • 面试官 问:简单说一下 Vue 的编译器都做了什么?

    Vue 的编译器做了三件事情:

    • 将组件的 html 模版解析成 AST 对象

    • 优化,遍历 AST,为每个节点做静态标记,标记其是否为静态节点,然后进一步标记出静态根节点,这样在后续更新的过程中就可以跳过这些静态节点了;标记静态根用于生成渲染函数阶段,生成静态根节点的渲染函数

    • 从 AST 生成运行渲染函数,即大家说的 render,其实还有一个,就是 staticRenderFns 数组,里面存放了所有的静态节点的渲染函数


  • 面试官:详细说一下静态标记的过程

    • 标记静态节点

      • 通过递归的方式标记所有的元素节点

      • 如果节点本身是静态节点,但是存在非静态的子节点,则将节点修改为非静态节点

    • 标记静态根节点,基于静态节点,进一步标记静态根节点

      • 如果节点本身是静态节点 && 而且有子节点 && 子节点不全是文本节点,则标记为静态根节点

      • 如果节点本身不是静态根节点,则递归的遍历所有子节点,在子节点中标记静态根


  • 面试官:什么样的节点才可以被标记为静态节点?

    • 文本节点

    • 节点上没有 v-bind、v-for、v-if 等指令

    • 非组件

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

微前端框架 之 single-spa 从入门到精通

微前端框架 之 single-spa 从入门到精通

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

logo

简介

从基本使用 -> 部署 -> 框架源码分析 -> 手写框架,带你全方位刨析 single-spa 框架

前序

目的

  • 会使用single-spa开发项目,然后打包部署上线

  • 刨析single-spa的源码原理

  • 手写一个自己的single-spa框架

过程

  • 编写示例项目

  • 打包部署

  • 框架源码解读

  • 手写框架

关于微前端的介绍这里就不再赘述了,网上有很多的文章,本文的重点在于刨析微前端框架single-spa的实现原理。

single-spa是一个很好的微前端基础框架,qiankun框架就是基于single-spa来实现的,在single-spa的基础上做了一层封装,也解决了single-spa的一些缺陷。

因为single-spa是一个基础的微前端框架,了解了它的实现原理,再去看其它的微前端框架,就会非常容易了。

提示

  • 先熟悉基本使用,熟悉常用的API,可通过示例项目 + 官网相结合来达成

  • 如果基础比较好,可以先读后面的手写 single-spa 框架部分,再回来阅读源码,效果可能会更好

  • 文章中涉及到的所有代码都在 github(示例项目 + single-spa源码分析 + 手写single-spa框架 + single-spa-vue源码分析)

示例项目

新建项目目录,接下来的所有代码都会在该目录中完成

mkdir micro-frontend && cd micro-frontend

示例代码都是通过vue来编写的,当然也可以采用其它的,比如react或者原生JS

子应用 app1

新建子应用

vue create app1

按图选择,去除一切项目不需要的干扰项,后面一路回车,等待应用创建完毕

image-20220202191145405

image-20220202191232287

配置子应用

以下所有的操作都在项目根目录/micro-frontend/app1下完成

vue.config.js

在项目根目录下新建vue.config.js文件

const package = require('./package.json')
module.exports = {
  // 告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载
  publicPath: '//localhost:8081',
  // 开发服务器
  devServer: {
    port: 8081
  },
  configureWebpack: {
    // 导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个全局对象获取一些信息,比如子应用导出的生命周期函数
    output: {
      // library的值在所有子应用中需要唯一
      library: package.name,
      libraryTarget: 'umd'
    }
  }
}
安装single-spa-vue
npm i single-spa-vue -S

single-spa-vue负责为vue应用生成通用的生命周期钩子,在子应用注册到single-spa的基座应用时需要用到

改造入口文件
// /src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'

Vue.config.productionTip = false

const appOptions = {
  el: '#microApp',
  router,
  render: h => h(App)
}

// 支持应用独立运行、部署,不依赖于基座应用
if (!window.singleSpaNavigate) {
  delete appOptions.el
  new Vue(appOptions).$mount('#app')
}

// 基于基座应用,导出生命周期函数
const vueLifecycle = singleSpaVue({
  Vue,
  appOptions
})

export function bootstrap (props) {
  console.log('app1 bootstrap')
  return vueLifecycle.bootstrap(() => {})
}

export function mount (props) {
  console.log('app1 mount')
  return vueLifecycle.mount(() => {})
}

export function unmount (props) {
  console.log('app1 unmount')
  return vueLifecycle.unmount(() => {})
}
更改视图文件
<!-- /views/Home.vue -->
<template>
  <div class="home">
    <h1>app1 home page</h1>
  </div>
</template>

<!-- /views/About.vue -->
<template>
  <div class="about">
    <h1>app1 about page</h1>
  </div>
</template>

环境配置文件

.env

应用独立运行时的开发环境配置

NODE_ENV=development
VUE_APP_BASE_URL=/
.env.micro

作为子应用运行时的开发环境配置

NODE_ENV=development
VUE_APP_BASE_URL=/app1
.env.buildMicro

作为子应用构建生产环境bundle时的环境配置,但这里的NODE_ENVdevelopment,而不是production,是为了方便,这个方便其实single-spa带来的弊端(js entry的弊端)

NODE_ENV=development
VUE_APP_BASE_URL=/app1
修改路由文件
// /src/router/index.js
// ...
const router = new VueRouter({
  mode: 'history',
  // 通过环境变量来配置路由的 base url
  base: process.env.VUE_APP_BASE_URL,
  routes
})
// ...
修改package.json中的script
{
  "name": "app1",
  // ...
  "scripts": {
    // 独立运行
    "serve": "vue-cli-service serve",
    // 作为子应用运行
    "serve:micro": "vue-cli-service serve --mode micro",
    // 构建子应用
    "build": "vue-cli-service build --mode buildMicro"
  },
 	// ...
}

启动应用

应用独立运行
npm run serve

当然下面的启动方式也可以,只不过会在pathname的开头加了/app1前缀

npm run serve:micro
作为子应用运行
npm run serve:micro
作为独立应用访问

子应用 app2

/micro-frontend目录下新建子应用app2,步骤同app1,只需把过程中出现的'app1'字样改成'app2'即可,vue.config.js中的8081改成8082`

启动应用,作为独立应用访问

子应用 app3(react)

这部分内容于2020/08/30添加,为什么后来添加这部分内容呢?是因为有同学希望增加一个react项目的示例,他们在集成react项目时遇到了一些困难,于是找时间就加了这部分内容;发现网上single-spa集成react的示例非常少,仅有的几个看了下也是对官网示例的抄写。

示例项目是基于react脚手架cra创建的,整个集成的过程中难点有两个:

  • webpack的配置,这部分内容官网有提供

  • 子应用入口的配置,单纯看官方文档的示例项目根本跑不起来,或者即使跑起来也有问题,reactvue的集成还不一样,react需要在主项目的配置中也加一点东西,这部分官网配置没说,是通过single-spa-react源码看出来的

接下来就开始吧,在/micro-frontend目录下通过cra脚手架新建子应用app3

安装 app3

create-react-app app3

以下所有操作都在/micro-frontend/app3目录下进行

安装react-router-domsingle-spa-react

npm i react-router-dom single-spa-react -S

打散配置

打散项目的配置,方便更改webpack的配置内容,当然通过react-app-rewired覆写默认配置应该也是可以的,官网也有提到,不过我这里没试,采用的是直接打散配置

npm run eject

更改 webpack 配置文件

/config/webpack.config.js,官网
  • 删掉optimization部分,这部分配置和chunk有关,有动态生成的异步chunk存在,会导致主应用无法配置,因为chunk的名字会变,其实这也是single-spa的缺陷,或者说采用JS entry的缺陷,JS entry建议将所有内容都打成一个bundle - app.js

  • 更改entryoutput部分

{
  ...
  entry: [
      paths.appIndexJs,
    ].filter(Boolean),
  output: {
    path: isEnvProduction ? paths.appBuild : undefined,
    filename: 'js/app.js',
    publicPath: '//localhost:3000',
    jsonpFunction: `webpackJsonp${appPackageJson.name}`,
    library: 'app3',
    libraryTarget: 'umd'
  },
  ...
}

项目入口文件改造

我这里将无关紧要的内容都删了,只留了/src/index.js/src/index.css

/src/index.js

由于文章内容太多,字数超出限制,这部分代码就通过图片的形式来展示了,如果需要拷贝可去 github

/src/index.css
body {
  text-align: center;
}

启动子应用

npm run start

浏览器访问localhost:3000

基座应用 layout

/micro-frontend目录下新建基座应用,为了简洁明了,新建项目时选择的配置项和子应用一样;在本示例中基座应用采用了vue来实现,用别的方式或者框架实现也可以,比如自己用webpack构建一个项目。

以下操作都在/micro-frontend/layout目录下进行

安装single-spa

npm i single-spa -S

改造基座项目

入口文件
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerApplication, start } from 'single-spa'

Vue.config.productionTip = false

// 远程加载子应用
function createScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url
    script.onload = resolve
    script.onerror = reject
    const firstScript = document.getElementsByTagName('script')[0]
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}

// 记载函数,返回一个 promise
function loadApp(url, globalVar) {
  // 支持远程加载子应用
  return async () => {
    await createScript(url + '/js/chunk-vendors.js')
    await createScript(url + '/js/app.js')
    // 这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数
    return window[globalVar]
  }
}

// 子应用列表
const apps = [
  {
    // 子应用名称
    name: 'app1',
    // 子应用加载函数,是一个promise
    app: loadApp('http://localhost:8081', 'app1'),
    // 当路由满足条件时(返回true),激活(挂载)子应用
    activeWhen: location => location.pathname.startsWith('/app1'),
    // 传递给子应用的对象
    customProps: {}
  },
  {
    name: 'app2',
    app: loadApp('http://localhost:8082', 'app2'),
    activeWhen: location => location.pathname.startsWith('/app2'),
    customProps: {}
  },
  {
    // 子应用名称
    name: 'app3',
    // 子应用加载函数,是一个promise
    app: loadApp('http://localhost:3000', 'app3'),
    // 当路由满足条件时(返回true),激活(挂载)子应用
    activeWhen: location => location.pathname.startsWith('/app3'),
    // 传递给子应用的对象,这个很重要,该配置告诉react子应用自己的容器元素是什么,这块儿和vue子应用的集成不一样,官网并没有说这部分,或者我没找到,是通过看single-spa-react源码知道的
    customProps: {
      domElement: document.getElementById('microApp'),
      // 添加 name 属性是为了兼容自己写的lyn-single-spa,原生的不需要,当然加了也不影响
      name: 'app3'
    }
  }
]

// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {
  registerApplication(apps[i])
}

new Vue({
  router,
  mounted() {
    // 启动
    start()
  },
  render: h => h(App)
}).$mount('#app')
App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/app1">app1</router-link> |
      <router-link to="/app2">app2</router-link>
    </div>
    <!-- 子应用容器 -->
    <div id = "microApp">
      <router-view/>
    </div>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

#nav {
  padding: 30px;
}

#nav a {
  font-weight: bold;
  color: #2c3e50;
}

#nav a.router-link-exact-active {
  color: #42b983;
}
</style>
路由
import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = []

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

启动基座应用

npm run serve

浏览器访问基座应用

image-20220202191442761

image-20220202191519804

终于看到了结果。

小技巧

有时候single-spa可能会报一些我们现在无法理解的错误,我们可能需要去做代码调试,阅读源码时碰到不理解的地方也需要编写示例 + 单步调试,但是默认的是已经打包压缩后的代码,不太方便做这些,大家可以在node_modules目录找到single-spa目录,把目录下的package.json中的module字段的值改为lib/single-spa.dev.js,这是一个未压缩的bundle,利于代码的阅读的调试,当然需要重启应用。

子应用也是一样类似的技巧,因为single-spa-vue就一个文件,可以直接拷贝出来放到项目的/src目录下,将main.js中的引入的single-spa-vue改成当前目录即可。

打包部署

打包

在各个项目的根目录下分别执行

npm run build

部署

可以将打包后的bundle发布到nginx服务器上,这个nginx服务器可以是单独的服务器、或者虚拟机、亦或是docker容器都行,这里采用serve在本地模拟部署

如果你有条件部署到nginx上,需要注意nginx的代理配置

  • 对于子应用静态资源的加载只需要拦截相应的前缀将请求转发到对应子应用的目录下即可
  • 页面刷新只需要拦截到主应用即可,主应用内部自己根据activeWhen去挂载对应的子应用

全局安装 serve

npm i serve -g

在各个项目的根目录下启动 serve

serve ./dist -p port

在浏览器访问基座应用的地址,发现得到和刚才一样的结果

single-spa 源码分析

整个阅读过程以示例项目为例,阅读源码时一定要多动手写注释、做笔记,遇到不理解的地方编写示例代码 + console.log + 单步调试,切记不要只看不动手

single-spa 源码阅读思维导图

这是我在阅读时整理的一个思维导图,源码中也写了大量的注释,大家可以参照着进行阅读。Ok !!这就开始吧

从源码目录中可以看到,single-spa是使用rollup来打包的,从rollup.config.js中可以发现入口是single-spa.js
打开会发现里面导出了一大堆东西,有我们非常熟悉的各个方法,我们就从registerApplication方法开始

registerApplication 注册子应用

single-spa/src/applications/apps.js

/**
 * 注册应用,两种方式
 * registerApplication('app1', loadApp(url), activeWhen('/app1'), customProps)
 * registerApplication({
 *    name: 'app1',
 *    app: loadApp(url),
 *    activeWhen: activeWhen('/app1'),
 *    customProps: {}
 * })
 * @param {*} appNameOrConfig 应用名称或者应用配置对象
 * @param {*} appOrLoadApp 应用的加载方法,是一个 promise
 * @param {*} activeWhen 判断应用是否激活的一个方法,方法返回 true or false
 * @param {*} customProps 传递给子应用的 props 对象
 */
export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  /**
   * 格式化用户传递的应用配置参数
   * registration = {
   *    name: 'app1',
   *    loadApp: 返回promise的函数,
   *    activeWhen: 返回boolean值的函数,
   *    customProps: {},
   * }
   */
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );

  // 判断应用是否重名
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(
      formatErrorMessage(
        21,
        __DEV__ &&
          `There is already an app registered with name ${registration.name}`,
        registration.name
      )
    );

  // 将各个应用的配置信息都存放到 apps 数组中
  apps.push(
    // 给每个应用增加一个内置属性
    assign(
      {
        loadErrorTime: null,
        // 最重要的,应用的状态
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );

  // 浏览器环境运行
  if (isInBrowser) {
    // https://zh-hans.single-spa.js.org/docs/api#ensurejquerysupport
    // 如果页面中使用了jQuery,则给jQuery打patch
    ensureJQuerySupport();
    reroute();
  }
}

sanitizeArguments 格式化用户传递的子应用配置参数

single-spa/src/applications/apps.js

// 返回处理后的应用配置对象
function sanitizeArguments(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  // 判断第一个参数是否为对象
  const usingObjectAPI = typeof appNameOrConfig === "object";

  // 初始化应用配置对象
  const registration = {
    name: null,
    loadApp: null,
    activeWhen: null,
    customProps: null,
  };

  if (usingObjectAPI) {
    // 注册应用的时候传递的参数是对象
    validateRegisterWithConfig(appNameOrConfig);
    registration.name = appNameOrConfig.name;
    registration.loadApp = appNameOrConfig.app;
    registration.activeWhen = appNameOrConfig.activeWhen;
    registration.customProps = appNameOrConfig.customProps;
  } else {
    // 参数列表
    validateRegisterWithArguments(
      appNameOrConfig,
      appOrLoadApp,
      activeWhen,
      customProps
    );
    registration.name = appNameOrConfig;
    registration.loadApp = appOrLoadApp;
    registration.activeWhen = activeWhen;
    registration.customProps = customProps;
  }

  // 如果第二个参数不是一个函数,比如是一个包含已经生命周期的对象,则包装成一个返回 promise 的函数
  registration.loadApp = sanitizeLoadApp(registration.loadApp);
  // 如果用户没有提供 props 对象,则给一个默认的空对象
  registration.customProps = sanitizeCustomProps(registration.customProps);
  // 保证activeWhen是一个返回boolean值的函数
  registration.activeWhen = sanitizeActiveWhen(registration.activeWhen);

  // 返回处理后的应用配置对象
  return registration;
}

validateRegisterWithConfig

single-spa/src/applications/apps.js

/**
 * 验证应用配置对象的各个属性是否存在不合法的情况,存在则抛出错误
 * @param {*} config = { name: 'app1', app: function, activeWhen: function, customProps: {} }
 */
export function validateRegisterWithConfig(config) {
  // 异常判断,应用的配置对象不能是数组或者null
  if (Array.isArray(config) || config === null)
    throw Error(
      formatErrorMessage(
        39,
        __DEV__ && "Configuration object can't be an Array or null!"
      )
    );
  // 配置对象只能包括这四个key
  const validKeys = ["name", "app", "activeWhen", "customProps"];
  // 找到配置对象存在的无效的key
  const invalidKeys = Object.keys(config).reduce(
    (invalidKeys, prop) =>
      validKeys.indexOf(prop) >= 0 ? invalidKeys : invalidKeys.concat(prop),
    []
  );
  // 如果存在无效的key,则抛出一个错误
  if (invalidKeys.length !== 0)
    throw Error(
      formatErrorMessage(
        38,
        __DEV__ &&
          `The configuration object accepts only: ${validKeys.join(
            ", "
          )}. Invalid keys: ${invalidKeys.join(", ")}.`,
        validKeys.join(", "),
        invalidKeys.join(", ")
      )
    );
  // 验证应用名称,只能是字符串,且不能为空
  if (typeof config.name !== "string" || config.name.length === 0)
    throw Error(
      formatErrorMessage(
        20,
        __DEV__ &&
          "The config.name on registerApplication must be a non-empty string"
      )
    );
  // app 属性只能是一个对象或者函数
  // 对象是一个已被解析过的对象,是一个包含各个生命周期的对象;
  // 加载函数必须返回一个 promise
  // 以上信息在官方文档中有提到:https://zh-hans.single-spa.js.org/docs/configuration
  if (typeof config.app !== "object" && typeof config.app !== "function")
    throw Error(
      formatErrorMessage(
        20,
        __DEV__ &&
          "The config.app on registerApplication must be an application or a loading function"
      )
    );
  // 第三个参数,可以是一个字符串,也可以是一个函数,也可以是两者组成的一个数组,表示当前应该被激活的应用的baseURL
  const allowsStringAndFunction = (activeWhen) =>
    typeof activeWhen === "string" || typeof activeWhen === "function";
  if (
    !allowsStringAndFunction(config.activeWhen) &&
    !(
      Array.isArray(config.activeWhen) &&
      config.activeWhen.every(allowsStringAndFunction)
    )
  )
    throw Error(
      formatErrorMessage(
        24,
        __DEV__ &&
          "The config.activeWhen on registerApplication must be a string, function or an array with both"
      )
    );
  // 传递给子应用的props对象必须是一个对象
  if (!validCustomProps(config.customProps))
    throw Error(
      formatErrorMessage(
        22,
        __DEV__ && "The optional config.customProps must be an object"
      )
    );
}

validateRegisterWithArguments

single-spa/src/applications/apps.js

// 同样是验证四个参数是否合法
function validateRegisterWithArguments(
  name,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  if (typeof name !== "string" || name.length === 0)
    throw Error(
      formatErrorMessage(
        20,
        __DEV__ &&
          `The 1st argument to registerApplication must be a non-empty string 'appName'`
      )
    );

  if (!appOrLoadApp)
    throw Error(
      formatErrorMessage(
        23,
        __DEV__ &&
          "The 2nd argument to registerApplication must be an application or loading application function"
      )
    );

  if (typeof activeWhen !== "function")
    throw Error(
      formatErrorMessage(
        24,
        __DEV__ &&
          "The 3rd argument to registerApplication must be an activeWhen function"
      )
    );

  if (!validCustomProps(customProps))
    throw Error(
      formatErrorMessage(
        22,
        __DEV__ &&
          "The optional 4th argument is a customProps and must be an object"
      )
    );
}

sanitizeLoadApp

single-spa/src/applications/apps.js

// 保证第二个参数一定是一个返回 promise 的函数
function sanitizeLoadApp(loadApp) {
  if (typeof loadApp !== "function") {
    return () => Promise.resolve(loadApp);
  }

  return loadApp;
}

sanitizeCustomProps

single-spa/src/applications/apps.js

// 保证 props 不为 undefined
function sanitizeCustomProps(customProps) {
  return customProps ? customProps : {};
}

sanitizeActiveWhen

single-spa/src/applications/apps.js

// 得到一个函数,函数负责判断浏览器当前地址是否和用户给定的baseURL相匹配,匹配返回true,否则返回false
function sanitizeActiveWhen(activeWhen) {
  // []
  let activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
  // 保证数组中每个元素都是一个函数
  activeWhenArray = activeWhenArray.map((activeWhenOrPath) =>
    typeof activeWhenOrPath === "function"
      ? activeWhenOrPath
      // activeWhen如果是一个路径,则保证成一个函数
      : pathToActiveWhen(activeWhenOrPath)
  );

  // 返回一个函数,函数返回一个 boolean 值
  return (location) =>
    activeWhenArray.some((activeWhen) => activeWhen(location));
}

pathToActiveWhen

single-spa/src/applications/apps.js

export function pathToActiveWhen(path) {
  // 根据用户提供的baseURL,生成正则表达式
  const regex = toDynamicPathValidatorRegex(path);

  // 函数返回boolean值,判断当前路由是否匹配用户给定的路径
  return (location) => {
    const route = location.href
      .replace(location.origin, "")
      .replace(location.search, "")
      .split("?")[0];
    return regex.test(route);
  };
}

reroute 更改app.status和执行生命周期函数

single-spa/src/navigation/reroute.js

/**
 * 每次切换路由前,将应用分为4大类,
 * 首次加载时执行loadApp
 * 后续的路由切换执行performAppChange
 * 为四大类的应用分别执行相应的操作,比如更改app.status,执行生命周期函数
 * 所以,从这里也可以看出来,single-spa就是一个维护应用的状态机
 * @param {*} pendingPromises 
 * @param {*} eventArguments 
 */
export function reroute(pendingPromises = [], eventArguments) {
  // 应用正在切换,这个状态会在执行performAppChanges之前置为true,执行结束之后再置为false
  // 如果在中间用户重新切换路由了,即走这个if分支,暂时看起来就在数组中存储了一些信息,没看到有什么用
  // 字面意思理解就是用户等待app切换
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  // 将应用分为4大类
  const {
    // 需要被移除的
    appsToUnload,
    // 需要被卸载的
    appsToUnmount,
    // 需要被加载的
    appsToLoad,
    // 需要被挂载的
    appsToMount,
  } = getAppChanges();

  let appsThatChanged;

  // 是否已经执行 start 方法
  if (isStarted()) {
    // 已执行
    appChangeUnderway = true;
    // 所有需要被改变的的应用
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    // 执行改变
    return performAppChanges();
  } else {
    // 未执行
    appsThatChanged = appsToLoad;
    // 加载Apps
    return loadApps();
  }

  // 整体返回一个立即resolved的promise,通过微任务来加载apps
  function loadApps() {
    return Promise.resolve().then(() => {
      // 加载每个子应用,并做一系列的状态变更和验证(比如结果为promise、子应用要导出生命周期函数)
      const loadPromises = appsToLoad.map(toLoadPromise);

      return (
        // 保证所有加载子应用的微任务执行完成
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => [])
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }

  function performAppChanges() {
    return Promise.resolve().then(() => {
      // https://github.com/single-spa/single-spa/issues/545
      // 自定义事件,在应用状态发生改变之前可触发,给用户提供搞事情的机会
      window.dispatchEvent(
        new CustomEvent(
          appsThatChanged.length === 0
            ? "single-spa:before-no-app-change"
            : "single-spa:before-app-change",
          getCustomEventDetail(true)
        )
      );

      window.dispatchEvent(
        new CustomEvent(
          "single-spa:before-routing-event",
          getCustomEventDetail(true)
        )
      );
      // 移除应用 => 更改应用状态,执行unload生命周期函数,执行一些清理动作
      // 其实一般情况下这里没有真的移除应用
      const unloadPromises = appsToUnload.map(toUnloadPromise);

      // 卸载应用,更改状态,执行unmount生命周期函数
      const unmountUnloadPromises = appsToUnmount
        .map(toUnmountPromise)
        // 卸载完然后移除,通过注册微任务的方式实现
        .map((unmountPromise) => unmountPromise.then(toUnloadPromise));

      const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);

      const unmountAllPromise = Promise.all(allUnmountPromises);

      // 卸载全部完成后触发一个事件
      unmountAllPromise.then(() => {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true)
          )
        );
      });

      /* We load and bootstrap apps while other apps are unmounting, but we
       * wait to mount the app until all apps are finishing unmounting
       * 这个原因其实是因为这些操作都是通过注册不同的微任务实现的,而JS是单线程执行,
       * 所以自然后续的只能等待前面的执行完了才能执行
       * 这里一般情况下其实不会执行,只有手动执行了unloadApplication方法才会二次加载
       */
      const loadThenMountPromises = appsToLoad.map((app) => {
        return toLoadPromise(app).then((app) =>
          tryToBootstrapAndMount(app, unmountAllPromise)
        );
      });

      /* These are the apps that are already bootstrapped and just need
       * to be mounted. They each wait for all unmounting apps to finish up
       * before they mount.
       * 初始化和挂载app,其实做的事情很简单,就是改变app.status,执行生命周期函数
       * 当然这里的初始化和挂载其实是前后脚一起完成的(只要中间用户没有切换路由)
       */
      const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => {
          return tryToBootstrapAndMount(appToMount, unmountAllPromise);
        });

      // 后面就没啥了,可以理解为收尾工作
      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
           * events (like hashchange or popstate) should have been cleaned up. So it's safe
           * to let the remaining captured event listeners to handle about the DOM event.
           */
          callAllEventListeners();

          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) => {
              pendingPromises.forEach((promise) => promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn);
        });
    });
  }
}

getAppChanges

single-spa/src/applications/apps.js

// 将应用分为四大类
export function getAppChanges() {
  // 需要被移除的应用
  const appsToUnload = [],
    // 需要被卸载的应用
    appsToUnmount = [],
    // 需要被加载的应用
    appsToLoad = [],
    // 需要被挂载的应用
    appsToMount = [];

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  const currentTime = new Date().getTime();

  apps.forEach((app) => {
    // boolean,应用是否应该被激活
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

    switch (app.status) {
      // 需要被加载的应用
      case LOAD_ERROR:
        if (currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      // 需要被加载的应用
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      // 状态为xx的应用
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
          // 需要被移除的应用
          appsToUnload.push(app);
        } else if (appShouldBeActive) {
          // 需要被挂载的应用
          appsToMount.push(app);
        }
        break;
      // 需要被卸载的应用,已经处于挂载状态,但现在路由已经变了的应用需要被卸载
      case MOUNTED:
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;
      // all other statuses are ignored
    }
  });

  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}

shouldBeActive

single-spa/src/applications/app.helpers.js

// 返回boolean值,应用是否应该被激活
export function shouldBeActive(app) {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    handleAppError(err, app, SKIP_BECAUSE_BROKEN);
    return false;
  }
}

toLoadPromise

single-spa/src/lifecycles/load.js

/**
 * 通过微任务加载子应用,其实singleSpa中很多地方都用了微任务
 * 这里最终是return了一个promise出行,在注册了加载子应用的微任务
 * 概括起来就是:
 *  更改app.status为LOAD_SOURCE_CODE => NOT_BOOTSTRAP,当然还有可能是LOAD_ERROR
 *  执行加载函数,并将props传递给加载函数,给用户处理props的一个机会,因为这个props是一个完备的props
 *  验证加载函数的执行结果,必须为promise,且加载函数内部必须return一个对象
 *  这个对象是子应用的,对象中必须包括各个必须的生命周期函数
 *  然后将生命周期方法通过一个函数包裹并挂载到app对象上
 *  app加载完成,删除app.loadPromise
 * @param {*} app 
 */
export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) {
      // 说明app已经在被加载
      return app.loadPromise;
    }

    // 只有状态为NOT_LOADED和LOAD_ERROR的app才可以被加载
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }

    // 设置App的状态
    app.status = LOADING_SOURCE_CODE;

    let appOpts, isUserErr;

    return (app.loadPromise = Promise.resolve()
      .then(() => {
        // 执行app的加载函数,并给子应用传递props => 用户自定义的customProps和内置的比如应用的名称、singleSpa实例
        // 其实这里有个疑问,这个props是怎么传递给子应用的,感觉跟后面的生命周期函数有关
        const loadPromise = app.loadApp(getProps(app));
        // 加载函数需要返回一个promise
        if (!smellsLikeAPromise(loadPromise)) {
          // The name of the app will be prepended to this error message inside of the handleAppError function
          isUserErr = true;
          throw Error(
            formatErrorMessage(
              33,
              __DEV__ &&
                `single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName(
                  app
                )}', loadingFunction, activityFunction)`,
              toName(app)
            )
          );
        }
        // 这里很重要,这个val就是示例项目中加载函数中return出来的window.singleSpa,这个属性是子应用打包时设置的
        return loadPromise.then((val) => {
          app.loadErrorTime = null;

          // window.singleSpa
          appOpts = val;

          let validationErrMessage, validationErrCode;

          // 以下进行一系列的验证,已window.singleSpa为例说明,简称g.s

          // g.s必须为对象
          if (typeof appOpts !== "object") {
            validationErrCode = 34;
            if (__DEV__) {
              validationErrMessage = `does not export anything`;
            }
          }

          // g.s必须导出bootstrap生命周期函数
          if (!validLifecycleFn(appOpts.bootstrap)) {
            validationErrCode = 35;
            if (__DEV__) {
              validationErrMessage = `does not export a bootstrap function or array of functions`;
            }
          }

          // g.s必须导出mount生命周期函数
          if (!validLifecycleFn(appOpts.mount)) {
            validationErrCode = 36;
            if (__DEV__) {
              validationErrMessage = `does not export a bootstrap function or array of functions`;
            }
          }

          // g.s必须导出unmount生命周期函数
          if (!validLifecycleFn(appOpts.unmount)) {
            validationErrCode = 37;
            if (__DEV__) {
              validationErrMessage = `does not export a bootstrap function or array of functions`;
            }
          }

          const type = objectType(appOpts);

          // 说明上述验证失败,抛出错误提示信息
          if (validationErrCode) {
            let appOptsStr;
            try {
              appOptsStr = JSON.stringify(appOpts);
            } catch {}
            console.error(
              formatErrorMessage(
                validationErrCode,
                __DEV__ &&
                  `The loading function for single-spa ${type} '${toName(
                    app
                  )}' resolved with the following, which does not have bootstrap, mount, and unmount functions`,
                type,
                toName(app),
                appOptsStr
              ),
              appOpts
            );
            handleAppError(validationErrMessage, app, SKIP_BECAUSE_BROKEN);
            return app;
          }

          if (appOpts.devtools && appOpts.devtools.overlays) {
            // app.devtoolsoverlays添加子应用的devtools.overlays的属性,不知道是干嘛用的
            app.devtools.overlays = assign(
              {},
              app.devtools.overlays,
              appOpts.devtools.overlays
            );
          }

          // 设置app状态为未初始化,表示加载完了
          app.status = NOT_BOOTSTRAPPED;
          // 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
          app.bootstrap = flattenFnArray(appOpts, "bootstrap");
          app.mount = flattenFnArray(appOpts, "mount");
          app.unmount = flattenFnArray(appOpts, "unmount");
          app.unload = flattenFnArray(appOpts, "unload");
          app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);

          // 执行到这里说明子应用已成功加载,删除app.loadPromise属性
          delete app.loadPromise;

          return app;
        });
      })
      .catch((err) => {
        // 加载失败,稍后重新加载
        delete app.loadPromise;

        let newStatus;
        if (isUserErr) {
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          newStatus = LOAD_ERROR;
          app.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, app, newStatus);

        return app;
      }));
  });
}

getProps

single-spa/src/lifecycles/prop.helpers.js

/**
 * 得到传递给子应用的props
 * @param {} appOrParcel => app 
 * 以下返回内容其实在官网也都有提到,比如singleSpa实例,目的是为了子应用不需要重复引入single-spa
 * return {
 *    ...customProps,
 *    name,
 *    mountParcel: mountParcel.bind(appOrParcel),
 *    singleSpa, 
 * }
 */
export function getProps(appOrParcel) {
  // app.name
  const name = toName(appOrParcel);
  // app.customProps,以下对customProps对象的判断逻辑有点多余
  // 因为前面的参数格式化已经保证customProps肯定是一个对象
  let customProps =
    typeof appOrParcel.customProps === "function"
      ? appOrParcel.customProps(name, window.location)
      : appOrParcel.customProps;
  if (
    typeof customProps !== "object" ||
    customProps === null ||
    Array.isArray(customProps)
  ) {
    customProps = {};
    console.warn(
      formatErrorMessage(
        40,
        __DEV__ &&
          `single-spa: ${name}'s customProps function must return an object. Received ${customProps}`
      ),
      name,
      customProps
    );
  }

  const result = assign({}, customProps, {
    name,
    mountParcel: mountParcel.bind(appOrParcel),
    singleSpa,
  });

  if (isParcel(appOrParcel)) {
    result.unmountSelf = appOrParcel.unmountThisParcel;
  }

  return result;
}

smellsLikeAPromise

single-spa/src/lifecycles/lifecycle.helpers.js

// 判断一个变量是否为promise
export function smellsLikeAPromise(promise) {
  return (
    promise &&
    typeof promise.then === "function" &&
    typeof promise.catch === "function"
  );
}

flattenFnArray

single-spa/src/lifecycles/lifecycle.helpers.js

/**
 * 返回一个接受props作为参数的函数,这个函数负责执行子应用中的生命周期函数,
 * 并确保生命周期函数返回的结果为promise
 * @param {*} appOrParcel => window.singleSpa,子应用打包后的对象
 * @param {*} lifecycle => 字符串,生命周期名称
 */
export function flattenFnArray(appOrParcel, lifecycle) {
  // fns = fn or []
  let fns = appOrParcel[lifecycle] || [];
  // fns = [] or [fn]
  fns = Array.isArray(fns) ? fns : [fns];
  // 有些生命周期函数子应用可能不会设置,比如unload
  if (fns.length === 0) {
    fns = [() => Promise.resolve()];
  }

  const type = objectType(appOrParcel);
  const name = toName(appOrParcel);

  return function (props) {
    // 这里最后返回了一个promise链,这个操作似乎没啥必要,因为不可能出现同名的生命周期函数,所以,这里将生命周期函数放数组,没太理解目的是啥
    return fns.reduce((resultPromise, fn, index) => {
      return resultPromise.then(() => {
        // 执行生命周期函数,传递props给函数,并验证函数的返回结果,必须为promise
        const thisPromise = fn(props);
        return smellsLikeAPromise(thisPromise)
          ? thisPromise
          : Promise.reject(
              formatErrorMessage(
                15,
                __DEV__ &&
                  `Within ${type} ${name}, the lifecycle function ${lifecycle} at array index ${index} did not return a promise`,
                type,
                name,
                lifecycle,
                index
              )
            );
      });
    }, Promise.resolve());
  };
}

toUnloadPromise

single-spa/src/lifecycles/unload.js

const appsToUnload = {};
/**
 * 移除应用,就更改一下应用的状态,执行unload生命周期函数,执行清理操作
 * 
 * 其实一般情况是不会执行移除操作的,除非你手动调用unloadApplication方法
 * 单步调试会发现appsToUnload对象是个空对象,所以第一个if就return了,这里啥也没做
 * https://zh-hans.single-spa.js.org/docs/api#unloadapplication
 * */ 
export function toUnloadPromise(app) {
  return Promise.resolve().then(() => {
    // 应用信息
    const unloadInfo = appsToUnload[toName(app)];

    if (!unloadInfo) {
      /* No one has called unloadApplication for this app,
       * 不需要移除
       * 一般情况下都不需要移除,只有在调用unloadApplication方法手动执行移除时才会
       * 执行后面的内容
       */
      return app;
    }

    // 已经卸载了,执行一些清理操作
    if (app.status === NOT_LOADED) {
      /* This app is already unloaded. We just need to clean up
       * anything that still thinks we need to unload the app.
       */
      finishUnloadingApp(app, unloadInfo);
      return app;
    }

    // 如果应用正在执行挂载,路由突然发生改变,那么也需要应用挂载完成才可以执行移除
    if (app.status === UNLOADING) {
      /* Both unloadApplication and reroute want to unload this app.
       * It only needs to be done once, though.
       */
      return unloadInfo.promise.then(() => app);
    }

    if (app.status !== NOT_MOUNTED) {
      /* The app cannot be unloaded until it is unmounted.
       */
      return app;
    }

    // 更改状态为 UNLOADING
    app.status = UNLOADING;
    // 在合理的时间范围内执行生命周期函数
    return reasonableTime(app, "unload")
      .then(() => {
        // 一些清理操作
        finishUnloadingApp(app, unloadInfo);
        return app;
      })
      .catch((err) => {
        errorUnloadingApp(app, unloadInfo, err);
        return app;
      });
  });
}

finishUnloadingApp

single-spa/src/lifecycles/unload.js

// 移除完成,执行一些清理动作,其实就是从appsToUnload数组中移除该app,移除生命周期函数,更改app.status
// 但应用不是真的被移除,后面再激活时不需要重新去下载资源,,只是做一些状态上的变更,当然load的那个过程还是需要的,这点可能需要再确认一下
function finishUnloadingApp(app, unloadInfo) {
  delete appsToUnload[toName(app)];

  // Unloaded apps don't have lifecycles
  delete app.bootstrap;
  delete app.mount;
  delete app.unmount;
  delete app.unload;

  app.status = NOT_LOADED;

  /* resolve the promise of whoever called unloadApplication.
   * This should be done after all other cleanup/bookkeeping
   */
  unloadInfo.resolve();
}

reasonableTime

single-spa/src/applications/timeouts.js

/**
 * 合理的时间,即生命周期函数合理的执行时间
 * 在合理的时间内执行生命周期函数,并将函数的执行结果resolve出去
 * @param {*} appOrParcel => app
 * @param {*} lifecycle => 生命周期函数名
 */
export function reasonableTime(appOrParcel, lifecycle) {
  // 应用的超时配置
  const timeoutConfig = appOrParcel.timeouts[lifecycle];
  // 超时警告
  const warningPeriod = timeoutConfig.warningMillis;
  const type = objectType(appOrParcel);

  return new Promise((resolve, reject) => {
    let finished = false;
    let errored = false;

    // 这里很关键,之前一直奇怪props是怎么传递给子应用的,这里就是了,果然和之前的猜想是一样的
    // 是在执行生命周期函数时像子应用传递的props,所以之前执行loadApp传递props不会到子应用,
    // 那么设计估计是给用户自己处理props的一个机会吧,因为那个时候处理的props已经是{ ...customProps, ...内置props }
    appOrParcel[lifecycle](getProps(appOrParcel))
      .then((val) => {
        finished = true;
        resolve(val);
      })
      .catch((val) => {
        finished = true;
        reject(val);
      });

    // 下面就没啥了,就是超时的一些提示信息
    setTimeout(() => maybeTimingOut(1), warningPeriod);
    setTimeout(() => maybeTimingOut(true), timeoutConfig.millis);

    const errMsg = formatErrorMessage(
      31,
      __DEV__ &&
        `Lifecycle function ${lifecycle} for ${type} ${toName(
          appOrParcel
        )} lifecycle did not resolve or reject for ${timeoutConfig.millis} ms.`,
      lifecycle,
      type,
      toName(appOrParcel),
      timeoutConfig.millis
    );

    function maybeTimingOut(shouldError) {
      if (!finished) {
        if (shouldError === true) {
          errored = true;
          if (timeoutConfig.dieOnTimeout) {
            reject(Error(errMsg));
          } else {
            console.error(errMsg);
            //don't resolve or reject, we're waiting this one out
          }
        } else if (!errored) {
          const numWarnings = shouldError;
          const numMillis = numWarnings * warningPeriod;
          console.warn(errMsg);
          if (numMillis + warningPeriod < timeoutConfig.millis) {
            setTimeout(() => maybeTimingOut(numWarnings + 1), warningPeriod);
          }
        }
      }
    }
  });
}

toUnmountPromise

single-spa/src/lifecycles/unmount.js

/**
 * 执行了状态上的更改
 * 执行unmount生命周期函数
 * @param {*} appOrParcel => app
 * @param {*} hardFail => 索引
 */
export function toUnmountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    // 只卸载已挂载的应用
    if (appOrParcel.status !== MOUNTED) {
      return appOrParcel;
    }
    // 更改状态
    appOrParcel.status = UNMOUNTING;

    // 有关parcels的一些处理,没使用过parcels,所以unmountChildrenParcels = []
    const unmountChildrenParcels = Object.keys(
      appOrParcel.parcels
    ).map((parcelId) => appOrParcel.parcels[parcelId].unmountThisParcel());

    let parcelError;

    return Promise.all(unmountChildrenParcels)
      // 在合理的时间范围内执行unmount生命周期函数
      .then(unmountAppOrParcel, (parcelError) => {
        // There is a parcel unmount error
        return unmountAppOrParcel().then(() => {
          // Unmounting the app/parcel succeeded, but unmounting its children parcels did not
          const parentError = Error(parcelError.message);
          if (hardFail) {
            throw transformErr(parentError, appOrParcel, SKIP_BECAUSE_BROKEN);
          } else {
            handleAppError(parentError, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        });
      })
      .then(() => appOrParcel);

    function unmountAppOrParcel() {
      // We always try to unmount the appOrParcel, even if the children parcels failed to unmount.
      return reasonableTime(appOrParcel, "unmount")
        .then(() => {
          // The appOrParcel needs to stay in a broken status if its children parcels fail to unmount
          if (!parcelError) {
            appOrParcel.status = NOT_MOUNTED;
          }
        })
        .catch((err) => {
          if (hardFail) {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          } else {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        });
    }
  });
}

tryToBootstrapAndMount

single-spa/src/navigation/reroute.js

/**
 * Let's imagine that some kind of delay occurred during application loading.
 * The user without waiting for the application to load switched to another route,
 * this means that we shouldn't bootstrap and mount that application, thus we check
 * twice if that application should be active before bootstrapping and mounting.
 * https://github.com/single-spa/single-spa/issues/524
 * 这里这个两次判断还是很重要的
 */
function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    // 一次判断为true,才会执行初始化
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromise.then(() =>
        // 第二次, 两次都为true才会去挂载
        shouldBeActive(app) ? toMountPromise(app) : app
      )
    );
  } else {
    // 卸载
    return unmountAllPromise.then(() => app);
  }
}

toBootstrapPromise

single-spa/src/lifecycles/bootstrap.js

// 初始化app,更改app.status,在合理的时间内执行bootstrap生命周期函数
export function toBootstrapPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
      return appOrParcel;
    }

    appOrParcel.status = BOOTSTRAPPING;

    return reasonableTime(appOrParcel, "bootstrap")
      .then(() => {
        appOrParcel.status = NOT_MOUNTED;
        return appOrParcel;
      })
      .catch((err) => {
        if (hardFail) {
          throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
        } else {
          handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          return appOrParcel;
        }
      });
  });
}

toMountPromise

single-spa/src/lifecycles/mount.js

// 挂载app,执行mount生命周期函数,并更改app.status
export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel;
    }

    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }

    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;

        if (!firstMountFired) {
          // single-spa其实在不同的阶段提供了相应的自定义事件,让用户可以做一些事情
          window.dispatchEvent(new CustomEvent("single-spa:first-mount"));
          firstMountFired = true;
        }

        return appOrParcel;
      })
      .catch((err) => {
        // If we fail to mount the appOrParcel, we should attempt to unmount it before putting in SKIP_BECAUSE_BROKEN
        // We temporarily put the appOrParcel into MOUNTED status so that toUnmountPromise actually attempts to unmount it
        // instead of just doing a no-op.
        appOrParcel.status = MOUNTED;
        return toUnmountPromise(appOrParcel, true).then(
          setSkipBecauseBroken,
          setSkipBecauseBroken
        );

        function setSkipBecauseBroken() {
          if (!hardFail) {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
            return appOrParcel;
          } else {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        }
      });
  });
}

start(opts)

single-spa/src/start.js

let started = false
/**
 * https://zh-hans.single-spa.js.org/docs/api#start
 * 调用start之前,应用会被加载,但不会初始化、挂载和卸载,有了start可以更好的控制应用的性能
 * @param {*} opts 
 */
export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

export function isStarted() {
  return started;
}

if (isInBrowser) {
  // registerApplication之后如果一直没有调用start,则在5000ms后给出警告提示
  setTimeout(() => {
    if (!started) {
      console.warn(
        formatErrorMessage(
          1,
          __DEV__ &&
            `singleSpa.start() has not been called, 5000ms after single-spa was loaded. Before start() is called, apps can be declared and loaded, but not bootstrapped or mounted.`
        )
      );
    }
  }, 5000);
}

监听路由变化

single-spa/src/navigation/navigation-events.js

以下代码会被打包进bundle的全局作用域内,bundle被加载以后就会自动执行。这句提示不需要的话可自动忽略

/**
 * 监听路由变化
 */
if (isInBrowser) {
  // We will trigger an app change for any routing events,监听hashchange和popstate事件
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  // Monkeypatch addEventListener so that we can ensure correct timing
  /**
   * 扩展原生的addEventListener和removeEventListener方法
   * 每次注册事件和事件处理函数都会将事件和处理函数保存下来,当然移除时也会做删除
   * */ 
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      if (
        // eventName只能是hashchange或popstate && 对应事件的fn注册函数没有注册
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) => listener === fn)
      ) {
        // 注册(保存)eventName 事件的处理函数
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    // 原生方法
    return originalAddEventListener.apply(this, arguments);
  };

  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      // 从captureEventListeners数组中移除eventName事件指定的事件处理函数
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) => fn !== listenerFn);
        return;
      }
    }

    return originalRemoveEventListener.apply(this, arguments);
  };

  // 增强pushstate和replacestate
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );

  if (window.singleSpaNavigate) {
    console.warn(
      formatErrorMessage(
        41,
        __DEV__ &&
          "single-spa has been loaded twice on the page. This can result in unexpected behavior."
      )
    );
  } else {
    /* For convenience in `onclick` attributes, we expose a global function for navigating to
     * whatever an <a> tag's href is.
     * singleSpa暴露出来的一个全局方法,用户也可以基于它去判断子应用是运行在基座应用上还是独立运行
     */
    window.singleSpaNavigate = navigateToUrl;
  }
}

patchedUpdateState

single-spa/src/navigation/navigation-events.js

/**
 * 通过装饰器模式,增强pushstate和replacestate方法,除了原生的操作历史记录,还会调用reroute
 * @param {*} updateState window.history.pushstate/replacestate
 * @param {*} methodName 'pushstate' or 'replacestate'
 */
function patchedUpdateState(updateState, methodName) {
  return function () {
    // 当前url
    const urlBefore = window.location.href;
    // pushstate或者replacestate的执行结果
    const result = updateState.apply(this, arguments);
    // pushstate或replacestate执行后的url地址
    const urlAfter = window.location.href;

    // 如果调用start传递了参数urlRerouteOnly为true,则这里不会触发reroute
    // https://zh-hans.single-spa.js.org/docs/api#start
    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      urlReroute(createPopStateEvent(window.history.state, methodName));
    }

    return result;
  };
}

createPopStateEvent

single-spa/src/navigation/navigation-events.js

function createPopStateEvent(state, originalMethodName) {
  // https://github.com/single-spa/single-spa/issues/224 and https://github.com/single-spa/single-spa-angular/issues/49
  // We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
  // all the applications can reroute. We explicitly identify this extraneous event by setting singleSpa=true and
  // singleSpaTrigger=<pushState|replaceState> on the event instance.
  let evt;
  try {
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    // IE 11 compatibility https://github.com/single-spa/single-spa/issues/299
    // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate", false, false, state);
  }
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName;
  return evt;
}

urlReroute

single-spa/src/navigation/navigation-events.js

export function setUrlRerouteOnly(val) {
  urlRerouteOnly = val;
}

function urlReroute() {
  reroute([], arguments);
}

小结

以上就是对整个single-spa框架源码的解读,相信读到这里你会有不一样的理解吧,当然第一遍读完你有可能有点懵,我当时就是这样,这时候就需要那句古话了,书读百遍,其义自现(干了这碗鸡汤)

整个框架的源码读完以后,你会发现:single-spa的原理其实很简单,它就是一个子应用加载器 + 状态机的结合体,而且具体怎么加载子应用还是基座应用提供的;框架里面维护了各个子应用的状态,以及在适当的时候负责更改子应用的状态、执行相应的生命周期函数

想想框架好像也不复杂,对吧??那接下来就来实现一个自己的single-spa框架吧

手写 single-spa 框架

经过上面的阅读,相信对single-spa已经有一定的理解了,接下来就来实现一个自己的single-spa,就叫lyn-single-spa吧。

我们好像只需要实现registerApplicationstart两个方法并导出即可。

写代码之前,必须理清框架内子应用的各个状态以及状态的变更过程,为了便于理解,代码写详细的注释,希望大家看完以后都可以实现一个自己的single-spa

// 实现子应用的注册、挂载、切换、卸载功能

/**
 * 子应用状态
 */
// 子应用注册以后的初始状态
const NOT_LOADED = 'NOT_LOADED'
// 表示正在加载子应用源代码
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'
// 执行完 app.loadApp,即子应用加载完以后的状态
const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED'
// 正在初始化
const BOOTSTRAPPING = 'BOOTSTRAPPING'
// 执行 app.bootstrap 之后的状态,表是初始化完成,处于未挂载的状态
const NOT_MOUNTED = 'NOT_MOUNTED'
// 正在挂载
const MOUNTING = 'MOUNTING'
// 挂载完成,app.mount 执行完毕
const MOUNTED = 'MOUNTED'
const UPDATING = 'UPDATING'
// 正在卸载
const UNMOUNTING = 'UNMOUNTING'
// 以下三种状态这里没有涉及
const UNLOADING = 'UNLOADING'
const LOAD_ERROR = 'LOAD_ERROR'
const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN'

// 存放所有的子应用
const apps = []

/**
 * 注册子应用
 * @param {*} appConfig = {
 *    name: '',
 *    app: promise function,
 *    activeWhen: location => location.pathname.startsWith(path),
 *    customProps: {}
 * }
 */
export function registerApplication (appConfig) {
  apps.push(Object.assign({}, appConfig, { status: NOT_LOADED }))
  reroute()
}

// 启动
let isStarted = false
export function start () {
  isStarted = true
}

function reroute () {
  // 三类 app
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges()
  if (isStarted) {
    performAppChanges()
  } else {
    loadApps()
  }

  function loadApps () {
    appsToLoad.map(toLoad)
  }

  function performAppChanges () {
    // 卸载
    appsToUnmount.map(toUnmount)
    // 初始化 + 挂载
    appsToMount.map(tryToBoostrapAndMount)
  }
}

/**
 * 挂载应用
 * @param {*} app 
 */
async function tryToBoostrapAndMount(app) {
  if (shouldBeActive(app)) {
    // 正在初始化
    app.status = BOOTSTRAPPING
    // 初始化
    await app.bootstrap(app.customProps)
    // 初始化完成
    app.status = NOT_MOUNTED
    // 第二次判断是为了防止中途用户切换路由
    if (shouldBeActive(app)) {
      // 正在挂载
      app.status = MOUNTING
      // 挂载
      await app.mount(app.customProps)
      // 挂载完成
      app.status = MOUNTED
    }
  }
}

/**
 * 卸载应用
 * @param {*} app 
 */
async function toUnmount (app) {
  if (app.status !== 'MOUNTED') return app
  // 更新状态为正在卸载
  app.status = MOUNTING
  // 执行卸载
  await app.unmount(app.customProps)
  // 卸载完成
  app.status = NOT_MOUNTED
  return app
}

/**
 * 加载子应用
 * @param {*} app 
 */
async function toLoad (app) {
  if (app.status !== NOT_LOADED) return app
  // 更改状态为正在加载
  app.status = LOADING_SOURCE_CODE
  // 加载 app
  const res = await app.app()
  // 加载完成
  app.status = NOT_BOOTSTRAPPED
  // 将子应用导出的生命周期函数挂载到 app 对象上
  app.bootstrap = res.bootstrap
  app.mount = res.mount
  app.unmount = res.unmount
  app.unload = res.unload
  // 加载完以后执行 reroute 尝试挂载
  reroute()
  return app
}

/**
 * 将所有的子应用分为三大类,待加载、待挂载、待卸载
 */
function getAppChanges () {
  const appsToLoad = [],
    appsToMount = [],
    appsToUnmount = []
  
  apps.forEach(app => {
    switch (app.status) {
      // 待加载
      case NOT_LOADED:
        appsToLoad.push(app)
        break
      // 初始化 + 挂载
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (shouldBeActive(app)) {
          appsToMount.push(app)
        } 
        break
      // 待卸载
      case MOUNTED:
        if (!shouldBeActive(app)) {
          appsToUnmount.push(app)
        }
        break
    }
  })
  return { appsToLoad, appsToMount, appsToUnmount }
}

/**
 * 应用需要激活吗 ?
 * @param {*} app 
 * return true or false
 */
function shouldBeActive (app) {
  try {
    return app.activeWhen(window.location)
  } catch (err) {
    console.error('shouldBeActive function error', err);
    return false
  }
}

// 让子应用判断自己是否运行在基座应用中
window.singleSpaNavigate = true
// 监听路由
window.addEventListener('hashchange', reroute)
window.history.pushState = patchedUpdateState(window.history.pushState)
window.history.replaceState = patchedUpdateState(window.history.replaceState)
/**
 * 装饰器,增强 pushState 和 replaceState 方法
 * @param {*} updateState 
 */
function patchedUpdateState (updateState) {
  return function (...args) {
    // 当前url
    const urlBefore = window.location.href;
    // pushState or replaceState 的执行结果
    const result = Reflect.apply(updateState, this, args)
    // 执行updateState之后的url
    const urlAfter = window.location.href
    if (urlBefore !== urlAfter) {
      reroute()
    }
    return result
  }
}

看着是不是很简单,加注释也才200行而已,当然,这只是一个简版的single-spa框架,没什么健壮性可言,但也正因为简单,所以更能说明single-spa框架的本质。

single-spa-vue 源码分析

single-spa-vue负责为vue应用生成通用的生命周期钩子,这些钩子函数负责子应用的初始化、挂载、更新(数据)、卸载。

import "css.escape";

const defaultOpts = {
  // required opts
  Vue: null,
  appOptions: null,
  template: null
};

/**
 * 判断参数的合法性
 * 返回生命周期函数,其中的mount方法负责实例化子应用,update方法提供了基座应用和子应用通信的机会,unmount卸载子应用,bootstrap感觉没啥用
 * @param {*} userOpts = {
 *    Vue,
 *    appOptions: {
 *      el: '#id',
 *      store,
 *      router,
 *      render: h => h(App)
 *    } 
 * }
 * return 四个生命周期函数组成的对象
 */
export default function singleSpaVue(userOpts) {
  // object
  if (typeof userOpts !== "object") {
    throw new Error(`single-spa-vue requires a configuration object`);
  }

  // 合并用户选项和默认选项
  const opts = {
    ...defaultOpts,
    ...userOpts
  };

  // Vue构造函数
  if (!opts.Vue) {
    throw Error("single-spa-vue must be passed opts.Vue");
  }

  // appOptions
  if (!opts.appOptions) {
    throw Error("single-spa-vue must be passed opts.appOptions");
  }

  // el选择器
  if (
    opts.appOptions.el &&
    typeof opts.appOptions.el !== "string" &&
    !(opts.appOptions.el instanceof HTMLElement)
  ) {
    throw Error(
      `single-spa-vue: appOptions.el must be a string CSS selector, an HTMLElement, or not provided at all. Was given ${typeof opts
        .appOptions.el}`
    );
  }

  // Just a shared object to store the mounted object state
  // key - name of single-spa app, since it is unique
  let mountedInstances = {};

  /**
   * 返回一个对象,每个属性都是一个生命周期函数
   */
  return {
    bootstrap: bootstrap.bind(null, opts, mountedInstances),
    mount: mount.bind(null, opts, mountedInstances),
    unmount: unmount.bind(null, opts, mountedInstances),
    update: update.bind(null, opts, mountedInstances)
  };
}

function bootstrap(opts) {
  if (opts.loadRootComponent) {
    return opts.loadRootComponent().then(root => (opts.rootComponent = root));
  } else {
    return Promise.resolve();
  }
}

/**
 * 做了三件事情:
 *  大篇幅的处理el元素
 *  然后是render函数
 *  实例化子应用
 */
function mount(opts, mountedInstances, props) {
  const instance = {};
  return Promise.resolve().then(() => {
    const appOptions = { ...opts.appOptions };
    // 可以通过props.domElement属性单独设置自应用的渲染DOM容器,当然appOptions.el必须为空
    if (props.domElement && !appOptions.el) {
      appOptions.el = props.domElement;
    }

    let domEl;
    if (appOptions.el) {
      if (typeof appOptions.el === "string") {
        // 子应用的DOM容器
        domEl = document.querySelector(appOptions.el);
        if (!domEl) {
          throw Error(
            `If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}`
          );
        }
      } else {
        // 处理DOM容器是元素的情况
        domEl = appOptions.el;
        if (!domEl.id) {
          // 设置元素ID
          domEl.id = `single-spa-application:${props.name}`;
        }
        appOptions.el = `#${CSS.escape(domEl.id)}`;
      }
    } else {
      // 当然如果没有id,这里会自动生成一个id
      const htmlId = `single-spa-application:${props.name}`;
      appOptions.el = `#${CSS.escape(htmlId)}`;
      domEl = document.getElementById(htmlId);
      if (!domEl) {
        domEl = document.createElement("div");
        domEl.id = htmlId;
        document.body.appendChild(domEl);
      }
    }

    appOptions.el = appOptions.el + " .single-spa-container";

    // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
    // We want domEl to stick around and not be replaced. So we tell Vue to mount
    // into a container div inside of the main domEl
    if (!domEl.querySelector(".single-spa-container")) {
      const singleSpaContainer = document.createElement("div");
      singleSpaContainer.className = "single-spa-container";
      domEl.appendChild(singleSpaContainer);
    }

    instance.domEl = domEl;

    // render
    if (!appOptions.render && !appOptions.template && opts.rootComponent) {
      appOptions.render = h => h(opts.rootComponent);
    }

    // data
    if (!appOptions.data) {
      appOptions.data = {};
    }

    appOptions.data = { ...appOptions.data, ...props };

    // 实例化子应用
    instance.vueInstance = new opts.Vue(appOptions);
    if (instance.vueInstance.bind) {
      instance.vueInstance = instance.vueInstance.bind(instance.vueInstance);
    }

    mountedInstances[props.name] = instance;

    return instance.vueInstance;
  });
}

// 基座应用通过update生命周期函数可以更新子应用的属性
function update(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    // 应用实例
    const instance = mountedInstances[props.name];
    // 所有的属性
    const data = {
      ...(opts.appOptions.data || {}),
      ...props
    };
    // 更新实例对象上的属性值,vm.test = 'xxx'
    for (let prop in data) {
      instance.vueInstance[prop] = data[prop];
    }
  });
}

// 调用$destroy钩子函数,销毁子应用
function unmount(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    const instance = mountedInstances[props.name];
    instance.vueInstance.$destroy();
    instance.vueInstance.$el.innerHTML = "";
    delete instance.vueInstance;

    if (instance.domEl) {
      instance.domEl.innerHTML = "";
      delete instance.domEl;
    }
  });
}

结语

到这里就结束了,文章比较长,写这篇文章也花费了好几天的时间,但是感觉真的很好,收获满满,特别是最后手写框架部分。

也给各位同学一个建议,一定要勤动手,不动笔墨不读书,当你真的把框架写出来时,那个感觉是只看源码完全所不能比拟的,检验你是否真的懂框架原理的最好办法,就是看你能否写一个框架出来

愿同学们也能收获满满!!

链接

微前端专栏

github

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

李永宁lyn

Vue 源码解读(6)—— 实例方法

Vue 源码解读(6)—— 实例方法

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 Vue 源码解读(5)—— 全局 API 详细介绍了 Vue 的各个全局 API 的实现原理,本篇文章将会详细介绍各个实例方法的实现原理。

目标

深入理解以下实例方法的实现原理。

  • vm.$set

  • vm.$delete

  • vm.$watch

  • vm.$on

  • vm.$emit

  • vm.$off

  • vm.$once

  • vm._update

  • vm.$forceUpdate

  • vm.$destroy

  • vm.$nextTick

  • vm._render

源码解读

入口

/src/core/instance/index.js

该文件是 Vue 实例的入口文件,包括 Vue 构造函数的定义、各个实例方法的初始化。

// Vue 的构造函数
function Vue (options) {
  // 调用 Vue.prototype._init 方法,该方法是在 initMixin 中定义的
  this._init(options)
}

// 定义 Vue.prototype._init 方法
initMixin(Vue)
/**
 * 定义:
 *   Vue.prototype.$data
 *   Vue.prototype.$props
 *   Vue.prototype.$set
 *   Vue.prototype.$delete
 *   Vue.prototype.$watch
 */
stateMixin(Vue)
/**
 * 定义 事件相关的 方法:
 *   Vue.prototype.$on
 *   Vue.prototype.$once
 *   Vue.prototype.$off
 *   Vue.prototype.$emit
 */
eventsMixin(Vue)
/**
 * 定义:
 *   Vue.prototype._update
 *   Vue.prototype.$forceUpdate
 *   Vue.prototype.$destroy
 */
lifecycleMixin(Vue)
/**
 * 执行 installRenderHelpers,在 Vue.prototype 对象上安装运行时便利程序
 * 
 * 定义:
 *   Vue.prototype.$nextTick
 *   Vue.prototype._render
 */
renderMixin(Vue)

vm.$data、vm.$props

src/core/instance/state.js

这是两个实例属性,不是实例方法,这里简单介绍以下,当然其本身实现也很简单

// data
const dataDef = {}
dataDef.get = function () { return this._data }
// props
const propsDef = {}
propsDef.get = function () { return this._props }
// 将 data 属性和 props 属性挂载到 Vue.prototype 对象上
// 这样在程序中就可以通过 this.$data 和 this.$props 来访问 data 和 props 对象了
Object.defineProperty(Vue.prototype, '$data', dataDef)
Object.defineProperty(Vue.prototype, '$props', propsDef)

vm.$set

/src/core/instance/state.js

Vue.prototype.$set = set

set

/src/core/observer/index.js

/**
 * 通过 Vue.set 或者 this.$set 方法给 target 的指定 key 设置值 val
 * 如果 target 是对象,并且 key 原本不存在,则为新 key 设置响应式,然后执行依赖通知
 */
export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  // 更新数组指定下标的元素,Vue.set(array, idx, val),通过 splice 方法实现响应式更新
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 更新对象已有属性,Vue.set(obj, key, val),执行更新即可
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  // 不能向 Vue 实例或者 $data 添加动态添加响应式属性,vmCount 的用处之一,
  // this.$data 的 ob.vmCount = 1,表示根组件,其它子组件的 vm.vmCount 都是 0
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // target 不是响应式对象,新属性会被设置,但是不会做响应式处理
  if (!ob) {
    target[key] = val
    return val
  }
  // 给对象定义新属性,通过 defineReactive 方法设置响应式,并触发依赖更新
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

vm.$delete

/src/core/instance/state.js

Vue.prototype.$delete = del

del

/src/core/observer/index.js

/**
 * 通过 Vue.delete 或者 vm.$delete 删除 target 对象的指定 key
 * 数组通过 splice 方法实现,对象则通过 delete 运算符删除指定 key,并执行依赖通知
 */
export function del (target: Array<any> | Object, key: any) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }

  // target 为数组,则通过 splice 方法删除指定下标的元素
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__

  // 避免删除 Vue 实例的属性或者 $data 的数据
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  // 如果属性不存在直接结束
  if (!hasOwn(target, key)) {
    return
  }
  // 通过 delete 运算符删除对象的属性
  delete target[key]
  if (!ob) {
    return
  }
  // 执行依赖通知
  ob.dep.notify()
}

vm.$watch

/src/core/instance/state.js

/**
 * 创建 watcher,返回 unwatch,共完成如下 5 件事:
 *   1、兼容性处理,保证最后 new Watcher 时的 cb 为函数
 *   2、标示用户 watcher
 *   3、创建 watcher 实例
 *   4、如果设置了 immediate,则立即执行一次 cb
 *   5、返回 unwatch
 * @param {*} expOrFn key
 * @param {*} cb 回调函数
 * @param {*} options 配置项,用户直接调用 this.$watch 时可能会传递一个 配置项
 * @returns 返回 unwatch 函数,用于取消 watch 监听
 */
Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  // 兼容性处理,因为用户调用 vm.$watch 时设置的 cb 可能是对象
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  // options.user 表示用户 watcher,还有渲染 watcher,即 updateComponent 方法中实例化的 watcher
  options = options || {}
  options.user = true
  // 创建 watcher
  const watcher = new Watcher(vm, expOrFn, cb, options)
  // 如果用户设置了 immediate 为 true,则立即执行一次回调函数
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value)
    } catch (error) {
      handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
    }
  }
  // 返回一个 unwatch 函数,用于解除监听
  return function unwatchFn() {
    watcher.teardown()
  }
}

vm.$on

/src/core/instance/events.js

const hookRE = /^hook:/
/**
 * 监听实例上的自定义事件,vm._event = { eventName: [fn1, ...], ... }
 * @param {*} event 单个的事件名称或者有多个事件名组成的数组
 * @param {*} fn 当 event 被触发时执行的回调函数
 * @returns 
 */
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  const vm: Component = this
  if (Array.isArray(event)) {
    // event 是有多个事件名组成的数组,则遍历这些事件,依次递归调用 $on
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$on(event[i], fn)
    }
  } else {
    // 将注册的事件和回调以键值对的形式存储到 vm._event 对象中 vm._event = { eventName: [fn1, ...] }
    (vm._events[event] || (vm._events[event] = [])).push(fn)
    // hookEvent,提供从外部为组件实例注入声明周期方法的机会
    // 比如从组件外部为组件的 mounted 方法注入额外的逻辑
    // 该能力是结合 callhook 方法实现的
    if (hookRE.test(event)) {
      vm._hasHookEvent = true
    }
  }
  return vm
}

关于 hookEvent,下一篇文章会详细介绍。

vm.$emit

/src/core/instance/events.js

/**
 * 触发实例上的指定事件,vm._event[event] => cbs => loop cbs => cb(args)
 * @param {*} event 事件名
 * @returns 
 */
Vue.prototype.$emit = function (event: string): Component {
  const vm: Component = this
  if (process.env.NODE_ENV !== 'production') {
    // 将事件名转换为小些
    const lowerCaseEvent = event.toLowerCase()
    // 意思是说,HTML 属性不区分大小写,所以你不能使用 v-on 监听小驼峰形式的事件名(eventName),而应该使用连字符形式的事件名(event-name)
    if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
      tip(
        `Event "${lowerCaseEvent}" is emitted in component ` +
        `${formatComponentName(vm)} but the handler is registered for "${event}". ` +
        `Note that HTML attributes are case-insensitive and you cannot use ` +
        `v-on to listen to camelCase events when using in-DOM templates. ` +
        `You should probably use "${hyphenate(event)}" instead of "${event}".`
      )
    }
  }
  // 从 vm._event 对象上拿到当前事件的回调函数数组,并一次调用数组中的回调函数,并且传递提供的参数
  let cbs = vm._events[event]
  if (cbs) {
    cbs = cbs.length > 1 ? toArray(cbs) : cbs
    const args = toArray(arguments, 1)
    const info = `event handler for "${event}"`
    for (let i = 0, l = cbs.length; i < l; i++) {
      invokeWithErrorHandling(cbs[i], vm, args, vm, info)
    }
  }
  return vm
}

vm.$off

/src/core/instance/events.js

/**
 * 移除自定义事件监听器,即从 vm._event 对象中找到对应的事件,移除所有事件 或者 移除指定事件的回调函数
 * @param {*} event 
 * @param {*} fn 
 * @returns 
 */
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
  const vm: Component = this
  // vm.$off() 移除实例上的所有监听器 => vm._events = {}
  if (!arguments.length) {
    vm._events = Object.create(null)
    return vm
  }
  // 移除一些事件 event = [event1, ...],遍历 event 数组,递归调用 vm.$off
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$off(event[i], fn)
    }
    return vm
  }
  // 除了 vm.$off() 之外,最终都会走到这里,移除指定事件
  const cbs = vm._events[event]
  if (!cbs) {
    // 表示没有注册过该事件
    return vm
  }
  if (!fn) {
    // 没有提供 fn 回调函数,则移除该事件的所有回调函数,vm._event[event] = null
    vm._events[event] = null
    return vm
  }
  // 移除指定事件的指定回调函数,就是从事件的回调数组中找到该回调函数,然后删除
  let cb
  let i = cbs.length
  while (i--) {
    cb = cbs[i]
    if (cb === fn || cb.fn === fn) {
      cbs.splice(i, 1)
      break
    }
  }
  return vm
}

vm.$once

/src/core/instance/events.js

/**
 * 监听一个自定义事件,但是只触发一次。一旦触发之后,监听器就会被移除
 * vm.$on + vm.$off
 * @param {*} event 
 * @param {*} fn 
 * @returns 
 */
Vue.prototype.$once = function (event: string, fn: Function): Component {
  const vm: Component = this

  // 调用 $on,只是 $on 的回调函数被特殊处理了,触发时,执行回调函数,先移除事件监听,然后执行你设置的回调函数
  function on() {
    vm.$off(event, on)
    fn.apply(vm, arguments)
  }
  on.fn = fn
  vm.$on(event, on)
  return vm
}

vm._update

/src/core/instance/lifecycle.js

/**
 * 负责更新页面,页面首次渲染和后续更新的入口位置,也是 patch 的入口位置 
 */
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // 首次渲染,即初始化页面时走这里
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 响应式数据更新时,即更新页面时走这里
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

vm.$forceUpdate

/src/core/instance/lifecycle.js

/**
 * 直接调用 watcher.update 方法,迫使组件重新渲染。
 * 它仅仅影响实例本身和插入插槽内容的子组件,而不是所有子组件
 */
Vue.prototype.$forceUpdate = function () {
  const vm: Component = this
  if (vm._watcher) {
    vm._watcher.update()
  }
}

vm.$destroy

/src/core/instance/lifecycle.js

/**
 * 完全销毁一个实例。清理它与其它实例的连接,解绑它的全部指令及事件监听器。
 */
Vue.prototype.$destroy = function () {
  const vm: Component = this
  if (vm._isBeingDestroyed) {
    // 表示实例已经销毁
    return
  }
  // 调用 beforeDestroy 钩子
  callHook(vm, 'beforeDestroy')
  // 标识实例已经销毁
  vm._isBeingDestroyed = true
  // 把自己从老爹($parent)的肚子里($children)移除
  const parent = vm.$parent
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm)
  }
  // 移除依赖监听
  if (vm._watcher) {
    vm._watcher.teardown()
  }
  let i = vm._watchers.length
  while (i--) {
    vm._watchers[i].teardown()
  }
  // remove reference from data ob
  // frozen object may not have observer.
  if (vm._data.__ob__) {
    vm._data.__ob__.vmCount--
  }
  // call the last hook...
  vm._isDestroyed = true
  // 调用 __patch__,销毁节点
  vm.__patch__(vm._vnode, null)
  // 调用 destroyed 钩子
  callHook(vm, 'destroyed')
  // 关闭实例的所有事件监听
  vm.$off()
  // remove __vue__ reference
  if (vm.$el) {
    vm.$el.__vue__ = null
  }
  // release circular reference (#6759)
  if (vm.$vnode) {
    vm.$vnode.parent = null
  }
}

vm.$nextTick

/src/core/instance/render.js

Vue.prototype.$nextTick = function (fn: Function) {
  return nextTick(fn, this)
}

nextTick

/src/core/util/next-tick.js

const callbacks = []
/**
 * 完成两件事:
 *   1、用 try catch 包装 flushSchedulerQueue 函数,然后将其放入 callbacks 数组
 *   2、如果 pending 为 false,表示现在浏览器的任务队列中没有 flushCallbacks 函数
 *     如果 pending 为 true,则表示浏览器的任务队列中已经被放入了 flushCallbacks 函数,
 *     待执行 flushCallbacks 函数时,pending 会被再次置为 false,表示下一个 flushCallbacks 函数可以进入
 *     浏览器的任务队列了
 * pending 的作用:保证在同一时刻,浏览器的任务队列中只有一个 flushCallbacks 函数
 * @param {*} cb 接收一个回调函数 => flushSchedulerQueue
 * @param {*} ctx 上下文
 * @returns 
 */
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 用 callbacks 数组存储经过包装的 cb 函数
  callbacks.push(() => {
    if (cb) {
      // 用 try catch 包装回调函数,便于错误捕获
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 执行 timerFunc,在浏览器的任务队列中(首选微任务队列)放入 flushCallbacks 函数
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

vm._render

/src/core/instance/render.js

/**
 * 通过执行 render 函数生成 VNode
 * 不过里面加了大量的异常处理代码
 */
Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    )
  }

  // 设置父 vnode。这使得渲染函数可以访问占位符节点上的数据。
  vm.$vnode = _parentVnode
  // render self
  let vnode
  try {
    currentRenderingInstance = vm
    // 执行 render 函数,生成 vnode
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    handleError(e, vm, `render`)
    // 到这儿,说明执行 render 函数时出错了
    // 开发环境渲染错误信息,生产环境返回之前的 vnode,以防止渲染错误导致组件空白
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production' && vm.$options.renderError) {
      try {
        vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
      } catch (e) {
        handleError(e, vm, `renderError`)
        vnode = vm._vnode
      }
    } else {
      vnode = vm._vnode
    }
  } finally {
    currentRenderingInstance = null
  }
  // 如果返回的 vnode 是数组,并且只包含了一个元素,则直接打平
  if (Array.isArray(vnode) && vnode.length === 1) {
    vnode = vnode[0]
  }
  // render 函数出错时,返回一个空的 vnode
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
      warn(
        'Multiple root nodes returned from render function. Render function ' +
        'should return a single root node.',
        vm
      )
    }
    vnode = createEmptyVNode()
  }
  // set parent
  vnode.parent = _parentVnode
  return vnode
}

installRenderHelpers

src/core/instance/render-helpers/index.js

该方法负责在实例上安装大量和渲染相关的简写的工具函数,这些工具函数用在编译器生成的渲染函数中,比如 v-for 编译后的 vm._l,还有大家最熟悉的 h 函数(vm._c),不过它没在这里声明,是在 initRender 函数中声明的。

installRenderHelpers 方法是在 renderMixin 中被调用的。

/**
 * 在实例上挂载简写的渲染工具函数
 * @param {*} target Vue 实例
 */
export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
  target._d = bindDynamicKeys
  target._p = prependModifier
}

如果对某个方法感兴趣,可以自行深究。

总结

  • 面试官 问:vm.$set(obj, key, val) 做了什么?

    vm.$set 用于向响应式对象添加一个新的 property,并确保这个新的 property 同样是响应式的,并触发视图更新。由于 Vue 无法探测对象新增属性或者通过索引为数组新增一个元素,比如:this.obj.newProperty = 'val'this.arr[3] = 'val'。所以这才有了 vm.$set,它是 Vue.set 的别名。

    • 为对象添加一个新的响应式数据:调用 defineReactive 方法为对象增加响应式数据,然后执行 dep.notify 进行依赖通知,更新视图

    • 为数组添加一个新的响应式数据:通过 splice 方法实现


  • 面试官 问:vm.$delete(obj, key) 做了什么?

    vm.$delete 用于删除对象上的属性。如果对象是响应式的,且能确保能触发视图更新。该方法主要用于避开 Vue 不能检测属性被删除的情况。它是 Vue.delete 的别名。

    • 删除数组指定下标的元素,内部通过 splice 方法来完成

    • 删除对象上的指定属性,则是先通过 delete 运算符删除该属性,然后执行 dep.notify 进行依赖通知,更新视图


  • 面试官 问:vm.$watch(expOrFn, callback, [options]) 做了什么?

    答:

    vm.$watch 负责观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。当其发生变化时,回调函数就会被执行,并为回调函数传递两个参数,第一个为更新后的新值,第二个为老值。

    这里需要 注意 一点的是:如果观察的是一个对象,比如:数组,当你用数组方法,比如 push 为数组新增一个元素时,回调函数被触发时传递的新值和老值相同,因为它们指向同一个引用,所以在观察一个对象并且在回调函数中有新老值是否相等的判断时需要注意。

    vm.$watch 的第一个参数只接收简单的响应式数据的键路径,对于更复杂的表达式建议使用函数作为第一个参数。

    至于 vm.$watch 的内部原理是:

    • 设置 options.user = true,标志是一个用户 watcher

    • 实例化一个 Watcher 实例,当检测到数据更新时,通过 watcher 去触发回调函数的执行,并传递新老值作为回调函数的参数

    • 返回一个 unwatch 函数,用于取消观察


  • 面试官 问:vm.$on(event, callback) 做了什么?

    监听当前实例上的自定义事件,事件可由 vm.$emit 触发,回调函数会接收所有传入事件触发函数(vm.$emit)的额外参数。

    vm.$on 的原理很简单,就是处理传递的 event 和 callback 两个参数,将注册的事件和回调函数以键值对的形式存储到 vm._event 对象中,vm._events = { eventName: [cb1, cb2, ...], ... }。


  • 面试官 问:vm.$emit(eventName, [...args]) 做了什么?

    触发当前实例上的指定事件,附加参数都会传递给事件的回调函数。

    其内部原理就是执行 vm._events[eventName] 中所有的回调函数。

    备注:从 $on 和 $emit 的实现原理也能看出,组件的自定义事件其实是谁触发谁监听,所以在这会儿再回头看 Vue 源码解读(2)—— Vue 初始化过程 中关于 initEvent 的解释就会明白在说什么,因为组件自定义事件的处理内部用的就是 vm.$on、vm.$emit。


  • 面试官 问:vm.$off([event, callback]) 做了什么?

    移除自定义事件监听器,即移除 vm._events 对象上相关数据。

    • 如果没有提供参数,则移除实例的所有事件监听

    • 如果只提供了 event 参数,则移除实例上该事件的所有监听器

    • 如果两个参数都提供了,则移除实例上该事件对应的监听器


  • 面试官 问:vm.$once(event, callback) 做了什么?

    监听一个自定义事件,但是该事件只会被触发一次。一旦触发以后监听器就会被移除。

    其内部的实现原理是:

    • 包装用户传递的回调函数,当包装函数执行的时候,除了会执行用户回调函数之外还会执行 vm.$off(event, 包装函数) 移除该事件

    • vm.$on(event, 包装函数) 注册事件


  • 面试官 问:vm._update(vnode, hydrating) 做了什么?

    官方文档没有说明该 API,这是一个用于源码内部的实例方法,负责更新页面,是页面渲染的入口,其内部根据是否存在 prevVnode 来决定是首次渲染,还是页面更新,从而在调用 __patch__ 函数时传递不同的参数。该方法在业务开发中不会用到。


  • 面试官 问:vm.$forceUpdate() 做了什么?

    迫使 Vue 实例重新渲染,它仅仅影响组件实例本身和插入插槽内容的子组件,而不是所有子组件。其内部原理到也简单,就是直接调用 vm._watcher.update(),它就是 watcher.update() 方法,执行该方法触发组件更新。


  • 面试官 问:vm.$destroy() 做了什么?

    负责完全销毁一个实例。清理它与其它实例的连接,解绑它的全部指令和事件监听器。在执行过程中会调用 beforeDestroydestroy 两个钩子函数。在大多数业务开发场景下用不到该方法,一般都通过 v-if 指令来操作。其内部原理是:

    • 调用 beforeDestroy 钩子函数

    • 将自己从老爹肚子里($parent)移除,从而销毁和老爹的关系

    • 通过 watcher.teardown() 来移除依赖监听

    • 通过 vm.__patch__(vnode, null) 方法来销毁节点

    • 调用 destroyed 钩子函数

    • 通过 vm.$off 方法移除所有的事件监听


  • 面试官 问:vm.$nextTick(cb) 做了什么?

    vm.$nextTick 是 Vue.nextTick 的别名,其作用是延迟回调函数 cb 的执行,一般用于 this.key = newVal 更改数据后,想立即获取更改过后的 DOM 数据:

    this.key = 'new val'
    
    Vue.nextTick(function() {
      // DOM 更新了
    })

    其内部的执行过程是:

    • this.key = 'new val',触发依赖通知更新,将负责更新的 watcher 放入 watcher 队列

    • 将刷新 watcher 队列的函数放到 callbacks 数组中

    • 在浏览器的异步任务队列中放入一个刷新 callbacks 数组的函数

    • vm.$nextTick(cb) 来插队,直接将 cb 函数放入 callbacks 数组

    • 待将来的某个时刻执行刷新 callbacks 数组的函数

    • 然后执行 callbacks 数组中的众多函数,触发 watcher.run 的执行,更新 DOM

    • 由于 cb 函数是在后面放到 callbacks 数组,所以这就保证了先完成的 DOM 更新,再执行 cb 函数


  • 面试官 问:vm._render 做了什么?

    官方文档没有提供该方法,它是一个用于源码内部的实例方法,负责生成 vnode。其关键代码就一行,执行 render 函数生成 vnode。不过其中加了大量的异常处理代码。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

PDF 生成(4)— 目录页

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

封面.png

回顾

上一篇 PDF 生成(3)— 封面、尾页 介绍了如何为通过 Puppeteer 生成的 PDF 文件添加封面和尾页,现在再来整体回顾一下:

  • 首先,技术方案决定了一个页面对应一份 PDF 文件,这是大前提,因为 page.xx 方法的所有配置都是针对当前页的
  • 在大前提下,我们通过 PDF 文件合并方案(pdf-lib),分别将封面 PDF、内容页 PDF 和尾页 PDF 三份文件合并为一份报告包含封面、内容页和尾页的完整 PDF

在上一篇结束后,PDF 文件的整体框架已经基本形成(包括封面、内容页、尾页),但还有一点点不完整,比如缺少目录页。一份完整的文件或文章怎么能没有目录呢?

简介

本文详细阐述了如何为 PDF 文件增加目录页,让文件更加完整和易于阅读。目录页在一本书或一篇长文中扮演着非常重要的角色,它是内容的整体概览,可以帮助读者快速了解内容的整体结构,并定位到感兴趣的章节。

PDF 文件也不例外,比如前面我们生成的百度新闻的 PDF 文件的内容部分已经有 5页了,共 12个版块,但每个版块的内容是什么,用户只有全部浏览一遍才能知道,效率太低,体验太差。

所以我们的 PDF 文件需要一个目录页,能让用户快速了解到这份文件都包含哪些板块,每个版块在什么位置,并能直接定位过去。
PDF 文件生成目录页的方案非常少,几乎没找到,比如 jsPDF 这个库,它可以生成一份 PDF 文件,并对指定位置的内容设置链接,跳转到某个页面,但它不擅长加载已有的 PDF 文件,并编辑它。

在我们这套技术架构下,有一个现成的方案,就是 HTML 锚点,因为通过浏览器打印系统生成的 PDF 文件,可以保留部分 HTML + CSS 的能力,接下来就进入实战阶段,为百度新闻 PDF 文件增加目录页。

注意:为 PDF 生成目录页是整套方案中的难点之一,特别是页码部分

生成目录页

前面我们提到了目录页的方案是 HTML 锚点,也就是说目录页中的每个目录项都是一个 a 标签,通过点击 a 标签实现页面跳转,因此,我们需要在内容页的前面增加一段由 a 标签组成的 html。

但可惜百度新闻页不是我们自己的页面,没办法直接在页面的开始位置增加这段 HTML,而且这段 HTML 属于 PDF 文件独有的,直接加到现有页面上也不合适。怎么办?

想想我们在 PDF 生成(2)— 生成 PDF 文件 一文中 打印完整网页(网页滚动 — 懒加载场景) 模块,我们通过 page.evaluate 方法到回调函数操作页面滚动从而加载更多内容。同样,这里我们可以通过该方法为当前页面增加目录页 DOM。代码如下:

image.png
image.png
image.png

整体思路是:

  • 通过 page.evaluate 方法为浏览器注入一段代码,这段代码会通过 JS 操作 DOM 的方式,完成整个目录页 DOM 的创建,并将 DOM 插入到新闻页 DOM 的最前面
  • 目录显示了当前文档都有哪些版块,并且通过 a 标签的锚点实现页面跳转的效果

效果如下:

292667592-e0ce8dae-1c1f-4418-83e7-ce9b274a1d0b.gif

发现目录的基本能力我们已经实现了,但仔细看,会发现有两个问题:

  • 目录项没有页码
  • 目录页一般都是独立自成一页,但现在却和内容页混到了一页

第二个问题比较简单,我们先来解决。

目录页自成一页

直接上结论:这个问题可以通过 break-after: page 这个 CSS 样式来解决。

这里讲三个用于在打印中控制元素如何分页或分割的样式,分别是 break-after、break-before 和 break-inside。简单讲,这三个样式的作用类似,都是用来在页面中合适的位置设置断点(即元素边界),比如:

  • break-after,用来控制当前元素后面内容的行为,比如 break-after: page 意思是当前元素后面的内容强制分页(即新开一页)
  • break-before,用来控制当前元素之前的分页行为
  • break-inside,用来控制当前元素内部的分页行为

具体内容,大家可以查询 MDN,有详细讲解。

所以,我们只需要给目录的容器节点设置 page-after: page 样式,让后面的内容页强制新开一页,和目录页分开。

代码如下:
image.png

效果如下:

image.png

目录项页码

一样,先上结论:目录项的页码 = 锚点对应的元素距离页面顶部的高度 / PDF 一页的高度。这里大家思考一下再继续,原理很好理解,就不细讲了,直接上代码。

image-20240308130347761这里有一点

需要注意:在计算页码之前需要将页面滚动回顶部,因为前面我们为了加载完整的页面,将页面滚动到了底部,直接计算的话,会发现大部分元素的const { y } = anchorEl.getBoundingClientRect()为负值,出现这个问题的原因是因为el.getBoundingClientRect()是基于视口来计算的,如果不在页面底部,可以想象一下,相关元素大都在视口的上方了,所以计算的 y 就是负值了。

image-20240308130433665

效果如下:

image.png

但这时候发现,页码准确性有点问题,比如最后一个图片新闻

image.png

出现这个问题的原因是因为计算公式中PDF 一页的高度的假设,大家可以看上面的代码,这个高度设定的是 1123,那为什么是 1123px 呢?

这里有一个知识点 — A4 纸在不同屏幕分辨率下的像素尺寸

A4 纸的标准尺寸是 210mm * 297mm,但是,在屏幕上显示 A4 纸的像素尺寸取决于屏幕的分辨率,比如:

屏幕分辨率(像素/英寸) A4 纸像素尺寸(宽 × 高)
72 595 × 842
96(默认) 794 × 1123
120 1487 x 2105
150 1240 x 1754
300 2480 × 3508

其中默认 DPI 为 96,即一英寸显示 96 个像素点,1英寸 = 25.4mm,即 25.4mm = 96px,所以 (210 / 25.4 * 96) * (297 / 25.4 * 96) = 794 * 1123

这就是为什么 PDF 一页的高度设置为 1123px 了。

但现在按照这个值计算出来的页码有问题,是因为百度新闻页的开发(设计)尺寸不是 794px * 1123px。

所以,这里有一个结论:如果不知道页面的设计尺寸,没办法计算出准确的页码

如何构造准确的目录页(目录项页码的准确性)

本节不是反驳上面的结论,在当前 Demo 的场景下确实是没办法计算出准确的页码。但我们在实际的 PDF 文件生成业务中,是一定要保证的页码的正确性的。这是整个方案的难点之一,探索过程很难,但知道了结果,发现也就那样,所以也算是一个最佳实践吧。接下来我们就一步步进行,去构造一个拥有准确页码的目录页。

设计稿尺寸要求

在 PDF 生成业务场景中,我们是一定知道页面设计尺寸的(毕竟 UI 都是我们自己写的),而且 PDF 页面一般是 A4 纸大小,在 master go 中 A4 纸的大小是 595 * 842 像素。

但设计稿需要以 2 倍图的尺寸来设计,因为真按照 595 * 842 的尺寸来设计的话,最终的设计稿会发现文字的大小基本上都是在 12px 以下,这非常不利于开发。这点需要注意,属于最佳实践。

注意:* page.pdf({ format: 'A4' })一定要和设计稿规范一致,比如都是 A4 纸大小,否则计算页码时还会有问题,原因请继续往下看。

页面缩放

先上结论:页面缩放是指将设计稿尺寸为 X * Y 的页面放到 M * N 的 PDF 页面中,比如将 1190 * 1684 的 UI 页面放到 794 * 1123 的 PDF 页面中。

1190 * 1684 是 A4 纸设计稿的 2 倍图尺寸,794 * 1123 是 DPI 为 96(默认)的分辨率对应的 A4 纸的尺寸

现在,我们通过一个 Demo 来模拟真实的 PDF 生成需求,假设有如下场景:

  • 设计稿尺寸:1190 * 1684
  • 有一个页面,分别在 10px 和 1500px 像素的位置有两个锚点
  • 页眉、页脚,页面左右两侧留白空间都为 80px,如下图所示

image.png

  • 屏幕分辨率:96 像素/英寸,即对应的 A4 纸的像素是 794 * 1123

根据需求,我们对代码做如下改动:

增加exact-page-num.html作为 Demo 页面,代码如下:

image.png

改动/server/index.mjs

  • 替换访问的页面为我们的 Demo 页

image.png

  • 设置新的目录项

image.png

  • 设置页眉、页脚和左右两侧的留白空间

image.png

  • 设置 PDF 页面的像素高度

image.png

改造完成,重新生成 PDF,效果如下:

image.png
image.png
image.png

看完生成的 PDF 文件,可以发现如下问题:

  • 目录页中的两个目录项对应的页码都是 2,但这两项实际分别在第 2 页和第 3 页
  • 设计稿的尺寸是 1190 * 1684,Demo 页面的高度也是 1684px,那理论上 PDF 的内容应该只有一页,但现在却有两页
  • 页眉、页脚、左右两侧的空间太大了,根本不是 80px 应该有的效果

为什么会出现这三个问题呢?

其原因是我们前面提到的分辨率,设计稿是按照 1190 * 1684 来设计的,对应的 DPI 是 144,但实际生成 PDF 时的 DPI 只有 96,对应的像素尺寸是 794 × 1123。

1190 * 1684 是 595 * 842 的 2 倍图尺寸,前面的分辨率和 A4 纸像素尺寸表中显示 595 * 842 对应的 DPI 是 72

因此就出现了一页的设计稿,生成 PDF 之后就变成了两页,因为 PDF 一页的高度只有 1123px,另外也导致了页码计算错误。

上述情况,就相当于高分辨率的内容放到了低分屏上显示。因此,要想生成的 PDF 内容和设计稿一致,我们需要缩放页面,即开头提到的,将 1190 * 1684 的页面放到 794 * 1123 的页面中。

page.pdf 方法提供了一个 scale 的参数,专门用来缩放页面,默认是 1,我们需要的缩放比例是 1123 / 1684,我们对代码做如下改动:

image.png
image.png

效果如下:

image.png
image.png

现在,目录页中目录项对应的页码终于对了,PDF 内容页的显示也对了。但还没结束,因为还有一个场景需要处理。

说明:关于缩放这块儿,其实页眉、页脚的内容也需要缩放,这部分内容我们没有演示。

换页 — 高度补充

有时候我们会要求大标题新开一页,就像书籍一样,下一章或下一节一般会新起一页。在 PDF 生成业务中同样要求如此,比如,要求锚点 2 新开一页。

实现层面我们可以通过前面介绍的break-before: page样式来完成,代码如下:

image.png

生成的 PDF 效果如下:

image.png
image.png

锚点 2 新开了一页,很完美?但大家再看下目录页中目录项的页码:

image.png

发现两个目录项对应的页码还是 2,但实际页码分别是 2 和 3,这个结果很好解释:页码的计算方式没变,break-before: page 属于打印样式,只在打印场景生效,即浏览器直接打开页面看不到 break-before: page 的样式效果

也就是说,锚点是按照页面中的高度来计算的,计算时它所在的高度是第 2 页,但生成 PDF 的时候,由于发现锚点上有break-before: page样式,于是就把本该在第二页的元素放到了第 3 页(新起一页)。

原因很简单,但结果是,目录页中目录项的页码显示错误,这个问题该怎么解决呢?

可以发现,如果没有break-before: page,一切都很完美,都是因为加上这个样式之后,生成的 PDF 文件中“莫名其妙”的多出来一段空白(想象 Word 文档中的换页效果),这段空白区域导致页码不准。

假设这样一个场景,如果每个锚点的位置都很完美,刚好在下一页的开始位置,那这个问题就不存在了。那有没有可能我们人为的去构造这样一个完美的场景呢?答案是可以

我们知道每页的高度,也知道锚点在页面中的位置(高度),这就可以计算出当前锚点是否处于 PDF 页面的开头,如果刚好在开头,我们不做任何处理,如果不在,就意味着我们需要让它移动到页面的开头,那怎么让锚点元素移动到开头呢?有两种方案:

  • 用一个实际的元素来填充空白区域,将当前锚点元素顶到下一页的开始位置
  • 修正元素的计算高度,假如元素距离下一页的开始位置差 20px,那就在计算页码时,将当前元素的高度手动补 20px,这样就可以假设元素在页面的开始位置,从而计算出正确的页码

方案一的效果:

image.png

但我们最终采用了方案二,虽然两个方案原理和效果一样,但方案二的容错性更强,方案一可能会因为一些意外情况(比如计算错误、精度问题等)导致填充元素高度异常,导致产生额外的空白页,比如:锚点x 本来就在页面的开始位置,但由于计算错误,填充一个高度为 2px 的空白区域,导致本该在页面开始位置的元素距离顶部多了 2px,在打印时就会被自动移动到下一页,我们实际看到的 PDF 页面就会多出来一页空白页;方案二同样存在意外,但最差的情况也就是部分目录项的页码计算错误,而不会引起很明显的显示问题。

代码如下:

image.png
image-20240308130938911

效果如下:

image.png

内容页没变,和前面的效果一样。到这里,一份完善的 PDF 目录页总算是出来了。

总结

我们再来回顾一下本文的内容:

  • 开头,我们通过 page.evaluate 方法为浏览器注入 JS 代码,通过这段 JS 在 PDF 内容页的开始位置(body 的第一个子元素)插入由 a 标签和对应的样式组成的目录页 DOM,从而通过 HTML 锚点实现目录项的页面跳转能力
  • 接下来,我们通过为目录页的容器元素设置break-after: page样式实现目录页自成一页的效果(和内容页分别两页)
  • 然后剩下的所有篇幅都是在讲如何生成带有准确页码的目录项
    • 首先,页码是按照锚点元素在页面中的高度 / PDF 一页的高度来计算的
    • 后来,我们通过下面三步来保证目录页中目录项对应页码的准确性
      • 规范化设计稿尺寸(按照 A4 纸对应的 2 倍图尺寸设计)
      • 通过页面缩放解决设计稿 DPI 和实际生成 PDF 时 DPI 的差异问题(彻底统一计算时 PDF 一页的像素高度)
      • 通过页面高度补充的方案解决章节标题换页引起目录项页码计算错误的问题

到这里,PDF 文件的整体框架已完全成型,包括封面、目录页、内容页和尾页四部分。但系列还没结束,接下来我们会通过 PDF 生成(5)— 内容页支持由多页面组成 来提升接入方的使用体验和前端代码可维护性。
开始之前,大家回顾一下现在 PDF 文件内容页的生成,站在接入方的角度看,是否存在问题?假设一个场景,接入方的 PDF 文件呈现的内容量非常大,比如拥有几十甚至几百页的内容,那接入方的这个前端页面的代码该怎么维护呢?页面性能该怎么保证呢?

链接

  • PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

HTML Entry 源码分析

HTML Entry 源码分析

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

202202031927948

简介

从 HTML Entry 的诞生原因 -> 原理简述 -> 实际应用 -> 源码分析,带你全方位刨析 HTML Entry 框架。

序言

HTML Entry 这个词大家可能比较陌生,毕竟在 google 上搜 HTML Entry 是什么 ? 都搜索不到正确的结果。但如果你了解微前端的话,可能就会有一些了解。

致读者

本着不浪费大家时间的原则,特此说明,如果你能读懂 HTML Entry 是什么?? 部分,则可继续往下阅读,如果看不懂建议阅读完推荐资料再回来阅读

JS Entry 有什么问题

说到 HTML Entry 就不得不提另外一个词 JS Entry,因为 HTML Entry 就是来解决 JS Entry 所面临的问题的。

微前端领域最著名的两大框架分别是 single-spaqiankun,后者是基于前者做了二次封装,并解决了前者的一些问题。

single-spa 就做了两件事情:

  • 加载微应用(加载方法还得用户自己来实现)
  • 管理微应用的状态(初始化、挂载、卸载)

JS Entry 的理念就在加载微应用的时候用到了,在使用 single-spa 加载微应用时,我们加载的不是微应用本身,而是微应用导出的 JS 文件,而在入口文件中会导出一个对象,这个对象上有 bootstrapmountunmount 这三个接入 single-spa 框架必须提供的生命周期方法,其中 mount 方法规定了微应用应该怎么挂载到主应用提供的容器节点上,当然你要接入一个微应用,就需要对微应用进行一系列的改造,然而 JS Entry 的问题就出在这儿,改造时对微应用的侵入行太强,而且和主应用的耦合性太强。

single-spa 采用 JS Entry 的方式接入微应用。微应用改造一般分为三步:

  • 微应用路由改造,添加一个特定的前缀
  • 微应用入口改造,挂载点变更和生命周期函数导出
  • 打包工具配置更改

侵入型强其实说的就是第三点,更改打包工具的配置,使用 single-spa 接入微应用需要将微应用整个打包成一个 JS 文件,发布到静态资源服务器,然后在主应用中配置该 JS 文件的地址告诉 single-spa 去这个地址加载微应用。

不说其它的,就现在这个改动就存在很大的问题,将整个微应用打包成一个 JS 文件,常见的打包优化基本上都没了,比如:按需加载、首屏资源加载优化、css 独立打包等优化措施。

注意:子应用也可以将包打成多个,然后利用 webpack 的 webpack-manifest-plugin 插件打包出 manifest.json 文件,生成一份资源清单,然后主应用的 loadApp 远程读取每个子应用的清单文件,依次加载文件里面的资源;不过该方案也没办法享受子应用的按需加载能力

项目发布以后出现了 bug ,修复之后需要更新上线,为了清除浏览器缓存带来的应用,一般文件名会带上 chunkcontent,微应用发布之后文件名都会发生变化,这时候还需要更新主应用中微应用配置,然后重新编译主应用然后发布,这套操作简直是不能忍受的,这也是 微前端框架 之 single-spa 从入门到精通 这篇文章中示例项目中微应用发布时的环境配置选择 development 的原因。

qiankun 框架为了解决 JS Entry 的问题,于是采用了 HTML Entry 的方式,让用户接入微应用就像使用 iframe 一样简单。

如果以上内容没有看懂,则说明这篇文章不太适合你阅读,建议阅读 微前端框架 之 single-spa 从入门到精通,这篇文章详细讲述了 single-spa 的基础使用和源码原理,阅读完以后再回来读这篇文章会有事半功倍的效果,请读者切勿强行阅读,否则可能出现头昏脑胀的现象。

HTML Entry

HTML Entry 是由 import-html-entry 库实现的,通过 http 请求加载指定地址的首屏内容即 html 页面,然后解析这个 html 模版得到 template, scripts , entry, styles

{
  template: 经过处理的脚本,link、script 标签都被注释掉了,
  scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
  styles: [样式的http地址],
 	entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
}

然后远程加载 styles 中的样式内容,将 template 模版中注释掉的 link 标签替换为相应的 style 元素。

然后向外暴露一个 Promise 对象

{
  // template 是 link 替换为 style 后的 template
	template: embedHTML,
	// 静态资源地址
	assetPublicPath,
	// 获取外部脚本,最终得到所有脚本的代码内容
	getExternalScripts: () => getExternalScripts(scripts, fetch),
	// 获取外部样式文件的内容
	getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
	// 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
	execScripts: (proxy, strictGlobal) => {
		if (!scripts.length) {
			return Promise.resolve();
		}
		return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
	}
}

这就是 HTML Entry 的原理,更详细的内容可继续阅读下面的源码分析部分

实际应用

qiankun 框架为了解决 JS Entry 的问题,就采用了 HTML Entry 的方式,让用户接入微应用就像使用 iframe 一样简单。

通过上面的阅读知道了 HTML Entry 最终会返回一个 Promise 对象,qiankun 就用了这个对象中的 templateassetPublicPathexecScripts 三项,将 template 通过 DOM 操作添加到主应用中,执行 execScripts 方法得到微应用导出的生命周期方法,并且还顺便解决了 JS 全局污染的问题,因为执行 execScripts 方法的时候可以通过 proxy 参数指定 JS 的执行上下文。

更加具体的内容可阅读 微前端框架 之 qiankun 从入门到源码分析

HTML Entry 源码分析

importEntry

/**
 * 加载指定地址的首屏内容
 * @param {*} entry 可以是一个字符串格式的地址,比如 localhost:8080,也可以是一个配置对象,比如 { scripts, styles, html }
 * @param {*} opts
 * return importHTML 的执行结果
 */
export function importEntry(entry, opts = {}) {
	// 从 opt 参数中解析出 fetch 方法 和 getTemplate 方法,没有就用默认的
	const { fetch = defaultFetch, getTemplate = defaultGetTemplate } = opts;
	// 获取静态资源地址的一个方法
	const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;

	if (!entry) {
		throw new SyntaxError('entry should not be empty!');
	}

	// html entry,entry 是一个字符串格式的地址
	if (typeof entry === 'string') {
		return importHTML(entry, { fetch, getPublicPath, getTemplate });
	}

	// config entry,entry 是一个对象 = { scripts, styles, html }
	if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {

		const { scripts = [], styles = [], html = '' } = entry;
		const setStylePlaceholder2HTML = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl);
		const setScriptPlaceholder2HTML = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl);

		return getEmbedHTML(getTemplate(setScriptPlaceholder2HTML(setStylePlaceholder2HTML(html))), styles, { fetch }).then(embedHTML => ({
			template: embedHTML,
			assetPublicPath: getPublicPath(entry),
			getExternalScripts: () => getExternalScripts(scripts, fetch),
			getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
			execScripts: (proxy, strictGlobal) => {
				if (!scripts.length) {
					return Promise.resolve();
				}
				return execScripts(scripts[scripts.length - 1], scripts, proxy, { fetch, strictGlobal });
			},
		}));

	} else {
		throw new SyntaxError('entry scripts or styles should be array!');
	}
}

importHTML

/**
 * 加载指定地址的首屏内容
 * @param {*} url 
 * @param {*} opts 
 * return Promise<{
  	// template 是 link 替换为 style 后的 template
		template: embedHTML,
		// 静态资源地址
		assetPublicPath,
		// 获取外部脚本,最终得到所有脚本的代码内容
		getExternalScripts: () => getExternalScripts(scripts, fetch),
		// 获取外部样式文件的内容
		getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
		// 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
		execScripts: (proxy, strictGlobal) => {
			if (!scripts.length) {
				return Promise.resolve();
			}
			return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
		},
   }>
 */
export default function importHTML(url, opts = {}) {
	// 三个默认的方法
	let fetch = defaultFetch;
	let getPublicPath = defaultGetPublicPath;
	let getTemplate = defaultGetTemplate;

	if (typeof opts === 'function') {
		// if 分支,兼容遗留的 importHTML api,ops 可以直接是一个 fetch 方法
		fetch = opts;
	} else {
		// 用用户传递的参数(如果提供了的话)覆盖默认方法
		fetch = opts.fetch || defaultFetch;
		getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
		getTemplate = opts.getTemplate || defaultGetTemplate;
	}

	// 通过 fetch 方法请求 url,这也就是 qiankun 为什么要求你的微应用要支持跨域的原因
	return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
		// response.text() 是一个 html 模版
		.then(response => response.text())
		.then(html => {

			// 获取静态资源地址
			const assetPublicPath = getPublicPath(url);
			/**
 	     * 从 html 模版中解析出外部脚本的地址或者内联脚本的代码块 和 link 标签的地址
			 * {
 			 * 	template: 经过处理的脚本,link、script 标签都被注释掉了,
       * 	scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
       *  styles: [样式的http地址],
 	     * 	entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
 			 * }
			 */
			const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath);

			// getEmbedHTML 方法通过 fetch 远程加载所有的外部样式,然后将对应的 link 注释标签替换为 style,即外部样式替换为内联样式,然后返回 embedHTML,即处理过后的 HTML 模版
			return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
				// template 是 link 替换为 style 后的 template
				template: embedHTML,
				// 静态资源地址
				assetPublicPath,
				// 获取外部脚本,最终得到所有脚本的代码内容
				getExternalScripts: () => getExternalScripts(scripts, fetch),
				// 获取外部样式文件的内容
				getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
				// 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
				execScripts: (proxy, strictGlobal) => {
					if (!scripts.length) {
						return Promise.resolve();
					}
					return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
				},
			}));
		}));
}

processTpl

/**
 * 从 html 模版中解析出外部脚本的地址或者内联脚本的代码块 和 link 标签的地址
 * @param tpl html 模版
 * @param baseURI
 * @stripStyles whether to strip the css links
 * @returns {{template: void | string | *, scripts: *[], entry: *}}
 * return {
 * 	template: 经过处理的脚本,link、script 标签都被注释掉了,
 * 	scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
 *  styles: [样式的http地址],
 * 	entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
 * }
 */
export default function processTpl(tpl, baseURI) {

	let scripts = [];
	const styles = [];
	let entry = null;
	// 判断浏览器是否支持 es module,<script type = "module" />
	const moduleSupport = isModuleScriptSupported();

	const template = tpl

		// 移除 html 模版中的注释内容 <!-- xx -->
		.replace(HTML_COMMENT_REGEX, '')

		// 匹配 link 标签
		.replace(LINK_TAG_REGEX, match => {
			/**
			 * 将模版中的 link 标签变成注释,如果有存在 href 属性且非预加载的 link,则将地址存到 styles 数组,如果是预加载的 link 直接变成注释
			 */
			// <link rel = "stylesheet" />
			const styleType = !!match.match(STYLE_TYPE_REGEX);
			if (styleType) {

				// <link rel = "stylesheet" href = "xxx" />
				const styleHref = match.match(STYLE_HREF_REGEX);
				// <link rel = "stylesheet" ignore />
				const styleIgnore = match.match(LINK_IGNORE_REGEX);

				if (styleHref) {

					// 获取 href 属性值
					const href = styleHref && styleHref[2];
					let newHref = href;

					// 如果 href 没有协议说明给的是一个相对地址,拼接 baseURI 得到完整地址
					if (href && !hasProtocol(href)) {
						newHref = getEntirePath(href, baseURI);
					}
					// 将 <link rel = "stylesheet" ignore /> 变成 <!-- ignore asset ${url} replaced by import-html-entry -->
					if (styleIgnore) {
						return genIgnoreAssetReplaceSymbol(newHref);
					}

					// 将 href 属性值存入 styles 数组
					styles.push(newHref);
					// <link rel = "stylesheet" href = "xxx" /> 变成 <!-- link ${linkHref} replaced by import-html-entry -->
					return genLinkReplaceSymbol(newHref);
				}
			}

			// 匹配 <link rel = "preload or prefetch" href = "xxx" />,表示预加载资源
			const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT);
			if (preloadOrPrefetchType) {
				// 得到 href 地址
				const [, , linkHref] = match.match(LINK_HREF_REGEX);
				// 将标签变成 <!-- prefetch/preload link ${linkHref} replaced by import-html-entry -->
				return genLinkReplaceSymbol(linkHref, true);
			}

			return match;
		})
		// 匹配 <style></style>
		.replace(STYLE_TAG_REGEX, match => {
			if (STYLE_IGNORE_REGEX.test(match)) {
				// <style ignore></style> 变成 <!-- ignore asset style file replaced by import-html-entry -->
				return genIgnoreAssetReplaceSymbol('style file');
			}
			return match;
		})
		// 匹配 <script></script>
		.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
			// 匹配 <script ignore></script>
			const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX);
			// 匹配 <script nomodule></script> 或者 <script type = "module"></script>,都属于应该被忽略的脚本
			const moduleScriptIgnore =
				(moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) ||
				(!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX));
			// in order to keep the exec order of all javascripts

			// <script type = "xx" />
			const matchedScriptTypeMatch = scriptTag.match(SCRIPT_TYPE_REGEX);
			// 获取 type 属性值
			const matchedScriptType = matchedScriptTypeMatch && matchedScriptTypeMatch[2];
			// 验证 type 是否有效,type 为空 或者 'text/javascript', 'module', 'application/javascript', 'text/ecmascript', 'application/ecmascript',都视为有效
			if (!isValidJavaScriptType(matchedScriptType)) {
				return match;
			}

			// if it is a external script,匹配非 <script type = "text/ng-template" src = "xxx"></script>
			if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) {
				/*
				collect scripts and replace the ref
				*/

				// <script entry />
				const matchedScriptEntry = scriptTag.match(SCRIPT_ENTRY_REGEX);
				// <script src = "xx" />
				const matchedScriptSrcMatch = scriptTag.match(SCRIPT_SRC_REGEX);
				// 脚本地址
				let matchedScriptSrc = matchedScriptSrcMatch && matchedScriptSrcMatch[2];

				if (entry && matchedScriptEntry) {
					// 说明出现了两个入口地址,即两个 <script entry src = "xx" />
					throw new SyntaxError('You should not set multiply entry script!');
				} else {
					// 补全脚本地址,地址如果没有协议,说明是一个相对路径,添加 baseURI
					if (matchedScriptSrc && !hasProtocol(matchedScriptSrc)) {
						matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI);
					}

					// 脚本的入口地址
					entry = entry || matchedScriptEntry && matchedScriptSrc;
				}

				if (scriptIgnore) {
					// <script ignore></script> 替换为 <!-- ignore asset ${url || 'file'} replaced by import-html-entry -->
					return genIgnoreAssetReplaceSymbol(matchedScriptSrc || 'js file');
				}

				if (moduleScriptIgnore) {
					// <script nomodule></script> 或者 <script type = "module"></script> 替换为
					// <!-- nomodule script ${scriptSrc} ignored by import-html-entry --> 或
					// <!-- module script ${scriptSrc} ignored by import-html-entry -->
					return genModuleScriptReplaceSymbol(matchedScriptSrc || 'js file', moduleSupport);
				}

				if (matchedScriptSrc) {
					// 匹配 <script src = 'xx' async />,说明是异步加载的脚本
					const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
					// 将脚本地址存入 scripts 数组,如果是异步加载,则存入一个对象 { async: true, src: xx }
					scripts.push(asyncScript ? { async: true, src: matchedScriptSrc } : matchedScriptSrc);
					// <script src = "xx" async /> 或者 <script src = "xx" /> 替换为 
					// <!-- async script ${scriptSrc} replaced by import-html-entry --> 或 
					// <!-- script ${scriptSrc} replaced by import-html-entry -->
					return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
				}

				return match;
			} else {
				// 说明是内部脚本,<script>xx</script>
				if (scriptIgnore) {
					// <script ignore /> 替换为 <!-- ignore asset js file replaced by import-html-entry -->
					return genIgnoreAssetReplaceSymbol('js file');
				}

				if (moduleScriptIgnore) {
					// <script nomodule></script> 或者 <script type = "module"></script> 替换为
					// <!-- nomodule script ${scriptSrc} ignored by import-html-entry --> 或 
					// <!-- module script ${scriptSrc} ignored by import-html-entry -->
					return genModuleScriptReplaceSymbol('js file', moduleSupport);
				}

				// if it is an inline script,<script>xx</script>,得到标签之间的代码 => xx
				const code = getInlineCode(match);

				// remove script blocks when all of these lines are comments. 判断代码块是否全是注释
				const isPureCommentBlock = code.split(/[\r\n]+/).every(line => !line.trim() || line.trim().startsWith('//'));

				if (!isPureCommentBlock) {
					// 不是注释,则将代码块存入 scripts 数组
					scripts.push(match);
				}

				// <script>xx</script> 替换为 <!-- inline scripts replaced by import-html-entry -->
				return inlineScriptReplaceSymbol;
			}
		});

	// filter empty script
	scripts = scripts.filter(function (script) {
		return !!script;
	});

	return {
		template,
		scripts,
		styles,
		// set the last script as entry if have not set
		entry: entry || scripts[scripts.length - 1],
	};
}

getEmbedHTML

/**
 * convert external css link to inline style for performance optimization,外部样式转换成内联样式
 * @param template,html 模版
 * @param styles link 样式链接
 * @param opts = { fetch }
 * @return embedHTML 处理过后的 html 模版
 */
function getEmbedHTML(template, styles, opts = {}) {
	const { fetch = defaultFetch } = opts;
	let embedHTML = template;

	return getExternalStyleSheets(styles, fetch)
		.then(styleSheets => {
			// 通过循环,将之前设置的 link 注释标签替换为 style 标签,即 <style>/* href地址 */ xx </style>
			embedHTML = styles.reduce((html, styleSrc, i) => {
				html = html.replace(genLinkReplaceSymbol(styleSrc), `<style>/* ${styleSrc} */${styleSheets[i]}</style>`);
				return html;
			}, embedHTML);
			return embedHTML;
		});
}

getExternalScripts

/**
 * 加载脚本,最终返回脚本的内容,Promise<Array>,每个元素都是一段 JS 代码
 * @param {*} scripts = [脚本http地址 or 内联脚本的脚本内容 or { async: true, src: xx }]
 * @param {*} fetch 
 * @param {*} errorCallback 
 */
export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => {
}) {

	// 定义一个可以加载远程指定 url 脚本的方法,当然里面也做了缓存,如果命中缓存直接从缓存中获取
	const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
		(scriptCache[scriptUrl] = fetch(scriptUrl).then(response => {
			// usually browser treats 4xx and 5xx response of script loading as an error and will fire a script error event
			// https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603
			if (response.status >= 400) {
				errorCallback();
				throw new Error(`${scriptUrl} load failed with status ${response.status}`);
			}

			return response.text();
		}));

	return Promise.all(scripts.map(script => {

			if (typeof script === 'string') {
				// 字符串,要不是链接地址,要不是脚本内容(代码)
				if (isInlineCode(script)) {
					// if it is inline script
					return getInlineCode(script);
				} else {
					// external script,加载脚本
					return fetchScript(script);
				}
			} else {
				// use idle time to load async script
				// 异步脚本,通过 requestIdleCallback 方法加载
				const { src, async } = script;
				if (async) {
					return {
						src,
						async: true,
						content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))),
					};
				}

				return fetchScript(src);
			}
		},
	));
}

getExternalStyleSheets

/**
 * 通过 fetch 方法加载指定地址的样式文件
 * @param {*} styles = [ href ]
 * @param {*} fetch 
 * return Promise<Array>,每个元素都是一堆样式内容
 */
export function getExternalStyleSheets(styles, fetch = defaultFetch) {
	return Promise.all(styles.map(styleLink => {
			if (isInlineCode(styleLink)) {
				// if it is inline style
				return getInlineCode(styleLink);
			} else {
				// external styles,加载样式并缓存
				return styleCache[styleLink] ||
					(styleCache[styleLink] = fetch(styleLink).then(response => response.text()));
			}

		},
	));
}

execScripts

/**
 * FIXME to consistent with browser behavior, we should only provide callback way to invoke success and error event
 * 脚本执行器,让指定的脚本(scripts)在规定的上下文环境中执行
 * @param entry 入口地址
 * @param scripts = [脚本http地址 or 内联脚本的脚本内容 or { async: true, src: xx }] 
 * @param proxy 脚本执行上下文,全局对象,qiankun JS 沙箱生成 windowProxy 就是传递到了这个参数
 * @param opts
 * @returns {Promise<unknown>}
 */
export function execScripts(entry, scripts, proxy = window, opts = {}) {
	const {
		fetch = defaultFetch, strictGlobal = false, success, error = () => {
		}, beforeExec = () => {
		},
	} = opts;

	// 获取指定的所有外部脚本的内容,并设置每个脚本的执行上下文,然后通过 eval 函数运行
	return getExternalScripts(scripts, fetch, error)
		.then(scriptsText => {
			// scriptsText 为脚本内容数组 => 每个元素是一段 JS 代码
			const geval = (code) => {
				beforeExec();
				(0, eval)(code);
			};

			/**
			 * 
			 * @param {*} scriptSrc 脚本地址
			 * @param {*} inlineScript 脚本内容
			 * @param {*} resolve 
			 */
			function exec(scriptSrc, inlineScript, resolve) {

				// 性能度量
				const markName = `Evaluating script ${scriptSrc}`;
				const measureName = `Evaluating Time Consuming: ${scriptSrc}`;

				if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
					performance.mark(markName);
				}

				if (scriptSrc === entry) {
					// 入口
					noteGlobalProps(strictGlobal ? proxy : window);

					try {
						// bind window.proxy to change `this` reference in script
						geval(getExecutableScript(scriptSrc, inlineScript, proxy, strictGlobal));
						const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
						resolve(exports);
					} catch (e) {
						// entry error must be thrown to make the promise settled
						console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
						throw e;
					}
				} else {
					if (typeof inlineScript === 'string') {
						try {
							// bind window.proxy to change `this` reference in script,就是设置 JS 代码的执行上下文,然后通过 eval 函数运行运行代码
							geval(getExecutableScript(scriptSrc, inlineScript, proxy, strictGlobal));
						} catch (e) {
							// consistent with browser behavior, any independent script evaluation error should not block the others
							throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
						}
					} else {
						// external script marked with async,异步加载的代码,下载完以后运行
						inlineScript.async && inlineScript?.content
							.then(downloadedScriptText => geval(getExecutableScript(inlineScript.src, downloadedScriptText, proxy, strictGlobal)))
							.catch(e => {
								throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`);
							});
					}
				}

				// 性能度量
				if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
					performance.measure(measureName, markName);
					performance.clearMarks(markName);
					performance.clearMeasures(measureName);
				}
			}

			/**
			 * 递归
			 * @param {*} i 表示第几个脚本
			 * @param {*} resolvePromise 成功回调 
			 */
			function schedule(i, resolvePromise) {

				if (i < scripts.length) {
					// 第 i 个脚本的地址
					const scriptSrc = scripts[i];
					// 第 i 个脚本的内容
					const inlineScript = scriptsText[i];

					exec(scriptSrc, inlineScript, resolvePromise);
					if (!entry && i === scripts.length - 1) {
						// resolve the promise while the last script executed and entry not provided
						resolvePromise();
					} else {
						// 递归调用下一个脚本
						schedule(i + 1, resolvePromise);
					}
				}
			}

			// 从第 0 个脚本开始调度
			return new Promise(resolve => schedule(0, success || resolve));
		});
}

结语

以上就是 HTML Entry 的全部内容,也是深入理解 微前端single-spaqiankun 不可或缺的一部分,源码在 github

阅读到这里如果你想继续深入理解 微前端single-spaqiankun 等,推荐阅读如下内容

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

Vue 源码解读(12)—— patch

Vue 源码解读(12)—— patch

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

前面我们说到,当组件更新时,实例化渲染 watcher 时传递的 updateComponent 方法会被执行:

const updateComponent = () => {
  // 执行 vm._render() 函数,得到 虚拟 VNode,并将 VNode 传递给 vm._update 方法,接下来就该到 patch 阶段了
  vm._update(vm._render(), hydrating)
}

首先会先执行 vm._render() 函数,得到组件的 VNode,并将 VNode 传递给 vm._update 方法,接下来就该进入到 patch 阶段了。今天我们就来深入理解组件更新时 patch 的执行过程。

历史

1.x 版本的 Vue 没有 VNode 和 diff 算法,那个版本的 Vue 的核心只有响应式原理:Object.definePropertyDepWatcher

  • Object.defineProperty: 负责数据的拦截。getter 时进行依赖收集,setter 时让 dep 通知 watcher 去更新

  • Dep:Vue data 选项返回的对象,对象的 key 和 dep 一一对应

  • Watcher:key 和 watcher 时一对多的关系,组件模版中每使用一次 key 就会生成一个 watcher

<template>
  <div class="wrapper">
    <!-- 模版中每引用一次响应式数据,就会生成一个 watcher -->
    <!-- watcher 1 -->
    <div class="msg1">{{ msg }}</div>
    <!-- watcher 2 -->
    <div class="msg2">{{ msg }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // 和 dep 一一对应,和 watcher 一 对 多
      msg: 'Hello Vue 1.0'
    }
  }
}
</script>

当数据更新时,dep 通知 watcher 去直接更新 DOM,因为这个版本的 watcher 和 DOM 时一一对应关系,watcher 可以非常明确的知道这个 key 在组件模版中的位置,因此可以做到定向更新,所以它的更新效率是非常高的。

虽然更新效率高,但随之也产生了严重的问题,无法完成一个企业级应用,理由很简单:当你的页面足够复杂时,会包含很多的组件,在这种架构下就意味这一个页面会产生大量的 watcher,这非常耗资源。

这时就在 Vue 2.0 中通过引入 VNode 和 diff 算法去解决 1.x 中的问题。将 watcher 的粒度放大,变成一个组件一个 watcher(就是我们说的渲染 watcher),这时候你页面再大,watcher 也很少,这就解决了复杂页面 watcher 太多导致性能下降的问题。

当响应式数据更新时,dep 通知 watcher 去更新,这时候问题就来了,Vue 1.x 中 watcher 和 key 一一对应,可以明确知道去更新什么地方,但是 Vue 2.0 中 watcher 对应的是一整个组件,更新的数据在组件的的什么位置,watcher 并不知道。这时候就需要 VNode 出来解决问题。

通过引入 VNode,当组件中数据更新时,会为组件生成一个新的 VNode,通过比对新老两个 VNode,找出不一样的地方,然后执行 DOM 操作更新发生变化的节点,这个过程就是大家熟知的 diff。

以上就是 Vue 2.0 为什么会引入 VNode 和 diff 算法的历史原因了,也是 Vue 1.x 到 2.x 的一个发展历程。

目标

  • 深入理解 Vue 的 patch 阶段,理解其 diff 算法的原理。

源码解读

入口

/src/core/instance/lifecycle.js

const updateComponent = () => {
  // 执行 vm._render() 函数,得到 VNode,并将 VNode 传递给 _update 方法,接下来就该到 patch 阶段了
  vm._update(vm._render(), hydrating)
}

vm._update

/src/core/instance/lifecycle.js

/**
 * 页面首次渲染和后续更新的入口位置,也是 patch 的入口位置 
 */
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  // 页面的挂载点,真实的元素
  const prevEl = vm.$el
  // 老 VNode
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  // 新 VNode
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  if (!prevVnode) {
    // 老 VNode 不存在,表示首次渲染,即初始化页面时走这里
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 响应式数据更新时,即更新页面时走这里
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  restoreActiveInstance()
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}

vm.__patch__

/src/platforms/web/runtime/index.js

/ 在 Vue 原型链上安装 web 平台的 patch 函数
Vue.prototype.__patch__ = inBrowser ? patch : noop

patch

/src/platforms/web/runtime/patch.js

// patch 工厂函数,为其传入平台特有的一些操作,然后返回一个 patch 函数
export const patch: Function = createPatchFunction({ nodeOps, modules })

nodeOps

src/platforms/web/runtime/node-ops.js

/**
 * web 平台的 DOM 操作 API
 */

/**
 * 创建标签名为 tagName 的元素节点
 */
export function createElement (tagName: string, vnode: VNode): Element {
  // 创建元素节点
  const elm = document.createElement(tagName)
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  // 如果是 select 元素,则为它设置 multiple 属性
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}

// 创建带命名空间的元素节点
export function createElementNS (namespace: string, tagName: string): Element {
  return document.createElementNS(namespaceMap[namespace], tagName)
}

// 创建文本节点
export function createTextNode (text: string): Text {
  return document.createTextNode(text)
}

// 创建注释节点
export function createComment (text: string): Comment {
  return document.createComment(text)
}

// 在指定节点前插入节点
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}

/**
 * 移除指定子节点
 */
export function removeChild (node: Node, child: Node) {
  node.removeChild(child)
}

/**
 * 添加子节点
 */
export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}

/**
 * 返回指定节点的父节点
 */
export function parentNode (node: Node): ?Node {
  return node.parentNode
}

/**
 * 返回指定节点的下一个兄弟节点
 */
export function nextSibling (node: Node): ?Node {
  return node.nextSibling
}

/**
 * 返回指定节点的标签名 
 */
export function tagName (node: Element): string {
  return node.tagName
}

/**
 * 为指定节点设置文本 
 */
export function setTextContent (node: Node, text: string) {
  node.textContent = text
}

/**
 * 为节点设置指定的 scopeId 属性,属性值为 ''
 */
export function setStyleScope (node: Element, scopeId: string) {
  node.setAttribute(scopeId, '')
}

modules

/src/platforms/web/runtime/modules 和 /src/core/vdom/modules

平台特有的一些操作,比如:attr、class、style、event 等,还有核心的 directive 和 ref,它们会向外暴露一些特有的方法,比如:create、activate、update、remove、destroy,这些方法在 patch 阶段时会被调用,从而做相应的操作,比如 创建 attr、指令等。这部分内容太多了,这里就不一一列举了,在阅读 patch 的过程中如有需要可回头深入阅读,比如操作节点的属性的时候,就去读 attr 相关的代码。

createPatchFunction

提示:由于该函数的代码量较大, 所以调整了一下代码结构,方便阅读和理解

/src/core/vdom/patch.js

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

/**
 * 工厂函数,注入平台特有的一些功能操作,并定义一些方法,然后返回 patch 函数
 */
export function createPatchFunction (backend) {
  let i, j
  const cbs = {}

  /**
   * modules: { ref, directives, 平台特有的一些操纵,比如 attr、class、style 等 }
   * nodeOps: { 对元素的增删改查 API }
   */
  const { modules, nodeOps } = backend

  /**
   * hooks = ['create', 'activate', 'update', 'remove', 'destroy']
   * 遍历这些钩子,然后从 modules 的各个模块中找到相应的方法,比如:directives 中的 create、update、destroy 方法
   * 让这些方法放到 cb[hook] = [hook 方法] 中,比如: cb.create = [fn1, fn2, ...]
   * 然后在合适的时间调用相应的钩子方法完成对应的操作
   */
  for (i = 0; i < hooks.length; ++i) {
    // 比如 cbs.create = []
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        // 遍历各个 modules,找出各个 module 中的 create 方法,然后添加到 cbs.create 数组中
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  /**
   * vm.__patch__
   *   1、新节点不存在,老节点存在,调用 destroy,销毁老节点
   *   2、如果 oldVnode 是真实元素,则表示首次渲染,创建新节点,并插入 body,然后移除老节点
   *   3、如果 oldVnode 不是真实元素,则表示更新阶段,执行 patchVnode
   */
  return patch
}

patch

src/core/vdom/patch.js

/**
 * vm.__patch__
 *   1、新节点不存在,老节点存在,调用 destroy,销毁老节点
 *   2、如果 oldVnode 是真实元素,则表示首次渲染,创建新节点,并插入 body,然后移除老节点
 *   3、如果 oldVnode 不是真实元素,则表示更新阶段,执行 patchVnode
 */
function patch(oldVnode, vnode, hydrating, removeOnly) {
  // 如果新节点不存在,老节点存在,则调用 destroy,销毁老节点
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    // 新的 VNode 存在,老的 VNode 不存在,这种情况会在一个组件初次渲染的时候出现,比如:
    // <div id="app"><comp></comp></div>
    // 这里的 comp 组件初次渲染时就会走这儿
    // empty mount (likely as component), create new root element
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    // 判断 oldVnode 是否为真实元素
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 不是真实元素,但是老节点和新节点是同一个节点,则是更新阶段,执行 patch 更新节点
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 是真实元素,则表示初次渲染
      if (isRealElement) {
        // 挂载到真实元素以及处理服务端渲染的情况
        // mounting to a real element
        // check if this is server-rendered content and if we can perform
        // a successful hydration.
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true)
            return oldVnode
          } else if (process.env.NODE_ENV !== 'production') {
            warn(
              'The client-side rendered virtual DOM tree is not matching ' +
              'server-rendered content. This is likely caused by incorrect ' +
              'HTML markup, for example nesting block-level elements inside ' +
              '<p>, or missing <tbody>. Bailing hydration and performing ' +
              'full client-side render.'
            )
          }
        }
        // 走到这儿说明不是服务端渲染,或者 hydration 失败,则根据 oldVnode 创建一个 vnode 节点
        // either not server-rendered, or hydration failed.
        // create an empty node and replace it
        oldVnode = emptyNodeAt(oldVnode)
      }

      // 拿到老节点的真实元素
      const oldElm = oldVnode.elm
      // 获取老节点的父元素,即 body
      const parentElm = nodeOps.parentNode(oldElm)

      // 基于新 vnode 创建整棵 DOM 树并插入到 body 元素下
      createElm(
        vnode,
        insertedVnodeQueue,
        // extremely rare edge case: do not insert if old element is in a
        // leaving transition. Only happens when combining transition +
        // keep-alive + HOCs. (#4590)
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // 递归更新父占位符节点元素
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode)
        while (ancestor) {
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
          }
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]()
              }
            }
          } else {
            registerRef(ancestor)
          }
          ancestor = ancestor.parent
        }
      }

      // 移除老节点
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

invokeDestroyHook

src/core/vdom/patch.js

/**
 * 销毁节点:
 *   执行组件的 destroy 钩子,即执行 $destroy 方法 
 *   执行组件各个模块(style、class、directive 等)的 destroy 方法
 *   如果 vnode 还存在子节点,则递归调用 invokeDestroyHook
 */
function invokeDestroyHook(vnode) {
  let i, j
  const data = vnode.data
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
  }
  if (isDef(i = vnode.children)) {
    for (j = 0; j < vnode.children.length; ++j) {
      invokeDestroyHook(vnode.children[j])
    }
  }
}

sameVnode

src/core/vdom/patch.js

/**
 * 判读两个节点是否相同 
 */
function sameVnode (a, b) {
  return (
    // key 必须相同,需要注意的是 undefined === undefined => true
    a.key === b.key && (
      (
        // 标签相同
        a.tag === b.tag &&
        // 都是注释节点
        a.isComment === b.isComment &&
        // 都有 data 属性
        isDef(a.data) === isDef(b.data) &&
        // input 标签的情况
        sameInputType(a, b)
      ) || (
        // 异步占位符节点
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

emptyNodeAt

src/core/vdom/patch.js

/**
 * 为元素(elm)创建一个空的 vnode
 */
function emptyNodeAt(elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

createElm

src/core/vdom/patch.js

/**
 * 基于 vnode 创建整棵 DOM 树,并插入到父节点上
 */
function createElm(
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // This vnode was used in a previous render!
    // now it's used as a new node, overwriting its elm would cause
    // potential patch errors down the road when it's used as an insertion
    // reference node. Instead, we clone the node on-demand before creating
    // associated DOM element for it.
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  /**
   * 重点
   * 1、如果 vnode 是一个组件,则执行 init 钩子,创建组件实例并挂载,
   *   然后为组件执行各个模块的 create 钩子
   *   如果组件被 keep-alive 包裹,则激活组件
   * 2、如果是一个普通元素,则什么也不错
   */
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // 获取 data 对象
  const data = vnode.data
  // 所有的孩子节点
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    // 未知标签
    if (process.env.NODE_ENV !== 'production') {
      if (data && data.pre) {
        creatingElmInVPre++
      }
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        warn(
          'Unknown custom element: <' + tag + '> - did you ' +
          'register the component correctly? For recursive components, ' +
          'make sure to provide the "name" option.',
          vnode.context
        )
      }
    }

    // 创建新节点
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    setScope(vnode)

    // 递归创建所有子节点(普通元素、组件)
    createChildren(vnode, children, insertedVnodeQueue)
    if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    // 将节点插入父节点
    insert(parentElm, vnode.elm, refElm)

    if (process.env.NODE_ENV !== 'production' && data && data.pre) {
      creatingElmInVPre--
    }
  } else if (isTrue(vnode.isComment)) {
    // 注释节点,创建注释节点并插入父节点
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    // 文本节点,创建文本节点并插入父节点
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

createComponent

src/core/vdom/patch.js

/**
 * 如果 vnode 是一个组件,则执行 init 钩子,创建组件实例,并挂载
 * 然后为组件执行各个模块的 create 方法
 * @param {*} vnode 组件新的 vnode
 * @param {*} insertedVnodeQueue 数组
 * @param {*} parentElm oldVnode 的父节点
 * @param {*} refElm oldVnode 的下一个兄弟节点
 * @returns 如果 vnode 是一个组件并且组件创建成功,则返回 true,否则返回 undefined
 */
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  // 获取 vnode.data 对象
  let i = vnode.data
  if (isDef(i)) {
    // 验证组件实例是否已经存在 && 被 keep-alive 包裹
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    // 执行 vnode.data.init 钩子函数,该函数在讲 render helper 时讲过
    // 如果是被 keep-alive 包裹的组件:则再执行 prepatch 钩子,用 vnode 上的各个属性更新 oldVnode 上的相关属性
    // 如果是组件没有被 keep-alive 包裹或者首次渲染,则初始化组件,并进入挂载阶段
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false /* hydrating */)
    }
    // after calling the init hook, if the vnode is a child component
    // it should've created a child instance and mounted it. the child
    // component also has set the placeholder vnode's elm.
    // in that case we can just return the element and be done.
    if (isDef(vnode.componentInstance)) {
      // 如果 vnode 是一个子组件,则调用 init 钩子之后会创建一个组件实例,并挂载
      // 这时候就可以给组件执行各个模块的的 create 钩子了
      initComponent(vnode, insertedVnodeQueue)
      // 将组件的 DOM 节点插入到父节点内
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        // 组件被 keep-alive 包裹的情况,激活组件
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

insert

src/core/vdom/patch.js

/**
 * 向父节点插入节点 
 */
function insert(parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

removeVnodes

src/core/vdom/patch.js

/**
 * 移除指定索引范围(startIdx —— endIdx)内的节点 
 */
function removeVnodes(vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}

patchVnode

src/core/vdom/patch.js

/**
 * 更新节点
 *   全量的属性更新
 *   如果新老节点都有孩子,则递归执行 diff
 *   如果新节点有孩子,老节点没孩子,则新增新节点的这些孩子节点
 *   如果老节点有孩子,新节点没孩子,则删除老节点的这些孩子
 *   更新文本节点
 */
function patchVnode(
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // 老节点和新节点相同,直接返回
  if (oldVnode === vnode) {
    return
  }

  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // clone reused vnode
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  const elm = vnode.elm = oldVnode.elm

  // 异步占位符节点
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // 跳过静态节点的更新
  // reuse element for static trees.
  // note we only do this if the vnode is cloned -
  // if the new node is not cloned it means the render functions have been
  // reset by the hot-reload-api and we need to do a proper re-render.
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    // 新旧节点都是静态的而且两个节点的 key 一样,并且新节点被 clone 了 或者 新节点有 v-once指令,则重用这部分节点
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  // 执行组件的 prepatch 钩子
  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  // 老节点的孩子
  const oldCh = oldVnode.children
  // 新节点的孩子
  const ch = vnode.children
  // 全量更新新节点的属性,Vue 3.0 在这里做了很多的优化
  if (isDef(data) && isPatchable(vnode)) {
    // 执行新节点所有的属性更新
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  if (isUndef(vnode.text)) {
    // 新节点不是文本节点
    if (isDef(oldCh) && isDef(ch)) {
      // 如果新老节点都有孩子,则递归执行 diff 过程
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      // 老孩子不存在,新孩子存在,则创建这些新孩子节点
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch)
      }
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 老孩子存在,新孩子不存在,则移除这些老孩子节点
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 老节点是文本节点,则将文本内容置空
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 新节点是文本节点,则更新文本节点
    nodeOps.setTextContent(elm, vnode.text)
  }
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

updateChildren

src/core/vdom/patch.js

/**
 * diff 过程:
 *   diff 优化:做了四种假设,假设新老节点开头结尾有相同节点的情况,一旦命中假设,就避免了一次循环,以提高执行效率
 *             如果不幸没有命中假设,则执行遍历,从老节点中找到新开始节点
 *             找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置
 *   如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作
 *   如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点
 */
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 老节点的开始索引
  let oldStartIdx = 0
  // 新节点的开始索引
  let newStartIdx = 0
  // 老节点的结束索引
  let oldEndIdx = oldCh.length - 1
  // 第一个老节点
  let oldStartVnode = oldCh[0]
  // 最后一个老节点
  let oldEndVnode = oldCh[oldEndIdx]
  // 新节点的结束索引
  let newEndIdx = newCh.length - 1
  // 第一个新节点
  let newStartVnode = newCh[0]
  // 最后一个新节点
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  // removeOnly是一个特殊的标志,仅由 <transition-group> 使用,以确保被移除的元素在离开转换期间保持在正确的相对位置
  const canMove = !removeOnly

  if (process.env.NODE_ENV !== 'production') {
    // 检查新节点的 key 是否重复
    checkDuplicateKeys(newCh)
  }

  // 遍历新老两组节点,只要有一组遍历完(开始索引超过结束索引)则跳出循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      // 如果节点被移动,在当前索引上可能不存在,检测这种情况,如果节点不存在则调整索引
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 老开始节点和新开始节点是同一个节点,执行 patch
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // patch 结束后老开始和新开始的索引分别加 1
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 老结束和新结束是同一个节点,执行 patch
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // patch 结束后老结束和新结束的索引分别减 1
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 老开始和新结束是同一个节点,执行 patch
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // 处理被 transtion-group 包裹的组件时使用
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      // patch 结束后老开始索引加 1,新结束索引减 1
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // 老结束和新开始是同一个节点,执行 patch
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // patch 结束后,老结束的索引减 1,新开始的索引加 1
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 如果上面的四种假设都不成立,则通过遍历找到新开始节点在老节点中的位置索引

      // 找到老节点中每个节点 key 和 索引之间的关系映射 => oldKeyToIdx = { key1: idx1, ... }
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // 在映射中找到新开始节点在老节点中的位置索引
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        // 在老节点中没找到新开始节点,则说明是新创建的元素,执行创建
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        // 在老节点中找到新开始节点了
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 如果这两个节点是同一个,则执行 patch
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // patch 结束后将该老节点置为 undefined
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 最后这种情况是,找到节点了,但是发现两个节点不是同一个节点,则视为新元素,执行创建
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      // 老节点向后移动一个
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 走到这里,说明老姐节点或者新节点被遍历完了
  if (oldStartIdx > oldEndIdx) {
    // 说明老节点被遍历完了,新节点有剩余,则说明这部分剩余的节点是新增的节点,然后添加这些节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    // 说明新节点被遍历完了,老节点有剩余,说明这部分的节点被删掉了,则移除这些节点
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}

checkDuplicateKeys

src/core/vdom/patch.js

/**
 * 检查一组元素的 key 是否重复 
 */
function checkDuplicateKeys(children) {
  const seenKeys = {}
  for (let i = 0; i < children.length; i++) {
    const vnode = children[i]
    const key = vnode.key
    if (isDef(key)) {
      if (seenKeys[key]) {
        warn(
          `Duplicate keys detected: '${key}'. This may cause an update error.`,
          vnode.context
        )
      } else {
        seenKeys[key] = true
      }
    }
  }
}

addVnodes

src/core/vdom/patch.js

/**
 * 在指定索引范围(startIdx —— endIdx)内添加节点
 */
function addVnodes(parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
  for (; startIdx <= endIdx; ++startIdx) {
    createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx)
  }
}

createKeyToOldIdx

src/core/vdom/patch.js

/**
 * 得到指定范围(beginIdx —— endIdx)内节点的 key 和 索引之间的关系映射 => { key1: idx1, ... }
 */
function createKeyToOldIdx(children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

findIdxInOld

src/core/vdom/patch.js

/**
  * 找到新节点(vnode)在老节点(oldCh)中的位置索引 
  */
function findIdxInOld(node, oldCh, start, end) {
  for (let i = start; i < end; i++) {
    const c = oldCh[i]
    if (isDef(c) && sameVnode(node, c)) return i
  }
}

invokeCreateHooks

src/core/vdom/patch.js

/**
 * 调用 各个模块的 create 方法,比如创建属性的、创建样式的、指令的等等 ,然后执行组件的 mounted 生命周期方法
 */
function invokeCreateHooks(vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  // 组件钩子
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    // 组件好像没有 create 钩子
    if (isDef(i.create)) i.create(emptyNode, vnode)
    // 调用组件的 insert 钩子,执行组件的 mounted 生命周期方法
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

createChildren

src/core/vdom/patch.js

/**
 * 创建所有子节点,并将子节点插入父节点,形成一棵 DOM 树
 */
function createChildren(vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    // children 是数组,表示是一组节点
    if (process.env.NODE_ENV !== 'production') {
      // 检测这组节点的 key 是否重复
      checkDuplicateKeys(children)
    }
    // 遍历这组节点,依次创建这些节点然后插入父节点,形成一棵 DOM 树
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    // 说明是文本节点,创建文本节点,并插入父节点
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

总结

  • 面试官 问:你能说一说 Vue 的 patch 算法吗?

    Vue 的 patch 算法有三个作用:负责首次渲染和后续更新或者销毁组件

    • 如果老的 VNode 是真实元素,则表示首次渲染,创建整棵 DOM 树,并插入 body,然后移除老的模版节点

    • 如果老的 VNode 不是真实元素,并且新的 VNode 也存在,则表示更新阶段,执行 patchVnode

      • 首先是全量更新所有的属性

      • 如果新老 VNode 都有孩子,则递归执行 updateChildren,进行 diff 过程

        针对前端操作 DOM 节点的特点进行如下优化:

        • 同层比较(降低时间复杂度)深度优先(递归)

        • 而且前端很少有完全打乱节点顺序的情况,所以做了四种假设,假设新老 VNode 的开头结尾存在相同节点,一旦命中假设,就避免了一次循环,降低了 diff 的时间复杂度,提高执行效率。如果不幸没有命中假设,则执行遍历,从老的 VNode 中找到新的 VNode 的开始节点

        • 找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置

        • 如果老的 VNode 先于新的 VNode 遍历结束,则剩余的新的 VNode 执行新增节点操作

        • 如果新的 VNode 先于老的 VNode 遍历结束,则剩余的老的 VNode 执行删除操纵,移除这些老节点

      • 如果新的 VNode 有孩子,老的 VNode 没孩子,则新增这些新孩子节点

      • 如果老的 VNode 有孩子,新的 VNode 没孩子,则删除这些老孩子节点

      • 剩下一种就是更新文本节点

    • 如果新的 VNode 不存在,老的 VNode 存在,则调用 destroy,销毁老节点


好了,到这里,Vue 源码解读系列就结束了,如果你认认真真的读完整个系列的文章,相信你对 Vue 源码已经相当熟悉了,不论是从宏观层面理解,还是某些细节方面的详解,应该都没问题。即使有些细节现在不清楚,但是当遇到问题时,你也能一眼看出来该去源码的什么位置去找答案。

到这里你可以试着在自己的脑海中复述一下 Vue 的整个执行流程。过程很重要,但 总结 才是最后的升华时刻。如果在哪个环节卡住了,可再回去读相应的部分就可以了。

还记得系列的第一篇文章中提到的目标吗?相信阅读几遍下来,你一定可以在自己的简历中写到:精通 Vue 框架的源码原理

接下来会开始 Vue 的手写系列。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

手写 Vue 系列 之 Vue1.x

手写 Vue 系列 之 Vue1.x

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

前面我们用 12 篇文章详细讲解了 Vue2 的框架源码。接下来我们就开始手写 Vue 系列,写一个自己的 Vue 框架,用最简单的代码实现 Vue 的核心功能,进一步理解 Vue 核心原理。

为什么要手写框架

有人会有疑问:我已经详细阅读过框架源码了,甚至不止两三遍,这难道还不够吗?我自认为对框架的源码已经很熟悉了,我觉得没必要再手写。

有没有必要手写框架 这个事情,和 有没有必要阅读框架源码 的答案一样。看你的出发点是什么。

读源码

如果你是抱以学习的态度,那不用说,阅读框架源码肯定是有必要的。

大家都明白,平时的业务开发中,你身边人的水平可能都跟你差不多,所以你在业务中基本是看不到太多的优秀编码和**。

而一个框架所包含的优秀设计和最佳实践就很多了,在阅读的时候有太多让你恍然大悟和惊艳的地方。即使你觉得自己现在段位不够,可能看不到那么多,但是源码对你的影响是潜移默化的。看多了优秀的代码,在你自己平时的编码中会不自觉的应用你学到的这些优秀编码方式。更何况 Vue 的大部分代码都是尤大自己写的,代码质量那是毋庸置疑的。

手写框架

至于 手写框架是否有必要 ?只要你读了框架源码,就必须自己手写一个。理由很简单,你阅读框架源码的目的是学习,你说你对源码已经非常熟了,你说你都学会了,那怎么检验?检验的方式也很简单,把你学到的东西向外输出,分三个阶段:

  1. 写技术博客、画思维导图(掌握 30%)

  2. 给他人分享,比如组内分享、录视频都行(掌握 60%)

  3. 手写框架,造轮子是检验你学习成果最好的方式(掌握 90%)

有没有发现前两阶段都是在讲他人的东西,你说你学到了,确实,你能向外输出,学你肯定是学到了,但是学到了多少呢?我觉得差不多是 60%,举个例子:

别人问你 Vue 的响应式原理是什么?经过前两个阶段的输出,你可能说的头头是道,比如 Object.defineProperty、getter、setter、依赖收集、依赖通知 watcher 更新等等。但是这整个过程你能否写出来呢?如果你第一次写,大概率是不行的,实现的时候会发现,根本不像你说的那么简单,要考虑东西远不止你说的那些。如果不信大家可以试试,检验一下。

要知道,造轮子的过程其实就是你应用的过程,只有你真的写出来了,你才算是真的学到了。如果只看不写,基本上可以算是进阶版的 只看不练

所以,检验你是否真的学会并深入理解某个框架的实现原理,模仿 造轮子 是最好的检验方式。

手写 Vue1.x

在开始之前,我们先做好准备工作,在自己的工作目录下,新建我们的源码目录,比如:

mkdir lyn-vue && cd lyn-vue

这里我不想额外安装和配置打包工具,太麻烦了,采用现代浏览器原生支持的 ESM 的方式,所以大家需要在本地装一个 serve,起一个服务器。vite 就是这个原理,只不过它的服务端是自己实现的,因为它需要针对 import 的不同资源做相应的处理,比如解析 import 请求的是 node_modules 还是 用户自己的模块,亦或者是 TS 模块的转译等等。

npm i serve -g

安装好之后,在 lyn-vue 目录下执行 serve 命令,会在本地起一个服务器,接下来就进入编码阶段。

目标

下面的示例代码就是今天的目标,用我们自己手写的 Vue 框架把这个示例跑起来。

我们需要实现以下能力:

  • 数据响应式拦截

    • 原始值

    • 普通对象

    • 数组

  • 数据响应式更新

    • 依赖收集,Dep

    • 依赖通知 Watcher 更新

    • 编译器,compiler

  • methods + 事件 + 数据响应式更新

  • v-bind 指令

  • v-model 双向绑定

    • input 输入框

    • checkbox

    • select

/vue1.0.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Lyn Vue1.0</title>
</head>

<body>
  <div id="app">
    <h3>数据响应式更新 原理</h3>
    <div>{{ t }}</div>
    <div>{{ t1 }}</div>
    <div>{{ arr }}</div>
    <h3>methods + 事件 + 数据响应式更新 原理</h3>
    <div>
      <p>{{ counter }}</p>
      <button v-on:click="handleAdd"> Add </button>
      <button v-on:click="handleMinus"> Minus </button>
    </div>
    <h3>v-bind</h3>
    <span v-bind:title="title">右键审查元素查看我的 title 属性</span>
    <h3>v-model 原理</h3>
    <div>
      <input type="text" v-model="inputVal" />
      <div>{{ inputVal }}</div>
    </div>
    <div>
      <input type="checkbox" v-model="isChecked">
      <div>{{ isChecked }}</div>
    </div>
    <div>
      <select v-model="selectValue">
        <option value="1">1</option>
        <option value="2">2</option>
        <option value="3">3</option>
      </select>
      <div>{{ selectValue }}</div>
    </div>
  </div>
  <script type="module">
    import Vue from './src/index.js'
    const ins = new Vue({
      el: '#app',
      data() {
        return {
          // 原始值和对象的响应式原理
          t: 't value',
          t1: {
            tt1: 'tt1 value'
          },
          // 数组的响应式原理
          arr: [1, 2, 3],
          // 响应式更新
          counter: 0,
          // v-bind
          title: '看我',
          // v-model
          inputVal: 'test',
          isChecked: true,
          selectValue: 2
        }
      },
      // methods + 事件 + 数据响应式更新 原理
      methods: {
        handleAdd() {
          this.counter++
        },
        handleMinus() {
          this.counter--
        }
      },
    })
    // 数据响应式拦截
    setTimeout(() => {
      console.log('********** 属性值为原始值时的 getter、setter ************')
      console.log(ins.t)
      ins.t = 'change t value'
      console.log(ins.t)
    }, 1000)

    setTimeout(() => {
      console.log('********** 属性的新值为对象的情况 ************')
      ins.t = {
        tt: 'tt value'
      }
      console.log(ins.t.tt)
    }, 2000)

    setTimeout(() => {
      console.log('********** 验证对深层属性的 getter、setter 拦截 ************')
      ins.t1.tt1 = 'change tt1 value'
      console.log(ins.t1.tt1)
    }, 3000)

    setTimeout(() => {
      console.log('********** 将值为对象的属性更新为原始值 ************')
      console.log(ins.t1)
      ins.t1 = 't1 value'
      console.log(ins.t1)
    }, 4000)

    setTimeout(() => {
      console.log('********** 数组操作方法的拦截 ************')
      console.log(ins.arr)
      ins.arr.push(4)
      console.log(ins.arr)
    }, 5000)
  </script>
</body>

</html>

数据响应式拦截

Vue 构造函数

/src/index.js

/**
 * Vue 构造函数
 * @param {*} options new Vue(options) 时传递的配置对象
 */
export default function Vue(options) {
  this._init(options)
}

this._init

/src/index.js

/**
 * 初始化配置对象
 * @param {*} options 
 */
Vue.prototype._init = function (options) {
  // 将 options 配置挂载到 Vue 实例上
  this.$options = options
  // 初始化 options.data
  // 代理 data 对象上的各个属性到 Vue 实例
  // 给 data 对象上的各个属性设置响应式能力
  initData(this)
}

initData

/src/initData.js

/**
 * 1、初始化 options.data
 * 2、代理 data 对象上的各个属性到 Vue 实例
 * 3、给 data 对象上的各个属性设置响应式能力
 * @param {*} vm 
 */
export default function initData(vm) {
  // 获取 data 选项
  let { data } = vm.$options
  // 设置 vm._data 选项,保证它的值肯定是一个对象
  if (!data) {
    vm._data = {}
  } else {
    vm._data = typeof data === 'function' ? data() : data
  }
  // 代理,将 data 对象上的的各个属性代理到 Vue 实例上,支持 通过 this.xx 的方式访问
  for (let key in vm._data) {
    proxy(vm, '_data', key)
  }
  // 设置响应式
  observe(vm._data)
}

proxy

/src/utils.js

/**
 * 将 key 代理到 target 上,
 * 比如 代理 this._data.xx 为 this.xx
 * @param {*} target 目标对象,比如 vm
 * @param {*} sourceKey 原始 key,比如 _data
 * @param {*} key 代理的原始对象上的指定属性,比如 _data.xx
 */
export function proxy(target, sourceKey, key) {
  Object.defineProperty(target, key, {
    // target.key 的读取操作实际上返回的是 target.sourceKey.key
    get() {
      return target[sourceKey][key]
    },
    // target.key 的赋值操作实际上是 target.sourceKey.key = newV
    set(newV) {
      target[sourceKey][key] = newV
    }
  })
}

observe

/src/observe.js

/**
 * 通过 Observer 类为对象设置响应式能力
 * @returns Observer 实例
 */
export default function observe(value) {
  // 避免无限递归
  // 当 value 不是对象直接结束递归
  if (typeof value !== 'object') return

  // value.__ob__ 是 Observer 实例
  // 如果 value.__ob__ 属性已经存在,说明 value 对象已经具备响应式能力,直接返回已有的响应式对象
  if (value.__ob__) return value.__ob__

  // 返回 Observer 实例
  return new Observer(value)
}

Observer

/src/observer.js

/**
 * 为普通对象或者数组设置响应式的入口 
 */
export default function Observer(value) {
  // 为对象设置 __ob__ 属性,值为 this,标识当前对象已经是一个响应式对象了
  Object.defineProperty(value, '__ob__', {
    value: this,
    // 设置为 false,禁止被枚举,
    // 1、可以在递归设置数据响应式的时候跳过 __ob__ 
    // 2、将响应式对象字符串化时也不限显示 __ob__ 对象
    enumerable: false,
    writable: true,
    configurable: true
  })

  if (Array.isArray(value)) {
    // 数组响应式
    protoArgument(value)
    this.observeArray(value)
  } else {
    // 对象响应式
    this.walk(value)
  }
}

/**
 * 遍历对象的每个属性,为这些属性设置 getter、setter 拦截
 */
Observer.prototype.walk = function (obj) {
  for (let key in obj) {
    defineReactive(obj, key, obj[key])
  }
}

// 遍历数组的每个元素,为每个元素设置响应式
// 其实这里是为了处理元素为对象的情况,以达到 this.arr[idx].xx 是响应式的目的
Observer.prototype.observeArray = function (arr) {
  for (let item of arr) {
    observe(item)
  }
}

defineReactive

/src/defineReactive.js

/**
 * 通过 Object.defineProperty 为 obj.key 设置 getter、setter 拦截
 */
export default function defineReactive(obj, key, val) {
  // 递归调用 observe,处理 val 仍然为对象的情况
  observe(val)

  Object.defineProperty(obj, key, {
    // 当发现 obj.key 的读取行为时,会被 get 拦截
    get() {
      console.log(`getter: key = ${key}`)
      return val
    },
    // 当发生 obj.key = xx 的赋值行为时,会被 set 拦截
    set(newV) {
      console.log(`setter: ${key} = ${newV}`)
      if (newV === val) return
      val = newV
      // 对新值进行响应式处理,这里针对的是新值为非原始值的情况,比如 val 为对象、数组
      observe(val)
    }
  })
}

protoArgument

/src/protoArgument.js

/**
 * 通过拦截数组的七个方法来实现
 */

// 数组默认原型对象
const arrayProto = Array.prototype
// 以数组默认原型对象为原型创建一个新的对象
const arrayMethods = Object.create(arrayProto)
// 被 patch 的七个方法,通过拦截这七个方法来实现数组响应式
// 为什么是这七个方法?因为只有这七个方法是能更改数组本身的,像 cancat 这些方法都是会返回一个新的数组,不会改动数组本身
const methodsToPatch = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse']

// 遍历 methodsToPatch
methodsToPatch.forEach(method => {
  // 拦截数组的七个方法,先完成本职工作,再额外完成响应式的工作
  Object.defineProperty(arrayMethods, method, {
    value: function(...args) {
      // 完成方法的本职工作,比如 this.arr.push(xx)
      const ret = arrayProto[method].apply(this, args)
      // 将来接着实现响应式相关的能力
      console.log('array reactive')
      return ret
    },
    configurable: true,
    writable: true,
    enumerable: true
  })
})

/**
 * 覆盖数组(arr)的原型对象
 * @param {*} arr 
 */
export default function protoArgument(arr) {
  arr.__proto__ = arrayMethods
}

效果

能达到如下效果,则表示数据响应式拦截功能完成。即能跑通目标中示例代码的 “数据响应式拦截” 部分的代码(最后的那堆 setTimeout)。

动图地址: https://gitee.com/liyongning/typora-image-bed/raw/master/202203092000920.image

响应式原理.gif

数据响应式更新

现在已经能拦截到对数据的获取和更新,接下来就可以在拦截数据的地方增加一些 “能力”,以完成 数据响应式更新 的功能。

增加的这些能力其实就是大家耳熟能详的东西:在 getter 中进行依赖收集,setter 中依赖通知 watcher 更新。

Vue1.x 中响应式数据对象的所有属性(key)和 dep 是一一对应对应关系,一个 key 对应一个 dep;响应式数据在页面中每引用一次就会产生一个 watcher,所以在 Vue1.0 中 dep 和 watcher 是一对多的关系。

依赖收集

Dep

/src/dep.js

/**
 * Dep
 * Vue1.0 中 key 和 Dep 是一一对应关系,举例来说:
 * new Vue({
 *   data() {
 *     return {
 *       t1: xx,
 *       t2: {
 *         tt2: xx
 *       },
 *       arr: [1, 2, 3, { t3: xx }]
 *     }
 *   }
 * })
 * data 函数 return 回来的对象是一个 dep
 * 对象中的 key => t1、t2、tt2、arr、t3 都分别对应一个 dep
 */
export default function Dep() {
  // 存储当前 dep 实例收集的所有 watcher
  this.watchers = []
}

// Dep.target 是一个静态属性,值为 null 或者 watcher 实例
// 在实例化 Watcher 时进行赋值,待依赖收集完成后在 Watcher 中又重新赋值为 null
Dep.target = null

/**
 * 收集 watcher
 * 在发生读取操作时(vm.xx) && 并且 Dep.target 不为 null 时进行依赖收集
 */
Dep.prototype.depend = function () {
  // 防止 Watcher 实例被重复收集
  if (this.watchers.includes(Dep.target)) return
  // 收集 Watcher 实例
  this.watchers.push(Dep.target)
}

/**
 * dep 通知自己收集的所有 watcher 执行更新函数
 */
Dep.prototype.notify = function () {
  for (let watcher of this.watchers) {
    watcher.update()
  }
}
Watcher

/src/watcher.js

import Dep from "./dep.js"

/**
 * @param {*} cb 回调函数,负责更新 DOM 的回调函数
 */
export default function Watcher(cb) {
  // 备份 cb 函数
  this._cb = cb
  // 赋值 Dep.target
  Dep.target = this
  // 执行 cb 函数,cb 函数中会发生 vm.xx 的属性读取,进行依赖收集
  cb()
  // 依赖收集完成,Dep.target 重新赋值为 null,防止重复收集
  Dep.target = null
}

/**
 * 响应式数据更新时,dep 通知 watcher 执行 update 方法,
 * 让 update 方法执行 this._cb 函数更新 DOM
 */
Watcher.prototype.update = function () {
  this._cb()
}
Observer

改造 Observer 构造函数,在 value.ob 对象上设置一个 dep 实例。这个 dep 是对象本身的 dep,方便在更新对象本身时使用,比如:数组依赖通知更新时就会用到。

/src/observer.js

/**
 * 为普通对象或者数组设置响应式的入口
 */
export default function Observer(value) {
  // 为对象本身设置一个 dep,方便在更新对象本身时使用,比如 数组通知依赖更新时就会用到
  this.dep = new Dep()  
  // ... 省略已有内容
}
defineReactive

改造 defineReactive 方法,增加依赖收集和依赖通知更新的代码

/src/defineReactive.js

/**
 * 通过 Object.defineProperty 为 obj.key 设置 getter、setter 拦截
 * getter 时收集依赖
 * setter 时依赖通过 watcher 更新
 */
export default function defineReactive(obj, key, val) {
  // 递归调用 observe,处理 val 仍然为对象的情况
  const childOb = observe(val)

  const dep = new Dep()

  Object.defineProperty(obj, key, {
    // 当发现 obj.key 的读取行为时,会被 get 拦截
    get() {
      // 读取数据时 && Dep.target 不为 null,则进行依赖收集
      if (Dep.target) {
        dep.depend()
        // 如果存在子 ob,则顺道一块儿完成依赖收集
        if (childOb) {
          childOb.dep.depend()
        }
      }
      console.log(`getter: key = ${key}`)
      return val
    },
    // 当发生 obj.key = xx 的赋值行为时,会被 set 拦截
    set(newV) {
      console.log(`setter: ${key} = ${newV}`)
      if (newV === val) return
      val = newV
      // 对新值进行响应式处理,这里针对的是新值为非原始值的情况,比如 val 为对象、数组
      observe(val)
      // 数据更新,让 dep 通知自己收集的所有 watcher 执行 update 方法
      dep.notify()
    }
  })
}
protoArgument

改造七个数组方法的 patch 补丁,当数组新增元素时,对新元素进行响应式处理和依赖通知更新。

/src/protoArgument.js

/**
 * 通过拦截数组的七个方法来实现
 */

// 数组默认原型对象
const arrayProto = Array.prototype
// 以数组默认原型对象为原型创建一个新的对象
const arrayMethods = Object.create(arrayProto)
// 被 patch 的七个方法,通过拦截这七个方法来实现数组响应式
// 为什么是这七个方法?因为只有这七个方法是能更改数组本身的,像 cancat 这些方法都是会返回一个新的数组,不会改动数组本身
const methodsToPatch = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse']

// 遍历 methodsToPatch
methodsToPatch.forEach(method => {
  // 拦截数组的七个方法,先完成本职工作,再额外完成响应式的工作
  Object.defineProperty(arrayMethods, method, {
    value: function(...args) {
      // 完成方法的本职工作,比如 this.arr.push(xx)
      const ret = arrayProto[method].apply(this, args)
      // 将来接着实现响应式相关的能力
      console.log('array reactive')
      // 新增的元素列表
      let inserted = []
      switch(method) {
        case 'push':
        case 'unshift':
          inserted = args
          break;
        case 'splice':
          // this.arr.splice(idx, num, x, x, x)
          inserted = args.slice(2)
          break;
      }
      // 如果数组有新增的元素,则对新增的元素进行响应式处理
      inserted.length && this.__ob__.observeArray(inserted)
      // 依赖通知更新
      this.__ob__.dep.notify()
      return ret
    },
    configurable: true,
    writable: true,
    enumerable: true
  })
})

/**
 * 覆盖数组(arr)的原型对象
 * @param {*} arr 
 */
export default function protoArgument(arr) {
  arr.__proto__ = arrayMethods
}

到这里依赖收集就全部完成了。但是你会发现页面还是没有发生任何变化,响应式数据在页面没有得到渲染,数据更新时页面更是没有任何变化。这是为什么?还需要做什么事情吗?

其实回顾依赖收集的代码会发现有一个被我们遗漏的地方,大家有没有发现 Watcher 构造函数似乎从来没有被实例化过,那也就是说依赖收集其实从来没有被触发过,因为只有实例化 Watcher 时 Dep.target 才会被赋值。

那么问题就来了,Watcher 应该在什么什么时候被实例化呢?大家可能没有看过 Vue1 的源码,但是 Vue2 的源码前面带大家看过了,仔细回想一下,什么时候会去实例化 Watcher。

答案是 mountComponent,也就是挂载阶段,初始化完成后执行 $mount,$mount 调用 mountComponent,mountComponent 方法中有一步就是在实例化 Watcher。如果这块儿有遗忘,大家可以再去翻看一下这部分的源码。

所以接下来我们要实现的就是编译器了,也就是 $mount 方法。

编译器

这部分利用 DOM 操作实现了一个简版的编译器。从中你可以看到节点树的编译过程,明白文本节点、v-on:click、v-bind、v-model 指令的实现原理。

$mount

/src/index.js

Vue.prototype._init = function (options) {
  ... 省略
  
  // 如果存在 el 配置项,则调用 $mount 方法编译模版
  if (this.$options.el) {
    this.$mount()
  }
}

Vue.prototype.$mount = function () {
  mount(this)
}
mount

/src/compiler/index.js

/**
 * 编译器
 */
export default function mount(vm) {
  // 获取 el 选择器所表示的元素
  let el = document.querySelector(vm.$options.el)

  // 编译节点
  compileNode(Array.from(el.childNodes), vm)
}
compileNode

/src/compiler/compileNode.js

/**
 * 递归编译整棵节点树
 * @param {*} nodes 节点
 * @param {*} vm Vue 实例
 */
export default function compileNode(nodes, vm) {
  // 循环遍历当前节点的所有子节点
  for (let i = 0, len = nodes.length; i < len; i++) {
    const node = nodes[i]
    if (node.nodeType === 1) { // 元素节点
      // 编译元素上的属性节点
      compileAttribute(node, vm)
      // 递归编译子节点
      compileNode(Array.from(node.childNodes), vm)
    } else if (node.nodeType === 3 && node.textContent.match(/{{(.*)}}/)) {
      // 编译文本节点
      compileTextNode(node, vm)
    }
  }
}
compileTextNode

文本节点响应式更新的原理

/src/compiler/compileTextNode.js

/**
 * 编译文本节点
 * @param {*} node 节点
 * @param {*} vm Vue 实例
 */
export default function compileTextNode(node, vm) {
  // <span>{{ key }}</span>
  const key = RegExp.$1.trim()
  // 当响应式数据 key 更新时,dep 通知 watcher 执行 update 函数,cb 会被调用
  function cb() {
    node.textContent = JSON.stringify(vm[key])
  }
  // 实例化 Watcher,执行 cb,触发 getter,进行依赖收集
  new Watcher(cb)
}
compileAttribute

v-on:click、v-bind 和 v-model 指令的原理

/src/compiler/compileAttribute.js

/**
 * 编译属性节点
 * @param {*} node 节点
 * @param {*} vm Vue 实例
 */
export default function compileAttribute(node, vm) {
  // 将类数组格式的属性节点转换为数组
  const attrs = Array.from(node.attributes)
  // 遍历属性数组
  for (let attr of attrs) {
    // 属性名称、属性值
    const { name, value } = attr
    if (name.match(/v-on:click/)) {
      // 编译 v-on:click 指令
      compileVOnClick(node, value, vm)
    } else if (name.match(/v-bind:(.*)/)) {
      // v-bind
      compileVBind(node, value, vm)
    } else if (name.match(/v-model/)) {
      // v-model
      compileVModel(node, value, vm)
    }
  }
}
compileVOnClick

/src/compiler/compileAttribute.js

/**
 * 编译 v-on:click 指令
 * @param {*} node 节点
 * @param {*} method 方法名
 * @param {*} vm Vue 实例
 */
function compileVOnClick(node, method, vm) {
  // 给节点添加一个 click 事件,回调函数是对应的 method
  node.addEventListener('click', function (...args) {
    // 给 method 绑定 this 上下文
    vm.$options.methods[method].apply(vm, args)
  })
}
compileVBind

/src/compiler/compileAttribute.js

/**
 * 编译 v-bind 指令
 * @param {*} node 节点
 * @param {*} attrValue 属性值
 * @param {*} vm Vue 实例
 */
function compileVBind(node, attrValue, vm) {
  // 属性名称
  const attrName = RegExp.$1
  // 移除模版中的 v-bind 属性
  node.removeAttribute(`v-bind:${attrName}`)
  // 当属性值发生变化时,重新执行回调函数
  function cb() {
    node.setAttribute(attrName, vm[attrValue])
  }
  // 实例化 Watcher,当属性值发生变化时,dep 通知 watcher 执行 update 方法,cb 被执行,重新更新属性
  new Watcher(cb)
}
compileVModel

/src/compiler/compileAttribute.js

/**
 * 编译 v-model 指令
 * @param {*} node 节点 
 * @param {*} key v-model 的属性值
 * @param {*} vm Vue 实例
 */
function compileVModel(node, key, vm) {
  // 节点标签名、类型
  let { tagName, type } = node
  // 标签名转换为小写
  tagName = tagName.toLowerCase()
  if (tagName === 'input' && type === 'text') {
    // <input type="text" v-model="inputVal" />

    // 设置 input 输入框的初始值
    node.value = vm[key]
    // 给节点添加 input 事件,当事件发生时更改响应式数据
    node.addEventListener('input', function () {
      vm[key] = node.value
    })
  } else if (tagName === 'input' && type === 'checkbox') {
    // <input type="checkbox" v-model="isChecked" />

    // 设置选择框的初始状态
    node.checked = vm[key]
    // 给节点添加 change 事件,当事件发生时更改响应式数据
    node.addEventListener('change', function () {
      vm[key] = node.checked
    })
  } else if (tagName === 'select') {
    // <select v-model="selectedValue"></select>

    // 设置下拉框初始选中的选项
    node.value = vm[key]
    // 添加 change 事件,当事件发生时更改响应式数据
    node.addEventListener('change', function () {
      vm[key] = node.value
    })
  }
}

总结

到这里,一个简版的 Vue1.x 就实现完了。回顾一下,我们实现了如下功能:

  • 数据响应式拦截

    • 普通对象

    • 数组

  • 数据响应式更新

    • 依赖收集

      • Dep

      • Watcher

    • 编译器

      • 文本节点

      • v-on:click

      • v-bind

      • v-model

目标 中示例代码的执行结果如下:

动图地址:https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fcceda69f08a4d0a8b4f9c1e96032ad6~tplv-k3u1fbpfcp-watermark.image

May-26-2021 09-12-48.gif

面试官 问:Vue1.x 数据响应式是如何实现的?

Vue 数据响应式的核心原理是 Object.defineProperty

通过递归遍历整个 data 对象,为对象中的每个 key 设置一个 getter、setter。如果 key 为数组,则走数组响应式的流程。

数组响应式是通过 Object.defineProperty 去拦截数组的七个方法实现的。首先增强了那个七个方法,在完成方法本职工作的基础上增加了依赖通知更新的能力,而且如果有新增数据,则新数据也会被进行响应式处理。

数据响应式更新的能力是通过数据响应式拦截结合 Dep、Watcher、编译器来实现的。

当做完数据初始化工作以后(即响应式拦截),就进入挂载阶段,开始编译整棵 DOM 树,编译过程中 碰到响应式数据,实例化 Watcher,这时会发生数据读取操作,触发 getter,进行依赖收集,将 Watcher 实例放到当前响应式属性对应的 dep 中。

待将来响应式数据更新时,触发 setter,然后出发 dep 通知自己收集的所有 Watcher 实例去执行 update 方法,触发回调函数的执行,从而更新 DOM。

以上 Vue1.x 的整个响应式原理的实现。

面试官 问:你如何评价 Vue1.x 响应式原理的设计?

Vue1.x 其实是尤大为了解决自己工作上的痛点而实现的,当时他觉得各种 DOM 操作太繁琐了,初始化时需要通过 DOM 操作将数据设置到节点上,还要监听 DOM 操作,当 DOM 更新时,更新相应的数据。于是他就想着能不能把这个过程自动化,这就产生了 Vue1.x。

这么一想,Vue1.x 的实现其实就很合理了,确实达到了预期的目标。通过 Object.defineProperty 拦截数据的读取和设置,页面初次渲染时,通过编译器编译整棵 DOM 树,给 DOM 节点设置初始值,当 DOM 节点更新时又自动更新了响应式数据,或者响应式数据更新时,通过 Watcher 自动更新对应的 DOM 节点。

这个时候的 Vue 在完成中小型 Web 系统是没有任何问题的。而且相比于 Vue 2.x 性能会更好,因为响应式数据更新时,Watcher 可以直接更新对应的 DOM 节点,没有 2.x 的 VNode 开销和 Diff 过程。

但是大型 Web 系统就搞不定了,理由也很简单,也是因为它的设计。因为 Vue1.x 中 Watcher 和 模版中响应式数据是 一一对应 关系,也就是说页面中每引用一次响应式数据,就会产生一个 Watcher。在大型系统中,一个页面的数据量可能是非常大的,那就会产生大量的 Watcher,占用大量资源,导致性能下降。

所以一句话总结就是,Vue1.x 在中小型系统中性能会很好,定向更新 DOM 节点,但是大型系统由于 Watcher 太多,导致资源占用过多,性能下降。

于是 Vue2.x 中通过引入 VNode 和 Diff 的来解决这个问题,具体的实现原理将在下一篇文章 手写 Vue 系列之 Vue2.x 中去介绍。

预告

接下来的文章,会将本篇文章中实现的 Vue1.x 升级为 Vue2.x,引入 Vnode、diff 算法来解决 Vue1.x 的性能瓶颈。

另外会额外实现一些其它的核心原理,比如 computed、异步更新队列、child component、插槽 等。

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

PDF 生成(3)— 封面、尾页

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

封面.png

回顾

PDF 生成(2)— 生成 PDF 文件 我们以百度新闻页为例为大家展示了 puppeteer 的基本使用:

  • 通过短短的 10行 代码将百度新闻页打印成一份 PDF 文件
  • 通过 puppeteer 的 page.evaluate 方法为浏览器注入一段 JS 代码,用代码来模拟页面滚动,以解决懒加载的问题,从而保证 PDF 文件内容的完整性
  • 通过自定义页眉、页脚的方式讲解了 puppeteer 中关于页眉、页脚相关选项的基本使用和其中的

文章最后也提到了 puppeteer 在 PDF 文件生成场景下的能力基本到头了,但现有内容在我们的技术架构中只是九牛一毛,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点

问题

一份专业的 PDF 文件都会有自己的封面尾页。在本文开始之前,大家先想想,基于现状如何为我们之前生成的 PDF 文件增加封面和尾页呢?比如

image-20240308125818882

所以,本文的内容就是为我们在上文中生成的 PDF 文件增加封面和尾页。

分析

不知道大家是否还记得在 PDF 生成(1)— 开篇 中的技术架构图,为什么架构图中的 PDF 生成服务会产出 3份 PDF 文件?带着问题接着往下看。

image.png

假设前文中我们用的百度新闻页就是我们自己开发的一个页面,那在页面的开始和结束位置分别加上封面和尾页的 DOM,然后直接生成 PDF 文件,是不是就可以了?想想,这样做最简单了,一个页面搞定所有内容,比如:

组 3 (1).png
但稍微一分析,就发现不行,因为我们我们在 page.pdf 方法中设置的 margin 属性和页眉、页脚是针对整个 PDF 文件的,但封面和尾页不需要边距和页眉、页脚。

一个页面(URL)对应一份 PDF 文件,这是大方向,是由技术方案本身的特性所决定的,因此封面和尾页不能和内容页放一起。

经过分析,结合架构图的指引,我们的实现思路是一份完整的 PDF 文件至少应该包括三个页面 —— 封面页、内容页、尾页,每个页面对应一份 PDF 文件,最后将三份 PDF 合并成一份 PDF,接下来就进入实战。

实战

前端页面的开发不是重点,所以这里我们就简单写了。

封面页 — /fe/cover.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      width: 100%;
      height: 1123px;
      background: linear-gradient(173deg, #F5F8FF 20%, #E1E9FC 80%, rgba(225, 233, 252, 0) 86%);
      display: flex;
      justify-content: center;
      align-items: center;
    }
  </style>
</head>
<body>
  <h1>我是封面</h1>
</body>
</html>

尾页 — /fe/last-page.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      width: 100%;
      height: 1123px;
      background: linear-gradient(173deg, #F5F8FF 20%, #E1E9FC 80%, rgba(225, 233, 252, 0) 86%);
      display: flex;
      justify-content: center;
      align-items: center;
    }
  </style>
</head>
<body>
  <h1>我是尾页</h1>
</body>
</html>

PDF 生成服务 — /server/index.mjs

/server/index.mjs 中增加如下代码,用来生成封面和尾页的 PDF 文件

image.png

截图中对应的代码如下:

/* 省略之前的代码... */
  // 封面
  await page.goto('file:///Users/liyongning/studyspace/generate-pdf/fe/cover.html')
  await page.pdf({
    path: './cover.pdf',
    format: 'A4',
    printBackground: true
  })
  // 尾页
  await page.goto('file:///Users/liyongning/studyspace/generate-pdf/fe/last-page.html')
  await page.pdf({
    path: './last-page.pdf',
    format: 'A4',
    printBackground: true
  })
/* 省略之后的代码... */

生成的 PDF 效果如下:

image.png
image.png

解析来就是本文的重点了 — PDF 文件合并,因为我们最终交付的是一份 PDF 文件,而不是三份。

PDF 文件合并

我们借助第三方库 pdf-lib 来完成 PDF 文件的合并。

  • 首先安装 pdf-lib —— npm i pdf-lib
  • 新建 /server/merge-pdf.mjs 文件来编写文件合并的代码

实现如下:

/server/index.mjs:

image-20240308125635526
image.png

/server/merge-pdf.mjs:

image-20240308125603095

这里大家可能会有两个疑问点:

  • 为什么不直接通过 Buffer.concat 合并内容,然后直接写盘,而是要通过 第三方库 先合并再写盘(page.pdf 的返回值是一个 Buffer 类型的数据)
  • 为什么不新创建一份 PDF 文件,然后将三个文件合并到一起,或者是将内容页 PDF 的各个页面和尾页 PDF 的页面添加到封面 PDF 中,而是分别将封面 PDF 的页面和尾页 PDF 的页面插到内容 PDF 的对应位置

第一个问题的答案是:数据格式问题,虽然都是保存在内存中的二进制内容,但是 PDF 文件的二进制内容格式有点特殊,如果直接通过 Buffer.concat 将内容拼接,会发现拼接的内容就丢了,所以这里需要借助专门操作 PDF 文件的第三方库。当然了,如果是一个普通的文本文件,通过 Buffer.concat 完全没问题,有兴趣的话大家可以自己写个简单的 Demo。

至于第二个问题,答案是:不行,简单解释就是 —— 在当前的技术架构下,会导致目录页中目录项的页面跳转能力失效,目录页会用到 HTML 锚点,这些锚点被 pdf-lib 处理之后就失效了。具体内容在后面 PDF 生成(4)— 目录页 详细讲解。

最终效果图如下:

image.png
image.png

PDF 文件合并(/server/merge-pdf.mjs)的完整代码如下:

import { PDFDocument } from 'pdf-lib'

/**
 * 将三份 PDF 文件合并为一份
 *    另外三个参数的类型都是 Buffer,是表示 PDF 文件加载到内存后二进制内容
 * @param { Buffer } coverBuffer 封面 PDF
 * @param { Buffer } contentBuffer 内容页 PDF
 * @param { Buffer } lastPageBuffer 尾页 PDF
 * @returns 合并后的 PDF 文件的二进制内容
 */
export default async function mergePDF(coverBuffer, contentBuffer, lastPageBuffer) {
  // 通过 pdf-lib 加载现有的 3份 PDF 文档
  const { load } = PDFDocument
  const [coverPdfDoc, contentPdfDoc, lastPagePdfDoc] = await Promise.all([load(coverBuffer), load(contentBuffer), load(lastPageBuffer)])
  // 分别将封面文档和尾页文档的第一页拷贝到内容文档
  const [[coverPage], [lastPagePage]] = await Promise.all([contentPdfDoc.copyPages(coverPdfDoc, [0]), contentPdfDoc.copyPages(lastPagePdfDoc, [0])])
  // 将封面页插入到 内容文档 的第 0 页,即最开始的位置
  contentPdfDoc.insertPage(0, coverPage)
  // 将尾页添加到 内容文档 的最后一页
  contentPdfDoc.addPage(lastPagePage)
  // 将合并后的 内容文档 序列化为字节数组(Uint8Array),并以二进制的格式返回
  return Buffer.from(await contentPdfDoc.save())
}

总结

本文介绍了如何为通过 Puppeteer 生成的 PDF 文件添加封面和尾页,现在再来整体回顾一下:

  • 首先,技术方案决定了一个页面对应一份 PDF 文件,这是大前提,因为 page.xx 方法的所有配置都是针对当前页的
  • 在大前提下,我们通过 PDF 文件合并方案(pdf-lib),分别将封面 PDF、内容页 PDF 和尾页 PDF 三份文件合并为一份报告包含封面、内容页和尾页的完整 PDF

到这里,PDF 文件的整体框架已经基本形成(包括封面、内容页、尾页),但还有一点不完整,比如缺少目录页,一份完整的文件或文章怎么能没有目录呢?所以,接下来我们就讲 PDF 生成(4)— 目录页

链接

  • PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(2)—— Vue 初始化过程

Vue 源码解读(2)—— Vue 初始化过程

当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

目标

深入理解 Vue 的初始化过程,再也不怕 面试官 的那道面试题:new Vue(options) 发生了什么?

找入口

想知道 new Vue(options) 都做了什么,就得先找到 Vue 的构造函数是在哪声明的,有两个办法:

  • 从 rollup 配置文件中找到编译的入口,然后一步步找到 Vue 构造函数,这种方式 费劲

  • 通过编写示例代码,然后打断点的方式,一步到位,简单

我们就采用第二种方式,写示例,打断点,一步到位。

  • /examples 目录下增加一个示例文件 —— test.html,在文件中添加如下内容:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Vue 源码解读</title>
</head>
<body>
  <div id="app">
    {{ msg }}
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    debugger
    new Vue({
      el: '#app',
      data: {
        msg: 'hello vue'
      }
    })
  </script>
</body>
</html>
  • 在浏览器中打开控制台,然后打开 test.html,则会进入断点调试,然后找到 Vue 构造函数所在的文件

点击查看演示动图,动图地址:https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d839ea6f3e5d4adcaf1ea9a8f6ff1a70~tplv-k3u1fbpfcp-watermark.awebp

得到 Vue 构造函数在 /src/core/instance/index.js 文件中,接下来正式开始源码阅读,带着目标去阅读。

在阅读过程中如遇到看不明白的地方,可通过编写示例代码,然后使用浏览器的调试功能进行一步步调试,配合理解,如果还是理解不了,就做个备注继续向后看,也许你看到其它地方,就突然明白这个地方在做什么,或者回头再来看,就会懂了,源码这个东西,一定要多看,要想精通,一遍两遍肯定是不够的

源码解读 —— Vue 初始化过程

Vue

/src/core/instance/index.js

import { initMixin } from './init'

// Vue 构造函数
function Vue (options) {
  // 调用 Vue.prototype._init 方法,该方法是在 initMixin 中定义的
  this._init(options)
}

// 定义 Vue.prototype._init 方法
initMixin(Vue)

export default Vue

Vue.prototype._init

/src/core/instance/init.js

/**
 * 定义 Vue.prototype._init 方法 
 * @param {*} Vue Vue 构造函数
 */
export function initMixin (Vue: Class<Component>) {
  // 负责 Vue 的初始化过程
  Vue.prototype._init = function (options?: Object) {
    // vue 实例
    const vm: Component = this
    // 每个 vue 实例都有一个 _uid,并且是依次递增的
    vm._uid = uid++

    // a flag to avoid this being observed
    vm._isVue = true
    // 处理组件配置项
    if (options && options._isComponent) {
      /**
       * 每个子组件初始化时走这里,这里只做了一些性能优化
       * 将组件配置对象上的一些深层次属性放到 vm.$options 选项中,以提高代码的执行效率
       */
      initInternalComponent(vm, options)
    } else {
      /**
       * 初始化根组件时走这里,合并 Vue 的全局配置到根组件的局部配置,比如 Vue.component 注册的全局组件会合并到 根实例的 components 选项中
       * 至于每个子组件的选项合并则发生在两个地方:
       *   1、Vue.component 方法注册的全局组件在注册时做了选项合并
       *   2、{ components: { xx } } 方式注册的局部组件在执行编译器生成的 render 函数时做了选项合并,包括根组件中的 components 配置
       */
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      // 设置代理,将 vm 实例上的属性代理到 vm._renderProxy
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    // 初始化组件实例关系属性,比如 $parent、$children、$root、$refs 等
    initLifecycle(vm)
    /**
     * 初始化自定义事件,这里需要注意一点,所以我们在 <comp @click="handleClick" /> 上注册的事件,监听者不是父组件,
     * 而是子组件本身,也就是说事件的派发和监听者都是子组件本身,和父组件无关
     */
    initEvents(vm)
    // 解析组件的插槽信息,得到 vm.$slot,处理渲染函数,得到 vm.$createElement 方法,即 h 函数
    initRender(vm)
    // 调用 beforeCreate 钩子函数
    callHook(vm, 'beforeCreate')
    // 初始化组件的 inject 配置项,得到 result[key] = val 形式的配置对象,然后对结果数据进行响应式处理,并代理每个 key 到 vm 实例
    initInjections(vm) // resolve injections before data/props
    // 数据响应式的重点,处理 props、methods、data、computed、watch
    initState(vm)
    // 解析组件配置项上的 provide 对象,将其挂载到 vm._provided 属性上
    initProvide(vm) // resolve provide after data/props
    // 调用 created 钩子函数
    callHook(vm, 'created')

    // 如果发现配置项上有 el 选项,则自动调用 $mount 方法,也就是说有了 el 选项,就不需要再手动调用 $mount,反之,没有 el 则必须手动调用 $mount
    if (vm.$options.el) {
      // 调用 $mount 方法,进入挂载阶段
      vm.$mount(vm.$options.el)
    }
  }
}

resolveConstructorOptions

/src/core/instance/init.js

/**
 * 从组件构造函数中解析配置对象 options,并合并基类选项
 * @param {*} Ctor 
 * @returns 
 */
export function resolveConstructorOptions (Ctor: Class<Component>) {
  // 配置项目
  let options = Ctor.options
  if (Ctor.super) {
    // 存在基类,递归解析基类构造函数的选项
    const superOptions = resolveConstructorOptions(Ctor.super)
    const cachedSuperOptions = Ctor.superOptions
    if (superOptions !== cachedSuperOptions) {
      // 说明基类构造函数选项已经发生改变,需要重新设置
      Ctor.superOptions = superOptions
      // 检查 Ctor.options 上是否有任何后期修改/附加的选项(#4976)
      const modifiedOptions = resolveModifiedOptions(Ctor)
      // 如果存在被修改或增加的选项,则合并两个选项
      if (modifiedOptions) {
        extend(Ctor.extendOptions, modifiedOptions)
      }
      // 选项合并,将合并结果赋值为 Ctor.options
      options = Ctor.options = mergeOptions(superOptions, Ctor.extendOptions)
      if (options.name) {
        options.components[options.name] = Ctor
      }
    }
  }
  return options
}

resolveModifiedOptions

/src/core/instance/init.js

/**
 * 解析构造函数选项中后续被修改或者增加的选项
 */
function resolveModifiedOptions (Ctor: Class<Component>): ?Object {
  let modified
  // 构造函数选项
  const latest = Ctor.options
  // 密封的构造函数选项,备份
  const sealed = Ctor.sealedOptions
  // 对比两个选项,记录不一致的选项
  for (const key in latest) {
    if (latest[key] !== sealed[key]) {
      if (!modified) modified = {}
      modified[key] = latest[key]
    }
  }
  return modified
}

mergeOptions

/src/core/util/options.js

/**
 * 合并两个选项,出现相同配置项时,子选项会覆盖父选项的配置
 */
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child)
  }

  if (typeof child === 'function') {
    child = child.options
  }

  // 标准化 props、inject、directive 选项,方便后续程序的处理
  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  // 处理原始 child 对象上的 extends 和 mixins,分别执行 mergeOptions,将这些继承而来的选项合并到 parent
  // mergeOptions 处理过的对象会含有 _base 属性
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  const options = {}
  let key
  // 遍历 父选项
  for (key in parent) {
    mergeField(key)
  }

  // 遍历 子选项,如果父选项不存在该配置,则合并,否则跳过,因为父子拥有同一个属性的情况在上面处理父选项时已经处理过了,用的子选项的值
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }

  // 合并选项,childVal 优先级高于 parentVal
  function mergeField (key) {
    // strats = Object.create(null)
    const strat = strats[key] || defaultStrat
    // 值为如果 childVal 存在则优先使用 childVal,否则使用 parentVal
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

initInjections

/src/core/instance/inject.js

/**
 * 初始化 inject 配置项
 *   1、得到 result[key] = val
 *   2、对结果数据进行响应式处理,代理每个 key 到 vm 实例
 */
export function initInjections (vm: Component) {
  // 解析 inject 配置项,然后从祖代组件的配置中找到 配置项中每一个 key 对应的 val,最后得到 result[key] = val 的结果
  const result = resolveInject(vm.$options.inject, vm)
  // 对 result 做 数据响应式处理,也有代理 inject 配置中每个 key 到 vm 实例的作用。
  // 不不建议在子组件去更改这些数据,因为一旦祖代组件中 注入的 provide 发生更改,你在组件中做的更改就会被覆盖
  if (result) {
    toggleObserving(false)
    Object.keys(result).forEach(key => {
      /* istanbul ignore else */
      if (process.env.NODE_ENV !== 'production') {
        defineReactive(vm, key, result[key], () => {
          warn(
            `Avoid mutating an injected value directly since the changes will be ` +
            `overwritten whenever the provided component re-renders. ` +
            `injection being mutated: "${key}"`,
            vm
          )
        })
      } else {
        defineReactive(vm, key, result[key])
      }
    })
    toggleObserving(true)
  }
}

resolveInject

/src/core/instance/inject.js

/**
 * 解析 inject 配置项,从祖代组件的 provide 配置中找到 key 对应的值,否则用 默认值,最后得到 result[key] = val
 * inject 对象肯定是以下这个结构,因为在 合并 选项时对组件配置对象做了标准化处理
 * @param {*} inject = {
 *  key: {
 *    from: provideKey,
 *    default: xx
 *  }
 * }
 */
export function resolveInject (inject: any, vm: Component): ?Object {
  if (inject) {
    // inject is :any because flow is not smart enough to figure out cached
    const result = Object.create(null)
    // inject 配置项的所有的 key
    const keys = hasSymbol
      ? Reflect.ownKeys(inject)
      : Object.keys(inject)

    // 遍历 key
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      // 跳过 __ob__ 对象
      // #6574 in case the inject object is observed...
      if (key === '__ob__') continue
      // 拿到 provide 中对应的 key
      const provideKey = inject[key].from
      let source = vm
      // 遍历所有的祖代组件,直到 根组件,找到 provide 中对应 key 的值,最后得到 result[key] = provide[provideKey]
      while (source) {
        if (source._provided && hasOwn(source._provided, provideKey)) {
          result[key] = source._provided[provideKey]
          break
        }
        source = source.$parent
      }
      // 如果上一个循环未找到,则采用 inject[key].default,如果没有设置 default 值,则抛出错误
      if (!source) {
        if ('default' in inject[key]) {
          const provideDefault = inject[key].default
          result[key] = typeof provideDefault === 'function'
            ? provideDefault.call(vm)
            : provideDefault
        } else if (process.env.NODE_ENV !== 'production') {
          warn(`Injection "${key}" not found`, vm)
        }
      }
    }
    return result
  }
}

initProvide

/src/core/instance/inject.js

/**
 * 解析组件配置项上的 provide 对象,将其挂载到 vm._provided 属性上 
 */
export function initProvide (vm: Component) {
  const provide = vm.$options.provide
  if (provide) {
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}

总结

Vue 的初始化过程(new Vue(options))都做了什么?

  • 处理组件配置项

    • 初始化根组件时进行了选项合并操作,将全局配置合并到根组件的局部配置上

    • 初始化每个子组件时做了一些性能优化,将组件配置对象上的一些深层次属性放到 vm.$options 选项中,以提高代码的执行效率

  • 初始化组件实例的关系属性,比如 $parent、$children、$root、$refs 等

  • 处理自定义事件

  • 调用 beforeCreate 钩子函数

  • 初始化组件的 inject 配置项,得到 ret[key] = val 形式的配置对象,然后对该配置对象进行浅层的响应式处理(只处理了对象第一层数据),并代理每个 key 到 vm 实例上

  • 数据响应式,处理 props、methods、data、computed、watch 等选项

  • 解析组件配置项上的 provide 对象,将其挂载到 vm._provided 属性上

  • 调用 created 钩子函数

  • 如果发现配置项上有 el 选项,则自动调用 $mount 方法,也就是说有了 el 选项,就不需要再手动调用 $mount 方法,反之,没提供 el 选项则必须调用 $mount

  • 接下来则进入挂载阶段

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(4)—— 异步更新

Vue 源码解读(4)—— 异步更新

当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇的 Vue 源码解读(3)—— 响应式原理 说到通过 Object.defineProperty 为对象的每个 key 设置 getter、setter,从而拦截对数据的访问和设置。

当对数据进行更新操作时,比如 obj.key = 'new val' 就会触发 setter 的拦截,从而检测新值和旧值是否相等,如果相等什么也不做,如果不相等,则更新值,然后由 dep 通知 watcher 进行更新。所以,异步更新 的入口点就是 setter 中最后调用的 dep.notify() 方法。

目的

  • 深入理解 Vue 的异步更新机制

  • nextTick 的原理

源码解读

dep.notify

/src/core/observer/dep.js

关于 dep 更加详细的介绍请查看上一篇文章 —— Vue 源码解读(3)—— 响应式原理,这里就不占用篇幅了。

/**
 * 通知 dep 中的所有 watcher,执行 watcher.update() 方法
 */
notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  // 遍历 dep 中存储的 watcher,执行 watcher.update()
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

watcher.update

/src/core/observer/watcher.js

/**
 * 根据 watcher 配置项,决定接下来怎么走,一般是 queueWatcher
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    // 懒执行时走这里,比如 computed
    // 将 dirty 置为 true,可以让 computedGetter 执行时重新计算 computed 回调函数的执行结果
    this.dirty = true
  } else if (this.sync) {
    // 同步执行,在使用 vm.$watch 或者 watch 选项时可以传一个 sync 选项,
    // 当为 true 时在数据更新时该 watcher 就不走异步更新队列,直接执行 this.run 
    // 方法进行更新
    // 这个属性在官方文档中没有出现
    this.run()
  } else {
    // 更新时一般都这里,将 watcher 放入 watcher 队列
    queueWatcher(this)
  }
}

queueWatcher

/src/core/observer/scheduler.js

/**
 * 将 watcher 放入 watcher 队列 
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 如果 watcher 已经存在,则跳过,不会重复入队
  if (has[id] == null) {
    // 缓存 watcher.id,用于判断 watcher 是否已经入队
    has[id] = true
    if (!flushing) {
      // 当前没有处于刷新队列状态,watcher 直接入队
      queue.push(watcher)
    } else {
      // 已经在刷新队列了
      // 从队列末尾开始倒序遍历,根据当前 watcher.id 找到它大于的 watcher.id 的位置,然后将自己插入到该位置之后的下一个位置
      // 即将当前 watcher 放入已排序的队列中,且队列仍是有序的
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        // 直接刷新调度队列
        // 一般不会走这儿,Vue 默认是异步执行,如果改为同步执行,性能会大打折扣
        flushSchedulerQueue()
        return
      }
      /**
       * 熟悉的 nextTick => vm.$nextTick、Vue.nextTick
       *   1、将 回调函数(flushSchedulerQueue) 放入 callbacks 数组
       *   2、通过 pending 控制向浏览器任务队列中添加 flushCallbacks 函数
       */
      nextTick(flushSchedulerQueue)
    }
  }
}

nextTick

/src/core/util/next-tick.js

const callbacks = []
let pending = false

/**
 * 完成两件事:
 *   1、用 try catch 包装 flushSchedulerQueue 函数,然后将其放入 callbacks 数组
 *   2、如果 pending 为 false,表示现在浏览器的任务队列中没有 flushCallbacks 函数
 *     如果 pending 为 true,则表示浏览器的任务队列中已经被放入了 flushCallbacks 函数,
 *     待执行 flushCallbacks 函数时,pending 会被再次置为 false,表示下一个 flushCallbacks 函数可以进入
 *     浏览器的任务队列了
 * pending 的作用:保证在同一时刻,浏览器的任务队列中只有一个 flushCallbacks 函数
 * @param {*} cb 接收一个回调函数 => flushSchedulerQueue
 * @param {*} ctx 上下文
 * @returns 
 */
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 用 callbacks 数组存储经过包装的 cb 函数
  callbacks.push(() => {
    if (cb) {
      // 用 try catch 包装回调函数,便于错误捕获
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 执行 timerFunc,在浏览器的任务队列中(首选微任务队列)放入 flushCallbacks 函数
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

timerFunc

/src/core/util/next-tick.js

// 可以看到 timerFunc 的作用很简单,就是将 flushCallbacks 函数放入浏览器的异步任务队列中
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  // 首选 Promise.resolve().then()
  timerFunc = () => {
    // 在 微任务队列 中放入 flushCallbacks 函数
    p.then(flushCallbacks)
    /**
     * 在有问题的UIWebViews中,Promise.then不会完全中断,但是它可能会陷入怪异的状态,
     * 在这种状态下,回调被推入微任务队列,但队列没有被刷新,直到浏览器需要执行其他工作,例如处理一个计时器。
     * 因此,我们可以通过添加空计时器来“强制”刷新微任务队列。
     */
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // MutationObserver 次之
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 再就是 setImmediate,它其实已经是一个宏任务了,但仍然比 setTimeout 要好
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 最后没办法,则使用 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

flushCallbacks

/src/core/util/next-tick.js

const callbacks = []
let pending = false

/**
 * 做了三件事:
 *   1、将 pending 置为 false
 *   2、清空 callbacks 数组
 *   3、执行 callbacks 数组中的每一个函数(比如 flushSchedulerQueue、用户调用 nextTick 传递的回调函数)
 */
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 遍历 callbacks 数组,执行其中存储的每个 flushSchedulerQueue 函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

flushSchedulerQueue

/src/core/observer/scheduler.js

/**
 * Flush both queues and run the watchers.
 * 刷新队列,由 flushCallbacks 函数负责调用,主要做了如下两件事:
 *   1、更新 flushing 为 ture,表示正在刷新队列,在此期间往队列中 push 新的 watcher 时需要特殊处理(将其放在队列的合适位置)
 *   2、按照队列中的 watcher.id 从小到大排序,保证先创建的 watcher 先执行,也配合 第一步
 *   3、遍历 watcher 队列,依次执行 watcher.before、watcher.run,并清除缓存的 watcher
 */
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  // 标志现在正在刷新队列
  flushing = true
  let watcher, id

  /**
   * 刷新队列之前先给队列排序(升序),可以保证:
   *   1、组件的更新顺序为从父级到子级,因为父组件总是在子组件之前被创建
   *   2、一个组件的用户 watcher 在其渲染 watcher 之前被执行,因为用户 watcher 先于 渲染 watcher 创建
   *   3、如果一个组件在其父组件的 watcher 执行期间被销毁,则它的 watcher 可以被跳过
   * 排序以后在刷新队列期间新进来的 watcher 也会按顺序放入队列的合适位置
   */
  queue.sort((a, b) => a.id - b.id)

  // 这里直接使用了 queue.length,动态计算队列的长度,没有缓存长度,是因为在执行现有 watcher 期间队列中可能会被 push 进新的 watcher
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    // 执行 before 钩子,在使用 vm.$watch 或者 watch 选项时可以通过配置项(options.before)传递
    if (watcher.before) {
      watcher.before()
    }
    // 将缓存的 watcher 清除
    id = watcher.id
    has[id] = null

    // 执行 watcher.run,最终触发更新函数,比如 updateComponent 或者 获取 this.xx(xx 为用户 watch 的第二个参数),当然第二个参数也有可能是一个函数,那就直接执行
    watcher.run()
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  /**
   * 重置调度状态:
   *   1、重置 has 缓存对象,has = {}
   *   2、waiting = flushing = false,表示刷新队列结束
   *     waiting = flushing = false,表示可以像 callbacks 数组中放入新的 flushSchedulerQueue 函数,并且可以向浏览器的任务队列放入下一个 flushCallbacks 函数了
   */
  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

/**
 * Reset the scheduler's state.
 */
function resetSchedulerState () {
  index = queue.length = activatedChildren.length = 0
  has = {}
  if (process.env.NODE_ENV !== 'production') {
    circular = {}
  }
  waiting = flushing = false
}

watcher.run

/src/core/observer/watcher.js

/**
 * 由 刷新队列函数 flushSchedulerQueue 调用,如果是同步 watch,则由 this.update 直接调用,完成如下几件事:
 *   1、执行实例化 watcher 传递的第二个参数,updateComponent 或者 获取 this.xx 的一个函数(parsePath 返回的函数)
 *   2、更新旧值为新值
 *   3、执行实例化 watcher 时传递的第三个参数,比如用户 watcher 的回调函数
 */
run () {
  if (this.active) {
    // 调用 this.get 方法
    const value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // 更新旧值为新值
      const oldValue = this.value
      this.value = value

      if (this.user) {
        // 如果是用户 watcher,则执行用户传递的第三个参数 —— 回调函数,参数为 val 和 oldVal
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        // 渲染 watcher,this.cb = noop,一个空函数
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

watcher.get

/src/core/observer/watcher.js

  /**
   * 执行 this.getter,并重新收集依赖
   * this.getter 是实例化 watcher 时传递的第二个参数,一个函数或者字符串,比如:updateComponent 或者 parsePath 返回的函数
   * 为什么要重新收集依赖?
   *   因为触发更新说明有响应式数据被更新了,但是被更新的数据虽然已经经过 observe 观察了,但是却没有进行依赖收集,
   *   所以,在更新页面时,会重新执行一次 render 函数,执行期间会触发读取操作,这时候进行依赖收集
   */
  get () {
    // 打开 Dep.target,Dep.target = this
    pushTarget(this)
    // value 为回调函数执行的结果
    let value
    const vm = this.vm
    try {
      // 执行回调函数,比如 updateComponent,进入 patch 阶段
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      // 关闭 Dep.target,Dep.target = null
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

以上就是 Vue 异步更新机制的整个执行过程。

总结

  • 面试官 问:Vue 的异步更新机制是如何实现的?

    Vue 的异步更新机制的核心是利用了浏览器的异步任务队列来实现的,首选微任务队列,宏任务队列次之。

    当响应式数据更新后,会调用 dep.notify 方法,通知 dep 中收集的 watcher 去执行 update 方法,watcher.update 将 watcher 自己放入一个 watcher 队列(全局的 queue 数组)。

    然后通过 nextTick 方法将一个刷新 watcher 队列的方法(flushSchedulerQueue)放入一个全局的 callbacks 数组中。

    如果此时浏览器的异步任务队列中没有一个叫 flushCallbacks 的函数,则执行 timerFunc 函数,将 flushCallbacks 函数放入异步任务队列。如果异步任务队列中已经存在 flushCallbacks 函数,等待其执行完成以后再放入下一个 flushCallbacks 函数。

    flushCallbacks 函数负责执行 callbacks 数组中的所有 flushSchedulerQueue 函数。

    flushSchedulerQueue 函数负责刷新 watcher 队列,即执行 queue 数组中每一个 watcher 的 run 方法,从而进入更新阶段,比如执行组件更新函数或者执行用户 watch 的回调函数。

    完整的执行过程其实就是今天源码阅读的过程。


面试关 问:Vue 的 nextTick API 是如何实现的?

Vue.nextTick 或者 vm.$nextTick 的原理其实很简单,就做了两件事:

  • 将传递的回调函数用 try catch 包裹然后放入 callbacks 数组

  • 执行 timerFunc 函数,在浏览器的异步任务队列放入一个刷新 callbacks 数组的函数

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Vue 源码解读(5)—— 全局 API

Vue 源码解读(5)—— 全局 API

当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

目标

深入理解以下全局 API 的实现原理。

  • Vue.use

  • Vue.mixin

  • Vue.component

  • Vue.filter

  • Vue.directive

  • Vue.extend

  • Vue.set

  • Vue.delete

  • Vue.nextTick

源码解读

从该系列的第一篇文章 Vue 源码解读(1)—— 前言 中的 源码目录结构 介绍中可以得知,Vue 的众多全局 API 的实现大部分都放在 /src/core/global-api 目录下。这些全局 API 源码阅读入口则是在 /src/core/global-api/index.js 文件中。

入口

/src/core/global-api/index.js

/**
 * 初始化 Vue 的众多全局 API,比如:
 *   默认配置:Vue.config
 *   工具方法:Vue.util.xx
 *   Vue.set、Vue.delete、Vue.nextTick、Vue.observable
 *   Vue.options.components、Vue.options.directives、Vue.options.filters、Vue.options._base
 *   Vue.use、Vue.extend、Vue.mixin、Vue.component、Vue.directive、Vue.filter
 *   
 */
export function initGlobalAPI (Vue: GlobalAPI) {
  // config
  const configDef = {}
  // Vue 的众多默认配置项
  configDef.get = () => config

  if (process.env.NODE_ENV !== 'production') {
    configDef.set = () => {
      warn(
        'Do not replace the Vue.config object, set individual fields instead.'
      )
    }
  }

  // Vue.config
  Object.defineProperty(Vue, 'config', configDef)

  /**
   * 暴露一些工具方法,轻易不要使用这些工具方法,处理你很清楚这些工具方法,以及知道使用的风险
   */
  Vue.util = {
    // 警告日志
    warn,
    // 类似选项合并
    extend,
    // 合并选项
    mergeOptions,
    // 设置响应式
    defineReactive
  }

  // Vue.set / delete / nextTick
  Vue.set = set
  Vue.delete = del
  Vue.nextTick = nextTick

  // 响应式方法
  Vue.observable = <T>(obj: T): T => {
    observe(obj)
    return obj
  }

  // Vue.options.compoents/directives/filter
  Vue.options = Object.create(null)
  ASSET_TYPES.forEach(type => {
    Vue.options[type + 's'] = Object.create(null)
  })

  // 将 Vue 构造函数挂载到 Vue.options._base 上
  Vue.options._base = Vue

  // 在 Vue.options.components 中添加内置组件,比如 keep-alive
  extend(Vue.options.components, builtInComponents)

  // Vue.use
  initUse(Vue)
  // Vue.mixin
  initMixin(Vue)
  // Vue.extend
  initExtend(Vue)
  // Vue.component/directive/filter
  initAssetRegisters(Vue)
}

Vue.use

/src/core/global-api/use.js

/**
 * 定义 Vue.use,负责为 Vue 安装插件,做了以下两件事:
 *   1、判断插件是否已经被安装,如果安装则直接结束
 *   2、安装插件,执行插件的 install 方法
 * @param {*} plugin install 方法 或者 包含 install 方法的对象
 * @returns Vue 实例
 */
Vue.use = function (plugin: Function | Object) {
  // 已经安装过的插件列表
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  // 判断 plugin 是否已经安装,保证不重复安装
  if (installedPlugins.indexOf(plugin) > -1) {
    return this
  }

  // 将 Vue 构造函数放到第一个参数位置,然后将这些参数传递给 install 方法
  const args = toArray(arguments, 1)
  args.unshift(this)

  if (typeof plugin.install === 'function') {
    // plugin 是一个对象,则执行其 install 方法安装插件
    plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') {
    // 执行直接 plugin 方法安装插件
    plugin.apply(null, args)
  }
  // 在 插件列表中 添加新安装的插件
  installedPlugins.push(plugin)
  return this
}

Vue.mixin

/src/core/global-api/mixin.js

/**
 * 定义 Vue.mixin,负责全局混入选项,影响之后所有创建的 Vue 实例,这些实例会合并全局混入的选项
 * @param {*} mixin Vue 配置对象
 * @returns 返回 Vue 实例
 */
Vue.mixin = function (mixin: Object) {
  // 在 Vue 的默认配置项上合并 mixin 对象
  this.options = mergeOptions(this.options, mixin)
  return this
}

mergeOptions

src/core/util/options.js

/**
 * 合并两个选项,出现相同配置项时,子选项会覆盖父选项的配置
 */
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child)
  }

  if (typeof child === 'function') {
    child = child.options
  }

  // 标准化 props、inject、directive 选项,方便后续程序的处理
  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  // 处理原始 child 对象上的 extends 和 mixins,分别执行 mergeOptions,将这些继承而来的选项合并到 parent
  // mergeOptions 处理过的对象会含有 _base 属性
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  const options = {}
  let key
  // 遍历 父选项
  for (key in parent) {
    mergeField(key)
  }

  // 遍历 子选项,如果父选项不存在该配置,则合并,否则跳过,因为父子拥有同一个属性的情况在上面处理父选项时已经处理过了,用的子选项的值
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }

  // 合并选项,childVal 优先级高于 parentVal
  function mergeField (key) {
    // strat 是合并策略函数,如何 key 冲突,则 childVal 会 覆盖 parentVal
    const strat = strats[key] || defaultStrat
    // 值为如果 childVal 存在则优先使用 childVal,否则使用 parentVal
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

Vue.component、Vue.filter、Vue.directive

/src/core/global-api/assets.js

这三个 API 实现比较特殊,但是原理又很相似,所以就放在了一起实现。

const ASSET_TYPES = ['component', 'directive', 'filter']

/**
 * 定义 Vue.component、Vue.filter、Vue.directive 这三个方法
 * 这三个方法所做的事情是类似的,就是在 this.options.xx 上存放对应的配置
 * 比如 Vue.component(compName, {xx}) 结果是 this.options.components.compName = 组件构造函数
 * ASSET_TYPES = ['component', 'directive', 'filter']
 */
ASSET_TYPES.forEach(type => {
  /**
   * 比如:Vue.component(name, definition)
   * @param {*} id name
   * @param {*} definition 组件构造函数或者配置对象 
   * @returns 返回组件构造函数
   */
  Vue[type] = function (
    id: string,
    definition: Function | Object
  ): Function | Object | void {
    if (!definition) {
      return this.options[type + 's'][id]
    } else {
      if (type === 'component' && isPlainObject(definition)) {
        // 如果组件配置中存在 name,则使用,否则直接使用 id
        definition.name = definition.name || id
        // extend 就是 Vue.extend,所以这时的 definition 就变成了 组件构造函数,使用时可直接 new Definition()
        definition = this.options._base.extend(definition)
      }
      if (type === 'directive' && typeof definition === 'function') {
        definition = { bind: definition, update: definition }
      }
      // this.options.components[id] = definition
      // 在实例化时通过 mergeOptions 将全局注册的组件合并到每个组件的配置对象的 components 中
      this.options[type + 's'][id] = definition
      return definition
    }
  }
})

Vue.extend

/src/core/global-api/extend.js

/**
 * Each instance constructor, including Vue, has a unique
 * cid. This enables us to create wrapped "child
 * constructors" for prototypal inheritance and cache them.
 */
Vue.cid = 0
let cid = 1

/**
 * 基于 Vue 去扩展子类,该子类同样支持进一步的扩展
 * 扩展时可以传递一些默认配置,就像 Vue 也会有一些默认配置
 * 默认配置如果和基类有冲突则会进行选项合并(mergeOptions)
 */
Vue.extend = function (extendOptions: Object): Function {
  extendOptions = extendOptions || {}
  const Super = this
  const SuperId = Super.cid

  /**
   * 利用缓存,如果存在则直接返回缓存中的构造函数
   * 什么情况下可以利用到这个缓存?
   *   如果你在多次调用 Vue.extend 时使用了同一个配置项(extendOptions),这时就会启用该缓存
   */
  const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
  if (cachedCtors[SuperId]) {
    return cachedCtors[SuperId]
  }

  const name = extendOptions.name || Super.options.name
  if (process.env.NODE_ENV !== 'production' && name) {
    validateComponentName(name)
  }

  // 定义 Sub 构造函数,和 Vue 构造函数一样
  const Sub = function VueComponent(options) {
    // 初始化
    this._init(options)
  }
  // 通过原型继承的方式继承 Vue
  Sub.prototype = Object.create(Super.prototype)
  Sub.prototype.constructor = Sub
  Sub.cid = cid++
  // 选项合并,合并 Vue 的配置项到 自己的配置项上来
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  )
  // 记录自己的基类
  Sub['super'] = Super

  // 初始化 props,将 props 配置代理到 Sub.prototype._props 对象上
  // 在组件内通过 this._props 方式可以访问
  if (Sub.options.props) {
    initProps(Sub)
  }

  // 初始化 computed,将 computed 配置代理到 Sub.prototype 对象上
  // 在组件内可以通过 this.computedKey 的方式访问
  if (Sub.options.computed) {
    initComputed(Sub)
  }

  // 定义 extend、mixin、use 这三个静态方法,允许在 Sub 基础上再进一步构造子类
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use

  // 定义 component、filter、directive 三个静态方法
  ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
  })

  // 递归组件的原理,如果组件设置了 name 属性,则将自己注册到自己的 components 选项中
  if (name) {
    Sub.options.components[name] = Sub
  }

  // 在扩展时保留对基类选项的引用。
  // 稍后在实例化时,我们可以检查 Super 的选项是否具有更新
  Sub.superOptions = Super.options
  Sub.extendOptions = extendOptions
  Sub.sealedOptions = extend({}, Sub.options)

  // 缓存
  cachedCtors[SuperId] = Sub
  return Sub
}

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

function initComputed (Comp) {
  const computed = Comp.options.computed
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key])
  }
}

Vue.set

/src/core/global-api/index.js

Vue.set = set

set

/src/core/observer/index.js

/**
 * 通过 Vue.set 或者 this.$set 方法给 target 的指定 key 设置值 val
 * 如果 target 是对象,并且 key 原本不存在,则为新 key 设置响应式,然后执行依赖通知
 */
export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  // 更新数组指定下标的元素,Vue.set(array, idx, val),通过 splice 方法实现响应式更新
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 更新对象已有属性,Vue.set(obj, key, val),执行更新即可
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  // 不能向 Vue 实例或者 $data 添加动态添加响应式属性,vmCount 的用处之一,
  // this.$data 的 ob.vmCount = 1,表示根组件,其它子组件的 vm.vmCount 都是 0
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // target 不是响应式对象,新属性会被设置,但是不会做响应式处理
  if (!ob) {
    target[key] = val
    return val
  }
  // 给对象定义新属性,通过 defineReactive 方法设置响应式,并触发依赖更新
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

Vue.delete

/src/core/global-api/index.js

Vue.delete = del

del

/src/core/observer/index.js

/**
 * 通过 Vue.delete 或者 vm.$delete 删除 target 对象的指定 key
 * 数组通过 splice 方法实现,对象则通过 delete 运算符删除指定 key,并执行依赖通知
 */
export function del (target: Array<any> | Object, key: any) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }

  // target 为数组,则通过 splice 方法删除指定下标的元素
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__

  // 避免删除 Vue 实例的属性或者 $data 的数据
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  // 如果属性不存在直接结束
  if (!hasOwn(target, key)) {
    return
  }
  // 通过 delete 运算符删除对象的属性
  delete target[key]
  if (!ob) {
    return
  }
  // 执行依赖通知
  ob.dep.notify()
}

Vue.nextTick

/src/core/global-api/index.js

Vue.nextTick = nextTick

nextTick

/src/core/util/next-tick.js

关于 nextTick 方法更加详细解析,可以查看上一篇文章 Vue 源码解读(4)—— 异步更新

const callbacks = []
/**
 * 完成两件事:
 *   1、用 try catch 包装 flushSchedulerQueue 函数,然后将其放入 callbacks 数组
 *   2、如果 pending 为 false,表示现在浏览器的任务队列中没有 flushCallbacks 函数
 *     如果 pending 为 true,则表示浏览器的任务队列中已经被放入了 flushCallbacks 函数,
 *     待执行 flushCallbacks 函数时,pending 会被再次置为 false,表示下一个 flushCallbacks 函数可以进入
 *     浏览器的任务队列了
 * pending 的作用:保证在同一时刻,浏览器的任务队列中只有一个 flushCallbacks 函数
 * @param {*} cb 接收一个回调函数 => flushSchedulerQueue
 * @param {*} ctx 上下文
 * @returns 
 */
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 用 callbacks 数组存储经过包装的 cb 函数
  callbacks.push(() => {
    if (cb) {
      // 用 try catch 包装回调函数,便于错误捕获
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 执行 timerFunc,在浏览器的任务队列中(首选微任务队列)放入 flushCallbacks 函数
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

总结

  • 面试官 问:Vue.use(plugin) 做了什么?

    负责安装 plugin 插件,其实就是执行插件提供的 install 方法。

    • 首先判断该插件是否已经安装过

    • 如果没有,则执行插件提供的 install 方法安装插件,具体做什么有插件自己决定


  • 面试官 问:Vue.mixin(options) 做了什么?

    负责在 Vue 的全局配置上合并 options 配置。然后在每个组件生成 vnode 时会将全局配置合并到组件自身的配置上来。

    • 标准化 options 对象上的 props、inject、directive 选项的格式

    • 处理 options 上的 extends 和 mixins,分别将他们合并到全局配置上

    • 然后将 options 配置和全局配置进行合并,选项冲突时 options 配置会覆盖全局配置


  • 面试官 问:Vue.component(compName, Comp) 做了什么?

    负责注册全局组件。其实就是将组件配置注册到全局配置的 components 选项上(options.components),然后各个子组件在生成 vnode 时会将全局的 components 选项合并到局部的 components 配置项上。

    • 如果第二个参数为空,则表示获取 compName 的组件构造函数

    • 如果 Comp 是组件配置对象,则使用 Vue.extend 方法得到组件构造函数,否则直接进行下一步

    • 在全局配置上设置组件信息,this.options.components.compName = CompConstructor


  • 面试官 问:Vue.directive('my-directive', {xx}) 做了什么?

    在全局注册 my-directive 指令,然后每个子组件在生成 vnode 时会将全局的 directives 选项合并到局部的 directives 选项中。原理同 Vue.component 方法:

    • 如果第二个参数为空,则获取指定指令的配置对象

    • 如果不为空,如果第二个参数是一个函数的话,则生成配置对象 { bind: 第二个参数, update: 第二个参数 }

    • 然后将指令配置对象设置到全局配置上,this.options.directives['my-directive'] = {xx}


  • 面试官 问:Vue.filter('my-filter', function(val) {xx}) 做了什么?

    负责在全局注册过滤器 my-filter,然后每个子组件在生成 vnode 时会将全局的 filters 选项合并到局部的 filters 选项中。原理是:

    • 如果没有提供第二个参数,则获取 my-filter 过滤器的回调函数

    • 如果提供了第二个参数,则是设置 this.options.filters['my-filter'] = function(val) {xx}


  • 面试官 问:Vue.extend(options) 做了什么?

    Vue.extend 基于 Vue 创建一个子类,参数 options 会作为该子类的默认全局配置,就像 Vue 的默认全局配置一样。所以通过 Vue.extend 扩展一个子类,一大用处就是内置一些公共配置,供子类的子类使用。

    • 定义子类构造函数,这里和 Vue 一样,也是调用 _init(options)

    • 合并 Vue 的配置和 options,如果选项冲突,则 options 的选项会覆盖 Vue 的配置项

    • 给子类定义全局 API,值为 Vue 的全局 API,比如 Sub.extend = Super.extend,这样子类同样可以扩展出其它子类

    • 返回子类 Sub


  • 面试官 问:Vue.set(target, key, val) 做了什么

    由于 Vue 无法探测普通的新增 property (比如 this.myObject.newProperty = 'hi'),所以通过 Vue.set 为向响应式对象中添加一个 property,可以确保这个新 property 同样是响应式的,且触发视图更新。

    • 更新数组指定下标的元素:Vue.set(array, idx, val),内部通过 splice 方法实现响应式更新

    • 更新对象已有属性:Vue.set(obj, key ,val),直接更新即可 => obj[key] = val

    • 不能向 Vue 实例或者 $data 动态添加根级别的响应式数据

    • Vue.set(obj, key, val),如果 obj 不是响应式对象,会执行 obj[key] = val,但是不会做响应式处理

    • Vue.set(obj, key, val),为响应式对象 obj 增加一个新的 key,则通过 defineReactive 方法设置响应式,并触发依赖更新


  • 面试官 问:Vue.delete(target, key) 做了什么?

    删除对象的 property。如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开 Vue 不能检测到 property 被删除的限制,但是你应该很少会使用它。当然同样不能删除根级别的响应式属性。

    • Vue.delete(array, idx),删除指定下标的元素,内部是通过 splice 方法实现的

    • 删除响应式对象上的某个属性:Vue.delete(obj, key),内部是执行 delete obj.key,然后执行依赖更新即可


  • 面试官 问:Vue.nextTick(cb) 做了什么?

    Vue.nextTick(cb) 方法的作用是延迟回调函数 cb 的执行,一般用于 this.key = newVal 更改数据后,想立即获取更改过后的 DOM 数据:

    this.key = 'new val'
    
    Vue.nextTick(function() {
      // DOM 更新了
    })

    其内部的执行过程是:

    • this.key = 'new val,触发依赖通知更新,将负责更新的 watcher 放入 watcher 队列

    • 将刷新 watcher 队列的函数放到 callbacks 数组中

    • 在浏览器的异步任务队列中放入一个刷新 callbacks 数组的函数

    • Vue.nextTick(cb) 来插队,将 cb 函数放入 callbacks 数组

    • 待将来的某个时刻执行刷新 callbacks 数组的函数

    • 然后执行 callbacks 数组中的众多函数,触发 watcher.run 的执行,更新 DOM

    • 由于 cb 函数是在后面放到 callbacks 数组,所以这就保证了先完成的 DOM 更新,再执行 cb 函数

链接

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

如何快速为团队打造自己的组件库(上)—— Element 源码架构

如何快速为团队打造自己的组件库(上)—— Element 源码架构

当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

封面

element-ui

简介

详细讲解了 ElementUI 的源码架构,为下一步基于 ElementUI 打造团队自己的组件库打好坚实的基础。

如何快速为团队打造自己的组件库?

组件库是现代前端领域中不可缺少的一项基建。它可以提高代码的复用性、可维护性,提高团队的生产效率,更好的服务于未来。

那么如何为团队打造自己的组件库呢? 最理想的方案是借用社区的能力,去裁剪一个优秀的开源库,只保留你需要的东西,比如它的架构、工程化和文档能力,以及部分基础组件,在裁剪的过程中你可能会发现它的一些问题,然后在你的组件库中去优化并解决。

Element 源码架构

因为团队的技术栈是 Vue,所以选择基于 element 进行二次开发,在开始前先对 element 框架源码进行详细的刨析,为打造组件库做知识储备。element 框架源码由工程化、官网、组件库、测试和类型声明这 5 部分组成。

工程化

element 的架构是真的优秀,通过大量的脚本实现优秀的工程化,致力于让组件库的开发者专注于事情本身。比如添加新组件时,一键生成组件所有文件并完成这些文件基本结构的编写和相关的引入配置,总共涉及 13 个文件的添加和改动,而你只需完成组件定义这件事即可。element 的工程化由 5 部分组成:build 目录下的工程化配置和脚本、eslint、travis ci、Makefile、package.json 的 scripts。

build

build 目录存放工程化相关配置和脚本。比如 /build/bin 目录下的 JS 脚本让组件库开发者专注于组件的开发,除此之外不需要管其他任何事情;build/md-loader 是官网组件页面根据 markdown 实现组件 demo + 文档 的关键;还有比如持续集成、webpack 配置等,接下来就详细介绍这些配置和脚本。

/build/bin/build-entry.js

组件配置文件(components.json)结合字符串模版库,自动生成 /src/index.js 文件,避免每次新增组件时手动在 /src/index.js 中引入和导出组件。

/**
 * 生成 /src/index.js
 *  1、自动导入组件库所有组件
 *  2、定义全量注册组件库组件的 install 方法
 *  3、导出版本、install、各个组件
 */

//  key 为包名、路径为值
var Components = require('../../components.json');
var fs = require('fs');
// 模版库
var render = require('json-templater/string');
// 负责将 comp-name 形式的字符串转换为 CompName
var uppercamelcase = require('uppercamelcase');
var path = require('path');
var endOfLine = require('os').EOL;

// 输出路径 /src/index.js
var OUTPUT_PATH = path.join(__dirname, '../../src/index.js');
// 导入模版,import CompName from '../packages/comp-name/index.js'
var IMPORT_TEMPLATE = 'import {{name}} from \'../packages/{{package}}/index.js\';';
// ' CompName'
var INSTALL_COMPONENT_TEMPLATE = '  {{name}}';
// /src/index.js 的模版
var MAIN_TEMPLATE = `/* Automatically generated by './build/bin/build-entry.js' */

{{include}}
import locale from 'element-ui/src/locale';
import CollapseTransition from 'element-ui/src/transitions/collapse-transition';

const components = [
{{install}},
  CollapseTransition
];

const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);

  components.forEach(component => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;

};

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}

export default {
  version: '{{version}}',
  locale: locale.use,
  i18n: locale.i18n,
  install,
  CollapseTransition,
  Loading,
{{list}}
};
`;

delete Components.font;

// 得到所有的包名,[comp-name1, comp-name2]
var ComponentNames = Object.keys(Components);

// 存放所有的 import 语句
var includeComponentTemplate = [];
// 组件名数组
var installTemplate = [];
// 组件名数组
var listTemplate = [];

// 遍历所有的包名
ComponentNames.forEach(name => {
  // 将连字符格式的包名转换成大驼峰形式,就是组件名,比如 form-item =》 FormItem
  var componentName = uppercamelcase(name);

  // 替换导入语句中的模版变量,生成导入语句,import FromItem from '../packages/form-item/index.js'
  includeComponentTemplate.push(render(IMPORT_TEMPLATE, {
    name: componentName,
    package: name
  }));

  // 这些组件从 components 数组中剔除,不需要全局注册,采用挂载到原型链的方式,在模版字符串的 install 方法中有写
  if (['Loading', 'MessageBox', 'Notification', 'Message', 'InfiniteScroll'].indexOf(componentName) === -1) {
    installTemplate.push(render(INSTALL_COMPONENT_TEMPLATE, {
      name: componentName,
      component: name
    }));
  }

  // 将所有的组件放到 listTemplates,最后导出
  if (componentName !== 'Loading') listTemplate.push(`  ${componentName}`);
});

// 替换模版中的四个变量
var template = render(MAIN_TEMPLATE, {
  include: includeComponentTemplate.join(endOfLine),
  install: installTemplate.join(',' + endOfLine),
  version: process.env.VERSION || require('../../package.json').version,
  list: listTemplate.join(',' + endOfLine)
});

// 将就绪的模版写入 /src/index.js
fs.writeFileSync(OUTPUT_PATH, template);
console.log('[build entry] DONE:', OUTPUT_PATH);
/build/bin/build-locale.js

通过 babel 将 ES Module 风格的所有翻译文件(/src/locale/lang)转译成 UMD 风格。

/**
 * 通过 babel 将 ES Module 风格的翻译文件转译成 UMD 风格
 */
var fs = require('fs');
var save = require('file-save');
var resolve = require('path').resolve;
var basename = require('path').basename;

// 翻译文件目录,这些文件用于官网
var localePath = resolve(__dirname, '../../src/locale/lang');
// 得到目录下的所有翻译文件
var fileList = fs.readdirSync(localePath);

// 转换函数
var transform = function(filename, name, cb) {
  require('babel-core').transformFile(resolve(localePath, filename), {
    plugins: [
      'add-module-exports',
      ['transform-es2015-modules-umd', {loose: true}]
    ],
    moduleId: name
  }, cb);
};

// 遍历所有文件
fileList
  // 只处理 js 文件,其实目录下不存在非 js 文件
  .filter(function(file) {
    return /\.js$/.test(file);
  })
  .forEach(function(file) {
    var name = basename(file, '.js');

    // 调用转换函数,将转换后的代码写入到 lib/umd/locale 目录下
    transform(file, name, function(err, result) {
      if (err) {
        console.error(err);
      } else {
        var code = result.code;

        code = code
          .replace('define(\'', 'define(\'element/locale/')
          .replace('global.', 'global.ELEMENT.lang = global.ELEMENT.lang || {}; \n    global.ELEMENT.lang.');
        save(resolve(__dirname, '../../lib/umd/locale', file)).write(code);

        console.log(file);
      }
    });
  });
/build/bin/gen-cssfile.js

自动在 /packages/theme-chalk/src/index.scss|css 中引入各个组件包的样式,在全量注册组件库时需要用到这个样式文件,即 import 'packages/theme-chalk/src/index.scss

/**
 * 自动在 /packages/theme-chalk/src/index.scss|css 中引入各个组件包的样式
 * 在全量注册组件库时需要用到该样式文件,即 import 'packages/theme-chalk/src/index.scss
 */
var fs = require('fs');
var path = require('path');
var Components = require('../../components.json');
var themes = [
  'theme-chalk'
];
// 得到所有的包名
Components = Object.keys(Components);
// 所有组件包的基础路径,/packages
var basepath = path.resolve(__dirname, '../../packages/');

// 判断指定文件是否存在
function fileExists(filePath) {
  try {
    return fs.statSync(filePath).isFile();
  } catch (err) {
    return false;
  }
}

// 遍历所有组件包,生成引入所有组件包样式的 import 语句,然后自动生成 packages/theme-chalk/src/index.scss|css 文件
themes.forEach((theme) => {
  // 是否是 scss,element-ui 默认使用 scss 编写样式
  var isSCSS = theme !== 'theme-default';
  // 导入基础样式文件 @import "./base.scss|css";\n
  var indexContent = isSCSS ? '@import "./base.scss";\n' : '@import "./base.css";\n';
  // 遍历所有组件包,并生成 @import "./comp-package.scss|css";\n
  Components.forEach(function(key) {
    // 跳过这三个组件包
    if (['icon', 'option', 'option-group'].indexOf(key) > -1) return;
    // comp-package.scss|css
    var fileName = key + (isSCSS ? '.scss' : '.css');
    // 导入语句,@import "./comp-package.scss|css";\n
    indexContent += '@import "./' + fileName + '";\n';
    // 如果该组件包的样式文件不存在,比如 /packages/form-item/theme-chalk/src/form-item.scss 不存在,则认为其被遗漏了,创建该文件
    var filePath = path.resolve(basepath, theme, 'src', fileName);
    if (!fileExists(filePath)) {
      fs.writeFileSync(filePath, '', 'utf8');
      console.log(theme, ' 创建遗漏的 ', fileName, ' 文件');
    }
  });
  // 生成 /packages/theme-chalk/src/index.scss|css,负责引入所有组件包的样式
  fs.writeFileSync(path.resolve(basepath, theme, 'src', isSCSS ? 'index.scss' : 'index.css'), indexContent);
});
/build/bin/i18n.js

根据模版(/examples/pages/template)生成四种语言的官网页面的 .vue 文件。

'use strict';

var fs = require('fs');
var path = require('path');
// 官网页面翻译配置,内置了四种语言
var langConfig = require('../../examples/i18n/page.json');

// 遍历所有语言
langConfig.forEach(lang => {
  // 创建 /examples/pages/{lang},比如: /examples/pages/zh-CN
  try {
    fs.statSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`));
  } catch (e) {
    fs.mkdirSync(path.resolve(__dirname, `../../examples/pages/${ lang.lang }`));
  }

  // 遍历所有的页面,根据 page.tpl 自动生成对应语言的 .vue 文件
  Object.keys(lang.pages).forEach(page => {
    // 比如 /examples/pages/template/index.tpl
    var templatePath = path.resolve(__dirname, `../../examples/pages/template/${ page }.tpl`);
    // /examples/pages/zh-CN/index.vue
    var outputPath = path.resolve(__dirname, `../../examples/pages/${ lang.lang }/${ page }.vue`);
    // 读取模版文件
    var content = fs.readFileSync(templatePath, 'utf8');
    // 读取 index 页面的所有键值对的配置
    var pairs = lang.pages[page];

    // 遍历这些键值对,通过正则匹配的方式替换掉模版中对应的 key
    Object.keys(pairs).forEach(key => {
      content = content.replace(new RegExp(`<%=\\s*${ key }\\s*>`, 'g'), pairs[key]);
    });

    // 将替换后的内容写入 vue 文件
    fs.writeFileSync(outputPath, content);
  });
});
/build/bin/iconInit.js

根据 icon.scss 样式文件中的选择器,通过正则匹配的方式,匹配出所有的 icon 名称,然后将这些 icon 名组成数组,将数组写入到 /examples/icon.json 文件中,该文件在官网的 icon 图标页用来自动生成所有的 icon 图标。

'use strict';

/**
 * 根据 icon.scss 样式文件中的选择器,通过正则匹配的方式,匹配出所有的 icon 名称,
 * 然后将所有 icon 名组成的数组写入到 /examples/icon.json 文件中
 * 该文件在官网的 icon 图标页用来自动生成所有的 icon 图标
 */
var postcss = require('postcss');
var fs = require('fs');
var path = require('path');
// icon.scss 文件内容
var fontFile = fs.readFileSync(path.resolve(__dirname, '../../packages/theme-chalk/src/icon.scss'), 'utf8');
// 得到样式节点
var nodes = postcss.parse(fontFile).nodes;
var classList = [];

// 遍历所有的样式节点
nodes.forEach((node) => {
  // 从选择器中匹配出 icon 名称,比如 el-icon-add,匹配得到 add
  var selector = node.selector || '';
  var reg = new RegExp(/\.el-icon-([^:]+):before/);
  var arr = selector.match(reg);

  // 将 icon 名称写入数组,
  if (arr && arr[1]) {
    classList.push(arr[1]);
  }
});

classList.reverse(); // 希望按 css 文件顺序倒序排列

// 将 icon 名组成的数组写入 /examples/icon.json 文件
fs.writeFile(path.resolve(__dirname, '../../examples/icon.json'), JSON.stringify(classList), () => {});
/build/bin/new-lang.js

为组件库添加新语言,比如 fr(法语),分别为涉及到的文件(components.json、page.json、route.json、nav.config.json、docs)设置该语言的相关配置,具体的配置项默认为英语,你只需要在相应的文件中将这些英文配置项翻译为对应的语言即可。

'use strict';

/**
 * 为组件库添加新语言,比如 fr(法语)
 *  分别为涉及到的文件(components.json、page.json、route.json、nav.config.json、docs)设置该语言的相关配置
 *  具体的配置项默认为英语,你只需要在相应的文件中将这些英文配置项翻译为对应的语言即可
 */

console.log();
process.on('exit', () => {
  console.log();
});

if (!process.argv[2]) {
  console.error('[language] is required!');
  process.exit(1);
}

var fs = require('fs');
const path = require('path');
const fileSave = require('file-save');
const lang = process.argv[2];
// const configPath = path.resolve(__dirname, '../../examples/i18n', lang);

// 添加到 components.json
const componentFile = require('../../examples/i18n/component.json');
if (componentFile.some(item => item.lang === lang)) {
  console.error(`${lang} already exists.`);
  process.exit(1);
}
let componentNew = Object.assign({}, componentFile.filter(item => item.lang === 'en-US')[0], { lang });
componentFile.push(componentNew);
fileSave(path.join(__dirname, '../../examples/i18n/component.json'))
  .write(JSON.stringify(componentFile, null, '  '), 'utf8')
  .end('\n');

// 添加到 page.json
const pageFile = require('../../examples/i18n/page.json');
// 新语言的默认配置为英语,你只需要去 page.json 中将该语言配置中的应为翻译为该语言即可
let pageNew = Object.assign({}, pageFile.filter(item => item.lang === 'en-US')[0], { lang });
pageFile.push(pageNew);
fileSave(path.join(__dirname, '../../examples/i18n/page.json'))
  .write(JSON.stringify(pageFile, null, '  '), 'utf8')
  .end('\n');

// 添加到 route.json
const routeFile = require('../../examples/i18n/route.json');
routeFile.push({ lang });
fileSave(path.join(__dirname, '../../examples/i18n/route.json'))
  .write(JSON.stringify(routeFile, null, '  '), 'utf8')
  .end('\n');

// 添加到 nav.config.json
const navFile = require('../../examples/nav.config.json');
navFile[lang] = navFile['en-US'];
fileSave(path.join(__dirname, '../../examples/nav.config.json'))
  .write(JSON.stringify(navFile, null, '  '), 'utf8')
  .end('\n');

// docs 下新建对应文件夹
try {
  fs.statSync(path.resolve(__dirname, `../../examples/docs/${ lang }`));
} catch (e) {
  fs.mkdirSync(path.resolve(__dirname, `../../examples/docs/${ lang }`));
}

console.log('DONE!');
/build/bin/new.js

为组件库添加新组件时会使用该脚本,一键生成组件所有文件并完成这些文件基本结构的编写和相关的引入配置,总共涉及 13 个文件的添加和改动,比如:make new city 城市列表。该脚本的存在,让你为组件库开发新组件时,只需专注于组件代码的编写即可,其它的一概不用管。

'use strict';

/**
 * 添加新组件
 *  比如:make new city 城市列表
 *  1、在 /packages 目录下新建组件目录,并完成目录结构的创建
 *  2、创建组件文档,/examples/docs/{lang}/city.md
 *  3、创建组件单元测试文件,/test/unit/specs/city.spec.js
 *  4、创建组件样式文件,/packages/theme-chalk/src/city.scss
 *  5、创建组件类型声明文件,/types/city.d.ts
 *  6、配置
 *      在 /components.json 文件中配置组件信息
 *      在 /examples/nav.config.json 中添加该组件的路由配置
 *      在 /packages/theme-chalk/src/index.scss 文件中自动引入该组件的样式文件
 *      将类型声明文件在 /types/element-ui.d.ts 中自动引入
 *  总之,该脚本的存在,让你只需专注于编写你的组件代码,其它的一概不用管
 */

console.log();
process.on('exit', () => {
  console.log();
});

if (!process.argv[2]) {
  console.error('[组件名]必填 - Please enter new component name');
  process.exit(1);
}

const path = require('path');
const fs = require('fs');
const fileSave = require('file-save');
const uppercamelcase = require('uppercamelcase');
// 组件名称,比如 city
const componentname = process.argv[2];
// 组件的中文名称
const chineseName = process.argv[3] || componentname;
// 将组件名称转换为大驼峰形式,city => City
const ComponentName = uppercamelcase(componentname);
// 组件包目录,/packages/city
const PackagePath = path.resolve(__dirname, '../../packages', componentname);
// 需要添加的文件列表和文件内容的基本结构
const Files = [
  // /packages/city/index.js
  {
    filename: 'index.js',
    // 文件内容,引入组件,定义组件静态方法 install 用来注册组件,然后导出组件
    content: `import ${ComponentName} from './src/main';

/* istanbul ignore next */
${ComponentName}.install = function(Vue) {
  Vue.component(${ComponentName}.name, ${ComponentName});
};

export default ${ComponentName};`
  },
  // 定义组件的基本结构,/packages/city/src/main.vue
  {
    filename: 'src/main.vue',
    // 文件内容,sfc
    content: `<template>
  <div class="el-${componentname}"></div>
</template>

<script>
export default {
  name: 'El${ComponentName}'
};
</script>`
  },
  // 四种语言的文档,/examples/docs/{lang}/city.md,并设置文件标题
  {
    filename: path.join('../../examples/docs/zh-CN', `${componentname}.md`),
    content: `## ${ComponentName} ${chineseName}`
  },
  {
    filename: path.join('../../examples/docs/en-US', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../examples/docs/es', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  {
    filename: path.join('../../examples/docs/fr-FR', `${componentname}.md`),
    content: `## ${ComponentName}`
  },
  // 组件测试文件,/test/unit/specs/city.spec.js
  {
    filename: path.join('../../test/unit/specs', `${componentname}.spec.js`),
    // 文件内容,给出测试文件的基本结构
    content: `import { createTest, destroyVM } from '../util';
import ${ComponentName} from 'packages/${componentname}';

describe('${ComponentName}', () => {
  let vm;
  afterEach(() => {
    destroyVM(vm);
  });

  it('create', () => {
    vm = createTest(${ComponentName}, true);
    expect(vm.$el).to.exist;
  });
});
`
  },
  // 组件样式文件,/packages/theme-chalk/src/city.scss
  {
    filename: path.join('../../packages/theme-chalk/src', `${componentname}.scss`),
    // 文件基本结构
    content: `@import "mixins/mixins";
@import "common/var";

@include b(${componentname}) {
}`
  },
  // 组件类型声明文件
  {
    filename: path.join('../../types', `${componentname}.d.ts`),
    // 类型声明文件基本结构
    content: `import { ElementUIComponent } from './component'

/** ${ComponentName} Component */
export declare class El${ComponentName} extends ElementUIComponent {
}`
  }
];

// 将组件添加到 components.json,{ City: './packages/city/index.js' }
const componentsFile = require('../../components.json');
if (componentsFile[componentname]) {
  console.error(`${componentname} 已存在.`);
  process.exit(1);
}
componentsFile[componentname] = `./packages/${componentname}/index.js`;
fileSave(path.join(__dirname, '../../components.json'))
  .write(JSON.stringify(componentsFile, null, '  '), 'utf8')
  .end('\n');

// 将组件样式文件在 index.scss 中引入
const sassPath = path.join(__dirname, '../../packages/theme-chalk/src/index.scss');
const sassImportText = `${fs.readFileSync(sassPath)}@import "./${componentname}.scss";`;
fileSave(sassPath)
  .write(sassImportText, 'utf8')
  .end('\n');

// 将组件的类型声明文件在 element-ui.d.ts 中引入
const elementTsPath = path.join(__dirname, '../../types/element-ui.d.ts');

let elementTsText = `${fs.readFileSync(elementTsPath)}
/** ${ComponentName} Component */
export class ${ComponentName} extends El${ComponentName} {}`;

const index = elementTsText.indexOf('export') - 1;
const importString = `import { El${ComponentName} } from './${componentname}'`;

elementTsText = elementTsText.slice(0, index) + importString + '\n' + elementTsText.slice(index);

fileSave(elementTsPath)
  .write(elementTsText, 'utf8')
  .end('\n');

// 遍历 Files 数组,创建列出的所有文件并写入文件内容
Files.forEach(file => {
  fileSave(path.join(PackagePath, file.filename))
    .write(file.content, 'utf8')
    .end('\n');
});

// 在 nav.config.json 中添加新组件对应的路由配置
const navConfigFile = require('../../examples/nav.config.json');

// 遍历配置中的各个语言,在所有语言配置中都增加该组件的路由配置
Object.keys(navConfigFile).forEach(lang => {
  let groups = navConfigFile[lang][4].groups;
  groups[groups.length - 1].list.push({
    path: `/${componentname}`,
    title: lang === 'zh-CN' && componentname !== chineseName
      ? `${ComponentName} ${chineseName}`
      : ComponentName
  });
});

fileSave(path.join(__dirname, '../../examples/nav.config.json'))
  .write(JSON.stringify(navConfigFile, null, '  '), 'utf8')
  .end('\n');

console.log('DONE!');

这里有个缺点就是,新建组件时不会自动重新生成 /src/index.js,也就是说不会将新生成的组件自动在组件库入口中引入。这也简单,只需要配置下 Makefile 即可,将 new 命令改成 node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS)) && npm run build:file 即可。

/build/bin/template.js

监听 /examples/pages/template 目录下的所有模版文件,当模版文件发生改变时自动执行 npm run i18n,即执行 i18n.js 脚本,重新生成四种语言的 .vue 文件。

/**
 * 监听 /examples/pages/template 目录下的所有模版文件,当模版文件发生改变时自动执行 npm run i18n,
 * 即执行 i18n.js 脚本,重新生成四种语言的 .vue 文件
 */

const path = require('path');
// 监听目录
const templates = path.resolve(process.cwd(), './examples/pages/template');

// 负责监听的库
const chokidar = require('chokidar');
// 监听模板目录
let watcher = chokidar.watch([templates]);

// 当目录下的文件发生改变时,自动执行 npm run i18n
watcher.on('ready', function() {
  watcher
    .on('change', function() {
      exec('npm run i18n');
    });
});

// 负责执行命令
function exec(cmd) {
  return require('child_process').execSync(cmd).toString().trim();
}
/build/bin/version.js

根据 /package.json 文件,自动生成 /examples/version.json,用于记录组件库的版本信息,这些版本洗洗在官网组件页面的头部导航栏会用到。

/**
 * 根据 package.json 自动生成 /examples/version.json,用于记录组件库的版本信息
 * 这些版本信息在官网组件页面的头部导航栏会用到
 */
var fs = require('fs');
var path = require('path');
var version = process.env.VERSION || require('../../package.json').version;
var content = { '1.4.13': '1.4', '2.0.11': '2.0', '2.1.0': '2.1', '2.2.2': '2.2', '2.3.9': '2.3', '2.4.11': '2.4', '2.5.4': '2.5', '2.6.3': '2.6', '2.7.2': '2.7', '2.8.2': '2.8', '2.9.2': '2.9', '2.10.1': '2.10', '2.11.1': '2.11', '2.12.0': '2.12', '2.13.2': '2.13', '2.14.1': '2.14' };
if (!content[version]) content[version] = '2.15';
fs.writeFileSync(path.resolve(__dirname, '../../examples/versions.json'), JSON.stringify(content));
/build/md-loader

它是一个 loader,官网组件页面的 组件 demo + 文档的模式一大半的功劳都是源自于它。

可以在 /examples/route.config.js 中看到 registerRoute 方法生成组件页面的路由配置时,使用 loadDocs 方法加载/examples/docs/{lang}/comp.md 。注意,这里加载的 markdown 文档,而不是平时常见的 vue 文件,但是却能想 vue 文件一样在页面上渲染成一个 Vue 组件,这是怎么做到的呢?

我们知道,webpack 的理念是一切资源都可以 require,只需配置相应的 loader 即可。在 /build/webpack.demo.js 文件中的 module.rules 下可以看到对 markdow(.md) 规则的处理,先通过 md-loader 处理 markdown 文件,从中解析出 vue 代码,然后交给 vue-loader,最终生成 sfc(vue 单文件组件)渲染到页面。这就能看到组件页面的文档 + 组件 demo 展示效果。

{
  test: /\.md$/,
  use: [
    {
      loader: 'vue-loader',
      options: {
        compilerOptions: {
          preserveWhitespace: false
        }
      }
    },
    {
      loader: path.resolve(__dirname, './md-loader/index.js')
    }
  ]
}

如果对 loader 的具体实现感兴趣可以自行深入阅读。

/build/config.js

webpack 的公共配置,比如 externals、alias 等。通过 externals 的配置解决了组件库部分代码的冗余问题,比如组件和组件库公共模块的代码,但是组件样式冗余问题没有得到解决;alias 别名配置为开发组件库提供了方便。

/**
 * webpack 公共配置,比如 externals、alias
 */
var path = require('path');
var fs = require('fs');
var nodeExternals = require('webpack-node-externals');
var Components = require('../components.json');

var utilsList = fs.readdirSync(path.resolve(__dirname, '../src/utils'));
var mixinsList = fs.readdirSync(path.resolve(__dirname, '../src/mixins'));
var transitionList = fs.readdirSync(path.resolve(__dirname, '../src/transitions'));
/**
 * externals 解决组件依赖其它组件并按需引入时代码冗余的问题
 *     比如 Table 组件依赖 Checkbox 组件,在项目中如果我同时引入 Table 和 Checkbox 时,会不会产生冗余代码
 *     如果没有以下内容的的话,会,这时候你会看到有两份 Checkbox 组件代码。
 *     包括 locale、utils、mixins、transitions 这些公共内容,也会出现冗余代码
 *     但有了 externals 的设置,就会将告诉 webpack 不需要将这些 import 的包打包到 bundle 中,运行时再从外部去
 *     获取这些扩展依赖。这样就可以在打包后 /lib/tables.js 中看到编译后的 table.js 对 Checkbox 组件的依赖引入:
 *     module.exports = require("element-ui/lib/checkbox")
 *     这么处理之后就不会出现冗余的 JS 代码,但是对于 CSS 部分,element-ui 并未处理冗余情况。
 *     可以看到 /lib/theme-chalk/table.css 和 /lib/theme-chalk/checkbox.css 中都有 Checkbox 组件的样式
 */
var externals = {};

Object.keys(Components).forEach(function(key) {
  externals[`element-ui/packages/${key}`] = `element-ui/lib/${key}`;
});

externals['element-ui/src/locale'] = 'element-ui/lib/locale';
utilsList.forEach(function(file) {
  file = path.basename(file, '.js');
  externals[`element-ui/src/utils/${file}`] = `element-ui/lib/utils/${file}`;
});
mixinsList.forEach(function(file) {
  file = path.basename(file, '.js');
  externals[`element-ui/src/mixins/${file}`] = `element-ui/lib/mixins/${file}`;
});
transitionList.forEach(function(file) {
  file = path.basename(file, '.js');
  externals[`element-ui/src/transitions/${file}`] = `element-ui/lib/transitions/${file}`;
});

externals = [Object.assign({
  vue: 'vue'
}, externals), nodeExternals()];

exports.externals = externals;

// 设置别名,方便使用
exports.alias = {
  main: path.resolve(__dirname, '../src'),
  packages: path.resolve(__dirname, '../packages'),
  examples: path.resolve(__dirname, '../examples'),
  'element-ui': path.resolve(__dirname, '../')
};

exports.vue = {
  root: 'Vue',
  commonjs: 'vue',
  commonjs2: 'vue',
  amd: 'vue'
};

exports.jsexclude = /node_modules|utils\/popper\.js|utils\/date\.js/;
/build/deploy-ci.sh

和 travis ci 结合使用的持续集成脚本,这个脚本在 .travis.yml 文件中被执行,代码被提交到 github 仓库以后会自动被 Tavis CI 执行,ci 会自动找项目中的 .travis.yml 文件,并执行里面的命令。但这个我们可能用不到,一般团队内部都会有自己的持续集成方案。

/build/git-release.sh

这里主要是和远程的 dev 分支做 diff 然后合并。

#!/usr/bin/env sh

# 这里主要是和远程的 dev 分支做 diff 然后合并

git checkout dev

if test -n "$(git status --porcelain)"; then
  echo 'Unclean working tree. Commit or stash changes first.' >&2;
  exit 128;
fi

if ! git fetch --quiet 2>/dev/null; then
  echo 'There was a problem fetching your branch. Run `git fetch` to see more...' >&2;
  exit 128;
fi

if test "0" != "$(git rev-list --count --left-only @'{u}'...HEAD)"; then
  echo 'Remote history differ. Please pull changes.' >&2;
  exit 128;
fi

echo 'No conflicts.' >&2;
/build/release.sh

脚本完成了以下工作:

  • 合并 dev 分支到 master、

  • 修改样式包和组件库的版本号

  • 发布样式包和组件库

  • 提交 master 和 dev 分支到远程仓库

该脚本在发布组件库时可以使用,特别是其中自动更改版本号的功能(每次 publish 时都忘改版本号)。这里提交代码到远程仓库的日志很简单,更详细的提交日志时通过更新日志文件 CHANGELOG.{lang}.md 提供的。

#!/usr/bin/env sh
set -e

# 合并 dev 分支到 master
# 编译打包
# 修改样式包和组件库的版本号
# 发布样式包和组件库
# 提交 master 和 dev 分支到远程仓库

# 合并 dev 分支到 master
git checkout master
git merge dev

# 版本选择 cli
VERSION=`npx select-version-cli`

# 是否确认当前版本信息
read -p "Releasing $VERSION - are you sure? (y/n)" -n 1 -r
echo    # (optional) move to a new line
if [[ $REPLY =~ ^[Yy]$ ]]
then
  echo "Releasing $VERSION ..."

  # build,编译打包
  VERSION=$VERSION npm run dist

  # ssr test
  node test/ssr/require.test.js            

  # publish theme
  echo "Releasing theme-chalk $VERSION ..."
  cd packages/theme-chalk
  # 更改主题包的版本信息
  npm version $VERSION --message "[release] $VERSION"
  # 发布主题
  if [[ $VERSION =~ "beta" ]]
  then
    npm publish --tag beta
  else
    npm publish
  fi
  cd ../..

  # commit
  git add -A
  git commit -m "[build] $VERSION"
  # 更改组件库的版本信息
  npm version $VERSION --message "[release] $VERSION"

  # publish,将 master 推到远程仓库
  git push eleme master
  git push eleme refs/tags/v$VERSION
  git checkout dev
  git rebase master
  git push eleme dev

  # 发布组件库
  if [[ $VERSION =~ "beta" ]]
  then
    npm publish --tag beta
  else
    npm publish
  fi
fi
/build/webpack.xx.js
  • webpack.common.js,构建 commonjs2 规范的包,会打一个全量的包

  • webpack.component.js,构建 commonjs2 规范的包,支持按需加载

    支持按需加载的重点在于 entry 和 ouput 的配置,将每个组件打成单独的包

  • webpack.conf.js,构建 UMD 规范的包,会打一个全量的包

  • webpack.demo.js,官网项目的 webpack 配置

  • webpack.extension.js,主题编辑器的 chorme 插件项目的 webpack 配置,项目在 extension 目录下

  • webpack.test.js,这个文件没什么用,不过看命名,应该是想用于测试项目的 webpack 配置,不过现在测试用的是 karma 框架

eslint

element 通过 eslint 来保证代码风格的一致性,还专门编写了 elemefe 作为 eslint 的扩展规则配置。为了保证官网项目的质量,在 /build/webpack.demo.js 中配置了 eslint-loader 规则,在项目启动时强制检查代码质量。但是 element 在代码质量控制这块儿做的还是不够,比如:代码自动格式化能力太弱、只保证了 /src、/test、/packages、/build 目录下的代码质量,对于官网项目做的不够,特别是 文档格式的限制。这里建议大家再集成一个 prettier 专门去做格式限制,让 eslint 专注于代码语法的限制,可以参考 搭建自己的 typescript 项目 + 开发自己的脚手架工具 ts-cli 中的 代码质量 部分去配置。

travis ci

travis ci 结合脚本的方式来完成持续集成的工作,不过这个可能对于内部项目用不上,因为 travis ci 只能用于 github,内部一般使用 gitlab,也有配套的持续集成

Makefile

make 命令的配置文件,写过 C、C++ 的同学应该比较熟悉。

执行 make 命令可以看到详细的帮助信息。比如:执行 make install 装包、make dev 启动本地开发环境、make new comp-name 中文名 新建组件等。使用 make 命令相较于 npm run xx 更方便、清晰、简单,不过其内部也是依赖于 npm run xx 来完成真正的工作,相当于为了更好的开发体验,将众多 npm run cmd 提供了一层封装。

image-20220210083138040

package.json -> scripts

elemnt 编写了很多 npm scripts,这些 script 结合 /build 中的众多脚本实现通过脚本来自动完成大量重复的体力劳动,比人工靠谱且效率更高,这个设计我觉得是 element 中最值得大家学习的地方,可以将这样的设计应用到自己的项目中,助力业务提效。

{
  // 装包
  "bootstrap": "yarn || npm i",
  // 通过JS脚本,自动生成以下文件:生成 examples/icon.json 文件 && 生成 src/index.js 文件 && 生成四种语言的官网的 .vue 文件 && 生成 examples/version.json 文件,包含了组件库的版本信息
  "build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js",
  // 构建主题样式:在 index.scss 中自动引入各个组件的样式文件 && 通过 gulp 将 scss 文件编译成 css 并输出到 lib 目录 && 拷贝基础样式 theme-chalk 到 lib/theme-chalk
  "build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk",
  // 通过 babel 编译 src 目录,然后将编译后的文件输出到 lib 目录,忽略 /src/index.js
  "build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js",
  // 将 ES Module 风格的翻译文件编译成 UMD 风格
  "build:umd": "node build/bin/build-locale.js",
  // 清除构建产物
  "clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage",
  // 构建官网项目
  "deploy:build": "npm run build:file && cross-env NODE_ENV=production webpack --config build/webpack.demo.js && echo element.eleme.io>>examples/element-ui/CNAME",
  // 构建主题插件
  "deploy:extension": "cross-env NODE_ENV=production webpack --config build/webpack.extension.js",
  // 启动主题插件的开发环境
  "dev:extension": "rimraf examples/extension/dist && cross-env NODE_ENV=development webpack --watch --config build/webpack.extension.js",
  // 启动组件库的本地开发环境。执行 build:file,自动化生成一些文件 && 启动 example 项目,即官网 && 监听 examples/pages/template 目录下所有模版文件的变化,如果改变了则重新生成 .vue",
  "dev": "npm run bootstrap && npm run build:file && cross-env NODE_ENV=development webpack-dev-server --config build/webpack.demo.js & node build/bin/template.js",
  // 组件测试项目,在 examples/play/index.vue 中可以引入组件库任意组件,也可以直接使用 dev 启动的项目,在文档中使用组件
  "dev:play": "npm run build:file && cross-env NODE_ENV=development PLAY_ENV=true webpack-dev-server --config build/webpack.demo.js",
  // 构建组件库
  "dist": "npm run clean && npm run build:file && npm run lint && webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js && npm run build:utils && npm run build:umd && npm run build:theme",
  // 生成四种语言的官网的 .vue 文件
  "i18n": "node build/bin/i18n.js",
  // lint,保证项目代码质量
  "lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet",
  // 装包 && 合并远程仓库的 dev 分支 && 合并 dev 分支到 master、打包编译、修改样式包和组件库的版本号、发布样式包和组件库、提交代码到远程仓库。使用时注掉最后一个脚本,那个脚本有问题
  "pub": "npm run bootstrap && sh build/git-release.sh && sh build/release.sh && node build/bin/gen-indices.js",
  // 生成测试报告,不论是 test 还是 test:watch,生成一次测试报告耗时太长了
  "test": "npm run lint && npm run build:theme && cross-env CI_ENV=/dev/ BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
  // 启动测试项目,可以检测测试文件的更新
  "test:watch": "npm run build:theme && cross-env BABEL_ENV=test karma start test/unit/karma.conf.js"
}

官网

element 的官网是和组件库在一个仓库内,官网的所有东西都放在 /examples 目录下,就是一个 vue 项目。

entry.js

官网项目的入口,在这里全量引入组件库,及其样式。

// 官网项目的入口,就是一个普通的 vue 项目
import Vue from 'vue';
import entry from './app';
import VueRouter from 'vue-router';
// 引入组件库,main 是别名,在 /build/config.js 中有配置
import Element from 'main/index.js';
import hljs from 'highlight.js';
// 路由配置
import routes from './route.config';
// 官网项目的一些组件
import demoBlock from './components/demo-block';
import MainFooter from './components/footer';
import MainHeader from './components/header';
import SideNav from './components/side-nav';
import FooterNav from './components/footer-nav';
import title from './i18n/title';

// 组件库样式
import 'packages/theme-chalk/src/index.scss';
import './demo-styles/index.scss';
import './assets/styles/common.css';
import './assets/styles/fonts/style.css';
// 将 icon 信息挂载到 Vue 原型链上,在 markdown 文档中被使用,在官网的 icon 图标 页面展示出所有的 icon 图标
import icon from './icon.json';

Vue.use(Element);
Vue.use(VueRouter);
Vue.component('demo-block', demoBlock);
Vue.component('main-footer', MainFooter);
Vue.component('main-header', MainHeader);
Vue.component('side-nav', SideNav);
Vue.component('footer-nav', FooterNav);

const globalEle = new Vue({
  data: { $isEle: false } // 是否 ele 用户
});

Vue.mixin({
  computed: {
    $isEle: {
      get: () => (globalEle.$data.$isEle),
      set: (data) => {globalEle.$data.$isEle = data;}
    }
  }
});

Vue.prototype.$icon = icon; // Icon 列表页用

const router = new VueRouter({
  mode: 'hash',
  base: __dirname,
  routes
});

router.afterEach(route => {
  // https://github.com/highlightjs/highlight.js/issues/909#issuecomment-131686186
  Vue.nextTick(() => {
    const blocks = document.querySelectorAll('pre code:not(.hljs)');
    Array.prototype.forEach.call(blocks, hljs.highlightBlock);
  });
  const data = title[route.meta.lang];
  for (let val in data) {
    if (new RegExp('^' + val, 'g').test(route.name)) {
      document.title = data[val];
      return;
    }
  }
  document.title = 'Element';
  ga('send', 'event', 'PageView', route.name);
});

new Vue({ // eslint-disable-line
  ...entry,
  router
}).$mount('#app');

nav.config.json

官网组件页面的侧边导航栏配置,一定要了解该 json 文件的结构,才能看懂 route.config.js 文件中生成组件页面所有路由的代码。

route.config.js

根据路由配置自动生成官网项目的路由配置。

// 根据路由配置自动生成官网项目的路由
import navConfig from './nav.config';
// 支持的所有语言
import langs from './i18n/route';

// 加载官网各个页面的 .vue 文件
const LOAD_MAP = {
  'zh-CN': name => {
    return r => require.ensure([], () =>
      r(require(`./pages/zh-CN/${name}.vue`)),
    'zh-CN');
  },
  'en-US': name => {
    return r => require.ensure([], () =>
      r(require(`./pages/en-US/${name}.vue`)),
    'en-US');
  },
  'es': name => {
    return r => require.ensure([], () =>
      r(require(`./pages/es/${name}.vue`)),
    'es');
  },
  'fr-FR': name => {
    return r => require.ensure([], () =>
      r(require(`./pages/fr-FR/${name}.vue`)),
    'fr-FR');
  }
};

const load = function(lang, path) {
  return LOAD_MAP[lang](path);
};

// 加载官网组件页面各个组件的 markdown 文件
const LOAD_DOCS_MAP = {
  'zh-CN': path => {
    return r => require.ensure([], () =>
      r(require(`./docs/zh-CN${path}.md`)),
    'zh-CN');
  },
  'en-US': path => {
    return r => require.ensure([], () =>
      r(require(`./docs/en-US${path}.md`)),
    'en-US');
  },
  'es': path => {
    return r => require.ensure([], () =>
      r(require(`./docs/es${path}.md`)),
    'es');
  },
  'fr-FR': path => {
    return r => require.ensure([], () =>
      r(require(`./docs/fr-FR${path}.md`)),
    'fr-FR');
  }
};

const loadDocs = function(lang, path) {
  return LOAD_DOCS_MAP[lang](path);
};

// 添加组件页的各个路由配置,以下这段代码要看懂必须明白 nav.config.json 文件的结构
const registerRoute = (navConfig) => {
  let route = [];
  // 遍历配置,生成四种语言的组件路由配置
  Object.keys(navConfig).forEach((lang, index) => {
    // 指定语言的配置,比如 lang = zh-CN,navs 就是所有配置项都是中文写的
    let navs = navConfig[lang];
    // 组件页面 lang 语言的路由配置
    route.push({
      // 比如: /zh-CN/component
      path: `/${ lang }/component`,
      redirect: `/${ lang }/component/installation`,
      // 加载组件页的 component.vue
      component: load(lang, 'component'),
      // 组件页的所有子路由,即各个组件,放这里,最后的路由就是 /zh-CN/component/comp-path
      children: []
    });
    // 遍历指定语言的所有配置项
    navs.forEach(nav => {
      if (nav.href) return;
      if (nav.groups) {
        // 该项为组件
        nav.groups.forEach(group => {
          group.list.forEach(nav => {
            addRoute(nav, lang, index);
          });
        });
      } else if (nav.children) {
        // 该项为开发指南
        nav.children.forEach(nav => {
          addRoute(nav, lang, index);
        });
      } else {
        // 其它,比如更新日志、Element React、Element Angular
        addRoute(nav, lang, index);
      }
    });
  });
  // 生成子路由配置,并填充到 children 中
  function addRoute(page, lang, index) {
    // 根据 path 决定是加载 vue 文件还是加载 markdown 文件
    const component = page.path === '/changelog'
      ? load(lang, 'changelog')
      : loadDocs(lang, page.path);
    let child = {
      path: page.path.slice(1),
      meta: {
        title: page.title || page.name,
        description: page.description,
        lang
      },
      name: 'component-' + lang + (page.title || page.name),
      component: component.default || component
    };
    // 将子路由添加在上面的 children 中
    route[index].children.push(child);
  }

  return route;
};

// 得到组件页面所有侧边栏的路由配置
let route = registerRoute(navConfig);

const generateMiscRoutes = function(lang) {
  let guideRoute = {
    path: `/${ lang }/guide`, // 指南
    redirect: `/${ lang }/guide/design`,
    component: load(lang, 'guide'),
    children: [{
      path: 'design', // 设计原则
      name: 'guide-design' + lang,
      meta: { lang },
      component: load(lang, 'design')
    }, {
      path: 'nav', // 导航
      name: 'guide-nav' + lang,
      meta: { lang },
      component: load(lang, 'nav')
    }]
  };

  let themeRoute = {
    path: `/${ lang }/theme`,
    component: load(lang, 'theme-nav'),
    children: [
      {
        path: '/', // 主题管理
        name: 'theme' + lang,
        meta: { lang },
        component: load(lang, 'theme')
      },
      {
        path: 'preview', // 主题预览编辑
        name: 'theme-preview-' + lang,
        meta: { lang },
        component: load(lang, 'theme-preview')
      }]
  };

  let resourceRoute = {
    path: `/${ lang }/resource`, // 资源
    meta: { lang },
    name: 'resource' + lang,
    component: load(lang, 'resource')
  };

  let indexRoute = {
    path: `/${ lang }`, // 首页
    meta: { lang },
    name: 'home' + lang,
    component: load(lang, 'index')
  };

  return [guideRoute, resourceRoute, themeRoute, indexRoute];
};

langs.forEach(lang => {
  route = route.concat(generateMiscRoutes(lang.lang));
});

route.push({
  path: '/play',
  name: 'play',
  component: require('./play/index.vue')
});

let userLanguage = localStorage.getItem('ELEMENT_LANGUAGE') || window.navigator.language || 'en-US';
let defaultPath = '/en-US';
if (userLanguage.indexOf('zh-') !== -1) {
  defaultPath = '/zh-CN';
} else if (userLanguage.indexOf('es') !== -1) {
  defaultPath = '/es';
} else if (userLanguage.indexOf('fr') !== -1) {
  defaultPath = '/fr-FR';
}

route = route.concat([{
  path: '/',
  redirect: defaultPath
}, {
  path: '*',
  redirect: defaultPath
}]);

export default route;

play

包括 play.jsplay/index.vue,示例项目,比如你想看一个 element 中某个组件的效果,特别是组件按需加载时的显示效果,可以在 play/index.vue 中引入使用,使用 npm run dev:play 命令启动项目,也是在 /build/webpack.demo.js 中通过环境变量来配置的。

// play.js
import Vue from 'vue';
// 全量引入组件库和其样式
import Element from 'main/index.js';
import 'packages/theme-chalk/src/index.scss';
import App from './play/index.vue';

Vue.use(Element);

new Vue({ // eslint-disable-line
  render: h => h(App)
}).$mount('#app');
<!-- play/index.vue -->
<template>
  <div style="margin: 20px;">
    <el-input v-model="input" placeholder="请输入内容"></el-input>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        input: 'Hello Element UI!'
      };
    }
  };
</script>

pages

官网的各个页面都在这里,通过 i18n.js 脚本 结合 pages/template 目录下的各个模版文件自动在 pages 目录下生成四种语言的 .vue 文件,这些 vue 文件会在 route.config.js 中被加载。

i18n

官网页面的翻译配置文件都在这里。

  • component.json,组件页面的翻译配置
  • page.json,其它页面的一些翻译配置,比如首页、设计页等
  • route.json,语言配置,表示组件库目前都支持那些语言
  • theme-editor.json,主题编辑器页面的翻译配置
  • title.json,官网各个页面在 tab 标签中显示的 title 信息

extension

主题编辑器的 chrome 插件项目。

dom

定义了 dom 样式操作方法,包括判断是否存在指定的样式、添加样式、移除样式、切换样式。

// dom/class.js
export const hasClass = function(obj, cls) {
  return obj.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)'));
};

export const addClass = function(obj, cls) {
  if (!hasClass(obj, cls)) obj.className += ' ' + cls;
};

export const removeClass = function(obj, cls) {
  if (hasClass(obj, cls)) {
    const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)');
    obj.className = obj.className.replace(reg, ' ');
  }
};

export const toggleClass = function(obj, cls) {
  if (hasClass(obj, cls)) {
    removeClass(obj, cls);
  } else {
    addClass(obj, cls);
  }
};

docs

组件文档目录,默认提供了四种语言的文档,目录结构为:docs/{lang}/comp-name.md。这些文档在组件页面加载(在 route.config.js 中有配置),先交给 md-loader 处理,提取其中的 vue 代码,然后交给 vue-loader 去处理,最后渲染到页面形成组件 demo + 文档。

demo-style

组件页面中显示的 组件 demo 的排版样式,和组件自身的样式无关,就像你业务代码中给组件定义排版样式一样。因为组件在有些场景下直接显示效果不好,所以就需要经过一定的排版,比如 button 页面、icon 页面等。

components

官网项目存放一些全局组件的目录。

assets

官网项目的静态资源目录

组件库

element 组件库由两部分组成:/src/packages

src

利用模块化的开发**,把组件依赖的一些公共模块放在 /src 目录下,并依据功能拆分出以下模块:

  • utils,定义了一些工具方法
  • transitions,动画
  • mixins,全局混入的一些方法
  • locale,国际化功能以及各种语言的 部分组件 的翻译文件
  • directives,指令

/src/index.js 是通过脚本 /build/bin/build-entry.js 脚本自动生成,是组件库的入口。负责自动导入组件库的所有组件、定义全量注册组件库组件的 install 方法,然后导出版本信息、install 和 各个组件。

/* 通过 './build/bin/build-entry.js' 文件自动生成 */

// 引入所有组件
import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
// ...

// 组件数组,有些组件没在里面,这些组件不需要通过 Vue.use 或者 Vue.component 的方式注册,直接挂载到 Vue 原型链上
const components = [
  Pagination,
  Dialog,
  // ...
]

// 定义 install 方法,负责全量引入组件库
const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);

  // 全局注册组件
  components.forEach(component => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  // 在 Vue 原型链上挂点东西
  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  // 这些组件不需要
  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;

};

// 通过 CDN 引入组件库时,走下面这段代码,全量注册组件库
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue);
}

// 导出版本信息、install 方法、各个组件
export default {
  version: '2.15.0',
  locale: locale.use,
  i18n: locale.i18n,
  install,
  CollapseTransition,
  Loading,
  // ...
}

为了减少篇幅,只贴出文件的一部分,但足以说明一切。

/packages

element 将组件全部都放在了 /packages 目录下,每个组件以目录为单位,目录结构以及其中的基本代码是通过脚本 /build/bin/new.js 自动生成的。目录结构为:

  • package-name,连字符形式的包名
    • index.js,组件的 install 方法,表示组件是以 Vue 插件的形式存在
    • src,组件的源码目录
      • main.vue 组件的基本结构已经就绪

比如新建的 city 组件的目录及文件是这样的:

  • city

    • index.js

      import City from './src/main';
      
      /* istanbul ignore next */
      City.install = function(Vue) {
        Vue.component(City.name, City);
      };
      
      export default City;
    • src

      • main.vue

        <template>
          <div class="el-city"></div>
        </template>
        
        <script>
        export default {
          name: 'ElCity'
        };
        </script>

其实 /packages 目录下除了组件之外,还有一个特殊的目录 theme-chalk,它是组件库的样式目录,所有组件的样式代码都在这里,element 的组件文件中没有定义样式。theme-chalk 目录也是一个项目,通过 gulp 打包,并支持独立发布,其目录结构是这样的:

  • theme-chalk

    • src,组件样式的源码目录

      • index.scss,引入目录下所有的样式文件
      • comp.scss,组件样式文件,比如:button.scss
      • other,比如:字体、公共样式、变量、方法等
    • .gitignore

    • gulpfile.js

      'use strict';
      
      // gulp 配置文件
      
      const { series, src, dest } = require('gulp');
      const sass = require('gulp-sass');
      const autoprefixer = require('gulp-autoprefixer');
      const cssmin = require('gulp-cssmin');
      
      // 将 scss 编译成 css 并压缩,最后输出到 ./lib 目录下
      function compile() {
        return src('./src/*.scss')
          .pipe(sass.sync())
          .pipe(autoprefixer({
            browsers: ['ie > 9', 'last 2 versions'],
            cascade: false
          }))
          .pipe(cssmin())
          .pipe(dest('./lib'));
      }
      
      // 拷贝 ./src/fonts 到 ./lib/fonts
      function copyfont() {
        return src('./src/fonts/**')
          .pipe(cssmin())
          .pipe(dest('./lib/fonts'));
      }
      
      exports.build = series(compile, copyfont);
    • package.json

    • README.md

测试

组件库的测试项目,使用 karma 框架

类型声明

每个组件的类型声明文件,TS 项目使用组件库时有更好的代码提示。

结束

到这里 element 的源码架构分析就结束了,建议读者参照文章,亲自去阅读框架源码并添加注释,这样理解会更深,也更利于后续工作的开展。下一篇将详细讲解 基于 Element 为团队打造组件库 的过程。

链接

  • Element 源码架构 思维导图版
  • Element 源码架构 视频版,关注微信公众号,回复: "Element 源码架构视频版" 获取
  • 组件库专栏
    • 如何快速为团队打造自己的组件库(下)—— 基于 element-ui 为团队打造自己的组件库
  • github

感谢各位的:点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github,欢迎 Watch 和 Star。

手写 Vue2 系列 之 computed

手写 Vue2 系列 之 computed

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

前言

上一篇文章 手写 Vue2 系列 之 patch —— diff 实现了 DOM diff 过程,完成页面响应式数据的更新。

目标

本篇的目标是实现 computed 计算属性,完成模版中计算属性的展示。涉及的知识点:

  • 计算属性的本质

  • 计算属性的缓存原理

实现

接下来就开始实现 computed 计算属性,。

_init

/src/index.js

/**
 * 初始化配置对象
 * @param {*} options 
 */
Vue.prototype._init = function (options) {
  // ...
  // 初始化 options.data
  // 代理 data 对象上的各个属性到 Vue 实例
  // 给 data 对象上的各个属性设置响应式能力
  initData(this)
  // 初始化 computed 选项,并将计算属性代理到 Vue 实例上
  // 结合 watcher 实现缓存
  initComputed(this)
  // 安装运行时的渲染工具函数
  renderHelper(this)
  // ...
}

initComputed

/src/initComputed.js

/**
 * 初始化 computed 配置项
 * 为每一项实例化一个 Watcher,并将其 computed 属性代理到 Vue 实例上
 * 结合 watcher.dirty 和 watcher.evalute 实现 computed 缓存
 * @param {*} vm Vue 实例
 */
export default function initComputed(vm) {
  // 获取 computed 配置项
  const computed = vm.$options.computed
  // 记录 watcher
  const watcher = vm._watcher = Object.create(null)
  // 遍历 computed 对象
  for (let key in computed) {
    // 实例化 Watcher,回调函数默认懒执行
    watcher[key] = new Watcher(computed[key], { lazy: true }, vm)
    // 将 computed 的属性 key 代理到 Vue 实例上
    defineComputed(vm, key)
  }
}

defineComputed

/src/initComputed.js

/**
 * 将计算属性代理到 Vue 实例上
 * @param {*} vm Vue 实例
 * @param {*} key computed 的计算属性
 */
function defineComputed(vm, key) {
  // 属性描述符
  const descriptor = {
    get: function () {
      const watcher = vm._watcher[key]
      if (watcher.dirty) { // 说明当前 computed 回调函数在本次渲染周期内没有被执行过
        // 执行 evalute,通知 watcher 执行 computed 回调函数,得到回调函数返回值
        watcher.evalute()
      }
      return watcher.value
    },
    set: function () {
      console.log('no setter')
    }
  }
  // 将计算属性代理到 Vue 实例上
  Object.defineProperty(vm, key, descriptor)
}

Watcher

/src/watcher.js

/**
 * @param {*} cb 回调函数,负责更新 DOM 的回调函数
 * @param {*} options watcher 的配置项
 */
export default function Watcher(cb, options = {}, vm = null) {
  // 备份 cb 函数
  this._cb = cb
  // 回调函数执行后的值
  this.value = null
  // computed 计算属性实现缓存的原理,标记当前回调函数在本次渲染周期内是否已经被执行过
  this.dirty = !!options.lazy
  // Vue 实例
  this.vm = vm
  // 非懒执行时,直接执行 cb 函数,cb 函数中会发生 vm.xx 的属性读取,从而进行依赖收集
  !options.lazy && this.get()
}

watcher.get

/src/watcher.js

/**
 * 负责执行 Watcher 的 cb 函数
 * 执行时进行依赖收集
 */
Watcher.prototype.get = function () {
  pushTarget(this)
  this.value = this._cb.apply(this.vm)
  popTarget()
}

watcher.update

/src/watcher.js

/**
 * 响应式数据更新时,dep 通知 watcher 执行 update 方法,
 * 让 update 方法执行 this._cb 函数更新 DOM
 */
Watcher.prototype.update = function () {
  // 通过 Promise,将 this._cb 的执行放到 this.dirty = true 的后面
  // 否则,在点击按钮时,computed 属性的第一次计算会无法执行,
  // 因为 this._cb 执行的时候,会更新组件,获取计算属性的值的时候 this.dirty 依然是
  // 上一次的 false,导致无法得到最新的的计算属性的值
  // 不过这个在有了异步更新队列之后就不需要了,当然,毕竟异步更新对象的本质也是 Promise
  Promise.resolve().then(() => {
    this._cb()
  })
  // 执行完 _cb 函数,DOM 更新完毕,进入下一个渲染周期,所以将 dirty 置为 false
  // 当再次获取 计算属性 时就可以重新执行 evalute 方法获取最新的值了
  this.dirty = true
}

watcher.evalute

/src/watcher.js

Watcher.prototype.evalute = function () {
  // 执行 get,触发计算函数 (cb) 的执行
  this.get()
  // 将 dirty 置为 false,实现一次刷新周期内 computed 实现缓存
  this.dirty = false
}

pushTarget

/src/dep.js

// 存储所有的 Dep.target
// 为什么会有多个 Dep.target?
// 组件会产生一个渲染 Watcher,在渲染的过程中如果处理到用户 Watcher,
// 比如 computed 计算属性,这时候会执行 evalute -> get
// 假如直接赋值 Dep.target,那 Dep.target 的上一个值 —— 渲染 Watcher 就会丢失
// 造成在 computed 计算属性之后渲染的响应式数据无法完成依赖收集
const targetStack = []

/**
 * 备份本次传递进来的 Watcher,并将其赋值给 Dep.target
 * @param {*} target Watcher 实例
 */
export function pushTarget(target) {
  // 备份传递进来的 Watcher
  targetStack.push(target)
  Dep.target = target
}

popTarget

/src/dep.js

/**
 * 将 Dep.target 重置为上一个 Watcher 或者 null
 */
export function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

结果

好了,到这里,Vue computed 属性实现就完成了,如果你能看到如下效果图,则说明一切正常。

动图地址:https://gitee.com/liyongning/typora-image-bed/raw/master/202203161832189.image

Jun-20-2021 10-50-02.gif

可以看到,页面中的计算属性已经正常显示,而且也可以做到响应式更新,且具有缓存的能力(通过控制台查看 computed 输出)。

到这里,手写 Vue 系列就剩最后一部分内容了 —— 手写 Vue 系列 之 异步更新队列

链接

感谢各位的:关注点赞收藏评论,我们下期见。


当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

PDF 生成(5)— 内容页支持由多页面组成

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

封面

封面.png

回顾

在本篇开始之前,我们先来回顾一下上篇 PDF 生成(4)— 目录页 的内容:

  • 开头,我们通过 page.evaluate 方法为浏览器注入 JS 代码,通过这段 JS 在 PDF 内容页的开始位置(body 的第一个子元素)插入由 a 标签和对应的样式组成的目录页 DOM,从而通过 HTML 锚点实现目录项的页面跳转能力
  • 接下来,我们通过为目录页的容器元素设置break-after: page样式实现目录页自成一页的效果(和内容页分别两页)
  • 然后剩下的所有篇幅都是在讲如何生成带有准确页码的目录项
    • 首先,页码是按照锚点元素在页面中的高度 / PDF 一页的高度来计算的
    • 后来,我们通过下面三步来保证目录页中目录项对应页码的准确性
      • 规范化设计稿尺寸(按照 A4 纸对应的 2 倍图尺寸设计)
      • 通过页面缩放解决设计稿 DPI 和实际生成 PDF 时 DPI 的差异问题(彻底统一计算时 PDF 一页的像素高度)
      • 通过页面高度补充的方案解决章节标题换页引起目录项页码计算错误的问题

上篇结束之后,PDF 文件的整体框架已完全成型,包括封面、目录页、内容页和尾页四部分。但系列还没结束,接下来我们会通过本文来提升接入方的使用体验和前端代码可维护性。

开始之前,上篇给大家留了一个问题:回顾一下现在 PDF 文件内容页的生成,站在接入方的角度看,是否存在问题?假设一个场景,接入方的 PDF 文件呈现的内容量非常大,比如拥有几十甚至几百页的内容,那接入方的这个前端页面的代码该怎么维护?页面性能又该怎么保证呢?

简介

本系列的 PDF 生成(1)— 开篇PDF 生成(2)— 生成 PDF 文件PDF 生成(3)— 封面、尾页PDF 生成(4)— 目录页 都是在一步步完善 PDF 文件的整体框架,包括封面、目录页、内容页和尾页四部分,截止上篇,PDF 文件的整体框架已完全成型。

本篇是站在用户角度(接入方)来进行的一次技术迭代。目的是为了解决用户前端代码的可维护性问题。

问题

问题 1:到目前为止,我们的 PDF 文件内容页是怎么生成的?

:关键代码之一 await page.goto('https://content.cn', { waitUntil: ['load', 'networkidle0'] }),也就是说,PDF 文件的内部部分都是由该链接背后的前端页面提供的。


问题 2:如果一份 PDF 文件由几十、几百页组成,其中包含几十个模块,这份 PDF 背后的前端页面的代码该怎么维护?页面性能怎么保障?

:首先,这么庞大的一个页面的前端代码,基本上是非常难维护的;至于性能问题,可以通过滚动懒加载的方案来解决,但这个优化本来是没必要的,完全是由于现有的 PDF 生成服务能力不足导致的。

分析

我们在做架构设计时,不论是一个系统,还是一个项目,亦或是一个页面甚至一个组件一个方法,都会尽量去避免模块过于复杂,导致难以维护,所以为了更好的可维护性,会尽量将内容进行拆分,比如微服务、组件化。

一个包含几十个模块的页面,不论你怎么去组件化,都避免不了这个页面的庞大,做的再极致,一个由几十个组件组成的页面都是难以维护的,而且如果不做滚动懒加载,这个页面首屏的性能会非常差。当下我们的用户就面临这样的问题,因为我们的 PDF 内容页必须是由一个前端页面构成。

所以,就在想,怎么才能让我们的用户不这么难受呢?开发 PDF 需求,就像开发普通的 Web 项目一样(这句话我们 PDF 生成(1)— 开篇 的技术选型中就提过),代码可以按照业务逻辑进行合理的划分,而不是全部模块堆叠在一个页面上。

其实,经过上面的问题和分析之后,解决方向很明确:PDF 生成服务不应该限制用户对于项目的设计和编码,所以,PDF 的内容页应该支持多页面。但怎么支持呢?

方案限制

  • puppeteer 生成 PDF 文件,只能是一个页面对应一份 PDF 文件,这是最底层的限制。page.gotopage.pdf都是针对当前页面的(浏览器的打印功能,只能打印当前渲染的页面)
  • 目录页方案的限制
    • 页面跳转能力是基于 HTML 锚点实现的,意味着相关 DOM 必须在一个页面中
    • 目录项对应的页码是通过 DOM 节点在页面中的位置(高度)来计算的,所以如果 DOM 位于不同的页面就意味着没办法计算了

这两个既是限制,也是进一步迭代的大前提。也就是说,我们现有的能力(大框架)不能动,也没办法动。PDF 内容页必须只能对应一个前端页面,至少在 puppeteer 层面是这样的

怎么做?

PDF 生成服务是基于 puppeteer 来实现的,也就是说 puppeteer 和用户之间还隔着一个 PDF 生成服务。那如果在 PDF 生成服务上增加一个胶水层呢?即 PDF 生成服务将用户提供的众多内容页合并成一个,然后将合并后的页面提供给 puppeteer。这是在现有技术架构上做加法,完全不影响现有技术方案和效果。

简单来讲就是:

  • 首先,通过 page.goto 方法依次打开用户提供的众多内容页,并拿到这些内容页的 HTML 信息
  • 然后,通过 page.gogo 打开 PDF 生成服务提供的容器页面,将上一步拿到的所有 HTML 信息都填充到该容器页中
  • 最后,通过 page.pdf 方法打印填充后的容器页得到 PDF 内容页

这方案可行,但有问题,这就遇到了整套方案中第二个难点了。

难点(问题)

问题:我们将用户提供的所有页面的 HTML 都塞到了一个页面中渲染,怎么解决可能会出现的样式和 JS 冲突?

:首先,冲突问题很有可能会出现,用户有义务保证自己的页面内部不出现冲突,但她没有义务确保不同的页面不出现冲突。解决问题的关键是沙箱,PDF 生成服务需要提供一套沙箱来确保容器页中各个页面的隔离性。

沙箱

浏览器中的沙箱包括样式沙箱和 JS 沙箱,实现沙箱方式一般有以下几种:

  • JS 沙箱
    • iframe
    • 代理,比如微前端框架 qiankun 的 JS 沙箱实现方案之一就是 Proxy
  • 样式沙箱
    • iframe
    • Web Component,通过 shadow dom 将不同页面的 HTML 和 CSS 包裹起来,以实现和外部环境的隔离
    • scoped,比如 Vue 组件中的 scoped 属性,qiankun 的样式沙箱方案之一

JS 沙箱

首先,我们不需要 JS 沙箱,因为我们获取的是已经渲染好的 HTML 页面,所以会剔除掉 script 标签(打印成 PDF 文件也用不上 JS),JS 的存在反而会带来不确定性和复杂性。

样式沙箱

iframe 最简单,但浏览器的 Web 安全策略会导致我们计算页码时存在问题,因为,跨域场景下没办法操作 iframe 中的 DOM。

Web Component,其整体实现思路是:

  • 利用 Web Component 的隔离特性作为各个页面的容器,来实现页面的样式隔离
  • 通过 JS 给目录项增加点击事件,借用 JS 的能力取到 Web Component 内的目标节点,通过 scrollIntoView 滚动到对应的位置
  • 最后,在容器页面中,拼接目录、各个页面对应的 Web Component 组件。

这套方案在浏览器场景中没有任何问题,而且也比较简单,但生成 PDF 就有问题了,因为生成 PDF 文件后,JS 的能力就丢了,之前的目录跳转是依靠原生的 HTML 锚点能力,现在有了 Web Component 的隔离,a 标签的 href 就取不到 Web Component 内部的元素了。但是,Web Component 实在是一个不错的样式沙箱方案,其实现思路如下,以后有机会可以在浏览器中使用:

/**
 * 生成 PDF 内容页
 * @param { Array<htmlElStr> } htmlElList 
 */
function generatePdfContent(htmlElList) {
  // 定义 Web Component,用来承载 PDF 内容
  class PDFContent extends HTMLElement {
    constructor() {
      super()
      this.shadow = this.attachShadow({ mode: 'open' })
    }
    connectedCallback() {
      const htmlStr = this.getAttribute('html-content')
      this.shadow.innerHTML = htmlStr
    }
  }

  customElements.define('pdf-content', PDFContent)

  // 向 页面内 添加 pdf-content 组件
  const fragment = document.createDocumentFragment()
  for (let i = 0; i < htmlElList.length; i++) {
    const pdfContentEl = document.createElement('pdf-content')
    pdfContentEl.setAttribute('html-content', htmlElList[i])
    fragment.appendChild(pdfContentEl)
  }
  document.body.appendChild(fragment)
} Ï

/**
 * 为目录设置锚点。这里的锚点跳转是通过 JS 的 scrollIntoView 来实现的
 */
function setAnchorPointForDir() {
  // 获取目录页所有的 a 标签
  const links = document.querySelectorAll('.pdf-directory__wrapper a')
  links.forEach(link => {
    // 为每个目录项添加点击事件
    link.addEventListener('click', function (e) {
      // 阻止元素的默认行为 —— a 标签的链接跳转行为
      e.preventDefault()
      // 获取被点击目录项的 href 属性,是一个 id 选择器,比如: #xx
      const targetId = link.getAttribute('href')
      // 找到页面上所有的 pdf-content 元素,这些元素是 web component
      const pdfContentComps = document.querySelectorAll('pdf-content')
      // 遍历这些 web component,从 web component 里查找对应的元素(目录上的 id 选择器),找到后将目标元素滚动到屏幕中间
      for (let i = 0, len = pdfContentComps.length; i < len; i++) {
        const targetElement = pdfContentComps[i].shadowRoot.querySelector(targetId)
        if (targetElement) {
          targetElement.scrollIntoView({ behavior: 'smooth' })
          break;
        }
      }
    })
  })
}

所以,样式沙箱,就只剩方案三 —— Scoped,这里我们借鉴 qiankun 的实验性样式隔离方案,以页面为维度,为页面中的所有样式规则增加一个特殊的选择器来限定其影响范围,因此改写后的样式会变成如下结构:

/* 原始样式 */
.app-main {
  font-size: 14px;
  color: #EFEFEF;
}

/* 改写后的样式 */
.sandbox-cae17ae7-ad3a-7269-b9a0-07da189346a7 .app-main {
  font-size: 14px;
  color: #EFEFEF;
}

到这里,整个方案分析就结束了,接下来就进入实操阶段。

实战

  • 新建第二个内容页 /fe/second-content-page.html,并制造和第一个内容页的样式冲突(body 选择器)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>第二个内容页</title>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
    body {
      width: 100%;
      height: 1684px;
      /* 和 exact-page-num.html 的背景色不一样,但都是样式选择器都是 body */
      background-color: green;
    }
    .anchor-wrapper1 {
      width: 100%;
      height: 1400px;
    }

    #second-content-page-anchor1 {
      color: red;
      break-before: page;
    }
    #second-content-page-anchor2 {
      color: blue;
      break-before: page;
    }
  </style>
</head>
<body>
  <div class="anchor-wrapper1">
    <h1 id="second-content-page-anchor1">第二个内容页 —— 锚点 1</h1>
  </div>
  <div class="anchor-wrapper2">
    <h1 id="second-content-page-anchor2">第二个内容页 —— 锚点 2</h1>
  </div>
</body>
</html>
  • 新建 PDF 内容页的容器页面 /fe/pdf-content.html,来承载目录和众多 PDF 内容页的 HTML + CSS
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PDF 生成服务</title>
  <meta name="description" content="PDF 内容页的容器页面,内容全部由 PDF 生成服务中的 JS 动态添加,由 目录 和 众多 PDF 内容页组成">
</head>
<body>
</body>
</html>
  • 改动 /server/index.mjs,由于代码量太大,就不像之前一样贴详细的改动逻辑了,以主流程为主,另外为了方便演示,相关代码都放在了一个文件中,没有进一步模块化,详细代码大家可以通过 github 访问,顺便 Star 一下呗

image.png
image.png
image-20240308131357531
image.png

PDF 内容页生成过程如下,特别是最后多页面合并后的效果(目录、页面 1 和 页面 2)

Mar-03-2024 19-44-16.gif

最终的 PDF 效果如下:

image.png
image.png
image.png
image.png
image.png
image.png
image.png

总结

我们再来回顾一下本文:

  • 首先,PDF 内容页只能由一个前端页面构成,这样的限制在复杂 PDF 文件中会给接入方的前端项目带来代码可维护性问题
  • 接着,我们通过在 PDF 服务中引入胶水层,支持将多个页面黏合成一个页面,然后交给 puppeteer 来打印
  • 然后,讲了在浏览器中沙箱的实现方案,并通过样式沙箱来解决多页面黏合后出现的样式冲突问题

到目前为止,整套 PDF 生成方案基本完成了:

  • 我们通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分
  • 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑

至此,PDF 生成的能力齐了,但怎么给用户使用呢?接下来我们会再用一篇来讲 PDF 生成的服务化和配置化,这样整个方案就彻底完善了。

链接

  • PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.