3

如何打造一个react技术栈的多页面的框架应用

 3 years ago
source link: https://www.xiabingbao.com/post/react/create-react-app-multiple-page-qxphi7.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

如何打造一个react技术栈的多页面的框架应用

蚊子前端博客
发布于 2021-08-12 10:56
如何打造一个react技术栈的多页面的框架应用,本文将讲述基于create-react-app的改造过程

现在很多脚手架基本上都是单页面入口的应用,比如create-react-app等,整个的入口就是src/index.js

即使是Next.js,可以使用next export,导出成一个个单独的 html 页面。但在编译时,依然是全量编译,作为本身是为同构直出量身定制的服务端框架,在导出成纯前端运行的页面时,会多出很多无用的代码。

我已将改造完成的框架放在了GitHub上,您可以直接下载下来进行体验:https://github.com/wenzi0github/create-react-app-mpa

开心的一天-蚊子的前端博客

我们业务的主要特点是:

  1. 更偏向于活动,每个活动都搭建一个脚手架和对接构建流水线,很麻烦;

  2. 每个活动 h5 都有单独定制的部分,且用户交互也多,只能人工开发;

  3. 希望把 Git 仓库进行聚合,避免分散在各个位置;

因此,这里我基于create-react-app进行改造,实现后的功能有:

  1. 命令行快速初始化一个项目或者页面;

  2. 统一代码规范,接入公司的 eslint 代码检查;

  3. 每次提交后,只构建当前发生修改的项目;

  4. 测试环境和预发布环境自动插入 vconsole 等调试工具和构建时间;

  5. 兼容之前已构建的 html 页面,因之前是采用纯 html 发布的,这里还要兼容一下的(我们部门独有的方式,各位可以忽略);

2. 改造流程

接下来按照我的步骤,一步步下来,就能顺利的搭建起来了。

2.1 创建项目

我们首先使用 create-react-app 初始化一个项目。我比较喜欢使用 typescript,因此可以:

npx create-react-app my-app --template typescript

# or

yarn create react-app my-app --template typescript

进入到项目my-app中,因脚手架将配置隐藏了,改造时,需使用npm run eject命令释放出配置。

注意,这是一个单项操作,执行后所有的配置就不能再收回去了。

$ npm run eject

执行后可以看到多了 config 和 scripts 两个文件夹,我们几乎所有的改动都在这两个里面。

不同的版本,在配置上可能稍微有点区别。

2.2 添加 sass 和 less

sass 的配置是create-react-app本身已配置好,但还需要自己下载一下sass这个包:

$ npm i sass --save-dev

# or

$yarn add sass

对于 less,我们就需要自己配置一下,先安装 less 和 less-loader,然后直接参考 sass 的配置即可,在config/webpack.config.js中。

安装 less:

$ npm install less less-loader --save-dev
// config/webpack.config.js

// 64行左右
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
+ const lessRegex = /\.less$/; // 新增less的后缀名
+ const lessModuleRegex = /\.module\.less$/; // 新增less的后缀名

// 538行左右
rules: [
  // 紧接着sass的配置
  {
    test: lessRegex,
    exclude: lessModuleRegex,
    use: getStyleLoaders(
      {
        importLoaders: 3,
        sourceMap: isEnvProduction
          ? shouldUseSourceMap
          : isEnvDevelopment,
      },
      'less-loader'
    ),
    sideEffects: true,
  },
  {
    test: lessModuleRegex,
    use: getStyleLoaders(
      {
        importLoaders: 3,
        sourceMap: isEnvProduction
          ? shouldUseSourceMap
          : isEnvDevelopment,
        modules: {
          getLocalIdent: getCSSModuleLocalIdent,
        },
      },
      'less-loader'
    ),
  },
]

这样项目中 sass 和 less 都可以支持了。

头脑发热-蚊子的前端博客

2.3 多入口的配置

config/paths.js中,有各种的配置,这里我们需要添加几个根据项目目录构建的配置。

// config/paths.js

