vue + webcomponents 丝滑体验

1,883 阅读5分钟

Web Components标准已经出来很久了
浏览器现在支持的程度也不错

但是缺少诸如数据响应,属性传递限制等等
所以现在还不是很火热

本质上Web Components定义好后,就是一个HTML Element
vue 或者react angula等框架中都是可以直接用的
并且还能跨框架使用 🙉

我们以 vue 为例,看看整个解析过程是怎么样的

写一个customElements

我们从 mdn 的 web-components-examples 中找一个 web-components

就是很简单的一个demo,显示一个文字,点击就可以编辑


customElements.define('edit-span',
  class extends HTMLElement {
    constructor() {
      super();
    }
    
    connectedCallback() {
      const shadowRoot = this.attachShadow({ mode: 'open' });
      const form = document.createElement('form');
      const input = document.createElement('input');
      const span = document.createElement('span');

      const style = document.createElement('style');
      style.textContent = 'span { background-color: #eef; padding: 0 2px }';

      shadowRoot.appendChild(style);
      shadowRoot.appendChild(form);
      shadowRoot.appendChild(span);

      span.textContent = this.textContent;
      input.value = this.textContent;

      form.appendChild(input);
      form.style.display = 'none';
      span.style.display = 'inline-block';
      input.style.width = span.clientWidth + 'px';

      this.setAttribute('tabindex', '0');
      input.setAttribute('required', 'required');
      this.style.display = 'inline-block';

      this.addEventListener('click', () => {
        span.style.display = 'none';
        form.style.display = 'inline-block';
        input.focus();
        input.setSelectionRange(0, input.value.length)
      });

      form.addEventListener('submit', e => {
        updateDisplay();
        e.preventDefault();
      });

      input.addEventListener('blur', updateDisplay);

      function updateDisplay() {
        span.style.display = 'inline-block';
        form.style.display = 'none';
        span.textContent = input.value;
        input.style.width = span.clientWidth + 'px';
      }
    }
  }
);

然后我们在vue中引用一下

<template>
  <div>
    my name is <edit-span>foo</edit-span>
  </div>
</template>

这里我们改写了一下demo,将创建子元素工作在connectedCallback中完成
不然你会得到如下错误

Uncaught DOMException: Failed to construct 'CustomElement': The result must not have attributes

这是因为当前节点还没创建完,不应该操作自身属性或者子节点
WHATWG规范也强调了这点,并且建议在connectedCallback中完成

  • The element's attributes and children must not be inspected, as in the non-upgrade case none will be present, and relying on upgrades makes the element less usable.

  • The element must not gain any attributes or children, as this violates the expectations of consumers who use the createElement or createElementNS methods.

好了, 不出意外我们会得到如下效果

嗯一切都很正常,因为 vue 中创建节点时
首先会尝试创建vue组件

    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

这里edit-span并没有注册在components内
所以将会继续往下走,根据平台的创建节点方法来生成节点
(web平台就是document.createElement方法)

nodeOps.createElement(tag, vnode);

接着被 insert 到文档中,我们就能看到了

这里如果我们没有注册自定义元素,会得到如下警告

这里判断也很有意思

  if (tag.indexOf('-') > -1) {
    // http://stackoverflow.com/a/28210364/1070244
    return (unknownElementCache[tag] = (
      el.constructor === window.HTMLUnknownElement ||
      el.constructor === window.HTMLElement
    ))
  }

如果是自定义元素(规范自定义元素必须用"-"分割)
并且我们通过customElements.define注册了
它的 constructor 则是我们自定义的class

如果没有customElements.define则是window.HTMLElement
如果是没有带"-"的元素,并且是html未知元素
它的constructor则是window.HTMLUnknownElement

text node 处理

好了,一切看起来都正常了,不过不对,我们的数据是写死的
实际上当然是动态的内容,我们改一下代码

<template>
  <div>
    <section>
      my name is
      <edit-span>{{name}}</edit-span>
    </section>
    <section>
      <button @click="name='bar'">change</button>
    </section>
  </div>
</template>

