注册
vue

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

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


链接:https://juejin.cn/post/6983688046843527181

0 个评论

要回复文章请先登录注册