Vue3+TypeScript+Django Rest Fram

2021-08-27  本文已影响0人  落霞__孤鹜

博客网站已经部署完成,在过程中我们解决了很多问题,曾子曰:吾日三省,这一篇就是总结我们遇到的问题,俗称:踩坑

大家好,我是落霞孤鹜,经过几个星期的努力,我们已经把开发的博客部署上线了,这一章我们主要讲讲过程中解决的比较麻烦的问题和可以作为经验的地方。

一、软件版本

关于软件版本的选择,实际上没有特定的标准,整理而言,我个人的开发是后端尽可能选择稳定的版本,前端尽可能跟随时代潮流。因为前端的变化很快,每个月都在推出新的版本和新的解决方案,而后端更注重的是业务逻辑的完备度和系统的稳定性,因此在技术选型的时候,尽可能选择稳定版本。

1.1 Python 技术栈选型

目前 Python的版本已经到 3.10 版本,但我没有选择最高的版本,而是选择了 3.7 版本,这里面有几个考量:

  1. Django 版本对 Python 版本的支持程度
  2. Django 生态中对 Python 和 Django 版本的支持程度,比如Django Rest Framework、Django-Filter、Django-MPTT等。

在选型中,基于核心框架进行选择,Django 的版本我没有选择最新的 3.0,而是选择了 2.2 版本中最新版本。在这样的选择下,我这边在使用第三方包时,就不用太担心第三方包对 Python 和 Django 版本的兼容问题。

可能有人会疑惑,为什么没有选择 Python 2,我的观点是,毕竟 Python 3才是后面的发展方向。

1.2 Vue 技术栈选型

目前 Vue 也存在两个版本,我们这里选择了 Vue 3,虽然 Vue 2 更成熟,但考虑到前端是一个发展非常快的领域,因此尽可能选择跟随时代潮流的软件和工具。

构建工具选择了 Vite,确实热编译的速度太快了,我们的博客网站由于代码量较少,几乎感觉不到有延迟,修改完立马生效。

TypeScript 的选择也是前端领域里面的一种趋势,它更适合多人协作的中大型项目,由于 JavaScript 是弱类型的动态语言,在类型判断时无法及时有效的发现潜在的问题,而通过 TypeScript的语法,可以很好的约束和规范代码,在编写阶段就能发现尽可能多的问题。

我们使用了 Less 作为 CSS 的预编译器,从而支持更灵活的 CSS 定义和管理。

是国内比较成熟的UI组件库,是Element-UI 对 Vue 3 版本的支持版本。

二、后端踩坑记

2.1 Django跨域问题

我们的博客网站使用的是Session认证机制,而Django默认会验证跨域cookieheader,因此我们需要做 3 件事情

2.1.1 去掉中间件

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    # 'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

2.1.2 编写不验证CSRF header 中间件

from django.utils.deprecation import MiddlewareMixin


class DisableCSRF(MiddlewareMixin):
    def process_request(self, request):
        setattr(request, '_dont_enforce_csrf_checks', True)

2.1.3 加入自定义的中间件

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    # 'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'project.middleware.request.DisableCSRF',
]

2.2 扩充Django用户表

Django中默认的用户表,属性一般不能完全满足业务需求,因此会考虑扩充表属性,这里需要做几个点:

2.2.1 继承AbstractUser

class User(AbstractUser, AbstractBaseModel):
    avatar = models.CharField('头像', max_length=1000, blank=True)
    nickname = models.CharField('昵称', null=True, blank=True, max_length=200)

    class Meta(AbstractUser.Meta):
        db_table = 'blog_user'
        swappable = 'AUTH_USER_MODEL'

2.2.2 修改认证用户表名称

settings.py 中增加一条配置

AUTH_USER_MODEL = 'common.User'

2.2.3 自定义登录和登出接口

这里通过调用Django自带的方法authenticatelogin完成账号认证和登录,这里面实现了session管理的机制

登录代码:

def post(self, request, *args, **kwargs):
  username = request.data.get('username', '')
  password = request.data.get('password', '')

  user = authenticate(username=username, password=password)
  if user is not None and user.is_active:
    login(request, user)
    serializer = UserSerializer(user)
    return Response(serializer.data, status=200)
  else:
    ret = {'detail': 'Username or password is wrong'}
    return Response(ret, status=403)

通过django自带的方法auth_logout实现登出,这里面实现了session失效机制。代码如下:

