构建一个基于react+express多页面网站应用-开发环境(前端篇)

887 阅读9分钟

[toc]

为什么要自己配置 webpack

目前社区提供的基于webpack的前端打包方案很多 umi create-react-app 等,但是查看对应的文档后,它们所能配置的MPA(多页面应用),均不是我想要的;

既然很多整合的打包方案基于webpack,又得知在webpack4 以后配置简化了很多,并且构建速度更快,为什么不按照自己的需求配置一套webpackMPA打包方案呢?了解了webpack后,再使用 umi之类二次封装的工具会更得心应手吧.

本文仅用于记录学习,文章中出现的代码托管在: frmachao/webpack-examples

网站目录结构

后端工程和前端工程 在同一个工程中,前端编译TS/JSX文件后交给服务端使用

这可能不是一个好的方式

|--
    |--build
        |--build.js # 构建脚本 例如:node build/build.js all --build
        |--rules.js # 
        |--utils.js
        |--webpack.common.js
        |--webpack.dev.js
        |--webpack.prod.js
    |--dist # 前端打包后的资源没有`html`文件,`html`交给服务端处理
        |--mpa
            |--page1
                |--index.js
            |--page2
                |--index.js
            |--verdor.js
        |--spa1
        |--xxx
    |--server # 网站后端
        |--dist # 后端构建
        |--src
            |--middlewares
            |--controllers
            |--models
            |--app.ts
        |--public
        |--view
    |--site # 网站前端
        |--mpa #多页面应用资源目录
            |--page1
                |--index.tsx
            |--page2
        |--spa1 # 单页面应用
        |--spa2
        |--xxxx

遇到的问题

  • CleanWebpackPlugin is not a constructor
    • 将引用方式改为 const { CleanWebpackPlugin } = require("clean-webpack-plugin");
  • 新版本的CleanWebpackPlugin 不知道为啥 不能清除dist 目录,暂时先用旧版本的
  • css module 和 antd 冲突
    • 解决
  • eslint 与路径 别名冲突
    • 解决
  • **当仓库根目录存在两个文件夹时 eslint 不生效 **

自己加的一些插件

当我想通过 npm run watch-mpa 来执行开发时构建所有的多页面应用,那么就需要方便的拿到 命令行中参数

    "build": "node build/build.js all --build",
    "build-mpa": "node build/build.js site/mpa --build",
    "watch-mpa": "node build/build.js site/mpa --watch",

dotenv 用于从文件中加载环境变量。环境变量用于程序运行时动态加载参数,除了环境变量,我们还可以在启动Node.js 程序时直接指定命令行参数:

node index.js --beep=boop -t -z 12 -n5 foo bar
console.log(process.argv);
// ['/bin/node', '/tmp/index.js', '--beep=boop', '-t', '-z', '12', '-n5', 'foo', 'bar']

process.argv 变量是一个数组,数组前两项分别是 node 程序位置和js脚本位置,数组中随后的元素都是我们启动Node.js后的参数,这些参数以空格分隔成数组

虽然从 process.argv 中可以得到启动参数列表,但是我们仍需要对参数进行进一步解析处理才行。

minimist minimist 是一个专门用于处理Node.js启动参数的库,可以将 process.argv 中的参数列表转换成更加易于使用的格式:

node index.js --beep=boop -t -z 12 -n5 foo bar
const argv = require('minimist')(process.argv.slice(2));
console.dir(argv);
// { _: [ 'foo', 'bar' ], beep: 'boop', t: true, z: 12, n: 5 }

配置typescript支持

babel 7 @babel/preset-typescript + babel-loader 的方案:

优点:

  • 编译速度块,可以使用babel生态中的插件 比如 antd 的 babel-plugin-import(当初折腾fuse-box配置时就是因为它放弃了..)

缺点:

  • 有四种语法在 babel 中是无法编译的
    • Namespace语法不推荐,改用标准的 ES6 module(import/export)。
    • 不支持x 语法转换类型,改用x as newtype。
    • const 枚举
    • 历史遗留风格的 import/export 语法。比如:import foo = require(...) 和 export = foo。

第1条和第4条不用,而且已经过时了。

第2条缺陷改一下语法就好了,这个语法会直接提示语法报错,很好改,问题不大。

第3条缺陷已经没有了。

  • 没有了类型检查

VSCode自带 TS 检测,个人项目来说所以这也不是问题

