初步理解
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这个源码,我还不是看得很熟、很懂,)