Skip to content

Commit

Permalink
feat($dualImport): the 2.0 requires babel-plugin-dual-import to fetch…
Browse files Browse the repository at this point in the history
… js + css (faster builds!)
  • Loading branch information
faceyspacey committed Jul 7, 2017
1 parent f73b148 commit a9b036a
Show file tree
Hide file tree
Showing 4 changed files with 86 additions and 132 deletions.
53 changes: 11 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,45 @@
</p>

# extract-css-chunks-webpack-plugin
> TIP: remove `style-loader` from your dependencies. It's included in this package and must resolve to the correct *latest* version (June 2017).
> **UPDATE (July 7th):** [babel-plugin-dual-import](https://github.com/faceyspacey/babel-plugin-dual-import) is now required to asynchronously import both css + js. *Much Faster Builds!*
Like `extract-text-webpack-plugin`, but creates multiple css files (one per chunk). Then, as part of server side rendering, you can deliver just the css chunks needed by the current request. The result is the most minimal CSS initially served compared to emerging JS-in-CSS solutions.

*Note: this is a companion package to:*
- [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks)
- [react-universal-component](https://github.com/faceyspacey/react-universal-component)
- [babel-plugin-dual-import](https://github.com/faceyspacey/babel-plugin-dual-import)

For a complete usage example, see the [Flush Chunks Boilerplates](https://github.com/faceyspacey/webpack-flush-chunks#boilerplates).

Here's the sort of CSS you can expect to serve:

```
<head>
<link rel='stylesheet' href='/static/0.css' />
<link rel='stylesheet' href='/static/main.css' />
<link rel='stylesheet' href='/static/0.css' />
<link rel='stylesheet' href='/static/7.css' />
</head>
<body>
<div id="react-root"></div>
<script type='text/javascript' src='/static/vendor.js'></script>
<script type='text/javascript' src='/static/0.no_css.js'></script>
<script type='text/javascript' src='/static/main.no_css.js'></script>
<script type='text/javascript' src='/static/0.js'></script>
<script type='text/javascript' src='/static/7.js'></script>
<script type='text/javascript' src='/static/main.js'></script>
</body>
```

If you use [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks), it will scoop up the exact stylesheets to embed in your response string for you.

[babel-plugin-dual-import](https://github.com/faceyspacey/babel-plugin-dual-import) is required for ascynchronous requests via `import()`. It requests both your js + your css. *Very Nice!* Read *Sokra's* (author of webpack) article on how [on how this is the future of CSS for webpack](https://medium.com/webpack/the-new-css-workflow-step-1-79583bd107d7).

## Perks:
- **HMR:** It also has first-class support for **Hot Module Replacement** across ALL those css files/chunks!!!

- **2 VERSIONS OF YOUR JS CHUNKS:** In addition to generating CSS chunks, this plugin in fact creates 2 javascript chunks instead of 1. It leaves untouched your typical chunk that injects styles via `style-loader` and creates another chunk named `name.no_css.js`, which has all CSS removed, thereby greatly reducing its file size. This allows for future asynchronously-loaded bundles (which will be loaded *without* a css file) to also have their corresponding styles, ***while reducing the size of your initially served javascript chunks as much as possible*** 🤓

- cacheable stylesheets
- smallest total bytes sent compared to "render-path" css-in-js solutions that include your CSS definitions in JS
- Faster than V1!


## Installation
Expand Down Expand Up @@ -101,36 +105,6 @@ Keep in mind we've added sensible defaults, specifically: `[name].css` is used w
The 2 exceptions are: `allChunks` will no longer do anything, and `fallback` will no longer do anything when passed to to `extract`. Basically just worry about passing your `css-loader` string and `localIdentName` 🤓



## How It Works

Just like your JS, it moves all the the required CSS into corresponding css chunk files. So entry chunks might be named: `main.12345.css` and dynamic split chunks would be named: `0.123456.css`, `1.123456.css`, etc. You will however now have 2 files for each javascript chunk: `0.no_css.js` and `0.js`. The former is what you should serve in the initial request (and what [webpack-flush-chunks](https://github.com/faceyspacey/webpack-flush-chunks) in conjunction with [react-universal-component](https://github.com/faceyspacey/react-universal-component) will automatically serve). The latter, *which DOES contain css injection via style-loader*, is what will asyncronously be loaded in future async requests. This solves the fact that they otherwise would be missing CSS, since the webpack async loading mechanism isn't built to serve both a JS and CSS file. In total, 3 files are created for each named entry chunk and 3 files for each dynamically split chunk, e.g:

**entry chunk:**
- `main.js`
- `main.no.js`
- `main.css`

**dynamic chunks**:

*chunk 0:*
- `0.js`
- `0.no_css.js`
- `0.css`

*chunk 1:*
- `1.js`
- `1.no_css.js`
- `1.css`

*chunk 2:*
- `2.js`
- `2.no_css.js`
- `2.css`

As part of server-side rendering, you obviously should embed within the page the js files with the `no_css.js` extension. The `.js` files will be loaded by default when Webpack asyncronously requests them, *and you won't have to worry about embedding them in any response strings.*


## What about Aphrodite, Glamor, StyleTron, Styled-Components, Styled-Jsx, etc?

If you effectively use code-splitting, **Exract Css Chunks** can be a far better option than using emerging solutions like *StyleTron*, *StyledComponents*, and slightly older tools like *Aphrodite*, *Glamor*, etc. We aren't fans of either rounds of tools because of several issues, but particularly because they all have a runtime overhead. Every time your React component is rendered with those, CSS is generated and updated within the DOM. On the server, you're going to also see unnecessary cycles for flushing the CSS along the critical render path. *Next.js's* `styled-jsx`, by the way, doesn't even work on the server--*not so good when it comes to flash of unstyled content (FOUC).*
Expand Down Expand Up @@ -174,11 +148,6 @@ As an aside, so many apps share code between web and React Native--so the answer
**Long live the dream of Code Splitting Everywhere!**


## Notes on extract-text-webpack-plugin

Most the code comes from the original Extract Text Webpack Plugin--the goal is to merge this functionality back into that package at some point, though that process is not looking good. So that might be a while. Until then I'd feel totally comfortable just using this package. Though it took a while to make (and figure out how the original package worked), very little code has changed, and it won't be hard to keep in sync with upstream changes.


## Contributing
We use [commitizen](https://github.com/commitizen/cz-cli), so run `npm run cm` to make commits. A command-line form will appear, requiring you answer a few questions to automatically produce a nicely formatted commit. Releases, semantic version numbers, tags and changelogs will automatically be generated based on these commits thanks to [semantic-release](https://github.com/semantic-release/semantic-release). Be good.

24 changes: 21 additions & 3 deletions hotModuleReplacement.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,31 @@ module.exports = function(publicPath, outputFilename) {
var newChunk = newHref.split('.')[0];

if (oldChunk === newChunk) {
var oldSheet = styleSheets[i]
var url = newHref + '?' + (+new Date)
var head = document.getElementsByTagName('head')[0]
var link = document.createElement('link')

// date insures sheets update when [contenthash] is not used in file names
var url = newHref + '?' + (+new Date);
styleSheets[i].href = url;
console.log('[HMR]', 'Reload css: ', url);
link.href = url
link.charset = 'utf-8'
link.type = 'text/css'
link.rel = 'stylesheet'

head.insertBefore(link, oldSheet.nextSibling)

// remove the old sheet only after the old one loads so it's seamless
// we gotta do it this way since link.onload basically doesn't work
var img = document.createElement('img')
img.onerror = function() {
oldSheet.remove()
console.log('[HMR]', 'Reload css: ', url);
}
img.src = url
break;
}
}
}
}
}

114 changes: 43 additions & 71 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,6 @@ function ExtractTextPluginCompilation() {
this.modulesByIdentifier = {};
}

// ExtractTextPlugin.prototype.mergeNonInitialChunks = function(chunk, intoChunk, checkedChunks) {
// if (chunk.chunks) {
// // Fix error when hot module replacement used with CommonsChunkPlugin
// chunk.chunks = chunk.chunks.filter(function(c) {
// return typeof c !== 'undefined';
// })
// }

// if(!intoChunk) {
// checkedChunks = [];
// chunk.chunks.forEach(function(c) {
// if(c.isInitial()) return;
// this.mergeNonInitialChunks(c, chunk, checkedChunks);
// }, this);
// } else if(checkedChunks.indexOf(chunk) < 0) {
// checkedChunks.push(chunk);
// chunk.modules.slice().forEach(function(module) {
// intoChunk.addModule(module);
// module.addChunk(intoChunk);
// });
// chunk.chunks.forEach(function(c) {
// if(c.isInitial()) return;
// this.mergeNonInitialChunks(c, intoChunk, checkedChunks);
// }, this);
// }
// };

ExtractTextPluginCompilation.prototype.addModule = function(identifier, originalModule, source, additionalInformation, sourceMap, prevModules) {
var m;
Expand Down Expand Up @@ -328,8 +302,28 @@ ExtractTextPlugin.prototype.apply = function(compiler) {
}.bind(this));
}.bind(this));

// HMR: inject file name into corresponding javascript modules in order to trigger
// appropriate hot module reloading of CSS
if (DEV) {
compilation.plugin("optimize-module-ids", function(modules){
extractedChunks.forEach(function(extractedChunk) {
extractedChunk.modules.forEach(function (module) {
if(module.__fileInjected) {
return;
}
module.__fileInjected = true;

extractedChunk.modules.forEach(function(module){
var originalModule = module.getOriginalModule();
var file = getFile(compilation, filename, module, extractedChunk);
originalModule._source._value = originalModule._source._value.replace('%%extracted-file%%', file);
});
});
});
});
}
compilation.plugin("additional-assets", function(callback) {
extractedChunks.forEach(function(extractedChunk) {
extractedChunks.forEach(function(extractedChunk) {
if(extractedChunk.modules.length) {
extractedChunk.modules.sort(function(a, b) {
if(!options.ignoreOrder && isInvalidOrder(a, b)) {
Expand All @@ -338,57 +332,35 @@ ExtractTextPlugin.prototype.apply = function(compiler) {
}
return getOrder(a, b);
});
var chunk = extractedChunk.originalChunk;
var source = this.renderExtractedChunk(extractedChunk);

var getPath = (format) => compilation.getPath(format, {
chunk: chunk
}).replace(/\[(?:(\w+):)?contenthash(?::([a-z]+\d*))?(?::(\d+))?\]/ig, function() {
return loaderUtils.getHashDigest(source.source(), arguments[1], arguments[2], parseInt(arguments[3], 10));
});

var file = (isFunction(filename)) ? filename(getPath) : getPath(filename);
var chunk = extractedChunk.originalChunk;
var module = this.renderExtractedChunk(extractedChunk);
var file = getFile(compilation, filename, module, extractedChunk)

// add the css files to assets and the files array corresponding to its chunk
compilation.assets[file] = source;
compilation.assets[file] = module;
chunk.files.push(file);

// HMR: inject file name into corresponding javascript modules in order to trigger
// appropriate hot module reloading of CSS
extractedChunk.modules.forEach(function(module){
var originalModule = module.getOriginalModule();
originalModule._source._value = originalModule._source._value.replace('%%extracted-file%%', file);
});
}
}, this);
callback();
}.bind(this));

// duplicate js chunks into secondary files that don't have css injection,
// giving the additional js files the extension: `.no_css.js`
Object.keys(compilation.assets).forEach(function(name) {
var asset = compilation.assets[name];

if (/\.js$/.test(name) && asset._source) {
var newName = name.replace(/\.js/, '.no_css.js');
var newAsset = new CachedSource(asset._source);
var regex = /\/\*__START_CSS__\*\/[\s\S]*?\/\*__END_CSS__\*\//g
}.bind(this));
};

// remove js that adds css to DOM via style-loader, so that React Loadable
// can serve smaller files (without css) in initial request.
newAsset._cachedSource = asset.source().replace(regex, '');

compilation.assets[newName] = newAsset;
function getFile(compilation, filename, module, chunk) {
return typeof filename === 'function'
? filename(getPath(compilation, module.source(), chunk))
: getPath(compilation, module.source(), chunk)(filename)
}

// add no_css file to files associated with chunk so that they are minified,
// and receive source maps, and can be found by React Loadable
extractedChunks.forEach(function(extractedChunk) {
var chunk = extractedChunk.originalChunk;
if (chunk.files.indexOf(name) > -1) {
chunk.files.push(newName);
}
})
}
})
callback()
}.bind(this));
}.bind(this));
};
function getPath(compilation, source, chunk) {
return function(format) {
return compilation.getPath(format, {
chunk: chunk
}).replace(/\[(?:(\w+):)?contenthash(?::([a-z]+\d*))?(?::(\d+))?\]/ig, function() {
return loaderUtils.getHashDigest(source, arguments[1], arguments[2], parseInt(arguments[3], 10));
});
}
}
27 changes: 11 additions & 16 deletions loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,10 @@ var SingleEntryPlugin = require("webpack/lib/SingleEntryPlugin");
var LimitChunkCountPlugin = require("webpack/lib/optimize/LimitChunkCountPlugin");

