Vue2&Vue3组件打包成Web-Components的区别

944 阅读6分钟

初步理解

Web Components 的定义

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 web 应用中使用它们。

简单来讲,就是Javascript标准提供了一套可以自定义页面标签的方法,供开发使用,方便我们写除了原生HTML标签外的 其他自定义复杂HTML结构。

Why Web Components ?

这个问题 就好比如 Why Vue ? Why React? 一样。我们知道是MVVM框架的核心是提供对View 和 ViewModel 的双向数据绑定(响应式原理),以及组件化的开发体验。

那现在,从Web Components的定义来看,似乎Javascript提供一套原生的组件化方案(标签元素复用方案),那么,意味着 我们可以将我们的代码,打包成一个又一个的Web-Components,通过标签的方式 直接引用,通过这样的方式来搭建页面。

如:

<!DOCTYPE html>
<html lang="en">
  <head>
    <script type="text/javascript" src="/dist/web-component-1.js"></script>
    <script type="text/javascript" src="/dist/web-component-2.js"></script>
    <!-- <script src="/dist/common/index.js"></script> -->
  </head>

  <body>
    <base-component-1></base-component-1>
    <base-component-2></base-component-2>
    <base-component-1></base-component-1>
  </body>
</html>

如何解决Web Components 没有响应式的问题。

读到这里 很多人可能会问,你这个自定义的 Web Components 又没有响应式,那我们这种"Vue工程师"、"React工程师",怎么习惯这种开发模式呢?

咱就是说,有没有一种可能可以利用Vue、React的响应式来构建我们的Web Components,那这样就能达到,既能构建出 Web Components,又可以享受到响应式框架的响应式原理。

本文接下来会围绕 Vue2 和 Vue3 来进行 Web Components 的构建。


Vue2打包成Web-Components

Vue2构建命令; Vue2 在Vue-cli 的官方文档中,是提供了通过Vue-cli一键打包成Web Components的命令的:

vue build --target wc --name componentName  src/components/xxxxxx.vue

官方也解释到,利用的是 官方仓库下@vue/web-component-wrapper,将Vue组件包装成Web Component

这个包的使用方式如下:

import WebComponent1 from './index.vue?shadow';
import wrap from '@vue/web-component-wrapper';

if (!window.Vue) {
  console.error('you need import vue2.runtime.js');
}
const CustomElement = wrap(window.Vue, WebComponent1);
window.customElements.define('web-component-1', CustomElement);

Vue2 是通过这样来进行将 Web Components 组件打包成 Web-Component


Vue3打包成Web-Components

Vue3中呢,官方是提供了专门的API的,Vue 提供了一个和定义一般 Vue 组件几乎完全一致的 defineCustomElement 方法来支持创建自定义元素。这个方法接收的参数和 defineComponent 完全相同。但它会返回一个继承自 HTMLElement 的自定义元素构造器:

// 这个是官方的例子
import { defineCustomElement } from 'vue'

const MyVueElement = defineCustomElement({
  // 这里是同平常一样的 Vue 组件选项
  props: {},
  emits: {},
  template: `...`,

  // defineCustomElement 特有的:注入进 shadow root 的 CSS
  styles: [`/* inlined css */`]
})

// 注册自定义元素
// 注册之后,所有此页面中的 `<my-vue-element>` 标签
// 都会被升级
customElements.define('my-vue-element', MyVueElement)

// 你也可以编程式地实例化元素:
// (必须在注册之后)
document.body.appendChild(
  new MyVueElement({
    // 初始化 props(可选)
  })
)

上面就是 Vue2 & Vue3 在 构建一个 Web-Components 时的区别。


Vue3的实现