def get(self, request, *args, **kwargs):
  auth_logout(request)
  return Response({'detail': 'logout successful !'})

2.3 Filter 外键查询

在列表查询中,一般都会提供各种入参条件来完成查询,这里我们使用的是Django-Filter第三方包实现的。具体教程见:django-filter — django-filter 2.4.0 documentation

如果希望能通过外键ID作为过滤条件,比如通过分类ID查询文章列表,同时也希望在返回的列表中能展示分类名称,此时就需要做如下操作。

serializer中,对外键字段不做什么调整,采用默认方式,对返回结果中的分类信息,通过SerializerMethodField定一个只读字段实现。

如下代码,catalog_info通过方法定义,而原始的catalog字段,不做任何定义,默认即可,然后在field中列出。这样就可以在查询条件中,通过分类ID来过滤文章列表。

class ArticleListSerializer(serializers.ModelSerializer):
    tags_info = serializers.SerializerMethodField(read_only=True)
    catalog_info = serializers.SerializerMethodField(read_only=True)
    status = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = Article
        fields = ['id', 'title', 'excerpt', 'cover', 'created_at', 'modified_at', 'tags',
                  'tags_info', 'catalog', 'catalog_info', 'views', 'comments', 'words', 'likes', 'status', ]

        extra_kwargs = {
            'tags': {'write_only': True},
            'catalog': {'write_only': True},
            'views': {'read_only': True},
            'comments': {'read_only': True},
            'words': {'read_only': True},
            'likes': {'read_only': True},
            'created_at': {'read_only': True},
            'modified_at': {'read_only': True},
        }

2.4 路由定义

通过Rest Framework中的routers.DefaultRouter()定义路由后,如果在project/urls.py中通过include方式引入,则需要在app中的urls.py中定义app

blog/urls.py的代码:

from django.urls import include, path
from rest_framework import routers

from blog import views

router = routers.DefaultRouter()
router.register('article', views.ArticleViewSet)

app_name = 'blog'

urlpatterns = [
    path('', include(router.urls)),
]

project/urls.py的代码

path('', include('blog.urls', namespace='blog')),

2.5 前后端路由区分

在部署的时候,为了方便Nginx的路由区分代理,我们在后端的所有接口都增加api前缀,这也符合open api规范要求。

所以在project/urls.py 中,所有的接口前都增加api前缀。

path('api/', include('blog.urls', namespace='blog')),
path('api/', include('common.urls', namespace='common')),

2.6 Windows 和 类 Unix 系统路径兼容

在我们开发的时候, 我们使用的是windows系统,其路径表达方式是\,而部署的时候是放在类unix 系统中,其路径表达方式是 /,为了兼容两种表达方式,Python 默认使用 / 方式,因此在上传文件的时候,统一成一种方式,将 \ 转换成 /。

def get_upload_file_path(upload_name):
    # Generate date based path to put uploaded file.
    date_path = datetime.now().strftime('%Y/%m/%d')

    # Complete upload path (upload_path + date_path).
    upload_path = os.path.join(settings.UPLOAD_URL, date_path)
    full_path = os.path.join(settings.BASE_DIR, upload_path)
    make_sure_path_exist(full_path)
    file_name = slugify_filename(upload_name)
    return os.path.join(full_path, file_name).replace('\\', '/'), os.path.join('/', upload_path, file_name).replace('\\', '/')

2.7 表中自动添加创建时间和修改时间

在业务表中,我们一般都会填写时间戳字段,用来记录创建时间和最后一次修改时间,如果每一个表都要单独定义和维护,是一件非常麻烦的事情,我们通过定义抽象类实现。

DjangoORM提供了两个非常有用的字段类属性auto_now_addauto_now

class AbstractBaseModel(models.Model):
    creator = models.IntegerField('creator', null=True)
    created_at = models.DateTimeField(verbose_name='Created at', auto_now_add=True)

    modifier = models.IntegerField('modifier', null=True)
    modified_at = models.DateTimeField(verbose_name='Modified at', auto_now=True)

    class Meta:
        abstract = True

然后所有的模型类继承这个抽象类,就可以自动完成创建时间和修改时间的维护。

2.8 自动记录创建人和修改人

在模型中我们自动完成了创建时间和修改时间的维护,那么创建人和修改人可以通过定义公共的ViewSet类方法完成。

通过重写perform_updateperform_create方法,然后所有业务类通过混入( Python 可以多继承)继承的方式,实现自动维护这两个字段的信息。

class BaseViewSetMixin(object):
    def perform_update(self, serializer):
        user = self.fill_user(serializer, 'update')
        return serializer.save(**user)

    def perform_create(self, serializer):
        user = self.fill_user(serializer, 'create')
        return serializer.save(**user)

    @staticmethod
    def fill_user(serializer, mode):
        """
        before save, fill user info into para from session
        :param serializer: Model's serializer
        :param mode: create or update
        :return: None
        """
        request = serializer.context['request']

        user_id = request.user.id
        ret = {'modifier': user_id}

        if mode == 'create':
            ret['creator'] = user_id
        return ret


class BaseModelViewSet(BaseViewSetMixin, viewsets.ModelViewSet):
    pass

  
class ArticleViewSet(BaseViewSetMixin,
                     mixins.CreateModelMixin,
                     mixins.RetrieveModelMixin,
                     mixins.UpdateModelMixin,
                     mixins.DestroyModelMixin,
                     GenericViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

    def perform_create(self, serializer):
        extra_infos = self.fill_user(serializer, 'create')
        extra_infos['author'] = self.request.user
        serializer.save(**extra_infos)

    def filter_queryset(self, queryset):
        queryset = super(ArticleViewSet, self).filter_queryset(queryset)
        if self.is_reader():
            queryset = queryset.exclude(status=Constant.ARTICLE_STATUS_DRAFT).exclude(
                status=Constant.ARTICLE_STATUS_DELETED)
        return queryset

    def perform_destroy(self, instance: Article):
        instance.status = Constant.ARTICLE_STATUS_DELETED
        instance.save()

    def retrieve(self, request, *args, **kwargs):
        instance: Article = self.get_object()
        serializer = self.get_serializer(instance)
        if self.is_reader():
            instance.views += 1
            instance.save()
        return Response(serializer.data)

三、前端踩坑记

3.1 构建工具 Vite

3.1.1 vite.config.ts

这个文件的配置点不多,但却非常重要,包括后端接口代理地址,BASE URL 等。

  1. 代理地址配置

有几个点需要注意:

proxy 下出现的 key 是我们在调试时,前端页面访问后端接口的时候,通过 src/api/index.ts 中配置的 URL 前缀,增加了api 前缀,然后通过 rewrite 规则,决定对该前缀是否做替换,这里自动增加 api 前缀的原因是因为Vite自己本身也是一个静态资源服务器,为了区分请求是通过Vite代理的静态资源还是后端接口而做的处理。

vite.config.ts 核心代码如下:

server: {
        host: "localhost",
        port: 3000,
        proxy: {
            '/api': {
                target: 'http://localhost:8000/',
                changeOrigin: true,
                ws: false,
                rewrite: (pathStr) => pathStr.replace('/api', '/api'),
                timeout: 5000,
            },
            '/upload': {
                target: 'http://localhost:8000/',
                changeOrigin: true,
                ws: false,
                rewrite: (pathStr) => pathStr.replace('/api', ''),
                timeout: 5000,
            },
        },
    }

src/api/index.ts核心代码:

const request = axios.create({
    baseURL: import.meta.env.MODE !== 'production' ? '/api' : '',
})
  1. BASE 地址配置

在配置服务器地址的时候,Vite 提供的是base 属性。这里有两种配置方式base: '/'base: './',我们配置的是第一种,这里取决于我们使用的路由模式

 base: '/',
  1. Vue 插件配置

需要 Vite 支持 Vue,通过引入Vue插件方式实现,核心代码:

plugins: [
    vue(),
],

3.1.2 package.json

如果要使用 Vite,需要在这个文件里面配置scripts。然后才能在命令行中使用yarn devyarn build命令。核心代码如下:

"scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "serve": "vite preview"
},

3.1.3 vite-evn.d.ts

文件位置:src/vite-evn.d.ts,核心代码:

/// <reference types="vite/client" />

3.2 TypeScript 配置

启用 TypeScript 需要配置几个地方,一个是配置文件,一个是package.jsonbuild命令。

3.2.1 tsconfig.json

核心代码:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["esnext", "dom"]
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

3.2.2 package.json

scripts部分的build命令中,主要是在编译前先基于TypeScript语法进行检测,如果有语法不正确的地方,则编译不通过,用这种方式可以保证在运行之前发现尽可能多的问题。

"scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "serve": "vite preview"
},

3.3 懒加载

3.3.1 图片懒加载

博客都有封面,在博客列表界面,会有较多的图片同时加载,因此可以通过限流实现图片的按需延迟加载,减少网络流量和压力。

实现的方式主要绑定浏览器的滚动实践,滚动时实现分类展示,同时计算文章列表界面上的滚动位置距离屏幕底部的距离,当图片马上要进入到可视区域是,开始加载图片。

我们实现的方式:

1、就是创建一个自定义属性data-src存放真正需要显示的图片路径,而img自带的src放一张大小为通用的图片路径。
2、当页面滚动直至此图片出现在可视区域时,用取到该图片的data-src的值并赋值各src,然后将data-has-lazy-src设置为true,表示已经加载过图片。

TypeScript核心代码:

const viewHeight = window.innerHeight || document.documentElement.clientHeight;
const lazyload = throttle(() => {
  const imgs = document.querySelectorAll("#list .item img");
  let num = 0;
  for (let i = num; i < imgs.length; i++) {
    let distance = viewHeight - imgs[i].getBoundingClientRect().top;
    let imgItem: any = imgs[i];
    if (distance >= 100) {
      let hasLaySrc = imgItem.getAttribute("data-has-lazy-src");
      if (hasLaySrc === "false") {
        imgItem.src = imgItem.getAttribute("data-src");
        imgItem.setAttribute("data-has-lazy-src", "true");
      }
      num = i + 1;
    }
  }
}, 1000);

onMounted(() => {
      window.onscroll = () => {
        if (getScrollTop() + getWindowHeight() > getDocumentHeight() - 100) {
          if (state.isLoadEnd === false && state.isLoading === false) {
            console.info("222");
            handleSearch();
          }
        }
      };
      document.addEventListener("scroll", lazyload);
      handleSearch();
});

template 中图片的设置核心代码data-has-lazy-src="false",通过设置这个属性值的truefalse标记图片是否已经加载。data-src存放真正的图片url,这个属性名可以自定义。

<img :data-src="article.cover" alt="文章封面" class="wrap-img img-blur-done" data-has-lazy-src="false" src="/src/assets/cover.jpg"/>

3.3.2 页面按需加载

前后端分离情况下,首屏加载速度一直是一个热点问题,通常的方案有很多种:

  1. 页面懒加载
  2. 网络传输压缩
  3. 后端渲染

我们这里采用了前两种方案。

页面懒加载主要在路由设置中,核心代码如下:

{
    path: "/article/",
    name: "ArticleDetail",
    component: () =>
            import("../views/client/ArticleDetail.vue")
},

这里的组件不是通过文件头部的import方式导入,而是在具体需要的地方通过匿名函数导入,从而在需要展示该页面的时候开始加载,从而第一次网络请求的流量。

3.4 组件通信

在单页应用中,组件通信是一个非常重要的功能,也很复杂,有各种解决方案。我这里主要介绍一下在博客开发中用到的几种方案。

3.4.1 父子通信

在博客的开发过程中,有很多地方都出现了父组件的数据传递给子组件中,也是单页开发中最常见的场景,实现方案有两种:

通过:user-id的方式,将父组件中的值赋值给属性,在组件中,通过this.$props.userIduserId获取该属性值,这里的中划线会被 Vue 自动转换成骆驼命名的属性。

<UserDetail
      :user-id="state.userId"
      :visible="state.showDialog"
      @close="state.showDialog = false"
  />

通过在子组件中标记slot和名称,占位,在父组件中引用子组件时,在子组件的<></>中,通过<template v-slot:slot_name>的方式定义slot内容,此时需要传递到子组件slot的数据,可以直接在父组件中直接赋值。比如下面代码中的loading属性等

<template v-slot:footer>
    <div class="dialog-footer">
    <el-button
             v-if="isLogin"
             :loading="state.btnLoading"
             type="primary"
             @click="handleOk"
             >
    登 录
    </el-button>
    <el-button
             v-if="isRegister"
             :loading="state.btnLoading"
             type="primary"
             @click="handleOk"
             >注 册
    </el-button>
  </div>
</template>

3.4.2 子父通信

子组件向父组件通信,有两种,一种是通过父组件提供回调函数,在属性中传入函数,在子组件中调用回调函数,完成子组件向组件通信。

