全栈项目实践二:完成前端工程化和基础化搭建

75 阅读3分钟

本文内容学习自 哲玄前端 《大前端全栈实践》课程

前端工程化的由来

在互联网早期,前端开发主要是编写简单的 HTML、CSS 和 JavaScript,页面逻辑简单,代码量少。随着 Web 应用复杂度的提升(从内容展示到复杂交互的单页应用 SPA),以及 React、Vue 等框架的兴起,前端开发发生了根本性变化。

  • 项目从几个文件变为成千上万个模块
  • 用户对加载速度和交互体验的要求越来越高
  • 团队开发中,如何保持代码风格、模块依赖、项目结构的一致性

Node.js的出现是前端工程化的关键转折点。它让JavaScript具备了文件操作和网络服务能力,催生了Webpack、Babel、npm等强大的工具链生态,为前端工程化提供了坚实的技术基础。

前端工程化的目标

  • 通过自动化构建工具(如Webpack、Vite)处理编译、打包等重复劳动,让开发者能专注于业务逻辑。
  • 通过模块化(将代码拆分为独立功能单元)和组件化(构建可复用的UI部件)来组织代码,使得代码结构清晰、易于复用和维护。同时,利用ESLint、Prettier等工具强制统一代码风格,并结合自动化测试,确保代码的健壮性和可读性。
  • 通过代码分割、懒加载、Tree Shaking、压缩资源等优化手段,显著减少资源体积,提升应用加载速度和运行时性能。

工程化.png

该项目中工程化的一些配置

多页面的构建

为每个页面配置独立的入口和应用实例 使用 glob 工具动态扫描约定目录 app/pages/**/entry.*.js,为每个找到的入口文件生成一个配置项。通过 webpack 的 entry 和 HtmlWebpackPlugin,为每个入口生成对应的模板(.tpl)文件并自动注入对应的 chunks。这使得增加新页面只需添加符合约定的文件,无需修改构建配置,实现了“约定大于配置”。

  • 采用 tpl 的原因:1. 有动态数据,而且动态数据是由服务端注入进去的。 2. html 往往是静态数据。直接返回 html 无法利用框架的数据绑定能力
// app/webpack/config/webpack.base.js
/**
 * 动态构造 entry 入口配置和最终渲染的页面模板 HtmlWebpackPlugin
 * entry: {
 *   'entry.xxx': './app/pages/entry.xxx.js',
 * }
 *
 * new HtmlWebpackPlugin({
 *   template: path.resolve(process.cwd(), 'app/view/entry.tpl'),
 *   filename: path.resolve(process.cwd(), 'app/public/dist', 'entry.xxx.tpl')
 *   chunks: ['entry.xxx'],
 * })
 */
const entryFileList = glob.sync(path.resolve(process.cwd(), 'app/pages/**/entry.*.js'))
const entry = {}
const HtmlWebpackPluginList = []
entryFileList.forEach(entryFilePath => {
  const entryFileName = path.basename(entryFilePath, '.js')
  entry[entryFileName] = entryFilePath

  HtmlWebpackPluginList.push(
    new HtmlWebpackPlugin({
      // 指定要使用的模板文件
      template: path.resolve(process.cwd(), 'app/view/entry.tpl'),
      // 产物(最终模板)输出路径
      filename: path.resolve(process.cwd(), 'app/public/dist', `${entryFileName}.tpl`),
      // 要注入的代码块, 对应 entry 中的 key
      chunks: [entryFileName],
    })
  )
})

  • Koa 静态服务挂载 app/public ,模板引擎(Nunjucks)负责渲染 .tpl 模板文件, controller 选择注入的入口对应模板,形成完整页面。

分包策略

分包的目的是对代码进行拆分,优化缓存和加载性能。需要综合考虑模块的变更频率、依赖关系和缓存效果。 这里把 js 文件打包成 3 类

  1. vendor: 将 node_modules 中的第三方库单独打包。这些代码基本不改动, 除非依赖版本升级。可以充分利用浏览器长效缓存。
  2. common: 业务组件代码的公共部分抽取出来, 改动较少
  3. entry.{page}: 不同页面 entry 里的业务组件代码的差异部分, 会经常改动
// app/webpack/config/webpack.base.js
module.exports = {
  ...
  optimization: {
    splitChunks: {
      chunks: 'all', // 对同步和异步模块都进行分割
      maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
      maxInitialRequests: 10, // 入口点的最大并行请求数
      cacheGroups: {
        vendor: {
          // 第三方依赖库
          test: /[\\/]node_modules[\\/]/, // 把 node_modules 下的代码抽离出来
          name: 'vendor', // 模块名称
          priority: 20, // 数字越大,优先级越高。默认是 0
          enforce: true, // 强制执行。忽略 minSize、minChunks、maxAsyncRequests、maxInitialRequests 选项
          reuseExistingChunk: true, // 复用已有的公共 chunk
        },
        common: {
          // 公共模块
          test:/[\\/]common|widgets[\\/]/,
          name: 'common',
          priority: 10,
          reuseExistingChunk: true,
          minSize: 1, // 最小分割文件大小(字节)
          minChunks: 2, // 被两处引用即归为公共模块
        },
      },
    },
    // 将 webpack 运行时生成的代码打包到 runtime.js 中
    runtimeChunk: true,
  },
  ...
}

  • optimization.splitChunks: 当 webpack 处理文件路径时,它们始终包含 Unix 系统中的 / 和 Windows 系统中的 \。这就是为什么在 {cacheGroup}.test 字段中使用 [\\/] 来表示路径分隔符的原因。{cacheGroup}.test 中的 /\ 会在跨平台使用时产生问题。
  • runtimeChunk: true :将 Webpack 运行时代码抽到独立的 runtime.js ,减少入口包的无效改动,提升缓存命中。

