人生海海

人生海海 查看完整档案

广州编辑广东机电职业技术学院  |  软件 编辑人生海海  |  前端开发 编辑 gitee.com/chae/events 编辑
编辑

love you all!

个人动态

人生海海 收藏了文章 · 2月2日

实施微前端的六种方式

微前端架构是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用

由此带来的变化是,这些前端应用可以独立运行独立开发独立部署。以及,它们应该可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

注意:这里的前端应用指的是前后端分离的单应用页面,在这基础才谈论微前端才有意义。

结合我最近半年在微前端方面的实践和研究来看,微前端架构一般可以由以下几种方式进行:

  1. 使用 HTTP 服务器的路由来重定向多个应用
  2. 在不同的框架之上设计通讯、加载机制,诸如 MooaSingle-SPA
  3. 通过组合多个独立应用、组件来构建一个单体应用
  4. iFrame。使用 iFrame 及自定义消息传递机制
  5. 使用纯 Web Components 构建应用
  6. 结合 Web Components 构建

不同的方式适用于不同的使用场景,当然也可以组合一起使用。那么,就让我们来一一了解一下,为以后的架构演进做一些技术铺垫。

基础铺垫:应用分发路由 -> 路由分发应用

在一个单体前端、单体后端应用中,有一个典型的特征,即路由是由框架来分发的,框架将路由指定到对应的组件或者内部服务中。微服务在这个过程中做的事情是,将调用由函数调用变成了远程调用,诸如远程 HTTP 调用。而微前端呢,也是类似的,它是将应用内的组件调用变成了更细粒度的应用间组件调用,即原先我们只是将路由分发到应用的组件执行,现在则需要根据路由来找到对应的应用,再由应用分发到对应的组件上。

后端:函数调用 -> 远程调用

在大多数的 CRUD 类型的 Web 应用中,也都存在一些极为相似的模式,即:首页 -> 列表 -> 详情:

  • 首页,用于面向用户展示特定的数据或页面。这些数据通常是有限个数的,并且是多种模型的。
  • 列表,即数据模型的聚合,其典型特点是某一类数据的集合,可以看到尽可能多的数据概要(如 Google 只返回 100 页),典型见 Google、淘宝、京东的搜索结果页。
  • 详情,展示一个数据的尽可能多的内容。

如下是一个 Spring 框架,用于返回首页的示例:

@RequestMapping(value="/")
public ModelAndView homePage(){
   return new ModelAndView("/WEB-INF/jsp/index.jsp");
}

对于某个详情页面来说,它可能是这样的:

@RequestMapping(value="/detail/{detailId}")
public ModelAndView detail(HttpServletRequest request, ModelMap model){
   ....
   return new ModelAndView("/WEB-INF/jsp/detail.jsp", "detail", detail);
}

那么,在微服务的情况下,它则会变成这样子:

@RequestMapping("/name")
public String name(){
    String name = restTemplate.getForObject("http://account/name", String.class);
    return Name" + name;
}

而后端在这个过程中,多了一个服务发现的服务,来管理不同微服务的关系。

前端:组件调用 -> 应用调用

在形式上来说,单体前端框架的路由和单体后端应用,并没有太大的区别:依据不同的路由,来返回不同页面的模板。

const appRoutes: Routes = [
  { path: 'index', component: IndexComponent },
  { path: 'detail/:id', component: DetailComponent },
];

而当我们将之微服务化后,则可能变成应用 A 的路由:

const appRoutes: Routes = [
  { path: 'index', component: IndexComponent },
];

外加之应用 B 的路由:

const appRoutes: Routes = [
  { path: 'detail/:id', component: DetailComponent },
];

而问题的关键就在于:怎么将路由分发到这些不同的应用中去。与此同时,还要负责管理不同的前端应用。

路由分发式微前端

路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。

就当前而言,通过路由分发式的微前端架构应该是采用最多、最易采用的 “微前端” 方案。但是这种方式看上去更像是多个前端应用的聚合,即我们只是将这些不同的前端应用拼凑到一起,使他们看起来像是一个完整的整体。但是它们并不是,每次用户从 A 应用到 B 应用的时候,往往需要刷新一下页面。

在几年前的一个项目里,我们当时正在进行遗留系统重写。我们制定了一个迁移计划:

  1. 首先,使用静态网站生成动态生成首页
  2. 其次,使用 React 计划栈重构详情页
  3. 最后,替换搜索结果页

整个系统并不是一次性迁移过去,而是一步步往下进行。因此在完成不同的步骤时,我们就需要上线这个功能,于是就需要使用 Nginx 来进行路由分发。

如下是一个基于路由分发的 Nginx 配置示例:

