Petite Vue source code analysis - start from the static view

Posted by mithu_sree on Fri, 04 Mar 2022 17:44:24 +0100

Introduction to code base structure

  • Examples various usage examples
  • Scripts packaging and publishing scripts
  • tests test cases
  • src

    • Implementation of built-in instructions such as directives v-if
    • app.ts createApp function
    • block.ts block object
    • context.ts context object
    • eval.ts provides expression operation functions such as v-if="count === 1"
    • scheduler.ts scheduler
    • utils.ts tool function
    • walk.ts template parsing

If you want to build your own version, just execute npm run build on the console.

In depth understanding of the rendering process of static views

Static view means that after the first rendering, it will not be re rendered due to the change of UI state. The view does not contain any UI state, and the state will not be updated after the first rendering according to the UI state. This article will explain the former.

Example:

<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>

The first step is the createApp method, which is used to create root context object (root context), global scope object (root scope) and return mount,unmount and direct methods. Then use the mount method to find the child nodes with the [v-scope] attribute (excluding the descendant nodes matching [v-scope] [v-scope]) and create root block objects for them.
The source code is as follows (based on this example, I have partially deleted the source code to make it easier to read):

// Documents/ src/app.ts

export const createApp = (initialData: any) => {
  // Create root context object
  const ctx = createContext()
  // The global scope object is actually a responsive object
  ctx.scope = reactive(initialData)
  /* Bind this of all function members of scope as scope.
   * If the arrow function is used to assign a value to a function member, the above operation is invalid for the function member.
   */
  bindContextMethods(ctx.scope)
  
  /* Root block object collection
   * petite-vue Multiple root block objects are supported, but here we can simplify it to only support one root block object.
   */
  let rootBlocks: Block[]

  return {
    // It is simplified as that it must be attached to an element with '[v-scope]'
    mount(el: Element) {
      let roots = el.hasAttribute('v-scope') ? [el] : []
      // Create root block object
      rootBlocks = roots.map(el => new Block(el, ctx, true))
      return this
    },
    unmount() {
      // When the node is unloaded (removeChild), the cleanup of the block object is performed. Note: this operation will not be triggered when refreshing the interface.
      rootBlocks.forEach(block => block.teardown())
    }
  }
}

Although the code is very short, it leads to three core objects: context object, scope and block object. Their relationship is:

  • The context object and scope have a 1-to-1 relationship;
  • The context block refers to the current object (context block) through the context object (context block) and the parent object (context block), where the context block points to the current object (context block);
  • Scope and block object are 1-to-many relationships.

The specific conclusions are:

  • The root context object can be referenced by multiple root block objects through ctx;
  • When creating a block object, it will create a new context object (context) based on the current context object (context), and point to the original context object (context) through parentCtx;
  • During the parsing process, v-scope will build a new scope object based on the current scope object, and copy the current context object to form a new context object (context) for the parsing and rendering of child nodes, but it will not affect the context pointed to by the current block object.

Let's understand it one by one.

Scope

The scope here is consistent with the scope we said when writing JavaScript. The purpose is to limit the available range of functions and variables and reduce naming conflicts.
It has the following characteristics:

  1. There are parent-child relationships and sibling relationships between scopes, which form a scope tree as a whole;
  2. A variable or attribute of a child scope can override the accessibility of a variable or attribute of the same name in the ancestor scope;
  3. If you assign a value to a variable or attribute that exists only in the ancestor scope, it will be assigned to the variable or attribute of the ancestor scope.
// global scope
var globalVariable = 'hello'
var message1 = 'there'
var message2 = 'bye'

(() => {
  // Local scope A
  let message1 = 'Local scope A'
  message2 = 'see you'
  console.log(globalVariable, message1, message2)
})()
// Echo: hello local scope A see you

(() => {
  // Local scope B
  console.log(globalVariable, message1, message2)
})()
// Echo: hello there see you

Moreover, the scope is dependent on the context, so the creation and destruction of the scope are naturally located in the implementation of the context (. / src/context.ts).
In addition, the scope in Petite Vue is not an ordinary JavaScript object, but a responsive object processed by @ vue/reactivity. The purpose is to trigger the execution of relevant side-effect functions once the scope members are modified, so as to re render the interface.

Block object

Scope is used to manage the available scope of variables and functions in JavaScript, while block object is used to manage DOM objects.

// Documents/ src/block.ts

// Based on the example, I cut the code
export class Block {
  template: Element | DocumentFragment // Not pointing to $template, but the currently resolved template element
  ctx: Context // Context object created with block object
  parentCtx?: Context // The context object to which the current block object belongs. The root block object has no context object to which it belongs

  // Based on the above example, the < template > element is not adopted, and the static view does not contain any UI state, so I simplified the code
  construct(template: Element, parentCtx: Context, isRoot = false) {
    if (isRoot) {
      // For the root block object, the mount point element is directly used as the template element
      this.template = template
    }
    if (isRoot) {
      this.ctx = parentCtx
    }

    // Use the depth first strategy to resolve elements (the parsing process will push rendering tasks into the asynchronous task queue)
    walk(this.template, this.ctx)
  }
}
// Documents/ src/walk.ts