export function defineCustomElement(
  options: any,
  hydrate?: RootHydrateFunction
): VueElementConstructor {
  // defineComponent 就是Vue3中的定义组件的内容。
  const Comp = defineComponent(options as any)

  // 这里开始定义 
  class VueCustomElement extends VueElement {
    // 将原来的Vue组件存放到 def属性中。
    static def = Comp

    constructor(initialProps?: Record<string, any>) {
      /**
       * 组件
       * 属性
       * hydrate
       */
      super(Comp, initialProps, hydrate)
    }
  }

  return VueCustomElement
}
// 这里的BaseClass 就是 HTMLElement
export class VueElement extends BaseClass {
  /**
   * @internal
   */
  _instance: ComponentInternalInstance | null = null

  private _connected = false
  private _resolved = false
  private _numberProps: Record<string, true> | null = null
  private _styles?: HTMLStyleElement[]

  constructor(
    private _def: InnerComponentDef,
    private _props: Record<string, any> = {},
    hydrate?: RootHydrateFunction
  ) {
    super()
    if (this.shadowRoot && hydrate) {
      // hydrate模式 我之前没有了解过,暂不讨论
      hydrate(this._createVNode(), this.shadowRoot)
    } else {
      if (__DEV__ && this.shadowRoot) {
        warn(
          `Custom element has pre-rendered declarative shadow root but is not ` +
            `defined as hydratable. Use \`defineSSRCustomElement\`.`
        )
      }
      // 这里是创建一个 shadow root  可以直接通过this.shadowRoot访问
      this.attachShadow({ mode: 'open' })
    }
  }
  
  // 功能:当自定义元素第一次被连接到文档 DOM 时被调用
  // 
  connectedCallback() {
    // 修改对象上_connected属性
    this._connected = true
    // 判断有没有_instance 属性,没有的话执行_resolveDef方法
    if (!this._instance) {
      // 处理定义
      this._resolveDef()
    }
  }
  

  // 当自定义元素与文档 DOM 断开连接时被调用
  disconnectedCallback() {
    this._connected = false

    // vue的nextTick,待视图刷新后执行。
    nextTick(() => {
      if (!this._connected) {
        // vueRender 渲染函数,取消挂载
        render(null, this.shadowRoot!)
        this._instance = null
      }
    })
  }

  /**
   * resolve inner component definition (handle possible async component)
   */
  private _resolveDef() {
    // 如果被处理过则返回
    if (this._resolved) {
      return
    }
    // 加上处理标志
    this._resolved = true

    // set initial attrs
    // 处理出事的 attributes 处理对象上的 一些属性值
    // <component-1 key1="123" key2="234">
    for (let i = 0; i < this.attributes.length; i++) {
      this._setAttr(this.attributes[i].name)
    }

    // watch future attr changes
    // 监听 attributes的更新,更新的话会触发更新
    new MutationObserver(mutations => {
      for (const m of mutations) {
        this._setAttr(m.attributeName!) // typescript感叹号强调属性存在
      }
    }).observe(this, { attributes: true })

    const resolve = (def: InnerComponentDef) => {
      // 提取传入属性props 和 样式
      const { props, styles } = def
      const hasOptions = !isArray(props)
      // 取props key Array
      const rawKeys = props ? (hasOptions ? Object.keys(props) : props) : []

      // cast Number-type props set before resolve
      let numberProps
      // 如果props 不是数组形式。
      if (hasOptions) {
        // 前面 setProps 写入 到_props属性
        for (const key in this._props) {
          const opt = props[key]
          if (opt === Number || (opt && opt.type === Number)) {
            this._props[key] = toNumber(this._props[key])
            ;(numberProps || (numberProps = Object.create(null)))[key] = true
          }
        }
      }
      // 存放到 对象属性中,专门用来处理 属性为number类型的。
      this._numberProps = numberProps
      
      // check if there are props set pre-upgrade or connect
      for (const key of Object.keys(this)) {
        if (key[0] !== '_') {
          //非下划线 属性都加入到setProp中
          this._setProp(key, this[key as keyof this], true, false)
        }
      }

      // defining getter/setters on prototype
      // 给属性添加
      for (const key of rawKeys.map(camelize)) {
        Object.defineProperty(this, key, {
          get() {
            return this._getProp(key)
          },
          set(val) {
            this._setProp(key, val)
          }
        })
      }

      // apply CSS
      // 插入样式表
      this._applyStyles(styles)

      // initial render
      //渲染
      this._update()
    }

    const asyncDef = (this._def as ComponentOptions).__asyncLoader
    // 是否为异步组件
    if (asyncDef) {
      asyncDef().then(resolve)
    } else {
      resolve(this._def)
    }
  }
  /**
   * 设置Attr 
   */
  protected _setAttr(key: string) {
    let value = this.getAttribute(key)

    // 因为Vue 组件里的属性有可能 为 String,Numbers 所以这里会对props遍历判断属性,如果是数字类型的话会强转一下。
    if (this._numberProps && this._numberProps[key]) {
      value = toNumber(value)
    }
    // 给 shadowRoot 设置Props
    // 属性名转驼峰 设置属性值
    // 是否马上反应。 这个阶段不需要
    this._setProp(camelize(key), value, false)
  }

