为支持公司业务发展,方便业务在非工作时间段在手机端处理部分工作,需要新开发一个 移动飞书 H5 工作台
系统。
很高兴能负责此次的项目搭建,让我有机会从头到尾地经历一次项目的搭建过程。
在此,我将记录并分享从项目设计到最终上线的完整步骤,总结搭建过程中遇见过的坑。希望下次有这样的机会时,能在本次搭建的基础上,更快地搭建出质量更高的项目。
手机
及 pad
。中文
、英文
两种语言方式。PC
或者 Pad
中访问时,需要 SSO
登录。bug
使用信息。PV
、UV
等用户访问信息。分类 | 库 | 版本 |
---|---|---|
前端框架 | React | 18.2.0 |
构建工具 | vite | 4.4.5 |
类型检查 | TypeScript | 4.9.5 |
组件库 | antd-mobile | 5.32.0 |
国际化支持 | react-intl | 6.4.4 |
路由 | react-router-dom | 6.14.2 |
接口请求 | axios | 1.4.0 |
样式 | less | 4.1.3 |
方法库 | lodash | 4.17.21 |
接口自动生成 TS 定义 | pont-engine | 0.3.10(二次开发) |
代码风格检查 | Eslint + Pretter | 8.45.0 |
图标 | iconfont | -- |
登录 | SSO | -- |
错误收集 | @sentry/react | 7.64.0 |
数据收集 | spark-trace | 5.1.4 |
移动端调试工具 | vconsole | 3.15.1 |
资源全球加速 | 腾讯 CDN | -- |
仓库 | gitlab | -- |
构建 | CI / CD | -- |
代码提交检查 | husky + commitlint | 8.0.3 + 17.6.7 |
飞书免登录 | feishu api | -- |
除部分需要 Node V16+
版本及以上的包外,基本使用的都是对应库的最新版本。这样有什么优缺点呢?
bug
,使用最新库遇见问题时,定位以及修复方法不好找,需要花更多的时间。Node
没使用 V16+
的呢?gitlab runner
都使用的同一个。有一些项目搭建时间较久,能使用最高的 Node
版本为 V14+
,超过 V14+
安装的包有兼容性问题。为了不影响其它系统的正常使用,我们这次的 Node
最高版本也只支持在 V14+
。Vite
来构建初始化项目?Vite
、 Webpack
等都是很好的构建工具。
Vite
提出,它利用浏览器开始支持 ES
模块的特性,基于原生 ES
模块进行构建,可以极大地缩短项目启动以及 HMR
的时间。
阅文千遍,不如亲身实践一遍。所以正好借此机会,从实际使用上来感受一下 Vite
与 Webpack
在开发体验上的区别。
pnpm
是同类工具速度的将近 2
倍。node_modules
中的所有文件均克隆或硬链接自单一存储位置。pnpm
内置了对单个源码仓库中包含多个软件包的支持。pnpm
创建的 node_modules
默认并非扁平结构,因此代码无法对任意软件包进行访问。> pnpm create vite $projectName --template react-ts
> cd $projectName
> pnpm i
> pnpm dev
初始化项目后,tsconfig.json
报错:
解决办法
修改 moduleResolution
的值为 node
:
//tsconfig.json
"moduleResolution": "node"
tsconfig.node.json
处也做相同修改。
tsconfig.json
报 Unknown compiler option 'allowImportingTsExtensions'
错误:
解决办法
将 allowImportingTsExtensions
移动到和 compilerOptions
同级的地方。
// tsconfig.node.json
{
"compilerOptions": {
// ...
},
"allowImportingTsExtensions": true,
}
参考链接:https://blog.csdn.net/qq_46266305/article/details/131140524
main.tsx
报 This module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag
错误
解决办法
tsconfig.json
中配置
// tsconfig.json
"noFallthroughCasesInSwitch": true
main.tsx
报 An import path cannot end with a '.tsx' extension. Consider importing './App.js' instead.
错
解决办法
去掉 .tsx
结尾。
> pnpm i less -D
如果需要以 styles.
的方式使用 less
,需要将 less
的文件命名为 *.module.less
。
import styles from '*.module.less';
部分变量需要在很多 less
文件中使用,在 vite.config.ts
中配置全局 less
// vite.config.ts
css: {
modules: {
generateScopedName: "[local]__[hash:base64:5]",
hashPrefix: "prefix",
},
preprocessorOptions: {
less: {
javascriptEnabled: true,
// 配置 less 全局变量
additionalData: `@import "${path.resolve(
__dirname,
"src/assets/styles/variable.less"
)}";`,
},
},
// 开发环境生成 less 的 sourceMap
devSourcemap: !isOnline,
},
参考链接:https://cn.vitejs.dev/guide/features.html#css-modules
> pnpm i eslint -D
因为有使用 TypeScript
。所以还需要安装
@typescript-eslint/parser
@typescript-eslint/eslint-plugin
eslint-plugin-react-hooks
eslint-plugin-react-refresh
在 package.json
的 scripts
中配置
// package.json
"lint-staged:js": "eslint --cache --ext .js,.jsx,.ts,.tsx ./src"
执行命令
> pnpm run lint-staged:js
如果还有依赖包没安装好,在 terminal
中会有提示,根据提示信息完善依赖包的 install
就行。
配置好 eslint
后,将初始化项目中,不符合 eslint
规范的代码先修改一下,以免后面不符合 eslint
的越来越多。
和 Eslint
较为相关的当然是 husky
了。
它的作用是什么呢?
能在 git
操作过程中,触发对应的钩子,执行对应的命令。在这里与 Eslint
结合,能在提前到 git
仓库前,对 Eslint
进行一次校验,以防止不符合 Eslint
规范的代码提交到无端仓库。
具体的安装步骤,可以参考 github
官方文档来操作:https://github.com/typicode/husky
然后在 husky
的 pre-commit
钩子方法里面写
// .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo 'precommit'
npm run lint-staged:js
// package.json
"scripts": {
"lint-staged:js": "eslint --cache --ext .js,.jsx,.ts,.tsx ./src"
}
它的作用是什么?
规范 commit
信息。如果是功能提交,commit message
需要以 feat:
开头,如果是修复 bug
,需要以 fix:
开头,如果只是样式修改,需要以 style
开头等等。
这样可以从 commit
处,直观看到本次 commit
的具体作用是什么。
build
chore
ci
docs
feat
fix
perf
refactor
revert
style
test
具体操作步骤参考官方文档:https://github.com/conventional-changelog/commitlint
在安装 commitlint
过程中,遇见一个报错:
SyntaxError: Failed to load plugin '@typescript-eslint' declared in '.eslintrc.js » @vue/eslint-config-typescript/recommended » /home/viktord/Projects/renthome/dashboard/node_modules/@vue/eslint-config-typescript/index.js': Unexpected token '??='
Referenced from: /home/viktord/Projects/renthome/dashboard/node_modules/@vue/eslint-config-typescript/index.js
/home/viktord/Projects/renthome/dashboard/node_modules/@typescript-eslint/typescript-estree/dist/convert.js:176
result.range ??= (0, node_utils_1.getRange)(node, this.ast);
^^^
SyntaxError: Unexpected token '??='
at wrapSafe (internal/modules/cjs/loader.js:1001:16)
at Module._compile (internal/modules/cjs/loader.js:1049:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
at Module.load (internal/modules/cjs/loader.js:950:32)
at Function.Module._load (internal/modules/cjs/loader.js:790:12)
at Module.require (internal/modules/cjs/loader.js:974:19)
at require (internal/modules/cjs/helpers.js:101:18)
at Object.<anonymous> (/home/viktord/Projects/renthome/dashboard/node_modules/@typescript-eslint/typescript-estree/dist/ast-converter.js:4:19)
at Module._compile (internal/modules/cjs/loader.js:1085:14)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
Process finished with exit code -1
原因:最新的 @typescript-eslint/eslint-plugin v6.2.0
有问题,先降级为 5.62.0
。
等 bug
修复后,可以尝试再次升级为 v6+
。
有许多移动端的组件,引入一个完整的组件库,可以减少很多重复造轮子的时间。
> pnpm i antd-mobile
在 https://www.iconfont.cn/ 中上传自己的图标。
以 Symbol
的方式生成图标
将资源下载到项目的 public
文件夹下。
然后在 index.html
处引入对应的 js
// index.html
<body>
<div id="root"></div>
<script src="/iconfont.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
如果使用的是 @ant-design/icons
它提供了一个方法 createFromIconfontCN
可以扩展字体图标。但我们不需要它的图标,所以不需要安装它的包。
这样,就需要自己实现一个 Iconfont
组件。
// components/Iconfont.tsx
import { FC } from "react";
interface IProps extends IObject {
/** 图标类型 icon- */
type: string;
/** 图标大小 */
size: number;
/** 图标颜色 */
color?: string;
}
const Iconfont: FC<IProps> = (props) => {
const { type, color, size, style, ...rest } = props;
return (
<svg
className="iconfont"
aria-hidden="true"
fill={color}
style={{ width: size, height: size, ...style }}
{...rest}
>
<use xlinkHref={`#${type}`}></use>
</svg>
);
};
export default Iconfont;
这样就能使用 IconFont
了。
<Iconfont type="icon-image" size={24} className="posterButtonItem-btnIcon" />
国际化语言支持。
> pnpm i react-intl
修改 main.tsx
// main.tsx
const localeInfo = locales();
ReactDOM.createRoot(document.getElementById("root")!).render(
<IntlProvider locale={localeInfo.locale} messages={localeInfo.localeMessages}>
<React.StrictMode>
<App />
</React.StrictMode>
</IntlProvider>
);
添加 src/locales/index.ts
。优先读取 localStorage
中是否有保存语言环境,如果没有,默认读取浏览器语言环境。切换语言后,将语言信息存放在 localStorage
中。
// src/locales/index.ts
function locales() {
const language = localStorage.getItem('language-locale') || navigator.language;
switch(language) {
case 'zh-CN':
return { locale: navigator.language, localeMessages: zhCN };
default:
return { locale: navigator.language, localeMessages: enUS };
}
}
export default locales;
切换语言组件
// components/SwitchLanguage.tsx
import { Button, Popover } from "antd-mobile";
import { Action } from "antd-mobile/es/components/popover";
import styles from "./index.module.less";
enum languageEnum {
"zh-CN" = "zh-CN",
"en-US" = "en-US",
}
const actions: Action[] = [
{ key: languageEnum["zh-CN"], text: "简体中文(CN)" },
{ key: languageEnum["en-US"], text: "English(EN)" },
];
/** 切换语言 */
const SelectLanguage = () => {
const currentLanguage =
localStorage.getItem("language-locale") || navigator.language;
// 如果找不到,就展示 English。因为 zh-CN 只有一个,其它的语言很多
const languageText =
actions.find((item) => item.key === currentLanguage)?.text ||
actions[1].text;
const handleLanguageChange = (node: Action) => {
// 如果选择的是和目前一样的语言,不做任何处理
if (node.key === currentLanguage) return;
localStorage.setItem("language-locale", node.key as string);
window.location.reload();
};
return (
<section className={styles.selectLanguage}>
<Popover.Menu
actions={actions}
onAction={handleLanguageChange}
placement="bottom-start"
trigger="click"
>
<Button>{languageText}</Button>
</Popover.Menu>
</section>
);
};
export default SelectLanguage;
然后在对应的组件中,就可以使用了
import { useIntl } from "react-intl";
const intl = useIntl();
intl.formatMessage({ id: "global.copySuccess" })
> pnpm i axios
添加了 axios
后,需要封装 request
请求。请求数据时,给接口添加请求头,返回数据时,对接口进行统一错误处理。
正常情况下,在一个文件中,使用相对路径加载其他模块,如果文件层级较深,经常会出现 ../../../../*.tsx
这样的写法。在文件目录发生变化时,很容易出现路径修改不完整而导致运行错误。
配置 alias
,可以以绝对路径的方式引入其他模块。例如:import Loading from '@/components/Loading'
。
配置 alias
的方法如下:
在 vite.config.ts
中配置
// vite.config.ts
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
另外,还需要在 tsconfig.ts
中配置
// tsconfig.ts
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
}
pont
是什么?
pont
在法语中是“桥”的意思,寓意着前后端之间的桥梁。
Pont
把swagger
、rap
、dip
等多种接口文档平台,转换成Pont
元数据。Pont
利用接口元数据,可以高度定制化生成前端接口层代码,接口mock
平台和接口测试平台。其中
swagger
数据源,Pont
已经完美支持。并在一些大型项目中使用了近两年,各种高度定制化需求都可以满足。https://github.com/alibaba/pont
通俗来说就是
Swagger
定义,自动生成对应的 TS
的 interface
。request
方法,自动生成对应的 api
。diff
、updateMod
、updateInterface
、updateBo
等方法进行更新。首先,安装包
> pnpm i pont-engine -D
在项目的根目录下添加 pont-config.json
// pont-config.json
{
"outDir": "./src/services/auto-gen-api/src",
"templatePath": "./generate-api-template",
"originType": "SwaggerV2 | SwaggerV3",
"prettierConfig": {
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"endOfLine": "lf",
"printWidth": 100,
"proseWrap": "never"
},
"origins": [
{
"originType": "SwaggerV2 | SwaggerV3",
"originUrl": "https://**/v2/api-docs?group=api",
"name": "projectNameApi",
"usingMultipleOrigins": true,
"usingOperationId": true,
}
]
}
同级目录下添加 generate-api-template.ts
,里面的具体内容,根据接口定义以及封装的 request
进行自定义开发。
// generate-api-template.ts
export default class MyGenerator extends CodeGenerator {
// ...
}
做完这些,项目运行时,我们可以对生成的文件进行检验,看其语法是否正确,执行 node ./config/swagger-to-api/compile.js
。
// package.json
"scripts": {
"compile": "cross-env NODE_ENV=development pnpm compileApi",
"diff": "npx pont diff",
"compileApi": "node ./config/swagger-to-api/compile.js",
"updateMod": "npx pont updateMod",
"updateBo": "npx pont updateBo",
"updateInterface": "npx pont updateInterface",
},
在 compile.js
中,对生成的文件使用 ttsc
进行校验。
在执行 pnpm diff
时,有些会报 ES Module
错误。
解决办法:
去掉 package.json
中的
// package.json
type: 'module'
> pnpm install react-router-dom
修改 main.tsx
// main.tsx
ReactDOM.createRoot(document.getElementById("root")!).render(
<IntlProvider locale={locale} messages={localeMessages}>
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
</IntlProvider>
);
后面的所有的页面都会经过 Authority.tsx
组件。
为避免 router.ts
文件过大,不好维护。将路由分散在各个主功能下,使用 import
的方式进行引入。
// router.ts
import { createBrowserRouter } from "react-router-dom";
import Authority from "@/layout/Authority";
import ErrorBoundary from "@/layout/ErrorBoundary";
import Home from "@/pages/home";
import Search from "@/pages/search";
import clueRouters from "@/pages/clue/router";
const router = createBrowserRouter([
{
path: "/",
element: <Authority />,
errorElement: <ErrorBoundary />,
children: [
{
path: "/home",
element: <Home />,
},
{
path: "/search",
element: <Search />,
},
...clueRouters,
],
},
]);
export default router;
如果页面中有由 JS
执行出错的地方,会进入 errorElement
。在这里可以做一些页面错误的统一处理。
通过 react-router-dom
的 useRouteError
能获取到错误的具体信息。
// ErrorBoundary
import { useRouteError } from "react-router-dom";
import PageError from "./PageError";
// 错误处理
const ErrorBoundary = () => {
const error = useRouteError() as IObject;
// ...
return <PageError message={error.message} />;
};
export default ErrorBoundary;
在入口文件处引入封装好的 SSO SDK
。由于不同环境的 SDK
对应的 CDN
地址不一致。所有的页面都会经过 layout/Authority.tsx
组件。所以我们根据当前运行环境,在 Authority.tsx
的 useLayoutEffect
中进行动态加载,并且进行授权校验。
// Authority.tsx
useLayoutEffect(() => {
// 动态加载 SSO 文件
const script = document.createElement("script");
script.src = `${process.env.SSO_SERVER}/t.js`;
document.head.appendChild(script);
script.onload = async () => {
// 封装的 SSO 资源加载完成后,实例化 SSO 方法
window.authentication = new window.$logo(config);
setLoading(false);
};
// ...
}, []);
在后面的接口请求时,在 Network
中发现,第一次请求的接口,都执行了两次。原因是 React
使用了 StrictMode
模式,这是官方的预期行为。
https://react.dev/reference/react/StrictMode
https://juejin.cn/post/7231842222782054461
CDN
资源申请。由于系统需要国内国外都能访问,所以服务器资源需要全球加速。
申请 CDN
资源后,修改 vite.config.ts
中 base
字段的值。
// vite.config.ts
base: process.env.PKM_CDN_PATH || "/",
由于项目最后是由 PKM
发布的,所以需要在 PKM
系统中申请签名。将签名信息在 CI
中配置。
项目部署后,在移动设备上使用,遇见问题时,不能像在 浏览器
中一样按 F12
打开控制台进行调试代码。所以添加 vconsole
插件,打开时,可以看到 console
、network
等信息,方便 debug
。
> pnpm i vconsole -D
然后在 Authority.tsx
的顶部添加
// 非线上环境,开启 VConsole,方便在飞书中查看日志
if (process.env.ENV !== "online") {
new VConsole();
}
在 qa
及 sim
环境,可以看到 vconsole
信息,线上环境不会出现。
> pnpm i @sentry/react @sentry/tracing
在不同的环境中分别配置 Sentry
需要的信息。
// env.ts
SENTRY_DSN: '',
SENTRY_AUTH_TOKEN: '',
SENTRY_ORG: '',
SENTRY_PROJECT: '',
SENTRY_URL: '',
初始化 Sentry
// initSentry.ts
import * as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";
// 初始化 Sentry
function initSentry() {
if (!process.env.SENTRY_DSN) return;
Sentry.init({
dsn: process.env.SENTRY_DSN as string,
tracesSampleRate: 1.0,
release: process.env.PKM_VERSION,
integrations: [new BrowserTracing()],
ignoreErrors: [
"Request failed with status code 403",
});
}
export default initSentry;
在刚才写的 ErroryBoundry.tsx
组件中添加错误数据上报。
// ErrorBoundary
// 错误处理
const ErrorBoundary = () => {
const error = useRouteError();
// ...
Sentry.captureException(error, {});
// ...
};
将 sourcemap
资源上传到 Sentry
中,以便能更好地定位错误位置。
> pnpm i @sentry/vite-plugin
修改 vite.config.ts
,只在线上环境才上传 sourcemap
到 Sentry
。
// vite.config.ts
import { sentryVitePlugin } from "@sentry/vite-plugin";
export default defineConfig({
build: {
sourcemap: isOnline,
},
plugins: [
// ...
isOnline
? sentryVitePlugin({
url: definedEnv.SENTRY_URL,
org: definedEnv.SENTRY_ORG,
project: definedEnv.SENTRY_PROJECT,
authToken: definedEnv.SENTRY_AUTH_TOKEN,
sourcemaps: {
ignore: ["node_modules"],
},
release: {
name: process.env.PKM_VERSION,
uploadLegacySourcemaps: {
paths: [path.join(process.cwd(), "/dist")],
urlPrefix: process.env.PKM_CDN_PATH || "~/",
},
},
})
: null,
]
注意如果公司有自己配置 Sentry
,一定要配置 url
,不然我们写了 authToken
,会报 token invalid
错误。
使用的是字节的用户信息收集。
cache:
policy: pull
key: ${CI_COMMIT_REF_NAME}
paths:
- .pnpm-store
// ...
配置好对应的 CI
后,等运维将服务资源申请好,域名申请好后,部署到对应的环境中,进行一次测试,看流程是否跑通。
在飞书工作台 https://open.feishu.cn/app 申请应用。根据要求填写好申请信息,给需要看到的人配置权限,提交申请,审核通过后,有权限的人就可以在飞书的工作台中看到对应的应用,点击添加就可以添加到工作台中。
然后联系运维,配置对应环境与飞书的关联,实现在飞书工作台的免登录操作。
Authority.tsx
中添加
// Authority.tsx
const location = useLocation();
useEffect(() => {
// 路由变化时,页面滚动到最顶部
if (location.pathname) {
window.scrollTo({ top: 0 });
}
}, [location.pathname]);
将图片资源进行无损压缩,减少空间占用。
使用 vite-plugin-imp
对部分组件进行按需引入。
> pnpm i vite-plugin-imp -D
修改 vite.config.ts
// vite.config.ts
export default defineConfig({
plugins: [
vitePluginImp({
libList: [
{
libName: "lodash",
libDirectory: "",
camel2DashComponentName: false,
},
{
libName: "antd-mobile",
libDirectory: "es/components",
style() {
return `antd-mobile/es/global/index.js`;
},
},
],
}),
]
})
默认情况下,打包后的 JS
只有一个,达到了 2M+
,在请求时,如果网络较慢,这个资源可能等待的时间会比较久。
浏览器有一个特性,就是并发请求,同一时间可以同时请求多个资源,所以,使用 npx vite-bundle-visualizer -c vite.config.ts
命令对包体积进行分析,对包进行合理拆分。
// vite.config.ts
build: {
// ...
chunkSizeWarningLimit: 1024,
rollupOptions: {
output: {
manualChunks: (id) => {
const bigNodeModules = [
"html2canvas",
"cos-js-sdk",
"crypto-js",
"jsencrypt",
].some((item) => id.includes(item));
if (bigNodeModules) {
return "vendorLarge";
}
if (id.includes("node_modules")) {
return "vendor";
}
},
},
},
},
现在较新的浏览器都支持较新的语法。但在监控平台发现,还有好些用在使用类似 iOS 9
等版本较低的系统及浏览器。所以,我们还需要对较低版本的系统及浏览器增加兼容。
> pnpm i @vitejs/plugin-legacy terser -D
// vite.config.ts
plugins: [
// ...
legacy({
// 需要兼容的目标列表,可以设置多个
targets: ["defaults", "ie >= 11", "chrome >= 70"],
additionalLegacyPolyfills: ["regenerator-runtime/runtime"],
renderLegacyChunks: true,
// 下面的数组可以自定义添加低版本转换的方法
polyfills: [
"es.symbol",
"es.array.filter",
"es.promise",
"es.promise.finally",
"es/map",
"es/set",
"es.array.for-each",
"es.object.define-properties",
"es.object.define-property",
"es.object.get-own-property-descriptor",
"es.object.get-own-property-descriptors",
"es.object.keys",
"es.object.to-string",
"web.dom-collections.for-each",
"esnext.global-this",
"esnext.string.match-all",
],
}),
注意
这个是在 build 时才生效,本地因为 vite 使用的是 module 的方式打包构建的,所以本地加了这个,在本地启动项目,用手机连接本地时,也是无法访问的。
兼容的版本越低,生成的包的文件会越大。需要根据项目使用的具体情况,合理调整最小兼容版本。
https://github.com/vitejs/vite/tree/main/packages/plugin-legacy
通过这次搭建,我更加清晰地了解了从 0
到 1
这样一个完整的开发流程。除了正常业务开发外,对项目结构、公共组件设计、性能优化、兼容性、信息收集以及分析等都有了更进一步的了解及思考。以前不太明白的一些点,这次亲身实践后,都有了更清晰的认识。
虽然花了更多的时间,但收获很多,非常充实。