http {
  server {
    listen       80;
    server_name  www.phodal.com;
    location /api/ {
      proxy_pass http://http://172.31.25.15:8000/api;
    }
    location /web/admin {
      proxy_pass http://172.31.25.29/web/admin;
    }
    location /web/notifications {
      proxy_pass http://172.31.25.27/web/notifications;
    }
    location / {
      proxy_pass /;
    }
  }
}

在这个示例里,不同的页面的请求被分发到不同的服务器上。

随后,我们在别的项目上也使用了类似的方式,其主要原因是:跨团队的协作。当团队达到一定规模的时候,我们不得不面对这个问题。除此,还有 Angluar 跳崖式升级的问题。于是,在这种情况下,用户前台使用 Angular 重写,后台继续使用 Angular.js 等保持再有的技术栈。在不同的场景下,都有一些相似的技术决策。

因此在这种情况下,它适用于以下场景:

  • 不同技术栈之间差异比较大,难以兼容、迁移、改造
  • 项目不想花费大量的时间在这个系统的改造上
  • 现有的系统在未来将会被取代
  • 系统功能已经很完善,基本不会有新需求

而在满足上面场景的情况下,如果为了更好的用户体验,还可以采用 iframe 的方式来解决。

使用 iFrame 创建容器

iFrame 作为一个非常古老的,人人都觉得普通的技术,却一直很管用。

HTML 内联框架元素<iframe> 表示嵌套的正在浏览的上下文,能有效地将另一个 HTML 页面嵌入到当前页面中。

iframe 可以创建一个全新的独立的宿主环境,这意味着我们的前端应用之间可以相互独立运行。采用 iframe 有几个重要的前提:

  • 网站不需要 SEO 支持
  • 拥有相应的应用管理机制

如果我们做的是一个应用平台,会在我们的系统中集成第三方系统,或者多个不同部门团队下的系统,显然这是一个不错的方案。一些典型的场景,如传统的 Desktop 应用迁移到 Web 应用:

Angular Tabs 示例

如果这一类应用过于复杂,那么它必然是要进行微服务化的拆分。因此,在采用 iframe 的时候,我们需要做这么两件事:

  • 设计管理应用机制
  • 设计应用通讯机制

加载机制。在什么情况下,我们会去加载、卸载这些应用;在这个过程中,采用怎样的动画过渡,让用户看起来更加自然。

通讯机制。直接在每个应用中创建 postMessage 事件并监听,并不是一个友好的事情。其本身对于应用的侵入性太强,因此通过 iframeEl.contentWindow 去获取 iFrame 元素的 Window 对象是一个更简化的做法。随后,就需要定义一套通讯规范:事件名采用什么格式、什么时候开始监听事件等等。

有兴趣的读者,可以看看笔者之前写的微前端框架:Mooa

不管怎样,iframe 对于我们今年的 KPI 怕是带不来一丝的好处,那么我们就去造个轮子吧。

自制框架兼容应用

不论是基于 Web Components 的 Angular,或者是 VirtualDOM 的 React 等,现有的前端框架都离不开基本的 HTML 元素 DOM。

那么,我们只需要:

  1. 在页面合适的地方引入或者创建 DOM
  2. 用户操作时,加载对应的应用(触发应用的启动),并能卸载应用。

第一个问题,创建 DOM 是一个容易解决的问题。而第二个问题,则一点儿不容易,特别是移除 DOM 和相应应用的监听。当我们拥有一个不同的技术栈时,我们就需要有针对性设计出一套这样的逻辑。

尽管 Single-SPA 已经拥有了大部分框架(如 React、Angular、Vue 等框架)的启动和卸载处理,但是它仍然不是适合于生产用途。当我基于 Single-SPA 为 Angular 框架设计一个微前端架构的应用时,我最后选择重写一个自己的框架,即 Mooa

虽然,这种方式的上手难度相对比较高,但是后期订制及可维护性比较方便。在不考虑每次加载应用带来的用户体验问题,其唯一存在的风险可能是:第三方库不兼容

但是,不论怎样,与 iFrame 相比,其在技术上更具有可吹牛逼性,更有看点。同样的,与 iframe 类似,我们仍然面对着一系列的不大不小的问题:

  • 需要设计一套管理应用的机制。
  • 对于流量大的 toC 应用来说,会在首次加载的时候,会多出大量的请求

而我们即又要拆分应用,又想 blabla……,我们还能怎么做?

组合式集成:将应用微件化

组合式集成,即通过软件工程的方式在构建前、构建时、构建后等步骤中,对应用进行一步的拆分,并重新组合。

从这种定义上来看,它可能算不上并不是一种微前端——它可以满足了微前端的三个要素,即:独立运行独立开发独立部署。但是,配合上前端框架的组件 Lazyload 功能——即在需要的时候,才加载对应的业务组件或应用,它看上去就是一个微前端应用。

