├── .gitignore ├── LICENSE ├── README.md ├── ReactScriptLoader.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | #* 3 | .#* 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Yariv Sadan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #What is it? 2 | 3 | ReactScriptLoader simplifies creating React components whose rendering depends on dynamically loaded scripts. It can be used for lazily loading heavy scripts but it's especially useful for loading components that rely on 3rd party scripts, such as Google Maps or Stripe Checkout. 4 | 5 | #Why use it? 6 | 7 | React apps are typically single-page apps that are rendered client-side in Javascript. When loading a site built with React, the browser typically pre-loads the javascript necessary to render the site's React components so that they can be rendered with no latency. This works well for sites that serve a relatively small amount of javascript from their own servers in a single bundle. However, in some situations pre-loading all the scripts necessary to render the site's components is impractial. For example, a site may have a Map component that relies on a dynamically loaded 3rd party library to render itself. It may be possible to delay rendering the app until the third party library is finished loading but doing so would make the site feel unnecessarily sluggish. It's a much better strategy to first render the page with a placeholder for the map and asynchronously render the map once the third party library has loaded. Deferring the loading of the external script is even more important when the map component isn't rendered right away but is only revealed after user interaction. 8 | 9 | #How does it work? 10 | 11 | ReactScriptLoader provides a [React mixin](http://facebook.github.io/react/docs/reusable-components.html#mixins) that you can add to any component class. In addition to using the mixin, your class should provide a few methods that tell ReactScriptLoaderMixin the script's URL and how to handle script load and error events. ReactScriptLoaderMixin handles loading the scripts, notifying the components that depend on a script after they mount and cleaning before the components unmount. ReactScriptLoader ensures that a script is only loaded once no matter how many components use it. 12 | 13 | # Examples 14 | 15 | ## Basic Example 16 | 17 | Here's the most basic example for implementing a React class that uses ReactScriptLoaderMixin. It uses require-js to import modules. 18 | 19 | ```javascript 20 | /** @jsx React.DOM */ 21 | 22 | var React = require('react'); 23 | var ReactScriptLoaderMixin = require('react-script-loader').ReactScriptLoaderMixin; 24 | 25 | var Foo = React.createClass({ 26 | mixins: [ReactScriptLoaderMixin], 27 | getInitialState: function() { 28 | return { 29 | scriptLoading: true, 30 | scriptLoadError: false, 31 | }; 32 | }, 33 | 34 | // this function tells ReactScriptLoaderMixin where to load the script from 35 | getScriptURL: function() { 36 | return 'http://d3js.org/d3.v3.min.js'; 37 | }, 38 | 39 | // ReactScriptLoaderMixin calls this function when the script has loaded 40 | // successfully. 41 | onScriptLoaded: function() { 42 | this.setState({scriptLoading: false}); 43 | }, 44 | 45 | // ReactScriptLoaderMixin calls this function when the script has failed to load. 46 | onScriptError: function() { 47 | this.setState({scriptLoading: false, scriptLoadError: true}); 48 | }, 49 | 50 | render: function() { 51 | var message; 52 | if (this.state.scriptLoading) { 53 | message = 'loading script...'; 54 | } else if (this.state.scriptLoadError) { 55 | message = 'loading failed'; 56 | } else { 57 | message = 'loading succeeded'; 58 | } 59 | return {message}; 60 | } 61 | }); 62 | ``` 63 | 64 | ## A Google Maps component 65 | 66 | You may want to do some additional initialization after the script loads and before ReactScriptLoaderMixin calls onScriptLoaded. For example, the Google maps API calls a JSONP callback on your page before you can start using the API. If you naively try calling the Google maps methods in onScriptLoaded you'll probably see 'undefined is not a function' errors in the javascript console. ReactScriptLoader helps you avoid this problem by implementing the ***deferOnScriptLoaded()*** callback in your component class. If this method returns true, ReactScriptLoaderMixin will wait on calling onScriptLoaded() until you manually call ***ReactScriptLoader.triggerOnScriptLoaded(scriptURL)*** method. This is best illustrated in the following example: 67 | 68 | ```javascript 69 | /** @jsx React.DOM */ 70 | 71 | var React = require('react.js'); 72 | 73 | var ReactScriptLoaderModule = require('react-script-loader'); 74 | var ReactScriptLoaderMixin= ReactScriptLoaderModule.ReactScriptLoaderMixin; 75 | var ReactScriptLoader= ReactScriptLoaderModule.ReactScriptLoader; 76 | 77 | var scriptURL = 'https://maps.googleapis.com/maps/api/js?v=3.exp&callback=initializeMaps'; 78 | 79 | // This function is called by the Google maps API after its initialization is 80 | // complete. 81 | // We need to define this function in the window scope to make it accessible 82 | // to the Google maps script. 83 | window.initializeMaps = function() { 84 | 85 | // This triggers the onScriptLoaded method call on all mounted Map components. 86 | ReactScriptLoader.triggerOnScriptLoaded(scriptURL); 87 | } 88 | 89 | var Map = React.createClass({ 90 | mixins: [ReactScriptLoaderMixin], 91 | getScriptURL: function() { 92 | return scriptURL; 93 | }, 94 | 95 | // Ensure that onScriptLoaded is deferred until the 96 | // ReactScriptLoader.triggerOnScriptLoaded() call above is made in 97 | // initializeMaps(). 98 | deferOnScriptLoaded: function() { 99 | return true; 100 | }, 101 | 102 | onScriptLoaded: function() { 103 | // Render a map with the center point given by the component's lat and lng 104 | // properties. 105 | var center = new google.maps.LatLng(this.props.lat, this.props.lng); 106 | var mapOptions = { 107 | zoom: 12, 108 | center: center, 109 | disableDefaultUI: true, 110 | draggable: false, 111 | zoomControl: false, 112 | scrollwheel: false, 113 | disableDoubleClickZoom: true, 114 | }; 115 | var map = new google.maps.Map(this.getDOMNode(), mapOptions); 116 | }, 117 | onScriptError: function() { 118 | // Show the user an error message. 119 | }, 120 | render: function() { 121 | return
; 122 | }, 123 | 124 | }); 125 | 126 | exports.Map = Map; 127 | ``` 128 | 129 | ## A Stripe Checkout example 130 | 131 | This last example shows how to create a component called StripeButton that renders a button and uses Stripe Checkout to pop up a payment dialog when the user clicks on it. The button is rendered immediately but if the user clicks before the script is loaded the user sees a loading indicator, which disappears when the script has loaded. (Additional logic should be added to remove the loading dialog once all StripeButtons have been unmounted from the page. This remains an exercise for the reader :) ) If the script fails to load, we show the user an error message when the user clicks on the button. 132 | 133 | ```javascript 134 | /** @jsx React.DOM */ 135 | 136 | var React = require('react'); 137 | var ReactScriptLoaderMixin = require('react-script-loader').ReactScriptLoaderMixin; 138 | 139 | var StripeButton = React.createClass({ 140 | mixins: [ReactScriptLoaderMixin], 141 | getScriptURL: function() { 142 | return 'https://checkout.stripe.com/checkout.js'; 143 | }, 144 | 145 | statics: { 146 | stripeHandler: null, 147 | scriptDidError: false, 148 | }, 149 | 150 | // Indicates if the user has clicked on the button before the 151 | // the script has loaded. 152 | hasPendingClick: false, 153 | 154 | onScriptLoaded: function() { 155 | // Initialize the Stripe handler on the first onScriptLoaded call. 156 | // This handler is shared by all StripeButtons on the page. 157 | if (!StripeButton.stripeHandler) { 158 | StripeButton.stripeHandler = StripeCheckout.configure({ 159 | key: 'YOUR_STRIPE_KEY', 160 | image: '/YOUR_LOGO_IMAGE.png', 161 | token: function(token) { 162 | // Use the token to create the charge with a server-side script. 163 | } 164 | }); 165 | if (this.hasPendingClick) { 166 | this.showStripeDialog(); 167 | } 168 | } 169 | }, 170 | showLoadingDialog: function() { 171 | // show a loading dialog 172 | }, 173 | hideLoadingDialog: function() { 174 | // hide the loading dialog 175 | }, 176 | showStripeDialog: function() { 177 | this.hideLoadingDialog(); 178 | StripeButton.stripeHandler.open({ 179 | name: 'Demo Site', 180 | description: '2 widgets ($20.00)', 181 | amount: 2000 182 | }); 183 | }, 184 | onScriptError: function() { 185 | this.hideLoadingDialog(); 186 | StripeButton.scriptDidError = true; 187 | }, 188 | onClick: function() { 189 | if (StripeButton.scriptDidError) { 190 | console.log('failed to load script'); 191 | } else if (StripeButton.stripeHandler) { 192 | this.showStripeDialog(); 193 | } else { 194 | this.showLoadingDialog(); 195 | this.hasPendingClick = true; 196 | } 197 | }, 198 | render: function() { 199 | return ( 200 | 201 | ); 202 | } 203 | }); 204 | 205 | exports.StripeButton = StripeButton; 206 | ``` 207 | -------------------------------------------------------------------------------- /ReactScriptLoader.js: -------------------------------------------------------------------------------- 1 | 2 | // A dictionary mapping script URLs to a dictionary mapping 3 | // component key to component for all components that are waiting 4 | // for the script to load. 5 | var scriptObservers = {}; 6 | 7 | // A dictionary mapping script URL to a boolean value indicating if the script 8 | // has already been loaded. 9 | var loadedScripts = {}; 10 | 11 | // A dictionary mapping script URL to a boolean value indicating if the script 12 | // has failed to load. 13 | var erroredScripts = {}; 14 | 15 | // A counter used to generate a unique id for each component that uses 16 | // ScriptLoaderMixin. 17 | var idCount = 0; 18 | 19 | var ReactScriptLoader = { 20 | componentDidMount: function(key, component, scriptURL) { 21 | if (typeof component.onScriptLoaded !== 'function') { 22 | throw new Error('ScriptLoader: Component class must implement onScriptLoaded()'); 23 | } 24 | if (typeof component.onScriptError !== 'function') { 25 | throw new Error('ScriptLoader: Component class must implement onScriptError()'); 26 | } 27 | if (loadedScripts[scriptURL]) { 28 | component.onScriptLoaded(); 29 | return; 30 | } 31 | if (erroredScripts[scriptURL]) { 32 | component.onScriptError(); 33 | return; 34 | } 35 | 36 | // If the script is loading, add the component to the script's observers 37 | // and return. Otherwise, initialize the script's observers with the component 38 | // and start loading the script. 39 | if (scriptObservers[scriptURL]) { 40 | scriptObservers[scriptURL][key] = component; 41 | return; 42 | } 43 | 44 | var observers = {}; 45 | observers[key] = component; 46 | scriptObservers[scriptURL] = observers; 47 | 48 | var script = document.createElement('script'); 49 | 50 | if (typeof component.onScriptTagCreated === 'function') { 51 | component.onScriptTagCreated(script); 52 | } 53 | 54 | script.src = scriptURL; 55 | script.async = 1; 56 | 57 | var callObserverFuncAndRemoveObserver = function(func) { 58 | var observers = scriptObservers[scriptURL]; 59 | for (var key in observers) { 60 | var observer = observers[key]; 61 | var removeObserver = func(observer); 62 | if (removeObserver) { 63 | delete scriptObservers[scriptURL][key]; 64 | } 65 | } 66 | //delete scriptObservers[scriptURL]; 67 | } 68 | script.onload = function() { 69 | loadedScripts[scriptURL] = true; 70 | callObserverFuncAndRemoveObserver(function(observer) { 71 | if (observer.deferOnScriptLoaded && observer.deferOnScriptLoaded()) { 72 | return false; 73 | } 74 | observer.onScriptLoaded(); 75 | return true; 76 | }); 77 | }; 78 | script.onreadystatechange = function () { 79 | if (this.readyState == 'complete' || this.readyState == 'loaded') { 80 | script.onload(); 81 | } 82 | }; 83 | script.onerror = function(event) { 84 | erroredScripts[scriptURL] = true; 85 | callObserverFuncAndRemoveObserver(function(observer) { 86 | observer.onScriptError(); 87 | return true; 88 | }); 89 | }; 90 | // (old) MSIE browsers may call 'onreadystatechange' instead of 'onload' 91 | script.onreadystatechange = function() { 92 | if (this.readyState == 'loaded') { 93 | // wait for other events, then call onload if default onload hadn't been called 94 | window.setTimeout(function() { 95 | if (loadedScripts[scriptURL] !== true) script.onload(); 96 | }, 0); 97 | } 98 | }; 99 | 100 | document.body.appendChild(script); 101 | }, 102 | componentWillUnmount: function(key, scriptURL) { 103 | // If the component is waiting for the script to load, remove the 104 | // component from the script's observers before unmounting the component. 105 | var observers = scriptObservers[scriptURL]; 106 | if (observers) { 107 | delete observers[key]; 108 | } 109 | }, 110 | triggerOnScriptLoaded: function(scriptURL) { 111 | if (!loadedScripts[scriptURL]) { 112 | throw new Error('Error: only call this function after the script has in fact loaded.'); 113 | } 114 | var observers = scriptObservers[scriptURL]; 115 | for (var key in observers) { 116 | var observer = observers[key]; 117 | observer.onScriptLoaded(); 118 | } 119 | delete scriptObservers[scriptURL]; 120 | } 121 | }; 122 | 123 | var ReactScriptLoaderMixin = { 124 | componentDidMount: function() { 125 | if (typeof this.getScriptURL !== 'function') { 126 | throw new Error("ScriptLoaderMixin: Component class must implement getScriptURL().") 127 | } 128 | ReactScriptLoader.componentDidMount(this.__getScriptLoaderID(), this, this.getScriptURL()); 129 | }, 130 | componentWillUnmount: function() { 131 | ReactScriptLoader.componentWillUnmount(this.__getScriptLoaderID(), this.getScriptURL()); 132 | }, 133 | __getScriptLoaderID: function() { 134 | if (typeof this.__reactScriptLoaderID === 'undefined') { 135 | this.__reactScriptLoaderID = 'id' + idCount++; 136 | } 137 | 138 | return this.__reactScriptLoaderID; 139 | }, 140 | }; 141 | 142 | exports.ReactScriptLoaderMixin = ReactScriptLoaderMixin; 143 | exports.ReactScriptLoader = ReactScriptLoader; 144 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-script-loader", 3 | "version": "0.0.1", 4 | "main": "ReactScriptLoader.js", 5 | "engines": { 6 | "node": ">=0.10.x" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/yariv/ReactScriptLoader.git" 11 | }, 12 | "license": "MIT", 13 | "author": { 14 | "name": "Yariv Sadan" 15 | } 16 | } 17 | --------------------------------------------------------------------------------