前端面试总结|vue核心面试题(vue中模板编译原理)

一、Vue 编译原理这块的整体逻辑主要分三个部分:
1.将模板字符串转换成element AST(解析器parser)
2.对ast进行静态节点标记,主要用来做虚拟dom的渲染优化(优化器optimizer)
3.使用element AST 生成render函数代码字符串(代码生成器code generator )
二、模板编译原理源码分析及总结
第一步:vue怎么将 template 转化成 render 函数:将模板字符串会扔到 while中去循环,然后 一段一段的截取,把截取到的截取,把截取到的进行解析,直到最后截没了,这时就解析成了element AST。
1.源码文件转换入口文件路径:src/compiler/index.js

// 创建了一个编辑器 export const createCompiler = createCompilerCreator(function baseCompile ( template: string, options: CompilerOptions ): CompiledResult { const ast = parse(template.trim(), options) // 将模板转化成ast树 // 虚拟dom:用一个对象来描述dom元素 if (options.optimize !== false) { // 优化树 optimize(ast, options) } const code = generate(ast, options) // 将ast树生成代码 return { ast, render: code.render, staticRenderFns: code.staticRenderFns } })

2.怎么把div变成render函数(src/compiler/parser的文件夹下),下面是截取用到的正则
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; const qnameCapture = `((?:${ncname}\\:)?${ncname})`; const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是 标签名 const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+| ([^\s"'=<>`]+)))?/; // 匹配属性的 const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的> let root; let currentParent; let stack = []

(1)parseHTML生成语法树(模板字符串转换成element AST语法树)
parseHTML(`hellozf
`); function parseHTML(html) { while (html) { let textEnd = html.indexOf('<'); // 判断是否是<开头 if (textEnd == 0) { // 如果开头是<,textEnd就是0 const startTagMatch = parseStartTag(); // 是否是开始标签 if (startTagMatch) { start(startTagMatch.tagName, startTagMatch.attrs); // 将标签名和属性传到start方法中 continue; } const endTagMatch = html.match(endTag); // 匹配结束标签 if (endTagMatch) { advance(endTagMatch[0].length); end(endTagMatch[1]) // 调用end方法 } } let text; if (textEnd >= 0) { 如果开头不是<,textEnd就会大于0 rest = html.slice(textEnd) while ( // 循环截取完不符合标签形式的字符串 !endTag.test(rest) && !startTagOpen.test(rest) && !comment.test(rest) && !conditionalComment.test(rest) ) { // < in plain text, be forgiving and treat it as text next = rest.indexOf('<', 1) if (next < 0) break textEnd += next rest = html.slice(textEnd) } text = html.substring(0, textEnd) } if (text) { advance(text.length); chars(text); // 将拿到的字符串存到charts中 } } // 字符串截取 function advance(n) { html = html.substring(n); } function parseStartTag() { // 判断是否是开始标签 const start = html.match(startTagOpen); if (start) { const match = { // 拿到元素后会把这个名字存起来放在tagName中 tagName: start[1], attrs: [] // 传入元素的属性放在attrs } advance(start[0].length); // 将当前的字符串截取 let attr, end // 截取完成后会进行循环,首先判断是不是关闭标签,然后匹配属性,然后将属性也截取掉,并将属性放到attrs中 while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { advance(attr[0].length); match.attrs.push({ name: attr[1], value: attr[3] }) } // 是否是结束标签,是的话将他截取掉 if (end) { advance(end[0].length); return match } } } }

(2)start方法(每当解析到标签的开始位置时,触发该函数)
// tag标签名 // attrs属性 // unary 是否是闭合和标签 function start(tag, attrs, unary, start, end) { let element: ASTElement = createASTElement(tag, attrs, currentParent) if (!root) { // 如果root上没东西就将ASTElement树放的root上 root = element; } if (!unary) { currentParent = element stack.push(element) } else { closeElement(element) } currentParent = element; }

(3)end方法(每当解析到标签的结束位置时,触发该函数)
end (tag, start, end) { const element = stack[stack.length - 1] // pop stack stack.length -= 1 currentParent = stack[stack.length - 1] if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) { element.end = end } closeElement(element) }

(4)charts方法(每当解析到文本时,触发该函数)
function chars(text) { // 解析文本 // 纯文本 currentParent.children.push({ type: 3, text }) // 带变量的 const expression = parseText(text, delimiters) currentParent.children.push({ type: 2, expression, text }) }

(5)comment 方法(解析到注释执行)
comment (text: string, start, end) { if (currentParent) { const child: ASTText = { type: 3, text, isComment: true } if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) { child.start = start child.end = end } currentParent.children.push(child) } }

(5)createASTElement方法(创建ast树)
// 有名字和属性就可以创建一个ast对象 function createASTElement ( tag: string, attrs: Array, parent: ASTElement | void ): ASTElement { return { type: 1, tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), rawAttrsMap: {}, parent, children: [] } }

(6)parseText(用来解析带变量的文本)
export function parseText ( text: string, delimiters?: [string, string] ): TextParseResult | void { const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE if (!tagRE.test(text)) { return } const tokens = [] const rawTokens = [] let lastIndex = tagRE.lastIndex = 0 let match, index, tokenValue while ((match = tagRE.exec(text))) { index = match.index // push text token if (index > lastIndex) { rawTokens.push(tokenValue = https://www.it610.com/article/text.slice(lastIndex, index)) tokens.push(JSON.stringify(tokenValue)) } // tag token const exp = parseFilters(match[1].trim()) tokens.push(`_s(${exp})`) rawTokens.push({'@binding': exp }) lastIndex = index + match[0].length } if (lastIndex < text.length) { rawTokens.push(tokenValue = https://www.it610.com/article/text.slice(lastIndex)) tokens.push(JSON.stringify(tokenValue)) } return { expression: tokens.join('+'), tokens: rawTokens } }

【前端面试总结|vue核心面试题(vue中模板编译原理)】总结:
在compiler的index文件中会创建一个编辑器(createCompilerCreator),传入了一个baseCompile方法,baseCompile方法将模板转换为render函数的。baseCompile中首先将模板转化成ast树,ast就是用对象来描述真实的js语法,然后进行树的优化,标记下哪些树是静态节点,然后将ast树再生成为js代码。
在将模板编译为render函数时,会调用parseHTML方法,将模板传入,进行模板循环,判断是否是<开头,如果是的话,textEnd就等于0(在等于0下会有两种情况:一个就是开始标签,一个就是结束标签),然后通过parseStartTag方法判断是否是开始标签,parseStartTag中首先通过字符串的match方法匹配标签名,match方法返回的是一个数组,match中第一个元素是带<符好的,第二个元素就是标签名,然后将标签名存入tagName,在追加一个atrrs属性用来存放元素的属性,下来会把标签名及标签名前面的字符串进行截取,截取完成后会进行循环,首先判断是不是关闭标签,然后在判断是否存在属性,如果非闭合标签,并且存在属性,然后会将属性截取掉,将属性的key和value拆分出来存放到attrs中,直到循环到结束标签位置,然后将其进行字符串截取,返回一个存放标签名和属性的json,然后调用start方法。如果不是开始标签就判断它是否是结束标签,如果是就进行结束标签截取,拿到结束标签名,调用parseEndTag方法将结束标签名传入,然后调用end方法。
start方法:将标签名和属性传到start方法中,在start方法中通过createASTElement创建生成一个对象。然后将这个属性赋值给一个currentParent全局属性上,并且追加到stack中。stack就是用来记录一个层级关系,记录DOM的深度,更准确的说,当解析到一个 开始标签或者文本,无论是什么,stack中的最后一项永远是当前正在被解析的节点的parentNode父节点,通过stack解析器就可以把当前正在解析的节点parent属性设置为父节点。并不是解析到一个标签的开始部分就把当前标签push到stack中。所以当解析到一个标签的开始时,要判断当前被解析的标签是否是自闭和标签,如果不是自闭和标签才push到stack中。
end方法:将结束标签名,开始位置和结束位置传入,在里面做一个处理是用当前标签名在stack从后往前找,将找到的 stack中的位置往后的所有标签全部删除(意思是,已经解析到当前的结束标签,那么它的子集肯定都是解析过的,试想一下当前标签都关闭了,它的子集肯定也都关闭了,所以需要把当前标签位置往后从 stack中都清掉)。结束标签不需要解析,只需要将 stack中的当前标签删掉就好。虽然不用解析,但 vue还是做了一个优化处理,children中的最后一项如果是空格' ',,则删除最后这一项,因为最后这一项空格是没有用的。
如果开头不是<,并且textEnd大于0,那它就是一个字符串文本,但是传入的模板中是有元素标签的。使用chars方法开始做字符串截取,如果文本截取完之后,剩余的模板字符串开头不符合标签的格式规则,那么肯定就是有没截取完的文本,这个时候只需要循环把textEnd累加,直到剩余的模板字符串符合标签的规则之后在一次性把text从模板字符串中截取出来,截取完调用chars方法将字符串存放到currentParent.children。截取之后就需要对文本进行解析,不过在解析文本之前需要进行预处理,也就是先简单加工一下文本,如果文本不为空,判断父标签是不是script或style,如果是则什么都不管,如果不是需要 decode一下编码,使用github上的 he 这个类库的 decodeHTML方法。如果文本为空,判断有没有兄弟节点,也就是 parent.children.length是不是为 0,如果大于0 返回" ",如果为 0 返回"",结果发现这一次的 text 正好命中最后的那个'',,所以这一次就什么都不用做继续下一轮解析就好。
带变量的文本和不带变量的纯文本是不同的处理方式。带变量的文本是指 Hello {{ name }}这个name就是变量,不带变量的文本是这样的 Hello Berwin这种没有访问数据的纯文本。纯文本比较简单,直接将 文本节点的ast push到 parent节点的 children中就行了。而带变量的文本要多一个解析文本变量的操作,{{name}}经过parseText解析后expression是_s(name)所以push到currentParent.children中的节点就是{expression: "_s(name)", text:"{{name}}", type:2}
如果开头不是<,并且textEnd小于0,那么传入的就是一段不存在任何元素标签的字符串文本,直接将它赋值给text做文本解析。
就这样一直循环解析,当textEnd小于0了,所有的字符串都截没了也就解析完了。解析完毕退出while循环,这时候就拿到了element ASTs。
第二步:对ast进行静态节点标记,主要用来做虚拟dom的渲染优化
1.优化器的目标是找出那些静态节点并打上标记,而静态节点指的是 DOM不需要发生变化的节点。
2.标记静态节点有两个好处:
(1)每次重新渲染的时候不需要为静态节点创建新节点
(2)在 Virtual DOM 中 patching 的过程可以被跳过
3.优化器的实现原理主要分两步:
(1)用递归的方式将所有节点添加static属性,标识是不是静态节点
(2)标记所有静态根节点
4.源码(optimizer.js中)
(1)markStatic 方法(标记静态节点)
function markStatic (node: ASTNode) { node.static = isStatic(node) // 先根据自身是不是静态节点做一个标记 if (node.type === 1) { // 如果type是1表示是一个元素节点 //不要将组件插槽内容设为静态。这样可以避免 //1.组件无法改变插槽节点 //2.静态插槽内容无法进行热重新加载 if ( !isPlatformReservedTag(node.tag) && node.tag !== 'slot' && node.attrsMap['inline-template'] == null ) { return } // 用递归标记所有静态节点 for (let i = 0, l = node.children.length; i < l; i++) {//循环 children const child = node.children[i] markStatic(child) if (!child.static) { // 如果节点不是静态节点,在将当前节点的标记修改成false node.static = false } } if (node.ifConditions) { for (let i = 1, l = node.ifConditions.length; i < l; i++) { const block = node.ifConditions[i].block markStatic(block) if (!block.static) { node.static = false } } } } }

(2).isStatic方法(判断是否是静态节点)
function isStatic (node: ASTNode): boolean { if (node.type === 2) { // expression(带变量的动态文本节点) return false } if (node.type === 3) { // text(不带变量的纯文本节点) return true } // type等于1是元素节点 return !!(node.pre || ( !node.hasBindings && // no dynamic bindings !node.if && !node.for && // not v-if or v-for or v-else !isBuiltInTag(node.tag) && // not a built-in isPlatformReservedTag(node.tag) && // not a component !isDirectChildOfTemplateFor(node) && Object.keys(node).every(isStaticKey) )) }

(3)markStaticRoots 方法(标记静态根节点)
function markStaticRoots (node: ASTNode, isInFor: boolean) { if (node.type === 1) { if (node.static || node.once) { node.staticInFor = isInFor } //要使节点符合静态根,它应该具有不仅仅是静态文本。 if (node.static && node.children.length && !( node.children.length === 1 && node.children[0].type === 3 )) { node.staticRoot = true return } else { node.staticRoot = false } if (node.children) { for (let i = 0, l = node.children.length; i < l; i++) { markStaticRoots(node.children[i], isInFor || !!node.for) } } if (node.ifConditions) { for (let i = 1, l = node.ifConditions.length; i < l; i++) { markStaticRoots(node.ifConditions[i].block, isInFor) } } } }

5.总结:
通过markStatic方法对静态节点进行标记,首先根据自身是不是静态节点做一个标记,调用isStatic方法,在isStatic中如果type === 2那肯定不是静态节点返回false,如果type===3那就是静态节点,返回true。那如果type是1,通过node.pre是true可以认为当前节点是静态节点。或者node.hasBindings(node.hasBindings属性是在解析器转换 AST时设置的,如果当前节点的 attrs中,有 v-、@、:开头的 attr,就会把node.hasBindings设置为true那么就不是一个静态节点)为false并且元素节点不能有 if和for属性(node.if和node.for也是在解析器转换 AST时设置的,在解析的时候发现节点使用了 v-if,就会在解析的时候给当前节点设置一个 if属性,就是说元素节点不能使用 v-if v-for v-else等指令),并且元素节点不能是slot和component,并且元素节点不能是组件??????,并且元素节点的父级节点不能是带v-for和template,并且元素节点上不能出现额外的属性(额外的属性指的是不能出现 :type,tag,attrsList,attrsMap,plain,parent,children,attrs,staticClass,staticStyle这几个属性之外的其他属性)如果出现其他属性则认为当前节点不是静态节点。只有符合上面所有条件的节点才会被认为是静态节点。
判断元素节点是不是静态节点不能光看它自身是不是静态节点,如果它的子节点不是静态节点,那就算它自身符合上面讲的静态节点的条件,它也不是静态节点。所以在根据自身是不是静态节点做了标记后,然后在循环 children,如果 children中出现不是静态节点,在将当前节点的标记修改成false,通过循环,然后对每个不同的子节点走相同的逻辑去循环它的 children,这样递归下来所有的节点都会被打上标记。
标记静态根节点:通过递归调用markStaticRoots方法实现,如果当前节点是静态节点,并且有子节点,并且子节点不是单个静态文本节点这种情况会将当前节点标记为根静态节点。所以这里我们如果发现一个节点是静态节点,那就能证明它的所有子节点也都是静态节点,而我们要标记的是静态根节点,所以如果一个静态节点只包含了一个文本节点那就不会被标记为静态根节点。
整体逻辑其实就是递归 AST语法树,然后将静态节点和静态根节点进行打标记。
第三步:使用element AST 生成render函数代码字符串 1.AST生成render是
// htmlhello

render: { with(this){ return_c('div',[_c('p',[_v(_s(hello))])]) } }

2.生成后的代码字符串中看到了有几个函数调用_c_v_s:
_c:对应的是 createElement,,它的作用是创建一个元素。
_v:的意思是创建一个文本节点。
_s:是返回参数中的字符串。
3.代码生成器的总体逻辑其实就是使用 element ASTs去递归,然后拼出一个字符串,这儿使用with会不安全,但可以帮助我们解决作用域的问题。
4.源码:
(1)generate 方法
function generate ( // render函数生成方法 ast: ASTElement | void, options: CompilerOptions ): CodegenResult { const state = new CodegenState(options) const code = ast ? genElement(ast, state) : '_c("div")' // 通过genElement方法去生成一个代码字符串 // 存在ast就获取genElement,如果为空就创建一个div return { render: `with(this){return ${code}}`, // 将生成的代码字符串拼接到with中 staticRenderFns: state.staticRenderFns } } with会不安全,可以帮我们解决作用域的问题

(2)genElement 方法(生成代码字符串)
export function genElement (el: ASTElement, state: CodegenState): string { if (el.parent) { el.pre = el.pre || el.parent.pre }if (el.staticRoot && !el.staticProcessed) { return genStatic(el, state) } else if (el.once && !el.onceProcessed) { return genOnce(el, state) } else if (el.for && !el.forProcessed) { return genFor(el, state) } else if (el.if && !el.ifProcessed) { return genIf(el, state) } else if (el.tag === 'template' && !el.slotTarget && !state.pre) { return genChildren(el, state) || 'void 0' } else if (el.tag === 'slot') { return genSlot(el, state) } else { // component or element let code if (el.component) { code = genComponent(el.component, el, state) } else { let data if (!el.plain || (el.pre && state.maybeComponent(el))) { data = https://www.it610.com/article/genData(el, state) // 获取data }const children = el.inlineTemplate ? null : genChildren(el, state, true) // 获取children code = `_c('${el.tag}'${ data ? `,${data}` : '' // data }${ children ? `,${children}` : '' // children })` } // module transforms for (let i = 0; i < state.transforms.length; i++) { code = state.transforms[i](el, code) } return code } }

(3)genData 方法(生成data)
// 根据ast上的属性和数据拼接一个data字符串 export function genData (el: ASTElement, state: CodegenState): string { let data = 'https://www.it610.com/article/{' // directives first. // directives may mutate the el's other properties before they are generated. const dirs = genDirectives(el, state) if (dirs) data += dirs + ',' // key if (el.key) { data += `key:${el.key},` } // ref if (el.ref) { data += `ref:${el.ref},` } if (el.refInFor) { data += `refInFor:true,` } // pre if (el.pre) { data += `pre:true,` } // record original tag name for components using "is" attribute if (el.component) { data += `tag:"${el.tag}",` } // module data generation functions for (let i = 0; i < state.dataGenFns.length; i++) { data += state.dataGenFns[i](el) } // attributes if (el.attrs) { data += `attrs:${genProps(el.attrs)},` } // DOM props if (el.props) { data += `domProps:${genProps(el.props)},` } // event handlers if (el.events) { data += `${genHandlers(el.events, false)},` } if (el.nativeEvents) { data += `${genHandlers(el.nativeEvents, true)},` } // slot target // only for non-scoped slots if (el.slotTarget && !el.slotScope) { data += `slot:${el.slotTarget},` } // scoped slots if (el.scopedSlots) { data += `${genScopedSlots(el, el.scopedSlots, state)},` } // component v-model if (el.model) { data += `model:{value:${ el.model.value },callback:${ el.model.callback },expression:${ el.model.expression }},` } // inline-template if (el.inlineTemplate) { const inlineTemplate = genInlineTemplate(el, state) if (inlineTemplate) { data += `${inlineTemplate},` } } data = https://www.it610.com/article/data.replace(/,$/,'') + '}' // 针对不同属性做处理然后拼接成一个字符串返回去 // v-bind dynamic argument wrap // v-bind with dynamic arguments must be applied using the same v-bind object // merge helper so that class/style/mustUseProp attrs are handled correctly. if (el.dynamicAttrs) { data = https://www.it610.com/article/`_b(${data},"${el.tag}",${genProps(el.dynamicAttrs)})` } // v-bind data wrap if (el.wrapData) { data = https://www.it610.com/article/el.wrapData(data) } // v-on data wrap if (el.wrapListeners) { data = el.wrapListeners(data) } return data }

(4)genChildren
// 拼接children字符串 export function genChildren ( el: ASTElement, state: CodegenState, checkSkip?: boolean, altGenElement?: Function, altGenNode?: Function ): string | void { const children = el.children if (children.length) { const el: any = children[0] // optimize single v-for if (children.length === 1 && el.for && el.tag !== 'template' && el.tag !== 'slot' ) { const normalizationType = checkSkip ? state.maybeComponent(el) ? `,1` : `,0` : `` return `${(altGenElement || genElement)(el, state)}${normalizationType}` } const normalizationType = checkSkip ? getNormalizationType(children, state.maybeComponent) : 0 const gen = altGenNode || genNode return `[${children.map(c => gen(c, state)).join(',')}]${ normalizationType ? `,${normalizationType}` : '' }` } }

(5)genProps 方法
// 拼接ast上属性字符串 function genProps (props: Array): string { let staticProps = `` let dynamicProps = `` for (let i = 0; i < props.length; i++) { const prop = props[i] const value = https://www.it610.com/article/__WEEX__ ? generateValue(prop.value) : transformSpecialNewlines(prop.value) if (prop.dynamic) { dynamicProps += `${prop.name},${value},` } else { staticProps += `"${prop.name}":${value},` } } staticProps = `{${staticProps.slice(0, -1)}}` if (dynamicProps) { return `_d(${staticProps},[${dynamicProps.slice(0, -1)}])` } else { return staticProps } }

(6)genNode
function genNode (node: ASTNode, state: CodegenState): string { // 根据不同的节点类型执行对应的方法 if (node.type === 1) { return genElement(node, state) } else if (node.type === 3 && node.isComment) { return genComment(node) } else { return genText(node) } }

5.总结:在进行ast生成render时,会调用generate方法将ast传入,最后会返回一个render函数。通过genElement方法去生成一个code,在genElement中主要逻辑就是用genData和genChildren获取data和children,然后拼接到_c中去,拼完后把拼好的 "_c(tagName, data, children)"返回。
在genData中根据AST上当前节点上都有什么属性,然后针对不同的属性做一些不同的处理,最后拼出一个字符串。在genData中如果ast上有atrrs和props会调用genProps 进行处理,最后返回一个属性字符串。
在genChildren中生成children的过程其实就是循环 AST中当前节点的 children,通过genNode 方法把每一项在重新按不同的节点类型去执行 genElement,genComment,genText。如果genElement中又有 children在循环生成,如此反复递归,最后一圈跑完之后能拿到一个完整的 render函数代码字符串,就是类似"_c('div',[_c('p',[_v(_s(hello))])])"这个样子。最后把生成的code放到with中。
在generate方法中主要有两个部分,第一个就是生成一个code(如果有ast就去调用genElement,如果没有就创建一个空 的div),第二个就是将code放入with中return出去一个render。
其原理就是:通过递归去拼一个函数执行代码的字符串,递归的过程根据不同的节点类型调用不同的生成方法,如果发现是一个元素节点就拼一个_c(tagName, data, children)的函数调用字符串,然后 data和children也是使用 AST中的属性去拼字符串。如果 children中还有 children则递归去拼。最后拼出一个完整的render函数代码
涉及知识点: 1. v-pre(node.pre):
跳过此元素及其所有子元素的编译。您可以使用它来显示原始的胡须标签。跳过没有指令的大量节点也可以加快编译速度。
2.str.match
字符串方法,可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配。可通过字符串或者正则检索,返回结果是数组。该数组的内容依赖于 regexp 是否具有全局标志 g。
regexp 没有标志 g,那么 match() 方法就只能在 stringObject 中执行一次匹配。如果没有找到任何匹配的文本, match() 将返回 null。否则,它将返回一个数组,其中存放了与它找到的匹配文本有关的信息。该数组的第 0 个元素存放的是匹配文本,而其余的元素存放的是与正则表达式的子表达式匹配的文本。除了这些常规的数组元素之外,返回的数组还含有两个对象属性。index 属性声明的是匹配文本的起始字符在 stringObject 中的位置,input 属性声明的是对 stringObject 的引用。
regexp 具有标志 g,则 match() 方法将执行全局检索,找到 stringObject 中的所有匹配子字符串。若没有找到任何匹配的子串,则返回 null。如果找到了一个或多个匹配子串,则返回一个数组。不过全局匹配返回的数组的内容与前者大不相同,它的数组元素中存放的是 stringObject 中所有的匹配子串,而且也没有 index 属性或 input 属性。
注意:在全局检索模式下,match() 即不提供与子表达式匹配的文本的信息,也不声明每个匹配子串的位置。如果您需要这些全局检索的信息,可以使用 RegExp.exec()。

    推荐阅读