与此同时,由于所有的依赖、Pollyfill 已经尽可能地在首次加载了,CSS 样式也不需要重复加载。

常见的方式有:

  • 独立构建组件和应用,生成 chunk 文件,构建后再归类生成的 chunk 文件。(这种方式更类似于微服务,但是成本更高)
  • 开发时独立开发组件或应用,集成时合并组件和应用,最后生成单体的应用。
  • 在运行时,加载应用的 Runtime,随后加载对应的应用代码和模板。

应用间的关系如下图所示(其忽略图中的 “前端微服务化”):

组合式集成对比

这种方式看上去相当的理想,即能满足多个团队并行开发,又能构建出适合的交付物。

但是,首先它有一个严重的限制:必须使用同一个框架。对于多数团队来说,这并不是问题。采用微服务的团队里,也不会因为微服务这一个前端,来使用不同的语言和技术来开发。当然了,如果要使用别的框架,也不是问题,我们只需要结合上一步中的自制框架兼容应用就可以满足我们的需求。

其次,采用这种方式还有一个限制,那就是:规范!规范!规范!。在采用这种方案时,我们需要:

  • 统一依赖。统一这些依赖的版本,引入新的依赖时都需要一一加入。
  • 规范应用的组件及路由。避免不同的应用之间,因为这些组件名称发生冲突。
  • 构建复杂。在有些方案里,我们需要修改构建系统,有些方案里则需要复杂的架构脚本。
  • 共享通用代码。这显然是一个要经常面对的问题。
  • 制定代码规范。

因此,这种方式看起来更像是一个软件工程问题。

现在,我们已经有了四种方案,每个方案都有自己的利弊。显然,结合起来会是一种更理想的做法。

考虑到现有及常用的技术的局限性问题,让我们再次将目光放得长远一些。

纯 Web Components 技术构建

在学习 Web Components 开发微前端架构的过程中,我尝试去写了我自己的 Web Components 框架:oan。在添加了一些基本的 Web 前端框架的功能之后,我发现这项技术特别适合于作为微前端的基石

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 Web 应用中使用它们。

它主要由四项技术组件:

  • Custom elements,允许开发者创建自定义的元素,诸如 <today-news></today-news>。
  • Shadow DOM,即影子 DOM,通常是将 Shadow DOM 附加到主文档 DOM 中,并可以控制其关联的功能。而这个 Shadow DOM 则是不能直接用其它主文档 DOM 来控制的。
  • HTML templates,即 <template><slot> 元素,用于编写不在页面中显示的标记模板。
  • HTML Imports,用于引入自定义组件。

每个组件由 link 标签引入:

<link rel="import" href="components/di-li.html">
<link rel="import" href="components/d-header.html">

随后,在各自的 HTML 文件里,创建相应的组件元素,编写相应的组件逻辑。一个典型的 Web Components 应用架构如下图所示:

Web Components 架构

可以看到这边方式与我们上面使用 iframe 的方式很相似,组件拥有自己独立的 ScriptsStyles,以及对应的用于单独部署组件的域名。然而它并没有想象中的那么美好,要直接使用 Web Components 来构建前端应用的难度有:

  • 重写现有的前端应用。是的,现在我们需要完成使用 Web Components 来完成整个系统的功能。
  • 上下游生态系统不完善。缺乏相应的一些第三方控件支持,这也是为什么 jQuery 相当流行的原因。
  • 系统架构复杂。当应用被拆分为一个又一个的组件时,组件间的通讯就成了一个特别大的麻烦。

Web Components 中的 ShadowDOM 更像是新一代的前端 DOM 容器。而遗憾的是并不是所有的浏览器,都可以完全支持 Web Components。

结合 Web Components 构建

Web Components 离现在的我们太远,可是结合 Web Components 来构建前端应用,则更是一种面向未来演进的架构。或者说在未来的时候,我们可以开始采用这种方式来构建我们的应用。好在,已经有框架在打造这种可能性。

就当前而言,有两种方式可以结合 Web Components 来构建微前端应用:

  • 使用 Web Components 构建独立于框架的组件,随后在对应的框架中引入这些组件
  • 在 Web Components 中引入现有的框架,类似于 iframe 的形式

前者是一种组件式的方式,或者则像是在迁移未来的 “遗留系统” 到未来的架构上。

在 Web Components 中集成现有框架

现有的 Web 框架已经有一些可以支持 Web Components 的形式,诸如 Angular 支持的 createCustomElement,就可以实现一个 Web Components 形式的组件:

