6

玩转 CMS - 彭加李

 7 months ago
source link: https://www.cnblogs.com/pengjiali/p/18021782
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

玩转 CMS

目前接手的内容管理系统(CMS)基于 ant-design-vue-pro(简称模板项目ant-vue-pro) 开发的,经过许多次迭代,形成了现在的模样(简称本地项目)。

假如让一名新手接手这个项目,他会遇到很多问题,比如 .env 的作用、开发时后端接口没有写好如何联调、样式使用less还是 CSS Modules、表单和表格如何使用等等

技术是为产品服务,只需要能用技术做出项目,不需要所有技术、所有最佳实践都清楚。好比中医发展了好几千年,许多本源的东西老中医也是不清楚的,但我们摸索出一套规则,按照这个能治病,这个就很好。

本地项目使用的是 ant design vue 1.x 版本,基于 vue 2

本系列目的:让新手快速接手这个 CMS 系统

Ant Design Pro 默认使用 less 作为样式语言。

Tip: less 语法 —— 重要,不紧急(后续补上)。直接在 less 中用 css 语法也能完成项目,然后逐步的利用 less 功能。

vscode 搜索,发现 90% 以上都有 scoped,样式语言也确实是 less。

// 69处
<style 

// 49处
<style lang="less" scoped>

// 15处
<style scoped>

样式开发过程,要避免全局污染,通过 scoped 特性和 css modules 设置组件样式作用域。

<style lang="less" scoped>
  .chart-trend {
    display: inline-block;
    font-size: 16px;
    line-height: 24px;
  }
</style>

Tip:

  • 有关 scoped 更多介绍请看这里
  • 如何在 Vue 中优雅地使用 CSS Modules,可以参考网友的 文章

:避免在 scoped 中使用元素选择器。比如转成 button[data-v-xxxxxx] 会比类选择器组合要慢,因为要匹配的元素太多了。

@import

在单文件组件的样式中,通过 @import 引入 less 文件:

<template>
  <div>
    <p class="red">hello</p>
  </div>
</template>

<style lang="less" scoped>
@import './index.less';
</style>
// index.less
.red{
    color: red;
    font-size: 23.5px;
}

请问 .red 是全局的还是局部的,是否会影响到其他页面?

笔者测试发现,是局部的。最后编译出来是这样:

<style type="text/css">.red[data-v-2b80bebf] {
  color: red;
  font-size: 23.5px;
}
</style>

Tip:网上有的说这么写是全局。

如果不加 scoped 则会全局生效。就像这样:

<style lang="less">
@import './index.less';
</style>
  • 从 ant-design-vue 库的样式文件中导入 index.less 文件
// index.less 
// ~ant-design-vue/lib/style/index 表示从 ant-design-vue 库的样式文件中导入 index 文件
// 导入的是 index.less 文件,而不是 index.css
// 在 Less 中,通过 @import 关键字导入的文件可以是 Less 文件或 CSS 文件。如果导入的文件没有指定后缀名,Less 会尝试导入同名的 .less 文件,如果不存在,Less 会尝试导入同名的 .css 文件。
@import '~ant-design-vue/lib/style/index';
  • 同一目录下的名为chart.less的文件
<style lang="less" scoped>
// 同一目录下的名为chart.less的文件。不存在,Less 将会继续尝试导入同名的chart.css文件
@import "chart";
</style>
  • @import '../index.less'; 是 Less 的新语法格式,它不使用 url() 函数。更加简洁和直观。
// 旧语法
@import url('../index.less')

// 新语法
@import '../index.less';

使用的是支持新语法的 Less 版本,这两种写法是等价的。

  • @import "~@/components/index.less"; 是一种在 Less 中导入模块化组件的常见方式。
<style lang="less" scoped>
// 正确
@import "~@/components/test.less";

// 错误。less 并不会识别 @ 符号作为项目根目录的表示
// @import "@/components/test.less";
</style>

样式文件类别

在一个项目中,样式文件根据功能不同,可以划分为不同的类别

可以将样式提取到一个公共文件,比如 Pro 提取的 src/global.less 然后在 main.js 将样式引入 import './global.less'

src/utils/utils.less 这里可以放置一些工具函数供调用,比如清除浮动 .clearfix。

// mixins for clearfix
// ------------------------
.clearfix() {
  zoom: 1;

  &::before,
  &::after {
    display: table;
    content: ' ';
  }

  &::after {
    height: 0;
    clear: both;
    font-size: 0;
    visibility: hidden;
  }
}

