我理解的 v-on 指令的转换过程

130 阅读4分钟

1. 前置知识

在 Vue 2 中,模板编译主要分为将模板字符串转换为抽象语法树(AST),再把 AST 转换为渲染函数,渲染函数返回虚拟 DOM(VNode),最后将虚拟 DOM 渲染为真实 DOM 这几个步骤。v-on 指令在这个流程里会被转换为 JavaScript 代码,从而为元素添加事件监听器。

2. 详细转换过程

步骤 1:模板解析为 AST

Vue 会把模板字符串解析成抽象语法树(AST)。对于模板 <button v-on:click="handleClick">Click me</button>,解析后的 AST 会包含元素节点、属性节点、事件节点等信息。以下是一个简化的 AST 表示:

{
    "type":1,
    "tag":"button",
    "attrsList":[],
    "attrsMap":{},
    "events":{"click":"handleClick"},
    "children":[{"type":3,"text":"Click me"}]
}
解析模板为 AST 的转换函数
function parseTemplate(template) {
    template = template.trim();
    let startIndex = template.indexOf('<') + 1;
    let endIndex = template.indexOf(' ', startIndex);
    if (endIndex === -1) {
        endIndex = template.indexOf('>', startIndex);
    }
    const tag = template.slice(startIndex, endIndex);

    const attrsList = [];
    const attrsMap = {};
    const events = {};
    let attrStart = endIndex;

    while (attrStart < template.length && template[attrStart] !== '>') {
        // 查找下一个属性起始位置
        attrStart = template.indexOf(' ', attrStart);
        if (attrStart === -1 || attrStart >= template.indexOf('>', endIndex)) {
            break;
        }

        // 找到属性名的结束位置(等号位置)
        const attrEnd = template.indexOf('=', attrStart + 1);
        if (attrEnd === -1) break;
        
        const attrName = template.slice(attrStart + 1, attrEnd).trim();
        if (!attrName) break;

        // 找到属性值的起始和结束位置
        const valueStart = template.indexOf('"', attrEnd) + 1;
        const valueEnd = template.indexOf('"', valueStart);

        // 如果没有找到引号,说明格式错误,退出循环
        if (valueStart === 0 || valueEnd === -1) {
            break;
        }

        const attrValue = template.slice(valueStart, valueEnd);

        if (attrName.startsWith('v-on:')) {
            const eventName = attrName.slice(5);
            events[eventName] = attrValue;
        } else {
            attrsList.push({ name: attrName, value: attrValue });
            attrsMap[attrName] = attrValue;
        }

        // 更新 attrStart 到属性值结束位置
        attrStart = valueEnd;
    }

    const contentStart = template.indexOf('>', endIndex) + 1;
    const contentEnd = template.lastIndexOf('</');
    const textContent = template.slice(contentStart, contentEnd).trim();
    const children = textContent ? [
        {
            type: 3,
            text: textContent
        }
    ] : [];

    const ast = {
        type: 1,
        tag,
        attrsList,
        attrsMap,
        events,
        children
    };
    return ast;
}

const template = '<button v-on:click="handleClick">Click me</button>';
const ast = parseTemplate(template);
console.log(ast);
步骤 2:AST 转换为渲染函数

在这一步,Vue 会把 AST 转换为渲染函数。对于 v-on 指令,会将其转换为 JavaScript 代码来为元素添加事件监听器。以下是一个简化的渲染函数生成过程:

javascript

function generateRenderFunction(ast) {
  const eventStr = Object.entries(ast.events).map(([eventName, handler]) => {
    return `{ name: '${eventName}', handler: this.${handler} }`;
  }).join(', ');

  const childrenStr = ast.children.map(child => {
    if (child.type === 3) {
      return `_v('${child.text}')`;
    }
    // 这里省略了子元素节点的处理,仅处理文本节点
    return '';
  }).join(', ');

  return `with(this) { return _c('${ast.tag}', { on: [${eventStr}] }, [${childrenStr}]) }`;
}

const ast = {
  type: 1,
  tag: 'button',
  attrsList: [],
  attrsMap: {},
  events: {
    click: 'handleClick'
  },
  children: [
    {
      type: 3,
      text: 'Click me'
    }
  ]
};

const renderFunction = generateRenderFunction(ast);
console.log(renderFunction);

上述代码中,generateRenderFunction 函数接收一个 AST 节点作为参数,遍历其事件列表,将事件信息转换为 JavaScript 代码。同时处理子节点,对于文本节点使用 _v 函数进行处理。最后返回一个渲染函数字符串: with(this) { return _c('button', { on: [{ name: 'click', handler: this.handleClick }] }, [_v('Click me')]) }

步骤 3:执行渲染函数生成虚拟 DOM

当渲染函数被执行时,会根据当前组件的状态生成虚拟 DOM。以下是一个简化的虚拟 DOM 生成过程:

// 模拟 Vue 实例
const vm = {
  handleClick() {
    console.log('Button clicked!');
  }
};

// 模拟 Vue 的 _c 和 _v 函数
function _c(tag, data, children) {
  return {
    tag,
    data,
    children
  };
}

function _v(text) {
  return {
    type: 3,
    text
  };
}

// 执行渲染函数
const vnode = new Function(renderFunction).call(vm);
console.log(vnode);

在这个代码中,我们模拟了一个 Vue 实例 vm,包含 handleClick 方法。_c 函数用于创建虚拟 DOM 节点,_v 函数用于创建文本虚拟节点。通过 new Function(renderFunction).call(vm) 执行渲染函数,生成虚拟 DOM 节点:

{
    "tag":"button",
    "data":{"on":[{"name":"click"}]},
    "children":[{"type":3,"text":"Click me"}]
}
步骤 4:虚拟 DOM 渲染为真实 DOM

最后,Vue 会比较新旧虚拟 DOM 的差异,只更新需要更新的真实 DOM 节点。对于 v-on 指令,会为按钮添加 handleClick 事件监听器。以下是一个简化的渲染过程:

function renderVNode(vnode) {
  if (vnode.type === 3) {
    return document.createTextNode(vnode.text);
  }
  const el = document.createElement(vnode.tag);
  vnode.data.on.forEach(event => {
    el.addEventListener(event.name, event.handler);
  });
  vnode.children.forEach(child => {
    el.appendChild(renderVNode(child));
  });
  return el;
}

const realDom = renderVNode(vnode);
document.body.appendChild(realDom);

在这个代码中,renderVNode 函数接收一个虚拟 DOM 节点作为参数,对于文本节点创建文本节点,对于元素节点创建对应的真实 DOM 元素,并为其添加事件监听器,同时递归处理子节点。最后将真实 DOM 元素添加到页面中。

3. 转换后结果

最终渲染的 HTML 如下:

html

<button>Click me</button>

虽然 HTML 代码看起来没有变化,但实际上按钮已经绑定了 handleClick 方法,当点击按钮时会触发该方法。

注意事项

  • 这个实现是一个非常简化的版本,仅适用于简单的模板字符串,对于复杂的模板(如嵌套标签、动态内容等)可能无法正常工作。
  • 在实际的 Vue 源码中,模板解析要复杂得多,会处理各种情况,包括错误处理、指令解析等。