  /**
   * @internal
   */
  protected _getProp(key: string) {
    return this._props[key]
  }

  /**
   * @internal
   */
  protected _setProp(
    key: string,
    val: any,
    shouldReflect = true,
    shouldUpdate = true
  ) {
    // 判断如果属性改变时 会重新设置。
    if (val !== this._props[key]) {
      this._props[key] = val
      if (shouldUpdate && this._instance) {
        // 如果需要更新,那就更新
        this._update()
      }
      // reflect
      if (shouldReflect) {
        if (val === true) {
          this.setAttribute(hyphenate(key), '')
        } else if (typeof val === 'string' || typeof val === 'number') {
          this.setAttribute(hyphenate(key), val + '')
        } else if (!val) {
          this.removeAttribute(hyphenate(key))
        }
      }
    }
  }

  private _update() {
    
    render(this._createVNode(), this.shadowRoot!)
  }

  private _createVNode(): VNode<any, any> {
    // 
    const vnode = createVNode(this._def, extend({}, this._props))
    if (!this._instance) {
      vnode.ce = instance => {
        this._instance = instance
        instance.isCE = true
        // HMR
        if (__DEV__) {
          instance.ceReload = newStyles => {
            // always reset styles
            if (this._styles) {
              this._styles.forEach(s => this.shadowRoot!.removeChild(s))
              this._styles.length = 0
            }
            // 添加样式表
            this._applyStyles(newStyles)
            // if this is an async component, ceReload is called from the inner
            // component so no need to reload the async wrapper
            if (!(this._def as ComponentOptions).__asyncLoader) {
              // reload
              this._instance = null
              this._update()
            }
          }
        }

        // intercept emit
        // 出发事件
        instance.emit = (event: string, ...args: any[]) => {
          this.dispatchEvent(
            new CustomEvent(event, {
              detail: args
            })
          )
        }

        // locate nearest Vue custom element parent for provide/inject
        let parent: Node | null = this
        while (
          (parent =
            parent && (parent.parentNode || (parent as ShadowRoot).host))
        ) {
          if (parent instanceof VueElement) {
            instance.parent = parent._instance
            break
          }
        }
      }
    }
    return vnode
  }
  // 创建样式表
  private _applyStyles(styles: string[] | undefined) {
    if (styles) {
      styles.forEach(css => {
        const s = document.createElement('style')
        s.textContent = css
        this.shadowRoot!.appendChild(s)
        // record for HMR
        if (__DEV__) {
          ;(this._styles || (this._styles = [])).push(s)
        }
      })
    }
  }
}

Vue2源码


const camelizeRE = /-(\w)/g;
const camelize = str => {
  return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : '')
};

