头图

You Yuxi's 5KB petite-vue source code analysis

Peter谭老师
中文

Write at the beginning

  • You Yuxi recently released a 5kb petite-vue. Curious, I cloned his source code and gave you an analysis.
  • Recently, due to a lot of work, the pace of originality has been slowed down! Everyone understand
  • If you want to see my handwritten source code + various source code analysis, you can follow my public GitHub see my 060ee453515e86. Basically, the front-end framework source code has been parsed.

Officially begin

  • petite-vue is a vue with only 5kb, let’s find the warehouse first and clone it

    https://github.com/vuejs/petite-vue
  • After cloning, I found that it was started with vite + petite-vue + multi-page form
  • Start command:

    git clone https://github.com/vuejs/petite-vue
    cd /petite-vue
    npm i 
    npm run dev
    
  • Then open http://localhost:3000/ to see the page:


Nanny teaching

  • The project has been started. Next, let's analyze the entry of the project first. Since the build tool used is vite , we can find it from the population index.html

    <h2>Examples</h2>
    <ul>
    <li><a href="/examples/todomvc.html">TodoMVC</a></li>
    <li><a href="/examples/commits.html">Commits</a></li>
    <li><a href="/examples/grid.html">Grid</a></li>
    <li><a href="/examples/markdown.html">Markdown</a></li>
    <li><a href="/examples/svg.html">SVG</a></li>
    <li><a href="/examples/tree.html">Tree</a></li>
    </ul>
    
    <h2>Tests</h2>
    <ul>
    <li><a href="/tests/scope.html">v-scope</a></li>
    <li><a href="/tests/effect.html">v-effect</a></li>
    <li><a href="/tests/bind.html">v-bind</a></li>
    <li><a href="/tests/on.html">v-on</a></li>
    <li><a href="/tests/if.html">v-if</a></li>
    <li><a href="/tests/for.html">v-for</a></li>
    <li><a href="/tests/model.html">v-model</a></li>
    <li><a href="/tests/once.html">v-once</a></li>
    <li><a href="/tests/multi-mount.html">Multi mount</a></li>
    </ul>
    
    <style>
    a {
      font-size: 18px;
    }
    </style>
  • This is a demo project of multi-page mode+vue+vite, we found a simple demo page commits :

    <script type="module">
    import { createApp, reactive } from '../src'
    
    const API_URL = `https://api.github.com/repos/vuejs/vue-next/commits?per_page=3&sha=`
    
    createApp({
      branches: ['master', 'v2-compat'],
      currentBranch: 'master',
      commits: null,
    
      truncate(v) {
        const newline = v.indexOf('\n')
        return newline > 0 ? v.slice(0, newline) : v
      },
    
      formatDate(v) {
        return v.replace(/T|Z/g, ' ')
      },
    
      fetchData() {
        fetch(`${API_URL}${this.currentBranch}`)
          .then((res) => res.json())
          .then((data) => {
            this.commits = data
          })
      }
    }).mount()
    </script>
    
    <div v-scope v-effect="fetchData()">
    <h1>Latest Vue.js Commits</h1>
    <template v-for="branch in branches">
      <input
        type="radio"
        :id="branch"
        :value="branch"
        name="branch"
        v-model="currentBranch"
      />
      <label :for="branch">{{ branch }}</label>
    </template>
    <p>vuejs/vue@{{ currentBranch }}</p>
    <ul>
      <li v-for="{ html_url, sha, author, commit } in commits">
        <a :href="html_url" target="_blank" class="commit"
          >{{ sha.slice(0, 7) }}</a
        >
        - <span class="message">{{ truncate(commit.message) }}</span><br />
        by
        <span class="author"
          ><a :href="author.html_url" target="_blank"
            >{{ commit.author.name }}</a
          ></span
        >
        at <span class="date">{{ formatDate(commit.author.date) }}</span>
      </li>
    </ul>
    </div>
    
    <style>
    body {
      font-family: 'Helvetica', Arial, sans-serif;
    }
    a {
      text-decoration: none;
      color: #f66;
    }
    li {
      line-height: 1.5em;
      margin-bottom: 20px;
    }
    .author, .date {
      font-weight: bold;
    }
    </style>
    
  • You can see the introduction at the top of the page

    import { createApp, reactive } from '../src'

    Start from the source code to start the function

  • The startup function is createApp , find the source code:

    //index.ts
    export { createApp } from './app'
    ...
    import { createApp } from './app'
    
    let s
    if ((s = document.currentScript) && s.hasAttribute('init')) {
    createApp().mount()
    }
    
    The Document.currentScript property returns the <script> element that the currently running script belongs to. The script that calls this attribute cannot be a JavaScript module, and the module should use the import.meta object.
  • s code means to create a 060ee453516455 variable to record the currently running script element. If there is a specified attribute init , then call the createApp and mount methods.
  • But in this project, the exposed createApp method is actively called. Let’s take a look at createApp method. There are about 80 lines of code.