module.exports = {
  // build后的目录
  appProjectBuild: (project) => {
    if (project) {
      return resolveApp(`dist/${project.project}`);
    }
    return resolveApp('dist');
  },
  // 之前是指定的src/index的文件
  // 现在是读取src/pages中的项目的文件名
  appProjectIndexJs: (project) => resolveModule(resolveApp, `src/pages/${project.project}/${project.name}`), // 项目的入口文件
};

这里我们添加了 2 个配置,一个是项目打包时的构建目录,一个是构建的入口文件。

config/webpack.config.js中的导出,是一个函数,这里是我们主要改造的地方。

  1. 按照传入的 project 设置 entery 字段和 output 字段;

  2. 添加postcss-px-to-viewport插件,将 px 转为 vw 单位;

  3. 设置 alias;

  4. 删除不必要的 service worker(如果需要的话,那可以不删除);

在多入口的配置上,本地开发和线上发布的是不一样的,这里我们通过变量 isEnvProduction 进行了区分。在本地开发过程中,只能同时启动一个项目,因此会把该项目中所有的页面都作为入口文件;而在发布的过程中,则是通过判断项目是否变动,来进行构建,因此这里没有限定构建项目,而是可以根据变动的项目,产生多个入口。

2.3.1 设置入口和出口路径

默认的 entry 和 output 都是固定的,我们要修改为与启动和打包的项目相关。

// config/webpack.config.js

// 添加第2参数project
module.exports = function (webpackEnv, project) {
  /**
   * 获取入口
   * @param {object} entryPages 当前项目所有的页面
   * @returns {object}
   */
  const getEntries = (entryPages) => {
    if (isEnvProduction) {
      return [paths.appProjectIndexJs(entryPages)];
    }
    const entries = {};
    entryPages.forEach((page) => {
      entries[page.name] = !shouldUseReactRefresh
        ? [webpackDevClientEntry, paths.appProjectIndexJs(page)]
        : paths.appProjectIndexJs(page);
    });
    return entries;
  };

  /**
   * 根据页面名称生成htmlplugins
   * @param {string[]} pages 项目所有的页面名称
   * @returns {any[]}
   */
  const getHtmlPlugins = (pages) => {
    if (isEnvProduction) {
      return [
        new HtmlWebpackPlugin(
          Object.assign(
            {},
            {
              title: pages.config.title,
              inject: true,
              template: paths.appHtml,
              filename: `${pages.name}.html`,
              compiledTime: new Date().toLocaleString(),
              projectname: pages.config.project,
              minify: {
                removeComments: true,
                collapseWhitespace: true,
                removeRedundantAttributes: true,
                useShortDoctype: true,
                removeEmptyAttributes: true,
                removeStyleLinkTypeAttributes: true,
                keepClosingSlash: true,
                minifyJS: true,
                minifyCSS: true,
                minifyURLs: true,
              },
            },
          ),
        ),
      ];
    }
    return pages.map((page) => {
      return new HtmlWebpackPlugin(
        Object.assign(
          {},
          {
            title: page.config.title,
            inject: true,
            projectname: page.config.project,
            template: paths.appHtml,
            filename: `${page.name}.html`,
            chunks: [page.name],
          },
        ),
      );
    });
  };

  return {
    entry: getEntries(project),
    output: {
      path: isEnvProduction ? paths.appProjectBuild(project) : undefined,
    },
  };
};

在 public/index.html 中,我们发现有使用到htmlWebpackPlugin.options.title的变量,这是使用HtmlWebpackPlugin插件定义的。

我还自己定义了一个变量compiledTime,用来标识该项目的编译时间。

然后我在 index.html 中作为 meta 标签来使用:

<meta name="createtime" content="<%= htmlWebpackPlugin.options.compiledTime %>" />

当然,有些同学喜欢用注释的方式,也是可以的:

<!-- createtime: <%= htmlWebpackPlugin.options.compiledTime %> -->

2.3.2 添加postcss-px-to-viewport插件