.clearfix() 混合器定义了清除浮动的样式。然后,你可以通过 .clearfix() 在选择器 .selector 中调用混合器,从而应用清除浮动的样式:

.selector {
  // 调用 .clearfix() 混合器
  .clearfix();
}
通用模块级

例如 src/layouts/BasicLayout.less,里面包含一些基本布局的样式,被 src/layouts/BasicLayout.vue 引用,项目中使用这种布局的页面就不需要再关心整体布局的设置。如果你的项目中需要使用其他布局,也建议将布局相关的 js 和 less 放在这里 src/layouts

组件相关的样式,有一些在页面中重复使用的片段或相对独立的功能,你可以提炼成组件,相关的样式也应该提炼出来放在组件中,而不是混淆在页面里。

Tip:有时样式配置特别简单,也没有重复使用,你也可以用内联样式 style="{ fontSize: fontSizeVar }" 来设置。

覆盖组件样式

由于业务的个性化需求,我们经常会遇到需要覆盖组件样式的情况。请看示例:

<template>
  <div class="test-wrapper">
      <a-select v-model="name" style="width:400px">
          <a-select-option value="1">Option 1</a-select-option>
          <a-select-option value="2">Option 2</a-select-option>
          <a-select-option value="3">Option 3</a-select-option>
      </a-select>
  </div>
</template>

<script>
export default {
  data(){
    return {
      name: 'Option 1'
    }
  }
}
</script>
<style lang="less" scoped>
// 使用 scss, less 时,可以用 /deep/ 进行样式穿透
.test-wrapper ::v-deep .ant-select {
  font-size: 26px;
}

.test-wrapper /deep/ .ant-select {
  font-weight: 700;
}
</style>

<style scoped>
/* 这里注释不可以用 `//` */
.test-wrapper >>> .ant-select {
  color: blue
}
</style>

在 scss、less 中可以使用 /deep/::v-deep 进行样式穿透,在css 中可以使用 >>> 穿透。

最终渲染成:

<style type="text/css">
.test-wrapper[data-v-2b80bebf] .ant-select {
  font-size: 26px;
}
.test-wrapper[data-v-2b80bebf] .ant-select {
  font-weight: 700;
}
</style>

<style type="text/css">
/* 这里注释不可以用 `//` */
.test-wrapper[data-v-2b80bebf] .ant-select {
  color: blue
}
</style>

axios

首先回顾下 axios 如何使用的。

在 vue-admin-template(基于 element-ui) 中使用 axios 有以下几步(参考这里):

  • 安装 axios 包
  • 对 axios 进行封装,比如封装到 request.js 文件中。关键增加请求拦截器和响应拦截器,比如返回 403、500等都会通过 Message 组件提示给用户
  • 每个页面(或模块)引入 request.js,导出接口。例如 api/table.js

Tip: 以前我们研究的 spug 开源项目(基于react)中 axios 也是类似用法 —— react axios

ant-vue-pro 中axios 用法类似:

  • 通过 src\utils\request.js 封装 request.js
  • 每个页面(或模块)引入 request.js,导出接口。例如登录模块对应 src\api\login.js

为了方便管理维护,统一的请求处理都放在 @/src/api 文件夹中,并且一般按照 model 纬度进行拆分文件,如:

api/
  user.js
  permission.js
  goods.js
  ...

本地项目 api

本地项目的 api 大概是这样:

import { axios } from '@/utils/request'
import cancelAxios from 'axios'
import qs from 'qs'

/* 取消请求 */
var CancelToken = cancelAxios.CancelToken
export let cancellistApi

// 列表
export function list (parameter) {
  return axios({
    url: '/acms/demo/list',
    method: 'get',
    // params 参数用于将数据通过查询字符串的形式添加到请求的 URL 中。这种方式适用于 GET 请求
    params: parameter,
    cancelToken: new CancelToken(function (c) {
      cancellistApi = c
    }),
    // paramsSerializer 是 axios 的一个配置选项,用于将请求参数序列化为 URL 查询字符串格式
    // 比如转换开始结束时间的格式:rangeDate[]=2023-11-11&rangeDate[]=2023-12-03 转成 rangeDate=2023-11-11&rangeDate=2023-12-03
    paramsSerializer: function (params) {
      return qs.stringify(params, {
        arrayFormat: 'repeat'
      })
    }
  })
}

// get请求
export function review (id) {
  return axios({
    url: `/acms/demo/detail/${id}`,
    method: 'get'
  })
}

