本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
虚拟 dom 中我们按照 vue 本身的目录接口进行了整理,通过 render 函数返回虚拟 dom 最终完成页面的渲染。这篇文章,我们来实现自定义组件。
整体思路
我们需要完成三件事情:
生成自定义组件对应的虚拟
dom通过自定义组件的虚拟
dom来生成浏览器的dom自定义组件的响应式
最终我们要把下边的例子跑起来:
import Vue from "./src/platforms/web/entry-runtime";const Hello = { props: { title: String, }, data() { return { text: "component world", }; }, methods: { click() { this.text = ",component world"; }, }, render(h) { return h( "div", { on: { click: this.click, }, }, [this.title, this.text] ); },};new Vue({ el: "#root", data() { return { text: "world", title: "hello", }; }, components: { Hello }, methods: { click() { this.title = "hello2"; // this.text = "hello2"; }, }, render(createElement) { const test = createElement( "div", { on: { // click: this.click, }, }, [ createElement("Hello", { props: { title: this.title } }), this.text, ] ); return test; },});我们定义了一个 Hello 组件,在 new Vue 中的 components 中声明该组件,然后在 render 函数的 createElement 中直接使用 Hello 组件,并且传入 prop 的 title 值。
生成虚拟 dom
虚拟 dom 是由 render 函数传入的 createElement 生成的。
对应的源码就是:
// code/22.Vue2剥丝抽茧-虚拟dom之组件/src/core/vdom/create-element.jsexport function _createElement(context, tag, data, children) { if (!tag) { // in case of component :is set to falsy value return createEmptyVNode(); } if (Array.isArray(data) || isPrimitive(data)) { children = data; data = undefined; } children = normalizeChildren(children); let vnode = new VNode(tag, data, children, undefined, undefined, context); return vnode;}为了适配自定义组件,生成 VNode 的时候我们需要新增判断,如果 tag 是 dom 的 tag 就走原逻辑,否则的话就去走到创建组件的 vnode 的逻辑。
export function _createElement(context, tag, data, children) { if (!tag) { // in case of component :is set to falsy value return createEmptyVNode(); } if (Array.isArray(data) || isPrimitive(data)) { children = data; data = undefined; } children = normalizeChildren(children); let vnode; let Ctor; if (config.isReservedTag(tag)) { vnode = new VNode(tag, data, children, undefined, undefined, context); } else if ( isDef((Ctor = resolveAsset(context.$options, "components", tag))) ) { // component vnode = createComponent(Ctor, data, context, children, tag); } return vnode;}isReservedTag
判断是否是 dom 原生 tag 的 isReservedTag 函数定义如下:
export const isHTMLTag = makeMap( "html,body,base,head,link,meta,style,title," + "address,article,aside,footer,header,h1,h2,h3,h4,h5,h6,hgroup,nav,section," + "div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul," + "a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby," + "s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video," + "embed,object,param,source,canvas,script,noscript,del,ins," + "caption,col,colgroup,table,thead,tbody,td,th,tr," + "button,datalist,fieldset,form,input,label,legend,meter,optgroup,option," + "output,progress,select,textarea," + "details,dialog,menu,menuitem,summary," + "content,element,shadow,template,blockquote,iframe,tfoot");// this map is intentionally selective, only covering SVG elements that may// contain child elements.export const isSVG = makeMap( "svg,animate,circle,clippath,cursor,defs,desc,ellipse,filter,font-face," + "foreignobject,g,glyph,image,line,marker,mask,missing-glyph,path,pattern," + "polygon,polyline,rect,switch,symbol,text,textpath,tspan,use,view", true);export const isReservedTag = (tag) => { return isHTMLTag(tag) || isSVG(tag);};export function makeMap(str, expectsLowerCase) { const map = Object.create(null); const list = str.split(","); for (let i = 0; i < list.length; i++) { map[list[i]] = true; } return expectsLowerCase ? (val) => map[val.toLowerCase()] : (val) => map[val];}很简单粗暴,枚举了所有的 tag ,然后通过 makeMap 生成一个 map 进行判断。
resolveAsset
if (config.isReservedTag(tag)) { vnode = new VNode(tag, data, children, undefined, undefined, context);} else if ( isDef((Ctor = resolveAsset(context.$options, "components", tag)))) {}本质上就是从 new Vue 传入的 options 的 components 属性中拿到当前 tag 对应的 options 对象。
export function resolveAsset(options, type, id) { /* istanbul ignore if */ if (typeof id !== "string") { return; } const assets = options[type]; // check local registration variations first if (hasOwn(assets, id)) return assets[id]; const camelizedId = camelize(id); if (hasOwn(assets, camelizedId)) return assets[camelizedId]; const PascalCaseId = capitalize(camelizedId); if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]; // fallback to prototype chain const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; return res;}先判断当前 options['components'] 有没有我们要的 tag 。
然后将 tag 调用 camelize 驼峰化继续寻找,将形如 abc-def-ghi 的名字转为 abcDefGhi 。
如果还没找到,就调用 capitalize 将驼峰命名转为帕斯卡命名,也就是将驼峰的首字母大写,abcDefGhi 转为 AbcDefGhi 继续寻找。
camelize 方法利用 replace 方法结合正则来转换,比较巧妙。
/** * Create a cached version of a pure function. */export function cached(fn) { const cache = Object.create(null); return function cachedFn(str) { const hit = cache[str]; return hit || (cache[str] = fn(str)); };}/** * Camelize a hyphen-delimited string. */const camelizeRE = /-(\w)/g;export const camelize = cached((str) => { return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ""));});createComponent
if (config.isReservedTag(tag)) { vnode = new VNode(tag, data, children, undefined, undefined, context);} else if ( isDef((Ctor = resolveAsset(context.$options, "components", tag)))) { // component vnode = createComponent(Ctor, data, context, children, tag);}拿到组件对应的 options 后,我们就可以调用 createComponent 函数来生成 vnode 了。
// code/22.Vue2剥丝抽茧-虚拟dom之组件/src/core/vdom/create-component.jsexport function createComponent(Ctor, data, context, children, tag) { if (isUndef(Ctor)) { return; } const baseCtor = context.$options._base; // plain options object: turn it into a constructor if (isObject(Ctor)) { Ctor = baseCtor.extend(Ctor); } data = data || {}; // extract props const propsData = extractPropsFromVNodeData(data, Ctor, tag); // extract listeners, since these needs to be treated as // child component listeners instead of m DOM listeners const listeners = data.on; // replace with listeners with .native modifier // so it gets processed during parent component patch. data.on = data.nativeOn; // install component management hooks onto the placeholder node installComponentHooks(data); // return a placeholder vnode const name = Ctor.options.name || tag; const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ""}`, data, undefined, undefined, undefined, context, { Ctor, propsData, listeners, tag, children } ); return vnode;}重要的有四个点:
生成
Ctor构造函数。const baseCtor = context.$options._base;// plain options object: turn it into a constructorif (isObject(Ctor)) { Ctor = baseCtor.extend(Ctor);}context.$options._base其实就是Vue构造函数,在code/22.Vue2剥丝抽茧-虚拟dom之组件/src/core/global-api/index.js中进行初始化的,Vue.options._base = Vue;。这里的
extend方法就是基于Vue构造函数,生成一个VueComponent函数。Vue.extend = function (extendOptions) { extendOptions = extendOptions || {}; const Super = this; const SuperId = Super.cid; const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {}); if (cachedCtors[SuperId]) { return cachedCtors[SuperId]; } const name = extendOptions.name || Super.options.name; const Sub = function VueComponent(options) { this._init(options); }; Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub; Sub.cid = cid++; Sub.options = mergeOptions(Super.options, extendOptions); Sub["super"] = Super; // For props and computed properties, we define the proxy getters on // the Vue instances at extension time, on the extended prototype. This // avoids Object.defineProperty calls for each instance created. if (Sub.options.props) { initProps(Sub); } if (Sub.options.computed) { initComputed(Sub); } // allow further extension/mixin/plugin usage Sub.extend = Super.extend; Sub.mixin = Super.mixin; Sub.use = Super.use; // create asset registers, so extended classes // can have their private assets too. ASSET_TYPES.forEach(function (type) { Sub[type] = Super[type]; }); // enable recursive self-lookup if (name) { Sub.options.components[name] = Sub; } // keep a reference to the super options at extension time. // later at instantiation we can check if Super's options have // been updated. Sub.superOptions = Super.options; Sub.extendOptions = extendOptions; Sub.sealedOptions = extend({}, Sub.options); // cache constructor cachedCtors[SuperId] = Sub; return Sub;};最关键的就是定义了
Sub函数,然后返回,这个函数会在生成dom的时候用到。const Sub = function VueComponent(options) { this._init(options);};其实和我们的
Vue函数长的一样,都是调用_init方法。function Vue(options) { this._init(options);}此外,还对
Props进行了代理:function initProps(Comp) { const props = Comp.options.props; for (const key in props) { proxy(Comp.prototype, `_props`, key); }}当我们访问
this.xxx的时候,会取到this._props.xxx,和 响应式系统 中介绍的对data、methods进行代理是一个意思。传递
props的值我们在子组件中定义了
props声明。props: { title: String,},具体的
title值就是通过extractPropsFromVNodeData来拿到。const propsData = extractPropsFromVNodeData(data, Ctor, tag);export function extractPropsFromVNodeData(data, Ctor, tag) { // 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)) { return; } const res = {}; const { attrs, props } = data; if (isDef(attrs) || isDef(props)) { for (const key in propOptions) { const altKey = hyphenate(key); checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false); } } return res;}function checkProp(res, hash, key, altKey, preserve) { if (isDef(hash)) { 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;}其中也有一个有意思的正则
hyphenate函数:/** * Hyphenate a camelCase string. */const hyphenateRE = /\B([A-Z])/g;export const hyphenate = cached((str) => { return str.replace(hyphenateRE, "-$1").toLowerCase();});它会将驼峰表达式转为连字符相连并且全部小写的形式,例如
abcDefGhi转为abd-def-ghi。安装钩子函数
installComponentHooks(data);function installComponentHooks(data) { const hooks = data.hook || (data.hook = {}); for (let i = 0; i < hooksToMerge.length; i++) { const key = hooksToMerge[i]; const existing = hooks[key]; const toMerge = componentVNodeHooks[key]; if (existing !== toMerge && !(existing && existing._merged)) { hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge; } }}function mergeHook(f1, f2) { 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;}把
data中传入的和我们当前定义的hooksToMerge进行了合并。// inline hooks to be invoked on component VNodes during patchconst componentVNodeHooks = { init(vnode) { const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance)); child.$mount(); }, prepatch(oldVnode, vnode) { const options = vnode.componentOptions; const child = (vnode.componentInstance = oldVnode.componentInstance); updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children ); },};const hooksToMerge = Object.keys(componentVNodeHooks);提前定义了两个钩子,
init用来生成dom,prepatch用来更新prop,后边这两个函数都会用到。生成
vnode,将参数{ Ctor, propsData, listeners, tag, children }都放到了componentOptions中。const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ""}`, data, undefined, undefined, undefined, context, { Ctor, propsData, listeners, tag, children } );对应的
VNode构造函数:constructor( tag, data, children, text, elm, context, componentOptions, asyncFactory )
至此 createElement 主干就介绍完了,未来调用 render 方法就会走到上边的逻辑来生成组件对应的虚拟 dom 。
render(createElement) { const test = createElement( "div", { on: { // click: this.click, }, }, [ createElement("Hello", { props: { title: this.title } }), this.text, ] ); return test;},生成 dom
通过之前的系列文章,我们知道 dom 生成是在 patch 函数中生成。
function patch(oldVnode, vnode) { const isRealElement = isDef(oldVnode.nodeType); if (!isRealElement && sameVnode(oldVnode, vnode)) { // 通过新旧 vnode 进行更新 patchVnode(oldVnode, vnode); } else { // vnode 发生改变或者是第一次渲染 if (isRealElement) { // either not server-rendered, or hydration failed. // create an empty node and replace it oldVnode = emptyNodeAt(oldVnode); } // replacing existing element const oldElm = oldVnode.elm; const parentElm = nodeOps.parentNode(oldElm); // create new node createElm(vnode, parentElm, nodeOps.nextSibling(oldElm)); removeVnodes([oldVnode], 0, 0); } return vnode.elm;}对于组件,第一次是没有 oldVnode 的,因此我们需要加 if 条件来判断一下:
function patch(oldVnode, vnode) { if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element createElm(vnode); } else { .... } return vnode.elm;}接下来继续完善 createElm 函数。
function createElm(vnode, parentElm, refElm) { const data = vnode.data; // dom 相关的属性都放到 data 中 const children = vnode.children; const tag = vnode.tag; if (isDef(tag)) { vnode.elm = nodeOps.createElement(tag); createChildren(vnode, children); if (isDef(data)) { invokeCreateHooks(vnode); } insert(parentElm, vnode.elm, refElm); } else { vnode.elm = nodeOps.createTextNode(vnode.text); insert(parentElm, vnode.elm, refElm); }}原来的 createElm 只考虑了 vnode 是正常的 dom,比如 div、span 标签这种,没有考虑自定义组件。
我们需要在 createElm 开头尝试生成自定义组件的 dom ,生成成功就直接 return 。
function createElm(vnode, parentElm, refElm) { if (createComponent(vnode, parentElm, refElm)) { return; } ...}重点就是 createComponent 函数的实现了
function createComponent(vnode, parentElm, refElm) { let i = vnode.data; if (isDef(i)) { 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)) { initComponent(vnode); insert(parentElm, vnode.elm, refElm); return true; } }}首先就是拿到我们生成 vnode 时候的 init 钩子进行调用。
if (isDef((i = i.hook)) && isDef((i = i.init))) { i(vnode, false /* hydrating */);}再看一下之前的 init 方法。
init(vnode) { const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance)); child.$mount();},export function createComponentInstanceForVnode( // we know it's MountedComponentVNode but flow doesn't vnode, // activeInstance in lifecycle state parent) { const options = { _isComponent: true, _parentVnode: vnode, parent, }; return new vnode.componentOptions.Ctor(options);}最终会调用 vnode.componentOptions.Ctor 方法,其实就是前边定义的 VueComponent 方法。
const Sub = function VueComponent(options) { this._init(options);};这里的 _init 就是 Vue 上的 _init 了。
Vue.prototype._init = function (options) { const vm = this; if (options && options._isComponent) { // optimize internal component instantiation // since dynamic options merging is pretty slow, and none of the // internal component options needs special treatment. initInternalComponent(vm, options); } else { vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor), options || {}, vm ); } vm._renderProxy = vm; initRender(vm); initState(vm); if (vm.$options.el) { vm.$mount(vm.$options.el); }};如果是 options._isComponent 为真,我们就调用 initInternalComponent 方法。
export function initInternalComponent(vm, options) { const opts = (vm.$options = Object.create(vm.constructor.options)); // doing this because it's faster than dynamic enumeration. const parentVnode = options._parentVnode; opts.parent = options.parent; opts._parentVnode = parentVnode; const vnodeComponentOptions = parentVnode.componentOptions; opts.propsData = vnodeComponentOptions.propsData; opts._parentListeners = vnodeComponentOptions.listeners; opts._renderChildren = vnodeComponentOptions.children; opts._componentTag = vnodeComponentOptions.tag; if (options.render) { opts.render = options.render; opts.staticRenderFns = options.staticRenderFns; }}主要是将 componentOptions 上的各种属性,挂到当前组件的 options 上。
最终我们拿到了当前组件对应的 Vue 实例,调用 mount 方法进行挂载。
init(vnode) { const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance)); child.$mount();},mount 方法就是调用 render ,生成 dom 同时进行依赖收集。
export function mountComponent(vm, el) { vm.$el = el; let updateComponent; updateComponent = () => { vm._update(vm._render()); }; // we set this to vm._watcher inside the watcher's constructor // since the watcher's initial patch may call $forceUpdate (e.g. inside child // component's mounted hook), which relies on vm._watcher being already defined new Watcher(vm, updateComponent, noop /* isRenderWatcher */); return vm;}回到最开始的 createComponent 函数,上边的一大堆其实就是调用了 i 函数,将自定义组件的 vnode 变成了 dom 。
function createComponent(vnode, parentElm, refElm) { let i = vnode.data; if (isDef(i)) { 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)) { initComponent(vnode); insert(parentElm, vnode.elm, refElm); return true; } }}function initComponent(vnode) { vnode.elm = vnode.componentInstance.$el; if (isPatchable(vnode)) { invokeCreateHooks(vnode); }}最后我们只需要将生成 dom 通过 insert 方法挂到父 dom 中。
if (isDef(vnode.componentInstance)) { initComponent(vnode); insert(parentElm, vnode.elm, refElm); return true;}组件响应式
内部响应式
对于组件内部的 data、computed ,因为我们给组件生成了一个 Vue 实例,init 的时候已经进行了响应式的处理。
这里我们只需要补充一下对 prop 的响应式处理。
// code/22.Vue2剥丝抽茧-虚拟dom之组件/src/core/instance/state.jsexport function initState(vm) { const opts = vm.$options; if (opts.props) initProps(vm, opts.props); if (opts.methods) initMethods(vm, opts.methods); if (opts.data) { initData(vm); } else { observe((vm._data = {})); } if (opts.computed) initComputed(vm, opts.computed); if (opts.watch) { initWatch(vm, opts.watch); }}function initProps(vm, propsOptions) { debugger; var propsData = vm.$options.propsData || {}; const props = (vm._props = {}); const keys = (vm.$options._propKeys = []); for (const key in propsOptions) { keys.push(key); const value = propsData[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)) { proxy(vm, `_props`, key); } }}通过 defineReactive(props, key, value); 就实现了对于 prop 更改的响应式。
外部响应式
如果我们给组件传递的值是 data 中的变量,
new Vue({ el: "#root", data() { return { text: "world", title: "hello", }; }, components: { Hello }, ... render(createElement) { const test = createElement( "div", { on: { // click: this.click, }, }, [ createElement("Hello", { props: { title: this.title } }), this.text, ] ); return test; },});title 变化的时候,我们需要更新组件内部的 prop 的值,从而触发内部组件的更新。
当 title 更新的时候,会进行新旧 vnode 的对比来更新 dom,即走到 patchVnode 方法。
function patchVnode(oldVnode, vnode) { if (oldVnode === vnode) { return; } const elm = (vnode.elm = oldVnode.elm); const oldCh = oldVnode.children; const ch = vnode.children; const data = vnode.data; if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); } if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch); } else if (isDef(ch)) { addVnodes(elm, null, ch, 0, ch.length - 1); } else if (isDef(oldCh)) { removeVnodes(oldCh, 0, oldCh.length - 1); } } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text); }}我们可以将内部组件 prop 更新放到函数开头,也就是调用之前的 prepatch 的钩子。
function patchVnode(oldVnode, vnode) { if (oldVnode === vnode) { return; } const elm = (vnode.elm = oldVnode.elm); const oldCh = oldVnode.children; const ch = vnode.children; const data = vnode.data; if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) { i(oldVnode, vnode); } ...} // i 方法对应于下边的 prepatch prepatch(oldVnode, vnode) { const options = vnode.componentOptions; const child = (vnode.componentInstance = oldVnode.componentInstance); updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children ); },updateChildComponent 定义如下:
// code/22.Vue2剥丝抽茧-虚拟dom之组件/src/core/instance/lifecycle.jsexport function updateChildComponent( vm, propsData, listeners, parentVnode, renderChildren) { vm.$options._parentVnode = parentVnode; vm.$vnode = parentVnode; // update vm's placeholder node without re-render if (vm._vnode) { // update child tree's parent vm._vnode.parent = parentVnode; } vm.$options._renderChildren = renderChildren; // update $attrs and $listeners hash // these are also reactive so they may trigger child update if the child // used them during render vm.$attrs = parentVnode.data.attrs || emptyObject; vm.$listeners = listeners || emptyObject; // update props if (propsData && vm.$options.props) { const props = vm._props; const propKeys = vm.$options._propKeys || []; for (let i = 0; i < propKeys.length; i++) { const key = propKeys[i]; props[key] = propsData[key]; } // keep a copy of raw propsData vm.$options.propsData = propsData; }}当我们更新 props 值的时候就会触发内部组件的 render 函数,实现视图的更新。
总
对于自定义组件本质上是通过 Vue 生成了一个对象实例,该对象自己内部完成 dom 的渲染和响应式更新。然后在父组件适当的位置,通过预先定义的钩子函数去初始化和更新子组件。
核心思想比较简单,主要是各个函数抽离到了各个地方,不同属性挂载的地方也很分散,理顺整个过程到最后 demo 跑起来还是花了不少时间。
至此,虚拟 dom 也全部介绍完成了,下一篇章会开始「模版编译」了。
文本对应源码详见:vue.windliang.wang