Skip to content

Implementing Fragment

Issues with the current implementation

Let's try running the following code in a playground:

ts
import { createApp, defineComponent } from 'chibivue'

const App = defineComponent({
  template: `<header>header</header>
<main>main</main>
<footer>footer</footer>`,
})

const app = createApp(App)

app.mount('#app')

You may encounter an error like this:

fragment_error.png

Looking at the error message, it seems to be related to the Function constructor.

In other words, the code generation seems to be successful up to a certain point, so let's see what code is actually generated.

ts
return function render(_ctx) {
  with (_ctx) {
    const { createVNode: _createVNode } = ChibiVue

    return _createVNode("header", null, "header")"\n  "_createVNode("main", null, "main")"\n  "_createVNode("footer", null, "footer")
   }
}

The code after the return statement is incorrect. The current code generation implementation does not handle cases where the root is an array (i.e., not a single node).

We will fix this issue.

What code should be generated?

Even though we are making modifications, what kind of code should be generated?

In conclusion, the code should look like this:

ts
return function render(_ctx) {
  with (_ctx) {
    const { createVNode: _createVNode, Fragment: _Fragment } = ChibiVue

    return _createVNode(_Fragment, null, [
      [
        _createVNode('header', null, 'header'),
        '\n  ',
        _createVNode('main', null, 'main'),
        '\n  ',
        _createVNode('footer', null, 'footer'),
      ],
    ])
  }
}

This Fragment is a symbol defined in Vue.

In other words, Fragment is not represented as an AST like FragmentNode, but simply as a tag of ElementNode.

We will implement the processing for Fragment in the renderer, similar to Text.

Implementation

The Fragment symbol will be implemented in runtime-core/vnode.ts.

Let's add it as a new type in VNodeTypes.

ts
export type VNodeTypes = Component | typeof Text | typeof Fragment | string

export const Fragment = Symbol()

Implement the renderer.

Add a branch for fragment in the patch function.

ts
if (type === Text) {
  processText(n1, n2, container, anchor)
} else if (shapeFlag & ShapeFlags.ELEMENT) {
  processElement(n1, n2, container, anchor, parentComponent)
} else if (type === Fragment) {
  // Here
  processFragment(n1, n2, container, anchor, parentComponent)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
  processComponent(n1, n2, container, anchor, parentComponent)
} else {
  // do nothing
}

Note that inserting or removing elements should generally be implemented with anchor as a marker.

As the name suggests, an anchor indicates the start and end positions of a fragment.

The starting element is represented by the existing el property in VNode, but currently there is no property to represent the end. Let's add it.

ts
export interface VNode<HostNode = any> {
  // .
  // .
  // .
  anchor: HostNode | null // fragment anchor // Added
  // .
  // .
}

Set the anchor during mount.

Pass the fragment's end as the anchor in mount/patch.

ts
const processFragment = (
  n1: VNode | null,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
) => {
  const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
  const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!

  if (n1 == null) {
    hostInsert(fragmentStartAnchor, container, anchor)
    hostInsert(fragmentEndAnchor, container, anchor)
    mountChildren(
      n2.children as VNode[],
      container,
      fragmentEndAnchor,
      parentComponent,
    )
  } else {
    patchChildren(n1, n2, container, fragmentEndAnchor, parentComponent)
  }
}

Be careful when the elements of the fragment change during updates.

ts
const move = (
  vnode: VNode,
  container: RendererElement,
  anchor: RendererElement | null,
) => {
  const { type, children, el, shapeFlag } = vnode

  // .

  if (type === Fragment) {
    hostInsert(el!, container, anchor)
    for (let i = 0; i < (children as VNode[]).length; i++) {
      move((children as VNode[])[i], container, anchor)
    }
    hostInsert(vnode.anchor!, container, anchor) // Insert the anchor
    return
  }
  // .
  // .
  // .
}

During unmount, also rely on the anchor to remove elements.

ts
const remove = (vnode: VNode) => {
  const { el, type, anchor } = vnode
  if (type === Fragment) {
    removeFragment(el!, anchor!)
  }

  // .
  // .
  // .
}

const removeFragment = (cur: RendererNode, end: RendererNode) => {
  let next
  while (cur !== end) {
    next = hostNextSibling(cur)! // ※ Add this to nodeOps!
    hostRemove(cur)
    cur = next
  }
  hostRemove(end)
}

Testing

The code we wrote earlier should work correctly.

ts
import { Fragment, createApp, defineComponent, h, ref } from 'chibivue'

const App = defineComponent({
  template: `<header>header</header>
<main>main</main>
<footer>footer</footer>`,
})

const app = createApp(App)

app.mount('#app')

Currently, we cannot use directives like v-for, so we cannot write a description that uses a fragment in the template and changes the number of elements.

Let's simulate the behavior by writing the compiled code and see how it works.

ts
import { Fragment, createApp, defineComponent, h, ref } from 'chibivue'

// const App = defineComponent({
//   template: `<header>header</header>
//   <main>main</main>
//   <footer>footer</footer>`,
// });

const App = defineComponent({
  setup() {
    const list = ref([0])
    const update = () => {
      list.value = [...list.value, list.value.length]
    }
    return () =>
      h(Fragment, {}, [
        h('button', { onClick: update }, 'update'),
        ...list.value.map(i => h('div', {}, i)),
      ])
  },
})

const app = createApp(App)

app.mount('#app')

It seems to be working correctly!

Source code up to this point: GitHub

Released under the MIT License.