vue 源码分析:实现 v-for、v-bind:class 和 v-bind:style 指令

1,700 阅读4分钟

前言

本篇文章代码是在 vue 模版编译vue 数据劫持的基础上实现的。除了实现指令的解析以外,还做出了以下几点变动:

  1. 更换打包工具:由使用 webpack 转为使用 rollup。
  2. demo 的目录和函数,基本上都依照 vue 源码目录和函数重新进行调整。

虽然,demo 的目录和函数重新进行了调整,但是代码量总体较小且每个函数都写了注释。因此,就算没有看过前两篇有关 vue 解读的文章,也不会影响阅读,尤其是通过 demo 运行调试,将更容易理解

另外,本篇文章提供的 demo,实际上就是将 vue 源码(相关部分)的核心功能进行抽离。只关注实现,不关注细节。这样做,是为了让同学们摆脱面对超多代码的无力感,同时有能快速掌握功能的实现原理,而且这也有助于你回头重新阅读 vue 源码。

指令

关于指令,本文章会先介绍它们的实现步骤,而后介绍实现它们的功能函数。另外,在功能函数中出现的辅助函数,只介绍它们的作用,若想阅读具体代码实现,可以到 GitHub 下载 demo 查看,里面做了详细的注释。

v-for

  1. 要解析指令,首先需要获取它。那么就得在 parseHTML() 函数解析 html 时调用。

相关路径:src/compiler/parser/index.js

截屏2021-09-20 18.44.36.png

核心代码

// done: 处理 v-for 指令
function processFor(el) {
    // 获取并从 attrsList 数组中删除属性
    const exp = getAndRemoveAttr(el, 'v-for');
    // 判断 v-for 是否存在
    if (exp && typeof exp === 'string') {
        const res = parseFor(exp); // 解析 v-for 指令
        if (res) {
            // 将 res中的属性(例如:item、arrList)添加到
            // 目标对象(el即ast对象)中
            extend(el, res);
        } else {
            console.log(`Invalid v-for expression: ${exp}`);
        }
    }
}

// done: 解析 v-for 指令
function parseFor(exp) {
    // 匹配 v-for='(item,index) in arrList' 中的:'(item,index) in arrList',
    // 这里仅是举例
    const inMatch = exp.match(forAliasRE);
    // 如果没有找到任何匹配的文本, match() 将返回 null。否则,它将返回一个数组,
    // 其中存放了与它找到的匹配文本有关的信息。
    if (!inMatch) return;

    const res = {};
    
    // 获取 arrList 并去掉其前后空格
    res.for = inMatch[2].trim();
    
    // 获取 (item, index) 并去掉其前后空格以及将圆括号即 () 替换为空。
    // 例如:(item, index) ——> item, index
    const alias = inMatch[1].trim().replace(stripParensRE, '');
    // 匹配 ',' 和 'index',例如:item, index ——> , index
    const iteratorMatch = alias.match(forIteratorRE);

    // iteratorMatch 为真,说明你使用 v-for 时,写的是 (item, index)。
    // 否则,就是 (item) 或 item。
    if (iteratorMatch) {
        // 将 ',' 和 'index'替换为空,得到 item 并去除其前后空格。
        // 例如:item, index ——> item
        res.alias = alias.replace(forIteratorRE, '').trim();
        
        // 获取 index 并去除其前后空格
        res.iterator1 = iteratorMatch[1].trim();
        
        if (iteratorMatch[2]) {
            // 若是走到这里,说明你多写了逗号。例如:(item, , , index)。
            // 而这时,index 的值在 iteratorMatch[2] 的位置
            res.iterator2 = iteratorMatch[2].trim();
        }
    } else {
        res.alias = alias;
    }

    return res;
}
  1. genElement() 函数用于处理元素(也就是其相对应的 ast 对象)上的属性,以便生成相应的字符串。

相关路径:src/compiler/codegen/index.js