platformBrowser()
    .bootstrapModuleFactory(MyPopupModuleNgFactory)
        .then(({injector}) => {
            const MyPopupElement = createCustomElement(MyPopup, {injector});
            customElements.define(‘my-popup’, MyPopupElement);
});

在未来,将有更多的框架可以使用类似这样的形式,集成到 Web Components 应用中。

集成在现有框架中的 Web Components

另外一种方式,则是类似于 Stencil 的形式,将组件直接构建成 Web Components 形式的组件,随后在对应的诸如,如 React 或者 Angular 中直接引用。

如下是一个在 React 中引用 Stencil 生成的 Web Components 的例子:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

import 'test-components/testcomponents';

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

在这种情况之下,我们就可以构建出独立于框架的组件。

同样的 Stencil 仍然也只是支持最近的一些浏览器,比如:Chrome、Safari、Firefox、Edge 和 IE11

复合型

复合型,对就是上面的几个类别中,随便挑几种组合到一起。

我就不废话了~~。

结论

那么,我们应该用哪种微前端方案呢?答案见下一篇《微前端快速选型指南》

相关资料:

查看原文

人生海海 赞了回答 · 2020-12-09

解决v-for 组件加载缓慢

确定是循环渲染 list 导致慢的话可以把这个渲染延后执行,比如——
初始化 list 的时候赋值为空数组:

data: () => ({
    list: [],
    originList: [],  // 这里放 list 本该赋予的值
})

然后在 mounted 阶段给 list 赋正确的值:

mounted() {
    // ...
    this.list = this.originList
}

关注 2 回答 1

人生海海 关注了问题 · 2020-12-09

解决v-for 组件加载缓慢

子组件因为内部需要运算加载比较慢 导致父组件需要等待子组件加载之后才加载
有无办让父组件包含的其他组件先加载?

<div v-for="item in list">
    <a />
    <b />
</div>

b加载缓慢 能否让a先加载?

关注 2 回答 1

人生海海 提出了问题 · 2020-12-09

解决v-for 组件加载缓慢

子组件因为内部需要运算加载比较慢 导致父组件需要等待子组件加载之后才加载
有无办让父组件包含的其他组件先加载?

<div v-for="item in list">
    <a />
    <b />
</div>

b加载缓慢 能否让a先加载?

关注 2 回答 1

人生海海 提出了问题 · 2020-10-15

svg 位移之后缩放位置偏离问题

用snapsvg画图 在使用translate位移之后 使用scale(scaleX, scaleY, x, y) 缩放时 会出现位置偏移
在未发生位移之前进行缩放没有发生位置偏移的情况

关注 1 回答 0

人生海海 赞了文章 · 2020-08-20

vue动态渲染svg、添加点击事件

欢迎关注[前端小讴的github],阅读更多原创技术文章

业务需求(vue项目中)

1.页面展示svg内容
2.监听svg内部的点击事件
3.动态改变svg内部元素的属性和值

html标签

经多次实验,用embed、img等标签改变src属性的方式,均无法实现上述全部功能(尤其是svg内部点击事件),最终采用Vue.extend()方法完整实现,代码也较为简洁,html结构如下:

<template>
  <div>
    <div id="svgTemplate"></div>
  </div>
</template>
直接将svg文件的内容复制粘贴到.vue文件里,是可以在标签内直接添加@click事件完成需求的,方式简单但会造成文件过长,本文不多陈述

实现思路

1.创建xhr对象

const xhr = new XMLHttpRequest();
this.svgUrl = ...; // svg的绝对地址,在浏览器中打开能看到的那个
xhr.open("GET", this.svgUrl, true);
xhr.send();

2.监听xhr对象(获取svg的dom -> 添加事件 -> 修改dom -> 转成虚拟dom并挂载)

xhr.addEventListener("load", () => {
    // ① 获取svg的dom
    const resXML = xhr.responseXML;
    this.svgDom = resXML.documentElement.cloneNode(true);     // console.log(this.svgDom);
    
    // ② 添加click事件
    let btn = this.svgDom.getElementById("...");
    btn.setAttribute("v-on:click", "this.handleClick()");
    // ↑↑↑ 此处注意:原生事件handleClick此时在window层,解决办法见后文

    // ③ 修改 dom
    this.svgDom.getElementById("...").childNodes[0].nodeValue = ...
    this.svgDom.getElementById("...").setAttribute("style",
          `....; fill:${this.photoResult.resultColor}; ...`);
    // ↑↑↑ 用js操作dom的语法,动态设置svg部件的属性和值
   
    // ④ 将svgDom对象转换成vue的虚拟dom,创建实例并挂载到元素上
    var oSerializer = new XMLSerializer();
    var sXML = oSerializer.serializeToString(this.svgDom);
    var Profile = Vue.extend({
        template: "<div id='svgTemplate'>" + sXML + "</div>"
    });
    new Profile().$mount("#svgTemplate");
});

