Resolving Components
Actually, our chibivue template cannot resolve components yet. Let's implement it here, as Vue.js provides several ways to resolve components.
First, let's review some of the resolution methods.
Resolution Methods for Components
1. Components Option (Local Registration)
This is probably the simplest way to resolve components.
https://vuejs.org/api/options-misc.html#components
<script>
import MyComponent from './MyComponent.vue'
export default {
components: {
MyComponent,
MyComponent2: MyComponent,
},
}
</script>
<template>
<MyComponent />
<MyComponent2 />
</template>
The key names specified in the components option object become the component names that can be used in the template.
2. Registering on the app (Global Registration)
You can register components that can be used throughout the application by using the .component()
method of the created Vue application.
https://vuejs.org/guide/components/registration.html#global-registration
import { createApp } from 'vue'
const app = createApp({})
app
.component('ComponentA', ComponentA)
.component('ComponentB', ComponentB)
.component('ComponentC', ComponentC)
3. Dynamic Components + is Attribute
By using the is attribute, you can dynamically switch components.
https://vuejs.org/api/built-in-special-elements.html#component
<script>
import Foo from './Foo.vue'
import Bar from './Bar.vue'
export default {
components: { Foo, Bar },
data() {
return {
view: 'Foo',
}
},
}
</script>
<template>
<component :is="view" />
</template>
4. Importing during script setup
In script setup, you can directly use the imported components.
<script setup>
import MyComponent from './MyComponent.vue'
</script>
<template>
<MyComponent />
</template>
In addition, there are also asynchronous components, embedded components, and the component
tag, but this time I will try to handle the above two (1, 2).
Regarding 3, if 1 and 2 can handle it, it is just an extension. As for 4, since script setup has not been implemented yet, we will put it off for a while.
Basic Approach
The basic approach to resolving components is as follows:
- Somewhere, store the names and component records used in the template.
- Use helper functions to resolve components based on their names.
Both the form of 1 and the form of 2 simply store the names and component records, with the only difference being where they are registered.
If you have the records, you can resolve the components from the names when necessary, so both implementations will be similar.
First, let's take a look at the expected code and the compilation result.
<script>
import MyComponent from './MyComponent.vue'
export default defineComponent({
components: { MyComponent },
})
</script>
<template>
<MyComponent />
</template>
// Compilation result
function render(_ctx) {
const {
resolveComponent: _resolveComponent,
createVNode: _createVNode,
Fragment: _Fragment,
} = ChibiVue
const _component_MyComponent = _resolveComponent('MyComponent')
return _createVNode(_Fragment, null, _createVNode(_component_MyComponent))
}
It looks like this.
Implementation
AST
In order to generate code that resolves components, we need to know that "MyComponent" is a component.
At the parse stage, we handle the tag name and separate it into a regular Element and a Component on the AST.
First, let's consider the definition of the AST.
The ComponentNode, like a regular Element, has props and children.
While consolidating these common parts as BaseElementNode
, we will rename the existing ElementNode
to PlainElementNode
,
and make ElementNode
a union of PlainElementNode
and ComponentNode
.
// compiler-core/ast.ts
export const enum ElementTypes {
ELEMENT,
COMPONENT,
}
export type ElementNode = PlainElementNode | ComponentNode
export interface BaseElementNode extends Node {
type: NodeTypes.ELEMENT
tag: string
tagType: ElementTypes
isSelfClosing: boolean
props: Array<AttributeNode | DirectiveNode>
children: TemplateChildNode[]
}
export interface PlainElementNode extends BaseElementNode {
tagType: ElementTypes.ELEMENT
codegenNode: VNodeCall | SimpleExpressionNode | undefined
}
export interface ComponentNode extends BaseElementNode {
tagType: ElementTypes.COMPONENT
codegenNode: VNodeCall | undefined
}
The content is the same as before, but we distinguish them by tagType
and treat them as separate ASTs.
We will use this in the transform phase to add helper functions, etc.
Parser
Next, let's implement the parser to generate the above AST.
Basically, we just need to determine the tagType
based on the tag name.
The problem is how to determine whether it is an Element or a Component.
The basic idea is simple: just determine whether it is a "native tag" or not.
・
・
・
"Wait, wait, that's not what I'm asking. How do we actually implement it?"
Yes, this is a brute force approach. We predefine a list of native tag names and determine whether it matches or not.
As for what items should be enumerated, all of them should be written in the specification, so we will trust it and use it.
One problem, if any, is that "what is a native tag" can vary depending on the environment.
In this case, it's the browser. What I mean is that "compiler-core should not depend on the environment".
We have implemented such DOM-dependent implementations in compiler-dom so far, and this enumeration is no exception.
With that in mind, we will implement it so that the function "whether it is a native tag or not" can be injected as an option from outside the parser, considering future possibilities and making it easy to add various options later.
type OptionalOptions = 'isNativeTag' // | TODO: Add more in the future (maybe)
type MergedParserOptions = Omit<Required<ParserOptions>, OptionalOptions> &
Pick<ParserOptions, OptionalOptions>
export interface ParserContext {
// .
// .
options: MergedParserOptions
// .
// .
}
function createParserContext(
content: string,
rawOptions: ParserOptions,
): ParserContext {
const options = Object.assign({}, defaultParserOptions)
let key: keyof ParserOptions
// prettier-ignore
for (key in rawOptions) {
options[key] =
rawOptions[key] === undefined
? defaultParserOptions[key]
: rawOptions[key];
}
// .
// .
// .
}
export const baseParse = (
content: string,
options: ParserOptions = {},
): RootNode => {
const context = createParserContext(
content,
options,
)
const children = parseChildren(context, [])
return createRoot(children)
}
Now, in the compiler-dom, we will enumerate the native tag names and pass them as options.
Although I mentioned compiler-dom, the enumeration itself is done in shared/domTagConfig.ts.
import { makeMap } from './makeMap'
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element
const HTML_TAGS =
'html,body,base,head,link,meta,style,title,address,article,aside,footer,' +
'header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' +
'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' +
'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' +
'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' +
'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' +
'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' +
'option,output,progress,select,textarea,details,dialog,menu,' +
'summary,template,blockquote,iframe,tfoot'
export const isHTMLTag = makeMap(HTML_TAGS)
It looks quite ominous, doesn't it?
But this is the correct implementation.
Create compiler-dom/parserOptions.ts and pass it to the compiler.
// compiler-dom/parserOptions.ts
import { ParserOptions } from '../compiler-core'
import { isHTMLTag, isSVGTag } from '../shared/domTagConfig'
export const parserOptions: ParserOptions = {
isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag),
}
export function compile(template: string, option?: CompilerOptions) {
const defaultOption = { isBrowser: true }
if (option) Object.assign(defaultOption, option)
return baseCompile(
template,
Object.assign(
{},
parserOptions,
defaultOption,
{
directiveTransforms: DOMDirectiveTransforms,
},
),
)
}
The implementation of the parser is complete, so we will now proceed to implement the remaining parts.
The remaining part is very simple. We just need to determine whether it is a component or not and assign a tagType.
function parseElement(
context: ParserContext,
ancestors: ElementNode[],
): ElementNode | undefined {
// .
// .
let tagType = ElementTypes.ELEMENT
// prettier-ignore
if (isComponent(tag, context)) {
tagType = ElementTypes.COMPONENT;
}
return {
// .
tagType,
// .
}
}
function isComponent(tag: string, context: ParserContext) {
const options = context.options
if (
// NOTE: In Vue.js, tags starting with uppercase letters are treated as components.
// ref: https://github.com/vuejs/core/blob/32bdc5d1900ceb8df1e8ee33ea65af7b4da61051/packages/compiler-core/src/parse.ts#L662
/^[A-Z]/.test(tag) ||
(options.isNativeTag && !options.isNativeTag(tag))
) {
return true
}
}
With this, the parser and AST are complete. We will now proceed to implement the transform and codegen using these.
Transform
What needs to be done in the transform is very simple.
In transformElement, we just need to make a slight conversion if the Node is a ComponentNode.
At this time, we also register the component in the context.
This is done so that we can resolve it collectively during codegen. As mentioned later, components will be resolved collectively as assets in codegen.
// compiler-core/transforms/transformElement.ts
export const transformElement: NodeTransform = (node, context) => {
return function postTransformElement() {
// .
// .
const isComponent = node.tagType === ElementTypes.COMPONENT
const vnodeTag = isComponent
? resolveComponentType(node as ComponentNode, context)
: `"${tag}"`
// .
// .
}
}
function resolveComponentType(node: ComponentNode, context: TransformContext) {
let { tag } = node
context.helper(RESOLVE_COMPONENT)
context.components.add(tag) // explained later
return toValidAssetId(tag, `component`)
}
// util.ts
export function toValidAssetId(
name: string,
type: 'component', // | TODO:
): string {
return `_${type}_${name.replace(/[^\w]/g, (searchValue, replaceValue) => {
return searchValue === '-' ? '_' : name.charCodeAt(replaceValue).toString()
})}`
}
We also make sure to register it in the context.
export interface TransformContext extends Required<TransformOptions> {
// .
components: Set<string>
// .
}
export function createTransformContext(
root: RootNode,
{
nodeTransforms = [],
directiveTransforms = {},
isBrowser = false,
}: TransformOptions,
): TransformContext {
const context: TransformContext = {
// .
components: new Set(),
// .
}
}
And then, all the components in the context are registered in the RootNode of the target components.
export interface RootNode extends Node {
type: NodeTypes.ROOT
children: TemplateChildNode[]
codegenNode?: TemplateChildNode | VNodeCall
helpers: Set<symbol>
components: string[]
}
export function transform(root: RootNode, options: TransformOptions) {
const context = createTransformContext(root, options)
traverseNode(root, context)
createRootCodegen(root, context)
root.helpers = new Set([...context.helpers.keys()])
root.components = [...context.components]
}
With this, all that's left is to use RootNode.components in codegen.
Codegen
The code simply generates code by passing the name to helper functions to resolve, just like the compilation result we saw at the beginning. We are abstracting it as "assets" for future considerations.
export const generate = (ast: RootNode, option: CompilerOptions): string => {
// .
// .
genFunctionPreamble(ast, context) // NOTE: Move this outside the function in the future
// prettier-ignore
if (ast.components.length) {
genAssets(ast.components, "component", context);
newline();
newline();
}
push(`return `)
// .
// .
}
function genAssets(
assets: string[],
type: 'component' /* TODO: */,
{ helper, push, newline }: CodegenContext,
) {
if (type === 'component') {
const resolver = helper(RESOLVE_COMPONENT)
for (let i = 0; i < assets.length; i++) {
let id = assets[i]
push(
`const ${toValidAssetId(id, type)} = ${resolver}(${JSON.stringify(
id,
)})`,
)
if (i < assets.length - 1) {
newline()
}
}
}
}
Implementation on the runtime-core side
Now that we have generated the desired code, let's move on to the implementation in runtime-core.
Adding "component" as an option for components
This is simple, just add it to the options.
export type ComponentOptions<
// .
// .
> = {
// .
components?: Record<string, Component>
// .
}
Adding "components" as an option for the app
This is also simple.
export interface AppContext {
// .
components: Record<string, Component>
// .
}
export function createAppContext(): AppContext {
return {
// .
components: {},
// .
}
}
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
): CreateAppFunction<HostElement> {
return function createApp(rootComponent) {
// .
const app: App = (context.app = {
// .
// prettier-ignore
component(name: string, component: Component): any {
context.components[name] = component;
return app;
},
})
}
}
Implementing a function to resolve components from the above two
There is nothing special to explain here.
It searches for components registered locally and globally, and returns the component.
If it is not found, it returns the name as is as a fallback.
// runtime-core/helpers/componentAssets.ts
export function resolveComponent(name: string): ConcreteComponent | string {
const instance = currentInstance || currentRenderingInstance // explained later
if (instance) {
const Component = instance.type
const res =
// local registration
resolve((Component as ComponentOptions).components, name) ||
// global registration
resolve(instance.appContext.components, name)
return res
}
return name
}
function resolve(registry: Record<string, any> | undefined, name: string) {
return (
registry &&
(registry[name] ||
registry[camelize(name)] ||
registry[capitalize(camelize(name))])
)
}
One thing to note is currentRenderingInstance
.
In order to traverse locally registered components in resolveComponent
, we need to access the currently rendering component.
(We want to search the components
option of the component being rendered)
With that in mind, let's prepare currentRenderingInstance
and update it when rendering.
// runtime-core/componentRenderContexts.ts
export let currentRenderingInstance: ComponentInternalInstance | null = null
export function setCurrentRenderingInstance(
instance: ComponentInternalInstance | null,
): ComponentInternalInstance | null {
const prev = currentRenderingInstance
currentRenderingInstance = instance
return prev
}
// runtime-core/renderer.ts
const setupRenderEffect = (
instance: ComponentInternalInstance,
initialVNode: VNode,
container: RendererElement,
anchor: RendererElement | null,
) => {
const componentUpdateFn = () => {
// .
// .
const prev = setCurrentRenderingInstance(instance)
const subTree = (instance.subTree = normalizeVNode(render(proxy!)))
setCurrentRenderingInstance(prev)
// .
// .
}
// .
// .
}
Let's try it out
Great job! We can finally resolve components.
Let's try running it in the playground!
import { createApp } from 'chibivue'
import App from './App.vue'
import Counter from './components/Counter.vue'
const app = createApp(App)
app.component('GlobalCounter', Counter)
app.mount('#app')
App.vue
<script>
import Counter from './components/Counter.vue'
import { defineComponent } from 'chibivue'
export default defineComponent({
components: { Counter },
})
</script>
<template>
<Counter />
<Counter />
<GlobalCounter />
</template>
components/Counter.vue
<script>
import { ref, defineComponent } from 'chibivue'
export default defineComponent({
setup() {
const count = ref(0)
return { count }
},
})
</script>
<template>
<button @click="count++">count: {{ count }}</button>
</template>
It seems to be working fine! Great job!
Source code up to this point: GitHub