import { reactive } from '@vue/reactivity'
import { Block } from './block'
import { Directive } from './directives'
import { createContext } from './context'
import { toDisplayString } from './directives/text'
import { nextTick } from './scheduler'

export default function createApp(initialData?: any){
...
}
  • The createApp method receives an initial data, which can be of any type or not. This method is an entry function, and it depends on many functions, so we have to calm down. This function came in and made a lot of things

    createApp(initialData?: any){
     // root context
    const ctx = createContext()
    if (initialData) {
      ctx.scope = reactive(initialData)
    }
    
    // global internal helpers
    ctx.scope.$s = toDisplayString
    ctx.scope.$nextTick = nextTick
    ctx.scope.$refs = Object.create(null)
    
    let rootBlocks: Block[]
    
    }
  • The above code creates a ctx context object and assigns many attributes and methods to it. Then provide it to the object returned by createApp to use
  • createContext Create context:

    export const createContext = (parent?: Context): Context => {
    const ctx: Context = {
      ...parent,
      scope: parent ? parent.scope : reactive({}),
      dirs: parent ? parent.dirs : {},
      effects: [],
      blocks: [],
      cleanups: [],
      effect: (fn) => {
        if (inOnce) {
          queueJob(fn)
          return fn as any
        }
        const e: ReactiveEffect = rawEffect(fn, {
          scheduler: () => queueJob(e)
        })
        ctx.effects.push(e)
        return e
      }
    }
    return ctx
    }
    
  • According to the parent object passed in, a simple inheritance is made, and then a new ctx object is returned.
I almost fell into a misunderstanding at the beginning. I wrote this article to let everyone understand the simple vue , like the source code analysis of the Nuggets editor I wrote last time, the writing is too detailed and too tired. Simplify this time so that everyone can understand that these things are not important. The createApp function returns an object:
return {
  directive(name: string, def?: Directive) {
      if (def) {
        ctx.dirs[name] = def
        return this
      } else {
        return ctx.dirs[name]
      }
    },
mount(el?: string | Element | null){}...,
unmount(){}...
}
  • There are three methods on the object. For example, the directive instruction will use the attributes and methods of ctx So at the beginning, a lot of things were mounted to ctx in order to use the following methods
  • Focus on the mount method:

       mount(el?: string | Element | null) {
       if (typeof el === 'string') {
          el = document.querySelector(el)
          if (!el) {
            import.meta.env.DEV &&
              console.error(`selector ${el} has no matching element.`)
            return
          }
        }
       ...
      
       }
  • First, it will judge if the incoming string is string, then go back to find this node, otherwise it will find document

    el = el || document.documentElement
    • Define roots , a node array

      let roots: Element[]
      if (el.hasAttribute('v-scope')) {
        roots = [el]
      } else {
        roots = [...el.querySelectorAll(`[v-scope]`)].filter(
          (root) => !root.matches(`[v-scope] [v-scope]`)
        )
      }
      if (!roots.length) {
        roots = [el]
      }
  • If there is v-scope , store el in the array and assign it to roots . Otherwise, go to this el find all v-scope attribute, and then filter out these nodes with the v-scope attribute without the v-scope attribute. roots array

    roots is still empty at this time el it.
    Here is a warning in development mode: Mounting on documentElement - this is non-optimal as petite-vue , which means that using document is not the best choice.
  • After roots , start the action.

    rootBlocks = roots.map((el) => new Block(el, ctx, true))
        // remove all v-cloak after mount
        ;[el, ...el.querySelectorAll(`[v-cloak]`)].forEach((el) =>
          el.removeAttribute('v-cloak')
        )
  • The Block constructor is the key point. After passing in the node and context, the outside just removes the'v-cloak' attribute, and the call to the mount function is over, so the principle is hidden in Block .