使用中遇到的问题:

  • ts 无法使用 JSX,除非提供了 "--jsx" 标志
    • 定义 tsconfig 文件 在里面声明对jsx的支持,注意:我们并不使用tsc来编译,仅仅是开发时让ide有提示
  • webpack 使用 @babel/preset-typescript 编译ts 打包时,会找不到别名的路径
    • 自动解析确定的扩展名 这个大坑

参考:

babel 配置

当前使用的babel版本:"@babel/core": "^7.9.0", 一定要看跟自己使用版本对应的文档!! babel-preset-env

  • 使用babel 的目的:
    • 使用最新的 TS/JS 语法,甚至一些处于提案阶段的特性
    • babel转译后兼容更多的浏览的版本

babel 这块的配置,我当时主要卡在 @babel/preset-env 的useBuiltIns 选项 和@babel/plugin-transform-runtime 插件。因为babel 在转译高版本JS代码时,只会转译语法不会处理新的API,看下面的例子:

// src/index.js
const add = (a, b) => a + b
  
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise()[object Object]

babel执行后

// dist/index.js
"use strict";
  
var add = function add(a, b) {
  return a + b;
};
  
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise();

Babel 把 ES6 的标准分为 syntax 和 built-in 两种类型。syntax 就是语法,像 const、=> 这些默认被 Babel 转译的就是 syntax 的类型。而对于那些可以通过改写覆盖的语法就认为是 built-in,像 includes 和 Promise 这些都属于 built-in。而 Babel 默认只转译 syntax 类型的,对于 built-in 类型的就需要通过 @babel/polyfill 来完成转译。@babel/polyfill 实现的原理也非常简单,就是覆盖那些 ES6 新增的 built-in。示意如下:

Object.defineProperty(Array.prototype, 'includes',function(){
  ...
})

babel在7.4版本中废弃 @babel/polyfill ,而是通过 core-js 替代; 在 @babel/preset-env 中通过 useBuiltIns 参数来控制 built-in 的注入; useBuiltIns 这个选项配置@babel/preset-env 处理 polyfills 的方式,它可以设置为 'entry'、'usage' 和 false 。默认值为 false,不注入垫片。

  • 'entry' 时,只需要在整个项目的入口处,导入 core-js 即可。
  • 'usage' 时,就不用在项目的入口处,导入 core-js了,Babel 会在编译源码的过程中根据 built-in 的使用情况来选择注入相应的实现
// src/index.js
const add = (a, b) => a + b
  
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise()
  
// dist/index.js
"use strict";
  
require("core-js/modules/es6.promise");
  
require("core-js/modules/es6.object.to-string");
  
require("core-js/modules/es7.array.includes");
  
var add = function add(a, b) {
  return a + b;
};
  
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise();

继续优化 引入 @babel/plugin-transform-runtime 插件: 在介绍 @babel/plugin-transform-runtime 的用途之前,先前一个例子:

// src/index.js
const add = (a, b) => a + b
  
const arr = [1, 2]
const hasThreee = arr.includes(3)
new Promise(resolve=>resolve(10))
  
class Person {
  static a = 1;
  static b;
  name = 'morrain';
  age = 18
}
  
// dist/index.js
"use strict";
  
require("core-js/modules/es.array.includes");
  
require("core-js/modules/es.object.define-property");
  
require("core-js/modules/es.object.to-string");
  
require("core-js/modules/es.promise");
  
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
  
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
  
var add = function add(a, b) {
  return a + b;
};
  
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise(function (resolve) {
  return resolve(10);
});
var Person = function Person() {
  _classCallCheck(this, Person);
  
  _defineProperty(this, "name", 'morrain');
  
  _defineProperty(this, "age", 18);
};
  
_defineProperty(Person, "a", 1);
  
_defineProperty(Person, "b", void 0);

在编译的过程中,对于 built-in 类型的语法通过 require("core-js/modules/xxxx") polyfill 的方式来兼容,对于 syntax 类型的语法在转译的过程会在当前模块中注入类似 _classCallCheck 和 _defineProperty 的 helper 函数来实现兼容。对于一个模块而言,可能还好,但对于项目中肯定是很多模块,每个模块模块都注入这些 helper 函数,势必会造成代码量变得很大。

而 @babel/plugin-transform-runtime 就是为了复用这些 helper 函数,缩小代码体积而生的。当然除此之外,它还能为编译后的代码提供一个沙箱环境,避免全局污染。

  • @ babel/runtime + @babel/plugin-transform-runtime
npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime

@babel/plugin-transform-runtime 是编译时使用的,安装为开发依赖,而 @babel/runtime 其实就是 helper 函数的集合,需要被引入到编译后代码中,所以安装为生产依赖

// 修改babel 配置
const presets = [
  [
    '@babel/env',
    {
      debug: true,
      useBuiltIns: 'usage',
      corejs: 3,
      targets: {}
    }
  ]
]
const plugins = [
  '@babel/plugin-proposal-class-properties',
  [
    '@babel/plugin-transform-runtime'
  ]
]
  
  • 之前的例子,再次编译后,可以看到,之前的 helper 函数,都变成类似require("@babel/runtime/helpers/classCallCheck") 的实现了。
// dist/index.js
"use strict";
  
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
  
require("core-js/modules/es.array.includes");
  
require("core-js/modules/es.object.to-string");
  
require("core-js/modules/es.promise");
  
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
  
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
  
var add = function add(a, b) {
  return a + b;
};
  
var arr = [1, 2];
var hasThreee = arr.includes(3);
new Promise(function (resolve) {
  return resolve(10);
});
  
var Person = function Person() {
  (0, _classCallCheck2["default"])(this, Person);
  (0, _defineProperty2["default"])(this, "name", 'morrain');
  (0, _defineProperty2["default"])(this, "age", 18);
};
  
(0, _defineProperty2["default"])(Person, "a", 1);
(0, _defineProperty2["default"])(Person, "b", void 0);

最终项目中的babel配置:

    {
        test: /\.(js|jsx|tsx)$/,
        // include: [path.join(__dirname, '../site')],
        exclude: /(node_modules|bower_components)/,
        use: {
            loader: "babel-loader",
            options: {
                cacheDirectory: true,
                // presets执行顺序 倒叙
                presets: [
                    [
                        "@babel/preset-env",
                        process.env.NODE_ENV === "production"
                            ? {
                                targets: {
                                    // 浏览器占有率 >5% 最新的两个版本 火狐最新版 ie9 以下
                                    browsers: [
                                        "> 5%",
                                        "last 2 versions",
                                        "Firefox ESR",
                                        "not ie <= 9",
                                    ],
                                },
                                useBuiltIns: "usage",
                                corejs: { version: 3, proposals: true },
                            }
                            : {
                                targets: { browsers: ["last 2 Chrome versions"] }, // 想让开发环境构建的更快一些 只针对 chrome 最新的两个版本编译
                            },
                    ],
                    "@babel/preset-react",
                    ["@babel/preset-typescript"],
                ],
                plugins: [
                    // 开启对处于实验性质 ES 提案支持 ,忘记当初为什么配置这个了
                    ["@babel/plugin-proposal-decorators", { "legacy": true }],// 装饰器支持
                    ["@babel/plugin-proposal-class-properties", { "loose": true }],// 类内部静态属性支持
                    // 配置antd desgin 按需加载
                    [
                        "import",
                        {
                            libraryName: "antd",
                            libraryDirectory: "es",
                            style: "css", // `style: true` 会加载 less 文件
                        },
                    ],
                    // 避免在编译后的输出中重复代码 以及 避免全局变量污染
                    "@babel/plugin-transform-runtime",
                    // 懒加载 前端路由按需加载
                    "@babel/plugin-syntax-dynamic-import"
                ],
            },
        },
    },

参考:

使用ESLint+Prettier规范React+Typescript项目

参考:

webpack loaders中的include/exclude有什么用?

其实就是官方文档中有写,甚至就在配置的开头,唉 看东西要有耐心啊,完整的看完文档真的是基本要求

    rules: [
      // 模块规则(配置 loader、解析器等选项)
      {
        test: /\.jsx?$/,
        include: [
          path.resolve(__dirname, "app")
        ],
        exclude: [
          path.resolve(__dirname, "app/demo-files")
        ],
        // 这里是匹配条件,每个选项都接收一个正则表达式或字符串
        // test 和 include 具有相同的作用,都是必须匹配选项
        // exclude 是必不匹配选项(优先于 test 和 include)
        // 最佳实践:
        // - 只在 test 和 文件名匹配 中使用正则表达式
        // - 在 include 和 exclude 中使用绝对路径数组
        // - 尽量避免 exclude,更倾向于使用 include
        issuer: { test, include, exclude },
        // issuer 条件(导入源)
        enforce: "pre",
        enforce: "post",
        // 标识应用这些规则,即使规则覆盖(高级选项)
        loader: "babel-loader",
        // 应该应用的 loader,它相对上下文解析
        // 为了更清晰,`-loader` 后缀在 webpack 2 中不再是可选的
        // 查看 webpack 1 升级指南。
        options: {
          presets: ["es2015"]
        },
        // loader 的可选项
      },
      ]

