Compiling the script block
What we want to do
Now, the original script section of SFC looks like this:
export default {
setup() {},
}
I want to extract only the following part:
{
setup() {},
}
Is there any way to do this?
If I can extract this part, I can mix it nicely with the previously generated render function and export it as follows:
const _sfc_main = {
setup() {},
}
export default { ..._sfc_main, render }
Using external libraries
To achieve the above, I will use the following two libraries:
- @babel/parser
- magic-string
Babel
You may have heard of Babel if you are familiar with JavaScript.
Babel is a toolchain used to convert JavaScript into backward-compatible versions.
In simple terms, it is a compiler (transpiler) from JS to JS.
In this case, I will use Babel not only as a compiler but also as a parser.
Babel has an internal parser for converting to AST, as it plays the role of a compiler.
AST stands for Abstract Syntax Tree, which is a representation of JavaScript code.
You can find the AST specification here (https://github.com/estree/estree).
Although you can refer to the GitHub md file, I will briefly explain AST in JavaScript.
The entire program is represented by a Program AST node, which contains an array of statements (represented using TS interfaces for clarity).
interface Program {
body: Statement[]
}
Statement represents a "statement" in JavaScript, which is a collection of statements.
Examples include "variable declaration statement," "if statement," "for statement," and "block statement."
interface Statement {}
interface VariableDeclaration extends Statement {
/* omitted */
}
interface IfStatement extends Statement {
/* omitted */
}
interface ForStatement extends Statement {
/* omitted */
}
interface BlockStatement extends Statement {
body: Statement[]
}
// There are many more
Statements usually have an "expression" in most cases.
An expression is something that can be assigned to a variable.
Examples include "object," "binary operation," and "function call."
interface Expression {}
interface BinaryExpression extends Expression {
operator: '+' | '-' | '*' | '/' // There are many more, but omitted
left: Expression
right: Expression
}
interface ObjectExpression extends Expression {
properties: Property[] // omitted
}
interface CallExpression extends Expression {
callee: Expression
arguments: Expression[]
}
// There are many more
If we consider an if statement, it has the following structure:
interface IfStatement extends Statement {
test: Expression // condition
consequent: Statement // statements to be executed if the condition is true
alternate: Statement | null // statements to be executed if the condition is false
}
In this way, JavaScript syntax is parsed into the AST mentioned above.
I think this explanation is easy to understand for those who have already implemented the template compiler for chibivue. (It's the same thing)
The reason why I use Babel is twofold.
First, it's simply because it's cumbersome.
If you have implemented a parser before, it may be technically possible to implement a JS parser while referring to estree.
However, it is very cumbersome, and it is not very important for the purpose of "deepening understanding of Vue" in this case.
The other reason is that the official Vue also uses Babel for this part.
magic-string
https://github.com/rich-harris/magic-string
There is another library I want to use.
This library is also used by the official Vue.
It is a library that makes string manipulation easier.
const input = 'Hello'
const s = new MagicString(input)
You can generate an instance like this and use the convenient methods provided by the instance to manipulate strings.
Here are some examples:
s.append('!!!') // Append to the end
s.prepend('message: ') // Prepend to the beginning
s.overwrite(9, 13, 'こんにちは') // Overwrite within a range
There is no need to use it forcefully, but I will use it to align with the official Vue.
Whether it's Babel or magic-string, you don't need to understand the actual usage at this point.
I will explain and align the implementation later, so it's okay to have a rough understanding for now.
Rewriting the default export of the script
To recap the current goal:
export default {
setup() {},
// Other options
}
I want to rewrite the code above to:
const _sfc_main = {
setup() {},
// Other options
}
export default { ..._sfc_main, render }
In other words, if I can extract the export target from the original code's export statement and assign it to a variable called _sfc_main
, I will achieve the goal.
First, let's install the necessary libraries.
pwd # ~
ni @babel/parser magic-string
Create a file called "rewriteDefault.ts".
pwd # ~
touch packages/compiler-sfc/rewriteDefault.ts
Make sure that the function "rewriteDefault" can receive the target source code as "input" and the variable name to be bound as "as".
Return the converted source code as the return value.
~/packages/compiler-sfc/rewriteDefault.ts
export function rewriteDefault(input: string, as: string): string {
// TODO:
return ''
}
First, let's handle the case where the export declaration does not exist.
Since there is no export, bind an empty object and finish.
const defaultExportRE = /((?:^|\n|;)\s*)export(\s*)default/
const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)(?:as)?(\s*)default/s
export function rewriteDefault(input: string, as: string): string {
if (!hasDefaultExport(input)) {
return input + `\nconst ${as} = {}`
}
// TODO:
return ''
}
export function hasDefaultExport(input: string): boolean {
return defaultExportRE.test(input) || namedDefaultExportRE.test(input)
}
Here comes the Babel parser and magic-string.
import { parse } from '@babel/parser'
import MagicString from 'magic-string'
// .
// .
export function rewriteDefault(input: string, as: string): string {
// .
// .
const s = new MagicString(input)
const ast = parse(input, {
sourceType: 'module',
}).program.body
// .
// .
}
From here, we will manipulate the string s
based on the JavaScript AST (Abstract Syntax Tree) obtained by the Babel parser.
Although it is a bit long, I will provide additional explanations in the comments in the source code.
Basically, we traverse the AST and write conditional statements based on the type
property, and manipulate the string s
using the methods of magic-string
.
export function rewriteDefault(input: string, as: string): string {
// .
// .
ast.forEach(node => {
// In case of default export
if (node.type === 'ExportDefaultDeclaration') {
if (node.declaration.type === 'ClassDeclaration') {
// If it is `export default class Hoge {}`, replace it with `class Hoge {}`
s.overwrite(node.start!, node.declaration.id.start!, `class `)
// Then, add code like `const ${as} = Hoge;` at the end.
s.append(`\nconst ${as} = ${node.declaration.id.name}`)
} else {
// For other default exports, replace the declaration part with a variable declaration.
// eg 1) `export default { setup() {}, }` -> `const ${as} = { setup() {}, }`
// eg 2) `export default Hoge` -> `const ${as} = Hoge`
s.overwrite(node.start!, node.declaration.start!, `const ${as} = `)
}
}
// There may be a default export in the declaration even in the case of named export.
// Mainly 3 patterns
// 1. In the case of declaration like `export { default } from "source";`
// 2. In the case of declaration like `export { hoge as default }` from 'source'
// 3. In the case of declaration like `export { hoge as default }`
if (node.type === 'ExportNamedDeclaration') {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ExportSpecifier' &&
specifier.exported.type === 'Identifier' &&
specifier.exported.name === 'default'
) {
// If there is a keyword `from`
if (node.source) {
if (specifier.local.name === 'default') {
// 1. In the case of declaration like `export { default } from "source";`
// In this case, extract it into an import statement and give it a name, then bind it to the final variable.
// eg) `export { default } from "source";` -> `import { default as __VUE_DEFAULT__ } from 'source'; const ${as} = __VUE_DEFAULT__`
const end = specifierEnd(input, specifier.local.end!, node.end!)
s.prepend(
`import { default as __VUE_DEFAULT__ } from '${node.source.value}'\n`,
)
s.overwrite(specifier.start!, end, ``)
s.append(`\nconst ${as} = __VUE_DEFAULT__`)
continue
} else {
// 2. In the case of declaration like `export { hoge as default }` from 'source'
// In this case, rewrite all specifiers as they are in the import statement, and bind the variable that is as default to the final variable.
// eg) `export { hoge as default } from "source";` -> `import { hoge } from 'source'; const ${as} = hoge
const end = specifierEnd(
input,
specifier.exported.end!,
node.end!,
)
s.prepend(
`import { ${input.slice(
specifier.local.start!,
specifier.local.end!,
)} } from '${node.source.value}'\n`,
)
// 3. In the case of declaration like `export { hoge as default }`
// In this case, simply bind it to the final variable.
s.overwrite(specifier.start!, end, ``)
s.append(`\nconst ${as} = ${specifier.local.name}`)
continue
}
}
const end = specifierEnd(input, specifier.end!, node.end!)
s.overwrite(specifier.start!, end, ``)
s.append(`\nconst ${as} = ${specifier.local.name}`)
}
}
}
})
return s.toString()
}
// Calculate the end of the declaration statement
function specifierEnd(input: string, end: number, nodeEnd: number | null) {
// export { default , foo } ...
let hasCommas = false
let oldEnd = end
while (end < nodeEnd!) {
if (/\s/.test(input.charAt(end))) {
end++
} else if (input.charAt(end) === ',') {
end++
hasCommas = true
break
} else if (input.charAt(end) === '}') {
break
}
}
return hasCommas ? end : oldEnd
}
Now you can rewrite the default export.
Let's try using it in a plugin.
import type { Plugin } from 'vite'
import { createFilter } from 'vite'
import { parse, rewriteDefault } from '../../compiler-sfc'
import { compile } from '../../compiler-dom'
export default function vitePluginChibivue(): Plugin {
const filter = createFilter(/\.vue$/)
return {
name: 'vite:chibivue',
transform(code, id) {
if (!filter(id)) return
const outputs = []
outputs.push("import * as ChibiVue from 'chibivue'")
const { descriptor } = parse(code, { filename: id })
// --------------------------- From here
const SFC_MAIN = '_sfc_main'
const scriptCode = rewriteDefault(
descriptor.script?.content ?? '',
SFC_MAIN,
)
outputs.push(scriptCode)
// --------------------------- To here
const templateCode = compile(descriptor.template?.content ?? '', {
isBrowser: false,
})
outputs.push(templateCode)
outputs.push('\n')
outputs.push(`export default { ...${SFC_MAIN}, render }`) // Here
return { code: outputs.join('\n') }
},
}
}
Before that, let's make a small modification.
~/packages/runtime-core/component.ts
export const setupComponent = (instance: ComponentInternalInstance) => {
// .
// .
// .
// Add the component's render option to the instance
const { render } = component
if (render) {
instance.render = render as InternalRenderFunction
}
}
Now you should be able to render!!!
The styles are not applied because they are not supported, but now you can render the component.