petite-vue源码学习之其他常用指令

471 阅读6分钟

其他常用指令

本篇是petite-vue源码研究文章系列的第五篇,以前的文章可点击查看,今天还是接着分析一下其他常用指令,例如v-model/v-bind等指令的实现,本篇完结后就不再对指令进行讲解了,后面将梳理petite-vue的流程与设计,为这个系列画上一个句号,废话不多说,就开始今天的主题吧。

v-model

v-model指令提供了双向数据绑定的能力,在很多场景中提供了极大的便利,先回顾一下vue官网对v-model的介绍吧:

  • text 和 textarea 元素使用 value property 和 input 事件;
  • checkbox 和 radio 使用 checked property 和 change 事件;
  • select 字段将 value 作为 prop 并将 change 作为事件。

说得很清楚,就是为不同的表单元素绑定不同的响应事件,当目标对象状态发生变化,同步更新上下文的scope对象,scope对象由于是响应式的,因此可以触发其他依赖副作用,例如<input v-model="text" />,其实效果就雷同下面这个代码片段:

<input type="text">
<script type="text/javascript">
	const input = document.querySelector('input');
	const data = {
		_text: '',
		get text() {
			return this._text;
		},
		set text(newText) {
			this._text = newText;
			input.value = (this._text).toUpperCase(); // 为了显示同步效果,转换成大写
		}
	};
	input.addEventListener('input', (e) => {
		data.text = e.target.value;
	});
</script>

经过小小的例子,应该对v-model有个初步的印象了,其实最重要的作用就是事件与状态的关联同步,接下来分几个部分对其进行分析。

1.延迟执行

在walk函数中,当解析到v-model指令时,其实是不会立刻执行这个指令的相关逻辑的,相反,推迟到目标元素的所有其他指令执行完毕才会执行,源代码有一句注释defer v-model since it relies on :value bindings to be processed first,这段逻辑对应的代码如下:

let deferredModel
for (const { name, value } of [...el.attributes]) {
  if (dirRE.test(name) && name !== 'v-cloak') {
    if (name === 'v-model') {
      // defer v-model since it relies on :value bindings to be processed
      // first
      deferredModel = value
    } else {
      processDirective(el, name, value, ctx)
    }
  }
}
if (deferredModel) {
  processDirective(el, 'v-model', deferredModel, ctx)
}

推迟v-model的原因已经很明确了,刚开始还是有点想不明白,感觉不太需要延迟吧,但是想想下面这种情况:

<inut v-model="text" :type="type">

type不一样,会导致v-model的处理逻辑是不一样的,因此必须等到type绑定完毕才能执行v-model指令,因此这样设计是合理的。

2.事件绑定

前面了解到,针对不同的表单元素,需要绑定不同的事件,因此首先要判断类型,因此可以在源码中发现这样的判断:

