Vue.filter源码分析

589 阅读4分钟

0.前言

今天在刷 Vue.js官网 ,看到 【过滤器】这个概念后对它的内部实现产生了兴趣。先看看它的基本用法:

<!-- 在双花括号中 -->
{{ message | capitalize }}

<!-- 在 `v-bind` 中 -->
<div v-bind:id="rawId | formatId"></div>

官方解释过滤器作用是用于文本格式化,有点类似于 computed ,区别在于可使用管道符号 | 分隔过滤器并进行参数传递,不显式传参的话则以前一层的过滤器结果作为后一层过滤器的参数。例如:

{{ message | filterA | filterB }}
<!--
filterA 被定义为接收单个参数的过滤器函数,表达式 message
的值将作为参数传入到函数中。然后继续调用同样被定义为接收单个参数的过滤器
函数 filterB,将 filterA 的结果传递到 filterB 中。
-->

也可以显式传参,例如:

{{ message | filterA('arg1', arg2) }}
<!-- 
这里,filterA 被定义为接收三个参数的过滤器函数。其中 message
的值作为第一个参数,普通字符串 'arg1' 作为第二个参数,表达式 arg2
的值作为第三个参数。
-->

1.思考

看完用法后产生的一个问题是“管道”符号是什么东西?百度了一下,得到两个结论:

  • linux 系统中天然支持管道符,管道左边表达式的输出作为右边表达式输入;
  • JavaScript 中有管道操作符,但写法是 |>,且目前在试验阶段,浏览器均不支持;

至此,我们不禁要发问:Vue.js 中管道是基于什么方式实现的?

俗话说:用已知求未知,可矣。看看 | 操作符是否似陈相识?没错,它长得和 JavaScript 中的位运算符一样(也难怪实验中的管道运算符是 |>)。

查看 MDN文档我们知道,| 是按位或运算符,作用是将运算符两边的操作数转为二进制数进行操作,然后返回标准的 JS 数值。具体来说操作数会被转为 32 位形式比特码,然后每个比特位依次匹配运算,最后得到新的比特位数值。举例说明:

     9 (base 10) = 00000000000000000000000000001001 (base 2)
    14 (base 10) = 00000000000000000000000000001110 (base 2)
                   --------------------------------
14 | 9 (base 10) = 00000000000000000000000000001111 (base 2) = 15 (base 10)

据此我们可以看出该运算符逻辑显然与 Vue.js 中的管道符语法规则不一致,而且浏览器环境或者 node.js 也没实现类似 linux 中所谓的管道运算符。唯一的解释是 Vue.js 内部实现了才操作符。

2.源码 - compiler

那么,Vue.js 中是如何实现管道运算符的呢。让我们回到最开始的用法案例:

{{ message | filterA('arg1', arg2) }}
<!-- 
这里,filterA 被定义为接收三个参数的过滤器函数。其中 message
的值作为第一个参数,普通字符串 'arg1' 作为第二个参数,表达式 arg2
的值作为第三个参数。
-->

我们发现管道运算符作用后实际会生成过滤器函数,看看源码是怎么处理的。众所周知,Vue.js 针对不同的平台有对应的runtime文件和compiler文件。据此定位到可疑代码位置:

// 文件位置:src/compiler/parser/filter-parser.js
...
export function parseFilters (exp: string): string {
    ...
    for (i = 0; i < exp.length; i++) {
        ...
        // 添加校验,保证是 | 操作符而不是 || 运算符
        else if (
          c === 0x7C && // pipe
          exp.charCodeAt(i + 1) !== 0x7C &&
          exp.charCodeAt(i - 1) !== 0x7C &&
          !curly && !square && !paren
        ) {
          if (expression === undefined) {
            // first filter, end of expression
            lastFilterIndex = i + 1
            expression = exp.slice(0, i).trim()
          } else {
            pushFilter()
          }
        } 
    }
}

可以看到针对管道操作符 |(对应 Unicode 值为0x7C)进行了健壮性校验,避免识别到的是 || 或运算符。后面的代码是调用了 pushFilter() 方法,定位到该方法:

function pushFilter () {
    (filters || (filters = [])).push(exp.slice(lastFilterIndex, i).trim())
    lastFilterIndex = i + 1
}

是根据具体操作符往 filters 数组中存值,接着往下看:

...
  if (filters) {
    for (i = 0; i < filters.length; i++) {
      expression = wrapFilter(expression, filters[i])
    }
  }

  return expression
}

最后返回的是 expression 这个内容,而在返回之前调用了 wrapFilter 方法针对不同的过滤器进行处理。

function wrapFilter (exp: string, filter: string): string {
  const i = filter.indexOf('(')
  if (i < 0) {
    // _f: resolveFilter
    return `_f("${filter}")(${exp})`
  } else {
    const name = filter.slice(0, i)
    const args = filter.slice(i + 1)
    return `_f("${name}")(${exp}${args !== ')' ? ',' + args : args}`
  }
}

