企业级业务平台项目设计、架构、业务全解之平台篇

业务方面

菜单管理

7.0系统的菜单只有两级(第二级可以拥有子页面):

一级(比如http://localhost:3000/#/index)一定是菜单不是有效路由。会被重定向到二级路由(http://localhost:3000/#/index/home)

二级路由才是有效路由。

点击详情会进入所谓的三级路由(其实就是二级路由,在路由里注册了,菜单视图里却没有注册)

菜单管理二级路由的操作栏没有添加子路由按钮是怎么实现的?

javascript 复制代码
const actions = [
    ...,
    {
      label: t('list.添加子路由'),
      sign: row =>
        hasPermission('sys:route:add') && row.pcodes.split(',').length < 3,
      click: add
    }
    ...
]

这里的sign传的不是布尔值而是函数,而且这个函数还接受了一个参数。回到组件库看看:

html 复制代码
<el-table-column
  v-if="actions.filter(rr => !!rr.sign).length > 0"
  :label="t('message.操作')"
    <template #default="scope">
        <el-button v-for="(info, index) in getActions(scope.row)" :key="index">
         {{ info?.label || '' }}
        </el-button>
    </template>
<el-table-column/>
javascript 复制代码
  const getActions = (row: any) =>
    props.actions.filter(t =>
      typeof t.sign === 'function' ? t.sign(row) : t.sign
    );

按钮权限

根据路由分类按钮权限。在登录后会通过接口获取当前用户的所有权限集,并存储在状态管理器里。

javascript 复制代码
import { usePermissionStoreHook } from '@/store/modules/router';
const permissionStore = usePermissionStoreHook();
export function hasPermission(key: string) {
  const jurisdictionArr: Array<string> = permissionStore.appPermission;

  if (jurisdictionArr && jurisdictionArr.length) {
    return (
      jurisdictionArr.indexOf(key) > -1 || jurisdictionArr.indexOf('all') > -1
    );
  } else {
    // 无权限
    return false;
  }
}

组织管理

组织拥有多层结构。绑定资源和用户概念。

用户、角色、查看范围、权限、路由

概念:

  1. 一个组织下有许多用户(用户只能有一个组织)。
  2. 用户的查看范围不止它的所属组织(可以查看多个组织)。
  3. 用户可以绑定多个角色。
  4. 每种角色可以绑定按钮权限和路由。

角色:

框架亮点

微内核+插件化架构设计

怎么理解组件库组件提供了统一的设计语言和交互模式

架构设计有几种模式

Web 开发 7 年,八千字浅谈前端架构设计与工程化结合自己多年的 Web 开发实际项目经验,分享一些我对前端架构设计、 - 掘金

前端设计模式有哪几种

盘点前端开发中最常见的几种设计模式设计模式介绍 设计模式是开发的过程中,遇到一些问题时的解决方案,这些方案是通过大量试验 - 掘金

项目中涉及到的几种前端设计模式举例

单例模式

单例模式的作用
资源管理 : 确保关键资源只有一个实例
配置统一 : 避免重复配置
内存优化: 减少不必要的对象创建

工厂模式

动态创建 : 根据参数动态创建对象
解耦 : 客户端不需要知道具体的创建过程
扩展性 : 易于添加新的产品类型
工厂模式(Factory Pattern) 是一种创建型设计模式,它提供了一种创建对象的最佳方式。工厂模式的核心思想是:
不直接使用 new 操作符创建对象
通过工厂方法来创建对象
将对象的创建逻辑封装起来

简单来说就是用户只需要传入配置,就能享受到工厂根据配置在内部加工后产出的产品了,用户看不到工厂内部做了什么,也不关心它做了什么。

装饰器模式

装饰器模式的作用
功能扩展 : 在不修改原有代码的基础上添加新功能
职责分离 : 将不同的功能分离到不同的装饰器中
动态组合: 可以动态地组合不同的装饰器

应用层和组件库的国际化处理方案

组件库和应用库的国际化分开管理

分包自己维护一套i18n

平台的语言包状态生效后,调用组件库里提供的方法切换语言包

切换分包里的i18n状态就行了

颜色主题无缝切换

业务包:修改css变量

组件库:

无需改动,用的也是跟主包一样的css变量名。到时引入后会继承docuemnt最顶层也就是主包里的css变量值

脚本工具梳理

mac权限

初始化注入husky

bash 复制代码
"prepare": "husky install"

确保在每一次安装依赖后husky都能被正常安装

bash 复制代码
"postinstall": "npm run prepare && cd script && node prepareHusky"

prepare会被执行两次(一次是pnpm install一次是husky install),但是没办法,prepare的顺序在postinstall之后。我必须确保husky已经安装上。

把自定义的检查文件移动到.husky文件夹中:

实际流程:

引用本地npm组件

bash 复制代码
"localBase": "pnpm uninstall yzy-base && cd script && node localBase",

语言包差异比较

找到两个语言包的地址并解析文件内容,比较差异

javascript 复制代码
const zhCnPath = path.join(path.resolve('..'), 'src/lang/package/zh-cn.ts');
const enPath = path.join(path.resolve('..'), 'src/lang/package/en.ts');

// 把文件里面的键值对导出到node.js里来(由文件变成实际数据)
const zhCnData = parseLangFile(zhCnPath);
const enData = parseLangFile(enPath);

function parseLangFile(filePath) {
    // 使用 UTF-8 编码同步读取指定路径的文件内容
    const content = fs.readFileSync(filePath, 'utf8');
    // (['"]?) - 第一个捕获组:匹配可选的引号(单引号或双引号)
    // ([^'":\s]+) - 第二个捕获组:匹配键名,不能包含引号、冒号或空格
    // \1 - 反向引用:确保键名两边的引号类型一致
    // \s*:\s* - 匹配冒号前后的可选空格
    // ['"]([^'"]*)['"] - 第三个捕获组:匹配被引号包围的值
    const regex = /(['"]?)([^'":\s]+)\1\s*:\s*['"]([^'"]*)['"]/g;
    let match;
    while ((match = regex.exec(content)) !== null) {
      const key = match[2];
      const value = match[3];
      if (key && value) {
        keyValuePairs[key] = value;
      }
    }
    return keyValuePairs;
}

console.log(`zh-cn.ts 文件包含 ${Object.keys(zhCnData).length} 个键值对`);
console.log(`en.ts 文件包含 ${Object.keys(enData).length} 个键值对\n`);

设置基准对象(一般就是中文包,判断中文包里的键名英文包里是否拥有),进行比较

javascript 复制代码
// 找出缺少的键
  const missingKeys = findMissingKeys(zhCnData, enData);

// 比较两个对象,找出缺少的键
function findMissingKeys(baseObj, compareObj) {
  const missingKeys = [];

  for (const key in baseObj) {
    if (!(key in compareObj)) {
      missingKeys.push({
        key: key,
        value: baseObj[key]
      });
    }
  }

  return missingKeys;
}

判断missingKeys长度是否大于0,大于0输出这个键值对就行了

如果想输出为文本形式,则拼接为文本字符串,并输出:

javascript 复制代码
const outputPath = path.join(__dirname, 'logs/missing-keys.txt');
const outputContent = missingKeys
  .map(item => `'${item.key}': '${item.value}',`)
  .join('\n');
fs.writeFileSync(outputPath, outputContent, 'utf8')

Websocket相关

心跳

当链接建立或收到消息后,3秒后发ping,6秒后检查连接状态。如果在3-6秒之间又收到消息(pong或ws消息),就会取消检查连接状态,并开始重复上面操作。如果在3秒内收到,就会取消ping发送和连接状态。如果没有收到,就会主动检查ws.readyState(实时的,但有延迟)。如果没有异常,则继续执行心跳。

javascript 复制代码
// 在链接成功和收到消息时执行心跳检查
class Ws {
    ...
    ws.onopen = e => {
        ...
        this.heartCheck();
    }
    // 消息接收
    ws.onmessage = e => {
        // 心跳
        this.heartCheck();
        // 回调
        this.onmessage(e);
    };
    // 心跳检查
     private heartCheck() {
      // 清除心跳、检查连接接定时器
      this.h_timer && clearTimeout(this.h_timer);
      this.c_timer && clearTimeout(this.c_timer);
      // 
      this.h_timer = setTimeout(() => {
        // 发送ping
        (this.ws as WebSocket).send('ping');
        // 检查连接状态定时器,确认连接状态
        this.c_timer = setTimeout(() => {
          // 连接失败,进行关闭
          if ((this.ws as WebSocket).readyState !== 1) {
            this.close();
          }else {
             // 心跳
             this.heartCheck();
          }
        }, 3000);
      }, 3000);
    }
    ...
}

HTTP拦截器

在axios提供的请求/响应拦截器注入系统的逻辑就好了。

初始化请求对象:

请求拦截器:

响应拦截器:

错误处理(一般是响应状态码不对),接上面的error函数

路由鉴权

路由守卫设计

javascript 复制代码
import NProgress from 'nprogress'; //进度条插件
import 'nprogress/nprogress.css';

// 白名单路由 不需要登录即可访问
const whiteList = ['/login'];

router.beforeEach(async (to, from, next) => {
    // 进度条启动
    NProgress.start();
    const hasToken = sessionStorage.getItem('accessToken');
    // 没有token,则没有登录
    if (!hasToken) {
        // 未登录可以访问白名单页面
        if (whiteList.indexOf(to.path) >= 0) {
          next();
        } else {
          // redirect是登录后顺着原路跳转回去的路径
          next(`/login?redirect=${to.path === '/login' ? from.path : to.path}`);
          NProgress.done();
        }
    } else {
        // 查看权限路由是否已经生成。如果生成了则放行
      if (permissionStore.routes.length === 0) {
         // 路由鉴权核心逻辑:生成账号权限路由
         const accessRoutes = await permissionStore.generateRoutes();
         accessRoutes?.forEach((route: any) => {
           // router看下面
           router.addRoute(route);
         });
         next({ ...to, replace: true });
       } else {
         next();
       }
       NProgress.done();
    }
})

为什么判断有权限路由就给放行呢?因为一旦是跳转的页面已经注册了路由,那么可以正常跳转。如果没有注册,则会统一跳到404页面去,所以不需要额外的判断

路由初始化注册

沿用了路由懒加载(vite会根据动态引入进行分chunk)

javascript 复制代码
// hash模式
import { createRouter, createWebHashHistory } from 'vue-router';

/** @name 创建路由 */
const router = createRouter({
  history: createWebHashHistory(),
  routes: constantRoutes as RouteRecordRaw[],
  // 刷新时,滚动条位置还原
  scrollBehavior: () => ({ left: 0, top: 0 })
});

export const constantRoutes = [
   {
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        name: 'Login',
        meta: { hidden: true }
   },
   // 404
   {
        path: '/:pathMatch(.*)',
        component: () => import('@/views/error/404.vue')
   },
   // 初始入口'/'重定向为'/index'
  {
    path: '/',
    component: Layout,
    redirect: '/index',
    children: [
      {
        path: '/index/task_Center',
        component: () => import('@/views/task_Center/index.vue'),
        name: 'task_Center',
        meta: {
          keepAlive: false,
          title: t('list.任务中心')
        }
      }
    ]
  },
]

export default router;

// main.ts
app.use(router)

上面是初始化router,只有三个。生成权限路由后会通过router.addRoute(route)挂载到路由里去。

生成权限路由

原理是将后端返回的路由与本地的路由表比对后生成route格式的路由进行挂载。

javascript 复制代码
const accessRoutes = await permissionStore.generateRoutes();
accessRoutes?.forEach((route: any) => {
    router.addRoute(route);
});

面包屑

面包学就是红色框,显示了当前页面标签和历史页面标签。可以通过切换或关闭进行控制。

layout

这是页面骨架。这个系统只有二级路由才是有效路由(结构:一级路由/二级路由)

面包屑实现

html 复制代码
 <q-tags-view
  :routers="visitedViews"      // 面包屑列表
  :route="route"               // 当前面包屑
  @change="changeView"         // 切换面包屑
/>

业务组件只是个视图容器而已。我们关注visitedViews、route、changeView怎么生成就好了。

visitedViews

在状态管理器里维护,在路由守卫里触发新增

javascript 复制代码
// store
const visitedViews = ref<TagView[]>([]);
function addVisitedView(view: TagView) {
    // 没有标题无法正常显示
    if (!view.meta || !view.meta.title) return;
    // 已经有重复路由了
    if (visitedViews.value.some(v => v.path === view.path)) {
      replaceVisitedViewQuery(view);
      return;
    }
    visitedViews.value.push(view)
}

// 处理重复路由
function replaceVisitedViewQuery(view: TagView) {
    const viewInfo = visitedViews.value.find(v => v.path === view.path);
    if (viewInfo && viewInfo.query !== view.query) {
      viewInfo.query = view.query;
    }
}

route

javascript 复制代码
 import { useRoute } from 'vue-router';
 const route: any = useRoute();

// 组件库判断当前路径
function isActive(tag: Router) {
    return tag.path === props.route?.path;
}

changeView

这个方法监听了视图组件库的一系列变化:点击、关闭当前面包屑、关闭其他面包屑

javascript 复制代码
const changeView = (tag: any) => {
    const index = visitedViews.value.findIndex(
      (v: { path: string }) => v.path === tag.router.path
    );
    // 发生操作后用户应该跳转到哪个页面
    // ① 如果不是最后一个,那么无论是发生点击还是删除,用户下一个目标页一定是index
    // ② 如果是最后一个,那么如果是点击就不变,删除就是往前推一个
     const currIndex =
      index < visitedViews.value.length - 1
        ? index
        : tag.type === 'click'
          ? index
          : visitedViews.value.length - 2;
    
}

页面缓存

html 复制代码
<!-- layout.vue -->

<!-- 精简写法 -->
<el-main class="main" id="v7main">
    <router-view v-slot="{ Component }">
      <keep-alive :include="caches">
        <component :is="Component" />
      </keep-alive>
    </router-view>
  </el-main>

<!-- 完整写法 -->
<router-view v-slot="slotProps">
  <keep-alive :include="caches">
    <component :is="slotProps.Component" />
  </keep-alive>
</router-view>

<!-- 更完整写法 -->
<router-view>
  <template v-slot:default="slotProps">
    <keep-alive :include="caches">
      <component :is="slotProps.Component" />
    </keep-alive>
  </template>
</router-view>

当使用 v-slot 时,如果不指定插槽名称,Vue 会将其视为默认插槽。对于默认插槽,可以直接在组件标签上使用 v-slot,而不需要包装在 <template> 中。

javascript 复制代码
// cacheHook.ts
import { useRoute } from 'vue-router';
// 一级路由不计入缓存
const ignoreGathers = ['base-layout'];
const caches = ref<string[]>([]);
export default function useRouteCache() {
    const route = useRoute();
    function gatherCaches() {
        watch(() => route.path, storeRouteCaches, {
          immediate: true
        });
    }
    // 对当前路由进行缓存
    function storeRouteCaches() {
        // route.matched会包含从根路由到当前路由的所有匹配的路由记
        // 例如:[
        // { path: '/user', component: UserLayout, meta: { keepAlive: true } },
        // { path: '/user/profile', component: ProfileLayout, meta: { keepAlive: false } },
        // { path: '/user/profile/settings', component: SettingsPage, meta: { keepAlive: true }}]
        route.matched.forEach(routeMatch => {
            const componentDef: any = routeMatch.components?.default;
            const componentName = componentDef?.name || componentDef?.__name;
            if (ignoreGathers.includes(componentName)) return;
            // 配置了meta.keepAlive的路由组件添加到缓存
            if (routeMatch.meta.keepAlive) {
                if (!componentName) {
                  // eslint-disable-next-line no-console
                  console.warn(`${routeMatch.path} 路由的组件名称name为空`);
                  return;
                }
                caches.value.push(componentName as string);
             }
        })
    }
    
    
}

// APP.vue 一级路由是router-view
<template>
  <ElConfigProvider ref="el" :locale="appStore.locale" :size="appStore.size">
    <router-view />
  </ElConfigProvider>
</template>

<script lang="ts" setup>
  ...
  const { gatherCaches } = useRouteCache();
  // 开始缓存收集
  gatherCaches();
</script>

TS global全局声明

/src/types/global.d.ts

声明在系统中全局类型接口,用于业务逻辑

/src/vite-env.d.ts

声明Vite环境模块 类型,用于构建工具集成

vite是运行在node环境下的,它遵循tsconfig.node.json里的配置。这里面要求ts在node环境下读取

vite-env.d.ts的声明。

TypeScript 复制代码
declare module 'element-plus';
declare module 'yzy-base';

系统打包

应用层的打包,依旧是build属性配置:

TypeScript 复制代码
  target: 'es2015',
  outDir: 'dist',

这块在组件库的打包里讲过。

bash 复制代码
 sourcemap: env.BUILD_SOURCEMAP === 'true',

根据环境变量配置。

sourcemap

生成js.map文件

.map文件包含了从压缩代码到原始源代码的映射关系(主要是mappings属性,包括了原始文件和编译文件的行列对应关系)。

举例:

开启了sourceMap:

浏览器控制台还原"源映射"后:

关闭sourcemap后浏览器显示:、

H5平台

针对首屏加载,采用路由懒加载+KeepAlive+ManualChunks方案

KeepAlive跟上面web平台一样,通过路由元信息判断,这里不讲了。

ManualChunks

javascript 复制代码
// vite.config.ts
import { configManualChunk } from './config/vite/optimizer'
...
 rollupOptions: {
    output: {
      manualChunks: configManualChunk,
    },
  },

这个实现起来比较复杂,可以看面试篇

javascript 复制代码
// ./config/vite/optimizer

// 分包策略执行
export const configManualChunk = (id: string) => {
  if (/[\\/]node_modules[\\/]/.test(id)) {
    const matchItem = vendorLibs.find((item) => {
      const reg = new RegExp(`[\\/]node_modules[\\/]_?(${item.match.join('|')})(.*)`, 'ig')
      return reg.test(id)
    })
    return matchItem ? matchItem.output : null
  }
}

// 分包策略
const vendorLibs: { match: string[]; output: string }[] = [
  // Vue 核心库(必须优先加载)
  {
    match: ['vue', 'vue-router', 'pinia'],
    output: 'vue-vendor'
  },
  
  // UI 组件库(体积较大)
  {
    match: ['vant'],
    output: 'vant-vendor'
  },
  
  // 地图相关(按需加载)
  {
    match: ['@amap'],
    output: 'amap-vendor'
  },
  
  // 工具库
  {
    match: ['axios', 'dayjs', 'lodash'],
    output: 'utils-vendor'
  },
  
  // 图表库(体积大,且不是首屏必需)
  {
    match: ['echarts'],
    output: 'echarts-vendor'
  }
]

基于postcss-pxtorem完成PX到REM的自动转换,动态设置根字体

html 复制代码
<meta
  name="viewport"
  content="width=device-width, 
  initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
/>

width=device-width:视口宽度等于设备宽度
initial-scale=1:初始缩放比例为 1
maximum-scale=1, minimum-scale=1:禁止缩放
user-scalable=no:禁止用户手动缩放

PostCSS

在开发阶段和生产构建阶段皆参与转换。

开发阶段:

构建阶段:

postcss-pxtorem:用于移动端响应式适配的 PostCSS 插件配置,它会自动将 CSS 中的 px 单位转换为 rem 单位,实现不同屏幕尺寸的自适应布局。

为什么移动端上rem单位比px单位好呢?
原生H5的移动端项目是否可以直接用rem呢?

可以,但是需要手动设置基准值(root元素的font-size)。

设计思想

问题的本质:一套代码适配所有屏幕

项目里对移动端响应式适配的处理

通过 postcss-pxtorem(构建时自动转 px 到 rem) + setRootFontSize(运行时动态设置基准值) 两部分配合

setDomFontSize:根据上面的思想,对设备宽度进行十等分,一个rem等于设备宽度/10

javascript 复制代码
// main.ts

const setDomFontSize = (): void => {
  const width = document.documentElement.clientWidth 
|| document.body.clientWidth
  // 如果屏幕375宽 那么fontSize是37.5 则rem * 37.5 = px与设计图刚好契合
  // 如果屏幕是750宽,那么fontSize是75  则rem * 75 = 2倍px 刚好对设计图放大了两倍
  const fontsize = setDomFontSize / 10 + 'px'
  document.getElementsByTagName('html')[0].style.fontSize = fontsize
}
setDomFontSize()

const setDomFontSizeDebounce = _.debounce(setDomFontSize, 400)
window.addEventListener('resize', setDomFontSizeDebounce)

postcss配置:

核心配置rootValue,设计稿是750,则750/10,设计稿是300,则300/10

javascript 复制代码
// postcss.config.js
'postcss-pxtorem': {
   rootValue: 75,              // 基于750设计稿,75 = 750/10
   unitPrecision: 6,           // 保留6位小数,确保精度
   propList: ['*'],            // 所有属性都转换
   selectorBlackList: [        // 不转换的选择器
        '.no-rem',
        'van-',                   // Vant组件不转换
        'el-',                    // Element Plus不转换(如果有)
   ],
  replace: true,              // 直接替换,不保留px
  mediaQuery: true,           // 媒体查询中的px也转换
  minPixelValue: 1,           // 最小转换值,1px也转换
  exclude: /node_modules/i,   // 排除第三方库
},

autoprefixer

javascript 复制代码
// postcss.config.js

module.exports = {
  plugins: {
    autoprefixer: {
      overrideBrowserslist: [
        'Android 4.1',
        'iOS 7.1',
        'Chrome > 31',
        'not ie <= 11', //不考虑IE浏览器
        'ff >= 30', //仅新版本用"ff>=30
        '> 1%', //  全球统计有超过1%的使用率使用">1%";
        'last 2 versions', // 所有主流浏览器最近2个版本
      ],
      grid: true, // 开启grid布局的兼容(浏览器IE除外其他都能兼容grid,可以关闭开启)
    }
}

转成浏览器适配代码

零拷贝、自动同步的canvas离屏渲染

webworker

canvas的离屏渲染将在worker线程中执行

ts类型声明
javascript 复制代码
import dvsWaterfallCanvasWorker from '/@/aiot/worker/waterfallCanvas?worker&inline'
worker.value = new dvsWaterfallCanvasWorker()
离屏canvas + webWorker渲染
html 复制代码
<template lang="pug">
    canvas(ref="gkDvsWaterfallCanvasRef")
</template>

<style>
    canvas {
      position: absolute;
      width: 100%;
      height: 100%;
    }
</style>

发送离屏渲染能力到后台worker

javascript 复制代码
const width = gkDvsWaterfallContainerRef.value.offsetWidth
const height = gkDvsWaterfallContainerRef.value.offsetHeight
// 把canvas转成离屏绘画对象
const offscreen = gkDvsWaterfallCanvasRef.value.transferControlToOffscreen()
worker.value.postMessage(
    {
      type: 'config',
      data: {
        canvas: offscreen,
        {width, height}
      },
    }
  )

worker代码:

javascript 复制代码
// self 是 Web Worker 中的全局对象,就像浏览器主线程中的 window 一样
self.onmessage = (event) => {
    if (event.data.type === 'config') {
      dataCanvasEl = data.canvas
      dataCtx = dataCanvasEl.getContext('2d')
      dataCtx.fillRect(0, 0, 100, 100);  // 绘制后自动显示在页面的 <canvas> 上!
    }else if ...
}

// 向主线程发送信息
// self.postMessage({ type: 'camera', data: imgData })

// Worker 线程
const ctx = offscreen.getContext('2d');
ctx.fillRect(0, 0, 100, 100);  // 绘制后自动显示在页面的 <canvas> 上!

注意,不需要通讯。在worker里绘画的话,通过共享内存,前台就能同步显示。

两种离屏画布的区别

transferControlToOffscreen和new OffscreenCanvas都是离屏画布,但是本质上有很大的区别!

transferControlToOffscreen:

new OffscreenCanvas:

相关推荐
该用户已不存在1 小时前
免费 SSL 证书缩短至 90 天,你的运维成本还能hold住吗
前端·后端·https
汤姆Tom1 小时前
前端转战后端:JavaScript 与 Java 对照学习指南 (第二篇 - 基本数据类型对比)
java·javascript·全栈
七月十二1 小时前
【Vite】离线打包@iconify/vue的图标
前端·vue.js
星空的资源小屋1 小时前
Explorer++:更强大的Windows文件管理器
javascript·人工智能·django·电脑
米花丶1 小时前
解决前端监控上报 Script Error实践
前端·javascript
JarvanMo1 小时前
如何在 Flutter 应用中大规模实现多语言翻译并妥善处理 RTL(从右到左)布局?
前端
Haha_bj1 小时前
iOS深入理解事件传递及响应
前端·ios·app
1024小神1 小时前
用html和css实现放苹果的liquidGlass效果
前端
拜晨1 小时前
CG-01: 深入理解 2D 变换的数学原理
前端