const hyphenateRE = /\B([A-Z])/g;
const hyphenate = str => {
  return str.replace(hyphenateRE, '-$1').toLowerCase()
};

// 获取初始化属性
function getInitialProps (propsList) {
  const res = {};
  propsList.forEach(key => {
    res[key] = undefined;
  });
  return res
}

// 注入钩子
function injectHook (options, key, hook) {
   // 多做一步
  options[key] = [].concat(options[key] || []);
  options[key].unshift(hook);
}


// 回调钩子
function callHooks (vm, hook) {
  if (vm) {
    const hooks = vm.$options[hook] || [];
    hooks.forEach(hook => {
      hook.call(vm);
    });
  }
}

function createCustomEvent (name, args) {
  return new CustomEvent(name, {
    bubbles: false,
    cancelable: false,
    detail: args
  })
}

// 判断是否位布尔值
const isBoolean = val => /function Boolean/.test(String(val));
// 判断是否位数字类型
const isNumber = val => /function Number/.test(String(val));

// 处理传入属性
function convertAttributeValue (value, name, { type } = {}) {
  if (isBoolean(type)) {
    if (value === 'true' || value === 'false') {
      return value === 'true'
    }
    if (value === '' || value === name || value != null) {
      return true
    }
    return value
  } else if (isNumber(type)) {
    const parsed = parseFloat(value, 10);
    return isNaN(parsed) ? value : parsed
  } else {
    return value
  }
}
// 转为VNodes数组
function toVNodes (h, children) {
  const res = [];
  for (let i = 0, l = children.length; i < l; i++) {
    res.push(toVNode(h, children[i]));
  }
  return res
}

// 单个VNode 生成
function toVNode (h, node) {
  if (node.nodeType === 3) {
    return node.data.trim() ? node.data : null
  } else if (node.nodeType === 1) {
    const data = {
      attrs: getAttributes(node),
      domProps: {
        innerHTML: node.innerHTML
      }
    };
    if (data.attrs.slot) {
      data.slot = data.attrs.slot;
      delete data.attrs.slot;
    }
    return h(node.tagName, data)
  } else {
    return null
  }
}

// 获取属性上的属性,并返回一个对象
function getAttributes (node) {
  const res = {};
  for (let i = 0, l = node.attributes.length; i < l; i++) {
    const attr = node.attributes[i];
    res[attr.nodeName] = attr.nodeValue;
  }
  return res
}

