前端工程化

492 阅读7分钟

工程化

一套好的工程化解决方案,能在提高开发效率的同时,确保整个系统的伸缩性(各种不同的部署环境)及健壮性(安全),同时在性能上又能有一个很优异的表现(主要是各种缓存策略加载策略等),而且这套方案又应该是对工程师无感知(或感知很小)趋于自动化的一套方案。

为什么需要工程化 ?

  • 开始一个项目时,需要安装一系列的插件,在使用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配置文件,仅供大家参考学习(其中./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);

以上代码过多,但是都是在实践中使用的,有需要的同学可以参考噢

THE END ~