快速摸一个 Ant design vue 项目

3,473 阅读4分钟

初始化项目

  • 用 vue-cli 快速构建项目 vue create ant-design-vue-pro

  • cd ant-design-vue-pro/

  • 安装必要依赖 npm i ant-design-vue moment

  • 删除/初始化 不需要的文件

    // clear
    
    └──src/
        ├───router/
        │   └───index.js
        ├───views/
        │   └───Home.vue
        └───App.vue
    
    
  • 引入 ant-design-vue

    import Antd from "ant-design-vue";
    import "ant-design-vue/dist/antd.css";
    

    debugger

    import "ant-design-vue/dist/antd.less";
    
    // 报错
    Syntax Error:
    
    // https://github.com/ant-design/ant-motion/issues/44
      .bezierEasingMixin();
    
    // 解决方案:开启 javascript
    css: {
      loaderOptions: {
        less: {
          loader: "less-loader",
          options: {
            javascriptEnabled: true,
          },
        },
      },
    },
    

按需引入 UI 组件

import Button from "ant-design-vue/lib/button";
import "ant-design-vue/lib/button/style";
  • babel-plugin-import
    • 修改 babel.config.js 文件,配置 babel-plugin-import
      module.exports = {
        presets: ["@vue/app"],
        +  plugins: [
        +    [
        +      "import",
        +      { libraryName: "ant-design-vue", libraryDirectory: "es", style: true }
        +    ]
        +  ]
        };
      
    • src/main.js
      - import Button from 'ant-design-vue/lib/button';
      + import { Button } from 'ant-design-vue';
      - import 'ant-design-vue/dist/antd.css'
      
    • bug
      // ❌ 无法全局引入
      import Antd from 'antd-design-vue
      

高扩展性的路由

  • 现有方案

    • 基于配置
    • 基于约定:轮子根据文件结构生成路由
  • component

    const routes = [
      {
        path: "/user",
        component: () =>
          import(/* webpackChunkName: user */ "./component/RenderRouterView.vue"),
        children: [
          //...
        ],
      },
    ];
    
    const routes = [
      {
        path: "/user",
        component: { render: (h) => h("router-view") },
        children: [
          //...
        ],
      },
    ];
    
  • NProgress

    • NProgress.start() — shows the progress bar
    • NProgress.set(0.4) — sets a percentage
    • NProgress.inc() — increments by a little
    • NProgress.done() — completes the progress

可动态改变的页面布局

  • 通过路由传递配置变量

如何将菜单和路由结合

  • 约定

    • 在 routes 中添加 标志位,筛选需要渲染到菜单的路由项。hideInMenu: true
    • 处理 routes 中的嵌套路由逻辑,约定name 字段才进行渲染
    • 隐藏子路由 hideChildrenMenu,处理 “页面在子路由时,菜单依然高亮” 的逻辑
    • 添加显示的元信息 meta,icon / title ...
  • 根据约定,生成动态菜单

    const menuData = getMenuData(this.$router.options.routes);
    getMenuData(routes){
    
    }
    
  • 利用函数式组件(无状态,只接受参数) + 组件递归,渲染处理后的 routes 对象。

  • .sync 修饰符

    • 在有些情况下,我们可能需要对一个 prop 进行“双向绑定”。不幸的是,真正的双向绑定会带来维护上的问题,因为子组件可以变更父组件,且在父组件和子组件都没有明显的变更来源。
    • 这也是为什么我们推荐以 update:myPropName 的模式触发事件取而代之。
    • 举个例子,在一个包含 title prop 的假设的组件中,我们可以用以下方法表达对其赋新值的意图:
      • this.$emit('update:title', newTitle)
      • 父组件
        <text-document
          v-bind:title="doc.title"
          v-on:update:title="doc.title = $event"
        ></text-document>
        
    • 为了方便起见,我们为这种模式提供一个缩写,即 .sync 修饰符:
      <text-document v-bind:title.sync="doc.title"></text-document>
      

