From fddd2cf799bb687070b97f9286a3d70abcd07c68 Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Tue, 29 Nov 2016 17:34:19 +0000 Subject: [PATCH] wip add lazy styles --- package.json | 2 +- packages/angular-cli/lib/config/schema.json | 46 +++++- .../models/json-schema/schema-tree.ts | 17 ++- .../models/webpack-build-common.ts | 133 ++++++++++-------- .../models/webpack-build-development.ts | 31 +--- .../models/webpack-build-production.ts | 35 +---- .../angular-cli/models/webpack-build-utils.ts | 66 ++++++++- packages/angular-cli/models/webpack-config.ts | 8 +- packages/angular-cli/package.json | 2 +- .../suppress-entry-chunks-webpack-plugin.ts | 44 ++++++ 10 files changed, 251 insertions(+), 133 deletions(-) create mode 100644 packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts diff --git a/package.json b/package.json index 8d4d90dfe8a1..6a0044e75dc4 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "rimraf": "^2.5.3", "rsvp": "^3.0.17", "rxjs": "5.0.0-beta.12", - "sass-loader": "^3.2.0", + "sass-loader": "^4.0.1", "script-loader": "^0.7.0", "semver": "^5.1.0", "silent-error": "^1.0.0", diff --git a/packages/angular-cli/lib/config/schema.json b/packages/angular-cli/lib/config/schema.json index 37668441c0b0..2334acd76427 100644 --- a/packages/angular-cli/lib/config/schema.json +++ b/packages/angular-cli/lib/config/schema.json @@ -32,11 +32,17 @@ "default": "dist/" }, "assets": { - "fixme": true, - "type": "array", - "items": { - "type": "string" - }, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], "default": [] }, "index": { @@ -62,7 +68,20 @@ "description": "Global styles to be included in the build.", "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": true + } + ] }, "additionalProperties": false }, @@ -70,7 +89,20 @@ "description": "Global scripts to be included in the build.", "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": true + } + ] }, "additionalProperties": false }, diff --git a/packages/angular-cli/models/json-schema/schema-tree.ts b/packages/angular-cli/models/json-schema/schema-tree.ts index 9ea195e579b4..eb76ab437ac2 100644 --- a/packages/angular-cli/models/json-schema/schema-tree.ts +++ b/packages/angular-cli/models/json-schema/schema-tree.ts @@ -129,12 +129,21 @@ export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { protected _createChildProperty(name: string, value: T, forward: SchemaTreeNode, schema: Schema, define = true): SchemaTreeNode { - // TODO: fix this - if (schema['fixme'] && typeof value === 'string') { - value = ([ value ]); + let type: string; + + if (!schema['oneOf']) { + type = schema['type']; + } else { + for (let maybeSchema of schema['oneOf']) { + if ((maybeSchema['type'] === 'array' && Array.isArray(value)) + || typeof value === maybeSchema['type']) { + type = maybeSchema['type']; + schema = maybeSchema; + break; + } + } } - const type = schema['type']; let Klass: any = null; switch (type) { diff --git a/packages/angular-cli/models/webpack-build-common.ts b/packages/angular-cli/models/webpack-build-common.ts index 27b85cfad977..6bccd9c3a2f0 100644 --- a/packages/angular-cli/models/webpack-build-common.ts +++ b/packages/angular-cli/models/webpack-build-common.ts @@ -1,14 +1,15 @@ import * as webpack from 'webpack'; import * as path from 'path'; -import {GlobCopyWebpackPlugin} from '../plugins/glob-copy-webpack-plugin'; -import {packageChunkSort} from '../utilities/package-chunk-sort'; -import {BaseHrefWebpackPlugin} from '@angular-cli/base-href-webpack'; +import { GlobCopyWebpackPlugin } from '../plugins/glob-copy-webpack-plugin'; +import { SuppressEntryChunksWebpackPlugin } from '../plugins/suppress-entry-chunks-webpack-plugin'; +import { packageChunkSort } from '../utilities/package-chunk-sort'; +import { BaseHrefWebpackPlugin } from '@angular-cli/base-href-webpack'; +import { extraEntryParser, makeCssLoaders } from './webpack-build-utils'; -const ProgressPlugin = require('webpack/lib/ProgressPlugin'); +const ProgressPlugin = require('webpack/lib/ProgressPlugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const autoprefixer = require('autoprefixer'); - export function getWebpackCommonConfig( projectRoot: string, environment: string, @@ -23,21 +24,60 @@ export function getWebpackCommonConfig( const appRoot = path.resolve(projectRoot, appConfig.root); const appMain = path.resolve(appRoot, appConfig.main); const nodeModules = path.resolve(projectRoot, 'node_modules'); - const styles = appConfig.styles - ? appConfig.styles.map((style: string) => path.resolve(appRoot, style)) - : []; - const scripts = appConfig.scripts - ? appConfig.scripts.map((script: string) => path.resolve(appRoot, script)) - : []; - const extraPlugins: any[] = []; - - let entry: { [key: string]: string[] } = { + + let extraPlugins: any[] = []; + let extraRules: any[] = []; + let lazyChunks: string[] = []; + + let entryPoints: { [key: string]: string[] } = { main: [appMain] }; - // Only add styles/scripts if there's actually entries there - if (appConfig.styles.length > 0) { entry['styles'] = styles; } - if (appConfig.scripts.length > 0) { entry['scripts'] = scripts; } + // process global scripts + if (appConfig.scripts.length > 0) { + const globalScrips = extraEntryParser(appConfig.scripts, appRoot, 'scripts'); + + // add entry points and lazy chunks + globalScrips.forEach(script => { + if (script.lazy) { lazyChunks.push(script.entry); } + entryPoints[script.entry] = (entryPoints[script.entry] || []).concat(script.path); + }); + + // load global scripts using script-loader + extraRules.push({ + include: globalScrips.map((script) => script.path), test: /\.js$/, loader: 'script-loader' + }); + } + + // process global styles + if (appConfig.styles.length === 0) { + // create css loaders for component css + extraRules.push(...makeCssLoaders()); + } else { + const globalStyles = extraEntryParser(appConfig.styles, appRoot, 'styles'); + let extractedCssEntryPoints: string[] = []; + // add entry points and lazy chunks + globalStyles.forEach(style => { + if (style.lazy) { lazyChunks.push(style.entry); } + if (!entryPoints[style.entry]) { + // since this entry point doesn't exist yet, it's going to only have + // extracted css and we can supress the entry point + extractedCssEntryPoints.push(style.entry); + entryPoints[style.entry] = (entryPoints[style.entry] || []).concat(style.path); + } else { + // existing entry point, just push the css in + entryPoints[style.entry].push(style.path); + } + }); + + // create css loaders for component css and for global css + extraRules.push(...makeCssLoaders(globalStyles.map((style) => style.path))); + + if (extractedCssEntryPoints.length > 0) { + // don't emit the .js entry point for extracted styles + extraPlugins.push(new SuppressEntryChunksWebpackPlugin({ chunks: extractedCssEntryPoints })); + } + } if (vendorChunk) { extraPlugins.push(new webpack.optimize.CommonsChunkPlugin({ @@ -47,12 +87,7 @@ export function getWebpackCommonConfig( })); } - if (progress) { - extraPlugins.push(new ProgressPlugin({ - profile: verbose, - colors: true - })); - } + if (progress) { extraPlugins.push(new ProgressPlugin({ profile: verbose, colors: true })); } return { devtool: sourcemap ? 'source-map' : false, @@ -61,7 +96,7 @@ export function getWebpackCommonConfig( modules: [nodeModules] }, context: path.resolve(__dirname, './'), - entry: entry, + entry: entryPoints, output: { path: path.resolve(projectRoot, appConfig.outDir), filename: '[name].bundle.js', @@ -70,48 +105,22 @@ export function getWebpackCommonConfig( }, module: { rules: [ - { - enforce: 'pre', - test: /\.js$/, - loader: 'source-map-loader', - exclude: [ nodeModules ] - }, - // in main, load css as raw text -        { - exclude: styles, - test: /\.css$/, - loaders: ['raw-loader', 'postcss-loader'] - }, { - exclude: styles, - test: /\.styl$/, - loaders: ['raw-loader', 'postcss-loader', 'stylus-loader'] }, -        { - exclude: styles, - test: /\.less$/, - loaders: ['raw-loader', 'postcss-loader', 'less-loader'] - }, { - exclude: styles, - test: /\.scss$|\.sass$/, - loaders: ['raw-loader', 'postcss-loader', 'sass-loader'] - }, - - - // load global scripts using script-loader - { include: scripts, test: /\.js$/, loader: 'script-loader' }, + { enforce: 'pre', test: /\.js$/, loader: 'source-map-loader', exclude: [nodeModules] }, -        { test: /\.json$/, loader: 'json-loader' }, -        { test: /\.(jpg|png|gif)$/, loader: 'url-loader?limit=10000' }, -        { test: /\.html$/, loader: 'raw-loader' }, + { test: /\.json$/, loader: 'json-loader' }, + { test: /\.(jpg|png|gif)$/, loader: 'url-loader?limit=10000' }, + { test: /\.html$/, loader: 'raw-loader' }, { test: /\.(otf|ttf|woff|woff2)$/, loader: 'url-loader?limit=10000' }, { test: /\.(eot|svg)$/, loader: 'file-loader' } - ] + ].concat(extraRules) }, plugins: [ new HtmlWebpackPlugin({ template: path.resolve(appRoot, appConfig.index), filename: path.resolve(appConfig.outDir, appConfig.index), - chunksSortMode: packageChunkSort(['inline', 'styles', 'scripts', 'vendor', 'main']) + chunksSortMode: packageChunkSort(['inline', 'styles', 'scripts', 'vendor', 'main']), + excludeChunks: lazyChunks }), new BaseHrefWebpackPlugin({ baseHref: baseHref @@ -130,12 +139,18 @@ export function getWebpackCommonConfig( }), new GlobCopyWebpackPlugin({ patterns: appConfig.assets, - globOptions: {cwd: appRoot, dot: true, ignore: '**/.gitkeep'} + globOptions: { cwd: appRoot, dot: true, ignore: '**/.gitkeep' } }), new webpack.LoaderOptionsPlugin({ test: /\.(css|scss|sass|less|styl)$/, options: { - postcss: [ autoprefixer() ] + postcss: [autoprefixer()], + cssLoader: { sourceMap: sourcemap }, + sassLoader: { sourceMap: sourcemap }, + lessLoader: { sourceMap: sourcemap }, + stylusLoader: { sourceMap: sourcemap }, + // context needed as a workaround https://github.com/jtangelder/sass-loader/issues/285 + context: path.resolve(__dirname, './') }, }) ].concat(extraPlugins), diff --git a/packages/angular-cli/models/webpack-build-development.ts b/packages/angular-cli/models/webpack-build-development.ts index d38748abdf2a..851824f13473 100644 --- a/packages/angular-cli/models/webpack-build-development.ts +++ b/packages/angular-cli/models/webpack-build-development.ts @@ -1,11 +1,9 @@ const path = require('path'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); export const getWebpackDevConfigPartial = function(projectRoot: string, appConfig: any) { const appRoot = path.resolve(projectRoot, appConfig.root); - const styles = appConfig.styles - ? appConfig.styles.map((style: string) => path.resolve(appRoot, style)) - : []; - const cssLoaders = ['style-loader', 'css-loader?sourcemap', 'postcss-loader']; + return { output: { path: path.resolve(projectRoot, appConfig.outDir), @@ -13,27 +11,8 @@ export const getWebpackDevConfigPartial = function(projectRoot: string, appConfi sourceMapFilename: '[name].bundle.map', chunkFilename: '[id].chunk.js' }, - module: { - rules: [ - // outside of main, load it via style-loader for development builds -        { - include: styles, - test: /\.css$/, - loaders: cssLoaders - }, { - include: styles, - test: /\.styl$/, - loaders: [...cssLoaders, 'stylus-loader?sourcemap'] - }, { - include: styles, - test: /\.less$/, - loaders: [...cssLoaders, 'less-loader?sourcemap'] - }, { - include: styles, - test: /\.scss$|\.sass$/, - loaders: [...cssLoaders, 'sass-loader?sourcemap'] - }, - ] - } + plugins: [ + new ExtractTextPlugin({filename: '[name].bundle.css'}) + ] }; }; diff --git a/packages/angular-cli/models/webpack-build-production.ts b/packages/angular-cli/models/webpack-build-production.ts index 7e0b73331d36..92e39fdbeccb 100644 --- a/packages/angular-cli/models/webpack-build-production.ts +++ b/packages/angular-cli/models/webpack-build-production.ts @@ -1,7 +1,8 @@ import * as path from 'path'; +import * as webpack from 'webpack'; + const WebpackMd5Hash = require('webpack-md5-hash'); const CompressionPlugin = require('compression-webpack-plugin'); -import * as webpack from 'webpack'; const ExtractTextPlugin = require('extract-text-webpack-plugin'); declare module 'webpack' { @@ -16,12 +17,9 @@ declare module 'webpack' { export const getWebpackProdConfigPartial = function(projectRoot: string, appConfig: any, + sourcemap: boolean, verbose: any) { const appRoot = path.resolve(projectRoot, appConfig.root); - const styles = appConfig.styles - ? appConfig.styles.map((style: string) => path.resolve(appRoot, style)) - : []; - const cssLoaders = ['css-loader?sourcemap&minimize', 'postcss-loader']; return { output: { @@ -30,38 +28,17 @@ export const getWebpackProdConfigPartial = function(projectRoot: string, sourceMapFilename: '[name].[chunkhash].bundle.map', chunkFilename: '[id].[chunkhash].chunk.js' }, - module: { - rules: [ - // outside of main, load it via extract-text-plugin for production builds -        { - include: styles, - test: /\.css$/, - loaders: ExtractTextPlugin.extract(cssLoaders) - }, { - include: styles, - test: /\.styl$/, - loaders: ExtractTextPlugin.extract([...cssLoaders, 'stylus-loader?sourcemap']) - }, { - include: styles, - test: /\.less$/, - loaders: ExtractTextPlugin.extract([...cssLoaders, 'less-loader?sourcemap']) - }, { - include: styles, - test: /\.scss$|\.sass$/, - loaders: ExtractTextPlugin.extract([...cssLoaders, 'sass-loader?sourcemap']) - }, - ] - }, plugins: [ - new ExtractTextPlugin('[name].[contenthash].bundle.css'), + new ExtractTextPlugin('[name].[chunkhash].bundle.css'), new WebpackMd5Hash(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }), + new webpack.LoaderOptionsPlugin({ minimize: true }), new webpack.optimize.UglifyJsPlugin({ mangle: { screw_ie8 : true }, compress: { screw_ie8: true, warnings: verbose }, - sourceMap: true + sourceMap: sourcemap }), new CompressionPlugin({ asset: '[path].gz[query]', diff --git a/packages/angular-cli/models/webpack-build-utils.ts b/packages/angular-cli/models/webpack-build-utils.ts index b2aa2148800e..6ffa76f03423 100644 --- a/packages/angular-cli/models/webpack-build-utils.ts +++ b/packages/angular-cli/models/webpack-build-utils.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +const ExtractTextPlugin = require('extract-text-webpack-plugin'); export const ngAppResolve = (resolvePath: string): string => { return path.resolve(process.cwd(), resolvePath); @@ -27,7 +28,66 @@ const verboseWebpackOutputOptions = { }; export function getWebpackStatsConfig(verbose = false) { - return verbose - ? Object.assign(webpackOutputOptions, verboseWebpackOutputOptions) - : webpackOutputOptions; + return verbose + ? Object.assign(webpackOutputOptions, verboseWebpackOutputOptions) + : webpackOutputOptions; +} + +export interface ExtraEntry { + input: string; + output?: string; + lazy?: boolean; + path?: string; + entry?: string; +} + +// create array of css loaders +export function makeCssLoaders(stylePaths: string[] = []) { + const baseRules = [ + { test: /\.css$/, loaders: [] }, + { test: /\.scss$|\.sass$/, loaders: ['sass-loader'] }, + { test: /\.less$/, loaders: ['less-loader'] }, + { test: /\.styl$/, loaders: ['stylus-loader'] } + ]; + + const commonLoaders = ['css-loader', 'postcss-loader']; + + // load component css as raw strings + let cssLoaders: any = baseRules.map(({test, loaders}) => ({ + exclude: stylePaths, test, loaders: ['raw-loader', ...commonLoaders, ...loaders] + })); + + if (stylePaths.length > 0) { + // load global css as css files + cssLoaders.push(...baseRules.map(({test, loaders}) => ({ + include: stylePaths, test, loaders: ExtractTextPlugin.extract({ + loader: [...commonLoaders, ...loaders], + fallbackLoader: 'style-loader' + }) + }))); + } + + return cssLoaders; +} + +// convert all extra entries into the object representation, fill in defaults +export function extraEntryParser( + maybeExtraEntries: (string | ExtraEntry)[], + appRoot: string, + defaultEntry: string +): ExtraEntry[] { + return maybeExtraEntries + .map((maybeExtraEntry: string | ExtraEntry) => + typeof maybeExtraEntry === 'string' ? { input: maybeExtraEntry } : maybeExtraEntry) + .map((extraEntry: ExtraEntry) => { + extraEntry.path = path.resolve(appRoot, extraEntry.input); + if (extraEntry.output) { + extraEntry.entry = extraEntry.output.replace(/\.(js|css)$/i, ''); + } else if (extraEntry.lazy) { + extraEntry.entry = extraEntry.input.replace(/\.(js|css|scss|sass|less|styl)$/i, ''); + } else { + extraEntry.entry = defaultEntry; + } + return extraEntry; + }); } diff --git a/packages/angular-cli/models/webpack-config.ts b/packages/angular-cli/models/webpack-config.ts index d605305bc96c..f21f3b5f48a0 100644 --- a/packages/angular-cli/models/webpack-config.ts +++ b/packages/angular-cli/models/webpack-config.ts @@ -44,7 +44,9 @@ export class NgCliWebpackConfig { verbose, progress ); - let targetConfigPartial = this.getTargetConfig(this.ngCliProject.root, appConfig, verbose); + let targetConfigPartial = this.getTargetConfig( + this.ngCliProject.root, appConfig, sourcemap, verbose + ); const typescriptConfigPartial = isAoT ? getWebpackAotConfigPartial(this.ngCliProject.root, appConfig) : getWebpackNonAotConfigPartial(this.ngCliProject.root, appConfig); @@ -66,12 +68,12 @@ export class NgCliWebpackConfig { ); } - getTargetConfig(projectRoot: string, appConfig: any, verbose: boolean): any { + getTargetConfig(projectRoot: string, appConfig: any, sourcemap: boolean, verbose: boolean): any { switch (this.target) { case 'development': return getWebpackDevConfigPartial(projectRoot, appConfig); case 'production': - return getWebpackProdConfigPartial(projectRoot, appConfig, verbose); + return getWebpackProdConfigPartial(projectRoot, appConfig, sourcemap, verbose); default: throw new Error("Invalid build target. Only 'development' and 'production' are available."); } diff --git a/packages/angular-cli/package.json b/packages/angular-cli/package.json index 34beafbcc7f1..af9be7250abb 100644 --- a/packages/angular-cli/package.json +++ b/packages/angular-cli/package.json @@ -90,7 +90,7 @@ "rimraf": "^2.5.3", "rsvp": "^3.0.17", "rxjs": "5.0.0-beta.12", - "sass-loader": "^3.2.0", + "sass-loader": "^4.0.1", "script-loader": "^0.7.0", "semver": "^5.1.0", "silent-error": "^1.0.0", diff --git a/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts new file mode 100644 index 000000000000..0d62fc4f3ee3 --- /dev/null +++ b/packages/angular-cli/plugins/suppress-entry-chunks-webpack-plugin.ts @@ -0,0 +1,44 @@ +// ExtractTextPlugin leaves behind the entry points, which we might not need anymore +// if they were entirely css. This plugin removes those entry points. + +export interface SuppressEntryChunksWebpackPluginOptions { + chunks: string[]; +} + +export class SuppressEntryChunksWebpackPlugin { + constructor(private options: SuppressEntryChunksWebpackPluginOptions) { } + + apply(compiler: any): void { + let { chunks } = this.options; + compiler.plugin('compilation', function (compilation: any) { + // Remove the js file for supressed chunks + compilation.plugin('after-seal', (callback: any) => { + compilation.chunks + .filter((chunk: any) => chunks.indexOf(chunk.name) !== -1) + .forEach((chunk: any) => { + let newFiles: string[] = []; + chunk.files.forEach((file: string) => { + if (file.match(/\.js$/)) { + // remove js files + delete compilation.assets[file]; + } else { + newFiles.push(file); + } + }); + chunk.files = newFiles; + }); + callback(); + }); + // Remove scripts tags with a css file as source, because HtmlWebpackPlugin will use + // a css file as a script for chunks without js files. + compilation.plugin('html-webpack-plugin-alter-asset-tags', + (htmlPluginData: any, callback: any) => { + const filterFn = (tag: any) => + !(tag.tagName === 'script' && tag.attributes.src.match(/\.css$/)); + htmlPluginData.head = htmlPluginData.head.filter(filterFn); + htmlPluginData.body = htmlPluginData.body.filter(filterFn); + callback(null, htmlPluginData); + }); + }); + } +}