1 | import { getType, EMPTY_OBJECT, EMPTY_ARRAY, merge, areEqual } from './utils'
|
2 | import { patch } from './vdom'
|
3 | import { mount } from './mount'
|
4 | import { 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 | */
|
10 | const 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 | */
|
38 | export 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 | }
|