UmiJs整合Egg
一. 初始化项目
这里,我们用UmiJs脚手架来初始化一个项目:
1.创建一个名为Umi_Egg
的空文件夹:
2.在该文件夹目录中输入命令:
npm create @umijs/umi-app
结果如下,项目结构为:
3.在根目录下,创建Egg项目
npm init egg --type=simple
运行如下:
4.注意,我们这里利用脚手架分别创建了两个项目,自然而然的会生成两个package.json
文件,这里我们将里面的内容整合下,保留最外层的package.json
文件,内容如下:
{
"private": true,
"name": "umi_egg",
"scripts": {
"start": "egg-scripts start --daemon --title=egg-server-app",
"stop": "egg-scripts stop --title=egg-server-app",
"clean": "ets clean",
"build": "umi build",
"postinstall": "umi generate tmp",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"dev": "npm run clean && egg-bin dev --port 4396",
"test": "umi-test",
"debug": "egg-bin debug --port 4396",
"test:coverage": "umi-test --coverage"
},
"gitHooks": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.{js,jsx,less,md,json}": [
"prettier --write"
],
"*.ts?(x)": [
"prettier --parser=typescript --write"
]
},
"egg": {
"typescript": true,
"declarations": true
},
"dependencies": {
"@ant-design/pro-layout": "^6.5.0",
"axios": "^0.27.2",
"egg": "^2.29.3",
"egg-cors": "^2.2.3",
"egg-http-proxy": "^1.0.1",
"egg-scripts": "^2.13.0",
"egg-socket.io": "^4.1.6",
"egg-view-assets": "^1.6.1",
"egg-view-ejs": "^2.0.1",
"react": "17.x",
"react-dom": "17.x",
"umi": "^3.5.23"
},
"devDependencies": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@umijs/preset-react": "1.x",
"@umijs/test": "^3.5.23",
"autod": "^3.0.1",
"autod-egg": "^1.1.0",
"egg-bin": "^4.11.0",
"egg-ci": "^1.11.0",
"egg-mock": "^3.21.0",
"egg-ts-helper": "^1.25.9",
"eslint": "^7.32.0",
"eslint-config-egg": "^9.0.0",
"eslint-plugin-react": "^7.25.0",
"lint-staged": "^10.0.7",
"prettier": "^2.2.0",
"typescript": "^4.6.4",
"yorkie": "^2.0.0"
}
}
5.对目录做出如下调整:(mock
以及app
下的test
目录也没啥用,可以删掉)
更改后目录结构如下:
6.最重要的环节:
npm install
二. Umi和Egg的细节整合
到这里,初始化工作也完成了,接下来开始讲两个框架进行整合。
咱们先来启动下项目,看看是否能够正常启动并访问,大家使用命令:
npm run dev
结果应该如下:
访问对应的URL:http://127.0.0.1:4396/
结果如下:如果正常访问则说明项目是没什么问题的
2.1 使用TypeScript
为什么要使用TypeScript
呢,这里做个解释,TypeScript本身就是作为JavaScript的一个超集语言。而TypeScript可以用于编写前端页面(index.tsx
)以及后端代码(index.ts
),方便语言的一个统一。
1.安装TypeScript:
npm install typescript
npm install @typescript-eslint/eslint-plugin --save-dev
此时,我们就能将后端代码改成这样,例如:
controller/home.ts
文件:
import { Controller } from 'egg';
class HomeController extends Controller {
getData() {
this.ctx.body = this.service.userService.getUserName();
}
}
export default HomeController;
router.ts
文件:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router } = app;
router.prefix('/zong*');
router.post('/user/getData', controller.home.getData);
};
service/UserService.ts
文件:
import { Service } from 'egg';
class UserService extends Service {
getUserName() {
return 'LJJ';
}
}
export default UserService;
在这里,有的读者可能会发现,在写代码的时候,通过controler.xxx
的时候,没有对应的提示代码。这里需要借助egg-ts-helper
插件来生成对应的声明(dev依赖中已经添加了):
在package.json
文件中的scripts
属性下添加脚本命令:
"tsc": "ets && tsc -p tsconfig.json",
同时,我们要在tsconfig.json
文件中做对应的配置,目的是防止一些不必要的目录被egg-ts-helper
插件执行,生成对应的js
文件,可能会出现很多Error
。内容如下:
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"moduleResolution": "node",
"importHelpers": true,
"jsx": "react",
"esModuleInterop": true,
// "outDir":"./dist",
"baseUrl": "./",
"strict": true,
"paths": {
"@public/*": ["public/*"],
"@/*": ["src/*"],
"@@/*": ["src/.umi/*"],
"@defineType/*": ["./model/*"]
},
"resolveJsonModule": true,
"suppressImplicitAnyIndexErrors": true,
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"charset": "utf8",
"allowJs": false,
"pretty": true,
"noEmitOnError": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"strictPropertyInitialization": false,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"inlineSourceMap": true,
"skipDefaultLibCheck": true
},
"exclude": [
"app/public",
"app/views",
"node_modules",
"lib",
"es",
"dist",
"**/__test__",
"test",
"docs",
"tests"
]
}
然后运行命令
npm run tsc
运行结果如下:(下面的错误其实没关系,因为前端的代码我们还没有改动过,先放着,不过可以看到红框地方,插件会生成对应的声明文件)
生成好后,咱们写代码的时候就会有这样的效果:
小伙伴本记得运行下npm run clean
哦。
2.2 使用Eslint
配置下相关的eslint
,这样开发起来会规范一点,更改下.eslintrc.json
文件:
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"eslint-config-egg/typescript"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint"],
"rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"react/display-name": ["off", { "ignoreTranspilerName": true }],
"react/prop-types": ["off"],
"@typescript-eslint/no-var-requires": 0,
"no-bitwise": ["off"]
}
}
配置好后,效果如图:(记得设置下保存的时候自动使用eslint
)
到这里为止,我们后端已经写完了,那么如何在前端页面调用后端的接口呢?
2.3 前端发起请求
我们利用axios
来发起请求,先下载对应的包:
npm install axios
在src/utils
目录下创建一个工具类:axiosHelper.ts
:
import axios from 'axios';
export default async function(
prefixUrl: string,
methodName: string,
data: any,
) {
let response;
try {
response = await axios({
url:
location.protocol +
'//' +
window.location.host +
'/zong' +
prefixUrl +
methodName,
method: 'post',
data,
withCredentials: true,
});
} catch (error) {
console.log(error);
}
return response;
}
page/index.tsx
文件:
import React from 'react';
import { Button } from 'antd';
import axios from '../utils/axiosHelper';
const UserPage = () => {
return <Button onClick={() => axios('/user', '/getData', { msg: 'hello' }).then(val => { console.log(val); })}>数据获取</Button>;
};
export default UserPage;
到这里,前端页面也写好了,那么大家可以从这个角度来思考下以下问题:
- 通过Egg的脚手架创建的后端程序,运行命令为:
egg-bin dev
。 - 通过UmiJs脚手架创建的前端程序,运行命令为:
umi dev
两者命令不一样,启动时的默认端口也不一样,那么怎么去同时启动呢?一定要配置跨域吗?若是如此,这还能叫做Umi整合Egg吗?不对。接下来开始讲解本文的细节部分。
2.4 利用egg-view模板来加载umiJs
到目前为止,项目中比较重要,但是又没有动过的,只有egg
的配置文件了:
话不多说,我们直接上代码(后面再讲具体的流程):
1.更改config.default.ts
文件(自己改下后缀哦):先npm install cross-env
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EggAppConfig, EggAppInfo, PowerPartial } from 'egg';
export default (appInfo: EggAppInfo): any => {
/**
* built-in config
* @type {Egg.EggAppConfig}
**/
const config = {} as PowerPartial<EggAppConfig>;
// 业务相关配置,这里是拿到我们的启动环境
const bizConfig = {
envName: appInfo.pkg.config.env.toLowerCase(),
};
// use for cookie sign key, should change to your own and keep security
config.keys = appInfo.name + '_1650629680875_8410';
// add your middleware config here
config.middleware = [];
config.security = {
csrf: {
enable: false,
},
};
// 开启ejs模板的使用,这里一定要配置,否则egg不会使用ejs模板,会报错
config.view = {
mapping: {
'.ejs': 'ejs',
},
};
// 本地代理,启动umi dev,监听8007端口
config.assets = {
publicPath: '/public',
devServer: {
// 这里是本地的一个代理,用了cross-env命令,因此需要npm install cross-env
// 同时UMI_ENV=dev命令,若使用,则必须拥有一个名为.umirc.dev.ts的文件
// 也因此后续要创建两个文件,一个.umirc.dev.ts,一个.umirc.ts,一般用于生产和本地开发配置的区分
command: 'cross-env UMI_ENV=dev umi dev --port=8007',
port: 8007,
env: {
// 这里的baseDir也就是 下文的 server.js文件中赋值的。程序启动的时候,会自动读取
APP_ROOT: appInfo.baseDir,
BROWSER: 'none',
ESLINT: 'none',
SOCKET_SERVER: 'http://127.0.0.1:8007',
PUBLIC_PATH: 'http://127.0.0.1:8007',
},
},
};
return {
...config,
...bizConfig,
};
};
2.更改plugin.ts
文件:
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EggPlugin } from 'egg';
const plugin: EggPlugin = {
assets: {
enable: true,
package: 'egg-view-assets',
},
ejs: {
enable: true,
package: 'egg-view-ejs',
},
httpProxy: {
enable: true,
package: 'egg-http-proxy',
},
cors: {
enable: true,
package: 'egg-cors',
},
};
export default plugin;
3.根目录下创建文件.umirc.dev.ts
,和原有的.umirc.ts
文件内容也跟下面的配置保持一致。
import { defineConfig } from 'umi';
export default defineConfig({
base: '/zong/',
nodeModulesTransform: {
type: 'none',
},
manifest: {
fileName: './config/manifest.json',
publicPath: '/public/',
},
// 打成的包输出的路径
outputPath: './app/public',
publicPath: '/public/',
fastRefresh: {},
});
4.根目录下创建server.js
文件:
/* eslint-disable indent */
/* eslint-disable @typescript-eslint/indent */
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { Application } = require('egg');
const pkj = require('./package.json');
const app = new Application({
baseDir: path.resolve('./'),
});
app.ready(() => app.listen(pkj.config.port));
5.根目录下创建app.config.js
文件:
module.exports = {
Env: 'dev',
};
6.根目录下创建app.ts
文件:
import { Application } from 'egg';
class AppBootHook {
private app: Application;
constructor(app: Application) {
this.app = app;
}
configWillLoad() {
// 此时 config 文件已经被读取并合并,但是还并未生效
// 这是应用层修改配置的最后时机
// 这里则加入一个版本号,目的是为了下文ejs模板里面,加载umiJs后,能保证每次项目重新发布(启动的时候),不会因为缓存而导致未更新
this.app.config.fileVersion = new Date().getTime().toString();
}
}
module.exports = AppBootHook;
7.在app
目录下创建view
目录,用于放ejs
文件,同时创建index.ejs
文件:
内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Test</title>
<% if (envName == "dev") { %>
<%- helper.assets.getStyle('umi.css') %>
<% } else { %>
<link rel="stylesheet" type="text/css" href='/<%- contextPath %>/public/umi.css?v=<%- fileVersion %>' />
<% } %>
</head>
<body>
<div id='root' class='subRootContent'>
</div>
<script>
window.resourceBaseUrl = '<%= helper.assets.resourceBase %>';
<% if (envName != "dev") { %>
window.staticUrl = '/<%- contextPath %>/public'
window.resourceBaseUrl = '/<%- contextPath %><%= helper.assets.resourceBase %>';
<% } %>
window.publicPath = resourceBaseUrl
</script>
<% if (envName == 'dev') { %>
<%- (helper.assets.getScript('umi.js')) %>
<% } else { %>
<script src='/<%- contextPath %>/public/umi.js?v=<%- fileVersion %>'></script>
<% } %>
</body>
</html>
8.在config
目录下,创建manifest.json
映射文件:
{
"umi.css": "umi.css",
"umi.js": "umi.js",
"index.html": "index.html"
}
9.修改controller/home.ts
文件:增加一个index
方法,主要是使用index.ejs
模板。
async index() {
const { config } = this;
const { envName, fileVersion } = config;
// 这里的data数据,加载到ejs模板后,直接可以通过对应的属性名来获取对应的值。
// 同时注意,要使用ejs模板,必须要有个contextPath
const data = {
envName,
fileVersion,
contextPath: 'zong',
};
await this.ctx.render('index.ejs', data);
}
10.修改路由,增加监听:这样页面上的任何一个请求,都会被Egg路由捕捉到,然后到对应的index方法下去执行。
router.get('**/**', controller.home.index);
router.post('**/**', controller.home.index);
最后先npm run build
,在npm run dev
,项目倘若启动成功:会出现以下字样
- 4396是Egg的端口,出现了说明Egg程序运行成功。
- 8007端口是
umi dev
命令的一个启动指定端口,出现了说明前端也运行成功了。
运行成功后,访问页面http://localhost:4396/zong/
,可以看到,我们用Egg程序的4396端口就能访问前端Umi的页面,同时还能够正常的获得请求。说明整合成功了。
然后我将代码的源码发给大家,大家可以对照下代码,若出错了,看下是否哪里配置有问题(也有可能是我写的不够详细或者漏说,若哪里漏说了,还请指正)源码
大家把代码拉下来,npm install
后就可以跑了。
三. 整合的原理和相关知识点
3.1 项目启动过程
1.npm run dev
的时候,实际上跑的命令是egg-bin dev
,因此,本项目跑起来后实际上是一个Egg项目!
2.程序会在启动的过程中,加载这几个文件:
app.config.js
:配置了个Env
属性,只在本地开发有效。server.js
:配置了项目的地址baseDir
。config/config.default.ts
:Egg的相关配置。- 其他的略。
3.egg配置中,我们使用了assets
代理:主要通过command
命令,执行了umi dev命令
,同时指定端口8007。
config.assets = {
publicPath: '/public',
devServer: {
command: 'cross-env UMI_ENV=dev umi dev --port=8007',
port: 8007,
env: {
APP_ROOT: appInfo.baseDir,
BROWSER: 'none',
ESLINT: 'none',
SOCKET_SERVER: 'http://127.0.0.1:8007',
PUBLIC_PATH: 'http://127.0.0.1:8007',
},
},
};
4.到这里,前后端实际上都是运行成功了。那么我们在URL上,通过4396(Egg后端端口)就能访问前端页面的原理是啥?大家请看路由:
router.get('**/**', controller.home.index);
router.post('**/**', controller.home.index);
此时,URL上访问的任何路径,都会被该路由捕捉,毕竟**/**
摆在这里😂,然后会根据路由指定的controller.home.index
方法去执行对应的逻辑。
async index() {
const { config } = this;
const { envName, fileVersion } = config;
const data = {
envName,
fileVersion,
contextPath: 'zong',
};
await this.ctx.render('index.ejs', data);
}
那么这里我们将版本号、环境属性、URL的相关路径都塞到了ejs
里面,我们来看下ejs
:注意这里我只是在上文基础上加了注释,实际上不能这么写的哦。
<head>
// 这里也就是很普通的一个HTML文件的头
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Test</title>
// 如果环境是dev,那么我们会根据项目里配置的assets,通过它来走代理,自动获取名为umi.css的文件。umi.css是umiJs打包后必会生成的东西。
<% if (envName == "dev") { %>
<%- helper.assets.getStyle('umi.css') %>
<% } else { %>
// 否则,我们将加载项目静态目录public下的umi.css文件,记住,生产上访问的都是静态文件。前端都是打包打好的
<link rel="stylesheet" type="text/css" href='/<%- contextPath %>/public/umi.css?v=<%- fileVersion %>' />
<% } %>
</head>
<body>
<div id='root' class='subRootContent'>
</div>
<script>
// resourceBaseUrl,staticUrl,都是必须要配置的,这里的脚本,可以说是一个固定的模板了,要和项目内的前缀URL保持一致,我这里配置的是zong
window.resourceBaseUrl = '<%= helper.assets.resourceBase %>';
<% if (envName != "dev") { %>
window.staticUrl = '/<%- contextPath %>/public'
window.resourceBaseUrl = '/<%- contextPath %><%= helper.assets.resourceBase %>';
<% } %>
window.publicPath = resourceBaseUrl
</script>
// 同理上述的umi.css文件,实际上我们的前端页面都打包到umi.js了,
<% if (envName == 'dev') { %>
<%- (helper.assets.getScript('umi.js')) %>
<% } else { %>
<script src='/<%- contextPath %>/public/umi.js?v=<%- fileVersion %>'></script>
<% } %>
</body>
5.我们可以发现,访问URL:http://localhost:4396/zong/
的时候,实际上Egg渲染了ejs
模板,而ejs
模板里面,引入了umi.js
文件。而umi.js
文件里面控制了前端页面的相关路由。访问/zong/
实际上对应的就是我们的页面src/pages/index.tsx
。因此是能够访问到的。
3.2 几个注意点
1 package.json
文件中,若项目使用TypeScript,不可缺少配置"typescript": true
,否则读取egg配置的时候,都是读取以js
为结尾的文件。
"egg": {
"typescript": true,
"declarations": true
},
2.若项目配置的相关的前缀,那么ejs模板里的contextPath和base属性要保持一致,例如:
.umirc.ts
文件:
import { defineConfig } from 'umi';
export default defineConfig({
base: '/zong/',
// 省略
});
router.ts
文件:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router } = app;
// 路由的前缀
router.prefix('/zong*');
router.post('/user/getData', controller.home.getData);
router.get('**/**', controller.home.index);
router.post('**/**', controller.home.index);
};
home.ts
文件中,塞入到ejs
模板里的contextPath
属性:
async index() {
const { config } = this;
const { envName, fileVersion } = config;
const data = {
envName,
fileVersion,
contextPath: 'zong',
};
await this.ctx.render('index.ejs', data);
}
(照着格式写就行啦)
3.router.ts
文件中并没有配置相关的路由routes
,若不配置,那么程序会根据约定式路由,自动生成对应的路由,因此我们只需要关注在pages
目录下写页面即可。无需自己手动配置路由。