阅读 75

petite-vue源码学习之事件实现

开始

上一次模仿petite-vue实现了一个乞丐版的demo,可以绑定值实现文本的渲染,还支持表达式的语法,语法与petite-vue一致,具体可点击查看这篇文章,这一次在前面的基础上,来实现事件的绑定,先给个示例吧,稍后就按照这个示例去实现一个我们自己的版本:

<script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'
    createApp({
        count: 0,
        increment() {
            this.count++
        }
    }).mount()
</script>
<div v-scope>
    <p>{{ count }}</p>
    <button @click="increment">increment</button>
    <button v-on:click="count--">decrement</button>
</div>
复制代码

一个很简单的示例,一个控制累加的按钮,一个控制累减的按钮,在手写代码之前,先分析一下实现思路吧,可以分为下面几个步骤:

1.事件绑定指令解析

我们要分析的指令整体结构是这样的:指令名:参数.修饰符="...",绑定事件指令还有两种语法,v-on和@,因此在解析的时候需要加以区分,具体的解析流程可以分四步:

  1. 从节点所有属性中筛选出petite-vue指令属性,通过判断是否是以@:v-开头即可;
  2. 如果是以@开头,或者是指令名是v-on,那么对应的内置指令就是on,实际就是一个处理事件的函数;
  3. 解析修饰符,同时我们也能获取到指令对应的值或者表达式exp,即等号后面引号内的字符串;
  4. 调用指令on函数,完成回调事件的生成和事件与DOM节点的绑定;

2.组装事件回调函数

完成指令的解析后,我们就拿到了这样的数据:

{   
    ctx: context,
    el: button,
    exp: 'increment'
    arg: 'click',
    modifiers: {...},
}
复制代码

这里需要注意的是exp的值,就是我们希望事件发生之后能执行的回调逻辑,由于支持行内表达式和方法名两种写法,那么最终生成的事件回调函数就会有不同,怎么个不同呢,下面分别分析一下;

  • 1.行内表达式

我们示例中第二个按钮解析的exp就是count--;由于是js表达式,压根都不是一个函数,因此需要包装在一个函数中吧,比如const handler = () => { count-- };,然后count是上下文中状态对象中的属性,要进行关联起来才能正确执行吧,看过第一篇的小伙伴应该有印象,通过with(scope) {...}包装起来,最终生成的函数回调应该就是下面这个样子:

const handler = (function(scope) {
    with(scope) {
        return (
            ($event) => {
                count--;
            }
        );
    }
})(scope);
复制代码

最终我们的handler就是return里面的函数,exp表达式被包裹在最里面,等事件发生时再执行,由于scope已经在handler生成的时候填充进去了,因此保证了count--实际执行的就是的是scope.count--,这样scope就和我们的表达式关联起来了。还需要注意的一点就是事件对象$event,这个需要传递下去。

  • 2.方法名

了解了行内表达式的处理方式之后,方法名这种写法,就比较好办了,最终的样式就像下面这样了:

const handler = (function(scope) {
    with(scope) {
        return (
            (e) => {
                increment(e);
            }
        );
    }
})(scope);
复制代码

上面区分了两种回调写法,那么通过代码怎么区分呢,petite-vue通过了一个很长的正则表达式来实现的,先看下长什么样吧:

const simplePathRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/;
复制代码

本能是拒绝看这么长的正则的,正则本来就比较难读,还这么长,真是摇头啊,但是遇到不熟悉的就跳过也没有学习的效果吧,那么我们就来拆分一下这一长串正则吧,好在这个正则长,但是不难,分开看还是比较好懂的。

  • [A-Za-z_$]:字母(大小写)下划线或者$开头,也就是js变量名开头的规则;
  • [\w$]*:变量名开头之后,后面可以跟字母、数字、下划线、$,重复0次或者多次;
  • \.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*]:是一个分组,里面又分为下面5种情况:
  • \.[A-Za-z_$][\w$]*:【.】开头,后面是合法的js标识符;
  • \['[^']*?']:匹配['xxxx'],[^']匹配除了单引号的所有字符;
  • \["[^"]*?"]:匹配["xxxx"];
  • \[\d+]:匹配[123];
  • \[[A-Za-z_$][\w$]*]:匹配合法的js标识符;

综上所述,simplePathRE也就是匹配调用scope中存在的方法,例如:foo、scope.foo、scope['foo'],如果不匹配那么就是行内表达式了。

3.绑定事件

经过前面两步骤之后,我们已经有了完整的回调事件,现在就是把事件和目标元素绑定起来就行了,通过addEventListener这个API就可以了,很简单,这里就省略了,稍后直接写代码。

分析完毕之后,我们终于可以写一点代码了,还是以第一篇文章提供的代码基础上继续完善,这里暂时省略无关代码;

