javascript - 利用思否猫素材实现一个丝滑的轮播图(html + css + js)_个人文章 - SegmentFault 思否
使用思否猫素材实现一个轮播图本文参与了1024程序员节,欢迎正在阅读的你也加入。通过本文,你将学到:htmlcssjs没错,就是html,css,js,现在是框架盛行的时代,所以很少会有人在意原生三件
顺晟科技
2022-10-19 13:56:16
137
本文参与了1024程序员节,欢迎正在阅读的你也加入。
通过本文,你将学到:
vue3的核心语法vue3的状态管理工具pinia的用法sass的用法基本算法canvas实现一个下雪的效果,一些canvas的基本用法rem布局typescript知识点在开始之前,我们先来看一下最终的成品是怎么样的,如下图所示:
首页如下:
游戏页如下:
如上图所示,我们本游戏包含了两部分,第一部分就是首页,第二部分则是游戏页面。然后首页我们又可以分成两个部分,第一部分则是下雪花的效果,第二部分就是一个背景图和按钮。游戏页面同理也是分成两个部分,第一个部分就是列表,第二个部分则是倒计时效果。
当然其实还有隐藏的第三部分,其实也就是一个弹框组件,因为游戏结束或者游戏赢了,我们要给予一个反馈,而这个反馈就是弹框组件。
所有页面分析完成,接下来让我们初始化一个vite工程项目。
首先在电脑上任意一个目录按住shift + 鼠标右键,选择打开powershell,也就是终端。然后输入如下命令:
npm create vite <项目名> --template vue-ts
然后一路回车,初始化完成工程,初始化完成之后,输入npm install,下载依赖,下载完依赖,由于我们使用到了sass,所以需要额外输入npm install sass --save-dev来安装sass依赖。当然由于我们可能会写tsx,所以我们也安装@vitejs/plugin-vue-jsx,还有就是我们设置导入路径的别名,需要用到node的path模块,所以也需要额外安装@types/node依赖。
笔记: 初始化工程都是照着官网文档来的。
所有依赖安装完成之后,我们修改一下vite.config.ts的配置,如下:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), vueJsx()],
base: "./", //打包路径配置
esbuild: {
jsxFactory: "h",
jsxFragment: "Fragment",
}, //tsx相关配置
server: {
port: 30001,
},//修改端口
resolve: {
alias: [
{
find: "@",
replacement: path.resolve(__dirname, "src"),
},
{
find: "~",
replacement: path.resolve(__dirname, "src/assets/"),
},
],
}, //配置@和~导入别名
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/style/variable.scss";`, //顾名思义,这里是一个定义变量scss文件,变量应该是作用于全局的,所以在这里全局导入
},
},
} //新增的导入全局scss文件的配置
})
以上代码注释所解释的都是新增的配置,vite默认的配置就只有一个plugins:[vue()]
。
修改完成配置之后,接下来我们来修改目录(主要是修改src目录)以及文件,修改后的目录应该如下所示:
// assets: 存储静态资源的目录
// components: 公共组件目录
// core: 游戏核心逻辑目录
// directives: 指令目录
// store: 状态管理目录
// style: 样式目录
// utils: 工具函数目录
// views: 页面视图目录
思考一下,我们这里需要用到vue-router吗?最开始我也是在思考,但是后面想了一下,这个页面很简单,暂时可以不需要,可是当我们后面进行扩展就需要了,比如自定义关卡和难度配置页面。
ok,调整好了,让我们继续下一步。
由于本游戏我们会将游戏参数抽离出来,并且用到了typescript,所以我们可以额外的创建一个type.d.ts文件,用于定义全局的接口类型。并且vite工程已经帮我们做好了默认导入全局接口类型,所以我们不需要做额外的配置,在src目录下,新建type.d.ts文件,然后写上如下接口:
enum Status {
START,
RUNNING,
ENDING
}
declare namespace GlobalModule {
export type LevelType = number | string;
export type ElementType = HTMLElement | Document | Window | null | Element;
export interface SnowOptionType {
snowNumber?: number;
snowShape?: number;
speed?: number;
}
export interface GameConfigType {
materialList:Record<string,string> [],
time: number,
gameStatus: Status
}
export interface MaterialData {
active: boolean
src: string
title?: string
id: string
isMatch: boolean
}
export type DocumentHandler = <T extends MouseEvent|Event>(mouseup:T,mousedown:T) => void;
export type FlushList = Map<HTMLElement,{ DocumentHandler:DocumentHandler,bindingFn:(...args:unknown[]) => unknown }>
}
以上代码我们定义了一个全局命名空间GlobalModule,定义了一个枚举Status代表游戏的状态。然后我们来看命名空间里面所有的接口类型代表什么。
LevelType: 数值或者字符串类型,这里是用作h1 ~ h6标签名的组成的类型,也就是说我们在后面将会封装一个Head组件,代表标题组件,组件会用到动态的标签名,也就是这里的1 ~ 6属性,它可以是字符串或者数值,所以定义在这里。ElementType: 顾名思义,就是定义元素的类型,这在实现下雪花以及获取Dom元素当中用到。SnowOptionType: 下雪花效果配置对象的类型,包含三个参数值,雪花数量,雪花形状以及雪花速度,都是数值类型。GameConfigType: 游戏配置类型,materialList代表素材列表类型,是一个对象数组,因此定义成Record<string,string> []
,time代表倒计时时间,gameStatus代表游戏状态。MaterialData: 素材列表对象类型。DocumentHandler: 文档对象回调函数类型,是一个函数,这在实现自定义指令中会用到。FlushList: 用map数据结构存储元素节点的事件回调函数类型,也是用在实现自定义指令当中。在store目录下新建store.ts,写下如下代码:
import { defineStore } from 'pinia'
import { defaultConfig } from '../core/gameConfig'
export const useConfigStore = defineStore('config',{
state:() => ({
gameConfig:{ ...defaultConfig }
}),
actions:{
setGameConfig(config:GlobalModule.GameConfigType) {
this.gameConfig = config;
},
reset(){
this.$reset();
}
}
})
代码逻辑很简单,就是定义一个游戏配置的状态,以及修改游戏配置状态的action函数,这里有点意思的就是reset函数,this.$reset是哪里来的?可能会有人有疑问。
答案当然是pinia,因为pinia内部封装了一个重置状态的函数,我们可以直接拿来用就是啦。
随后,我们在main.ts文件里面,注入pinia。修改代码如下:
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
//新增的样式初始化文件
import "./style/reset.scss"
//新增的代码,调用createPinia函数
const pinia = createPinia()
//修改的代码
createApp(App).use(pinia).mount('#app')
还有一个defaultConfig,也就是游戏默认配置,也非常简单,在core目录下,新建一个gameConfig.ts文件,添加如下代码:
// 素材列表是可以随意更换的
export const BASE_IMAGE_URL = "https://www.eveningwater.com/my-web-projects/js/26/img/";
export const materialList: Record<string,string> [] = new Array(12).fill(null).map((item,index) => ({ title:`图片-${index + 1}`,src:`${BASE_IMAGE_URL + (index + 1)}.jpg`}));
export const defaultConfig: GlobalModule.GameConfigType = {
materialList,
time: 120,
gameStatus: 0
}
这里面其实就做了两件事,第一件事当然是导出素材列表,第二件事就是导出游戏默认配置啦。
让我们继续,接下来,先初始化一些scss样式变量和初始化样式,在style目录下新建reset.scss和variable.scss文件。
varaible.scss代码如下:$prefix: bm-;
$white: #fff;
$black: #000;
@mixin setProperty($prop,$value){
#{$prop}:$value;
}
.flex-center {
@include setProperty(display,flex);
@include setProperty(justify-content,center);
@include setProperty(align-items,center);
}
这个文件干了什么?
定义了一个class命名前缀bm-,用$prefix变量代表,接着定义了白色和黑色的变量。随后又定义了一个mixin setProperty。
纵观css无非就是属性名和属性值,所以我定义一个mixin传入两个参数,就是分别代表动态设置属性名和属性值。
PS: 这里纯属添加了个人的爱好在里面,因为我喜欢这么写scss。
至于用法,我想在flex-center里面已经体现出来了。就是@include setProperty(属性名,属性值)。
reset.scss* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body,html {
width: percentage(1);
height: percentage(1);
overflow: hidden;
background: url("~/header_bg.jpg") no-repeat center / cover;
}
.#{$prefix}clearfix::after {
@include setProperty(content,'');
@include setProperty(display,table);
@include setProperty(clear,both);
}
ul,li {
@include setProperty(list-style,none);
}
.app {
@include setProperty(position,absolute);
@include setProperty(width,percentage(1));
@include setProperty(height,percentage(1));
}
初始化样式的代码也很好理解,首先是通配选择器*,将所有的外间距和内间距初始化为0,并且设置body和html的宽高,截断溢出内容,并设置背景。加了一个.bm-clearfix用于清除浮动的样式,因为后面会涉及到这个类名的使用,接着是重置ul,li的列表富豪,以及设置类名为app元素的样式。
基本样式初始化完成,接下来,我们就来实现一下页面当中会用到的工具函数。
在utils目录下新建一个util.ts,首先在指令当中会用到的就是一个isServer,用来判断是否是服务端环境,也比较好理解,直接判断window对象是否存在即可。代码如下:
export const isServer = typeof window === "undefined";
接下来,简单封装一个on方法,用来给元素添加事件,on方法接受4个参数,第一个参数为添加事件的元素,类型就是ElementType,第二个参数为事件类型,是一个字符串,比如‘click’,第三个参数是事件回调函数,类型为EventListenerOrEventListenerObject,这个类型是DOM内置定义好的事件回调函数类型,第四个参数也就是一个配置,是一个布尔值,代表事件是冒泡还是捕获阶段。
这个代码,其实我们就是利用addEventListener方法来简单的封装一下,所以最终代码如下:
export function on(
element: GlobalModule.ElementType,
type: string,
handler: EventListenerOrEventListenerObject,
useCapture: boolean = false
) {
if (element && type && handler) {
element.addEventListener(type, handler, useCapture);
}
}
相应的,我们也有off方法,其实就是将addEventListener缓存removeEventListener方法即可,但在本项目当中似乎并没有用到,所以不必封装。
接下来是第三个工具方法,叫做isDom,顾名思义,就是判断一个元素是否是一个DOM元素。思考一下,我们如何判断一个元素是否是DOM元素呢?
或者我们可以这么想,DOM元素都有哪些特点?
首先第一点,当HTMLElement对象存在时,那么DOM对象节点一定是该对象的一个子实例,因此我们有:
if(typeof HTMLElement === 'object'){
return el instanceof HTMLElement;
}
其次,如果HTMLElement不是一个对象,那我们可以判断el instanceof HTMLCollection。
最后一种判断方法,那就是判断el是否是一个对象,并且存在nodeType和nodeName属性,其中nodeType = 1代表是一个DOM元素节点,具体可以查看文档知晓这个属性的值分别代表什么。
综上所述,isDom方法就呼之欲出了,如下:
export function isDom(el: any): boolean {
return typeof HTMLElement === 'object' ?
el instanceof HTMLElement :
el && typeof el === 'object' && el.nodeType === 1 && typeof el.nodeName === 'string'
|| el instanceof HTMLCollection;
}
接下来的这个工具方法不需细讲,就是一个创建uuid的工具函数,代码如下:
export const createUUID = (): string => (Math.random() * 10000000).toString(16).substr(0, 4) + '-' + (new Date()).getTime() + '-' + Math.random().toString().substr(2, 5);
接下来的一个工具方法可是重中之重,也就是倒计时工具函数,让我们来思考一下,我们主要要返回一个状态出去,也就是倒计时的值,即一个数值,倒计时会有一个起始值,也会有一个结束值,并且还有一个步长,以及执行时间。
如何实现一个倒计时?这里很显然就要用到定时器啦,不过我这里采用的是另一种方式,也就是延迟函数+递归来实现。一共有5个参数,所以我们的函数结构如下:
export const CountDown = (start:number,end:number,step:number = 1,duration:number = 2000,callback:(args: { status:string,value:number,clear:() => void } ) => any) => {
//核心逻辑
}
这个函数的参数比较长,一共有5个参数,主要在第5个参数上,它是一个函数,参数是3个{ status:'running',value:1,clear:() => {}}
,其中status代表当前是什么状态,value就是倒计时的数值,clear是一个函数,用来清空定时器,并阻止递归。
接下来第一步,定义3个变量,分别代表定时器,当前倒计时数值以及步长,如下:
let timer: ReturnType<typeof setTimeout>,
current = start + 1,
step = (end - start) * step < 0 ? -step : step;
紧接着定义一个需要执行递归的函数,并调用它,然后返回一个clear方法,如下:
const handler = () => {
//核心代码
}
handler();
return {
clear:() => clearTimeout(timer);
}
在递归函数handler中,我们通过current与步长相加得到了倒计时值,随后我们回调状态以及值出去,最后判断当满足了递归条件,就阻止递归并清除定时器,然后将结束状态以及倒计时值回调出去,否则就是延迟递归执行handler函数。如下:
current += _step;
callback({
status:"running",
value: current,
clear:() => {
//这里需要注意,必须要修改current为最终状态值,才能清除定时器并停止递归
if(end - start > 0){
current = end - 1;
}else{
current = end + 1;
}
clearTimeout(timer);
}
});
//这里就是递归终止条件
const isOver = end - start > 0 ? current >= end - 1 : current <= end + 1;
if(isOver){
clearTimeout(timer);
callback({
status:"running",
value: current,
clear:() => {
//这里需要注意,必须要修改current为最终状态值,才能清除定时器并停止递归
if(end - start > 0){
current = end - 1;
}else{
current = end + 1;
}
clearTimeout(timer);
}
});
}else{
timer = setTimeout(handler,Math.abs(duration));
}
合并以上代码就成了我们最终的倒计时函数,如下:
export const CountDown = (start: number,
end: number,
step: number = 1,
duration: number = 2000,
callback: (args: { status: string, value: number, clear: () => void }) => any): { clear: () => void } => {
let timer: ReturnType<typeof setTimeout>,
current = start + 1,
_step = (end - start) * step < 0 ? -step : step;
const handler = () => {
current += _step;
callback({
status: "running",
value: current,
clear: () => {
// 需要修改值
if (end - start > 0) {
current = end - 1;
} else {
current = end + 1;
}
clearTimeout(timer);
}
});
const isOver = end - start > 0 ? current >= end - 1 : current <= end + 1;
if (isOver) {
clearTimeout(timer);
callback({
status: "end",
value: current,
clear: () => {
// 需要修改值
if (end - start > 0) {
current = end - 1;
} else {
current = end + 1;
}
clearTimeout(timer);
}
})
} else {
timer = setTimeout(handler, Math.abs(duration));
}
}
handler();
return {
clear: () => clearTimeout(timer)
}
}
在utils下新建snow.ts,然后我们思考一下,如何实现下雪花的效果?
我们可以知道下雪花分成两部分下雪花和雪花,在这里,我们需要用到canvas相关语法,我们把下雪花叫做SnowMove,雪花叫做Snow,如此一来,我们就可以定义好两个类,代码如下:
class Snow {
//雪花类核心代码
}
class SnowMove {
//下雪花类核心代码
}
现在,我们先来实现雪花类,首先我们要知道要实现雪花,就需要添加一个canvas标签,在这里我们选择的是动态添加canvas标签,所以雪花类构造函数中应当有2个参数,第一个就是canvas元素添加的容器元素,另一个就是雪花配置对象。因此,我们继续添加如下代码:
class Snow {
constructor(element:GlobalModule.ElementType,option?:GlobalModule.SnowOptionType){
//初始化代码
}
}
注意2个参数的类型,还有第2个参数是可选的,这样我们就可以定义一个默认配置对象,如果没有传option,就采用默认配置对象,接下来我们要在构造函数里面做什么?那当然是要初始化一些属性,定义一些公共属性来存储容器元素和配置对象。
class Snow {
public el: GlobalModule.ElementType;
public snowOption: GlobalModule.SnowOptionType;
public defaultSnowOption: Required<GlobalModule.SnowOptionType> = {
snowNumber: 200,
snowShape: 5,
speed: 1
};
public snowCan: HTMLCanvasElement | null;
public snowCtx: CanvasRenderingContext2D | null;
public snowArr: SnowMove [];
constructor(element:GlobalModule.ElementType,option?:GlobalModule.SnowOptionType){
this.el = element;
this.snowOption = option || this.defaultSnowOption;
this.snowCan = null;
this.snowCtx = null;
this.snowArr = [];
}
}
以上代码虽然稍微有点长,但事实上很好理解,我们就是在类的this对象上绑定了一些属性,比如容器元素,还有初始化canvas元素和元素上下文对象,可能不好理解的是这里有一个snowArr属性,它代表存储的每一个雪花移动的类的数组。
初始化属性完成,接下来创建一个init方法,用来初始化雪花的一些方法,在init方法中,我们调用了3个方法。
createCanvas: 顾名思义,就是创建canvas元素的方法。createSnowShape: 这是一个创建雪花形状的方法。drawSnow: 画雪花的方法。代码如下:
class Snow {
//省略了部分代码
init(){
this.createCanvas();
this.createSnowShape();
this.drawSnow();
}
}
让我们先来看第一个方法,createCanvas方法的实现,我们知道动态创建一个元素,其实也就是使用document.createElement方法,创建canvas元素之后,我们需要额外设置一点样式让canvas填充满整个容器元素,为了方便获取canvas元素,我们给它添加一个id,随后我们需要设置canvas元素的宽度和高度,最后我们将canvas元素添加到容器元素中去。
但是我们需要知道,在这里屏幕可能会发生变动,发生了变动之后,我们的canvas元素应该也会变动,所以我们还需要监听resize事件,用来修改元素的宽高。
让我们来看一下实现的代码吧:
import { isDom,on } from './util'
class Snow {
//省略了代码
createCanvas(){
//创建一个canvas元素
this.snowCan = document.createElement('canvas');
// 设置上下文
this.snowCtx = this.snowCan.getContext('2d');
// 设置id属性
this.snowCan.id = "snowCanvas";
// canvas元素设置样式
this.snowCan.style.cssText += "position:absolute;left:0;top:0;z-index:1;";
//设置canvas元素宽度和高度
this.snowCan.width = isDom(this.el) ? (this.el as HTMLElement).offsetWidth : document.documentElement.clientWidth;
this.snowCan.height = isDom(this.el) ? (this.el as HTMLElement).offsetHeight : document.documentElement.clientHeight;
// 监听resize事件
on(window,'resize',() => {
(this.snowCan as HTMLElement).width = document.documentElement.clientWidth;
(this.snowCan as HTMLElement).height = document.documentElement.clientHeight;
});
//最后一步,将canvas元素添加到页面中去
if(isDom(this.el)){
(this.el as HTMLElement).appendChild(this.snowCan);
}else{
document.body.appendChild(this.snowCan);
}
}
//省略了代码
}
createCanvas到此为止了,接下来我们来看下一个方法,也就是createSnowShape方法。这个方法其实也很简单,主要是根据参数创建一个雪花移动的数组并存储起来。如下:
class Snow {
//省略了代码
createSnowShape(){
const maxNumber = this.snowOption.snowNumber || this.defaultSnowption.snowNumber,
shape = this.snowOption.snowShape || this.defaultSnowption.snowShape,
{ width,height } = this.snowCan as HTMLCanvasElement,
snowArr: SnowMove [] = this.snowArr = [];
for(let i = 0;i < maxNumber;i++){
snowArr.push(
new SnowMove(width,height,shape,{ ...this.defaultSnowOption,...this.snowOption })
)
}
}
//省略了代码
}
显然这个方法就是把每一个雪花移动当作一个实例存储到数组中,这个雪花移动的类我们后面会说到,这里先不说。让我们来看下一个方法drawSnow。
其实通过这个方法我们也可以看到真正画雪花是在SnowMove类当中,这个类当中我们实现了render也就是渲染雪花的方法,以及update更新雪花的方法。所以在这个方法但这个方法当中,我们主要做的事情就是
22
2022-10
19
2022-10
17
2022-09
13
2022-09