3.将methods里要执行的事件绑定到window下面,供外部(刚添加的 handleClick 事件)调用

async mounted() {
    window["handleClick"] = () => {
       this.takePhoto();
    };
},
methods:{
    takePhoto(){ ... }
}

到这里就基本完成需求:动态渲染了svg、用js操作dom的语法修改svg部件的属性和值、给svg部件动态添加了事件 handleClick,最后将 takePhoto() 事件绑定给了 window 对象的 handleClick,可以放心大胆的在 takePhoto() 里写你要执行的内容了!

特殊注意

  • 给svg的dom部件添加事件时:
    1.经多次尝试,只有 setAttribute + v-on:click 写法有效
    2.setAttribute 不支持 @click(非原生事件),会报语法错误
    3.addEventListener 和 onclick 均会被 vue 拦截
  • 将svgDom对象转换成vue的虚拟dom时:
    1.如果报错如下

    则将 import Vue from "vue" 改为 import Vue from "vue/dist/vue.esm.js"
    其原因及其他解决办法本文不做探讨可自行百度。
    2.vue.extend() 方法是 vue 的一个构造器,用来动态创建 vue 实例,template 组件模板只能有一个根元素
    3.$mount 手动挂载到 id 为 svgTemplate的 元素上,挂载后将替换原本的dom(替换原本的 <div id="svgTemplate"></div>)。由于每次更新 svg 都要重新挂载,没有找到 dom 元素是无法挂载的,因此 template 里面最外层的 div 也要加上 id 的属性:

    var Profile = Vue.extend({
         template: "<div id='svgTemplate'>" + sXML + "</div>" 
         // ↑↑↑ 最外层的 id 不能省略,否则首次渲染后找不到 #svgTemplate
    });
    new Profile().$mount("#svgTemplate"); 
    // ↑↑↑ 原本的 #svgTemplate 将被替换成 Profile 的 template

完整代码

<template>
  <div>
    <div id="svgTemplate"></div>
  </div>
</template>
<script>
import Vue from "vue/dist/vue.esm.js";

// window.handleClick = () => {
   // 原本的 handleClick 事件是 window 的
// };

export default {
  name: "svg-drawing",
  data() {
    return {
      /* 全局 */
      svgUrl: "", // svg的url
      svgDom: null, // 获取到的svg元素
      /* svg的变量 */
      photoResult: {
        resultVal: 0, // 测试结果 - 值
        resultMsg: "未检测", // 测试结果 - 字段
        resultColor: "#dcdee2" // 测试结果 - 字段背景色
      }
    };
  },
  async mounted() {
    // 将takePhoto方法绑定到window下面,提供给外部调用
    window["handleClick"] = () => {
      this.takePhoto();
    };
  },
  created() {
    this.getSvg();
  },
  methods: {
    // 初始化svg
    getSvg() {
      /* 创建xhr对象 */
      const xhr = new XMLHttpRequest();
      this.svgUrl = this.baseUrl + "/svgs/" + "test.svg";
      xhr.open("GET", this.svgUrl, true);
      xhr.send();

      /* 监听xhr对象 */
      xhr.addEventListener("load", () => {
        /* 1. 获取 dom */
        const resXML = xhr.responseXML;
        this.svgDom = resXML.documentElement.cloneNode(true);

        /* 2.SVG对象添加click事件 */
        let btnTakePhotoDom = this.svgDom.getElementById("...");
        btnTakePhotoDom.setAttribute("v-on:click", "this.handleClick()");

        /* 3. 修改 dom */
        this.svgDom.getElementById("...").childNodes[0].nodeValue = ...;
        this.svgDom.getElementById("...").setAttribute("style",
          `....; fill:${this.photoResult.resultColor}; ...`);

        /* 4.将svgDom对象转换成vue的虚拟dom */
        var oSerializer = new XMLSerializer();
        var sXML = oSerializer.serializeToString(this.svgDom);
        var Profile = Vue.extend({
          template: "<div id='svgTemplate'>" + sXML + "</div>"
        });
        // 创建实例,并挂载到元素上
        new Profile().$mount("#svgTemplate");
      });
    },
    // 事件
    takePhoto() { ... },
  },
  beforeDestroy() {
    this.svgDom = null;
  },
  watch: {
    photoResult: {
      handler(newVal, oldVal) {
        this.getSvg();
      },
      deep: true
    }
  }
};
</script>
查看原文

赞 3 收藏 1 评论 14

人生海海 回答了问题 · 2020-08-13

解决canvas 鼠标移动绘制 如何保存上一个矩形

saveData() {
      const canvasMap = document.getElementById('canvasMap')
      var ctx = canvasMap.getContext('2d')
      this.drawingSurfaceImageData = ctx.getImageData(0, 0, 800, 500)
    },
    restoreData() {
      const canvasMap = document.getElementById('canvasMap')
      var ctx = canvasMap.getContext('2d')
      ctx.putImageData(this.drawingSurfaceImageData, 0, 0)
    },
    mousedown(e) {
      this.flag = true
      this.x = e.offsetX // 鼠标落下时的X
      this.y = e.offsetY // 鼠标落下时的Y
      this.saveData()
    },
    mouseup() {
      this.flag = false
    },
    mousemove(e) {
      if (this.flag) {
        this.restoreData()
        this.drawRect(e)
      }
    },
    drawRect(e) {
      const canvasMap = document.getElementById('canvasMap')
      var ctx = canvasMap.getContext('2d')
      const x = this.x
      const y = this.y
      ctx.save()
      ctx.beginPath()
      ctx.strokeStyle = '#00ff00'
      ctx.lineWidth = 1
      ctx.strokeRect(x, y, e.offsetX - x, e.offsetY - y)
      ctx.restore()
    }
  }

关注 1 回答 1

人生海海 提出了问题 · 2020-08-13

解决canvas 鼠标移动绘制 如何保存上一个矩形

 mousedown(e) {
      const canvasMap = document.getElementById('canvasMap')
      var ctx = canvasMap.getContext('2d')
      ctx.save()    // 保存之前的原始环境
      this.flag = true
      this.x = e.offsetX // 鼠标落下时的X
      this.y = e.offsetY // 鼠标落下时的Y
    },
    mouseup(e) {
      const canvasMap = document.getElementById('canvasMap')
      var ctx = canvasMap.getContext('2d')
      ctx.restore()    // 保存之前的原始环境

      this.flag = false
    },
    mousemove(e) {
      this.drawRect(e)
    },
    drawRect(e) {
      if (this.flag) {
        const canvasMap = document.getElementById('canvasMap')

        var ctx = canvasMap.getContext('2d')
        const x = this.x
        const y = this.y

        ctx.clearRect(0, 0, canvasMap.width, canvasMap.height)
        ctx.beginPath()

        // 设置线条颜色,必须放在绘制之前
        ctx.strokeStyle = '#00ff00'
        // 线宽设置,必须放在绘制之前
        ctx.lineWidth = 1

        ctx.strokeRect(x, y, e.offsetX - x, e.offsetY - y)
      }
    }

在鼠标移动的时候调用clearRect 清除了移动过程中绘制的矩形
但是这样每次绘制第二个矩形的时候都会清除了上一个矩形

关注 1 回答 1

人生海海 收藏了问题 · 2020-07-31

求在线画图工具实现思路?

现在需要做一个web端画图工具.
用来画一个拓扑图,可以在线编辑,监控我们公司服务器的拓扑关系, 服务器的健康度.
原来只是一个CRUD的前端搬砖工, 对Canvas了解很少, 相关的类库更少.
以我对canvas的了解, 原生canvas接口只有非常基础的接口.
我目前想到需要的功能,

  1. 拖拽, 碰撞检测, 比如我要把一个圆形放到一个矩形里去.
  2. canvas内元素的事件监听, 弹出相应的tooltip, 最好支持html. 比如我点了某一个服务器图标, 我需要弹出一个tooltip框,来显示这个服务器的详细信息和一些按钮, 用来做一些交互.

我想问有没有什么js库支持这些需求的?
谢谢各位大神.

人生海海 赞了回答 · 2020-07-31

求在线画图工具实现思路?

不用 canvas 也能搞,以前随手写的 DEMO