我个人在开发中,喜欢使用vwvh单位,来开发移动端的应用,那么postcss-px-to-viewport插件就是必然要安装的。

$ npm install postcss-px-to-viewport --save-dev

然后在postcss-loader中的 options.plugins 里添加上这个插件:

// config/webpack.config.js

// 读取viewportWidth的配置,这个后面要讲
// 正式环境是按照页面构建的,project表示是一个页面的配置
// 本地环境是按照项目构建的,project是页面的数组集合
const { viewportWidth } = isEnvProduction ? project.config : project[0].config;


const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    // 忽略其他代码
    {
      loader: require.resolve('postcss-loader'),
      options: {
        // Necessary for external CSS imports to work
        // https://github.com/facebook/create-react-app/issues/2677
        ident: 'postcss',
        plugins: () => [
          require('postcss-flexbugs-fixes'),
          require('postcss-preset-env')({
            autoprefixer: {
              flexbox: 'no-2009',
            },
            stage: 3,
          }),
+         require('postcss-px-to-viewport')({
+           viewportWidth: viewportWidth || 750,
+           unitPrecision: 3,
+           viewportUnit: 'vw',
+         }),
          // Adds PostCSS Normalize as the reset css with default options,
          // so that it honors browserslist config in package.json
          // which in turn let's users customize the target behavior as per their needs.
          postcssNormalize(),
        ],
        sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
      },
    },
  ];
  // 忽略其他代码
};

2.3.3 设置 alias

我们可以设置根路径,避免每次都按照相对路径来进行引用。

这里要配置 2 个文件,一个是 config/webpack.config.js,另一个是 tsconfig.json。

config/webpack.config.js:

// config/webpack.config.js

// 直接查找resolve.alias的位置
module.exports = function (webpackEnv, project) {
  /* 忽略其他代码 */
  return {
    resolve: {
      alias: {
        // Support React Native Web
        // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
        'react-native': 'react-native-web',
+       '@': path.resolve(__dirname, '..', 'src'), // 以src目录为起始的根目录
        // Allows for better profiling with ReactDevTools
        ...(isEnvProductionProfile && {
          'react-dom$': 'react-dom/profiling',
          'scheduler/tracing': 'scheduler/tracing-profiling',
        }),
        ...(modules.webpackAliases || {}),
      },
    },
  };
};

tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

这样就可以通过@来引用了:

import api from '@/common/config/api'; // 项目目录/src/common/config/api

2.3.4 删除不必要的代码

有些代码是我们项目中用不到的,这里进行了删除处理,如果您的项目用到的话,那就别删除了。

删除 service worker:

// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the webpack build.
isEnvProduction &&
  fs.existsSync(swSrc) &&
  new WorkboxWebpackPlugin.InjectManifest({
    swSrc,
    dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
    exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/],
    // Bump up the default maximum size (2mb) that's precached,
    // to make lazy-loading failure scenarios less likely.
    // See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
    maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
  }),

2.3.5 关于 manifest 的配置

在 webpack.config.js 中有一个关于ManifestPlugin的配置,这里之前一直没管。每次启动项目时,就会卡在

Starting the development server

的位置。既不报错,也启动不起来。

后来查询了一下,才知道是ManifestPlugin这里配置错了。

我这里呢,是直接删除了,确实没有用到。如果要用的话,应该怎么修改呢?

entrypoints.main开始时,是一个数组,可以执行filter()方法。但修改上面的 webpack 配置后,entrypoints 不再一定包含 main 属性,如图。

修改webpack后的entrypoints-蚊子的前端博客

因此这里修改为:

let list = [];

// 获取到entrypoints所有的数组
for (const key in entrypoints) {
  list = list.concat(entrypoints[key]);
}
// 文件去重,并筛选掉.map结尾的文件
const entrypointFiles = [...new Set(list)].filter((fileName) => !fileName.endsWith('.map'));

