Code Monkey home page Code Monkey logo

blogs's Introduction

Hey there

❤️ Programming | 💚 Dota | 💙 Anime

Software Engineering Graduate 2015. My passion for software lies with dreaming up ideas and making them come true with elegant interfaces.

  • 🛠️ I am currently studying relevant knowledge in the field of visual construction.
  • 📚 I’m currently learning Gamified thinking | Web development | App Development | Cyber Security
  • ❓ Ask me about anything. I will try to help you as much as I can.
  • 🎤 Quote: "Front-end means to be at the front of the user."
  • 🚗 How to reach me:
zhihu logo github logo juejin logo gmail logo

Profile Trophies

trophy


What I love

Coding Dota Anime

blogs's People

Contributors

howtopick avatar muwoo avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blogs's Issues

将你的 Virtual dom 渲染成 Canvas

项目概述

一个基于Vue的virtual dom插件库,按照Vue render 函数的写法,直接将Vue生成的Vnode渲染到canvas中。支持常规的滚动操作和一些基础的元素事件绑定。

github 地址: github

demo实例:demo

背景

从一个小的需求说起:某一天,产品提了一个这样的需求,需要制作一个微信活动页,活动页可以分享包含用户相关信息的图片。这些信息是需要从接口取的,而且每个人都不一样。第一次碰到这种需求的时候,基本上都会去手撸canvasAPI去做渲染功能,这种情况的步骤大致如下:

  1. 写一大串 dom template 标签
  2. 渲染template成dom标签
  3. 开始捕捉dom元素,绘制canvas
  4. canvas 渲染图片

面临的主要问题是复用性太差,其次是性能上也有问题,用户看到的界面不一定和正式渲染出的界面一致,可能存在渲染差异。作为一个有追求的前端,当然得想想看有没有更好的法子。于是乎了解到了一个html2canvas 这样一个库。但是总是感觉还是要转成dom再去绘制,而且感觉性能和稳定性也不是很好。

我们知道vue通过vnode实现了对不同端的渲染工作,那有没有可能通过vnode实现对canvas的渲染呢?也就是说,没有vnode -> html -> canvas 而是直接vnode -> canvas。 同时利用vue的数据驱动,来达到绘制的数据驱动。想法有了,下面开始实施。

调研

这篇文章对此有详细的介绍:60 FPS on the mobile web 这里简单的概括一下:

canvas是一种立即模式的渲染方式,不会存储额外的渲染信息。Canvas 受益于立即模式,允许直接发送绘图命令到 GPU。但若用它来构建用户界面,需要进行一个更高层次的抽象。例如一些简单的处理,比如当绘制一个异步加载的资源到一个元素上时会出现问题,如在图片上绘制文本。在HTML中,由于元素存在顺序,以及 CSS 中存在 z-index,因此是很容易实现的。
dom渲染是一种保留模式,保留模式是一种声明性API,用于维护绘制到其中的对象的层次结构。保留模式 API 的优点是,对于你的应用程序,他们通常更容易构建复杂的场景,例如 DOM。通常这都会带来性能成本,需要额外的内存来保存场景和更新场景,这可能会很慢。

看来canvas绘制页面的研究,很久之前就已经有人付出过研究了。而且性能还是很不错的。那我们更要试试看,到底我们的想法能不能实现了!越来越期待....

开始

canvas 的渲染其实也是一种尝试,既然前人以及做了充分的实践,那么我们便站在巨人的肩膀上去基于vue来实现一个数据驱动的canvas渲染。说做就做!(我们这里只提供思路,不做具体实现细节的讨论,因为实现起来有点复杂,如果有兴趣可以参考我的项目实现,或者一起交流探讨 )

处理vnode

熟悉Vue源码的应该都知道,Vue通过render函数,传入createElement方法来构造出一个vnode,通过发布--订阅模式来实现对数据的监听,重新生成vnode。我们要做的就是在vnode这一层开始。所以,我们基于Vue源码的方式,实现一个监听函数,并混入Vue实例中:

Vue.mixin({
    // ...
    created() {
      if (this.$options.renderCanvas) {
        // ...
        // 监听vnode中引用的变化,重新渲染
        this.$watch(this.updateCanvas, this.noop)
        // ...
      }
    },
    methods: {
      updateCanvas() {
        // 模拟Vue render 函数
        // 寻找实例中定义的 renderCanvas 方法,并传入createElement方法
        let vnode = this.$options.renderCanvas.call(this._renderProxy, this.$createElement)
      }
})

这样我们就可以愉快的在组件内部使用:

renderCanvas (h) {
  return h(...)
}

canvas 元素处理

render 的vnode我们需要做额外的一些约束,也就是说我们需要怎么样的渲染标签,来渲染对应的canvas元素(举个🌰):

  1. view/scrollView/scrollItem --> fillRect
  2. text --> fillText
  3. image --> drawImage

其中这些元素类分别都继承于一个Super类,并且由于它们各有不同的展示方式,因此它们分别实现自己的draw方法,做定制化的展示。

绘制对象的布局机制实现

绘制 canvas 布局最基础的写法是为canvas 元素传入一系列坐标点和相关的基础宽高,这样写到实际项目中可能是这样的:

renderCanvas(h) {
  return h('view', {
     style: {
       left: 10,
       top: 10,
       width: 100,
       height: 100
     }
  })
}

这样写确实有点不方便维护,目前有好几种解决方案,一种是使用css-layout去做管理。css-layout支持的转换属性如下:

image

这样也只是做了一层转换,帮我们更好的用css思维去写canvas,但是如果我们很不爽css in js的写法,其实我们还可以写一个webpack loader 来加载外部css:

const css = require('css')
module.exports = function (source, other) {
  let cssAST = css.parse(source)
  let parseCss = new ParseCss(cssAST)
  parseCss.parse()
  this.cacheable();
  this.callback(null, parseCss.declareStyle(), other);
};

class ParseCss {
  constructor(cssAST) {
    this.rules = cssAST.stylesheet.rules
    this.targetStyle = {}
  }

  parse () {
    this.rules.forEach((rule) => {
      let selector = rule.selectors[0]
      this.targetStyle[selector] = {}
      rule.declarations.forEach((dec) => {
        this.targetStyle[selector][dec.property] = this.formatValue(dec.value)
      })
    })
  }

  formatValue (string) {
    string = string.replace(/"/g, '').replace(/'/g, '')
    return string.indexOf('px') !== -1 ? parseInt(string) : string
  }

  declareStyle (property) {
    return `window.${property || 'vStyle'} = ${JSON.stringify(this.targetStyle)}`
  }
}

主要也就是将 css 文件转成AST语法树,之后再对语法树做转换,转成canvas需要的定义形式。并以变量的形式注入到组件中。

实现列表滚动

如果我们的元素很多,需要滚动时,我们必须解决canvas内部元素滚动的问题。这里我选择了使用Zynga Scroller 来模拟用户滚动方法,通过他返回的滚动坐标点,来对canvas进行重绘。

详细的参考这里

事件模拟

对于click,touch等dom事件的模拟,我们采用的方案是根据点击区域进行检测,并找出最底层的元素,递归寻找父元素并触发对应事件处理程序,从而模拟事件冒泡。

详细的实现可以参考这里

最后

canvas绘制页面也是一种创新的尝试,希望这里的研究对你有启发,也欢迎您的PR。这里也做了很多性能优化,限于篇幅不在赘述了,有兴趣也可以一起探讨。

最后:它并不意味着完全取代基于DOM的渲染,这仍然需要文本输入,复制/粘贴,可访问性和SEO。
出于这些原因,我们可以使用canvas和基于DOM的渲染的组合。

你应该了解的parcel

什么是parcel

网上关于parcel的报道很多,其实也是个和webpack类似的资源打包编译工具,官网上对其主要的宣传点还是在打包更快速,零配置,入口支持html,css,js...
当然,听到这里,或许每个人都想去尝鲜。因为我现在项目主要的技术栈是vue,所以我们来基于Vue来试试parcel 到底怎么样。

环境安装

运行以下命令,安装parcel:

npm install -g parcel-bundler

准备ES6+ 环境

按照官方的说法,0配置,理论上我什么都不安装即可以完成项目的构建,于是乎我兴高采烈的创建了一个main.js用来作为我ES6的测试文件:

// main.js
const a = 'hello parcel'
console.log(a)

再建立一个index.html作为其入口文件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>parcel</title>
</head>
<body>
</body>
<script src="./main.js"></script>
</html>

当我们愉快的运行parcel index.html的时候报错了:

image

很显然,说transform-runtime 模块在.babelrc 文件中找不到....oh fuck,行吧,我就依你!

# 安装依赖
npm i babel-plugin-transform-runtime --save-dev

建立.babelrc文件

{ 
  "plugins": [
    "transform-runtime"
  ]
}

再次运行命令,终于编译成功了,产生了两个目录,一个是 .cache 一个是dist目录。.cache存放的是编译缓存文件,可以提高parcel再次打包编译的速度。dist便是我们最终生成的资源文件。

Vue环境

首先是准备我们的Vue包
npm install vue --save
然后改写我们的main.js

import Vue from 'vue/dist/vue.min.js'

new Vue({
  el: '#app',
  data: {
    msg: 'Hello Parcel'
  }
})

至于这里为什么要引入vue.min.js有兴趣的可以参考我的这篇文章 vue 目录介绍
然后是我们的html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>parcel</title>
</head>
<body>
  <div id="app">{{msg}}</div>
</body>
<script src="./main.js"></script>
</html>

访问localhost:12345 一切正常。

单文件组件

由于这里我们没有任何配置,没办法像webpack那样记载vue-loader,好在parcel提供了一个加载Vue文件的插件 parcel-plugin-vue,里面也有具体的demo,这里不再赘述。

使用中的一些问题

  1. 在进行parcel生产环境构建的时候,NODE_ENV=production parcel build index.html -d build无意中抛了一个错误:
    image
    然后去parcel issues 里面找了好久,终于找到了一些回答:parcel-bundler/parcel#390,大致是说,parcel-bundler 版本如果低于1.3, build 的时候压缩会出现问题,必须带上--no-minify 参数。
  2. 不支持tree-shacking,打包体积也是很大的。
  3. Parcel 本身是零配置的,但 HTML、JS 和 CSS 分别是通过 posthtml、babel 和 postcss 处理的,所以我们得分别配 .posthtmlrc、.babelrc 和 .postcssrc
  4. Parcel 还很年轻,所以你可能会碰到一些麻烦的问题,所以切换项目请慎用。

vue-router 实现 -- new VueRouter(options)

为了构造出 router 对象,我们还需要对VueRouter进行实例化的操作,比如这样:

const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', name: 'home', component: Home },
    { path: '/foo', name: 'foo', component: Foo },
    { path: '/bar/:id', name: 'bar', component: Bar }
  ]
})

constructor

我们来看一下在VueRouter内部的源码定义:

export default class VueRouter {
 
  // ...
  constructor (options: RouterOptions = {}) {
    this.app = null
    this.apps = []
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    this.matcher = createMatcher(options.routes || [], this)

    let mode = options.mode || 'hash'
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode

    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }

  match (
    raw: RawLocation,
    current?: Route,
    redirectedFrom?: Location
  ): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

  get currentRoute (): ?Route {
    return this.history && this.history.current
  }

  init () {}
  beforeEach () {}
  beforeResolve () {}
  afterEach () {}
  onReady () {}
  onError () {}
  push () {}
  replace () { }
  go () {}
  back () { }
  forward () { }
  getMatchedComponents () { }
  resolve ( ) { }
  addRoutes () { }
}

这里我们忽略了大部分的函数实现,后面我么再展开来看。先来看一下constructor实例化的时候将会做的处理:通过new VueRouter({...})我们创建了一个 VueRouter 的实例。VueRouter中通过参数mode来指定路由模式,前面已经简单的了解了一下前端路由的2种模式。通过上面的代码,我们可以看出来 VueRouter对不同模式的实现大致是这样的:

  1. 首先根据mode来确定所选的模式,如果当前环境不支持history模式,会强制切换到hash模式;
  2. 如果当前环境不是浏览器环境,会切换到abstract模式下。然后再根据不同模式来生成不同的history操作对象。

由于上篇文章已经介绍了在 install 的过程中,会执行改对象的 init 函数。我们接下来的主要任务就是分析init 的实现。

init

  init (app: any /* Vue component instance */) {
    // ...
    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }

    this.app = app

    const history = this.history

    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

    history.listen(route => {
      this.apps.forEach((app) => {
        app._route = route
      })
    })
  }

回顾一下在 inistall 的 beforCreate 钩子内,我们通过这种方式调用了实例的init方法:

this._router.init(this)

然后我们来分析一下执行的大致过程:init 方法内的 app变量便是存储的当前的vue实例的this。然后将 app 存入数组apps中。通过this.app判断是实例否已经被初始化。然后通过history来确定不同路由的切换动作动作 history.transitionTo。最后通过 history.listen来注册路由变化的响应回调。
接下来我们就要了解一下 history.transitionTo的主要流程以及 history.listen的实现。当然最基础的是先明白history是个什么东西。接下来我们会分别介绍不同mode下的 history 的实现。

简单的聊聊,顺便招前端

前言

前不久,我刚刚入职了蚂蚁金服,由于自己一直对蚂蚁技术的向往,所以这次也算是如愿以偿啦。如果看到本文的你也想着来大厂历练一下,欢迎简历砸到:[email protected]

简单的随笔一下吧

其实我也不知道说些什么,我就简单的谈谈自己对前端的认识和自己的成长的过程吧。15年的时候,我刚毕业,自己本身毕业的时候也不是专门做前端的,当时做的是Android、Java之类的,因为当时对偏向于界面交互的东西感兴趣,所以选择了前端这个行业,因为自己对编程语言有点底子,所以相对来说,入门前端并不是很难。但是入门和掌握是两码事。
当时我在一个普通的公司做前端开发,也负责做一些架构和前端技术选型的事情,当时深刻感觉到自己的瓶颈带来的问题,在知乎上我还特地回答了一下这样一个问题:大公司和小公司的程序员差别在哪?当时自己的理解大致是这样的:

个人亲身体会,有三个方面吧:眼界,眼界,眼界。就跟当年高考选择学校差不多,更多人愿意去大城市的院校,而不愿意待在3-5线城市一样。我之前就在一个类似于作坊型的公司待过,前端4-5个人,当时算是顶配了。但是会发现在做业务的过程中,总是会遇到各种奇怪难以解决的坑,而这些坑需要花费团队很大的精力去处理。而且,还不一定能解决掉,就算解决了,也有可能是最笨的方法。但是这些问题,可能在大公司内存在着一整套完整的体系结构,一整套已经成熟的解决方案。其次,小公司内可能只存在一个诸葛亮,什么事情都是由这个人和大家商讨决定,当然,这个诸葛亮的天花板,决定着整个团队的天花板。但是大公司内,存在着各式各样的诸葛亮,所谓术业有专攻!大家在不同领域,不同层级,发挥着自己独特的优势和价值,从中只要你愿意去学,能学到很多有用的知识!当然,小公司也有自身的优势,百废待兴。所以,如果你有信心让这个公司兴起来,那么你就是王者。

所以当时的自己深深被这种眼界的问题困扰,就是自己做的东西,遇到了自己解决不了的问题。可能是自己的一开始设计就错了,可能是哪一个环节错了。这种排查问题的过程给我带来了很大的困扰。所以后面我选择去了大搜车。在大搜车我也主要是带着很多疑问去的,在里面我学习到了很多知识,很多很多。自己之前想了很久的问题在这里慢慢被解开。

然后对自己帮助很大的就是社区了,因为有时候自己喜欢这种解决难题带来的成就感,而这种成就感想分享到社区,一方面可以帮助到和我遇到同样问题的人,另一方面也可以得到别人的认可,或者说别人有更好的方案,可以一起探讨出更优秀的解决方法。所以后面我每次学习成长,我都会通过知乎、掘金、Github等渠道进行分享。期间我结识了很多优秀的小伙伴,我们一起有一些沟通和交流,感觉这个也是我能力提升的很大一个因素。

最后还是不断地思考吧,我相信我们作为程序员,肯定是热爱这个行业,而不仅仅是为了工作。有的时候遇到问题或者好的解决方案,我们应该多想为什么这么做,他是怎么实现的,这么实现的好处,有没有更简洁的办法实现。之前看了很多优秀的大神的源码设计,然后我发现我在写代码的过程中开始模仿他们了,很多设计模式或者说是代码的整洁组织规范,都可以得到很大的提升。

最后

最后吧,我还是挺喜欢这样一句话的:首先我需要是一个程序员,其次我才是前端工程师。欢迎共勉!!最后再说一下:蚂蚁金服招前端,base杭州。有兴趣的可以投投简历,就算没兴趣,交个朋友也好。

为什么会写这篇文章

如今对于每一个前端工程师来说,webpack 已经成为了一项基础技能,它基本上包办了本地开发、编译压缩、性能优化的所有工作,从这个角度上来说,webpack 确实是伟大的,它的诞生意味着一整套工程化体系开始普及,并且慢慢统一了前端自动构建的让前端开发彻底告别了之前的刀耕火种时代。

但是,即使它如此伟大,也有一个巨大的问题,那就是 webpack 实在是太难用了!!!
我从多年前的 webpack 1.0 时代就一直在用它,现在也不能说完全掌握了它,很多时候真的让我产生了怀疑,究竟是因为我的能力不足,还是因为 webpack 自身的设计就太难用?随着我接触到越来越多的前端项目,听到越来越多的吐槽,我也越发地相信,是 webpack 自身的问题,导致它变得如此复杂又难用。

举个简单的例子,一个 vue-cli 生成的最简单的脚手架项目,开发、构建相关的文件就有 14 个之多,代码超过 800 行,而真实的项目只会比这个更多:

可是有的时候我们就跟被包办婚姻一样,由脚手架给我们包办了所有的配置,我们开箱既用。如果你也跟我一样不喜欢这种包办,或者更希望了解整个过程和原理,那我们可以一起来共同学习关于webpack的那些事。

vue-router 实现 -- install

Vue 通过 use 方法,加载VueRouter中的 install 方法。install 完成 Vue 实例对 VueRouter 的挂载过程。下面我们来分析一下具体的执行过程:

export function install (Vue) {
 // ...
  // 混入 beforeCreate 钩子
  Vue.mixin({
    beforeCreate () {
      // 在option上面存在router则代表是根组件 
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
        // 执行_router实例的 init 方法
        this._router.init(this)
        // 为 vue 实例定义数据劫持
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 非根组件则直接从父组件中获取
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })
 
  // 设置代理,当访问 this.$router 的时候,代理到 this._routerRoot._router
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })
  // 设置代理,当访问 this.$route 的时候,代理到 this._routerRoot._route
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })
 
  // 注册 router-view 和 router-link 组件
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)

  // Vue钩子合并策略
  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
  // ...
}

在构造Vue实例的时候,我们会传入router对象:

new Vue({
  router
})

此时的router会被挂载到 Vue 的跟组件this.$options选项中。在 option 上面存在 router 则代表是根组件。如果存在this.$options,则对_routerRoot_router进行赋值操作,之后执行 _router.init() 方法。

为了让 _router 的变化能及时响应页面的更新,所以又接着又调用了 Vue.util.defineReactive方法来进行getset的响应式数据定义。

然后通过 registerInstance(this, this)这个方法来实现对router-view的挂载操作:

 // 执行 vm.$options._parentVnode.data.registerRouteInstance 渲染 router-view 组件
 const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }

因为只有 router-view 组件定义了data.registerRouteInstance函数。data.registerRouteInstance 主要用来执行 render 的操作,创建 router-view 组件的 Vnode :

data.registerRouteInstance = (vm, val) => {
  // ...
  return h(component, data, children)
}

后续步骤便是为Vue全局实例注册2个属性$router$route;以及组件RouterViewRouterLink

关于Vue.config.optionMergeStrategies 参考 自定义选项合并策略。下一篇我们会接着介绍一下 VueRouter 实例化的过程
有兴趣可以移步vue-router 实现 -- new VueRouter(options)

编写webpack loader

关于webpack loader

Loader 是支持链式执行的,如处理 sass 文件的 loader,可以由 sass-loader、css-loader、style-loader 组成,由 compiler 对其由右向左或者从下向上执行,第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果回传给下一个接着处理,最后的 Loader 将处理后的结果以 String 或 Buffer 的形式返回给 compiler。

{
  test: /\.js/,
  use: [
    'bar-loader',
    'foo-loader'
  ]
}

无状态(Stateless)

虽然loader支持链式调用,但是请确保 loader 在不同模块转换之间不保存状态。每次运行都应该独立于其他编译模块以及相同模块之前的编译结果。

编写一个 loader

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将调用 loader API,并通过 this 上下文访问。那我们便可以这样来定义一下最基本的loader:

// 当一个 loader 在资源中使用,这个 loader 只能传入一个参数 - 这个参数是一个包含包含资源文件内容的字符串
module.exports = function(source) {
  return source;
};

上面使用返回 return 返回,是因为是同步类的 Loader 且返回的内容唯一,如果你希望将处理后的结果(不止一个)返回给下一个 Loader,那么就需要调用 Webpack 所提供的 API。 一般来说,构建系统都会提供一些特有的 API 供开发者使用。Webpack 也如此,提供了一套 Loader API,可以通过在node module中使用 this 来调用,如 this.callback(err, value…),这个 API 支持返回多个内容的结果给下一个 Loader 。

module.exports = function(source) {
  let other = '';
  return this.callback(null, source, other);
};

更多

1. 缓存

从提高执行效率上,如何处理利用缓存是极其重要的。 Mac OS 会让内存充分使用、尽量占满来提高交互效率。回到 Webpack,Hot-Replace 以及 React Hot Loader 也充分地利用缓存来提高编译效率。 Webpack Loader 同样可以利用缓存来提高效率,并且只需在一个可缓存的 Loader 上加一句 this.cacheable(); 就是这么简单:

module.exports = function(source) {
    this.cacheable();
    return source;
};

2. 异步

异步并不陌生,当一个 Loader 无依赖,可异步的时候我想都应该让它不再阻塞地去异步。在一个异步的模块中,回传时需要调用 Loader API 提供的回调方法 this.async(),使用起来也很简单:

module.exports = function(source) {
    var callback = this.async();
    // 做异步的事
    doSomeAsyncOperation(content, function(err, result) {
        if(err) return callback(err);
        callback(null, result);
    });
};

3. raw loader

默认的情况,原文件是以 UTF-8 String 的形式传入给 Loader,而在上面有提到的,module 可使用 buffer 的形式进行处理,针对这种情况,只需要设置 module.exports.raw = true; 这样内容将会以 raw Buffer 的形式传入到 loader 中了

module.exports = function(content) {
 
};
module.exports.raw = true;

4. loader 工具库

充分利用 loader-utils 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。这里有一个简单使用两者的例子:

const { getOptions } = require('loader-utils');
const validateOptions = require('schema-utils')

const schema = {
  type: 'object',
  properties: {
    test: {
      type: 'string'
    }
  }
}

export default function(source) {
  const options = getOptions(this);

  validateOptions(schema, options, 'Example Loader');

  this.callback(null, source);
}

一些例子

比如编写一个处理Vue组件上的style,把px转成rem:

const { getOptions } = require('loader-utils');
const validateOptions = require('schema-utils')

const schema = {
    type: 'object',
    properties: {
        remUnit: {
            type: 'number'
        },
        forcePxProperty: {
            type: 'array'
        }
    }
}

module.exports = function(source) {
    const options = getOptions(this);
    validateOptions(schema, options, 'style2rem Loader');

    source = source.replace(/<template>(.|\n)*?<\/template>/ig, function (content) {
        return content.replace(/style="(.|\n)*?"/ig, function (styleStr) {
            let start = 0
            return styleStr.replace(/:(.|\n)*?px/ig, function (px, num, end) {
                let key = ''
                let need = true
                for (let i = start; i < end; i++) {
                    if (styleStr[i] !== ';' && styleStr[i]!==' ') {
                        key += styleStr[i]
                    }
                }
                options.forcePxProperty.forEach((property) => {
                    if(key.indexOf(property) !== -1) {
                        need = false
                    }
                })
                start = end
                return px && need ? `: ${(parseInt(px.substring(1)) / options.remUnit).toFixed(6)}rem` : px
            })
        })
    })
    this.cacheable();
    this.callback(null, source);
};

然后我们需要添加到webpack loader中:

module: {
        rules: [
            {
                test: /\.vue$/,
                use: [{
                    loader: "vue-loader",
                    options: {
                        preserveWhitespace: false
                    }
                }, {
                    loader: path.join(__dirname, './style2rem'),
                    options: {
                        remUnit: 37.5,
                        forcePxProperty: ['font-size']
                    }
                }]
            }
           ...
        ]
     ...
}

参考资料

如何开发一个 Webpack Loader ( 一 )

Vue nextTick 机制

背景

我们先来看一段Vue的执行代码:

export default {
  data () {
    return {
      msg: 0
    }
  },
  mounted () {
    this.msg = 1
    this.msg = 2
    this.msg = 3
  },
  watch: {
    msg () {
      console.log(this.msg)
    }
  }
}

这段脚本执行我们猜测1000m后会依次打印:1、2、3。但是实际效果中,只会输出一次:3。为什么会出现这样的情况?我们来一探究竟。

queueWatcher

我们定义watch监听msg,实际上会被Vue这样调用vm.$watch(keyOrFn, handler, options)$watch是我们初始化的时候,为vm绑定的一个函数,用于创建Watcher对象。那么我们看看Watcher中是如何处理handler的:

this.deep = this.user = this.lazy = this.sync = false
...
  update () {
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
...

初始设定this.deep = this.user = this.lazy = this.sync = false,也就是当触发update更新的时候,会去执行queueWatcher方法:

const queue: Array<Watcher> = []
let has: { [key: number]: ?true } = {}
let waiting = false
let flushing = false
...
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

这里面的nextTick(flushSchedulerQueue)中的flushSchedulerQueue函数其实就是watcher的视图更新:

function flushSchedulerQueue () {
  flushing = true
  let watcher, id
  ...
 for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    watcher.run()
    ...
  }
}

另外,关于waiting变量,这是很重要的一个标志位,它保证flushSchedulerQueue回调只允许被置入callbacks一次。
接下来我们来看看nextTick函数,在说nexTick之前,需要你对Event LoopmicroTaskmacroTask有一定的了解,Vue nextTick 也是主要用到了这些基础原理。如果你还不了解,可以参考我的这篇文章Event Loop 简介
好了,下面我们来看一下他的实现:

export const nextTick = (function () {
  const callbacks = []
  let pending = false
  let timerFunc

  function nextTickHandler () {
    pending = false
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  // An asynchronous deferring mechanism.
  // In pre 2.4, we used to use microtasks (Promise/MutationObserver)
  // but microtasks actually has too high a priority and fires in between
  // supposedly sequential events (e.g. #4521, #6690) or even between
  // bubbling of the same event (#6566). Technically setImmediate should be
  // the ideal choice, but it's not available everywhere; and the only polyfill
  // that consistently queues the callback after all DOM events triggered in the
  // same loop is by using MessageChannel.
  /* istanbul ignore if */
  if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
      setImmediate(nextTickHandler)
    }
  } else if (typeof MessageChannel !== 'undefined' && (
    isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]'
  )) {
    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = nextTickHandler
    timerFunc = () => {
      port.postMessage(1)
    }
  } else
  /* istanbul ignore next */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // use microtask in non-DOM environments, e.g. Weex
    const p = Promise.resolve()
    timerFunc = () => {
      p.then(nextTickHandler)
    }
  } else {
    // fallback to setTimeout
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

  return function queueNextTick (cb?: Function, ctx?: Object) {
    let _resolve
    callbacks.push(() => {
      if (cb) {
        try {
          cb.call(ctx)
        } catch (e) {
          handleError(e, ctx, 'nextTick')
        }
      } else if (_resolve) {
        _resolve(ctx)
      }
    })
    if (!pending) {
      pending = true
      timerFunc()
    }
    // $flow-disable-line
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        _resolve = resolve
      })
    }
  }
})()

