petite-vue:【源码解析】回归原生,无虚拟DOM的极简体验
调研原因
近期,petite-vue以极简、去虚拟DOM的特点令大家眼前一亮。大家都以为它是不是尤大大推出vue3的mini版(可能看到名字会被诱导),但其实它的存在,也是有它一个独特的意义。它也许会引领我们关于原生dom渲染和借助虚拟dom的思考。而且最近随着SolidJS和Svelte两个框架的大火,它们都是编译时通过将状态更新编译为独立的DOM操作方法,省去了虚拟DOM比较这一步所消耗的时间。无论在编译-运行时-响应原理相对于“御三家”都有了不一样的体验,所以更值得我们去探究一下。
截止发文前,petite-vue的star数已经3.8k,尤大大的魅力可不一般哦
初体验
你可以通过script的形式直接引入到html文件中【建议配上init和defer】
<script init defer src="https://unpkg.com/petite-vue">
</script>
【defer】
不去阻塞dom的渲染和其他资源加载,同时保证dom解析完成后执行
【init】
自动初始化petite-vue,可以直接在全局中获取PetiteVue全局变量
【API使用】(以官网例子为准)
<div v-scope="{ str: 'Hello World' }">
{{ str }}
</div>
通过v-scope标识出需要解析的区域,并且通过指定str作为一个变量的形式,再通过经典的vue插槽表达式形式,去展示渲染,就可以完成初体验了。
目前,petite-vue是基于vite和vue3的新玩法进行项目的书写。
生命周期:目前仅支持两个【mounted和unmounted】
模板:模板组件$template和非模板组件,灵活书写小组件
指令和响应式:你可以灵活去使用vue3中部分的响应式API和指令
缺陷:目前还是存在多个特性不能完全兼容vue3,包括computed等
兼容与不兼容的特性
这个地方参考了【掘金读者:陌小路】进行补充和总结
兼容特性:
{{ }} 插值表达式
v-bind
(including : shorthand and class/style special handling)
v-on (including @ shorthand and all modifiers)
v-model (all input types + non-string :value bindings)
v-if / v-else / v-else-if
v-for
v-show
v-html
v-text
v-pre
v-once
v-cloak
reactive()
nextTick()
不兼容的特性:
ref()、computed()等
template仅支持选择器
不支持render function,因为petite-vue没有虚拟DOM
v-on="object"
v-is & <component :is="xxx">
v-bind:style自动添加前缀不支持
Transition, KeepAlive, Teleport, Suspense
v-for 深层解构
不支持的响应式类型(Set、Map等)
petite-vue项目启动
第一步:项目拉取
git clone https://github.com/vuejs/petite-vue.git
第二步:安装依赖
yarn
第三步:启动项目
npm run dev
第四步:访问页面
http://localhost:3000/
以上步骤进入完成之后,当你访问3000端口,就会显示很多vue基本api
这个时候,你肯定很想打debug调试,然后就立刻点击了对应源码的小红点或者直接键入debugger,发现没有反应。肯定啦,你当前就压根还没调用到功能API和服务,那我们应该如何调试呢?
debugger调试petite-vue
第一步:项目启动
yarn run dev
第二步:访问页面
http://localhost:3000/
第三步:根据你想要了解的vue-API特性选择一个点击a标签link跳转
第四步:观测主入口src/index.ts
重点关注createApp().mount()
然后可以结合debugger一边看源码一边分析
源码解读
createApp
首先来到src/index.ts,我们可以看到这么一段代码
if ((s = document.currentScript) && s.hasAttribute('init')) {
createApp().mount()
}
这里大家会注意到document.currentScript这个属性
document.currentScript属性返回当前正在运行的脚本所属的<script>元素这里是通过创建s变量记录当前运行的脚本元素,如果存在制定属性init,那么就调用createApp和mount方法。
然后我们看看createApp做了什么操作,来到src/app.ts中,可以看到createApp是接收一个可选的initialData初始化数据
一、创建根ctx上下文【赋予了很多属性和方法】
const ctx = createContext()
二、利用reactive将初始化数据代理成响应式
if (initialData) {
ctx.scope = reactive(initialData)
}
三、创建全局helpers,绑定在scope中
ctx.scope.$s = toDisplayString
ctx.scope.$nextTick = nextTick
ctx.scope.$refs = Object.create(null)
四、最终返回一个对象
对象中包含三个方法:directive、mount、unmount
directive、mount、unmount
【directive】
注册自定义指令。实际上是向ctx的dirs添加一个属性,当调用applyDirective时,就可以得到对应的处理函数。
【mount】
处理el参数,找到应用挂载的根DOM节点,然后执行初始化流程
具体过程:
(1)首先判断传入的el是不是string类型,如果是直接通过document.querySelector去取;否则,就会取document.documentElement
(2)初始化roots【节点数组】
let roots: Element[]
(3)判断是否存在v-scope,有就将其放入root数组;否则就要去这个el下面找到所以的带v-scope属性的节点,然后筛选出这些带v-scope属性下面的不带v-scope属性的节点,塞入roots数组
(4)判断这时roots数组依然为空,如果是就将el放入roots
(这时如果在开发环境甚至就会出现警告)
(5)然后基于root进行map对于roots中每个el进行Block实例化
rootBlocks = roots.map((el) => new Block(el, ctx, true))
这里的Block是个重点概念,它的作用主要是用于统一DOM节点渲染、插入、移除和销毁等操作。
(6)最后再进行对元素包含'v-cloak'属性的进行移除该属性,然后返回this实例本身
【unmount】
销毁的过程,基于每个block实例化调用teardown方法进行销毁
teardown() {
this.ctx.blocks.forEach((child) => {
child.teardown()
})
this.ctx.effects.forEach(stop)
this.ctx.cleanups.forEach((fn) => fn())
}
从这里开始,我们就要重点关注Block的处理。那我们去src/block.ts中进行分析
Block
(注意:Block是一个类)
初始化constructor
主要接收三个元素el、ctx、isRoot
- 第一步:初始化template
首先会判断isRoot是否是根元素
如果是就是直接取el传入的元素作为template
否则就会基于template调用cloneNode方法进行获取作为template
(注意:为什么这里会使用cloneNode这个方法,是因为queryselector之后我们拿到的是一个obj)
- 第二步:初始化ctx
如果是根节点,就直接用传入的ctx;如果不是根节点,就递归的继承ctx
- 第三步:调用walk方法构建应用
walk(this.template, this.ctx)
另外,Block还包含了其他的方法
insert新增,主要负责对节点的插入控制
remove移除,主要负责对节点的移除处理
teardown销毁,主要负责对整体的销毁清空
Block初始化的过程主要是处理template和ctx,最终传递给walk进行构建,那这个时候,我们就去重点观察walk的工作,那我们就要来到src/walk.ts中去分析
walk
walk主要接收两个参数node: Node, ctx: Context
- 第一步:首先拿到节点的nodeType
const type = node.nodeType
- 第二步:根据nodeType进行不同的处理,这里主要分为了三种
第一种:nodeType === 1 代表元素
(1)先去判断是否具有v-if、v-for、v-scope、v-once、ref属性,分别对应有不同的内置方法进行处理
(2)walkChildren(el, ctx)先处理子节点,在处理节点自身的属性
walkChildren其实就是获取首个字节点,不断遍历去递归字节点去调用walk方法对于子节点进行处理
(3)最后处理节点属性相关的指令,包括内置指令和自定义指定
第二种:nodeType === 3 元素或者属性中的文本内容
(1)先拿到node的data
(2)通过匹配include方法,然后正则匹配需要替换的文本内容
(3)applyDirective(node, text, segments.join('+'), ctx)
(通过调用该方法最终返回一个文本字符串)
第三种:nodeType === 11 轻量级的document对象
直接调用walkChildren进行遍历递归子节点进行walk方法调用
nodeType
此属性只读且传回一个数值。
有效的数值符合以下的型别:
1-ELEMENT
2-ATTRIBUTE
3-TEXT
4-CDATA
5-ENTITY REFERENCE
6-ENTITY
7-PI (processing instruction)
8-COMMENT
9-DOCUMENT
10-DOCUMENT TYPE
11-DOCUMENT FRAGMENT
12-NOTATION
总结
以上就是整体从创建-解析-渲染的整个流程,通过源码分析,可以更加明确得看出来,petite-vue没有依赖虚拟dom,没有虚拟DOM,就无需通过template构建render函数进行渲染,而是直接递归遍历DOM节点,借助正则去匹配hasAttribute或者checkAttr进行解析各种指令。并基于@vue/reactivity,完美继承和使用vue3的响应式API特性进行操作和使用,但也离不开通过ctx.effect()收集依赖。个人觉得,petite-vue还是维持着革新的亮点,从体积和复古原生dom的形式就可以映射出来。其无需考虑虚拟DOM跨平台的功能,在源码中直接使用浏览器相关API操作DOM,减少了框架runtime运行时的成本。(开箱即用、对于Jquery这类型的项目,可以直接迁移转换到petite-vue也是非常好的替代哦)