// post请求
export function pass (data) {
  return axios({
    url: `/acms/demo/pass`,
    method: 'post',
    // data 参数则是将数据作为请求的正文发送给服务器。这种方式适用于 POST、PUT、DELETE 等请求
    // 请求中的 Content-Type 头附带的是 application/json 或 multipart/form-data 等适合传递数据的类型
    data
  })
}

// 删除文章
export function delArticle (id) {
  return axios({
    url: `/acms/article/${id}`,
    // DELETE 方法用于请求服务器删除指定的资源。它通常需要在请求中指定要删除的资源的标识符。例如,使用 DELETE 方法可以删除用户账号、删除文章等。
    method: 'delete'
  })
}
// 上线文章
// PUT 方法用于向指定的 URL 发送数据,通常是用于更新服务器上的资源
export function onlineArticle (id) {
  return axios({
    url: `/acms/article/online/${id}`,
    method: 'put'
  })
}

get、post、put、delete请求,有时引入 qs 包,用于将请求的参数对象序列化,比如处理开始时间和结束时间。

Tip: qs 是一个用于序列化和反序列化 URL 查询字符串的 JavaScript 库。比如:

  • 序列化:将 JavaScript 对象序列化为 URL 查询字符串的格式,以便作为请求参数添加到 URL 中。例如,将 { key1: 'value1', key2: 'value2' } 转换为 key1=value1&key2=value2。
  • 反序列化:将 URL 查询字符串解析为 JavaScript 对象,方便进行参数的提取和处理。例如,将 key1=value1&key2=value2 转换为 { key1: 'value1', key2: 'value2' }。
  • 处理复杂参数:qs 支持处理复杂对象、数组等数据结构,可以将它们转换为合适的 URL 查询字符串格式,方便进行网络请求。

cancelAxios 用于取消请求。不过有的同事用法不对,他用在搜索的 input 框中,想实现输入字符延迟查询。可以用 .lazy 或 lodash 的延迟。

Tip: ant design vue 中 lazy(<a-input v-model.lazy=)不起作用。根据场景可以使用lodash 中的防抖或节流。当调用 delayedRequest 函数时,如果在 1000 毫秒内没有再次调用该函数,那么延迟时间结束后,请求逻辑将会执行。

import { debounce } from 'lodash';

const delayedRequest = debounce(() => {
  // 在这里执行你的请求逻辑
}, 1000); // 延迟时间为 1000 毫秒

// 调用 delayedRequest 函数
delayedRequest();

async和Promise

Tip:有关 promise 和 async 的介绍请看笔者之前文章:Promiseasync

在 ant-vue-pro 中只使用了 Promise,没有使用 async

从 ./src/views 中搜索:

  • async、await 都没有
  • promise 在13个文件中有 23 处。

用法大致如下:

// 模拟网络请求、卡顿 800ms
new Promise((resolve) => {
   
}).then(() => {
    
})
// 两个都成功才进入 then
Promise.all([repositoryForm, taskForm]).then(values => {
    
}).catch(() => {
   
})
new Promise((resolve) => {

}).then(() => {
    
}).catch(() => {

// 总是会执行。比如关闭`加载中...`弹框
}).finally(() => {
    
})

笔者认为 async 也需要用起来,async 和 Promise 不是替代关系,各有其使用场景 —— 使用 promise 还是 async/await

本地项目的写法

本地项目的写法有以下几种:

  • 取消发布。只处理了成功的情况
// axios 在响应拦截器中已经处理了http 非 200 的请求,也处理的 5000、4000 等 token 过期或其他错误,最后到这里通常是约定好的接口数据。
cancelPublish(params).then((res) => {
    if (res.code === 0) {
        this.getDataList()
        this.$message.success(res.msg)
    }
})
  • 编辑。在 finally 中关闭关闭加载中...弹框
async editFn (contentType, params) {
    this.loading = true
    try {
        const res = await updateArticle(params)
        if (res.code === 0) {
            // 请求成功...
        }
    } catch (err) {

    } finally {
        this.loading = false
    }
},
  • 只有一个异步请求,并且需要处理错误情况。可以这么写:
fetchData(false).then(() => {
    // do ...
}).catch(() => {
    console.log('error')
})

如果不觉得 try...catch 麻烦,也可以这样:

try {
    let p = await fetchData(false)
    // do ...
} catch (e) {
    console.log('error')
}

:try...catch 除了可以捕获语法报错,还能捕获 reject 。

const fetchData = new Promise((resolve, reject) => {
    reject(11);
});

async function myAsyncFunction() {
    try {
        let p = await fetchData;
        console.log('p', p);
        // 其他代码...
    } catch (e) {
        console.log('error', e);
    }
}

myAsyncFunction();