截屏2021-09-20 19.15.52.png

有 v-for 指令的元素对象——ast

截屏2021-09-20 19.18.20.png

核心代码

// DONE: 处理有 v-for 指令的 ast 对象
function genFor(el, state) {
  const exp = el.for; // 要遍历的数组
  const alias = el.alias; // 数组中的每一项
  
  // 每一项的下标值即 index
  const iterator1 = el.iterator1 ? `,${el.iterator1}` : "";
  const iterator2 = el.iterator2 ? `,${el.iterator2}` : "";

  el.forProcessed = true; // 避免递归时,重复处理
  
  // 生成字符串函数
  return (
    `${"_l"}((${exp}),` +
    `function(${alias}${iterator1}${iterator2}){` +
    `return ${genElement(el, state)}` +
    "})"
  );
}

代码中会返回一个字符串拼接成的函数—— _l(是 renderList 函数的别名),专门用来处理 v-for 中的数组。

_l 函数生成样例:从 html 结构转换成的相应字符串

// 在 html 中结构
<span v-for="(item, index) in students" :key="item.id">
    {{ item.name }}
</span>

// 在 js 中生成的字符串
_l(students, function (item, index) {
  return _c("span", { key: item.id }, [_v(_s(item.name))]);
})

_l 函数代码

// done: 处理 v-for 指令中的 lists(即要遍历的数组、字符串、数字或对象)
export function renderList(val, render) {
    var ret, i, l, keys, key;
    if (Array.isArray(val) || typeof val === "string") {
        // val 是数组或字符串
        ret = new Array(val.length);
        for (i = 0, l = val.length; i < l; i++) {
            ret[i] = render(val[i], i);
        }
    } else if (typeof val === "number") {
        // val 是数字
        ret = new Array(val);
        for (i = 0; i < val; i++) {
            ret[i] = render(i + 1, i);
        }
    } else if (isObject(val)) {
        // val 是 'object' 对象

        if (hasSymbol && val[Symbol.iterator]) {
            // val 是 Symbol
            ret = [];
            var iterator = val[Symbol.iterator]();
            var result = iterator.next();
            while (!result.done) {
                ret.push(render(result.value, ret.length));
                result = iterator.next();
            }
        } else {
            // val 是普通对象
            keys = Object.keys(val);
            ret = new Array(keys.length);
            for (i = 0, l = keys.length; i < l; i++) {
                key = keys[i];
                ret[i] = render(val[key], key, i);
            }
        }
    }
    // ret 不存在,则设置为空数组
    if (!isDef(ret)) {
        ret = [];
    }
    // 标记为已处理
    ret._isVList = true;
    
    return ret;
}

v-bind:class 和 v-bind:style

编译阶段

1. 解析 class 和 style,将它们添加到 ast 对象中

相关路径:src/compiler/parser/index.js

  • 获取 class 和 style 的 transformNode 函数,发生在调用 parse 函数解析模版字符串时。

截屏2021-09-22 11.59.38.png

  • 调用 class 和 style 的 transformNode 函数,发生在调用 processElement 函数处理生成的 ast 对象时,实际上是将它们添加到 ast 对象中。

截屏2021-09-22 12.02.34.png

2. 将 ast 对象中的 class 和 style 拼接成字符串,放入 data 对象中

相关路径:src/compiler/codegen/index.js

  • 获取 class 和 style 的 genData 函数,发生在调用 generate 函数生成代码字符串时。

截屏2021-09-22 14.42.38.png

  • 调用 class 和 style 的 genData 函数,将它们拼接成字符串。

截屏2021-09-22 15.39.25.png

3. 核心代码

  • 处理 class 的函数
import { getAndRemoveAttr, getBindingAttr } from '../../../compiler/helpers';

