整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

如何在Vue.js中创建模态弹窗

载说明:原创不易,未经授权,谢绝任何形式的转载

开篇

模态框(弹出层对话框)在大多数现代应用程序中非常常见。它们主要用于呈现简洁的信息,非常适合显示广告和促销内容。模态框提供了一种快速传达信息的方式,并提供了用户友好的关闭选项。

在本文中,我们将使用Vuejs构建一个弹出模态框。该模态框将包括一个取消或关闭按钮,以方便用户在完成任务后关闭它。此外,我们还将实现一个功能,允许用户在模态框区域外点击以关闭它。

模态弹出组件

<script setup lang="ts">
import { ref } from 'vue';

const message = ref('This is a modal popup');
const emit = defineEmits(['close']);

const closeModal = () => {
  emit('close');
};
</script>
<template>
  <div class="popup" @click.self="closeModal">
    <div class="popup-content">
      <div class="popup-header">
        <h2 class="popup-title">{{ message }}</h2>
        <button class="popup-close-button" @click.prevent="closeModal">X</button>
      </div>
      <article>
        <div class="popup-content-text">
          This is a simple modal popup in Vue.js
        </div>
      </article>
    </div>
  </div>
</template>

Script Section

<script setup lang="ts">
import { ref } from 'vue';
const message = ref('This is a modal popup');
const emit = defineEmits(['close']);
const closeModal = () => {
 emit('close');
};
</script>

在这个部分,我们从Vue中导入所需的功能。

  • ref 用于创建一个包含在模态框中显示的响应式变量消息。
  • emit用于定义一个名为“close”的事件,该事件可被触发以关闭模态框。
  • closeModal是一个函数,当调用时会触发“close”事件,从而有效地关闭模态框。

Template Section

<template>
   <div class="popup" @click.self="closeModal">
   <div class="popup-content">
   <div class="popup-header">
   <h2 class="popup-title">{{ message }}</h2>
   <button class="popup-close-button" @click.prevent="closeModal">X</button>
 </div>
 <article>
   <div class="popup-content-text">
   This is a simple modal popup in Vue.js
   </div>
 </article>
 </div>
 </div>
</template>

本段代码义了模板中模态框的结构。

  • 具有“popup”类的最外层div用作模态框的背景。
  • @click.self="closeModal"事件监听器附加到背景上,允许在其内容之外点击时关闭模态框。
  • 内容包括一个标题(popup-title)和一个关闭按钮(popup-close-button)。
  • 在标题下方,有一个文章部分,其中包含了模态框的主要内容。

渲染模态框组件

<script setup lang="ts">
import { ref } from 'vue'
import Popup from "@/components/Popup.vue"; // @ is an alias to /src

const msg = ref('Hello World!')
const isOpened = ref(false)

</script>
<template>
  <div>
    <h1>{{ msg }}</h1>
    <button @click="isOpened = !isOpened">Open Popup</button>
    <Teleport to="body">
      <Popup v-if="isOpened" @close="isOpened = !isOpened" />
    </Teleport>
  </div>
</template>

数据和状态管理:

代码使用Vue的ref函数创建了两个响应式变量:

  • - msg: 初始设置为“Hello World!”的文本消息被存储。
  • - isOpened: 这是一个布尔变量,初始值为false,表示弹出窗口是否打开或关闭。

按钮点击事件

模板中有一个带有点击事件监听器(@click)的<button>元素。当按钮被点击时,它会切换isOpened变量的值,从而有效地打开或关闭弹出窗口。

导入弹出框组件

  • 代码导入了一个弹出组件(Popup.vue)。
  • 在模板中,使用v-if条件渲染弹出窗口组件。只有当isOpened变量为true时(v-if="isOpened"),弹出窗口才会显示,表示弹出窗口应该是打开的。
  • <Teleport>用于将弹出窗口组件移动到HTML文档的元素中。这样可以确保弹出窗口在当前组件的DOM层次结构之外渲染,并且可以显示在页面上的其他内容之上。

组件之间的通信:

  • 当需要关闭弹出组件时,Popup组件会触发一个关闭事件@close。父组件使用@close事件监听器来监听此关闭事件。
  • 当Popup组件发出事件时,它切换isOpened变量,从而关闭弹出窗口。

您可以在CodeSandbox上使用本文中设计的代码进行操作。

https://codesandbox.io/s/suspicious-kepler-993dmh?file=%2Fsrc%2Fviews%2FHome.vue%3A0-420

结束