There is a question here. We currently only get the el dom , but there are template grammars in vue. How do those template grammars become real dom?
  • Block turned out to be not a function, but a class.

  • Can be seen in the constructor constructor

    constructor(template: Element, parentCtx: Context, isRoot = false) {
      this.isFragment = template instanceof HTMLTemplateElement
    
      if (isRoot) {
        this.template = template
      } else if (this.isFragment) {
        this.template = (template as HTMLTemplateElement).content.cloneNode(
          true
        ) as DocumentFragment
      } else {
        this.template = template.cloneNode(true) as Element
      }
    
      if (isRoot) {
        this.ctx = parentCtx
      } else {
        // create child context
        this.parentCtx = parentCtx
        parentCtx.blocks.push(this)
        this.ctx = createContext(parentCtx)
      }
    
      walk(this.template, this.ctx)
    }
  • The above code can be divided into three logics

    • Create the template template (using the clone node method, because the dom node is an object after it is obtained, a layer of clone is made)
    • If it is not the root node, recursively inherit the ctx context
    • After processing ctx and Template, call walk function
  • walk function analysis:

  • Will judge according to nodetype first, and then do different processing
  • If it is a element node, different instructions must be processed, such as v-if

  • Here is a utility function to look at first

    export const checkAttr = (el: Element, name: string): string | null => {
    const val = el.getAttribute(name)
    if (val != null) el.removeAttribute(name)
    return val
    }
  • This function means to check whether the node contains v-xx , then return the result and delete the attribute
  • Take v-if an example. When it is judged that this node has the v-if attribute, then call the method to process it, and delete this attribute (it has been processed as an identifier)

    I wanted to go to bed before 12 o’clock. Others told me that it’s only 5kb. I wanted to find the simplest instruction to parse. As a result, each instruction code has more than 100 lines. I worked overtime until 9 o’clock tonight. After transforming the micro front end into production, I still want to persevere and write it for everyone. It's early morning now
  • v-if processing function is about 60 lines

    export const _if = (el: Element, exp: string, ctx: Context) => {
    ...
    }
  • First, the _if function first gets the value of the v-if of the el node and exp, and the ctx context object

    if (import.meta.env.DEV && !exp.trim()) {
      console.warn(`v-if expression cannot be empty.`)
    }
    
  • If it is empty, report a warning
  • Then get the parent node of the el node, and create a comment node (temporary storage) based on the value of exp and insert it before el, and create a branches array to store exp and el

     const parent = el.parentElement!
    const anchor = new Comment('v-if')
    parent.insertBefore(anchor, el)
    
    const branches: Branch[] = [
      {
        exp,
        el
      }
    ]
    
    // locate else branch
    let elseEl: Element | null
    let elseExp: string | null
    The Comment interface represents textual notations between markups. Although it is usually not displayed, you can see them when viewing the source code. In HTML and XML, comments are the content between '<!--' and'-->'. In XML, the character sequence'--' cannot appear in comments.
  • Then create elseEl and elseExp , and loop through the collection of all the else branches, and store them in the branches

    while ((elseEl = el.nextElementSibling)) {
      elseExp = null
      if (
        checkAttr(elseEl, 'v-else') === '' ||
        (elseExp = checkAttr(elseEl, 'v-else-if'))
      ) {
        parent.removeChild(elseEl)
        branches.push({ exp: elseExp, el: elseEl })
      } else {
        break
      }
    }
    In this way, there are all branches of v-if in Branches, which can be regarded as a tree traversal (breadth first search)
  • Next, according to the triggering of the side-effect function, each time to traverse the branches to find the branch that needs to be activated, insert the node into the parentNode, and return to the nextNode to achieve the effect of v-if

    Since it is all html here, we have omitted the virtual dom, but the above only deals with a single node. If it is a deep-level dom node, the depth-first search will be used later.
     // process children first before self attrs
      walkChildren(el, ctx)
    
    
    const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
    let child = node.firstChild
    while (child) {
      child = walk(child, ctx) || child.nextSibling
    }
    }
    
  • When there are no v-if the node, then take their first child node to do the above actions, matching each instruction such as v-if v-for