var NS = fs.realpathSync(__dirname);
var DEV = process.env.NODE_ENV === 'development'

module.exports = function(source) {
if(this.cacheable) this.cacheable();
// Even though this gets overwritten if extract+remove are true, without it, the runtime doesn't get added to the chunk
return `require("style-loader/lib/addStyles.js");
if (module.hot) { require('${require.resolve("./hotModuleReplacement.js")}'); }
${source}`;
return source;
};

module.exports.pitch = function(request) {
Expand Down Expand Up @@ -132,22 +129,20 @@ module.exports.pitch = function(request) {
resultSource += "\nmodule.exports = " + JSON.stringify(text.locals) + ";";
}

// module.hot.data is undefined on initial load, and an object in hot updates
var jsescOpts = { wrap: true, quotes: "double" };
resultSource += `
/*__START_CSS__*/
var moduleId = ${jsesc(text[0][0], jsescOpts)};
var css = ${jsesc(text[0][1], jsescOpts)};
var addStyles = require("style-loader/lib/addStyles.js");
addStyles([[moduleId, css]], "");
/*__END_CSS__*/
// module.hot.data is undefined on initial load, and an object in hot updates.
//
// All we need is a date that changes during dev, to trigger a reload since
// hashes generated based on the file contents are what trigger HMR.
if (DEV) {
resultSource += `
if (module.hot) {
module.hot.accept();
if (module.hot.data) {
require("${require.resolve('./hotModuleReplacement.js')}")("${publicPath}", "%%extracted-file%%");
var neverUsed = ${+new Date()}
require(${loaderUtils.stringifyRequest(this, path.join(__dirname, "hotModuleReplacement.js"))})("${publicPath}", "%%extracted-file%%");
}
}`;
}
}
} catch(e) {
return callback(e);
Expand Down

0 comments on commit a9b036a

Please sign in to comment.