定制一个可以react和vue共存的ts项目

2,730 阅读14分钟

前言

跟风微前端,看了一圈源码,觉得微前端并不适合公司目前的项目,例如css会有多次加载的问题等,而且我们也不会有juqery的库,所以将html解析成字符串动态插入并执行的路子在我这并非最优解,现在花点时间做一个共用的项目

github

https://github.com/757566833/react-vue

库的结构

以react为主,vue辅助,layout也是react写的

预览

项目期望

支持前后端分离,自带前端prod服务器,方便集成websocket等

关键字

react vue antd webpack umd

设计思路

main项目 作为整个项目的layout
react项目 负责react部分
vue项目 负责vue部分

main

main 主要负责外层的layout 需要的数据是menu
当点击menu的时候,如果有tab,就跳到tab上;如果没有,就新出一个tab
渲染的逻辑和销毁的 也都在layout里面

react

任何react的页面,都在这里面,为了可以集成于一个项目,采用umd打包的方式

vue

任何vue的页面,都在这里面,为了可以集成于一个项目,采用umd打包的方式

如何渲染(这里和市面上的微前端不一样)

当打开一个tab时 ,会带出来一个element 并根据url给这个element 定义id 保证这个 id 的唯一性
当加载任意 react/vue的时候,就执行会reactdom.render这类的函数,加载了哪个页面的js,就挂载到哪个id上

子项目为何使用umd?

由于我们对系统进行优化,鉴于浏览器缓存机制,必然使用script标签等引入外部文件,这就导致了多次打开关闭 重复引入的问题,如何设计缓冲层来进行优化呢,大体上有三层,react/vue项目有自己的assets.json,指定了当前浏览器需要的js/css 叫什么,然后读取指定的 js/css文件,将js文件加载后由于umd的关系,直接new一个对象挂载在windows上,以后所有的重新打开tab,都直接取缓存的,不再去线上,css暂时没想到好的办法 目前是加一个id判断是否已经加载

这中间的关系 assets.json相当于索引

1.windows.modules里面找(自己定义的object) 2.找不到去assets.json里面找(webpack插件生成的文件索引) 3.根据浏览器自己的特性 是从disk中读取还是http请求服务器

1.前端,2.redis,3.数据库 = 1.内存读取object进行渲染 2.浏览器的srcipt标签缓存优化 3.真正从服务器拉文件

前后端的设计模式 是通用的

接下来就是又臭又长的开发环境搭建,不想关注可以直接跳到正式开始项目

整个项目需要的库

解析ts用的babel而不是ts-loader/awesome-typescript-loader

// 这里不会细说webpack 如果用开源脚手架例如umi等 需要了解下webpack内容

// 前两个是核心 第三个是热更新服务器 第四个是区分webpack mode 用的merge工具,具体内容请看webpack官方文档(不要看中文版本)
yarn add webpack webpack-cli webpack-dev-server webpack-merge --dev

//  babel全家桶和react等,具体查看babel官网,还有很多插件可用
yarn add  @babel/core  @babel/plugin-proposal-class-properties  @babel/plugin-proposal-decorators  @babel/preset-env  @babel/preset-react  @babel/preset-typescript  --dev

// webpack插件和loader 有些插件不是必须安装 例如clean可以用rm命令代替 cross-env在非多人合作下也没什么用 error-overlay 在此项目也会失效 自己酌情处理 在笔者写这个文档的时候 vue-loader刚好更新到16 改版有点大
yarn add assets-webpack-plugin  babel-loader  clean-webpack-plugin  cross-env  css-loader  error-overlay-webpack-plugin  file-loader  fork-ts-checker-webpack-plugin  html-webpack-plugin  less less-loader  mini-css-extract-plugin  node-sass   sass-loader  vue-loader@15 webpack-bundle-analyzer url-loader  --dev

// react 热更新插件
yarn add @hot-loader/react-dom react-hot-loader --dev

// eslint 自己酌情安装
yarn add eslint --dev

// 最主要的库
yarn add typescript  react immutable  react-dom react-redux react-router react-router-dom styled-components  redux  vue  vue-class-component  vue-property-decorator  vue-template-compiler --dev

// ui库 vue的省略了
yarn add antd react-resizable --dev

// prod 下服务器的库(koa)
yarn add koa  koa-router  koa-send  koa2-cors 

// 补一下types
yarn add @types/assets-webpack-plugin  @types/html-webpack-plugin  @types/koa  @types/koa-router  @types/koa-send  @types/koa2-cors  @types/mini-css-extract-plugin  @types/react  @types/react-dom  @types/react-hot-loader @types/react-redux  @types/react-resizable  @types/react-router-dom  @types/styled-components  @types/webpack-bundle-analyzer @types/webpack-dev-server  @types/webpack-merge   --dev
// ts 运行环境
yarn add ts-node --dev

最终我的package.json

{
  "devDependencies": {
    "@babel/core": "^7.10.3",
    "@babel/plugin-proposal-class-properties": "^7.10.1",
    "@babel/plugin-proposal-decorators": "^7.10.3",
    "@babel/preset-env": "^7.10.3",
    "@babel/preset-react": "^7.10.1",
    "@babel/preset-typescript": "^7.10.1",
    "@hot-loader/react-dom": "^16.13.0",
    "@types/assets-webpack-plugin": "^3.9.0",
    "@types/html-webpack-plugin": "^3.2.3",
    "@types/koa": "^2.11.3",
    "@types/koa-router": "^7.4.1",
    "@types/koa-send": "^4.1.2",
    "@types/koa2-cors": "^2.0.1",
    "@types/mini-css-extract-plugin": "^0.9.1",
    "@types/react": "^16.9.41",
    "@types/react-dom": "^16.9.8",
    "@types/react-hot-loader": "^4.1.1",
    "@types/react-redux": "^7.1.9",
    "@types/react-resizable": "^1.7.2",
    "@types/react-router-dom": "^5.1.5",
    "@types/styled-components": "^5.1.0",
    "@types/webpack-bundle-analyzer": "^3.8.0",
    "@types/webpack-dev-server": "^3.11.0",
    "@types/webpack-merge": "^4.1.5",
    "@typescript-eslint/eslint-plugin": "^3.4.0",
    "@typescript-eslint/parser": "^3.4.0",
    "antd": "^4.3.5",
    "assets-webpack-plugin": "^5.0.2",
    "babel-loader": "^8.1.0",
    "clean-webpack-plugin": "^3.0.0",
    "cross-env": "^7.0.2",
    "css-loader": "^3.6.0",
    "error-overlay-webpack-plugin": "^0.4.1",
    "eslint": "^7.3.1",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-react": "^7.20.0",
    "eslint-plugin-vue": "^6.2.2",
    "file-loader": "^6.0.0",
    "fork-ts-checker-webpack-plugin": "^5.0.5",
    "html-webpack-plugin": "^4.3.0",
    "immutable": "^4.0.0-rc.12",
    "less": "^3.11.3",
    "less-loader": "^6.1.2",
    "mini-css-extract-plugin": "^0.9.0",
    "node-sass": "^4.14.1",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-hot-loader": "^4.12.21",
    "react-redux": "^7.2.0",
    "react-resizable": "^1.10.1",
    "react-router": "^5.2.0",
    "react-router-dom": "^5.2.0",
    "redux": "^4.0.5",
    "sass-loader": "^8.0.2",
    "styled-components": "^5.1.1",
    "ts-node": "^8.10.2",
    "typescript": "^3.9.5",
    "vue": "^2.6.11",
    "vue-class-component": "^7.2.3",
    "vue-loader": "15",
    "vue-property-decorator": "^9.0.0",
    "vue-template-compiler": "^2.6.11",
    "webpack": "^4.43.0",
    "webpack-bundle-analyzer": "^3.8.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0",
    "webpack-merge": "^4.2.2"
  },
  "dependencies": {
    "koa": "^2.13.0",
    "koa-router": "^9.0.1",
    "koa-send": "^5.0.0",
    "koa2-cors": "^2.0.6"
  }
}


eslint

// 这个会有命令提示,自己选就好了,最后会提示你缺的依赖,最后提示我缺的依赖我没用自动安装,手动用yarn安装的,因为自动安装调用的npm命令。注意,eslint有一些和ts兼容不是很好
npx eslint --init
// 添加官方推荐的eslint
 yarn add eslint-plugin-react-hooks  --dev

最终我的eslint

我不熟vue 需要自己加

env:
  browser: true
  es2020: true
  node: true
extends:
  - "eslint:recommended"
  - "plugin:react/recommended"
  - google
parser: "@typescript-eslint/parser"
parserOptions:
  ecmaFeatures:
    jsx: true
  ecmaVersion: 11
  sourceType: module
plugins:
  - react
  - "@typescript-eslint"
  - "react-hooks"
rules:
  no-unused-vars: "off"
  no-prototype-builtins: "off"
  react-hooks/rules-of-hooks: "error"
  react-hooks/exhaustive-deps: "warn"
  react/jsx-uses-react: "error"
  react/jsx-uses-vars: "error"
  no-undef: "off"
  object-curly-spacing: ["error", "always"]
  react/prop-types: 0
  max-len: ["error", { "code": 120 }]

typescript

npx typescript --init

最终我的tsconfig