首先Vue通过callback数组来模拟事件队列,事件队里的事件,通过nextTickHandler方法来执行调用,而何事进行执行,是由timerFunc来决定的。我们来看一下timeFunc的定义:

  if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
      setImmediate(nextTickHandler)
    }
  } else if (typeof MessageChannel !== 'undefined' && (
    isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]'
  )) {
    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = nextTickHandler
    timerFunc = () => {
      port.postMessage(1)
    }
  } else
  /* istanbul ignore next */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // use microtask in non-DOM environments, e.g. Weex
    const p = Promise.resolve()
    timerFunc = () => {
      p.then(nextTickHandler)
    }
  } else {
    // fallback to setTimeout
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

可以看出timerFunc的定义优先顺序macroTask --> microTask,在没有Dom的环境中,使用microTask,比如weex

setImmediate、MessageChannel VS setTimeout

我们是优先定义setImmediateMessageChannel为什么要优先用他们创建macroTask而不是setTimeout?
HTML5中规定setTimeout的最小时间延迟是4ms,也就是说理想环境下异步回调最快也是4ms才能触发。Vue使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel 和 setImmediate 的延迟明显是小于setTimeout的。

解决问题

有了这些基础,我们再看一遍上面提到的问题。因为Vue的事件机制是通过事件队列来调度执行,会等主进程执行空闲后进行调度,所以先回去等待所有的进程执行完成之后再去一次更新。这样的性能优势很明显,比如:

现在有这样的一种情况,mounted的时候test的值会被++循环执行1000次。 每次++时,都会根据响应式触发setter->Dep->Watcher->update->run。 如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。 所以Vue实现了一个queue队列,在下一个Tick(或者是当前Tick的微任务阶段)的时候会统一执行queueWatcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。 保证更新视图操作DOM的动作是在当前栈执行完以后下一个Tick(或者是当前Tick的微任务阶段)的时候调用,大大优化了性能。

有趣的问题

var vm = new Vue({
    el: '#example',
    data: {
        msg: 'begin',
    },
    mounted () {
      this.msg = 'end'
      console.log('1')
      setTimeout(() => { // macroTask
         console.log('3')
      }, 0)
      Promise.resolve().then(function () { //microTask
        console.log('promise!')
      })
      this.$nextTick(function () {
        console.log('2')
      })
  }
})

这个的执行顺序想必大家都知道先后打印:1、promise、2、3。

  1. 因为首先触发了this.msg = 'end',导致触发了watcherupdate,从而将更新操作callback push进入vue的事件队列。

  2. this.$nextTick也为事件队列push进入了新的一个callback函数,他们都是通过setImmediate --> MessageChannel --> Promise --> setTimeout来定义timeFunc。而 Promise.resolve().then则是microTask,所以会先去打印promise。

  3. 在支持MessageChannelsetImmediate的情况下,他们的执行顺序是优先于setTimeout的(在IE11/Edge中,setImmediate延迟可以在1ms以内,而setTimeout有最低4ms的延迟,所以setImmediate比setTimeout(0)更早执行回调函数。其次因为事件队列里,优先收入callback数组)所以会打印2,接着打印3

  4. 但是在不支持MessageChannelsetImmediate的情况下,又会通过Promise定义timeFunc,也是老版本Vue 2.4 之前的版本会优先执行promise。这种情况会导致顺序成为了:1、2、promise、3。因为this.msg必定先会触发dom更新函数,dom更新函数会先被callback收纳进入异步时间队列,其次才定义Promise.resolve().then(function () { console.log('promise!')})这样的microTask,接着定义$nextTick又会被callback收纳。我们知道队列满足先进先出的原则,所以优先去执行callback收纳的对象。

参考文章

Vue.js 升级踩坑小记

【Vue源码】Vue中DOM的异步更新策略以及nextTick机制

JS 数据类型、赋值、深拷贝和浅拷贝

js 数据类型

  1. 六种 基本数据类型:
  • Boolean. 布尔值,true 和 false.
  • null. 一个表明 null 值的特殊关键字。 JavaScript 是大小写敏感的,因此 null 与 Null、NULL或其他变量完全不同。
  • undefined. 变量未定义时的属性。
  • Number. 表示数字,例如: 42 或者 3.14159。
  • String. 表示字符串,例如:"Howdy"
  • Symbol ( 在 ECMAScript 6 中新添加的类型).。一种数据类型,它的实例是唯一且不可改变的。
  1. 以及 Object 对象引用数据类型

大多数情况下,我们可以通过typeof属性来判断。只不过有一些例外,比如:

var fn = new Function ('a', 'b', 'return a + b')

typeof fn // function

关于function属不属于js的数据类型,这里也有相关的讨论JavaScript 里 Function 也算一种基本类型?

基本类型 和 引用数据类型 的相关区别

基本数据类型

我们来看一下 MDN 中对基本数据类型的一些定义:

除 Object 以外的所有类型都是不可变的(值本身无法被改变)。例如,与 C 语言不同,JavaScript 中字符串是不可变的(译注:如,JavaScript 中对字符串的操作一定返回了一个新字符串,原始字符串并没有被改变)。我们称这些类型的值为“原始值”。

var a = 'string'
a[0] = 'a'
console.log(a)  // string

我们通常情况下都是对一个变量重新赋值,而不是改变基本数据类型的值。在 js 中是没有方法是可以改变布尔值和数字的。倒是有很多操作字符串的方法,但是这些方法都是返回一个新的字符串,并没有改变其原有的数据。比如:

  • 获取一个字符串的子串可通过选择个别字母或者使用 String.substr().
  • 两个字符串的连接使用连接操作符 (+) 或者 String.concat().

引用数据类型

引用类型(object)是存放在堆内存中的,变量实际上是一个存放在栈内存的指针,这个指针指向堆内存中的地址。每个空间大小不一样,要根据情况开进行特定的分配,例如。

var person1 = {name:'jozo'};
var person2 = {name:'xiaom'};
var person3 = {name:'xiaoq'};

引用类型的值是可变的:

person1['name'] = 'muwoo'

console.log(person1) // {name: 'muwoo'}

传值与传址

了解了基本数据类型与引用类型的区别之后,我们就应该能明白传值与传址的区别了。
在我们进行赋值操作的时候,基本数据类型的赋值(=)是在内存中新开辟一段栈内存,然后再把再将值赋值到新的栈中。例如:

var a = 10;
var b = a;

a ++ ;
console.log(a); // 11
console.log(b); // 10

所以说,基本类型的赋值的两个变量是两个独立相互不影响的变量。

但是引用类型的赋值是传址。只是改变指针的指向,例如,也就是说引用类型的赋值是对象保存在栈中的地址的赋值,这样的话两个变量就指向同一个对象,因此两者之间操作互相有影响。例如:

var a = {}; // a保存了一个空对象的实例
var b = a;  // a和b都指向了这个空对象

a.name = 'jozo';
console.log(a.name); // 'jozo'
console.log(b.name); // 'jozo'

b.age = 22;
console.log(b.age);// 22
console.log(a.age);// 22

console.log(a == b);// true

浅拷贝

先来看一段代码的执行:

var obj = {a: 1, b: {c: 2}}
var obj1 = obj
var obj2 = shallowCopy(obj);
function shallowCopy(src) {
    var dst = {};
     for (var prop in src) {
         if (src.hasOwnProperty(prop)) {
             dst[prop] = src[prop];
          }
      }
     return dst;
}

var obj3 = Object.assign({}, obj)

obj.a = 2
obj.b.c = 3

console.log(obj) // {a: 2, b: {c: 3}}
console.log(obj1) // {a: 2, b: {c: 3}}
console.log(obj2) // {a: 1, b: {c: 3}}
console.log(obj3) // {a: 1, b: {c: 3}}

这段代码可以说明赋值得到的对象 obj1 只是将指针改变,其引用的仍然是同一个对象,而浅拷贝得到的的 obj2 则是重新创建了新对象。但是,如果原对象obj中存在另一个对象,则不会对对象做另一次拷贝,而是只复制其变量对象的地址。这是因为浅拷贝只复制一层对象的属性,并不包括对象里面的为引用类型的数据。
对于数组,更长见的浅拷贝方法便是slice(0)concat()
ES6 比较常见的浅拷贝方法便是 Object.assign

深拷贝

通过上面的这些说明,相信你对深拷贝大致了解了是怎样一个东西了:深拷贝是对对象以及对象的所有子对象进行拷贝。那么如何实现这样一个深拷贝呢?

1. JSON.parse(JSON.stringify(obj))

对于常规的对象,我们可以通过JSON.stringify来讲对象转成一个字符串,然后在用JSON.parse来为其分配另一个存储地址,这样可以解决内存地址指向同一个的问题:

var obj = {a: {b: 1}}
var copy = JSON.parse(JSON.stringify(obj))

obj.a.b = 2
console.log(obj) // {a: {b: 2}}
console.log(copy) // {a: {b: 1}}

但是 JSON.parse()JSON.stringify也存在一个问题,JSON.parse() 和J SON.stringify()能正确处理的对象只有Number、String、Array等能够被 json 表示的数据结构,因此函数这种不能被 json 表示的类型将不能被正确处理。

var target = {
    a: 1,
    b: 2,
    hello: function() { 
            console.log("Hello, world!");
    }
};
var copy = JSON.parse(JSON.stringify(target));
console.log(copy);   // {a: 1, b: 2}
console.log(JSON.stringify(target)); // "{"a":1,"b":2}"

2. 遍历实现属性复制

既然浅拷贝只能实现非object第一层属性的复制,那么遇到object只需要通过递归实现浅拷贝其中内部的属性即可:

function extend (source) {
  var target
  if (typeof source === 'object') {
    target = Array.isArray(source) ? [] : {}
    for (var key in source) {
      if (source.hasOwnProperty(key)) {
        if (typeof source[key] !== 'object') {
          target[key] = source[key]
        } else {
          target[key] = extend(source[key])
        }
      }
    }
  } else {
    target = source
  }
  return target
}

var obj1 = {a: {b: 1}}
var cpObj1 = extend(obj1)
obj1.a.b = 2
console.log(cpObj1) // {a: {b: 1}}

var obj2 = [[1]]
var cpObj2 = extend(obj2) 
obj2[0][0] = 2
console.log(cpObj2) // [[1]]

我们再来看一下 Zepto 中深拷贝的代码:

    // 内部方法:用户合并一个或多个对象到第一个对象
    // 参数:
    // target 目标对象  对象都合并到target里
    // source 合并对象
    // deep 是否执行深度合并
    function extend(target, source, deep) {
        for (key in source)
            if (deep && (isPlainObject(source[key]) || isArray(source[key]))) {
                // source[key] 是对象,而 target[key] 不是对象, 则 target[key] = {} 初始化一下,否则递归会出错的
                if (isPlainObject(source[key]) && !isPlainObject(target[key]))
                    target[key] = {}

                // source[key] 是数组,而 target[key] 不是数组,则 target[key] = [] 初始化一下,否则递归会出错的
                if (isArray(source[key]) && !isArray(target[key]))
                    target[key] = []
                // 执行递归
                extend(target[key], source[key], deep)
            }
            // 不满足以上条件,说明 source[key] 是一般的值类型,直接赋值给 target 就是了
            else if (source[key] !== undefined) target[key] = source[key]
    }

内部实现其实也是差不多。

参考资料

js 深拷贝 vs 浅拷贝

Vue官网中的约束源码解释 -- 数据与方法

当一个 Vue 实例被创建时,它向 Vue 的响应式系统中加入了其 data 对象中能找到的所有的属性。当这些属性的值发生改变时,视图将会产生“响应”,即匹配更新为新的值。

// 我们的数据对象
var data = { a: 1 }

// 该对象被加入到一个 Vue 实例中
var vm = new Vue({
  data: data
})

// 获得这个实例上的属性
// 返回源数据中对应的字段
vm.a == data.a // => true

// 设置属性也会影响到原始数据
vm.a = 2
data.a // => 2

// ……反之亦然
data.a = 3
vm.a // => 3

为什么不是访问vm.$options.data.a而是 vm.a

其实我们知道,为new Vue({data: ...})的时候,会进行mergeOptions,也就是吧所有的参数挂载到vm.$options中,我们定义的data也是会被挂载进去,那么,为什么我们可以通过vm.a来取到我们想要的值呢?我们来看一下源码的实现:

// core/instance/state.js
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
   ...
}

Vue通过initData函数,为实例vm定义了一个_data属性,他的值等于我们的vm.$options.data。并做了函数处理,因为有可能我们是通过一个functionreturn一个data。那到这一步,我们顶多可以通过this._data.xx来访问属性,那如何实现this.xx来访问呢?我们接着来看:

...
const keys = Object.keys(data)
let i = keys.length
while (i--) {
  ...
   proxy(vm, `_data`, key)
}
...

我们省略了一些代码,主要来看核心的实现。首先我们会遍历data中定义的属性,然后有一个proxy这样的东西

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

这里用了一个Object.defineProperty函数来定义了target = vmsourceKey = _datakey = xx。并改写了target.keygetset方法。
到这里我们就明白了,当我们访问this.xx的时候,其实是被Object.defineProperty拦截了,代理到this._data.xx上面。

用Electron做个小工具?这个或许是终极版

故事背景

之前在网上有看到很多小伙伴基于 electron 实现了非常多好用的桌面端工具,比如图床管理工具 PicGo,就专门做图床工具。也有一些其他的类似的小工具,比如 saladict-desktop 专门做沙拉翻译查词的桌面端应用,colorpicker 专做桌面端取色工具...

我们也参考了这些小工具的设计理念,尝试在公司内部做一款桌面端工具,解决网络抓包、代理、图床、性能测评等常见场景的使用问题。最后在推广的时候,遇到了一个比较严重的问题,就是很多小工具对特定用户来说并不需要。比如测试只需要使用网络抓包、代理的功能,其他功能并不关心。此时就需要设计一款桌面端应用,类似于 App Store 那样,用到什么下载安装什么即可。这就需要实现桌面端应用的插件化。

于是乎,我们看到了 uTools 是支持插件化的桌面端应用,但是前提是我们的插件必须发布到 uTools 插件市场,才能实现多端同步下载的功能,但是公司内部的工具库有些涉及到安全信息又无法发布到 uTools 插件中,所以我们特别渴望有一款类似于 uTools 的内部工具箱。

为了进一步提高开发工作效率,最近我们基于 electron 开发了一款媲美 uTools 的开源工具箱 rubick。该工具箱不仅仅开源,最重要的是可以使用 uTools 生态内所有开源插件!这将是巨大的能力,意味着 uTools 生态内所有插件可以无差异化使用到 rubick 中。

QQ20210705-210753.gif

代码仓库:https://github.com/clouDr-f2e/rubick

插件化之旅

一开始想到做插件化,无非就是使用 electronwebview 能力,实现类似于原生内嵌h5那样的方式,h5 页面可以做独立发布,原生提供 nativaAPI 之间通过 jsBridge 来桥接调用原生的方法。这样实现并无问题,我们也尝试了做了一次。最终思路大概是:

electron webview 方式

1. electron 中使用 webview

<webview src="https://xxx.xx.com/index.html" preload="preload.js" />

2. 实现 bridge

// preload.js
window.rubickBridge = {
  sayHello() {
    console.log('hello world')
  }
}

3. 插件借助 bridge 调用 electron 的能力

<html>
 <body>
     <div>这是一个插件<div>
 </body>
 <script>
  window.rubickBridge.sayHello()
</script>
</html>

4. 通信

因为 proload.jselectronrenderer 进程的,所以如果需要使用部分 main 进程的能力,则需要使用通信机制:

// main process
ipcMain.on('msg-trigger', async (event, arg) => {
    const window = arg.winId ? BrowserWindow.fromId(arg.winId) : mainWindow
    const operators = arg.type.split('.');
    let fn = Api;
    operators.forEach((op) => {
      fn = fn[op];
    });
    const data = await fn(arg, window);
    event.sender.send(`msg-back-${arg.type}`, data);
});
  
// renderer process
ipcRenderer.send('msg-trigger', {
  type: 'getPath',
  name,
});
ipcRenderer.on(`msg-back-getPath`, (e, result) => {
  console.log(result)
});

为什么后来我们又放弃了这条路🤔 ?

其实上面的思路大致是没啥问题的,我们也基于上面的思路成功把功能抽成了插件,按照插件的方式进行安装加载。直到我们注意到 utools 的强大,感觉 utools 的生态非常丰富,我们要是能集成 utools 的生成那该多好呀!所以我们秉持着干不过他就成为他的原则,我们尝试着成为他。但是 utools 本身并没有开源,所以没有办法去吸取一些优秀的代码实现,但是我们可以看他的官方文档。

我们发现其实 utools 大多数插件都是和 container 层分离的,也就是说 utools 只是一个插件的容器,为插件提供了一些 api 能力和方法。所以一旦我们实现了utools加载插件的能力,实现 utools 的所有 API 函数,是不是就约等于实现了 utools ! 我们就可以使用 utools 的插件?

utools 方式

按照 utools 的 文档,首先我们需要实现一个插件,必须要有个 plugin.json,这玩意就是用来告诉 utools 插件的信息。我们也按照文档来写:

{
    "pluginName": "helloWorld",
    "description": "我的第一个uTools插件",
    "main": "index.html",
    "version": "0.0.1",
    "logo": "logo.png",
    "features": [
        {
          "code": "hello",
          "explain": "hello world",
          "cmds":["hello", "你好"]
        }
    ]
}

接下来是将写好的插件用 utools 跑起来,按照 utools的交互是复制 plugin.jsonutools搜索框即可,我们也可以实现:

// 监听 input change
// 读取剪切板内容
const fileUrl = clipboard.read('public.file-url').replace('file://', '');
// 复制文件
if (fileUrl && value === 'plugin.json') {
  // 读取 plugin.json 配置
  const config = JSON.parse(fs.readFileSync(fileUrl, 'utf-8'));
  const pluginConfig = {
    ...config,
    // index.html 文件位置,用于webview加载
    sourceFile: path.join(fileUrl, `../${config.main || 'index.html'}`),
    id: uuidv4(),
    type: 'dev',
    icon: 'image://' + path.join(fileUrl, `../${config.logo}`),
    subType: (() => {
      if (config.main) {
        return ''
      }
      return 'template';
    })()
  };
}

实现效果如下:

image.png

接下来就是进行命令搜索插件:

image.png

实现这个功能其实也就是对之前存储的pluginConfig的里面的 features 进行遍历,找到相应的 cmd 后进行下拉框展示即可。

然后我们要去实现选择功能,用 webview 加载页面的能力:

<template>
  <div>
    <webview id="webview" :src="path" :preload="preload"/>
  </div>
</template>
<script>
export default {
  data() {
    return {
      path: `File://${this.$route.query.sourceFile}`,
      preload: `File://${path.join(__static, './preload.js')}`,
      webview: null,
      query: this.$route.query,
      config: {}
    }
  }
}
</script>

image.png

到此结束了?并没有!!!由于篇幅的原因,我们后续再说。本出写的插件demo已上传github: https://github.com/clouDr-f2e/rubick-plugin-demo

目前支持能力

加载utools生态插件

github 上开源的 斗图 插件举例,要加载斗图插件,只需要将代码 clone下来后,复制其 plugin.json 进入搜索框即可使用

斗图:https://github.com/vst93/doutu-uToolsPlugin

image.png

超级面板

长按鼠标右键,即可呼起超级面板,可以根据当前鼠标选择内容,匹配对应插件能力。比如当前选择图片后长按右击,则会呼起上传图床插件:

image.png

模板

为了更贴合 uTools 的插件能力,需要实现模板功能,模板即是一个内置 UI 样式的功能插件。

image.png

utools 自带的系统命令

取色

image.png

截屏

image.png

全局快捷键

image.png

最后

目前 rubick 已经实现 utools 大多数核心能力,最重要的是可以使用 utools 所有生态 ! 更多能力可以前往 github 体验。如果感觉有用,可以帮忙反手一个 star ✨

Rubick github

webpack4 零配置了解一下

webpack4 最主要的卖点便是 0 配置,话不多说,让我们从头开始体验 webpack 4 的一些特性。

entry 和 output

首先创建一个空目录,然后初始化一些配置:

mkdir webpack4-quickstart
cd  webpack4-quickstart
npm init -y

接着,我们需要安装webpack的相关依赖:

npm i webpack --save-dev
npm i webpack-cli --save-dev
  • webpack: 即 webpack 核心库。它提供了很多 API, 通过 Node.js 脚本中 require('webpack') 的方式来使用 webpack。
  • webpack-cli:是 webpack 的命令行工具。webpack 4 之前命令行工具是集成在 webpack 包中的,4.0 开始 webpack 包本身不再集成 cli。

现在,在package.json中添加一下构建命令:

"scripts": {
  "build": "webpack"
}

让我们来运行一下:

npm run build

然后我们就看到了这样的错误:

ERROR in Entry module not found: Error: Can't resolve './src' in '~/webpack4-quickstart'

这是因为webpack4因为没有webpack.config这样的配置文件指定entry,会把./src/index.js作为默认的入口文件。所以我们可以来简单创建一下:

// ./src/index.js
console.log(`I'm a silly entry point`);

然后再次运行:

npm run build

然后发现我们在目录下面生成出了一个这样的一个文件./dist/main.js,这也是因为webpack4会默认指定./dist/main.js作为其输出目录。

production 和 development 模式

webpack 4 以前,拥有2份配置文件是webpack项目常见的情况,一个常规的项目配置可能是这样的:

  • 一份开发环境的配置,用来配置 dev server 和其他的一些东西
  • 一份生产环境的配置,配置一些 UglifyJSPlugin、sourcemaps 等等

但是在webpack 4中,我们可以通过设置命令行参数productiondevelopment来区分环境:

"scripts": {
  "dev": "webpack --mode development",
  "build": "webpack --mode production"
}

让我们运行:

npm run dev

再来看看 ./dist/main.js发现mian.js并没有被压缩了。再试试

npm run build

结果时一个已经被压缩的js。webpack 4另一个新的特性就是mode。可以通过--mode标识开使用。

  • Production mode 可以实现各种优化,包括 代码压缩、tree-shaking...
  • Development mode 则是在速度上进行了优化,只不过不会提供压缩功能。

覆盖默认的 entry/output

webpack 4中我们可以通过下面的方式来覆盖默认的 entry/output

"scripts": {
  "dev": "webpack --mode development ./foo/src/js/index.js --output ./foo/main.js",
  "build": "webpack --mode production ./foo/src/js/index.js --output ./foo/main.js"
}

使用 Babel 转译 ES6

webpack 在没有 babel-loader 的情况下,是没办法进行 ES6 转译的。将 ES6 转成 ES5 语法,我们需要这些依赖:

  • babel-core
  • babel-loader
  • babel-preset-env 编译 ES6 -> ES5

让我们来安装他们:

npm i babel-core babel-loader babel-preset-env --save-dev

下一步是通过 .babelrc来声明编译使用的转换器:

{
    "presets": [
        "env"
    ]
}

在这里我们有2种方式来配置babel-loader:

  • 使用一个 webpack.config.js 来配置
  • npm script 中,使用 --module-bind参数

我们知道webpack 4是0配置的,为什么我们还需要去写这些配置工具能?关于 webpack4 0 配置适用于:

  • entry. 默认 ./src/index.js
  • output. 默认 ./dist/main.js
  • production 和 development mode (不需要创建2份配置文件)

额,也就是这些,所以说如果你需要使用loader,那么你还是需要创建一个webpack 配置文件😊。关于有没有可能将 loader 也加入到0配置中去。Sean 有这样的一段回答:

For the future (after v4, maybe 4.x or 5.0), we have already started the exploration of how a preset or addon system will help define this. What we don’t want: To try and shove a bunch of things into core as defaults What we do want: Allow other to extend

简单的来说,就是在后续系统中会开始探索这一点,但是不会无脑的把什么东西都加入到0配置中去,需要保持其高度的可扩展性。所以我们还是乖乖地先配置我们的webpack.config,js:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader"
        }
      }
    ]
  }
};

这里就不必再定义 entry 和 output 了。
上面说的另一种方式就是通过 --module-bind来定义 loader:

"scripts": {
    "dev": "webpack --mode development --module-bind js=babel-loader",
    "build": "webpack --mode production --module-bind js=babel-loader"
  }

不太推荐这种方式,这样会使 script内容变得臃肿。

基于 Electron 实现 uTools 的超级面板

前言

为了进一步提高开发工作效率,最近我们基于 electron 开发了一款媲美 uTools 的开源工具箱 rubick。该工具箱不仅仅开源,最重要的是可以使用 uTools 生态内所有开源插件!这将是巨大的能力,意味着 uTools 生态内所有插件可以无差异化使用到 rubick 中。

设计交互上为了更能提高用户的使用效率,我们又尝试去实现了 uTools 中非常优秀的一些设计,比如:超级面板。该功能可以通过鼠标快速唤起uTools 插件能力,而不用再打开应用。比如上传图片,只要我们安装了图床插件,那么当鼠标选择桌面上某张图片时,即可快速呼出上传图片的菜单选项,方便省事。接下来一起看看实现方式吧!

代码仓库

Rubick github

功能截图:

文件夹下长按右建

选择文件后长按右键

选择文字后长按右键

实现原理

获取选中文案

要实现改功能核心是要读取当前用户选中的文案或者文件,根据当前选择内容进行不同功能展示。但是核心有一个问题是如何来实现获取当前选中的内容。这个问题思考了很久很久,要想获取选中的文案,感觉唯一的办法是使用 ctrl + c 或者 command + c 来先复制到剪切板,再通过 electron clipboard 来获取当前剪切板内容。但是 utools 可不是通过先复制再长按这样的操作来实现的,而是直接选中文本或者文件长按后呼起超级面板。所以一定要在右击长按前获取到当前选中的内容。

如果要这么干,可能真的无解了,之前就因为这么想,才被无解了。正确的思路应该是先长按再获取选中的内容。别看只是掉了个个,但实现确实天壤之别:

  1. 先获取选中内容:这就要求我们必须监听原生系统选中事件,但是 electron 并没有提供能力,我们也无法监听系统选择事件。
  2. 先右击,后获取内容,这样的好处在于先右击可以通过监听鼠标右击事件,相比选择事件更加容易。

所以思路就有了,先监听长按右击事件:

// macos
const mouseEvents = require("osx-mouse");

const mouseTrack = mouseEvents();
// 按下去的 time
let down_time = 0;

// 是否弹起
let isPress = false;

// 监听右击
mouseTrack.on('right-down', () => {
    isPress = true;
    down_time = Date.now();
    // 长按 500ms 后触发
    setTimeout(async () => {
      if (isPress) {
        // 获取选中内容
        const copyResult = await getSelectedText();
    }, 500);
})
mouseTrack.on('right-up', () => {
    isPress = false;
});

接下来一步就是要去实现获取选中内容,要获取选中内容有个比较*的操作,就是:

  1. 通过 clipboard 先获取当前剪切板内容,并存下 A
  2. 通过 robot.js 来调用系统 command + c 或者 ctrl + c
  3. 再通过 clipboard 先获取当前剪切板内容,并存下 B
  4. 再将 A 写到剪切板中,返回 B

先存剪切板内容的目的在于我们是偷偷帮用户执行了复制动作,当读取完用户选择内容后,需要回复用户之前的剪切板内容。接下来看一下简单的实现:

const getSelected = () => {
  return new Promise((resolve) => {
    // 缓存之前的文案
    const lastText = clipboard.readText('clipboard');

    const platform = process.platform;
    
    // 执行复制动作
    if (platform === 'darwin') {
      robot.keyTap('c', 'command');
    } else {
      robot.keyTap('c', 'control');
    }

    setTimeout(() => {
      // 读取剪切板内容
      const text = clipboard.readText('clipboard') || ''
      const fileUrl = clipboard.read('public.file-url');
      
      // 恢复剪切板内容
      clipboard.writeText(lastText);

      resolve({
        text,
        fileUrl
      })
    }, 300);
  })
}

通知超级面板窗口当前选中内容

当获取到了选中内容后,接下来就是需要创建超级面板的 BrowserWindow:

const { BrowserWindow, ipcMain, app } = require("electron");

module.exports = () => {
  let win;

  let init = (mainWindow) => {
    if (win === null || win === undefined) {
      createWindow();
    }
  };

  let createWindow = () => {
    win = new BrowserWindow({
      frame: false,
      autoHideMenuBar: true,
      width: 250,
      height: 50,
      show: false,
      alwaysOnTop: true,
      webPreferences: {
        webSecurity: false,
        enableRemoteModule: true,
        backgroundThrottling: false,
        nodeIntegration: true,
        devTools: false,
      },
    });
    win.loadURL(`file://${__static}/plugins/superPanel/index.html`);
    win.once('ready-to-show', () => win.show());
    win.on("closed", () => {
      win = undefined;
    });
  };

  let getWindow = () => win;

  return {
    init: init,
    getWindow: getWindow,
  };
};

然后再通知 superPanel 进行内容展示:

 win.webContents.send('trigger-super-panel', {
  ...copyResult,
  optionPlugin: optionPlugin.plugins,
});

超级面板点击操作

接下来要实现超级面板点击操作,这块也是比较简单的了,直接上代码好了:

1. 打开 Terminal

const { spawn } = require ('child_process');

spawn('open', [ '-a', 'Terminal', fileUrl ]);

2. 新建文件

remote.dialog.showSaveDialog({
  title: "请选择要保存的文件名",
  buttonLabel: "保存",
  defaultPath: fileUrl.replace('file://', ''),
  showsTagField: false,
  nameFieldLabel: '',
}).then(result => {
  fs.writeFileSync(result.filePath, '');
});

3. 复制路径

clipboard.writeText(fileUrl.replace('file://', ''))

最后

本篇主要介绍如何实现一个类似于 utools 的超级面板功能,当然这远远不是 utools 的全部,下期我们再继续介绍如何实现 utools 其他能力。欢迎大家前往体验 Rubick 有问题可以随时提 issue 我们会及时反馈。

另外,如果觉得设计实现思路对你有用,也欢迎给个 Star:https://github.com/clouDr-f2e/rubick

使用 Charles 来进行前端多环境开发

前端开发过程中,多环境切换是必不可少的一个环节。新的需求,我们需要发布代码到测试环境,提供给测试一个测试的入口地址。测试完成,我们可能需要一个预发环境,模拟真实线上数据,进行测试。一切确定没问题后,我们会发布线上环境。但是这一整套发布流程中,因为环境切换带来的bug问题,也是时长出现。这里我们主要介绍一下Charles是如何进行多环境的管理工作。

手淘rem适配方案

有了上篇文章的介绍相关基础单位,我们可以大致了解了viewport,dpr 之类的概念。这次主要记录一下手套的移动适配方案rem

什么是rem

CSS3新增的一个相对单位rem(root em,根em)。rem是相对于根节点(或者是html节点)。如果根节点设置了font-size:10px;那么font-size:1.2rem;字体大小等于12px。

如何处理视觉稿

想想看,因为rem是一个相对单位,那么只要根节点的font-size属性随着屏幕自适应,那么下面通过rem设置的单位便可以达到自适应的目的!
当我们拿到设计稿的时候,主要会将视觉稿分成100份(主要为了以后能更好的兼容vh和vw),而每一份被称为一个单位a。同时1rem单位被认定为10a。就相当于1rem可以定为宽度为屏幕的10%。那么针对一个750px宽的设计稿,我们可以算出:

1a   = 7.5px
1rem = 75px 

那么这个设计稿也就被分成了10rem份。此时淘宝开源库lib-flexible会为根节点的html和body元素设置一个data-dprfont-size两个属性。其中dpr在IOS内会根据不同型号的机器动态的计算生成,大多数机型的data-dpr=2但是对于安卓来说,由于其机型特别复杂多样,dpr会全部设置成1。
font-size = 1rem,对于一个750的设计稿来说,font-size=75px,而这个就是rem的基准值。这样一来,对于视觉稿上的元素尺寸换算,只需要原始的px值除以rem基准值即可。例如此例视觉稿中的图片,其尺寸是176px * 176px,转换成为2.346667rem * 2.346667rem。

lib-flexible 原理

1. 计算页面的dpr

if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
        // iOS下,3和3以上的屏,用3倍的方案,2用2倍方案,其余的用1倍方案
        if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {                
            dpr = 3;
        } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
            dpr = 2;
        } else {
            dpr = 1;
        }
    } else {
        // 其他设备下,仍旧使用1倍的方案
        dpr = 1;
    }
    scale = 1 / dpr;
}

2. 改写页面meta标签

flexible实际上就是能过JS来动态改写meta标签,代码类似这样:

var metaEl = doc.createElement('meta');
var scale = 1 / dpr;
metaEl.setAttribute('name', 'viewport');
metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
if (docEl.firstElementChild) {
    document.documentElement.firstElementChild.appendChild(metaEl);
} else {
    var wrap = doc.createElement('div');
    wrap.appendChild(metaEl);
    documen.write(wrap.innerHTML);
}

如何转换rem

对于使用webpack的来说,其实也是有一个postcss插件的px2rem,可以很轻松的配置相关属性,转换成相对于的rem单位。比如我们的.postcssrc 配置如下:

module.exports = {
  "plugins": {
    "postcss-px2rem": {
      baseDpr: 1,
      remUnit: 37.5,
      onePxComment: '1px',
      forcePxComment: '!px',
      keepComment: '!no',
      forcePxProperty: ['font-size'] // font-size强制使用px单位
    }
  }
}

1. 文本字号不建议使用rem

前面大家都见证了如何使用rem来完成H5适配。那么文本又将如何处理适配。是不是也通过rem来做自动适配。

显然,我们在iPhone3G和iPhone4的Retina屏下面,希望看到的文本字号是相同的。也就是说,我们不希望文本在Retina屏幕下变小,另外,我们希望在大屏手机上看到更多文本,以及,现在绝大多数的字体文件都自带一些点阵尺寸,通常是16px和24px,所以我们不希望出现13px和15px这样的奇葩尺寸。

如此一来,就决定了在制作H5的页面中,rem并不适合用到段落文本上。所以在Flexible整个适配方案中,考虑文本还是使用px作为单位。只不过使用[data-dpr]属性来区分不同dpr下的文本字号大小。

配置完成之后,在实际使用时,你只要像下面这样使用:

.selector {
    width: 150px;
    height: 150px; 
    font-size: 12px; 
    border: 1px solid #ddd; 
}

px2rem处理之后将会变成:

.selector {
    width: 2rem;
   height: 2rem;
    border: 1px solid #ddd;
}
[data-dpr="1"] .selector {
    font-size: 12px;
}
[data-dpr="2"] .selector {
    font-size: 28px;
}
[data-dpr="3"] .selector {
    font-size: 42px;
}

因为不同的dpr,屏幕会缩成不同比例,所以这里的字号会根据dpr进行相应的放大。举个例子,如果data-dpr = 2 那么页面的viewport会被缩小,所以字号需要相应的乘以比例进行放大。

厌倦了写活动页?快来撸一个页面生成器吧!

前言

如果你经常接触一些公司的活动页,可能会经常头疼以下问题:这些项目周期短,需求频繁,迭代快,技术要求不高,成长空间也小。但是我们还是马不停蹄的赶着产品提来的一个个需求,随着公司规模的增加,我们不可能无限制的增加人手不断地重复着这些活动。这里我就不具体介绍一些有的没的的一些概念了,因为要介绍的概念实在太多了,作为一个前端的我们,直接上代码撸就好了!!!!
想要了解更多,也欢迎访问:

源地址

blogs

目标

我们的目标是实现一个页面制作后台,在后台中我们可以对页面进行 组件选择 --> 布局样式调整 --> 发布上线 --> 编辑修改这样的流程操作。

架构设计

首先是要能提供组件给用户进行选择,那么我们需要一个组件库,然后需要对选择的组件进行布局样式调整,所以我们需要一个页面编辑后台接着我们需要将编辑产出的数据渲染成真实的页面,所以我们需要一个node服务和用于填充的template 模板。发布上线,这个直接对接各个公司内部的发布系统就好了,这里我们不做过多阐述。最后的编辑修改功能也就是针对配置的修改,所以我们需要一个数据库,这里我选择的是用了mysql。当然你也可以顺便做做权限管理,页面管理....等等之类的活。
啰嗦了这么长,我们来画个图,了解下大概的流程:

开撸

组件的实现

首先我们来实现组件这一部分,因为组件关联着后台编辑的预览和最后发布的使用。组件设计我们应该尽量保持组件的对外一致性,这样在进行渲染的时候,我们可以提供一个统一的对外数据接口。这里我们的技术选型是基于 Vue 的,所以下面的代码部分也主要是基于 Vue 的,但是万变不离其宗,其他语言也类似。

根据上图,我们的组件是会被一个个拆分单独发布到 npm仓库的,为什么这么设计呢?其实之前也考虑过设计成一个组件库,所有组件都包含在一个组件库内,这样只需要发布一个组件库包,用的时候按需加载就好了。后来在实践的过程中发现这样并不合适协同开发,其他前端如果想贡献组件,接入的改造成本也很大。举个🌰:小明在业务中写了个Button组件,这个组件经常会被其他项目复用,他想把这个组件贡献到我们的系统中,被模板使用,如果是一个组件库的话,他首先得拉取我们组件库的代码,然后按照组件库的规范格式进行提交。这样一来,偷懒的小明可能就不太愿意这么干,最爽的方法当然是在本地构建一个npm库,开发选用的是用TypeScript还是其他的我们不关心,选用的 Css 预处理器我们也不关心,甚至编码规范的ESLint我们也不关心。最后只需通过编译后的文件即可。这样就避免了一个组件库的约束。依托于NPM完善的发布/拉取,以及版本控制机制,可以让我们少做一些额外的工作,也可以快速的把平台搭建起来。

说了这么多,代码呢?,我们以一个Button为例,我们对外提供这样的形式组件:

<template>
  <div :style="data.style.container" class="w_button_container">
    <button :style="data.style.btn"> {{data.context}}</button>
  </div>
</template>
<script>

export default {
  name: 'WButton',
  props: {
    data: {
      type: Object,
      default: () => {}
    }
  }
}
</script>

可以看到我们只对外暴露了一个props,这样做法的好处是可以统一组件对外暴露的数据,组件内部爱怎么玩怎么玩。注意,这里我们也可以引入一些第三方组件库,比如mint-ui之类的。

后台编辑的实现

在写代码前,我们先考虑一下需要实现哪些功能:

  1. 一个属性编辑区,提供给使用者编辑组件内部props的功能
  2. 一个组件选择区,提供使用者选择需要的组件
  3. 一个组件预览区,提供使用者拖拽排序页面预览的功能
编辑区的实现

按照顺序,我们先来实现组件的属性编辑功能。我们要考虑,一个组件暴露出哪些可配置的信息。这些可配置的信息如何同步到后台编辑区,让使用者进行编辑,一个按钮的可配置信息可能是这样:

image

如果把这些配置全部写在后台库里面,根据当前选择的组件加载不同的配置,维护起来会相当麻烦,而且随着组件数量的增加,也会变得臃肿,所以我们可以将这些配置存储在服务端,后台只需要根据存储的规则进行解析便可,举个例子,我们其实可以存储这样的编辑配置:

[
  {
    "blockName": "按钮布局设置", 
    "settings": {
      "src": {
        "type": "input",  
        "require": true,
        "label": "按钮文案"
      }
    }
  }
]

我们在编辑后台,通过接口请求到这些配置,便可以进行规则渲染:

    /**
     * 根据类型,选择创建对应的组件
     * @param {VNode} vm
     * @returns {any}
     */
    createEditorElement (vm: VNode) {
      let dom = null
      switch (vm.config.type) {
        case 'align':
          dom = this.createAlignElement(vm)
          break;
        case 'select':
          dom = this.createSelectElement(vm)
          break;
        case 'actions':
          dom = this.createActionElement(vm)
          break;
        case 'vue-editor':
          dom = this.createVueEditor(vm)
          break;
        default:
          dom = this.createBasicElement(vm)
      }
      return dom
    }

组件选择功能

首先我们需要考虑的是,组件怎么进行注册?因为组件被用户选用的时候,我们是需要渲染该组件的,所以我们可以提供一段 node 脚本来遍历所需组件,进行组件的安装注册:

// 定义渲染模板和路径
var OUTPUT_PATH = path.join(__dirname, '../packages/index.js');
console.log(chalk.yellow('正在生成包引用文件...'))
var INSTALL_COMPONENT_TEMPLATE = '    {{name}}';
var IMPORT_TEMPLATE = 'import {{componentName}} from \'{{name}}\'';
var MAIN_TEMPLATE = `/* Automatic generated by './compiler/build-entry.js' */

{{include}}

const components = [
{{install}}
]

const install = function(Vue) {
    components.map((component) => {
        Vue.component(component.name, component)
    })
}

/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue)
}

export {
    install,
{{list}}
}
`;
// 渲染引用文件
var template = render(MAIN_TEMPLATE, {
  include: includeComponentTemplate.join(endOfLine),
  install: installTemplate.join(`,${endOfLine}`),
  version: process.env.VERSION || require('../package.json').version,
  list: listTemplate.join(`,${endOfLine}`)
});

// 写入引用
fs.writeFileSync(OUTPUT_PATH, template);

最后渲染出来的文件大概是这样:

import WButton from 'w-button'
const components = [
    WButton
]
const install = function(Vue) {
    components.map((component) => {
        Vue.component(component.name, component)
    })
}
/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
    install(window.Vue)
}
export {
    install,
    WButton
}

这个也是组件库的通用写法,所以这里的**就是把发布到npm上的组件,进行聚合,聚合成一个组件包引用,我们在后台编辑的时候,是需要全量引入的:

import * as W_UI from '../../packages'

Vue.use(W_UI)

这样,我们组件便注册完了,组件选择区,主要是提供组件的可选项,我们可以遍历组件,提供一个个 List 让用户选择,当然如果我们每个组件如果只提供一个组件名,用户可能并不知道组件长什么样,所以我们最好可以提供一下组件长什么样的缩略图。这里我们可以在组件发布的时候,也通过 node 脚本进行。这里要实现的代码比较多,我就大致说一下过程,因为也不是核心逻辑,可有可无,只能说有了体验上会好一点:

  1. 用户启用 dev-server 进行代码编写测试
  2. server 脚本使用 Chrome 工具 puppeteer,调整页面到手机端模式, 进行当前 dev-server 截图。
  3. 生成截图文件,上传到node服务,关联组件

这样,就可以在加载组件选择区的时候,为组件附上缩略图。

组件预览区

当用户在选择区选择了组件,我们需要展示在预览区域,那么我们怎么知道用户选择了哪些组件呢?总不能提前全部把组件写入渲染区域,通过 v-if来判断选择吧?当然没有这么蠢,Vue 已经提供了动态组件的功能了:

<div 
   :class="[index===currentEditor ? 'active' : '']" 
   :is="select.name" 
   :data="select.data">
</div>

为什么我们不用缩略图代替真实组件?一方面生成的缩略图尺寸存在问题,另一方面,我们需要编辑的联动性,就是编辑区的编辑需要及时的反馈给用户。

额外的问题

说了这么多,貌似一切都很顺利,但是这样在实践的时候,发现了存在一个明显的问题就是:我们中间的预览区域其实就是为了尽可能模拟移动端页面效果。但是如果我们加入了一些包含类似 position: fixed 样式的组件,会发现样式上就出现了明显的问题。典型的比如Dialog Loading 等。
所以我们参考了 m-ui组件库的设计,将中间预览操作容器展示为一个iframe。将iframe大小调整为375 * 667,模拟 iPhone 6 的手机端。这样就不会存在样式问题了。可是这样又出现了另一个难点,那就是左侧的编辑数据如何及时的反应到iframe中?没错,就是postMessgae,大致思路如下:

利用 vuex 做数据存储池,所有的变化,通过 postMessgae进行同步,这样我们只用确保数据池中的数据变化,便可以映射到渲染层的变化。比如,我们在预览区进行了组件选择和拖拽排序,那么我们只需通过vuex出发同步信息便可:

// action.ts
const action = {
  setCurrentPage ({commit, state}, page: number) {
      // 更新当前store
      commit('setCurrentPage',page)
      // 对应postMessage
      helper.postMsgToChild({type: 'syncState', value: state})
    },
  // ...
}

Template 模板的实现

模板的设计实现,我参考了 Vue-cli 2.x 版本的**,把这里的模板,存在了对应的 git 仓库中。当用户需要进行页面构建的时候,直接从 git 仓库中拉取对应的模板即可。当然拉取完,也会缓存一份在本地,以后渲染,直接从本地缓存中读取即可。我们现在把中心放在模板的格式和规范上。模板我们采用什么样的语法无所谓,这里我才用了和 Vue-cli一样的Handlerbars引擎。这里直接上我们模板的设计:

<template>
  <div class="pg-index" :style="{backgroundColor: '{{bgColor}}'}">
      <div class="main-container" :style="{
        backgroundColor: '{{bgColor}}',
        backgroundImage: '{{bgImage}}' ? 'url({{bgImage}})' : null,
        backgroundSize: '{{bgSize}}',
        backgroundRepeat: 'no-repeat'
      }">
        {{#components}}
          <div class="cp-module-editor {{className}} {{data.className}}">
            <{{name}} class="temp-component" :data="{{tostring data}}" data-type="{{upcasefirst name}}"></{{name}}>
          </div>
        {{/components}}
      </div>
  </div>
</template>

<script>
    {{#noRepeatCpsName}}
  import {{upcasefirst this}} from '{{this}}'
    {{/noRepeatCpsName}}
export default {
  name: '{{upcasefirst repoName}}',
  components: {
    {{#noRepeatCpsName}}
      {{upcasefirst this}},
    {{/noRepeatCpsName}}
  }
}
</script>

为了简化逻辑,我们把模板都设计成流式布局,所有组件一个个堆叠往下顺序排列。这个文件便是我们vue-webpack-simple的模板中的App.vue。我们对其进行了改写。这样在数据填充万,便可以渲染出一个 Vue 单文件。这里我只举着一个例子,我们还可以实现多页模板等等复杂的模板,根据需求拉取不同的模板即可。

Node 渲染服务

当后台提交渲染请求的时候,我们的 node 服务所做的工作主要是:

  1. 拉取对应模板
  2. 渲染数据
  3. 编译

拉取也就是去指定仓库中通过download-git-repo插件进行拉取模板。编译其实也就是通过metalsmith静态模板生成器把模板作为输入,数据作为填充,按照handlebars的语法进行规则渲染。最后产出build构建好的目录。在这一步,我们之前所需的组件,会被渲染进package.json文件。我们来看一下核心代码:

// 这里就像一个管道,以数据入口为生成源,通过renderTemplateFiles编译产出到目标目录
function build(data, temp_dest, source, dest, cb) {
  let metalsmith = Metalsmith(temp_dest)
    .use(renderTemplateFiles(data))
    .source(source)
    .destination(dest)
    .clean(false)

  return metalsmith.build((error, files) => {
    if (error) console.log(error);
    let f = Object.keys(files)
      .filter(o => fs.existsSync(path.join(dest, o)))
      .map(o => path.join(dest, o))
    cb(error, f)
  })
}


function renderTemplateFiles(data) {
  return function (files) {
    Object.keys(files).forEach((fileName) => {
      let file = files[fileName]
      // 渲染方法
      file.contents = Handlebars.compile(file.contents.toString())(data)
    })
  }
}

最后我们得到的是一个 Vue 项目,此时还不能直接跑在浏览器端,这里就涉及到当前发布系统所支持的形式了。怎么说?如果你的公司发布系统需要在线编译,那么你可以把源文件直接上传到git仓库,触发仓库的 WebHook 让发布系统替你发掉这个项目即可。如果你们的发布系统是需要你编译后提交编译文件进行发布的,那么你可以通过 node 命令,进行本地构建,产出 HTML,CSS,JS。直接提交给发布系统即可。
到这里,我们的任务就差不多了~具体的核心实心大多已经阐述清楚,如果实现当中有什么问题和不妥,也欢迎一起探讨交流!!

题外话

实现这样一套页面构建系统,其实我这里简化了很多东西,旨在给大家提供一种思路。另外,其实我们的页面全部在服务端构建的时候产出,我们可以再服务端这一层做很多工作,比如页面的性能优化,因为页面数据我们全部都有,我们也可以做页面的预渲染,骨架屏,ssr,编译时优化等等。而且我们也可以对产出的活动页做数据分析~有很多想象的空间。

tinypng-upload一键压缩上传工具

关于 tinypng-upload

这是一个基于 electron的图片压缩上传工具,压缩过程主要通过调用tinypng提供的API完成。上传配置参考iView的文件上传配置。
因为是桌面端,所以很方便我们将图片拖拽到任务托盘进行压缩上传,极大地提升了前端的工作效率,可以让我们更专注于业务开发。

操作过程:

image

image

压缩前后体积对比(图片压缩完成已自动上传到指的CDN):

使用

1. 下载可执行文件

因为暂时没有发布到应用商店,所以需要自己编译出可执行文件:

git clone https://github.com/muwoo/tinypng-upload.git
cd tinypng-upload
npm i
npm run build

然后会在build目录下生成对应的可执行文件,运行改文件即可

2. 配置tinypng API key

因为该项目压缩过程是通过调用 tinypng API来实现的,所以我们需要去tinypng网站上注册一个API key

然后将该值粘贴到我们的配置一栏中:
image

只不过有一点限制,免费的每个月可以压缩 500 张图片。github 上也有人通过循环注册的过程,生成了多个账户API key达到近似于不限制压缩次数的目的。有兴趣也可以了解一下~

3. 配置压缩后图片上传请求

压缩完成之后,我们希望图片可以直接上传到我们公司的CDN上,tiny-png upload上传参考了iView的图片上传参数和设置:上传 upload

Property Decription Type Default
action Upload request URL, required. String -
headers Upload request header. Object {}
data Extra data with upload request. Object {}
name The key in upload request targeting to the file. String file
with-credentials Enable certification info in Cookie or not. Boolean false

灵感来源

之前在掘金上看了一篇关于electron图片上传的工具PicGo,很感谢作者提供的文章参考PicGo的star数破1000的心路历程

前端路由简介以及vue-router实现原理

后端路由简介

路由这个概念最先是后端出现的。在以前用模板引擎开发页面时,经常会看到这样

http://www.xxx.com/login

大致流程可以看成这样:

  1. 浏览器发出请求

  2. 服务器监听到80端口(或443)有请求过来,并解析url路径

  3. 根据服务器的路由配置,返回相应信息(可以是 html 字串,也可以是 json 数据,图片等)

  4. 浏览器根据数据包的 Content-Type 来决定如何解析数据

简单来说路由就是用来跟后端服务器进行交互的一种方式,通过不同的路径,来请求不同的资源,请求不同的页面是路由的其中一种功能。

前端路由

1. hash 模式

随着 ajax 的流行,异步数据请求交互运行在不刷新浏览器的情况下进行。而异步交互体验的更高级版本就是 SPA —— 单页应用。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的,为了实现单页应用,所以就有了前端路由。
类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 url 路径,进行解析,然后动态的渲染出区域 html 内容。但是这样存在一个问题,就是 url 每次变化的时候,都会造成页面的刷新。那解决问题的思路便是在改变 url 的情况下,保证页面的不刷新。在 2014 年之前,大家是通过 hash 来实现路由,url hash 就是类似于:

http://www.xxx.com/#/login

这种 #。后面 hash 值的变化,并不会导致浏览器向服务器发出请求,浏览器不发出请求,也就不会刷新页面。另外每次 hash 值的变化,还会触发hashchange 这个事件,通过这个事件我们就可以知道 hash 值发生了哪些变化。然后我们便可以监听hashchange来实现更新页面部分内容的操作:

function matchAndUpdate () {
   // todo 匹配 hash 做 dom 更新操作
}

window.addEventListener('hashchange', matchAndUpdate)

2. history 模式

14年后,因为HTML5标准发布。多了两个 API,pushStatereplaceState,通过这两个 API 可以改变 url 地址且不会发送请求。同时还有 popstate 事件.调用history.pushState()或者history.replaceState()不会触发popstate事件. popstate事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在JavaScript中调用history.back()、history.forward()、history.go()方法).。通过这些就能用另一种方式来实现前端路由了,但原理都是跟 hash 实现相同的。用了 HTML5 的实现,单页路由的 url 就不会多出一个#,变得更加美观。但因为没有 # 号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求。为了避免出现这种情况,所以这个实现需要服务器的支持,需要把所有路由都重定向到根页面。

function matchAndUpdate () {
   // todo 匹配路径 做 dom 更新操作
}

window.addEventListener('popstate', matchAndUpdate)

Vue router 实现

我们来看一下vue-router是如何定义的:

import VueRouter from 'vue-router'
Vue.use(VueRouter)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
})

new Vue({
  router
  ...
})

可以看出来vue-router是通过 Vue.use的方法被注入进 Vue 实例中,在使用的时候我们需要全局用到 vue-routerrouter-viewrouter-link组件,以及this.$router/$route这样的实例对象。那么是如何实现这些操作的呢?下面我会分几个章节详细的带你进入vue-router的世界。

vue-router 实现 -- install

vue-router 实现 -- new VueRouter(options)

vue-router 实现 -- HashHistory

vue-router 实现 -- HTML5History

vue-router 实现 -- 路由变更监听

造轮子 -- 动手实现一个数据驱动的 router

经过上面的阐述,相信您已经对前端路由以及vue-router有了一些大致的了解。那么这里我们为了贯彻无解肥,我们来手把手撸一个下面这样的数据驱动的 router

new Router({
  id: 'router-view', // 容器视图
  mode: 'hash', // 模式
  routes: [
    {
      path: '/',
      name: 'home',
      component: '<div>Home</div>',
      beforeEnter: (next) => {
        console.log('before enter home')
        next()
      },
      afterEnter: (next) => {
        console.log('enter home')
        next()
      },
      beforeLeave: (next) => {
        console.log('start leave home')
        next()
      }
    },
    {
      path: '/bar',
      name: 'bar',
      component: '<div>Bar</div>',
      beforeEnter: (next) => {
        console.log('before enter bar')
        next()
      },
      afterEnter: (next) => {
        console.log('enter bar')
        next()
      },
      beforeLeave: (next) => {
        console.log('start leave bar')
        next()
      }
    },
    {
      path: '/foo',
      name: 'foo',
      component: '<div>Foo</div>'
    }
  ]
})

思路整理

首先是数据驱动,所以我们可以通过一个route对象来表述当前路由状态,比如:

current = {
    path: '/', // 路径
    query: {}, // query
    params: {}, // params
    name: '', // 路由名
    fullPath: '/', // 完整路径
    route: {} // 记录当前路由属性
}

current.route内存放当前路由的配置信息,所以我们只需要监听current.route的变化来动态render页面便可。

接着我么需要监听不同的路由变化,做相应的处理。以及实现hashhistory模式。

数据驱动

这里我们延用vue数据驱动模型,实现一个简单的数据劫持,并更新视图。首先定义我们的observer

class Observer {
  constructor (value) {
    this.walk(value)
  }

  walk (obj) {
    Object.keys(obj).forEach((key) => {
      // 如果是对象,则递归调用walk,保证每个属性都可以被defineReactive
      if (typeof obj[key] === 'object') {
        this.walk(obj[key])
      }
      defineReactive(obj, key, obj[key])
    })
  }
}

function defineReactive(obj, key, value) {
  let dep = new Dep()
  Object.defineProperty(obj, key, {
    get: () => {
      if (Dep.target) {
        // 依赖收集
        dep.add()
      }
      return value
    },
    set: (newValue) => {
      value = newValue
      // 通知更新,对应的更新视图
      dep.notify()
    }
  })
}

export function observer(value) {
  return new Observer(value)
}

再接着,我们需要定义DepWatcher:

export class Dep {
  constructor () {
    this.deppend = []
  }
  add () {
    // 收集watcher
    this.deppend.push(Dep.target)
  }
  notify () {
    this.deppend.forEach((target) => {
      // 调用watcher的更新函数
      target.update()
    })
  }
}

Dep.target = null

export function setTarget (target) {
  Dep.target = target
}

export function cleanTarget() {
  Dep.target = null
}

// Watcher
export class Watcher {
  constructor (vm, expression, callback) {
    this.vm = vm
    this.callbacks = []
    this.expression = expression
    this.callbacks.push(callback)
    this.value = this.getVal()

  }
  getVal () {
    setTarget(this)
    // 触发 get 方法,完成对 watcher 的收集
    let val = this.vm
    this.expression.split('.').forEach((key) => {
      val = val[key]
    })
    cleanTarget()
    return val
  }

  // 更新动作
  update () {
    this.callbacks.forEach((cb) => {
      cb()
    })
  }
}

到这里我们实现了一个简单的订阅-发布器,所以我们需要对current.route做数据劫持。一旦current.route更新,我们可以及时的更新当前页面:

  // 响应式数据劫持
  observer(this.current)

  // 对 current.route 对象进行依赖收集,变化时通过 render 来更新
  new Watcher(this.current, 'route', this.render.bind(this))

恩....到这里,我们似乎已经完成了一个简单的响应式数据更新。其实render也就是动态的为页面指定区域渲染对应内容,这里只做一个简化版的render:

  render() {
    let i
    if ((i = this.history.current) && (i = i.route) && (i = i.component)) {
      document.getElementById(this.container).innerHTML = i
    }
  }

hash 和 history

接下来是hashhistory模式的实现,这里我们可以沿用vue-router的**,建立不同的处理模型便可。来看一下我实现的核心代码:

this.history = this.mode === 'history' ? new HTML5History(this) : new HashHistory(this)

当页面变化时,我们只需要监听hashchangepopstate事件,做路由转换transitionTo:

  /**
   * 路由转换
   * @param target 目标路径
   * @param cb 成功后的回调
   */
  transitionTo(target, cb) {
    // 通过对比传入的 routes 获取匹配到的 targetRoute 对象
    const targetRoute = match(target, this.router.routes)
    this.confirmTransition(targetRoute, () => {
      // 这里会触发视图更新
      this.current.route = targetRoute
      this.current.name = targetRoute.name
      this.current.path = targetRoute.path
      this.current.query = targetRoute.query || getQuery()
      this.current.fullPath = getFullPath(this.current)
      cb && cb()
    })
  }

  /**
   * 确认跳转
   * @param route
   * @param cb
   */
  confirmTransition (route, cb) {
    // 钩子函数执行队列
    let queue = [].concat(
      this.router.beforeEach,
      this.current.route.beforeLeave,
      route.beforeEnter,
      route.afterEnter
    )
    
    // 通过 step 调度执行
    let i = -1
    const step = () => {
      i ++
      if (i > queue.length) {
        cb()
      } else if (queue[i]) {
        queue[i](step)
      } else {
        step()
      }

    }
    step(i)
  }
}

这样我们一方面通过this.current.route = targetRoute达到了对之前劫持数据的更新,来达到视图更新。另一方面我们又通过任务队列的调度,实现了基本的钩子函数beforeEachbeforeLeavebeforeEnterafterEnter
到这里其实也就差不多了,接下来我们顺带着实现几个API吧:

  /**
   * 跳转,添加历史记录
   * @param location 
   * @example this.push({name: 'home'})
   * @example this.push('/')
   */
  push (location) {
    const targetRoute = match(location, this.router.routes)

    this.transitionTo(targetRoute, () => {
      changeUrl(this.router.base, this.current.fullPath)
    })
  }

  /**
   * 跳转,添加历史记录
   * @param location
   * @example this.replaceState({name: 'home'})
   * @example this.replaceState('/')
   */
  replaceState(location) {
    const targetRoute = match(location, this.router.routes)

    this.transitionTo(targetRoute, () => {
      changeUrl(this.router.base, this.current.fullPath, true)
    })
  }

  go (n) {
    window.history.go(n)
  }

  function changeUrl(path, replace) {
    const href = window.location.href
    const i = href.indexOf('#')
    const base = i >= 0 ? href.slice(0, i) : href
    if (replace) {
      window.history.replaceState({}, '', `${base}#/${path}`)
    } else {
      window.history.pushState({}, '', `${base}#/${path}`)
    }
  }

到这里也就基本上结束了。源码地址:

动手撸一个 router

有兴趣可以自己玩玩。实现的比较粗陋,如有疑问,欢迎指点。

前言

前言:可视化搭建诞生背景

关于我

笔者曾经在 51信用卡从0到1设计实现过一套基于 h5 活动的可视化搭建体系,半年上线了近 1000 + 活动页。后面去了 蚂蚁集团 接触到了内部使用的 凤蝶 可视化搭建体系,让我对可视化搭建有了一起额外的认知。现在也是在当前公司负责可视化搭建这块的内容,可以说在 H5 可视化搭建领域,自己也是有一点摸爬滚打的经验,可以给大家分享,如果这个专题可以给你们带来一些灵感或者规避掉一些问题,那这个专题的目的就达到了。

什么是可视化搭建

可视化搭建字面意思就是通过 GUI 可视化交互搭建出之前通过代码开发出来的界面,可视化搭建并不是一个新词,如果之前接触过安卓开发的同学应该有用过 Android Studio,其便是一款支持可视化拖拉生成代码的开发工具:

Android Studio 可视化演示

当然,前端在很早之前也有很多可以搭建页面的 GUI 工具,比如 Dreamweaver

Dreamweaver 可视化演示

为什么需要可视化搭建

C 端活动

公司的发展离不开用户,公司业务的发展势必需要诞生很多的交互页面,比如活动、问卷等等。这些页面都有一些共性:活动周期短、发布频率快、需求重复多。但公司为了降低运营成本,是不会允许无限制的增加开发人力去做这些页面。而且对开发而言,重复劳动并不是一个最优解。所以我们可能需要有一个可视化搭建的能力,去助力业务,把开发的职责归聚到解决问题的本身.

中后台系统

对于中后台系统需求复杂度较低,至少 90% 的页面可以形成标准化。所以大量人员投入在重复性的工作上,以致于成长空间窄小,也无法发挥开发人员实际能力。
如果中后台体系可以实现由核心团队研发,能够给公司内包括前端、后端、测试等研发人员使用,通过简单的图形化配置和低代码,不仅直接实现业务需求,还提供权限、环境、灰度、埋点、监控等能力。那必将会大大缩减前端开发的负担和压力,也会加速业务进展。

现阶段的可视化搭建的分类

笔者在写可视化搭建工具之前,调研了现在市面上的比较常见的几款可视化搭建工具,并根据其功能特性,按照 3 个维度进行了一个简单的分类:

可视化搭建分类

以上调研了那么多业界比较牛的前端可视化的框架工程,大致思路是类似的,百家争鸣,但是总的来看还是存在以下两点问题:

  1. 交互逻辑需要侵入开发,无法自动生成
  2. 只能在受限、具体的业务场景下发挥作用

这两个问题存在就会导致我们生产设计出来的东西需要 low code,那么 low code 面向的用户肯定是有一定编程经验的前端同学或者后端同学。但这种设计模式维护性较差,一般适用于非产品化的页面。

从工具开发的角度说, 页面可视化搭建工具是需要架构设计的, 不同工具的区分, 其实是不同的页面可视化搭建框架间的差异。本小册不会一一介绍如何实现这么多类型的可视化搭建,我们核心是通过其中一种类型来详细解剖其中的架构和技术细节原理。

目前互联网公司核心业务需求一般都是继续篇业务功能的搭建体系,比如活动页等场景,所以本小册也主要从这里出发,一步步去设计一个基于业务功能组件化的 h5 可视化搭建能力。

相关链接地址:

总结

本章节我们主要通过可视化搭建历史被分类介绍了可视化搭建相关知识,希望可以帮你建立一个最初的认知体系,大概清楚可视化搭建是个啥,要解决什么问题。那我们接下来就好开展工作了。

可视化编辑区实现

可视化编辑区实现

前面几个小节的介绍可能大家没有直观的感觉到跟可视化搭建的具体联系,因为还没有涉及到编辑这一块的内容。本小节开始,我们就可以开始设计编辑器的相关工作啦!

初始化项目

编辑器我们采用的是基于 vue3 来的,为啥不是 react 或者 vue 2.x,纯属个人喜好,别无他意。当然用什么框架不是重点,重点是只要我们掌握了**就可以“为所欲为”。好了,先初始化 vue3 项目,对于 Vue 3,你应该使用 npm 上可用的 Vue CLI v4.5 作为 @vue/cli@next

npm install -g @vue/cli@next

vue create coco-web

然后选择 vue3 模板一路到底。这里的目录结构我们调整我不做详细介绍,因为跟可视化搭建没啥关系,但是方便大家后续阅读,先把我自己的目录结构贴出来:

coco-web
├─babel.config.js
├─package.json
├─vue.config.js
├─src
|  ├─App.vue
|  ├─main.js
|  ├─router
|  ├─pages
|  |   ├─edit
|  |   |   └index.vue
|  ├─hooks
|  ├─components
|  ├─common
|  ├─assets
|  ├─api
├─public

如何获取模板&组件信息

前面几个章节我们主要介绍的模板和组件如何给编辑器发消息,那么编辑器如何收消息?我们编辑器对模板的展示采用的是 iframe 的方式,之前我们提了采用 iframe 的方式是为了模板和编辑器解耦。(其实还有另一个原因:我们中间的预览区域其实就是为了尽可能模拟移动端页面效果。但是如果我们加入了一些包含类似 position: fixed 样式的组件,会发现样式上就出现了明显的问题。典型的比如 Dialog Loading 等)。

所以我们也是可以通过监听postMessage 的方式来实现消息接收:

// 监听事件
window.addEventListener('message',(e) => {
  // 不接受消息源来自于当前窗口的消息
  if (e.source === window || e.data === 'loaded') {
    return;
  }
  // 调用消息处理函数,对传入的数据进行格式化
  store.commit(e.data.type, e.data.data);
});

上面的代码简单的展示了我们会通过 vuex 来管理消息通知后的调用函数,比如接受模板的信息通知:

const mutations = {
  returnConfig(state, payload) {
    // todo 数据结构处理
  }
}

如何编辑

到这里,我们完成了对模板消息的接收动作,最终编辑器接收到的 模板消息 + server端全局组件 信息后我们格式化后是这样的:

{
  "components": [
    {
      "snapshot": "",
      "description": "banner组件",
      "name": "coco-banner",
      "schema": {"type": "object", "properties": {}},
    },
    {
      "snapshot": "",
	  "description": "form组件",
      "name": "coco-form",
      "schema": {"type": "object", "properties": {}},
    }
  ],
  "userSelectComponent": [
    {
      "snapshot": "",
      "description": "banner组件",
      "name": "coco-banner",
      "schema": {"type": "object", "properties": {}},
    },
    {
      "snapshot": "",
     "description": "form组件",
      "name": "coco-form",
      "schema": {"type": "object", "properties": {}},
    },
    {
      "snapshot": "",
     "description": "banner组件",
      "name": "coco-banner",
      "schema": {"type": "object", "properties": {}},
    }
  ],
  "remoteComponents": [
    {
      "name": "coco-global-banner",
      "description": "全局banner组件",
      "snapshot": "",
      "config": {
        "js": "",
        "css": ""
      }
    }
  ]
}

这样我们的模板就可以同过 userSelectComponents 来进行页面的动态渲染,编辑区则根据 componentsremoteComponent 来渲染对应的组件选择区域,用于提供用户选择。到这里我们就完成了可选组件部分。接下来我们需要对页面进行可视化编辑。

1. 选择组件

可视化编辑器要实现的第一个功能就是对页面组件进行选择性编辑,要实现这个功能,我们先看一下市面上的可视化搭建体系的组件选择功能是什么交互,先看一下 h5dooring:

可以看到是可以对模板页面直接进行高亮选中,之后进行拖拽排序。直接选中我们可以做,但是进行拖拽排序我们无法实现,为啥这么说呢?可以想象一下,我们的模板和编辑器是通过 iframe 通信的,若要进行拖拽排序,则模板内部必须需要对排序动作进行实现,主要的是交互动画。当然实现并不是难事,我们可以利用 Vue.Draggable 不超过20行代码即可搞定。

但是吧,这并不符合设计模式,因为就相当于我们的模板页面需要为了编辑器的功能强行注入了不应该拥有的代码,而且万一 Vue.Draggable 这玩意有啥浏览器兼容性 bug 或者其他的问题,我们的开发同学是不可控的,本身就不属于我们的业务需求。所以我们暂时不采用这种方案,我们再看一下云凤蝶的交互:

可以看到他的实现也是基于 iframe 的方式,没有直接对模板的结构进行拖拽编辑,选中也是通过无侵入方式的编辑器特定区块高亮实现。这个操作交互正好符合我们的设计。
但是再思考一个问题,我们要实现无侵入方式的高亮,必然得得知当前点击了哪个组件,以及组件的高度,我们才好把这块区域画出来。这就涉及到父级操作子 iframe的相关问题,

先说结论:只需要我们对页面和编辑器设置相同主域,便可以进行跨域操作,获取子 iframe 的元素:

 const eventInit = () => {
  // 获取子 iframe 的 dom
  const componentsPND = document.getElementById('frame').contentWindow.document.getElementById('slider-view');
  // 为页面组件绑定 click 事件
  componentsPND.addEventListener('click', (e) => {
    let node = e.target;
    // 遍历元素,找到以 'coco-render-id-_component_'  作为 id 的组件元素,计算高度和位置
    while(node.tagName !== 'HTML') {
      let currentId = node?.getAttribute('id') || '';
      if (currentId.indexOf('coco-render-id-_component_') >= 0) {
        const top = getElementTop(node);
        const { height } = getComputedStyle(node);
        restStyle(height, top, 'activeStyle');
      }
      node = node.parentNode;
    }
  });
  // 为页面组件绑定 mouseover 事件
  componentsPND.addEventListener('mouseover', (e) => {
    let node = e.target;
    // 遍历元素,找到以 'coco-render-id-_component_'  作为 id 的组件元素,计算高度和位置
    while(node.tagName !== 'HTML') {
      let currentId = node?.getAttribute('id') || '';
      if (currentId.indexOf('kaer-render-id-_component_') >= 0) {
        try {
          const top = getElementTop(node);
          const { height } = getComputedStyle(node);
          restStyle(height, top, 'hoverStyle');
        } catch (e) {
          // ignore
        }

      }
      node = node.parentNode;
    }
  });
}

找到需要标记的元素后,我们需要对其做高亮展示(这里需要注意的一点是需要判断一下操作按钮浮层是否超出编辑器展示范围,如果超出了需要重置上去):

  const restStyle = (height, top, type) => {
    state[type] = {
      height,
      top: `${top}px`,
    };
    nextTick(() => {
      const toolND = document.getElementById('se-view-tools');
      const toolHeight = parseInt(getComputedStyle(toolND).height, 10);
      state.toolStyle = {
        top: `${top + 10 + toolHeight > state.containerHeight ? top - toolHeight + parseInt(height, 10) : top + 10}px`,
      };
    });
  }

这样我们便完成了对可视化编辑区的类似于 云凤蝶 那样的标记工作。

2. 调整组件顺序

调整组件顺序,我们也可以采用类似于云凤蝶那样的实现方式,在可视化编辑区右侧挂上编辑操作按钮:

<div
    v-show="toolStyle.top"
    :style="{
      top: toolStyle.top
    }"
    class="se-view-tools"
    id="se-view-tools"
>
  <div :class="['sev-tools-move', (isTop || isBottom) && 'sev-tools-move-single']">
    <ArrowUpOutlined @click="changeIndex(-1)" v-if="!isTop" />
    <ArrowDownOutlined @click="changeIndex(1)" v-if="!isBottom" />
  </div>
  <div class="sev-tools-copy">
  	<CopyOutlined @click="copyComponent" />
  </div>
  <div class="sev-tools-copy">
  	<DeleteOutlined @click="() => deleteComponent()" />
  </div>
</div>
<script>
export default {
  setup() {
    const changeIndex = (op) => {
      postMsgToChild({type: 'sortComponent', data: {op, index: editorState.current}});
    };
    
    const copyComponent = () => {
      postMsgToChild({type: 'copyComponent', data: editorState.current});
    };

    const deleteComponent = (index) => {
      postMsgToChild({
        type: 'deleteComponent',
        data: index !== undefined ? index : editorState.current,
      });
    };
    
    return {
      changeIndex,
      copyComponent,
      deleteComponent
    } 
  }
}
</script>

3. 添加组件

添加组件,我们可以通过拖拽的方式动态的添加到编辑区,这个需要用的一个 html 的一个拖方的 API HTML 拖放 API

确定什么是可拖拽的

我们的组件预览是可拖拽的:

<div class="list-view">
  <div
    @dragstart="(e) => setDragStart(e, true, item)"
    @dragend="(e) => setDragStart(e, false)"
    draggable
    class="co-item"
    :key="index"
    v-for="(item, index) in components"
  >
    <el-image
      class="preview-item"
      :src="item.snapshot"
      fit="fit"
    />
    <div class="co-title">{{item.description}}</div>
  </div>
</div>
定义拖拽数据

将组件的信息,定义成拖拽数据

 setDragStart({ev, v, data}) {
  if (data) {
    ev.dataTransfer.setData("text/plain", JSON.stringify(data));
  }
}
定义一个放置区
<div
  @drop="drop_handler"
  @dragover="dragover_handler"
  v-show="editState.uiConfig.dragStart"
  class="drag-hover"
/>

总结

本章我们主要介绍了编辑器如何显示对模板页面的编辑功能,接下来我们尝试着再思考一个问题:模板很多都是跟业务挂钩的,所以大多数都会发起业务的 api 请求,如果我们用的 api 返回的数据格式不对,或者缺少某些内容,那么整个操作区的交互将会非常丑陋,使用者也无法看到页面的所有功能,那么要怎么解决?以及如何对编辑后的页面进行预览?

Symbols, Iterators, Generators, Async/Await, Async Iterators  的那些事

JS 有很多特性,往往需要关联在一起才能更好地理解彼此,如果单独去看某一部分,可能理解其功能设计就会比较吃力。这篇文章主要是将其联系在一起探讨一下他们的出现背景和使用方式。

Symbols

ES6 引入了一种新的原始数据类型Symbol,表示独一无二的值。它是 JavaScript 语言的第七种数据类型,前六种是:undefined、null、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。

那么为什么需要这样一种数据类型呢?

1. 为对象向后兼容的添加新属性

某些情况下,我们需要为 object 对象添加一些属性,但同时不会影响属性已经存在的一些方法。比如:for infor of循环。或者Object.keysObject.getOwnPropertyNames()JSON.stringify()

举个例子🌰:现在我们需要向服务端发送一个通过 JSON.stringify 转换过的字符串,我们可以这么写

var myObject = { 
    firstName:  'mu', 
    lastName:  'woo'
}

var myObjectStr = JSON.stringify(myObject)

ajax.post(url, {str: myObjectStr})

如果我们此时需要再为myObject 添加一个新的属性newproperty,那么我们 ajax 请求的myObjectStr 属性便会又多了一个属性newproperty。这个时候如果你添加一个Symbol类型的属性,那么JSON.stringify将依然返回之前那样的数据格式:

var newproperty = Symbol()

var myObject = { 
    firstName:  'mu', 
    lastName:  'woo',
    [newproperty]: 'test'
}

var myObjectStr = JSON.stringify(myObject) // "{"firstName":"mu","lastName":"woo"}"

ajax.post(url, {str: myObjectStr})

2. 避免命名冲突

有些情况,我们希望我们的属性是独一无二的,通过 Symbol 这种方式,可以不断向全局添加新属性(并且可以添加对象属性),而不用担心名称冲突。
假如我们现在需要向 Array 对象中添加一个新的属性toUpperCase ,我们可能会这么做:

Array.prototype.toUpperCase = function () {
   return this.map(function (item) { 
       return item.toUpperCase() 
    })
}

var myArray = ["mu", "woo"]

myArray.toUpperCase() // ["MU", "WOO"]

设想一下,假如我们再引入其他的库,或者说 ES2019 支持了Array.prototype.toUpperCase这样的特性,那么我们的命令将会存在冲突。所以,Symbol 作为独一无二的命名就可以解决这样的问题:

var toUpperCase = Symbol('this is a function')

Array.prototype[toUpperCase] = function () {
   return this.map(function (item) { 
       return item.toUpperCase() 
    })
}

var myArray = ["mu", "woo"]

myArray[toUpperCase]() // ["MU", "WOO"]

3. 通过Symbol,调用语言内部使用的方法

除了定义自己使用的 Symbol 值以外,ES6 还提供了 11 个内置的 Symbol 值,指向语言内部使用的方法。比如instanceof:对象的Symbol.hasInstance属性,指向一个内部方法。当其他对象使用instanceof运算符,判断是否为该对象的实例时,会调用这个方法。比如,foo instanceof Foo在语言内部,实际调用的是FooSymbol.hasInstance

class MyClass {
  [Symbol.hasInstance](foo) {
    return foo instanceof Array;
  }
}

[1, 2, 3] instanceof new MyClass() // true

我们也可以对其进行改写:

class Even {
  static [Symbol.hasInstance](obj) {
    return Number(obj) % 2 === 0;
  }
}

// 等同于
const Even = {
  [Symbol.hasInstance](obj) {
    return Number(obj) % 2 === 0;
  }
};

1 instanceof Even // false
2 instanceof Even // true
12345 instanceof Even // false

Iterator

遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是 ES6 创造了一种新的遍历命令for...of循环,Iterator 接口主要供for...of消费。

Iterator 的遍历过程是这样的。

(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。

(2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。

(3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。

(4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。

每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value和done两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。

下面是一个模拟next方法返回值的例子。

var it = makeIterator(['a', 'b']);

it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }

function makeIterator(array) {
  var nextIndex = 0;
  return {
    next: function() {
      return nextIndex < array.length ?
        {value: array[nextIndex++], done: false} :
        {value: undefined, done: true};
    }
  };
}

ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内。
凡是部署了Symbol.iterator属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象:

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

基于 electron 实现简单易用的抓包、mock 工具

背景

经常我们要去看一些页面所发出的请求时,经常会用到 Charles 做为抓包工具来进行接口抓取,但一方面市面是很多抓包工具都是收费或者无法二次开发的。当前我们团队大多数用的也都是 Charles,但是对于一般新手来说,单纯想抓个包或者修改和接口返回数据,直接上手 Charles 不管配置成本和学习成本都相对较高。所以我们有必要自己按照自己最爽的状态来撸一个适合自己的抓包工具。

结果

基于以上诉求,我们自己重新设计并修改了交互,搞了一个符合我们诉求,使用简单的桌面端抓包工具。本身这个插件是可以集成到 utools 里面的,但是由于很多涉及到内部的功能,我们没法通过 utools 进行发布,所以我们自己做了一个可以使用 utools 所有生态的工具箱 Rubick。此次抓包工具也是基于 Rubick 的插件工具。

试玩地址:

Rubick: https://github.com/clouDr-f2e/rubick

抓包插件:https://github.com/clouDr-f2e/rubick-network

这次我们先不看代码,直接来看我们实现的抓包工具的效果:

支持HTTP/HTTPS请求抓取。

1624454215086_2E5F5BE8-7687-4301-A2A9-859674E5BA09.png

支持接口mock

只需要将需要mock 的接口,右击加入 mock 即可完成对接口数据的mock动作,后续所有接口请求将会走到该mock场景:

image.png

支持代理转发

image.png

如何使用

首先我们需要clone Rubick 工具箱,他是一个基于 Electron 的类似于 utools 的工具箱,
仓库地址:Rubick: https://github.com/clouDr-f2e/rubick 然后可以本地运行:

npm i
npm run dev

之后我们会看到这样的界面:

image.png

如果熟悉 utools 的同学,可以直接略过后面的步骤了。

然后再 clonerubick-network 插件,一切准备就绪后,直接复制 rubick-network 下的 plugin.jsonrubick 输入框 选择新建插件即可看到插件信息:

image.png

然后开启插件,即可进行搜索使用!

image.png

相比 utools 而言,rubick 最大的优势是开源!!! 欢迎社区一起建设完善 rubick

技术实现

以后我们再来介绍一下是如何实现这样一款抓包代理工具的。在实现抓包代理工具之前,首先大家需要去自学一下关于 nodejs 实现代理 的一些知识点,这里推荐几篇不错的文章:

HTTP 代理原理及实现(一)

HTTP 代理原理及实现(二)

简单来讲就是要实现一个中间人,用户通过设置代理,网络请求就会通过中间人代理,再发往正式服务器。

这种中间人的实现方式有两种。

一种为普通的HTTP代理,通过Node.js开启一个HTTP服务,并将我们的浏览器或手机设置到该服务所在的ip和端口,那么HTTP流量就会经过该代理,从而实现数据的拦截。

image.png

对于非HTTP请求,比如HTTPS, 或其他应用层请求。可以通过在Node.js 中开启一个TCP服务,监听CONNECT请求,因为应用层也是基于传输层的,所以数据在到达应用层之前会首先经过传输层,从而我们能实现传输层数据监听。

image.png

但是对于CONNECT捕抓到的请求,无法获取到HTTP相关的信息,包括头信息等,这对一般的前端分析作用不大,那么想要真正监听HTTPS,还需要支持证书相关的验证。

关于证书如何生成网上也有很多教程,这里就不在赘述,可以自行百度。不过看了一圈别人的设计,自己再动手实现一个 http 代理服务就是轻而易举的事情了,但是为了更加快捷的实现功能,这里我们选择了 anyproxy 作为基础服务,站在巨人的肩膀上进行开发。

anyproxy 安装后提供了一个 websocket 服务,我们只需要监听 websocket 端口对代理过来的数据进行动态展示即可:

function initWs(wsPort = 8002, path = 'do-not-proxy') {
  if(!WebSocket){
    throw (new Error('WebSocket is not supported on this browser'));
  }

  const wsClient = new WebSocket(`ws://localhost:${wsPort}/${path}`);
  wsClient.onerror = (error) => {
    console.error(error);
  };

  wsClient.onopen = (e) => {
    console.info('websocket opened: ', e);
  };

  wsClient.onclose = (e) => {
    console.info('websocket closed: ', e);
  };

  return wsClient;
}

// 链接websocket
const connectWs = () => {
  const wsClient = ws.initWs();
  wsClient.addEventListener('message', (e) => {
    const data = JSON.parse(e.data);
    store.commit('addRecord', data.content);
  });
}

但是 anyproxy 仅仅提供 node 端的能力,所以正好 electron 可以使用 nodejs 能力,也就是我们可以借助 electron 来实现 nodejs 能力。这里由于我们是插件化的,所以参考 utools 的设计,实现方式如下:

// preload.js
const AnyProxy = require('anyproxy');

const options = {
  port: 8001,
  webInterface: {
    enable: true,
    webPort: 8002
  },
  forceProxyHttps: true,
  wsIntercept: false, // 不开启websocket代理
  silent: true
};

class Network {
  constructor() {
    this.mockList = [];
    this.proxyServer = null;
  }
  initNetwork(op, {
    beforeSendRequest,
    beforeSendResponse,
    success,
    onRecord,
  }) {
    if (op === 'start') {
      if (!this.proxyServer || !this.proxyServer.recorder) {
        const _this = this;
        options.rule = {
          *beforeSendRequest(requestDetail) {
            if (beforeSendRequest) {
              return beforeSendRequest(requestDetail);
            }
            return requestDetail
          },
          *beforeSendResponse (requestDetail, responseDetail) {
            if (beforeSendResponse) {
              return beforeSendResponse(requestDetail, responseDetail);
            }
            return responseDetail;
          }
        };
        this.proxyServer = new AnyProxy.ProxyServer(options);
        this.proxyServer.once('ready', () => {
          console.log('启动完成');
          success && success(this.proxyServer);
        });
      }
      this.proxyServer.start();
    } else {
      AnyProxy.utils.systemProxyMgr.disableGlobalProxy('http');
      AnyProxy.utils.systemProxyMgr.disableGlobalProxy('https');
      this.proxyServer.close();
      success && success();
    }
  }
  getIPAddress() {
    const interfaces = require('os').networkInterfaces();
    for (const devName in interfaces) {
      const iface = interfaces[devName];
      for (let i = 0; i < iface.length; i++) {
        const alias = iface[i];
        if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
          return alias.address;
        }
      }
    }
  }
}

只需要在 preload.js 中加上 anyproxy 服务的实现,即可完成我们的诉求!

结语

什么是 rubick ?

基于 electron 的工具箱,媲美 utools的开源插件,已实现 utools 大部分的 API 能力,所以可以做到无缝适配 utools 开源的插件。 之所以做这个工具箱一方面是 utools 本身并未开源,但是公司内部的工具库又无法发布到 utools 插件中,所以为了既要享受 utools 生态又要有定制化需求,我们自己参考 utools 设计,做了 Rubick.

欢迎大家给rubick pr 和提 issue 帮助我们完善

Rubick: https://github.com/clouDr-f2e/rubick

相关参考:

HTTP 代理原理及实现(一)

HTTP 代理原理及实现(二)

Electron + Vue 实现一个代理客户端

js 深入了解执行上下文和执行栈

执行上下文(Execution Context)

在运行一段 js 代码的时候,就会生成一个可执行的上下文,根据不同类型的区分,可能会存在以下几种类型的代码执行上下文:

  • Global code - 代码第一时间指的全局环境
  • Function code - 函数代码块
  • Eval code - eval 函数包含的代码块

我们来看一段代码:

这段代码中,我们存在一个 global context 和 3 个不同的 function context。你可以随意创建这样的function context但是每一个函数都会创建一个新的执行上下文(EC)。函数外部定义的变量可以被函数内部使用,但是函数内部定义的变量确不能被函数外部使用,这是为什么呢?

执行栈(Execution Context Stack)

浏览器解释器执行 js 是单线程的过程,这就意味着同一时间,只能有一个事情在进行。其他的活动和事件只能排队等候,生成出一个等候队列执行栈(Execution Stack):

我们知道,在一开始执行代码的时候,变确定了一个全局执行上下文global execution context作为默认值。如果在你的全局环境中,调用了其他的函数,程序将会再创建一个新的 EC,然后将此 EC推入进执行栈中execution stack

如果函数内再调用其他函数,相同的步骤将会再次发生:创建一个新的EC -> 把EC推入执行栈。一旦一个EC执行完成,变回从执行栈中推出(pop):

(function foo(i) {
    if (i === 3) {
        return;
    }
    else {
        foo(++i);
    }
}(0));

上面这个函数完成了对自身的3次调用,第一次调用时,生成一个EC推入执行栈,在执行的过程中,又遇到一个新的函数foo(1)此时,又会创建一个新的EC,再次推入...

关于执行栈,有5个关键点可以了解一下:

  • 单线程
  • 同步执行
  • 1 个全局执行上下文 ( Global context)
  • 无线多的 function context
  • 每个函数调用都会创建一个新的 EC,即使是调用自身

执行上下文(EC)的一些细节

从上文了解到了一旦函数被调用,都会创建一个新的执行上下文。但是在 js 解释器中,调用执行上下文有2个阶段:

  1. 创建阶段(函数被调用,但是还没有执行):
  • 创建作用域链 scope chain
  • 定义arguments、变量、函数
  • 确定 this 值
  1. 激活/代码执行阶段
  • 主要是对之前定义的变量分配值,开始解释执行代码。

当每一个 function 被调用时,我们可以大致描述一下会创建这样一个executionContextObj对象

executionContextObj = {
    'scopeChain': { /* 变量对象 + 父执行上下文上的变量对象 */ },
    'variableObject': { /* function arguments /  parameters,内部的 variable 和 function 声明 */ },
    'this': {}
}

活动对象和变量对象(Activation / Variable Object [AO/VO])

executionContextObj在函数被调用的时候创建,在创建阶段,会扫描函数的所有参数和变量。扫描的结果成了executionContextObj内部的variableObject属性。下面是执行步骤的细化:

  1. 发现了一个函数调用
  2. 在执行函数执行,创建了一个 EC,推入执行栈
  3. 进入执行阶段:
  • 初始化作用域链
  • 创建 variableObject:内部包含 arguments 对象;内部定义的函数,以及绑定上对应的变量环境;内部定义的变量
  1. 激活阶段:运行函数内部的代码,对变量复制,代码一行一行的被解释执行.

可以通过一段代码来分析一下:

function foo(i) {
    var a = 'hello';
    var b = function privateB() {

    };
    function c() {

    }
}

foo(22);

当我们执行foo(22)的时候,EC创建阶段会类似生成下面这样的对象:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: undefined,
        b: undefined
    },
    this: { ... }
}

正如你看到的,在创建阶段,会发生属性名称的定义,但是并没有赋值。一旦创建阶段(creation stage)结束,变进入了激活 / 执行阶段,那么fooExecutionContext便会完成赋值,变成这样:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}

px、dp、dpr、ppi、viewport 相关概念

最近一直在看一些手淘移动端适配rem之类的技术方案,在研究这些技术方案之前,首先需要掌握一些基本的单位概念,所以在网上也搜了一些资料。虽然全部看下来还是存在一些疑惑的地方,在此做一下记录。

有趣的问题

人物:前端实习生「阿树」与 切图工程师「玉凤」
事件:设计师出设计稿,前端实现页面

玉凤:树,设计稿发给你啦,差那么点像素,就叼死你┏(  ̄へ ̄)=☞
阿树:(>_<)没问题啦~
阿树:哇靠,为啥你给的设计稿是750px宽 ,iPhone 6不是375px宽吗???
玉凤:A pixel is not a pixel is not a pixel, you know ?
阿树:(#‵′),I know Google。。。

为什么会出现以上的情况,难道他们当中一位出错了,摆了这样的乌龙?
事实上,他们都是对的,只是谈的不是同一个「像素」。

1. dp (设备像素)

1.1 概念

设备像素设是物理概念,指的是设备中使用的物理像素,顾名思义,显示屏是由一个个物理像素点组成的,通过控制每个像素点的颜色,使屏幕显示出不同的图像,屏幕从工厂出来那天起,它上面的物理像素点就固定不变了,单位pt。

1.2 相关说明

设备像素其实就是一个固定的尺寸,1pt = 1/72(inch),inch及英寸,而1英寸等于2.54厘米。
不同的设备,其图像基本单位是不同的,比如显示器的点距,可以认为是显示器的物理像素。现在的液晶显示器的点距一般在0.25mm到0.29mm之间。而打印机的墨点,也可以认为是打印机的物理像素,300DPI就是0.085mm,600DPI就是0.042mm。

2. px (CSS pixels)

CSS像素是Web编程的概念,指的是CSS样式代码中使用的逻辑像素。在CSS规范中,长度单位可以分为两类,绝对(absolute)单位以及相对(relative)单位。px是一个相对单位,相对的是设备像素(device pixel)。

比如iPhone 6使用的是Retina视网膜屏幕,使用2px x 2px的 device pixel 代表 1px x 1px 的 css pixel,所以设备像素数为750 x 1334px,而CSS逻辑像素数为375 x 667px。
image

3. dpr(Device Pixel Ratio) 设备像素比

设备像素比表示1个CSS像素(宽度)等于几个物理像素(宽度):

DPR = 物理像素数 / 逻辑像素数

比如dpr=2时,1个CSS像素宽度等于2个物理像素宽度。1css像素由2 * 2个物理像素点组成,见上面对CSS像素的解释。DPR不是单位,而是一个属性名,比如在浏览器中通过window.devicePixelRatio获取屏幕的DPR。

4. ppi (pixel per inch)每英寸的像素数,像素密度。

image

5. viewport 视窗

在桌面浏览器中,viewport就是浏览器窗口的宽度高度。
但移动设备的屏幕比桌面屏幕要小得多,为了要让网页在小尺寸的屏幕上显示正确,就需要对viewport做些处理。需要把viewport分成两部分:visual viewport和layout viewport。George Cummins在Stack Overflow上对这两个概念做了分析。大致意思如下:

把layout viewport想像成为一张大图。现在用一个比较小的框,通过它来看这张大图。在框内看到的部分就是visual viewport。框中的度量单位是CSS像素。可以把这个框靠近一些(放大看局部)或靠远一些(缩小看整体)。也可以改变框的方向,但是大图layout viewport的大小和形状永远不会变。

5.1 visual viewport

visual viewport是页面当前显示在屏幕上的部分。用户可以通过滚动来改变他所看到的页面的部分,或者通过缩放来改变visual viewport的大小。
image

5.2 layout viewport

layout viewport就是页面原来的大小。
image
但是我们用在手机用浏览器打开PC的网页的时候,会看到网页被浏览器自动缩小了,变的太小会导致无法浏览内容。

5.3 idea viewport

布局视口的默认宽度并不是一个理想的宽度,大家从上面的图就可以看出来了,所以苹果公司就引进了理想窗口这个概念:

它是对设备来说最理想的布局视口尺寸。显示在理想视口中的网站拥有最理想的浏览和阅读的宽度,用户刚进入页面时不再需要缩放。

注意:

  • 理想窗口的尺寸是由浏览器厂商决定的,同一设备可以有不同的尺寸
  • 不同设备的相同浏览器理想窗口也会不同,比如手机和平板
    而且会随着设备转向改变
  • 虽然有那么多不同尺寸的理想视口,但是平时开发我们只要告诉浏览器使用它的理想视口(也就是width=device-width或者initial-scale=1.0),就没问题了。

5.4 viewport meta标签

为了不让浏览器自动缩小,引入了viewport元标签。通过这个元标签控制layout viewport的宽度。

<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">

上面这行代码就是告诉浏览器,布局视口的宽度应该与理想视口的宽度一致。

  1. width:设置 layout viewport 的宽。
  2. initial-scale:初始缩放比例,也即是当页面第一次 load 的时候缩放比例,上面是变成设备的宽度,也就是layout viewport= DP或PT。
  3. maximum-scale:允许用户缩放到的最大比例。
  4. minimum-scale:允许用户缩放到的最小比例。
  5. user-scalable:用户是否可以手动缩放。

image

注意下图片中的红色框,第二张图片内容超了出来,应该是给文字设置了宽度,并且这个宽度大于layout viewport导致了出来。但其实在Android和IOS中会有不一样的表现。在文章《A tale of two viewports》中指出:

  1. 通过 document.documentElement.clientWidth 获取 layout viewport 的宽度
  2. 通过 window.innerWidth 获取 visual viewport 的宽度

移动端 1px 像素问题

一直以来我们实现边框的方法都是设置 border: 1px solid #ccc ,但是在 retina 屏上因为设备像素比(dpr)的不同,边框在移动设备上的表现也不相同: 1px 可能会被渲染成 2px, 3px....也就是说逻辑像素1px会被用不同大小的物理像素来表示。(这里只介绍几种常用的方式,更多可以自行Google)

rem + viewport

按照上面这种表述,首先想到的最快解决这种问题的肯定是根据dpr不同来进行缩放就好了,这种方式也就是常说的 rem + viewpor。关于rem的介绍可以参考这里。核心的实现如下:

<script>  
            var viewport = document.querySelector("meta[name=viewport]");  
            //下面是根据设备像素设置viewport  
            if (window.devicePixelRatio == 1) {  
                viewport.setAttribute('content', 'width=device-width,initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no');  
            }  
            if (window.devicePixelRatio == 2) {  
                viewport.setAttribute('content', 'width=device-width,initial-scale=0.5, maximum-scale=0.5, minimum-scale=0.5, user-scalable=no');  
            }  
            if (window.devicePixelRatio == 3) {  
                viewport.setAttribute('content', 'width=device-width,initial-scale=0.3333333333333333, maximum-scale=0.3333333333333333, minimum-scale=0.3333333333333333, user-scalable=no');  
            }  
            var docEl = document.documentElement;  
            var fontsize = 10 * (docEl.clientWidth / 320) + 'px';  
            docEl.style.fontSize = fontsize;   
</script>  

大概意思就是设置网页根字体 font-size 为 37.5 * dpr,这样根据 rem 产出的网页就会被放大 dpr 倍,此时css里面写的还是1px,然后再通过initial-scale= 1 / dpr来对网页进行缩小dpr倍。这样 1px 就会显示成 1 / dprpx。

border-image 实现

这篇文章是腾讯github上的解决方案border-image来实现的。缺点是,你需要制作图片,圆角的时候会出现模糊。

.border-image-1px {
    border-width: 1px 0px;
    -webkit-border-image: url("border.png") 2 0 stretch;
    border-image: url("border.png") 2 0 stretch;
}

border.png也可以用 base64 图片替代。

background-image 渐变实现

除啦用图片,难道纯粹的css就不能实现吗?我的确不想使用图片,感觉制作起来很麻烦,其实百度糯米团首页就是这么做的但是这种方法有个缺点,就是不能实现圆角

.border {
      background-image:linear-gradient(180deg, red, red 50%, transparent 50%),
      linear-gradient(270deg, red, red 50%, transparent 50%),
      linear-gradient(0deg, red, red 50%, transparent 50%),
      linear-gradient(90deg, red, red 50%, transparent 50%);
      background-size: 100% 1px,1px 100% ,100% 1px, 1px 100%;
      background-repeat: no-repeat;
      background-position: top, right top,  bottom, left top;
      padding: 10px;
  }

box-shadow 实现

利用阴影我们也可以实现,那么我们来看看阴影,优点是圆角不是问题,缺点是颜色不好控制。

div{
    -webkit-box-shadow:0 1px 1px -1px rgba(0, 0, 0, 0.5);
}

伪类 + transform 实现

原理是把原先元素的 border 去掉,然后利用 :before 或者 :after 重做 border ,并 transform 的 scale 缩小一半,原先的元素相对定位,新做的 border 绝对定位。
单条border样式设置(其他的类似):

.scale-1px{
  position: relative;
  border:none;
}
.scale-1px:after{
  content: '';
  position: absolute;
  bottom: 0;
  background: #000;
  width: 100%;
  height: 1px;
  -webkit-transform: scaleY(0.5);
  transform: scaleY(0.5);
  -webkit-transform-origin: 0 0;
  transform-origin: 0 0;
}

优点可以实现圆角,京东就是这么实现的。缺点是按钮添加active比较麻烦,对于已经使用伪类的元素(例如clearfix),可能需要多层嵌套。

vue-router 实现 -- HTML5History

vue-router通过设置mode = history可以在浏览器支持 history 模式的情况下,用来开启 HTML5History 模式。我们知道在install挂载的时候,会在beforeCreate钩子内执行init方法:

init () {
    // ...
    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    }
    // ...
}

当判断当前模式是 HTML5History的时候,会执行 history 对象上的 transitionTo方法。接下来我们主要分析 HTML5History 的主要功能。

constructor

vue-router实例化过程中,执行对 HTML5History 的实例化:

this.history = new HTML5History(this, options.base)

此时会执行 HTML5History 中的 constructor:

  constructor (router: Router, base: ?string) {
    // 实现 base 基类中的构造函数
    super(router, base)
    
    // 滚动信息处理
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      setupScroll()
    }

    const initLocation = getLocation(this.base)
    window.addEventListener('popstate', e => {
      const current = this.current

      // 避免在有的浏览器中第一次加载路由就会触发 `popstate` 事件
      const location = getLocation(this.base)
      if (this.current === START && location === initLocation) {
        return
      }
      // 执行跳转动作
      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
    })
  }

可以看到在这种模式下,初始化作的工作相比 hash 模式少了很多,只是调用基类构造函数以及初始化监听事件,不需要再做额外的工作。由于在上篇文章中已经介绍了 transitionToconfirmTransition。这里不再过多介绍了。到这里好像也就没什么,那么我们来看几个之前没介绍的一下 API 吧

一些API

vue-router中我们通常会通过这种方式操作路由信息:

router.push(location, onComplete?, onAbort?)
router.replace(location, onComplete?, onAbort?)
router.go(n)
router.back()
router.forward()

具体的可以参考这里编程式导航
来一起看一下 vue-router 的统一实现:

  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.push(location, onComplete, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.replace(location, onComplete, onAbort)
  }

  go (n: number) {
    this.history.go(n)
  }

  back () {
    this.go(-1)
  }

  forward () {
    this.go(1)
  }

可以看到,也都是调用了history内部的方法。

你也许注意到 router.pushrouter.replacerouter.gowindow.history.pushStatewindow.history.replaceStatewindow.history.go好像, 实际上它们确实是效仿 window.history API 的。
因此,如果你已经熟悉 Browser History APIs,那么在 Vue Router 中操作 history 就是超级简单的。
还有值得提及的,Vue Router 的导航方法 (push、 replace、 go) 在各类路由模式 (history、 hash 和 abstract) 下表现一致。

举个例子🌰:

// go
go (n: number) {
  window.history.go(n)
}

// push最终也是调用的history API
history.pushState({ key: _key }, '', url)

vue-router 实现 -- 路由变更监听

对于 Vue SPA 页面,改变可以有两种:一种是用户点击链接元素,一种是更新浏览器本身的前进后退导航来更新。

用户点击链接元素

户点击链接交互,即点击了 <router-link>,这个组件是在install的时候被注册的。我们来看一下这个组建的核心内容:

  props: {
    // ...
    // 标签名,默认是 <a></a>
    tag: {
      type: String,
      default: 'a'
    },
    // 绑定的事件
    event: {
      type: eventTypes,
      default: 'click'
    }
  },
  render (h: Function) {
    // ...
    const handler = e => {
      if (guardEvent(e)) {
        if (this.replace) {
          router.replace(location)
        } else {
          router.push(location)
        }
      }
    }

    const on = { click: guardEvent }
    if (Array.isArray(this.event)) {
      this.event.forEach(e => { on[e] = handler })
    } else {
      // 事件绑定处理函数
      on[this.event] = handler
    } 
    // ...
    return h(this.tag, data, this.$slots.default)   
  }
  // ....

该组件主要是通过render函数,默认创建一个a标签,同时为标签绑定click事件。在绑定事件的函数中,有这样一个方法值得注意guardEvent。我们来看看他所做的工作:

function guardEvent (e) {
  // 忽略带有功能键的点击
  if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
  // 调用preventDefault时不重定向
  if (e.defaultPrevented) return
  // 忽略右击
  if (e.button !== undefined && e.button !== 0) return
  // 如果 `target="_blank"` 也不进行
  if (e.currentTarget && e.currentTarget.getAttribute) {
    const target = e.currentTarget.getAttribute('target')
    if (/\b_blank\b/i.test(target)) return
  }
  // 判断是否存在`e.preventDefault`,在 weex 中没有这个方法
  if (e.preventDefault) {
    e.preventDefault()
  }
  return true
}

可以看到,这里主要对是否跳转进行了一些判断。那么我们再看看点击事件的处理函数:

const handler = e => {
  if (guardEvent(e)) {
    if (this.replace) {
      router.replace(location)
    } else {
      router.push(location)
    }
  }
}

可以看到其实他们只是代理而已,真正做事情的还是 history 来做。

浏览器本身的跳转动作

对于这种情况,我们之前文章也简单的分析过,先来看看 hash的方式,当发生变得时候会判断当前浏览器环境是否支持 supportsPushState 来选择监听 popstate还是hashchange

window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {
  const current = this.current
  if (!ensureSlash()) {
    return
  }
  this.transitionTo(getHash(), route => {
    if (supportsScroll) {
      handleScroll(this.router, route, current, true)
    }
    if (!supportsPushState) {
      replaceHash(route.fullPath)
    }
  })
})

对应的history其实也是差不多。只不顾既然是history模式了,默认也就只用监听popstate就好了:

window.addEventListener('popstate', e => {
  const current = this.current

  // Avoiding first `popstate` event dispatched in some browsers but first
  // history route not updated since async guard at the same time.
  const location = getLocation(this.base)
  if (this.current === START && location === initLocation) {
    return
  }

  this.transitionTo(location, route => {
    if (supportsScroll) {
      handleScroll(router, route, current, true)
    }
  })
})

到这里其实vue-router实现已经介绍的差不多了。相信能看到这里的小伙伴也能对vue-router有个清晰地认识。

在webpack中使用babel来编译你的es6和es7

你需要了解的babel

babel 是一个javaScript 编译工具,babel 已经支持最新的 javascript 版本,下面我们来介绍 babel 常用的几个工具

babel-preset-env

安装:

npm install babel-preset-env --save-dev

不需要任何配置,babel-preset-env表现的同babel-preset-latest一样(或者可以说同babel-preset-es2015, babel-preset-es2016, and babel-preset-es2017结合到一起,表现的一致)
你也可以通过配置polyfills和transforms来支持你所需要支持的浏览器,仅配置需要支持的语法来使你的打包文件更轻量级。

{
  "presets": ["env"]
}

有了上面这些调研,我们来看一下vue-cli 脚手架里有这样一段话:

{"presets": [["env", {
      "targets": {
          "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
       }
    }]]
}

通过上面的这些研究,我们知道了:

  • ">1% " 兼容全球使用率大于1%的流览器
  • last 2 versions 兼容每个游览器的最近两个版本
  • not ie <= 8 不兼容ie8及以下

说到这里,我们开始我们第一个demo,我们来编译我们的ES6 项目:

// src/test.js
export default 'hello ES6'
//  src/index.js
import test from './test'

console.log(test)
// webpack.config.js
const path = require('path')

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: '[name].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
       options: {
          presets: [["env", {
            "targets": {
              "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
            }
          }]]
        }
      }
    ]
  }
}

但是随着项目的复杂度增加,我们在options里面的配置会越来越多,所以我们可以利用babel 提供的.babelrc 来提供相同的用法:

// .babelrc
 presets: [["env", {
     "targets": {
          "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
}]]

babel-polyfill

babel 本身只提供预发的转换,当我们使用一些箭头函数这样的新的语法,其实在babel看来,更像是一种语法糖。但是babel不能转义一些ES6、ES7...的新的全局属性,例如 Promise 、新的原生方法如 String.padStart (left-pad) 等。这个时候我们就需要使用babel-polyfill.

npm install --save babel-polyfill

更详细的介绍可以参考:https://babeljs.cn/docs/usage/polyfill

这里主要体积一下:
Babel 转译后的代码要实现源代码同样的功能需要借助一些帮助函数,例如,{ [name]: 'JavaScript' } 转译后的代码如下所示:

'use strict';
function _defineProperty(obj, key, value) {
  if (key in obj) {
    Object.defineProperty(obj, key, {
      value: value,
      enumerable: true,
      configurable: true,
      writable: true
    });
  } else {
    obj[key] = value;
  }
  return obj;
}
var obj = _defineProperty({}, 'name', 'JavaScript');

类似上面的帮助函数 _defineProperty 可能会重复出现在一些模块里,导致编译后的代码体积变大。Babel 为了解决这个问题,提供了单独的包 babel-runtime 供编译模块复用工具函数。

启用插件 babel-plugin-transform-runtime 后,Babel 就会使用 babel-runtime 下的工具函数,转译代码如下:

'use strict';
// 之前的 _defineProperty 函数已经作为公共模块 `babel-runtime/helpers/defineProperty` 使用
var _defineProperty2 = require('babel-runtime/helpers/defineProperty');
var _defineProperty3 = _interopRequireDefault(_defineProperty2);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
var obj = (0, _defineProperty3.default)({}, 'name', 'JavaScript');

只不过那些需要修改内置api才能达成的功能,譬如:扩展String.prototype,给上面增加includes方法,就属于修改内置API的范畴。这类操作就由polyfill提供,所以项目中可以视情况选择不同的类型库

Event loop 机制简介

堆、栈、队列

  1. 堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:

    • 堆中某个节点的值总是不大于或不小于其父节点的值;
    • 堆总是一棵完全二叉树。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。常见的堆有二叉堆、斐波那契堆等。
  2. 堆是在程序运行时,而不是在程序编译时,申请某个大小的内存空间。即动态分配内存,对其访问和对一般内存的访问没有区别。

  3. 堆是应用程序在运行的时候请求操作系统分配给自己内存,一般是申请/给予的过程。

  4. 堆是指程序运行时申请的动态内存,而栈只是指一种使用堆的方法(即先进后出)。

  1. 栈(stack)又名堆栈,一个数据集合,可以理解为只能在一端进行插入或删除操作的列表。其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另一端称为栈底。
  2. 栈就是一个桶,后放进去的先拿出来,它下面本来有的东西要等它出来之后才能出来(先进后出),对应js数组操作里的push(入栈)pop(出栈)
  3. 栈(Stack)是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有FIFO的特性,在编译的时候可以指定需要的Stack的大小。

队列

是一种支持先进先出(FIFO)的集合,即先被插入的数据,先被取出!

执行栈

当javascript代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 但是我们这里说的执行栈和上面这个栈的意义却有些不同。
js 在执行可执行的脚本时,首先会创建一个全局可执行上下文globalContext,每当执行到一个函数调用时都会创建一个可执行上下文(execution context)EC。当然可执行程序可能会存在很多函数调用,那么就会创建很多EC,所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。当函数调用完成,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境... 这个过程反复进行,直到执行栈中的代码全部执行完毕:

下面来看个简单的例子:

function fun3() {
    console.log('fun3')
}
function fun2() {
    fun3();
}
function fun1() {
    fun2();
}
fun1();

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:

1.执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈

ECStack = [
    globalContext
];

2.全局上下文初始化

   globalContext = {
        VO: [global],
        Scope: [globalContext.VO],
        this: globalContext.VO
    }

3.初始化的同时,fun1 函数被创建,保存作用域链到函数的内部属性[[scope]]

    fun1.[[scope]] = [
      globalContext.VO
    ];

4.执行 fun1 函数,创建 fun1 函数执行上下文,fun1 函数执行上下文被压入执行上下文栈

    ECStack = [
        fun1,
        globalContext
    ];

5.fun1函数执行上下文初始化:

  1. 复制函数 [[scope]] 属性创建作用域链,
  2. 用 arguments 创建活动对象,
  3. 初始化活动对象,即加入形参、函数声明、变量声明,
  4. 将活动对象压入fun1 作用域链顶端。
    同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]
    checkscopeContext = {
        AO: {
            arguments: {
                length: 0
            },
            scope: undefined,
            f: reference to function f(){}
        },
        Scope: [AO, globalContext.VO],
        this: undefined
    }
  1. 执行 fun2() 函数,重复步骤2。
  2. 最终形成这样的执行栈:
    ECStack = [
        fun3
        fun2,
        fun1,
        globalContext
    ];

8.fun3执行完毕,从执行栈中弹出...一直到fun1

事件队列

以上的过程说的都是同步代码的执行。那么当一个异步代码(如发送ajax请求数据)执行后会如何呢?接下来需要了解的另一个概念就是:事件队列(Task Queue)。
当js引擎遇到一个异步事件后,其实不会说一直等到异步事件的返回,而是先将异步事件进行挂起。等到异步事件执行完毕后,会被加入到事件队列中。(注意,此时只是异步事件执行完成,其中的回调函数并没有去执行。)当执行队列执行完毕,主线程处于闲置状态时,会去异步队列那抽取最先被推入队列中的异步事件,放入执行栈中,执行其中的回调同步代码。如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。
为了更好的理解,我们来看一张图:(转引自Philip Roberts的演讲《Help, I'm stuck in an event-loop》)

主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

macro task与micro task

在介绍之前,我们先看一段经典的代码执行:

setTimeout(function () {
    console.log(1);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})

会看到控制台先后分别输出:2、3、1。
先看一下阮老师对setTimeout的一些解释:

setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。
实际上,一般因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

以下事件属于宏任务:

  • setTimeout
  • MessageChannel
  • postMessage
  • setImmediate

以下事件属于微任务

  • new Promise()
  • new MutaionObserver()

前面我们介绍过,在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。

我们只需记住当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
所以我们就很好解释上面的那段代码了。

参考资料

详解JavaScript中的Event Loop(事件循环)机制

JavaScript 运行机制详解:再谈Event Loop

前端常见安全与防范

XSS 攻击

XSS 全称Cross SiteScript,因为与 CSS 混淆,所以命名 XSS。跨站脚本攻击,是Web程序中常见的漏洞,XSS属于被动式且用于客户端的攻击方式,所以容易被忽略其危害性。其原理是攻击者向有XSS漏洞的网站中输入(传入)恶意的HTML代码,当其它用户浏览该网站时,这段HTML代码会自动执行,从而达到攻击的目的。如,盗取用户Cookie、破坏页面结构、重定向到其它网站等。

攻击方式

攻击者使被攻击者在浏览器中执行脚本后,如果需要收集来自被攻击者的数据(如cookie或其他敏感信息),可以自行架设一个网站,让被攻击者通过JavaScript等方式把收集好的数据作为参数提交,随后以数据库等形式记录在攻击者自己的服务器上。

常用的XSS攻击手段和目的有:

盗用cookie,获取敏感信息。
利用植入 Flash,通过crossdomain权限设置进一步获取更高权限;或者利用Java等得到类似的操作。
利用iframe、frame、XMLHttpRequest或上述Flash等方式,以(被攻击)用户的身份执行一些管理动作,或执行一些一般的如发微博、加好友、发私信等操作。
利用可被攻击的域受到其他域信任的特点,以受信任来源的身份请求一些平时不允许的操作,如进行不当的投票活动。
在访问量极大的一些页面上的 XSS 可以攻击一些小型网站,实现DDoS攻击的效果。

防范

记住一句至理名言——“所有用户输入都是不可信的。”(注意: 攻击代码不一定在<script></script>中)

1. 使用XSS Filter

输入过滤,对用户提交的数据进行有效性验证,仅接受指定长度范围内并符合我们期望格式的的内容提交,阻止或者忽略除此外的其他任何数据。
输出转义,当需要将一个字符串输出到Web网页时,同时又不确定这个字符串中是否包括XSS特殊字符,为了确保输出内容的完整性和正确性,输出HTML属性时可以使用HTML转义编码(HTMLEncode)进行处理,输出到<script>中,可以进行JS编码。

2. 使用 HttpOnly Cookie

将重要的cookie标记为httponly,这样的话当浏览器向Web服务器发起请求的时就会带上cookie字段,但是在js脚本中却不能访问这个cookie,这样就避免了XSS攻击利用JavaScript的document.cookie获取cookie。

3. 困难和幸运

真正麻烦的是,在一些场合我们要允许用户输入HTML,又要过滤其中的脚本。这就要求我们对代码小心地进行转义。否则,我们可能既获取不了用户的正确输入,又被XSS攻击。
幸好,由于XSS臭名昭著历史悠久又极其危险,现代web开发框架如vue.js、react.js等,在设计的时候就考虑了XSS攻击对html插值进行了更进一步的抽象、过滤和转义,我们只要熟练正确地使用他们,就可以在大部分情况下避免XSS攻击。
同时,许多基于MVVM框架的SPA(单页应用)不需要刷新URL来控制view,这样大大防止了XSS隐患。另外,我们还可以用一些防火墙来阻止XSS的运行。

XSRF 或 CSRF

跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去执行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。

例子:

假如一家银行用以执行转账操作的URL地址如下: http://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName

那么,一个恶意攻击者可以在另一个网站上放置如下代码: <img src="http://www.examplebank.com/withdraw?account=Alice&amount=1000&for=Badman">

如果有账户名为 Alice 的用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会损失 1000 资金。

这种恶意的网址可以有很多种形式,藏身于网页中的许多地方。此外,攻击者也不需要控制放置恶意网址的网站。例如他可以将这种地址藏在论坛,博客等任何用户生成内容的网站中。这意味着如果服务器端没有合适的防御措施的话,用户即使访问熟悉的可信网站也有受攻击的危险。

透过例子能够看出,攻击者并不能通过CSRF攻击来直接获取用户的账户控制权,也不能直接窃取用户的任何信息。他们能做到的,是欺骗用户浏览器,让其以用户的名义执行操作。

防范

1. 检查Referer字段

HTTP头中有一个Referer字段,这个字段用以标明请求来源于哪个地址。在处理敏感数据请求时,通常来说,Referer字段应和请求的地址位于同一域名下。以上文银行操作为例,Referer字段地址通常应该是转账按钮所在的网页地址,应该也位于www.examplebank.com之下。而如果是CSRF攻击传来的请求,Referer字段会是包含恶意网址的地址,不会位于www.examplebank.com之下,这时候服务器就能识别出恶意的访问。

这种办法简单易行,工作量低,仅需要在关键访问处增加一步校验。但这种办法也有其局限性,因其完全依赖浏览器发送正确的Referer字段。虽然http协议对此字段的内容有明确的规定,但并无法保证来访的浏览器的具体实现,亦无法保证浏览器没有安全漏洞影响到此字段。并且也存在攻击者攻击某些浏览器,篡改其Referer字段的可能。

2. 添加校验token

由于CSRF的本质在于攻击者欺骗用户去访问自己设置的地址,所以如果要求在访问敏感数据请求时,要求用户浏览器提供不保存在cookie中,并且攻击者无法伪造的数据作为校验,那么攻击者就无法再执行CSRF攻击。这种数据通常是表单中的一个数据项。服务器将其生成并附加在表单中,其内容是一个伪乱数。当客户端通过表单提交请求时,这个伪乱数也一并提交上去以供校验。正常的访问时,客户端浏览器能够正确得到并传回这个伪乱数,而通过CSRF传来的欺骗性攻击中,攻击者无从事先得知这个伪乱数的值,服务器端就会因为校验token的值为空或者错误,拒绝这个可疑请求。

React **

在深入学习react之前,我们需要先对react基本理念有一定的认知,只有在了解react设计初衷和解决什么问题后我们才能更好的理解他,学习他。

1. virtual dom 封装dom操作

为什么先提vdom呢,因为在此之前有关于react的各种能力的,React最大的价值究竟是什么?是高性能虚拟DOM、服务器端Render、封装过的事件机制、还是完善的错误提示信息?尽管每一点都足以重要。但是在此之前其实react设计之初所提倡的vdom理念核心是为了解决开发者手动操作dom带来的性能问题。我们知道不同dom操作所带来的性能问题有很大的差别,但是Facebook内部的开发者并不是每个人都可以对dom操作使用最优的方案,而且每个人写的风格迥异。vdom设计之初也是想基于此在真实的dom上封装一层,这样开发者操作到的只是封装好的vdom,vdom到真实的dom的操作便有框架解决,再怎么弄也是个虚拟的dom,底层只要做好性能处理,便不会有太大的问题。
我们再来说一下作为数据驱动的框架,一个数据模型的变化可能导致分散在界面多个角落的UI同时发生变化。界面越复杂,这种数据和界面的一致性越难维护。在Facebook内部他们称之为“Cascading Updates”,即层叠式更新,意味着UI界面之间会有一种互相依赖的关系。开发者为了维护这种依赖更新,有时不得不触发大范围的界面刷新,而其中很多并不真的需要。React的初衷之一就是,既然整体刷新一定能解决层叠更新的问题,那我们为什么不索性就每次都这么做呢?让框架自身去解决哪些局部UI需要更新的问题。这听上去非常有挑战,但React却做到了,实现途径就是通过虚拟DOM(Virtual DOM)的 Diff,当数据变更时,产生一个新的Dom树,和旧的Dom树进行比对,决策出最小更新单元,关于Diff算法,我之前写过一篇Vue的diff原理,不过大体类似,这里不再赘述。
然后我们再提及一下React vdom底层不仅仅可以实现vdom -> dom 的渲染,作为vdom我们只需要变更不同的DSL便可跨端渲染。这也正是react 服务端渲染、RN等跨端解决方案的根本之一。也有基于vdom做其他方向渲染的案例,比如Mpvue,react canvas 等等。我也曾尝试过基于Vue的vdom做canvas的渲染。

2. JSX

将HTML直接嵌入到JavaScript代码中看上去确实是一件足够疯狂的事情。人们花了多年时间总结出的界面和业务逻辑相互分离的“最佳实践”就这么被彻底打破。那么React为何要如此另类?
前端界面的最基本功能在于如何将数据准确的展示到页面中,我们先抛开jquery命令式的方式不谈,来看看其他模板引擎是如何实现:
Angular

<div ng-if="person != null">
    Welcome back, <b>{{person.firstName}} {{person.lastName}}</b>!
</div>
<div ng-if="person == null">
    Please log in.
</div>

emberjs

{{#if person}}
  Welcome back, <b>{{person.firstName}} {{person.lastName}}</b>!
{{else}}
  Please log in.
{{/if}}

Knockoutjs

<div data-bind="if: person != null">
    Welcome back, <b>{{person.firstName}} {{person.lastName}}</b>!
</div>
<div data-bind="if: person == null">
    Please log in.
</div>

模板可以直观的定义 UI 来展现 Model 中的数据,你不必手动的去拼出一个很长的 HTML 字符串,几乎每种框架都有自己的模板引擎。传统 MVC 框架强调界面展示逻辑和业务逻辑的分离,因此为了应对复杂的展示逻辑需求,这些模板引擎几乎都不可避免的需要发展成一门独立的语言,如上面代码所示,每个框架都有自己的模板语言语法。而这无疑增加了框架的门槛和复杂度。
如果说掌握一种模板语言并不是很大的问题,那么其实由模板带来的架构复杂性则是让框架也变得复杂的重要原因之一,例如:
模板需要对应数据模型,即上下文,如何去绑定和实现?
模板可以嵌套,不同部分界面可能来自不同数据模型,如何处理?
模板语言终究是一个轻量级语言,为了满足项目需求,你很可能需要扩展模板引擎的功能。
为了解决这些复杂度,框架本身需要精心的设计,以及创造新的概念(例如 Angular 的 Directive)。这些都会让框架变得复杂和难以掌握,不仅增加了开发成本,各种难以调试的 Bug 还会降低开发质量。
例如在 Angular 中:

<ul class="unstyled">
  <li ng-repeat="todo in todoList.todos">
    <input type="checkbox" ng-model="todo.done">
    <span class="done-{{todo.done}}">{{todo.text}}</span>
  </li>
</ul>

而使用 JSX,则代码如下:

var ul = (
  <ul class="unstyled">
    {
        this.todoList.todos.map((todo) => (
        <li>
          <input type="checkbox" checked={todo.done}>
          <span className={'done-' + todo.done}>{todo.text}</span>
        </li>
      ))
    }
  </ul>
);

可以看到,JSX中除了另类的HTML标记之外,并没有引入其它任何新的概念。Angular中的repeat在这里被一个简单的数组方法map所替代。在这里你可以利用熟悉的JavaScript语法去定义界面,在你的思维过程中其实已经不需要存在模板的概念,需要考虑的仅仅是如何用代码构建整个界面。这种自然而直观的方式直接降低了React的学习门槛并且让代码更容易理解。

3. 单向数据流动

单向数据流对应的就是多项数据流,我们先来看看多项数据流带来的问题:想想看,如果我们现在视图上有个switch按钮,按钮状态依赖组件内部的store,用户可以触发点击操作修改store从而触发视图的更新。但是如果我们需要做另一个操作:纪录用户的行为,在用户退出时存储行为到服务端,再进入时展示上一次的操作。此时数据源又多了个server。这样view的展示逻辑就变得复杂,想想看,如果再来其他的n个数据源,要经过多少判断处理?
所以react提出的单向数据流的优点是所有状态变化都可以被记录、跟踪,状态变化通过手动调用通知,源头易追溯,没有“暗箱操作”。同时组件数据只有唯一的入口和出口,使得程序更直观更容易理解,有利于应用的可维护性。整体的设计理念也是希望数据更新策略变得简单化,让一切变化变得更加可控。

4. Immutability Data

Immutability含义是只读数据,React提倡使用只读数据来建立数据模型。这又是一个听上去相当疯狂的机制:所有数据都是只读的,如果需要修改它,那么你只能产生一份包含新的修改的数据。假设有如下数据:

var employee = {
  name: ‘John’,
  age: 28
};

如果要修改年龄,那么你需要产生一份新的数据:

var updated = {
  name: employee.name,
  age: 29
};

这样,原来的employee对象并没有发生任何变化,相反,产生了一个新的updated对象,体现了年龄发生了变化。这时候需要把新的updated对象应用到界面组件上来进行界面的更新。在javascript中我们可以通过deep clone来模拟Immutable Data,就是每次对数据进行操作,新对数据进行deep clone出一个新数据。当然你或许意识到了,这样非常的慢。有兴趣的可以继续了解https://github.com/immutable-js/immutable-js

实现一个简易的webpack

背景

随着前端复杂度的不断提升,诞生出很多打包工具,比如最先的gruntgulp。到后来的webpack Parcel。但是目前很多脚手架工具,比如vue-cli已经帮我们集成了一些构建工具的使用。有的时候我们可能并不知道其内部的实现原理。其实了解这些工具的工作方式可以帮助我们更好理解和使用这些工具,也方便我们在项目开发中应用。

一些知识点

在我们开始造轮子前,我们需要对一些知识点做一些储备工作。

模块化知识

首先是模块的相关知识,主要的是 es6 modulescommonJS模块化的规范。更详细的介绍可以参考这里 CommonJS、AMD/CMD、ES6 Modules 以及 webpack 原理浅析。现在我们只需要了解:

  1. es6 modules 是一个编译时就会确定模块依赖关系的方式。
  2. CommonJS的模块规范中,Node 在对 JS 文件进行编译的过程中,会对文件中的内容进行头尾包装
    ,在头部添加(function (export, require, modules, __filename, __dirname){\n 在尾部添加了\n};。这样我们在单个JS文件内部可以使用这些参数。

AST 基础知识

什么是抽象语法树?

在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

image

大家可以通过Esprima 这个网站来将代码转化成 ast。首先一段代码转化成的抽象语法树是一个对象,该对象会有一个顶级的type属性Program,第二个属性是body是一个数组。body数组中存放的每一项都是一个对象,里面包含了所有的对于该语句的描述信息:

type:描述该语句的类型 --变量声明语句
kind:变量声明的关键字 -- var
declaration: 声明的内容数组,里面的每一项也是一个对象
	type: 描述该语句的类型 
	id: 描述变量名称的对象
		type:定义
		name: 是变量的名字
        init: 初始化变量值得对象
		type: 类型
		value: 值 "is tree" 不带引号
		row: "\"is tree"\" 带引号

进入正题

webpack 简易打包

有了上面这些基础的知识,我们先来看一下一个简单的webpack打包的过程,首先我们定义3个文件:

// index.js
import a from './test'

console.log(a)

// test.js
import b from './message'

const a = 'hello' + b

export default a

// message.js
const b = 'world'

export default b

方式很简单,定义了一个index.js引用test.jstest.js内部引用message.js。看一下打包后的代码:

(function (modules) {
  var installedModules = {};

  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
  }

  // expose the modules object (__webpack_modules__)
  __webpack_require__.m = modules;
  // expose the module cache
  __webpack_require__.c = installedModules;
  // define getter function for harmony exports
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {enumerable: true, get: getter});
    }
  };
  // define __esModule on exports
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
    }
    Object.defineProperty(exports, '__esModule', {value: true});
  };
  // create a fake namespace object
  // mode & 1: value is a module id, require it
  // mode & 2: merge all properties of value into the ns
  // mode & 4: return value when already ns object
  // mode & 8|1: behave like require
  __webpack_require__.t = function (value, mode) {
    /******/
    if (mode & 1) value = __webpack_require__(value);
    if (mode & 8) return value;
    if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
    var ns = Object.create(null);
    __webpack_require__.r(ns);
    Object.defineProperty(ns, 'default', {enumerable: true, value: value});
    if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function (key) {
      return value[key];
    }.bind(null, key));
    return ns;
  };
  // getDefaultExport function for compatibility with non-harmony modules
  __webpack_require__.n = function (module) {
    var getter = module && module.__esModule ?
      function getDefault() {
        return module['default'];
      } :
      function getModuleExports() {
        return module;
      };
    __webpack_require__.d(getter, 'a', getter);
    return getter;
  };
  // Object.prototype.hasOwnProperty.call
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };
  // __webpack_public_path__
  __webpack_require__.p = "";
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {

    "use strict";
    eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./test */ \"./src/test.js\");\n\n\nconsole.log(_test__WEBPACK_IMPORTED_MODULE_0__[\"default\"])\n\n\n//# sourceURL=webpack:///./src/index.js?");

  }),
  "./src/message.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  }),
  "./src/test.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  })
});

看起来很乱?没关系,我们来屡一下。一眼看过去我们看到的是这样的形式:

(function(modules) {
  // ...
})({
 // ...
})

这样好理解了吧,就是一个自执行函数,传入了一个modules对象,modules 对象是什么样的格式呢?上面的代码已经给了我们答案:

{
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  }),
  "./src/message.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  }),
  "./src/test.js": (function (module, __webpack_exports__, __webpack_require__) {
    // ...
  })
}

是这样的一个 路径 --> 函数 这样的 key,value 键值对。而函数内部是我们定义的文件转移成 ES5 之后的代码,通过eval来执行,为了方便大家理解,我对eval内的代码做了一下格式化:

"use strict";
__webpack_require__.r(__webpack_exports__);
// 获取"./src/test.js" 依赖
var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/test.js");

console.log(_test__WEBPACK_IMPORTED_MODULE_0__["default"])

到这里基本上结构是分析完了,接着我们看看他的执行,自执行函数一开始执行的代码是:

__webpack_require__(__webpack_require__.s = "./src/index.js");

调用了__webpack_require_函数,并传入了一个moduleId参数是"./src/index.js"。再看看函数内部的主要实现:

// 定义 module 格式   
var module = installedModules[moduleId] = {
      i: moduleId, // moduleId
      l: false, // 是否已经缓存
      exports: {} // 导出对象,提供挂载
};

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

这里调用了我们modules中的函数,并传入了 __webpack_require__函数作为函数内部的调用。module.exports参数作为函数内部的导出。因为index.js里面引用了test.js,所以又会通过 __webpack_require__来执行对test.js的加载:

var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/test.js");

test.js内又使用了message.js所以,test.js内部又会执行对message.js的加载。message.js执行完成之后,因为没有依赖项,所以直接返回了结果:

var b = 'world'
__webpack_exports__["default"] = (b)

执行完成之后,再一级一级返回到根文件index.js。最终完成整个文件依赖的处理。
整个过程中,我们像是通过一个依赖关系树的形式,不断地向数的内部进入,等返回结果,又开始回溯到根。

开发一个简单的 tinypack

通过上面的这些调研,我们先考虑一下一个基础的打包编译工具可以做什么?

  1. 转换ES6语法成ES5
  2. 处理模块加载依赖
  3. 生成一个可以在浏览器加载执行的 js 文件

第一个问题,转换语法,其实我们可以通过babel来做。核心步骤也就是:

  • 通过babylon生成AST
  • 通过babel-core将AST重新生成源码
/**
 * 获取文件,解析成ast语法
 * @param filename // 入口文件
 * @returns {*}
 */
function getAst (filename) {
  const content = fs.readFileSync(filename, 'utf-8')

  return babylon.parse(content, {
    sourceType: 'module',
  });
}

/**
 * 编译
 * @param ast
 * @returns {*}
 */
function getTranslateCode(ast) {
  const {code} = transformFromAst(ast, null, {
    presets: ['env']
  });
  return code
}

接着我们需要处理模块依赖的关系,那就需要得到一个依赖关系视图。好在babel-traverse提供了一个可以遍历AST视图并做处理的功能,通过 ImportDeclaration 可以得到依赖属性:

function getDependence (ast) {
  let dependencies = []
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      dependencies.push(node.source.value);
    },
  })
  return dependencies
}

/**
 * 生成完整的文件依赖关系映射
 * @param fileName
 * @param entry
 * @returns {{fileName: *, dependence, code: *}}
 */
function parse(fileName, entry) {
  let filePath = fileName.indexOf('.js') === -1 ? fileName + '.js' : fileName
  let dirName = entry ? '' : path.dirname(config.entry)
  let absolutePath = path.join(dirName, filePath)
  const ast = getAst(absolutePath)
  return {
    fileName,
    dependence: getDependence(ast),
    code: getTranslateCode(ast),
  };
}

到目前为止,我们也只是得到根文件的依赖关系和编译后的代码,比如我们的index.js依赖了test.js但是我们并不知道test.js还需要依赖message.js,他们的源码也是没有编译过。所以此时我们还需要做深度遍历,得到完成的深度依赖关系:

/**
 * 获取深度队列依赖关系
 * @param main
 * @returns {*[]}
 */
function getQueue(main) {
  let queue = [main]
  for (let asset of queue) {
    asset.dependence.forEach(function (dep) {
      let child = parse(dep)
      queue.push(child)
    })
  }
  return queue
}

那么进行到这一步我们已经完成了所有文件的编译解析。最后一步,就是需要我们按照webpack的**对源码进行一些包装。第一步,先是要生成一个modules对象:

function bundle(queue) {
  let modules = ''
  queue.forEach(function (mod) {
    modules += `'${mod.fileName}': function (require, module, exports) { ${mod.code} },`
  })
  // ...
}

得到 modules 对象后,接下来便是对整体文件的外部包装,注册requiremodule.exports

(function(modules) {
      function require(fileName) {
          // ...
      }
     require('${config.entry}');
 })({${modules}})

而函数内部,也只是循环执行每个依赖文件的 JS 代码而已,完成代码:

function bundle(queue) {
  let modules = ''
  queue.forEach(function (mod) {
    modules += `'${mod.fileName}': function (require, module, exports) { ${mod.code} },`
  })

  const result = `
    (function(modules) {
      function require(fileName) {
        const fn = modules[fileName];

        const module = { exports : {} };

        fn(require, module, module.exports);

        return module.exports;
      }

      require('${config.entry}');
    })({${modules}})
  `;

  // We simply return the result, hurray! :)
  return result;
}

到这里基本上也就介绍完了,接下来就是输出编译好的文件了,这里我们为了可以全局使用tinypack包,我们还需要为其添加到全局命令(这里直接参考我的源码吧,不再赘述了)。我们来测试一下:

npm i [email protected] -g

cd examples

tinypack

看一下输出的文件:

(function (modules) {
  function require(fileName) {
    const fn = modules[fileName];

    const module = {exports: {}};

    fn(require, module, module.exports);

    return module.exports;
  }

  require('./src/index.js');
})({
  './src/index.js': function (require, module, exports) {
    "use strict";

    var _test = require("./test");

    var _test2 = _interopRequireDefault(_test);

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : {default: obj};
    }

    console.log(_test2.default);
  }, './test': function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });

    var _message = require("./message");

    var _message2 = _interopRequireDefault(_message);

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : {default: obj};
    }

    var a = 'hello' + _message2.default;
    exports.default = a;
  }, './message': function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    var b = 'world';

    exports.default = b;
  },
})

再测试一下:
image

恩,基本上已经完成一个建议的 tinypack

参考文章

抽象语法树 Abstract syntax tree

一看就懂的JS抽象语法树

源码

tinypack 所有的源码已经上传 github

为什么会有这篇文章

webpack 为什么这么难用?


如今对于每一个前端工程师来说,webpack 已经成为了一项基础技能,它基本上包办了本地开发、编译压缩、性能优化的所有工作,从这个角度上来说,webpack 确实是伟大的,它的诞生意味着一整套工程化体系开始普及,并且慢慢统一了前端自动构建的让前端开发彻底告别了之前的刀耕火种时代。现在 webpack 之于前端开发,正如同 gcc/g++ 之于 C/C++,是一个你无论如何都绕不开的工具。
但是,即使它如此伟大,也有一个巨大的问题,那就是 webpack 实在是太难用了!!!
我从多年前的 webpack 1.0 时代就一直在用它,现在也不能说完全掌握了它,很多时候真的让我产生了怀疑,究竟是因为我的能力不足,还是因为 webpack 自身的设计就太难用?随着我接触到越来越多的前端项目,听到越来越多的吐槽,我也越发地相信,是 webpack 自身的问题,导致它变得如此复杂又难用。
举个简单的例子,一个 vue-cli 生成的最简单的脚手架项目,开发、构建相关的文件就有 14 个之多,代码超过 800 行,而真实的项目只会比这个更多:

可是有的时候我们就跟被包办婚姻一样,由脚手架给我们包办了所有的配置,我们开箱既用。如果你也跟我一样不喜欢这种包办,或者更希望了解整个过程和原理,那我们可以一起来共同学习关于webpack的那些事。

vue-router 实现 -- HashHistory

因为我们用的比较多的是 vue 的 HashHistory。下面我们首先来介绍一下 HashHistory。我们知道,通过mode来确定使用 history的方式,如果当前mode = 'hash',则会执行:

this.history = new HashHistory(this, options.base, this.fallback)

this.fallback是用来判断当前mode = 'hash'是不是通过降级处理的:

this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false

接下来我们看看HashHistory的内部实现,首先是看一下 new HashHistory()的时候,实例化做了哪些事:

constructor

// 继承 History 基类
export class HashHistory extends History {
  constructor (router: VueRouter, base: ?string, fallback: boolean) {
    // 调用基类构造器
    super(router, base)

    // 如果说是从 history 模式降级来的
    // 需要做降级检查
    if (fallback && this.checkFallback()) {
      // 如果降级 且 做了降级处理 则什么也不需要做
      return
    }
    // 保证 hash 是以 / 开头
    ensureSlash()
  }
// ...
}

function checkFallback (base) {
    // 得到除去 base 的真正的 location 值
    const location = getLocation(this.base)
    if (!/^\/#/.test(location)) {
      // 如果说此时的地址不是以 /# 开头的
      // 需要做一次降级处理 降级为 hash 模式下应有的 /# 开头
      window.location.replace(
        cleanPath(this.base + '/#' + location)
      )
      return true
    }
}

// 保证 hash 以 / 开头
function ensureSlash (): boolean {
  // 得到 hash 值
  const path = getHash()
  // 如果说是以 / 开头的 直接返回即可
  if (path.charAt(0) === '/') {
    return true
  }
  // 不是的话 需要手工保证一次 替换 hash 值
  replaceHash('/' + path)
  return false
}

export function getHash (): string {
  // 因为兼容性问题 这里没有直接使用 window.location.hash
  // 因为 Firefox decode hash 值
  const href = window.location.href
  const index = href.indexOf('#')
  // 如果此时没有 # 则返回 ''
  // 否则 取得 # 后的所有内容
  return index === -1 ? '' : href.slice(index + 1)
}

可以看到在实例化过程中主要做两件事情:针对于不支持 history api 的降级处理,以及保证默认进入的时候对应的 hash 值是以 / 开头的,如果不是则替换。

如果细心点,可以发现这里并没有对 hashchange事件做处理。主要是因为这个问题:beforeEnter fire twice on root path ('/') after async next call

简要来说就是说如果在 beforeEnter 这样的钩子函数中是异步的话,beforeEnter 钩子就会被触发两次,原因是因为在初始化的时候如果此时的 hash 值不是以 / 开头的话就会补上 #/,这个过程会触发 hashchange 事件,所以会再走一次生命周期钩子,也就意味着会再次调用 beforeEnter 钩子函数。

transitionTo

还记得 init的时候,有这样的动作:

   if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

如果historyHashHistory 的实例。则调用historytransitionTo方法。调用transitionTo的时候传入了3个参数,第一个是history.getCurrentLocation(),后面的都是setupHashListener。先来看一下getCurrentLocation:

  getCurrentLocation () {
    return getHash()
  }

也就是返回了当前路径。接着是setupHashListener函数,其内部定义了history.setupListeners()的执行。后面我们在具体分析他所做的工作,我们现在只需要明白这几个参数的含义。
接下来我们来看一下transitionTo的实现:

  transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const route = this.router.match(location, this.current)
    this.confirmTransition(route, () => {
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()

      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {
      if (onAbort) {
        onAbort(err)
      }
      if (err && !this.ready) {
        this.ready = true
        this.readyErrorCbs.forEach(cb => { cb(err) })
      }
    })
  }

该函数执行的时候,先去定义了route变量:

const route = this.router.match(location, this.current)

我们知道location代表了当前的 hash 路径。那么this.current又是什么呢?不要着急,我们找到this.current的定义:

export function createRoute (
  record: ?RouteRecord,
  location: Location,
  redirectedFrom?: ?Location,
  router?: VueRouter
): Route {
  const stringifyQuery = router && router.options.stringifyQuery

  let query: any = location.query || {}
  try {
    // 一个深拷贝
    query = clone(query)
  } catch (e) {}

  const route: Route = {
    name: location.name || (record && record.name),
    meta: (record && record.meta) || {},
    path: location.path || '/',
    hash: location.hash || '',
    query,
    params: location.params || {},
    fullPath: getFullPath(location, stringifyQuery),
    matched: record ? formatMatch(record) : []
  }
  if (redirectedFrom) {
    route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery)
  }
  return Object.freeze(route)
}

export const START = createRoute(null, {
  path: '/'
})

this.current = START

this.current就是START,通过createRoute来创建返回。注意返回的是通过Object.freeze定义的只读对象 route。可以简单看一下大致返回的内容可能是这样的:

image

接着,我们会调用this.router.match方法,来获取route对象。来看一下match方法:

  this.matcher = createMatcher(options.routes || [], this)
  match (
    raw: RawLocation,
    current?: Route,
    redirectedFrom?: Location
  ): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

大致能看出来 match函数执行this.macher对象的match方法调用。this.matcher 对象通过createMatcher方法返回。看一下this.matcher.match方法:

  function match (
    raw: RawLocation,  // 目标url
    currentRoute?: Route, // 当前url对应的route对象
    redirectedFrom?: Location // 重定向
  ): Route {
    // 解析当前 url,得到 hash、path、query和name等信息
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location
    // 如果是命名路由
    if (name) {
      //  得到路由记录
      const record = nameMap[name]
      // 不存在记录 返回
      if (!record) return _createRoute(null, location)
      const paramNames = record.regex.keys
        .filter(key => !key.optional)
        .map(key => key.name)

      if (typeof location.params !== 'object') {
        location.params = {}
      }
      // 复制 currentRoute.params 到  location.params
      if (currentRoute && typeof currentRoute.params === 'object') {
        for (const key in currentRoute.params) {
          if (!(key in location.params) && paramNames.indexOf(key) > -1) {
            location.params[key] = currentRoute.params[key]
          }
        }
      }
      // 如果存在 record 记录
      if (record) {
        location.path = fillParams(record.path, location.params, `named route "${name}"`)
        return _createRoute(record, location, redirectedFrom)
      }
    } else if (location.path) {
      // 处理非命名路由
      location.params = {}
       // 这里会遍历pathList,找到合适的record,因此命名路由的record查找效率更高
      for (let i = 0; i < pathList.length; i++) {
        const path = pathList[i]
        const record = pathMap[path]
        if (matchRoute(record.regex, location.path, location.params)) {
          return _createRoute(record, location, redirectedFrom)
        }
      }
    }
    // 没有匹配到的情况
    return _createRoute(null, location)
  }

这里我们可能需要理解一下pathListpathMapnameMap这几个变量。他们是通过createRouteMap来创建的几个对象:

const { pathList, pathMap, nameMap } = createRouteMap(routes)

routes 使我们定义的路由数组,可能是这样的:

const router = new VueRouter({
  mode: 'history',
  base: __dirname,
  routes: [
    { path: '/', name: 'home', component: Home },
    { path: '/foo', name: 'foo', component: Foo },
    { path: '/bar/:id', name: 'bar', component: Bar }
  ]
})

createRouteMap主要作用便是处理传入的routes属性,整理成3个对象:

  1. nameMap
    nameMap

  2. pathList
    pathList

  3. pathMap
    pathMap

所以 match的主要功能是通过目标路径匹配定义的route 数据,根据匹配到的记录,来进行_createRoute操作。而_createRoute会根据RouteRecord执行相关的路由操作,最后返回Route对象:

  function _createRoute (
    record: ?RouteRecord,
    location: Location,
    redirectedFrom?: Location
  ): Route {
    // 重定向
    if (record && record.redirect) {
      return redirect(record, redirectedFrom || location)
    }
    // 别名
    if (record && record.matchAs) {
      return alias(record, location, record.matchAs)
    }
    // 普通路由
    return createRoute(record, location, redirectedFrom, router)
  }

现在我们知道了this.mather.match最终返回的就是Route对象。到这里,我们再回到之前所说的transitionTo方法:

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // 匹配目标url的route对象
    const route = this.router.match(location, this.current)
    // 调用this.confirmTransition,执行路由转换
    this.confirmTransition(route, () => {
      // ...跳转完成
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()
      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {	
      // ...处理异常
    })
  }
}

得到正确的路由对象route后,我们开始跳转动作confirmTransition。接下来看看confirmTransition的主要操作

confirmTransition

  confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    // 定义中断处理
    const abort = err => {
      // ...
      onAbort && onAbort(err)
    }
   
    // 同路由且 matched.length 相同
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort()
    }

    const {
      updated,
      deactivated,
      activated
    } = resolveQueue(this.current.matched, route.matched)
    
    // 整个切换周期的队列
    const queue: Array<?NavigationGuard> = [].concat(
      // 得到即将被销毁组建的 beforeRouteLeave 钩子函数
      extractLeaveGuards(deactivated),
      // 全局 router before hooks
      this.router.beforeHooks,
      // 得到组件 updated 钩子
      extractUpdateHooks(updated),
      // 将要更新的路由的 beforeEnter 钩子
      activated.map(m => m.beforeEnter),
      // 异步组件
      resolveAsyncComponents(activated)
    )

    this.pending = route
    // 每一个队列执行的 iterator 函数
    const iterator = (hook: NavigationGuard, next) => {
       // ...
    }
    

    // 执行队列 leave 和 beforeEnter 相关钩子
    runQueue(queue, iterator, () => {
       // ...
    })
  }

这里有一个很关键的路由对象的 matched 实例,从上次的分析中可以知道它就是匹配到的路由记录的合集;这里从执行顺序上来看有这些 resolveQueueextractLeaveGuardsextractUpdateHooksresolveAsyncComponentsrunQueue 关键方法。我们先来看看resolveQueue方法:

1. resolveQueue
function resolveQueue (
  current: Array<RouteRecord>,
  next: Array<RouteRecord>
): {
  updated: Array<RouteRecord>,
  activated: Array<RouteRecord>,
  deactivated: Array<RouteRecord>
} {
  let i
  // 取得最大深度
  const max = Math.max(current.length, next.length)
  for (i = 0; i < max; i++) {
    // 如果记录不一样则停止
    if (current[i] !== next[i]) {
      break
    }
  }

  // 分别返回哪些需要更新,哪些需要激活,哪些需要卸载
  return {
    updated: next.slice(0, i),
    activated: next.slice(i),
    deactivated: current.slice(i)
  }
}

可以看出 resolveQueue 就是交叉比对当前路由的路由记录和现在的这个路由的路由记录来确定出哪些组件需要更新,哪些需要激活,哪些组件被卸载。再执行其中的对应钩子函数。

2. extractLeaveGuards/extractUpdateHooks
function extractLeaveGuards (deactivated: Array<RouteRecord>): Array<?Function> {
  return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}

function extractGuards (
  records: Array<RouteRecord>,
  name: string,
  bind: Function,
  reverse?: boolean
): Array<?Function> {
  const guards = flatMapComponents(records, (def, instance, match, key) => {
    // 获取组建的 beforeRouteLeave 钩子函数
    const guard = extractGuard(def, name)
    if (guard) {
      return Array.isArray(guard)
        ? guard.map(guard => bind(guard, instance, match, key))
        : bind(guard, instance, match, key)
    }
  })
  return flatten(reverse ? guards.reverse() : guards)
}

function extractGuard (
  def: Object | Function,
  key: string
): NavigationGuard | Array<NavigationGuard> {
  if (typeof def !== 'function') {
    // extend now so that global mixins are applied.
    def = _Vue.extend(def)
  }
  return def.options[key]
}

export function flatMapComponents (
  matched: Array<RouteRecord>,
  fn: Function
): Array<?Function> {
  return flatten(matched.map(m => {
    // 遍历得到组建的 template, instance, macth,和组件名
    return Object.keys(m.components).map(key => fn(
      m.components[key],
      m.instances[key],
      m, key
    ))
  }))
}

// 抹平数组得到一个一维数组
export function flatten (arr: Array<any>): Array<any> {
  return Array.prototype.concat.apply([], arr)
}

总的来说 extractLeaveGuards的功能就是找到即将被销毁的路由组件的beforeRouteLeave钩子函数。处理成一个由深到浅的顺序组合的数组。接下来的extractUpdateHooks函数功能也是类似,主要是处理beforeRouteUpdate钩子函数。这里不再过多介绍了。

function extractUpdateHooks (updated: Array<RouteRecord>): Array<?Function> {
  return extractGuards(updated, 'beforeRouteUpdate', bindGuard)
}
3. resolveAsyncComponents
export function resolveAsyncComponents (matched: Array<RouteRecord>): Function {
  // 返回“异步”钩子函数
  return (to, from, next) => {
    let hasAsync = false
    let pending = 0
    let error = null

    flatMapComponents(matched, (def, _, match, key) => {
      // 这里假定说路由上定义的组件 是函数 但是没有 options
      // 就认为他是一个异步组件。
      // 这里并没有使用 Vue 默认的异步机制的原因是我们希望在得到真正的异步组件之前
      // 整个的路由导航是一直处于挂起状态
      if (typeof def === 'function' && def.cid === undefined) {
        hasAsync = true
        // ...
        
      }
    })

    if (!hasAsync) next()
  }
}

这里主要是用来处理异步组建的问题,通过判断路由上定义的组件 是函数且没有 options来确定异步组件,然后在得到真正的异步组件之前将其路由挂起。

4. runQueue
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
  const step = index => {
    // 如果全部执行完成则执行回调函数 cb
    if (index >= queue.length) {
      cb()
    } else {
      // 如果存在对应的函数
      if (queue[index]) {
        // 这里的 fn 传过来的是个 iterator 函数
        fn(queue[index], () => {
          // 执行队列中的下一个元素
          step(index + 1)
        })
      } else {
        // 执行队列中的下一个元素
        step(index + 1)
      }
    }
  }
  // 默认执行钩子队列中的第一个数据
  step(0)
}

我们知道在confirmTransition中通过这样的方式来调度队列的执行:

 runQueue(queue, iterator, () => { })

runQueue函数 fn 参数传入了一个iterator函数。接下来我们看看iterator函数的执行:

this.pending = route
const iterator = (hook: NavigationGuard, next) => {
  // 如果当前处理的路由,已经不等于 route 则终止处理
  if (this.pending !== route) {
    return abort()
  }
  try {
    // hook 是queue 中的钩子函数,在这里执行
    hook(route, current, (to: any) => {
      // 钩子函数外部执行的 next 方法
      // next(false): 中断当前的导航。
      // 如果浏览器的 URL 改变了 (可能是用户手动或者浏览器后退按钮)
      // 那么 URL 地址会重置到 from 路由对应的地址。
      if (to === false || isError(to)) {
        this.ensureURL(true)
        abort(to)
      } else if (
        // next('/') 或者 next({ path: '/' }): 跳转到一个不同的地址。
        // 当前的导航被中断,然后进行一个新的导航。
        typeof to === 'string' ||
        (typeof to === 'object' && (
          typeof to.path === 'string' ||
          typeof to.name === 'string'
        ))
      ) {
        // next('/') or next({ path: '/' }) -> redirect
        abort()
        if (typeof to === 'object' && to.replace) {
          this.replace(to)
        } else {
          this.push(to)
        }
      } else {
        // 当前钩子执行完成,移交给下一个钩子函数
        // 注意这里的 next 指的是 runQueue 中传过的执行队列下一个方法函数: step(index + 1)
        next(to)
      }
    })
  } catch (e) {
    abort(e)
  }
}

我们来屡一下现在主要的流程:

  1. 执行transitionTo函数,先得到需要跳转路由的 match 对象route
  2. 执行confirmTransition函数
  3. confirmTransition函数内部判断是否是需要跳转,如果不需要跳转,则直接中断返回
  4. confirmTransition判断如果是需要跳转,则先得到钩子函数的任务队列 queue
  5. 通过 runQueue 函数来批次执行任务队列中的每个方法。
  6. 在执 queue 的钩子函数的时候,通过iterator来构造迭代器由用户传入 next方法,确定执行的过程
  7. 一直到整个队列执行完毕后,开始处理完成后的回调函数。

大致流程便是这样,我们接下来看处理完整个钩子函数队列之后将要执行的回调是什么样的:

runQueue(queue, iterator, () => {
  const postEnterCbs = []
  const isValid = () => this.current === route
  // 获取 beforeRouteEnter 钩子函数
  const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
  // 获取 beforeResolve 钩子函数 并合并生成另一个 queue
  const queue = enterGuards.concat(this.router.resolveHooks)
  runQueue(queue, iterator, () => {
    // 处理完,就不需要再次执行
    if (this.pending !== route) {
      return abort()
    }
    // 清空
    this.pending = null
    // 调用 onComplete 函数
    onComplete(route)
    if (this.router.app) {
      // nextTick 执行 postEnterCbs 所有回调
      this.router.app.$nextTick(() => {
        postEnterCbs.forEach(cb => { cb() })
      })
    }
  })
})

可以看到,处理完整个钩子函数队列之后将要执行的回调主要就是接入路由组件后期的钩子函数beforeRouteEnterbeforeResolve,并进行队列执行。一切处理完成后,开始执行transitionTo的回调函数onComplete

this.confirmTransition(route, () => {
  // 更新 route 
  this.updateRoute(route)
  // 执行 onComplete
  onComplete && onComplete(route)
  // 更新浏览器 url
  this.ensureURL()

  // 调用 ready 的回调
  if (!this.ready) {
    this.ready = true
    this.readyCbs.forEach(cb => { cb(route) })
  }
}, err => {
  // ...
})

updateRoute (route: Route) {
    const prev = this.current
    // 当前路由更新
    this.current = route
    // cb 执行
    this.cb && this.cb(route)
    // 调用 afterEach 钩子
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
}

可以看到,到这里,已经完成了对当前 route 的更新动作。我们之前已经分析了,在 install函数中设置了对route的数据劫持。此时会触发页面的重新渲染过程。还有一点需要注意,在完成路由的更新后,同时执行了onComplete && onComplete(route)。而这个便是在我们之前篇幅中介绍的setupHashListener:

const setupHashListener = () => {
  history.setupListeners()
}
history.transitionTo(
  history.getCurrentLocation(),
  setupHashListener,
  setupHashListener
)


setupListeners () {
  const router = this.router
  // 处理滚动
  const expectScroll = router.options.scrollBehavior
  const supportsScroll = supportsPushState && expectScroll

  if (supportsScroll) {
    setupScroll()
  }
  // 通过 supportsPushState 判断监听popstate 还是 hashchange
  window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {
    const current = this.current
    // 判断路由格式
    if (!ensureSlash()) {
      return
    }
    this.transitionTo(getHash(), route => {
      if (supportsScroll) {
        handleScroll(this.router, route, current, true)
      }
      // 如果不支持 history 模式,则换成 hash 模式
      if (!supportsPushState) {
        replaceHash(route.fullPath)
      }
    })
  })
}

可以看到 setupListeners这里主要做了 2 件事情,一个是对路由切换滚动位置的处理,具体的可以参考这里滚动行为。另一个是对路由变动做了一次监听 window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {})

总结

到这里,hash模式下的主要操作便差不多介绍完成了,接下来我们会去介绍history模式。

参考:
vue-router 源码分析-history

【开源自荐】SolidUI 一句话生成任何图形

本人介绍

本人从事十年年大数据相关工作,做过用户增长,BI,大数据中台,知识图谱,AI中台,擅长大数据AI相关技术栈。在CSDN输出很多专栏,是CSDN博客专家,CSDN大数据领域优质创作者,2018年参与共建WeDataSphere开源社区,社区属性是数据相关综合社区,共建过DataSphereStudio(开发管理集成框架),Exchangis(数据交换工具),Streamis(流式应用开发管理系统),Apache Linkis (计算中间件) 。个人发起SolidUI数据可视化社区。Apache Asia 2022 讲师 ,Hadoop Meetup 2022 讲师,WeDataSphere Meetup 2022讲师。Apache Linkis Committer , EXIN DPO (数据保护官)。

2023年2月开始创业,全职运营SolidUI。

SolidUI介绍

一句话生成任何图形。

随着文本生成图像的语言模型兴起,SolidUI想帮人们快速构建可视化工具,可视化内容包括2D,3D,3D场景,从而快速构三维数据演示场景。SolidUI 是一个创新的项目,旨在将自然语言处理(NLP)与计算机图形学相结合,实现文生图功能。通过构建自研的文生图语言模型,SolidUI 利用 RLHF (Reinforcement Learning Human Feedback) 流程实现从文本描述到图形生成的过程。

SolidUI Gitee https://gitee.com/CloudOrc/SolidUI
SolidUI GitHub https://github.com/CloudOrc/SolidUI
SolidUI 官网地址 https://cloudorc.github.io/SolidUI-Website/
Discord https://discord.gg/NGRNu2mGeQ
SolidUI v0.3.0 发版文章 https://mp.weixin.qq.com/s/KEFseiQJgK87zvpslhAAXw
SolidUI v0.3.0 概念视频 https://www.bilibili.com/video/BV1GV411A7Wn/
SolidUI v0.3.0 教程视频 https://www.bilibili.com/video/BV1xh4y1e7j6/
SolidUI 演示环境 http://www.solidui.top/ admin/admin

基于 electron 实现前端页面远程调试工具

前言

当业务代码发布到线上的时候,突然报了某一个机型白屏或者某个功能无法使用的时候,这种场景我们最需要的就是能知道究竟是代码哪里出错了。常见的手段是通过 vconsole 注入到我们的代码中,然后再找一下对应机型,进行功能调试。但是往往影响功能的兼容性不仅仅跟机型有关,有可能和系统版本,客户端版本等等综合因素。也就是说别人报错,你的相同机型不一定会报错,这种情况可能就又得找到当事人的手机来再注入vconsole来复现问题。

vconsole 终究是需要注入到代码里面的,如果想不注入代码里,可以通过 Charles 做请求劫持,给页面再插入 vconsole 的地址,让页面来加载,从而达到在用户手机远程查看的目的。但 vconsole 在有限的屏幕上去做展示,整体体验不是很好。其次 vconsole 也并没有达到远程调试的目的。接下来我们将尝试借助于 rubick 的能力来实现一个桌面端真正意义上的远程调试工具。

先直接上代码:

rubick 工具箱

远程调试 rubick 插件

动手实现

基于 Chrome devtools

要实现一个远程调试工具,其实 chrome 已经开源了一个可用于远程调试的工具,可以借助于 devtools-frontend 来实现远程调试功能,要说到 devtools-frontend 我们有必要先了解一下 chrome devtools 原理。

Chrome DevTools 是辅助开发者进行 Web 开发的重要调试工具,DevToolsChromium 的一部分,可整体集成于一些常见应用中,比如 electron微信开发者工具

DevTools 主要由四部分组成:

  • Frontend:调试器前端,默认由 Chromium 内核层集成,DevTools Frontend 是一个 Web 应用程序;
  • Backend:调试器后端,Chromium、V8 或 Node.js;
  • Protocol:调试协议,调试器前端和后端使用此协议通信。 它分为代表被检查实体的语义方面的域。 每个域定义类型、命令(从前端发送到后端的消息)和事件(从后端发送到前端的消息)。该协议基于 json rpc 2.0 运行;
  • Message Channels:消息通道,消息通道是在后端和前端之间发送协议消息的一种方式。包括:Embedder Channel、WebSocket Channel、Chrome Extensions Channel、USB/ADB Channel。
    这四部分的交互逻辑如下图所示:

image.png

简单来说:被调试页面引入 Backend 后,会跟 Frontend 通过 websocket 建立连接;在 backend 中,对于一些 JavaScript API 或者 DOM 操作等进行了监听和 mock,从而页面执行对应操作时,会发送消息到 Frontend。同时 Backend 也会监听来自于 Frontend 的消息,收到消息后进行对应处理。

所以要去实现基于 electron 的远程调试工具,我们需要一个 frontend 客户端来对 backend 发过来的消息进行展示。此时需要将 frontend 内置到 electron 当中,基于此,我已经实现了一个版本:

image.png

但是发现了2个问题:

  • electron 集成 devtools-frontEnd 导致项目体积增加
  • devtools 远程调试响应速度特别慢

所以我们暂时放弃了此方案,寻找一些替代方案。直到发现了 weinre

基于 weinre

weinre 就相对简单多了,只需要在 weinre 启动服务的时候,为需要调试的页面注入 target-script-min.js 即可和 weinre 建立连接。但是难点在于怎么为页面自动注入 target-script-min.js。其实我们可以参考 Charles 那样去实现一个代理服务器,当检测到页面是我们的目标页面时,动态注入 target-script-min.js 即可。要实现一个代理服务器可以参考之前写的一篇文章 基于 electron 实现简单易用的抓包、mock 工具
这次的远程调试工具也是基于该插件进行的修改。先来看一下我们实现的效果:

image.png

可以通过该插件实现对移动端的远程调试动作。接下来看看通过 rubick 如何实现这样一款插件。

Rubick 是基于 electron 的工具箱,媲美 utools的开源插件,已实现 utools 大部分的 API 能力,所以可以做到无缝适配 utools 开源的插件。 之所以做这个工具箱一方面是 utools 本身并未开源,但是公司内部的工具库又无法发布到 utools 插件中,所以为了既要享受 utools 生态又要有定制化需求,我们自己参考 utools 设计,做了 Rubick

rubick 使用和 utools 使用几乎一毛一样。首先先创建 plugin.json 作为入口文件

{
  "pluginName": "网络抓包",
  "description": "网络抓包、mock、多环境联调",
  "main": "index.html",
  "version": "0.0.1",
  "logo": "logo.png",
  "preload": "preload.js",
  "author": "muwoo",
  "name": "rubick-network",
  "features": [
    {
      "explain": "网络抓包",
      "cmds":["network", "抓包"]
    },
    {
      "explain": "远程调试",
      "code": "devtools",
      "cmds":["devtools", "远程调试"]
    }
  ]
}

network 功能之前我已经实现了,这里不再讲解如何实现抓包功能,主要介绍一下 devtools。这里 plugin.json 声明了 2 个 feature,当我们再功能栏搜索 devtools 或者 远程调试的时候 会在 rubickonPluginEnter 生命周期中传入 code 告诉启动方式。所以只需要监听该生命周期跳转到远程调试页面即可:

export default {
  setup() {
    const router = useRouter();
    window.utools.onPluginEnter(({code}) => {
      if (code === 'devtools') {
        router.push('/devtools');
      }
    })
  },
}

接下来用户需要输入为哪个页面开启远程调试功能,开启远程调试需要干3件事情:

1. 初始化 weinre

rubickpreload.js 中可以使用 nodejs,注入 weinre

// preload.js
const weinre = require('weinre2');

const optionDefaults = {
  httpPort: '9333',
  boundHost: network.getIPAddress(),
  verbose: false,
  debug: false,
  readTimeout: 5
};
weinre.run(optionDefaults);

2. 启动抓包服务

addUrlListener({commit, state}, payload) {
  if (!payload) return;
  // 服务未启动需要启动服务
  if (!state.serverInfo.ipAddress) {
    // 启动 anyproxy 服务
    effects.actions.startServer({commit, state});
  }
  commit('setDevtoolsUrl', payload);
}

3. 监听返回地址,如果是需要远程调试的页面,注入 weinre

beforeSendResponse: (requestDetail, responseDetail) => {
  // ...
  // 如果返回的 url 和需要远程调试的url一致,则注入 target-script-min.js
  if (state.devtoolsUrl && requestDetail.url === state.devtoolsUrl) {
    const result = parseUri(state.devtoolsUrl);
    newResponse.body = `<script src="https://wenire.${result.host}/target/target-script-min.js#anonymous"></script>${newResponse.body}`;
  }

  return { response: newResponse };
}

这里需要注意的是我们注入的 js 是 "https://wenire.${result.host}/target/target-script-min.js#anonymous" 这样的方式,注意到 src 并不是本地的 wenire 启动的服务地址,为什么要这个搞呢?主要因为微信环境如果在 https 网站上发起了一个和当前域名主域名不一致的 http 请求,可能会被拦截,本地 server 启动的大多是 http://192.168.xx.x 这样的地址,经常会导致注入的js加载失败。

但是由于 https://wenire.${result.host} 这个域名并没有真实存在,所以需要修改请求体,让其指向正确的资源地址:

beforeSendRequest: (requestDetail) => {
  // wenire
  const result = parseUri(state.devtoolsUrl);
  // 如果是自定义域名,需要重新指向正确的地址
  if (requestDetail.url.indexOf(`https://wenire.${result.host}`) >= 0) {
    const newRequestOptions = requestDetail.requestOptions;
    requestDetail.protocol = 'http';
    newRequestOptions.hostname = network.getIPAddress();
    newRequestOptions.port = DEVTOOLS_PORT;
  }
  return requestDetail;
}

故事到这里就差不多结束了,我们已经开发好了一个基于 rubick 的远程调试插件,快去拿给小伙伴 show 一下吧!

结语

什么是 rubick ?

基于 electron 的工具箱,媲美 utools的开源插件,已实现 utools 大部分的 API 能力,所以可以做到无缝适配 utools 开源的插件。 之所以做这个工具箱一方面是 utools 本身并未开源,但是公司内部的工具库又无法发布到 utools 插件中,所以为了既要享受 utools 生态又要有定制化需求,我们自己参考 utools 设计,做了 Rubick.

欢迎大家给rubick pr 和提 issue 帮助我们完善

Rubick: https://github.com/clouDr-f2e/rubick

本章节插件代码已上传github: https://github.com/clouDr-f2e/rubick-network

你好请求个问题

你好,看了你这篇文章特意也做了个简单的打包,好奇webpack打包后的代码。但打包后的都是压缩混淆的。我只配置的入口和出口都没配置别的。想问下怎么打包出来不压缩呢。

关于babel的.babelrc配置

一个基本的.babelrc配置可能是这样的:

{
  "presets": [
    "env",
    "stage-0"
  ],
  "plugins": ["transform-runtime"]
}

presets env

presets 是babel的一个预设,使用的时候需要安装对应的插件,对应babel-preset-xxx,例如下面的配置,需要npm install babel-preset-env

{
  "presets": ["env"]
}

每年每个 preset 只编译当年批准的内容。 而 babel-preset-env 相当于 es2015 ,es2016 ,es2017 及最新版本。

presets stage

stage 代表着ES提案的各个阶段,一共有5个阶段,存在依赖关系。也就是说stage-0是包括stage-1的,以此类推:

  • Stage 0 - 稻草人: 只是一个想法,可能是 babel 插件。
  • Stage 1 - 提案: 初步尝试。
  • Stage 2 - 初稿: 完成初步规范。
  • Stage 3 - 候选: 完成规范和浏览器初步实现。
  • Stage 4 - 完成: 将被添加到下一年度发布。

plugins

其实看了上面的应该也明白了,presets,也就是一堆plugins的预设,起到方便的作用。如果你不采用presets,完全可以单独引入某个功能,比如以下的设置就会引入编译箭头函数的功能。

{
  "plugins": ["transform-es2015-arrow-functions"]
}

如果你要引入ES6 的所有特性,那么这样一个个写实在是太麻烦了,我们可以预设支持ES6的特性:babel-preset-es2015

关于transform-runtime 可以参考这里

自定义预设或插件

Babel支持自定义的预设(presets)或插件(plugins)。如果你的插件在npm上,可以直接采用这种方式"plugins": ["babel-plugin-myPlugin"],当然,你也可以缩写,它和"plugins": ["myPlugin"]是等价的。此外,你还可以采用本地的相对路径引入插件,比如"plugins": ["./node_modules/asdf/plugin"]

presets同理。

plugins/presets排序

插件中每个访问者都有排序问题。
这意味着如果两次转译都访问相同的”程序”节点,则转译将按照 plugin 或 preset 的规则进行排序然后执行。

  • Plugin 会运行在 Preset 之前。
  • Plugin 会从第一个开始顺序执行。ordering is first to last.
  • Preset 的顺序则刚好相反(从最后一个逆序执行)。

小程序框架原来也就是这么回事

小程序内部实现原理概览

小程序原理demo

对于小程序框架实现原理,在支付宝小程序官方文档上有这样一段描述:

与传统的 H5 应用不同,小程序运行架构分为 webview 和 worker 两个部分。webview 负责渲染,worker 则负责存储数据和执行业务逻辑。
1.webview 和 worker 之间的通信是异步的。这意味着当我们调用 setData 时,我们的数据并不会立即渲染,而是需要从 worker 异步传输到 webview。
2.数据传输时需要序列化为字符串,然后通过 evaluateJavascript 方式传输,数据大小会影响性能。

概括一下,大致意思是小程序框架核心是通过2个线程来完成的,主线程负责webView的渲染工作,worker线程负责js执行。说到这里,你是不是会产生一个疑问:为什么多线程通信损耗性能还要搞多线程呢? 可能大多数人都知道因为Web技术实在是太开放了,开发者可以为所欲为。这种情况在小程序中是不允许的,不允许使用<iframe>、不允许直接外跳到其他在线网页、不允许开发者触碰DOM、不允许使用某些未知的危险API等。但是,仔细想想其实单线程也有能力来阻止用户操作这些危险动作,比如通过全局配置黑名单API、改写框架内部编译机制,屏蔽危险操作... 但是却始终无法解决一个问题:如何防止开发者做一些我们想禁用的功能。因为是一个网页,开发者可以执行JS,可以操作DOM,可以操作BOM,可以做一切事情。so,我们需要一个沙箱环境,来运行我们的js,这个沙箱环境需要可以屏蔽掉所有的危险动作。说了这么多,大致想法如下:
image
关于UI层的渲染,有很多实现方式,比如通过类似VNode -> diff的自定义渲染方式来实现了一个简易的小程序框架:

function App (props) {
  const {msg} = props; 
  return () => (
    <div class="main">
      {msg}
    </div>
  )
}

render(<App msg="hello world" />)

核心就是通过定义@babel/plugin-transform-react-jsx插件来转换 jsx,生成Vnode,再交给Worker通过Diff,最后通过worker postmsg 来通知渲染进程更新:

let index = 0;
// 得到diff差异
let diffData = diff(0, index, oldVnode, newVnode);
// 通知渲染进程更新
self.postMessage(JSON.stringify(diffData)); 

有点麻烦?能不能继承现有框架能力?比如Vue、React。当然可以,我们下面就来介绍基于Vue来实现的demo.

实现一个基于Vue的小程序框架

有了上面的知识,我们先不着急写代码,先来捋一下我们需要什么,首先我们需要实现这样一个能力:渲染层和逻辑层分离,emmm。。。大致我们的小程序是这样的

// page.js 逻辑层
export default {
  data: {
    msg: 'hello Vox',
  },
  create() {
    console.log(window);
    setTimeout(() => {
      this.setData({
        msg: 'setData',
      })
    }, 1000);
  },
}
// page.vxml.js 渲染层
export default () => {
  return '<div>{{msg}}</div>';
}

这里的渲染层为啥不是类似于微信或者支付宝小程序 wxml,axml这样的呢?当然可以,其实我只是为了方便而已,我们可以手写一个webpack loader 来处理一下我们自定义的文件。这里有兴趣的小伙伴可以尝试一下。不是本次介绍的核心。

好了,上面是我们想要的功能,我们核心是框架,框架层要干的事核心有2个:构造worker初始化引擎;构造渲染引擎。

// index.worker.js 构造worker
const voxWorker = options => {
  const {config} = options;
  // Vue生命周期收集
  const lifeCircleMap = {
    'lifeCircle:create': [config.create],
  };
  // 定义setData方法用于通知UI层渲染更新
  self.setData = (data) => {
    console.log('setData called');
    self.postMessage(
      JSON.stringify({
        type: 'update',
        data,
      })
      ,
      null
    );
  };
  // worker构建完成,通知渲染层初始化
  self.postMessage(
    JSON.stringify({
      type: 'init',
      data: config.data,
    })
    ,
    null
  );
  // 执行生命周期函数
  self.onmessage = e => {
    const {type} = JSON.parse(e.data);
    lifeCircleMap[type].forEach(lifeCircle => lifeCircle.call(self))
  }
}

export default voxWorker;

上面代码核心干的事其实并不复杂,也就是:

  1. 收集需要用到的生命周期
  2. 定义setData函数,提供给用户层更新UI
  3. 定义监听函数,处理生命周期函数执行
  4. 通知UI进程开启渲染。

当我们通知UI进程开始渲染的时候,UI进程也就是需要构造Vue实例,进行页面render:

worker.onmessage = e => {
    const {type, data} = JSON.parse(e.data);
    if (type === 'init') {
      const mountNode = document.createElement('div');
      document.body.appendChild(mountNode);
      target = new Vue({
        el: mountNode,
        data: () => data,
        template: template(),
        created(){
          worker.postMessage(JSON.stringify({
              type: 'lifeCircle:create',
            })
            ,
            null);
        }
      });
    }
  }

可以看到UI线程在初始化的时候,一并初始化了 worker层传递来的data,并对生命周期进行了声明。当生命周期函数在UI层触发的时候,会通知 worker。在我们的例子中,create 钩子通过setData 进行了一个更新data的动作。我们知道 setData 就是拿到数据进行通知更新:

// UI线程接收到通知消息,更新UI
 if (type === 'update') {
      Object.keys(data).map(key => {
        target[key] = data[key];
      });
    }

说到这里,似乎一切都感觉清楚多了。我们很容易想到不断地数据传输对性能的损耗,所以我们当然可以做进一步的优化,多个setData可以组合一起发送?就是建立一个通信。其次再看一些,我们的worker再通信传输数据的过程中不断通过字符串的parsestringify

image

绿色部分时原生JSON.stringify(), 关于这一块如何提升性能一方面可以通过减少数据传输量,其他的优化也可以参考这里如何提升JSON.stringify()的性能

最后你可能会问,小程序用法都是 <view>, <text> 之类的标签,为啥我这里直接用了 <div>。其实吧, 也就是

的语法糖,写一个 vue组件,组件名称叫view是不是就可以了呢?

有兴趣的可以查看源码Vox

结语

引用知乎上的一段话:

其实,大家对小程序的底层实现都是使用双线程模型,大家对外宣称都会说是为了:方便多个页面之间数据共享和交互为native开发者提供更好的编码体验为了性能(防止用户的JS执行卡住UI线程)其他好处但其实真正的原因其实是:“安全”和“管控”,其他原因都是附加上去的。因为Web技术是非常开放的,JavaScript可以做任何事。但在小程序这个场景下,它不会给开发者那么高的权限:不允许开发者把页面跳转到其他在线网页不允许开发者直接访问DOM不允许开发者随意使用window上的某些未知的可能有危险的API,当然,想解决这些问题不一定非要使用双线程模型,但双线程模型无疑是最合适的技术方案。

经过上面的介绍,是不是发现小程序其实也就那么回事,并没有多么....这边文章主要希望能让你对经常使用的框架有一个原理性的初步认识,至少我们再用的时候可以规避掉一些坑,或者性能问题。

参考文章:
https://zhuanlan.zhihu.com/p/81775922
双线程前端框架:Voe.js

浏览器的工作原理

前言

浏览器对于前端来说,并不陌生。但是我们往往接触的都是一个黑盒的浏览器,并不知道其内部的工作原理,这篇文章我们主要来研究一下浏览器内部是如何去加载一个页面的。这里大多数是一些理论知识点,读起来可能有点枯燥,此文是笔者在网络上搜寻了大量的文章和资料,整理输出而得。不过耐心的了解一下,对理解一些页面性能加载,浏览器一些怪异的问题还是有很多帮助的。

浏览器的组成

  1. 用户界面 - 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
  2. 浏览器引擎 - 在用户界面和渲染引擎之间传送指令。
  3. 渲染引擎 - 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  4. 网络 - 用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
  5. 用户界面后端 - 用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。
  6. JavaScript 解释器。用于解析和执行 JavaScript 代码。
  7. 数据存储。这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。

image

渲染引擎(Rendering engine)

浏览器渲染引擎是由各大浏览器厂商依照 W3C 标准自行研发的,也被称之为「浏览器内核」。目前,市面上使用的主流浏览器内核有5类:Trident、Gecko、Presto、Webkit、Blink

Trident:俗称 IE 内核,也被叫做 MSHTML 引擎,目前在使用的浏览器有 IE11 -,以及各种国产多核浏览器中的IE兼容模块。另外微软的 Edge 浏览器不再使用 MSHTML 引擎,而是使用类全新的引擎 EdgeHTML。

Gecko:俗称 Firefox 内核,Netscape6 开始采用的内核,后来的 Mozilla FireFox(火狐浏览器)也采用了该内核,Gecko 的特点是代码完全公开,因此,其可开发程度很高,全世界的程序员都可以为其编写代码,增加功能。因为这是个开源内核,因此受到许多人的青睐,Gecko 内核的浏览器也很多,这也是 Gecko 内核虽然年轻但市场占有率能够迅速提高的重要原因。

Presto:Opera 前内核,为啥说是前内核呢?因为 Opera12.17 以后便拥抱了 Google Chrome 的 Blink 内核,此内核就没了寄托。

Webkit:Safari 内核,也是 Chrome 内核原型,主要是 Safari 浏览器在使用的内核,也是特性上表现较好的浏览器内核。也被大量使用在移动端浏览器上。

Blink:由 Google 和 Opera Software 开发,在Chrome(28及往后版本)、Opera(15及往后版本)和Yandex浏览器中使用。Blink 其实是 Webkit 的一个分支,添加了一些优化的新特性,例如跨进程的 iframe,将 DOM 移入 JavaScript 中来提高 JavaScript 对 DOM 的访问速度等,目前较多的移动端应用内嵌的浏览器内核也渐渐开始采用 Blink。

渲染引擎的工作流程

浏览器渲染引擎最重要的工作就是将 HTML 和 CSS 文档解析组合最终渲染到浏览器窗口上。如下图所示,渲染引擎在接受到 HTML 文件后主要进行了以下操作:解析 HTML 构建 DOM 树 -> 构建渲染树 -> 渲染树布局 -> 渲染树绘制

image

解析 HTML 构建 DOM 树时渲染引擎会将 HTML 文件的便签元素解析成多个 DOM 元素对象节点,并且将这些节点根据父子关系组成一个树结构。同时 CSS 文件被解析成 CSS 规则表,然后将每条 CSS 规则按照「从右向左」的方式在 DOM 树上进行逆向匹配,生成一个具有样式规则描述的 DOM 渲染树。接下来就是将渲染树进行布局、绘制的过程。首先根据 DOM 渲染树上的样式规则,对 DOM 元素进行大小和位置的定位,关键属性如position;width;margin;padding;top;border;...,接下来再根据元素样式规则中的color;background;shadow;...规则进行绘制。
另外,这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。

再者,需要注意的是,在浏览器渲染完首屏页面后,如果对 DOM 进行操作会引起浏览器引擎对 DOM 渲染树的重新布局和重新绘制,我们叫做「重排」和「重绘」。

主流程示例

WebKit 主流程

解析

在呈现引擎中解析是非常重要的环节。解析文档即是将文档转化为有意义的结构,解析后得到的结果通常代表了文档结构的节点树,被称作解析树或语法树。

而解析的过程一般为词法分析语法分析,词法分析即是大量的标记过程,词法分析器根据特定的字典(语言的词汇)对输入内容进行标记;语法分析即是应用语言语法的过程。不同语言拥有不同的解析器,在这里不做多的赘述,如果想了解更多,可以参考浏览器的工作原理:新式网络浏览器幕后揭秘。在浏览器中,有HTML解析器,CSS解析器,JavaScript解析器等。

HTML解析

HTML解析器概括的来说就是一种特别独特的解析方式,需要有一定的容错性,HTML 无法用常规的自上而下或自下而上的解析器进行解析。原因在于:

  1. 语言的宽容本质。
  2. 浏览器历来对一些常见的无效 HTML 用法采取包容态度。
  3. 解析过程需要不断地反复。源内容在解析过程中通常不会改变,但是在 HTML 中,脚本标记如果包含 document.write,就会添加额外的标记,这样解析过程实际上就更改了输入内容。

由于不能使用常规的解析技术,浏览器就创建了自定义的解析器来解析 HTML,此算法由两个阶段组成:标记化和树构建。

标记化是词法分析过程,将输入内容解析成多个标记。HTML 标记包括起始标记、结束标记、属性名称和属性值。

标记生成器识别标记,传递给树构造器,根据传入的标记生成与之对应的 HTMLElement 然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。这一点有点像 Vue Virtual Dom 的生成过程,也是在不断地标记化输入的 HTML 字符串,根据标签,解析生成对应的 vnode。

CSS解析

CSS 解析器将 CSS 文件解析成 StyleSheet 对象,且每个对象都包含 CSS 规则。CSS 规则对象则包含选择器和声明对象,以及其他与 CSS 语法对应的对象。

image

渲染树构建

在 DOM 树构建的同时,浏览器还会构建另一个树结构:渲染树。这是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是让您按照正确的顺序绘制内容。渲染器知道如何布局并将自身及其子元素绘制出来。 WebKits RenderObject 类是所有呈现器的基类,其定义如下:

class RenderObject{
  virtual void layout();
  virtual void paint(PaintInfo);
  virtual void rect repaintRect();
  Node* node;  //the DOM node
  RenderStyle* style;  // the computed style
  RenderLayer* containgLayer; //the containing z-index layer
}

每一个渲染器都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,这一点在 CSS2 规范中有所描述。它包含诸如宽度、高度和位置等几何信息。
框的类型会受到与节点相关的“display”样式属性的影响。下面这段 WebKit 代码描述了根据 display 属性的不同,针对同一个 DOM 节点应创建什么类型的渲染器。

RenderObject* RenderObject::createObject(Node* node, RenderStyle* style)
{
    Document* doc = node->document();
    RenderArena* arena = doc->renderArena();
    ...
    RenderObject* o = 0;

    switch (style->display()) {
        case NONE:
            break;
        case INLINE:
            o = new (arena) RenderInline(node);
            break;
        case BLOCK:
            o = new (arena) RenderBlock(node);
            break;
        case INLINE_BLOCK:
            o = new (arena) RenderBlock(node);
            break;
        case LIST_ITEM:
            o = new (arena) RenderListItem(node);
            break;
       ...
    }

    return o;
}

元素类型也是考虑因素之一,例如表单控件和表格都对应特殊的框架。
在 WebKit 中,如果一个元素需要创建特殊的呈现器,就会替换 createRenderer 方法。呈现器所指向的样式对象中包含了一些和几何无关的信息。

渲染树和 DOM 树的关系

渲染器是和 DOM 元素相对应的,但并非一一对应。非可视化的 DOM 元素不会插入呈现树中,例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在渲染树中(但是 visibility 属性值为“hidden”的元素仍会显示)。
有一些 DOM 元素对应多个可视化对象。它们往往是具有复杂结构的元素,无法用单一的矩形来描述。例如,“select”元素有 3 个渲染器:一个用于显示区域,一个用于下拉列表框,还有一个用于按钮。如果由于宽度不够,文本无法在一行中显示而分为多行,那么新的行也会作为新的渲染器而添加
另一个关于多渲染器的例子是格式无效的 HTML。根据 CSS 规范,inline 元素只能包含 block 元素或 inline 元素中的一种。如果出现了混合内容,则应创建匿名的 block 渲染器,以包裹 inline 元素。

DOM树和渲染树的对应关系

渲染树样式计算

假设有这样的HTML代码:

<html>
  <body>
    <div class="err" id="div1">
      <p>
        this is a <span class="big"> big error </span>
        this is also a
        <span class="big"> very  big  error</span> error
      </p>
    </div>
    <div class="err" id="div2">another error</div>
  </body>
</html>

还有如下规则:

div {margin:5px;color:black}
.err {color:red}
.big {margin-top:3px}
div span {margin-bottom:4px}
#div1 {color:blue}
#div2 {color:green}

形成的规则树如下图所示(节点的标记方式为“节点名 : 指向的规则序号”):
image

结合 DOM Tree 生成的上下文树(Style Context Tree)如下图所示(把CSS Rule结点Attach到DOM Tree上,节点名 : 指向的规则节点):
image

所以,Firefox基本上来说是通过CSS 解析 生成 CSS Rule Tree,然后,通过比对DOM生成Style Context Tree,然后Firefox通过把Style Context Tree和其Render Tree(Frame Tree)关联上,就完成了。注意:Render Tree会把一些不可见的结点去除掉。而Firefox中所谓的Frame就是一个DOM结点,不要被其名字所迷惑了

注:Webkit不像Firefox要用两个树来干这个,Webkit也有Style对象,它直接把这个Style对象存在了相应的DOM结点上了。

布局(Layout)

渲染器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。坐标系是相对于根框架而建立的,使用的是上坐标和左坐标。

布局是一个递归的过程。它从根渲染器(对应于 HTML 文档的 元素)开始,然后递归遍历部分或所有的框架层次结构,为每一个需要计算的呈现器计算几何信息。

根渲染器的位置左边是 0,0,其尺寸为视口(也就是浏览器窗口的可见区域)。
所有的渲染器都有一个“layout”或者“reflow”方法,每一个渲染器都会调用其需要进行布局的子代的 layout 方法。

这里重要要说两个概念,一个是Reflow,另一个是Repaint。这两个不是一回事。

Repaint ——屏幕的一部分要重画,比如某个CSS的背景色变了。但是元素的几何尺寸没有变。
Reflow ——意味着元件的几何尺寸变了,我们需要重新验证并计算Render Tree。是Render Tree的一部分或全部发生了变化。这就是Reflow,或是Layout。(HTML使用的是flow based layout,也就是流式布局,所以,如果某元件的几何尺寸发生了变化,需要重新布局,也就叫reflow)reflow 会从这个root frame开始递归往下,依次计算所有的结点几何尺寸和位置,在reflow过程中,可能会增加一些frame,比如一个文本字符串必需被包装起来。

定位

下面主要介绍一些postionz-index的知识,详细的可以参考这里

参考文献

浏览器的工作原理:新式网络浏览器幕后揭秘

浏览器渲染原理

「前端那些事儿」① 浏览器渲染引擎

CommonJS、AMD/CMD、ES6 Modules 以及 webpack 原理浅析

CommonJS

Node.js是commonJS规范的主要实践者,它有四个重要的环境变量为模块化的实现提供支持:module、exports、require、global。实际使用时,用module.exports定义当前模块对外输出的接口,用require加载模块。

// 定义模块 area.js
function area(radius) {
  return Math.PI * radius * radius;
}

// 在这里写上需要向外暴露的函数、变量
module.exports = { 
  area: area
}

// 引用自定义的模块时,参数包含路径
var math = require('./math');
math.area(2);

但是我们并没有直接定义 module、exports、require这些模块,以及 Node 的 API 文档中提到的__filename、__dirname。那么是从何而来呢?其实在编译的过程中,Node 对我们定义的 JS 模块进行了一次基础的包装:

(function(exports, require, modules, __filename, __dirname)) {
  ...
})

这样我们便可以访问这些传入的arguments以及隔离了彼此的作用域。CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。commonJS用同步的方式加载模块,只有在代码执行到require的时候,才回去执行加载。在服务端,模块文件都存在本地磁盘,读取非常快,所以这样做不会有问题。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。

AMD

AMD 是 RequireJS 在推广过程中对模块定义的规范化产出。AMD 规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。说了这么多,来看一下一个AMD规范的RequireJS 是如何定义的:

// 定义 moduleA 依赖 a, b模块
define(['./a','./b'],function(a,b){
   a.doSomething()
   b.doSomething()
}) 

// 使用
require(['./moduleA'], function(moduleA) {
  // ...
})

CMD

CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。比如require.js在申明依赖的模块时会在第一之间加载并执行模块内的代码,而CMD则是在使用的时候就近定义:

define(function(require, exports, module) {
  var a = require('./a')
  a.doSomething()
  var b = require('./b')
  b.doSomething()
})

代码在运行时,首先是不知道依赖的,需要遍历所有的require关键字,找出后面的依赖。具体做法是将function toString后,用正则匹配出require关键字后面的依赖。显然,这是一种牺牲性能来换取更多开发便利的方法。而 AMD 是依赖前置的,换句话说,在解析和执行当前模块之前,模块作者必须指明当前模块所依赖的模块。代码在一旦运行到此处,能立即知晓依赖。而无需遍历整个函数体找到它的依赖,因此性能有所提升,缺点就是开发者必须显式得指明依赖——这会使得开发工作量变大,比如:当你写到函数体内部几百上千行的时候,忽然发现需要增加一个依赖,你不得不回到函数顶端来将这个依赖添加进数组。

ES6 Module

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

import a from './a'
import b from './b'

a.doSomething()
b.doSomething()

function c () {}

export default c

ES6 Modules不是对象,import命令会被 JavaScript 引擎静态分析,在编译时就引入模块代码,而不是在代码运行时加载,所以无法实现条件加载。也正因为这个,使得静态分析成为可能。

ES6 模块与 CommonJS 模块的差异

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用

  1. CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
  2. ES6 Modules 的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的 import 有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

  1. 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
  2. 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”

CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

参考文章

前端模块化:CommonJS,AMD,CMD,ES6

搭建一个基础的cli工具

前言

作为Vue的用户,离不开Vue-cli,当我们构建项目的时候,项目大部分都会运行以下命令:vue init webpack myProject
根据交互式问答,我们一步步构建出了自己的项目基本结构,如果你已经知道如何搭建这样一个交互式脚手架,下面的内容就可以不用看了...

npm 全局模板

1. hello world

通过我们会通过npm install -g xxx来进行全局包的安装。-g 是将一个包安装为全局可用的可执行命令,他根据包描述文件package.json中的bin字段进行配置。

// package.json
{
  "name": "demo",
  "version": "0.0.1",
  "bin": {
    "demo": "./bin/test"
  }
}

./bin/test

#!/usr/bin/env node
console.log('hello world')

下面运行npm i ./ -g 来全局安装当前目录到全局环境,运行demo, 看到了熟悉的hello world。

注意脚本中需要指定#!/usr/bin/env node来声明脚本的执行环境为nodejs。

2. commder

当一个Nodejs程序运行时,会有许多存在内存中的全局变量,其中有一个叫做process,意为进程对象。process对象中有一个叫做argv的属性。命令行程序的第一个重头戏就是解析这个process.argv属性。
我们把上面的例子改造一下,把process.argv打印出来看看:
image
当我们需要根据参数,执行不同任务的时候,我们就需要去解析这样的参数,如果不嫌麻烦,我们也可以自己写一个简陋的函数去处理这样的事情。
commander.js是TJ所写的一个工具包,其作用是让node命令行程序的制作更加简单。下面我们来一个简单的🌰

#!/usr/bin/env node

require('commander')
  .version('0.0.1')
  .description('a test cli program')
  .option('-n, --name <name>', 'your name', 'monkeyWang')
  .option('-a, --age <age>', 'your age', '22')
  .parse(process.argv)

运行demo -h
image
此时,会列举出cli脚手架的一些提示信息,提示信息通过option选项进行定义,描述信息通过description选项进行定义。最后可以通过parse来解析命令行参数

Vue官网中的约束源码解释 -- 生命周期

关于生命周期的源码执行

首先我们先来看一张官网的图:

然后我们来看一下源码里什么时候开始执行各个生命周期的:

1. beforeCreate、created

beforeCreatecreated钩子在core/instance/init.js_init方法中执行

initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
nitProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

这里主要是初始化一些vm的属性,initState主要为定义的data属性进行obsever以及处理一些propswatchcomputed:

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

2. beforMounted

在执行beforMounted的钩子的时候,会进行几部判断:

1. 判断存不存在$el属性
  if (vm.$options.el) {
      vm.$mount(vm.$options.el)
  }
2. 判断存不存在template属性:
    let template = options.template
    let template = options.template
    if (template) {
      // string
      if (typeof template === 'string') {
        // 如果第一个字符是#,则 template = query(id).innerHTML
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
        }
     // dom 节点
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }

3. mounted

这一步主要是经过了 render --> VNode --> path步骤后生成了一个真实的dom节点,并挂载到el上:

return function patch () {
  ...
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}
function invokeInsertHook (vnode, queue, initial) {
   ...
    for (let i = 0; i < queue.length; ++i) {
        queue[i].data.hook.insert(queue[i])
     }
 }

insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      // 这里执行 mounted
      callHook(componentInstance, 'mounted')
    }
 }

4. beforeUpdate

当我们执行dom更新之前,且已经经过mounted。会触发的钩子:

vm._update(vm._render(), hydrating)
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  ...
    if (vm._isMounted) {
       callHook(vm, 'beforeUpdate')
    }
  ...
}

5. updated

这个钩子函数主要是在异步更新队列中执行,也就是nextTick更新dom后会执行的钩子

function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted) {
      callHook(vm, 'updated')
    }
  }
}

function flushSchedulerQueue () {
   ...
   watcher.run()
  ...
  callUpdatedHooks(updatedQueue)
}

关于什么是nextTick?以及Event loop相关知识,有兴趣可以参考我的这两篇文章:

Vue nextTick 机制

Event loop 简介

6. beforeDestroy destroyed

$destroy函数被调用时,会首先触发beforeDestroy钩子:

 Vue.prototype.$destroy = function () {
    const vm: Component = this
    if (vm._isBeingDestroyed) {
      return
    }
    callHook(vm, 'beforeDestroy')
    vm._isBeingDestroyed = true
    // remove self from parent
    const parent = vm.$parent
    if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
      remove(parent.$children, vm)
    }
    // teardown watchers
    if (vm._watcher) {
      vm._watcher.teardown()
    }
    let i = vm._watchers.length
    while (i--) {
      vm._watchers[i].teardown()
    }
    // remove reference from data ob
    // frozen object may not have observer.
    if (vm._data.__ob__) {
      vm._data.__ob__.vmCount--
    }
    // call the last hook...
    vm._isDestroyed = true
    // invoke destroy hooks on current rendered tree
    vm.__patch__(vm._vnode, null)
    // fire destroyed hook
    callHook(vm, 'destroyed')
    // turn off all instance listeners.
    vm.$off()
    // remove __vue__ reference
    if (vm.$el) {
      vm.$el.__vue__ = null
    }
    // release circular reference (#6759)
    if (vm.$vnode) {
      vm.$vnode.parent = null
    }
  }
}

可以看到,destroy步骤如下:

  1. remove(parent.$children, vm)从父节点中先移除自己
  2. vm._watcher.teardown() 销毁watchers
  3. vm._data.__ob__.vmCount-- 从数据ob中删除引用
  4. vm.__patch__(vm._vnode, null) 调用当前渲染树上的销毁钩子
  5. callHook(vm, 'destroyed') 调用destroyed钩子
  6. vm.$off()销毁事件监听 ...

到这里差不多就执行完了销毁任务,从而触发了destroyed钩子

一些警告

不要在选项属性或回调上使用箭头函数,比如 created: () => console.log(this.a) 或 vm.$watch('a', newValue => this.myMethod())。因为箭头函数是和父级上下文绑定在一起的,this 不会是如你所预期的 Vue 实例,经常导致 Uncaught TypeError: Cannot read property of undefined Uncaught TypeError: this.myMethod is not a function 之类的错误。
我们可以看一下Vue是如何执行生命周期函数的:

export function callHook (vm: Component, hook: string) {
  const handlers = vm.$options[hook]
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm)
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
}

比如我们执行beforeCreate钩子:callHook(vm, 'beforeCreate')。因为是箭头函数,所以可以先了解箭头函数的几个特性:

  1. 箭头函数作为函数的一种形式,对于this的处理和普通函数有所区别,其没有自己的this上下文,也就是说通过bind/call/apply函数方法设置this值时无效的,会被忽略
  2. 因为箭头函数没有自己的this上下文,那么当它作为对象的方法函数运行时,this并不指向这个对象
  3. 箭头函数的的函数体中出现的this在运行时绑定到最近的作用域上下文对象
  4. 你可以认为箭头函数的this和调用者无关,只和其定义时所在的上下文相关

说到这里,应该明白了为什么不要在选项属性或回调上使用箭头函数了吧...

Vue 3.x Form render

Vue 3.x Form render

github: vue form render

在线演示

为什么要造这个轮子?

之前用过 React 的 Form Render 的小伙伴应该比较清楚 Form Render 可以基于 JSON Schema 快速构建出表单区块。不得不说 For Render 在以下场景中的使用会给开发带来巨大的便利:

  1. 规范化表单视图的快速生成:写好对应的参数配置,快速生成一个标准表单,省去了使用类 Ant Design 表单的麻烦地方
  2. 可视化配置界面生成:并可以从代码层面 自动生成 JSON Schema,来完成整体流程的打通
  3. 服务能力配置界面生成:常用于后台字段系统中,接口同学通过吐 JSON Schema 字段给前端界面,渲染出他所想要的界面以及获取用户的输入进行提交给后端,可以起到无需发布就可无缝扩展各种类型的作用
  4. 作为配置输入和搭建系统配合使用:FormRender 在正常展示的情况下,可以很简单的进行和原主题的适配使用

但是现在我们的场景是基于 vue 3.x 的框架基础上去使用 form render 但是 form render 目前也只支持 react。然后我再 Google 上搜了一大圈,也没找到一个还可以的 vue 3.xform render,不过 vue 2.x 的还是挺多的。出于这样的诉求,自己动手撸了一个。

功能

vue-form-render 是基于 Form Render 基本能力作为原型实现的 Vue 3.x 版本的表单渲染器,目前支持 90% 左右的 Form Render 功能,后续会不断的维护支持。

Array

  • 支持excel导入数据,方便快快捷生成form Data
  • 支持拖拽排序
"listName2": {
  "title": "对象数组",
  "description": "对象数组嵌套功能",
  "type": "array",
  "minItems": 1,
  "maxItems": 3,
  "ui:displayType": "row",
  "items": {
    "type": "object",
    "properties": {
      "input1": {
        "title": "简单输入框",
        "type": "string"
      },
      "selet1": {
        "title": "单选",
        "type": "string",
        "enum": [
          "a",
          "b",
          "c"
        ],
        "enumNames": [
          "早",
          "中",
          "晚"
        ]
      }
    }
  }
}

string

 "string": {
  "title": "字符串",
  "type": "string",
  "maxLength": 4,
  "ui:options": {
    "placeholder": "试着输入超过4个字符"
  }
}

color-picker

 "color": {
  "title": "颜色选择",
  "type": "string",
  "format": "color"
}

date-picker

 "date": {
  "title": "日期选择",
  "type": "string",
  "format": "date"
}

image

"image": {
  "title": "图片展示",
  "type": "string",
  "format": "image"
}

number

"allNumber": {
  "title": "number类",
  "type": "object",
  "properties": {
    "number1": {
      "title": "数字输入框",
      "description": "1 - 1000",
      "type": "number",
      "min": 1,
      "max": 1000
    },
    "number2": {
      "title": "带滑动条",
      "type": "number",
      "ui:widget": "slider"
    }
  }
}

boolean

"allBoolean": {
  "title": "boolean类",
  "type": "object",
  "properties": {
    "radio": {
      "title": "是否通过",
      "type": "boolean"
    },
    "switch": {
      "title": "开关控制",
      "type": "boolean",
      "ui:widget": "switch"
    }
  }
}

date-range

 "allRange": {
  "title": "range类",
  "type": "object",
  "properties": {
    "dateRange": {
      "title": "日期范围",
      "type": "range",
      "format": "dateTime",
      "ui:options": {
        "placeholder": [
          "开始时间",
          "结束时间"
        ]
      }
    }
  }
}

emun

 "allEnum": {
  "title": "选择类",
  "type": "object",
  "properties": {
    "select": {
      "title": "单选",
      "type": "string",
      "enum": [
        "a",
        "b",
        "c"
      ],
      "enumNames": [
        "早",
        "中",
        "晚"
      ]
    },
    "radio": {
      "title": "单选",
      "type": "string",
      "enum": [
        "a",
        "b",
        "c"
      ],
      "enumNames": [
        "早",
        "中",
        "晚"
      ],
      "ui:widget": "radio"
    },
    "multiSelect": {
      "title": "多选",
      "description": "下拉多选",
      "type": "array",
      "items": {
        "type": "string"
      },
      "enum": [
        "A",
        "B",
        "C",
        "D"
      ],
      "enumNames": [
        "杭州",
        "武汉",
        "湖州",
        "贵阳"
      ],
      "ui:widget": "multiSelect"
    },
    "boxes": {
      "title": "多选",
      "description": "checkbox",
      "type": "array",
      "items": {
        "type": "string"
      },
      "enum": [
        "A",
        "B",
        "C",
        "D"
      ],
      "enumNames": [
        "杭州",
        "武汉",
        "湖州",
        "贵阳"
      ]
    }
  }
}

Object

"obj1": {
  "title": "可折叠对象",
  "description": "这是个对象类型",
  "type": "object",
  "ui:options": {
    "collapsed": true
  },
  "properties": {
    "input1": {
      "title": "输入框1",
      "type": "string"
    },
    "input2": {
      "title": "输入框2",
      "type": "string"
    }
  }
}

rich-text

{
  "type": "object",
  "properties": {
    "content": {
      "title": "富文本编辑器",
      "type": "string",
      "format": "richText"
    }
  }
}

快速使用

依赖ant-design-vue

import { createApp } from 'vue'
import App from './App.vue'

import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/antd.css';

const app = createApp(App);
app.use(Antd);
app.mount('#app');

引入vue-form-render

npm i kaer-form-render --save
<template>
  <div>
    <formRender
      :schema="schema"
      :formData="formData"
      @on-change="change"
      @on-validate="validate"
    />
  </div>
</template>

<script>
import {reactive, toRefs} from 'vue';

// render index
import FormRender from 'kaer-form-render';
// form render style
import 'kaer-form-render/lib/kaer-form-render.css';

export default {
  name: 'App',
  setup() {
    const state = reactive({
      schema: {
        type: 'object',
        properties: {
          string: {
            title: 'string',
            type: 'string',
            maxLength: 4,
            'ui:options': {
              placeholder: 'enter more than 4 characters',
            },
          }
        },
      },
      formData: {
        string: 'aaa'
      },
    });

    const change = (v) => {
      state.formData = v;
      console.log(v);
    }
    const validate = (v) => {
      console.log(v);
    }

    return {
      ...toRefs(state),
      change,
      validate,
    }
  },
  components: {
    FormRender,
  }
}
</script>

API

Props

参数 说明 类型 默认值
schame JSON Schema object --
formData 表单的数据 object --

Events

事件名 说明 回调函数
on-change 用户触发表单更新的回调函数 function(value: formData)
on-validate 用户触发表单更新的校验回调函数 function(value: validates)

最后

欢迎大家使用并pr,我们一起打造一款好用的vue form render

github: vue form render

在线演示

vue dist 目录各个文件使用说明

Vue 2.x 版本,dist 构建目录生成的文件如下:

vue.common.js
vue.esm.js
vue.js
vue.min.js
vue.runtime.common.js
vue.runtime.esm.js
vue.runtime.js
vue.runtime.min.js

瞬间就懵逼了, 这些文件该怎么选?
下面就来说下, 这 8 个作用都用在什么场景, 有什么区别

按照构建方式分, 可以分成 完整构建(包含独立构建和运行时构建) 和 运行时构建
按照规范分, 可以分成 UMD, CommonJS 和 ES Module

简单来说, 完整构建 和 运行时构建的区别就是, 可不可以用template选项, 和文件大一点,小一点。

vue.common.js

属于: 基于 CommonJS 的完整构建
可以用于 Webpack-1 和 Browserify 之类打包工具
因为是完整构建, 所以可以使用template选项, 如:

import Vue from 'vue'
new Vue({
  template: `
    <div id="app">
      <h1>Basic</h1>
    </div>
  `
}).$mount('#app')

注意: 用 webpack-1 之类打包工具时, 使用该版本, 需要配置别名, 以 webpack 为例:

{
  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.common.js'
    }
  }
}

vue.esm.js

属于: 基于 ES Module 的完整构建
可以用于 Webpack-2 和 rollup 之类打包工具,因为是完整构建, 所以可以使用template选项, 如:

import Vue from 'vue'
new Vue({
  template: `
    <div id="app">
      <h1>Basic</h1>
    </div>
  `
}).$mount('#app')

注意: 用 webpack-2 之类打包工具时, 使用该版本, 需要配置别名, 以 webpack 为例:

{
  resolve: {
    alias: {
      'vue$': 'vue.esm.js'
    }
  }
}

vue.js

属于: 基于 UMD 的完整构建
可以用于直接 CDN 引用
因为是完整构建, 所以可以使用template选项, 如:

<script src="https://unkpg.com/vue/dist/vue.js"></script>
<script>
new Vue({
  template: `
    <div id="app">
      <h1>Hi Vue</h1>
    </div>
  `
}).$mount('#app')
</script>

vue.min.js

和 vue.js 一样, 属于压缩后版本

vue.runtime.common.js

属于: 基于 CommonJS 的运行时构建
可以用于 Webpack-1 和 Browserify 之类打包工具
运行时构建不包含模板编译器,因此不支持template选项,只能用render选项,但即使使用运行时构建,在单文件组件中也依然可以写模板,因为单文件组件的模板会在构建时预编译为render函数, render函数的使用, 请参考: http://cn.vuejs.org/v2/guide/render-function.html

import Vue from 'vue'
new Vue({
  render: function(h){
    return h('h1', 'Hi Vue')
  }
}).$mount('#app')

vue.runtime.esm.js

属于: 基于 ES Module 的运行时构建
可以用于 Webpack-2 和 rollup 之类打包工具
运行时构建不包含模板编译器,因此不支持template选项,只能用render选项,但即使使用运行时构建,在单文件组件中也依然可以写模板,因为单文件组件的模板会在构建时预编译为render函数, render函数的使用, 请参考: http://cn.vuejs.org/v2/guide/render-function.html

import Vue from 'vue'
new Vue({
  render: function(h){
    return h('h1', 'Hi Vue')
  }
}).$mount('#app')

vue.runtime.js

属于: 基于 UMD 的运行时构建
可以用于直接 CDN 引用
该版本和vue.js类似, 可以用于直接 CDN 引用, 因为不包含编译器, 所以不能使用template选项, 只能使用render函数

<script src="https://unkpg.com/vue/dist/vue.runtime.js"></script>
<script>
new Vue({
  render: function(h){
    return h('h1', 'Hi Vue')
  }
}).$mount('#app')
</script>

vue.runtime.min.js

和 vue.runtime.js 一样, 属于压缩后版本

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.