// => error 11
需要注意的几点

遮罩层没有消失

async function request() {
    console.log('开启遮罩')
    // 报错就退出了
    let json = await requestUserList() // {1}
    // 处理数据...
    console.log('关闭遮罩');
}
await 与并行
// 下面这段代码是串行
async function foo() {
    let a = await createPromise(1)
    let b = await createPromise(2)
}

可以通过下面两种方法改为并行:

// 方式一
async function foo() {
    let p1 = createPromise(1)
    let p2 = createPromise(2)
    // 至此,两个异步操作都已经发出
    await p1
    await p2
}

// 方式二
async function foo() {
    let [p1, p2] = await Promise.all([createPromise(1), createPromise(2)])
}
async 有时会比 Promise 更容易调试
promise.catch

以下两段代码等效

promise1.then(null, () => {
    console.log('拒绝')
})

// 等价于

promise1.catch(() => {
    console.log('拒绝')
})
  • 链式捕获错误
let p1 = new Promise((resolve, reject) => {
    resolve('10') // {1}
})

// 三个完成处理程序都有可能出错,我们可以在末尾添加一个已拒绝处理的程序对这个链式统一处理
p1.then(() => {
    throw new Error('fail')
    console.log(1)
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).catch(e => {
    console.log(e.message)
})

// 输出:fail

如果将 {1} 改成 reject(10),也会直接到 catch 中,这时 e 就是 10。

await 的返回值

await 命令后面是一个 Promise 对象。如果不是,会被转为一个立即 resolve 的 Promise 对象。Promise 的解决值会被当作该 await 表达式的返回值。

async function fa() {
    return await 1
}

// 等价于 

async function fa() {
    return await Promise.resolve(1)
}

// 等价于 
async function foo() {
    return await new Promise((resolve, reject) => {
        resolve(1)
    })
 }

在看一个示例:

function resolveAfter2Seconds(x) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(x);
    }, 2000);
  });
}

async function f1() {
  let x = await resolveAfter2Seconds(10).then(res => {return 1});
  console.log(x); // 1
}

f1();

每调用一次 then 就会创建一个新的 Promise。

async 函数中的 return

return 返回值,会成为 then() 方法回调函数的参数

async function foo() {
    return 'hello'
}

foo().then(v => {
    console.log(v)
})

// hello
Promise 执行器错误

每个执行器中都隐含一个 try-catch 块,所以错误会被捕获并传入给已拒绝回调。以下两段代码等价:

let p1 = new Promise(function(resolve, reject){
    throw new Error('fail')
})

p1.catch(v => {
    console.log(v.message) // fail
})
let p1 = new Promise(function(resolve, reject){
    try{
        throw new Error('fail')
    }catch(e){
        reject(e)
    }
})

...

.env 是一种用来存储环境变量的文件。

模板项目的 env

在 ant-vue-pro 中一共有三个 .env 文件。

// .env
NODE_ENV=production
VUE_APP_PREVIEW=false
VUE_APP_API_BASE_URL=/api

// .env.development
NODE_ENV=development
VUE_APP_PREVIEW=true
VUE_APP_API_BASE_URL=/api

// .env.preview
NODE_ENV=production
VUE_APP_PREVIEW=true
VUE_APP_API_BASE_URL=/api

.env - 在所有的环境中被载入
.env.[mode] - 只在指定的模式中被载入

一个环境文件只包含环境变量的“键=值”对:

FOO=bar
VUE_APP_NOT_SECRET_CODE=some_value
FOO2 = bar # 等号前后加空格也可以

:只有 NODE_ENVBASE_URL 和以 VUE_APP_ 开头的变量默认可以被识别。比如 FOO=bar 就不会被识别,除非使用其他手段进行变量扩展。

默认情况下,一个 Vue CLI 项目有三个模式:

  • development 模式用于 vue-cli-service serve
  • test 模式用于 vue-cli-service test:unit
  • production 模式用于 vue-cli-service build 和 vue-cli-service test:e2e

比如配置如下:

// .env
NODE_ENV=production
VUE_APP_PREVIEW=false
VUE_APP_API_BASE_URL=/api

VUE_APP_address = 长沙

// .env.development
VUE_APP_PREVIEW=true
VUE_APP_API_BASE_URL=/api

tel = 2222
VUE_APP_NAME=peng 3
VUE_APP_tel = 1111

运行 npm run serve(对应package.json中 "serve": "vue-cli-service serve",),会依次加载 .env 和 .env.development,后者会将前者的值覆盖,所以最后通过 process.env 输出:

console.log(process.env)

{
BASE_URL: "/"
NODE_ENV: "development"
VUE_APP_API_BASE_URL: "/api"
VUE_APP_NAME: "peng 3"
VUE_APP_PREVIEW: "true"
VUE_APP_address: "长沙"
VUE_APP_tel: "1111"
}
  • VUE_APP_address 从 .env 中得到
  • tel 被忽略
  • NODE_ENV 在 .env.development 中被自动加上该属性
  • VUE_APP_tel 中 = 前后有空格也能生效

Tip:每次修改 .env,需要重新启动服务才会生效。

--mode

可以通过 --mode 覆写默认的模式。比如本地开发我可以代理到测试的url,也像代理到预发布的url,我可以这样做:
增加 .env.pre:

VUE_APP_URL=/myapi

package.json 增加:

"scripts": {
    "serve": "vue-cli-service serve",
  + "serve:pre": "vue-cli-service serve --mode pre",

通过 npm run serve:pre 就能操作预发布环境的数据。现在输出:

console.log(process.env)

{
BASE_URL: "/"
NODE_ENV: "development"
VUE_APP_API_BASE_URL: "/api"
VUE_APP_PREVIEW: "false"
VUE_APP_URL: "/myapi"
VUE_APP_address: "长沙"
}

注意,现在 NODE_ENV 是 development,这个值来自 .env,vue-cli 没有给我们增加一个 NODE_ENV 的变量。

execSync

关于构建,有的人可能会通过配置一个 js 去执行,这样能更灵活,比如运维需要你创建一个每次创建一个文件。就像这样:

// package.json

"scripts": {
  "build": "node src/libs/shell.js",
  "build:pre": "node src/libs/shell.js pre",
  "build:test": "node src/libs/shell.js test"
// shell.js

var dist = 'dist'
var d = new Date().getTime().toString()
var env = process.argv.splice(2)[0]
var writeFileSync = require('fs').writeFileSync
var execSync = require('child_process').execSync

if (env == 'test') {
    execSync('vue-cli-service build --mode test')
} else if (env == 'pre') {
    execSync('vue-cli-service build --mode pre')
} else {
    execSync('vue-cli-service build --mode prod')
}

writeFileSync(dist + '/xx.txt', d)

Tip: execSync 是 Node.js 的 child_process 模块提供的一个同步执行外部命令的函数。它允许通过 JavaScript 代码来执行系统命令,并等待命令执行完成后再继续执行后续代码。

ant-vue-pro 使用的是 mockjs2(好像和 mockjs 是同一个东西)。

Tip: mockjs 不会在浏览器中看到请求发出,更多有关 mockjs 的使用方法,请看 这里

本地项目使用的 proxy,当后端没有提供接口给前端时,前端还是需要自己去模拟数据。

笔者通过如下方式给模板项目添加 mockjs。

首先模板项目中有 ant-vue-pro 中的 mockjs2 包,直接跳过安装包。

创建 src/mock/index.js:

// 判断环境不是 prod 时加载 mock 服务
if (process.env.NODE_ENV !== 'production') {
 
  console.log('[antd-pro] mock mounting')
  const Mock = require('mockjs2')
  require('./skin.js')
  
  Mock.setup({
    timeout: 100 // 设置所有请求的响应时间为100ms
  })
}
// skin.js
import Mock from 'mockjs2'
const navList = {
    "code": 0,
    "msg": "查询成功",
    "error": "",
    "url": null,
    "data": [
        {...},
    ],
    "success": true
};

Mock.mock(/\/mockjs-cms\/channel\/list\/navigation/, 'get', navList)

main.js 中引入 src/mock/index:

import './mock/index.js'

最后是发起请求:

export function queryNavigation() {
    return axios({
        url: `/mockjs-cms/channel/list/navigation`,
        method: 'get',
    })
}

:中途笔者遇到两个问题:

  • mockjs 返回了导航数据,但页面没有显示导航。将 timeout: 800 改成 timeout: 100
  • 本地开发时,保存 mock/index.js(按 ctrl + s) 文件 vscode 不会触发自动编译,在别的文件中保存会触发自动编译。最后引入这个资源,比如在 main.js 中引入,然后重启服务或vscode即可实现保存自动编译。

另外公司使用了 eolink,后端定义好接口后,就有一个简易 Mock 链接,开发阶段前端可以这样:

export function queryNavigation() {
    return axios({
        // 简易 Mock 链接
        url: 'https://mockapi.eolink.com/81F5kJv4c4f5ff6d3b8a7880xxxx',
        method: 'get',
    })
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK