UNPKG

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