由于文章内容篇幅有限,今天的内容就分享到这里,文章结尾,我想提醒您,文章的创作不易,如果您喜欢我的分享,请别忘了点赞和转发,让更多有需要的人看到。同时,如果您想获取更多前端技术的知识,欢迎关注我,您的支持将是我分享最大的动力。我会持续输出更多内容,敬请期待。

作为 TOB 的业务方,我们偶尔会收到一些如下图所示的反馈。

作为 PC 页面为主的业务方,大多数用户在一天的工作中,可能都不太会刷新或者重新打开我们的页面,导致我们在下午或者白天发布的前端版本,往往需要到几个小时甚至第二天,才能覆盖到 98% 以上的用户。

我们统计了 bscm 平台 5 次下午 2-3 点左右发布的版本,在发布后每个时间段内老版本用户的占比情况。选择这个时间点发布的原因是这个时间点基本是平台用户的上班时间,是最有可能出现用户已经打开了页面同时我们在发布新代码的场景的,比较具有代表性。按平台用户六七点下班来看,我们可以看到还有将近 6% 的用户在当天是会一直访问老版本的前端代码的,按照 bscm 平台 1w+的 uv 来看,约有 600 多人会可能遇到前端版本过低导致的使用问题。

方案

弹窗内容

弹窗的触发条件

首先介绍两个概念,本地版本号和云端版本号。本地版本号是用户请求到的前端页面的代码版本号,是用户访问页面时决定;云端版本号可以理解为最新前端版本号,它是每次开发者发布前端代码时决定的。

判断触发条件的时机

有了弹窗的触发条件,我们还需要去决定什么时候判断弹窗是否满足触发的条件,上面也提到了,出现这类问题的场景多见于用户在使用过程中,开发者进行了前端代码发布,那我们主要可以有两个类型的时机去进行触发条件的判断。

  1. 前端代码去感知什么时候有新版本的代码发布了,去进行条件判断(消息推送)
  2. 前端在一定的条件下主动去判断触发条件(轮询,请求后端接口时,一些中频前端事件的监听)

我们对这些时机在更新是否及时,判断次数多少、实现成本高低等维度进行一个对比。

⭐️ 越多表示这个维度得分越高

根据表格可以看到 websocket 消息推送和前端事件监听这两种方案综合来看是更合适一些的,但是前端事件监听其实它的劣势在实际运用场景中会被弱化(一天的上线数量有限,请求次数一天不会多太多次),但是实现成本远低于 websocket,所以无疑是实际落地场景中比较理想的选择。

根据 can i use 的结果我们也可以发现 visibilitychange 事件也基本符合我们目前 B 端页面对于 PC 浏览器的要求。

版本号的生成

本地版本号

本地版本号是用户访问时决定的,那无疑页面的 html 文件就是这个版本号存在的最佳载体,我们可以在打包时通过 plugin 给 html 文件注入一个版本号。

云端版本号

云端版本号的选择则有很多方式了,数据库、cdn 等等都可以满足需求。不过考虑到实现成本和泳道的情况,想了一下两个思路一个是打包的同时生成一个 version.json 文件,配一个路由去访问;另一个是直接访问对应的 html 代码,解析出注入的版本号,二者各自有适合的场景。

微前端的适配

我们现在的大多数项目都包含了主应用和子应用,那其实不管是子应用的更新还是主应用的更新都应该有相关的提示,而且相互独立,但同时又需要保证弹窗不同时出现。

想要沿用之前的方案其实只需要解决三个问题。

  1. 主子应用的本地版本号标识需要有区分,因为 html 文件只有一个,需要能在 html 文件中区分出哪个应用的版本是什么,这个我们只需在 plugin 中注入标识即可解决。
  2. 云端版本号请求时也要请求对应的云端版本号,这个目前采用的方案是主应用去请求唯一的 version.json 文件,因为主应用路由是唯一的,子应用则去请求最新的 html 资源文件,解析出云端版本号。
  3. 不重复弹窗我们只需要在展示弹窗前,多加一个是否已经有弹窗展示的判断即可了。

具体实现

版本号的写入和读取

监听时机和频控逻辑

正如前文提到的,本身版本发布不是一个高频事件,但是监听事件的频次有时候可能过高了,不希望频繁的去进行触发条件判断。同时如果出现一天内多次发布的场景,也不希望这个弹窗对于用户有过多的打扰,所以需要去添加一个频控逻辑。

具体代码

plugin

/* eslint-disable */
import { CoraWebpackPlugin, WebpackCompiler } from '@ies/eden-web-build';
const fs = require('fs');
const path = require('path');
const cheerio = require('cheerio');

