阅读 2432

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

本文同步在个人博客shymean.com上,欢迎关注

最近发现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

核心实现

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这三个地方使用

看一下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目录中的示例而言,应该能满足一些简单的需求场景了,也许可以尝试在一些比较小型的历史项目中使用。

文章分类
前端