如何使用路由进行权限管理

  • 权限验证相关函数

    export async function getCurrentAuthority() {
      const { role } = await this.$axios.$get("/user");
      return ["admin"];
    }
    
    // some() 方法测试数组中是不是至少有1个元素通过了被提供的函数测试。它返回的是一个Boolean类型的值。
    export function check(authority) {
      const current = getCurrentAuthority();
      return current.some((item) => authority.includes(item));
    }
    
    export function isLogin() {
      const current = getCurrentAuthority();
      return current && current[0] !== "guest";
    }
    
  • 路由守卫

    import findLast from "lodash/findLast";
    import { check, isLogin } from "utils/auth";
    router.beforeEach((to, from, next) => {
      // ...
      const record = findLast(to.matched, (item) => item.meta.authority);
      if (record && !check(record.meta.authority)) {
        if (!isLogin() && to.path !== "/user/login") {
          next({ path: "/user/login" });
        } else if (to.path !== "/403") {
          next({ path: "/403" });
        }
        // loading = false
        // ...
      }
      // ...
    });
    
  • 侧边栏鉴权

    routes.forEach((item) => {
      if (item.meta && item.meta.authority && !check(item.meta.authority)) {
        return;
      }
    });
    
  • 403 添加弹窗提醒

    import { notifiction } from "ant-deisgn-vue";
    if (to.path !== "/403") {
      notifiction.error({
        message: "403",
        description: "您没有权限访问该页面,请联系管理员",
      });
      next({ path: "/403" });
    }
    

更加精细的权限设计(权限组件、权限指令)

  • 权限组件 - 函数式组件

    export default {
      functional: true,
      render: function (h, context) {
        const { props, scopeSlots } = context;
        return check(props.authority) ? scopeSlots.default() : null;
      },
    };
    
  • 权限指令 - 插件式

    export function install(Vue, options = {}) {
      const { name = "auth" } = options;
      Vue.directive(name, {
        // 当被绑定的元素插入到 DOM 中时……
        inserted: function (el, binding) {
          if (!check(binding.value)) {
            el.parentNode && el.parentNode.removeChild(el);
          }
        },
      });
    }
    
  • 比较

    • 指令在 inserted 时 remove 后,当权限动态改变时,无法重新添加 el。
    • 组件的响应更灵活,但使用需要嵌套目标 el。

如何在组件中使用EChartsAntv等其他第三方库

  • vue-echarts

    1. 插入所需的图表组件。
    2. 抽象可配置参数。
    3. 优化(防抖)
    4. 添加更多需求(动态改变数据)
  • echart 渲染宽度超出容器?

    • 因为 echart 是在真正渲染完成前获取高度。
    • 解决:
      • import { addListener, removeListener } from 'resize-detector'
  • resize 时,添加防抖

    • lodash/debounce
      • 该函数会从上一次被调用后,延迟wait毫秒后调用 func 方法。
      • 提供一个 cancel 方法取消延迟的函数调用以及 flush 方法立即调用
      • options.leading 与|或 options.trailing 决定延迟前后如何触发(注:是 先调用后等待 还是 先等待后调用)。
    • created(){
        this.resize = debounce(this.resize, 300)
      }
      
  • 监听 option 变化

    • 深度监听: 耗性能( Vue3 劫持整个对象 )
      export default {
        watch: {
          option: {
            handler: () => {},
            deep: true,
          },
        },
      };
      
    • 手动替换整个对象
      option = {...option}

如何高效使用 Mock 数据进行开发

  • 剥离 mock 数据和业务代码
  • mock 数据不更新:清除指定模块缓存
    • require.cache: 被引入的模块将被缓存在这个对象中。
    • require.resolve:在 node 中,可以使用 require.resolve 来查询某个模块的完整路径
    • delete require.cache[require.resolve(name)]
  • module.exports = {
      devServer: {
        proxy: {
          "/api": {
            target: "http://localhost:8081",
            bypass: function (req, res) {
              if (req.headers.accept.indexOf("html") !== -1) {
                console.log("Skipping proxy for browser request.");
                return "/index.html";
              } else {
                //  根据约定寻找文件
                const name = req.path.split("/api/")[1].split("/").join("_");
                const mock = require(`./mock/${name}`);
                const result = mock(req.method);
                // 清理模块缓存
                require.cache(require.resolve(`./mock/${name}`));
                return res.send(result);
              }
            },
          },
        },
      },
    };
    

如何与服务端进行交互(Axios)

  • 添加环境变量 MOCK

    • cross-env

      • 是什么?

        运行跨平台设置和使用环境变量(Node 中的环境变量)的脚本。

      • 为什么需要?

        我们在自定义配置环境变量的时候,由于在不同的环境下,配置方式也是不同的。例如在 window 和 linux 下配置环境变量。

    • package.json

    • {
        "scripts": {
          "serve:no-mock": "cross-env MOCK=NONE "
        }
      }
      
      const app = new (require("koa"))();
      const mount = require("koa-mount");
      
      app.use(
        mount("/api/dashboard/chart", async (ctx) => {
          ctx.body = [10, 20, 30, 40, 50];
        })
      );
      
      app.listen(8081);
      
  • axios 拦截:二次封装,统一错误处理

    • request.js
      import axios from "axios";
      function request(options) {
        return axios(options)
          .then((res) => {
            return res;
          })
          .catch((error) => {
            const {
              response: { status, statusText },
            } = error;
            notifiction.error({
              message: status,
              describtion: statusText,
            });
            return Promise.reject(error);
          });
      }
      
    • Vue.prototype.$request = request
    • jsx: @vue/babel-preset-jsx

创建一个分步表单

  • vuex: 临时存储表单数据
    • modules/form.js
      const state = () => ({ step: { payAccount: "" } });
      const mutation = {
        saveStepFormData(state, payload) {
          state.step = { ...state.step, ...payload };
        },
      };
      const actions = {
        async submitStepForm({ commit }, payload) {
          await request({ method: "POST", url: "", data: payload });
          // 不应该是清空表单吗?
          commit("saveStepFormData", payload);
          router.push("");
        },
      };
      export default {
        namespaced: true,
        state,
        mutations,
        actions,
      };
      

如何管理系统中的图标

  • 来自 iconfont
    import { Icon } from "ant-design-vue";
    const IconFont = Icon.createFromIconfontCN({ scriptUrl: "" });
    
    <icon-font type="icon-404" />
    
  • svg
    • <image url>
    • 手动注册 component / 利用 svg-loader 转换成 component
  • 查看 vue cli 内部配置 vue inspect > output.js

如何定制主题及动态切换主题

  • 全局:config 配置

    module.exports = {
      css: {
        loaderOption: {
          less: {
            modifyVars: {
              "primary-color": "#1DA57A",
              "link-color": "#1DA57A",
              "border-radius-base": "2px",
            },
          },
        },
      },
    };
    
  • 局部:深度作用选择器

    如果你希望 scoped 样式中的一个选择器能够作用得“更深”,例如影响子组件,你可以使用 >>> 操作符:

    <style scoped>
    .a >>> .b { /* ... */ }
    </style>
    
  • 在线动态编译主题色

    • 耗性能,
    • 如有需求,可以在本地编译好多个主题样式文件,再从从服务端拉取
    • antd-theme-webpack-plugin
      • 该 webpack 插件用于生成特定于颜色的 less / css 并将其注入到 index.html 文件中,以便您可以在浏览器中更改 Ant Design 特定的颜色主题。

国际化

  • antd-vue 组件库国际化:localProvider -> configProvider

    <template>
      <div id="app">
        <a-config-provider :locale="locale"> </a-config-provider>
      </div>
    </template>
    
    import zhCN from "ant-design-vue/lib/locale-provider/zh_CN";
    import enUS from "ant-design-vue/lib/locale-provider/en_US";
    
    export default = {
      data(){
        return {
          locale: enUS
        }
      },
      watch:{
        "$route.query.locale"(val){
          this.locale = val === 'enUS'? enUS : zhCN
        }
      }
    }
    
  • moment 国际化

     import moment from 'moment';
     export default={
     watch:{
        "$route.query.locale"(val){
          moment.locale(val==='enUS'?'en':'zh_cn');
        }
      }}
    
  • 业务代码国际化:VueI18n

    • main.js

      import VueI18n from "vue-i18n";
      import zhCN from "./locale/zhCN";
      import enUS from "./locale/enUS";
      import queryString from "query-string";
      
      const i18n = new VueI18n({
        locale: queryString.parse(location.search).locale || "zhCN",
        message: {
          zhCN: {
            message: zhCN,
          },
          enUS: {
            message: enUS,
          },
        },
      });
      
      new Vue({
        router,
        store,
        i18n,
        render: (h) => h(App),
      }).$mount("#app");
      
    • zhCN.js / enUS.js

      export default {
        "app.workspace.title": "时间",
      };
      
      export default {
        "app.workspace.title": "TIME",
      };
      
    • workspace.vue

      <template> {{$t('message')['app.workspace.title']}} </template>
      
    • handleLocale

      export default {
        watch: {
          "$route.query.locale"(val) {
            this.$i18n.locale = val;
          },
        },
      };
      

