├── .eslintignore
├── .npmignore
├── .travis.yml
├── .babelrc
├── tests.webpack.js
├── .gitignore
├── LICENSE
├── package.json
├── karma.conf.js
├── test
├── index.spec.js
└── utils.spec.js
├── src
├── utils.js
└── index.js
└── README.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | lib
2 | node_modules
3 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | src
3 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 'iojs'
4 | - '0.12'
5 | - '0.10'
6 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [ "stage-0", "es2015", "react" ],
3 | "plugins": [ "transform-class-properties" ]
4 | }
5 |
--------------------------------------------------------------------------------
/tests.webpack.js:
--------------------------------------------------------------------------------
1 | // require all modules ending in ".spec.js" from the
2 | // current directory and all subdirectories
3 | var testsContext = require.context("./test", true, /\.spec\.js$/)
4 | testsContext.keys().forEach(testsContext)
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 |
16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 |
19 | # node-waf configuration
20 | .lock-wscript
21 |
22 | # Compiled binary addons (http://nodejs.org/api/addons.html)
23 | build/Release
24 |
25 | # Dependency directory
26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
27 | node_modules
28 | lib
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 高振东
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.
22 |
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-async-script-loader",
3 | "version": "0.2.3",
4 | "description": "A decorator for script lazy loading on react component",
5 | "main": "lib/index.js",
6 | "scripts": {
7 | "build": "babel src --out-dir lib",
8 | "prepublish": "npm run build",
9 | "test": "karma start"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/leozdgao/react-script-loader.git"
14 | },
15 | "keywords": [
16 | "react",
17 | "reactjs",
18 | "react-component"
19 | ],
20 | "author": "leozdgao",
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/leozdgao/react-script-loader/issues"
24 | },
25 | "homepage": "https://github.com/leozdgao/react-script-loader#readme",
26 | "peerDependencies": {
27 | "react": "^15.0.1 || ^16.0.0"
28 | },
29 | "devDependencies": {
30 | "babel-cli": "^6.1.18",
31 | "babel-eslint": "^4.1.5",
32 | "babel-loader": "~6.2.4",
33 | "babel-plugin-syntax-class-properties": "^6.1.18",
34 | "babel-plugin-transform-class-properties": "^6.1.20",
35 | "babel-preset-es2015": "^6.1.18",
36 | "babel-preset-react": "^6.1.18",
37 | "babel-preset-stage-0": "^6.1.18",
38 | "chai": "~3.5.0",
39 | "eslint": "^1.9.0",
40 | "karma": "~0.13.22",
41 | "karma-chai": "~0.1.0",
42 | "karma-mocha": "~0.2.2",
43 | "karma-mocha-reporter": "~2.0.0",
44 | "karma-phantomjs-launcher": "~1.0.0",
45 | "karma-sourcemap-loader": "~0.3.7",
46 | "karma-webpack": "~1.7.0",
47 | "mocha": "~2.4.5",
48 | "phantomjs-prebuilt": "~2.1.7",
49 | "prop-types": "~15.5.8",
50 | "react": "^15.0.1",
51 | "react-addons-test-utils": "^15.0.1",
52 | "react-dom": "^15.0.1",
53 | "webpack": "~1.12.14"
54 | },
55 | "dependencies": {
56 | "hoist-non-react-statics": "^1.0.3"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 |
3 | const webpack = require('webpack')
4 |
5 | module.exports = function (config) {
6 | config.set({
7 |
8 | // base path that will be used to resolve all patterns (eg. files, exclude)
9 | basePath: './',
10 |
11 |
12 | // frameworks to use
13 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
14 | frameworks: [ 'mocha', 'chai' ],
15 |
16 |
17 | // list of files / patterns to load in the browser
18 | files: [
19 | 'tests.webpack.js'
20 | ],
21 |
22 | // list of files to exclude
23 | exclude: [
24 | ],
25 |
26 |
27 | // preprocess matching files before serving them to the browser
28 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
29 | preprocessors: {
30 | 'tests.webpack.js': [ 'webpack', 'sourcemap' ]
31 | },
32 |
33 | webpack: {
34 | devtool: 'inline-source-map',
35 | module: {
36 | loaders: [
37 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' }
38 | ]
39 | },
40 | plugins: [
41 | new webpack.DefinePlugin({
42 | 'process.env.NODE_ENV': JSON.stringify('test')
43 | })
44 | ]
45 | },
46 |
47 | webpackMiddleware: {
48 | noInfo: true
49 | },
50 |
51 | // test results reporter to use
52 | // possible values: 'dots', 'progress'
53 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
54 | reporters: [ 'mocha' ],
55 |
56 | // web server port
57 | port: 9876,
58 |
59 |
60 | // enable / disable colors in the output (reporters and logs)
61 | colors: true,
62 |
63 |
64 | // level of logging
65 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
66 | logLevel: config.LOG_INFO,
67 |
68 |
69 | // enable / disable watching file and executing tests whenever any file changes
70 | autoWatch: false,
71 |
72 |
73 | // start these browsers
74 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
75 | browsers: [ 'PhantomJS' ],
76 |
77 |
78 | // Continuous Integration mode
79 | // if true, Karma captures browsers, runs the tests and exits
80 | singleRun: true,
81 |
82 | // Concurrency level
83 | // how many browser should be started simultaneous
84 | concurrency: Infinity
85 | })
86 | }
87 |
--------------------------------------------------------------------------------
/test/index.spec.js:
--------------------------------------------------------------------------------
1 | const ReactTestUtils = require('react-dom/test-utils')
2 | const React = require('react')
3 | const AsyncScriptLoader = require('../src').default
4 |
5 | class TestComponent extends React.Component {
6 | render () {
7 | return
8 | }
9 | }
10 |
11 | function renderTestComponent (deps, onScriptLoaded) {
12 | const MockedComponent = AsyncScriptLoader.apply(null, deps)(TestComponent)
13 | const result = ReactTestUtils.renderIntoDocument()
14 |
15 | return ReactTestUtils.findRenderedComponentWithType(result, TestComponent)
16 | }
17 |
18 | function checkScriptLoaded (getComponent, done) {
19 | return _ => {
20 | const com = getComponent()
21 |
22 | expect(com.props.isScriptLoaded).to.be.true
23 | expect(com.props.isScriptLoadSucceed).to.be.true
24 |
25 | done()
26 | }
27 | }
28 |
29 | describe('Test this module', _ => {
30 | it('[react-async-script-loader] Load external script after component mounted',
31 | function (done) {
32 | const deps = [ 'https://ajax.googleapis.com/ajax/libs/jquery/3.0.0/jquery.min.js' ]
33 | const com = renderTestComponent(deps, onScriptLoaded)
34 |
35 | this.timeout(5000)
36 |
37 | // check script tags
38 | deps.forEach(testScript => {
39 | const tag = document.querySelector(`script[src='${testScript}']`)
40 | expect(tag).to.be.exist
41 | })
42 |
43 | // check component props before loading
44 | expect(com.props.isScriptLoaded).to.be.false
45 | expect(com.props.isScriptLoadSucceed).to.be.false
46 |
47 | function onScriptLoaded () {
48 | expect(com.props.isScriptLoaded).to.be.true
49 | expect(com.props.isScriptLoadSucceed).to.be.true
50 |
51 | done()
52 | }
53 | }
54 | )
55 |
56 | it('[react-async-script-loader] No redundant script tag will be appended',
57 | function (done) {
58 | const deps = [ '//cdn.bootcss.com/jquery/2.1.1/jquery.min.js' ]
59 | const com0 = renderTestComponent(deps, checkScriptLoaded(_ => com0, checkAllDone))
60 | const com1 = renderTestComponent(deps, checkScriptLoaded(_ => com1, checkAllDone))
61 | let count = 0
62 |
63 | this.timeout(5000)
64 |
65 | // check script tags
66 | deps.forEach(testScript => {
67 | const tags = document.querySelectorAll(`script[src='${testScript}']`)
68 | expect(tags.length).to.equal(1)
69 | })
70 |
71 | function checkAllDone () {
72 | count ++
73 | if (count == 2) done()
74 | }
75 | }
76 | )
77 | })
78 |
--------------------------------------------------------------------------------
/test/utils.spec.js:
--------------------------------------------------------------------------------
1 | const { newScript, parallel, series } = require('../src/utils')
2 |
3 | describe('Test util functions', _ => {
4 | const testTask = v => cb => setTimeout(_ => cb(null, v), 100)
5 | const taskBundle = [1, 2, 3, 4, 5].map(testTask)
6 |
7 | it('[utils/newScript] A thunk task, append new script tag', function (done) {
8 | const testScript = 'https://ajax.googleapis.com/ajax/libs/jquery/3.0.0/jquery.min.js'
9 | const task = newScript(testScript)
10 |
11 | // start task
12 | task((err, src) => {
13 | const tag = document.querySelector(`script[src='${testScript}']`)
14 |
15 | // assert
16 | expect(err).to.not.exist
17 | expect(src).to.equal(testScript)
18 | expect(tag).to.exist
19 |
20 | done()
21 | })
22 | })
23 | it('[utils/newScript] A thunk task, append new script tag with id', function (done) {
24 | const testScript = 'https://ajax.googleapis.com/ajax/libs/jquery/3.0.0/jquery.min.js'
25 | const task = newScript({src: testScript, id: 'test'})
26 |
27 | // start task
28 | task((err, src) => {
29 | const tag = document.querySelector(`script[src='${testScript}']`)
30 |
31 | // assert
32 | expect(err).to.not.exist
33 | expect(src).to.equal(testScript)
34 | expect(tag).to.exist
35 |
36 | done()
37 | })
38 | })
39 |
40 | it('[utils/parallel] Run thunk task in parallel mode', function (done) {
41 | const startTime = Date.now()
42 |
43 | parallel.apply(null, taskBundle)((err, val, i) => {
44 | // assert for iteration
45 | expect(err).to.not.exist
46 | expect(val).to.equal(i + 1)
47 | })((err, ret) => {
48 | const finishTime = Date.now()
49 | const delta = finishTime - startTime
50 |
51 | // assert for success callback
52 | expect(err).to.not.exist
53 | expect(ret).to.eql([1, 2, 3, 4, 5])
54 | // check execute time, parallel mode would take about >100, <200 ms
55 | expect(delta).to.be.below(200)
56 |
57 | done()
58 | })
59 | })
60 |
61 | it('[utils/series] Run thunk task in series mode', function (done) {
62 | const startTime = Date.now()
63 |
64 | series.apply(null, taskBundle)((err, val, i) => {
65 | // assert for iteration
66 | expect(err).to.not.exist
67 | expect(val).to.equal(i + 1)
68 | })((err, ret) => {
69 | const finishTime = Date.now()
70 | const delta = finishTime - startTime
71 |
72 | // assert for success callback
73 | expect(err).to.not.exist
74 | expect(ret).to.eql([1, 2, 3, 4, 5])
75 | // check execute time, series mode would >500 ms
76 | expect(delta).to.be.above(500)
77 |
78 | done()
79 | })
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | export const isDefined = val => val != null
2 | export const isFunction = val => typeof val === 'function'
3 | export const noop = _ => { }
4 |
5 | export const newScript = (src) => (cb) => {
6 | const scriptElem = document.createElement('script')
7 | if (typeof src === 'object') {
8 | // copy every property to the element
9 | for (var key in src) {
10 | if (Object.prototype.hasOwnProperty.call(src, key)) {
11 | scriptElem[key] = src[key];
12 | }
13 | }
14 | src = src.src;
15 | } else {
16 | scriptElem.src = src
17 | }
18 | scriptElem.addEventListener('load', () => cb(null, src))
19 | scriptElem.addEventListener('error', () => cb(true, src))
20 | document.body.appendChild(scriptElem)
21 | return scriptElem
22 | }
23 |
24 | const keyIterator = (cols) => {
25 | const keys = Object.keys(cols)
26 | let i = -1
27 | return {
28 | next () {
29 | i++ // inc
30 | if (i >= keys.length) return null
31 | else return keys[i]
32 | }
33 | }
34 | }
35 |
36 | // tasks should be a collection of thunk
37 | export const parallel = (...tasks) => (each) => (cb) => {
38 | let hasError = false
39 | let successed = 0
40 | const ret = []
41 | tasks = tasks.filter(isFunction)
42 |
43 | if (tasks.length <= 0) cb(null)
44 | else {
45 | tasks.forEach((task, i) => {
46 | const thunk = task
47 | thunk((err, ...args) => {
48 | if (err) hasError = true
49 | else {
50 | // collect result
51 | if (args.length <= 1) args = args[0]
52 |
53 | ret[i] = args
54 | successed ++
55 | }
56 |
57 | if (isFunction(each)) each.call(null, err, args, i)
58 |
59 | if (hasError) cb(true)
60 | else if (tasks.length === successed) {
61 | cb(null, ret)
62 | }
63 | })
64 | })
65 | }
66 | }
67 |
68 | // tasks should be a collection of thunk
69 | export const series = (...tasks) => (each) => (cb) => {
70 | tasks = tasks.filter(val => val != null)
71 | const nextKey = keyIterator(tasks)
72 | const nextThunk = () => {
73 | const key = nextKey.next()
74 | let thunk = tasks[key]
75 | if (Array.isArray(thunk)) thunk = parallel.apply(null, thunk).call(null, each)
76 | return [ +key, thunk ] // convert `key` to number
77 | }
78 | let key, thunk
79 | let next = nextThunk()
80 | key = next[0]
81 | thunk = next[1]
82 | if (thunk == null) return cb(null)
83 |
84 | const ret = []
85 | const iterator = () => {
86 | thunk((err, ...args) => {
87 | if (args.length <= 1) args = args[0]
88 | if (isFunction(each)) each.call(null, err, args, key)
89 |
90 | if (err) cb(err)
91 | else {
92 | // collect result
93 | ret.push(args)
94 |
95 | next = nextThunk()
96 | key = next[0]
97 | thunk = next[1]
98 | if (thunk == null) return cb(null, ret) // finished
99 | else iterator()
100 | }
101 | })
102 | }
103 | iterator()
104 | }
105 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-async-script-loader
2 |
3 | [](https://travis-ci.org/leozdgao/react-async-script-loader) [](https://badge.fury.io/js/react-async-script-loader)
4 |
5 | A decorator for script lazy loading on react component.
6 |
7 | ## Description
8 |
9 | Some component may depend on other vendors which you may not want to load them until you really need them. So here it is, use **High Order Component** to decorate your component and it will handle lazy loading for you, it support parallel and sequential loading.
10 |
11 | ## Installation
12 |
13 | ```bash
14 | npm install --save react-async-script-loader
15 | ```
16 |
17 | ## API
18 |
19 | ```javascript
20 | scriptLoader(...scriptSrc)([WrappedComponent])
21 | ```
22 |
23 | `scriptSrc` can be a string of source or an array of source. `scriptSrc` will be loaded sequentially, but array of source will be loaded parallelly. It also cache the loaded script to avoid duplicated loading. More lively description see use case below.
24 |
25 | ## Properties
26 |
27 | Decorated component will receive following properties:
28 |
29 | |Name|Type|Description|
30 | |----|----|-----------|
31 | |isScriptLoaded|Boolean|Represent scripts loading process is over or not, maybe part of scripts load failed.|
32 | |isScriptLoadSucceed|Boolean|Represent all scripts load successfully or not.|
33 | |onScriptLoaded|Function|Triggered when all scripts load successfully.|
34 |
35 | ## How to use
36 |
37 | You can use it to decorate your component.
38 |
39 | ```javascript
40 | import React, { Component } from 'react'
41 | import scriptLoader from 'react-async-script-loader'
42 |
43 | class Editor extends Component {
44 | ...
45 |
46 | componentWillReceiveProps ({ isScriptLoaded, isScriptLoadSucceed }) {
47 | if (isScriptLoaded && !this.props.isScriptLoaded) { // load finished
48 | if (isScriptLoadSucceed) {
49 | this.initEditor()
50 | }
51 | else this.props.onError()
52 | }
53 | }
54 |
55 | componentDidMount () {
56 | const { isScriptLoaded, isScriptLoadSucceed } = this.props
57 | if (isScriptLoaded && isScriptLoadSucceed) {
58 | this.initEditor()
59 | }
60 | }
61 |
62 | ...
63 | }
64 |
65 | export default scriptLoader(
66 | [
67 | 'https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js',
68 | 'https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.5/marked.min.js'
69 | ],
70 | '/assets/bootstrap-markdown.js'
71 | )(Editor)
72 | ```
73 |
74 | The example above means that the `jquery` and `marked` will be loading parallelly, and after loaded these 2 vendors, load `bootstrap-markdown` sequentially.
75 |
76 | It is possible that some script will be failed to load. ScriptLoader will cache the script that load successfully and will remove the script node which fail to load before.
77 |
78 | *Currently, if you try to reload scripts, you have to remount your component.*
79 |
80 | And it's cooler if you use decorator syntax. (ES7)
81 |
82 | ```javascript
83 | @scriptLoader(
84 | [
85 | 'https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js',
86 | 'https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.5/marked.min.js'
87 | ],
88 | '/assets/bootstrap-markdown.js'
89 | )
90 | class Editor extends Component {
91 |
92 | }
93 | ```
94 |
95 | ## license
96 |
97 | MIT
98 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import hoistStatics from 'hoist-non-react-statics'
4 | import { newScript, series, noop } from './utils'
5 |
6 | const loadedScript = []
7 | const pendingScripts = {}
8 | let failedScript = []
9 |
10 | export function startLoadingScripts(scripts, onComplete = noop) {
11 | // sequence load
12 | const loadNewScript = (script) => {
13 | const src = typeof script === 'object' ? script.src : script
14 | if (loadedScript.indexOf(src) < 0) {
15 | return taskComplete => {
16 | const callbacks = pendingScripts[src] || []
17 | callbacks.push(taskComplete)
18 | pendingScripts[src] = callbacks
19 | if (callbacks.length === 1) {
20 | return newScript(script)(err => {
21 | pendingScripts[src].forEach(cb => cb(err, src))
22 | delete pendingScripts[src]
23 | })
24 | }
25 | }
26 | }
27 | }
28 | const tasks = scripts.map(src => {
29 | if (Array.isArray(src)) {
30 | return src.map(loadNewScript)
31 | }
32 | else return loadNewScript(src)
33 | })
34 |
35 | series(...tasks)((err, src) => {
36 | if (err) {
37 | failedScript.push(src)
38 | }
39 | else {
40 | if (Array.isArray(src)) {
41 | src.forEach(addCache)
42 | }
43 | else addCache(src)
44 | }
45 | })(err => {
46 | removeFailedScript()
47 | onComplete(err)
48 | })
49 | }
50 |
51 | const addCache = (entry) => {
52 | if (loadedScript.indexOf(entry) < 0) {
53 | loadedScript.push(entry)
54 | }
55 | }
56 |
57 | const removeFailedScript = () => {
58 | if (failedScript.length > 0) {
59 | failedScript.forEach((script) => {
60 | const node = document.querySelector(`script[src='${script}']`)
61 | if (node != null) {
62 | node.parentNode.removeChild(node)
63 | }
64 | })
65 |
66 | failedScript = []
67 | }
68 | }
69 |
70 | const scriptLoader = (...scripts) => (WrappedComponent) => {
71 | class ScriptLoader extends Component {
72 | static propTypes = {
73 | onScriptLoaded: PropTypes.func
74 | }
75 |
76 | static defaultProps = {
77 | onScriptLoaded: noop
78 | }
79 |
80 | constructor (props, context) {
81 | super(props, context)
82 |
83 | this.state = {
84 | isScriptLoaded: false,
85 | isScriptLoadSucceed: false
86 | }
87 |
88 | this._isMounted = false;
89 | }
90 |
91 | componentDidMount () {
92 | this._isMounted = true;
93 | startLoadingScripts(scripts, err => {
94 | if(this._isMounted) {
95 | this.setState({
96 | isScriptLoaded: true,
97 | isScriptLoadSucceed: !err
98 | }, () => {
99 | if (!err) {
100 | this.props.onScriptLoaded()
101 | }
102 | })
103 | }
104 | })
105 | }
106 |
107 | componentWillUnmount () {
108 | this._isMounted = false;
109 | }
110 |
111 | getWrappedInstance () {
112 | return this.refs.wrappedInstance;
113 | }
114 |
115 | render () {
116 | const props = {
117 | ...this.props,
118 | ...this.state,
119 | ref: 'wrappedInstance'
120 | }
121 |
122 | return (
123 |
124 | )
125 | }
126 | }
127 |
128 | return hoistStatics(ScriptLoader, WrappedComponent)
129 | }
130 |
131 | export default scriptLoader
132 |
--------------------------------------------------------------------------------