// Based on the above example, the static view does not contain any UI state, so I simplified the code
export const walk = (node: Node, ctx: Context): ChildNode | null | void => {
  const type= node.nodeType
  if (type === 1) {
    // node is of Element type
    const el = node as Element

    let exp: string | null
    if ((exp = checkAttr(el, 'v-scope')) || exp === '') {
      // The element with 'v-scope' calculates the latest action object. If the value of 'v-scope' is null, the latest scope object is null
      const scope = exp ? evaluate(ctx.scope, exp) : {}
      // Update the scope of the current context
      ctx = createScopedContext(ctx, scope)
      // If there is ` $template 'in the current scope, it will be rendered to the DOM tree as an online template, which will be parsed recursively in the future
      // Note: the ` $template 'attribute of the parent scope will not be read here. It must be of the current scope
      if (scope.$template) {
        resolveTemplate(el, scope.$template)
      }
    }

    walkChildren(el, ctx)
  }
}

// First, resolve the first child node. If there is no child, resolve the sibling node
const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
  let child = node.firstChild
  while (child) {
    child = walk(child, ctx) || child.nextSibling
  }
}

// Based on the above example, I simplified the code
const resolveTemplate = (el: Element, template: string) => {
  // Thanks to the fact that the template adopted by Vue fully conforms to the HTML specification, attribute names such as ` @ click 'and `: value' will not be lost after rendering to HTML elements directly and simply
  el.innerHTML = template
}

In order to make it easier to read, I simplified the code of expression operation (removing the hints and caching mechanism in the development stage)

// Documents/ 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 object (context)

From the above, we know that scope is used to manage the available range of variables and functions of JavaScript, while block object is used to manage DOM object, and context object is the carrier connecting scope and block object, It is also a connection point for multiple block objects to form a tree structure ([root block object. CTX] - [root context object, root context object. Blocks] - [sub block object] - [sub context object]).

// Documents/ src/context.ts

export interface Context {
  scope: Record<string, any> // Scope object corresponding to the current context
  cleanups: (()=>void)[] // Cleanup function for current context instruction
  blocks: Block[] // The block object that belongs to the current context
  effect: typeof rawEffect // The effect method is similar to @ vue/reactivity, but the scheduling method can be selected according to conditions
  effects: ReativeEffectRunner[] // The current context holds the side-effect method, which is used to reclaim the side-effect method and release resources when the context is destroyed
}

/**
 * The Block constructor calls to create a new context object with the following characteristics:
 * 1. The scope of the new context object is consistent with the parent context object
 * 2. The new context object has new effects, blocks, and cleanups members
 * Conclusion: the creation of context object initiated by Block constructor does not affect the scope object, but the context object will independently manage its side-effect methods, Block objects and instructions
 */
export const createContext = (parent? Context): Context => {
  const ctx: Context = {
    ...parent,
    scope: parent ? parent.scope : reactive({}), // Point to parent context scope object
    effects: [],
    blocks: [],
    cleanups: [],
    effect: fn => {
      // When the resolution encounters the 'v-once' attribute, 'inOnce' is set to 'true', and the side effect function 'fn' is directly pushed into the asynchronous task queue for execution once. Even if the dependent state changes, the side effect function will not be triggered.
      if (inOnce) {
        queueJob(fn)
        return fn as any
      }
      // Generate a side effect function that is automatically triggered when the state changes
      const e: ReactiveEffectRunner = rawEffect(fn, {
        scheduler: () => queueJob(e)
      })
      ctx.effects.push(e)
      return e
    }
  }
  return ctx
}

/**
 * When the 'v-scope' attribute is encountered during parsing and there is a valid value, this method will be called to create a new scope object based on the current scope, copy the current context attribute, and build a new context object for the parsing and rendering of child nodes.
 */
export const createScopedContext = (ctx: Context, data = {}): Context => {
  const parentScope = ctx.scope
  /* Construct scope object prototype chain 
   * At this time, if the property set does not exist in the current scope, the property will be created and assigned in the current scope.
   */
  cosnt mergeScope = Object.create(parentScope)
  Object.defineProperties(mergeScope, Object.getOwnPropertyDescriptors(data))
  // Construct ref object prototype chain
  mergeScope.$ref = Object.create(parentScope.$refs)
  // Construct scope chain
  const reactiveProxy = reactive(
    new Proxy(mergeScope, {
      set(target, key, val, receiver) {
        // If the set property does not exist in the current scope, set the value to the parent scope. Since the parent scope is created in the same way, recursively find the ancestor scope with the property and assign a value
        if (receiver === reactiveProxy && !target.hasOwnProperty(key)) {
          return Reflect.set(parentScope, key, val)
        }
        return Reflect.set(target, key, val, receiver)
      }
    })
  )

  /* Bind this of all function members of scope as scope.
   * If the arrow function is used to assign a value to a function member, the above operation is invalid for the function member.
   */
  bindContextMethods(reactiveProxy)
  return {
    ...ctx,
    scope: reactiveProxy
  }
}

Human flesh single step debugging

  1. Call createApp to generate the global scope rootScope according to the input parameters and create the root context rootCtx;
  2. Call mount to build the root block object rootBlock for < div V-Scope = "app" > < / div >, and use it as a template for parsing;
  3. The v-scope attribute is recognized during parsing, the local scope is obtained based on the global scope rootScope, and a new context ctx is constructed based on the root context rootCtx for the parsing and rendering of child nodes;
  4. Get the value of $template attribute and generate HTML elements;
  5. Depth first traversal resolves child nodes.

To be continued

Through a simple example, we have a certain understanding of the parsing, scheduling and rendering process of Petite Vue. In the next article, we will see how v-if and v-for change the DOM tree structure according to the state through the static view again.
In addition, some friends may have the following questions

  1. What is the Proxy receiver?
  2. What is the difference between new Function and eval?

These follow-up meetings will be introduced in a special article. Please look forward to:)

Topics: Javascript Vue.js