Hyper Ultimate Super Extreme Minimal Vue
Настройка проекта (0.5 мин)
# Клонируйте этот репозиторий и перейдите в него.
git clone https://github.com/chibivue-land/chibivue
cd chibivue
# Создайте проект с помощью команды setup.
# Укажите корневой путь проекта в качестве аргумента.
nr setup ../my-chibivue-project
Настройка проекта завершена.
Теперь давайте реализуем packages/index.ts.
createApp (1 мин)
Для функции create app давайте рассмотрим сигнатуру, которая позволяет указывать функции setup и render. С точки зрения пользователя, это будет использоваться так:
const app = createApp({
setup() {
// TODO:
},
render() {
// TODO:
},
})
app.mount('#app')
Давайте реализуем это:
type CreateAppOption = {
setup: () => Record<string, unknown>
render: (ctx: Record<string, unknown>) => VNode
}
Затем мы можем вернуть объект, который реализует функцию mount:
export const createApp = (option: CreateAppOption) => ({
mount(selector: string) {
const container = document.querySelector(selector)!
// TODO: patch рендеринг
},
})
На этом всё для этой части.
Функция h и Virtual DOM (0.5 мин)
Для выполнения patch рендеринга нам нужен Virtual DOM и функции для его генерации.
Virtual DOM представляет имена тегов, атрибуты и дочерние элементы с помощью объектов JavaScript. Рендерер Vue обрабатывает Virtual DOM и применяет обновления к реальному DOM.
Давайте рассмотрим VNode, который представляет имя, обработчик события клика и дочерние элементы (текст) для этого примера:
type VNode = { tag: string; onClick: (e: Event) => void; children: string }
export const h = (
tag: string,
onClick: (e: Event) => void,
children: string,
): VNode => ({ tag, onClick, children })
На этом всё для этой части.
patch рендеринг (2 мин)
Теперь давайте реализуем рендерер.
Этот процесс рендеринга часто называют патчингом, потому что он сравнивает старый и новый Virtual DOM и применяет различия к реальному DOM.
Сигнатура функции будет такой:
export const render = (n1: VNode | null, n2: VNode, container: Element) => {
// TODO:
}
n1 представляет старый VNode, n2 представляет новый VNode, а container - это корень реального DOM. В этом примере #app
будет контейнером (элемент, смонтированный с помощью createApp).
Нам нужно рассмотреть два типа операций:
- Mount
Это начальный рендеринг. Если n1 равен null, это означает, что это первый рендеринг, поэтому нам нужно реализовать процесс монтирования. - Patch
Это сравнивает VNodes и применяет различия к реальному DOM.
Однако в этот раз мы только обновляем дочерние элементы и не обнаруживаем различия.
Давайте реализуем это:
export const render = (n1: VNode | null, n2: VNode, container: Element) => {
const mountElement = (vnode: VNode, container: Element) => {
const el = document.createElement(vnode.tag)
el.textContent = vnode.children
el.addEventListener('click', vnode.onClick)
container.appendChild(el)
}
const patchElement = (_n1: VNode, n2: VNode) => {
;(container.firstElementChild as Element).textContent = n2.children
}
n1 == null ? mountElement(n2, container) : patchElement(n1, n2)
}
На этом всё для этой части.
Система реактивности (2 мин)
Теперь давайте реализуем логику для отслеживания изменений состояния, определенных в опции setup, и запуска функции render. Этот процесс отслеживания изменений состояния и выполнения определенных действий называется "Системой реактивности".
Давайте рассмотрим использование функции reactive
для определения состояний:
const app = createApp({
setup() {
const state = reactive({ count: 0 })
const increment = () => state.count++
return { state, increment }
},
// ..
// ..
})
В этом случае, когда состояние, определенное с помощью функции reactive
, изменяется, мы хотим запустить процесс патчинга.
Это можно достичь с помощью объекта Proxy. Прокси позволяют нам реализовать функциональность для операций get/set. В этом случае мы можем использовать операцию set для выполнения процесса патчинга, когда происходит операция set.
export const reactive = <T extends Record<string, unknown>>(obj: T): T =>
new Proxy(obj, {
get: (target, key, receiver) => Reflect.get(target, key, receiver),
set: (target, key, value, receiver) => {
const res = Reflect.set(target, key, value, receiver)
// ??? Здесь мы хотим выполнить процесс патчинга
return res
},
})
Вопрос в том, что мы должны запустить в операции set? Обычно мы бы отслеживали изменения с помощью операции get, но в этом случае мы определим функцию update
в глобальной области видимости и будем ссылаться на нее.
Давайте используем ранее реализованную функцию render для создания функции update:
let update: (() => void) | null = null // Мы хотим ссылаться на это с помощью Proxy, поэтому это должно быть в глобальной области видимости
export const createApp = (option: CreateAppOption) => ({
mount(selector: string) {
const container = document.querySelector(selector)!
let prevVNode: VNode | null = null
const setupState = option.setup() // Запускаем setup только при первом рендеринге
update = () => {
// Генерируем замыкание для сравнения prevVNode и VNode
const vnode = option.render(setupState)
render(prevVNode, vnode, container)
prevVNode = vnode
}
update()
},
})
Теперь нам просто нужно вызвать это в операции set прокси:
export const reactive = <T extends Record<string, unknown>>(obj: T): T =>
new Proxy(obj, {
get: (target, key, receiver) => Reflect.get(target, key, receiver),
set: (target, key, value, receiver) => {
const res = Reflect.set(target, key, value, receiver)
update?.() // Выполняем обновление
return res
},
})
Вот и всё!
компилятор шаблонов (5 мин)
До сих пор мы могли реализовать декларативный UI, позволяя пользователям использовать опцию render и функцию h. Однако в реальности мы хотим писать это в HTML-подобном виде.
Поэтому давайте реализуем компилятор шаблонов, который преобразует HTML в функцию h.
Цель - преобразовать строку вида:
<button @click="increment">state: {{ state.count }}</button>
в функцию вида:
h("button", increment, "state: " + state.count)
Давайте разобьем это немного.
- parse
Разбираем HTML-строку и преобразуем ее в объект, называемый AST (Abstract Syntax Tree). - codegen
Генерируем желаемый код (строку) на основе AST.
Теперь давайте реализуем AST и parse.
type AST = {
tag: string
onClick: string
children: (string | Interpolation)[]
}
type Interpolation = { content: string }
AST, с которым мы имеем дело в этот раз, показан выше. Он похож на VNode, но он совершенно другой и используется для генерации кода. Interpolation представляет синтаксис mustache. Строка вида {{ state.count }}
разбирается в объект (AST) вида { content: "state.count" }
.
Далее давайте реализуем функцию parse, которая генерирует AST из данной строки. Пока что давайте реализуем это быстро с помощью регулярных выражений и некоторых операций со строками.
const parse = (template: string): AST => {
const RE = /<([a-z]+)\s@click=\"([a-z]+)\">(.+)<\/[a-z]+>/
const [_, tag, onClick, children] = template.match(RE) || []
if (!tag || !onClick || !children) throw new Error('Invalid template!')
const regex = /{{(.*?)}}/g
let match: RegExpExecArray | null
let lastIndex = 0
const parsedChildren: AST['children'] = []
while ((match = regex.exec(children)) !== null) {
lastIndex !== match.index &&
parsedChildren.push(children.substring(lastIndex, match.index))
parsedChildren.push({ content: match[1].trim() })
lastIndex = match.index + match[0].length
}
lastIndex < children.length && parsedChildren.push(children.substr(lastIndex))
return { tag, onClick, children: parsedChildren }
}
Далее идет codegen. Генерируем вызов функции h на основе AST.
const codegen = (node: AST) =>
`(_ctx) => h('${node.tag}', _ctx.${node.onClick}, \`${node.children
.map(child =>
typeof child === 'object' ? `\$\{_ctx.${child.content}\}` : child,
)
.join('')}\`)`
Состояние ссылается из аргумента _ctx
.
Объединяя это, мы можем завершить функцию compile.
const compile = (template: string): string => codegen(parse(template))
Ну, на самом деле, как есть, он только генерирует вызов функции h как строку, поэтому он еще не работает.
Мы реализуем это вместе с компилятором sfc.
С этим компилятор шаблонов завершен.
sfc компилятор (vite-plugin) (4 мин)
Последнее! Давайте реализуем плагин для vite для поддержки sfc.
В плагинах vite есть опция transform, которая позволяет трансформировать содержимое файла.
Функция transform возвращает что-то вроде { code: string }
, и строка обрабатывается как исходный код. Другими словами, например,
export const VitePluginChibivue = () => ({
name: "vite-plugin-chibivue",
transform: (code: string, id: string) => ({
code: "";
}),
});
сделает содержимое всех файлов пустой строкой. Исходный код можно получить в качестве первого аргумента, поэтому, правильно преобразовав это значение и вернув его в конце, вы можете трансформировать его.
Нужно сделать 5 вещей.
- Извлечь то, что экспортируется по умолчанию из скрипта.
- Преобразовать его в код, который присваивает его переменной. (Для удобства давайте назовем переменную A.)
- Извлечь HTML-строку из шаблона и преобразовать ее в вызов функции h с помощью функции compile, которую мы создали ранее. (Для удобства давайте назовем результат B.)
- Сгенерировать код вида
Object.assign(A, { render: B })
. - Сгенерировать код, который экспортирует A по умолчанию.
Теперь давайте реализуем это.
const compileSFC = (sfc: string): { code: string } => {
const [_, scriptContent] =
sfc.match(/<script>\s*([\s\S]*?)\s*<\/script>/) ?? []
const [___, defaultExported] =
scriptContent.match(/export default\s*([\s\S]*)/) ?? []
const [__, templateContent] =
sfc.match(/<template>\s*([\s\S]*?)\s*<\/template>/) ?? []
if (!scriptContent || !defaultExported || !templateContent)
throw new Error('Invalid SFC!')
let code = ''
code +=
"import { h, reactive } from 'hyper-ultimate-super-extreme-minimal-vue';\n"
code += `const options = ${defaultExported}\n`
code += `Object.assign(options, { render: ${compile(templateContent)} });\n`
code += 'export default options;\n'
return { code }
}
После этого реализуем это в плагине.
export const VitePluginChibivue = () => ({
name: 'vite-plugin-chibivue',
transform: (code: string, id: string) =>
id.endsWith('.vue') ? compileSFC(code) : code, // Только для файлов с расширением .vue
})
Конец
Да. С этим мы успешно реализовали всё до SFC. Давайте еще раз посмотрим на исходный код.
// create app api
type CreateAppOption = {
setup: () => Record<string, unknown>
render: (ctx: Record<string, unknown>) => VNode
}
let update: (() => void) | null = null
export const createApp = (option: CreateAppOption) => ({
mount(selector: string) {
const container = document.querySelector(selector)!
let prevVNode: VNode | null = null
const setupState = option.setup()
update = () => {
const vnode = option.render(setupState)
render(prevVNode, vnode, container)
prevVNode = vnode
}
update()
},
})
// Virtual DOM patch
export const render = (n1: VNode | null, n2: VNode, container: Element) => {
const mountElement = (vnode: VNode, container: Element) => {
const el = document.createElement(vnode.tag)
el.textContent = vnode.children
el.addEventListener('click', vnode.onClick)
container.appendChild(el)
}
const patchElement = (_n1: VNode, n2: VNode) => {
;(container.firstElementChild as Element).textContent = n2.children
}
n1 == null ? mountElement(n2, container) : patchElement(n1, n2)
}
// Virtual DOM
type VNode = { tag: string; onClick: (e: Event) => void; children: string }
export const h = (
tag: string,
onClick: (e: Event) => void,
children: string,
): VNode => ({ tag, onClick, children })
// Система реактивности
export const reactive = <T extends Record<string, unknown>>(obj: T): T =>
new Proxy(obj, {
get: (target, key, receiver) => Reflect.get(target, key, receiver),
set: (target, key, value, receiver) => {
const res = Reflect.set(target, key, value, receiver)
update?.()
return res
},
})
// компилятор шаблонов
type AST = {
tag: string
onClick: string
children: (string | Interpolation)[]
}
type Interpolation = { content: string }
const parse = (template: string): AST => {
const RE = /<([a-z]+)\s@click=\"([a-z]+)\">(.+)<\/[a-z]+>/
const [_, tag, onClick, children] = template.match(RE) || []
if (!tag || !onClick || !children) throw new Error('Invalid template!')
const regex = /{{(.*?)}}/g
let match: RegExpExecArray | null
let lastIndex = 0
const parsedChildren: AST['children'] = []
while ((match = regex.exec(children)) !== null) {
lastIndex !== match.index &&
parsedChildren.push(children.substring(lastIndex, match.index))
parsedChildren.push({ content: match[1].trim() })
lastIndex = match.index + match[0].length
}
lastIndex < children.length && parsedChildren.push(children.substr(lastIndex))
return { tag, onClick, children: parsedChildren }
}
const codegen = (node: AST) =>
`(_ctx) => h('${node.tag}', _ctx.${node.onClick}, \`${node.children
.map(child =>
typeof child === 'object' ? `\$\{_ctx.${child.content}\}` : child,
)
.join('')}\`)`
const compile = (template: string): string => codegen(parse(template))
// sfc компилятор (vite transformer)
export const VitePluginChibivue = () => ({
name: 'vite-plugin-chibivue',
transform: (code: string, id: string) =>
id.endsWith('.vue') ? compileSFC(code) : null,
})
const compileSFC = (sfc: string): { code: string } => {
const [_, scriptContent] =
sfc.match(/<script>\s*([\s\S]*?)\s*<\/script>/) ?? []
const [___, defaultExported] =
scriptContent.match(/export default\s*([\s\S]*)/) ?? []
const [__, templateContent] =
sfc.match(/<template>\s*([\s\S]*?)\s*<\/template>/) ?? []
if (!scriptContent || !defaultExported || !templateContent)
throw new Error('Invalid SFC!')
let code = ''
code +=
"import { h, reactive } from 'hyper-ultimate-super-extreme-minimal-vue';\n"
code += `const options = ${defaultExported}\n`
code += `Object.assign(options, { render: ${compile(templateContent)} });\n`
code += 'export default options;\n'
return { code }
}
Удивительно, но мы смогли реализовать это примерно в 110 строках. (Теперь никто не будет жаловаться, фух...)
Пожалуйста, обязательно попробуйте также основную часть основной части!! (Хотя это всего лишь приложение 😙)