期望是 button 触发修改 name 为 bar
但是我们会发现没效果,html中text确实被修改了
但是span和input中的文字还是旧的

仔细一想,也对,我们自定义的元素只是初始化的时候,读取了文本然后创建span和input
webcomponents有监听自身属性变更的方法

static get observedAttributes() { return ['attr1', 'attr2']; }

但是却不能监听子文本节点修改

陷入深思……

不过再来回想一下,vue触发文本节点更新
patch 的时候发现是 sameNode -> patchVnode -> 最终发现文本节点 oldVnode.text !== vnode.text 于是触发

nodeOps.setTextContent(elm, vnode.text);

这里最终会调用平台的setTextContent
web下就是

elm.textContent = vnode.text  

那就好办,我们重写一下我们的文本节点元素的 textContent 就行咯

customElements.define('edit-span',
  class extends HTMLElement {
    ...
    connectedCallback() {
        ...
        Object.defineProperty(this.childNodes[0], "textContent", {
        set(value) {
          span.textContent = value;
        }
      })
        ...
    }
    ...
}
);

emm…… 功能是实现了,不过真的好蹩脚啊

完全不推荐上面这种写法,纯粹是我无聊... 应该改写成 <edit-span :value="name"></edit-span>

props

好了回到正轨...
我们改成属性形式

<edit-span :value="name"></edit-span>

然后再来修改下组件监听和修改

customElements.define('edit-span',
  class extends HTMLElement {
    ...
    static get observedAttributes() { return ['value']; }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === "value" && this.shadowRoot) {
        this.shadowRoot.querySelector("span").textContent = newValue;
        this.shadowRoot.querySelector("input").value = newValue;
      }
    }
    ...
}
);

ok,这里就是常规操作
不同于vue组件的props更新会通过prepatch触发子组件的props更新
普通html节点会由attr的update钩子 直接触发 updateAttrs

function setAttr (el, key, value) {
  if (el.tagName.indexOf('-') > -1) {
    baseSetAttr(el, key, value);
  }...
}



function baseSetAttr (el, key, value) {
    ...
    el.setAttribute(key, value);
}

可以看到vue这里为了减少判断,如果是自定义元素
就直接触发baseSetAttr,然后就setAttribute了

所以我们自定义元素的属性更新就能触发回调
然后更新DOM了

事件 event

那么接下来就只有最后一件事情了,就是我们自定义元素更新之后
要通知vue,这是很重要的,尤其对于一个mvvm框架来说

当然,这个也完全没有问题
vue中会通过even的create和update钩子来触发事件的创建和更新


function updateDOMListeners (oldVnode, vnode) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  var on = vnode.data.on || {};
  var oldOn = oldVnode.data.on || {};
  target$1 = vnode.elm;
  normalizeEvents(on);
  updateListeners(on, oldOn, add$1, remove$2, createOnceHandler$1, vnode.context);
  target$1 = undefined;
}

var events = {
  create: updateDOMListeners,
  update: updateDOMListeners
};

所以,我们自定义的元素如果添加一个事件

      <edit-span :value="name" @change="change"></edit-span>
  methods: {
    change(e) {
      console.log("from <edit-span>:" + e.detail.value);
    }
  }

vue这里会调用平台的创建事件方法,web下就是addEventListener

target$1.addEventListener(
    name,
    handler,
    supportsPassive
      ? { capture: capture, passive: passive }
      : capture
  );

于是乎,我们只要在自定义元素内触发这个事件就行
再来改写下

      const _this = this;
      function updateDisplay() {
        span.style.display = 'inline-block';
        form.style.display = 'none';
        span.textContent = input.value;
        input.style.width = span.clientWidth + 'px';

        const event = new CustomEvent('change', {
          detail: {
            value: input.value
          },
          bubbles: true,
          cancelable: true,
        });
        _this.dispatchEvent(event);
      }

当我们修改了input后,直接触发dispatchEvent,然后把value传递过去
来试下

ok了

最后

如果没有修改textContent这种骚操作
vue当中使用webcomponents 是丝滑无比的(本来就是一个HTML Element..)
我就是标题党无误了

丝滑体验github地址:点我