GitHub 上对应的改动 commit:https://github.com/wenzi0github/create-react-app-mpa/commit/16c6de467d8b18faa229b67855cf4014e7988c0a

快顶不住了-蚊子的前端博客

2.4 配置命令行

所有执行的命令,执行文件均放在scripts目录中,然后在package.json中进行配置。

2.4.1 添加 commit 信息的检查

安装组件工具 commitlint 和 husky

首先按照 commitlint 组件,我们在上面的 2.2.2 小节已经安装安装了 husky,若没有安装时,这里也要安装下 husky。

# Install commitlint cli and conventional config
npm install --save-dev @commitlint/{config-conventional,cli}
# For Windows:
npm install --save-dev @commitlint/config-conventional @commitlint/cli

# Configure commitlint to use conventional config
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

执行后,会在项目根目录生成 commitlint.config.js 的配置文件。

然后将 commitlint 添加到 husky 的配置中:

# Add hook
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

最后我们要添加上我们的规则,这里要修改commitlint.config.js文件了。

首先所有的 commit 信息都要包含下面的关键词:

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat', // 新功能(feature)
        'fix', // 修补bug
        'docs', // 文档(documentation)
        'style', // 格式(不影响代码运行的变动)
        'refactor', // 重构(即不是新增功能,也不是修改bug的代码变动)
        'test', // 增加测试
        'chore', // 构建过程或辅助工具的变动
        'revert', // 回滚
      ],
    ],
  },
};

接着我们要添加自定义规则,约定每个 commit 信息都需要包含 tapid id:

/* eslint-disable */
module.exports = {
  extends: ['@commitlint/config-conventional'],
  plugins: [
    {
      rules: {
        'need-tapd': (params) => {
          // http://km.oa.com/group/39598/articles/show/440469
          // 提交规范的文档:https://iwiki.woa.com/pages/viewpage.action?pageId=172232748
          const { subject, body, footer } = params;
          const reg = /(--bug=|--story=|--task=|--issue=)(\d+)(\s|$)/;
          if (reg.test(subject) || reg.test(body) || reg.test(footer)) {
            // 只要任意一个位置包含了tapd的相关信息即可
            return [true];
          }
          // 若没有关联tapd,则返回错误,并进行提示
          return [
            false,
            'should contain --story or --bug or --task or --issue\n' +
              'see https://iwiki.woa.com/pages/viewpage.action?pageId=172232748',
          ];
        },
      },
    },
  ],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat', // 新功能(feature)
        'fix', // 修补bug
        'docs', // 文档(documentation)
        'style', // 格式(不影响代码运行的变动)
        'refactor', // 重构(即不是新增功能,也不是修改bug的代码变动)
        'test', // 增加测试
        'chore', // 构建过程或辅助工具的变动
        'revert', // 回滚
      ],
    ],
    'need-tapd': [2, 'always'], // 添加上我们的自定义规则
  },
};

若您还有其他的规则,则接着在 plugins.rules 中继续添加即可。

2.4.2 启动某个单独的项目

我们是一个多项目的应用,本地开发时,肯定是要启动一个或几个项目,而不能把所有的项目都启动起来。

这里我们写了一个函数,通过传入的参数,来启动对应的项目。

/* eslint-disable */
const chalk = require('react-dev-utils/chalk');
const glob = require('glob');
const path = require('path');
const getConfigByConfig = require('./getConfig');

const noFileMsg = (project) => `start error, the reason may be:\n
1. project enter error, please ensure you right;
2. in pages/${project} has not .tsx file, at least one .tsx fle\n`;

