一、用法回顾
1. 前言
过滤器在我们日常开发中应该算是一个非常常用的功能了,它经常会被用来格式化模板中的文本。过滤器可以单个使用,也可以多个串联一起使用,还可以传参数使用。那么在Vue
中过滤器是如何工作的呢?其内部原理是怎样的?Vue
又是如何识别出我们所写的过滤器的?接下来,我们将从源码角度出发,探究过滤器内部的工作原理,分析其工作流程,揭开它神秘的面纱。
2. 用法回顾
在介绍过滤器内部原理之前,我们先根据官方文档来回顾一下过滤器的使用方法。
2.1 使用方式
过滤器有两种使用方式:在双花括号插值中和在 v-bind 表达式中 (后者从 2.1.0+ 开始支持)。过滤器应该被添加在 JavaScript 表达式的尾部,由“|”符号指示:
<!-- 在双花括号中 --> {{ message | capitalize }} <!-- 在 `v-bind` 中 --> <div v-bind:id="rawId | formatId"></div>
2.2 过滤器的定义
你可以在一个组件的选项中定义本地的过滤器:
filters: { capitalize: function (value) { if (!value) return '' value = value.toString() return value.charAt(0).toUpperCase() + value.slice(1) } }
也可以在创建 Vue
实例之前使用全局APIVue.filter
来定义全局过滤器:
Vue.filter('capitalize', function (value) { if (!value) return '' value = value.toString() return value.charAt(0).toUpperCase() + value.slice(1) })
当全局过滤器和局部过滤器重名时,会采用局部过滤器。
2.3 串联过滤器
过滤器函数总接收表达式的值 (之前的操作链的结果) 作为第一个参数。在上述例子中,capitalize
过滤器函数将会收到 message
的值作为第一个参数。
过滤器可以串联:
{{ message | filterA | filterB }}
在这个例子中,filterA
被定义为接收单个参数的过滤器函数,表达式 message
的值将作为参数传入到函数中。然后继续调用同样被定义为接收单个参数的过滤器函数 filterB
,将 filterA
的结果传递到 filterB
中。
过滤器是 JavaScript 函数,因此可以接收参数:
{{ message | filterA('arg1', arg2) }}
这里,filterA
被定义为接收三个参数的过滤器函数。其中 message
的值作为第一个参数,普通字符串 'arg1'
作为第二个参数,表达式 arg2
的值作为第三个参数。
3.小结
通过用法回顾可以知道,过滤器有两种使用方式,分别是在双花括号插值中和在 v-bind 表达式中。无论是哪种使用方式,它的使用形式都是表达式 | 过滤器1 | 过滤器2 | ...
。
并且,我们知道了过滤器的定义也有两种方式,分别是在组件选项内定义和使用全局APIVue.filter
定义全局过滤器。在组件选项内定义的过滤器称为本地过滤器,它只能用于当前组件中。使用Vue.filter
定义的过滤器称为全局过滤器,它可以用在任意组件中。
另外,我们还知道了过滤器不仅可以单个使用,还可以多个串联一起使用。当多个过滤器串联一起使用的时候,前一个过滤器的输出是后一个过滤器的输入,通过将多种不同的过滤器进行组合使用来将文本处理成最终需要的格式。
最后,官方文档还说了所谓过滤器本质上就是一个JS
函数,所以我们在使用过滤器的时候还可以给过滤器传入参数,过滤器接收的第一个参数永远是表达式的值,或者是前一个过滤器处理后的结果,后续其余的参数可以被用于过滤器内部的过滤规则中。
了解了过滤器的用法之后,那么接下来我们就对其内部原理一探究竟。
二、工作原理
1. 前言
通过用法回顾章节我们知道,过滤器有两种使用方式,分别是在双花括号插值中和在 v-bind 表达式中。但是无论是哪一种使用方式,过滤器都是写在模板里面的。既然是写在模板里面,那么它就会被编译,会被编译成渲染函数字符串,然后在挂载的时候会执行渲染函数,从而就会使过滤器生效。举个例子:
假如有如下过滤器:
{{ message | capitalize }} filters: { capitalize: function (value) { if (!value) return '' value = value.toString() return value.charAt(0).toUpperCase() + value.slice(1) } }
那么它被编译成渲染函数字符串后,会变成这个样子:
_f("capitalize")(message)
如果你是初次看到这个_f
这样的函数,请不要惊慌。这跟我们在介绍模板编译篇中代码生成阶段时所看到的_c
、_e
函数一样,它都对应着一个函数,_f
对应的是resolveFilter
函数,通过模板编译会生成一个_f
函数调用字符串,当执行渲染函数的时候,就会执行_f
函数,从而让过滤器生效。
也就是说,真正让过滤器生效的是_f
函数,即resolveFilter
函数,所以接下来我们就分析一下resolveFilter
函数的内部原理。
2. resolveFilter函数分析
resolveFilter
函数的定义位于源码的src/core/instance/render-helpers.js
中,如下:
import { identity, resolveAsset } from 'core/util/index' export function resolveFilter (id) { return resolveAsset(this.$options, 'filters', id, true) || identity }
可以看到,resolveFilter
函数内部只有一行代码,就是调用resolveAsset
函数并获取其返回值,如果返回值不存在,则返回identity
,而identity
是一个返回同参数一样的值,如下:
/** * Return same value */ export const identity = _ => _
显然,更令我们关心的是resolveAsset
函数,该函数的定义位于源码的src/core/util/options.js
中,如下:
export function resolveAsset (options,type,id,warnMissing) { if (typeof id !== 'string') { return } const assets = options[type] // 先从本地注册中查找 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] // 再从原型链中查找 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 }
调用该函数时传入了4个参数,分别是当前实例的$options
属性,type
为filters
,id
为当前过滤器的id
。
在该函数内部,首先判断传入的参数id
(即当前过滤器的名称id
)是否为字符串类型,如果不是,则直接退出程序。如下:
if (typeof id !== 'string') { return }
接着,获取到当前实例的$options
属性中所有的过滤器,赋给变量assets
,上篇文章中说过,定义过滤器有两种方式,一种是定义在组件的选项中,一种是使用Vue.filter
定义。在之前的文章中我们说过,组件中的所有选项都会被合并到当前实例的$options
属性中,并且使用Vue.filter
定义的过滤器也会被添加到$options
中的filters
属性中,所以不管是以何种方式定义的过滤器,我们都可以从$options
中的filters
属性中获取到。如下:
const assets = options[type]
获取到所有的过滤器后,接下来我们只需根据过滤器id
取出对应的过滤器函数即可,如下:
// 先从本地注册中查找 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] // 再从原型链中查找 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
上述代码中,根据过滤器id
查找过滤器首先先从本地注册中查找,先通过hasOwn
函数检查assets
自身中是否存在,如果存在则直接返回;如果不存在,则将过滤器id
转化成驼峰式后再次查找,如果存在则直接返回;如果也不存在,则将过滤器id
转化成首字母大写后再次查找,如果存在则直接返回;如果还不存在,则再从原型链中查找,如果存在则直接返回;如果还不存在,则在非生产环境下抛出警告。
以上,就是resolveFilter
函数的所有逻辑。可以看到,resolveFilter
函数其实就是在根据过滤器id
获取到用户定义的对应的过滤器函数并返回,拿到用户定义的过滤器函数之后,就可以调用该函数并传入参数使其生效了。如下图所示:
3. 串联过滤器原理
上文分析了单个过滤器的工作原理,对于多个过滤器串联一起使用其原理也是相同的,还是先根据过滤器id
获取到对应的过滤器函数,然后传入参数调用即可,唯一有所区别的是:对于多个串联过滤器,在调用过滤器函数传递参数时,后一个过滤器的输入参数是前一个过滤器的输出结果。举个例子:
假如有如下过滤器:
{{ message | filterA | filterB }} filters: { filterA: function (value) { // ... }, filterB: function (value) { // ... }, }
那么它被编译成渲染函数字符串后,会变成这个样子:
可以看到,过滤器filterA
的执行结果作为参数传给了过滤器filterB
。
4. 过滤器接收参数
上一篇文章中说了,过滤器本质上就是一个JS
函数,既然是函数,那它肯定就可以接收参数,唯一一点需要注意的就是:过滤器的第一个参数永远是表达式的值,或者是前一个过滤器处理后的结果,后续其余的参数可以被用于过滤器内部的过滤规则中。举个例子:
假如有如下过滤器:
{{ message | filterA | filterB(arg) }} filters: { filterA: function (value) { // ... }, filterB: function (value,arg) { return value + arg }, }
那么它被编译成渲染函数字符串后,会变成这个样子:
可以看到,当过滤器接收其余参数时,它的参数都是从第二个参数开始往后传入的。
5. 小结
本篇文章介绍了过滤器的内部工作原理,就是将用户写在模板中的过滤器通过模板编译,编译成_f
函数的调用字符串,之后在执行渲染函数的时候会执行_f
函数,从而使过滤器生效。
所谓_f
函数其实就是resolveFilter
函数的别名,在resolveFilter
函数内部是根据过滤器id
从当前实例的$options
中的filters
属性中获取到对应的过滤器函数,在之后执行渲染函数的时候就会执行获取到的过滤器函数。
现在我们已经了解了过滤器的工作原理,那么Vue
在模板编译的时候是如何识别出用户所写的过滤器并且解析出过滤器中的内容呢?下篇文章我们来介绍Vue
如何解析过滤器。
三、解析过滤器
1. 前言
在工作原理中我们说了,无论用户是以什么方式使用过滤器,终归是将解析器写在模板中,既然是在模板中,那它肯定就会被解析编译,通过解析用户所写的模板,进而解析出用户所写的过滤器message | filterA | filterB
中哪部分是被处理的表达式,哪部分是过滤器id
及其参数。
还记得我们在介绍模板编译篇的解析阶段中说过,用户所写的模板会被三个解析器所解析,分别是HTML
解析器parseHTML
、文本解析器parseText
和过滤器解析器parseFilters
。其中HTML
解析器是主线,在使用HTML
解析器parseHTML
函数解析模板中HTML
标签的过程中,如果遇到文本信息,就会调用文本解析器parseText
函数进行文本解析;如果遇到文本中包含过滤器,就会调用过滤器解析器parseFilters
函数进行解析。在之前的文章中,我们只对HTML
解析器parseHTML
和文本解析器parseText
其内部原理进行了分析,没有分析过滤器解析器parseFilters
,那么本篇文章就来分析过滤器解析器的内部原理。
2. 在何处解析过滤器
我们一再强调,过滤器有两种使用方式,分别是在双花括号插值中和在 v-bind 表达式中,如下:
<!-- 在双花括号中 --> {{ message | capitalize }} <!-- 在 `v-bind` 中 --> <div v-bind:id="rawId | formatId"></div>
两种不同的使用方式唯一的区别是将过滤器写在不同的地方,既然有两种不同的地方可以书写过滤器,那解析的时候必然要在这两种不同地方都进行解析。
- 写在 v-bind 表达式中
v-bind 表达式中的过滤器它属于存在于标签属性中,那么写在该处的过滤器就需要在处理标签属性时进行解析。我们知道,在HTML
解析器parseHTML
函数中负责处理标签属性的函数是processAttrs
,所以会在processAttrs
函数中调用过滤器解析器parseFilters
函数对写在该处的过滤器进行解析,如下:
function processAttrs (el) { // 省略无关代码... if (bindRE.test(name)) { // v-bind // 省略无关代码... value = parseFilters(value) // 省略无关代码... } // 省略无关代码... }
- 写在双花括号中
在双花括号中的过滤器它属于存在于标签文本中,那么写在该处的过滤器就需要在处理标签文本时进行解析。我们知道,在HTML
解析器parseHTML
函数中,当遇到文本信息时会调用parseHTML
函数的chars
钩子函数,在chars
钩子函数内部又会调用文本解析器parseText
函数对文本进行解析,而写在该处的过滤器它就是存在于文本中,所以会在文本解析器parseText
函数中调用过滤器解析器parseFilters
函数对写在该处的过滤器进行解析,如下:
export function parseText (text,delimiters){ // 省略无关代码... const exp = parseFilters(match[1].trim()) // 省略无关代码... }
现在我们已经知道了过滤器会在何处进行解析,那么接下来我们就来分析过滤器解析器parseFilters
函数,来看看其内部是如何对过滤器进行解析的。
3. parseFilters函数分析
parseFilters
函数的定义位于源码的src/complier/parser/filter-parser.js
文件中,其代码如下:
export function parseFilters (exp) { let inSingle = false // exp是否在 '' 中 let inDouble = false // exp是否在 "" 中 let inTemplateString = false // exp是否在 `` 中 let inRegex = false // exp是否在 \\ 中 let curly = 0 // 在exp中发现一个 { 则curly加1,发现一个 } 则curly减1,直到culy为0 说明 { ... }闭合 let square = 0 // 在exp中发现一个 [ 则curly加1,发现一个 ] 则curly减1,直到culy为0 说明 [ ... ]闭合 let paren = 0 // 在exp中发现一个 ( 则curly加1,发现一个 ) 则curly减1,直到culy为0 说明 ( ... )闭合 let lastFilterIndex = 0 let c, prev, i, expression, filters for (i = 0; i < exp.length; i++) { prev = c c = exp.charCodeAt(i) if (inSingle) { if (c === 0x27 && prev !== 0x5C) inSingle = false } else if (inDouble) { if (c === 0x22 && prev !== 0x5C) inDouble = false } else if (inTemplateString) { if (c === 0x60 && prev !== 0x5C) inTemplateString = false } else if (inRegex) { if (c === 0x2f && prev !== 0x5C) inRegex = false } 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() } } else { switch (c) { case 0x22: inDouble = true; break // " case 0x27: inSingle = true; break // ' case 0x60: inTemplateString = true; break // ` case 0x28: paren++; break // ( case 0x29: paren--; break // ) case 0x5B: square++; break // [ case 0x5D: square--; break // ] case 0x7B: curly++; break // { case 0x7D: curly--; break // } } if (c === 0x2f) { // / let j = i - 1 let p // find first non-whitespace prev char for (; j >= 0; j--) { p = exp.charAt(j) if (p !== ' ') break } if (!p || !validDivisionCharRE.test(p)) { inRegex = true } } } } if (expression === undefined) { expression = exp.slice(0, i).trim() } else if (lastFilterIndex !== 0) { pushFilter() } function pushFilter () { (filters || (filters = [])).push(exp.slice(lastFilterIndex, i).trim()) lastFilterIndex = i + 1 } if (filters) { for (i = 0; i < filters.length; i++) { expression = wrapFilter(expression, filters[i]) } } return expression } 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'
这样的过滤器字符串转化成_f("capitalize")(message)
,接下来我们就来分析一下其内部逻辑。
在该函数内部,首先定义了一些变量,如下:
let inSingle = false let inDouble = false let inTemplateString = false let inRegex = false let curly = 0 let square = 0 let paren = 0 let lastFilterIndex = 0
- inSingle:标志exp是否在 ’ … ’ 中;
- inDouble:标志exp是否在 " … " 中;
- inTemplateString:标志exp是否在
...
中; - inRegex:标志exp是否在 \ … \ 中;
- curly = 0 : 在exp中发现一个 { 则curly加1,发现一个 } 则curly减1,直到culy为0 说明 { … }闭合;
- square = 0:在exp中发现一个 [ 则curly加1,发现一个 ] 则curly减1,直到culy为0 说明 [ … ]闭合;
- paren = 0:在exp中发现一个 ( 则curly加1,发现一个 ) 则curly减1,直到culy为0 说明 ( … )闭合;
- lastFilterIndex = 0:解析游标,每循环过一个字符串游标加1;
接着,从头开始遍历传入的exp
每一个字符,通过判断每一个字符是否是特殊字符(如'
,"
,{
,}
,[
,]
,(
,)
,\
,|
)进而判断出exp
字符串中哪些部分是表达式,哪些部分是过滤器id
,如下:
for (i = 0; i < exp.length; i++) { prev = c c = exp.charCodeAt(i) if (inSingle) { if (c === 0x27 && prev !== 0x5C) inSingle = false } else if (inDouble) { if (c === 0x22 && prev !== 0x5C) inDouble = false } else if (inTemplateString) { if (c === 0x60 && prev !== 0x5C) inTemplateString = false } else if (inRegex) { if (c === 0x2f && prev !== 0x5C) inRegex = false } 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() } } else { switch (c) { case 0x22: inDouble = true; break // " case 0x27: inSingle = true; break // ' case 0x60: inTemplateString = true; break // ` case 0x28: paren++; break // ( case 0x29: paren--; break // ) case 0x5B: square++; break // [ case 0x5D: square--; break // ] case 0x7B: curly++; break // { case 0x7D: curly--; break // } } if (c === 0x2f) { // / let j = i - 1 let p // find first non-whitespace prev char for (; j >= 0; j--) { p = exp.charAt(j) if (p !== ' ') break } if (!p || !validDivisionCharRE.test(p)) { inRegex = true } } } } if (expression === undefined) { expression = exp.slice(0, i).trim() } else if (lastFilterIndex !== 0) { pushFilter() } function pushFilter () { (filters || (filters = [])).push(exp.slice(lastFilterIndex, i).trim()) lastFilterIndex = i + 1 }
可以看到,虽然代码稍微有些长,但是其逻辑非常简单。为了便于阅读,我们提供一个上述代码中所涉及到的ASCII
码与字符的对应关系,如下:
0x22 ----- " 0x27 ----- ' 0x28 ----- ( 0x29 ----- ) 0x2f ----- / 0x5C ----- \ 0x5B ----- [ 0x5D ----- ] 0x60 ----- ` 0x7C ----- | 0x7B ----- { 0x7D ----- }
上述代码的逻辑就是将字符串exp
的每一个字符都从前往后开始一个一个匹配,匹配出那些特殊字符,如'
,"
,,
{,
},
[,
],
(,
),
`,|
。
如果匹配到'
,"
,字符,说明当前字符在字符串中,那么直到匹配到下一个同样的字符才结束,同时, 匹配
(),
{},
[]这些需要两边相等闭合, 那么匹配到的
| 才被认为是过滤器中的
|`。
当匹配到过滤器中的|
符时,那么|
符前面的字符串就认为是待处理的表达式,将其存储在 expression
中,后面继续匹配,如果再次匹配到过滤器中的 |
符 ,并且此时expression
有值, 那么说明后面还有第二个过滤器,那么此时两个|
符之间的字符串就是第一个过滤器的id
,此时调用 pushFilter
函数将第一个过滤器添加进filters
数组中。举个例子:
假如有如下过滤器字符串:
message | filter1 | filter2(arg)
那么它的匹配过程如下图所示:
将上例中的过滤器字符串都匹配完毕后,会得到如下结果:
expression = message filters = ['filter1','filter2(arg)']
接下来遍历得到的filters
数组,并将数组的每一个元素及expression
传给wrapFilter
函数,用来生成最终的_f
函数调用字符串,如下:
if (filters) { for (i = 0; i < filters.length; i++) { expression = wrapFilter(expression, filters[i]) } } function wrapFilter (exp, filter) { const i = filter.indexOf('(') if (i < 0) { return `_f("${filter}")(${exp})` } else { const name = filter.slice(0, i) const args = filter.slice(i + 1) return `_f("${name}")(${exp}${args !== ')' ? ',' + args : args}` } }
可以看到, 在wrapFilter
函数中,首先在解析得到的每个过滤器中查找是否有(
,以此来判断过滤器中是否接收了参数,如果没有(
,表示该过滤器没有接收参数,则直接构造_f
函数调用字符串即_f("filter1")(message)
并返回赋给expression
,如下:
const i = filter.indexOf('(') if (i < 0) { return `_f("${filter}")(${exp})` }
接着,将新的experssion
与filters
数组中下一个过滤器再调用wrapFilter
函数,如果下一个过滤器有参数,那么先取出过滤器id
,再取出其带有的参数,生成第二个过滤器的_f
函数调用字符串,即_f("filter2")(_f("filter1")(message),arg)
,如下:
const name = filter.slice(0, i) const args = filter.slice(i + 1) return `_f("${name}")(${exp}${args !== ')' ? ',' + args : args}`
这样就最终生成了用户所写的过滤器的_f
函数调用字符串。
4. 小结
本篇文章介绍了Vue
是如何解析用户所写的过滤器的。
首先,我们介绍了两种不同写法的过滤器会在不同的地方进行解析,但是解析原理都是相同的,都是调用过滤器解析器parseFilters
函数进行解析。
接着,我们分析了parseFilters
函数的内部逻辑。该函数接收一个形如'message | capitalize'
这样的过滤器字符串作为,最终将其转化成_f("capitalize")(message)
输出。在parseFilters
函数的内部是通过遍历传入的过滤器字符串每一个字符,根据每一个字符是否是一些特殊的字符从而作出不同的处理,最终,从传入的过滤器字符串中解析出待处理的表达式expression
和所有的过滤器filters
数组。
最后,将解析得到的expression
和filters
数组通过调用wrapFilter
函数将其构造成_f
函数调用字符串。