UNPKG

9.47 kBJavaScriptView Raw
1import {h} from './h'
2import {patch} from './patch'
3import {mixin} from './utils/mixin'
4
5/**
6 * @description A cross-browser normalization/polyfill for requestAnimationFrame.
7 */
8const rAF = window && window.requestAnimationFrame
9 || window && window.webkitRequestAnimationFrame
10 || window && window.mozRequestAnimationFrame
11 || window && window.msRequestAnimationFrame
12 || function(cb) { return setTimeout(cb, 16) }
13
14/**
15 * @description This is a Time Object used as a key to create a pseudo-private property in the Component class for holding state.
16 * @type {Object} dataStore A Date object to use as pseudo-private key to store the component's state.
17 */
18const dataStore = new Date().getTime()
19
20/**
21 * @description Component can be instantiated with the new keyword, or extended to create a custom version of the class.
22 * @class Class to create a component.
23 * @example New instance of Component class:
24 * const title = new Component({
25 * container: 'header',
26 * state: 'World',
27 * render: message => <h1>Hello, {message}!</h1>
28 * })
29 * @example Extending Component class:
30 * class UserList extends Component {
31 * constructor(props) {
32 * super(props)
33 * this.state = users
34 * this.container = 'section'
35 * }
36 * render(users) {
37 * return (
38 * <ul class='user-list'>
39 * {
40 * users.map(user => <li>{user.name}</li>)
41 * }
42 * </ul>
43 * )
44 * }
45 * }
46 */
47export class Component {
48 /**
49 * @description Constructor for Component class.
50 *
51 * Possible values to pass to new Component are:
52 * 1. container - element to render component in.
53 * 2. state - data for the component to consume.
54 * 2. render - a function to return markup to create.
55 * 3. componentWillMount - a callback to execute before the component is mount.
56 * 4. componentDidMount - a callback to execute after the component mounts.
57 * 5. componentWillUpdate - a callback to execute before the component updates.
58 * 6. componentDidUpdate - a callback to execute after the component updates.
59 * 7. componentWillUnmount - a callback to execute before the component unmounts.
60 *
61 * @typedef {object} props An object of property/values to configure the class instance.
62 * @property {string|element} props.container The container element in which to render the component.
63 * @property {state} [props.state] The state object of the component. This can be of type boolean, string, number, object or array.
64 * @property {function} props.render A function that returns nodes to render to the DOM.
65 * @constructs Component
66 */
67 constructor(props) {
68 if (!props) props = {}
69 /** @property {string} */
70 this.selector = props.container || 'body'
71
72 if (props.render) {
73 /** @property {Function} */
74 this.render = props.render
75 }
76
77 if (props.state) {
78 /** @property {boolean|number|string|object|array} */
79 this.state = props.state
80 }
81
82 /** @property {null, Object} */
83 this.oldNode = null
84 if (this.selector) {
85 /** @property {HTMLElement} */
86 this.container = document.querySelector(this.selector)
87 }
88
89 /** @property {boolean} */
90 this.componentShouldUpdate = true
91
92 /** @property {boolean} */
93 this.mounted = false
94
95 /**
96 * @property {HTMLElement|undefined}
97 * @default {undefined}
98 */
99 this.element
100
101 /**
102 * @description Handle lifecycle hooks.
103 */
104 if (props.componentWillMount)
105 this.componentWillMount = props.componentWillMount
106
107 if (props.componentDidMount)
108 this.componentDidMount = props.componentDidMount
109
110 if (props.componentWillUpdate)
111 this.componentWillUpdate = props.componentWillUpdate
112
113 if (props.componentDidUpdate)
114 this.componentDidUpdate = props.componentDidUpdate
115
116 if (props.componentWillUnmount)
117 this.componentWillUnmount = props.componentWillUnmount
118 }
119
120 /**
121 * @description This is getter to access the component's state using the pseudo-private key dataStore.
122 * @returns {boolean|number|string|object|any[]} The component's state
123 */
124 get state() {
125 return this[dataStore]
126 }
127
128 /**
129 * @description 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.
130 * @param {string|number|boolean|object|array} data Data to set as component state.
131 * @returns {undefined} void
132 */
133 set state(data) {
134 this[dataStore] = data
135 rAF(() => this.update())
136 }
137
138 /**
139 * @description 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. This receives the state as its argument. You need to return the state changes in order to update the component's state.
140 * @example
141 * this.setState(true)
142 * this.setState(0)
143 * this.setState({name: 'Joe'})
144 * this.setState([1,2,3])
145 * this.setState(prevState => prevState + 1)
146 * @property Component#setState
147 * @param {string|number|boolean|object|array|Function} data - The data to set.
148 * @param {number} [position] The index of an array whose data you want to set.
149 * @returns {undefined} void
150 */
151 setState(data, position) {
152 if (typeof data === 'function') {
153 const state = data.call(this, this.state)
154 if (typeof state !== 'function' && !!state) this.setState(state)
155 } else if (Array.isArray(this.state)) {
156 const state = this.state
157 if (position || position === 0) {
158 if (typeof state[position] === 'object') {
159 this.state = mixin(state[position], data)
160 } else {
161 state[position] = data
162 this.state = state
163 }
164 } else {
165 this.state = state
166 }
167 } else if (typeof this.state === 'object') {
168 const state = this.state
169 this.state = mixin(state, data)
170 } else {
171 this.state = data
172 }
173 }
174
175 /**
176 * @description Function to render component after data changes.
177 * If data is passed as argument, it will be used.
178 * Otherwise state will be used.
179 * @property Component#update
180 * @param {boolean|number|string|object|array} [data]
181 * @returns {undefined} void
182 */
183 update(data) {
184 if (!this.render) return
185
186 // If componentShouldUpdate is set to false, render one time only.
187 // All other updates will be ignored.
188 if (!this.componentShouldUpdate && this.mounted) return
189
190 // If data is 0 or non-boolean, use,
191 // else use component state.
192 let __data = this.state
193 if (data !== true && data) __data = data
194
195 if (this.container && typeof this.container === 'string') {
196 this.selector = this.container
197 this.container = document.querySelector(this.container)
198 }
199
200 // Check if vnode already exists.
201 // Used for deciding whether to fire lifecycle events.
202 const __oldNode = this.oldNode
203 const __render = this.render
204 function testIfVNodesDiffer(oldNode, data) {
205 if (this && JSON.stringify(oldNode) === JSON.stringify(__render(data))) {
206 return false
207 } else {
208 return true
209 }
210 }
211
212 // Create virtual dom and check if component id
213 // already exists in document.
214 const vdom = this.render(__data)
215 let elem
216 if (
217 vdom
218 && vdom.props
219 && vdom.props.id
220 && this.container
221 ) {
222 elem = this.container && this.container.querySelector(`#${vdom.props.id}`)
223 }
224
225 // If component element id already exists in DOM,
226 // remove it before rendering the component.
227 if (elem && !this.mounted) {
228 elem.parentNode.removeChild(elem)
229 }
230
231 // Patch DOM with component update.
232 this.oldNode = this.render(__data)
233 this.element = patch(
234 this.oldNode,
235 this.element
236 )
237 if (!this.mounted) {
238 this.componentWillMount && this.componentWillMount(this)
239 this.container.appendChild(this.element)
240 this.mounted = true
241 this.componentDidMount && this.componentDidMount(this)
242 return
243 }
244
245 if (this.mounted && this.oldNode && testIfVNodesDiffer(__oldNode, __data)) {
246 this.componentWillUpdate && this.componentWillUpdate(this)
247 }
248 this.componentDidUpdate && testIfVNodesDiffer(__oldNode, __data) && this.componentDidUpdate(this)
249 }
250
251 /**
252 * @description Method to destroy component.
253 * First unbind events.
254 * Then remove component element from DOM.
255 * Also null out component properties.
256 * @property Component#unmount
257 * @returns {undefined} void
258 */
259 unmount() {
260 const self = this
261 const eventWhitelist = [
262 'change',
263 'click',
264 'dblclick',
265 'input',
266 'keydown',
267 'keypress',
268 'keyup',
269 'mousedown',
270 'mouseleave',
271 'mouseout',
272 'mouseover',
273 'mouseup',
274 'pointercancel',
275 'pointerdown',
276 'pointermove',
277 'pointerup',
278 'select',
279 'submit',
280 'touchcancel',
281 'touchend',
282 'touchmove',
283 'touchstart'
284 ]
285 if (!this.element) return
286 this.componentWillUnmount && this.componentWillUnmount(this)
287 eventWhitelist.map(event => {
288 this.element.removeEventListener(event, this)
289 })
290 this.container.removeChild(this.element)
291 this.container = undefined
292 for (let key in this) {
293 delete this[key]
294 }
295 delete this.state
296 this.update = undefined
297 this.unmount = undefined
298 }
299}