图片描述

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Work Flow Demo</title>
    <style type="text/css">
    .clear-fix:after {content:'\200B';display:block;height:0;clear:both;}
    .toolbar {font-size:24px;background-color:#F5F5F5;border:1px solid #CCC;}
    .toolbar .item {float:left;margin:0.5em 0.25em;width:1.5em;height:1.5em;line-height:1.5em;text-align:center;border:1px solid #CCC;background-color:#FFF;box-sizing:border-box;}
    .workspace {position:relative;height:500px;font-size:24px;border:1px solid #CCC;border-top:0 none transparent;background-color:#F5F5F5;
    }
    .workspace .item {position:absolute;width:1.5em;height:1.5em;line-height:1.5em;text-align:center;border:1px solid #CCC;background-color:#FFF;}
    .workspace .item:after {content:attr(data-id);display:block;font-size:12px;height:1em;line-height:1.2em;}

    .line {position:absolute;height:2px;
        background-image: -moz-linear-gradient(0deg, #FFF 25%, #000 25%, #000 50%, #FFF 50%, #FFF 75%, #000 75%, #000);background-size:40px 40px;box-shadow:0 0 2px 0 #000;
        -webkit-transform-origin:left center;
        -moz-transform-origin:left center;
        -ms-transform-origin:left center;
        -o-transform-origin:left center;
        transform-origin:left center;
        -webkit-transform: rotate(0deg);
        -moz-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        -o-transform: rotate(0deg);
        transform: rotate(0deg);
        -webkit-animation: flow 2s linear infinite;
        -moz-animation: flow 2s linear infinite;
        -o-animation: flow 2s linear infinite;
        animation: flow 2s linear infinite;
    }
    /*.line:after {position:absolute;content:'\200B';width:0;height:0;top:50%;right:0;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000;
        -webkit-transform: translateY(-50%);
        -moz-transform: translateY(-50%);
        -ms-transform: translateY(-50%);
        -o-transform: translateY(-50%);
        transform: translateY(-50%);;
    }/**/
    @keyframes flow {
        0% {background-position-x:0;}
        100% {background-position-x:39px;}
    }
    </style>
    <script>
    window.addEventListener('load', function(){

        var instanceId = 1;

        var domWorkspace = document.querySelector('.workspace');

        function line_by_id($itemId)
        {
            return {
                from : Array.prototype.slice.call(document.querySelectorAll('[data-id-from="'+$itemId+'"]'))
                ,to  : Array.prototype.slice.call(document.querySelectorAll('[data-id-to="'+$itemId+'"]'))
            };
        }
        function line_get($fromId, $toId)
        {
            var selector = '[data-id-from="'+$fromId+'"][data-id-to="'+$toId+'"]';
            var dom = document.querySelector(selector);
            if(dom instanceof HTMLElement == false)
            {
                dom = document.createElement('DIV');
                dom.className = 'line';
                dom.setAttribute('data-id-from', $fromId);
                dom.setAttribute('data-id-to', $toId);
                domWorkspace.appendChild(dom);
            }

            return dom;
        }
        function line_update($domLine, $domBase)
        {
            var fromId = $domLine.getAttribute('data-id-from');
            var toId   = $domLine.getAttribute('data-id-to');

            var domFrom = $domBase.querySelector('.item[data-id="'+fromId+'"]');
            var domTo   = $domBase.querySelector('.item[data-id="'+toId+'"]');

            var rectBase = domWorkspace.getBoundingClientRect();
            var rectFrom = domFrom.getBoundingClientRect();
            var rectTo   = domTo.getBoundingClientRect();

            var sx, sy, ex, ey;
            if(rectFrom.right < rectTo.left)
            {
                sx = rectFrom.right - rectBase.left;
                sy = rectFrom.top + Math.floor(rectFrom.height/2) - rectBase.top;
                ex = rectTo.left - rectBase.left;
                ey = rectTo.top + Math.floor(rectTo.height/2) - rectBase.top;
            }
            else if(rectFrom.left > rectTo.right)
            {
                sx = rectFrom.left - rectBase.left;
                sy = rectFrom.top + Math.floor(rectFrom.height/2) - rectBase.top;
                ex = rectTo.right - rectBase.left;
                ey = rectTo.top + Math.floor(rectTo.height/2) - rectBase.top;
            }
            else
            {
                sx = rectFrom.right - rectBase.left;
                sy = rectFrom.top + Math.floor(rectFrom.height/2) - rectBase.top;
                ex = rectTo.left - rectBase.left;
                ey = rectTo.top + Math.floor(rectTo.height/2) - rectBase.top;
            }

            var deg = Math.atan2((ey-sy),(ex-sx))*180/Math.PI;

            $domLine.style.top = sy + 'px';
            $domLine.style.left = sx + 'px';
            $domLine.style.width = Math.sqrt( Math.pow(ex-sx, 2) + Math.pow(ey-sy, 2) ) + 'px';
            $domLine.style.transform = 'rotate('+deg+'deg)';
        }

        var domDoc  = document.querySelector('.toolbar .item[data-type="doc"]');
        var domGear = document.querySelector('.toolbar .item[data-type="gear"]');

        domDoc.addEventListener('dragstart', function($evt){
            $evt.dataTransfer.effectAllowed = 'copy';
            $evt.dataTransfer.setData('text/plain', 'doc');

            var json = {mx:$evt.offsetX||$evt.layerX,my:$evt.offsetY||$evt.layerY};
            $evt.dataTransfer.setData('text/json', JSON.stringify(json));
        }, false);
        domGear.addEventListener('dragstart', function($evt){
            $evt.dataTransfer.effectAllowed = 'copy';
            $evt.dataTransfer.setData('text/plain', 'gear');

            var json = {mx:$evt.offsetX||$evt.layerX,my:$evt.offsetY||$evt.layerY};
            $evt.dataTransfer.setData('text/json', JSON.stringify(json));
        }, false);

        domWorkspace.addEventListener('dragenter', function($evt){
        }, false);
        domWorkspace.addEventListener('dragleave', function($evt){
        }, false);
        domWorkspace.addEventListener('dragover', function($evt){
            $evt.preventDefault();
        }, false);
        domWorkspace.addEventListener('drop', function($evt){
            $evt.preventDefault();

            var rect = domWorkspace.getBoundingClientRect();
            var type = $evt.dataTransfer.getData('text/plain');
            var json = JSON.parse($evt.dataTransfer.getData('text/json'));

            var dom;
            if($evt.dataTransfer.effectAllowed == 'copy')
            {
                switch(type)
                {
                    case 'doc':
                        dom = domDoc.cloneNode(true);
                        dom.setAttribute('data-id', (instanceId++).toString());
                        dom.style.top  = ($evt.pageY - rect.top - json.my) + 'px';
                        dom.style.left = ($evt.pageX - rect.left - json.mx) + 'px';
                        domWorkspace.appendChild(dom);

                        dom.addEventListener('dragstart', function($evt){
                            $evt.dataTransfer.effectAllowed = 'move';
                            $evt.dataTransfer.setData('text/plain', 'doc');

                            var json = {id:this.getAttribute('data-id'),mx:$evt.offsetX||$evt.layerX,my:$evt.offsetY||$evt.layerY};
                            $evt.dataTransfer.setData('text/json', JSON.stringify(json));
                        });
                        dom.addEventListener('drop', function($evt) {
                            $evt.preventDefault();

                            var json = JSON.parse($evt.dataTransfer.getData('text/json'));

                            var fromId = json.id;
                            var toId   = this.getAttribute('data-id');

                            if(fromId == toId)
                                return;

                            var domLine = line_get(fromId, toId);
                            line_update(domLine, domWorkspace);
                        });
                        break;
                    case 'gear':
                        dom = domGear.cloneNode(true);
                        dom.setAttribute('data-id', (instanceId++).toString());
                        dom.style.top  = ($evt.pageY - rect.top - json.my) + 'px';
                        dom.style.left = ($evt.pageX - rect.left - json.mx) + 'px';
                        domWorkspace.appendChild(dom);

                        dom.addEventListener('dragstart', function($evt){
                            $evt.dataTransfer.effectAllowed = 'move';
                            $evt.dataTransfer.setData('text/plain', 'gear');

                            var json = {id:this.getAttribute('data-id'),mx:$evt.offsetX||$evt.layerX,my:$evt.offsetY||$evt.layerY};
                            $evt.dataTransfer.setData('text/json', JSON.stringify(json));
                        });
                        dom.addEventListener('drop', function($evt) {
                            $evt.preventDefault();

                            var json = JSON.parse($evt.dataTransfer.getData('text/json'));

                            var fromId = json.id;
                            var toId   = this.getAttribute('data-id');

                            if(fromId == toId)
                                return;

                            var domLine = line_get(fromId, toId);
                            line_update(domLine, domWorkspace);
                        });
                        break;
                }
            }
            else if($evt.dataTransfer.effectAllowed == 'move')
            {
                if($evt.target.classList.contains('workspace') == true)
                {
                    var itemId = json.id;
                    dom = domWorkspace.querySelector('.item[data-id="'+itemId+'"]');
                    if(dom instanceof HTMLElement)
                    {
                        dom.style.top  = ($evt.pageY - rect.top - json.my) + 'px';
                        dom.style.left = ($evt.pageX - rect.left - json.mx) + 'px';

                        var i;
                        var map = line_by_id(itemId);
                        if(Array.isArray(map.from) == true)
                        {
                            for(i=0; i<map.from.length; i++)
                            {
                                line_update(map.from[i], domWorkspace);
                            }
                        }
                        if(Array.isArray(map.to) == true)
                        {
                            for(i=0; i<map.to.length; i++)
                            {
                                line_update(map.to[i], domWorkspace);
                            }
                        }
                    }
                }
            }
        }, false);
    });
    </script>
</head>
<body>
<div class="toolbar clear-fix">
    <div class="item" data-type="doc" draggable="true">&#128462;</div>
    <div class="item" data-type="gear" draggable="true">&#9881;</div>
</div>
<div class="workspace">

</div>
</body>
</html>

关注 3 回答 2

认证与成就

  • 获得 29 次点赞
  • 获得 13 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 11 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-05-18
个人主页被 929 人浏览