UNPKG

14 kBJavaScriptView Raw
1import { getType, EMPTY_OBJECT, EMPTY_ARRAY, merge, areEqual } from './utils'
2import { patch } from './vdom'
3import { mount } from './mount'
4import { vnodeFromElement } from './vnode'
5
6/**
7 * This is a numeric value derived from the Date object used as a key to create a pseudo-private property in the Component class for holding state.
8 * @type {string} dataStore A hex value to use as pseudo-private key to store the component's state.
9 */
10const dataStore = new Date().getTime().toString(16)
11
12/**
13 * Component can be instantiated with the new keyword, or extended to create a custom version of the class.
14 * @class Class to create a component.
15 *
16 * @example
17 *
18 * ```
19 * // Extending Component class:
20 * class UserList extends Component {
21 * constructor(props) {
22 * super(props)
23 * this.state = users
24 * this.container = 'section'
25 * }
26 * render(users) {
27 * return (
28 * <ul class='user-list'>
29 * {
30 * users.map(user => <li>{user.name}</li>)
31 * }
32 * </ul>
33 * )
34 * }
35 * }
36 ```
37 */
38export class Component {
39 /**
40 * Constructor for Component class.
41 * @property {state} [props.state] The state object of the component. This can be of type boolean, string, number, object or array.
42 * @property {string} selector A CSS selector describing the DOM container in which to render the component.
43 * @property {HTMLElement} container The DOM node in which the component is rendered.
44 * @property {boolean} componentShouldUpdate A flag to determine whether a component can render or not. Setting this to false allows you to maipulate a component's state without triggering and automatic render. After setting to true, you may need to execute `update()` on a component instance to force render it.
45 * @property {boolean} isMounted A boolean flag that tracks whether a component has been mounted in the DOM or not. This is used internally by Composi, do not touch!
46 * @property {Node} element The root or base element of a component's DOM tree. You can use it to register events or as the basis of a component-specific DOM query.
47 * @method componentWillMount A callback that is called before a component is mounted in the DOM.
48 * @method componentDidMount A callback that is called after a component is mounted in the DOM. Use this to register events, query the component DOM, etc.
49 * @method componentWillUpdate A callback that is called before a component is updated. This is not called the first time a component is rendered.
50 * @method componentDidUpdate A callback that is called after a component is updated. This is not called the first time a component is rendered.
51 * @method componentWillUnmount A callback that is called before a component is unmounted from the DOM. Use this for any environmental cleanup.
52 * @method render A method that returns nodes to render to the DOM.¸
53 * @method update A method that renders the component template with provided data to the DOM. Data may be provided directly as the primary argument, or it can be derived from the component's state. Data provided as an argument will override use of component state.
54 * @method unmount A method to unmount a component from the DOM. This deletes the DOM tree structure starting from the component's base element, and sets the component instance properties to null.
55 * @constructs Component
56 */
57 constructor(props) {
58 if (!props) props = {}
59 /**
60 * @property {Object} props An object literal of options passed to the class constructor during initialization.
61 */
62 this.props = props
63 /**
64 * @property {string | HTMLElement} container The HTML element in which the component gets rendered. This can be a CSS selector describing the container or a DOM node reference.
65 */
66 this.selector = props.container || 'body'
67
68 if (props.render) {
69 /**
70 * @property {Function} render A method to convert markup into DOM nodes to inject in the document. The method itself gets provided at init time by a function provided by the user as an argument, or in the case of extending, a method defined directly on the class extension.
71 */
72 this.render = props.render
73 }
74
75 if (props.state) {
76 /**
77 * @property {boolean | number | string | Object | any[]}
78 */
79 this.state = props.state
80 }
81
82 if (this.selector) {
83 /**
84 * @property {HTMLElement} container The HTML element in which the component gets rendered.
85 */
86 this.container = document.querySelector(this.selector)
87 }
88
89 /**
90 * @typedef {import('./vnode').VNode} VNode
91 * @type {VNode}
92 */
93 this.currentVNode = null
94
95 /**
96 * @property {boolean} componentShouldUpdate Determines whether a component should update. Set `componentShouldUpdate` to `false`, make changes, then set `componentShouldUpdate` to `true` and update component with `update` method.
97 */
98 this.componentShouldUpdate = true
99
100 /**
101 * @property {boolean} isMounted Indicates whether a component is mounted in the DOM or not. This is used internally, so do not change!
102 */
103 this.isMounted = false
104
105 /**
106 * @property {HTMLElement} element The base element of the rendered component. You can use component as the base for comopnent instance specific DOM queries or event registration.
107 */
108 this.element = null
109
110 /**
111 * @property {VNode} this.hydrate
112 */
113 this.hydrate = props.hydrate || null
114
115 if (props.componentWillMount)
116 /**
117 * @property {() => void} componentWillMount A method to execute before the component mounts. The callback gets a reference to the component instance as its argument.
118 * @return {void} undefined
119 */
120 this.componentWillMount = props.componentWillMount
121
122 if (props.componentDidMount)
123 /**
124 * @property {() => void} componentDidMount A method to execute after the component mounts.
125 * @return {void} undefined
126 */
127 this.componentDidMount = props.componentDidMount
128
129 if (props.componentWillUpdate)
130 /**
131 * @property {() => void} componentWillUpdate A method to execute before the component updates. The callback gets a reference to the component instance as its argument.
132 * @return {void} undefined
133 */
134 this.componentWillUpdate = props.componentWillUpdate
135
136 if (props.componentDidUpdate)
137 /**
138 * @property {() => void} componentDidUpdate -A method to execute after the component updates. The callback gets a reference to the component instance as its argument.
139 * @return {void} undefined
140 */
141 this.componentDidUpdate = props.componentDidUpdate
142
143 if (props.componentWillUnmount)
144 /**
145 * @property {() => void} componentWillUnmount A method to execute before the component unmounts. The callback gets a reference to the component instance as its argument.
146 * @return {void} undefined
147 */
148 this.componentWillUnmount = props.componentWillUnmount
149 }
150
151 /**
152 * Start type stubs for Component methods.
153 */
154 noop() {}
155
156 /**
157 * @method A method to execute before the component mounts.
158 * @param {() => void} [cb] A callback to execute.
159 * @return {void} undefined
160 */
161 componentWillMount(cb) {
162 if (cb && typeof cb === 'function') {
163 cb.call(cb, this)
164 }
165 }
166
167 /**
168 * @method A method to execute after the component mounts.
169 * @return {void} undefined
170 */
171 componentDidMount() {
172 this.noop()
173 }
174
175 /**
176 * @method A method to execute before the component updates.
177 * @param {() => void} [cb] A callback to execute.
178 * @return {void} undefined
179 */
180 componentWillUpdate(cb) {
181 if (cb && typeof cb === 'function') {
182 cb.call(cb, this)
183 }
184 }
185
186 /**
187 * @method A method to execute after the component updates.
188 * @return {void} undefined
189 */
190 componentDidUpdate() {
191 this.noop()
192 }
193
194 /**
195 * @method A method to execute after the component updates.
196 * @param {() => void} [cb] A callback to execute.
197 * @return {void} undefined
198 */
199 componentWillUnmount(cb) {
200 if (cb && typeof cb === 'function') {
201 cb.call(cb, this)
202 }
203 }
204
205 /**
206 * @method A method to create a virtual node from data and markup. The returned virtual node will get converted into a node that gets injected in the DOM.
207 * @param {*} data
208 */
209 render(data) {
210 return data
211 }
212 /** End of type stubs */
213
214 /**
215 * @method This is a getter to access the component's state using the pseudo-private key dataStore.
216 * @return {boolean | number | string | Object | any[]} The component's state
217 */
218 get state() {
219 return this[dataStore]
220 }
221
222 /**
223 * @method This is a setter to define the component's state. It uses the dataStore object as a pseudo-private key. It uses requestAnimationFrame to throttle component updates to avoid layout thrashing.
224 * @param {string | number | boolean | Object | any[]} data Data to set as component state.
225 * @return {void} undefined
226 */
227 set state(data) {
228 this[dataStore] = data
229 setTimeout(() => this.update(), 1000 / 60)
230 }
231
232 /**
233 * @method Method to set a component's state. This accepts simple types or Objects. If updating an array, you can pass in the data and the position (number) in the array to update. Optionally you can pass a callback, which receives the state as its argument. You need to return the state changes in order to update the component's state.
234 * @example Set state on component:
235 *
236 * ```
237 * this.setState(true)
238 * this.setState(0)
239 * this.setState({name: 'Joe'})
240 * this.setState([1,2,3])
241 * this.setState(prevState => prevState + 1)
242 ```
243 * @param {string | number | boolean | Object | any[] | Function} data The data to set. If a callback is passed as the argument to execute, it gets passed the previous state as its argument. You need to make sure the callback returns the final state or the component will not update.
244 * @return {void} undefined
245 */
246 setState(data) {
247 if (typeof data === 'function') {
248 let copyOfState
249 if (getType(this.state) === 'Object') {
250 copyOfState = merge(EMPTY_OBJECT, this.state)
251 } else if (Array.isArray(this.state)) {
252 copyOfState = EMPTY_ARRAY.concat(EMPTY_ARRAY, this.state)
253 } else {
254 copyOfState = this.state
255 }
256 const newState = data.call(this, copyOfState)
257 if (newState) this.state = newState
258 } else if (getType(this.state) === 'Object' && getType(data) === 'Object') {
259 const newState = merge(this.state, data)
260 this.state = newState
261 } else {
262 this.state = data
263 }
264 }
265
266 /**
267 * @method Function to render component after data changes.
268 * If data is passed as argument, it will be used.
269 * Otherwise state will be used.
270 * @param {boolean | number | string | Object | any[]} [data] By default, data will be the component's current state, otherwise, if data is provided as an argument, that will be used, overriding the state.
271 * @return {void} undefined
272 */
273 update(data) {
274 if (!this.render) return
275
276 // If componentShouldUpdate is set to false,
277 // render one time only.
278 // All other updates will be ignored.
279 if (!this.componentShouldUpdate && this.isMounted) return
280
281 // If data is 0 or non-boolean, use,
282 // else use component state.
283 let __data = this.state
284 if (data !== true && data) __data = data
285
286 if (this.container && typeof this.container === 'string') {
287 this.selector = this.container
288 this.container = document.querySelector(this.container)
289 }
290
291 if (this.hydrate && !this.isMounted) {
292 if (typeof this.hydrate === 'string') {
293 this.hydrate = document.querySelector(this.hydrate)
294 }
295 this.currentVNode = vnodeFromElement(this.hydrate)
296 }
297 /**
298 * @typedef {import('./vnode').VNode} VNode
299 * @type {VNode | null}
300 */
301 const vdom = this.render(__data)
302
303 const self = this
304 function doneUpdating() {
305 self.oldVNode = self.render(__data)
306 self.currentVNode = patch(vdom, self.currentVNode, self.container)
307 self.element = self.currentVNode.element
308 self.isMounted = true
309 self.componentDidUpdate()
310 }
311
312 function doneMounting() {
313 self.oldVNode = vdom
314 self.currentVNode = mount(vdom, self.container)
315
316 self.element = self.currentVNode.element
317 self.isMounted = true
318 self.componentDidMount()
319 }
320
321 if (!this.isMounted) {
322 // First render, so mount component:
323 if (!this.currentVNode) {
324 this.componentWillMount(doneMounting)
325 } else {
326 // Not mounted, but has vnode, so hydrate DOM element:
327 this.oldVNode = vdom
328 this.currentVNode = patch(vdom, this.currentVNode, this.container)
329 this.element = this.currentVNode.element
330 this.isMounted = true
331 this.componentDidMount()
332 }
333
334 // If container for component is invalid,
335 // log error and exit:
336 if (!this.container || this.container.nodeType !== 1) {
337 console.error(
338 'The container for this class component is not a valid DOM node. Check the selector provided for the class to make sure it is a valid CSS selector and that the container exists in the DOM. You might be targeting a nonexistent node.'
339 )
340 }
341 return
342 } else {
343 // The vnodes are identical, so exit:
344 if (areEqual(vdom, this.oldVNode)) {
345 return
346 } else {
347 // Update mounted component:
348 this.componentWillUpdate(doneUpdating)
349 }
350 }
351 }
352
353 /**
354 * @method Method to destroy a component.
355 * First unbind events.
356 * Then remove component element from DOM.
357 * Also null out component properties.
358 * @return {void} undefined
359 */
360 unmount() {
361 if (!this.element) return
362 const self = this
363 function done() {
364 self.container.removeChild(self.element)
365 for (let key in self) {
366 delete self[key]
367 }
368 self['__proto__'] = null
369 }
370
371 this.componentWillUnmount(done)
372 }
373}