最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • petite-vue源码分析:无虚拟DOM的极简版Vue

    正文概述 掘金(橙红年代)   2021-07-12   759

    最近发现Vue增加了一个petite-vue的仓库,大概看了一下,这是一个无虚拟DOM的mini版Vue,前身貌似是vue-lite(瞎猜的~),主要用于在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。颇有意思,于是看了下源码(v0.2.3),整理了本文。

    起步

    开发调试环境

    整个项目的开发环境非常简单

    git clone git@github.com:vuejs/petite-vue.git
    
    yarn 
    
    # 使用vite启动
    npm run dev
    
    # 访问http://localhost:3000/
    

    (不得不说,用vite来搭开发环境还是挺爽的~

    新建一个测试文件exmaples/demo.html,写点代码

    <script type="module">
      import { createApp, reactive } from '../src'
    
      createApp({
        msg: "hello"
      }).mount("#app")
    </script>
    
    <div id="app">
        <h1>{{msg}}</h1>
    </div>
    

    然后访问http://localhost:3000/demo.html即可

    目录结构

    从readme可以看见项目与标准vue的一些差异

    • Only ~5.8kb,体积很小
    • Vue-compatible template syntax,与Vue兼容的模板语法
    • DOM-based, mutates in place,基于DOM驱动,就地转换
    • Driven by @vue/reactivity,使用@vue/reactivity驱动

    目录结构也比较简单,使用ts编写,外部依赖基本上只有@vue/reactivity

    petite-vue源码分析:无虚拟DOM的极简版Vue

    核心实现

    createContext

    从上面的demo代码可以看出,整个项目从createApp开始。

    export const createApp = (initialData?: any) => {
      // root context
      const ctx = createContext()
      if (initialData) {
        ctx.scope = reactive(initialData) // 将初始化数据代理成响应式
      }
      // app的一些接口
      return {
        directive(name: string, def?: Directive) {},
        mount(el?: string | Element | null) {},
        unmount() {}
      }
    }
    

    关于Vue3中的reactive,可以参考之前整理的:Vue3中的数据侦测reactive,这里就不再展开了。

    createApp中主要是通过createContext创建根context,这个上下文现在基本不陌生了,来看看createContext

    export const createContext = (parent?: Context): Context => {
      const ctx: Context = {
        ...parent,
        scope: parent ? parent.scope : reactive({}),
        dirs: parent ? parent.dirs : {}, // 支持的指令
        effects: [],
        blocks: [],
        cleanups: [],
        // 提供注册effect回调的接口,主要使用调度器来控制什么时候调用
        effect: (fn) => {
          if (inOnce) {
            queueJob(fn)
            return fn as any
          }
          // @vue/reactivity中的effect方法
          const e: ReactiveEffect = rawEffect(fn, {
            scheduler: () => queueJob(e)
          })
          ctx.effects.push(e)
          return e
        }
      }
      return ctx
    }
    

    稍微看一下queueJob就可以发现,还是Vue中熟悉的nextTick实现,

    • 通过一个全局变量queue队列保存回调
    • 在下一个微任务处理阶段,依次执行queue中的每一个回调,然后清空queue

    mount

    基本使用

    createApp().mount("#app")
    

    mount方法最主要的作用就是处理el参数,找到应用挂载的根DOM节点,然后执行初始化流程

    mount(el?: string | Element | null) {
        let roots: Element[]
        // ...根据el参数初始化roots
        // 根据el创建Block实例
        rootBlocks = roots.map((el) => new Block(el, ctx, true))
        return this
    }
    

    Block是一个抽象的概念,用于统一DOM节点渲染、插入、移除和销毁等操作。

    下图是依赖这个Block的地方,可以看见主要在初始化、iffor这三个地方使用

    petite-vue源码分析:无虚拟DOM的极简版Vue

    看一下Block的实现

    // src/block.ts
    export class Block {
      template: Element | DocumentFragment
      ctx: Context
      key?: any
      parentCtx?: Context
    
      isFragment: boolean
      start?: Text
      end?: Text
    
      get el() {
        return this.start || (this.template as Element)
      }
    
      constructor(template: Element, parentCtx: Context, isRoot = false) {
        // 初始化this.template
        // 初始化this.ctx
        
        // 构建应用
        walk(this.template, this.ctx)
      }
      // 主要在新增或移除时使用,可以先不用关心实现
      insert(parent: Element, anchor: Node | null = null) {}
      remove() {}
      teardown() {}
    }
    

    这个walk方法,主要的作用是递归节点和子节点,如果之前了解过递归diff,这里应该比较熟悉。但petite-vue中并没有虚拟DOM,因此在walk中会直接操作更新DOM。

    export const walk = (node: Node, ctx: Context): ChildNode | null | void => {
      const type = node.nodeType
      if (type === 1) {
        // 元素节点
        const el = node as Element
        // ...处理 如v-if、v-for
        // ...检测属性执行对应的指令处理 applyDirective,如v-scoped、ref等
    
        // 先处理子节点,在处理节点自身的属性
        walkChildren(el, ctx)
    
        // 处理节点属性相关的自定,包括内置指令和自定义指令
      } else if (type === 3) {
        // 文本节点
        const data = (node as Text).data
        if (data.includes('{{')) {
          // 正则匹配需要替换的文本,然后 applyDirective(text)
          applyDirective(node, text, segments.join('+'), ctx)
        }
      } else if (type === 11) {
        walkChildren(node as DocumentFragment, ctx)
      }
    }
    
    const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
      let child = node.firstChild
      while (child) {
        child = walk(child, ctx) || child.nextSibling
      }
    }
    

    可以看见会根据node.nodeType区分处理处理

    • 对于元素节点,先处理了节点上的一些指令,然后通过walkChildren处理子节点。
      • v-if,会根据表达式决定是否需要创建Block然后执行插入或移除
      • v-for,循环构建Block,然后执行插入
    • 对于文本节点,替换{{}}表达式,然后替换文本内容

    v-if

    来看看if的实现,通过branches保存所有的分支判断,activeBranchIndex通过闭包保存当前位于的分支索引值。

    在初始化或更新时,如果某个分支表达式结算结果正确且与上一次的activeBranchIndex不一致,就会创建新的Block,然后走Block构造函数里面的walk。

    export const _if = (el: Element, exp: string, ctx: Context) => {
      const parent = el.parentElement!
      const anchor = new Comment('v-if')
      parent.insertBefore(anchor, el)
    
      // 存放条件判断的各种分支
      const branches: Branch[] = [{ exp,el }]
    
      // 定位if...else if ... else 等分支,放在branches数组中
    
      let block: Block | undefined
      let activeBranchIndex: number = -1 // 通过闭包保存当前位于的分支索引值
    
      const removeActiveBlock = () => {
        if (block) {
          parent.insertBefore(anchor, block.el)
          block.remove()
          block = undefined
        }
      }
    
      // 收集依赖
      ctx.effect(() => {
        for (let i = 0; i < branches.length; i++) {
          const { exp, el } = branches[i]
          if (!exp || evaluate(ctx.scope, exp)) {
            // 当判断分支切换时,会生成新的block
            if (i !== activeBranchIndex) {
              removeActiveBlock()
              block = new Block(el, ctx)
              block.insert(parent, anchor)
              parent.removeChild(anchor)
              activeBranchIndex = i
            }
            return
          }
        }
        // no matched branch.
        activeBranchIndex = -1
        removeActiveBlock()
      })
    
      return nextNode
    }
    

    v-for

    for指令的主要作用是循环创建多个节点,这里还根据key实现了类似于diff算法来复用Block的功能

    export const _for = (el: Element, exp: string, ctx: Context) => {
      // ...一些工具方法如createChildContexts、mountBlock
    
      ctx.effect(() => {
        const source = evaluate(ctx.scope, sourceExp)
        const prevKeyToIndexMap = keyToIndexMap
        // 根据循环项创建多个子节点的context
        ;[childCtxs, keyToIndexMap] = createChildContexts(source)
        if (!mounted) {
          // 首次渲染,创建新的Block然后insert
          blocks = childCtxs.map((s) => mountBlock(s, anchor))
          mounted = true
        } else {
          // 更新时
          const nextBlocks: Block[] = []
          // 移除不存在的block
          for (let i = 0; i < blocks.length; i++) {
            if (!keyToIndexMap.has(blocks[i].key)) {
              blocks[i].remove()
            }
          }
          // 根据key进行处理
          let i = childCtxs.length
          while (i--) {
            const childCtx = childCtxs[i]
            const oldIndex = prevKeyToIndexMap.get(childCtx.key)
            const next = childCtxs[i + 1]
            const nextBlockOldIndex = next && prevKeyToIndexMap.get(next.key)
            const nextBlock =
              nextBlockOldIndex == null ? undefined : blocks[nextBlockOldIndex]
            // 不存在旧的block,直接创建
            if (oldIndex == null) {
              // new
              nextBlocks[i] = mountBlock(
                childCtx,
                nextBlock ? nextBlock.el : anchor
              )
            } else {
              // 存在旧的block,复用,检测是否需要移动位置
              const block = (nextBlocks[i] = blocks[oldIndex])
              Object.assign(block.ctx.scope, childCtx.scope)
              if (oldIndex !== i) {
                if (blocks[oldIndex + 1] !== nextBlock) {
                  block.insert(parent, nextBlock ? nextBlock.el : anchor)
                }
              }
            }
          }
          blocks = nextBlocks
        }
      })
    
      return nextNode
    }
    

    处理指令

    所有的指令都是通过applyDirectiveprocessDirective来处理的,后者是基于前者的二次封装,主要处理一些内置的指令快捷方式builtInDirectives

    export const builtInDirectives: Record<string, Directive<any>> = {
      bind,
      on,
      show,
      text,
      html,
      model,
      effect
    }
    

    每种指令都是基于ctx和el等来实现快速实现某些逻辑,具体实现可以参考对应源码。

    当调用app.directive注册自定义指令时,

    directive(name: string, def?: Directive) {
        if (def) {
            ctx.dirs[name] = def
            return this
        } else {
            return ctx.dirs[name]
        }
    },
    

    实际上是向contenx的dirs添加一个属性,当调用applyDirective时,就可以得到对应的处理函数

    const applyDirective = (el: Node,dir: Directive<any>,exp: string,ctx: Context,arg?: string,modifiers?: Record<string, true>) => {
      const get = (e = exp) => evaluate(ctx.scope, e, el)
      // 执行指令方法
      const cleanup = dir({
        el,
        get,
        effect: ctx.effect,
        ctx,
        exp,
        arg,
        modifiers
      })
      // 收集那些需要在卸载时清除的副作用
      if (cleanup) {
        ctx.cleanups.push(cleanup)
      }
    }
    

    因此,可以利用上面传入的这些参数来构建自定义指令

    app.directive("auto-focus", ({el})=>{
        el.focus()
    })
    

    小结

    整个代码看起来,确实非常精简

    • 没有虚拟DOM,就无需通过template构建render函数,直接递归遍历DOM节点,通过正则处理各种指令就行了
    • 借助@vue/reactivity,整个响应式系统实现的十分自然,除了在解析指令的使用通过ctx.effect()收集依赖,基本无需再关心数据变化的逻辑

    文章开头提到,petite-vue的主要作用是:在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。

    就我目前接触到的大部分服务端渲染HTML的项目,如果要实现一些DOM交互,一般使用

    • jQuery操作DOM,yyds
    • 当然Vue也是可以通过script + template的方式编写的,但为了一个div的交互接入Vue,又有点杀鸡焉用牛刀的感觉
    • 其他如React框架等同上

    petite-vue使用了与Vue基本一致的模板语法和响应式功能,开发体验上应该很不错。且其无需考虑虚拟DOM跨平台的功能,在源码中直接使用浏览器相关API操作DOM,减少了框架runtime运行时的成本,性能方面应该也不错。

    总结一下,感觉petite-vue结合了Vue标准版本的开发体验,以非常小的代码体积、良好的开发体验和还不错的运行性能,也许可以用来替代jQuery,用更现代的方式来操作DOM。

    该项目是6月30号提交的第一个版本,目前相关的功能和接口应该不是特别稳定,可能会有调整。但就exmples目录中的示例而言,应该能满足一些简单的需求场景了,也许可以尝试在一些比较小型的历史项目中使用。


    起源地下载网 » petite-vue源码分析:无虚拟DOM的极简版Vue

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元