webpack 文档

webpack 打包优化

runtimeChunk? 这个我没搞懂 它是不是真的需要

构建脚本 ./build/build.js

const cp = require('child_process');
const path = require('path');
const fs = require('fs');
const { getEntry } = require('./utils')

const buildRoot = path.join(__dirname)
const projectRoot = path.join(buildRoot, '..')
const distRoot = path.join(projectRoot, '/dist')
/**
 * 开发中产出dist目录交给服务端使用 node build.js [site/map|spa1|all]  --watch
 * 构建生产环境 node build.js all --build
 */
const argv = require('minimist')(process.argv.slice(2));
let hasWatch = argv.watch ? '--watch' : ''
isBuild = argv.build ? `${buildRoot}/webpack.prod.js` : `${buildRoot}/webpack.dev.js`
fileName = argv._[0];
isHashName = argv.build ? '[name][hash]' : '[name]'
if (argv.build) {
    process.env.NODE_ENV = 'production'
}
if (fileName === 'all') {
    fs.readdirSync(`${projectRoot}/site`).forEach(file => {
        const blockFiles = ['.DS_Store', 'common', 'mpa']
        if (blockFiles.indexOf(file) === -1) {
            buildSPA(file)
        }
    });
    buildMPA()
    return false;
}
if (fileName === 'site/mpa') {
    buildMPA()
    return false;
}
buildSPA(fileName)

function buildMPA() {
    let ENTRY_FILESNAME = {};
    OUT_PUT_STR = `${distRoot}/mpa/${isHashName}.js --output-public-path=/fe-static/mpa/`;
    if (!fs.readdirSync(`${projectRoot}/site/mpa`)) {
        console.log('err:there is no such project。');
        return false;
    }
    // 遍历文件夹目录 获取到多页面文件夹名称 
    // getEntry(): 处理成 <name> = <request> 的键值对 来动态生成 <entry>
    // 最终像这样:datum/index=/Users/ma/DEV/webSite/site/mpa/datum/index.tsx game/index=/Users/ma/DEV/webSite/site/mpa/game/index.tsx 
    fs.readdirSync(`${projectRoot}/site/mpa`).forEach(file => {
        const blockFiles = ['.DS_Store']
        if (blockFiles.indexOf(file) === -1) {
            ENTRY_FILESNAME[`${file}/index`] = (`${projectRoot}/site/mpa/${file}/index.tsx`);
        }
    });
    buildOne(getEntry(ENTRY_FILESNAME), OUT_PUT_STR);
}
function buildSPA(name) {
    console.log('buildSPA===', name)
    let ENTRY_FILESNAME = {};
    let OUT_PUT_STR = `${distRoot}/${name}/${isHashName}.js --output-public-path=/fe-static/${name}/`;
    if (!fs.readdirSync(`${projectRoot}/site`).find(file => file === name)) {
        console.log(`err!!!!!!!!:${projectRoot}/site is no such project。`);
        return false;
    }
    ENTRY_FILESNAME[`index`] = (`${projectRoot}/site/${name}/index.tsx`);
    buildOne(getEntry(ENTRY_FILESNAME), OUT_PUT_STR);
}
function buildOne(ENTRY_FILESNAME_STR, OUT_PUT_STR) {
    console.log('ENTRY_FILESNAME_STR===========>', ENTRY_FILESNAME_STR)
    // -o <output> 它将被映射到 output.path 和 output.filename 的配置选项
    const cpBuild = cp.exec(
        `npx webpack ${hasWatch} ${ENTRY_FILESNAME_STR} --config ${isBuild} -o ${OUT_PUT_STR}`,
        (error, stdout, stderr) => {
            if (error) {
                console.error('error:', error);
            }
        }
    )
    cpBuild.stdout.on('data', (data) => {
        console.log('on stdout: ' + data);
    });
    cpBuild.stderr.on('on data', (data) => {
        if (data) {
            console.log('on stderr: ' + data);
        }
    });
}

如何构建前端工程

   开发中产出dist目录交给服务端使用 node build.js [`site/map`|`spa1`|`all`]  --watch
   构建生产环境 node build.js all --build
   'site/mpa' 是约定的多页面工程目录