// 处理 class 的静态和动态。
function transformNode(el) {
    // 获取静态的 class
    const staticClass = getAndRemoveAttr(el, 'class');
    if (staticClass) {
        el.staticClass = JSON.stringify(staticClass);
    }

    // 获取动态绑定的 class
    const classBinding = getBindingAttr(el, 'class', false /* getStatic */);
    if (classBinding) {
        el.classBinding = classBinding;
    }
}

// 分别拼接 class 静态和动态属性
function genData(el) {
    let data = '';
    
    if (el.staticClass) { // 静态
        data += `staticClass:${el.staticClass},`;
    }
    
    if (el.classBinding) { // 动态
        data += `class:${el.classBinding},`;
    }
    return data;
}

export default {
    staticKeys: ['staticClass'],
    transformNode,
    genData
};

  • 处理 style 的函数
import { getAndRemoveAttr, getBindingAttr } from '../../../compiler/helpers';
import { parseStyleText } from '../util/style';

// 处理静态和动态的 style
function transformNode(el) {
    // 获取静态 style
    const staticStyle = getAndRemoveAttr(el, 'style');
    if (staticStyle) {
        // 解析 style 并字符串化
        el.staticStyle = JSON.stringify(parseStyleText(staticStyle));
    }

    // 获取动态绑定的 style
    const styleBinding = getBindingAttr(el, 'style', false);
    if (styleBinding) {
        el.styleBinding = styleBinding;
    }
}

// 分别拼接 style 静态和动态属性
function genData(el) {
    let data = '';
    if (el.staticStyle) { // 静态
        data += `staticStyle:${el.staticStyle},`;
    }
    if (el.styleBinding) { // 动态
        data += `style:(${el.styleBinding}),`;
    }
    return data;
}

export default {
    staticKeys: ['staticStyle'],
    transformNode,
    genData
};

运行阶段

1. 获取 class 和 style 的值并设置

相关路径:src/core/vdom/patch.js

  • 以 hooks 字符串数组中的元素为类别,对处理 class 和 style 的各类函数进行分类。

截屏2021-09-22 17.54.46.png

  • 遍历 create 数组来调用 class 和 style 的 updateClass 函数,其目的是为了给它们设置属性值。

截屏2021-09-22 18.16.14.png

invokeCreateHooks 函数于创建元素的 createElm 函数中调用。

截屏2021-09-22 18.21.19.png

2. 核心代码

  • 处理 class 的函数
import { isUndef } from '../../../shared/util';
import { genClassForVnode } from '../util/class';

// 更新 class
function updateClass(oldVnode, vnode) {
    const el = vnode.elm;
    const data = vnode.data;
    const oldData = oldVnode.data;
    
    // 判断新旧节点是否有 staticClass 和 class
    if (
        isUndef(data.staticClass) &&
        isUndef(data.class) &&
        (isUndef(oldData) ||
            (isUndef(oldData.staticClass) && isUndef(oldData.class)))
    ) {
        return;
    }

    // 获取 class 的值
    let cls = genClassForVnode(vnode);

    // 如果当前元素的 class 和其上一个设置的class 名相同,则不在重复设置
    if (cls !== el._prevClass) {
        el.setAttribute('class', cls);
        el._prevClass = cls;
    }
}

export default {
    create: updateClass,
    update: updateClass
};

  • 处理 style 的函数
import { getStyle, normalizeStyleBinding } from "../util/style";
import {
  cached,
  camelize,
  extend,
  isDef,
  isUndef,
  hyphenate,
} from "../../../shared/util";

const cssVarRE = /^--/;
const importantRE = /\s*!important$/;