function walk(node, context) {
    ...
    walkChildren(node, context);
    // 先递归解析子节点,然后解析node自己的属性
    const dirRE = /^(@|:|v-)/; // 以@/:/v-开头,筛选出petite-vue指令属性
    for (const { name, value } of [...node.attributes]) {// attributes能拿到节点所有属性,包括自定义属性
        if (dirRE.test(name)) { 
            processDirective(node, name, value, context);
        }
    }
}
const simplePathRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/;
function on({ el, get, exp, arg, modifiers }) {
    if (!arg) { // 对于事件指令来说,必须要有指令参数,即事件名称才是合法的
        console.error(`v-on="obj" syntax is not supported in petite-vue.`);
        return;
    }
    let handler = simplePathRE.test(exp)
        ? get(`(e => ${exp}(e))`) /*直接调用的是scope中的方法*/
        : get(`($event => { ${exp} })`) /*行内js语句,需要被包裹在函数中再执行*/;
    if (modifiers) {
        // 暂时省略对修饰符的处理
    }
    el.addEventListener(arg, handler, false);
}
// 所有内置指令集合,目前就只有on
const builtInDirectives = {
    on,
};
function processDirective(el, raw, exp, ctx) {
    let dir = null; // 指令名
    let arg = null; // 指令参数
    let modifiers = null; // 指令所有的修饰符
    let modMatch = null;
    while((modMatch = modifierRE.exec(raw))) { // 提取出事件修饰符,@click.a.b => { a: true, b: true }
        if (!modifiers) {
            modifiers = {};
        }
        modifiers[modMatch[1]] = true;
    }
    raw = raw.includes('.') ? raw.slice(0, raw.indexOf('.')) : raw; // 【.】后面的字符都被截取掉了,此时raw就变成了@click/v-on:click
    if(raw[0] === ':') {
        // bind指令,暂时未实现
    }
    if (raw[0] === '@') {
        dir = on;
        arg = raw.slice(1);
    } else { // v-xx形式的指令
        const argIndex = raw.indexOf(':');
        const dirName = argIndex > 0 ? raw.slice(2, argIndex) : raw.slice(2);
        dir = builtInDirectives[dirName] || ctx.dirs[dirName] /*自定义指令*/;
        arg = argIndex > 0 ? raw.slice(argIndex + 1) : undefined;
    }
    if (dir) {
        applyDirective(el, dir, exp, ctx, arg, modifiers);
        el.removeAttribute(raw);
    } else {
        console.error(`unknown custom directive ${raw}.`);
    }
}
function applyDirective(el, dir, exp, ctx, arg, modifiers) {
    const get = (e = exp) => createFunc(e)(ctx.scope);
    dir({
        el,
        get,
        effect: ctx.effect,
        ctx,
        exp,
        arg,
        modifiers,
    });
}
复制代码

这里要说明的一点是在提取事件修饰符的时候,while里面的代码是和petite-vue源码不一致的,源码中是这样的:

while((modMatch = modifierRE.exec(raw))) {
    ;(modifiers || (modifiers = {}))[modMatch[1]] = true;
    raw = raw.slice(0, modMatch.index);
}
复制代码

(modifiers || (modifiers = {}))[modMatch[1]] = true;这一行代码也就是把我们while循环体内的代码合成一行,比较精简,但是不太容易理解,我在实现过程中就写个容易理解的版本吧,本质是一样的,关于raw = raw.slice(0, modMatch.index)这一行,这里要特别说明一下,这样写的话只会提取第一个修饰符,然后就把后面带解析的修饰符都给接去掉了,因此@click.a.b.c这样的属性,最终modifiers只会包含a,而b和c都丢失了,因此我在实现的时候就改了一下,while执行完毕之后再截取,因此就可以完整提取出所有的修饰符了。

还差一步

现在事件可以正常执行了,count也是累加了,但是界面却没有自动更新,忙活了大半天,还不如直接jquery一把梭,莫急,说了还差一步喏,就差响应式了,稍微了解vue的同学都会聊几句,Proxy嘛,这是vue3做的一个重大调整,还单独成名为@vue/reactivity的npm包了,petite-vue也是基于此实现响应式的,本篇不打算详细讲解@vue/reactivity了,放到后面再说,这里简单说一下实现思路,文本的单向绑定渲染是通过一个函数完成的,那么在count改变时,去触发这个函数就可以了,那么问题又来了,我咋知道变量和函数的依赖关系呢,不可能把所有副作用函数都拿来执行一遍吧,这里的重点就是收集状态值和副作用函数之间的依赖关系,我们希望是这样的:

scope1: {
    count: [f1, f2, ...]
},
scope2: {
    ...
}
复制代码

当count改变时,我把count对应的所有函数都拿出来挨个执行就完了,这是我们想要的数据结构,那么怎么实现呢,如果熟悉Proxy,其实也不难,在第一次执行副作用函数effect时,内部肯定会访问状态值,这里就会走Proxy的get捕获器了,这里不就知道了状态值和effect的依赖关系了么,此外利用一个activeEffect来保存当前执行的effect,在get捕获器里面就知道activeEffect和状态值count的关系了,保存起来即可,当count改变的时候,走Proxy的set捕获器里面,这个时候就是取出所有依赖的副作用函数,依次执行即可,这就是整体的思路。

乞丐版Reactive

let activeEffect = null;
const effectStack = [];
function createReactiveEffect(fn) { // 我们所有的副作用函数都要通过这里生成
    function reactiveEffect() {
        activeEffect = fn; // 执行的时候,将activeEffect指向当前执行的函数reactiveEffect
        effectStack.push(fn);
        fn();
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    }
    reactiveEffect.lazy = false;
    return reactiveEffect;
}
const targetMap = new WeakMap();
function trigger(target, key) {
    const m = targetMap.get(target);
    const effects = m.get(key);
    effects.forEach((effect) => {
        effect && effect();
    });
}
function reactive(target) { // 所有的scope都要被代理
    const proxy = new Proxy(target, {
        get(target, key) {
            const m = targetMap.get(target);
            if (!m.has(key)) {
                m.set(key, new Set());
            }
            const depMap = m.get(key);
            depMap.add(activeEffect);  // 收集依赖的副作用函数
            const result = Reflect.get(...arguments);
            return result;
        },
        set(target, key, value) {
            const result = Reflect.set(...arguments);
            trigger(...arguments); // 执行依赖的副作用函数
            return result;
        },
    });
    targetMap.set(target, new Map());
    return proxy;
}
复制代码

到这里就基本实现了事件绑定,视图更新的效果了,完整代码可查看这里,本篇文章就到此为止,后面将实现更多内部指令,让我们的轮子越来越完善。

文章分类
前端
文章标签