If it is a text node
else if (type === 3) {
    // Text
    const data = (node as Text).data
    if (data.includes('{{')) {
      let segments: string[] = []
      let lastIndex = 0
      let match
      while ((match = interpolationRE.exec(data))) {
        const leading = data.slice(lastIndex, match.index)
        if (leading) segments.push(JSON.stringify(leading))
        segments.push(`$s(${match[1]})`)
        lastIndex = match.index + match[0].length
      }
      if (lastIndex < data.length) {
        segments.push(JSON.stringify(data.slice(lastIndex)))
      }
      applyDirective(node, text, segments.join('+'), ctx)
    }
This place is very classic, it is through regular matching, and then a series of operations to match, and finally a text string is returned. This code is very essential, but due to time constraints, I won’t go into details here.
  • applyDirective function

    const applyDirective = (
    el: Node,
    dir: Directive<any>,
    exp: string,
    ctx: Context,
    arg?: string,
    modifiers?: Record<string, true>
    ) => {
    const get = (e = exp) => evaluate(ctx.scope, e, el)
    const cleanup = dir({
      el,
      get,
      effect: ctx.effect,
      ctx,
      exp,
      arg,
      modifiers
    })
    if (cleanup) {
      ctx.cleanups.push(cleanup)
    }
    }
  • Next, if the nodeType is 11, it means it is a Fragment node, so start directly from its first child node

    } else if (type === 11) {
      walkChildren(node as DocumentFragment, ctx)
    }
nodeType description
此属性只读且传回一个数值。
有效的数值符合以下的型别:
1-ELEMENT
2-ATTRIBUTE
3-TEXT
4-CDATA
5-ENTITY REFERENCE
6-ENTITY
7-PI (processing instruction)
8-COMMENT
9-DOCUMENT
10-DOCUMENT TYPE
11-DOCUMENT FRAGMENT
12-NOTATION

Combing summary

  • Pull code
  • Startup project
  • Find the entry createApp function
  • Define ctx and inheritance
  • Discover the block method
  • Separate processing according to whether the node is element or text
  • If it is text, go through regular matching, get the data and return the string
  • If it is an element, do a recursive process, parse all v-if , and return the real node

    All dom node changes here are directly operated dom through js

Interesting source code supplement

  • The nextTick implementation here is directly through promise.then

    const p = Promise.resolve()
    
    export const nextTick = (fn: () => void) => p.then(fn)
    

    Write at the end

  • It’s a bit late, I don’t know it until 1 o’clock, if I feel that I’ve written well, please help me to read/follow/like it
  • If you want to read previous source code analysis articles, you can follow my gitHub -public number: front-end peak
阅读 1.5k

前端巅峰
注重前端性能优化和前沿技术,重型跨平台开发,即时通讯技术等。 欢迎关注微信公众号:前端巅峰

前端架构师

14.1k 声望
28.3k 粉丝
0 条评论
你知道吗?

前端架构师

14.1k 声望
28.3k 粉丝
宣传栏