主题
最近的半年,我们公司前端组对公司产品进行了重构。旧的代码约80W行,启动项目缓慢,UI代码和业务逻辑大量耦合。mixins的使用让人苦不堪言,基本没有现成的组件使用,没有文档,大量复杂的异步调用。这让前端开发变得异常难受。当然我们要看一个背景,公司的产品始于4,5年前,那时候Vue才刚刚火起来,公司也才处于起步阶段,公司开发人员少,任务多,时间赶,使得项目中存在大量的重复代码,日积月累,代码堆到了数十万的级别。终于,在今年我们前端组,设计组,文档组,测试组联合起来对项目进行了重构。首先我介绍一下技术栈:
- React
- Nest.js
- dataloader
- antD
- GraphQL
- Qiankun
- TypeScript
- apollo-server
- ahooks
- umi
- cypress
- single-spa
- chalk
- dumi
- monaco-editor
- react-grid-layout
- react-intl
- react-markdown
- graphql-code-generator
- bizcharts
- 为什么使用React,而不是Vue?
react与ts搭配起来更好用。
- 为什么要使用Nest呢?
公司的后端严格意义上来说,是提供云计算能力的。把大量的UI逻辑放在java端是不合适的。放在前端又会出现业务代码和UI代码耦合的情况。后端提供的接口又是原子性的,基本没有联表查询的能力,所以存在大量的异步查询调用。于是有了中间层,这样我们可以对UI想要的数据进行自由的组合。而又不会对java端的能力层造成大的影响。
- 为什么使用GraphQL?
目前来说,国内使用GraphQL的并不多。一是文档都是英文,对国人开发不够友好;二是对新技术普遍持怀疑态度,再加上前端的轮子很多,大家都见怪不怪了。我们公司为啥用?一是我们公司产品业务足够复杂;二是我们团队乐于探索新技术为了更好的开发体验;三是微软已经在github上使用了。当然我们在决定最终使用之前,是做了大量的测试调研的。灵活性,扩展性,可描述性是我们使用它的理由。
- 为什么使用Qiankun?
其实Qiankun也是新技术,阿里的微前端框架。为了解决什么问题?项目大了之后,文件繁多,不便于查找,编译启动慢。使用了微前端,便于模块化开发,模块化部署,公用部分的抽离扩展。
- 为啥使用TypeScript?
类型。你能传什么值,我说了算;我能返回值,你也一目了然;开发阶段能快速暴露问题,编译报错。
- E2E?
cypress目前来看,足够简单,以一种类似JQuery操作dom的形式,来模拟用户的操作。我们可以拿测试的case来模拟整个测试流程,甚至给测试简单培训后,测试就能上手使用。这样一来能够覆盖大部分测试场景,提高测试效率。
- 访问记录
ts
# https://developer.mozilla.org/zh-cn/docs/web/api/window/popstate_event
import { usePersistFn } from 'ahooks'
const useRecordRecentVisitHistory = () => {
...
return {
recordRecentHistory: usePersistFn(() => {
...
}),
clearRecentHistory: usePersistFn(() => {
const userUuid = currentUser?.userUuid
setRecentVisitHistoryMap({
...recentVisitHistoryMap,
[userUuid]: []
})
})
}
const { recordRecentHistory } = useRecordRecentVistitHistory()
useMount(() => window.addEventListener('popstate', recordRecentHistory))
useUnmount(() => window.removeEventListener('popstate', recordRecentHistory))
- SVG主题色
jsx
import SVG from 'react-inlinesvg'
const getSVG = () => {
try {
const src = require('@/main/src/assets/images/logo.svg')
return (
<SVG
className={style['logo-svg']}
src={src}
uniquifyIDs={true}
// eslint-disable-next-line no-console
onError={error => console.error(error.message)}
/>
)
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
}
}
<svg width="16px" height="12px">
<polyline className={style.back} points="1 6 4 6 6 11 10 1 12 6 15 6" />
<polyline className={style.front} points="1 6 4 6 6 11 10 1 12 6 15 6" />
</svg>
less
.logo-svg {
path:first-child {
fill: @color-600;
}
path:last-child {
fill: @color-400;
}
}
svg {
polyline {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
&.back {
stroke: rgba(@color-600, 0.8);
}
&.front {
stroke: @color-600;
stroke-dasharray: 12, 36;
stroke-dashoffset: 48;
animation: dash 1s linear infinite;
}
}
}
- 动态修改gql
ts
const getGQL = (document: DocumentNode, keys: string[]): DocumentNode => {
const newAst: DocumentNode = visit(document, {
Field: {
leave(node) {
const {
selectionSet,
name: { value }
} = node
if (!selectionSet && keys.indexOf(value) < 0) {
return null
}
}
}
})
return newAst
}
import { queryList } from '@/src/gql/tag.gql'
import { useLazyQuery } from '@apollo/client'
const getOptionsGql = getGQL(queryList, ['name', 'uuid', 'color'])
const [getList, { data }] = useLazyQuery(getOptionsGql, { fetchPolicy: 'no-cache' })
- chalk
ts
export const LOG = {
info: (msg: string) => log(chalk.hex('#e8e8e8')(msg)),
tip: (msg: string) => log(chalk.hex('#73d13d')(msg)),
warning: (msg: string) => log(chalk.hex('#fff566')(msg)),
error: (msg: string) => log(chalk.hex('#ff7875')(msg))
}
- babel
ts
const { parse } = require('@babel/parser')
const code = readFileSync(listPath, { encoding: 'utf-8' })
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx', 'classProperties']
})
- shell
ts
const shell = require('shelljs')
const path = require('path')
const chalk = require('chalk')
const yParser = require('yargs-parser')
const readline = require('readline')
const { log } = console
const cwd = process.cwd()
const argv = yParser(process.argv.slice(2))
if (argv.clear) {
shell.rm('-Rf', path.resolve(cwd, 'dist'))
}
if (argv.list) {
log(apps)
process.exit()
}
- monaco-editor
ts
import { ControlledEditor } from '@monaco-editor/react'
<ControlledEditor
language={language}
value={value ?? defaultValue}
onChange={(ev, _value) => onChange?.(_value!)}
loading={<Spin />}
options={{
fontSize: 14,
scrollbar: { vertical: 'hidden', verticalScrollbarSize: 8 }
}}
/>
- exec
js
import { exec } from 'child_process'
import { promisify } from 'util'
const promisifyExec = promisify(exec)
export class ActionService {
async toDo () {
const { stdout } = await promisifyExec(`sudo top`)
}
}
- link
js
import { navigateToUrl } from 'single-spa'
<a href={`/${props.microAppName}${props.to}`} onClick={navigateToUrl}>{props.children}</a>
- react-grid-layout
js
import { WidthProvider, Responsive, Layouts, Layout } from 'react-grid-layout'
const ResponsiveReactGridLayout = WidthProvider(Responsive)
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0}}
cols={{lg: 12, md: 10, sm: 6, xs: 4, xxs: 2}}
>
...
</ResponsiveGridLayout>
- apollo-client
ts
import { ApolloClient, split, HttpLink, ApolloLink, from, InMemoryCache } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { WebSocketLink } from '@apollo/client/link/ws'
import { onError } from '@apollo/client/link/error'
import fetch from 'cross-fetch'
const uri = ''
const httpLink = new HttpLink({ uri, fetch })
const wsLink = new WebSocketLink({
uri: '',
options: {
reconnect: true
}
})
// 权限校验处理中间件
const authMiddleware = new ApolloLink((operation, forward) => {
operation.setContext(({ headers = {} }) => ({
headers: {
...headers,
'x-session-id': localStorage.getItem('session-id') ?? null
}
}))
return forward(operation)
})
// 错误信息处理中间件
const errorMiddleware = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
...
})
}
// graphQLErrors = []
if (networkError) {
...
}
})
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'
},
wsLink,
from([errorMiddleware, authMiddleware, httpLink])
)
const apollo = new ApolloClient({
defaultOptions: {
watchQuery: {
errorPolicy: 'all',
fetchPolicy: 'network-only',
nextFetchPolicy(lastFetchPolicy) {
...
}
},
query: {
// cache-first
// network-only
// ...
fetchPolicy: 'network-only'
}
},
cache: new InMemoryCache({
possibleTypes: {
...
},
typePolicies: {
...
}
}),
link: splitLink,
queryDeduplication: false
})
- data-loader
ts
import { Injectable, Inject } from '@nestjs/common'
import * as Dataloader from 'dataloader'
@Injectable()
export class AddressDataloader {
@Inject() queryAction: QueryAction
private userDataLoader
private userMap = {}
constructor() {
this.userDataLoader = new Dataloader(this._query)
}
query({ uuid, addressUuid }) {
this.userMap[uuid] = {
uuid,
addressUuid
}
return this.userDataLoader.load(uuid)
}
_query = async (uuids: string[]) => {
const addressUuids = uuids.map(uuid => this.userMap[uuid].addressUuid) ?? []
const params = { uuids: addressUuids }
const resp = await this.queryAction.call(params)
return uuids.map(uuid => {
const address = resp.find(it => it.uuid === this.userMap[uuid].addressUuid)
if (address) {
return address
} else {
return {}
}
})
}
}
ts
import { Inject } from '@nestjs/common'
import {
Int,
Resolver,
Args,
Query,
Parent,
ResolveField
} from '@nestjs/graphql'
import {
UserVO,
UserQueryResponse,
QueryUserArguments,
} from './user.model'
import { UserQueryService } from './user-query.service'
import { AddressDataloader } from './address.dataloader'
@Resolver(() => UserVO)
export class UserResolver {
@Inject()
addressDataloader: AddressDataloader
@Inject()
userQueryService: UserQueryService
@Query(() => UserQueryResponse)
async userList(@Args() args: QueryUserArguments) {
return await this.userQueryService.query(args)
}
@ResolveField()
async address(@Parent() user: UserVO) {
return await this.addressDataloader.query(user)
}
}
ts
import {
Field,
Int,
registerEnumType,
ArgsType,
ObjectType,
Float,
InputType
} from '@nestjs/graphql'
import { Type } from '@nestjs/common'
export enum SortDirectionValidValues {
asc = 'asc',
desc = 'desc'
}
registerEnumType(SortDirectionValidValues, {
name: 'SortDirectionValidValues'
})
@ArgsType()
@ObjectType()
export class QueryCommonArgs {
@Field(() => Int, { nullable: true })
limit?: number
@Field(() => Int, { nullable: true })
start?: number
@Field(() => Boolean, { nullable: true })
count?: boolean
@Field(() => String, { nullable: true, defaultValue: 'createDate' })
sortBy?: string
@Field(() => SortDirectionValidValues, { nullable: true, defaultValue: 'desc'})
sortDirection?: SortDirectionValidValues
}
export function QueryCommonResponse<T>(classRef: Type<T>): any {
@ObjectType({ isAbstract: true })
abstract class Result {
@Field(() => [classRef], { nullable: true })
list?: T[]
@Field(() => Float, { nullable: true })
total: number
}
return Result as any
}
@ObjectType()
export class User {
@Field(() => ID)
uuid: string
@Field(() => String, { nullable: true })
name: string
@Field(() => String, { nullable: true })
addressUuid: string
}
@ObjectType()
export class Address {
@Field(() => ID)
uuid: string
@Field(() => String)
province: string
@Field(() => String)
city: string
@Field(() => String, { nullable: true })
street: string
}
@ObjectType()
export class UserVO extends User {
@Field(() => Address)
address: Address
}
@ArgsType()
export class QueryUserArguments extends QueryCommonArgs {}
@ObjectType()
export class UserQueryResponse extends QueryCommonResponse(UserVO) {}
- bizcharts
js
const getColor = () => {}
<Chart
autoFit
width="100%"
height={360}
scale={scale}
data={intervalData}
padding={[40, 40]}
animate={false}
>
<Tooltip shared />
<Interaction type="active-region" />
<Legend visible={false} animate={false} />
<Interval
position="time*value"
animate={false}
color={['type', (type: IType) => getColor()]}
adjust={[
{
type: 'stack'
}
]}
size={8}
tooltip={[
'value*type',
(value, type) => {
return {
name: '',
value
}
}
]}
/>
<Axis
name="time"
line={{
style: {
stroke: '',
lineWidth: 2
}
}}
/>
<Axis
name="value"
grid={{
line: {
type: 'line',
style: {
stroke: '',
lineDash: [3, 3],
lineWidth: 1
}
}
}}
/>
<View data={lineData} scale={scale} padding={0} animate={false}>
<Line
position="time*value"
color={['type', (type: IType) => getColor()]}
size={1}
tooltip={[
'value*type',
(value, type) => {
return {
name: '',
value
}
}
]}
/>
<Point
position="time*value"
color={['type', (type: IType) => getColor()]}
size={4}
shape="circle"
tooltip={false}
/>
</View>
</Chart>
- react-markdown
js
import React from 'react'
import ReactMarkdown from 'react-markdown'
import { useIntl } from 'umi'
const intl = useIntl()
<ReactMarkdown>
{intl.formatMessage({
id: 'quotations',
defaultMessage: `
### 保持理智 \n
1. 相信未来。\n
2. 保持理智。`
})}
</ReactMarkdown>
- cypress
js
import { toString } from 'lodash'
Cypress.on('uncaught:exception', (err, runnable) => {
// returning false here prevents Cypress from
// failing the test
return false
})
// 阻止每次自动清localStorage
const clear = Cypress.LocalStorage.clear
Cypress.LocalStorage.clear = function (keys) {
if (keys?.length) {
return clear.apply(this, arguments) as void
}
}
Cypress.Commands.overwrite('clearLocalStorage', (originFn, keys?: string[]) => {
clear(keys)
})
Cypress.Commands.add('clickDropdownSubmenu', (btnName: string) => {
return cy
.get('.ant-dropdown:not(.ant-dropdown-hidden)')
.should('be.visible')
.find(`.ant-dropdown-menu-submenu:contains(${btnName})`)
.click({ force: true })
.then(($el) => {
const visible = Cypress.dom.isVisible($el)
if (visible) {
cy.wrap($el).trigger('mouseover')
} else {
cy.wrap($el).contains(btnName).trigger('mouseover', {
force: true
})
}
})
.wait(500) // 不阻塞动画
})
Cypress.Commands.add('clickRadio', (btnName: string) => {
return cy.get(`label:contains(${btnName})`).click()
})
Cypress.Commands.overwrite(
'hover',
(originalFn, subject: JQuery<HTMLElement>, options = { force: true }) => {
return cy.wrap(subject).trigger('mouseover', options).wait(2000)
}
)
Cypress.Commands.add(
'getTarget',
(target: { type: keyof typeof TargetType; contains: string }) => {
const { type, contains } = target
if ([TargetType.div, TargetType.span].includes(type as TargetType)) {
return cy.get(`${TargetType[type]}:contains(${contains}):visible`)
}
return cy.get(`.ant-${TargetType[type]}:contains(${contains}):visible`)
}
)
js
import mnEnv from '../common.env'
describe('Host', () => {
before(() => {
cy.cleanEnv().simulatorEnv(mnEnv)
cy.iam1Login('admin', 'password').visit('/hardware/host')
})
beforeEach(() => {
cy.resolveTarget({
type: 'table'
}).should('exist')
})
it('单个删除Host', () => {
cy.resolveTarget({
type: 'row',
contains: 'Host-1'
})
.find('button')
.last()
.click()
.clickDropdownItem('删除')
.clickCheckbox('我已知晓上述风险')
.clickBtn('确 定')
.inputFormItem('密码', 'password')
.clickBtn('确 定')
cy.resolveTarget({
type: 'row',
contains: 'Host-1'
}).should('not.exist')
})
it('详情页删除Host', () => {
cy.contains('Host-2').click()
// 进入详情页
cy.url().should('contain', '/detail')
cy.clickBtn('更多操作')
.clickDropdownItem('删除')
.clickCheckbox('我已知晓上述风险')
.clickBtn('确 定')
.inputFormItem('密码', 'password')
.clickBtn('确 定')
cy.go(-1)
cy.url().should('contain', '/hardware/host')
cy.resolveTarget({
type: 'row',
contains: 'Host-2'
}).should('not.exist')
})
it('批量删除Host', () => {
cy.get('thead .ant-checkbox')
.should('not.have.class', 'ant-checkbox-disabled')
.click()
.clickBtn('批量操作')
.clickDropdownItem('删除')
.clickCheckbox('我已知晓上述风险')
.clickBtn('确 定')
.inputFormItem('密码', 'password')
.clickBtn('确 定')
cy.resolveTarget({
type: 'row',
contains: 'Host'
}).should('not.exist')
})
})
js
import mnEnv from '../common.env'
describe('Host', () => {
before(() => {
cy.cleanEnv().simulatorEnv(mnEnv)
cy.iam1Login('admin', 'password').visit('/hardware/host')
})
beforeEach(() => {
cy.resolveTarget({
type: 'table'
}).should('exist')
})
it('添加Host-IP地址', () => {
cy.resolveTarget({
type: 'row',
contains: '127.0.1.5'
})
.find('button')
.last()
.click()
.clickDropdownItem('删除')
.clickCheckbox('我已知晓上述风险')
.clickBtn('确 定')
.inputFormItem('密码', 'password')
.clickBtn('确 定')
cy.resolveTarget({
type: 'row',
contains: '127.0.1.5'
}).should('not.exist')
cy.clickBtn('添加Host')
cy.inputFormItem('名称', 'host测试创建-IP地址-名称')
cy.inputFormItem('简介', 'host测试创建-IP地址-简介')
cy.clickBtn('绑定标签')
cy.get('thead .ant-checkbox')
.should('not.have.class', 'ant-checkbox-disabled')
.click()
cy.get('.ant-drawer-wrapper-body').as('hostAttachTagsDrawer')
cy.get('@hostAttachTagsDrawer').should('contain', '绑定标签')
cy.get('@hostAttachTagsDrawer').find('#drawer-ok').first().click()
cy.clickBtn('选择集群')
cy.get("tbody .ant-radio")
.first()
.click()
cy.get('.ant-drawer-wrapper-body').as('hostSelectClusterDrawer')
cy.get('@hostSelectClusterDrawer').should('contain', '选择集群')
cy.get('@hostSelectClusterDrawer').find('#drawer-ok').last().click()
cy.inputFormItem('IP地址', '127.0.1.5')
cy.inputFormItem('密码', 'password')
cy.get("#create-ok").click()
cy.resolveTarget({
type: 'row',
contains: 'host测试创建-IP地址-名称'
}).should('exist')
})
})