petite-vue源码剖析-从静态视图开始
代码库结构介绍
- examples 各种使用示例
- scripts 打包发布脚本
- tests 测试用例
- src
- directives
v-if
等内置指令的实现 - app.ts
createApp
函数 - block.ts 块对象
- context.ts 上下文对象
- eval.ts 提供
v-if="count === 1"
等表达式运算功能 - scheduler.ts 调度器
- utils.ts 工具函数
- walk.ts 模板解析
- directives
若想构建自己的版本只需在控制台执行npm run build
即可。
深入理解静态视图的渲染过程
静态视图是指首次渲染后,不会因UI状态变化引发重新渲染。其中视图不包含任何UI状态,和根据UI状态首次渲染后状态不再更新两种情况,本篇将针对前者进行讲解。
示例:
<div v-scope="App"></div>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp({
App: {
$template: `
<span> OFFLINE </span>
<span> UNKOWN </span>
<span> ONLINE </span>
`
}
}).mount('[v-scope]')
</script>
首先进入的就是createApp
方法,它的作用就是创建根上下文对象(root context)、全局作用域对象(root scope)并返回mount
,unmount
和directive
方法。然后通过mount
方法寻找附带[v-scope]
属性的孩子节点(排除匹配[v-scope] [v-scope]
的子孙节点),并为它们创建根块对象。
源码如下(基于这个例子,我对源码进行部分删减以便更容易阅读):
// 文件 ./src/app.ts
export const createApp = (initialData: any) => {
// 创建根上下文对象
const ctx = createContext()
// 全局作用域对象,作用域对象其实就是一个响应式对象
ctx.scope = reactive(initialData)
/* 将scope的函数成员的this均绑定为scope。
* 若采用箭头函数赋值给函数成员,则上述操作对该函数成员无效。
*/
bindContextMethods(ctx.scope)
/* 根块对象集合
* petite-vue支持多个根块对象,但这里我们可以简化为仅支持一个根块对象。
*/
let rootBlocks: Block[]
return {
// 简化为必定挂载到某个带`[v-scope]`的元素下
mount(el: Element) {
let roots = el.hasAttribute('v-scope') ? [el] : []
// 创建根块对象
rootBlocks = roots.map(el => new Block(el, ctx, true))
return this
},
unmount() {
// 当节点卸载时(removeChild)执行块对象的清理工作。注意:刷新界面时不会触发该操作。
rootBlocks.forEach(block => block.teardown())
}
}
}
代码虽然很短,但引出了3个核心对象:上下文对象(context)、作用域(scope)和块对象(block)。他们三的关系是:
- 上下文对象(context) 和 作用域(scope) 是 1 对 1 关系;
- 上下文对象(context) 和 块对象(block) 是 多 对 多 关系,其中块对象(block)通过
ctx
指向当前上下文对象(context),并通过parentCtx
指向父上下文对象(context); - 作用域(scope) 和 块对象(block) 是 1 对 多 关系。
具体结论是:
- 根上下文对象(context) 可被多个根块对象通过
ctx
引用; - 块对象(block)创建时会基于当前的上下文对象(context)创建新的上下文对象(context),并通过
parentCtx
指向原来的上下文对象(context); - 解析过程中
v-scope
就会基于当前作用域对象构建新的作用域对象,并复制当前上下文对象(context)组成一个新的上下文对象(context)用于子节点的解析和渲染,但不会影响当前块对象指向的上下文。
下面我们逐一理解。
作用域(scope)
这里的作用域和我们编写JavaScript时说的作用域是一致的,作用是限定函数和变量的可用范围,减少命名冲突。
具有如下特点:
- 作用域之间存在父子关系和兄弟关系,整体构成一颗作用域树;
- 子作用域的变量或属性可覆盖祖先作用域同名变量或属性的访问性;
- 若对仅祖先作用域存在的变量或属性赋值,将赋值给祖先作用域的变量或属性。
// 全局作用域
var globalVariable = 'hello'
var message1 = 'there'
var message2 = 'bye'
(() => {
// 局部作用域A
let message1 = '局部作用域A'
message2 = 'see you'
console.log(globalVariable, message1, message2)
})()
// 回显:hello 局部作用域A see you
(() => {
// 局部作用域B
console.log(globalVariable, message1, message2)
})()
// 回显:hello there see you
而且作用域是依附上下文存在的,所以作用域的创建和销毁自然而然都位于上下文的实现中(./src/context.ts
)。
另外,petite-vue中的作用域并不是一个普通的JavaScript对象,而是一个经过@vue/reactivity
处理的响应式对象,目的是一旦作用域成员被修改,则触发相关副作用函数执行,从而重新渲染界面。
块对象(block)
作用域(scope)是用于管理JavaScript的变量和函数可用范围,而块对象(block)则用于管理DOM对象。
// 文件 ./src/block.ts
// 基于示例,我对代码进行了删减
export class Block {
template: Element | DocumentFragment // 不是指向$template,而是当前解析的模板元素
ctx: Context // 有块对象创建的上下文对象
parentCtx?: Context // 当前块对象所属的上下文对象,根块对象没有归属的上下文对象
// 基于上述例子没有采用<template>元素,并且静态视图不包含任何UI状态,因此我对代码进行了简化
construct(template: Element, parentCtx: Context, isRoot = false) {
if (isRoot) {
// 对于根块对象直接以挂载点元素作为模板元素
this.template = template
}
if (isRoot) {
this.ctx = parentCtx
}
// 采用深度优先策略解析元素(解析过程会向异步任务队列压入渲染任务)
walk(this.template, this.ctx)
}
}
// 文件 ./src/walk.ts
// 基于上述例子为静态视图不包含任何UI状态,因此我对代码进行了简化
export const walk = (node: Node, ctx: Context): ChildNode | null | void => {
const type= node.nodeType
if (type === 1) {
// node为Element类型
const el = node as Element
let exp: string | null
if ((exp = checkAttr(el, 'v-scope')) || exp === '') {
// 元素带`v-scope`则计算出最新的作用对象。若`v-scope`的值为空,则最新的作用域对象为空对象
const scope = exp ? evaluate(ctx.scope, exp) : {}
// 更新当前上下文的作用域
ctx = createScopedContext(ctx, scope)
// 若当前作用域存在`$template`渲染到DOM树上作为在线模板,后续会递归解析处理
// 注意:这里不会读取父作用域的`$template`属性,必须是当前作用域的
if (scope.$template) {
resolveTemplate(el, scope.$template)
}
}
walkChildren(el, ctx)
}
}
// 首先解析第一个孩子节点,若没有孩子则解析兄弟节点
const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
let child = node.firstChild
while (child) {
child = walk(child, ctx) || child.nextSibling
}
}
// 基于上述例子我对代码进行了简化
const resolveTemplate = (el: Element, template: string) => {
// 得益于Vue采用的模板完全符合HTML规范,所以这么直接简单地渲染为HTML元素后,`@click`和`:value`等属性名称依然不会丢失
el.innerHTML = template
}
为了更容易阅读我又对表达式运算的代码进行了简化(移除开发阶段的提示和缓存机制)
// 文件 ./src/eval.ts
export const evaluate = (scope: any, exp: string, el? Node) => execute(scope, exp, el)
const execute = (scope: any, exp: string, el? Node) => {
const fn = toFunction(exp)
return fn(scope, el)
}
const toFunction = (exp: string): Function => {
try {
return new Function('$data', '$el', `with($data){return(${exp})}`)
}
catch(e) {
return () => {}
}
}
上下文对象(context)
上面我们了解到作用域(scope)是用于管理JavaScript的变量和函数可用范围,而块对象(block)则用于管理DOM对象,那么上下文对象(context)则是连接作用域(scope)和块对象(block)的载体,也是将多个块对象组成树状结构的连接点([根块对象.ctx] -> [根上下文对象, 根上下文对象.blocks] -> [子块对象] -> [子上下文对象]
)。
// 文件 ./src/context.ts
export interface Context {
scope: Record<string, any> // 当前上下文对应的作用域对象
cleanups: (()=>void)[] // 当前上下文指令的清理函数
blocks: Block[] // 归属于当前上下文的块对象
effect: typeof rawEffect // 类似于@vue/reactivity的effect方法,但可根据条件选择调度方式
effects: ReativeEffectRunner[] // 当前上下文持有副作用方法,用于上下文销毁时回收副作用方法释放资源
}
/**
* 由Block构造函数调用创建新上下文对象,特性如下:
* 1. 新上下文对象作用域与父上下文对象一致
* 2. 新上下文对象拥有全新的effects、blocks和cleanups成员
* 结论:由Block构造函数发起的上下文对象创建,不影响作用域对象,但该上下文对象会独立管理旗下的副作用方法、块对象和指令
*/
export const createContext = (parent? Context): Context => {
const ctx: Context = {
...parent,
scope: parent ? parent.scope : reactive({}), // 指向父上下文作用域对象
effects: [],
blocks: [],
cleanups: [],
effect: fn => {
// 当解析遇到`v-once`属性,`inOnce`即被设置为`true`,而副作用函数`fn`即直接压入异步任务队列执行一次,即使其依赖的状态发生变化副作用函数也不会被触发。
if (inOnce) {
queueJob(fn)
return fn as any
}
// 生成状态发生变化时自动触发的副作用函数
const e: ReactiveEffectRunner = rawEffect(fn, {
scheduler: () => queueJob(e)
})
ctx.effects.push(e)
return e
}
}
return ctx
}
/**
* 当解析时遇到`v-scope`属性并存在有效值时,便会调用该方法基于当前作用域创建新的作用域对象,并复制当前上下文属性构建新的上下文对象用于子节点的解析和渲染。
*/
export const createScopedContext = (ctx: Context, data = {}): Context => {
const parentScope = ctx.scope
/* 构造作用域对象原型链
* 此时若当设置的属性不存在于当前作用域,则会在当前作用域创建该属性并赋值。
*/
cosnt mergeScope = Object.create(parentScope)
Object.defineProperties(mergeScope, Object.getOwnPropertyDescriptors(data))
// 构造ref对象原型链
mergeScope.$ref = Object.create(parentScope.$refs)
// 构造作用域链
const reactiveProxy = reactive(
new Proxy(mergeScope, {
set(target, key, val, receiver) {
// 若当设置的属性不存在于当前作用域则将值设置到父作用域上,由于父作用域以同样方式创建,因此递归找到拥有该属性的祖先作用域并赋值
if (receiver === reactiveProxy && !target.hasOwnProperty(key)) {
return Reflect.set(parentScope, key, val)
}
return Reflect.set(target, key, val, receiver)
}
})
)
/* 将scope的函数成员的this均绑定为scope。
* 若采用箭头函数赋值给函数成员,则上述操作对该函数成员无效。
*/
bindContextMethods(reactiveProxy)
return {
...ctx,
scope: reactiveProxy
}
}
人肉单步调试
- 调用
createApp
根据入参生成全局作用域rootScope
,创建根上下文rootCtx
; - 调用
mount
为<div v-scope="App"></div>
构建根块对象rootBlock
,并将其作为模板执行解析处理; - 解析时识别到
v-scope
属性,以全局作用域rootScope
为基础运算得到局部作用域scope
,并以根上下文rootCtx
为蓝本一同构建新的上下文ctx
,用于子节点的解析和渲染; - 获取
$template
属性值并生成HTML元素; - 深度优先遍历解析子节点。
待续
通过简单的例子我们对petite-vue的解析、调度和渲染过程有了一定程度的了解,下一篇我们将再次通过静态视图看看v-if
和v-for
是如何根据状态改变DOM树结构的。
另外,可能有朋友会有如下疑问
- Proxy的receiver是什么?
new Function
和eval
的区别?
这些后续会在专门的文章介绍,敬请期待:)
petite-vue源码剖析-从静态视图开始的更多相关文章
- petite-vue源码剖析-逐行解读@vue/reactivity之reactive
在petite-vue中我们通过reactive构建上下文对象,并将根据状态渲染UI的逻辑作为入参传递给effect,然后神奇的事情发生了,当状态发生变化时将自动触发UI重新渲染.那么到底这是怎么做到 ...
- Django Rest Framework源码剖析(八)-----视图与路由
一.简介 django rest framework 给我们带来了很多组件,除了认证.权限.序列化...其中一个重要组件就是视图,一般视图是和路由配合使用,这种方式给我们提供了更灵活的使用方法,对于使 ...
- 逐行剖析Vue源码(一)——写在最前面
1. 前言 博主作为一名前端开发,日常开发的技术栈是Vue,并且用Vue开发也有一年多了,对其用法也较为熟练了,但是对各种用法和各种api使用都是只知其然而不知其所以然,因此,有时候在排查bug的时候 ...
- rest_framework之视图及源码剖析
最初形态(工作中可能会使用) 引子 Django的CBV我们应该都有所了解及使用,大体概括一下就是通过定义类并在类中定义get post put delete等对应于请求方法的方法,当请求来的时候会自 ...
- 大白话Vue源码系列(01):万事开头难
阅读目录 Vue 的源码目录结构 预备知识 先捡软的捏 Angular 是 Google 亲儿子,React 是 Facebook 小正太,那咱为啥偏偏选择了 Vue 下手,一句话,Vue 是咱见过的 ...
- 大白话Vue源码系列(03):生成render函数
阅读目录 优化 AST 生成 render 函数 小结 本来以为 Vue 的编译器模块比较好欺负,结果发现并没有那么简单.每一种语法指令都要考虑到,处理起来相当复杂.上篇已经生成了 AST,本篇依然对 ...
- 大白话Vue源码系列(04):生成render函数
阅读目录 优化 AST 生成 render 函数 小结 本来以为 Vue 的编译器模块比较好欺负,结果发现并没有那么简单.每一种语法指令都要考虑到,处理起来相当复杂.上篇已经生成了 AST,本篇依然对 ...
- 手牵手,从零学习Vue源码 系列一(前言-目录篇)
系列文章: 手牵手,从零学习Vue源码 系列一(前言-目录篇) 手牵手,从零学习Vue源码 系列二(变化侦测篇) 手牵手,从零学习Vue源码 系列三(虚拟DOM篇) 陆续更新中... 预计八月中旬更新 ...
- 大白话Vue源码系列(03):生成AST
阅读目录 AST 节点定义 标签的正则匹配 解析用到的工具方法 解析开始标签 解析结束标签 解析文本 解析整块 HTML 模板 未提及的细节 本篇探讨 Vue 根据 html 模板片段构建出 AST ...
随机推荐
- new实例化和反射实例化有什么区别?
在工厂设计模式中,使用反射实例化,子类可以随便增加,工厂类不需要做任何的修改 使用反射之后最大的好处就是解耦合
- kubernetes之配置Metrics Server
Kubernetes 1.8 关于资源使用情况的 metrics,可以通过 Metrics API 获取到, Kubernetes 1.11 已经废弃 heapster.这里我们基于 Kubernet ...
- Windows下cmd/powershell命令混淆绕过
前言 在Windows下绕过杀毒软件的主动防御机制的常见思路. Bypass 1.特殊符号.大小写 常用符号: " ^ , ; 可以绕过一些常规的waf 2.环境变量 拿到一台机器时,可以先 ...
- ApacheCN 数据库译文集 20211112 更新
创建你的 Mysql 数据库 零.前言 一.介绍 MySQL 设计 二.数据采集 三.数据命名 四.数据分组 五.数据结构调整 六.补充案例研究 Redis 学习手册 零.序言 一.NoSQL 简介 ...
- 将string字符串中的换行符进行替换
/** * 方法名称:replaceBlank * 方法描述: 将string字符串中的换行符进行替换为"" * */ public static String replaceBl ...
- git init和git init –bare的区别:
感谢原文作者:ljchlx 原文链接:https://blog.csdn.net/ljchlx/article/details/21805231 git init 和 git init –bare 的 ...
- 导出SQL语句
转载请注明来源:https://www.cnblogs.com/hookjc/ if(!($db_conn=mysql_connect($db_server,$db_name,$db_pass))){ ...
- git每次操作都要输入账号密码 解决方案
1.执行命令: git config --global credential.helper store git pull 2.输入用户名密码,以后就不会再次要求用户名密码了
- iOS应用启动main函数
#import <UIKit/UIKit.h> #import "AppDelegate.h" int main(int argc, char * argv[]) { ...
- 一张图让你看懂 iPhone 各种分辨率问题! #DF
话不多说,直接看图! Source: paintcodeapp.com