{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Basic Options */
    // "incremental": true,                   /* Enable incremental compilation */
    "target": "es5",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
    // "lib": [],                             /* Specify library files to be included in the compilation. */
    // "allowJs": true,                       /* Allow javascript files to be compiled. */
    // "checkJs": true,                       /* Report errors in .js files. */
    "jsx": "react",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
    "sourceMap": true,                     /* Generates corresponding '.map' file. */
    // "outFile": "./",                       /* Concatenate and emit output to single file. */
    // "outDir": "./",                        /* Redirect output structure to the directory. */
    // "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    // "composite": true,                     /* Enable project compilation */
    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
    // "removeComments": true,                /* Do not emit comments to output. */
    // "noEmit": true,                        /* Do not emit outputs. */
    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
    "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true,                           /* Enable all strict type-checking options. */
    // "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
    // "strictNullChecks": true,              /* Enable strict null checks. */
    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
    // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */

    /* Additional Checks */
    "noUnusedLocals": true,                /* Report errors on unused locals. */
    "noUnusedParameters": true,            /* Report errors on unused parameters. */
    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */

    /* Module Resolution Options */
    // "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
    "paths": {
      "@/*":["./src/*"],
    },                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
    // "typeRoots": [],                       /* List of folders to include type definitions from. */
    // "types": [],                           /* Type declaration files to be included in compilation. */
    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
    // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */

    /* Source Map Options */
    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */

    /* Advanced Options */
    "skipLibCheck": true,                     /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true  /* Disallow inconsistently-cased references to the same file. */
  }
}

代码组成

layout 为main项目,页面本身为r和v项目
main 和r 用react
v用vue

项目结构

项目结构未必合理,仅仅是一个demo 根目录下新建src

|-- src
    |-- asset      静态文件
    |-- global     全局文件例如方便moment全局设置等
    |-- components 项目通用组件
    |-- config     项目的一些配置
    |-- http       封装http请求,例如fetch axios等
    |-- layouts    main的具体内容
    |-- menu       layout 左侧的menu
    |-- pages
        |--react   react的内容
        |--vue     vue的内容
    |--redux      实际上没什么用 在这里仅仅main用到了
    |--services   各种接口
    |--util       工具
        |--react.tsx
        |--vue.ts

webpack

这里我项目和其他的不一样 antd 不再使用按需加载,因为已有的项目使用了antd的全部组件

跟目录下新建webpack文件夹 webpack文件夹下 新建 main react vue三个文件夹

三个文件夹下分别新建webpack.common.ts webpack.dev.ts webpack.prod.ts

main下额外新建一个 template.html

main 的common

import path from 'path';
// 生成html的插件
import HtmlWebpackPlugin from 'html-webpack-plugin';
// 把css拆出来的插件
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import webpack from 'webpack';

const config: webpack.Configuration = {
  entry: {
    main: './src/layouts/index.tsx',
  },
  module: {
    rules: [
      {
        test: /\.(tsx|ts)?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                '@babel/preset-env',
                '@babel/preset-typescript',
                '@babel/preset-react',
              ],
              plugins: [
                ['@babel/plugin-proposal-decorators', { legacy: true }],
                ['@babel/plugin-proposal-class-properties', { loose: true }],
              ],
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: [{
          loader: MiniCssExtractPlugin.loader,
        },
        {
          loader: 'css-loader',
        },
        ],
        exclude: /node_modules/,
      },
      {
        test: /\.less$/,
        use: [{
          loader: MiniCssExtractPlugin.loader,
        },
        {
          loader: 'css-loader',
        },
        {
          loader: 'less-loader',
        },
        ],
      },
      {
        test: /\.scss$/,
        use: [{
          loader: MiniCssExtractPlugin.loader,
        }, {
          loader: 'css-loader',
        }, {
          loader: 'sass-loader',
        }],
        exclude: /node_modules/,
      },
      {
        test: /\.(jpg|jpeg|png|svg|gif|woff|woff2|otf|ttf)?$/,
        loader: 'url-loader',
        options: {
          limit: 8192,
          publicPath: '/',
          name: 'img/[name].[hash:7].[ext]',
        },

      },
    ],
  },
  resolve: {
    // 自动后缀
    extensions: ['.tsx', '.ts'],
    // 软连接
    alias: {
      '@': path.resolve('src'),
    },
  },
  plugins: [
    // 生成html
    new HtmlWebpackPlugin({
      title: 'test',
      template: path.resolve(__dirname, 'template.html'),
    }),
    // 拆css
    new MiniCssExtractPlugin({
      filename: 'main/[name].[contenthash].css',
    }),
    // 检查类型
    new ForkTsCheckerWebpackPlugin(),
  ],

};
export default config;

main 的dev

