美文网首页
Vue3核心源码解析 (三) : 虚拟DOM到底是什么

Vue3核心源码解析 (三) : 虚拟DOM到底是什么

作者: 奋斗_登 | 来源:发表于2023-04-09 21:59 被阅读0次

    1. 什么是虚拟DOM

      在浏览器中,HTML页面由基本的DOM树组成,当其中一部分发生变化时,其实就是对应某个DOM节点发生了变化,当DOM节点发生变化时就会触发对应的重绘或者重排,当过多的重绘和重排在短时间内发生时,就可能会引起页面卡顿,所以改变DOM是有一些代价的,如何优化DOM变化的次数以及在合适的时机改变DOM就是开发者需要注意的事情。
      虚拟DOM就是为了解决上述浏览器性能问题而被设计出来的。当一次操作中有10次更新DOM的动作时,虚拟DOM不会立即操作DOM,而是和原本的DOM进行对比,将这10次更新的变化部分内容保存到内存中,最终一次性地应用在DOM树上,再进行后续操作,避免大量无谓的计算量。
      虚拟DOM实际上就是采用JavaScript对象来存储DOM节点的信息,将DOM的更新变成对象的修改,并且这些修改计算在内存中发生,当修改完成后,再将JavaScript转换成真实的DOM节点,交给浏览器,从而达到性能的提升。
    例如以下dom节点

    <div id="app">
           <p class="text">Hello</p>
    </div>
    

    转成一般的虚拟dom结构如下:

    {
        tag:'div',
        props: {
            id: 'app'
        },
        chidren:[
            {
                tag: 'p',
                props: {
                    className : 'text'
                },
                chidren:[
                    'Hello'
                ]
            }
        ]
    }
    

    这是一个简单的虚拟dom结构,vue3中真实的虚拟Dom比这复杂多了。

    2. Vue 3虚拟DOM

    在Vue中,我们写在<template>标签内的内容都属于DOM节点,这部分内容最终会被转换成Vue中的虚拟DOM对象VNode,其中的步骤比较复杂,主要有以下几个过程:

    • 抽取<template>内容进行编译。
    • 得到抽象语法树(Abstract Syntax Tree, AST),并生成render方法。
    • 执行render方法得到VNode对象。
    • VNode转换为真实DOM并渲染到页面中。
      以一个简单的demo为例,demo代码如下:
     <div id="app">
       <div>
         {{name}}
       </div>
       <p>123</p>
     </div>
     Vue.createApp({
       data(){
         return {
           name : 'abc'
         }
       }
     }).mount("#app")
    

    上面的代码中,data中定义了一个响应式数据name,在template中使用插值表达式{{name}}进行展示,还有一个静态节点<p>123</p>。

    3. 获取<template>的内容

    我们看下createApp的源码: packages\runtime-dom\src\

    export const createApp = ((...args) => {
      const app = ensureRenderer().createApp(...args)
    
      if (__DEV__) {
        injectNativeTagCheck(app)
        injectCompilerOptionsCheck(app)
      }
    
      const { mount } = app
      app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
        const container = normalizeContainer(containerOrSelector)
        if (!container) return
    
        const component = app._component
        if (!isFunction(component) && !component.render && !component.template) {
          // __UNSAFE__
          // Reason: potential execution of JS expressions in in-DOM template.
          // The user must make sure the in-DOM template is trusted. If it's
          // rendered by the server, the template should not contain any user data.
          component.template = container.innerHTML
          // 2.x compat check
          if (__COMPAT__ && __DEV__) {
            for (let i = 0; i < container.attributes.length; i++) {
              const attr = container.attributes[i]
              if (attr.name !== 'v-cloak' && /^(v-|:|@)/.test(attr.name)) {
                compatUtils.warnDeprecation(
                  DeprecationTypes.GLOBAL_MOUNT_CONTAINER,
                  null
                )
                break
              }
            }
          }
        }
    
        // clear content before mounting
        container.innerHTML = ''
        const proxy = mount(container, false, container instanceof SVGElement)
        if (container instanceof Element) {
          container.removeAttribute('v-cloak')
          container.setAttribute('data-v-app', '')
        }
        return proxy
      }
    
      return app
    }) as CreateAppFunction<Element>
    

    对于根组件来说,<template>的内容由挂载的#app元素里面的内容组成,如果项目采用npm和Vue Cli+Webpack这种前端工程化的方式,那么对于<template>的内容,主要由对应的loader在构建时对文件进行处理来获取,这和在浏览器运行时的处理方式是不一样的。

    4. 生成AST

      在得到<template>后,就依据内容生成AST。AST是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现而类似于if-condition-then这样的条件跳转语句,可以使用带有三个分支的节点来表示。代码如下:

         while b ≠ 0
           if a > b
         a := a ? b
           else
         b := b ? a
         return a
    

    将上述代码转换成广泛意义上的语法树:


    AST

    对于<template>的内容,其大部分是由DOM组成的,但是也会有if-condition-then这样的条件语句,例如v-if、v-for指令等。在Vue 3中,这部分逻辑在源码packages\compiler-core\src\compile.ts的baseCompile方法中,核心代码如下:

    export function baseCompile(
      template: string | RootNode,
      options: CompilerOptions = {}
    ): CodegenResult {
      const onError = options.onError || defaultOnError
      const isModuleMode = options.mode === 'module'
      /* istanbul ignore if */
      if (__BROWSER__) {
        if (options.prefixIdentifiers === true) {
          onError(createCompilerError(ErrorCodes.X_PREFIX_ID_NOT_SUPPORTED))
        } else if (isModuleMode) {
          onError(createCompilerError(ErrorCodes.X_MODULE_MODE_NOT_SUPPORTED))
        }
      }
    
      const prefixIdentifiers =
        !__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
      if (!prefixIdentifiers && options.cacheHandlers) {
        onError(createCompilerError(ErrorCodes.X_CACHE_HANDLER_NOT_SUPPORTED))
      }
      if (options.scopeId && !isModuleMode) {
        onError(createCompilerError(ErrorCodes.X_SCOPE_ID_NOT_SUPPORTED))
      }
    // 通过template生成AST结构
      const ast = isString(template) ? baseParse(template, options) : template
      const [nodeTransforms, directiveTransforms] =
        getBaseTransformPreset(prefixIdentifiers)
    
      if (!__BROWSER__ && options.isTS) {
        const { expressionPlugins } = options
        if (!expressionPlugins || !expressionPlugins.includes('typescript')) {
          options.expressionPlugins = [...(expressionPlugins || []), 'typescript']
        }
      }
    //转换
      transform(
        ast,
        extend({}, options, {
          prefixIdentifiers,
          nodeTransforms: [
            ...nodeTransforms,
            ...(options.nodeTransforms || []) // user transforms
          ],
          directiveTransforms: extend(
            {},
            directiveTransforms,
            options.directiveTransforms || {} // user transforms
          )
        })
      )
    
      return generate(
        ast,
        extend({}, options, {
          prefixIdentifiers
        })
      )
    }
    

    baseCompile方法主要做了以下事情:

    • 生成Vue中的AST对象。
    • 将AST对象作为参数传入transform函数,进行转换。
    • 将转换后的AST对象作为参数传入generate函数,生成render函数。

    baseParse方法用来创建AST对象。

    export function baseParse(
      content: string,
      options: ParserOptions = {}
    ): RootNode {
      const context = createParserContext(content, options)
      const start = getCursor(context)
      return createRoot(
        parseChildren(context, TextModes.DATA, []),
        getSelection(context, start)
      )
    }
    

    在Vue 3中,AST对象是一个RootNode类型的树状结构,在源码packages\compiler-core\src\ast.ts中,其结构如下:

    export function createRoot(
      children: TemplateChildNode[],
      loc = locStub
    ): RootNode {
      return {
        type: NodeTypes.ROOT, //元素类型
        children, //子元素
        helpers: new Set(),//帮助函数
        components: [], //子组件
        directives: [], //指令
        hoists: [], //表示静态节点
        imports: [],
        cached: 0,//缓存标志位
        temps: 0,
        codegenNode: undefined,//存储生成render函数的字符串
        loc //描述元素在AST的位置信息
      }
    }
    

    其中,children存储的是后代元素节点的数据,这就构成一个AST结构,type表示元素的类型NodeType,主要分为HTML普通类型和Vue指令类型等,常见的有以下几种:

         ROOT,               // 根元素 0
         ELEMENT,            // 普通元素 1
         TEXT,               // 文本元素 2
         COMMENT,            // 注释元素 3
         SIMPLE_EXPRESSION,  // 表达式 4
         INTERPOLATION,      // 插值表达式 {{ }} 5
         ATTRIBUTE,          // 属性 6
         DIRECTIVE,          // 指令 7
         IF,                 // if节点 9
         JS_CALL_EXPRESSION, // 方法调用 14
    

    hoists是一个数组,用来存储一些可以静态提升的元素,在后面的transform会将静态元素和响应式元素分开创建,这也是Vue 3中优化的体现;codegenNode则用来存储最终生成的render方法的字符串;loc表示元素在AST的位置信息。
    在生成AST时,Vue 3在解析<template>内容时会用一个栈来保存解析到的元素标签。当它遇到开始标签时,会将这个标签推入栈,遇到结束标签时,将刚才的标签弹出栈。它的作用是保存当前已经解析了但还没解析完的元素标签。这个栈还有另一个作用,在解析到某个字节点时,通过stack[stack.length - 1]可以获取它的父元素。
    demo代码中生成的AST如下图。


    AST

    5. 生成render方法字符串

    在得到AST对象后,会进入transform方法,在源码packages\compiler-core\src\transform.ts中,其核心代码如下:

    export function transform(root: RootNode, options: TransformOptions) {
     // 数据组装
      const context = createTransformContext(root, options)
    // 转换代码
      traverseNode(root, context)
    // 静态提升
      if (options.hoistStatic) {
        hoistStatic(root, context)
      }
    //服务端渲染
      if (!options.ssr) {
        createRootCodegen(root, context)
      }
    // 透传元信息
      // finalize meta information
      root.helpers = new Set([...context.helpers.keys()])
      root.components = [...context.components]
      root.directives = [...context.directives]
      root.imports = context.imports
      root.hoists = context.hoists
      root.temps = context.temps
      root.cached = context.cached
    
      if (__COMPAT__) {
        root.filters = [...context.filters!]
      }
    }
    

    transform方法主要是对AST进行进一步转化,为generate函数生成render方法做准备,主要做了以下事情:

    • traverseNode方法将会递归地检查和解析AST元素节点的属性,例如结合helpers方法对@click等事件添加对应的方法和事件回调,对插值表达式、指令、props添加动态绑定等。
    • 处理类型逻辑包括静态提升逻辑,将静态节点赋值给hoists,以及为不同类型的节点打上不同的patchFlag,以便于后续diff使用。
    • 在AST上绑定并透传一些元数据。

    generate方法主要是生成render方法的字符串code,在源码packages\compiler-core\src\codegen.ts中,其代码如下:

    export function generate(
      ast: RootNode,
      options: CodegenOptions & {
        onContextCreated?: (context: CodegenContext) => void
      } = {}
    ): CodegenResult {
      const context = createCodegenContext(ast, options)
      if (options.onContextCreated) options.onContextCreated(context)
      const {
        mode,
        push,
        prefixIdentifiers,
        indent,
        deindent,
        newline,
        scopeId,
        ssr
      } = context
    
      const helpers = Array.from(ast.helpers)
      const hasHelpers = helpers.length > 0
      const useWithBlock = !prefixIdentifiers && mode !== 'module'
      const genScopeId = !__BROWSER__ && scopeId != null && mode === 'module'
      const isSetupInlined = !__BROWSER__ && !!options.inline
    
      // preambles
      // in setup() inline mode, the preamble is generated in a sub context
      // and returned separately.
      const preambleContext = isSetupInlined
        ? createCodegenContext(ast, options)
        : context
      if (!__BROWSER__ && mode === 'module') {
        genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
      } else {
        genFunctionPreamble(ast, preambleContext)
      }
      // enter render function
      const functionName = ssr ? `ssrRender` : `render`
      const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
      if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
        // binding optimization args
        args.push('$props', '$setup', '$data', '$options')
      }
      const signature =
        !__BROWSER__ && options.isTS
          ? args.map(arg => `${arg}: any`).join(',')
          : args.join(', ')
    
      if (isSetupInlined) {
        push(`(${signature}) => {`)
      } else {
        push(`function ${functionName}(${signature}) {`)
      }
    //缩进
      indent()
    
      if (useWithBlock) {
        push(`with (_ctx) {`)
        indent()
        // function mode const declarations should be inside with block
        // also they should be renamed to avoid collision with user properties
        if (hasHelpers) {
          push(`const { ${helpers.map(aliasHelper).join(', ')} } = _Vue`)
          push(`\n`)
          newline()
        }
      }
    
      // generate asset resolution statements
      if (ast.components.length) {
         // 单独处理component、directive、filters
        genAssets(ast.components, 'component', context)
        if (ast.directives.length || ast.temps > 0) {
          newline()
        }
      }
      if (ast.directives.length) {
        genAssets(ast.directives, 'directive', context)
        if (ast.temps > 0) {
          newline()
        }
      }
      if (__COMPAT__ && ast.filters && ast.filters.length) {
        newline()
        genAssets(ast.filters, 'filter', context)
        newline()
      }
    
      if (ast.temps > 0) {
        push(`let `)
        for (let i = 0; i < ast.temps; i++) {
          push(`${i > 0 ? `, ` : ``}_temp${i}`)
        }
      }
      if (ast.components.length || ast.directives.length || ast.temps) {
        push(`\n`)
        newline()
      }
    
      // generate the VNode tree expression
      if (!ssr) {
        push(`return `)
      }
      if (ast.codegenNode) {
         // 处理NodeTypes中的所有类型
        genNode(ast.codegenNode, context)
      } else {
        push(`null`)
      }
    
      if (useWithBlock) {
        deindent()
        push(`}`)
      }
    
      deindent()
      push(`}`)
    
     // 返回code字符串
      return {
        ast,
        code: context.code,
        preamble: isSetupInlined ? preambleContext.code : ``,
        // SourceMapGenerator does have toJSON() method but it's not in the types
        map: context.map ? (context.map as any).toJSON() : undefined
      }
    }
    

    generate方法的核心逻辑在genNode方法中,其逻辑是根据不同的NodeTypes类型构造出不同的render方法字符串,部分代码如下:

    
         switch (node.type) {
         case NodeTypes.ELEMENT:
         case NodeTypes.IF:
         case NodeTypes.FOR:// for关键字元素节点
           genNode(node.codegenNode!, context)
           break
         case NodeTypes.TEXT:// 文本元素节点
           genText(node, context)
           break
         case NodeTypes.VNODE_CALL:// 核心:VNode混合类型节点(AST节点)
           genVNodeCall(node, context)
           break
         case NodeTypes.COMMENT: // 注释元素节点
           genComment(node, context)
           break
         case NodeTypes.JS_FUNCTION_EXPRESSION:// 方法调用节点
           genFunctionExpression(node, context)
           break
         ...
    

    其中:

    • 节点类型NodeTypes.VNODE_CALL对应genVNodeCall方法和ast.ts文件中的createVNodeCall方法,后者用来返回VNodeCall,前者生成对应的VNodeCall这部分render方法字符串,是整个render方法字符串的核心。
    • 节点类型NodeTypes.FOR对应for关键字元素节点,其内部递归地调用了genNode方法。
    • 节点类型NodeTypes.TEXT对应文本元素节点,负责静态文本的生成。
    • 节点类型NodeTypes.JS_FUNCTION_EXPRESSION对应方法调用节点,负责方法表达式的生成。
      经过一系列的加工,最终生成的render方法字符串结果如下:
    const _Vue = Vue
    const { createElementVNode: _createElementVNode } = _Vue
    // 静态节点
    const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "123", -1 /* HOISTED */)
    
    return function render(_ctx, _cache) {//render方法
      with (_ctx) {
        const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue //helper方法
    
        return (_openBlock(), _createElementBlock(_Fragment, null, [
          _createElementVNode("div", null, _toDisplayString(name), 1 /* TEXT */),
          _hoisted_1
        ], 64 /* STABLE_FRAGMENT */))
      }
    }
    

    上面的代码中,_createElementVNode和_openBlock是上一步传进来的helper方法。其中<p>123</p>这种属于没有响应式绑定的静态节点会被单独区分,而动态节点会使用createElementVNode方法来创建,最终这两种节点都会进入createElementBlock方法进行VNode的创建。
    在render方法中使用了with关键字,with的作用如下:

         const obj = {
           a:1
         }
         with(obj){
           console.log(a) // 打印1
         }
    

    在with(_ctx)包裹下,我们在data中定义的响应式变量才能正常使用,例如调用_toDisplayString(name),其中name就是响应式变量。

    6. 得到最终的VNode对象

    最终,这是一段可执行代码,会赋值给组件的Component.render方法,其源码在packages\runtime-core\src\component.ts中,代码如下:

    ...
    Component.render = compile(template, finalCompilerOptions)
    ...
    if (installWithProxy) { // 绑定代理
    installWithProxy(instance)
    }
    ...
    

    compile方法最初是baseCompile方法的入口,在完成赋值后,还需要绑定代理,执行installWithProxy方法,其源码在packages\runtime-core\src\component.ts中,代码如下:

    /**
     * For runtime-dom to register the compiler.
     * Note the exported method uses any to avoid d.ts relying on the compiler types.
     */
    export function registerRuntimeCompiler(_compile: any) {
      compile = _compile
      installWithProxy = i => {
        if (i.render!._rc) {
          i.withProxy = new Proxy(i.ctx, RuntimeCompiledPublicInstanceProxyHandlers)
        }
      }
    }
    

    这主要是给render中_ctx的响应式变量添加绑定,当上面的render方法中的name被使用时,可以通过代理监听到调用,这样就会响应式地监听收集track,当触发trigger监听时进行diff。
    在runtime-core/src/componentRenderUtils.ts源码中的renderComponentRoot方法中会执行render方法得到VNode对象,其核心代码如下:

     export function renderComponentRoot(){
           // 执行render
           let result = normalizeVNode(render!.call(
                 proxyToUse,
                 proxyToUse!,
                 renderCache,
                 props,
                 setupState,
                 data,
                 ctx
               ))
           ...
         
           return result
         }
    

    demo代码中最终得到的VNode对象如下图。


    VNode

      上图中就是通过render方法运行后得到的VNode对象,可以看到children和dynamicChildren的区别:前者包括两个子节点,分别是<div>和<p>,这个和在<template>中定义的内容是对应的;而后者只存储了动态节点,包括动态props,即data-a属性。同时,VNode也是树状结构,通过children和dynamicChildren一层一层地递进下去。
      在通过render方法得到VNode的过程也是对指令、插值表达式、响应式数据、插槽等一系列Vue语法的解析和构造过程,最终生成结构化的VNode对象,可以将整个过程总结成流程图。


    VNode生成流程图
    属性patchFlag,这个是后面进行VNode的diff时所用到的标志位,数字64表示稳定不需要改变。最后得到VNode对象后,需要转换成真实的DOM节点,这部分逻辑是在虚拟DOM的diff中完成的。

    相关文章

      网友评论

          本文标题:Vue3核心源码解析 (三) : 虚拟DOM到底是什么

          本文链接:https://www.haomeiwen.com/subject/ncmbddtx.html