// wrap 使用的方法。
// 传入Vue , 传入我们要打包的对象
function wrap (Vue, Component) {
  const isAsync = typeof Component === 'function' && !Component.cid;
  let isInitialized = false;
  let hyphenatedPropsList; // 横线连接props  xxx-xxxx
  let camelizedPropsList; // 驼峰props  如: collectionHandler
  let camelizedPropsMap; // 存放props 的map

  // 初始化方法,传入组件;
  function initialize (Component) {
    //  如果初始化过了就直接return
    if (isInitialized) return

    // 判断传入的components 是否位方法,如果是的话 就读取options, 不是就传入值就为Component
    const options = typeof Component === 'function'
      ? Component.options
      : Component;

    // extract props info
    const propsList = Array.isArray(options.props)
      ? options.props
      : Object.keys(options.props || {});
    hyphenatedPropsList = propsList.map(hyphenate); //连线props
    camelizedPropsList = propsList.map(camelize); // 小驼峰props


    const originalPropsAsObject = Array.isArray(options.props) ? {} : options.props || {};
    camelizedPropsMap = camelizedPropsList.reduce((map, key, i) => {
      map[key] = originalPropsAsObject[propsList[i]];
      return map
    }, {});

    // proxy $emit to native DOM events
    injectHook(options, 'beforeCreate', function () {
      const emit = this.$emit;
      // 
      this.$emit = (name, ...args) => {
        // 初始化事件
        this.$root.$options.customElement.dispatchEvent(createCustomEvent(name, args));
        return emit.call(this, name, ...args)
      };
    });

    injectHook(options, 'created', function () {
      // sync default props values to wrapper on created
      // created 时写属性,除了触发原来created里的内容 还要往 $root链上写属性。
      camelizedPropsList.forEach(key => {
        this.$root.props[key] = this[key];
      });
    });

    // proxy props as Element properties
    camelizedPropsList.forEach(key => {
      Object.defineProperty(CustomElement.prototype, key, {
        get () {
          return this._wrapper.props[key]
        },
        set (newVal) {
          this._wrapper.props[key] = newVal;
        },
        enumerable: false,
        configurable: true
      });
    });

    isInitialized = true;
  }

  function syncAttribute (el, key) {
    const camelized = camelize(key);
    const value = el.hasAttribute(key) ? el.getAttribute(key) : undefined;
    console.log('syncAttribute',value)
    console.log('syncAttribute',camelizedPropsMap[camelized])
    el._wrapper.props[camelized] = convertAttributeValue(
      value,
      key,
      camelizedPropsMap[camelized]
    );
  }

  class CustomElement extends HTMLElement {
    constructor () {
      const self = super();
      self.attachShadow({ mode: 'open' });

      const wrapper = self._wrapper = new Vue({
        name: 'shadow-root',
        customElement: self,
        shadowRoot: self.shadowRoot,
        data () {
          return {
            props: {},
            slotChildren: []
          }
        },
        render (h) {
          return h(Component, {
            ref: 'inner',
            props: this.props
          }, this.slotChildren)
        }
      });
      
      // Use MutationObserver to react to future attribute & slot content change
      const observer = new MutationObserver(mutations => {
        let hasChildrenChange = false;
        console.log('mutations',mutations)
        for (let i = 0; i < mutations.length; i++) {
          const m = mutations[i];
          if (isInitialized && m.type === 'attributes' && m.target === self) {
            syncAttribute(self, m.attributeName);
            console.log(1233333);
          } else {
            hasChildrenChange = true;
          }
        }
        if (hasChildrenChange) {
          wrapper.slotChildren = Object.freeze(toVNodes(
            wrapper.$createElement,
            self.childNodes
          ));
        }
      });
      // 监听属性变化
      observer.observe(self, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: true
      });
    }

    get vueComponent () {
      return this._wrapper.$refs.inner
    }

    connectedCallback () {
      const wrapper = this._wrapper;
      if (!wrapper._isMounted) {
        // initialize attributes
        const syncInitialAttributes = () => {
          wrapper.props = getInitialProps(camelizedPropsList);
          console.log(camelizedPropsList)
          console.log(hyphenatedPropsList)
          hyphenatedPropsList.forEach(key => {
            syncAttribute(this, key);
           
          });
        };

        if (isInitialized) {
          syncInitialAttributes();
        } else {
          // async & unresolved
          Component().then(resolved => {
            if (resolved.__esModule || resolved[Symbol.toStringTag] === 'Module') {
              resolved = resolved.default;
            }
            initialize(resolved);
            syncInitialAttributes();
          });
        }
        // initialize children
        wrapper.slotChildren = Object.freeze(toVNodes(
          wrapper.$createElement,
          this.childNodes
        ));
        wrapper.$mount();
        this.shadowRoot.appendChild(wrapper.$el);
      } else {
        callHooks(this.vueComponent, 'activated');
      }
    }

    disconnectedCallback () {
      callHooks(this.vueComponent, 'deactivated');
    }
  }

  if (!isAsync) {
    initialize(Component);
  }

  return CustomElement
}

export default wrap;

可以看出两者在打包成WC时的区别

总结

今天为大家带来了讲Vue2&Vue3组件打包成Web-Components的区别,下次有空的话 会细说下 在一些 customElment的封装 到底有哪些细节与不同的地方。(目前Vue3这个源码,我还不是看得很熟、很懂,)