进阶"/>
Vue.js 框架源码与进阶
导学
课程目标
- 了解什么是虚拟DOM,以及虚拟DOM的作用
- Snabbdom的基本使用
- Snabbdom的源码解析
什么是Virtual DOM
- Virtual DOM(虚拟DOM),是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫Virtual DOM
- 真实DOM成员
- 可以使用Virtual DOM来描述真实DOM,示例
{ sel: "div", data: {}, children: undefined, text: "Hello Virtual DOM", elm: undefined, key: undefined
}
注意:创建一个虚拟DOM比创建一个真实的DOM成本低很多
为什么使用虚拟DOM
- 手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery等库简化DOM操作,但是随着项目的复杂DOM操作复杂提升
- 为了简化DOM的复杂操作于是出现了各种MVVM框架,MVVM框架解决了视图和状态的同步问题
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现了
- Virtual DOM的好处是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual DOM内部将弄清楚如何有效(diff)的更新DOM
- 参考 github上 virtual-dom的描述
1. 虚拟DOM可以维护程序的状态,跟踪上一次的状态
2. 通过比较前后两次状态的差异更新真实DOM
虚拟DOM的作用
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 除了渲染DOM以外,还可以实现SSR(Nuxt.js/Next.js)、原生应用(weex/React Native)、小程序(mpvue/uni-app)等
Virtual DOM库
- Snabbdom
- Vue 2.x内部使用的Virtual DOM就是改造的Snabbdom
- 大约200SLOC(single line of code)
- 通过模块扩展
- 源码使用TypeScript开发
- 最快的Virtual DOM之一
- virtual-dom
Snabbdom基本使用
创建项目
- 打包工具为了方便使用parcel
- 创建项目,并安装parcel
# 创建项目目录
md snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
yarn init -y
# 本地安装 parcel
yarn add parcel-bundler
- 配置package.json的scripts
"scripts": {"dev": "parcel index.html --open","build": "parcel build index.html"
}
- 创建目录结构
index.html
package.json
01-basicuage.js
导入Snabbdom
中文文档:
yarn add snabbdom
- Snabbdom的官网demo中导入使用的是commonjs模块化语法,我们使用更流行的ES6模块化的语法import
- 关于模块化的语法请参考阮一峰老师的Module的语法
- ES6模块与commonJS模块的差异(.html)
import {init, h, thunk} from 'snabbdom'
- Snabbdom的核心仅提供最基本的功能,只导出了三个函数init()、h()、thunk()
- init()是一个高阶函数,返回patch()
- h()返回虚拟节点VNode,这个函数我们在使用Vue.js的时候见过
new Vue({router,store,render: h => h(App)
}).$mount('#app')
-
- thunk()是一种优化策略,可以在处理不变数据时使用
- thunk()是一种优化策略,可以在处理不变数据时使用
- 注意:导入时候不能使用import snabbdom from 'snabbdom'
- 原因: node_modules/src/snabbdom.ts末尾导出使用的语法是export导出API,没有使用export default导出默认输出
export {h} from './h'
export {thunk} from './thunk'export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI)
}
Snabbdom的基本用法
两个案例来展示init()和h()这两个函数的功能
01-basicusage.js:
import { h, init} from 'snabbdom'// 1. hello world
// 参数: 数组,模块
// 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
let patch = init([])
// 第一个参数: 标签+ 选择器
// 第二个参数: 如果是字符串的话就是标签中的内容
let vnode = h('div#container.cls','Hello World')let app = document.querySelector('#app')// 第一个参数: 可以是DOM元素,内部会把DOM元素转换成VNode
// 第二个参数: VNode
// 返回值:VNode
let oldVNode = patch(app, vnode)// 假设的时刻
vnode = h('div', 'Hello Snabbdom')patch(oldVNode, vnode)
02-basicusage.js:
// 2. div中放置子元素 h1,p
import { h , init } from 'snabbdom'let patch = init([])let vnode = h('div#container',[h('h1', 'Hello Snabbdom'),h('p', '这是一个p标签')
])let app = document.querySelector('#app')let oldVnode = patch(app, vnode)setTimeout(() => {vnode = h('div#container',[h('h1', 'Hello World'),h('p', 'Hello P')])patch(oldVnode, vnode)// 清空页面元素 -- 错误// patch(oldVnode, null)patch(oldVnode, h('!'))
}, 2000);
Snabbdom的模块
Snabbdom的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块
常用模块
- 官方提供了6个模块
- attributes
- 设置DOM元素的属性,使用setAttribute()
- 处理布尔类型的属性
- props
- 和attributes模块相似,设置DOM元素的属性element[attr] = value
- 不处理布尔类型的属性
- class
- 切换类样式
- 注意:给元素设置类样式是通过sel选择器
- dataset
- 切换类样式
- 注意:给元素设置类样式是通过 sel 选择器
- eventlisteners
- 注册和移除事件
- style
- 设置行内样式,支持动画
- delayed/remove/destroy
- attributes
模块使用
- 模块使用步骤
- 导入需要的模块
- init() 中注册模块
- 使用 h() 函数创建VNode的时候,可以把第二个参数设置为对象,其他参数往后移
代码演示:
import { init, h } from 'snabbdom'
// 1. 导入模块
import style from 'snabbdom/modules/style'
import eventlisteners from 'snabbdom/modules/eventlisteners'
// 2. 注册模块
let patch = init([style,eventlisteners
])
// 3. 使用h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {style: {backgroundColor: 'red'},on: {click: eventHandler}
},[h('h1', 'Hello Snabbdom'),h('p', '这是p标签')
])function eventHandler () {console.log('点击我了')
}let app = document.querySelector('#app')patch(app, vnode)
Snabbdom源码解析
概述
如何学习源码
- 先宏观了解
- 带着目标看源码
- 看源码的过程要求不求甚解
- 调试
- 参考资料
Snabbdom的核心
- 使用h()函数创建JavaScript对象(VNode)描述真实DOM
- init()设置模块,创建patch()
- patch()比较新旧两个VNode
- 把变化的内容更新到真实DOM树上
Snabbdom源码
- 源码地址
- src目录结构
h函数
- h()函数介绍
- 在使用Vue的时候见过h()函数
new Vue({router,store,render: h => h(App)
}).$mount('#app')
- h()函数最早见于hyperScript,使用JavaScript创建超文本
- Snabbdom中的h()函数不是用来创建超文本,而是创建VNode
- 函数重载
- 概念
- 参数个数或类型不同的函数
- JavaScript中没有重载的概念
- TypeScript中有重载,不过重载的实现还是通过代码调整参数
- 概念
- 重载的示意
function add(a, b) {console.log(a + b)
}
function add (a, b, c) {console.log(a + b + c)
}
add(1, 2)
add(1, 2, 3)
源码位置: src/h.ts (作用:调用vnode函数返回虚拟节点)
import { vnode, VNode, VNodeData } from './vnode'
import * as is from './is'export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {data.ns = ''if (sel !== 'foreignObject' && children !== undefined) {for (let i = 0; i < children.length; ++i) {const childData = children[i].dataif (childData !== undefined) {addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)}}}
}export function h(sel: string): VNode
export function h(sel: string, data: VNodeData | null): VNode
export function h(sel: string, children: VNodeChildren): VNode
export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {var data: VNodeData = {}var children: anyvar text: anyvar i: numberif (c !== undefined) {if (b !== null) {data = b}if (is.array(c)) {children = c} else if (is.primitive(c)) {text = c} else if (c && c.sel) {children = [c]}} else if (b !== undefined && b !== null) {if (is.array(b)) {children = b} else if (is.primitive(b)) {text = b} else if (b && b.sel) {children = [b]} else { data = b }}if (children !== undefined) {for (i = 0; i < children.length; ++i) {if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)}}if (sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&(sel.length === 3 || sel[3] === '.' || sel[3] === '#')) {addNS(data, children, sel)}return vnode(sel, data, children, text, undefined)
};
必备快捷键
- F12、Ctrl+ 单击鼠标左键,快速定位到引入的位置
- Alt + 左键,返回刚才的定位
- 选中array,F12查看array可以看见is下array的实现
vnode
export interface VNode {sel: string | undefineddata: VNodeData | undefinedchildren: Array<VNode | string> | undefinedelm: Node | undefinedtext: string | undefinedkey: Key | undefined
}export interface VNodeData {props?: Propsattrs?: Attrsclass?: Classesstyle?: VNodeStyledataset?: Dataseton?: Onhero?: HeroattachData?: AttachDatahook?: Hookskey?: Keyns?: string // for SVGsfn?: () => VNode // for thunksargs?: any[] // for thunks[key: string]: any // for any other 3rd party module
}export function vnode (sel: string | undefined,data: any | undefined,children: Array<VNode | string> | undefined,text: string | undefined,elm: Element | Text | undefined): VNode {const key = data === undefined ? undefined : data.keyreturn { sel, data, children, text, elm, key }
}
VNode渲染真实DOM
snabbdom
- patch(oldVnode, newVnode)
- 打补丁,把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧VNode是否相同节点(节点的key和sel相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的VNode是否有text,如果有并且和oldVnode的text不同,直接更新文本内容
- diff过程只进行同层级比较
init函数
设置模块,传入modules和domApi;没有传第二个参数domApi时,使用htmlDomApi(把虚拟DOM转换成真实DOM)
export interface DOMAPI {createElement: (tagName: any) => HTMLElementcreateElementNS: (namespaceURI: string, qualifiedName: string) => ElementcreateTextNode: (text: string) => TextcreateComment: (text: string) => CommentinsertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => voidremoveChild: (node: Node, child: Node) => voidappendChild: (node: Node, child: Node) => voidparentNode: (node: Node) => Node | nullnextSibling: (node: Node) => Node | nulltagName: (elm: Element) => stringsetTextContent: (node: Node, text: string | null) => voidgetTextContent: (node: Node) => string | nullisElement: (node: Node) => node is ElementisText: (node: Node) => node is TextisComment: (node: Node) => node is Comment
}function createElement (tagName: any): HTMLElement {return document.createElement(tagName)
}function createElementNS (namespaceURI: string, qualifiedName: string): Element {return document.createElementNS(namespaceURI, qualifiedName)
}function createTextNode (text: string): Text {return document.createTextNode(text)
}function createComment (text: string): Comment {return document.createComment(text)
}function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node | null): void {parentNode.insertBefore(newNode, referenceNode)
}function removeChild (node: Node, child: Node): void {node.removeChild(child)
}function appendChild (node: Node, child: Node): void {node.appendChild(child)
}function parentNode (node: Node): Node | null {return node.parentNode
}function nextSibling (node: Node): Node | null {return node.nextSibling
}function tagName (elm: Element): string {return elm.tagName
}function setTextContent (node: Node, text: string | null): void {node.textContent = text
}function getTextContent (node: Node): string | null {return node.textContent
}function isElement (node: Node): node is Element {return node.nodeType === 1
}function isText (node: Node): node is Text {return node.nodeType === 3
}function isComment (node: Node): node is Comment {return node.nodeType === 8
}export const htmlDomApi: DOMAPI = {createElement,createElementNS,createTextNode,createComment,insertBefore,removeChild,appendChild,parentNode,nextSibling,tagName,setTextContent,getTextContent,isElement,isText,isComment,
}
// 初始化转换虚拟节点的apiconst api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi// 把传入的所以模块的钩子函数,统一存储到cbs对象中
// 最终构建的cbs对象的形式 cbs = { crete: [fn1, fn2], update: [], ...}for (i = 0; i < hooks.length; ++i) {cbs[hooks[i]] = []for (j = 0; j < modules.length; ++j) {const hook = modules[j][hooks[i]]if (hook !== undefined) {(cbs[hooks[i]] as any[]).push(hook)}}}
module:
import { PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook } from '../hooks'export type Module = Partial<{pre: PreHookcreate: CreateHookupdate: UpdateHookdestroy: DestroyHookremove: RemoveHookpost: PostHook
}>
patch函数
// init 内部返回 patch函数,把 vnode 渲染成真实 dom,并返回vnode
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {let i: number, elm: Node, parent: Node// 保存新插入节点的队列,为了触发钩子函数const insertedVnodeQueue: VNodeQueue = []// 执行模块的pre 钩子函数for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()// 如果 oldVnode不是VNode,创建VNode 并设置 elmif (!isVnode(oldVnode)) {// 把DOM元素转换成空的 VNodeoldVnode = emptyNodeAt(oldVnode)}// 如果新旧节点是相同节点(key 和sel 相同)if (sameVnode(oldVnode, vnode)) {// 找节点的差异并更新 DOMpatchVnode(oldVnode, vnode, insertedVnodeQueue)} else {// 如果新旧节点不同,vnode创建对应的 DOM// 获取当前的 DOM元素elm = oldVnode.elm!parent = api.parentNode(elm) as Node// 创建 vnode对应的DOM元素,并触发 init/reate 钩子函数createElm(vnode, insertedVnodeQueue)if (parent !== null) {// 如果父节点不为空,把 vnode对应的 DOM插入到文档中api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))// 移除老节点removeVnodes(parent, [oldVnode], 0, 0)}}// 执行用户设置的insert 钩子函数for (i = 0; i < insertedVnodeQueue.length; ++i) {insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])}// 执行模块的post钩子函数for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()// 返回 vnodereturn vnode}
createElm
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {let i: anylet data = vnode.dataif (data !== undefined) {const init = data.hook?.initif (isDef(init)) {init(vnode)data = vnode.data}}// 把 vnode 转换成真实 DOM对象(没有渲染到页面)const children = vnode.childrenconst sel = vnode.selif (sel === '!') {// 如果选择器是!,创建注释节点if (isUndef(vnode.text)) {vnode.text = ''}vnode.elm = api.createComment(vnode.text!)} else if (sel !== undefined) {// 如果选择器不为空// 解析选择器// Parse selectorconst hashIdx = sel.indexOf('#')const dotIdx = sel.indexOf('.', hashIdx)const hash = hashIdx > 0 ? hashIdx : sel.lengthconst dot = dotIdx > 0 ? dotIdx : sel.lengthconst tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : selconst elm = vnode.elm = isDef(data) && isDef(i = data.ns)? api.createElementNS(i, tag): api.createElement(tag)if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))// 执行模块的 create 钩子函数for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)// 如果 vnode中有子节点,创建子vnode对应的DOM元素并追加到 DOM 树上if (is.array(children)) {for (i = 0; i < children.length; ++i) {const ch = children[i]if (ch != null) {api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))}}} else if (is.primitive(vnode.text)) {// 如果 vnode的text值是 string/number,创建文本节点并追加到 DOM 树api.appendChild(elm, api.createTextNode(vnode.text))}const hook = vnode.data!.hookif (isDef(hook)) {// 执行用户传入的钩子 createhook.create?.(emptyNode, vnode)if (hook.insert) {// 把vnode添加到队列中,为后续执行 insert 钩子做准备insertedVnodeQueue.push(vnode)}}} else {// 如果选择器为空,创建文本节点vnode.elm = api.createTextNode(vnode.text!)}// 返回新创建的 DOMreturn vnode.elm}
addVnodes和removeVnodes
function addVnodes (parentElm: Node,before: Node | null,vnodes: VNode[],startIdx: number,endIdx: number,insertedVnodeQueue: VNodeQueue) {for (; startIdx <= endIdx; ++startIdx) {const ch = vnodes[startIdx]if (ch != null) {api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)}}}
// 批量删除节点function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void {for (; startIdx <= endIdx; ++startIdx) {let listeners: numberlet rm: () => voidconst ch = vnodes[startIdx]if (ch != null) {if (isDef(ch.sel)) {invokeDestroyHook(ch)listeners = cbs.remove.length + 1rm = createRmCb(ch.elm!, listeners)for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)const removeHook = ch?.data?.hook?.removeif (isDef(removeHook)) {removeHook(ch, rm)} else {rm()}} else { // Text nodeapi.removeChild(parentElm, ch.elm!)}}}}
patchVnode
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {const hook = vnode.data?.hookhook?.prepatch?.(oldVnode, vnode)const elm = vnode.elm = oldVnode.elm!const oldCh = oldVnode.children as VNode[]const ch = vnode.children as VNode[]if (oldVnode === vnode) returnif (vnode.data !== undefined) {// 执行模块的 update钩子函数for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)// 执行用户设置的 update 钩子函数vnode.data.hook?.update?.(oldVnode, vnode)}// 如果vnode.text 未定义if (isUndef(vnode.text)) {// 如果新老节点都有 childrenif (isDef(oldCh) && isDef(ch)) {// 使用diff算法对比子节点,老节点没有childrenif (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)} else if (isDef(ch)) {// 如果新节点有children,老节点没有children// 如果老节点有text,清空dom元素的内容if (isDef(oldVnode.text)) api.setTextContent(elm, '')// 批量添加子节点addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)// 如果没有设置vnode.text} else if (isDef(oldCh)) {// 如果老节点有chaldren移除removeVnodes(elm, oldCh, 0, oldCh.length - 1)} else if (isDef(oldVnode.text)) {api.setTextContent(elm, '')}} else if (oldVnode.text !== vnode.text) {if (isDef(oldCh)) {removeVnodes(elm, oldCh, 0, oldCh.length - 1)}// 设置DOM元素的 textContent 为vnode.textapi.setTextContent(elm, vnode.text!)}// 最后执行用户设置的 postpatch 钩子函数hook?.postpatch?.(oldVnode, vnode)}
更多推荐
Vue.js 框架源码与进阶
发布评论