const setProp = (el, name, val) => {
  // cssVarRE 是针对使用 了CSS 自定义属性(变量)的情况,例如:element { color: var(--bg-color);}
  // 相关文档:https://developer.mozilla.org/zh-CN/docs/Web/CSS/Using_CSS_custom_properties
  if (cssVarRE.test(name)) {
    el.style.setProperty(name, val);
  } else if (importantRE.test(val)) {
    // 设置属性的同时,规定其优先级为 'important'
    el.style.setProperty(
      hyphenate(name),
      val.replace(importantRE, ""),
      "important"
    );
  } else {
    const normalizedName = normalize(name); // 规范化 style 属性名
    if (Array.isArray(val)) {
      // style 绑定中的 property 提供一个包含多个值的数组,常用于提供多个带前缀的值,例如:
      // <div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>
      // 逐个设置,浏览器将只设置它能识别的。如果浏览器支持不带浏览器前缀的 flexbox,
      // 那么就只会渲染 display: flex
      for (let i = 0, len = val.length; i < len; i++) {
        el.style[normalizedName] = val[i];
      }
    } else {
      el.style[normalizedName] = val;
    }
  }
};
// -moz --> Firefox浏览器  -webkit --> Chrome 和 Safari浏览器  -ms --> IE浏览器
const vendorNames = ["Webkit", "Moz", "ms"];

let emptyStyle;
// cached 用于创建一个纯函数的缓存
const normalize = cached(function (prop) {
  // 获取 style( CSSStyleDeclaration 样式声明对象 )上的所有属性
  emptyStyle = emptyStyle || document.createElement("div").style;

  // 将连字符分隔的字符串驼峰化,例如:background-color --> backgroundColor
  prop = camelize(prop);

  // 不是 filter 且存在于 emptyStyle 中
  if (prop !== "filter" && prop in emptyStyle) {
    return prop;
  }

  // 若是走到这,则说明是 filter
  const capName = prop.charAt(0).toUpperCase() + prop.slice(1);
  for (let i = 0; i < vendorNames.length; i++) {
    // 拼接上各个浏览器的前缀,例如:WebkitFilter
    const name = vendorNames[i] + capName;
    // 再次判断是否存在于 emptyStyle 中
    if (name in emptyStyle) {
      return name;
    }
  }
});

// done: 更新 style
function updateStyle(oldVnode, vnode) {
  const data = vnode.data;
  const oldData = oldVnode.data;

  // 判断新旧节点是否有 staticStyle 和 style
  if (
    isUndef(data.staticStyle) &&
    isUndef(data.style) &&
    isUndef(oldData.staticStyle) &&
    isUndef(oldData.style)
  ) {
    return;
  }

  let cur, name;
  const el = vnode.elm;
  const oldStaticStyle = oldData.staticStyle; // 旧的静态 style
  const oldStyleBinding = oldData.normalizedStyle || oldData.style || {}; // 旧的动态 style

  // 获取 style 的老样式
  const oldStyle = oldStaticStyle || oldStyleBinding;
  // 规范化 style 样式
  const style = normalizeStyleBinding(vnode.data.style) || {};

  // 提示:__ob__ 指的是 Observe 类,可参考相关文件路径:src/core/observe/index.js

  // 存储规范化样式在一个不同的键下,以便下一次 diff
  // 如果它是响应性的(style.__ob__),请确保克隆它,因为用户可能想要改变它
  vnode.data.normalizedStyle = isDef(style.__ob__) ? extend({}, style) : style;

  // 获取 style 样式
  const newStyle = getStyle(vnode, true);

  for (name in oldStyle) {
    // 旧的样式属性在新样式中不存在,则设置 style 属性为空
    if (isUndef(newStyle[name])) {
      setProp(el, name, "");
    }
  }
  for (name in newStyle) {
    cur = newStyle[name];
    // 新旧样式属性不重复,则设置 style 属性
    if (cur !== oldStyle[name]) {
      // Ie9设置为空没有效果,必须使用空字符串
      setProp(el, name, cur == null ? "" : cur);
    }
  }
}

export default {
  create: updateStyle,
  update: updateStyle,
};

最后

文章主要对实现指令的核心功能函数和它们是如何调用的进行了介绍,若是只看文章中列出的代码也仅是了解大概实现原理,因此建议感兴趣的小伙伴们下载 demo,边看注释边调试。