Skip to content

最近的半年,我们公司前端组对公司产品进行了重构。旧的代码约80W行,启动项目缓慢,UI代码和业务逻辑大量耦合。mixins的使用让人苦不堪言,基本没有现成的组件使用,没有文档,大量复杂的异步调用。这让前端开发变得异常难受。当然我们要看一个背景,公司的产品始于4,5年前,那时候Vue才刚刚火起来,公司也才处于起步阶段,公司开发人员少,任务多,时间赶,使得项目中存在大量的重复代码,日积月累,代码堆到了数十万的级别。终于,在今年我们前端组,设计组,文档组,测试组联合起来对项目进行了重构。首先我介绍一下技术栈:

  1. 为什么使用React,而不是Vue?

react与ts搭配起来更好用。

  1. 为什么要使用Nest呢?

公司的后端严格意义上来说,是提供云计算能力的。把大量的UI逻辑放在java端是不合适的。放在前端又会出现业务代码和UI代码耦合的情况。后端提供的接口又是原子性的,基本没有联表查询的能力,所以存在大量的异步查询调用。于是有了中间层,这样我们可以对UI想要的数据进行自由的组合。而又不会对java端的能力层造成大的影响。

  1. 为什么使用GraphQL?

目前来说,国内使用GraphQL的并不多。一是文档都是英文,对国人开发不够友好;二是对新技术普遍持怀疑态度,再加上前端的轮子很多,大家都见怪不怪了。我们公司为啥用?一是我们公司产品业务足够复杂;二是我们团队乐于探索新技术为了更好的开发体验;三是微软已经在github上使用了。当然我们在决定最终使用之前,是做了大量的测试调研的。灵活性,扩展性,可描述性是我们使用它的理由。

  1. 为什么使用Qiankun?

其实Qiankun也是新技术,阿里的微前端框架。为了解决什么问题?项目大了之后,文件繁多,不便于查找,编译启动慢。使用了微前端,便于模块化开发,模块化部署,公用部分的抽离扩展。

  1. 为啥使用TypeScript?

类型。你能传什么值,我说了算;我能返回值,你也一目了然;开发阶段能快速暴露问题,编译报错。

  1. 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')
  })

})

Powered by VitePress.