petite-vue源码剖析-从静态视图开始

2022-05-09 16:03:38 浏览数 (1)

代码库结构介绍

  • 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 模板解析

若想构建自己的版本只需在控制台执行npm run build即可。

深入理解静态视图的渲染过程

静态视图是指首次渲染后,不会因UI状态变化引发重新渲染。其中视图不包含任何UI状态,和根据UI状态首次渲染后状态不再更新两种情况,本篇将针对前者进行讲解。

示例:

代码语言:javascript复制
<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,unmountdirective方法。然后通过mount方法寻找附带[v-scope]属性的孩子节点(排除匹配[v-scope] [v-scope]的子孙节点),并为它们创建根块对象。 源码如下(基于这个例子,我对源码进行部分删减以便更容易阅读):

代码语言:javascript复制
// 文件 ./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时说的作用域是一致的,作用是限定函数和变量的可用范围,减少命名冲突。 具有如下特点:

  1. 作用域之间存在父子关系和兄弟关系,整体构成一颗作用域树;
  2. 子作用域的变量或属性可覆盖祖先作用域同名变量或属性的访问性;
  3. 若对仅祖先作用域存在的变量或属性赋值,将赋值给祖先作用域的变量或属性。
代码语言: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对象。

代码语言:javascript复制
// 文件 ./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)
  }
}
代码语言:javascript复制
// 文件 ./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
}

为了更容易阅读我又对表达式运算的代码进行了简化(移除开发阶段的提示和缓存机制)

代码语言:javascript复制
// 文件 ./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] -> [子块对象] -> [子上下文对象])。

代码语言:javascript复制
// 文件 ./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
  }
}

人肉单步调试

  1. 调用createApp根据入参生成全局作用域rootScope,创建根上下文rootCtx
  2. 调用mount<div v-scope="App"></div>构建根块对象rootBlock,并将其作为模板执行解析处理;
  3. 解析时识别到v-scope属性,以全局作用域rootScope为基础运算得到局部作用域scope,并以根上下文rootCtx为蓝本一同构建新的上下文ctx,用于子节点的解析和渲染;
  4. 获取$template属性值并生成HTML元素;
  5. 深度优先遍历解析子节点。

待续

通过简单的例子我们对petite-vue的解析、调度和渲染过程有了一定程度的了解,下一篇我们将再次通过静态视图看看v-ifv-for是如何根据状态改变DOM树结构的。 另外,可能有朋友会有如下疑问

  1. Proxy的receiver是什么?
  2. new Functioneval的区别?

这些后续会在专门的文章介绍,敬请期待:)

0 人点赞