开发环境与生产环境的区别

开发环境

  • 目标:快速反馈、热更新、较快构建、详细错误报告。

产物经 devServer 内存提供,模板 .tpl 写至 app/public/dist/dev/, 浏览器通过 publicPath 从 devServer 获取资源并进行热更新。(仅 .tpl 输出到磁盘)

  • 修改代码并保存后,几乎能立即在浏览器中看到变化,无需手动刷新页面

关键步骤:devServer、source-map、热更新、 内存系统。

HMR.png

// app/webpack/config/webpack.dev.js
const path = require('path')
const merge = require('webpack-merge')

// 引入基类配置
const webpackBaseConfig = require('./webpack.base')

const HOST = '127.0.0.1'
const PORT = 9002
// 开发环境配置
const webpackConfig = merge.smart(webpackBaseConfig, {
  mode: 'development',
  // 使浏览器可以重构原始源并在调试器中显示重建的原始源
  devtool: 'eval-cheap-module-source-map',
  output: {
    path: path.resolve(process.cwd(), 'app/public/dist/dev'), // 输出文件存储路径
    publicPath: `http://${HOST}:${PORT}/public/dist/dev/`, // 外部资源公共路径
    filename: 'js/[name]-[contenthash:8].bundle.js',
    crossOriginLoading: 'anonymous',
  },
  // 启用 devServer 进行 HMR
  devServer: {
    host: HOST,
    port: PORT,
    static: {
      directory: path.resolve(process.cwd(), 'app/public/dist'),
    },
    devMiddleware: {
      // 需要落地的文件
      writeToDisk: filePath => filePath.endsWith('.tpl'),
    },
    compress: true, // 启用 gzip compression 压缩文件
    hot: true,
    allowedHosts: [HOST],
  },
})

module.exports = webpackConfig

// package.json

{
  "scripts": {
    "build:dev": "webpack serve --config ./app/webpack/config/webpack.dev.js",
  },
}
  • 如果命令中没有 --config ./app/webpack/config/webpack.dev.js,Webpack 会忽略你的配置,使用内置默认配置(默认入口为 ./src),从而导致报错。
手动搭建devServer

在技术选型中使用的是 webpack-dev-middleware 而非 webpack-dev-server,使用 webpack-hot-middleware 依赖以在自定义服务器或应用程序上启用 HMR。

// app/webpack/config/webpack.dev.js
const path = require('path')
const merge = require('webpack-merge')
const webpack = require('webpack')

// 引入基类配置
const webpackBaseConfig = require('./webpack.base.js')

// devServer配置
const HOST = '127.0.0.1'
const PORT = 9002
const HRM_PATH = '__webpack_hmr'
const TIMEOUT = 1000 * 10
const DEV_SERVER_CONFIG = { HOST, PORT, HRM_PATH, TIMEOUT }

// 开发阶段的 entry 配置需要加入 hrm
Object.keys(webpackBaseConfig.entry).forEach(key => {
  webpackBaseConfig.entry[key] = [
    // 主入口文件
    webpackBaseConfig.entry[key],
    // hrm 更新入口,官方指定的 hmr 路径
    `webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HRM_PATH}&timeout=${TIMEOUT}&reload=true`,
  ]
})

const webpackConfig = merge(webpackBaseConfig, {
  // 开发环境配置
  mode: 'development',
  // 使浏览器可以重构原始源并在调试器中显示重建的原始源
  devtool: 'eval-cheap-module-source-map',
  output: {
    filename: 'js/[name]_[chunkhash:8].bundle.js',
    path: path.resolve(process.cwd(), './app/public/dist/dev'), // 输出文件存储路径
    publicPath: `http://${HOST}:${PORT}/public/dist/dev/`, // 外部资源公共路径
    globalObject: 'this',
  },
  plugins: [
    // 热更新插件。允许在应用程式运行时替换模块
    new webpack.HotModuleReplacementPlugin({
      multiStep: false,
    }),
  ],
})

module.exports = {
  webpackConfig,
  DEV_SERVER_CONFIG,
}
// app/webpack/dev.js
// 本地开发启动 devServer
const express = require('express')
const path = require('path')
const consolere = require('consoler')
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')

const { webpackConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev.js')

const app = express()

const compiler = webpack(webpackConfig)

// 指定静态文件目录
app.use(express.static(path.resolve(process.cwd(), './app/public/dist')))

// 引用 webpack-dev-middleware 中间件(监控文件改动)
app.use(
  webpackDevMiddleware(compiler, {
    // 需要落地的文件
    writeToDisk: filePath => filePath.endsWith('.tpl'),
    // 资源路径
    publicPath: webpackConfig.output.publicPath,
    // headers配置
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
    },
    stats: {
      colors: true,
    },
  })
)

// 引用 webpack-hot-middleware 中间件,用于实现热更新功能
app.use(
  webpackHotMiddleware(compiler, {
    path: `/${DEV_SERVER_CONFIG.HRM_PATH}`,
    log: () => {},
  })
)

consolere.info('请等待 webpack 初次构建完成提示...')

const port = DEV_SERVER_CONFIG.PORT
app.listen(port, () => {
  console.log(`app listening on port ${port}\n`)
})

  • 引入了 devMiddleware, 实质资源地址已经被 devMiddleware 这个覆盖
    • publicPath: webpackConfig.output.publicPath
  • devServer 的静态目录实质并没有具体能应用到的场景
    • devServer.static.directory: path.resolve(process.cwd(), 'app/public/dist')
    • app.use(express.static(path.resolve(process.cwd(), './app/public/dist')))

生产环境

  • 目标:性能优先、缓存友好。

产物写至 app/public/dist/prod/;浏览器通过 '/dist/prod/' 前缀加载静态资源。

关键步骤:压缩、混淆,拆分代码

多进程打包
Thread-loader

使用时,需将此 loader 放置在其他 loader 之前。放置在此 loader 之后的 loader 会在一个独立的 worker 池中运行。

  • MiniCssExtractPlugin.loader 需要在加载器链的最前面,它的作用是将 CSS 提取到单独的文件中,而不是嵌入到 JavaScript 文件中,需要直接与 css-loader 配合,因此应该放在加载器链的最前面。

每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。

// app/webpack/config/webpack.prod.js
const path = require('path')
const merge = require('webpack-merge')

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CSSMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin')
const HtmlWebpackInjectAttributesPlugin = require('html-webpack-inject-attributes-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

// 引入基类配置
const webpackBaseConfig = require('./webpack.base')

// 生产环境配置
const webpackConfig = merge.smart(webpackBaseConfig, {
  mode: 'production',
  output: {
    path: path.resolve(process.cwd(), 'app/public/dist/prod'),
    publicPath: '/dist/prod/',
    filename: 'js/[name]-[contenthash:8].bundle.js',
    crossOriginLoading: 'anonymous',
  },
  module: {
    rules: [
      {
        // 多进程打包 js
        test: /\.js$/,
        use: ['thread-loader', 'babel-loader'],
        include: [path.resolve(process.cwd(), 'app/pages')],
      },
      {
        // 多进程打包 css
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'thread-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    // 提取 css 的公共部分,有效利用缓存
    new MiniCssExtractPlugin({
      filename: 'css/[name]-[contenthash:8].bundle.css',
    }),
    // 优化并压缩 css
    new CSSMinimizerWebpackPlugin(),
    // 浏览器在请求资源时不发送用户的身份凭证
    new HtmlWebpackInjectAttributesPlugin({
      crossorigin: 'anonymous',
    }),
  ],
  optimization: {
    // 使用 TerserWebpackPlugin 的并发和缓存,提升压缩阶段的性能
    minimize: true,
    minimizer: [
      new TerserWebpackPlugin({
        cache: true, // 启用缓存来加速构建过程
        parallel: true, // 启用多核 CPU 的优势加快压缩速度
        terserOptions: {
          compress: {
            drop_console: true, // 清除console.log
          },
        },
      }),
    ],
  },
  performance: {
    hints: 'error',
  },
})

module.exports = webpackConfig

HappyPack
// app/webpack/config/webpack.prod.js
const path = require('path')
const os = require('os')
const merge = require('webpack-merge')

const HappyPack = require('happypack')
const happypackCommonConfig = {
  debug: false,
  threadPool: HappyPack.ThreadPool({ size: os.cpus().length }),
}

// 引入基类配置
const webpackBaseConfig = require('./webpack.base.js')

const webpackConfig = merge.smart(webpackBaseConfig, {
  ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'happypack/loader?id=css'],
      },
      {
        test: /\.js$/,
        include: [
          // 只对业务代码进行 babel
          path.resolve(process.cwd(), './app/pages'),
        ],
        use: 'happypack/loader?id=js',
      },
    ],
  },
  plugins: [
    ...
    // 多线程打包 js,加快打包速度
    new HappyPack({
      ...happypackCommonConfig,
      id: 'js',
      loaders: [
        `babel-loader?${JSON.stringify({
          presets: ['@babel/preset-env'],
          plugins: ['@babel/plugin-transform-runtime'],
        })}`,
      ],
    }),
    // 多线程打包 css,加快打包速度
    new HappyPack({
      ...happypackCommonConfig,
      id: 'css',
      loaders: [
        {
          path: 'css-loader',
          options: { importLoaders: 1 },
        },
      ],
    }),
  ],
  ...
})

module.exports = webpackConfig