工程化是软件工程的一种。性能,稳定性,可用性,可维护性,效率等都是工程化需要解决的。
前端的真正的发展,也就这么10年时间,工程化发展还不够完善。
而我们日常的开发,首先要解决创建项目问题。为此vue社区出现了vue-cli, react社区出现了create-react-app,然而这些脚手架只是些通用解决方案,他们不关注上层的设计,比如:统一ajax封装,library external,docker的构建,模板工程约束,固化团队风格的代码规范约束,公共依赖的统一控制,服务的监控报警,拨测,强缓存控制,CDN接入等等。
如果我们有多服务创建需求(庞大应用拆分,多项目),上述的问题,需要手动一个个接入,效率低下。
此时,我们需要一个脚手架来解决这些问题。
本文将以react为例,实现简单的脚手架, 完整的脚手架可以私我~
一. lerna
lerna是一个多包管理工具,他主要解决多个package相互依赖和管理的问题。
一般来说,我们的脚手架cli和模板工程,webpack scripts,以及utils都是 分包管理的,而这些包之间又有相互依赖关系。
更多信息,请参考: github.com/lerna/lerna
初始化项目
lerna init
通过lerna init 初始化一个多包项目,lerna自动为我们创建的lerna.json以及pakcages。这个pakcages就是我们将开发的各个包目录。
创建cli工程
# 创建cli项目
lerna create gw-cli
# 创建webpack scripts
lerna create gw-scripts
# 创建模板工程
lerna create gw-web-template
# 创建eslint, prettier模块
lerna create eslint-config-gw
其他命令:
初始化各个packages依赖
lerna bootstrap
为包相互依赖创建软连接
lerna link
包开发完成后,多包依赖自动发布
发布
lerna publish
二. gw-scripts
cli bin
进入gw-scripts文件夹,创建bin文件夹,bin文件下创建index.js,此时,需要注意的是,package.json中需要添加如下配置:
"bin": {
"gw-scripts": "./bin/index.js"
}
这意味着,当我们执行gw-scripts命令时,将执行bin/index.js内容:
(./bin/index.js):
#!/usr/bin/env node
const spawn = require('cross-spawn');
const chalk = require("chalk");
const args = process.argv.slice(2);
const scriptIndex = args.findIndex(
x => x === 'prod' || x === 'develop'
);
const script = scriptIndex === -1 ? args[0] : args[scriptIndex];
const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : [];
if(['develop', 'prod'].includes(script)) {
/**
* @returns result
* {
* status: 0,
* singal: null,
* output: [],
* pid: xx,
* error: '',
* ...
* }
*/
const result = spawn.sync(process.execPath,
nodeArgs
.concat(require.resolve('../scripts/' + script))
.concat(args.slice(scriptIndex + 1)),
{ stdio: 'inherit' }
);
if(result.signal) {
console.log(`build error : signal = ${result.signal}`);
console.log(`result = ${JSON.stringify(result)}`);
process.exit(1);
}
process.exit(result.status);
}else {
console.log()
console.log( chalk.red(`not exist script : ${script}`) );
console.log();
console.log( chalk.cyan("your can run develop or prod") );
}
安装依赖
yarn add @babel/core@7.12.3
babel-loader@8.1.0
babel-plugin-named-asset-import@0.3.7
babel-preset-react-app@10.0.0
mini-css-extract-plugin@0.6
optimize-css-assets-webpack-plugin@6.0.1
progress-bar-webpack-plugin@2.1.0
sass-loader@10.0.5
style-loader@1.3.0
url-loader@4.1.1
配置react webpack dev
仅供参考
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ProgressBarPlugin = require("progress-bar-webpack-plugin");
const paths = require('./paths');
process.env.NODE_ENV = 'development';
const hasJsxRuntime = (() => {
try {
require.resolve('react/jsx-runtime');
return true;
} catch (e) {
return false;
}
})();
const config = {
entry: paths.appIndexJs,
output: {
filename: 'bundle.js',
path: paths.appBuild,
publicPath: "/",
library: paths.appName,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${paths.appName}`,
},
resolve: {
alias: {
"@": paths.appSrc
},
extensions: [".js", ".json", ".jsx", ".css", ".scss", ".tsx"]
},
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
"antd": "antd"
},
module: {
strictExportPresence: true,
rules: [
{
parser: {
// require.ensure 不是标准,不允许使用
requireEnsure: false
}
},
{
oneOf: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: hasJsxRuntime ? 'automatic' : 'classic',
},
],
],
// webpack 未来的提案,将缓存编译的结果在: ./node_modules/.cache/babel-loader/
cacheDirectory: true,
cacheCompression: false,
compact: false
}
},
// css module
{
test: /\.module\.css$/,
use: [
'style-loader',
'css-loader'
]
},
// // scss
{
test: /\.(scss|sass)$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
},
// // sass module
{
test: /\.module\.(scss|sass)$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
}
]
}
]
},
plugins: [
new HtmlWebpackPlugin({
inject: true,
filename: "index.html",
template: "public/index.html"
}),
new ProgressBarPlugin()
],
node: {
module: 'empty',
dgram: 'empty',
dns: 'mock',
fs: 'empty',
http2: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
performance: false,
mode: 'development'
}
module.exports = config;
react webpack prod
仅供参考
const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ProgressBarPlugin = require("progress-bar-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const paths = require('./paths');
process.env.NODE_ENV = 'production';
process.env.BABEL_ENV = 'production';
module.exports = {
mode: "production",
bail: true,
devtool: false,
entry: paths.appIndexJs,
output: {
path: paths.appBuild,
filename: "static/js/[name].[chunkhash:8].js",
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
publicPath: '',
library: paths.appName,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${paths.appName}`,
devtoolModuleFilenameTemplate: info => path.resolve(paths.appSrc, info.absoluteResourcePath).replace(/\\/g, "/")
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
parse: {
ecma: 8
},
compress: {
ecma: 5,
warnings: false,
comparisons: false
},
mangle: {
safari10: true
},
output: {
ecma: 5,
comments: false,
ascii_only: true
}
},
parallel: true,
cache: true,
sourceMap: false
}),
new OptimizeCSSAssetsPlugin({
// 启用 cssnano safe模式
cssProcessorOptions: {
safe: true
}
})
],
splitChunks: {
chunks: "all",
name: true,
maxInitialRequests: Infinity
},
runtimeChunk: true
},
resolve: {
alias: {
"@": paths.appSrc
},
extensions: [".js", ".json", ".jsx", ".css", ".scss", ".tsx"]
},
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
"antd": "antd"
},
module: {
strictExportPresence: true,
rules: [
{
parser: {
// require.ensure 不是标准,不允许使用
requireEnsure: false
}
},
{
oneOf: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: 'automatic',
},
],
],
// webpack 未来的提案,将缓存编译的结果在: ./node_modules/.cache/babel-loader/
cacheDirectory: true,
cacheCompression: false,
compact: false
}
},
// css module
{
test: /\.module\.css$/,
use: [
'style-loader',
'css-loader'
]
},
// scss
{
test: /\.(scss|sass)$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
},
// sass module
{
test: /\.module\.(scss|sass)$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
}
]
}
]
},
plugins: [
new HtmlWebpackPlugin({
inject: true,
filename: "index.html",
template: "public/index.html"
}),
new ProgressBarPlugin(),
new MiniCssExtractPlugin({
filename: "static/css/[name].[contenthash:8].css",
chunkFilename: "static/css/[name].[contenthash:8].chunk.css"
}),
new ManifestPlugin({
fileName: "asset-manifest.json"
}),
],
node: {
dgram: "empty",
fs: "empty",
net: "empty",
tls: "empty",
child_process: "empty"
},
performance: false
}
校验开发环境端口使用情况
const chalk = require('chalk');
const detect = require('detect-port-alt');
function choosePort(host, defaultPort) {
return detect(defaultPort, host).then(
port => {
return new Promise(resolve => {
if (port === defaultPort) {
return resolve(port);
}else {
console.log(chalk.red(`Something is already running on port ${defaultPort}`))
return resolve(null);
}
})
},
err => {
throw new Error(
chalk.red(`error at ${chalk.bold(host)}.`) +
'\n' +
('Network error message: ' + err.message || err) +
'\n'
);
}
)
}
merge config
自定义webpack配置,可以放置根目录,如新建 gw.config.js,使用webpack-merge 合并配置
创建dev compiler 对象
function createCompiler(webpack, config) {
let compiler;
try {
compiler = webpack(config);
} catch (err) {
console.log(chalk.red('Failed to compile.'));
console.log();
console.log(err.message || err);
console.log();
process.exit(1);
}
compiler.plugin('invalid', () => {
console.log('Compiling...');
});
compiler.plugin('done', stats => {
const messages = stats.toJson({}, true)
const isSuccessful = !messages.errors.length && !messages.warnings.length;
// 成功
if (isSuccessful) {
console.log(chalk.green('Compiled successfully!'));
}
// error
if (messages.errors.length) {
console.log(chalk.red('Failed to compile.\n'));
console.log(messages.errors.join('\n\n'));
return;
}
// 警告
if (messages.warnings.length) {
console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(messages.warnings.join('\n\n'));
}
});
return compiler;
}
配置dev server
module.exports = function (proxy, allowedHost) {
return {
disableHostCheck: true,
compress: true,
clientLogLevel: 'none',
contentBase: paths.appPublic,
watchContentBase: true,
hot: true,
publicPath: config.output.publicPath,
quiet: true,
watchOptions: {
ignored: '/node_modules/',
poll: true
},
host: host,
overlay: true, // 编译出现错误时,将错误直接显示在页面上
historyApiFallback: {
disableDotRule: true,
},
stats: "normal",
public: allowedHost,
proxy,
before(app) {
// console.log('before',app)
},
after(app) {
// console.log('after')
},
};
};
创建dev server实例
const devServer = new WebpackDevServer(compiler, serverConfig);
devServer.listen(port, HOST, err => {
if(err) {
console.log(err);
return;
}
["SIGINT", "SIGTERM"].forEach(function(sig) {
process.on(sig, function() {
devServer.close();
process.exit();
});
});
});
三. eslint-config-gw
关于eslint重要性,这里不再赘述。 eslint的所有规则 不一定都要使用,而是使用 适合团队的 常用规范即可。
eslint规则,可以封装成单独模块,可以使用在web端, react native等,利用eslint extend ,我们就可以轻松的使用 公共基础规则。
基础rules
{
// 使用驼峰
"camelcase": 1,
// 禁用为声明的变量
"no-undef": 2,
// 禁止扩展原生类型,比如添加Object的原型
"no-extend-native": 2,
// 禁止在return语句中,使用赋值
"no-return-assign": 2,
// 自动调整import顺序
"import/order": 0,
// 禁止导入未在package.json中声明的依赖,可能是父级中有声明
"import/no-extraneous-dependencies": 0,
// commonjs的require与esm不同,可以提供运行时的动态解析,虽然有的场景需要使用,但建议还是静态化
"import/no-dynamic-require": 0,
// 某些文件解析算法运行省略引用文件扩展名,此处不需要强干预
"import/extensions": 0,
// 导入模块是文本文件系统的模块,此处关闭,避免eslint不认识webpack alias
"import/no-unresolved": 0,
// 当模块只有1个导出的时候,更喜欢使用export default,而不是命名导出
"import/prefer-default-export": 0,
// 禁止出现未使用的变量
"no-unused-vars": [2, { "vars": "all", "args": "none" }],
// 强制generate函数中的 * 周围使用一致的空格
"generator-star-spacing": 0,
// 禁止使一元操作符,此处不需要禁止
"no-plusplus": 0,
// 要求使用命名的function表达式,此处不需要
"func-names": 0,
// 禁止使用console.log,某些场景可能需要console打印日志
"no-console": 0,
// 强制在parseInt中使用基数参数,此处可以不需要
"radix": 0,
// 禁止正则表达式中使用控制语句,比如:/\x1f/
"no-control-regex": 2,
// 禁止使用continue,有些情况可能需要
"no-continue": 0,
// 禁止使用debugger,生产环境是不允许的,开发环境可以支持debugger
"no-debugger": process.env.NODE_ENV === 'production' ? 2 : 0,
// 禁止对function参数进行赋值,此处不强求,会警告
"no-param-reassign": 1,
// 禁止标识符出现 '_', 有时候lodash会出现,此处不强求,会警告
"no-underscore-dangle": 1,
// 要求require写在代码最上方,可能会先需要动态require的情况,此处给出警告
"global-require": 1,
// 定义变量,要求使用let, const,而不是 var
"no-var": 2,
// 要求所有的var,必须在作用域的顶部
"vars-on-top": 2,
// 优先使用对象和数组解构
"prefer-destructuring": 1,
// 禁止不必要的字符串字面量或模板字面量链接
"no-useless-concat": 1,
// 禁止声明与外层作用域同名的变量
"no-shadow": 2,
// 要求for in中,必须出现一个if语句,而不去过滤结果,可能出现bug,此处不需要
"guard-for-in": 0,
// 禁止使用特定语法,可以参考:https://cn.eslint.org/docs/rules/no-restricted-syntax
"no-restricted-syntax": 1,
// 强制方法必须有返回值,实际上是不一定
"consistent-return": 0,
// 判断相等,必须使用 === ,而不是 ==
"eqeqeq": 2,
// 禁止出现未使用过的表达式
"no-unused-expressions": 1,
// 在块级作用域外访问块内的变量,是否提示
"block-scoped-var": 1,
// 禁止多次声明,同一个变量
"no-redeclare": 2,
// 强制回调函数使用箭头函数
"prefer-arrow-callback": 1,
// 强制数组的回调方法中,需要return
"array-callback-return": 1,
// 要求switch语句,需要有default分支
"default-case": 2,
// 禁止在循环中,出现function声明和表达式
"no-loop-func": 2,
// 禁止case语句落空
"no-fallthrough": 2,
// 禁止连接赋值,let a = b = c = 1;
"no-multi-assign": 2,
// 禁止 if单独出现在 else语句中,else if会更加清晰
"no-lonely-if": 2,
// 禁止在字符串中不规范的空格,注释除外
"no-irregular-whitespace": 2,
// 要求使用const声明不再修改的变量
"prefer-const": 2,
// 禁止在定义变量之前使用变量
"no-use-before-define": 2,
// 禁止不必要的转义字符
"no-useless-escape": 2,
// 禁用array构造函数,可以参考:https://eslint.org/docs/rules/no-array-constructor#disallow-array-constructors-no-array-constructor
"no-array-constructor": 0,
// 要求或禁止字面量中的方法属性简写
"object-shorthand": 0,
// 禁止直接调用Object.prototype内置属性
"no-prototype-builtins": 2,
// 禁止多层嵌套三元表达式
"no-nested-ternary": 2,
// 禁止对String, Number, Boolean使用new操作符,没有任何理由将这些基本类型包装成构造函数
"no-new-wrappers": 2,
// promise reject的原因,需要通过Error对象包装
"prefer-promise-reject-errors": 1,
// 禁止使用label语句
"no-labels": 2,
// 格式化
"prettier/prettier": 2
}
vscode集成
- 首先安装vscode eslint插件,安装perttier插件。
- 配置vscode -> 文件 -> 首选项 -> setting.json,设置如下规则:
{
"eslint.validate": [
{
"language": "vue",
"autoFix": true
},
{
"language": "html",
"autoFix": true
},
{
"language": "javascript",
"autoFix": true
},
{
"language": "javascriptreact",
"autoFix": true
}
],
"eslint.autoFixOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"window.zoomLevel": 0,
"editor.fontSize": 16
}
四. gw-web-template
react web项目,需要一个模板工程。 控制每个项目的 react, react-router, react-dom, antd,以及库的版本号很重要。基础库的建设,组件库的建设,以及后期接入更友好的接入微前端,都十分重要。
模板工程
一个模板工程,应该内置这些(不局限于):
- react, react-dom, react-router, antd, reset.css 等基础库,并CDN引入
- 静态资源的构建path,以及上层的ingress的强缓存控制
- 合理的code split,以及hash chunk控制
- 统一css模块化方案,这里采用 css module
- 前端容器化部署,构建私有镜像库,内置符合业务的dockerfile。以及合理的镜像资源优化(如:镜像分层)
- 内置eslint
- 内置prettier
- 集成单元测试
- 常见的gitigore
- 内置ts,以及合理的tsconfig设置
- 统一ajax库引入,需要统一封装。可以是直接调用,也可以是hooks方式调用
- 集成redux,react-router,路由权限控制
- 自定义hooks库
- 自定义组件库
- 构建脚本,集成运维CI,CD
- 接入上层的服务拨测程序,或k8s的心跳
- 接入自定义异常监控服务
- 约束制定统一的规范组件,友好的接入前端自动化测试
- 合理的模块化方案,统一assert Path前缀,以便在上层的前端ingress转发和收集信息
- ...
以上都是模板工程必须做的,同时对于脚手架使用者都是 屏蔽的。 即脚手架使用者不关注这些,开发人员只需投入业务开发即可。
工程结构
工程结构并非一成不变,这只是团队的约束规范,大家统一遵守。
以下仅供参照
|--build 打包的结果文件夹
|--public 公共文件夹
|--src 源码文件夹
|-- api 接口定义
|-- assets iconfont等
|-- config 配置类文件夹,如axios配置
|-- hooks 项目级hooks公共库
|-- components 项目级组件库
|-- model 数据存储,如 redux
|-- page 视图层文件夹
|-- router 路由定义文件夹
|-- util 工具类文件夹
|-- index.tsx 主入口
|--types typescript 类型定义文件夹
|--.editorconfig
|--.eslintignore
|--.eslintrc.js
|--.gitattributes
|--.gitignore
|--.npmignore
|--.prettierignore
|--build.sh 构建脚本
|--Dockerfile
|--package.json
|--README.md
|--run.sh 容器镜像构建时需要执行的shell
|--tsconfig.json ts规则配置
五. cli
以上模块准备结束后,我们需要做一个npm模块,让用户安装。 自定义交互命令。这就是cli的主要作用。
理论上,命令行交互模块只做 交互的事件, scripts和模板工程以及其他模块,应该单独拆分,便于复用。
命令行交互设计,也取决于各个公司的实际情况。
大致可以分个大类:
- react web
- react native
- 文档库
- 组件库
- 微前端基座
- 微前端子服务
- hooks库
- ...
注意: 二级交互,不需要设置 eslint,ts等是否可选, 对于整个团队来说,最好统一规范,统一使用。 不必借鉴vue-cli, cra去做。 因为大家面对的 使用者 不一样,最终的目的也不一样。
设置全局命令
package.json中添加:
"bin": {
"gw": "./index.js"
},
校验node版本
const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = semver[0];
if (major < 10) {
console.error(
'You are running Node ' +
currentNodeVersion +
'.\n' +
'gw cli requires Node 10 or higher. \n' +
'we recommand 10.18.0'
);
process.exit(1);
}
commander
使用commander获取用户输入命令。
比如,我们希望
gw create <project-name>
代表创建一个项目。
我们也可以集成运行时,借鉴Ruby on Rails的交互,可以自动创建文件。
我们希望,通过如下命令,自动创建页面所需的index.tsx, index.module.css, dto.ts, index.test.js等等。
gw page <page-path>
等等...
我们可以自由发挥,但目的只有一个: 让重复性的工作交给机器
设置交互
交互模块使用 inquirer,可以做如下封装:
const inquirer = require("inquirer");
module.exports = function(callback) {
const prompt = inquirer.createPromptModule();
prompt([
{
name: "type",
type: 'list',
message: "'Which basic framework do you want to choose!",
choices: [
{ name: "react web", value: "web" },
{ name: "文档库", value: "doc" },
{ name: "组件库", value: 'component' },
{ name: "react native app", value: "react-native" },
{ name: "hooks库", value: "hooks" },
{ name: "微前端基座", value: "micro-pedestal" },
{ name: "微前端子服务", value: "micro-service" }
]
}
]).then(answer => {
callback(answer.type);
});
}
create project
当我们选择创建react-web时,我们如何获取模板工程?
大致有三种方式:
-
远程clone模板工程
-
npm安装模板工程,再cp -r *
-
模板工程和cli放在一起,直接cp -r *
这里,我们可以选择 npm 安装, 个人建议这种。
安装代码,可参考:
/*
* @param {String} root 安装目录
* @param {Array<string>} dependencies 依赖数组
* @param {Boolean} verbose
*/
function install(root, dependencies, verbose) => {
return new Promise((resolve, reject) => {
let command = 'yarnpkg';
let args = ['add', '--exact'];
[].push.apply(args, dependencies);
args.push('--cwd');
args.push(root);
if (verbose) {
args.push('--verbose');
}
const child = spawn(command, args, { stdio: 'inherit' });
child.on('close', code => {
if (code !== 0) {
reject({
command: `${command} ${args.join(' ')}`,
});
return;
}
resolve();
});
});
}
效果展示
六. 总结
前端的工程化是每位高工必须专研的方向,工程化 !== webpack,而整个大前端工程化都 基于 nodejs。 nodejs可以说是前端工程师的必修课啦~
码字不易,多多点赞关注~