interface IVersion {
  name?: string; // 编译完的文件夹名称
  subName?: string; // 子应用的名称,主应用可以不传
}

export class VersionPlugin implements CoraWebpackPlugin {
  readonly name = 'versionPlugin'; // 插件必须要有一个名字,这个名字不能和已有插件冲突
  private _version: number;
  private _name: string;
  private _subName: string;
  constructor(params: IVersion) {
    this._version = new Date().getTime();
    this._name = params?.name || 'build';
    this._subName = params?.subName || ''
  }
  apply(compiler: WebpackCompiler): void {
    compiler.hooks.afterCompile.tap('versionPlugin', () => {
      try {
        const filePath = path.resolve(`./${this._name}/template/version.json`);
        fs.writeFile(filePath, JSON.stringify({ version: this._version }), (err: any) => {
          if (err) {
            console.log('@@@err', err);
          }
        });
        const htmlPath = path.resolve(`./${this._name}/template/index.html`);
        const data = fs.readFileSync(htmlPath);
        const $ = cheerio.load(data);
        $('body').append(`<div id="${this._subName}versionTag" style="display: none">${this._version}</div>`);
        fs.writeFile(htmlPath, $.html(), (err: any) => {
          if (err) {
            console.log('@@@htmlerr', err);
          }
        });
      } catch (err) {
        console.log(err);
      }
    });
  }
}

弹窗组件

import React, { useEffect } from 'react';

import { Modal } from '@ecom/auxo';
import axios from 'axios';
import moment from 'moment';

export interface IProps {
  isSub?: boolean; // 是否为子应用
  subName?: string; // 子应用名称
  resourceUrl?: string; // 子应用的资源url
}

export type IType = 'visibilitychange' | 'popstate' | 'init';

export default React.memo<IProps>(props => {
  const { isSub = false, subName = '', resourceUrl = '' } = props || {};

  const cb = (latestVersion: number | undefined, currentVersion: number | undefined, type: IType) => {
    try {
      // 版本落后,提示可以刷新页面
      if (latestVersion && currentVersion && latestVersion > currentVersion) {
        // 提醒过了就设置一个更新提示过期时间,一天内不需要再提示了,弹窗过期时间暂时全局只需要一个!!
        localStorage.setItem(`versionUpdateExpireTime`, moment().endOf('day').format('x'));
        if (!document.getElementById('versionModalTitle')) {
          Modal.confirm({
            title: <div id="versionModalTitle">版本更新提示</div>,
            content:
              '您已经长时间未使用此页面,在此期间平台有过更新,如您此时在页面中没有填写相关信息等操作,请点击刷新页面使用最新版本!',
            okText: <div data-text={`前端版本升级引导-立即更新 ${type}`}>刷新页面</div>,
            cancelText: <div data-text={`前端版本升级引导-我知道了 ${type}`}>我知道了</div>,
            onCancel: () => {
              console.log('fe-version-watcher INFO: 未更新~');
            },
            onOk: () => {
              location.reload();
            },
          });
        }
      }
      // 不管版本是否落后,半小时内都不需要去重新请求判断
      localStorage.setItem(`versionInfoExpireTime`, String(new Date().getTime() + 1000 * 60 * 30));
    } catch {}
  };

  const formatVersion = (text?: string) => (text ? Number(text) : undefined);

  useEffect(() => {
    try {
      const fn = function (type: IType) {
        if (document.visibilityState === 'visible') {
          /**
           * @desc 为了防止打扰,版本更新每个应用一天只提示一次 所以过期时间设为当天23:59:59,没过期则直接return
           */
          if (Number(localStorage.getItem(`versionUpdateExpireTime`) || 0) >= new Date().getTime()) {
            return;
          }
          /**
           * @desc 不需要每次切换页面都去判断资源,每次从服务器获取到的版本信息,给半个小时的缓存时间,需要区分子应用
           */
          if (Number(localStorage.getItem(`versionInfoExpireTime`) || 0) > new Date().getTime()) {
            return;
          }

          if (!isSub) {
            /**
             * @desc 主应用使用version.json文件来获取最新的版本号
             */
            const dom = document.getElementById('versionTag');
            const currentVersion = formatVersion(dom?.innerText);
            axios.get(`/version?timestamp=${new Date().getTime()}`).then(res => {
              const latestVersion = res?.data?.version;
              cb(latestVersion, currentVersion, type);
            });
          } else {
            /**
             * @desc 子应用使用最新html中的innerText来获取最新版本号
             */
            if (resourceUrl) {
              const dom = document.getElementById(`${subName}versionTag`);
              const currentVersion = dom?.innerText ? Number(dom?.innerText) : undefined;
              axios.get(resourceUrl).then(res => {
                /** ignore_security_alert */
                try {
                  const html = res.data;
                  const doc = new DOMParser().parseFromString(html, 'text/html');
                  const latestVersion = formatVersion(doc.getElementById(`${subName}versionTag`)?.innerText);
                  cb(latestVersion, currentVersion, type);
                } catch {}
              });
            }
          }
        }
      };
      const visibleFn = () => {
        fn('visibilitychange');
      };
      const routerFn = () => {
        fn('popstate');
      };
      if (isSub) {
        // 子应用可能会有缓存,初始化的时候先判断一次
        fn('init');
      }
      document.addEventListener('visibilitychange', visibleFn);
      window.addEventListener('popstate', routerFn);
      return () => {
        document.removeEventListener('visibilitychange', visibleFn);
        window.removeEventListener('popstate', routerFn);
      };
    } catch {}
  }, []);

  return <div />;
});