const type = el.type;
if (el.tageName === 'SELECT') {
	listen(el, 'change', () => {
		...
	});
	effect(() => {
		...
	});
} else if (type === 'checkbox') {
	listen(el, 'change', () => {
		...
	});
	effect(() => {
		...
	});
} else if (type === 'radio') {
	listen(el, 'change', () => {
		...
	});
	effect(() => {
		...
	});
} else { // input/textarea
    listen(el, 'compositionstart', onCompositionStart);
    listen(el, 'compositionend', onCompositionEnd);
    listen(el, modifiers?.lazy ? 'change' : 'input', () => {
    	...
    };
    effect(() => {
    	...
    });
}

因此除开input/textarea对合成事件的处理外,其他处理逻辑都是类似的,首先绑定change/input事件,然后注册副作用,这样当事件回调触发时,修改上下文中的状态值,副作用effect触发,保证ui的同步更新,各种情况的具体实现这里就不细说了。

3.compositionEvent

在vue官网中,还有一段这样的描述:对于需要使用输入法 (如中文、日文、韩文等) 的语言,你会发现 v-model 不会在输入法组织文字过程中得到更新。如果你也想响应这些更新,请使用 input 事件监听器和 value 绑定,而不是使用 v-model,这里就是对于compositionEvent事件不支持了,因此需要自己手动实现。那么在petite-vue中是如何处理的呢,当用户在触发合成事件的过程中,其实是阻止了目标对象input/change事件回调的发生,在compositionend触发之后,再模拟触发input事件,从而保证界面的一致性;

listen(el, 'compositionstart', onCompositionStart);
listen(el, 'compositionend', onCompositionEnd);
listen(el, modifiers?.lazy ? 'change' : 'input', () => {
  if (el.composing) { // 如果是合成事件,直接return,因此发生compositionEvent,都不会执行update
	  return;
  }
  // update 
});

const onCompositionStart = (e) => {
  e.target.composing = true; // 标记合成事件
}

const onCompositionEnd = (e) => {
  const target = e.target;
  if (target.composing) { // 合成事件
    target.composing = false;
    trigger(target, 'input'); // 模拟触发input事件
  }
}

const trigger = (el, type) => {
  const e = document.createEvent('HTMLEvents');
  e.initEvent(type, true, true);
  el.dispatchEvent(e);
}

v-bind

v-bind指令用来给标签绑定属性,由v-bind接管的属性可以大致分为html标签原生属性和vue自定义属性,其中html原生属性class和style处理稍微麻烦一些,因为petite-vue支持比较灵活的写法,下面逐一分析其中的实现原理。

1.class

首先回顾一下v-bind支持的class的写法:

const data = (window.data = reactive({
    classes: ['foo', { red: true }],
}));
......
<div class="static" :class="classes"> // 渲染结果:<div class="static foo red">

绑定的class状态值可以是字符串、数组、对象,还需要与标签自有的属性class合并,跳出框架语法,其实我们需要实现下面这个getClass方法:

function getClass(reactiveClasses, rawClass = '') {
	// ...
}
getClass(['foo', { red: true }], 'static'); // static foo red

reactiveClasses类型可能是字符串、数组、对象中的一种,那么首先就需要判断,然后再提取有效的class名称,最后和rawClass拼接返回即可,现在重点就是不同类型reactiveClasses的处理方案,字符串最简单了,直接返回,数组和对象则需要迭代,现在来实现吧:

function isObject(o) {
	return Object.prototype.toString.call(o) === "[object Object]";
}
function getClass(reactiveClasses, rawClass = '') {
    const clsQueue = [rawClass];
    if (typeof reactiveClasses === 'string') {
        clsQueue.push(reactiveClasses);
    } else if (Array.isArray(reactiveClasses)) {
        reactiveClasses.forEach((classItem) => {
            clsQueue.push(getClass(classItem));
        });
    } else if (isObject(reactiveClasses)) {
        Object.entries(reactiveClasses).forEach(([k, v]) => {
            if (v) {
                clsQueue.push(k);
            }
        });
    }
    return clsQueue.filter(Boolean).join(' ');
}

现在来看下petite-vue是怎么实现的吧,源代码中提供了normalizeClass函数来实现class绑定处理,代码比较简单:

function normalizeClass(value) {
    let res = '';
    if (isString(value)) {
        res = value;
    }
    else if (isArray(value)) {
        for (let i = 0; i < value.length; i++) {
            const normalized = normalizeClass(value[i]);
            if (normalized) {
                res += normalized + ' ';
            }
        }
    }
    else if (isObject(value)) {
        for (const name in value) {
            if (value[name]) {
                res += name + ' ';
            }
        }
    }
    return res.trim();
}

跟自己实现的版本对比一下,其实干的事情都是差不多的,实现比较简单,就不多说了;

2.style

绑定style和class比较类似,但是有几点需要注意,首先是自定义属性,然后是!important的处理,最后是对小驼峰属性名的转换,例如fontSize需要变成font-size,但是如果直接通过el.style.xxx进行设置,就不用进行转换了,因为通过js访问本身就是符合小驼峰命名规则的,说完注意事项,先看个示例:

const data = (window.data = reactive({
    style: { color: 'blue' },
    arrayStyle: ['color: red', { fontSize: '16px' }]
}));
......
<div style="font-weight: bold" :style="style"></div> // 渲染结果:<div style="font-weight: bold; color: blue;"></div>
<div :style="arrayStyle"></div> // 渲染结果:<div style="color: red; font-size: 16px;"></div>
......

源代码的处理可以分为三步:

  • normalizeStyle返回样式对象

例如['color: red', { fontSize: '16px!important' }],转换之后:{ color: 'red', fontSize: '16px!important' }

  • 迭代styleObj调用setStytle设置DOM样式
  1. 如果属性值以--开头,就是自定义属性,调用style.setProperty方法;
  2. 如果属性值包含!important,则也需要style.setProperty方法设置,对于改方法的使用说明可以查看MDN文档,还要说明的一点就是,属性名需要转换成连字符;
  3. 其他情况,直接给style对象赋值
  • 删除不需要的样式属性

最后就是需要和上一次的样式对象对比,然后把本次没有的属性删除掉。

理清了思路和注意事项之后,再去看源码就事半功倍了,这里就不贴代码了,具体可查看。到这里就说完v-model和v-bind两个指令了,当然还有其他诸如v-htmlv-text等指令,通过前面一系列文章的讲解,再看这些指令就比较简单了,关于petite-vue指令部分的介绍就到此为止,后面将从全局的角度分析petite-vue的设计与流程。