my-framework-almaz 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/my-framework-almaz.js +243 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Fe-Fwk
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Code for the book "[Build a frontend framework from scratch](http://mng.bz/aM2o)".
|
|
6
|
+
|
|
7
|
+
This framework **isn't intended for production use**.
|
|
8
|
+
Its intention is to **teach you how frontend frameworks work** by building one yourself, from scratch.
|
|
9
|
+
|
|
10
|
+
## [v1.0 — Chapter 6](https://github.com/angelsolaorbaiceta/fe-fwk-book/tree/ch6/packages/runtime)
|
|
11
|
+
|
|
12
|
+
_See the example application in the [examples/ch06 folder](https://github.com/angelsolaorbaiceta/fe-fwk-book/tree/main/examples/ch06)_.
|
|
13
|
+
|
|
14
|
+
To checkout this version of the code:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
git checkout ch6
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This version of the framework introduces the concept of a _Virtual DOM_.
|
|
21
|
+
It implements the `h()`, `hString()` and `hFragment()` functions to create virtual nodes and the `mountDOM()` and `destroyDOM()` functions to turn the virtual DOM representing the view of the application into HTML elements in the browser.
|
|
22
|
+
|
|
23
|
+
The application instance, created using the `createApp()` function, holds all the state for the application, which can be changed by dispatching commands using an instance of a `Dispatcher`.
|
|
24
|
+
Every time a command is dispatched, the reducer subscribed to the command is run to update the state, and then, the entire view is destroyed and recreated from scratch.
|
|
25
|
+
This is done by registering the application's `renderApp()` function to run after each command is processed in the dispatcher.
|
|
26
|
+
|
|
27
|
+
In the next version we introduce the _reconciliation algorithm_ to only update the parts of the DOM that have changed.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
function addEventListener(eventName, handler, el) {
|
|
2
|
+
el.addEventListener(eventName, handler);
|
|
3
|
+
return handler
|
|
4
|
+
}
|
|
5
|
+
function addEventListeners(listeners = {}, el) {
|
|
6
|
+
const addedListeners = {};
|
|
7
|
+
Object.entries(listeners).forEach(([eventName, handler]) => {
|
|
8
|
+
const listener = addEventListener(eventName, handler, el);
|
|
9
|
+
addedListeners[eventName] = listener;
|
|
10
|
+
});
|
|
11
|
+
return addedListeners
|
|
12
|
+
}
|
|
13
|
+
function removeEventListeners(listeners, el) {
|
|
14
|
+
Object.entries(listeners).forEach(([eventName, handler]) => {
|
|
15
|
+
el.removeEventListener(eventName, handler);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function withoutNulls(arr) {
|
|
20
|
+
return arr.filter((item) => item != null)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DOM_TYPES = {
|
|
24
|
+
TEXT: 'text',
|
|
25
|
+
ELEMENT: 'element',
|
|
26
|
+
FRAGMENT: 'fragment',
|
|
27
|
+
};
|
|
28
|
+
function h(tag, props = {}, children = []) {
|
|
29
|
+
return {
|
|
30
|
+
tag,
|
|
31
|
+
props,
|
|
32
|
+
children: mapTextNodes(withoutNulls(children)),
|
|
33
|
+
type: DOM_TYPES.ELEMENT,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function hString(str) {
|
|
37
|
+
return { type: DOM_TYPES.TEXT, value: str }
|
|
38
|
+
}
|
|
39
|
+
function hFragment(vNodes) {
|
|
40
|
+
return {
|
|
41
|
+
type: DOM_TYPES.FRAGMENT,
|
|
42
|
+
children: mapTextNodes(withoutNulls(vNodes)),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function mapTextNodes(children) {
|
|
46
|
+
return children.map((child) =>
|
|
47
|
+
typeof child === 'string' ? hString(child) : child
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function destroyDOM(vdom) {
|
|
52
|
+
const { type } = vdom;
|
|
53
|
+
switch (type) {
|
|
54
|
+
case DOM_TYPES.TEXT: {
|
|
55
|
+
removeTextNode(vdom);
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
case DOM_TYPES.ELEMENT: {
|
|
59
|
+
removeElementNode(vdom);
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
case DOM_TYPES.FRAGMENT: {
|
|
63
|
+
removeFragmentNode(vdom);
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
default: {
|
|
67
|
+
throw new Error(`Can't destroy DOM of type: ${type}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
delete vdom.el;
|
|
71
|
+
}
|
|
72
|
+
function removeTextNode(vdom) {
|
|
73
|
+
const { el } = vdom;
|
|
74
|
+
el.remove();
|
|
75
|
+
}
|
|
76
|
+
function removeElementNode(vdom) {
|
|
77
|
+
const { el, children, listeners } = vdom;
|
|
78
|
+
el.remove();
|
|
79
|
+
children.forEach(destroyDOM);
|
|
80
|
+
if (listeners) {
|
|
81
|
+
removeEventListeners(listeners, el);
|
|
82
|
+
delete vdom.listeners;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function removeFragmentNode(vdom) {
|
|
86
|
+
const { children } = vdom;
|
|
87
|
+
children.forEach(destroyDOM);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class Dispatcher {
|
|
91
|
+
#subs = new Map()
|
|
92
|
+
#afterHandlers = []
|
|
93
|
+
subscribe(commandName, handler) {
|
|
94
|
+
if (!this.#subs.has(commandName)) {
|
|
95
|
+
this.#subs.set(commandName, []);
|
|
96
|
+
}
|
|
97
|
+
const handlers = this.#subs.get(commandName);
|
|
98
|
+
if (handlers.includes(handler)) {
|
|
99
|
+
return () => {}
|
|
100
|
+
}
|
|
101
|
+
handlers.push(handler);
|
|
102
|
+
return () => {
|
|
103
|
+
const idx = handlers.indexOf(handler);
|
|
104
|
+
handlers.splice(idx, 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
afterEveryCommand(handler) {
|
|
108
|
+
this.#afterHandlers.push(handler);
|
|
109
|
+
return () => {
|
|
110
|
+
const idx = this.#afterHandlers.indexOf(handler);
|
|
111
|
+
this.#afterHandlers.splice(idx, 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
dispatch(commandName, payload) {
|
|
115
|
+
if (this.#subs.has(commandName)) {
|
|
116
|
+
this.#subs.get(commandName).forEach((handler) => handler(payload));
|
|
117
|
+
} else {
|
|
118
|
+
console.warn(`No handlers for command: ${commandName}`);
|
|
119
|
+
}
|
|
120
|
+
this.#afterHandlers.forEach((handler) => handler());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setAttributes(el, attrs) {
|
|
125
|
+
const { class: className, style, ...otherAttrs } = attrs;
|
|
126
|
+
if (className) {
|
|
127
|
+
setClass(el, className);
|
|
128
|
+
}
|
|
129
|
+
if (style) {
|
|
130
|
+
Object.entries(style).forEach(([prop, value]) => {
|
|
131
|
+
setStyle(el, prop, value);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (const [name, value] of Object.entries(otherAttrs)) {
|
|
135
|
+
setAttribute(el, name, value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function setClass(el, className) {
|
|
139
|
+
el.className = '';
|
|
140
|
+
if (typeof className === 'string') {
|
|
141
|
+
el.className = className;
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(className)) {
|
|
144
|
+
el.classList.add(...className);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function setStyle(el, name, value) {
|
|
148
|
+
el.style[name] = value;
|
|
149
|
+
}
|
|
150
|
+
function setAttribute(el, name, value) {
|
|
151
|
+
if (value === null) {
|
|
152
|
+
removeAttribute(el, value);
|
|
153
|
+
} else if (name.startsWith('data-')) {
|
|
154
|
+
el.setAttribute(name, value);
|
|
155
|
+
} else {
|
|
156
|
+
el[name] = value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function removeAttribute(el, name) {
|
|
160
|
+
el[name] = null;
|
|
161
|
+
el.removeAttribute(name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function mountDOM(vdom, parentEl) {
|
|
165
|
+
switch (vdom.type) {
|
|
166
|
+
case DOM_TYPES.TEXT: {
|
|
167
|
+
createTextNode(vdom, parentEl);
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case DOM_TYPES.ELEMENT: {
|
|
171
|
+
createElementNode(vdom, parentEl);
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
case DOM_TYPES.FRAGMENT: {
|
|
175
|
+
createFragmentNodes(vdom, parentEl);
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
default: {
|
|
179
|
+
throw new Error(`Can't mount DOM of type: ${vdom.type}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function createTextNode(vdom, parentEl) {
|
|
184
|
+
const { value } = vdom;
|
|
185
|
+
const textNode = document.createTextNode(value);
|
|
186
|
+
vdom.el = textNode;
|
|
187
|
+
parentEl.append(textNode);
|
|
188
|
+
}
|
|
189
|
+
function createFragmentNodes(vdom, parentEl) {
|
|
190
|
+
const { children } = vdom;
|
|
191
|
+
vdom.el = parentEl;
|
|
192
|
+
children.forEach((child) => mountDOM(child, parentEl));
|
|
193
|
+
}
|
|
194
|
+
function createElementNode(vdom, parentEl) {
|
|
195
|
+
const { tag, props, children } = vdom;
|
|
196
|
+
const element = document.createElement(tag);
|
|
197
|
+
addProps(element, props, vdom);
|
|
198
|
+
vdom.el = element;
|
|
199
|
+
children.forEach((child) => mountDOM(child, element));
|
|
200
|
+
parentEl.append(element);
|
|
201
|
+
}
|
|
202
|
+
function addProps(el, props, vdom) {
|
|
203
|
+
const { on: events, ...attrs } = props;
|
|
204
|
+
vdom.listeners = addEventListeners(events, el);
|
|
205
|
+
setAttributes(el, attrs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function createApp({ state, view, reducers = {} }) {
|
|
209
|
+
let parentEl = null;
|
|
210
|
+
let vdom = null;
|
|
211
|
+
const dispatcher = new Dispatcher();
|
|
212
|
+
const subscriptions = [dispatcher.afterEveryCommand(renderApp)];
|
|
213
|
+
function emit(eventName, payload) {
|
|
214
|
+
dispatcher.dispatch(eventName, payload);
|
|
215
|
+
}
|
|
216
|
+
for (const actionName in reducers) {
|
|
217
|
+
const reducer = reducers[actionName];
|
|
218
|
+
const subs = dispatcher.subscribe(actionName, (payload) => {
|
|
219
|
+
state = reducer(state, payload);
|
|
220
|
+
});
|
|
221
|
+
subscriptions.push(subs);
|
|
222
|
+
}
|
|
223
|
+
function renderApp() {
|
|
224
|
+
if (vdom) {
|
|
225
|
+
destroyDOM(vdom);
|
|
226
|
+
}
|
|
227
|
+
vdom = view(state, emit);
|
|
228
|
+
mountDOM(vdom, parentEl);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
mount(_parentEl) {
|
|
232
|
+
parentEl = _parentEl;
|
|
233
|
+
renderApp();
|
|
234
|
+
},
|
|
235
|
+
unmount() {
|
|
236
|
+
destroyDOM(vdom);
|
|
237
|
+
vdom = null;
|
|
238
|
+
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { createApp, h, hFragment, hString };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-framework-almaz",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A frontend framework to teach web developers how frontend frameworks work.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"frontend",
|
|
7
|
+
"framework",
|
|
8
|
+
"book",
|
|
9
|
+
"learning",
|
|
10
|
+
"web",
|
|
11
|
+
"development"
|
|
12
|
+
],
|
|
13
|
+
"main": "dist/my-framework-almaz.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/my-framework-almaz.js"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "rollup -c",
|
|
20
|
+
"lint": "eslint src",
|
|
21
|
+
"lint:fix": "eslint src --fix",
|
|
22
|
+
"test": "vitest",
|
|
23
|
+
"test:coverage": "vitest run --coverage",
|
|
24
|
+
"prepack": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"author": "Angel Sola Orbaiceta",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@vitest/coverage-c8": "^0.28.5",
|
|
30
|
+
"eslint": "^8.35.0",
|
|
31
|
+
"rollup": "^3.18.0",
|
|
32
|
+
"rollup-plugin-cleanup": "^3.2.1",
|
|
33
|
+
"rollup-plugin-filesize": "^10.0.0",
|
|
34
|
+
"vitest": "^0.28.5"
|
|
35
|
+
}
|
|
36
|
+
}
|