// 在启动和构建之前执行的代码
// 主要用于检查输入的命令和读取配置文件
// feature-h5/card
const beforeFn = async () => {
  const argv = process.argv.slice(2); // 获取传入的参数
  const [project] = typeof argv === 'object' && argv.length ? argv : [''];

  if (project.length) {
    const list = glob.sync(`src/pages/${project}/*.tsx`).filter((item) => !item.includes('test.ts')); // 获取该项目中所有的入口文件

    if (list.length === 0) {
      // 若该项目没有入口文件,则进行提示
      console.log(chalk.red(noFileMsg(project)));
      process.exit(1);
    }
    const ss = list
      .map((item) => {
        const { ext, name } = path.parse(item);
        if (ext === '.tsx') {
          const config = getConfigByConfig(`src/pages/${project}/${name}.json`);

          return {
            name,
            project,
            config,
            src: item,
          };
        }
        return null;
      })
      .filter(Boolean);
    return Promise.resolve(ss);
  } else {
    // 命令不正确
    console.log(chalk.red(`please start like: \`npm run start home\`, or \`npm run start --page home\`\n`));
    process.exit(1);
  }
};

module.exports = beforeFn;

scripts/start.js中,我们来调用这个 beforeFn 函数,获取要启动的项目:

beforeFn()
  .then((projects) => {
    // project = [
    //   {
    //     name: 'index',
    //     project: 'home',
    //     src: 'src/pages/home/index.tsx'
    //   },
    //   {
    //     name: 'share',
    //     project: 'home',
    //     src: 'src/pages/home/share.tsx'
    //   }
    // ]
    startCheckBrowsers(projects);
  })
  .catch((err) => {
    console.log(err);
    process.exit(1);
  });

启动时,执行相应的命令即可,例如要启动 demo 项目时:

$ npm run start demo

一天到晚的叭叭-蚊子的前端博客

2.4.3 如何只构建发生变动的项目

在流水线中构建时,如何只构建发生变动的项目?

要完成这个功能,需要解决两个问题。

  1. 如何缓存已构建好的构建产物;

  2. 如何判断当前发生变动的项目;

先来说说如何缓存已构建好的构建产物。

只构建发生变动的项目的前提,是可以缓存已构建项目的构建产物,然后可以将本次的构建产物与之前的构建产物进行合并。然后一并发到服务器上。无论您是将构建好的 html 发到 cdn 上,还是存储到数据库中,还是其他方式(比如我们内部的蓝盾流水线可以缓存构建产物),都是可以的。

但若解决不了这个问题,则无法只构建当前发生变动的项目,因为下次的发布,会将之前已构建好的项目给顶掉。

否则,您只能每次都得把所有的项目构建一遍。不过这种方式,一是没有必要,再有就是浪费时间。毕竟之前的项目没发生变动。

第 2 个问题是如何判断当前发生变动的项目。

我们公司是通过 Git 网站提供的接口,拿两个 commit id 获取到本地改动的文件,然后判断出哪个项目发生了变动,则构建该项目。若您没有这种条件,还有一种方式是,您当前分支的名称跟您要构建的项目相关,然后构建时,获取这个分支的名称。

当然,您可以根据您公司现有的基础功能来决定采用什么方式。

这里默认您已经有可以缓存构建产物的方式和了。

完成获取所有发生变动项目的函数后,就可以在script/build.js中调用他了。

try {
  const entries = await checkFn('npm run build');
  let allResult = [];
  for (let key in entries) {
    const projects = entries[key].projects;
    // console.log(projects)
    if (projects.length) {
      const result = projects.map((item) => {
        return startCheckBrowser(item);
      });
      allResult = allResult.concat(result);
      Promise.all(result).then(() => {
        console.log(`${projects[0].project} all builded`);
        console.log(`>> step 2: ${projects[0].project} has been builded`);
      });
    }
  }
  await Promise.all(allResult);
  console.log(chalk.green(`>> step 3: all pages files builded success`));

  console.log('>> step 4: starting upload static files');
  // 构建完成后,则上传静态资源
  uploadStatic();
  console.log(`>> step 4.6: all files upload success`);
} catch (err) {
  console.log(err);
  process.exit(1);
}

在把create-react-app改造成多页面的应用过程中,最重要的是对 webpack 配置的改造。

终于把教程写完了,在文章里可能有遗漏,或者不太详尽的地方,还请留言,一起探讨。

好累-蚊子的前端博客


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK