工程化
一套好的工程化解决方案,能在提高开发效率的同时,确保整个系统的伸缩性(各种不同的部署环境)及健壮性(安全),同时在性能上又能有一个很优异的表现(主要是各种缓存策略加载策略等),而且这套方案又应该是对工程师无感知(或感知很小)趋于自动化的一套方案。
为什么需要工程化 ?
- 开始一个项目时,需要安装一系列的插件,在使用react时,一般需要使用react + react-router + react-redux + ant-design + Immutable.js; 在使用Vue时,一般需要 Vue + Vuex + elementUI + Vue router + Immutable.js。
- 想使用各种 ES6(解构...), ES7(Array.includes()...), ES8(Async、Await) 新特性,但是各个浏览器的支持程度不一致,统一浏览器的各个版本对这些新特性的支持也千差万别,所以在上线前需要使用babel转换成浏览器支持的语法规范,这一过程是polyfill, 其配置规则很多,实现方案也多种。
- css的编程能力弱,所以在实际项目中会使用浏览器不能识别的 less/scss,有些css属性需要使用前缀(-webkit/-moz/-opera)以支持各个浏览器。
- 开发时html中引用的脚本、样式表、图片等都是相对路径,上线前需要改为URL,需要发布到CDN。
- 考虑到网站性能,上线前对JS、CSS进行压缩合并,图片也需要压缩,零散小图片需要使用css雪碧图或使用base编码格式内嵌到css中。
- 采用模块化的开发方案后,不需要记住先引哪个js、后引哪个js,但是es6的模块化方案浏览器不能识别,上线前需要进行依赖分析与合并打包。
- 为了解决前后端协同开发效率,前后端约定好数据格式、请求方法后需要编写模拟数据来渲染界面,需检查请求组装的数据等工作。
- 如果有10个项目,上述过程得重复10次;同一个项目有多人参与时,每个人都有不同的编码风格,文件的组织方式也不一样,对维护来说这是一个大的挑战。
工程化建设目标
- 规范化
- 项目文件结构规范,知道什么文件在什么文件夹中
- 代码及技术栈规范,可提升协作水平,降低维护成本
- 流程规范化,降低开发成本、沟通成本,按文档操作即可
- 自动化
- 减少重复工作,专注于业务逻辑及交互即可
- 降低上手难度,开箱即用,不用关注细节
- 降低出错风险
项目架构图示例
各层级详解
- 用户层:采用commandjs+inquirer库
- 命令行终端:向开发人员暴露操作命令,然后根据命令调用平台层的功能模块
- 配置文件:通用的模板方案不满足要求时,可通过约定的配置文件格式进行自定义
- 平台层:
- 脚手架:创建项目文件结构、安装依赖包
- 开发服务器:实时预览项目运行效果,提供模拟网络数据请求及响应
- 构建:将源代码编译为宿主浏览器收款执行的代码,核心是产出各种资源,自动协调资源间的关系,是整个前端工程体系中最复杂、最重要的部分,构建功能包含以下部分
- ES规范转译
- CSS预编译语法转换(scss/less转换为css)
- EJS模板转换为Html、react的jsx模板转换、.vue文件拆解转换
- 代码分割,提取多个页面的公共代码,提取首屏不需要执行的代码异步加载
- 分析JS模块之间的依赖关系,将同步依赖的文件打包在一起,减少HTTP请求数量
- 将小图片转成base64编码的图片,嵌入到文档中,减少http请求
- css、js文件压缩,减少文件体积,缩短传输时间
- 文件变动后自动加hash指纹,应对浏览器静态资源缓存问题
- 域名/资源路径转换
- 部署:将打包出来的资源文件部署到oss或服务器上
- 内核层:
- 一系列前端工具的组合,这些工具中最重要、最难上手的是webpack,多入牛毛的配置让许多开发人员闻风丧胆
- 系统层:
- 幕后大boss,没有nodejs就没有前端工程化
构建工具
为什么选择webpack ?
Webpack 已经成为构建工具中的首选,这是有原因的:
- 大多数团队在开发新项目时会紧跟时代的技术,这些技术几乎都会采用“模块化+新语言+新框架”,webpack可以为这些项目提供一站式的解决方案
- webpack有良好的生态链和维护团队,能提供良好的开发体验和保证质量
- webpack被全世界的大量web开发者使用和验证,能找到各个层面所需的教程和经验分享
在此推荐两篇除了官网外,个人认为比较好的webpack入门参考资料
- 深入浅出Webpack(webpack.wuhaolin.cn/)
- 为什么我们要做三份Webpack配置文件(zhuanlan.zhihu.com/p/29161762?…)
关于webpack的实践,没有最佳,只有根据项目需求和经验积累进行配置。
以下是我在项目中遇到过的webpack配置文件,仅供大家参考学习(其中./configReader 是使用者的自定义配置)。
- webpack.config.common.js
import babel from '@babel/register';
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import path from 'path';
import HtmlWebpackPlugin from "html-webpack-plugin";
import webpack from "webpack";
import NpmImportPlugin from 'less-plugin-npm-import';
import configReader from './configReader';
import ora from 'ora';
babel({
presets: [require('@babel/preset-env').default],
});
const jsPath = `js/`;
const cssPath = `css/`;
const mediaPath = `media/`;
const devMode = process.env.NODE_ENV !== 'production';
const userDefinedConfig = configReader();
const forceSelfAlias = ['react-hot-loader', 'webpack-hot-middleware']
.reduce((accumulator, currentValue) => Object.assign(accumulator, {
[currentValue]: path.resolve(path.join(__dirname, '../../', 'node_modules', currentValue))
}), {});
let theme = {};
try {
theme = require(`${process.cwd()}/src/styles/theme`).default;
} catch (e) {
ora().warn(`import theme fail: ${e.message}`);
}
let babelPlugins = [], babelPresets = [];
if (devMode) {
babelPlugins = require('./babel').devPlugin;
babelPresets = require('./babel').devPreset;
} else {
babelPlugins = require('./babel').prodPlugin;
babelPresets = require('./babel').prodPreset;
}
babelPlugins = babelPlugins.concat(userDefinedConfig.babelPlugins.map(plugin => {
if (Array.isArray(plugin)) {
return [`${process.cwd()}/node_modules/${plugin[0]}`, plugin[1]];
}
return `${process.cwd()}/node_modules/${plugin}`
}));
babelPresets = babelPresets.concat(userDefinedConfig.babelPresets.map(preset => {
if (Array.isArray(preset)) {
return [`${process.cwd()}/node_modules/${preset[0]}`, preset[1]];
}
return `${process.cwd()}/node_modules/${preset}`
}));
const plugins = [
// new WebpackMd5Hash(),
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn/),
// Generate HTML file that contains references to generated bundles. See here for how this works: https://github.com/ampedandwired/html-webpack-plugin#basic-usage
new HtmlWebpackPlugin({
template: `${process.cwd()}/src/index.ejs`,
minify: {
removeComments: false,
collapseWhitespace: false,
removeRedundantAttributes: false,
useShortDoctype: true,
removeEmptyAttributes: false,
removeStyleLinkTypeAttributes: false,
keepClosingSlash: true,
minifyJS: false,
minifyCSS: false,
minifyURLs: false
},
inject: true,
dev: devMode
}),
new MiniCssExtractPlugin({
filename: `${cssPath}[name].css`,
}),
new webpack.DefinePlugin({
// __OLA_CONFIG__: JSON.stringify(userDefinedConfig)
__OLA_PROJECT_PATH__: JSON.stringify(process.cwd()),
__DEV__: devMode,
__OLA_USE_IMMUTABLE__: userDefinedConfig.immutable,
})
];
if (devMode) {
plugins.push(new webpack.HotModuleReplacementPlugin());
}
export default {
resolve: {
extensions: ['*', '.js', '.jsx', '.json'],
modules: [
`${process.cwd()}/src`,
`node_modules`,
`${process.cwd()}/node_modules`,
],
alias: {
'react': path.resolve(path.join(process.cwd(), 'node_modules', 'react')),
'react-dom': path.resolve(path.join(process.cwd(), 'node_modules', 'react-dom')),
'immutable': path.resolve(path.join(process.cwd(), 'node_modules', 'immutable')),
...forceSelfAlias,
},
},
output: {
path: path.resolve(process.cwd(), 'dist'),
publicPath: '/',
filename: devMode ? 'bundle.js' : `${jsPath}[name].js`
},
target: 'web',
plugins: plugins,
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
babelrc: false,
plugins: babelPlugins,
presets: babelPresets,
// https://github.com/webpack/webpack/issues/4039#issuecomment-419284940
sourceType: "unambiguous",
}
}
},
{
test: /\.eot(\?v=\d+.\d+.\d+)?$/,
use: [
{
loader: 'url-loader',
options: {
limit: userDefinedConfig.urlLimit,
name: `${mediaPath}[name].[ext]`
}
}
]
},
{
test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
use: [
{
loader: 'url-loader',
options: {
limit: userDefinedConfig.urlLimit,
mimetype: 'application/font-woff',
name: `${mediaPath}[name].[ext]`
}
}
]
},
{
test: /\.[ot]tf(\?v=\d+.\d+.\d+)?$/,
use: [
{
loader: 'url-loader',
options: {
limit: userDefinedConfig.urlLimit,
mimetype: 'application/octet-stream',
name: `${mediaPath}[name].[ext]`
}
}
]
},
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: 'url-loader',
options: {
limit: userDefinedConfig.urlLimit,
mimetype: 'image/svg+xml',
name: `${mediaPath}[name].[ext]`
}
}
]
},
{
test: /\.(jpe?g|png|gif|ico)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: userDefinedConfig.urlLimit,
name: `${mediaPath}[name][hash:base64:5].[ext]`
}
}
]
},
{
test: /\.(html)$/,
use: {
loader: 'html-loader',
options: {
minimize: true,
removeComments: true,
collapseWhitespace: true,
}
}
},
{
test: /\.css$/,
use: [devMode ? 'style-loader' : {
loader: MiniCssExtractPlugin.loader,
options: {}
}, 'css-loader']
},
{
test: /ola\.less$/,
use: [devMode ? 'style-loader' : {
loader: MiniCssExtractPlugin.loader,
options: {}
}, {
loader: "css-loader",
options: {
minimize: true,
sourceMap: !devMode,
importLoaders: 1,
modules: false,
localIdentName: '[local]--[hash:base64:5]'
}
}, {
loader: "less-loader",
options: {
sourceMap: true,
modifyVars: theme,
javascriptEnabled: true,
paths: [
path.resolve(process.cwd(), "node_modules")
]
}
}]
},
{
test: /\.(less|css)$/,
exclude: [
/ola\.less$/,
],
use: [
devMode ? 'style-loader' : {
loader: MiniCssExtractPlugin.loader,
options: {}
}, {
loader: "css-loader",
options: {
minimize: true,
sourceMap: true,
importLoaders: 1,
modules: true,
localIdentName: '[local]--[hash:base64:5]'
}
}, {
loader: "less-loader",
options: {
sourceMap: true,
modifyVars: theme,
javascriptEnabled: true,
plugins: [
new NpmImportPlugin({prefix: '~'})
]
}
}]
},
...userDefinedConfig.loaders,
]
}
};
- webpack.config.dev.js
import path from 'path';
import CommonConfig from './webpack.config.common';
export default Object.assign(CommonConfig, {
mode: 'development',
devtool: 'cheap-module-eval-source-map',
entry: [
'webpack-hot-middleware/client?noInfo=true&reload=true',
path.resolve(path.join(`${__dirname}`, '../'), 'appEntry/index.js')
],
});
- webpack.config.prod.js
import path from 'path';
import UglifyJsPlugin from 'uglifyjs-webpack-plugin';
import CommonConfig from './webpack.config.common';
import OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin';
export default Object.assign(CommonConfig, {
mode: 'production',
devtool: 'source-map',
entry: {
app: path.resolve(path.join(`${__dirname}`, '../'), 'appEntry/index.js'),
vendor: [
'react', 'react-dom', 'redux', 'react-redux', 'react-router-dom', 'react-router-config',
path.resolve(path.join(`${__dirname}`, '../../'), 'node_modules/connected-react-router'),
'immutable'
]
},
optimization: {
minimize: true,
nodeEnv: 'production',
sideEffects: true,
concatenateModules: true,
splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
},
runtimeChunk: false,
minimizer: [
new UglifyJsPlugin({
sourceMap: true,
parallel: true,
uglifyOptions: {
compress: {
drop_console: true,
drop_debugger: true,
}
},
exclude: [/\.min\.js$/gi]
}),
new OptimizeCssAssetsPlugin({}),
],
}
});
上面代码中的 ./configReader 的具体内容如下:
import babel from '@babel/register';
import merge from 'deepmerge';
import ora from 'ora';
babel({
presets: [require('@babel/preset-env').default],
});
let defaultConfig = {
loaders: [],
urlLimit: 3000,
babelPlugins: [],
babelPresets: [],
devPort: 3000,
previewPort: 4000,
targets: {
"ie": 11,
},
immutable: true,
};
let mergedConfig;
const mergeConfig = () => {
let userDefined;
try {
userDefined = require(`${process.cwd()}/ola-config`).default({
isDev: process.env.NODE_ENV !== 'production',
});
} catch (e) {
userDefined = {};
ora().warn(`import ola-config error: ${e.message}`);
}
return mergedConfig = merge(defaultConfig, userDefined);
};
export default () => mergedConfig || mergeConfig();
上述代码中 ./babel 内容如下:
import path from 'path';
import configReader from "./configReader";
function getPath(item) {
return path.resolve(path.join(__dirname, '../../', 'node_modules', item));
}
function resolve(preset) {
return Array.isArray(preset) ?
[getPath(preset[0]), preset[1]] :
getPath(preset);
}
const commonPlugins = [
['babel-plugin-transform-imports', {
"ola-(.*)": {
transform: (importName, matches) => `ola-${matches[1]}/lib/components/${importName}`
}
}],
["@babel/plugin-proposal-decorators", {
// decoratorsBeforeExport: true,
legacy: true
}],
['@babel/plugin-transform-runtime', {
// 文档未标记配置,用以将 @babel/runtime 指向 cli
absoluteRuntime: path.dirname(
require.resolve('@babel/runtime/package.json')
),
corejs: false,
regenerator: true,
}],
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-function-bind',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-object-rest-spread',
];
const devPlugins = commonPlugins.concat([
'react-hot-loader/babel'
]);
const prodPlugins = commonPlugins.concat([
'@babel/plugin-transform-react-constant-elements',
'babel-plugin-transform-react-remove-prop-types'
]);
export const devPlugin = devPlugins.map(resolve);
export const prodPlugin = prodPlugins.map(resolve);
const {targets} = configReader();
const commonPresets = [
['@babel/preset-env', {
"targets": targets,
// Users cannot override this behavior because this Babel
// configuration is highly tuned for ES5 support
// ignoreBrowserslistConfig: true,
// If users import all core-js they're probably not concerned with
// bundle size. We shouldn't rely on magic to try and shrink it.
useBuiltIns: false,
// Do not transform modules to CJS
modules: false,
// Exclude transforms that make all code slower
exclude: ['transform-typeof-symbol'],
}],
'@babel/preset-react',
];
const devPresets = [].concat(commonPresets);
const prodPresets = [].concat(commonPresets);
export const devPreset = devPresets.map(resolve);
export const prodPreset = prodPresets.map(resolve);
以上代码过多,但是都是在实践中使用的,有需要的同学可以参考噢