import path from 'path';
import webpack from 'webpack';
import merge from 'webpack-merge';
import common from './webpack.common';
// 因为是babel转译的ts 现在需要个插件检查类型
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
const config: webpack.Configuration = merge(common, {
  mode: 'development',
  devtool: 'eval-source-map',
  output: {
    path: path.resolve(__dirname, '..', '..', 'dist'),
    filename: 'main/app.js',
    publicPath: '/',
  },
  devServer: {
    // spa必备
    historyApiFallback: { index: '/' },
    contentBase: path.join(__dirname, '..', '..', 'dist'),
    host: '127.0.0.1',
    hot: true,
    port: 7000,
    // 这个的作用是让webpack安静点
    stats: 'errors-warnings',
    publicPath: '/',
  },
  plugins: [
    // 热更插件
    new webpack.HotModuleReplacementPlugin(),
    // 命名空间 也是热更用的
    new webpack.NamedModulesPlugin(),
    // 检查类型
    new ForkTsCheckerWebpackPlugin(),
    // 全局变量 区分环境
    new webpack.DefinePlugin({
      ENV_MODE: JSON.stringify('development'),
    }),
  ],
  // 热更必备
  resolve: {
    alias: {
      'react-dom': '@hot-loader/react-dom',
    },
  },
});
// 覆盖掉common的配置,加入热更的babel
const config2 = merge.smart(config, {
  module: {
    rules: [{
      test: /\.(tsx|ts)?$/,
      exclude: /node_modules/,
      use: [
        {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true,
            babelrc: false,
            presets: [
              '@babel/preset-env',
              '@babel/preset-typescript',
              '@babel/preset-react',
            ],
            plugins: [
              ['@babel/plugin-proposal-decorators', { legacy: true }],
              ['@babel/plugin-proposal-class-properties', { loose: true }],
              'react-hot-loader/babel',
            ],
          },
        },
      ],
    }],
  },
});
export default config2;

main 的prod

import path from 'path';
import webpack from 'webpack';
// 分析打包
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import merge from 'webpack-merge';
import common from './webpack.common';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
const config: webpack.Configuration = merge(common, {
  mode: 'production',
  devtool: 'source-map',
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
    // router用umd引入有些问题 具体可以去github issues 看
    // 'react-router': 'ReactRouter',
    // 'react-router-dom': 'ReactRouterDOM',
    'antd': 'antd',
  },
  output: {
    // 改成了chunk命名,避免出现0123这种
    filename: 'main/[name].[chunkhash].js',
    path: path.resolve(__dirname, '..', '..', 'dist'),
  },
  plugins: [
    new BundleAnalyzerPlugin(),
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ['main/**/*'],
    }),
    new webpack.DefinePlugin({
      ENV_MODE: JSON.stringify('production'),

    }),
  ],
});

export default config;

react的webpack common

