philo-scratch 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 +16 -0
- package/dist/philo-scratch.js +244 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# scratch
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Code for the book "Build a frontend framework from scratch".
|
|
6
|
+
You can get your copy [here](http://mng.bz/aM2o) and start learning how frontend frameworks work by building your own, from scratch!
|
|
7
|
+
|
|
8
|
+
## Commands
|
|
9
|
+
|
|
10
|
+
This package has the following commands:
|
|
11
|
+
|
|
12
|
+
- `npm run build` - Bundle the project into a single ESM file
|
|
13
|
+
- `npm run lint` - Lint the project
|
|
14
|
+
- `npm run lint:fix` - Lint the project and fix any issues
|
|
15
|
+
- `npm run test [<test-path>]` - Start the test runner in watch mode
|
|
16
|
+
- `npm run test:run [<test-path>]` - Run the tests once
|
|
@@ -0,0 +1,244 @@
|
|
|
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(children) {
|
|
20
|
+
return children.filter((child) => child != null);
|
|
21
|
+
}
|
|
22
|
+
function mapTextNodes(children) {
|
|
23
|
+
return children.map((child) => (typeof child === "string" ? hString(child) : child));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DOM_TYPES = {
|
|
27
|
+
TEXT: "text",
|
|
28
|
+
ELEMENT: "element",
|
|
29
|
+
FRAGMENT: "fragment",
|
|
30
|
+
};
|
|
31
|
+
function h(tag, props = {}, children = []) {
|
|
32
|
+
return {
|
|
33
|
+
tag,
|
|
34
|
+
props,
|
|
35
|
+
children: mapTextNodes(withoutNulls(children)),
|
|
36
|
+
type: DOM_TYPES.ELEMENT,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function hString(str) {
|
|
40
|
+
return {
|
|
41
|
+
value: str,
|
|
42
|
+
type: DOM_TYPES.TEXT,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function hFragment(vNodes) {
|
|
46
|
+
return {
|
|
47
|
+
type: DOM_TYPES.FRAGMENT,
|
|
48
|
+
children: mapTextNodes(withoutNulls(vNodes)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function destroyDOM(vdom) {
|
|
53
|
+
const { type } = vdom;
|
|
54
|
+
switch (type) {
|
|
55
|
+
case DOM_TYPES.TEXT: {
|
|
56
|
+
removeTextNode(vdom);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case DOM_TYPES.ELEMENT: {
|
|
60
|
+
removeElementNode(vdom);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case DOM_TYPES.FRAGMENT: {
|
|
64
|
+
removeFragmentNode(vdom);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
default: {
|
|
68
|
+
throw new Error(`Can't destroy DOM of type ${type}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
delete vdom.el;
|
|
72
|
+
}
|
|
73
|
+
function removeTextNode(vdom) {
|
|
74
|
+
const { el } = vdom;
|
|
75
|
+
el.remove();
|
|
76
|
+
}
|
|
77
|
+
function removeElementNode(vdom) {
|
|
78
|
+
const { el, children, listeners } = vdom;
|
|
79
|
+
el.remove();
|
|
80
|
+
children.forEach(destroyDOM);
|
|
81
|
+
if (listeners) {
|
|
82
|
+
removeEventListeners(listeners, el);
|
|
83
|
+
delete vdom.listeners;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function removeFragmentNode(vdom) {
|
|
87
|
+
const { children } = vdom;
|
|
88
|
+
children.forEach(destroyDOM);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function setAttributes(el, attrs) {
|
|
92
|
+
const { class: className, style, ...otherAttrs } = attrs;
|
|
93
|
+
if (className) {
|
|
94
|
+
setClass(el, className);
|
|
95
|
+
}
|
|
96
|
+
if (style) {
|
|
97
|
+
Object.entries(style).forEach(([prop, value]) => {
|
|
98
|
+
setStyle(el, prop, value);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
for (const [name, value] of Object.entries(otherAttrs)) {
|
|
102
|
+
setAttribute(el, name, value);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function setClass(el, className) {
|
|
106
|
+
el.className = "";
|
|
107
|
+
if (typeof className === "string") {
|
|
108
|
+
el.className = className;
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(className)) {
|
|
111
|
+
el.classList.add(...className);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function setStyle(el, name, value) {
|
|
115
|
+
el.style[name] = value;
|
|
116
|
+
}
|
|
117
|
+
function setAttribute(el, name, value) {
|
|
118
|
+
if (value == null) {
|
|
119
|
+
removeAttribute(el, name);
|
|
120
|
+
} else if (name.startsWith("data-")) {
|
|
121
|
+
el.setAttribute(name, value);
|
|
122
|
+
} else {
|
|
123
|
+
el[name] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function removeAttribute(el, name) {
|
|
127
|
+
el[name] = null;
|
|
128
|
+
el.removeAttribute(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function mountDOM(vdom, parentEl) {
|
|
132
|
+
switch (vdom.type) {
|
|
133
|
+
case DOM_TYPES.TEXT: {
|
|
134
|
+
createTextNode(vdom, parentEl);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case DOM_TYPES.ELEMENT: {
|
|
138
|
+
createElementNode(vdom, parentEl);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case DOM_TYPES.FRAGMENT: {
|
|
142
|
+
createFragmentNode(vdom, parentEl);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
default: {
|
|
146
|
+
throw new Error(`Can't mount DOM of type: ${vdom.type}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function createFragmentNode(vdom, parentEl) {
|
|
151
|
+
const { children } = vdom;
|
|
152
|
+
vdom.el = parentEl;
|
|
153
|
+
children.forEach((child) => mountDOM(child, parentEl));
|
|
154
|
+
}
|
|
155
|
+
function createTextNode(vdom, parentEl) {
|
|
156
|
+
const { value } = vdom;
|
|
157
|
+
const textNode = document.createTextNode(value);
|
|
158
|
+
vdom.el = textNode;
|
|
159
|
+
parentEl.append(textNode);
|
|
160
|
+
}
|
|
161
|
+
function createElementNode(vdom, parentEl) {
|
|
162
|
+
const { tag, props, children } = vdom;
|
|
163
|
+
const element = document.createElement(tag);
|
|
164
|
+
addProps(element, props, vdom);
|
|
165
|
+
vdom.el = element;
|
|
166
|
+
children.forEach((child) => mountDOM(child, element));
|
|
167
|
+
parentEl.append(element);
|
|
168
|
+
}
|
|
169
|
+
function addProps(el, props, vdom) {
|
|
170
|
+
const { on: events, ...attrs } = props;
|
|
171
|
+
vdom.listeners = addEventListeners(events, el);
|
|
172
|
+
setAttributes(el, attrs);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
class Dispatcher {
|
|
176
|
+
#subs = new Map();
|
|
177
|
+
#afterHandlers = [];
|
|
178
|
+
subscribe(commandName, handler) {
|
|
179
|
+
if (!this.#subs.has(commandName)) {
|
|
180
|
+
this.#subs.set(commandName, []);
|
|
181
|
+
}
|
|
182
|
+
const handlers = this.#subs.get(commandName);
|
|
183
|
+
if (handlers.includes(handler)) {
|
|
184
|
+
return () => {};
|
|
185
|
+
}
|
|
186
|
+
handlers.push(handler);
|
|
187
|
+
return () => {
|
|
188
|
+
const idx = handlers.indexOf(handler);
|
|
189
|
+
handlers.splice(idx, 1);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
afterEveryCommand(handler) {
|
|
193
|
+
this.#afterHandlers.push(handler);
|
|
194
|
+
return () => {
|
|
195
|
+
const idx = this.#afterHandlers.indexOf(handler);
|
|
196
|
+
this.#afterHandlers.splice(idx, 1);
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
dispatch(commandName, payload) {
|
|
200
|
+
if (this.#subs.has(commandName)) {
|
|
201
|
+
this.#subs.get(commandName).forEach((handler) => handler(payload));
|
|
202
|
+
} else {
|
|
203
|
+
console.warn(`No handlers for command : ${commandName}`);
|
|
204
|
+
}
|
|
205
|
+
this.#afterHandlers.forEach((handler) => handler());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createApp({ state, view, reducers = {} }) {
|
|
210
|
+
let parentEl = null;
|
|
211
|
+
let vdom = null;
|
|
212
|
+
const dispatcher = new Dispatcher();
|
|
213
|
+
const subscriptions = [dispatcher.afterEveryCommand(renderApp)];
|
|
214
|
+
function emit(eventName, payload) {
|
|
215
|
+
dispatcher.dispatch(eventName, payload);
|
|
216
|
+
}
|
|
217
|
+
for (const actionName in reducers) {
|
|
218
|
+
const reducer = reducers[actionName];
|
|
219
|
+
const subs = dispatcher.subscribe(actionName, (payload) => {
|
|
220
|
+
state = reducer(state, payload);
|
|
221
|
+
});
|
|
222
|
+
subscriptions.push(subs);
|
|
223
|
+
}
|
|
224
|
+
function renderApp() {
|
|
225
|
+
if (vdom) {
|
|
226
|
+
destroyDOM();
|
|
227
|
+
}
|
|
228
|
+
vdom = view(state, emit);
|
|
229
|
+
mountDOM(vdom, parentEl);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
mount(_parentEl) {
|
|
233
|
+
parentEl = _parentEl;
|
|
234
|
+
renderApp();
|
|
235
|
+
},
|
|
236
|
+
unmount() {
|
|
237
|
+
destroyDOM(vdom);
|
|
238
|
+
vdom = null;
|
|
239
|
+
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { createApp, h, hFragment, hString };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "philo-scratch",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "A frontend framework to teach web developers how frontend frameworks work.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"frontend",
|
|
8
|
+
"framework",
|
|
9
|
+
"book",
|
|
10
|
+
"learning",
|
|
11
|
+
"web",
|
|
12
|
+
"development"
|
|
13
|
+
],
|
|
14
|
+
"main": "dist/philo-scratch.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/philo-scratch.js"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "rollup -c",
|
|
20
|
+
"lint": "eslint src",
|
|
21
|
+
"lint:fix": "eslint src --fix",
|
|
22
|
+
"test": "vitest",
|
|
23
|
+
"test:run": "vitest run",
|
|
24
|
+
"prepack": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"eslint": "^8.47.0",
|
|
29
|
+
"jsdom": "^22.1.0",
|
|
30
|
+
"rollup": "^3.28.1",
|
|
31
|
+
"rollup-plugin-cleanup": "^3.2.1",
|
|
32
|
+
"rollup-plugin-filesize": "^10.0.0",
|
|
33
|
+
"vitest": "^0.34.3"
|
|
34
|
+
}
|
|
35
|
+
}
|