如何高效地构建打包方式

打包分析报告:( VUE CLI ) npm run build -- --report

  • UI 组件按需加载 / babel

  • router 中使用 webpackChunkName ,对路由进行懒加载和拆包

  • 按需引入 lodash

    1. import debounce from  'lodash/debounce'
      
    2. 使用插件 lodash-webpack-plugin

      npm i lodash-webpack-plugin babel-plugin-lodash -D
      

      babel.config.js

      module.exports = {
        presets: ["@vue/cli-plugin-babel/preset", "@vue/babel-preset-jsx"],
        plugins: ["lodash"],
      };
      

      vue.config.js

      const LodashModuleReplacementPlugin = require("lodash-webpack-plugin");
      module.exports = {
        chainWebpack: (config) => {
          config
            .plugin("loadshReplace")
            .use(new LodashModuleReplacementPlugin());
        },
      };
      
    3. lodash-es 结合 tree-shaking

      import { debounce } from 'lodash-es'
      

      tree-shaking 的作用,即移除上下文中未引用的代码(dead code)

      只有当函数给定输入后,产生相应的输出,且不修改任何外部的东西,才可以安全做 shaking 的操作

      如何使用tree-shaking

      1. 确保代码是 es6 格式,即 export,import

      2. package.json 中,设置 sideEffects

      3. 确保 tree-shaking 的函数没有副作用

      4. babelrc 中设置presets [["env", { "modules": false }]] 禁止转换模块,交由 webpack 进行模块化处理

      5. 结合 uglifyjs-webpack-plugin

如何构建可交互的组件文档

  • raw-loader + highlightjs main.js
    import hljs from "highlight.js";
    import "highlight.js/styles/github.css";
    Vue.use(hljs.vuePlugin);
    
    view.vue
    <highlightjs language="javascript" :code="ChartCode" />
    
  • 自己编写 loader:如 md-loader(成本高)

如何做好组件的单元测试

  • auth.spec.js

    import { authCurrent, check } from "@/utils/auth.js";
    
    describe("auth test", () => {
      it("empty auth", () => {
        authCurrent.splice(0, authCurrent.length);
        expect(check(["user"])).toBe(false);
        expect(check(["admin"])).toBe(false);
      });
    });
    
  • jest.config.js

    module.exports = {
      preset: "@vue/cli-plugin-unit-jest",
      moduleNameMapper: {
        "^@/(.*)$": "<rootDir>/src/$1",
      },
      resolver: null,
      collectCoverage: process.env.COVERAGE === "true",
      collectCoverageFrom: ["src/**/*.{js,vue}", "!**/node_modules/**"],
    };
    

如何发布组件到 NPM

  • 注册 npm 账号,填写 用户名、密码和邮箱;
  • 进入项目文件夹
  • 使用 npm login,登录自己的 npm 账号;
  • 使用 npm publish,·发布自己的包到 npm;
  • 查看自己发布的包是否成功,可以去别的项目执行 npm install 你发布的包名,下载成功。

注意

  1. 发布自己包之前,应先去 npm 官网搜索自己要发布的包名是否已经存在,已存在的包名会提交失败;
  2. 自己发布的包更新时,每次都要到 package.json, 将 version 修改,例如:从 1.0.0 改为 1.0.1。然后再执行 npm publish 更新;

GitHub相关生态应用(CI 持续集成、单车覆盖率、文档发布、issue管理)