如何接入

主应用版本

  1. 安装依赖
npm i @ecom/fe-version-watcher-plugin # 安装plugin 
npm i @ecom/logistics-supply-chain-fe-version-watcher # 安装引导弹窗
  1. 引入 versionPlugin,自动生成 version.json + html 文件中自动注入
import { VersionPlugin } from '@ecom/fe-version-watcher-plugin';

// 有些项目打包后template文件夹下的名字不是build而是build_cn
// 可以根据自己项目的实际情况传入{name: build_cn}

{
    ...,
    plugins: [
        ...,
        [VersionPlugin, {}],
    ]
 }
  1. 引入版本引导弹窗
import { FeVersionWatcher } from '@ecom/logistics-supply-chain-fe-version-watcher';

<FeVersionWatcher />
  1. goofy 新增路由配置,/version 指向 version.json 文件 (或者其它方式可以使得/version 的路由指向该 version.json 文件)

预告

采用 version.json 的方案,引入 FersionWatcher 组件就不再需要任何参数,目前主应用只支持这种模式。未来也将参考子应用,主应用支持读取 html 中版本标识的能力,将配置路由的工作改成组件 props 传入资源 url,开发者可以根据实际情况自行选择。

子应用版本

  1. 安装依赖
npm i @ecom/fe-version-watcher-plugin # 安装plugin
npm i @ecom/logistics-supply-chain-fe-version-watcher # 安装引导弹窗
  1. 引入 versionPlugin, html 文件中自动注入版本号,需要子应用标识参数(必填)
import { VersionPlugin } from '@ecom/fe-version-watcher-plugin';

// 有些项目打包后template文件夹下的名字不是build而是build_cn
// 可以根据自己项目的实际情况传入{name: build_cn}

{
    ...,
    plugins: [
        ...,
        [VersionPlugin, {subName: 'general-supplier', name: 'build_cn'}],
    ]
 }
  1. 引入版本引导弹窗(subName 和 plugin 中保持一致,resourceUrl 为配置的子应用路由)
import { FeVersionWatcher } from '@ecom/logistics-supply-chain-fe-version-watcher';

// subName需要和plugin的参数保持一致,resourceUrl为子应用资源的路径(子引用goofy上配置的路由)
<FeVersionWatcher isSub subName="general-supplier" resourceUrl="/webApp/general-supplier" />

resourceUrl一般就是goofy上配置的路由设置,,如果不同平台有区分,可以动态传入。

如何调试/效果展示

发布成功后,可以根据如下步骤测试:

  1. 删除 localstorage 中相关的 value
  2. 修改 html 中的 version,改成一个比较小的数值即可
  3. 切换路由,或者隐藏/打开页面,出现弹窗

收益统计

同样我们截取了 4 次该平台 2-3 点发布的版本情况,可以看到老版本用户的 uv 占比有着明显的下降。

上线至今共计提示 10 万+用户,帮助约 5 万人次及时更新了前端代码。


作者:费昀锋

来源:微信公众号:字节前端 ByteFE

出处:https://mp.weixin.qq.com/s/PT0PZ3S1Cvh2nltcIKwa3g

生javascript实现带动画的提示型弹窗,常用于网站弹层的弹窗也有很多,一般用插件比较多,所以今天就来写一写该功能,如有错误之处请指出!

弹出跟消失都有放大缩小动画在里面!

实现方法:

html:

可以自己输入内容,再点击弹出即可看到弹窗效果

css:

javascript: