阅读视图
如祺出行:预计2025年度利润同比大幅改善不少于43.4%
通化金马:董事长拟1000万元—2000万元增持公司股份
亚马逊花逾4亿美元购美知名高校校区用于AI建设
华能集团旗下海宁投资合伙企业注册资本增至617.51亿元
天津南港新能源发展公司注册资本增至19.5亿元
前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比
前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比
引言:当组件遇见 CSS
在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。
试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modules、styled-components 和 Vue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。
1. CSS 的“先天不足”与组件化的冲突
在传统网页开发中,我们通常这样写 CSS:
/* global.css */
.button {
background-color: blue;
color: white;
}
这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。
假设我们有 Button.jsx 和 AnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:
/* Button.css */
.button { background: blue; }
/* AnotherButton.css */
.button { background: red; }
效果图
最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。
为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。
2. React 中的 CSS Modules
2.1 什么是 CSS Modules?
CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。
2.2 基本用法
我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。
Button.module.css
.button {
background-color: blue;
color: white;
padding: 10px 20px;
}
.txt {
color: red;
background-color: orange;
font-size: 30px;
}
Button.jsx
import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }
export default function Button() {
return (
<>
<h1 className={styles.txt}>你好,世界!!!</h1>
<button className={styles.button}>My Button</button>
</>
);
}
效果图
在浏览器中,最终渲染的 HTML 类似:
<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id
可以看到,原始的类名 .button 和 .txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。
2.3 多人协作的保障
再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:
anotherButton.module.css
.button {
background-color: red;
color: black;
padding: 10px 20px;
}
AnotherButton.jsx
import styles from './anotherButton.module.css';
export default function AnotherButton() {
return <button className={styles.button}>My Another Button</button>;
}
两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxx 和 Button_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。
2.4 原理浅析
CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。
3. React 中的 styled-components
如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。
3.1 什么是 styled-components?
styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。
3.2 基本用法
首先安装 styled-components:
npm install styled-components
然后在组件中创建样式化组件:
import styled from 'styled-components';
// 定义一个带样式的 button 组件
const Button = styled.button`
background-color: ${props => props.primary ? 'blue' : 'white'};
color: ${props => props.primary ? 'white' : 'blue'};
border: 1px solid blue;
padding: 8px 16px;
border-radius: 4px;
`;
function App() {
return (
<>
<Button>默认按钮</Button>
<Button primary>主要按钮</Button>
</>
);
}
效果图
渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):
<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id
这里的
sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。
3.3 动态样式与 props
styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。
3.4 原理浅析
styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。
4. Vue 中的 scoped 样式
Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。
4.1 什么是 scoped?
在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。
4.2 基本用法
App.vue
<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<h1 class="txt">Hello world in App</h1>
<h2 class="txt2">一点点</h2>
<HelloWorld />
</div>
</template>
<style scoped>
.txt {
color: red;
}
.txt2 {
color: green;
}
</style>
HelloWorld.vue
<template>
<div>
<h1 class="txt">你好,世界!!!</h1>
<h2 class="txt2">一点点</h2>
</div>
</template>
<style scoped>
.txt {
color: blue;
}
.txt2 {
color: orange;
}
</style>
效果图
4.3 渲染结果与原理
编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):
html
<div data-v-7a7a37b1>
<h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
<h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>
<div data-v-e17ea971 data-v-7a7a37b1>
<h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
<h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>
仔细观察可以发现:
- App 组件内的所有元素(包括根
div)都带有自己的 IDdata-v-7a7a37b1。 - HelloWorld 组件内的所有元素(包括其根
div)都带有自己的 IDdata-v-e17ea971。特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 IDdata-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如.txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。
对应的 CSS 会被编译为:
css
.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }
由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。
打开控制台元素,我们就可以看到
4.4 与 CSS Modules 的对比
Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:
- CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
- Vue 的
scoped无需导入对象,直接在模板中使用原始类名,可读性更好。 - CSS Modules 需要显式引用
styles对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。
4.5 原理浅析
Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:
- 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
- 将
<style scoped>中的每条 CSS 规则都加上对应的属性选择器。 - 最终生成带作用域的 CSS。
整个过程在构建阶段完成,没有运行时开销,性能极佳。
5. 对比与总结
| 方案 | 框架 | 实现原理 | 优点 | 缺点 |
|---|---|---|---|---|
| CSS Modules | React / Vue | 编译时修改类名,生成哈希映射 | 静态样式,简单可靠;可与预处理器结合 | 类名需要引用,模板稍显啰嗦 |
| styled-components | React | 运行时生成唯一类名,注入 <style>
|
动态样式能力强;完全组件化;支持 props | 运行时开销;包体积较大;调试稍难 |
| Vue scoped | Vue | 编译时添加唯一属性,属性选择器限制 | 语法简洁;无运行时开销;保留原始类名 | 仅适用于 Vue;深度选择器需特殊处理 |
如何选择?
- 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
- 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
- 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。
当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。
结语
从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!
央行:2月中期借贷便利(MLF)净投放3000亿元
百奥泰:乌司奴单抗注射液获英国MHRA上市批准
Vue3 嵌套路由 KeepAlive:动态缓存与反向配置方案
在电商系统中,用户的操作路径往往是这样的:
进入商品列表页 → 进行筛选、排序、分页 → 点击进入商品详情页 → 查看后返回列表页。
这时,用户通常有一个非常明确的预期:
- 筛选条件还在
- 分页状态保持
- 滚动位置不丢失
但同时,我们也会遇到另一种场景:
- 从首页、搜索页或其他模块进入商品列表页时
- 希望列表页是“全新状态”
- 需要重新请求数据
也就是说:
同一个页面,在不同来源路径下,对“是否缓存”的期望是不同的。
在 Vue 项目开发中,KeepAlive 是提升用户体验的重要工具。但在复杂的嵌套路由场景(例如:Layout → SubLayout → Page)下,我们常常会遇到两个典型痛点:
1️⃣ 缓存失效
明明在路由里配置了 keepAlive: true,
但页面返回时依然重新挂载,onMounted 再次触发,状态丢失。
2️⃣ 控制逻辑越来越混乱
当我们尝试实现:
- 从 A 页面跳到 B 页面时缓存
- 从 C 页面跳到 B 页面时不缓存
路由守卫里开始出现大量 if-else 判断,
逻辑逐渐变得难以维护,甚至演变成“屎山”。
如何实现:
- 精准控制缓存来源
- 支持嵌套路由结构
- 避免父级 Layout 被误销毁
- 同时保持代码清晰、可维护
本文将分享一种“反向配置”的缓存设计方案,并深入解析嵌套路由场景下的一个核心原则:
父随子存。
通过这套设计,我们可以在复杂电商场景中,实现精细化、可控且可扩展的页面缓存策略。
一、核心痛点:为什么嵌套路由下的缓存容易失效?
在 Vue 中,KeepAlive 的本质其实是“链路存活”。
换句话说,只要组件所在的这条渲染链路还存在,它的缓存才能继续保留。一旦链路中某一层被销毁,缓存就会随之消失。
假设我们的路由结构是这样的:
- 一级容器:
WebsiteLayout(顶层布局) - 二级容器:
UserLayout(用户中心布局) - 三级页面:
UserProfile、UserFavorites等子页面
WebsiteLayout
└── UserLayout
└── UserProfile / UserFavorites
这里有一个很多人忽略的“真相”:
如果父容器(例如
UserLayout)被销毁,那么它内部缓存的所有子页面,也会被瞬间“物理清空”。
即使你在 UserProfile 上配置了keepAlive: true,
只要 UserLayout 这一层被重新挂载,
内部所有缓存都会失效,onMounted 会再次触发。
这就是嵌套路由场景下缓存“看起来配置了却没生效”的根本原因。
很多开发者只给子页面设置缓存,却忽略了一个关键原则:
子页面想存活,父容器必须先存活。
这也是后文要讲的核心设计理念——“父随子存”原则。
二、 解决方案:反向配置 + 递归缓存
1. 路由配置:由“去向页”决定“来源页”
我们不再在每个页面写复杂的判断,而是在详情页声明:“从我这里回退时,请保持谁的缓存”。
// router/index.ts
export const routes = [
{
path: '/user',
name: 'User',
component: () => import('@/layouts/UserLayout.vue'),
meta: { keepAlive: true }, // 父容器必须支持缓存
children: [
{
path: 'favorites',
name: 'UserFavorites',
component: () => import('@/views/user/Favorites.vue'),
meta: { keepAlive: true }
}
]
},
{
path: '/product/:id',
name: 'Product',
component: () => import('@/views/product/index.vue'),
meta: {
// 反向配置:从这里回退时,保护以下页面的缓存
keepAliveSources: ['UserFavorites', 'Home']
}
}
];
2. 全局守卫:实现“父随子存”
这是整套方案的灵魂。我们需要在路由守卫中做两件事:
-
补全链路:进入子页面时,强制将其所有父
Layout加入缓存名单。 - 精准清理:非合法来源进入时,即时销毁旧缓存。
export const cacheStack = ref<string[]>([]);
router.beforeEach(async (to, from, next) => {
const fromName = from.name as string;
const keepAliveSources = to.meta.keepAliveSources as string[] || [];
// 1️⃣ 核心:补全父路由缓存名单
// 遍历 to.matched,确保当前路由的所有父 Layout 都在缓存数组中
to.matched.forEach((record) => {
if (record.meta.keepAlive && record.name) {
const name = record.name as string;
if (!cacheStack.value.includes(name)) {
cacheStack.value.push(name);
}
}
});
// 2️⃣ 处理来源页逻辑
if (from.meta.keepAlive && fromName) {
// 如果来源页本身声明了需要缓存,保留它
if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
} else if (keepAliveSources.includes(fromName)) {
// 如果去向页声明了它是合法的回退来源,保留它
if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
} else if (fromName) {
// 3️⃣ 清理:如果不是合法来源,从名单中移除,触发组件销毁
const index = cacheStack.value.indexOf(fromName);
if (index > -1) cacheStack.value.splice(index, 1);
}
next();
});
3. 布局组件:视图层的配合
在所有包含 router-view 的 Layout 组件中,必须使用 include 绑定这个全局名单。
- WebsiteLayout.vue
// WebsiteLayout.vue
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cacheStackList">
<component
:is="Component"
:key="route.fullPath"
/>
</keep-alive>
</router-view>
</template>
<script lang="ts" setup name="WebsiteLayout">
import { defineComponent, computed } from 'vue';
import { cacheStack } from '/@/router/guard/index';
const cacheStackList = computed(() => {
return cacheStack.value
})
</script>
- UserLayout.vue
// UserLayout.vue
<template>
<div class="flex flex-col">
<div class="flex gap-6 py-4">
<!-- 左侧菜单 -->
<aside class="w-96 shrink-0">
<UserMenu />
</aside>
<!-- 右侧内容 -->
<main class="flex-1">
<router-view v-slot="{ Component, route }">
<keep-alive :include="cacheStackList">
<component
:is="Component"
:key="route.fullPath"
/>
</keep-alive>
</router-view>
</main>
</div>
</div>
</template>
<script setup lang="ts" name="User">
import UserMenu from './userMenu/index.vue'
import { cacheStack } from '/@/router/guard/index';
import { computed } from 'vue';
const cacheStackList = computed(() => {
return cacheStack.value
})
</script>
三、 方案优势
-
物理隔离确保生效:通过
to.matched递归确保父容器存活,彻底解决了嵌套路由“名单对了但不缓存”的问题。 -
配置解耦:新加一个详情页时,只需在详情页
meta里增加一行回退目标,无需改动任何业务组件。 -
内存友好:不满足回退条件时,缓存会被立即
splice清理,避免内存堆积。
以岭药业:泊马度胺化学原料药上市申请获批
深交所倡议全体深市公司开展“质量回报双提升”专项行动
现代汽车2月全球销量30.6万辆,同比下滑5.1%
广州港:2月预计完成货物吞吐量同比下降3.6%
Kotlin抽象类与接口:相爱相杀的编程“CP”
一、开篇引入
在 Kotlin 的编程世界里,你是否常常在定义一些通用行为或属性时,纠结于到底该使用抽象类还是接口呢?就像在建造一座大厦时,选择合适的建筑材料至关重要,在 Kotlin 编程中,正确选用抽象类和接口,对于构建健壮、可维护的代码结构同样意义非凡 。今天,我们就一起来深入探讨 Kotlin 中抽象类以及它与接口的区别。
二、Kotlin 抽象类详解
(一)抽象类定义
在 Kotlin 中,抽象类是一种不能被直接实例化的类,就像是一个还未完成的 “蓝图”,它主要的作用是作为其他类的基类(父类) ,为子类提供通用的属性和方法定义,而将具体的实现细节留给子类去完成。我们使用abstract关键字来声明一个抽象类。例如:
abstract class AbstractClass {
// 这里可以定义抽象属性和抽象方法
// 也可以定义非抽象属性和非抽象方法
}
抽象类不能被直接实例化,比如不能写成val abstractObj = AbstractClass(),这就如同你不能直接使用一个未完成的蓝图来建造实际的建筑一样。它存在的意义更多是为了提供一种通用的结构和规范,让子类基于它进行扩展和实现 。
(二)抽象类示例
以一个图形相关的程序为例,我们定义一个Shape抽象类:
abstract class Shape {
// 抽象属性,没有初始化值,必须在子类中重写
abstract val name: String
// 抽象方法,没有方法体,必须在子类中重写
abstract fun calculateArea(): Double
// 非抽象方法,有具体实现,可以被子类继承或重写
fun printName() {
println("形状名称: $name")
}
}
在这个Shape抽象类中,name是一个抽象属性,它代表图形的名称,每个具体的图形(如圆形、矩形)名称都不同,所以需要在子类中具体实现;calculateArea()是一个抽象方法,用于计算图形的面积,不同图形的面积计算方式不同,也需要子类去实现;而printName()是一个非抽象方法,它会打印出图形的名称,这个方法有具体的实现,子类可以直接继承使用,如果有特殊需求也可以重写。
(三)继承抽象类
当子类继承抽象类时,必须使用override关键字重写所有抽象属性和方法。例如,我们定义Circle(圆形)和Rectangle(矩形)类来继承Shape类:
// 子类Circle继承自抽象类Shape
class Circle(val radius: Double) : Shape() {
// 重写抽象属性
override val name: String = "圆形"
// 重写抽象方法
override fun calculateArea(): Double {
return Math.PI * radius * radius
}
}
// 子类Rectangle继承自抽象类Shape
class Rectangle(val width: Double, val height: Double) : Shape() {
override val name: String = "矩形"
override fun calculateArea(): Double {
return width * height
}
}
在Circle类中,我们重写了name属性为 “圆形”,并实现了calculateArea()方法来计算圆形的面积;在Rectangle类中,同样重写了name属性为 “矩形”,并实现calculateArea()方法来计算矩形面积。通过这种方式,抽象类的抽象成员在子类中得到了具体的实现 。
(四)抽象类特点总结
-
不能实例化:抽象类不能直接创建对象,它主要为子类提供一个通用的框架。就像我们不能直接使用一个抽象的 “交通工具” 类来创建一个具体的交通工具,而是需要基于它创建 “汽车”“飞机” 等具体子类的对象。
-
可包含抽象和非抽象成员:抽象类中既可以有抽象属性和抽象方法,这些需要子类去实现;也可以有非抽象属性和非抽象方法,子类可以直接继承使用,也可以根据需求重写。
-
子类必须实现所有抽象成员:如果一个子类继承了抽象类,那么它必须重写抽象类中的所有抽象属性和方法,否则这个子类也必须声明为抽象类。例如,下面这个只重写了部分抽象成员的类,就必须声明为抽象类:
abstract class Square(val sideLength: Double) : Shape() {
// 只重写了抽象属性,没有重写抽象方法calculateArea()
override val name: String = "正方形"
// 因此Square类也必须是抽象的
}
- 可以继承其他类:抽象类可以继承自另一个非抽象类或抽象类,进一步扩展和定制自己的行为和属性。例如:
open class Animal {
open fun makeSound() {
println("动物发出声音")
}
}
abstract class Dog : Animal() {
override abstract fun makeSound() // 重写并声明为抽象方法,子类必须实现
}
class Puppy : Dog() {
override fun makeSound() {
println("小狗汪汪叫")
}
}
在这个例子中,Dog抽象类继承自Animal类,并重写了makeSound()方法并声明为抽象方法,Puppy类再继承Dog类并实现了makeSound()方法 。
三、Kotlin 接口详解
(一)接口定义
在 Kotlin 中,接口是一种强大的抽象机制,它使用interface关键字来定义。与抽象类不同,接口主要用于定义一组方法的签名,这些方法可以是抽象的,也可以有默认实现 。接口无法存储状态,它就像是一份 “行为契约”,规定了实现它的类应该具备哪些行为 。接口可以包含抽象方法声明和方法实现 ,接口中的属性默认是抽象的,或必须提供getter实现 。例如:
interface MyInterface {
// 抽象方法,没有方法体,实现接口的类必须实现这个方法
fun abstractMethod()
// 带默认实现的方法,实现接口的类可以选择重写这个方法,也可以使用默认实现
fun methodWithDefaultImplementation() {
println("这是一个带有默认实现的方法")
}
}
(二)接口示例
以Vehicle接口为例,展示接口中抽象方法和带默认实现方法的定义:
interface Vehicle {
// 抽象方法,启动车辆,必须在实现接口的类中实现
fun start()
// 抽象方法,停止车辆,必须在实现接口的类中实现
fun stop()
// 带默认实现的方法,车辆鸣笛,实现接口的类可以选择重写,也可以使用默认实现
fun honk() {
println("嘟嘟!")
}
}
在这个Vehicle接口中,start()和stop()是抽象方法,因为不同类型的车辆启动和停止的方式可能不同,需要具体的实现类去实现;而honk()是一个带默认实现的方法,默认情况下车辆鸣笛输出 “嘟嘟!”,如果有特殊的鸣笛需求,实现类也可以重写这个方法 。
(三)实现接口
一个类或对象可以实现一个或多个接口。当一个类实现接口时,它必须实现接口中所有的抽象方法(除非这个类本身也是抽象类) 。以Car类实现Vehicle接口为例:
class Car : Vehicle {
override fun start() {
println("汽车启动")
}
override fun stop() {
println("汽车停止")
}
// 这里没有重写honk()方法,所以会使用接口中honk()的默认实现
}
在Car类中,通过override关键字重写了Vehicle接口中的start()和stop()抽象方法,来实现汽车的启动和停止逻辑 。而对于honk()方法,由于没有重写,所以当调用Car对象的honk()方法时,会执行接口中honk()的默认实现,输出 “嘟嘟!” 。
(四)接口继承与解决覆盖冲突
接口可以继承其他接口,通过继承,接口可以扩展和增强自身的功能 。例如:
interface Moveable {
fun move()
}
interface Flyable : Moveable {
fun fly()
}
在这个例子中,Flyable接口继承了Moveable接口,这意味着实现Flyable接口的类不仅要实现fly()方法,还要实现Moveable接口中的move()方法 。
当一个类实现多个接口时,如果这些接口中定义了相同签名的方法,就会出现方法覆盖冲突 。例如:
interface A {
fun foo() {
println("A中的foo方法")
}
}
interface B {
fun foo() {
println("B中的foo方法")
}
}
class C : A, B {
// 必须重写foo()方法来解决冲突
override fun foo() {
// 调用A接口中的foo()方法
super<A>.foo()
// 调用B接口中的foo()方法
super<B>.foo()
println("C中重写的foo方法")
}
}
在上述代码中,C类实现了A和B两个接口,而这两个接口都定义了foo()方法,所以在C类中必须重写foo()方法 。在重写的foo()方法中,通过super<A>.foo()和super<B>.foo()分别调用了A和B接口中的foo()方法,并添加了自己的逻辑 。这样就解决了方法覆盖冲突的问题 。
四、抽象类与接口的区别
通过前面的介绍,我们对 Kotlin 中的抽象类和接口都有了一定的了解 。接下来,我们来详细对比一下它们之间的区别,以便在实际开发中能够更准确地选择使用。
(一)构造函数
抽象类可以有构造函数,包括主构造函数和次构造函数,用于初始化抽象类中的属性和状态 。例如,我们在Shape抽象类中添加一个主构造函数:
abstract class Shape(val color: String) {
abstract val name: String
abstract fun calculateArea(): Double
fun printName() {
println("形状名称: $name")
}
}
在这个例子中,Shape抽象类有一个主构造函数,接受一个color参数,用于表示图形的颜色 。
而接口不能有构造函数 。这是因为接口主要用于定义行为,不存储状态,所以不需要构造函数来初始化 。不过,从 Kotlin 1.9 + 开始,虽然支持接口中定义带默认实现的属性,但仍然不能有构造函数 。
(二)多重继承
在 Kotlin 中,一个类只能继承一个抽象类,即抽象类是单继承的 。这是为了避免多重继承带来的复杂性和冲突 。例如:
abstract class Animal {
open fun makeSound() {
println("动物发出声音")
}
}
abstract class Dog : Animal() {
override abstract fun makeSound()
}
这里Dog抽象类继承自Animal抽象类,一个类不能同时继承多个抽象类 。
而接口则不同,一个类可以实现多个接口,通过实现多个接口,一个类可以拥有多个不同的行为集合 。例如:
interface Flyable {
fun fly()
}
interface Runable {
fun run()
}
class Bird : Flyable, Runable {
override fun fly() {
println("鸟儿飞翔")
}
override fun run() {
println("鸟儿奔跑")
}
}
在这个例子中,Bird类实现了Flyable和Runable两个接口,从而具备了飞翔和奔跑的行为 。
(三)属性
抽象类可以包含非抽象属性,这些属性可以有初始值,也可以在构造函数中初始化 。例如,我们在Shape抽象类中添加一个非抽象属性borderWidth:
abstract class Shape(val color: String) {
abstract val name: String
abstract fun calculateArea(): Double
val borderWidth: Int = 1
fun printName() {
println("形状名称: $name")
}
}
在这个例子中,borderWidth是一个非抽象属性,它有初始值1 。
接口中的属性默认是抽象的,必须在实现接口的类中重写并提供具体实现,除非该属性提供了getter的默认实现 。例如:
interface ShapeInterface {
val name: String
val borderWidth: Int
get() = 1
}
class Rectangle : ShapeInterface {
override val name: String = "矩形"
override val borderWidth: Int
get() = super.borderWidth
}
在这个例子中,ShapeInterface接口中的name属性是抽象的,没有默认实现,必须在实现类Rectangle中重写;而borderWidth属性提供了getter的默认实现,在Rectangle类中如果不需要修改其行为,可以直接使用默认实现 。
(四)方法实现
抽象类可以包含非抽象方法的实现,子类可以继承这些方法,也可以根据需要重写它们 。例如,我们在Shape抽象类中的printName()方法就是一个非抽象方法,有具体的实现 。
接口中的方法默认是抽象的,没有方法体,必须在实现接口的类中实现 。不过,从 Kotlin 1.4 + 开始,接口支持方法的默认实现 。例如,我们在Vehicle接口中添加一个带默认实现的方法startEngine():
interface Vehicle {
fun start()
fun stop()
fun honk() {
println("嘟嘟!")
}
fun startEngine() {
println("发动机启动")
}
}
在这个例子中,start()和stop()方法是抽象的,必须在实现类中实现;而honk()和startEngine()方法是带默认实现的方法,实现类可以选择重写这些方法,也可以使用默认实现 。
(五)访问修饰符
抽象类可以使用private、protected、public等访问修饰符来控制成员的访问权限 。private修饰的成员只能在抽象类内部访问,protected修饰的成员可以在抽象类及其子类中访问,public修饰的成员可以在任何地方访问 。例如:
abstract class Shape {
private val privateProperty: String = "私有属性"
protected val protectedProperty: String = "受保护属性"
val publicProperty: String = "公共属性"
private fun privateMethod() {
println("这是一个私有方法")
}
protected fun protectedMethod() {
println("这是一个受保护方法")
}
fun publicMethod() {
println("这是一个公共方法")
}
}
在这个例子中,privateProperty和privateMethod()是私有的,只能在Shape抽象类内部访问;protectedProperty和protectedMethod()是受保护的,可以在Shape抽象类及其子类中访问;publicProperty和publicMethod()是公共的,可以在任何地方访问 。
接口成员默认是public的,不能有private修饰符 。这是因为接口的主要目的是定义一组可供其他类实现的行为,这些行为通常是对外公开的 。例如:
interface MyInterface {
fun method1()
fun method2()
}
在这个MyInterface接口中,method1()和method2()方法默认都是public的,不能声明为private 。
五、使用建议与场景
(一)抽象类使用场景
当你需要定义一个通用的基类,并且这个基类包含一些通用的属性、方法以及构造函数时,抽象类是一个很好的选择 。例如,在 Android 开发中,我们经常会创建一个BaseActivity抽象类,它包含了一些所有 Activity 都通用的逻辑,如设置布局、初始化视图、加载数据等:
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(getLayoutId())
initViews()
initData()
}
// 抽象方法,由子类实现,返回布局ID
abstract fun getLayoutId(): Int
// 抽象方法,由子类实现,初始化视图
abstract fun initViews()
// 抽象方法,由子类实现,加载数据
abstract fun initData()
}
然后,具体的 Activity 类可以继承自BaseActivity,并实现其中的抽象方法 。例如:
class MainActivity : BaseActivity() {
override fun getLayoutId(): Int {
return R.layout.activity_main
}
override fun initViews() {
// 初始化视图的具体逻辑
}
override fun initData() {
// 加载数据的具体逻辑
}
}
通过这种方式,我们可以将通用的逻辑提取到BaseActivity抽象类中,减少代码重复,提高代码的可维护性和可扩展性 。
(二)接口使用场景
当你只需要定义一组抽象的行为,并且希望一个类可以实现多个这样的行为时,接口是更合适的选择 。例如,在一个图形绘制库中,我们可以定义多个接口来表示不同的功能:
// 定义一个可绘制的接口
interface Drawable {
fun draw()
}
// 定义一个可点击的接口
interface Clickable {
fun onClick()
}
// 定义一个可拖动的接口
interface Draggable {
fun drag()
fun drop()
}
然后,一个类可以实现多个接口,以具备多种行为 。比如一个Button类可以同时实现Drawable、Clickable和Draggable接口:
class Button : Drawable, Clickable, Draggable {
override fun draw() {
println("绘制按钮")
}
override fun onClick() {
println("按钮被点击")
}
override fun drag() {
println("按钮被拖动")
}
override fun drop() {
println("按钮被放下")
}
}
通过接口,我们可以让不同的类灵活地组合不同的行为,而不受单继承的限制,使代码更加灵活和可复用 。
六、总结回顾
通过今天的学习,我们深入了解了 Kotlin 中抽象类和接口这两个重要的概念 。抽象类像是一个未完成的蓝图,不能被直接实例化,它为子类提供通用的属性和方法定义,子类继承抽象类时必须重写所有抽象成员 。而接口则是一份行为契约,定义了一组方法签名,一个类可以实现多个接口,以获得多种行为 。
它们在构造函数、多重继承、属性、方法实现以及访问修饰符等方面都存在明显的区别 。在实际的 Kotlin 开发中,我们要根据具体的需求来选择使用抽象类还是接口 。如果需要定义一个通用的基类,并且这个基类包含构造函数、非抽象属性和方法,那么抽象类是合适的选择 ;如果只是需要定义一组抽象的行为,并且希望一个类可以实现多个这样的行为,接口则更为合适 。 希望大家在今后的 Kotlin 编程中,能够熟练运用抽象类和接口,构建出更加健壮、灵活和可维护的代码 。如果对今天的内容还有任何疑问,欢迎在评论区留言交流 。
郎酒股份投资11亿元设立3家新公司
Angular学习笔记24:Angular 响应式表单 FormArray 与 FormGroup 相互嵌套
Angular 响应式表单 FormArray 与 FormGroup 相互嵌套
在类文件中(组件的TS文件):
声明一个Form表单:
public validateForm: FormGroup;
在构造方法中:
private fb: FormBuild;
声明一个FormBuild的对象
在构造方法中:
constructor(private fb: FormBuilder) {
this.validateForm = this.fb.group({
name: [null],
sex : [null],
age : [null],
address: this.fb.array([
new FormGroup({
street : new FormControl(null),
country: new FormControl(null),
}),
]),
});
}
这样在组件中就构造出来了一个嵌套了FormArray的FormGroup,
这个时候,需要将validateForm这个表单中address的属性实例成一个FormArray
使用Angular中的get 方法
get addressFormArray(){
return this.validateForm.controls['address'] as FormArray;
}
这个时候,在组件中就会生成一个变量:addressFormArray;
当想对表单中的address中的控件进行操作,可以直接对变量:addressFormArray进行操作;
1.对validateForm中的address增加一对新的 street 和 country 有两种方法:
a.使用变量addressFormArray,具体如下:
this.addressFormArray.push(
new FormGroup({
street : new FormControl(null),
country: new FormControl(null),
}),
)
b.直接对validateForm进行操作
(this.validateForm.controls['address'] as FormArray).push(
new FormGroup({
street : new FormControl(null),
country: new FormControl(null),
})
)
对validateForm的增加,可以放在一个事件的方法里
2.去掉validateForm中的address对某一对属性的控制,
正常情况下,是可以知道在当前删除的是 street 和 country在address这个数组中的下标,从而可以快速准确的删除,同样,删除也是可以有两种方式:
a.使用变量addressFormArray,具体如下:
this.addressFormArray.removAt(需要删除元素的下标)
b.直接对validateForm进行操作,具体如下:
(this.validateForm.controls['address'] as FormArray).removeAt(需要删除的数组的下标)
3.在模版文件中如何显示
<form [FormGroup]="validateForm">
<div>
...
<!-- 关于直接在FormGroup的部分省略 -->
</div>
<div FormArrayName="adderss">
<div *ngFor="let address of validateForm.controls['address'].controls;let i = index">
<div [formGroupName]="i">
<div>
在这里就可以自己定义address 中FormGroup的内容了,增加关于FormGroup的控件。
</div>
</div>
</div>
</div>
Angular6学习笔记13:HTTP(3)
HTTP
继学习笔记12以后,可以模拟向后端发送get/post/put/delete请求了。在项目中,有一个table,这个table的数据非常多,就好比现在的heroList,需要根据用户输入的信息发送给远端服务器,让远端服务器通过这个信息,返回搜索结果。现在要检索heroList中的信息,就需要一个输入框,让用户输入检索的值,然后将这个值发送给远端服务器(模拟),然后让远端服务器(模拟)返回检索的结果。
1.在heroService中创建一个关于搜索的方法:searchHeroes()
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
return of([]);
}
return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
在这个方法中,当没有搜素词,则返回一个空的数组,当有搜索词的时候,在url中拼接上name
2.在仪表盘的组件中添加搜索功能
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4"
routerLink="/detail/{{hero.id}}">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
<app-hero-search></app-hero-search>
这里会让这个应用挂了,因为找不到<app-hero-search></app-hero-search>(接下来创建)
3.创建HeroSearchComponent
利用angular CLI 创建组件
ng generate component hero-search
CLI 生成了 HeroSearchComponent 的三个文件,并把该组件添加到了 AppModule 的声明中。
修改HeroSearch组件的模版文件:
<div id="search-component">
<h4>Hero Search</h4>
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
<ul class="search-result">
<li *ngFor="let hero of heroes$ | async" >
<a routerLink="/detail/{{hero.id}}">
{{hero.name}}
</a>
</li>
</ul>
</div>
在模版文件中,创建了keyup 事件,这个keyup事件绑定会调用该组件的 search() 方法,并传入新的搜索框的值。
*ngFor 是在一个名叫 heroes$ 的列表上迭代,在这里$ 是一个命名惯例,用来表明 heroes$ 是一个 Observable,而不是数组。
正常情况下,*ngFor是不能直接使用Observable,此时,就要用到管道,利用管道字符(|),后面紧跟着一个 async,它表示 Angular 的 AsyncPipe,AsyncPipe 会自动订阅到 Observable,这样你就不用再在组件类中订阅了。
美化这个HeroSearch组件,修改heroSearch的CSS文件
.search-result li {
border-bottom: 1px solid gray;
border-left: 1px solid gray;
border-right: 1px solid gray;
width:195px;
height: 16px;
padding: 5px;
background-color: white;
cursor: pointer;
list-style-type: none;
}
.search-result li:hover {
background-color: #607D8B;
}
.search-result li a {
color: #888;
display: block;
text-decoration: none;
}
.search-result li a:hover {
color: white;
}
.search-result li a:active {
color: white;
}
#search-box {
width: 200px;
height: 20px;
}
ul.search-result {
margin-top: 0;
padding-left: 0;
}
修改HeroSearch 的类文件
import { Component, OnInit } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import {
debounceTime, distinctUntilChanged, switchMap
} from 'rxjs/operators';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-search',
templateUrl: './hero-search.component.html',
styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
heroes$: Observable<Hero[]>;
private searchTerms = new Subject<string>();
constructor(private heroService: HeroService) {}
// Push a search term into the observable stream.
search(term: string): void {
this.searchTerms.next(term);
}
ngOnInit(): void {
this.heroes$ = this.searchTerms.pipe(
// wait 300ms after each keystroke before considering the term
debounceTime(300),
// ignore new term if same as previous term
distinctUntilChanged(),
// switch to new search observable each time the term changes
switchMap((term: string) => this.heroService.searchHeroes(term)),
);
}
}
注意,heroes$ 声明为一个 Observable
a.RxJS Subject 类型的 searchTerms
Subject 既是可观察对象的数据源,本身也是 Observable。可以像订阅任何 Observable 一样订阅 Subject。还可以通过调用它的 next(value) 方法往 Observable 中推送一些值,就像 search() 方法中一样。search() 是通过对文本框的 keystroke 事件的事件绑定来调用的。
private searchTerms = new Subject<string>();
search(term: string): void {
this.searchTerms.next(term);
}
每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。 searchTerms 变成了一个能发出搜索词的稳定的流。
b.串联 RxJS 操作符
每当用户击键后就直接调用 searchHeroes() 将导致创建海量的 HTTP 请求,浪费服务器资源并消耗大量网络流量。
应该怎么做呢?ngOnInit() 往 searchTerms 这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchHeroes() 的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 Hero[] )。
this.heroes$ = this.searchTerms.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term: string) => this.heroService.searchHeroes(term)),
);
-
在传出最终字符串之前,
debounceTime(300)将会等待,直到新增字符串的事件暂停了 300 毫秒。 你实际发起请求的间隔永远不会小于 300ms。 -
distinctUntilChanged()会确保只在过滤条件变化时才发送请求。 -
switchMap()会为每个从debounce和distinctUntilChanged中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。
借助 switchMap 操作符, 每个有效的击键事件都会触发一次 HttpClient.get() 方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。
switchMap() 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。
注意,取消前一个 searchHeroes() 可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。