当前位置:   article > 正文

vue2模板解析

vue2模板

vue2模板解析

在调用$mount方法时,会判断传参中是否存在render或者template(同时存在以render为准)如果没有render,而只有template的话需要先使用编译器把模板编译成render函数,之后进行渲染。

使用render方式生成节点:

new Vue({
  render: (h) => {
    return h('div', {}, [h('h1', 'use render')])
  },
})
  • 1
  • 2
  • 3
  • 4
  • 5

内部直接调用mountComponent方法组成updateComponent方法进行渲染。

使用template的方式

new Vue({
  template: '<div><h1>use render</h1></div>'
})
  • 1
  • 2
  • 3

entry-runtime-with-compiler.js文件中覆写了$mount方法,先将template进行解析。

template类型

template可以有三种类型:

  1. id标识,通过id在文档中查找对应的模板
<div id="app">
  <div id="temp">
    <h1>use id to get template</h1>
  </div>
</div>

new Vue({
  app: '#app',
  template: '#temp'
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  1. 真实节点,跟第一种类似,不过不需要vue内部通过id去查找节点,而是直接提供节点。
<div id="app">
  <div>
    <h1>use id to get template</h1>
  </div>
</div>

new Vue({
  app: '#app',
  template: document.getElementsByTagName('div')[1],
})
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  1. 模板字符串
new Vue({
  app: '#app',
  template: '<div><h1>use template string</h1></div>'
})
  • 1
  • 2
  • 3
  • 4

先看看$mount对传入的template进行处理逻辑:

// $mount方法逻辑
if (template) {
  if (typeof template === 'string') {
    // 处理传入id的情况:
    if (template.charAt(0) === '#') {
      template = idToTemplate(template)
      
    }
  // 传入真实节点情况:
  } else if (template.nodeType) {
    console.log(template)
    template = template.innerHTML
  // 非法格式
  } else {
    if (process.env.NODE_ENV !== 'production') {
      warn('invalid template option:' + template, this)
    }
    return this
  }
// 没有template默认渲染#app节点
} else if (el) {
  template = getOuterHTML(el)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

在确保了template存在后,就是生成render函数流程了,这也是本文章需要详细讲解的部分。

render函数的生成

来看看$mount中剩余逻辑:

if (template) {
  const { render, staticRenderFns } = compileToFunctions(template, { // 编译模板
    outputSourceRange: process.env.NODE_ENV !== 'production',
    shouldDecodeNewlines,
    shouldDecodeNewlinesForHref,
    delimiters: options.delimiters,
    comments: options.comments
  }, this)
  options.render = render
  options.staticRenderFns = staticRenderFns
}
// 渲染
return mount.call(this, el, hydrating) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

$mount方法通过compileToFunctions函数获取了render函数,而compileToFunctions这个方法用于通过createCompiler生成的,为什么要设计的这么嵌套呢?这是因为Vue内部需要兼容webweex两个平台,这两个平台对于DOM的操作是不同的,模板必然存在差异(比如说保留标签名,weex存在slider这个标签名,web不存在,那么web肯定处理不了)。

因此createCompiler接受了一个对象(不同平台配置,确保能正确编译),并返回一个包含了compileToFunctions方法的对象。本文章只讲解web端。

// src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)
  • 1
  • 2

createCompilercreateCompilerCreator工厂函数的返回值,可能大家会觉得很绕,不过vue这样设计是为了用户可以自定义编译器的行为,我们看看vue内部的编译器:

// src/compiler/index.js
 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);
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

可以看到vue的基础编译器非常容易理解:先解析,然后优化AST树,最后生成render函数。

根据createCompiler方法传入的平台配置并与用户传入的options合并后传作为最终的编译配置传入编译器中:

// src/compiler/create-compiler.js createCompilerCreator方法中compile逻辑
// 合并配置
if (options) {
  // merge custom modules
  if (options.modules) {
    finalOptions.modules =
      (baseOptions.modules || []).concat(options.modules)
  }
  // merge custom directives
  if (options.directives) {
    finalOptions.directives = extend(
      Object.create(baseOptions.directives || null),
      options.directives
    )
  }
  // copy other options
  for (const key in options) {
    if (key !== 'modules' && key !== 'directives') {
      finalOptions[key] = options[key]
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

前面提过,createCompilerCreator可以让用户自定义编译器,createCompileToFunctionFn也是同理,上面的代码都是基于vue内部配置合并后传入baseCompiler中,而对于一些自定义的编译器不想使用到内部定义的配置,那么可以直接通过给createCompileToFunctionFn工厂函数传入一个编译器,那么render函数就可以按照传入的编译器进行生成。

说了这么多vue的自定义编译器的设计,其实就是为了能让大家更好的理解这嵌套的函数,让我们把重点放回vue的编译器,看看是怎么解析模板的。

将上面提到的处理用户定义编译器相关的代码全部移除,单独把vue的编译器的核心代码提取出来:

// src/compiler/to-function.js createCompileToFunctionFn方法的返回值
function compileToFunctions (
  template: string,
  options?: CompilerOptions,
  vm?: Component
): CompiledFunctionResult {
  const compiled = compile(template, options)

  const res = {}
  res.render = createFunction(compiled.render, fnGenErrors)
  res.staticRenderFns = compiled.staticRenderFns.map(code => {
    return createFunction(code, fnGenErrors)
  })
  ...
}

// 将字符串转化成函数
function createFunction (code, errors) {
  return new Function(code)
}

// src/compiler/create-compiler.js createCompilerCreator中的返回值中定义的compile方法
function compile (
  template: string,
  options?: CompilerOptions
): CompiledResult {
  ...
  const compiled = baseCompile(template.trim(), finalOptions)
  ...
}

// src/compiler/index.js 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)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

可以看到vue在处理template的时候最终是通过baseCompile方法。

parse

这个方法把html转化成ast

function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  ...
  parseHTML(template, {
    ...
    // 编译器配置
  })
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

parseHTML是将template字符串转换成ast的核心,通过词法分析和语法分析,将template解析为一个树形结构的ast

parseHTML

解析template,根据<>字符来作为标识进行解析(HTML里边的标签都是包裹在<>内的)。

先看看parseHTML的设计,还记得前面提到的vue支持自定义解析器吗,parseHTML也是这样的设计,parseHTML方法里通过在解析出标签及属性后,会调用传入parseHTML中的startchartsend等方法并把解析出的标签传入其中,由其中的解析器对解析出的标签等进行更一步的处理。

我们梳理一遍parseHTML方法就能更理解这种设计了。

// src/compiler/parser/html-parser.js parseHTML 逻辑
while (html) {
  last = html
  // 不处理script、style、textarea标签
  if (!lastTag || !isPlainTextElement(lastTag)) {
    let textEnd = html.indexOf('<')
    // 第一个是<字符
    if (textEnd === 0) {
      ...
    }
    // 文本
    if (textEnd >= 0) {}
  } else {
    // 单独处理script、style、textarea标签
    ...
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

首先我们要知道为什么解析template,因为template中存在一些动态的属性(比如指令、表达式、响应式数据等等,还有组件标签<comp></comp>)普通HTML是无法处理这些东西的,因此需要解析出这些动态数据,之后通过document.createElement的方式把解析出的数据放入创建的节点中。
stylescript没有这样的设计,因此不需要这样的处理。

来看看vue是怎么处理stylescript

// 单独处理script、style、textarea标签

// 把标签内的内容当作文本处理,不解析
if (options.chars) {
  options.chars(text)
}
index += html.length - rest.length
html = rest
// 处理结束标签
parseEndTag(stackedTag, index - endTagLength, index)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

stylescript标签:

当第一个字符是<

也就是if (textEnd === 0)这个条件内的逻辑,我们能想到,这种情况就是遇到标签了,开始标签(例如<div>)、结束标签(</div>)、注释(<!--xxxxx-->

  1. 注释节点:
  if (comment.test(html)) {
    const commentEnd = html.indexOf('-->')
    if (commentEnd >= 0) {
      // 是否要生成注释节点。
      if (options.shouldKeepComment) {
        options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
      }
      // 移动指针
      advance(commentEnd + 3)
      continue
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

vue支持配置shouldKeepComment属性(默认undefined)用来选择要不要生成注释节点,调用options.comment方法并把通过正则获取到的注释节点交给编译器中的comment方法处理。

编译器中直接生成一个ast对象,用type来区分不同标签类型。

// src/compiler/parser/index.js parse 方法中传入parseHTML中的配置

comment(text, start, end) {
  if (currentParent) {
    const child = {
      type: 3,
      text,
      isComment: true
    }
    currentParent.children.push(child)
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  1. DOCTYPE

虽然不至于有开发者会在模板中添加<!DOCTYPE html>文档类型,但是vue也进行了校验,做法就是直接掠过不处理。

  1. 结束标签
  const endTagMatch = html.match(endTag)
  if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  function parseEndTag(tagName, start, end) {
    if (tagName) {
      // 匹配最近的开始标签
      lowerCasedTagName = tagName.toLowerCase()
      for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      pos = 0
    }

    if (pos >= 0) {
      for (let i = stack.length - 1; i >= pos; i--) {
        // 关闭标签
        if (options.end) {
          options.end(stack[i].tag, start, end)
        }
      }

      // Remove the open elements from the stack
      stack.length = pos
      lastTag = pos && stack[pos - 1].tag
    } else if (lowerCasedTagName === 'br') {
      if (options.start) {
        options.start(tagName, [], true, start, end)
      }
    } else if (lowerCasedTagName === 'p') {
      if (options.start) {
        options.start(tagName, [], false, start, end)
      }
      if (options.end) {
        options.end(tagName, start, end)
      }
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

parseEndTag用于解析结束标签,首先是需要在数组中找到当前结束标签对应的开始标签,然后调用options.end去处理。

不过也有几种情况要处理:

  • p,这种情况是p标签内存在其他标签,vue的处理先关闭开始的p标签(详情看下面开始标签解析中的p),这样就导致对应的结束标签p没有处理,因此vue的处理是直接调用start后调用end,生成一个空p标签
  • br,没有结束标签,在开始标签处理部分会直接调用parseEndTag处理。
  end(tag, start, end) {
    const element = stack[stack.length - 1]
    stack.length -= 1
    currentParent = stack[stack.length - 1]

    closeElement(element)
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  function closeElement(element) {
    if (!inVPre && !element.processed) {
      // 处理标签内的属性
      element = processElement(element, options)
    }
    ...
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

closeElement方法用于处理ref属性及slottemplate标签,并最后对attrs属性进行处理(添加dynamicAttrsattrs属性用于保存动态和静态属性)。

  1. 开始标签
  const startTagMatch = parseStartTag()
  if (startTagMatch) {
    handleStartTag(startTagMatch)
    if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
      advance(1)
    }
    continue
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
// src/compiler/parser/html-parser.js parseHTML/parseStartTag方法
  function parseStartTag() {
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
      ...
      // 遍历标签中的属性,放入attrs数组中 
      while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }
      ...
    }
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

parseStartTag方法就是把开始标签中的属性全部解析出来并放入一个数组中,通过使用一个对象来记录这个开始标签(tagNameattrs属性、unarySlash(用于判断是否是自关闭标签)等)

在通过parseStartTag生成了一个标签对象后,还需要调用handleStartTag方法进行处理:

这个方法内容比较多:

  • 首先就是p标签,可能存在嵌套其他标签的情况:
    比如:
<p><h1>1</h1></p>
  • 1

浏览器的处理是:

<p></p>
<h1>1</h1>
<p></p>
  • 1
  • 2
  • 3

因此vue也严格按照浏览器的解析,将p标签关闭后在处理其中的内容。

if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
  parseEndTag(lastTag)
}
  • 1
  • 2
  • 3
  • 其次就是不需要手动添加标签的情况(th,thead等),也直接将标签关闭
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
  parseEndTag(tagName)
}
  • 1
  • 2
  • 3
  • 遍历处理标签对象中的attrs,生成[{name, value}]格式便于后续的处理。

  • 把处理的好的标签放入一个队列中,用于后续处理关闭标签时能匹配到对应的开始标签,确保模板中的标签是对应的。

stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
  • 1
  • 调用options.start方法把解析出的标签放入解析器中进行更进一步的处理。
// src/compiler/parser/index.js parse 方法中传入parseHTML中的配置

start (tag, attrs, unary, start, end) {
  let element = createASTElement(tag, attrs, currentParent);

  // 处理指令
  processFor(element)
  processIf(element)
  processOnce(element)

  if (!unary) {
    currentParent = element
    stack.push(element)
  } else {
    closeElement(element)
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

生成一个对象用于保存解析出来的标签,处理v-forv-ifv-once指令。

vue还对ie6中的条件注释进行了处理,这里不做介绍。

当第一个字符不是<

这时候说明是标签中的内容,直接截取后调用options.chars生成文本。

// src/compiler/parser/index.js parse 方法中传入parseHTML中的配置

chars (text: string, start: number, end: number) {
  ...
  if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
    child = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
    child = {
      type: 3,
      text
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

这里主要考虑了文本中存在{{xxx}}的情况,也就是动态值的情况,使用parseText方法进行解析:

function parseText (text, delimiters) {
  ...
  while ((match = tagRE.exec(text))) {
    index = match.index
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    // 解析动态字符
    const exp = parseFilters(match[1].trim())
    // 用_s标记
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    lastIndex = index + match[0].length
  }
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

通过判断文本中是否存在模板分隔符,然后使用_s方法包裹表达式。

optimize

对静态节点进行标记,减少重新渲染时重复生成静态真实节点的耗时,从而进一步提高渲染性能。

function optimize (root, options) {
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  markStatic(root)
  markStaticRoots(root, false)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

markStatic的方法是用于给当前标签添加一个static属性。

static值:

  • true, 只有当节点为不存在v-ifv-bind、属性都是在modules中定义了过的静态属性、非组件标签、非v-for指令生成、非slot标签,且子节点也通过满足以上条件
  • false

里边的逻辑非常简单,就是遍历节点及其子节点,只要不满足上面提到的几种情况,那么节点的static属性就是true,否则哪怕只有一个子节点存在这种情况都是所有的父节点都是false

可以在src/core/vdom/patch.js文件中的createPatchFunction中的片段可以看到关于对static属性的使用:

if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    // 直接return,不执行下边的新旧节点对比逻辑
    return
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

markStaticRoots则是用来标记一组静态节点的根节点,当一个节点被标记成静态节点的根节点后,在渲染的时候就可以跳过这个节点的子树。

generate

前面的parse方法将template解析成一个AST对象,而optimize方法则是对静态节点进行标记,generate函数是用来解析元素节点(AST对象)并生成渲染函数代码的。

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
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

genElement函数会根据AST对象节点的标签名、属性、子节点等信息,生成对应的创建元素和设置属性的代码,并递归处理子节点。

function genElement (el: ASTElement, state: CodegenState): string {
// 如果元素节点是静态节点且未被处理过,我们调用genStatic函数处理并返回结果
  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 {
	...
    }
    return code
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

Vue中,每个AST都有一个type属性,用于表示该节点的类型。type属性的取值如下:

  1. 元素节点;
  2. 表达式节点;
  3. 文本节点;
  4. 注释节点;

genElement函数的作用是根据一个元素节点的AST描述对象生成该元素在模板中对应的渲染函数代码。在这个函数中,首先判断元素节点是否是静态节点、插槽节点、循环节点、条件节点或组件节点。如果是,则分别调用对应的函数处理;否则,根据ASTElement节点的tag属性生成创建元素的代码,并根据元素的静态属性、动态属性和子节点生成对应的代码片段,并将它们拼接成一个完整的创建元素的代码。

由于篇幅原因就不一一讲解是怎么处理。我们就重点就来看看renderstaticRenderFns函数。

render属性把生成的代码包裹在with(this){return ${code}}中,这是因为在genElement方法是把解析的节点包裹在渲染帮助方法中(src/core/instance/render-helpers/index.js中定义的方法)由于这些被添加到Vue.prototype中,通过with(this)帮上下文绑定到Vue实例中,这就可以调用_c这些方法了。

render函数会在每次重新渲染时调用,因此它可以响应动态数据的变化并更新视图。

staticRenderFns是静态渲染函数的数组,这些函数也用于渲染组件模板。与render不同的是,staticRenderFns生成的结果只在组件初始化时被使用一次,并且之后不会再更新。这意味着静态渲染函数可以在性能上提供一些优化,因为它们不需要在每次重新渲染时重新计算。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/运维做开发/article/detail/1011502
推荐阅读
相关标签
  

闽ICP备14008679号