// 采取约定式,pages下面的react里面所有的tsx文件(不包含某些关键字)全部为入口文件
...
const getEntry = (url: string) => {
  if (url.includes('component') ||
    url.includes('hooks') ||
    url.includes('services') ||
    url.includes('http')
  ) {
    return;
  }
  const list = fs.readdirSync(url);
  for (const iterator of list) {
    if (iterator.includes('.tsx')) {
      entry[`/${url}/${iterator}`
          .replace('/index.tsx', '')
          .replace('.tsx', '')
          .replace('src/pages/react/', '')
          .replace(/\//g, '')] = `./${url}/${iterator}`;
    } else if (!iterator.includes('.')) {
      getEntry(`${url}/${iterator}`);
    }
  }
};
const rootpath = path.join('src', 'pages', 'react');
...

其余的不再赘述 demo里有

正式开始项目

1.静态服务配置文件,这个和业务没啥关系,指定了不同环境下,服务器在哪

src/config/index.tsx

后缀ts tsx都可以,这个文件是main调用

// react 在7001端口 vue在7002
export const cssPrefix = 'egu-layout-';
export const modulePath = {
  r: {
    development: '//127.0.0.1:7001',
    production: '',
  },
  v: {
    development: '//127.0.0.1:7002',
    production: '',
  },
};

2. main开始

1. src/layouts/index.tsx

// 简单的react 入门 以后的入门级代码不再展示
import React from 'react';
import ReactDOM from 'react-dom';
import {
  BrowserRouter as Router,
  Route,
} from 'react-router-dom';
import store from '@/redux/store';
import { Provider } from 'react-redux';
import Base from './base';
import '@/global/react/global.scss';
import '@/global/react/global';
// antd 限定了中文
import { ConfigProvider } from 'antd';
import zhCN from 'antd/es/locale/zh_CN';
const Layout: React.FC = () => {
    return <Provider store={store}>
      <ConfigProvider locale={zhCN}>
         {/* base模块 */}
        <Base />
      </ConfigProvider>
    </Provider>;
};

const AppRouter: React.FC = () => {
  return (
    <Router>
      <Route path="*" component={Layout} />
    </Router>
  );
};

const App: React.FC = () => {
  return <>
    <AppRouter />
  </>;
};
ReactDOM.render(<App />, document.getElementById('root'));

2. src/menu/interface.ts (仅为demo)

数据类型不是通用的,此数据类型仅为demo,随意按自己想要的实现

// menu的item
export interface IMenuBean {
  title: string;
  path: string;
  module?: 'r' | 'v'
  type?: EMenuType;
  authority?: string;
  children?: IMenuBean[];
}
// menu item的类型
export enum EMenuType {
  SubMenu = 'SubMenu',
  Item = 'Item',
  NoMenu = 'NoMenu',
  Header = 'Header'
}
// 这个模块属于react还是vue
export enum module {
  react = 'r',
  vue = 'v'
}

3. src/menu/index.ts

import { IMenuBean } from './interface';
import basics from './basics';
import mall from './mall';
import assets from './assets';
import finance from './finance';
import config from './config';

const menu: IMenuBean[] = [
  basics, // 运营
  mall, // 业务
  finance, // 财务
  assets, // 资产
  config, // 设置

];
export default menu;

4.src/menu/basics.ts

import { IMenuBean, EMenuType, module } from './interface';
const basics: IMenuBean = {
  title: 'menu1',
  type: EMenuType.Header,
  path: '/basics/',
  authority: 'menu1',
  module: module.react,
  children: [
    {
      title: 'submenu1',
      type: EMenuType.SubMenu,
      path: '/basics/enterprise',
      authority: 'pc-op-enterprise',
      children: [
        {
          title: 'menu11',
          type: EMenuType.Item,
          path: '/basics/enterprise/authentication',
          authority: 'menu11',
        },
        {
          title: 'menu12',
          type: EMenuType.Item,
          path: '/basics/enterprise/menu',
          authority: 'menu12',
        },
      ],
    },
    {
      title: 'submenu2',
      type: EMenuType.SubMenu,
      path: '/basics/test',
      authority: 'pc-op-account',
      children: [
        {
          title: 'menu21',
          type: EMenuType.Item,
          path: '/basics/test/test1',
          module: module.vue,
          authority: 'menu21',
        },
      ],
    },
  ],
};
export default basics;

其余的看源码

5. src/services/index.ts

// 模拟权限接口
export const getWebAuthority:()=>Promise<{[key:string]:boolean}> = async ()=>{
    return {
        menu1:true,
        submenu1:true,
        menu11:true,
        submenu2:true,
        menu21:true
    }
}
  1. src/layouts/base/index.tsx
// 不再展示基础代码 github里面有
...
const Base: React.FC = () => {
  ...
  // 具体基础结构 和antd demo没什么区别
  return (
    <>
      <Spin
        size='large'
        spinning={loading}
        className={loading ?
      'egu-layout-loading max-HW' : 'disappear'
        }
        wrapperClassName={loading ?
        'egu-layout-loading-wrapper max-HW' :
         'disappear'
        }>
        <div className='max_HW' />
      </Spin>
      <Layout
        className={!loading ?'egu-layout' :'disappear'}
        style={{ height: '100vh' }}
      >
        <Sider>
          <div className='egu-layout-logo flex' />
          <MenuMemo />
        </Sider>
        <Layout className={'site-layout'}>
          <HeaderMemo />
          <Content>
            {/* 内容部分主要是这个index */}
            <Index />
          </Content>
        </Layout>
      </Layout>
    </>
  );
};
export default Base;

6. src/layouts/index/index.tsx

...
// 根据url获取当前有没有这个tab
const getTab = (url: string): IMenuBean | undefined => {
  if (url == '/') {
    return undefined;
  }
  let result: IMenuBean | undefined = undefined;
  let stop = false;
  let index = 0;
  let _menu: IMenuBean[] | undefined = menu;
  while (!stop) {
    if (!_menu || !_menu[index]) {
      return;
    }
    if (url == _menu[index].path && EMenuType.Item != _menu[index].type) {
      return;
    } else if (url.startsWith(_menu[index].path) &&
    _menu[index].type == EMenuType.Item) {
      result = _menu[index];
      stop = true;
    } else if (url.startsWith(_menu[index].path)) {
      _menu = _menu[index].children;
      index = 0;
    } else {
      index++;
    }
  }
  return result;
};
const Index: React.FC = () => {
  const dispatch = useDispatch();
  const [tabs, setTabs] = useState<{
    title: string, key: string, id: string
   }[]>([]);
   // 这边自己记录了一个history 方便自己用 看有没有用途 如果没有用途就可以去掉
  const history = useRef<{url:string}[]>([]);
  const [activeKey, setActiveKey] = useState<null | string>(null);
  const lastTab = useRef('');
  const url = useRouteMatch().url;
  // url变化时究竟是打开 还是跳转
  useEffect(() => {
    const activeTab = getTab(url);
    let hastab = false;
    for (const iterator of tabs) {
      if (iterator.key == activeTab?.path) {
        hastab = true;
        break;
      }
    }
    if (activeTab) {
      setActiveKey(activeTab.path);
      lastTab.current = activeTab.path;
      if (!hastab) {
        setTabs(
            [...tabs,
              {
                title: activeTab.title,
                key: activeTab.path,
                id: activeTab.path,
              },
            ],
        );
      }
    }
  }, [dispatch, history, tabs, url]);

  const routerHistory = useHistory();
  const tabsClick = (url: string) => {
    routerHistory.push(url);
  };
  const pushHistory=useCallback(()=>{
    history.current.push({ url: url });
  }, [url]);
  useEffect(()=>{
    pushHistory();
  }, [pushHistory, url]);
  const edit = (
      targetKey: React.MouseEvent | React.KeyboardEvent | string,
      action: 'add' | 'remove',
  ) => {
    console.log(targetKey, action);
    if (action == 'remove') {
      let index: null | number = null;
      console.log(tabs, targetKey);
      tabs.forEach((item, i) => {
        if (item.key == targetKey) {
          index = i;
          return;
        }
      });
      console.log(index);
      if (index != null) {
        if (activeKey == targetKey) {
          const reHistory = history.current.reverse();
          reHistory.shift();
          console.log('yes', reHistory);
          for (const iterator of reHistory) {
            let findLast = false;
            for (const tab of tabs) {
              if (tab.key==iterator.url) {
                routerHistory.push(iterator.url);
                findLast = true;
                break;
              }
            }
            if (findLast) {
              break;
            }
          }
          // routerHistory.push(history[]);
        }
        const newtabs = [...tabs.slice(0, index), ...tabs.slice(index + 1)];
        console.log(newtabs);
        setTabs(newtabs);
        if (window.jsmodules && typeof targetKey == 'string') {
          window.jsmodules[targetKey.replace(/\//g, '')].unRender();
        }
      }
    }
  };
  const showKey = activeKey ? activeKey : lastTab.current;
  return (
    <div className='egu-saas-layout-content'>
      <Tabs
        hideAdd={true}
        type="editable-card"
        activeKey={showKey}
        onTabClick={tabsClick}
        onEdit={edit}
        className='egu-saas-layout-content-tabs flex'
      >
        {tabs.map((pane) => (
          <TabPane
            forceRender={true}
            tab={pane.title}
            key={pane.key}
            className={`
                        egu-saas-layout-content-tabs-tabpane
                        ${activeKey == pane.key ?
                        'egu-saas-layout-content-tabs-tabpane-height-max' :
                          ''}`
            }
          >
            {/* 最终渲染用的组件 */}
            <Body id={pane.id} />
          </TabPane>
        ))}
      </Tabs>

    </div>

  );
};
export default Index;

7. (核心内容) src/layouts/components/body/index.tsx

import React, { useEffect, useState, useCallback } from 'react';
import './styles.scss';
import { Spin } from 'antd';
import { Http } from '@/http';
import { useRouteMatch } from 'react-router';
import { useSelector } from 'react-redux';
import { modulePath } from '@/config';
import { IState } from '@/redux/state';
import styled from 'styled-components';
const Padding = styled.div`
    height:100%;
    padding:12px
`;
const Body: React.FC<{ id: string }> = React.memo((props) => {
  console.log('body');
  const url = useRouteMatch().url;
  const moduleMap = useSelector(useCallback(
      (storeData:IState) => storeData.moduleMap
      , []),
  );
  const [loading, setLoading] = useState(false);
  const reRender =useCallback( () => {
    // 判断是为了迎合ts 理论上走到这个函数一定会有 jsmodules
    if (window.jsmodules) {
      try {
        window.jsmodules[props.id.replace(/\//g, '')].render();
      } catch (error) {
        console.log(error);
      }
    }
  }, [props.id]);
  const getComponent = useCallback(()=>{
    const module = new window.JSComponent(props.id.replace(/\//g, ''));

    if (window.jsmodules) {
      window.jsmodules[props.id.replace(/\//g, '')] = module;
    } else {
      window.jsmodules = {};
      window.jsmodules[props.id.replace(/\//g, '')] = module;
    }
    setLoading(false);
    reRender();
  }, [props.id, reRender]);

  const getComponetUrl =useCallback( (json:{[key:string]:any}, type:'r'|'v') => {
    console.log('getComponetUrl', json, props.id.replace(/\//g, ''));
    // 理论上走到这里必然会有asset
    const moduleJson = json[props.id.replace(/\//g, '')];
    if (moduleJson) {
      setLoading(true);
      if (moduleJson.js) {
        const modules = document.createElement('script');
        // 创建script标签;
        modules.type = 'text/javascript';
        modules.src = `${modulePath[type][ENV_MODE]}${moduleJson.js}`;
        modules.onload = getComponent; // 引入url;
        document.body.appendChild(modules);
      }
      if (moduleJson.css) {
        if (!document.getElementById(`${props.id.replace(/\//g, '')}css`)) {
          const link = document.createElement('link');
          link.type = 'text/css';
          link.rel = 'stylesheet';
          link.href = `${modulePath[type][ENV_MODE]}${moduleJson.css}`;
          link.id = `${props.id.replace(/\//g, '')}css`;
          document.body.appendChild(link);
        }
      }
    }
  }, [getComponent, props.id]);
  const getAsset = useCallback(async (type:'r'|'v') => {
    const modoleJson = await Http.getStatic(type);
    const json:{ [key: string]: any } = modoleJson?.text||{};
    switch (type) {
      case 'r':
        window.reactAssets = json;
        break;
      case 'v':
        window.vueAssets = json;
        break;

      default:
        break;
    }
    getComponetUrl(json, type);
  }, [getComponetUrl]);
  useEffect(() => {
    // 如果有静态文件的目录   如果有module的实体 且实体内有当然的模块实体
    console.log('useEffect', moduleMap);
    if (moduleMap) {
      const type = moduleMap[url];
      const jsmodules = window.jsmodules;
      const vueAssets = window.vueAssets;
      const reactAssets = window.reactAssets;
      if (
        (type=='r'&&reactAssets||type=='v'&&vueAssets)&&
        jsmodules&&
        jsmodules[props.id.replace(/\//g, '')]
      ) {
        reRender();
      } else if (type=='r'&&reactAssets) {
        // 如果只有静态文件
        getComponetUrl(reactAssets, type);
      } else if (type=='v'&&vueAssets) {
        getComponetUrl(vueAssets, type);
      } else {
        // 如果啥也没有
        console.log('getAsset');
        getAsset(type);
      }
    }
  }, [props.id, moduleMap, getComponetUrl, getAsset, reRender, url]);
  // window.sys
  return (
    <div className='egu-saas-layout-body'>
      <Spin spinning={loading} >
        <Padding>
          <div className='egu-saas-layout-root' id={props.id.replace(/\//g, '')} />
        </Padding>

      </Spin>
    </div>


  );
}, () => true);
Body.displayName = 'Body';
export default Body;

8. typings.d.ts 我们在windows上定义了一些东西,在上一步应该会有提示 补一下声明

declare module '*.css';
declare module '*.less';
declare module '*.png';

interface Window {
    jsmodules?: { [key: string]: any };
    reactAssets?:{ [key: string]: any },
    vueAssets?:{ [key: string]: any },
    httpApi: string;
    JSComponent: any;
    store: any
}

declare const ENV_MODE: 'development' | 'production';


declare module '*.vue' {
  import Vue from 'vue';
  export default Vue;
}

3. react 开始

1.react主函数

// 随便写个文件
// src/pages/react/basics/enterprise/authentication/index.tsx
import React from 'react';
import { reactHOC } from '@/util/react';

const Index: React.FC= () => {
  return (
    <div>react </div>
  );
};
export const JSComponent = reactHOC(Index);

2. reacthoc

上一个文件如果直接导出被reactdom.render 渲染一下 就可以直接用了,但是我们不仅满足于此,我们希望在main层调用的时候,由main来管理一个页面的生命周期,具体代码详见上文的 核心代码

// src/util/react.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/es/locale/zh_CN';


interface component{

}
/**
 *
 * @param {React.FC} ReactModule react每个模块的工厂
 * @return {component} 工具类 工厂模式
 */
export const reactHOC = (ReactModule: React.FC):component => {
  return class JSComponent {
    id: string
    timmer: NodeJS.Timeout | null | number
    /**
     * Create a point.
     * @param {string} id 字符串 标示着唯一值
     */
    constructor(id: string) {
      this.id = id;
      this.timmer = null;
    }
    /**
     * 最终的渲染函数
     */
    renderDom() {
      if (document.getElementById(this.id)) {
        ReactDOM.render(
            <ConfigProvider locale={zhCN}>
              <ReactModule />
            </ConfigProvider>

            , document.getElementById(this.id));
      }
    }
    /**
     * 渲染逻辑层,主要是为了兼容 浏览器空闲时间的api 否则会造成某些情况下不渲染的bug,猜测和requestIdleCallback相关
     */
    render() {
      this.renderDom();
      this.timmer = setInterval(() => {
        this.renderDom();
        if (document.getElementById(this.id)?.childElementCount != 0) {
          if (this.timmer) {
            clearInterval(Number(this.timmer));
            this.timmer = null;
          }
        }
      }, 200);
    }
    /**
     * 卸载函数
     */
    unRender() {
      const dom = document.getElementById(this.id);

      if (dom) {
        ReactDOM.unmountComponentAtNode(dom);
      }
    }
  };
};

4. vue

1. vue主函数 这就略过了 本身拿的也是vue脚手架的demo页面

2 vuehoc

// src/util/vue.ts
import Vue, { VueConstructor } from 'vue';
import CombinedVueInstance from 'vue/types';
export const vueHOC = (vue:VueConstructor)=>{
  return class JSComponent {
    id: string
    timmer: NodeJS.Timeout | null | number
    vue:CombinedVueInstance
    /**
     * Create a point.
     * @param {string} id 字符串 标示着唯一值
     */
    constructor(id: string) {
      this.id = id;
      this.timmer = null;
      this.vue = new Vue({
        render: (h) => h(vue),
      });
    }
    /**
     * 最终的渲染函数
     */
    renderDom() {
      if (window.document.querySelector(`#${this.id}`)) {
        this.vue.$mount(`#${this.id}`);
      }
    }
    /**
     * 渲染逻辑层,主要是为了兼容 浏览器空闲时间的api 否则会造成某些情况下不渲染的bug
     */
    render() {
      this.renderDom();
      this.timmer = setInterval(() => {
        this.renderDom();
        if (window.document.querySelector(`#${this.id}`)?.childElementCount != 0) {
          if (this.timmer) {
            clearInterval(Number(this.timmer));
            this.timmer = null;
          }
        }
      }, 200);
    }
    /**
     * 卸载函数
     */
    unRender() {
      console.log('vue unRender');
      this.vue.$destroy();
    }
  };
};

这里面react 官方推荐的 查找dom方法 react 是getelementbyid ,而vue实现的则是用document.querySelector

开发和打包

1.命令

// package.json
...
"scripts": {
    "start": "webpack-dev-server --config webpack/main/webpack.dev.ts",
    "build": "webpack --config webpack/main/webpack.prod.ts",
    "start:react": "webpack-dev-server --config webpack/react/webpack.dev.ts",
    "build:react": "webpack --config webpack/react/webpack.prod.ts",
    "start:vue": "webpack-dev-server --config webpack/vue/webpack.dev.ts",
    "build:vue": "webpack --config webpack/vue/webpack.prod.ts"
  }

2.开发模式

// main
yarn start
// react
yarn run start:react
// vue 
yarn run start:vue

3.生产模式

// main
yarn run build
// react
yarn run build:react
// vue 
yarn run build:vue

4.先后端分离

到此 其实就结束了,因为我们打包出来了静态文件
当然前后端分离的模式还有待商榷,如果你满足于做一个纯粹的前端,你大可只把静态文件提供到nginx下
如果你不满足于前端,还需要webnsocket,mongodb,redis,等前端全家桶,甚至为了实现serverless而打好基础,你都应该掌握nodejs

// 新建main.js
const path = require('path');
const Koa = require('koa');
const send = require('koa-send');
const Router = require('koa-router');
const cors = require('koa2-cors');
const app = new Koa();
const router = new Router();
app.use(cors({
  origin: "*",
  exposeHeaders: ["WWW-Authenticate", "Server-Authorization"],
  maxAge: 50000,
  credentials: true,
  allowMethods: ["GET", "PUT", "POST", "PATCH", "DELETE", "HEAD", "OPTIONS"],
  allowHeaders: ["Content-Type", "Authorization", "Accept"],
}));
// router.get()
router.get(/(.*?)/g, async (ctx) => {
  const url = ctx.path;
  if (url.includes('.')) {
    await send(ctx, ctx.path, {
      root: path.join(__dirname, 'dist'),
      maxAge: 365 * 24 * 60 * 60 * 1000
    });
  } else {
    await send(ctx, './index.html', {
      root: path.join(__dirname, 'dist'),
      maxAge: 0
    });
  }
})


app.use(router.routes()).use(router.allowedMethods())

app.listen(3000);

启动命令

node main.js

你仍旧可以选择pm2 docker 等工具 来从不同维度管理你的服务
这样 同样的方式用nginx反向代理到服务,所有的header头等,均由前端自己控制

未完成

  1. 未完成的部分例如antd的moment比较大进一步减小体积
  2. react-router 仍旧有些bug

结束