在我们的博客网站中,没有使用这种方式。

父组件中,通过@close定义一个事件,并传入一个定义好的函数handleCloseDrawer

<EditArticle
      :article-id="state.articleId"
      :visible="state.showDrawer"
      @close="handleCloseDrawer"
  />

子组件中,定义emits

emits: ["close",],

在需要触发事件的时候,调用context.emit('close', param)或者this.$emit('close', param),这里的param是需要传递的数据。

const saveArticle = async () => {
      try {
        state.loading = true
        if (state.catalogs.length) {
          state.article.catalog = state.catalogs[state.catalogs.length - 1]
        }
        if (props.articleId) {
          await remoteSaveArticle('put', state.article)
        } else {
          await remoteSaveArticle('post', state.article)
        }
        state.loading = false
        context.emit('close', true)
      } catch (e) {
        state.loading = false
      }
}

3.4.3 跨级及兄弟组件通信

这种场景下的组件通信比较复杂,如果通过属性通信,则需要层层传递,回调函数也需要层层回调,所以有了vuex,通过全局共享数据,实现跨组件通信。

将需要共享的数据放入到store中,在需要的组件,通过computed获取。

  1. store定义
export const store = createStore<State>({
    state() {
        return {
            navIndex: '1',
        }
    },
  mutations: {
        setNavIndex(state: object | any, navIndex: string) {
            state.navIndex = navIndex
        },
    },
}
  1. 需要获取数据的组件中通过computed获取
computed: {
        navIndex() {
      const store = useStore(StateKey);
      return store.state.navIndex;
    },
}
  1. 其他组件通过mutations中定义的方法改变store中的值
store.commit(SET_NAV_INDEX, "-1");

一旦这个值发生变化,通过computed定义的属性,就会同步发生变化,从而实现组件间的通信,在博客网站中,我们的用户信息也是这么使用的。

  1. 需要获取数据的组件也可以通过watch监控
watch: {
    "$store.state.articleParams": {
      handler(val: any, oldVal: any) {
        this.state.params.tags = val.tags;
        this.state.params.catalog = val.catalog;
        this.state.articlesList = [];
        this.state.params.page = 1;
        this.handleSearch();
      },
    },
  },

这种是当state发生变化时,可以获取到变换前和变化后的值,通过handler做更多的处理,完成该组件处理的逻辑。博客网站中我们在文章列表中点击标签筛选文章列表,就是通过这种方式实现的

watch中可以监控的对象有很多,可以参考 Vue 官网介绍:计算属性和侦听器 | Vue.js (vuejs.org)

watch: {
    '$props.visible': {
      handler(val, oldVal) {
        if (val != oldVal) {
          this.state.visible = val
        }
      }
    }
  },
watch: {
    $route: {
      handler(val: any, oldVal: any) {
        this.routeChange(val, oldVal);
      },
      immediate: true,
    },
  },
watch: {
    "$route.path": {
      handler(val, oldVal) {
        if (val !== oldVal && ["/admin/tag"].includes(val)) this.handleSearch();
      },
      deep: true,
    },
  },

3.5 CSS样式穿透

在使用UI组件的时候,会遇到需要修改组件样式的场景,但是由于组件本身并没有开发样式属性的定义能力,此时就需要通过穿透机制实现。

//抽屉//去掉element-ui的drawer标题选中状态
:deep(:focus){
  outline: 0;

}

这里需要注意几点,一般我们在定义一个组件的样式时,会设置<style lang="less" scoped>,这样会导致该样式穿透无效果,因此还需要做如下调整

  1. 在需要调整组件样式的地方,将组件包裹在一个div
  2. 给这个div定义一个class
  3. style中通过global穿透
.parent-div{
  :global(.el-card__body){
     margin: 0;
     padding: 0;
   }
}

也可以设置<style lang="less">,去掉scoped,然后使用:deep方式穿透。我这里的语法是基于vue 3less,如果使用其他的css编译器,请自行搜索验证。

3.6 刷新 404 问题

这个问题我们在部署篇已经介绍,这里贴一下Nginx的配置,关键的是if判断。

location / {
            root ~/blog/frontend/dist;
            index index.html index.htm;
            if (!-e $request_filename) {
                rewrite ^/(.*) /index.html last;
                break;
            }
        }

至此,这个博客搭建的内容已经结束了,恭喜你自己哦!

上一篇下一篇

猜你喜欢

热点阅读