我是怎么研究Polyfill的

2,717 阅读9分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!


背景

Polyfill是什么

众所周知,现在的web网站是不兼容ie678的,这辈子是不可能兼容。啊,,,真香。

好吧,兼容一下也没什么大不了的。无非就是干练的箭头函数不能用,骚气的迭代器不能用,promise不能用,async await不能用,import不能用,连个Array.map,Array.filter。。。都不是说想用就能用。于是一切代码都要从10年前说起。

作为一个有追求的程序员,肯定不能被现实打败,于是各种各样的hack方式如雨后春笋般出现了。比如

window.Promise = (function(window){
  if(window.Promise){
		return window.Promise
	}else{
  	window.Promise = function(){  } // 兼容代码
  }
})(window)

这样的代码是在工程目录中随处可见,有了这样的兼容代码,我们敲起一些骚操作,黑科技那是一个得心应手。像这样的代码就叫做polyfill。

世界上本来没有路,走的人多了,便有了路

鲁迅先生到底是有水平,一语中的。在项目中这样引入polyfill毕竟不是长久之计,于是babel出现了。

babel是一个被广泛使用的转码器。它可以自动帮我们把ES6的代码转码成指定版本的js。比如

// 转码前
input.map(item => item + 1);

// 转码后
input.map(function (item) {
  return item + 1;
});

但是babel默认是只是转化js的句法,而不是会转换新的API,比如Iterator、Generator、Promise等,因此需要使用babel提供的polyfill来去编译这些语法。

下面是引入polyfill进入项目的几种方法

@babel/polyfill

在项目中引入

require("babel-polyfill") 
// or
import 'babel-polyfill'

会一次性解决所有的兼容问题,并且会优先挂载到全局对象上,但是这样也会引入了所有的polyfill,打包后的js会增大很多。对于支持特性的浏览器也会造成浪费,不适合用于开发框架或者库。

@babel/preset-env

使用babel-preset-env插件和useBuiltIns属性,这种方法主要是在package.json中配置

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["ie >=9"]
      },
      "useBuiltIns": true,
      "debug": true
    }]
  ]
}

这样做的好处是,可以指定浏览器的版本号,并且最重要的是使用了useBuiltIns属性,来确保项目只会引入需要的polyfill而不会将全量的打包到项目中,很明显比上一中好多了。

polyfill.io

该方法是使用cdn的方式将所需要的特性已脚本的方式引入项目中,可以根据用户浏览器的ua和连接的参数来动态判断是否需要polyfill。

polyfill.io/v2/polyfill…

这个相对于上面两种方法更优雅的地方在于,不需要讲polyfill打包到自己的项目中,并且会更加精确的使用polyfill而不会浪费用户的性能,当然缺点是你必须了解自己所做的框架和库中,用到的API。

调研

根据上面对于polyfill的比较我们这次主要是研究一下第三种使用cdn的方式。

上面的是polyfill提供的cdn下面是阿里提供的cdn

polyfill.alicdn.com/static/sdk?…

我们可以直接使用这些cdn来用于公司或者个人项目中,但是这样的风险无异于会增大很多:

依赖于第三方服务的稳定

依赖于第三方的代码

基于这两点我们还是有必要调研一下如果在公司基本部署一个自己的polyfill service。

polyfill-service

根据polyfill.io/v3/

以及在本地基于github.com/Financial-T…项目搭建了一个服务

也可以通过了自由选择生成固定的代码。

经过调研上面代码发现有如下问题

  • node提供后端服务

  • 代码结构太过复杂

  • 引入外部链接太多

  • 引入太多不熟悉的工具(terraform, vcl, njk...)

  • 为之后维护成本和安全成本增加负担

代码分析

根据上面总结另外经过调研代码发现其服务虽然复杂,但是核心库还是在server文件夹下。也就是所有的主要逻辑都在里面,于是我们进行简单分析。

其中启动服务或者一些健康检查和进程守护都可以先忽略,直接看router中的v3版本的polyfill是如何处理的

原来所有的polyfill和polyfill.min的路由返回都在polyfillio包里了。于是我们跳过一起重重阻碍,直接进入这个包进行查看详情

polyfill-library

这个包的github项目在

github.com/financial-t…

从readme可以看出这个库提供的api只有5个可以说是非常简洁了。再看项目组织结构

项目入口文件在lib/index.js可以看到项目导出这就是这几个方法。另外polyfills里面项目太多,就不一一展开。另外在lib的另外三个文件都是工具辅助文件,所以也就不一一介绍了,下面就主要大致介绍index主要的方法

index.js

这个里面的文件核心函数就两个getPolyfills, getPolyfillString

所以这个也主要讲解着两个

简单用两个链路来表示一下这两个函数

  • getPolyfill

处理参数 → 获取当前浏览器ua → 查看缓存是否有ua缓存 → 获取并整理feature的依赖别名 → 将获取的源码进行按照UA对照过滤 → 进行Excludes对照过滤 → 对于系统使用的方法进行过滤 → 将所有剩下的feature对象返回去

  • getPolyfillString

处理参数,声明提示变量 → 调用getPolyfill来获取用户所需要的feature对象 → 根据feature所依赖的name进行分组(featureNodes, featureEdges, unknown...) → 为feature增加comment属性 → 对于分组后的进行排序去重 → 根据是否使用minify分别加载相应头部 → 遍历所有依赖 → 根据feature的gated参数判断是否加载属性判断 → 根据minify加载对应的源码 → 加载结尾代码 → 对应features设置成all的说明 → 处理还有callback参数的回调 → 返回所有的字符

根据上面介绍,在看polyfills里面的目录截图

可以看到每一个feature对象都有三个问题,

其中min是压缩好有的兼容代码,raw是完整的代码。meta是文件的所有说明。

这是其中的一个meta说明文件,browsers就是浏览器的兼容状况,所有的浏览器的UA匹配在

这个第三方包里,而项目的缓存就是根据

这样的缓存来节省一些性能比较cache则是使用的mnemonist/lru-cache工具。

开发

上面是对于polyfill服务器所需要的library一点简单的调研,那我们真正的是想做一个什么样的架构呢。

上面是我简单画了一个服务架构图,其中LB和kube不是这次的重点,我们主要是从nginx和nodejs上进行处理。

所以我们需要做两个功能

  1. nodejs服务器,用于解析url参数和用户UA来动态返回polyfill代码

  2. nginx根据url和UA来缓存已经计算过的代码减轻node服务器的压力

koa2

在nodejs中完成第一条功能并不复杂,在著名的nodejs的web框架中也有两个佼佼者,express和koa,本篇并不是比较两个框架或者讲解nodejs的web框架,因此我们这次就简单的使用了koa框架作为开发。

在工作目录中直接安装

npm init
npm install koa
yarn add koa
yarn add koa-bodyparser koa-router lodash moment polyfill-library

配置一下package.json和写一些app.js

const Koa = require('koa')
const app = new Koa()
const port = process.env.PORT || 3344

app.use(require('koa-bodyparser')())

const router = require('./routers/index.js') // 这里router在下面有介绍

app.use(router.routes())
app.use(router.allowedMethods())

app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})



"scripts": {
    "start": "NODE_ENV=production node ./app.js",
    "test": "NODE_ENV=test node ./app.js",
    "dev": "NODE_ENV=development node ./app.js",
    "lint": "eslint --ext .js ."
  },

直接yarn可以启动一下服务

router

上面启动好服务后我们需要思考一下router的设计,根据polyfill-library给我们提供了参数设置,我们目前只做一个最简单版本的polyfill服务。

  1. 使用polyfill.min.js返回压缩版本的js,使用polyfill.js返回未压缩的js
  2. 在路径后面跟feature=Array.of&feature=set来确定要得到的polyfill特性

在koa的router设定中我们采取约定大于配置原则,如图,所有的router在文件夹下,index作为出口

index.js的代码如下

const fs = require('fs')
const path = require('path')
const Router = require('koa-router')
const router = new Router()
const ROUTERS_PATH = './routers/'

function useRouter (customerRouters, controllerPath, prefix = '') {
  Object.keys(customerRouters).forEach(key => {
    let item = customerRouters[key]
    let method = item.method
    let action = item.action
    let uri = item.ignoreActionName ? `${prefix}` : `${prefix}/${action.name}`
    
    router[method](uri, async (ctx) => { 
      console.log(
        'make router succeed.',
        {
          'httpMethod': method,
          'routerPath': uri,
          'filePath': controllerPath,
          'actionName': action.name
        }
      )
      await action(ctx) 
    })
  })
}

function readAllRouterFileRecursive (routersPath = path.resolve(ROUTERS_PATH), prefix = '') {
  let list = fs.readdirSync(routersPath).filter(item => item !== 'index.js')

  list.forEach(item => {
    let resolvePath = routersPath + '/' + item
    let stat = fs.statSync(resolvePath)
    let isDir = stat.isDirectory()
    let isFile = stat.isFile()

    if (isDir) {
      readAllRouterFileRecursive(resolvePath, `${prefix}/${item}`)
    } else if (isFile) {
      useRouter(require(resolvePath), resolvePath, `${prefix}/${item}`)
    }
  })
}

readAllRouterFileRecursive()

module.exports = router

核心就是读取当前目录下,然后把文件夹名称作为路径,然后返回action里面的函数,因此polyfill.js和polyfill.min.js的里面都是如下配置

const generatorPolyfill = require('../lib/generatorPolyfill')
module.exports = [
  {
    method: 'get',
    ignoreActionName: true,
    action: async (ctx, next) => {
      const result = await generatorPolyfill(ctx, '')
      ctx.body = result
    }
  }
]



const generatorPolyfill = require('../lib/generatorPolyfill')
module.exports = [
  {
    method: 'get',
    ignoreActionName: true,
    action: async (ctx, next) => {
      const result = await generatorPolyfill(ctx, 'min')
      ctx.body = result
    }
  }
]

区别只是在generatorPolyfill的时候是否选择min参数。

在generatorPolyfill.js中的代码是

const polyfillLibrary = require('polyfill-library')
module.exports = async (ctx, isMin) => {
  const { request, query } = ctx
  const { features } = query
  const { header } = request
  let fkey = []
  if (typeof features === 'string') {
    fkey.push(features)
  } else if (Array.isArray(features)) {
    fkey = [].concat(features)
  }
  let paramsFeatures = fkey.reduce((result, item) => {
    result[item] = {
      flags: ['gated']
    }
    return result
  }, {})
  // const uaString = 'Mozilla/4.0 (compatible; MSIE 5.0; Windows NT 6.1; Trident/4.0)'
  const uaString = header['user-agent']
  console.log('uaString----------------->')
  console.log(uaString)
  console.log('<-------------end uaString')
  console.log(paramsFeatures)
  const result = await polyfillLibrary.getPolyfillString({
    uaString: uaString,
    minify: isMin === 'min',
    features: paramsFeatures
  })
  fkey = []
  paramsFeatures = {}
  return result
}

根据用户的user-agent和路径的参数去getPolfillString然后返回回去,也是比较好理解,这些配置好后,我们就可以访问了。

根据上面几次访问的测试,服务正常运行。

nginx

在nodejs这边服务正常运行后,我们还是要考虑另外一个问题了,当每次同样的ua,同样的feature返回的数据都是一样的,因此是不是再nodejs服务的前面一层nginx那边去缓存这些数据,事实上我们也是这样做的,我们首先看一下nginx配置,假设我们使用了一个www.test.com去代理了localhost:3344那么nginx的配置是

proxy_cache_path /data/nginx/cache/polyfill keys_zone=polyfill:10m use_temp_path=off;
server {
		listen 80;
		server_name test.com;
    location ~ ^/polyfill.*\.js {
      proxy_cache polyfill;
      proxy_cache_key "$request_uri$http_user_agent";
      proxy_ignore_headers Cache-Control;
      proxy_cache_min_uses 1;
      proxy_cache_valid  200 206 304 301 302 1y;
      add_header X-Cache-Status $upstream_cache_status;  #添加此行
      proxy_pass http://127.0.0.1:3344;
    }
}

然后修改一下 hosts配置

127.0.0.1 test.com

这样就可以正常访问了

我们来验证一下缓存的效果

每次访问node就会打印出日志

但是当我们配置好后刷新第二次的时候,也就是模拟两次一样的请求,那么node就不会打印日志,也就是请求在nginx那么就会命中了缓存(// TODO 这里确实动图)

这样一个完整的部署流程就完成了,但是nodejs服务还没有完。

github.com/wuyxp/polyf…

部署

服务完成后部署可以另起一项,因为这里涉及到,nodejs进程守护,日志收集,服务监控等,我们有缘再见吧。

参考

www.ruanyifeng.com/blog/2016/0…

www.babeljs.cn/docs/babel-…

www.thebasement.be/working-wit…

juejin.cn/post/684490…

www.jianshu.com/p/3b27dfc67…

yq.aliyun.com/articles/29…

cbutech.net/index.php/a…

www.helplib.com/GitHub/arti…