关于vue.js:petitevue源码剖析从静态视图开始

7次阅读

共计 6372 个字符,预计需要花费 16 分钟才能阅读完成。

代码库构造介绍

  • 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 状态首次渲染后状态不再更新 两种状况,本篇将针对前者进行解说。

示例:

<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] 的子孙节点),并为它们创立 根块对象
源码如下(基于这个例子,我对源码进行局部删减以便更容易浏览):

// 文件 ./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. 若对仅先人作用域存在的变量或属性赋值,将赋值给先人作用域的变量或属性。
// 全局作用域
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
  }
}

人肉单步调试

  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