petite-vue源码分析和理解
petite-vue源码分析
最近发现Vue增加了一个petite-vue的仓库,大概看了一下,这是一个无虚拟DOM的mini版Vue,前身貌似是vue-lite(瞎猜的~),主要用于在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。颇有意思,于是看了下源码(v0.2.3
),整理了本文。
1. 起步
1.1. 开发调试环境
整个项目的开发环境非常简单
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
即可
1.2. 目录结构
从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
。
2. 应用流程
2.1. 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
2.2. mount
基本使用
<span class="hljs-selector-tag">createApp</span>()<span class="hljs-selector-class">.mount</span>("<span class="hljs-selector-id">#app</span>")
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
的地方,可以看见主要在初始化、if
和for
这三个地方使用
看一下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,然后执行插入
- 对于文本节点,替换
{{}}
表达式,然后替换文本内容
2.3. 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
}
2.4. 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
}
2.5. 处理指令
所有的指令都是通过applyDirective
和processDirective
来处理的,后者是基于前者的二次封装,主要处理一些内置的指令快捷方式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()
})
3. 小结
整个代码看起来,确实非常精简
- 没有虚拟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目录中的示例而言,应该能满足一些简单的需求场景了,也许可以尝试在一些比较小型的历史项目中使用。