这里比较关键,代码一开始识别(,结合最开始的描述,过滤器可以选择性显式传参,如果显式传参表达式里一定会带 (。至此,管道运算符经过层层编译后会变成这样:

  • 不显示传参{{ message | capitalize }}或者<div v-bind:id="rawId | formatId"></div>。返回的是:
_f("capitalize")(message)

_f("formatId")(rawId)
  • 显示传参 {{ message | filterA('arg1', arg2) }}。返回的是:
_f("filterA")(message,'arg1',arg2)

filter-parse.js 是在 compiler/parser/index.js 中被调用的,具体核心代码如下:

import { parseFilters } from './filter-parser'
...
value = parseFilters(value)

而后 value 主要会以四种方式被处理:

  • addProp(el, name, value, list[i], isDynamic) 存储到 el.props
  • addAttr(el, name, value, list[i], isDynamic) 存储到 el.attrs
  • addHandler(el, name, value, modifiers, false, warn, list[i], isDynamic) 存储到 el.events
  • addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i]) 存储到 el.directives

.parser/index是在 /src/compiler/index.js 中被引用的,具体核心代码如下:

import { parse } from './parser/index'
import { generate } from './codegen/index'
...
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  const code = generate(ast, options)
  ...
})

这里的关键代码是来自 ./codegen/index 文件的 generate 方法,定位到具体内容:

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

这里有一层判定,ast ? genElement(ast, state) : '_c("div")',其中 ast 标识当前标签是否为抽象语法树,显然使用过滤器会执行 genElement 方法。最终根据过滤器在不同位置会返回不同结果,具体为:

  • v-bind 形式
"<div v-bind="a|b|c"></div>"
_f("c")(_f("b")(a))
  • 在双花括号中插值
"<div>{{ d | e() }}</div>"
_c('div',[_v(_s(_f("e")(d)))])

render-helpers/index.js文件中我们得知 _c,_f这些方法的作用:

export function installRenderHelpers (target: any) {
  target._s = toString;
  target._v = createTextVNode
  ...
  target._f = resolveFilter
}

接下来着重看看 resolveFilter 方法

3.源码 - instance

相关源码文件路径:
src/core/instance/render-helpers/resolve-filter.js
src/core/util/options.js

resolveFilter 的实现比较简单:

import { identity, resolveAsset } from 'core/util/index'

/**
 * Runtime helper for resolving filters
 */
export function resolveFilter (id: string): Function {
  return resolveAsset(this.$options, 'filters', id, true) || identity
}

从 resolveAsset 接收的参数,我们查看 flow 中 options 关于filters 的配置,可以看到 filters 定义的是键值为string类型,内容为 Function 类型的配置项:

// ./flow/options.js
declare type ComponentOptions = {
  ...
  // assets
  directives?: { [key: string]: Object };
  components?: { [key: string]: Class<Component> };
  transitions?: { [key: string]: Object };
  filters?: { [key: string]: Function };
  ...
}

接下来具体分析 resolveAsset 方法,完整代码如下:

// ./src/core/util/options.js
/**
 * Resolve an asset.
 * This function is used because child instances need access
 * to assets defined in its ancestor chain.
 */
export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  /* 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]
  if (process.env.NODE_ENV !== 'production' && warnMissing && !res) {
    warn(
      'Failed to resolve ' + type.slice(0, -1) + ': ' + id,
      options
    )
  }
  return res
}

基于前面调用方法时的传参内容 resolveAsset(this.$options, 'filters', id, true),方法内部首先获取 this.$options['filters'] 存入 assets 变量中;抛开中间健壮性校验,该方法的核心是根据 id 键值构建 this.$options['filters'][id] 这样的函数,存储在 res 变量中并返回。

健壮性代码的解读:首先根据 flow 的规则设置,校验 id 是否为 string 类型,如果不是则直接返回;而后判定当前实例对象或原型链上是否已经有类似挂载的方法,具体做法是将 id 转为驼峰格式 camelize(id) 和 帕斯卡格式 capitalize(camelizedId) 进行查找。最后的 warn 提示生产环境不会输出,仅在 res 没有值的时候输出。

这也解释了为何在组件内部可以通过 Vue.filter('filter方法名') 获取具体的过滤器方法。

4.总结

至此,关于 Vue.js 过滤器的源码解读就此结束。总结如下:

  • 1.JavaScript 并不天然支持管道运算符,过滤器的 | 完全是 Vue.js 的内部实现;
  • 2.Vue.js 内部进行 parse 的时候识别 | 的 Unicode 码,并依据传入的参数将其整个转化为形如 _f("${filter}")(${exp}) 的 expression 表达式,并通过 generate 方法最后生成 ast (抽象语法树)渲染成 Vnode
  • 3.具体的 _f 方法是 resolveFilter 的别名,其内部通过 resolveAsset 方法实现,将过滤器挂载到组件实例的 options.filters 上;
  • 4.至此组件内部使用时既能实现过滤器对数据的格式化,也能通过 Vue.filter('filter方法名') 获取到具体的过滤器

最后再补充一个简化版的数据流图: