游龙翔隼

游龙翔隼 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织 lonhon.top 编辑
编辑

个人动态

游龙翔隼 关注了用户 · 2020-07-07

qpwoeiru96 @qpwoeiru96

吴子棋

关注 29

游龙翔隼 赞了文章 · 2020-07-07

合理的技术栈永远比语言来的重要

知道我的人都知道我是做在线教育,准确的应该说是高中生在线一对一辅导平台。

这个平台最核心的服务应该就是上课服务了,这个上课服务里面包含着什么呢?我来列一下:

  1. 白板互动系统(屏幕共享系统)

  2. 语音即使通讯系统

  3. 文字即时通讯系统

  4. 课件中心

  5. 题库中心

其他服务先不说,首先说说这个课件中心,其实也就是将 PPT、PPTX、PDF 之类的文件转换成图片使其可以在白板互动系统里面使用,看似非常简单的模块,我们却碰到了非常多的问题。我总结了我们才到的几个坑,一一列在下面。

网上搜索到的并非是最完美的方案,仅仅能用而已.

最初我们的解决方式是使用网上 LibreOffice 转换 Office 文档到 PDF ,然后再使用 ImageMagick(或者GhostScript)转换 PDF 到指定图片格式的方案。 这个方案非常的简单,我甚至没有上队列,仅仅使用 CRON 定时脚本就搞定了了。

然后我们很快就放弃了这个方案,因为 LibreOffcie 对于 Microsoft Office的支持实在是太有限,很多 PPT 转换出来简直是面目全非。对于内嵌对象的支持跟上非常糟糕,很多内嵌对象往往无法显示,比如数学公式。当然还有转换时间过长的问题,因为我们屌丝的服务器,基本上转换一个 50 页的文档需要5分钟,这个体验太糟糕了!

某些时候云服务也不是非常靠谱.

鉴于我们使用的七牛存储,而七牛上刚好有 亿方云文档转换服务,所以我们很快就将我们的
PPT 转换到 PDF 这个本地步骤变成了使用亿方云的服务。这种方式我们使用了非常长的一段时间。但是无法转换成功的问题还是断续存在的,虽然转换时间大大降低了。总之我们在第一个方案上碰到的问题基本上第二个方案都有除了频率小了,转换速度快了。

clipboard.png

而事实上七牛也含蓄的说明了这个服务是不靠谱的。

有些时候99%的成功概率都难以容忍

随着我们规模的扩大,课件转换问题就像一个幽灵一样环绕在我的周围,因为即使只有 1% 的转换失败概率,那么我们一周也至少会碰到 5 例失败,这个非常影响我们的工作效率。而实际上的转换失败概率远比这个大。所以我明白了很多时候为什么很多公司要把可靠性要定在5个9甚至6个9那么高了。

这个影响非常的大,以至于我们不得不出了一个奇葩的规则补救:如果你上传 PPT 转换失败,请用 WPS 或者 Windows Office 转换成 PDF 再上传。但这个措施一出无异于我们放弃了 PPT 转换的方案,这样还不如直接跟用户说:请用 PDF 上传吧,PPT 我们不支持来的直接。

商业方案也不一定靠谱

到了今年,课件转换这个模块已经可以说是如鲠在喉,不吐不快了,无论是用户还是内部都抱怨多多。

这个时候我们试用了 Aspose.TotalSpire.Office 这两套方案,但是很快就被我们排除了,因为通过不了我们的“单元测试”(一大批来自老师的疑难课件)。

而经我们事后了解亿方云使用的就是 Aspose.Total 的解决方案。

PS: 特别夸一下 Spire.Office 是国人开发的,值得推荐。

Linux并非政治正确

这个时候我不得不把目光瞄向了 Microsoft Office,我想如果 Microsoft Office 都无法转换成功,那么这个世界上也没有什么软件可以转换这个课件了。

但如果使用 Microsoft Office 那么必须得安装 Windows Server 系列的系统,这无疑是对大多数Linux系运维架构的一个挑战(我们就碰到 Windows 自动更新自己重启了!自己重启了!!!)。

我用 COM Interop + C# + Windows Office 2013 迅速撸了一个转换命令行工具。然后迅速上架这个功能,发现效果提升非常明显。

首先转换内容出错肯定不存在问题了。第二个我们发现转换速度非常快,50页的PPT只需要上传、打开PowerPoint、输出图片、上传图片、更新数据库只需要不到10秒。而这个在之前是需要至少100秒的。

这使得我们在我们的产品上不得不改口说:

clipboard.png

而这个30秒还是我们夸大了讲的。

这套方案成功的转换过 500 多页的 PPT, 而这个 500 多页的 PPT 在以前的转换方案中是 100% 失败的,基本上不是在 LibreOffice 挂了,就是在 ImageMagick 挂了。

合理的技术栈永远比语言来的重要

阿里巴巴用 Java 我们也用 Java 吧?
PHP 这东西没什么难度的?
是不是 C++ 开发的会稳定点?
微软的东西,还是算了吧用开源的吧!

以上这些不是不懂技术的人会讲吗,懂技术的人也会讲。我曾经在微博上说过我们一家对手公司因为使用 C++ 开发客户端导致大半年时间浪费在处理 Windows 系统的兼容性上。我也见过多家公司因为听信 Java 好,而把技术方向从 PHP 转向 Java 导致公司一蹶不振的。对于大多数基础技术公司来讲,这根本不是哪种语言好的问题,而是你能不能 Hold 住的问题。

我一直对外讲我们公司用 PHP + JavaScript,但实际上我们用的还有 Go、TypeScript、C#、C++、Java,每一块都实现的非常好,各居其位、各谋其政。但我们的核心目前还是 PHP + JavaScript。

微服务是未来,虚拟化是未来、接口是未来

学过 Golang 的朋友都知道 Golang 的接口机制非常的奇怪,**因为它的接口机制是你有没有继承不是看你有没有说我继承自某某,而是看你会不会某某的功能。

通俗讲就是:如果我们定义 Bird 类只有一个方法叫 Fly(),如果你能 Fly,那么你也叫 Bird 。

所以你用 PHP 实现或者用 Java 实现一个服务化的功能并不重要,重要的是你实现的完不完美。你能不能 Hold
住。

举个例子:我在13年的时候用PHP实现过一个任务队列系统(PHP Coolie),今年我用 Go 重新实现 Windows 下任务队列的 Worker 。对于我来讲不是 Go 好,而是 Windows 下没有 PHP 没有 pcntl 。

Docker 大行其道的当天,对于大多数服务来讲都只需要 docker run 一下,谁还会在乎下面一层是用什么语言?

查看原文

赞 4 收藏 3 评论 0

游龙翔隼 赞了文章 · 2020-04-23

Mpx 小程序框架技术揭秘

Github: https://github.com/didi/mpx
本文作者: 肖磊(https://github.com/CommanderXL

与目前业内的几个小程序框架相比较而言,mpx 开发设计的出发点就是基于原生的小程序去做功能增强。所以从开发框架的角度来说,是没有任何“包袱”,围绕着原生小程序这个 core 去做不同功能的 patch 工作,使得开发小程序的体验更好。

于是我挑了一些我非常感兴趣的点去学习了下 mpx 在相关功能上的设计与实现。

编译环节

动态入口编译

不同于 web 规范,我们都知道小程序每个 page/component 需要被最终在 webview 上渲染出来的内容是需要包含这几个独立的文件的:js/json/wxml/wxss。为了提升小程序的开发体验,mpx 参考 vue 的 SFC(single file component)的设计思路,采用单文件的代码组织方式进行开发。既然采用这种方式去组织代码的话,那么模板、逻辑代码、json配置文件、style样式等都放到了同一个文件当中。那么 mpx 需要做的一个工作就是如何将 SFC 在代码编译后拆分为 js/json/wxml/wxss 以满足小程序技术规范。熟悉 vue 生态的同学都知道,vue-loader 里面就做了这样一个编译转化工作。具体有关 vue-loader 的工作流程可以参见我写的文章

这里会遇到这样一个问题,就是在 vue 当中,如果你要引入一个页面/组件的话,直接通过import语法去引入对应的 vue 文件即可。但是在小程序的标准规范里面,它有自己一套组件系统,即如果你在某个页面/组件里面想要使用另外一个组件,那么需要在你的 json 配置文件当中去声明usingComponents这个字段,对应的值为这个组件的路径。

在 vue 里面 import 一个 vue 文件,那么这个文件会被当做一个 dependency 去加入到 webpack 的编译流程当中。但是 mpx 是保持小程序原有的功能,去进行功能的增强。因此一个 mpx 文件当中如果需要引入其他页面/组件,那么就是遵照小程序的组件规范需要在usingComponents定义好组件名:路径即可,mpx 提供的 webpack 插件来完成确定依赖关系,同时将被引入的页面/组件加入到编译构建的环节当中

接下来就来看下具体的实现,mpx webpack-plugin 暴露出来的插件上也提供了静态方法去使用 loader。这个 loader 的作用和 vue-loader 的作用类似,首先就是拿到 mpx 原始的文件后转化一个 js 文本的文件。例如一个 list.mpx 文件里面有关 json 的配置会被编译为:

require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=json&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/json-compiler/index?root=!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=json&index=0!./list.mpx")

这样可以清楚的看到 list.mpx 这个文件首先 selector(抽离list.mpx当中有关 json 的配置,并传入到 json-compiler 当中) --->>> json-compiler(对 json 配置进行处理,添加动态入口等) --->>> extractor(利用 child compiler 单独生成 json 配置文件)

其中动态添加入口的处理流程是在 json-compiler 当中去完成的。例如在你的 page/home.mpx 文件当中的 json 配置中使用了 局部组件 components/list.mpx:

<script type="application/json">
  {
    "usingComponents": {
      "list": "../components/list"
    }
  }
</script>

在 json-compiler 当中:

...

const addEntrySafely = (resource, name, callback) => {
  // 如果loader已经回调,就不再添加entry
  if (callbacked) return callback()
  // 使用 webpack 提供的 SingleEntryPlugin 插件创建一个单文件的入口依赖(即这个 component)
  const dep = SingleEntryPlugin.createDependency(resource, name)
  entryDeps.add(dep)
  // compilation.addEntry 方法开始将这个需要被编译的 component 作为依赖添加到 webpack 的构建流程当中
  // 这里可以看到的是整个动态添加入口文件的过程是深度优先的
  this._compilation.addEntry(this._compiler.context, dep, name, (err, module) => {
    entryDeps.delete(dep)
    checkEntryDeps()
    callback(err, module)
  })
}

const processComponent = (component, context, rewritePath, componentPath, callback) => {
  ...
  // 调用 loaderContext 上提供的 resolve 方法去解析这个 component path 完整的路径,以及这个 component 所属的 package 相关的信息(例如 package.json 等)
  this.resolve(context, component, (err, rawResult, info) => {
    ...
    componentPath = componentPath || path.join(subPackageRoot, 'components', componentName + hash(result), componentName)
    ...
    // component path 解析完之后,调用 addEntrySafely 开始在 webpack 构建流程中动态添加入口
    addEntrySafely(rawResult, componentPath, callback)
  })
}

if (isApp) {
  ...
} else {
  if (json.usingComponents) {
    // async.forEachOf 流程控制依次调用 processComponent 方法
    async.forEachOf(json.usingComponents, (component, name, callback) => {
      processComponent(component, this.context, (path) => {
        json.usingComponents[name] = path
      }, undefined, callback)
    }, callback)
  }
  ...
}
...

这里需要解释说明下有关 webpack 提供的 SingleEntryPlugin 插件。这个插件是 webpack 提供的一个内置插件,当这个插件被挂载到 webpack 的编译流程的过程中是,会绑定compiler.hooks.make.tapAsynchook,当这个 hook 触发后会调用这个插件上的 SingleEntryPlugin.createDependency 静态方法去创建一个入口依赖,然后调用compilation.addEntry将这个依赖加入到编译的流程当中,这个是单入口文件的编译流程的最开始的一个步骤(具体可以参见 Webpack SingleEntryPlugin 源码)。

Mpx 正是利用了 webpack 提供的这样一种能力,在遵照小程序的自定义组件的规范的前提下,解析 mpx json 配置文件的过程中,手动的调用 SingleEntryPlugin 相关的方法去完成动态入口的添加工作。这样也就串联起了所有的 mpx 文件的编译工作。

Render Function

Render Function 这块的内容我觉得是 Mpx 设计上的一大亮点内容。Mpx 引入 Render Function 主要解决的问题是性能优化方向相关的,因为小程序的架构设计,逻辑层和渲染层是2个独立的。

这里直接引用 Mpx 有关 Render Function 对于性能优化相关开发工作的描述:

作为一个接管了小程序setData的数据响应开发框架,我们高度重视Mpx的渲染性能,通过小程序官方文档中提到的性能优化建议可以得知,setData对于小程序性能来说是重中之重,setData优化的方向主要有两个:

  • 尽可能减少setData调用的频次
  • 尽可能减少单次setData传输的数据

为了实现以上两个优化方向,我们做了以下几项工作:

将组件的静态模板编译为可执行的render函数,通过render函数收集模板数据依赖,只有当render函数中的依赖数据发生变化时才会触发小程序组件的setData,同时通过一个异步队列确保一个tick中最多只会进行一次setData,这个机制和Vue中的render机制非常类似,大大降低了setData的调用频次;

将模板编译render函数的过程中,我们还记录输出了模板中使用的数据路径,在每次需要setData时会根据这些数据路径与上一次的数据进行diff,仅将发生变化的数据通过数据路径的方式进行setData,这样确保了每次setData传输的数据量最低,同时避免了不必要的setData操作,进一步降低了setData的频次。

接下来我们看下 Mpx 是如何实现 Render Function 的。这里我们从一个简单的 demo 来说起:

<template>
  <text>Computed reversed message: "{{ reversedMessage }}"</text>
  <view>the c string {{ demoObj.a.b.c }}</view>
  <view wx:class="{{ { active: isActive } }}"></view>
</template>

<script>
import { createComponent } from "@mpxjs/core";

createComponent({
  data: {
    isActive: true,
    message: 'messages',
    demoObj: {
      a: {
        b: {
          c: 'c'
        }
      }
    }
  },
  computed() {
    reversedMessage() {
      return this.message.split('').reverse().join('')
    }
  }
})

</script>

.mpx 文件经过 loader 编译转换的过程中。对于 template 模块的处理和 vue 类似,首先将 template 转化为 AST,然后再将 AST 转化为 code 的过程中做相关转化的工作,最终得到我们需要的 template 模板代码。

packages/webpack-plugin/lib/template-compiler.js模板处理 loader 当中:

let renderResult = bindThis(`global.currentInject = {
    moduleId: ${JSON.stringify(options.moduleId)},
    render: function () {
      var __seen = [];
      var renderData = {};
      ${compiler.genNode(ast)}return renderData;
    }
};\n`, {
    needCollect: true,
    ignoreMap: meta.wxsModuleMap
  })

在 render 方法内部,创建 renderData 局部变量,调用compiler.genNode(ast)方法完成 Render Function 核心代码的生成工作,最终将这个 renderData 返回。例如在上面给出来的 demo 实例当中,通过compiler.genNode(ast)方法最终生成的代码为:

((mpxShow)||(mpxShow)===undefined?'':'display:none;');
if(( isActive )){
}
"Computed reversed message: \""+( reversedMessage )+"\"";
"the c string "+( demoObj.a.b.c );
(__injectHelper.transformClass("list", ( {active: isActive} )));

mpx 文件当中的 template 模块被初步处理成上面的代码后,可以看到这是一段可执行的 js 代码。那么这段 js 代码到底是用作何处呢?可以看到compiler.genNode方法是被包裹至bindThis方法当中的。即这段 js 代码还会被bindThis方法做进一步的处理。打开 bind-this.js 文件可以看到内部的实现其实就是一个 babel 的 transform plugin。在处理上面这段 js 代码的 AST 的过程中,通过这个插件对 js 代码做进一步的处理。最终这段 js 代码处理后的结果是:

/* mpx inject */ global.currentInject = {
  moduleId: "2271575d",
  render: function () {
    var __seen = [];
    var renderData = {};
    (renderData["mpxShow"] = [this.mpxShow, "mpxShow"], this.mpxShow) || (renderData["mpxShow"] = [this.mpxShow, "mpxShow"], this.mpxShow) === undefined ? '' : 'display:none;';
    "Computed reversed message: \"" + (renderData["reversedMessage"] = [this.reversedMessage, "reversedMessage"], this.reversedMessage) + "\"";
    "the c string " + (renderData["demoObj.a.b.c"] = [this.demoObj.a.b.c, "demoObj"], this.__get(this.__get(this.__get(this.demoObj, "a"), "b"), "c"));
    this.__get(__injectHelper, "transformClass")("list", { active: (renderData["isActive"] = [this.isActive, "isActive"], this.isActive) });
    return renderData;
  }
};

bindThis 方法对于 js 代码的转化规则就是:

  1. 一个变量的访问形式,改造成 this.xxx 的形式;
  2. 对象属性的访问形式,改造成 this.__get(object, property) 的形式(this.__get方法为运行时 mpx runtime 提供的方法)

这里的 this 为 mpx 构造的一个代理对象,在你业务代码当中调用 createComponent/createPage 方法传入的配置项,例如 data,都会通过这个代理对象转化为响应式的数据。

需要注意的是不管哪种数据形式的改造,最终需要达到的效果就是确保在 Render Function 执行的过程当中,这些被模板使用到的数据能被正常的访问到,在访问的阶段中,这些被访问到的数据即被加入到 mpx 构建的整个响应式的系统当中。

只要在 template 当中使用到的 data 数据(包括衍生的 computed 数据),最终都会被 renderData 所记录,而记录的数据形式是例如:

renderData['xxx'] = [this.xxx, 'xxx'] // 数组的形式,第一项为这个数据实际的值,第二项为这个数据的 firstKey(主要用以数据 diff 的工作)

以上就是 mpx 生成 Render Function 的整个过程。总结下 Render Function 所做的工作:

  1. 执行 render 函数,将渲染模板使用到的数据加入到响应式的系统当中;
  2. 返回 renderData 用以接下来的数据 diff 以及调用小程序的 setData 方法来完成视图的更新

Wxs Module

Wxs 是小程序自己推出的一套脚本语言。官方文档给出的示例,wxs 模块必须要声明式的被 wxml 引用。和 js 在 jsCore 当中去运行不同的是 wxs 是在渲染线程当中去运行的。因此 wxs 的执行便少了一次从 jsCore 执行的线程和渲染线程的通讯,从这个角度来说是对代码执行效率和性能上的比较大的一个优化手段。

有关官方提到的有关 wxs 的运行效率的问题还有待论证:

“在 android 设备中,小程序里的 wxs 与 js 运行效率无差异,而在 ios 设备中,小程序里的 wxs 会比 js 快 2~20倍。”

因为 mpx 是对小程序做渐进增强,因此 wxs 的使用方式和原生的小程序保持一致。在你的.mpx文件当中的 template block 内通过路径直接去引入 wxs 模块即可使用:

<template>
  <wxs data-original="../wxs/components/list.wxs" module="list">
  <view>{{ list.FOO }}</view>
</template>


// wxs/components/list.wxs

const Foo = 'This is from list wxs module'
module.exports = {
  Foo
}

在 template 模块经过 template-compiler 处理的过程中。模板编译器 compiler 在解析模板的 AST 过程中会针对 wxs 标签缓存一份 wxs 模块的映射表:

{
  meta: {
    wxsModuleMap: {
      list: '../wxs/components/list.wxs'
    }
  }
}

当 compiler 对 template 模板解析完后,template-compiler 接下来就开始处理 wxs 模块相关的内容:

// template-compiler/index.js

module.exports = function (raw) {
  ...

  const addDependency = dep => {
    const resourceIdent = dep.getResourceIdentifier()
    if (resourceIdent) {
      const factory = compilation.dependencyFactories.get(dep.constructor)
      if (factory === undefined) {
        throw new Error(`No module factory available for dependency type: ${dep.constructor.name}`)
      }
      let innerMap = dependencies.get(factory)
      if (innerMap === undefined) {
        dependencies.set(factory, (innerMap = new Map()))
      }
      let list = innerMap.get(resourceIdent)
      if (list === undefined) innerMap.set(resourceIdent, (list = []))
      list.push(dep)
    }
  }

  // 如果有 wxsModuleMap 即为 wxs module 依赖的话,那么下面会调用 compilation.addModuleDependencies 方法
  // 将 wxsModule 作为 issuer 的依赖再次进行编译,最终也会被打包进输出的模块代码当中
  // 需要注意的就是 wxs module 不仅要被注入到 bundle 里的 render 函数当中,同时也会通过 wxs-loader 处理,单独输出一份可运行的 wxs js 文件供 wxml 引入使用
  for (let module in meta.wxsModuleMap) {
    isSync = false
    let src = meta.wxsModuleMap[module]
    const expression = `require(${JSON.stringify(src)})`
    const deps = []
    // parser 为 js 的编译器
    parser.parse(expression, {
      current: { // 需要注意的是这里需要部署 addDependency 接口,因为通过 parse.parse 对代码进行编译的时候,会调用这个接口来获取 require(${JSON.stringify(src)}) 编译产生的依赖模块
        addDependency: dep => {
          dep.userRequest = module
          deps.push(dep)
        }
      },
      module: issuer
    })
    issuer.addVariable(module, expression, deps) // 给 issuer module 添加 variable 依赖
    iterationOfArrayCallback(deps, addDependency)
  }

  // 如果没有 wxs module 的处理,那么 template-compiler 即为同步任务,否则为异步任务
  if (isSync) {
    return result
  } else {
    const callback = this.async()

    const sortedDependencies = []
    for (const pair1 of dependencies) {
      for (const pair2 of pair1[1]) {
        sortedDependencies.push({
          factory: pair1[0],
          dependencies: pair2[1]
        })
      }
    }

    // 调用 compilation.addModuleDependencies 方法,将 wxs module 作为 issuer module 的依赖加入到编译流程中
    compilation.addModuleDependencies(
      issuer,
      sortedDependencies,
      compilation.bail,
      null,
      true,
      () => {
        callback(null, result)
      }
    )
  }
}

template/script/style/json 模块单文件的生成

不同于 Vue 借助 webpack 是将 Vue 单文件最终打包成单独的 js chunk 文件。而小程序的规范是每个页面/组件需要对应的 wxml/js/wxss/json 4个文件。因为 mpx 使用单文件的方式去组织代码,所以在编译环节所需要做的工作之一就是将 mpx 单文件当中不同 block 的内容拆解到对应文件类型当中。在动态入口编译的小节里面我们了解到 mpx 会分析每个 mpx 文件的引用依赖,从而去给这个文件创建一个 entry 依赖(SingleEntryPlugin)并加入到 webpack 的编译流程当中。我们还是继续看下 mpx loader 对于 mpx 单文件初步编译转化后的内容:

/* script */
export * from "!!babel-loader!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=script&index=0!./list.mpx"

/* styles */
require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=styles&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/wxss/loader?root=&importLoaders=1&extract=true!../../node_modules/@mpxjs/webpack-plugin/lib/style-compiler/index?{\"id\":\"2271575d\",\"scoped\":false,\"sourceMap\":false,\"transRpx\":{\"mode\":\"only\",\"comment\":\"use rpx\",\"include\":\"/Users/XRene/demo/mpx-demo-source/src\"}}!stylus-loader!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=styles&index=0!./list.mpx")

/* json */
require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=json&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/json-compiler/index?root=!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=json&index=0!./list.mpx")

/* template */
require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=template&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/wxml/wxml-loader?root=!../../node_modules/@mpxjs/webpack-plugin/lib/template-compiler/index?{\"usingComponents\":[],\"hasScoped\":false,\"isNative\":false,\"moduleId\":\"2271575d\"}!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=template&index=0!./list.mpx")

接下来可以看下 styles/json/template 这3个 block 的处理流程是什么样。

首先来看下 json block 的处理流程:list.mpx -> json-compiler -> extractor。第一个阶段 list.mpx 文件经由 json-compiler 的处理流程在前面的章节已经讲过,主要就是分析依赖增加动态入口的编译过程。当所有的依赖分析完后,调用 json-compiler loader 的异步回调函数:

// lib/json-compiler/index.js

module.exports = function (content) {

  ...
  const nativeCallback = this.async()
  ...

  let callbacked = false
  const callback = (err, processOutput) => {
    checkEntryDeps(() => {
      callbacked = true
      if (err) return nativeCallback(err)
      let output = `var json = ${JSON.stringify(json, null, 2)};\n`
      if (processOutput) output = processOutput(output)
      output += `module.exports = JSON.stringify(json, null, 2);\n`
      nativeCallback(null, output)
    })
  }
}

这里我们可以看到经由 json-compiler 处理后,通过nativeCallback方法传入下一个 loader 的文本内容形如:

var json = {
  "usingComponents": {
    "list": "/components/list397512ea/list"
  }
}

module.exports = JSON.stringify(json, null, 2)

即这段文本内容会传递到下一个 loader 内部进行处理,即 extractor。接下来我们来看下 extractor 里面主要是实现了哪些功能:

// lib/extractor.js

module.exports = function (content) {
  ...
  const contentLoader = normalize.lib('content-loader')
  let request = `!!${contentLoader}?${JSON.stringify(options)}!${this.resource}` // 构建一个新的 resource,且这个 resource 只需要经过 content-loader
  let resultSource = defaultResultSource
  const childFilename = 'extractor-filename'
  const outputOptions = {
    filename: childFilename
  }
  // 创建一个 child compiler
  const childCompiler = mainCompilation.createChildCompiler(request, outputOptions, [
    new NodeTemplatePlugin(outputOptions),
    new LibraryTemplatePlugin(null, 'commonjs2'), // 最终输出的 chunk 内容遵循 commonjs 规范的可执行的模块代码 module.exports = (function(modules) {})([modules])
    new NodeTargetPlugin(),
    new SingleEntryPlugin(this.context, request, resourcePath),
    new LimitChunkCountPlugin({ maxChunks: 1 })
  ])

  ...
  childCompiler.hooks.thisCompilation.tap('MpxWebpackPlugin ', (compilation) => {
    // 创建 loaderContext 时触发的 hook,在这个 hook 触发的时候,将原本从 json-compiler 传递过来的 content 内容挂载至 loaderContext.__mpx__ 属性上面以供接下来的 content -loader 来进行使用
    compilation.hooks.normalModuleLoader.tap('MpxWebpackPlugin', (loaderContext, module) => {
      // 传递编译结果,子编译器进入content-loader后直接输出
      loaderContext.__mpx__ = {
        content,
        fileDependencies: this.getDependencies(),
        contextDependencies: this.getContextDependencies()
      }
    })
  })

  let source

  childCompiler.hooks.afterCompile.tapAsync('MpxWebpackPlugin', (compilation, callback) => {
    // 这里 afterCompile 产出的 assets 的代码当中是包含 webpack runtime bootstrap 的代码,不过需要注意的是这个 source 模块的产出形式
    // 因为使用了 new LibraryTemplatePlugin(null, 'commonjs2') 等插件。所以产出的 source 是可以在 node 环境下执行的 module
    // 因为在 loaderContext 上部署了 exec 方法,即可以直接执行 commonjs 规范的 module 代码,这样就最终完成了 mpx 单文件当中不同模块的抽离工作
    source = compilation.assets[childFilename] && compilation.assets[childFilename].source()

    // Remove all chunk assets
    compilation.chunks.forEach((chunk) => {
      chunk.files.forEach((file) => {
        delete compilation.assets[file]
      })
    })

    callback()
  })

  childCompiler.runAsChild((err, entries, compilation) => {
    ...
    try {
      // exec 是 loaderContext 上提供的一个方法,在其内部会构建原生的 node.js module,并执行这个 module 的代码
      // 执行这个 module 代码后获取的内容就是通过 module.exports 导出的内容
      let text = this.exec(source, request)
      if (Array.isArray(text)) {
        text = text.map((item) => {
          return item[1]
        }).join('\n')
      }

      let extracted = extract(text, options.type, resourcePath, +options.index, selfResourcePath)
      if (extracted) {
        resultSource = `module.exports = __webpack_public_path__ + ${JSON.stringify(extracted)};`
      }
    } catch (err) {
      return nativeCallback(err)
    }
    if (resultSource) {
      nativeCallback(null, resultSource)
    } else {
      nativeCallback()
    }
  })
}

稍微总结下上面的处理流程:

  1. 构建一个以当前模块路径及 content-loader 的 resource 路径
  2. 以这个 resource 路径作为入口模块,创建一个 childCompiler
  3. childCompiler 启动后,创建 loaderContext 的过程中,将 content 文本内容挂载至 loaderContext.__mpx__ 上,这样在 content-loader 在处理入口模块的时候仅仅就是取出这个 content 文本内容并返回。实际上这个入口模块经过 loader 的过程不会做任何的处理工作,仅仅是将父 compilation 传入的 content 返回出去。
  4. loader 处理模块的环节结束后,进入到 module.build 阶段,这个阶段对 content 内容没有太多的处理
  5. createAssets 阶段,输出 chunk。
  6. 将输出的 chunk 构建为一个原生的 node.js 模块并执行,获取从这个 chunk 导出的内容。也就是模块通过module.exports导出的内容。

所以上面的示例 demo 最终会输出一个 json 文件,里面包含的内容即为:

{
  "usingComponents": {
    "list": "/components/list397512ea/list"
  }
}

运行时环节

以上几个章节主要是分析了几个 Mpx 在编译构建环节所做的工作。接下来我们来看下 Mpx 在运行时环节做了哪些工作。

响应式系统

小程序也是通过数据去驱动视图的渲染,需要手动的调用setData去完成这样一个动作。同时小程序的视图层也提供了用户交互的响应事件系统,在 js 代码中可以去注册相关的事件回调并在回调中去更改相关数据的值。Mpx 使用 Mobx 作为响应式数据工具并引入到小程序当中,使得小程序也有一套完成的响应式的系统,让小程序的开发有了更好的体验。

还是从组件的角度开始分析 mpx 的整个响应式的系统。每次通过createComponent方法去创建一个新的组件,这个方法将原生的小程序创造组件的方法Component做了一层代理,例如在 attched 的生命周期钩子函数内部会注入一个 mixin:

// attached 生命周期钩子 mixin

attached() {
  // 提供代理对象需要的api
  transformApiForProxy(this, currentInject)
  // 缓存options
  this.$rawOptions = rawOptions // 原始的,没有剔除 customKeys 的 options 配置
  // 创建proxy对象
  const mpxProxy = new MPXProxy(rawOptions, this) // 将当前实例代理到 MPXProxy 这个代理对象上面去
  this.$mpxProxy = mpxProxy // 在小程序实例上绑定 $mpxProxy 的实例
  // 组件监听视图数据更新, attached之后才能拿到properties
  this.$mpxProxy.created()
}

在这个方法内部首先调用transformApiForProxy方法对组件实例上下文this做一层代理工作,在 context 上下文上去重置小程序的 setData 方法,同时拓展 context 相关的属性内容:

function transformApiForProxy (context, currentInject) {
  const rawSetData = context.setData.bind(context) // setData 绑定对应的 context 上下文
  Object.defineProperties(context, {
    setData: { // 重置 context 的 setData 方法
      get () {
        return this.$mpxProxy.setData.bind(this.$mpxProxy)
      },
      configurable: true
    },
    __getInitialData: {
      get () {
        return () => context.data
      },
      configurable: false
    },
    __render: { // 小程序原生的 setData 方法
      get () {
        return rawSetData
      },
      configurable: false
    }
  })
  // context 绑定注入的render函数
  if (currentInject) {
    if (currentInject.render) { // 编译过程中生成的 render 函数
      Object.defineProperties(context, {
        __injectedRender: {
          get () {
            return currentInject.render.bind(context)
          },
          configurable: false
        }
      })
    }
    if (currentInject.getRefsData) {
      Object.defineProperties(context, {
        __getRefsData: {
          get () {
            return currentInject.getRefsData
          },
          configurable: false
        }
      })
    }
  }
}

接下来实例化一个 mpxProxy 实例并挂载至 context 上下文的 $mpxProxy 属性上,并调用 mpxProxy 的 created 方法完成这个代理对象的初始化的工作。在 created 方法内部主要是完成了以下的几个工作:

  1. initApi,在组件实例 this 上挂载$watch,$forceUpdate,$updated,$nextTick等方法,这样在你的业务代码当中即可直接访问实例上部署好的这些方法;
  2. initData
  3. initComputed,将 computed 计算属性字段全部代理至组件实例 this 上;
  4. 通过 Mobx observable 方法将 data 数据转化为响应式的数据;
  5. initWatch,初始化所有的 watcher 实例;
  6. initRender,初始化一个 renderWatcher 实例;

这里我们具体的来看下 initRender 方法内部是如何进行工作的:

export default class MPXProxy {
  ...
  initRender() {
    let renderWatcher
    let renderExcutedFailed = false
    if (this.target.__injectedRender) { // webpack 注入的有关这个 page/component 的 renderFunction
      renderWatcher = watch(this.target, () => {
        if (renderExcutedFailed) {
          this.render()
        } else {
          try {
            return this.target.__injectedRender() // 执行 renderFunction,获取渲染所需的响应式数据
          } catch(e) {
            ...
          }
        }
      }, {
        handler: (ret) => {
          if (!renderExcutedFailed) {
            this.renderWithData(ret) // 渲染页面
          }
        },
        immediate: true,
        forceCallback: true
      })
    }
  }
  ...
}

在 initRender 方法内部非常清楚的看到,首先判断这个 page/component 是否具有 renderFunction,如果有的话那么就直接实例化一个 renderWatcher:

export default class Watcher {
  constructor (context, expr, callback, options) {
    this.destroyed = false
    this.get = () => {
      return type(expr) === 'String' ? getByPath(context, expr) : expr()
    }
    const callbackType = type(callback)
    if (callbackType === 'Object') {
      options = callback
      callback = null
    } else if (callbackType === 'String') {
      callback = context[callback]
    }
    this.callback = typeof callback === 'function' ? action(callback.bind(context)) : null
    this.options = options || {}
    this.id = ++uid
    // 创建一个新的 reaction
    this.reaction = new Reaction(`mpx-watcher-${this.id}`, () => {
      this.update()
    })
    // 在调用 getValue 函数的时候,实际上是调用 reaction.track 方法,这个方法内部会自动执行 effect 函数,即执行 this.update() 方法,这样便会出发一次模板当中的 render 函数来完成依赖的收集
    const value = this.getValue()
    if (this.options.immediateAsync) { // 放置到一个队列里面去执行
      queueWatcher(this)
    } else { // 立即执行 callback
      this.value = value
      if (this.options.immediate) {
        this.callback && this.callback(this.value)
      }
    }
  }

  getValue () {
    let value
    this.reaction.track(() => {
      value = this.get() // 获取注入的 render 函数执行后返回的 renderData 的值,在执行 render 函数的过程中,就会访问响应式数据的值
      if (this.options.deep) {
        const valueType = type(value)
        // 某些情况下,最外层是非isObservable 对象,比如同时观察多个属性时
        if (!isObservable(value) && (valueType === 'Array' || valueType === 'Object')) {
          if (valueType === 'Array') {
            value = value.map(item => toJS(item, false))
          } else {
            const newValue = {}
            Object.keys(value).forEach(key => {
              newValue[key] = toJS(value[key], false)
            })
            value = newValue
          }
        } else {
          value = toJS(value, false)
        }
      } else if (isObservableArray(value)) {
        value.peek()
      } else if (isObservableObject(value)) {
        keys(value)
      }
    })
    return value
  }

  update () {
    if (this.options.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

  run () {
    const immediateAsync = !this.hasOwnProperty('value')
    const oldValue = this.value
    this.value = this.getValue() // 重新获取新的 renderData 的值
    if (immediateAsync || this.value !== oldValue || isObject(this.value) || this.options.forceCallback) {
      if (this.callback) {
        immediateAsync ? this.callback(this.value) : this.callback(this.value, oldValue)
      }
    }
  }

  destroy () {
    this.destroyed = true
    this.reaction.getDisposer()()
  }
}

Watcher 观察者核心实现的工作流程就是:

  1. 构建一个 Reaction 实例;
  2. 调用 getValue 方法,即 reaction.track,在这个方法内部执行过程中会调用 renderFunction,这样在 renderFunction 方法的执行过程中便会访问到渲染所需要的响应式的数据并完成依赖收集;
  3. 根据 immediateAsync 配置来决定回调是放到下一帧还是立即执行;
  4. 当响应式数据发生变化的时候,执行 reaction 实例当中的回调函数,即this.update()方法来完成页面的重新渲染。

mpx 在构建这个响应式的系统当中,主要有2个大的环节,其一为在构建编译的过程中,将 template 模块转化为 renderFunction,提供了渲染模板时所需响应式数据的访问机制,并将 renderFunction 注入到运行时代码当中,其二就是在运行环节,mpx 通过构建一个小程序实例的代理对象,将小程序实例上的数据访问全部代理至 MPXProxy 实例上,而 MPXProxy 实例即 mpx 基于 Mobx 去构建的一套响应式数据对象,首先将 data 数据转化为响应式数据,其次提供了 computed 计算属性,watch 方法等一系列增强的拓展属性/方法,虽然在你的业务代码当中 page/component 实例 this 都是小程序提供的,但是最终经过代理机制,实际上访问的是 MPXProxy 所提供的增强功能,所以 mpx 也是通过这样一个代理对象去接管了小程序的实例。需要特别指出的是,mpx 将小程序官方提供的 setData 方法同样收敛至内部,这也是响应式系统提供的基础能力,即开发者只需要关注业务开发,而有关小程序渲染运行在 mpx 内部去帮你完成。

性能优化

由于小程序的双线程的架构设计,逻辑层和视图层之间需要桥接 native bridge。如果要完成视图层的更新,那么逻辑层需要调用 setData 方法,数据经由 native bridge,再到渲染层,这个工程流程为:

小程序逻辑层调用宿主环境的 setData 方法;

逻辑层执行 JSON.stringify 将待传输数据转换成字符串并拼接到特定的JS脚本,并通过evaluateJavascript 执行脚本将数据传输到渲染层;

渲染层接收到后, WebView JS 线程会对脚本进行编译,得到待更新数据后进入渲染队列等待 WebView 线程空闲时进行页面渲染;

WebView 线程开始执行渲染时,待更新数据会合并到视图层保留的原始 data 数据,并将新数据套用在WXML片段中得到新的虚拟节点树。经过新虚拟节点树与当前节点树的 diff 对比,将差异部分更新到UI视图。同时,将新的节点树替换旧节点树,用于下一次重渲染。

文章来源

而 setData 作为逻辑层和视图层之间通讯的核心接口,那么对于这个接口的使用遵照一些准则将有助于性能方面的提升。

尽可能的减少 setData 传输的数据

Mpx 在这个方面所做的工作之一就是基于数据路径的 diff。这也是官方所推荐的 setData 的方式。每次响应式数据发生了变化,调用 setData 方法的时候确保传递的数据都为 diff 过后的最小数据集,这样来减少 setData 传输的数据。

接下来我们就来看下这个优化手段的具体实现思路,首先还是从一个简单的 demo 来看:

<script>
import { createComponent } from '@mpxjs/core'

createComponent({
  data: {
    obj: {
      a: {
        c: 1,
        d: 2
      }
    }
  }
  onShow() {
    setTimeout(() => {
      this.obj.a = {
        c: 1,
        d: 'd'
      }
    }, 200)
  }
})
</script>

在示例 demo 当中,声明了一个 obj 对象(这个对象里面的内容在模块当中被使用到了)。然后经过 200ms 后,手动修改 obj.a 的值,因为对于 c 字段来说它的值没有发生改变,而 d 字段发生了改变。因此在 setData 方法当中也应该只更新 obj.a.d 的值,即:

this.setData('obj.a.d', 'd')

因为 mpx 是整体接管了小程序当中有关调用 setData 方法并驱动视图更新的机制。所以当你在改变某些数据的时候,mpx 会帮你完成数据的 diff 工作,以保证每次调用 setData 方法时,传入的是最小的更新数据集。

这里也简单的分析下 mpx 是如何去实现这样的功能的。在上文的编译构建阶段有分析到 mpx 生成的 Render Function,这个 Render Function 每次执行的时候会返回一个 renderData,而这个 renderData 即用以接下来进行 setData 驱动视图渲染的原始数据。renderData 的数据组织形式是模板当中使用到的数据路径作为 key 键值,对应的值使用一个数组组织,数组第一项为数据的访问路径(可获取到对应渲染数据),第二项为数据路径的第一个键值,例如在 demo 示例当中的 renderData 数据如下:

renderData['obj.a.c'] = [this.obj.a.c, 'obj']
renderData['obj.a.d'] = [this.obj.a.d, 'obj']

当页面第一次渲染,或者是响应式输出发生变化的时候,Render Function 都会被执行一次用以获取最新的 renderData 来进行接下来的页面渲染过程。

// src/core/proxy.js

class MPXProxy {
  ...
  renderWithData(rawRenderData) { // rawRenderData 即为 Render Function 执行后获取的初始化 renderData
    const renderData = preprocessRenderData(rawRenderData) // renderData 数据的预处理
    if (!this.miniRenderData) { // 最小数据渲染集,页面/组件初次渲染的时候使用 miniRenderData 进行渲染,初次渲染的时候是没有数据需要进行 diff 的
      this.miniRenderData = {}
      for (let key in renderData) { // 遍历数据访问路径
        if (renderData.hasOwnProperty(key)) {
          let item = renderData[key] 
          let data = item[0]
          let firstKey = item[1] // 某个字段 path 的第一个 key 值
          if (this.localKeys.indexOf(firstKey) > -1) {
            this.miniRenderData[key] = diffAndCloneA(data).clone
          }
        }
      }
      this.doRender(this.miniRenderData)
    } else { // 非初次渲染使用 processRenderData 进行数据的处理,主要是需要进行数据的 diff 取值工作,并更新 miniRenderData 的值
      this.doRender(this.processRenderData(renderData))
    }
  }

  processRenderData(renderData) {
    let result = {}
    for (let key in renderData) {
      if (renderData.hasOwnProperty(key)) {
        let item = renderData[key]
        let data = item[0]
        let firstKey = item[1]
        let { clone, diff } = diffAndCloneA(data, this.miniRenderData[key]) // 开始数据 diff
        // firstKey 必须是为响应式数据的 key,且这个发生变化的 key 为 forceUpdateKey 或者是在 diff 阶段发现确实出现了 diff 的情况
        if (this.localKeys.indexOf(firstKey) > -1 && (this.checkInForceUpdateKeys(key) || diff)) {
          this.miniRenderData[key] = result[key] = clone
        }
      }
    }
    return result
  }
  ...
}

// src/helper/utils.js

// 如果 renderData 里面即包含对某个 key 的访问,同时还有对这个 key 的子节点访问的话,那么需要剔除这个子节点
/**
 * process renderData, remove sub node if visit parent node already
 * @param {Object} renderData
 * @return {Object} processedRenderData
 */
export function preprocessRenderData (renderData) { 
  // method for get key path array
  const processKeyPathMap = (keyPathMap) => {
    let keyPath = Object.keys(keyPathMap)
    return keyPath.filter((keyA) => {
      return keyPath.every((keyB) => {
        if (keyA.startsWith(keyB) && keyA !== keyB) {
          let nextChar = keyA[keyB.length]
          if (nextChar === '.' || nextChar === '[') {
            return false
          }
        }
        return true
      })
    })
  }

  const processedRenderData = {}
  const renderDataFinalKey = processKeyPathMap(renderData) // 获取最终需要被渲染的数据的 key
  Object.keys(renderData).forEach(item => {
    if (renderDataFinalKey.indexOf(item) > -1) {
      processedRenderData[item] = renderData[item]
    }
  })
  return processedRenderData
}

其中在 processRenderData 方法内部调用了 diffAndCloneA 方法去完成数据的 diff 工作。在这个方法内部判断新、旧值是否发生变化,返回的 diff 字段即表示是否发生了变化,clone 为 diffAndCloneA 接受到的第一个数据的深拷贝值。

这里大致的描述下相关流程:

  1. 响应式的数据发生了变化,触发 Render Function 重新执行,获取最新的 renderData;
  2. renderData 的预处理,主要是用以剔除通过路径访问时同时有父、子路径情况下的子路径的 key;
  3. 判断是否存在 miniRenderData 最小数据渲染集,如果没有那么 Mpx 完成 miniRenderData 最小渲染数据集的收集,如果有那么使用处理后的 renderData 和 miniRenderData 进行数据的 diff 工作(diffAndCloneA),并更新最新的 miniRenderData 的值;
  4. 调用 doRender 方法,进入到 setData 阶段

相关参阅文档:

尽可能的减少 setData 的调用频次

每次调用 setData 方法都会完成一次从逻辑层 -> native bridge -> 视图层的通讯,并完成页面的更新。因此频繁的调用 setData 方法势必也会造成视图的多次渲染,用户的交互受阻。所以对于 setData 方法另外一个优化角度就是尽可能的减少 setData 的调用频次,将多个同步的 setData 操作合并到一次调用当中。接下来就来看下 mpx 在这方面是如何做优化的。

还是先来看一个简单的 demo:

<script>
import { createComponent } from '@mpxjs/core'

createComponent({
  data: {
    msg: 'hello',
    obj: {
      a: {
        c: 1,
        d: 2
      }
    }
  }
  watch: {
    obj: {
      handler() {
        this.msg = 'world'
      },
      deep: true
    }
  },
  onShow() {
    setTimeout(() => {
      this.obj.a = {
        c: 1,
        d: 'd'
      }
    }, 200)
  }
})
</script>

在示例 demo 当中,msg 和 obj 都作为模板依赖的数据,这个组件开始展示后的 200ms,更新 obj.a 的值,同时 obj 被 watch,当 obj 发生改变后,更新 msg 的值。这里的逻辑处理顺序是:

obj.a 变化 -> 将 renderWatch 加入到执行队列 -> 触发 obj watch -> 将 obj watch 加入到执行队列 -> 将执行队列放到下一帧执行 -> 按照 watch id 从小到大依次执行 watch.run -> setData 方法调用一次(即 renderWatch 回调),统一更新 obj.a 及 msg -> 视图重新渲染

接下来就来具体看下这个流程:由于 obj 作为模板渲染的依赖数据,自然会被这个组件的 renderWatch 作为依赖而被收集。当 obj 的值发生变化后,首先触发 reaction 的回调,即 this.update() 方法,如果是个同步的 watch,那么立即调用 this.run() 方法,即 watcher 监听的回调方法,否则就通过 queueWatcher(this) 方法将这个 watcher 加入到执行队列:

// src/core/watcher.js
export default Watcher {
  constructor (context, expr, callback, options) {
    ...
    this.id = ++uid
    this.reaction = new Reaction(`mpx-watcher-${this.id}`, () => {
      this.update()
    })
    ...
  }

  update () {
    if (this.options.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

而在 queueWatcher 方法中,lockTask 维护了一个异步锁,即将 flushQueue 当成微任务统一放到下一帧去执行。所以在 flushQueue 开始执行之前,还会有同步的代码将 watcher 加入到执行队列当中,当 flushQueue 开始执行的时候,依照 watcher.id 升序依次执行,这样去确保 renderWatcher 在执行前,其他所有的 watcher 回调都执行完了,即执行 renderWatcher 的回调的时候获取到的 renderData 都是最新的,然后再去进行 setData 的操作,完成页面的更新。

// src/core/queueWatcher.js
import { asyncLock } from '../helper/utils'
const queue = []
const idsMap = {}
let flushing = false
let curIndex = 0
const lockTask = asyncLock()
export default function queueWatcher (watcher) {
  if (!watcher.id && typeof watcher === 'function') {
    watcher = {
      id: Infinity,
      run: watcher
    }
  }
  if (!idsMap[watcher.id] || watcher.id === Infinity) {
    idsMap[watcher.id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > curIndex && watcher.id < queue[i].id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    lockTask(flushQueue, resetQueue)
  }
}

function flushQueue () {
  flushing = true
  queue.sort((a, b) => a.id - b.id)
  for (curIndex = 0; curIndex < queue.length; curIndex++) {
    const watcher = queue[curIndex]
    idsMap[watcher.id] = null
    watcher.destroyed || watcher.run()
  }
  resetQueue()
}

function resetQueue () {
  flushing = false
  curIndex = queue.length = 0
}

Mpx github: https://github.com/didi/mpx
使用文档: https://didi.github.io/mpx/

查看原文

赞 27 收藏 13 评论 0

游龙翔隼 关注了用户 · 2020-03-27

梁晓冬 @liangxiaodong

现就职于Autodesk软件中国有限公司,主要负责Autodesk产品API和Forge云服务的推广和传播工作。微信号:thiscoldwood。
欢迎联系咨询Forge相关事宜

关注 140

游龙翔隼 关注了专栏 · 2020-03-27

Autodesk Forge 中文支持团队

本专栏分享一些Autodesk Forge相关的资讯,技术热帖,活动等通知

关注 104

游龙翔隼 赞了文章 · 2019-04-10

vue中如何使用sentry对错误日志进行监控

前言

之前看过一片前端错误日志的文章,但是没怎么在意,忘记是哪位大神写的了-.-! 知道前天跟公司后台哥们一说,说我们需要搭个前端的错误日志监控系统,然后他就把sentry的文档发过来了,他自己用python已经在公司服务器搭了一个sentry了,但是我在使用的过程中始终卡在了确认API_KEY这一步,所以自己就使用了github的账号操作了一遍,便有了下文.

sentry官网
vue errorHandler 文档说明
官方文档传送门

创建一个sentry账号

使用github账号登陆,新建一个组织,然后新建项目.
注意:vueBrower下,当时我看文档的时候没注意,还是参考react来配置的,差点走了弯路.
图片描述

项目新建好以后,跳转到一下界面,这时sentry已经生成了DSN,即sentry请求发送错误日志的链接.
拉到页尾去,使用model来引入sentry;

安装插件

cnpm i raven-js -S
//  这事官方自动帮你生成的
import Vue from 'vue';
import Raven from 'raven-js';
import RavenVue from 'raven-js/plugins/vue';

Raven
    .config('https://396b17802b834246156166ed6defd99cb898@sentry.io/52513545')
    .addPlugin(RavenVue, Vue)
    .install();

这是官方自动帮你生成的,但是目前无法捕捉vue中的错误日志,但是vue有个全局配置叫做
vue.config.errorHandler,我们就是使用他来发送vue的错误日志的

使用

由于我们项目一般都是分为测试环境和生产环境,因此有必要使用node.env来进行区分

    /main.js
    import Vue from 'vue';
    import Raven from 'raven-js';
    import RavenVue from 'raven-js/plugins/vue';
    
    const sentyDSN = process.env.NODE_ENV === 'test' ? 
                                                'https://生成的api-test':
                                                'https://生成的api-prod'
process.env.NODE_ENV === 'test' && Raven.config(
    sentyDSN,  //  
    {
        environment: process.env.NODE_ENV
    },
    {
        release:'staging@1.0.1'
    }
    )
    .addPlugin(RavenVue, Vue)
    .install()
// 注意,一定记得区分开发环境,否则开发环境的错误也会被提交到sntry去,并且本地是不会显示错误信息的    
if(process.env.NODE_ENV !== 'development' ){
    Vue.config.errorHandler = function(err, vm, info) {
        Raven.captureException(err)
    }
}

new Vue({
    el: '#app',
    router,
    store, //将store注册到vue实例中
    template: '<App/>',
    components: { App }
})
    

上述操作完成后,你可以自己抛出一个错误, 比如 我在index.vue中执行了this.testhhh(),由于此时并没有上source-map,因此只能看到出错的信息,无法定位到真正的错误所在;
所以需要上传source-map
图片描述

clipboard.png

这是我已经上传了sourcemap后的结果,是可以定位到具体的行号的.

图片描述

点进去..... 可以看到具体的出错信息.

图片描述

vue配合webpack,自动上传js,map文件到sentry错误日志系统

使用sentry-webpack-plugin,自动将生成的js map文件上传
sentry官网

source-map

参见 @游龙翔隼 Sentry前端部署拓展篇(sourcemap关联、issue关联、release控制)

webpack.prod.conf配置

安装 cnpm i @sentry/webpack-plugin -D
const SentryPlugin = require('@sentry/webpack-plugin')
new SentryPlugin({
        release: "staging@1.0.1",                           //发布的版本
        include: path.join(__dirname,'../dist/static/js/'), //需要上传到sentry服务器的资源目录,会自动匹配js 以及map文件
        ignore: ['node_modules', 'webpack.config.js'],  //  忽略文件目录,当然我们在inlcude中制定了文件路径,这个忽略目录可以不加
        configFile :`${__dirname}/sentry.properties`,   //  使用了类似于java的properties配置文件,里面包含了 -o组织 -p项目 以及authtoken
        urlPrefix : "~/static/js"           //  线上对应的url资源的相对路径 比如我的域名是 http://mydomin.com/,静态资源都在 static文件夹里面,
      }),

configfile:sentry.properties

# 生成的token
auth.token=61fbcb5798db46f7970dfb7aacc30389b72828188dfb493a9955a3141693d18d
# 默认的上传地址
defaults.url=https://sentry.io/
# 组织名
defaults.org=yunhe
# 项目名
defaults.project=vue_test

一些说明

  1. 暂未配置自动发送邮件的功能.
  2. 通过webpack插件的形式进行source-map上传的话,比较花时间.可以手动进行;
  3. webpack中的 上传的sourece-map通过realease来标注版本,这样在多版本的监控中可以对错误信息进行过滤.

参考文章

  1. @游龙翔隼 Sentry前端部署拓展篇(sourcemap关联、issue关联、release控制)
  2. 运维开发实践——基于Sentry搭建错误日志监控系统
  3. sentry官网
查看原文

赞 14 收藏 8 评论 7

游龙翔隼 赞了文章 · 2019-04-09

程序员练级攻略(2018):前端基础和底层原理

图片描述

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!

这个是我订阅 陈皓老师在极客上的专栏《左耳听风》,我整理出来是为了自己方便学习,同时也分享给你们一起学习,当然如果有兴趣,可以去订阅,为了避免广告嫌疑,我这就不多说了!以下第一人称是指陈皓老师。

对于前端的学习和提高,我的基本思路是这样的。首先,前端的三个最基本的东西 HTML5、CSS3 和 JavaScript(ES6)是必须要学好的。这其中有很多很多的技术,比如,CSS3 引申出来的 Canvas(位图)、SVG(矢量图) 和 WebGL(3D 图),以及 CSS 的各种图形变换可以让你做出非常丰富的渲染效果和动画效果。

ES6 简直就是把 JavaScript 带到了一个新的台阶,JavaScript 语言的强大,大大释放了前端开发人员的生产力,让前端得以开发更为复杂的代码和程序,于是像 React 和 Vue 这样的框架开始成为前端编程的不二之选。

我一直认为学习任何知识都要从基础出发,所以我会有很大的篇幅在讲各种技术的基础知识和基本原理,尤其是如下的这些知识,都是前端程序员需要一块一块啃掉的硬骨头。

  • JavaScript 的核心原理。这里我会给出好些网上很不错的讲 JavaScript 的原理的文章或图书,你一定要学好语言的特性和其中的各种坑。
  • 浏览器的工作原理。这也是一块硬骨头,我觉得这是前端程序员需要了解和明白的东西,不然,你将无法深入下去。
  • 网络协议 HTTP。也是要着重了解的,尤其是 HTTP/2,还有 HTTP 的几种请求方式:短连接、长连接、Stream 连接、WebSocket 连接。
  • 前端性能调优。有了以上的这些基础后,你就可以进入前端性能调优的主题了,我相信你可以很容易上手各种性能调优技术的。
  • 框架学习。我只给了 React 和 Vue 两个框架。就这两个框架来说,Virtual DOM 技术是其底层技术,组件化是其思想,管理组件的状态是其重点。而对于 React 来说,函数式编程又是其编程思想,所以,这些基础技术都是你需要好好研究和学习的。
  • UI 设计。设计也是前端需要做的一个事,比如像 Google 的 Material UI,或是比较流行的 Atomic Design 等应该是前端工程师需要学习的。

而对于工具类的东西,这里我基本没怎么涉及,因为本文主要还是从原理和基础入手。那些工具我觉得都很简单,就像学习 Java 我没有让你去学习 Maven 一样,因为只要你去动手了,这种知识你自然就会获得,我们还是把精力重点放在更重要的地方。

下面我们从前端基础和底层原理开始讲起。先来讲讲 HTML5 相关的内容。

HTML5

  • HTML5 权威指南 ,本书面向初学者和中等水平 Web 开发人员,是牢固掌握 HTML5、CSS3 和 JavaScript 的必读之作。书看起来比较厚,是因为里面的代码很多。
  • HTML5 Canvas 核心技术 ,如果你要做 HTML5 游戏的话,这本书必读。

对于 SVG、Canvas 和 WebGL 这三个对应于矢量图、位图和 3D 图的渲染来说,给前端开发带来了重武器,很多 HTML5 小游戏也因此蓬勃发展。所以,你可以学习一下。

学习这三个技术,我个人觉得最好的地方是 MDN。

最后是几个资源列表。

CSS

在《程序员练级攻略(2018)》系列文章最开始,我们就推荐过 CSS 的在线学习文档,这里再推荐一下

MDN Web Doc - CSS 。我个人觉得只要你仔细读一下文档,CSS 并不难学。绝大多数觉得难的,一方面是文档没读透,另一方面是浏览器支持的标准不一致。所以,学好 CSS 最关键的还是要仔细地读文档。

之后,在写 CSS 的时候,你会发现,你的 CSS 中有很多看起来相似的东西。你的 DRY - Don’t Repeat Yourself 洁癖告诉你,这是不对的。所以,你需要学会使用 LESSSaSS
这两个 CSS 预处理工具,其可以帮你提高很多效率。

然后,你需要学习一下 CSS 的书写规范,前面的《程序员修养》一文中提到过一些,这里再补充几个。

如果你需要更有效率,那么你还需要使用一些 CSS Framework,其中最著名的就是 Twitter 公司的 Bootstrap,其有很多不错的 UI 组件,页面布局方案,可以让你非常方便也非常快速地开发页面。除此之外,还有,主打清新 UI 的 Semantic UI 、主打响应式界面的 Foundation 和基于 Flexbox 的 Bulma

当然,在使用 CSS 之前,你需要把你浏览器中的一些 HTML 标签给标准化掉。所以,推荐几个 Reset 或标准化的 CSS 库:NormalizeMiniRest.csssanitize.cssunstyle.css

关于更多的 CSS 框架,你可以参看 Awesome CSS Frameworks

接下来,是几个公司的 CSS 相关实践,供你参考。

CodePen’s CSS

Github 的 CSS

Medium’s CSS is actually pretty f*ing good

CSS at BBC Sport

Refining The Way We Structure Our CSS At Trello

最后是一个可以写出可扩展的 CSS 的阅读列表 A Scalable CSS Reading List

JavaScript

下面是学习 JavaScript 的一些图书和文章。

浏览器原理

你需要了解一下浏览器是怎么工作的,所以,你必需要看《How browsers work》。这篇文章受众之大,后来被人重新整理并发布为《How Browsers Work: Behind the scenes of modern web browsers》,其中还包括中文版。这篇文章非常非常长,所以,你要有耐心看完。如果你想看个精简版的,可以看我在 Coolshell 上发的《浏览器的渲染原理简介》或是看一下这个幻灯片

然后,是对 Virtual DOM 的学习。Virtual DOM 是 React 的一个非常核心的技术细节,它也是前端渲染和性能的关键技术。所以,你有必要要好好学习一下这个技术的实现原理和算法。当然,前提条件是你需要学习过前面我所推荐过的浏览器的工作原理。下面是一些不错的文章可以帮你学习这一技术。

网络协议

小结

总结一下今天的内容。我一直认为学习任何知识都要从基础出发,所以今天我主要讲述了 HTML5、CSS3 和 JavaScript(ES6)这三大基础核心,给出了大量的图书、文章以及其他一些相关的学习资源。之后,我建议你学习浏览器的工作原理和网络协议相关的内容。我认为,掌握这些原理也是学好前端知识的前提和基础。值得花时间,好好学习消化。

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

你们的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

查看原文

赞 702 收藏 555 评论 11

游龙翔隼 发布了文章 · 2019-04-09

记一次Vue项目优化及思路

c24ebc18gy1g1vc83wu25j20al0ao3z3.jpg

记录一个前端项目优化的路程,效果如上图。

接下来我们在正文讲解具体优化步骤、思路以及优化前后对比,另外还有/static下文件被打包的解决方法。

PS:正文中图片模糊的话请右键“在新标签页打开图片”查看原图

原文首发于我的个人网站 lonhon.top,转载请注明出处。

WHY,为什么进行优化

本周在做的一个vue项目进入到测试阶段,在打包时候发现build耗时过长(近3分钟),觉得是有点异常,有过更复杂的项目但是耗时基本也在1分钟内,所以运行npm run build --report生成打包的矩阵树图(Treemap)来进行排查,report截图如下:

c24ebc18gy1g1vc72p8ovj21fb0q8thn.jpg

发现项目中Location页面(用于数据可视化地理空间展示)的可视化功能所用到的几个国家map文件赫然出现在最前面,而项目打包后尺寸也达到了8.76M

另外,在进入该页面时发现页面加载耗时明显增加(1.js有6.77M),说明页面渲染被堵塞。

HOW,如何进行优化

第一步,优化静态资源

分析后得出结论:map文件被打包到1.js中导致build和页面渲染时间增多

接下来是优化思路:

  1. map文件基本不会动,所以可以压缩后放在/static中引入,减少build耗时
  2. 使用defer引入,解决页面渲染被堵塞的问题

static踩坑

因为项目使用vue-cli工具,此前记得文档中说静态资源放在/static中会直接copy而不进行打包

把map文件直接移动到/static目录,还是会对这些文件进行打包,后面才想通:

  • 资源放在/static不会被打包
  • 不打包的资源放在/static ✅

首先,/static目录下的资源需要使用绝对路径进行引入,比如img.data-original="/static/xxx.png";其次,如果在vue或js文件中使用import引入/static目录下的资源也是会被跟踪到然后一起打包的。

所以最终是在index.html文件中直接使用script标签引入map资源,并使用defer方式避免堵塞页面正常渲染

<script defer data-original="/static/map1.js"/>

第一步优化结果

接下来看看优化效果:

c24ebc18gy1g1vc7b7s1kj21fb0q5dqn.jpg

可以看到现在打包后项目体积优化到2M

此外,实际build时间也从3min减少到50s左右,Location页面渲染时间过长的问题也得到解决。

第二步,分离echarts

虽然项目体积已经锐减,但是个人对2M这个数字还不够满意,可以看到现在图中Treemap sizes显示最大的文件是vendor.js,vendor.js里面放着项目的一些依赖模块如vue、vue-route、axios、element-ui、echarts等,同时也可以看到现在最大的模块是echarts,所以接下来试着将echarts通过cdn的方式引入来达到减少项目体积的目的。

此处优化关键字:webpack externals,具体介绍见webpack文档 。我们可以简单理解为从cdn加载第三方模块,从而减少服务器压力和项目体积。

在/build/webpack.prod.conf.js文件中添加externals(vue-cli版本不同会有差异):

{
  // other setting
  externals: {
    'echarts': 'echarts'
  }
}

在index.html中使用script标签从cdn引入echarts

    <script data-original="https://lonhon.top/echarts/4.1.0/echarts.min.js">    
    <div id="app"></div>

因为主要是一个可视化项目,用到echarts页面较多,所以这里在app之前就引入了。

通过externals方式分离echarts或其它模块,不用修改main.js里面的逻辑。

注1:也可以通过此种方式对其它模块如element-ui进行拆分

注2:针对echarts,也可以通过按需引入的方式达到优化效果。

第二步优化结果

再次运行npm run build --report查看项目打包情况:

c24ebc18gy1g1vc77g0o5j21f70q4gw4.jpg

可以看到项目体积已经优化到1.26M,vendor.js中也看不到echarts的踪影了。

结语

至此本文结束,实际开发中各个项目的主要优化点都各不相同,需要在开发过程中一一发掘。

本文主要想提供一些优化思路及手段,即如何定位(通过build report,查看页面加载时间)问题,然后再解决这些问题。

查看原文

赞 1 收藏 0 评论 0

游龙翔隼 收藏了文章 · 2018-10-30

vue修饰符--可能是东半球最详细的文档(滑稽)

为了方便大家写代码,vue.js给大家提供了很多方便的修饰符,比如我们经常用到的取消冒泡,阻止默认事件等等~
插播一则广告李雷的博客

目录

  • 表单修饰符
  • 事件修饰符
  • 鼠标按键修饰符
  • 键值修饰符
  • v-bind修饰符(实在不知道叫啥名字)

表单修饰符

填写表单,最常用的是什么?input!v-model~而我们的修饰符正是为了简化这些东西而存在的

  • .lazy
<div>
   <input type="text" v-model="value">
   <p>{{value}}</p>
</div>

clipboard.png

从这里我们可以看到,我们还在输入的时候,光标还在的时候,下面的值就已经出来了,可以说是非常地实时。
但是有时候我们希望,在我们输入完所有东西,光标离开才更新视图。

<div>
   <input type="text" v-model.lazy="value">
   <p>{{value}}</p>
</div>

这样即可~这样只有当我们光标离开输入框的时候,它才会更新视图,相当于在onchange事件触发更新。

  • .trim

在我们的输入框中,我们经常需要过滤一下一些输入完密码不小心多敲了一下空格的兄弟输入的内容。

<input type="text" v-model.trim="value">

clipboard.png

为了让你更清楚的看到,我改了一下样式,不过问题不大,相信你已经清楚看到这个大大的hello左右两边没有空格,尽管你在input框里敲烂了空格键。
需要注意的是,它只能过滤首尾的空格!首尾,中间的是不会过滤的

  • .number

看这个名字就知道,应该是限制输入数字或者输入的东西转换成数字,but不是辣么赶单。

clipboard.png

clipboard.png

如果你先输入数字,那它就会限制你输入的只能是数字。
如果你先输入字符串,那它就相当于没有加.number

事件修饰符

  • .stop

由于事件冒泡的机制,我们给元素绑定点击事件的时候,也会触发父级的点击事件。

<div @click="shout(2)">
  <button @click="shout(1)">ok</button>
</div>

//js
shout(e){
  console.log(e)
}
//1
//2

一键阻止事件冒泡,简直方便得不行。相当于调用了event.stopPropagation()方法。

<div @click="shout(2)">
  <button @click.stop="shout(1)">ok</button>
</div>
//只输出1
  • .prevent

用于阻止事件的默认行为,例如,当点击提交按钮时阻止对表单的提交。相当于调用了event.preventDefault()方法。

<!-- 提交事件不再重载页面 -->
<form v-on:submit.prevent="onSubmit"></form>

注意:修饰符可以同时使用多个,但是可能会因为顺序而有所不同。
用 v-on:click.prevent.self 会阻止所有的点击,而 v-on:click.self.prevent 只会阻止对元素自身的点击。
也就是从左往右判断~

  • .self

只当事件是从事件绑定的元素本身触发时才触发回调。像下面所示,刚刚我们从.stop时候知道子元素会冒泡到父元素导致触发父元素的点击事件,当我们加了这个.self以后,我们点击button不会触发父元素的点击事件shout,只有当点击到父元素的时候(蓝色背景)才会shout~从这个self的英文翻译过来就是‘自己,本身’可以看出这个修饰符的用法

<div class="blue" @click.self="shout(2)">
  <button @click="shout(1)">ok</button>
</div>

clipboard.png

  • .once

这个修饰符的用法也是和名字一样简单粗暴,只能用一次,绑定了事件以后只能触发一次,第二次就不会触发。

//键盘按坏都只能shout一次
<button @click.once="shout(1)">ok</button>
  • .capture

从上面我们知道了事件的冒泡,其实完整的事件机制是:捕获阶段--目标阶段--冒泡阶段。
默认的呢,是事件触发是从目标开始往上冒泡。
当我们加了这个.capture以后呢,我们就反过来了,事件触发从包含这个元素的顶层开始往下触发。

   <div @click.capture="shout(1)">
      obj1
      <div @click.capture="shout(2)">
        obj2
        <div @click="shout(3)">
          obj3
          <div @click="shout(4)">
            obj4
          </div>
        </div>
      </div>
    </div>
    // 1 2 4 3 

从上面这个例子我们点击obj4的时候,就可以清楚地看出区别,obj1,obj2在捕获阶段就触发了事件,因此是先1后2,后面的obj3,obj4是默认的冒泡阶段触发,因此是先4然后冒泡到3~

  • .passive

当我们在监听元素滚动事件的时候,会一直触发onscroll事件,在pc端是没啥问题的,但是在移动端,会让我们的网页变卡,因此我们使用这个修饰符的时候,相当于给onscroll事件整了一个.lazy修饰符

<!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 -->
<!-- 而不会等待 `onScroll` 完成  -->
<!-- 这其中包含 `event.preventDefault()` 的情况 -->
<div v-on:scroll.passive="onScroll">...</div>
  • .native

我们经常会写很多的小组件,有些小组件可能会绑定一些事件,但是,像下面这样绑定事件是不会触发的

<My-component @click="shout(3)"></My-component>

必须使用.native来修饰这个click事件(即<My-component @click.native="shout(3)"></My-component>),可以理解为该修饰符的作用就是把一个vue组件转化为一个普通的HTML标签,
注意:使用.native修饰符来操作普通HTML标签是会令事件失效的

鼠标按钮修饰符

刚刚我们讲到这个click事件,我们一般是会用左键触发,有时候我们需要更改右键菜单啥的,就需要用到右键点击或者中间键点击,这个时候就要用到鼠标按钮修饰符

  • .left 左键点击
  • .right 右键点击
  • .middle 中键点击
<button @click.right="shout(1)">ok</button>

键值修饰符

其实这个也算是事件修饰符的一种,因为它都是用来修饰键盘事件的。
比如onkeyup,onkeydown啊

  • .keyCode

如果不用keyCode修饰符,那我们每次按下键盘都会触发shout,当我们想指定按下某一个键才触发这个shout的时候,这个修饰符就有用了,具体键码查看键码对应表

<input type="text" @keyup.keyCode="shout(4)">

为了方便我们使用,vue给一些常用的键提供了别名

//普通键
.enter 
.tab
.delete //(捕获“删除”和“退格”键)
.space
.esc
.up
.down
.left
.right
//系统修饰键
.ctrl
.alt
.meta
.shift

可以通过全局 config.keyCodes 对象自定义按键修饰符别名:

// 可以使用 `v-on:keyup.f1`
Vue.config.keyCodes.f1 = 112

我们从上面看到,键分成了普通常用的键和系统修饰键,区别是什么呢?
当我们写如下代码的时候,我们会发现如果仅仅使用系统修饰键是无法触发keyup事件的。

<input type="text" @keyup.ctrl="shout(4)">

那该如何呢?我们需要将系统修饰键和其他键码链接起来使用,比如

<input type="text" @keyup.ctrl.67="shout(4)">

这样当我们同时按下ctrl+c时,就会触发keyup事件。
另,如果是鼠标事件,那就可以单独使用系统修饰符。

      <button @mouseover.ctrl="shout(1)">ok</button>
      <button @mousedown.ctrl="shout(1)">ok</button>
      <button @click.ctrl.67="shout(1)">ok</button>

大概是什么意思呢,就是你不能单手指使用系统修饰键的修饰符(最少两个手指,可以多个)。你可以一个手指按住系统修饰键一个手指按住另外一个键来实现键盘事件。也可以用一个手指按住系统修饰键,另一只手按住鼠标来实现鼠标事件。

  • .exact (2.5新增)

我们上面说了这个系统修饰键,当我们像这样<button type="text" @click.ctrl="shout(4)"></button>绑定了click键按下的事件,惊奇的是,我们同时按下几个系统修饰键,比如ctrl shift点击,也能触发,可能有些场景我们只需要或者只能按一个系统修饰键来触发(像制作一些快捷键的时候),而当我们按下ctrl和其他键的时候则无法触发。那就这样写。
注意:这个只是限制系统修饰键的,像下面这样书写以后你还是可以按下ctrl + c,ctrl+v或者ctrl+普通键 来触发,但是不能按下ctrl + shift +普通键来触发。

<button type="text" @click.ctrl.exact="shout(4)">ok</button>

然后下面这个你可以同时按下enter+普通键来触发,但是不能按下系统修饰键+enter来触发。相信你已经能听懂了8~

<input type="text" @keydown.enter.exact="shout('我被触发了')">

v-bind修饰符

  • .sync(2.3.0+ 新增)

在有些情况下,我们可能需要对一个 prop 进行“双向绑定”。不幸的是,真正的双向绑定会带来维护上的问题,因为子组件可以修改父组件,且在父组件和子组件都没有明显的改动来源。我们通常的做法是

//父亲组件
<comp :myMessage="bar" @update:myMessage="func"></comp>
//js
func(e){
 this.bar = e;
}
//子组件js
func2(){
  this.$emit('update:myMessage',params);
}

现在这个.sync修饰符就是简化了上面的步骤

//父组件
<comp :myMessage.sync="bar"></comp> 
//子组件
this.$emit('update:myMessage',params);

这样确实会方便很多,但是也有很多需要注意的点

  1. 使用sync的时候,子组件传递的事件名必须为update:value,其中value必须与子组件中props中声明的名称完全一致(如上例中的myMessage,不能使用my-message)
  2. 注意带有 .sync 修饰符的 v-bind 不能和表达式一起使用 (例如 v-bind:title.sync=”doc.title + ‘!’” 是无效的)。取而代之的是,你只能提供你想要绑定的属性名,类似 v-model。
  3. 将 v-bind.sync 用在一个字面量的对象上,例如 v-bind.sync=”{ title: doc.title }”,是无法正常工作的,因为在解析一个像这样的复杂表达式的时候,有很多边缘情况需要考虑。
  • .prop

要学习这个修饰符,我们首先要搞懂两个东西的区别。

Property:节点对象在内存中存储的属性,可以访问和设置。
Attribute:节点对象的其中一个属性( property ),值是一个对象。
可以通过点访问法 document.getElementById('xx').attributes 或者 document.getElementById('xx').getAttributes('xx') 读取,通过 document.getElementById('xx').setAttribute('xx',value) 新增和修改。
在标签里定义的所有属性包括 HTML 属性和自定义属性都会在 attributes 对象里以键值对的方式存在。

其实attribute和property两个单词,翻译出来都是属性,但是《javascript高级程序设计》将它们翻译为特性和属性,以示区分

//这里的id,value,style都属于property
//index属于attribute
//id、title等既是属性,也是特性。修改属性,其对应的特性会发生改变;修改特性,属性也会改变
<input id="uid" title="title1" value="1" :index="index">
//input.index === undefined
//input.attributes.index === this.index

从上面我们可以看到如果直接使用v-bind绑定,则默认会绑定到dom节点的attribute。
为了

  • 通过自定义属性存储变量,避免暴露数据
  • 防止污染 HTML 结构

我们可以使用这个修饰符,如下

<input id="uid" title="title1" value="1" :index.prop="index">
//input.index === this.index
//input.attributes.index === undefined
  • .camel

由于HTML 特性是不区分大小写的。

<svg :viewBox="viewBox"></svg>

实际上会渲染为

<svg viewbox="viewBox"></svg>

这将导致渲染失败,因为 SVG 标签只认 viewBox,却不知道 viewbox 是什么。
如果我们使用.camel修饰符,那它就会被渲染为驼峰名。
另,如果你使用字符串模版,则没有这些限制。

new Vue({
  template: '<svg :viewBox="viewBox"></svg>'
})

最后

不知道有没有漏的,如果有漏的麻烦在评论区告知一声,有建议或者意见也可以提一下,谢谢~

查看原文

游龙翔隼 评论了文章 · 2018-09-06

Sentry前端部署拓展篇(sourcemap关联、issue关联、release控制)

原文首发于我的个人博客: https://lonhon.top/

之前的《基础篇》主要介绍了Sentry和基本部署流程,在实际使用过程中你会发现Sentry受欢迎的原因:除了单纯的监控异常还有溯源、分发任务等一条龙服务。本篇文章主要讲述Sentry中较好的拓展功能,包括:

  • Release控制,分别处理线上、测试环境的异常
  • 通过SourceMap直接查看出错js源码
  • 报警邮件发送规则
  • Issue关联GITHUB/GITLAB

上篇文章已将Sentry的各种文档、社区贴出,本文更多是操作性的东西,代码、图片较多。

准备工作

需要安装Sentry对应的命令行管理工具 sentry-cli,方式如下:

npm i -g @sentry/cli

安装完成后可通过 sentry-cli -V 查看版本。

生成token

点击Sentry页面左下角头像,选择API后就可以生成token,记得勾选 project:write 权限。

登录

$ sentry-cli --url https://myserver/ login

回车后输入上一步获得的 token 即可,如果用的Sentry的SaaS可以不指定 url

Release控制

在开发过程中我们希望不监控本地环境下的异常,测试环境的和生产环境的异常分离,所以就需要Release来进行“异常”的版本控制。

创建Release

sentry-cli releases -o 组织 -p 项目 new staging@1.0.1

这里的 staging@1.0.1 就是我们指定的版本号. -o -p可以通过页面左上角可以看到。现在我们可以通过创建多个版本号来进行异常分类。
同时,也可以通过页面中"Releases"查看是否创建成功。

本地应用Release

回到前端项目中,在config添加对应的release,指定版本后,每次上报的异常就会分类到该版本下。

  Raven.config(DSN, {
    release: 'staging@1.0.1'
  }).addPlugin(RavenVue, Vue).install()

删除Release

sentry-cli releases -o 组织 -p 项目 delete staging@1.0.1 

注意 删除某个release时需要将其下的异常处理掉,并将该版本的sourcemap文件清空,完成上面两步可能还要等待2小时才能删除,不然会报错:该版本还有其它依赖。

SourceMap管理

目前来说,前端项目基本都会压缩混淆代码,这样导致Sentry捕捉到的异常堆栈无法理解。

我们希望在Sentry直接看到异常代码的源码时就需要上传对应的source和map。

1.上传 SourceMap

sentry-cli releases -o 组织 -p 项目 files staging@1.0.1 upload-sourcemaps js文件所在目录 --url-prefix 线上资源URI

这里需要注意,我们一般会将公共模块压缩在vendor.js中,此时可能会出现因为vendor.js.map的文件体积过大导致不能上传,目前我的做法是不上传vendor.js和vendor.js.map。

PS: 记得别把map文件传到生产环节了,又大又不安全...

PS: 免费服务的文件上限为40MB。

2.清空 SourceMap 文件

sentry-cli releases files staging@1.0.1 delete --all

也可以选择在 版本>工件 里点击一个个辣鸡桶进行删除(逃...

3.重要的url-prefix

这里的url-prefix可以通过线上看js文件的完整路径,有可能static不在根目录下

举例说明,项目线上资源URI如下:

https://www.baidu.com/asset/js/main.mini.js  

我们上传时文件的url-prefix就应该设置为 '~/asset/js/'

这个坑踩了好几天才弄明白,反正规则就是: ~/为网站根目录,后续路径须对应source

这个弄好了就能在Sentry上直接看到出错源码了:

image

报警邮件发送规则

Sentry默认会将所有采集到的异常发送警报邮件,有时我们可能希望只收到某个版本下的警报邮件,这时候就需要删除默认的警报规则,然后新建自定义规则。

在项目设置中找到Alerts,左上角 “New Alert Rule”即可添加设置报警规则。

image

一个比较常规的规则引擎,自己配置一下就可以搞定,还是比较简单。
如不想发送测试版本的异常,则设置过滤规则为 Release : staging 。

Issue关联GITHUB/GITLAB

Sentry关联项目仓库后可以直接为该异常创建issue,方便责任认定,顺便提高KPI :-)

1.选择仓库

项目设置>issue跟踪 选择自己所需的仓库,下面以GITHUB为例

2.关联仓库

image

点击上图中醒目的issue,然后进行GITHUB登录第三方授权,授权完成后再次点击“Create New Issue”就会出现下图了。

image

3.测试

Sentry中创建issue后就可以到我们GITHUB仓库中查看了,如下

image

修改sentry-cli默认设置

在上面的操作中,大家应该发现每次命令都需要重复输入一长串 -o xxx -p xxxx 来指定我们的项目,一点不DRY。

只需要找到当前用户文件夹下的 .sentryclirc 文件添加默认组织和项目即可,修改内容为如下:

[auth]
token=YOUR API TOKEN

[defaults]
url=服务器
org=组织
project=项目

结语

以上是自己目前在用的功能,基本涵盖了常见场景。

当然,我还会继续挖掘下去,大家遇到问题或者新发现也可以给我留言,互相交流。

下篇打算写一下前端异常监控的分类,也就是需要监控哪些异常,敬请期待~

查看原文

游龙翔隼 收藏了文章 · 2018-08-22

小程序开发实践总结

从微信发布小程序以来,各大公司纷纷跟进都想从微信这个流量池里捞一杯羹。我司也不例外,我们整个前端团队这半年来基本上都是在开发小程序。前前后后也开发了四五个小程序了。总觉得要留下点什么,既是记录那些年我们踩过的坑,也是希望大家别再掉坑。

那些年我们踩过的坑

  • css样式不能引用本地图片资源,只能引用线上资源(background-image),引用本地图片资源只能用<image>标签。
  • {{}}不能执行函数方法,{{}}只支持基本的简单运算和ES6拓展运算符。如价格格式化这种常用的处理,只能在js代码中处理好然后再模板中渲染。

    this.setData({
     price: this.formatPrice(this.data.price)
    })
  • 可以通过wxs模块解决{{}}中不能执行函数的问题。可以做到模拟vue.js中过滤器的功能。

    <!-- wxml模板 -->
    <wxs data-original="../../modules/formatPrice.wxs" module="tools" />
    
    <view>价格:{{tools.formatPrice(price)}}</view>
    // wxs模块
    var formatPrice = function (price){
        price = price >> 0;
        return Number(price / 100).toFixed(2);
    }
    
    module.exports = {
        formatPrice
    }
  • 小程序不支持分享链接到朋友圈,暂时的通用做法是生成保存有页面小程序码的图片到本地相册。又用户自行发朋友圈转发。前端可以利用canvas来实现,减轻服务端压力。但是会有图片锯齿不清晰的问题。建议预览图和保存到真机的图片采用不同的尺寸。保存在真机的图片按照750的宽度实现。相比于预览图要大一些,这样保存到手机的图片会清晰很多。
  • 小程序布局采用rpx单位,UI稿按照750的宽度出图。可直接使用UI稿的尺寸。但是在某些机型上1rpx会无法显示。可以用H5的方式实现1px效果。
  • iphoneX吸底按钮的适配,可以用媒体查询获取wx.getSystemInfo获取机型。参考

    @media only screen 
        and (device-width : 375px) 
        and (device-height : 812px) 
        and (-webkit-device-pixel-ratio : 3) { }
  • 页面A -> 页面B,页面B的操作触发了页面A的数据更新。返回更新页面A的数据,通常有两种方式来实现(我司采用了方案二):

    1. 在页面A监听onShow事件,在onShow事件触发时无脑更新页面数据。
    2. 通过EventBus来实现跨页面通信。
  • 复杂组件的开发,省市区三级联动选择器的开发,获取微信地址库的地址的编码和业务采用的省市区编码对不上。
  • 页面路径的层级,最大不能超过10层。
  • 小程序小程序分包加载,微信对小程序包的大小有如下限制。
  • 整个小程序所有分包大小不超过 8M
  • 单个分包/主包大小不能超过 2M

微信小程序主流框架对比

  • wepy
  • mpvue
  • Taro

wepy

wepy应该算是最早发布的小程序开发框架,提供了类vue.js的语法风格和特性,现阶段应该也是应用最广泛的框架吧。我开发的几个小程序也都是采用了wepy这个框架。我先来说说当初为什么选择这个框架的原因吧。

  1. 类Vue.js的语法风格,适合我们团队原有的的技术栈
  2. 支持组件化(当时微信官方的API还不支持组件化)
  3. 支持加载外部npm包
  4. 支持ES6的写法

前期使用wepy的过程中,wepy自带bug。不过好在开发者响应及时,基本上都能覆盖大部分场景。

但是有个最大的坑点就是,wepy组件的实现方式。组件使用的是静态编译组件,即组件是在编译阶段编译进页面的,每个组件都是唯一的一个实例。 多个组件共享同一个数据。并且静态编译组件。导致组件A,在页面A和页面B被引用,会copy两份代码到页面A和页面B内部。导致拆分组件并没有对包的体积有任何减少。后期微信官方API支持组件化编程后,我们逐步把一些比较核心,体积较大的组件用原声API重构了。

mpvue

由美团团队开发,mpvue和wepy一样也是在小程序上提供了类vue.js的开发体验。作为后来者,抢占了很多wepy的市场份额(ps:我们团队近期也在考虑从wepy迁移到mpvue)。这个框架的原理相比wepy要更加复杂一点,mpvue 修改了 Vue.js 的 runtime 和 compiler 实现,提供了更加接近于vue.js的开发体验。

Taro

Taro是由京东团队开源的一套遵循 React 语法规范的多端开发解决方案。本身我对React和Taro都不是很了解,就不多解释了。具体可以看开发团队的博客和代码了解更多细节多端统一开发框架 - Taro
图片描述

我看小程序

我想从技术的角度来谈谈我对微信小程序的理解,我觉得小程序本身是一个非常优秀的Hybrid App的技术方案。有很多值得学的地方,可以应用到我们Hybrid App的技术方案设计中来。了解和学习小程序技术原理也能更好的优化我们的代码。

渲染层和逻辑层分离

图片描述
相比于之前常见的Hybrid的方案,小程序使用了双线程模型:小程序的渲染层和逻辑层是是分开的,逻辑层通过JSCore来解析和执行,渲染层是通过webview来渲染。之前的常见Hybrid离线包的方案大多使用webview同时实现页面的渲染和js的解析。这样做的的结果就是隔离了js的runtime,在js代码中无法操作webview中的DOM对象和BOM对象。Js无法做任何和页面渲染有关的操作。只能通过setData把数据从JsCore传递到webview。

独立的JS运行环境,相比于webview同时处理页面的渲染和JS的执行带来了一些好处:

  1. js无法动态的在页面插入节点和干预页面的渲染,解决了安全和管控的问题,否则小程序的上线审核就变得毫无意义。
  2. 渲染层和逻辑层的分离,减轻了webview的压力,js的执行和页面的渲染可以并行,不会出现js执行卡主页面渲染的情况。
  3. 多个页面可以共享一个JS运行环境,数据很方便的共享,整个小程序的生命周期共享同一个上下文,接近App的体验。

坏处在于:

  1. 多了很多webview和JSCore数据传输的消耗,数据需要序列化成字符串格式进行传输。

离线包加载

离线包加载,常见的Hybrid App通过webview加载H5页面,前端页面都是放在服务器端。虽说保证了灵活性。但是加载性能收网速影响大。页面切换白屏时间长。小程序离线包的加载方式。一次性加载所有的前端资源到本地再解压。大大提升了用户体验。不过微信官方为了防止下载离线包的时间过程,也严格限制了小程序包的体积。(分包加载情况下子包大小不能超过2M,也就是初次打开加载的资源不能超过2M)

多webview架构

多webview的页面架构,小程序每新开一个页面,都会用一个新的webview来渲染。为了防止webview对内存的消耗。小程序限制层级不能超过10层。

预加载webview

预加载webview,微信会预加载多一个wkwebview(ios平台)放后台,用户打开小程序时省去初始化wkwebview时间。

查看原文

游龙翔隼 赞了文章 · 2018-08-22

小程序开发实践总结

从微信发布小程序以来,各大公司纷纷跟进都想从微信这个流量池里捞一杯羹。我司也不例外,我们整个前端团队这半年来基本上都是在开发小程序。前前后后也开发了四五个小程序了。总觉得要留下点什么,既是记录那些年我们踩过的坑,也是希望大家别再掉坑。

那些年我们踩过的坑

  • css样式不能引用本地图片资源,只能引用线上资源(background-image),引用本地图片资源只能用<image>标签。
  • {{}}不能执行函数方法,{{}}只支持基本的简单运算和ES6拓展运算符。如价格格式化这种常用的处理,只能在js代码中处理好然后再模板中渲染。

    this.setData({
     price: this.formatPrice(this.data.price)
    })
  • 可以通过wxs模块解决{{}}中不能执行函数的问题。可以做到模拟vue.js中过滤器的功能。

    <!-- wxml模板 -->
    <wxs data-original="../../modules/formatPrice.wxs" module="tools" />
    
    <view>价格:{{tools.formatPrice(price)}}</view>
    // wxs模块
    var formatPrice = function (price){
        price = price >> 0;
        return Number(price / 100).toFixed(2);
    }
    
    module.exports = {
        formatPrice
    }
  • 小程序不支持分享链接到朋友圈,暂时的通用做法是生成保存有页面小程序码的图片到本地相册。又用户自行发朋友圈转发。前端可以利用canvas来实现,减轻服务端压力。但是会有图片锯齿不清晰的问题。建议预览图和保存到真机的图片采用不同的尺寸。保存在真机的图片按照750的宽度实现。相比于预览图要大一些,这样保存到手机的图片会清晰很多。
  • 小程序布局采用rpx单位,UI稿按照750的宽度出图。可直接使用UI稿的尺寸。但是在某些机型上1rpx会无法显示。可以用H5的方式实现1px效果。
  • iphoneX吸底按钮的适配,可以用媒体查询获取wx.getSystemInfo获取机型。参考

    @media only screen 
        and (device-width : 375px) 
        and (device-height : 812px) 
        and (-webkit-device-pixel-ratio : 3) { }
  • 页面A -> 页面B,页面B的操作触发了页面A的数据更新。返回更新页面A的数据,通常有两种方式来实现(我司采用了方案二):

    1. 在页面A监听onShow事件,在onShow事件触发时无脑更新页面数据。
    2. 通过EventBus来实现跨页面通信。
  • 复杂组件的开发,省市区三级联动选择器的开发,获取微信地址库的地址的编码和业务采用的省市区编码对不上。
  • 页面路径的层级,最大不能超过10层。
  • 小程序小程序分包加载,微信对小程序包的大小有如下限制。
  • 整个小程序所有分包大小不超过 8M
  • 单个分包/主包大小不能超过 2M

微信小程序主流框架对比

  • wepy
  • mpvue
  • Taro

wepy

wepy应该算是最早发布的小程序开发框架,提供了类vue.js的语法风格和特性,现阶段应该也是应用最广泛的框架吧。我开发的几个小程序也都是采用了wepy这个框架。我先来说说当初为什么选择这个框架的原因吧。

  1. 类Vue.js的语法风格,适合我们团队原有的的技术栈
  2. 支持组件化(当时微信官方的API还不支持组件化)
  3. 支持加载外部npm包
  4. 支持ES6的写法

前期使用wepy的过程中,wepy自带bug。不过好在开发者响应及时,基本上都能覆盖大部分场景。

但是有个最大的坑点就是,wepy组件的实现方式。组件使用的是静态编译组件,即组件是在编译阶段编译进页面的,每个组件都是唯一的一个实例。 多个组件共享同一个数据。并且静态编译组件。导致组件A,在页面A和页面B被引用,会copy两份代码到页面A和页面B内部。导致拆分组件并没有对包的体积有任何减少。后期微信官方API支持组件化编程后,我们逐步把一些比较核心,体积较大的组件用原声API重构了。

mpvue

由美团团队开发,mpvue和wepy一样也是在小程序上提供了类vue.js的开发体验。作为后来者,抢占了很多wepy的市场份额(ps:我们团队近期也在考虑从wepy迁移到mpvue)。这个框架的原理相比wepy要更加复杂一点,mpvue 修改了 Vue.js 的 runtime 和 compiler 实现,提供了更加接近于vue.js的开发体验。

Taro

Taro是由京东团队开源的一套遵循 React 语法规范的多端开发解决方案。本身我对React和Taro都不是很了解,就不多解释了。具体可以看开发团队的博客和代码了解更多细节多端统一开发框架 - Taro
图片描述

我看小程序

我想从技术的角度来谈谈我对微信小程序的理解,我觉得小程序本身是一个非常优秀的Hybrid App的技术方案。有很多值得学的地方,可以应用到我们Hybrid App的技术方案设计中来。了解和学习小程序技术原理也能更好的优化我们的代码。

渲染层和逻辑层分离

图片描述
相比于之前常见的Hybrid的方案,小程序使用了双线程模型:小程序的渲染层和逻辑层是是分开的,逻辑层通过JSCore来解析和执行,渲染层是通过webview来渲染。之前的常见Hybrid离线包的方案大多使用webview同时实现页面的渲染和js的解析。这样做的的结果就是隔离了js的runtime,在js代码中无法操作webview中的DOM对象和BOM对象。Js无法做任何和页面渲染有关的操作。只能通过setData把数据从JsCore传递到webview。

独立的JS运行环境,相比于webview同时处理页面的渲染和JS的执行带来了一些好处:

  1. js无法动态的在页面插入节点和干预页面的渲染,解决了安全和管控的问题,否则小程序的上线审核就变得毫无意义。
  2. 渲染层和逻辑层的分离,减轻了webview的压力,js的执行和页面的渲染可以并行,不会出现js执行卡主页面渲染的情况。
  3. 多个页面可以共享一个JS运行环境,数据很方便的共享,整个小程序的生命周期共享同一个上下文,接近App的体验。

坏处在于:

  1. 多了很多webview和JSCore数据传输的消耗,数据需要序列化成字符串格式进行传输。

离线包加载

离线包加载,常见的Hybrid App通过webview加载H5页面,前端页面都是放在服务器端。虽说保证了灵活性。但是加载性能收网速影响大。页面切换白屏时间长。小程序离线包的加载方式。一次性加载所有的前端资源到本地再解压。大大提升了用户体验。不过微信官方为了防止下载离线包的时间过程,也严格限制了小程序包的体积。(分包加载情况下子包大小不能超过2M,也就是初次打开加载的资源不能超过2M)

多webview架构

多webview的页面架构,小程序每新开一个页面,都会用一个新的webview来渲染。为了防止webview对内存的消耗。小程序限制层级不能超过10层。

预加载webview

预加载webview,微信会预加载多一个wkwebview(ios平台)放后台,用户打开小程序时省去初始化wkwebview时间。

查看原文

赞 124 收藏 98 评论 13

游龙翔隼 赞了文章 · 2018-08-09

前端也需要好好的精进自己的算法

算法

前端发展的再快,也不要忘记精进自己的算法,算法是灵魂和核心。我会把我刷过的算法题总结归类,不断完善。欢迎大家关注

数组和堆栈

递归

链表

二叉树和递归

递归和回溯

动态规划

贪心算法

双指针,滑动窗口

其他

查看原文

赞 447 收藏 385 评论 10

游龙翔隼 关注了用户 · 2018-07-10

moonou @moonou

关注 3

游龙翔隼 回答了问题 · 2018-05-18

vue无法读取对象的值

pointproduct是否是异步获取的值?
如是则需要在{{pointproduct[isActive].Open}}的父级dom添加 v-if="hasValue(pointproduct)"

关注 6 回答 6

游龙翔隼 回答了问题 · 2018-04-24

输入框没输完就监听change事件

之前写的关于这个问题的博客。
https://lonhon.top/2017/10/09...

关注 8 回答 8

游龙翔隼 发布了文章 · 2018-04-20

Sentry异常监控方案部署-前端攻略

原文首发于我的个人博客: https://lonhon.top/

凡事只要有可能出错,那就一定会出错

对于任何一个项目而言,本地测试肯定做不到100%覆盖,而且,我们也不能保证用户能按照我们的预期进行操作,其实对我而言,用户才是最好的测试者,但是我们不能奢求每个用户遇到问题时候都会主动向我们反馈。

故而,我们需要在项目出现异常时主动对其进行收集上报,分析原因和影响后制定下一步解决方案。

任何人不能保证他写的项目0bug(除非没人用),但是如何更好、更快的解决项目的异常则是我们有能力追求的。所以,我们需要一款成熟的异常监控系统来协助我们。

选择Sentry的原因

Sentry是一款国外的异常监控开源服务,名字翻译过来就是“哨兵”。

有没有感觉像《冰与火》里的守夜人,其实也差不多,把bug想成异鬼就行了。

最近在公司项目中部署了Sentry,用于项目中异常监控,涵盖了前端Vue、后端Django。
在部署之前也了解了下国内的fundebug,但综合考虑以下几点最终决定用Sentry,

  • 闭源,只能在该平台上使用
  • 只能监控前端页面
  • 这个月开始收费了

另外vue文档中也提到了Sentry对vue的友好支持,本文主要从前端方向讲一下Sentry的部署流程以及遇到的坑。


准备工作

Sentry简介

Sentry在git上面的简介是:“跨平台应用监控,关注错误报告”。

官网: https://sentry.io 

文档: https://docs.sentry.io/clients/javascript/install/ 

git仓库: https://github.com/getsentry/sentry

社区除了git issue外还可以关注 https://forum.sentry.io/

如果想先体验的话建议注册账号,在其SaaS平台上练手。不自己搭建Sentry服务器的话也可以升级为付费服务。

项目背景

前端Vue@2.5.9 + axios,暂时只用关注这俩就行。


部署

  1. 注册账号
  2. 创建项目
  3. 前端项目部署
  4. 自动捕捉异常
  5. 主动捕捉异常

如果是公司自己搭建的Sentry服务器,对前端方面来说改动的地方很少。

1.注册账号

步骤略...

PS: 创建完成后进入dashbord点击左下角头像选择account,然后在Appearance中调整至本地时区,不然后面看到监控的bug创建时间会有差别。

2.创建项目

注册好后我们可以通过右上角 New Project 来创建,然后选择相应的项目,这里还是以vue为例子,如下图:

c24ebc18gy1fqiduusb0vj20yl0ai74a.jpg

接下来会进入到介绍页面了,到这里第一步就算完成,请详细阅读该页面

3.前端项目部署

切回本地项目,通过以下命令安装raven-js

npm i raven-js --save

然后打开main.js,如下图进行部署:

c24ebc18gy1fqie2mazulj20u607xdfy.jpg

这里和介绍页面有点差别的地方在于我将raven挂载到了window对象上,这是为了方便后续捕捉异步操作和接口中的异常。

记得把DSN(图片打码处)换成自己的,在介绍页面中可以找到,如果已经离开该页面,可以在 project-settings 中找到它。

坑: 部署独立服务器时在配置根目录时习惯性加了个"/"导致DSN最后变成了"//2"从而引发了http error:403

4.自动捕捉异常 + 查看

ok,部署操作已经完成,接下来我们主动上报一个bug试试水。

在App.vue的mounted中写一个bug:

console.log(window.aaa.bbb());

然后刷新页面触发bug,这时可以通过chrome调试工具查看上报异常的网络请求。

c24ebc18gy1fqidv04jotj20zu0afaaa.jpg

回到Sentry中,不出意外此时就可以看到相应的错误信息提示。

c24ebc18gy1fqidv311b1j21260ihgm4.jpg

点进去后就能看到更多的错误信息还有用户信息,包括浏览器、版本、ip等

5.主动捕捉异常

通过上面的操作我们已经能成功监控到vue中的错误、异常,但是还不能捕捉到异步操作、接口请求中的错误,比如接口返回404、500等信息,此时我们可以通过raven.caputureException()进行主动上报。

接口异常

由于项目中用的axios进行接口请求,axios提供了请求响应的拦截器 axios.interceptors.response,示例:

axios.interceptors.response.use(data => {
    return data;
  }, error => {
    window.$Raven.captureException(error);
  })

异步操作异常

在异步操作中的异常也不能被自动捕捉,我们需要手动处理:

  setTimeout(()=>{
      try {
        // do some
      } catch (err) {
        window.$Raven.captureException(err);
      }
  }, 1000)

另外,请在主动抛出的异常时使用new error进行创建,这样能更好的定位异常所在位置。

// good 
throw new error()

// bad
throw "error"

至此,本篇文章要讲的内容已经完成。

结语

Sentry其实还有很多可以挖掘的好东西,包括:

  • 集成gitlab 一键创建issue
  • 配置邮件通知
  • 配置规则,添加邮件发送条件
  • 配置版本号,为开发和线上配置不同的邮件发送规则
  • sourcemap,直接查看报错js代码片段

以上是自己发掘的一些功能,建议大家多看文档,有新发现或问题可以一起交流,后面应该会写一篇拓展版攻略。

查看原文

赞 9 收藏 9 评论 9

游龙翔隼 赞了文章 · 2018-04-12

DOM操作成本到底高在哪儿?

从我接触前端到现在,一直听到的一句话:操作DOM的成本很高,不要轻易去操作DOM。尤其是React、vue等MV*框架的出现,数据驱动视图的模式越发深入人心,jQuery时代提供的强大便利地操作DOM的API在前端工程里用的越来越少。刨根问底,这里说的成本,到底高在哪儿呢?

什么是DOM

Document Object Model 文档对象模型

什么是DOM?可能很多人第一反应就是div、p、span等html标签(至少我是),但要知道,DOM是Model,是Object Model,对象模型,是为HTML(and XML)提供的API。HTML(Hyper Text Markup Language)是一种标记语言,HTML在DOM的模型标准中被视为对象,DOM只提供编程接口,却无法实际操作HTML里面的内容。但在浏览器端,前端们可以用脚本语言(JavaScript)通过DOM去操作HTML内容。

那么问题来了,只有JavaScript才能调用DOM这个API吗?

答案是NO

Python也可以访问DOM。所以DOM不是提供给Javascript的API,也不是Javascript里的API。

PS: 实质上还存在CSSOM:CSS Object Model,浏览器将CSS代码解析成树形的数据结构,与DOM是两个独立的数据结构

浏览器渲染过程

讨论DOM操作成本,肯定要先了解该成本的来源,那么就离不开浏览器渲染。

这里暂只讨论浏览器拿到HTML之后开始解析、渲染。(怎么拿到HTML资源的可能后续另开篇总结吧,什么握握握手啊挥挥挥挥手啊,万恶的flag...)

  1. 解析HTML,构建DOM树(这里遇到外链,此时会发起请求)
  2. 解析CSS,生成CSS规则树
  3. 合并DOM树和CSS规则,生成render树
  4. 布局render树(Layout/reflow),负责各元素尺寸、位置的计算
  5. 绘制render树(paint),绘制页面像素信息
  6. 浏览器会将各层的信息发送给GPU,GPU将各层合成(composite),显示在屏幕上

1.构建DOM树

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img data-original="awesome-photo.jpg"></div>
  </body>
</html>
无论是DOM还是CSSOM,都是要经过Bytes → characters → tokens → nodes → object model这个过程。

DOM树构建过程:当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。

2.构建CSSOM树

上述也提到了CSSOM的构建过程,也是树的结构,在最终计算各个节点的样式时,浏览器都会先从该节点的普遍属性(比如body里设置的全局样式)开始,再去应用该节点的具体属性。还有要注意的是,每个浏览器都有自己默认的样式表,因此很多时候这棵CSSOM树只是对这张默认样式表的部分替换。

3.生成render树

DOM树和CSSOM树合并生成render树

简单描述这个过程:

DOM树从根节点开始遍历可见节点,这里之所以强调了“可见”,是因为如果遇到设置了类似display: none;的不可见节点,在render过程中是会被跳过的(但visibility: hidden; opacity: 0这种仍旧占据空间的节点不会被跳过render),保存各个节点的样式信息及其余节点的从属关系。

4.Layout 布局

有了各个节点的样式信息和属性,但不知道各个节点的确切位置和大小,所以要通过布局将样式信息和属性转换为实际可视窗口的相对大小和位置。

5.Paint 绘制

万事俱备,最后只要将确定好位置大小的各节点,通过GPU渲染到屏幕的实际像素。

Tips

  • 在上述渲染过程中,前3点可能要多次执行,比如js脚本去操作dom、更改css样式时,浏览器又要重新构建DOM、CSSOM树,重新render,重新layout、paint;
  • Layout在Paint之前,因此每次Layout重新布局(reflow 回流)后都要重新出发Paint渲染,这时又要去消耗GPU;
  • Paint不一定会触发Layout,比如改个颜色改个背景;(repaint 重绘)
  • 图片下载完也会重新出发Layout和Paint;

何时触发reflow和repaint

reflow(回流): 根据Render Tree布局(几何属性),意味着元素的内容、结构、位置或尺寸发生了变化,需要重新计算样式和渲染树;
repaint(重绘): 意味着元素发生的改变只影响了节点的一些样式(背景色,边框颜色,文字颜色等),只需要应用新样式绘制这个元素就可以了;
reflow回流的成本开销要高于repaint重绘,一个节点的回流往往回导致子节点以及同级节点的回流;

GoogleChromeLabs 里面有一个csstriggers,列出了各个CSS属性对浏览器执行Layout、Paint、Composite的影响。

引起reflow回流

现代浏览器会对回流做优化,它会等到足够数量的变化发生,再做一次批处理回流。
  1. 页面第一次渲染(初始化)
  2. DOM树变化(如:增删节点)
  3. Render树变化(如:padding改变)
  4. 浏览器窗口resize
  5. 获取元素的某些属性:
    浏览器为了获得正确的值也会提前触发回流,这样就使得浏览器的优化失效了,这些属性包括offsetLeft、offsetTop、offsetWidth、offsetHeight、 scrollTop/Left/Width/Height、clientTop/Left/Width/Height、调用了getComputedStyle()或者IE的currentStyle

引起repaint重绘

  1. reflow回流必定引起repaint重绘,重绘可以单独触发
  2. 背景色、颜色、字体改变(注意:字体大小发生变化时,会触发回流)

优化reflow、repaint触发次数

  • 避免逐个修改节点样式,尽量一次性修改
  • 使用DocumentFragment将需要多次修改的DOM元素缓存,最后一次性append到真实DOM中渲染
  • 可以将需要多次修改的DOM元素设置display: none,操作完再显示。(因为隐藏元素不在render树内,因此修改隐藏元素不会触发回流重绘)
  • 避免多次读取某些属性(见上)
  • 将复杂的节点元素脱离文档流,降低回流成本

为什么一再强调将css放在头部,将js文件放在尾部

DOMContentLoaded 和 load

  • DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片...
  • load 事件触发时,页面上所有的DOM,样式表,脚本,图片都已加载完成

CSS 资源阻塞渲染

构建Render树需要DOM和CSSOM,所以HTML和CSS都会阻塞渲染。所以需要让CSS尽早加载(如:放在头部),以缩短首次渲染的时间。

JS 资源

  • 阻塞浏览器的解析,也就是说发现一个外链脚本时,需等待脚本下载完成并执行后才会继续解析HTML

  • 普通的脚本会阻塞浏览器解析,加上defer或async属性,脚本就变成异步,可等到解析完毕再执行

    • async异步执行,异步下载完毕后就会执行,不确保执行顺序,一定在onload前,但不确定在DOMContentLoaded事件的前后
    • defer延迟执行,相对于放在body最后(理论上在DOMContentLoaded事件前)

举个栗子

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img data-original="awesome-photo.jpg"></div>
    <script data-original="app.js"></script>
  </body>
</html>

  • 浏览器拿到HTML后,从上到下顺序解析文档
  • 此时遇到css、js外链,则同时发起请求
  • 开始构建DOM树
  • 这里要特别注意,由于有CSS资源,CSSOM还未构建前,会阻塞js(如果有的话)
  • 无论JavaScript是内联还是外链,只要浏览器遇到 script 标记,唤醒JavaScript解析器,就会进行暂停 blocked 浏览器解析HTML,并等到 CSSOM 构建完毕,才执行js脚本
  • 渲染首屏(DOMContentLoaded 触发,其实不一定是首屏,可能在js脚本执行前DOM树和CSSOM已经构建完render树,已经paint)

首屏优化Tips

说了这么多,其实可以总结几点浏览器首屏渲染优化的方向
  • 减少资源请求数量(内联亦或是延迟动态加载)
  • 使CSS样式表尽早加载,减少@import的使用,因为需要解析完样式表中所有import的资源才会算CSS资源下载完
  • 异步js:阻塞解析器的 JavaScript 会强制浏览器等待 CSSOM 并暂停 DOM 的构建,导致首次渲染的时间延迟
  • so on...

知道操作DOM成本多高了吗?

其实写了这么多,感觉偏题了,大量的资料参考的是chrome开发者文档。感觉js脚本资源那块还是有点乱,包括和DOMContentLoaded的关系,希望大家能多多指点,多多批评,谢谢大佬们。

操作DOM具体的成本,说到底是造成浏览器回流reflow和重绘reflow,从而消耗GPU资源。

参考文献:

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/

已同步至个人博客-软硬皆施
Github 欢迎star :)
查看原文

赞 120 收藏 235 评论 23

游龙翔隼 赞了文章 · 2018-04-12

Vue.js最佳实践(五招让你成为Vue.js大师)

本文面向对象是有一定Vue.js编程经验的开发者。如果有人需要Vue.js入门系列的文章可以在评论区告诉我,有空就给你们写。

对大部分人来说,掌握Vue.js基本的几个API后就已经能够正常地开发前端网站。但如果你想更加高效地使用Vue来开发,成为Vue.js大师,那下面我要传授的这五招你一定得认真学习一下了。

第一招:化繁为简的Watchers

场景还原:

created(){
    this.fetchPostList()
},
watch: {
    searchInputValue(){
        this.fetchPostList()
    }
}

组件创建的时候我们获取一次列表,同时监听input框,每当发生变化的时候重新获取一次筛选后的列表这个场景很常见,有没有办法优化一下呢?

招式解析:
首先,在watchers中,可以直接使用函数的字面量名称;其次,声明immediate:true表示创建组件时立马执行一次。

watch: {
    searchInputValue:{
        handler: 'fetchPostList',
        immediate: true
    }
}

第二招:一劳永逸的组件注册

场景还原:

import BaseButton from './baseButton'
import BaseIcon from './baseIcon'
import BaseInput from './baseInput'

export default {
  components: {
    BaseButton,
    BaseIcon,
    BaseInput
  }
}
<BaseInput
  v-model="searchText"
  @keydown.enter="search"
/>
<BaseButton @click="search">
  <BaseIcon name="search"/>
</BaseButton>

我们写了一堆基础UI组件,然后每次我们需要使用这些组件的时候,都得先import,然后声明components,很繁琐!秉持能偷懒就偷懒的原则,我们要想办法优化!

招式解析:
我们需要借助一下神器webpack,使用 require.context() 方法来创建自己的(模块)上下文,从而实现自动动态require组件。这个方法需要3个参数:要搜索的文件夹目录,是否还应该搜索它的子目录,以及一个匹配文件的正则表达式。

我们在components文件夹添加一个叫global.js的文件,在这个文件里借助webpack动态将需要的基础组件统统打包进来。

import Vue from 'vue'

function capitalizeFirstLetter(string) {
  return string.charAt(0).toUpperCase() + string.slice(1)
}

const requireComponent = require.context(
  '.', false, /\.vue$/
   //找到components文件夹下以.vue命名的文件
)

requireComponent.keys().forEach(fileName => {
  const componentConfig = requireComponent(fileName)

  const componentName = capitalizeFirstLetter(
    fileName.replace(/^\.\//, '').replace(/\.\w+$/, '')
    //因为得到的filename格式是: './baseButton.vue', 所以这里我们去掉头和尾,只保留真正的文件名
  )

  Vue.component(componentName, componentConfig.default || componentConfig)
})

最后我们在main.js中import 'components/global.js',然后我们就可以随时随地使用这些基础组件,无需手动引入了。

第三招:釜底抽薪的router key

场景还原:
下面这个场景真的是伤透了很多程序员的心...先默认大家用的是Vue-router来实现路由的控制。
假设我们在写一个博客网站,需求是从/post-page/a,跳转到/post-page/b。然后我们惊人的发现,页面跳转后数据竟然没更新?!原因是vue-router"智能地"发现这是同一个组件,然后它就决定要复用这个组件,所以你在created函数里写的方法压根就没执行。通常的解决方案是监听$route的变化来初始化数据,如下:

data() {
  return {
    loading: false,
    error: null,
    post: null
  }
}, 
watch: {
  '$route': {
    handler: 'resetData',
    immediate: true
  }
},
methods: {
  resetData() {
    this.loading = false
    this.error = null
    this.post = null
    this.getPost(this.$route.params.id)
  },
  getPost(id){

  }
}

bug是解决了,可每次这么写也太不优雅了吧?秉持着能偷懒则偷懒的原则,我们希望代码这样写:

data() {
  return {
    loading: false,
    error: null,
    post: null
  }
},
created () {
  this.getPost(this.$route.params.id)
},
methods () {
  getPost(postId) {
    // ...
  }
}

招式解析:

那要怎么样才能实现这样的效果呢,答案是给router-view添加一个unique的key,这样即使是公用组件,只要url变化了,就一定会重新创建这个组件。(虽然损失了一丢丢性能,但避免了无限的bug)。同时,注意我将key直接设置为路由的完整路径,一举两得。

<router-view :key="$route.fullpath"></router-view>

第四招: 无所不能的render函数

场景还原:
vue要求每一个组件都只能有一个根元素,当你有多个根元素时,vue就会给你报错

<template>
  <li
    v-for="route in routes"
    :key="route.name"
  >
    <router-link :to="route">
      {{ route.title }}
    </router-link>
  </li>
</template>


 ERROR - Component template should contain exactly one root element. 
    If you are using v-if on multiple elements, use v-else-if 
    to chain them instead.

招式解析:
那有没有办法化解呢,答案是有的,只不过这时候我们需要使用render()函数来创建HTML,而不是template。其实用js来生成html的好处就是极度的灵活功能强大,而且你不需要去学习使用vue的那些功能有限的指令API,比如v-for, v-if。(reactjs就完全丢弃了template)

functional: true,
render(h, { props }) {
  return props.routes.map(route =>
    <li key={route.name}>
      <router-link to={route}>
        {route.title}
      </router-link>
    </li>
  )
}

第五招:无招胜有招的高阶组件

划重点:这一招威力无穷,请务必掌握
当我们写组件的时候,通常我们都需要从父组件传递一系列的props到子组件,同时父组件监听子组件emit过来的一系列事件。举例子:

//父组件
<BaseInput 
    :value="value"
    label="密码" 
    placeholder="请填写密码"
    @input="handleInput"
    @focus="handleFocus>
</BaseInput>

//子组件
<template>
  <label>
    {{ label }}
    <input
      :value="value"
      :placeholder="placeholder"
      @focus=$emit('focus', $event)"
      @input="$emit('input', $event.target.value)"
    >
  </label>
</template>

有下面几个优化点:

1.每一个从父组件传到子组件的props,我们都得在子组件的Props中显式的声明才能使用。这样一来,我们的子组件每次都需要申明一大堆props, 而类似placeholer这种dom原生的property我们其实完全可以直接从父传到子,无需声明。方法如下:

    <input
      :value="value"
      v-bind="$attrs"
      @input="$emit('input', $event.target.value)"
    >
   

$attrs包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定,并且可以通过 v-bind="$attrs" 传入内部组件——在创建更高层次的组件时非常有用。

2.注意到子组件的@focus=$emit('focus', $event)"其实什么都没做,只是把event传回给父组件而已,那其实和上面类似,我完全没必要显式地申明:

<input
    :value="value"
    v-bind="$attrs"
    v-on="listeners"
>

computed: {
  listeners() {
    return {
      ...this.$listeners,
      input: event => 
        this.$emit('input', event.target.value)
    }
  }
}

$listeners包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件——在创建更高层次的组件时非常有用。

3.需要注意的是,由于我们input并不是BaseInput这个组件的根节点,而默认情况下父作用域的不被认作 props 的特性绑定将会“回退”且作为普通的 HTML 特性应用在子组件的根元素上。所以我们需要设置inheritAttrs:false,这些默认行为将会被去掉, 以上两点的优化才能成功。


结尾

掌握了以上五招,你就能在Vue.js的海洋中自由驰骋了,去吧少年。
陆续可能还会更新一些别的招数,敬请期待。

如果你对VueJs或者ReactJs的虚拟DOM是如何实现的感兴趣,可以去看我的这个系列文章:
【React进阶系列】从零开始手把手教你实现一个Virtual DOM(一)
【React进阶系列】从零开始手把手教你实现一个Virtual DOM(二)
【React进阶系列】从零开始手把手教你实现一个Virtual DOM(二)

标题虽然是React进阶,但事实上Vuejs从2.0开始也使用了虚拟DOM,虽然你仍然可以使用Vuejs的模板template写代码,但本质上template最后也会complile成虚拟DOM。(官方文档Vuejs 渲染函数 & JSX)。

广告时间:阿里巴巴电商板块增速最快的新业务之一内推招人,请大家多多推荐或自荐,内部推荐优先面试,技术面试一周内出结果。可以私信我了解详情,或者直接把简历发到我邮箱:zeqiang.wang@alibaba-inc.com

查看原文

赞 652 收藏 1216 评论 47

游龙翔隼 赞了文章 · 2018-04-12

前端静态资源缓存最优解以及max-age的陷阱

前端静态资源缓存最优解以及max-age的陷阱

合理的使用缓存可以极大地提高网站的性能优势,还可以节约带宽从而降低服务器成本。但是很多站点有只弄对了一半或者一半都没有,如果是这样,就完全没有发挥出缓存的优势。很大程度上产生会由于静态资源的竞争关系而导致依赖的静态资源不同步。

以下为两个最佳静态资源缓存实践的例子。

一、资源内容不变 + 设置长时间max-age

// 设置缓存时间为1年
Cache-Control: max-age=31536000
  • 资源的内容不会更改,所以。。。
  • 浏览器/CDN可以缓存一年时间,在这期间资源不会出现问题。
  • 可以在不请求服务器的请情况下,一年内都使用缓存内容。

第一天

浏览器请求了/index-v1.js/base-v1.css以及/dog-v1.png这三个资源。

图片描述

第二天

这次浏览器请求了/index-v2.js/base-v2.css以及/dog-v1.png这三个资源。

此处注意:index.jsbase.css与第一天请求的版本号不同。

图片描述

过了一年

在一年的时间里,浏览器再也没有请求过/index-v1.js/base-v1.css以及/dog-v1.png这三个资源,浏览器缓存就会把它们给删掉。

图片描述

所以在这个例子中,为了让缓存发挥最大效率,你要做的并不是更改文件的内容,而是应该更改资源的URL:

<script data-original="/index-v3.js"></script>
<link rel="stylesheet" href="/base-v3.css">
<img data-original="/dog-v3.jpg" alt="…">

每一个静态资源URL都应该跟随其内容的修改而改变。例如示例index-v1.js中的v1,你对它的命名不需要有任何限制。它可以是一个版本号最后修改的日期,或者根据内容计算出来的散列值

绝大多数服务器端的框架都提供了工具来实现这一点,同样的在nodejs中有很多优秀的库来实现这个功能,比如gulp-revwebpackfis3


二、对于经常修改的内容,始终需要进行服务器认证

Cache-Control: no-cache
  • 该URL下资源的内容可能经常修改,所以。。。
  • 没有服务器的确认,任何本地缓存的版本内容都是不可信的。

第一天

图片描述

第二天

图片描述

注意:
no-cache并不意味着不缓存。它的意思是在使用缓存资源之前,它必须经过服务器的检查(revalidate也可以实现这个功能)。
no-store才是告诉浏览器不要缓存它。此外,must-revalidate并不意味着必须重新认证,它的前提是资源还在max-age的缓存期内,否则必须重新认证。

在此模式下 ,你也可以将ETag(你选择的版本ID)或者Last-modified日期添加到响应首部中。客户端下次获取资源时,他会分别通过If-None-Match(与ETage对应)和If-Modified-Since(与Last-Mofied对应)两个请求首部将值发送给服务器。如果服务器发现两次值都是对等的,就是返回一个HTTP 304

如果没有发送ETagLast-Modified,那么服务器将始终返回完整的资源内容。

但是这种方法有个缺点,就是它每次都会去服务器做一次验证,涉及到了网络提取,所以它不如第一个例子那样可以完全绕过网络。


三、在经常修改内容的静态资源上使用max-age是个错误的选择

这种情况并不少见,例如它就实实在在地发生在了github的页面上。

想象一下 :

  • /article/
  • /styles.css
  • /script.js

它们全部使用的是:

// 十分钟内不需要重新认证,超过十分钟就需要重新认证
Cache-Control: must-revalidate, max-age=600
  • 随着内容的修改,URLs发生改变
  • 在十分钟内,浏览器将会一直使用缓存住的内容,而不会去服务器请求最新的资源 。
  • 超过十分钟,在可用的前提下使用If-Modified-SinceIf-None-Match重新进行服务器认证。

第一次请求:

图片描述

六七分钟过后:

图片描述

最终:

图片描述

这种情况在测试中经常出现。但是想象一下,在线上环境你永远不知道浏览器前面坐着的是什么样的人,他很有可能无意中胡乱地用鼠标点点点,就打乱了浏览器的静态资源缓存机制,导致页面发生了错乱,而且真的很难追踪。

在上面的例子中,服务器实际上已经更新了HTML、CSS和JS,但是页面最后使用的是缓存中旧的HTML和JS,以及刚从服务器下载的最新的CSS。多个静态资源版本之间不匹配的问题随之出现。

通常,当我们对HTML进行重大修改时,我们可能会更改CSS文件来适配新的DOM结构,并且更新JS来配置样式和DOM的修改。这些资源都是相互依赖的,但携带缓存信息的HTTP首部可不管你这些有的没的。最终,用户很有可能会得到一个/两个静态资源新版本,而其他资源都是旧版本。

max-age是相对于服务器响应时间的,所以如果所有上述资源都在同一时间请求,即便它们都被设置为了相同的max-age时长,它们仍然存在很小的竞争可能性(毕竟有的资源先返回有的资源后返回)。如果你的某些页面不包含JS,或者包含不同的CSS,它们的缓存失效时间就有可能会不同步。更恶心的是,浏览器始终会从缓存中删除和获取资源,它并不知道这些资源中哪个是相互依赖的,只要过了缓存时间它就会毫不犹豫地删掉一个,并不会删掉这个过期文件所依赖的其他资源。把上面的种种可能性加在一起,就会大概率出现静态资源版本不匹配的问题。

不过还好,我们还有法子来解决这个问题:

强制刷新浏览器或者清除缓存

在强制刷新浏览器或者清除缓存后,请求的页面以及页面内的所有资源会忽略之前的max-age,去服务器做重新认证。因此,如果用户由于max-age出现问题之后,只需要强制刷新或者清缓存就可以修复问题。当然,强迫用户这样做只会让它们降低对你网站的信任度,认为你的网站不靠谱。。。

使用serviceWorker减少这种错误的出现几率

service Worker的执行时机:

图片描述

注册serviceWorker:

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/serviceworker.js', {
    scope: '/'
  });
}

执行serviceworker.js:

const version = '2';

self.addEventListener('install', event => {
  // 由于系统会随时睡眠SW,所以,为了防止执行中断,就需要使用 event.waitUntil 进行捕获
  event.waitUntil(
    caches.open(`static-${version}`)
    .then(cache => cache.addAll(
        // 不稳定文件或者大文件加载
        //...
      ), cache.addAll([
      // 稳定文件或小文件加载
      '/styles.css',
      '/script.js'
    ]));
  );
});

self.addEventListener('activate', event => {
  // …delete old caches…
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
    .then(response => response || fetch(event.request))
  );
});
  • 将script和styles缓存起来。
  • 如果有匹配到的缓存就从缓存中获取,如果没有就从服务器获取。

如果我们修改了JS/CSS,只需修改version就可以让service worker触发更新。

你也可以在service worker中跳过缓存:

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(`static-${version}`)
        .then(cache => cache.addAll([
            new Request('/styles.css', {
                cache: 'no-cache'
            }),
            new Request('/script.js', {
                cache: 'no-cache'
            })
        ]))
    );
});

不过很不巧的是,cache选项在和safari和opera中都不支持 ,只有firefox和chrome最近才开始支持。但是你可以这样做:

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(`static-${version}`)
        .then(cache => Promise.all(
            [
                '/styles.css',
                '/script.js'
            ].map(url => {
                // cache-bust using a random query string
                return fetch(`${url}?${Math.random()}`).then(response => {
                    // fail on 404, 500 etc
                    if (!response.ok) throw Error('Not ok');
                    return cache.put(url, response);
                })
            })
        ))
    );
});

你可以使用上面代码中的随机字符串,也可以使用散列值。这有点像在javascript中实现文章刚开始第一小节的方法,不过仅仅是在server worker中使用。


四、service worker和HTTP cache也可以很好的共存

通过上个的例子,你可以看到service worker可以很好的处理一些糟糕的缓存情况。但是仅仅是做一些hack处理而已,最重要的是再根源上解决问题。正确的使用缓存不仅可以更好地使用service worker,还可以很好地在那些不支持service worker的浏览器(IE/Safari/Opera)上提高网站的性能。除此之外,对你的CDN也是大有益处。

正确的使用缓存,可以大量简化service worker的代码:

const version = '23';

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(`static-${version}`)
        .then(cache => cache.addAll([
            '/',
            '/script-v3.js',
            '/styles-v3.css',
            '/dog-v3.jpg'
        ]))
    );
});

所以,我们可以使用第二小节的方法(服务器重新认证)来缓存根HTML页面。并使用第一小节的方法(不同的内容使用不同的URL)来缓存其他资源。每次service worker更新世都会去请求网站的根HTML页面,其他资源只有在更改URL时才会去下载,从而提高网站的性能。

虽然service worker擅长提高网站的性能,但它并不是一个完整的解决方案。因此要和HTTP cache配合使用才可以显著地提高性能。


五、max-age和『内容经常修改但是URL不变的静态资源』搭配使用

在内容经常修改但是URL不变的静态资源上使用max-age在通常意义上来说不是一个好点子,但事实却不总是如此。

假如一个页面的max-age为三分钟,并且在这个页面上不需要考虑静态资源的竞争关系(静态资源之间存在相互依赖,见第三小节),所以在这个页面上不存在任何的静态资源依赖。在这种情况下就可以尽情使用max-age。不过这也意味着网站的修改要再三分钟之后才可以被看到。

不过要是页面存在静态资源竞争关系的话,这种法子不好用了,比如我现在有两个文章A和B,我现在文章A中添加一个新的章节,然后在文章B中增加了一个指向文章A新增章节的超链接。然后我从文章B中访问这个链接,假如文章A的max-age没有过期,那么我访问到的文章A里将会发现文章并没有那个新增的章节。此时只能等max-age过期或者强制刷新浏览器,再或者清除缓存了。所以,一定要谨慎使用这种方法。

正确使用缓存可以代理巨大的性能收益并且有效节省服务器带宽。既支持版本号类型的静态资源缓存方式也支持服务器重新认证(no-cache、304)的方式。如果你觉得自己很勇敢,那么大可混合使用max-age和『内容经常修改但是URL不变的静态资源』,但是前提你得确定自己的HTML中没有静态资源竞争关系。

查看原文

赞 92 收藏 240 评论 2