酱菜

酱菜 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 www.zouhaohao.xyz 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

酱菜 赞了文章 · 9月6日

读《全局作用域中,用const和let声明的变量去哪了?》

输入

全局作用域中,用const和let声明的变量去哪了?

问题

各位大佬,问个问题,let、const声明的变量,暴露在全局,为什么没挂载到window下?究竟挂载到哪里去了?

输出

我们打开控制台,输入

const a = 123;
function abcd() {
    console.log(a);  // abcd函数的作用域能访问到a
};
dir(abcd);

可以在方法的[[Scopes]] 属性中,看到变量a

图片描述

const、let 这类都是,属于声明性环境记录,“Declarative Environment Records” ,和函数、类这些一样,在单独的存储空间。
var这类,属于对象性环境记录,“object environment record”,会挂载到某个对象上,也会沿着原型链去向上查找

说明const、let声明变量不挂载到对象上,但是在全局的活动对象中能访问到let、const 声明记录,也就是作用域链那边没问题

但是不是全局window对象的属性,所以window.a访问不到

查看原文

赞 2 收藏 1 评论 0

酱菜 赞了回答 · 6月16日

解决计算机专业的大三学生,对未来工作很迷茫,我该钻研点啥?

建议楼主可以忽略大多调侃性质的回答,略多人有时候会是黑色幽默一下

我还是来认真回答一下,希望对你有帮助。我是SegmentFault 的联合创始人和CEO。 我接触互联网很晚,2007年才有的自己第一个QQ号,但是我想还是兴趣驱动为主。我高中没考上大学,自考大学后,因讨厌中国的教育,在3个月后选择了退学。

我想说的一点就是,要不断的学习,我在2009年带着身上500快钱到北京的时候,对互联网工作一无所知,当时那一年,我几乎每天都是两点以后睡觉,我都是在干嘛呢?就是大量的阅读,看科技博客,体验不同的产品,去认识这个圈子里比自己更牛的人!

找到自己喜欢做的事情,然后努力学习,并且坚持下去就好!

如果以后想从事编程工作,就踏实多去参与学习一些开源项目,瞄准1-2门编程语言深入学习下去!
如果发现编程不是自己的菜,可以尝试去做互联网运营、产品等方向的工作去努力!

学会更好的利用互联网去学习!Learn from anything

  • Google、电子书(豆瓣读书)、公开课、TED等
  • 人肉(信息检索与连接)搜索能力
  • 乐于帮助别人,Get不同人的技能
  • 节点信息的整合(人、 领域 、组织(校友、商学院、公司)信息、事件)
  • 让自己成为T型人才(工具、社交网络、搜索引擎、人)

关注 7 回答 17

酱菜 赞了文章 · 2019-10-14

前端工程师学Docker ? 看这篇就够了 【原创精读】

前端工程师,为什么要学习Docker ?

传统的虚拟机,非常耗费性能

Docker可以看成一个高性能的虚拟机,并且不会浪费资源,主要用于Linux环境的虚拟化,类似VBox这种虚拟机,不同的是Docker专门为了服务器虚拟化,并支持镜像分享等功能。前端工程师也可以用于构建代码等等

目前看,Dokcer不仅带火了GO语言,还会持续火下去

首先,我们看看传统的虚拟机和Docker的区别

传统的虚拟机:

Docker:

可以看到,传统的虚拟机是每开一个虚拟机,相当于运行一个系统,这种是非常占用系统资源的,但是Docker就不会。但是也做到了隔离的效果

Docker容器虚拟化的优点:

  1. 环境隔离

Docker实现了资源隔离,实现一台机器运行多个容器互不影响。

  1. 更快速的交付部署

使用Docker,开发人员可以利用镜像快速构建一套标准的研发环境,开发完成后,测试和运维人员可以直接通过使用相同的环境来部署代码。

  1. 更高效的资源利用

Docker容器的运行不需要额外的虚拟化管理程序的支持,它是内核级的虚拟化,可以实现更高的性能,同时对资源的额外需求很低。

  1. 更易迁移扩展

Docker容器几乎可以在任意的平台上运行,包括乌力吉、虚拟机、公有云、私有云、个人电脑、服务器等,这种兼容性让用户可以在不同平台之间轻松的迁移应用。

  1. 更简单的更新管理

使用Dockerfile,只需要小小的配置修改,就可以替代以往的大量的更新工作。并且所有修改都是以增量的方式进行分发和更新,从而实现自动化和高效的容器管理。

正式开始

本文撰写于2019年10月13日

电脑系统:Mac OS

使用最新版官网下载的Docker

以下代码均手写,可运行

下载官网的Docker安装包,然后直接安装

https://www.docker.com/

Docker官网下载地址
安装后直接打开

打开终端命令行,输入docker,会出现以下信息,那么说明安装成功

下载安装成功后,首先学习下Docker的两个核心知识点

container(容器)和image(镜像)

Docker的整个生命周期由三部分组成:镜像(image)+容器(container)+仓库(repository

思维导图如下:

该如何理解呢?

每台宿主机(电脑),他下载好了Docker后,可以生成多个镜像,每个镜像,可以创建多个容器。发布到仓库时,以镜像为单位。可以理解成:一个容器就是一个独立的虚拟操作系统,互不影响,而镜像就是这个操作系统的安装包。想要生成一个容器,就用安装包(镜像)生成一次

上面就是Docker的核心概念,下面开始正式操作

补充一点:如果想深入Docker , 还是要去认真学习下原理,今天我们主要讲应用层面的

首先,我们回到终端命令行操作

输入:

docker images

如果你的电脑上之前有创建过的镜像,会得到如下:

如果没有的话就是空~

我们首先创建一个自己的镜像

先编写一个Node.js服务

创建index.js

// index.js
const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
   ctx.body = 'Hello docker';
});

app.listen(3000);

然后配置package.json文件

{
    "name": "app",
    "version": "1.0.0",
    "private": true,
    "scripts": {
      "start": "node server.js"
    },
    "dependencies": {
      "koa": "^2.5.0"
    }
   }
   

正常情况下 使用

npm startnode index.js 就可以启动服务
可是我们这里需要打包进Docker中,这里就需要写一个配置文件dockerfile

vsCode有天然插件支持

在目录下新建文件dockerfile,加入如下配置

FROM  node 
ADD . /app/
EXPOSE 3000
WORKDIR /app
RUN npm install
CMD ["node","./index.js"]

解释一下,上面这些配置的作用

FROM 是设置基础镜像,我们这里需要Node

ADD是将当前文件夹下的哪些文件添加到镜像中 参数是 [src,target]

这里我们使用的 . 意思是所有文件,当然跟git一样,可以配置ignore文件

EXPOSE是向外暴露的端口号

WORKDIR是说工作目录,我们这里将文件添加到的是app目录,所以配置app目录为工作目录,
这样就不用在命令行前面加/app

RUN是先要执行的脚本命令

CMD是执行的cmd命令

可以想一想,我们打包好镜像后,然后启动镜像会发生什么?

文件编写完,使用命令打包镜像

使用命令打包已经好的文件目录


 docker image build ./ -t app

打包后出现提示:

此时我们查看Docker镜像,使用命令:

docker images

我们可以清楚看到,app镜像已经打包成功,下面我们启动它



docker run -p 8000:3000 app 

使用上面命令即可启动我们的镜像,这时我们在命令中输入

curl 127.0.0.1:8000

得到返回内容

Hello docker

浏览器输入: 127.0.0.1:8000 即可访问到页面~

以上说明,我们的第一个Docker镜像已经制作成功

有人可能会觉得到这里,镜像和容器有点混淆了,不是先有镜像再有容器吗?

其实是我们启动的镜像有脚本命令帮我们启动了服务,于是Docker帮我们自动创建了容器

查看Docker容器命令:

docker ps -a 列出所有容器
不加 -a 仅列出正在运行的,像退出了的或者仅仅只是创建了的就不列出来
docker container ls 列出当前运行的容器

输入上面 docker container ls

得到结果

原来Docker看我们启动了脚本服务,帮我们自动生成了容器?

下面我们来一个生成镜像,再生成容器,最后手动启动容器的例子

这次我们配置,加入Nginx反向代理服务器

首先,创建用户需要看到的html文件

这里我们给一个普通的 hello-world内容的index.html文件即可

然后创建dickerfile文件,配置如下,将index.html文件添加到对应的位置


FROM nginx

COPY ./index.html /usr/share/nginx/html/index.html

EXPOSE 80

对外暴露端口号80

这里特别提示:配置文件怎么写,根据你的基础镜像来,百度基本都能找到,不用纠结这个

此时的文件结构:

老规矩,开始打包

docker build ./ -t html

打印信息:

输入终端命令:

docker images

得到结果:

新的镜像html已经构建成功,但是此时查看容器,是没有正在运行的

输入命令:

docker container ls //查看正在运行的所有容器
docker container ls -a //查看所有容器
得到结果是:

可以确认的是,我们创建镜像不会自动生成和启动容器

我们手动生成容器

docker container create -p 8000:80 html

此时命令行返回 一段值

输入

docker container ls

没有显示有任何启动的容器,这时候我们手动启动

输入

docker container start ***(上面那段值)

再重复 docker container ls 命令

得到结果

此时访问localhost:8000即可正常访问页面~

至此,我们可以确定,创建镜像只要不启动,不会生成容器,更不会运行容器

那怎样将Docker用在前端的日常构建中呢?

我们使用gitHub+travis+docker来形成一套完整的自动化流水线

只要我们push新的代码到gitHub上,自动帮我们构建出新的代码,然后我们拉取新的镜像即可(gitLab也有对应的代码更新事件钩子,可以参考那位手动实现Jenkens的文章)

首先我们先进入 Travis CI 官网配置,注册绑定自己的gitHub账号

然后在左侧将自己需要git push后自动构建镜像的仓库加入

接着在项目根目录配置 .travis.yml 文件



language: node_js
node_js:
  - '12'
services:
  - docker

before_install:
  - npm install
  - npm install -g parcel-bundler

script:
  - parcel build ./index.js
  - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
  - docker build -t jinjietan/mini-react:latest .
  - docker push jinjietan/mini-react:latest

每次更新push代码,都会下载,然后执行打包命令,这样你下载的镜像就是有最新的代码。不再需要每个人下载打开镜像再去build

为了降低复杂度,这里使用了Parcel打包工具,零配置

更改dockerfile内容,将parcel打包后的内容COPY进容器

FROM nginx
COPY ./index.html /usr/share/nginx/html/
COPY ./dist /usr/share/nginx/html/dist
EXPOSE 80

添加好了你的库之后,选择这里的设置

然后添加两个环境变量:

DOCKER_USERNAME和DOCKER_PASSWORD

这里,我将我编写的mini-react框架源码,放入docker中,然后使用parcel打包工具打包,再用nginx反向代理~

特别提示:这里的Docker容器,想要后台运行,就必须有一个前台进程。容器运行的命令如果不是那些一直挂起的命令(比如tcp,ping),就是会自动退出的

通过 docker ps -a 可以看到容器关闭的原因

注意 :jinejietan/mini-react应该换成你的用户名/包名,再push代码

这是思维导图:

当配置成功,代码被推送到gitHub上后,travis-ci帮我们自动构建发布新镜像

一定要学会使用: docker ps -a 查看容器的状态

成功的提示:

至此,发布,自动构建镜像已经完成

正式开始拉取镜像,启动容器

我们刚才发布的镜像名称是:jinjietan/mini-react

先使用下面几条命令

docker中 启动所有的容器命令
 
docker start $(docker ps -a | awk '{ print $1}' | tail -n +2)
docker中 关闭所有的容器命令

docker stop $(docker ps -a | awk '{ print $1}' | tail -n +2)
docker中 删除所有的容器命令

docker rm $(docker ps -a | awk '{ print $1}' | tail -n +2)
docker中 删除所有的镜像

docker rmi $(docker images | awk '{print $3}' |tail -n +2)
tail -n +2 表示从第二行开始读取

清除当前宿主机上面所有的镜像,容器,依次执行

然后使用:

docker image pull jinjietan/mini-react:latest

拉取镜像,这时候需要下载

拉取完成后,使用

docker images

可以看到jinjietan/mini-react:latest镜像已经存在了

我们使用

docker container create -p 8000:80 jinjietan/mini-react:latest

创建这个镜像的容器,并且绑定在端口号8000

最后输入下面的命令,即可启动mini-react框架的容器

docker container start  ***(上面create的返回值)

浏览器输入 127.0.0.1:8000 发现,访问成功,框架生效。

Docker的使用,我们大致就到这里,个人认为,用Docker比不用好,这个技术已经快跟TypeScript一样,到不学不行的阶段了。

并不是说你非要用它,而是比如说,你如果不怎么懂TypeScript,你就没办法把如今那些优秀库的大部门的源码搞得那么清楚。

越来越多的技术在依赖Docker

当然,其实这个mini-react框架源码也是不错的,如果有兴趣可以了解以下,源码都在:

mini-react框架+镜像配置源码,记得切换到diff-async分支哦~

https://github.com/JinJieTan/...

如果觉得写得不错,可以右下角点个在看

关注一下我的微信公众号:前端巅峰 ~ 回复加群即可加入大前端交流群

主要注重技术点:即时通讯,跨平台重型应用开发,全栈工程师方向前沿技术

查看原文

赞 148 收藏 99 评论 10

酱菜 赞了文章 · 2019-08-12

AST抽象语法树——最基础的javascript重点知识,99%的人根本不了解

抽象语法树(AST),是一个非常基础而重要的知识点,但国内的文档却几乎一片空白。

本文将带大家从底层了解AST,并且通过发布一个小型前端工具,来带大家了解AST的强大功能

Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。

我们对javascript生态了如指掌,却常忽视javascript本身。这台机器,究竟是哪些零部件在支持着它运行?

AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。

事实上,在javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。

人生第一次拆解Javascript

小时候,当我们拿到一个螺丝刀和一台机器,人生中最令人怀念的梦幻时刻便开始了:

我们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一起...

当我们把它重新照不同的方式组装起来,这时,机器重新又跑动了起来——世界在你眼中如获新生。

image

通过抽象语法树解析,我们可以像童年时拆解玩具一样,透视Javascript这台机器的运转,并且重新按着你的意愿来组装。

现在,我们拆解一个简单的add函数

function add(a, b) {
    return a + b
}

首先,我们拿到的这个语法块,是一个FunctionDeclaration(函数定义)对象。

用力拆开,它成了三块:

  • 一个id,就是它的名字,即add
  • 两个params,就是它的参数,即[a, b]
  • 一块body,也就是大括号内的一堆东西

add没办法继续拆下去了,它是一个最基础Identifier(标志)对象,用来作为函数的唯一标志,就像人的姓名一样。

{
    name: 'add'
    type: 'identifier'
    ...
}

params继续拆下去,其实是两个Identifier组成的数组。之后也没办法拆下去了。

[
    {
        name: 'a'
        type: 'identifier'
        ...
    },
    {
        name: 'b'
        type: 'identifier'
        ...
    }
]

接下来,我们继续拆开body
我们发现,body其实是一个BlockStatement(块状域)对象,用来表示是{return a + b}

打开Blockstatement,里面藏着一个ReturnStatement(Return域)对象,用来表示return a + b

继续打开ReturnStatement,里面是一个BinaryExpression(二项式)对象,用来表示a + b

继续打开BinaryExpression,它成了三部分,leftoperatorright

  • operator+
  • left 里面装的,是Identifier对象 a
  • right 里面装的,是Identifer对象 b

就这样,我们把一个简单的add函数拆解完毕,用图表示就是

image

看!抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。

那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?

请查看 AST对象文档

送给你的AST螺丝刀:recast

输入命令:

npm i recast -S

你即可获得一把操纵语法树的螺丝刀

接下来,你可以在任意js文件下操纵这把螺丝刀,我们新建一个parse.js示意:

parse.js

// 给你一把"螺丝刀"——recast
const recast = require("recast");

// 你的"机器"——一段代码
// 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
const code =
  `
  function add(a, b) {
    return a +
      // 有什么奇怪的东西混进来了
      b
  }
  `
// 用螺丝刀解析机器
const ast = recast.parse(code);

// ast可以处理很巨大的代码文件
// 但我们现在只需要代码块的第一个body,即add函数
const add  = ast.program.body[0]

console.log(add)

输入node parse.js你可以查看到add函数的结构,与之前所述一致,通过AST对象文档可查到它的具体属性:

FunctionDeclaration{
    type: 'FunctionDeclaration',
    id: ...
    params: ...
    body: ...
}

你也可以继续使用console.log透视它的更内层,如:

console.log(add.params[0])
console.log(add.body.body[0].argument.left)

recast.types.builders 制作模具

一个机器,你只会拆开重装,不算本事。

拆开了,还能改装,才算上得了台面。

recast.types.builders里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

最简单的例子,我们想把之前的function add(a, b){...}声明,改成匿名函数式声明const add = function(a ,b){...}

如何改装?

第一步,我们创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。

第二步,创建一个VariableDeclarator,放置add.id在左边, 右边是将创建的FunctionDeclaration对象

第三步,我们创建一个FunctionDeclaration,如前所述的三个组件,id params body中,因为是匿名函数id设为空,params使用add.params,body使用add.body。

这样,就创建好了const add = function(){}的AST对象。

在之前的parse.js代码之后,加入以下代码

// 引入变量声明,变量符号,函数声明三种“模具”
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders

// 将准备好的组件置入模具,并组装回原来的ast对象。
ast.program.body[0] = variableDeclaration("const", [
  variableDeclarator(add.id, functionExpression(
    null, // Anonymize the function expression.
    add.params,
    add.body
  ))
]);

//将AST对象重新转回可以阅读的代码
const output = recast.print(ast).code;

console.log(output)

可以看到,我们打印出了

const add = function(a, b) {
  return a +
    // 有什么奇怪的东西混进来了
    b
};

最后一行

const output = recast.print(ast).code;

其实是recast.parse的逆向过程,具体公式为

recast.print(recast.parse(source)).code === source

打印出来还保留着“原装”的函数内容,连注释都没有变。

我们其实也可以打印出美化格式的代码段:

const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

输出为

const add = function(a, b) {
  return a + b;
};
现在,你是不是已经产生了“我可以通过AST树生成任何js代码”的幻觉?

我郑重告诉你,这不是幻觉。

实战进阶:命令行修改js文件

除了parse/print/builder以外,Recast的三项主要功能:

  • run: 通过命令行读取js文件,并转化成ast以供处理。
  • tnt: 通过assert()和check(),可以验证ast对象的类型。
  • visit: 遍历ast树,获取有效的AST对象并进行更改。

我们通过一个系列小务来学习全部的recast工具库:

创建一个用来示例文件,假设是demo.js

demo.js

function add(a, b) {
  return a + b
}

function sub(a, b) {
  return a - b
}

function commonDivision(a, b) {
  while (b !== 0) {
    if (a > b) {
      a = sub(a, b)
    } else {
      b = sub(b, a)
    }
  }
  return a
}

recast.run —— 命令行文件读取

新建一个名为read.js的文件,写入
read.js

recast.run( function(ast, printSource){
    printSource(ast)
})

命令行输入

node read demo.js

我们查以看到js文件内容打印在了控制台上。

我们可以知道,node read可以读取demo.js文件,并将demo.js内容转化为ast对象。

同时它还提供了一个printSource函数,随时可以将ast的内容转换回源码,以方便调试。

recast.visit —— AST节点遍历

read.js

#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function({node}) {
        console.log(node)
        return false
      }
    });
});

recast.visit将AST对象内的节点进行逐个遍历。

注意

  • 你想操作函数声明,就使用visitFunctionDelaration遍历,想操作赋值表达式,就使用visitExpressionStatement。 只要在 AST对象文档中定义的对象,在前面加visit,即可遍历。
  • 通过node可以取到AST对象
  • 每个遍历函数后必须加上return false,或者选择以下写法,否则报错:
#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        printSource(node)
        this.traverse(path)
      }
    })
});

调试时,如果你想输出AST对象,可以console.log(node)

如果你想输出AST对象对应的源码,可以printSource(node)

命令行输入`
node read demo.js`进行测试。

#!/usr/bin/env node 在所有使用recast.run()的文件顶部都需要加入这一行,它的意义我们最后再讨论。

TNT —— 判断AST对象类型

TNT,即recast.types.namedTypes,就像它的名字一样火爆,它用来判断AST对象是否为指定的类型。

TNT.Node.assert(),就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)

TNT.Node.check(),则可以判断类型是否一致,并输出False和True

上述Node可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.value
        // 判断是否为ExpressionStatement,正确则输出一行字。
        if(TNT.ExpressionStatement.check(node)){
          console.log('这是一个ExpressionStatement')
        }
        this.traverse(path);
      }
    });
});

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
        TNT.ExpressionStatement.assert(node)
        this.traverse(path);
      }
    });
});

命令行输入`
node read demo.js`进行测试。

实战:用AST修改源码,导出全部方法

exportific.js

现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如

function add (a, b) {
    return a + b
}

想改变为

exports.add = (a, b) => {
  return a + b
}

除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题

查询AST对象文档

首先,我们先用builders凭空实现一个键头函数

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier:id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression,
  blockStatement
} = recast.types.builders

recast.run(function(ast, printSource) {
  // 一个块级域 {}
  console.log('\n\nstep1:')
  printSource(blockStatement([]))

  // 一个键头函数 ()=>{}
  console.log('\n\nstep2:')
  printSource(arrowFunctionExpression([],blockStatement([])))

  // add赋值为键头函数  add = ()=>{}
  console.log('\n\nstep3:')
  printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))

  // exports.add赋值为键头函数  exports.add = ()=>{}
  console.log('\n\nstep4:')
  printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
    arrowFunctionExpression([],blockStatement([])))))
});

上面写了我们一步一步推断出exports.add = ()=>{}的过程,从而得到具体的AST结构体。

使用node exportific demo.js运行可查看结果。

接下来,只需要在获得的最终的表达式中,把id('add')替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

另外,我们需要注意,在commonDivision函数内,引用了sub函数,应改写成exports.sub

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

recast.run(function (ast, printSource) {
  // 用来保存遍历到的全部函数名
  let funcIds = []
  recast.types.visit(ast, {
    // 遍历所有的函数定义
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      // 保存函数名
      funcIds.push(funcName.name)
      // 这是上一步推导出来的ast结构体
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      // 将原来函数的ast结构体,替换成推导ast结构体
      path.replace(rep)
      // 停止遍历
      return false
    }
  })


  recast.types.visit(ast, {
    // 遍历所有的函数调用
    visitCallExpression(path){
      const node = path.node;
      // 如果函数调用出现在函数定义中,则修改ast结构
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      // 停止遍历
      return false
    }
  })
  // 打印修改后的ast源码
  printSource(ast)
})

一步到位,发一个最简单的exportific前端工具

上面讲了那么多,仍然只体现在理论阶段。

但通过简单的改写,就能通过recast制作成一个名为exportific的源码编辑工具。

以下代码添加作了两个小改动

  1. 添加说明书--help,以及添加了--rewrite模式,可以直接覆盖文件或默认为导出*.export.js文件。
  2. 将之前代码最后的 printSource(ast)替换成 writeASTFile(ast,filename,rewriteMode)

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

const fs = require('fs')
const path = require('path')
// 截取参数
const options = process.argv.slice(2)

//如果没有参数,或提供了-h 或--help选项,则打印帮助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
  console.log(`
    采用commonjs规则,将.js文件内所有函数修改为导出形式。

    选项: -r  或 --rewrite 可直接覆盖原有文件
    `)
  process.exit(0)
}

// 只要有-r 或--rewrite参数,则rewriteMode为true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')

// 获取文件名
const clearFileArg = options.filter((item)=>{
  return !['-r','--rewrite','-h','--help'].includes(item)
})

// 只处理一个文件
let filename = clearFileArg[0]

const writeASTFile = function(ast, filename, rewriteMode){
  const newCode = recast.print(ast).code
  if(!rewriteMode){
    // 非覆盖模式下,将新文件写入*.export.js下
    filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
  }
  // 将新代码写入文件
  fs.writeFileSync(path.join(process.cwd(),filename),newCode)
}


recast.run(function (ast, printSource) {
  let funcIds = []
  recast.types.visit(ast, {
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      funcIds.push(funcName.name)
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      path.replace(rep)
      return false
    }
  })


  recast.types.visit(ast, {
    visitCallExpression(path){
      const node = path.node;
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      return false
    }
  })

  writeASTFile(ast,filename,rewriteMode)
})

现在尝试一下

node exportific demo.js

已经可以在当前目录下找到源码变更后的demo.export.js文件了。

npm发包

编辑一下package.json文件

{
  "name": "exportific",
  "version": "0.0.1",
  "description": "改写源码中的函数为可exports.XXX形式",
  "main": "exportific.js",
  "bin": {
    "exportific": "./exportific.js"
  },
  "keywords": [],
  "author": "wanthering",
  "license": "ISC",
  "dependencies": {
    "recast": "^0.15.3"
  }
}

注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js

这时,输入npm link 就在本地生成了一个exportific命令。

之后,只要哪个js文件想导出来使用,就exportific XXX.js一下。

这是在本地的玩法,想和大家一起分享这个前端小工具,只需要发布npm包就行了。

同时,一定要注意exportific.js文件头有

#!/usr/bin/env node

否则在使用时将报错。

接下来,正式发布npm包!

如果你已经有了npm 帐号,请使用npm login登录

如果你还没有npm帐号 https://www.npmjs.com/signup 非常简单就可以注册npm

然后,输入
npm publish

没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人都可以通过

npm i exportific -g

全局安装这一个插件。

提示:==在试验教程时,请不要和我的包重名,修改一下发包名称。==

结语

我们对javascript再熟悉不过,但透过AST的视角,最普通的js语句,却焕发出精心动魄的美感。你可以通过它批量构建任何javascript代码!

童年时,这个世界充满了新奇的玩具,再普通的东西在你眼中都如同至宝。如今,计算机语言就是你手中的大玩具,一段段AST对象的拆分组装,构建出我们所生活的网络世界。

所以不得不说软件工程师是一个幸福的工作,你心中住的仍然是那个午后的少年,永远有无数新奇等你发现,永远有无数梦想等你构建。

github地址:https://github.com/wanthering...

image

查看原文

赞 681 收藏 457 评论 20

酱菜 赞了文章 · 2019-08-12

你的Tree-Shaking并没什么卵用

本文将探讨tree-shaking在当下(webpack@3, babel@6 以下)的现状,以及研究为什么tree-shaking依旧举步维艰的原因,最终总结当下能提高tree-shaking效果的一些手段。

Tree-Shaking这个名词,很多前端coder已经耳熟能详了,它代表的大意就是删除没用到的代码。这样的功能对于构建大型应用时是非常好的,因为日常开发经常需要引用各种库。但大多时候仅仅使用了这些库的某些部分,并非需要全部,此时Tree-Shaking如果能帮助我们删除掉没有使用的代码,将会大大缩减打包后的代码量。

Tree-Shaking在前端界由rollup首先提出并实现,后续webpack在2.x版本也借助于UglifyJS实现了。自那以后,在各类讨论优化打包的文章中,都能看到Tree-Shaking的身影。

许多开发者看到就很开心,以为自己引用的elementUI、antd 等库终于可以删掉一大半了。然而理想是丰满的,现实是骨干的。升级之后,项目的压缩包并没有什么明显变化。

我也遇到了这样的问题,前段时间,需要开发个组件库。我非常纳闷我开发的组件库在打包后,为什么引用者通过ES6引用,最终依旧会把组件库中没有使用过的组件引入进来。

下面跟大家分享下,我在Tree-Shaking上的摸索历程。

Tree-Shaking的原理

这里我不多冗余阐述,直接贴百度外卖前端的一篇文章:Tree-Shaking性能优化实践 - 原理篇

如果懒得看文章,可以看下如下总结:

  1. ES6的模块引入是静态分析的,故而可以在编译时正确判断到底加载了什么代码。
  2. 分析程序流,判断哪些变量未被使用、引用,进而删除此代码。

很好,原理非常完美,那为什么我们的代码又删不掉呢?

先说原因:都是副作用的锅!

副作用

了解过函数式编程的同学对副作用这词肯定不陌生。它大致可以理解成:一个函数会、或者可能会对函数外部变量产生影响的行为。

举个例子,比如这个函数:

function go (url) {
  window.location.href = url
}

这个函数修改了全局变量location,甚至还让浏览器发生了跳转,这就是一个有副作用的函数。

现在我们了解了副作用了,但是细想来,我写的组件库也没有什么副作用啊,我每一个组件都是一个类,简化一下,如下所示:

// componetns.js
export class Person {
  constructor ({ name, age, sex }) {
    this.className = 'Person'
    this.name = name
    this.age = age
    this.sex = sex
  }
  getName () {
    return this.name
  }
}
export class Apple {
  constructor ({ model }) {
    this.className = 'Apple'
    this.model = model
  }
  getModel () {
    return this.model
  }
}
// main.js
import { Apple } from './components'

const appleModel = new Apple({
  model: 'IphoneX'
}).getModel()

console.log(appleModel)

用rollup在线repl尝试了下tree-shaking,也确实删掉了Person,传送门

可是为什么当我通过webpack打包组件库,再被他人引入时,却没办法消除未使用代码呢?

因为我忽略了两件事情:babel编译 + webpack打包

成也Babel,败也Babel

Babel不用我多解释了,它能把ES6/ES7的代码转化成指定浏览器能支持的代码。正是由于它,我们前端开发者才能有今天这样美好的开发环境,能够不用考虑浏览器兼容性地、畅快淋漓地使用最新的JavaScript语言特性。

然而也是由于它的编译,一些我们原本看似没有副作用的代码,便转化为了(可能)有副作用的。

如果懒得点开链接,可以看下Person类被babel编译后的结果:

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var _createClass = function() {
  function defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i];
      descriptor.enumerable = descriptor.enumerable || !1, descriptor.configurable = !0,
      "value" in descriptor && (descriptor.writable = !0), Object.defineProperty(target, descriptor.key, descriptor);
    }
  }
  return function(Constructor, protoProps, staticProps) {
    return protoProps && defineProperties(Constructor.prototype, protoProps), staticProps && defineProperties(Constructor, staticProps),
    Constructor;
  };
}()

var Person = function () {
  function Person(_ref) {
    var name = _ref.name, age = _ref.age, sex = _ref.sex;
    _classCallCheck(this, Person);

    this.className = 'Person';
    this.name = name;
    this.age = age;
    this.sex = sex;
  }

  _createClass(Person, [{
    key: 'getName',
    value: function getName() {
      return this.name;
    }
  }]);
  return Person;
}();

我们的Person类被封装成了一个IIFE(立即执行函数),然后返回一个构造函数。那它怎么就产生副作用了呢?问题就出现在_createClass这个方法上,你只要在上一个rollup的repl链接中,将Person的IIFE中的_createClass调用删了,Person类就会被移除了。至于_createClass为什么会产生副作用,我们先放一边。因为大家可能会产生另外一个疑问:Babel为什么要这样去声明构造函数的?

假如是我的话,我可能会这样去编译:

var Person = function () {
  function Person() {

  }
  Person.prototype.getName = function () { return this.name };
  return Person;
}();

因为我们以前就是这么写“类”的,那babel为什么要采用Object.defineProperty这样的形式呢,用原型链有什么不妥呢?自然是非常的不妥的,因为ES6的一些语法是有其特定的语义的。比如:

  1. 类内部声明的方法,是不可枚举的,而通过原型链声明的方法是可以枚举的。这里可以参考下阮老师介绍Class 的基本语法
  2. for...of的循环是通过遍历器(Iterator)迭代的,循环数组时并非是i++,然后通过下标寻值。这里依旧可以看下阮老师关于遍历器与for...of的介绍,以及一篇babel关于for...of编译的说明transform-es2015-for-of

所以,babel为了符合ES6真正的语义,编译类时采取了Object.defineProperty来定义原型方法,于是导致了后续这些一系列问题。

眼尖的同学可能在我上述第二点中发的链接transform-es2015-for-of中看到,babel其实是有一个loose模式的,直译的话叫做宽松模式。它是做什么用的呢?它会不严格遵循ES6的语义,而采取更符合我们平常编写代码时的习惯去编译代码。比如上述的Person类的属性方法将会编译成直接在原型链上声明方法。

这个模式具体的babel配置如下:

// .babelrc
{
  "presets": [["env", { "loose": false }]]
}

同样的,我放个在线repl示例方便大家直接查看效果:loose-mode

咦,如果我们真的不关心类方法能否被枚举,开启了loose模式,这样是不是就没有副作用产生,就能完美tree-shaking类了呢?

我们开启了loose模式,使用rollup打包,发现还真是如此!传送门

不够屌的UglifyJS

然而不要开心的太早,当我们用Webpack配合UglifyJS打包文件时,这个Person类的IIFE又被打包进去了? What???

为了彻底搞明白这个问题,我搜到一条UglifyJS的issue:Class declaration in IIFE considered as side effect,仔细看了好久。对此有兴趣、并且英语还ok的同学,可以快速去了解这条issue,还是挺有意思的。我大致阐述下这条issue下都说了些啥。

issue楼主-blacksonic 好奇为什么UglifyJS不能消除未引用的类。

UglifyJS贡献者-kzc说,uglify不进行程序流分析,所以不能排除有可能有副作用的代码。

楼主:我的代码没什么副作用啊。要不你们来个配置项,设置后,可以认为它是没有副作用的,然后放心的删了它们吧。

贡献者:我们没有程序流分析,我们干不了这事儿,实在想删除他们,出门左转 rollup 吼吧,他们屌,做了程序流分析,能判断到底有没有副作用。

楼主:迁移rollup成本有点高啊。我觉得加个配置不难啊,比如这样这样,巴拉巴拉。

贡献者:欢迎提PR。

楼主:别嘛,你们项目上千行代码,我咋提PR啊。我的代码也没啥副作用啊,您能详细的说明下么?

贡献者:变量赋值就是有可能产生副作用的!我举个例子:

var V8Engine = (function () {
  function V8Engine () {}
  V8Engine.prototype.toString = function () { return 'V8' }
  return V8Engine
}())
var V6Engine = (function () {
  function V6Engine () {}
  V6Engine.prototype = V8Engine.prototype // <---- side effect
  V6Engine.prototype.toString = function () { return 'V6' }
  return V6Engine
}())
console.log(new V8Engine().toString())
贡献者:V6Engine虽然没有被使用,但是它修改了V8Engine原型链上的属性,这就产生副作用了。你看rollup(楼主特意注明截至当时)目前就是这样的策略,直接把V6Engine 给删了,其实是不对的。

楼主以及一些路人甲乙丙丁,纷纷提出自己的建议与方案。最终定下,可以在代码上通过/*@__PURE__*/这样的注释声明此函数无副作用。

这个issue信息量比较大,也挺有意思,其中那位uglify贡献者kzc,当时提出rollup存在的问题后还给rollup提了issue,rollup认为问题不大不紧急,这位贡献者还顺手给rollup提了个PR,解决了问题。。。

我再从这个issue中总结下几点关键信息:

  1. 函数的参数若是引用类型,对于它属性的操作,都是有可能会产生副作用的。因为首先它是引用类型,对它属性的任何修改其实都是改变了函数外部的数据。其次获取或修改它的属性,会触发getter或者setter,而gettersetter是不透明的,有可能会产生副作用。
  2. uglify没有完善的程序流分析。它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。
  3. rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。

有的同学可能会想,连获取对象的属性也会产生副作用导致不能删除代码,这也太过分了吧!事实还真是如此,我再贴个示例演示一下:传送门

代码如下:

// maths.js
export function square ( x ) {
    return x.a
}
square({ a: 123 })

export function cube ( x ) {
    return x * x * x;
}
//main.js
import { cube } from './maths.js';
console.log( cube( 5 ) ); // 125

打包结果如下:

function square ( x ) {
  return x.a
}
square({ a: 123 });

function cube ( x ) {
    return x * x * x;
}
console.log( cube( 5 ) ); // 125

而如果将square方法中的return x.a 改为 return x,则最终打包的结果则不会出现square方法。当然啦,如果不在maths.js文件中执行这个square方法,自然也是不会在打包文件中出现它的。

所以我们现在理解了,当时babel编译成的_createClass方法为什么会有副作用。现在再回头一看,它简直浑身上下都是副作用。

查看uglify的具体配置,我们可以知道,目前uglify可以配置pure_getters: true来强制认为获取对象属性,是没有副作用的。这样可以通过它删除上述示例中的square方法。不过由于没有pure_setters这样的配置,_createClass方法依旧被认为是有副作用的,无法删除。

那到底该怎么办?

聪明的同学肯定会想,既然babel编译导致我们产生了副作用代码,那我们先进行tree-shaking打包,最后再编译bundle文件不就好了嘛。这确实是一个方案,然而可惜的是:这在处理项目自身资源代码时是可行的,处理外部依赖npm包就不行了。因为人家为了让工具包具有通用性、兼容性,大多是经过babel编译的。而最占容量的地方往往就是这些外部依赖包。

那先从根源上讨论,假如我们现在要开发一个组件库提供给别人用,该怎么做?

如果是使用webpack打包JavaScript库

先贴下webpack将项目打包为JS库的文档。可以看到webpack有多种导出模式,一般大家都会选择最具通用性的umd方式,但是webpack却没支持导出ES模块的模式。

所以,假如你把所有的资源文件通过webpack打包到一个bundle文件里的话,那这个库文件从此与Tree-shaking无缘。

那怎么办呢?也不是没有办法。目前业界流行的组件库多是将每一个组件或者功能函数,都打包成单独的文件或目录。然后可以像如下的方式引入:

import clone from 'lodash/clone'

import Button from 'antd/lib/button';

但是这样呢也比较麻烦,而且不能同时引入多个组件。所以这些比较流行的组件库大哥如antd,element专门开发了babel插件,使得用户能以import { Button, Message } form 'antd'这样的方式去按需加载。本质上就是通过插件将上一句的代码又转化成如下:

import Button from 'antd/lib/button';
import Message from 'antd/lib/button';

这样似乎是最完美的变相tree-shaking方案。唯一不足的是,对于组件库开发者来说,需要专门开发一个babel插件;对于使用者来说,需要引入一个babel插件,稍微略增加了开发成本与使用成本。

除此之外,其实还有一个比较前沿的方法。是rollup的一个提案,在package.json中增加一个key:module,如下所示:

{
  "name": "my-package",
  "main": "dist/my-package.umd.js",
  "module": "dist/my-package.esm.js"
}

这样,当开发者以es6模块的方式去加载npm包时,会以module的值为入口文件,这样就能够同时兼容多种引入方式,(rollup以及webpack2+都已支持)。但是webpack不支持导出为es6模块,所以webpack还是要拜拜。我们得上rollup!

(有人会好奇,那干脆把未打包前的资源入口文件暴露到module,让使用者自己去编译打包好了,那它就能用未编译版的npm包进行tree-shaking了。这样确实也不是不可以。但是,很多工程化项目的babel编译配置,为了提高编译速度,其实是会忽略掉node_modules内的文件的。所以为了保证这些同学的使用,我们还是应该要暴露出一份编译过的ES6 Module。)

使用rollup打包JavaScript库

吃了那么多亏后,我们终于明白,打包工具库、组件库,还是rollup好用,为什么呢?

  1. 它支持导出ES模块的包。
  2. 它支持程序流分析,能更加正确的判断项目本身的代码是否有副作用。

我们只要通过rollup打出两份文件,一份umd版,一份ES模块版,它们的路径分别设为mainmodule的值。这样就能方便使用者进行tree-shaking。

那么问题又来了,使用者并不是用rollup打包自己的工程化项目的,由于生态不足以及代码拆分等功能限制,一般还是用webpack做工程化打包。

使用webpack打包工程化项目

之前也提到了,我们可以先进行tree-shaking,再进行编译,减少编译带来的副作用,从而增加tree-shaking的效果。那么具体应该怎么做呢?

首先我们需要去掉babel-loader,然后webpack打包结束后,再执行babel编译文件。但是由于webpack项目常有多入口文件或者代码拆分等需求,我们又需要写一个配置文件,对应执行babel,这又略显麻烦。所以我们可以使用webpack的plugin,让这个环节依旧跑在webpack的打包流程中,就像uglifyjs-webpack-plugin一样,不再是以loader的形式对单个资源文件进行操作,而是在打包最后的环节进行编译。这里可能需要大家了解下webpack的plugin机制

关于uglifyjs-webpack-plugin,这里有一个小细节,webpack默认会带一个低版本的,可以直接用webpack.optimize.UglifyJsPlugin别名去使用。具体可以看webpack的相关说明

webpack =< v3.0.0 currently contains v0.4.6 of this plugin under webpack.optimize.UglifyJsPlugin as an alias. For usage of the latest version (v1.0.0), please follow the instructions below. Aliasing v1.0.0 as webpack.optimize.UglifyJsPlugin is scheduled for webpack v4.0.0

而这个低版本的uglifyjs-webpack-plugin使用的依赖uglifyjs也是低版本的,它没有uglifyES6代码的能力,故而如果我们有这样的需求,需要在工程中重新npm install uglifyjs-webpack-plugin -D,安装最新版本的uglifyjs-webpack-plugin,重新引入它并使用。

这样之后,我们再使用webpack的babel插件进行编译代码。

问题又来了,这样的需求比较少,因此webpack和babel官方都没有这样的插件,只有一个第三方开发者开发了一个插件babel-webpack-plugin。可惜的是这位作者已经近一年没有维护这个插件了,并且存在着一个问题,此插件不会用项目根目录下的.babelrc文件进行babel编译。有人对此提了issue,却也没有任何回应。

那么又没有办法,就我来写一个新的插件吧----webpack-babel-plugin,有了它之后我们就能让webpack在最后打包文件之前进行babel编译代码了,具体如何安装使用可以点开项目查看。注意这个配置需要在uglifyjs-webpack-plugin之后,像这样:

plugins: [
  new UglifyJsPlugin(),
  new BabelPlugin()
]

但是这样呢,有一个毛病,由于babel在最后阶段去编译比较大的文件,耗时比较长,所以建议区分下开发模式与生产模式。另外还有个更大的问题,webpack本身采用的编译器acorn不支持对象的扩展运算符(...)以及某些还未正式成为ES标准的特性,所以。。。。。

所以如果特性用的非常超前,还是需要babel-loader,但是babel-loader要做专门的配置,把还在es stage阶段的代码编译成ES2017的代码,以便于webpack本身做处理。

感谢掘金热心网友的提示,还有一个插件BabelMinifyWebpackPlugin,它所依赖的babel/minify也集成了uglifyjs。使用此插件便等同于上述使用UglifyJsPlugin + BabelPlugin的效果,如若有此方面需求,建议使用此插件。

总结

上面讲了这么多,我最后再总结下,在当下阶段,在tree-shaking上能够尽力的事。

  1. 尽量不写带有副作用的代码。诸如编写了立即执行函数,在函数里又使用了外部变量等。
  2. 如果对ES6语义特性要求不是特别严格,可以开启babel的loose模式,这个要根据自身项目判断,如:是否真的要不可枚举class的属性。
  3. 如果是开发JavaScript库,请使用rollup。并且提供ES6 module的版本,入口文件地址设置到package.json的module字段。
  4. 如果JavaScript库开发中,难以避免的产生各种副作用代码,可以将功能函数或者组件,打包成单独的文件或目录,以便于用户可以通过目录去加载。如有条件,也可为自己的库开发单独的webpack-loader,便于用户按需加载。
  5. 如果是工程项目开发,对于依赖的组件,只能看组件提供者是否有对应上述3、4点的优化。对于自身的代码,除1、2两点外,对于项目有极致要求的话,可以先进行打包,最终再进行编译。
  6. 如果对项目非常有把握,可以通过uglify的一些编译配置,如:pure_getters: true,删除一些强制认为不会产生副作用的代码。

故而,在当下阶段,依旧没有比较简单好用的方法,便于我们完整的进行tree-shaking。所以说,想做好一件事真难啊。不仅需要靠个人的努力,还需要考虑到历史的进程。

PS: 此文中涉及到的代码,我也传到了github,可以点击阅读原文下载查看。

--阅读原文

@丁香园F2E @相学长

--转载请先经过本人授权。

查看原文

赞 140 收藏 112 评论 15

酱菜 赞了文章 · 2019-07-03

从渲染原理谈前端性能优化

作者:李佳晓 原文:学而思网校技术团队

前言

合格的开发者知道怎么做,而优秀的开发者知道为什么这么做。

这句话来自《web性能权威指南》,我一直很喜欢,而本文尝试从浏览器渲染原理探讨如何进行性能提升。
全文将从网络通信以及页面渲染两个过程去探讨浏览器的行为及在此过程中我们可以针对那些点进行优化,有些的不足之处还请各位不吝雅正。

一、关于浏览器渲染的容易误解点总结

关于浏览器渲染机制已经是老生常谈,而且网上现有资料中有非常多的优秀资料对此进行阐述。遗憾的是网上的资料良莠不齐,经常在不同的文档中对同一件事的描述出现了极大的差异。怀着严谨求学的态度经过大量资料的查阅和请教,将会在后文总结出一个完整的流程。

1、DOM树的构建是文档加载完成开始的?

DOM树的构建是从接受到文档开始的,先将字节转化为字符,然后字符转化为标记,接着标记构建dom树。这个过程被分为标记化和树构建
而这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。
参考文档:
http://taligarsiel.com/Projec...

2、渲染树是在DOM树和CSS样式树构建完毕才开始构建的吗?

这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一边解析,一边渲染的工作现象。
参考文档:

http://www.jianshu.com/p/2d52...

3、css的标签嵌套越多,越容易定位到元素

css的解析是自右至左逆向解析的,嵌套越多越增加浏览器的工作量,而不会越快。
因为如果正向解析,例如「div div p em」,我们首先就要检查当前元素到 html 的整条路径,找到最上层的 div,再往下找,如果遇到不匹配就必须回到最上层那个 div,往下再去匹配选择器中的第一个 div,回溯若干次才能确定匹配与否,效率很低。
逆向匹配则不同,如果当前的 DOM 元素是 div,而不是 selector 最后的 em,那只要一步就能排除。只有在匹配时,才会不断向上找父节点进行验证。
打个比如 p span.showing
你认为从一个p元素下面找到所有的span元素并判断是否有class showing快,还是找到所有的span元素判断是否有class showing并且包括一个p父元素快
参考文档:
http://www.imooc.com/code/4570

二、页面渲染的完整流程

当浏览器拿到HTTP报文时呈现引擎将开始解析 HTML 文档,并将各标记逐个转化成“内容树”上的 DOM 节点。同时也会解析外部 CSS 文件以及样式元素中的样式数据。HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构:呈现树。浏览器将根据呈现树进行布局绘制。

以上就是页面渲染的大致流程。那么浏览器从用户输入网址之后到底做了什么呢?以下将会进行一个完整的梳理。鉴于本文是前端向的所以梳理内容会有所偏重。而从输入到呈现可以分为两个部分:网络通信页面渲染

我们首先来看网络通信部分:

1、用户输入url并敲击回车。

2、进行DNS解析。

如果用户输入的是ip地址则直接进入第三条。但去记录毫无规律且冗长的ip地址显然不是易事,所以通常都是输入的域名,此时就会进行dns解析。所谓DNS(Domain Name System)指域名系统。因特网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。通过主机名,最终得到该主机名对应的IP地址的过程叫做域名解析(或主机名解析)。这个过程如下所示:

浏览器会首先搜索浏览器自身的DNS缓存(缓存时间比较短,大概只有2分钟左右,且只能容纳1000条缓存)。

  • 如果浏览器自身缓存找不到则会查看系统的DNS缓存,如果找到且没有过期则停止搜索解析到此结束.
  • 而如果本机没有找到DNS缓存,则浏览器会发起一个DNS的系统调用,就会向本地配置的首选DNS服务器发起域名解析请求(通过的是UDP协议向DNS的53端口发起请求,这个请求是递归的请求,也就是运营商的DNS服务器必须得提供给我们该域名的IP地址),运营商的DNS服务器首先查找自身的缓存,找到对应的条目,且没有过期,则解析成功。
  • 如果没有找到对应的条目,则有运营商的DNS代我们的浏览器发起迭代DNS解析请求,它首先是会找根域的DNS的IP地址(这个DNS服务器都内置13台根域的DNS的IP地址),找打根域的DNS地址,就会向其发起请求(请问www.xxxx.com这个域名的IP地址是多少啊?)
  • 根域发现这是一个顶级域com域的一个域名,于是就告诉运营商的DNS我不知道这个域名的IP地址,但是我知道com域的IP地址,你去找它去,于是运营商的DNS就得到了com域的IP地址,又向com域的IP地址发起了请求(请问www.xxxx.com这个域名的IP地址是多少?),com域这台服务器告诉运营商的DNS我不知道www.xxxx.com这个域名的IP地址,但是我知道xxxx.com这个域的DNS地址,你去找它去,于是运营商的DNS又向linux178.com这个域名的DNS地址(这个一般就是由域名注册商提供的,像万网,新网等)发起请求(请问www.xxxx.com这个域名的IP地址是多少?),这个时候xxxx.com域的DNS服务器一查,诶,果真在我这里,于是就把找到的结果发送给运营商的DNS服务器,这个时候运营商的DNS服务器就拿到了www.xxxx.com这个域名对应的IP地址,并返回给Windows系统内核,内核又把结果返回给浏览器,终于浏览器拿到了www.xxxx.com对应的IP地址,这次dns解析圆满成功。

3、建立tcp连接

拿到域名对应的IP地址之后,User-Agent(一般是指浏览器)会以一个随机端口(1024< 端口 < 65535)向服务器的WEB程序(常用的有httpd,nginx等)80端口发起TCP的连接请求。这个连接请求(原始的http请求经过TCP/IP4层模型的层层封包)到达服务器端后(这中间通过各种路由设备,局域网内除外),进入到网卡,然后是进入到内核的TCP/IP协议栈(用于识别该连接请求,解封包,一层一层的剥开),还有可能要经过Netfilter防火墙(属于内核的模块)的过滤,最终到达WEB程序,最终建立了TCP/IP的连接。

tcp建立连接和关闭连接均需要一个完善的确认机制,我们一般将连接称为三次握手,而连接关闭称为四次挥手。而不论是三次握手还是四次挥手都需要数据从客户端到服务器的一次完整传输。将数据从客户端到服务端经历的一个完整时延包括:

  • 发送时延:把消息中的所有比特转移到链路中需要的时间,是消息长度和链路速度的函数
  • 传播时延:消息从发送端到接受端需要的时间,是信号传播距离和速度的函数
  • 处理时延:处理分组首部,检查位错误及确定分组目标所需的时间
  • 排队时延:到来的分组排队等待处理的时间以上的延迟总和就是客户端到服务器的总延迟时间

以上的延迟总和就是客户端到服务器的总延迟时间。因此每一次的连接建立和断开都是有巨大代价的。因此去掉不必要的资源和资源合并(包括js及css资源合并、雪碧图等)才会成为性能优化绕不开的方案。但是好消息是随着协议的发展我们将对性能优化这个主题有着新的看法和思考。虽然还未到来,但也不远了。如果你感到好奇那就接着往下看。

以下简述下tcp建立连接的过程:

clipboard.png

  • 第一次握手:客户端发送syn包(syn=x,x为客户端随机序列号)的数据包到服务器,并进入SYN_SEND状态,等待服务器确认;
  • 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(syn=y,y为服务端生成的随机序列号),即SYN+ACK包,此时服务器进入SYN_RECV状态;
  • 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1)

此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。理想状态下,TCP连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP连接都将被一直保持下去

这里注意, 三次握手是不携带数据的,而是在握手完毕才开始数据传输。因此如果每次数据请求都需要重新进行完整的tcp连接建立,通信时延的耗时是难以估量的!这也就是为什么我们总是能听到资源合并减少请求次数的原因。

下面来看看HTTP如何在协议层面帮我们进行优化的:

HTTP1.0

在http1.0时代,每个TCP连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。 TCP连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(TCP的拥塞控制开始时会启动慢启动算法)。在数据传输的开始只能发送少量包,并随着网络状态良好(无拥塞)指数增长。但遇到拥塞又要重新从1个包开始进行传输。

以下图为例,慢启动时第一次数据传输只能传输一组数据,得到确认后传输2组,每次翻倍,直到达到阈值16时开始启用拥塞避免算法,既每次得到确认后数据包只增加一个。当发生网络拥塞后,阈值减半重新开始慢启动算法。

clipboard.png

因此为避免tcp连接的三次握手耗时及慢启动引起的发送速度慢的情况,应尽量减少tcp连接的次数。

而HTTP1.0每个数据请求都需要重新建立连接的特点使得HTTP 1.0版本的性能比较差。随着网页加载的外部资源越来越多,这个问题就愈发突出了。 为了解决这个问题,有些浏览器在请求时,用了一个非标准的Connection字段。 Kepp-alive 一个可以复用的TCP连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段,不同实现的行为可能不一致,因此不是根本的解决办法。

HTTP1.1

http1.1(以下简称h1.1) 版的最大变化,就是引入了持久连接(persistent connection),即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive。 客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送Connection: close,明确要求服务器关闭TCP连接。 目前,对于同一个域名,大多数浏览器允许同时建立6个持久连接。相比与http1.0,1.1的页面性能有了巨大提升,因为省去了很多tcp的握手挥手时间。下图第一种是tcp建立后只能发一个请求的http1.0的通信状态,而拥有了持久连接的h1.1则避免了tcp握手及慢启动带来的漫长时延。

clipboard.png

从图中可以看到相比h1.0,h1.1的性能有所提升。然而虽然1.1版允许复用TCP连接,但是同一个TCP连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为"队头堵塞"(Head-of-line blocking)。 为了避免这个问题,只有三种方法:一是减少请求数,二是同时多开持久连接。这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入CSS代码、域名分片(domain sharding)等等。如果HTTP协议能继续优化,这些额外的工作是可以避免的。三是开启pipelining,不过pipelining并不是救世主,它也存在不少缺陷:

    • pipelining只能适用于http1.1,一般来说,支持http1.1的server都要求支持pipelining
    • 只有幂等的请求(GET,HEAD)能使用pipelining,非幂等请求比如POST不能使用,因为请求之间可能会存在先后依赖关系。
    • head of line blocking并没有完全得到解决,server的response还是要求依次返回,遵循FIFO(first in first out)原则。也就是说如果请求1的response没有回来,2,3,4,5的response也不会被送回来。
    • 绝大部分的http代理服务器不支持pipelining。 和不支持pipelining的老服务器协商有问题。 可能会导致新的队首阻塞问题。

    鉴于以上种种原因,pipelining的支持度并不友好。可以看看chrome对pipelining的描述:

    https://www.chromium.org/deve...

    clipboard.png

    HTTP2

    2015年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3。HTTP2将具有以下几个主要特点:

    • 二进制协议 :HTTP/1.1 版的头信息肯定是文本(ASCII编码),数据体可以是文本,也可以是二进制。HTTP/2 则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为"帧"(frame):头信息帧和数据帧。
    • 多工 :HTTP/2 复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了"队头堵塞"。
    • 数据流:因为 HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。 HTTP/2 将每个请求或回应的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流ID,用来区分它属于哪个数据流。另外还规定,客户端发出的数据流,ID一律为奇数,服务器发出的,ID为偶数。 数据流发送到一半的时候,客户端和服务器都可以发送信号(RST_STREAM帧),取消这个数据流。1.1版取消数据流的唯一方法,就是关闭TCP连接。这就是说,HTTP/2 可以取消某一次请求,同时保证TCP连接还打开着,可以被其他请求使用。 客户端还可以指定数据流的优先级。优先级越高,服务器就会越早回应。
    • 头信息压缩: HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如Cookie和User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。 HTTP2对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息使用gzip或compress压缩后再发送;另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
    • 服务器推送: HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。 常见场景是客户端请求一个网页,这个网页里面包含很多静态资源。正常情况下,客户端必须收到网页后,解析HTML源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。

    就这几个点我们分别讨论一下:
    就多工来看:虽然http1.1支持了pipelining,但是仍然会有队首阻塞问题,如果浏览器同时发出http请求请求和css,服务器端处理css请求耗时20ms,但是因为先请求资源是html,此时的css尽管已经处理好了但仍不能返回,而需要等待html处理好一起返回,此时的客户端就处于盲等状态,而事实上如果服务器先处理好css就先返回css的话,浏览器就可以开始解析css了。而多工的出现就解决了http之前版本协议的问题,极大的提升了页面性能。缩短了通信时间。我们来看看有了多工之后有那些影响:

    • 无需进行资源分片:为了避免请求tcp连接耗时长的和初始发送速率低的问题,浏览器允许同时打开多个tcp连接让资源同时请求。但是为了避免服务器压力,一般针对一个域名会有最大并发数的限制,一般来说是6个。允许一个页面同时对相同域名打开6个tcp连接。为了绕过最大并发数的限制,会将资源分布在不同的域名下,避免资源在超过并发数后需要等待才能开始请求。而有了http2,可以同步请求资源,资源分片这种方式就可以不再使用。
    • 资源合并:资源合并会不利于缓存机制,因为单文件修改会影响整个资源包。而且单文件过大对于 HTTP/2 的传输不好,尽量做到细粒化更有利于 HTTP/2 传输。而且内置资源也是同理,将资源以base64的形式放进代码中不利于缓存。且编码后的图片资源大小是要超过图片大小的。这两者都是以减少tcp请求次数增大单个文件大小来进行优化的。

    就头部压缩来看:HTTP/1.1 版的头信息是ASCII编码,也就是不经过压缩的,当我们请求只携带少量数据时,http头部可能要比载荷要大许多,尤其是有了很长的cookie之后这一点尤为显著,头部压缩毫无疑问可以对性能有很大提升。

    就服务器推送来看:少去了资源请求的时间,服务端可以将可能用到的资源推送给服务端以待使用。这项能力几乎是革新了之前应答模式的认知,对性能提升也有巨大帮助。

    因此很多优化都是在基于tcp及http的一些问题来避免和绕过的。事实上多数的优化都是针对网络通信这个部分在做。

    4、建立TCP连接后发起http请求

    5、服务器端响应http请求,浏览器得到html代码

    以上是网络通信部分,接下来将会对页面渲染部分进行叙述。

    • 当浏览器拿到HTML文档时首先会进行HTML文档解析,构建DOM树。
    • 遇到css样式如link标签或者style标签时开始解析css,构建样式树。HTML解析构建和CSS的解析是相互独立的并不会造成冲突,因此我们通常将css样式放在head中,让浏览器尽早解析css。
    • 当html的解析遇到script标签会怎样呢?答案是停止DOM树的解析开始下载js。因为js是会阻塞html解析的,是阻塞资源。其原因在于js可能会改变html现有结构。例如有的节点是用js动态构建的,在这种情况下就会停止dom树的构建开始下载解析js。脚本在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。而因此就会推迟页面首绘的时间。可以在首绘不需要js的情况下用async和defer实现异步加载。这样js就不会阻塞html的解析了。当HTML解析完成后,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”事件将随之触发。

    注意,异步执行是指下载。执行js时仍然会阻塞。

    • 在得到DOM树和样式树后就可以进行渲染树的构建了。应注意的是渲染树和 DOM 元素相对应的,但并非一一对应。比如非可视化的 DOM 元素不会插入呈现树中,例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在呈现树中(但是 visibility 属性值为“hidden”的元素仍会显示)

    clipboard.png

    渲染树构建完毕后将会进行布局。布局使用流模型的Layout算法。所谓流模型,即是指Layout的过程只需进行一遍即可完成,后出现在流中的元素不会影响前出现在流中的元素,Layout过程只需从左至右从上至下一遍完成即可。但实际实现中,流模型会有例外。Layout是一个递归的过程,每个节点都负责自己及其子节点的Layout。Layout结果是相对父节点的坐标和尺寸。其过程可以简述为:

    clipboard.png

    • 此时renderTree已经构建完毕,不过浏览器渲染树引擎并不直接使用渲染树进行绘制,为了方便处理定位(裁剪),溢出滚动(页内滚动),CSS转换/不透明/动画/滤镜,蒙版或反射,Z (Z排序)等,浏览器需要生成另外一棵树 - 层树。因此绘制过程如下:1、获取 DOM 并将其分割为多个层(RenderLayer) 2、将每个层栅格化,并独立的绘制进位图中 3、将这些位图作为纹理上传至 GPU 4、复合多个层来生成最终的屏幕图像(终极layer)。

    三、HTML及CSS样式的解析

    HTML解析是一个将字节转化为字符,字符解析为标记,标记生成节点,节点构建树的过程。。CSS样式的解析则由于复杂的样式层叠而变得复杂。对此不同的渲染引擎在处理上有所差异,后文将会就这点进行详细讲解

    1、HTML的解析分为标记化和树构建两个阶段

    标记化算法:

    是词法分析过程,将输入内容解析成多个标记。HTML标记包括起始标记、结束标记、属性名称和属性值。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。
    该算法的输出结果是 HTML 标记。该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。这意味着,即使接收的字符相同,对于下一个正确的状态也会产生不同的结果,具体取决于当前的状态。
    树构建算法:

    在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。
    标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为“插入模式”。

    以下将会举一个例子来分析这两个阶段:

    clipboard.png

    标记化:初始状态是数据状态。

    • 遇到字符 < 时,状态更改为“标记打开状态”。接收一个 a-z字符会创建“起始标记”,状态更改为“标记名称状态”。这个状态会一直保持到接收> 字符。在此期间接收的每个字符都会附加到新的标记名称上。在本例中,我们创建的标记是 html 标记。
    • 遇到 > 标记时,会发送当前的标记,状态改回“数据状态”。 标记也会进行同样的处理。目前 html 和 body 标记均已发出。现在我们回到“数据状态”。接收到 Hello world 中的 H 字符时,将创建并发送字符标记,直到接收</body> 中的<。我们将为 Hello world 中的每个字符都发送一个字符标记。
    • 现在我们回到“标记打开状态”。接收下一个输入字符 / 时,会创建 end tag token 并改为“标记名称状态”。我们会再次保持这个状态,直到接收 >。然后将发送新的标记,并回到“数据状态”。 输入也会进行同样的处理。

    还是以上的例子,我们来看看树构建

    树构建:树构建阶段的输入是一个来自标记化阶段的标记序列。

    • 第一个模式是“initial mode”。接收 HTML 标记后转为“before html”模式,并在这个模式下重新处理此标记。这样会创建一个 HTMLHtmlElement 元素,并将其附加到 Document 根对象上。
    • 然后状态将改为“before head”。此时我们接收“body”标记。即使我们的示例中没有“head”标记,系统也会隐式创建一个 HTMLHeadElement,并将其添加到树中。
    • 现在我们进入了“in head”模式,然后转入“after head”模式。系统对 body 标记进行重新处理,创建并插入 HTMLBodyElement,同时模式转变为“body”。
    • 现在,接收由“Hello world”字符串生成的一系列字符标记。接收第一个字符时会创建并插入“Text”节点,而其他字符也将附加到该节点
    • 接收 body 结束标记会触发“after body”模式。现在我们将接收 HTML 结束标记,然后进入“after after body”模式。接收到文件结束标记后,解析过程就此结束。解析结束后的操作

    在此阶段,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”事件将随之触发。

    完整解析过程如下图:

    clipboard.png

    2、CSS的解析与层叠规则

    每一个呈现器都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,这一点在 CSS2 规范中有所描述。它包含诸如宽度、高度和位置等几何信息。就是我们 CSS 里常提到的盒子模型。构建呈现树时,需要计算每一个呈现对象的可视化属性。这是通过计算每个元素的样式属性来完成的。由于应用规则涉及到相当复杂的层叠规则,所以给样式树的构建造成了巨大的困难。为什么说它复杂?因为同一个元素可能涉及多条样式,就需要判断最终到底哪条样式生效。首先我们来了解一下css的样式层叠规则

    ①层叠规则:

    根据不同的样式来源优先级排列从小到大:

    • 1>、用户端声明:来自浏览器的样式,被称作 UA style,是浏览器默认的样式。 比如,对于 DIV 元素,浏览器默认其 ‘display’ 的特性值是 “block”,而 SPAN 是 “inline”。
    • 2>、一般用户声明:这个样式表是使用浏览器的用户,根据自己的偏好设置的样式表。比如,用户希望所有 P 元素中的字体都默认显示成蓝色,可以先定义一个样式表,存成 css 文件。
    • 3>、一般作者声明:即开发者在开发网页时,所定义的样式表。
    • 4>、加了’!important’ 的作者声明
    • 5>、加了’!important’ 的用户声明

    !important 规则1:根据 CSS2.1 规范中的描述,’!important’ 可以提高样式的优先级,它对样式优先级的影响是巨大的。
    注意,’!important’ 规则在 IE7 以前的版本中是被支持不完善。因此,经常被用作 CSS hack2。

    如果来源和重要性相同则根据CSS specificity来进行判定。

    特殊性的值可以看作是一个由四个数组成的一个组合,用 a,b,c,d 来表示它的四个位置。 依次比较 a,b,c,d 这个四个数比较其特殊性的大小。比如,a 值相同,那么 b 值大的组合特殊性会较大,以此类推。 注意,W3C 中并不是把它作为一个 4 位数来看待的。
    a,b,c,d 值的确定规则:

    • 如果 HTML 标签的 ‘style’ 属性中该样式存在,则记 a 为 1;
    • 数一下选择器中 ID 选择器的个数作为 b 的值。比如,样式中包含 ‘#c1’ 和 ‘#c2’ 的选择器;
    • 其他属性以及伪类(pseudo-classes)的总数量是 c 的值。比如’.con’,’:hover’ 等;
    • 元素名和伪元素的数量是 d 的值

    在这里我们来看一个W3C给出的例子:

    clipboard.png

    那么在如下例子中字体的显示应当为绿色:

    clipboard.png

    总结为表格的话计算规则如下:

    clipboard.png

    ②CSS解析

    为了简化样式计算,Firefox 还采用了另外两种树:规则树和样式上下文树。Webkit 也有样式对象,但它们不是保存在类似样式上下文树这样的树结构中,只是由 DOM 节点指向此类对象的相关样式。

    1>、Firefox的规则树和样式上下文树:

    样式上下文包含端值。要计算出这些值,应按照正确顺序应用所有的匹配规则,并将其从逻辑值转化为具体的值。例如,如果逻辑值是屏幕大小的百分比,则需要换算成绝对的单位。规则树的点子真的很巧妙,它使得节点之间可以共享这些值,以避免重复计算,还可以节约空间。
    所有匹配的规则都存储在树中。路径中的底层节点拥有较高的优先级。规则树包含了所有已知规则匹配的路径。规则的存储是延迟进行的。规则树不会在开始的时候就为所有的节点进行计算,而是只有当某个节点样式需要进行计算时,才会向规则树添加计算的路径。
    这个想法相当于将规则树路径视为词典中的单词。如果我们已经计算出如下的规则树:

    clipboard.png

    假设我们需要为内容树中的另一个元素匹配规则,并且找到匹配路径是 B - E - I(按照此顺序)。由于我们在树中已经计算出了路径 A - B - E - I - L,因此就已经有了此路径,这就减少了现在所需的工作量。

    那么Firefox是如何解决样式计算难题的呢?接下来看一个样例,假设我们有如下HTML代码:

    clipboard.png

    并且我们有如下规则:

    clipboard.png

    为了简便起见,我们只需要填充两个结构:color 结构和 margin 结构。color 结构只包含一个成员(即“color”),而 margin 结构包含四条边。
    形成的规则树如下图所示(节点的标记方式为“节点名 : 指向的规则序号”):

    clipboard.png

    上下文树如下图所示(节点名 : 指向的规则节点):

    clipboard.png

    假设我们解析 HTML 时遇到了第二个 <div> 标记,我们需要为此节点创建样式上下文,并填充其样式结构。
    经过规则匹配,我们发现该 <div> 的匹配规则是第 1、2 和 6 条。这意味着规则树中已有一条路径可供我们的元素使用,我们只需要再为其添加一个节点以匹配第 6 条规则(规则树中的 F 节点)。
    我们将创建样式上下文并将其放入上下文树中。新的样式上下文将指向规则树中的 F 节点。
    现在我们需要填充样式结构。首先要填充的是 margin 结构。由于最后的规则节点 (F) 并没有添加到 margin 结构,我们需要上溯规则树,直至找到在先前节点插入中计算过的缓存结构,然后使用该结构。我们会在指定 margin 规则的最上层节点(即 B 节点)上找到该结构。
    我们已经有了 color 结构的定义,因此不能使用缓存的结构。由于 color 有一个属性,我们无需上溯规则树以填充其他属性。我们将计算端值(将字符串转化为 RGB 等)并在此节点上缓存经过计算的结构。
    第二个 元素处理起来更加简单。我们将匹配规则,最终发现它和之前的 span 一样指向规则 G。由于我们找到了指向同一节点的同级,就可以共享整个样式上下文了,只需指向之前 span 的上下文即可。
    对于包含了继承自父代的规则的结构,缓存是在上下文树中进行的(事实上 color 属性是继承的,但是 Firefox 将其视为 reset 属性,并缓存到规则树上)。
    例如,如果我们在某个段落中添加 font 规则:

    clipboard.png

    那么,该段落元素作为上下文树中的 div 的子代,就会共享与其父代相同的 font 结构(前提是该段落没有指定 font 规则)。

    2>、Webkit的样式解析

    在 Webkit 中没有规则树,因此会对匹配的声明遍历 4 次。首先应用非重要高优先级的属性(由于作为其他属性的依据而应首先应用的属性,例如 display),接着是高优先级重要规则,然后是普通优先级非重要规则,最后是普通优先级重要规则。这意味着多次出现的属性会根据正确的层叠顺序进行解析。最后出现的最终生效。

    四、渲染树的构建

    样式树和DOM树连接在一起形成一个渲染树,渲染树用来计算可见元素的布局并且作为将像素渲染到屏幕上的过程的输入。值得一提的是,Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。Webkit 使用的术语是“渲染树”,它由“呈现对象”组成。 Webkit 和 Gecko 使用的术语略有不同,但整体流程是基本相同的。

    接下来将来看一下两种渲染引擎的工作流程:
    Webkit 主流程:

    ![clipboard.png

    Mozilla 的 Gecko 呈现引擎主流程

    clipboard.png

    虽然 Webkit 和 Gecko 使用的术语略有不同,但整体流程是基本相同的。

    Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。
    Webkit 使用的术语是“呈现树”,它由“呈现对象”组成。
    对于元素的放置,Webkit 使用的术语是“布局”,而 Gecko 称之为“重排”。
    对于连接 DOM 节点和可视化信息从而创建呈现树的过程,Webkit 使用的术语是“附加”。有一个细微的非语义差别,就是 Gecko 在 HTML 与 DOM 树之间还有一个称为“内容槽”的层,用于生成 DOM 元素。我们会逐一论述流程中的每一部分。

    五、关于浏览器渲染过程中需要了解的概念

    Repaint(重绘)——屏幕的一部分要重画,比如某个CSS的背景色变了。但是元素的几何尺寸没有变。
    Reflow(重排)——意味着元件的几何尺寸变了,我们需要重新验证并计算Render Tree。是Render Tree的一部分或全部发生了变化。这就是Reflow,或是Layout。reflow 会从这个root frame开始递归往下,依次计算所有的结点几何尺寸和位置,在reflow过程中,可能会增加一些frame,比如一个文本字符串必需被包装起来。
    onload事件——当 onload 事件触发时,页面上所有的DOM,样式表,脚本,图片,flash都已经加载完成了。
    DOMContentLoaded事件——当 DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片,flash。
    首屏时间——当浏览器显示第一屏页面所消耗的时间,在国内的网络条件下,通常一个网站,如果“首屏时间”在2秒以内是比较优秀的,5秒以内用户可以接受,10秒以上就不可容忍了。
    白屏时间——指浏览器开始显示内容的时间。但是在传统的采集方式里,是在HTML的头部标签结尾里记录时间戳,来计算白屏时间。在这个时刻,浏览器开始解析身体标签内的内容。而现代浏览器不会等待CSS树(所有CSS文件下载和解析完成)和DOM树(整个身体标签解析完成)构建完成才开始绘制,而是马上开始显示中间结果。所以经常在低网速的环境中,观察到页面由上至下缓慢显示完,或者先显示文本内容后再重绘成带有格式的页面内容。

    六、页面优化方案

    本文的主题在于从浏览器的渲染过程谈页面优化。了解浏览器如何通信并将拿到的数据如何进行解析渲染,本节将从网络通信、页面渲染、资源预取及如何除了以上方案外,如何借助chrome来针对一个页面进行实战优化四个方面来谈。

    从网络通信过程入手可以做的优化

    减少DNS查找

    每一次主机名解析都需要一次网络往返,从而增加请求的延迟时间,同时还会阻塞后续请求。

    重用TCP连接

    尽可能使用持久连接,以消除 TCP 握手和慢启动延迟;

    减少HTTP重定向

    HTTP 重定向极费时间,特别是不同域名之间的重定向,更加费时;这里面既有额外的 DNS 查询、TCP 握手,还有其他延迟。最佳的重定向次数为零。

    使用 CDN(内容分发网络)

    把数据放到离用户地理位置更近的地方,可以显著减少每次 TCP 连接的网络延迟,增大吞吐量。

    去掉不必要的资源

    任何请求都不如没有请求快。说到这,所有建议都无需解释。延迟是瓶颈,最快的速度莫过于什么也不传输。然而,HTTP 也提供了很多额外的机制,比如缓存和压缩,还有与其版本对应的一些性能技巧。

    在客户端缓存资源

    应该缓存应用资源,从而避免每次请求都发送相同的内容。(浏览器缓存)

    传输压缩过的内容

    传输前应该压缩应用资源,把要传输的字节减至最少:确保每种要传输的资源采用最好的压缩手段。(Gzip,减少60%~80%的文件大小)

    消除不必要的请求开销

    减少请求的 HTTP 首部数据(比如HTTPcookie),节省的时间相当于几次往返的延迟时间。

    并行处理请求和响应

    请求和响应的排队都会导致延迟,无论是客户端还是服务器端。这一点经常被忽视,但却会无谓地导致很长延迟。

    针对协议版本采取优化措施

    HTTP 1.x 支持有限的并行机制,要求打包资源、跨域分散资源,等等。相对而言,
    HTTP 2.0 只要建立一个连接就能实现最优性能,同时无需针对 HTTP 1.x 的那些优化方法。
    但是压缩、使用缓存、减少dns等的优化方案无论在哪个版本都同样适用

    你需要了解的资源预取

    preload :可以对当前页面所需的脚本、样式等资源进行预加载,而无需等到解析到 script 和 link 标签时才进行加载。这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。
    用法文档:

    https://developer.mozilla.org...

    prefetch:prefetch 和 preload 一样,都是对资源进行预加载,但是 prefetch 一般预加载的是其他页面会用到的资源。 当然,prefetch 不会像 preload 一样,在页面渲染的时候加载资源,而是利用浏览器空闲时间来下载。当进入下一页面,就可直接从 disk cache 里面取,既不影响当前页面的渲染,又提高了其他页面加载渲染的速度。
    用法文档:

    https://developer.mozilla.org...

    subresource: 被Chrome支持了有一段时间,并且已经有些搔到预加载当前导航/页面(所含有的资源)的痒处了。但它有一个问题——没有办法处理所获取内容的优先级(as也并不存在),所以最终,这些资源会以一个相当低的优先级被加载,这使得它能提供的帮助相当有限

    prerender:prerender 就像是在后台打开了一个隐藏的 tab,会下载所有的资源、创建DOM、渲染页面、执行js等等。如果用户进入指定的链接,隐藏的这个页面就会立马进入用户的视线。 但是要注意,一定要在十分确定用户会点击某个链接时才使用该特性,否则客户端会无端的下载很多资源和渲染这个页面。 正如任何提前动作一样,预判总是有一定风险出错。如果提前的动作是昂贵的(比如高CPU、耗电、占用带宽),就要谨慎使用了。

    preconnect: preconnect 允许浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括

    dns-prefetch:通过 DNS 预解析来告诉浏览器未来我们可能从某个特定的 URL 获取资源,当浏览器真正使用到该域中的某个资源时就可以尽快地完成 DNS 解析

    这些属性虽然并非所有浏览器都支持,但是不支持的浏览器也只是不处理而已,而是别的话则会省去很多时间。因此,合理的使用资源预取可以显著提高页面性能。

    高效合理的css选择符可以减轻浏览器的解析负担。

    因为css是逆向解析的所以应当避免多层嵌套。

    避免使用通配规则。如 *{} 计算次数惊人!只对需要用到的元素进行选择

    尽量少的去对标签进行选择,而是用class。如:#nav li{},可以为li加上nav_item的类名,如下选择.nav_item{}

    不要去用标签限定ID或者类选择符。如:ul#nav,应该简化为#nav

    尽量少的去使用后代选择器,降低选择器的权重值。后代选择器的开销是最高的,尽量将选择器的深度降到最低,最高不要超过三层,更多的使用类来关联每一个标签元素。

    考虑继承。了解哪些属性是可以通过继承而来的,然后避免对这些属性重复指定规则

    从js层面谈页面优化

    ①解决渲染阻塞
    如果在解析HTML标记时,浏览器遇到了JavaScript,解析会停止。只有在该脚本执行完毕后,HTML渲染才会继续进行。所以这阻塞了页面的渲染。
    解决方法:在标签中使用 async或defer特性
    ②减少对DOM的操作
    对DOM操作的代价是高昂的,这在网页应用中的通常是一个性能瓶颈。
    解决办法:修改和访问DOM元素会造成页面的Repaint和Reflow,循环对DOM操作更是罪恶的行为。所以请合理的使用JavaScript变量储存内容,考虑大量DOM元素中循环的性能开销,在循环结束时一次性写入。
    减少对DOM元素的查询和修改,查询时可将其赋值给局部变量。
    ③使用JSON格式来进行数据交换
    JSON是一种轻量级的数据交换格式,采用完全独立于语言的文本格式,是理想的数据交换格式。同时,JSON是 JavaScript原生格式,这意味着在 JavaScript 中处理 JSON数据不需要任何特殊的 API 或工具包。
    ④让需要经常改动的节点脱离文档流
    因为重绘有时确实不可避免,所以只能尽可能限制重绘的影响范围。

    如何借助chrome针对性优化页面

    首先打开控制台,点击Audits一栏,会看到如下表单。在选取自己需要模拟测试的情况后点击run audits,即可开始页面性能分析。

    clipboard.png

    然后将会得到分析结果及优化建议:

    clipboard.png

    我们可以逐项根据现有问题进行优化,如性能类目(performance)中的第一项优化建议延迟加载屏幕外图像(defer offscreen images),点击后就能看到详情以下详情:

    clipboard.png

    而具体页面的指标优化可以根据给出的建议进行逐条优化。目前提供的性能分析及建议的列表包括性能分析、渐进式web应用、最佳实践、无障碍访问及搜索引擎优化五个部分。基本上涵盖了常见优化方案及性能点的方方面面,开发时合理使用也能更好的提升页面性能

    相信以上优化方案之所以行之有效的原因大都可以在本文中找出原因。理论是用来指导实践的,即不能闭门造车式的埋头苦干,也不能毫不实践的夸夸其谈。这样才会形成完整的知识体系,让知识体系树更加庞大。知道该如何优化是一回事,真正合理应用是另一回事,要有好的性能,要着手于能做的每一件“小事”。

    七、附录

    性能优化是一门艺术,更是一门综合艺术。这其中涉及很多知识点。而这些知识点都有很多不错的文章进行了总结。如果你想深入探究或许这里推荐的文章会给你启发。

    HTTP2详解:

    https://www.jianshu.com/p/e57...
    TCP拥塞控制:

    https://www.cnblogs.com/losby...
    页面性能分析网站:

    https://gtmetrix.com/analyze....
    Timing官方文档:

    https://www.w3.org/TR/navigat...
    chrome中的高性能网络:

    https://www.cnblogs.com/xuan5...

    查看原文

    赞 237 收藏 181 评论 3

    酱菜 发布了文章 · 2019-06-12

    尘埃落定——鹅厂暑期实习面经

    博主 3 月份在腾讯官网投递 Web 前端开发岗,一路从提前批走到正式批,战线长达3个月...真的不容易(菜是原罪),特此记录下征战鹅厂的面经,希望可以帮到大家。

    提前批

    一面

    官网显示流程——初试
    3.14(电话面 + 视频面)

    1. 自我介绍,怎么学前端的
    2. CSS 常见两列布局、三列布局
    3. CSS 水平垂直居中
    4. 闭包,JS 没有闭包的话会怎么样
    5. typeof 和 instanceof
    6. js 的原型链,继承
    7. js 的 bind、apply、call 有什么区别
    8. var、let、const的区别
    9. new 操作符原理(手动实现 new 给出思路)
    10. 箭头函数,箭头函数 this 问题,箭头函数是否可以被 new
    11. promise 知道吗,手写一个 promise 怎么写(说思路)
    12. promise.all 应用场景
    13. promise 和 async/await 的区别
    14. vue 的生命周期(我说我 React 比较熟)
    15. react 的生命周期(React16)
    16. react 性能优化
    17. react 的 diff 算法
    18. react 的 Fiber 架构
    19. 状态码 304(强缓存和协商缓存)
    20. 你有什么要问的吗?

    面完加了面试官 qq,第二天qq远程视频,手写原生 DOM 拖拽和大数相加
    一面大概 1 个小时左右,比较注重 JS 和 CSS 的基础能力。面试官人很温和,通过后立马打电话和我说,这种尘埃落定的感觉真好~

    面试完官网状态从初试变成复试~

    二面

    3.20(电话面)

    1. 自我介绍,说说项目遇到的坑
    2. 看你项目 ES6 用的比较多,说说 ES6 的一些新特性
    3. 有没有考虑对图片处理的优化手段,说说常用的
    4. 图片懒加载怎么做
    5. 考虑过缓存方面的优化吗,强缓存和协商缓存区别
    6. 防抖和节流
    7. 实现无缝滑屏,你觉得怎么实现
    8. dns 查询原理
    9. tcp 握手和挥手
    10. tcp 和 udp 区别,udp 使用场景
    11. https 和 http 区别
    12. http2.0 相比 1.0 好在哪
    13. 抓包会吗,抓包原理,fiddler 用过吗
    14. 跨域
    15. csrf、xss,如何预防
    16. 项目的 webpack 配置
    17. plugin 和 loader 的区别
    18. 写过 webpack 的插件吗(没写过)
    19. webpack 单路口和多路口打包配置,为什么需要多路口
    20. babel 的编译原理,抽象语法树
    21. 你有什么要问的吗?

    二面聊了很久,一个多小时,面试官非常厉害(应该是部门组长),整体处于被碾压的情况...然后面试官说可能会有三面,让我再准备下
    感觉答得很一般,当时回去恶补了 webpack 以及性能优化的东西..

    面试完官网状态依旧是复试..

    三面

    3.28(视频面)

    1. 自我介绍,为什么会选择学前端呢?
    2. 看你做了挺多项目,有没有在架构层面上考虑过对项目的优化
    3. MVC MVP MVVM 架构了解吗,他们的使用场景
    4. 怎么理解前后端分离思想
    5. 和后端一般是怎么沟通和联调的
    6. 网络安全
    7. 看你用过 nginx,聊聊 nginx 吧
    8. docker 也用过?(不是很熟还是别往简历上写给自己挖坑啦..)
    9. 后端技术栈了解哪些
    10. 有什么想问的吗?

    三面大概半小时...面试官应该是部门技术总监,问的问题非常广,从大的架构层面往小的技术方面问,由广度到深度
    整体气氛比较深沉...和巨佬聊技术有点格格不入T_T,巨佬说会考核下,然后让我等电话..

    后来..后来..官网流程就灰了...电话呢?! 提前批——挂。

    但好在比较幸运是,在面腾讯之前拿到爱奇艺的实习机会~ 所以也不至于无路可走T_T
    在等正式批的这段时间里,白天在公司上班,晚上恶补基础,在恶补的过程中,才发现自己之前面试答得简直一坨shi ...很多需要深挖的知识点,渐渐感觉时间不太够用(..•˘_˘•..)

    正式批

    26号突然收到面试邮件,约了 28 号晚上 7 点的面试..
    赶紧到官网查看流程,灰了一个多月,终于亮起来了!! 感动!!

    一面

    官网面试流程——初试
    4.28(电话面)

    1. 自我介绍,在校情况
    2. 圣杯布局、双飞翼布局
    3. CSS 媒体查询
    4. CSS 动画、CSS 对网页性能优化
    5. 浏览器渲染原理、回流与重绘
    6. JS 单线程、EventLoop、宏队列、微队列
    7. Go 语言知道吗? 为什么 Go 效率比较高? (只是了解,效率高大概是因为多线程?)
    8. Ajax 和 Fetch
    9. 怎么同时让多个异步请求并行?
    10. 跨域问题
    11. xss 和 csrf (聊到跨域基本都会聊跨域安全问题,所以这两个知识点可以一起准备)
    12. session 和 cookie
    13. 服务器怎么知道 session 过期?
    14. 怎么设置 cookie 过期时间
    15. sessionStorage 和 localStorage
    16. 强缓存和协商缓存
    17. ES6 数组新增方法
    18. ES6 箭头函数和普通函数区别
    19. promise、generator、async/await
    20. react 父子组件传参
    21. PureComponent 知道吗
    22. React 性能优化
    23. Redux 原理,Redux 源码看过吗? Redux 中间件呢?

    正式批一面了大概1个半小时... 全程没喝一口水... 自我感觉答得还行,面试官也说还不错hh
    但是...但是...第二天看官网居然灰了????
    当时心里拔凉拔凉的,晚上没吃饭没洗澡躺尸,亏我准备这么久

    但是过了两天,突然又接到电话,是正式批一面面试官打来的..........
    他说他的部门(IEG)HC不够了..把我调剂到另外的部门(PCG)去了...然后要重新启动流程,所以把我灰了..让我赶紧准备另一个部门的面试

    所以又开始了艰难的——走流程..
    不得不吐槽鹅厂流程太长了!! 也有可能是自己太菜...排名比较靠后

    正式批补录

    一面

    官网面试流程回到初试..
    5.07(电话面)

    1. 自我介绍,看你简历,以前是写Java的?
    2. 那你觉得 java 里的继承和 JS 里的继承有什么区别
    3. JS 垃圾回收
    4. JS EventLoop
    5. ES6 新特性
    6. 知道装饰器吗
    7. 数组方法 map、filter、reduce
    8. 新数据结构 Set、Map
    9. babel 的编译原理
    10. webpack 工作流程和原理,怎么写一个插件
    11. JS 基础还行,问问网络相关知识?(好..)
    12. 从 url 到页面渲染过程
    13. 你刚说到 DNS 解析 能详细说说嘛? DNS 递归和迭代的区别呢?
    14. TCP ? UDP ? 区别是什么,你说 TCP 头部很大,具体有哪些报文信息呢?
    15. 页面渲染 重绘与重排 页面加载如何优化
    16. http1.1 / http2.0 / https
    17. 聊聊数据结构的东西吧 算法怎么样?(一般..)
    18. 栈、队列、树、图一些基础
    19. 最短路径算法能简单聊聊实现吗 (迪杰斯特拉算法)
    20. 树的深度优先遍历、广度优先遍历实现和区别
    21. 一棵二叉树要用数组存储,这棵树要具备哪种条件? (完全二叉树)
    22. 实现括号匹配用数据结构怎么做?说说思路 (栈)
    23. 快速排序原理
    24. 有什么想问我的? (实习在什么事业部,具体做什么?手Q,做手Q新业务)

    一面大概1个多小时,面试官比较严肃,也很厉害... 问的问题拓展性很强。
    而且非常注重基础, 数据结构、计算机网络...很重要!
    虽然感觉答得一般,但面试官评价还行...运气真好

    二面

    5.13(电话面)

    1. 自我介绍,说说你学前端的历程吧
    2. 说说项目中遇到的坑,怎么解决的
    3. 项目中有考虑到哪些优化的地方?
    4. 小程序的富文本为什么选用 wxParse,富文本原理
    5. 图片有哪些格式,知道 WebP 格式的图片吗,图片的一些优化手段
    6. 图片懒加载原理
    7. 跨域
    8. 前端常见攻击方式
    9. 状态码
    10. 强缓存和协商缓存
    11. Node 的优势
    12. Express 和 Koa 区别
    13. react 路由原理
    14. react hooks
    15. redux 异步中间件实现原理
    16. Vue MVVM原理
    17. 服务端渲染原理
    18. nginx 的配置,反向代理、负载均衡原理
    19. 知道 PWA 吗
    20. hybrid 技术
    21. Flutter 了解吗
    22. 看过源码吗?
    23. 有什么想问的?

    二面聊了一个多小时,面试体验很棒!面试官人超好,不断的引导我回答出答案...
    最后还说帮我约三面的面试官,给人感觉很暖!
    后来看官网状态从初试变成复试~

    三面

    5.22(视频面)

    1. 自我介绍,说说项目踩坑
    2. 使用框架踩到坑时,有没有去看过源码?
    3. 在做项目时,有没有从架构层面考虑过?
    4. 我现在有个需求,需要实现一个 web 端的微信,你想想该怎么实现
    5. 怎么看待前后端分离思想,以及服务端渲染技术
    6. 写过脚手架吗?
    7. 了解过设计模式吗?
    8. 后端的技术栈有了解吗?
    9. 平时是怎么学习的,学习习惯,为什么学前端?
    10. 你有什么想问的?

    三面聊了半个小时,面试官是部门技术总监,看上去很和蔼...一直在安静听我说
    面试感觉就像在一起聊天,也没有提前批的那种沉重感...大概是被虐多了,习以为常~
    面试完官网状态秒变成 HR 面试!!

    HR面

    5.27 (电话面)

    1. 自我介绍
    2. 家庭情况
    3. 平时是怎么学习的
    4. 说一个你做的最好的项目~
    5. 怎么看待现在的互联网趋势 (我说5G可能是第四次工业革命的导火索...)
    6. 如果要来深圳的话,方便吗
    7. 你有什么想问的吗?

    HR 是一个小哥哥,人很好,声音很好听,面试了10分钟左右
    最后还说会帮忙催 offer,真的很感谢他!

    OC

    6.11 (拿到offer)
    oc真的等的很着急...好在终于拿到了 offer!!

    我的经验

    1. 实习并不难,实习不是社招,比起社招难度小得多!基础很重要,面试官基本是看你的基础能力和你的发展潜力。
    2. 简历很重要,一个好的简历可以让面试官快速了解你,当然记住不要给自己挖坑。
    3. 沟通很重要,不会就说不会,毕竟不可能啥都会,实事求是。然后尽量引导面试官向你会的问题上问,多准备点亮点,例如框架源码,新技术等..
    4. 电话面试时,可以录音,回过头听听面试官的问题,慢慢积累面经。

    博主期间也面试了阿里、京东、头条... 以后有时间再总结下面经给大家分享
    最后祝大家都能顺利拿下心仪 offer! 我的 github 面经

    查看原文

    赞 102 收藏 74 评论 19

    酱菜 赞了文章 · 2019-05-05

    react工程搭建系列之---移动端适配与antd-mobile高清适配方案

    一、逻辑像素(css像素)与物理像素(设备像素)

    机型逻辑像素物理像素Scale Factor
    iphone 3GS320 x 480320 x 4801x
    iphone 4320 x 480640 x 9602x
    iphone 4S320 x 480640 x 9602x
    iphone 5320 x 568640 x 11362x
    iphone 5C320 x 568640 x 11362x
    iphone 5S320 x 568640 x 11362x
    iphone 5SE320 x 568640 x 11362x
    iphone 6375 x 667750 x 11342x
    iphone 6P414 x 7361080 x 19202.6x
    iphone 6S375 x 667750 x 11342x
    iphone 6SP414 x 7361080 x 19202.6x
    iphone 7375 x 667750 x 11342x
    iphone 7P414 x 7361080 x 19202.6x
    • 设备像素:设备硬件的物理像素
    • 逻辑像素:软件所支持的像素
    • dpr(Device Pixel Ratio: Number of device pixels per CSS Pixel): 设备像素比
      也叫dppx 就是一个css像素控制几个物理像素,物理分辨率/逻辑分辨率(css分辨率)= dpr
    • iphone 3GS,可以看到一个逻辑像素是由一个物理像素构成,随着技术发展出现了Retina屏使得设备分辨率提高一倍,一个逻辑像素可以由 (640/320)* (960/480) = 4个物理像素构成,这样屏幕看起来更清晰

    图片描述

    二、三种viewport

    1.the visual viewport

    the visual viewport是在屏幕上显示页面的一部分,用户可以滚动以更改他看到的页面部分,或者缩放以更改可视视口的大小
    图片描述

    the visual viewport的大小等于window.innerWidth/Height

    2.the layout viewport

    css布局尤其是百分比宽是相对于the layout viewport来计算的,the layout viewport比the visual viewport宽的多。
    浏览器会控制layout viewport尺寸使其在完全缩小的情况下覆盖整个屏幕,这时the visual viewport=the layout viewport
    图片描述

    因此,the layout viewport的宽度和高度等于在最大缩小模式下可以在屏幕上显示的任何宽度和高度。当用户放大这些尺寸时保持不变
    图片描述

    the layout viewport的大小等于document.documentElement.clientWidth/Height

    3. the ideal viewport

    它为每个设备上的web页面提供了一个理想尺寸,每个设备的理想尺寸都会不同。在非Retina屏的时代,the ideal viewport等于物理像素数,但这不是必须的。具有高物理像素密度的新型设备任然保留了原有的ideal viewport,因为它非常适合设备。
    4S以上版本包含4S,iPhone理想的视口是320x480,无论它是否有视网膜屏幕。那是因为320x480是这些iPhone上web页面的理想尺寸。

    关于ideal viewport有两点很关键:

    1. the layout viewport可以被设置成the ideal viewport,使用meta标签的The width=device-width 和initial-scale=1指令实现
    2. 所有的scale指令是相对于the ideal viewport而言,不管the layout viewport拥有多大的宽度,因此maximum-scale=3 意味着web页面可以放大到the ideal viewport的300%

    三、meta viewport

    1.meta viewport标签

    meta viewport标签包含有关视口(viewports)和缩放(zooming)的浏览器指令。特别是,它允许Web开发人员设置layout viewport的宽度,这个宽度直接影响到width:20%这样的css声明的计算

    meta viewport标签具有以下语法:

    <meta name="viewport" content="name=value,name=value">

    2.指令

    viewport mata标签的每一对name/value都是一条指令。总共有6条指令:

    1. width: 用来设置layout viewport的宽度。
    2. initial-scale: 用来设置页面的初始缩放值以及layout viewport的宽度。
    3. minimum-scale: 用来设置允许的最小缩放值(例如,用户可以缩小至什么程度)。
    4. maximum-scale: 用来设置允许的最大缩放值(例如,用户可以放大至什么程度)。
    5. height: 期望用于设置layout viewport的高度。但一直没被支持。
    6. user-scalable: 当设置为no时,则禁止用户进行缩放。

    3.device-width值

    width指令有一个特殊的值:device-width。它能将layout viewport的宽度设置成ideal viewport宽度。 理论上同样有一个类似的device-height值,但实际上这个值并不起作用。

    四、缩放对viewport的影响

    1.缩放

    缩放是棘手的。理论上讲很简单:确定用户可以放大或缩小的缩放系数(zoom factor)。这里存在两个问题:

    1. 我们不能够直接读取缩放系数,而是需要读取visual viewport的宽度,它与缩放系数成反比关系。缩放系数越大,visual viewport的宽度越小。因此,最小缩放系数决定了最大visual viewport宽度,反之亦然。
    2. 事实证明,无论layout viewport的当前大小是什么,所有缩放因子都相对于ideal viewport

    因此关于缩放这个名字的问题,缩放实际上是比例,而viewport meta的指令称之为initial-scale、minimum-scale、maximum-scale。其它浏览器为了保持和针对iPhone适配的网站兼容也只好被迫实现了这些指令。
    这三个指令期望一个缩放因子,例如2意味着“缩放到ideal viewport宽度的200%”

    2.公式

    visual viewport width = ideal viewport width / zoom factor
    zoom factor = ideal viewport width / visual viewport width

    3.理解

    我们先来梳理一下:

    • 我们平时开发的css是基于layout viewport来计算
    • 在完全缩小的情况下layout viewport=visual viewport
    • 使用meta viewport的width指令设置layout viewport的宽,当width=device-width 和initial-scale=1时,layout viewport=ideal viewport。以iphone4S为例,ideal width是320,此时layout viewport也是320,初始缩放系数是1也就是没有缩放
    • 当用户进行缩放的时候layout viewport是不会变的,visual viewport与缩放系数成反比

    我的理解:

    这里以手机拍照为例,用手机后置摄像头拍摄电脑上的一个网页,调整手机与电脑之间的距离使得整个页面刚好拍进手机里,但是网页变小了,这时layout viewport=visual viewport,这种情况就叫做完全缩小
    缩放系数,如果我想让网页变大,调近手机与电脑之间的距离,这时网页变大了,但是网页看不全了也就是可视区域变小了;同理我想让网页变小,那么调远手机与电脑之间的距离,这时网页变小了,但是网页能看到的东西多了,也就是可视区域变大了

    五、移动端适配方案

    1.目前行业内流行几种适配方法

    • JS根据屏幕动态计算 使用js判断页面宽度算出页面应有的font-size
    • 媒体查询 使用媒体查询 来兼容不同尺寸屏幕 设置不同尺寸下的rem大小
    • flex布局 CSS3中提出的新布局方案 移动端的兼容性较好

    使用rem作为移动端尺寸单位替代px,那么1rem=?px
    目前1rem有三种方案:

    1. 1rem=16px
      这个是默认的大小
    2. 1rem= 75px
      这个是手淘团队在flexible方案中在iphone6中的显示结果
      flexible方案核心就是根据屏幕的dpr和尺寸 动态算出当前页的rem大小 动态的修改meta标签
      该方案目前也被应用在手淘首页中
    3. 1rem=100px
      这个是阿里旗下的蚂蚁金服在Ant-mobile中的方案
      ant-mobile也有自己高清解决方案 其核心跟flexible类似
      现应用于ant-mobile中

    如果项目中使用的是1rem=16px,又集成了antd-mobile,那么就会导致antd-mobile中的组件特别小,这就面临着方案转换的问题,如果我们在项目的样式中以rem作为单位,现在是16px转100px,如果以后用75px那么所有的样式文件中rem就都需要进行转换工程量很大。所以,样式文件中我们还使用px作为单位,然后使用插件将px转成rem,这样就算有方案转换,我们也只需要修改插件中的配置和一些脚本文件

    图片描述

    六、高清适配方案

    1.在public/index.html中删除meta viewport标签,然后用下列代码动态生成meta viewport标签

    'use strict';
    
    /**
     * @param {Number} [baseFontSize = 100] - 基础fontSize, 默认100px;
     * @param {Number} [fontscale = 1] - 有的业务希望能放大一定比例的字体;
     */
    const win = window;
    export default win.flex = (baseFontSize, fontscale) => {
      const _baseFontSize = baseFontSize || 100;
      const _fontscale = fontscale || 1;
    
      const doc = win.document;
      const ua = navigator.userAgent;
      const matches = ua.match(/Android[\S\s]+AppleWebkit\/(\d{3})/i);
      const UCversion = ua.match(/U3\/((\d+|\.){5,})/i);
      const isUCHd = UCversion && parseInt(UCversion[1].split('.').join(''), 10) >= 80;
      const isIos = navigator.appVersion.match(/(iphone|ipad|ipod)/gi);
      let dpr = win.devicePixelRatio || 1;
      if (!isIos && !(matches && matches[1] > 534) && !isUCHd) {
        // 如果非iOS, 非Android4.3以上, 非UC内核, 就不执行高清, dpr设为1;
        dpr = 1;
      }
      const scale = 1 / dpr;
    
      let metaEl = doc.querySelector('meta[name="viewport"]');
      if (!metaEl) {
        metaEl = doc.createElement('meta');
        metaEl.setAttribute('name', 'viewport');
        doc.head.appendChild(metaEl);
      }
      metaEl.setAttribute('content', `width=device-width,user-scalable=no,initial-scale=${scale},maximum-scale=${scale},minimum-scale=${scale}`);
      doc.documentElement.style.fontSize = `${_baseFontSize / 2 * dpr * _fontscale}px`;
    };
    flex(100, 1);

    代码理解:以iphone6为准,dpr=2,scale=1/2,fontSize=100;由之前介绍的viewport可以知道,scale=1/2那么visual viewport=2*ideal viewport

    2.修改package.json

    "theme": {
        "hd": "2px",
        "brand-primary": "red",
        "color-text-base": "#333"
      },

    3.修改config-overrides.js在webpack配置中使用 postcss-pxtorem 把 px 转成 rem 单位

    安装react-app-rewire-postcss

    npm install react-app-rewire-postcss --save-dev

    配置postcss,完整代码如下:

    const { injectBabelPlugin, getLoader } = require('react-app-rewired');
    const rewirePostcss = require('react-app-rewire-postcss');
    const pxtorem = require('postcss-pxtorem');
    const autoprefixer = require('autoprefixer');
    const theme = require('./package.json').theme;
    const fileLoaderMatcher = function (rule) {
        return rule.loader && rule.loader.indexOf(`file-loader`) != -1;
    }
    module.exports = function override(config, env) {
        // do stuff with the webpack config...
        config = injectBabelPlugin(['import', {
            libraryName: 'antd-mobile',
            // style: 'css',
            style: true, // use less for customized theme
        }], config);
        console.log(config.module.rules[2].oneOf);
    
        // sass
        config.module.rules[2].oneOf.unshift(
            {
                test: /\.scss$/,
                use: [
                    require.resolve('style-loader'),
                    require.resolve('css-loader'),
                    require.resolve('sass-loader'),
                    {
                        loader: require.resolve('postcss-loader'),
                        options: {
                            // Necessary for external CSS imports to work
                            // https://github.com/facebookincubator/create-react-app/issues/2677
                            ident: 'postcss',
                            plugins: () => [
                                require('postcss-flexbugs-fixes'),
                                autoprefixer({
                                    browsers: [
                                        '>1%',
                                        'last 4 versions',
                                        'Firefox ESR',
                                        'not ie < 9', // React doesn't support IE8 anyway
                                    ],
                                    flexbox: 'no-2009',
                                })
                            ],
                        },
                    }
                ]
            }
        );
        //less
        config.module.rules[2].oneOf.unshift(
            {
                test: /\.less$/,
                use: [
                    require.resolve('style-loader'),
                    require.resolve('css-loader'),
                    {
                        loader: require.resolve('postcss-loader'),
                        options: {
                            // Necessary for external CSS imports to work
                            // https://github.com/facebookincubator/create-react-app/issues/2677
                            ident: 'postcss',
                            plugins: () => [
                                require('postcss-flexbugs-fixes'),
                                autoprefixer({
                                    browsers: [
                                        '>1%',
                                        'last 4 versions',
                                        'Firefox ESR',
                                        'not ie < 9', // React doesn't support IE8 anyway
                                    ],
                                    flexbox: 'no-2009',
                                }),
                            ],
                        },
                    },
                    {
                        loader: require.resolve('less-loader'),
                        options: {
                            // theme vars, also can use theme.js instead of this.
                            modifyVars: theme,
                        },
                    },
                ]
            }
        );
    
        config = rewirePostcss(config,{
            plugins: () => [
                require('postcss-flexbugs-fixes'),
                require('postcss-preset-env')({
                    autoprefixer: {
                        flexbox: 'no-2009',
                    },
                    stage: 3,
                }),
                pxtorem({
                    rootValue: 100,    //以100px为准,不同方案修改这里
                    propWhiteList: [],
                })
            ],
        });
    
        // file-loader exclude
        let l = getLoader(config.module.rules, fileLoaderMatcher);
        l.exclude.push(/\.scss$/);
        l.exclude.push(/\.less$/);
        return config;
    };

    将src/App.css改成scss格式并修改App.js如下:

    /*src/App.scss*/
    .App {
      text-align: center;
      .App-Button{
        width: 750px;
        height: 88px;
      }
    }
    /*src/App.js*/
    import React, { Component } from 'react';
    import './App.scss';
    import {Button} from 'antd-mobile';
    
    class App extends Component {
      render() {
        return (
          <div className="App">
              <Button type='primary' className='App-Button'>{document.documentElement.clientWidth}</Button>
          </div>
        );
      }
    }
    
    export default App;

    最终运行结果如下图:

    图片描述

    可以看到px已经被转换成rem了,layout viewport = 750px

    项目地址:https://github.com/SuRuiGit/m...

    查看原文

    赞 25 收藏 18 评论 8

    酱菜 发布了文章 · 2019-04-05

    React Loops 尝鲜

    一个简单的Loops

    我们先来实现一个简单的需求,使用 React 打印 数组 中的数据并显示

    相信聪明的你早已想到了解决方案,以下是我的实现

    import React from 'react'
    
    const List = () => {
      const arr = [1, 2, 3, 4]
      
      return (
        <ul>
          {
            arr.map((item, index) => <li key={index}>{item}</li>) // 需要带上 key 属性
          }
        </ul>
      )
    }
    
    export default List

    使用 ES6 的 map 方法遍历并返回 <li></li> 结构

    我们再来看看使用 loops 的版本

    使用 loops 前我们首先要安装 react-loops
    npm install react-loops 或者 yarn add react-loops
    import React from 'react'
    import { For } from 'react-loops' // 引入 react-loops
    
    const List = () => {
      const arr = [1, 2, 3, 4]
    
      return (
        <div>
          <For of={arr} as={item => <li>{item}</li>} /> // 可省略 key 属性
        </div>
      )
    }
    
    export default List

    你会发现使用 loops 后,只是替换了之前的 map 方法,以一个 <For /> 标签的形式来遍历数据,of 属性中写入需要遍历项,as 则类似于 map 方法中的回调函数。但其实内部做了很多优化和封装。
    值得注意的是,在使用第一种方法时,为了 diff 算法优化,我们必须带上 key 属性,否则浏览器会报警告错误。

    而使用 loops 后,其内部会为每项自动注入 key 属性,省去我们手动操作或是遗漏带来的麻烦。

    为什么会有Loops

    增强代码语义化

    loops 译为'循环',顾名思义是为了优化 React 中的循环操作。 它以其独特的 <For /> 标签的形式,使团队开发的代码风格更加统一化,并具有良好的可读性。而loops 的出现,本身是受到了 Angular(ng-repeat)Vue(v-for) 指令语法的启发,熟悉 AngularVue 的朋友们应该会有既视感。三大框架的发展本质就是相互学(抄)习(袭)和启发的过程。

    让For标签一统循环界江湖

    我们知道在 JS 中有许多可遍历结构,除数组之外,还有伪数组、对象、Map、Set...可迭代结构,这个时候我们需要针对不同存储结构来进行遍历方法的选择。比如我们现在修改一下之前的需求

    使用 React 打印 对象 中的数据并显示

    此处思考一分钟,聪明的你可以想想如何实现。

    我们知道对象身上是没有 map 方法的,这时我们需要先将对象的 key 转为一个数组的集合,再使用 map 方法来进行操作。

    import React from 'react'
    
    const List = () => {
      const obj = {
        name: 'z',
        age: 20
      }
    
      return (
        <ul>
          {
            // Object.keys(obj) => ['name', 'age']
            Object.keys(obj).map(item => <li key={item}>{obj[item]}</li>) // 需要带上 key 属性
          }
        </ul>
      )
    }
    
    export default List

    又或者我们可以使用 for in 循环来遍历这个对象得出遍历结果并打印。可以发现遍历的方法有很多种,而 loops就是为了统一循环风格而生,可遍历项均可以使用一个 <For /> 标签来实现,值得注意的是,在遍历 对象 时,我们需要用 <For /> 标签上的 in 属性,这个后面详细说明。

    import React from 'react'
    import { For } from 'react-loops'
    
    const List = () => {
      const obj = {
        name: 'z',
        age: 20,
      }
    
      return (
        <div>
          // 遍历对象时,需要使用 in 属性
          <For in={obj} as={item => <li>{item}</li>} /> // 可省略 key 属性
        </div>
      )
    }
    
    export default List

    怎样使用Loops

    在使用 loops 时,分为两种情况

    • 遍历数组、伪数组、Iterables(可迭代对象 Map、Set等)
    • 遍历对象

    For-of Loops

    在使用 of 属性时,我们可以接收数组、伪数组、Iterables(可迭代对象 Map、Set等)

    const arr = [1, 2, 3, 4] // 可以接收数组
    const arrLike = { 0: 'z', 1: 'h', length: 2 } // 可接收伪数组
    const setLoop = new Set([1, 2, 3, 4]) // 可接收 Set
    const mapLoop = new Map([['name', 'z'], ['age', 20]) // 可接收 Map
    
    return (
      <div>
        <For of={arr} as={item => <li>{item}</li>} />
      </div>
    )

    as 属性则类似于 map 方法的回调函数,当然你也可以不用 as 属性,这时需要将回调函数嵌套在 <For></For>

    return (
      <For of={arr}>
        {item => <li>{item}</li>}
      </For>
    )

    当然 as 这个回调函数在 map 方法之上又做了一层封装,其第二个参数非常特别,它给我们提供了一些与遍历有关的属性。

    return (
      <div>
        // metadata 为回调函数的第二个参数
        <For of={arr} as={(item, metadata) => {
          console.log(metadata) // 打印输出 metadata
          
          return <li>{item}</li>
        }} />
      </div>
    )

    我们先打印输出下第二个参数

    • index --- 遍历项标记值,从0开始,类似于 map((item, index) => {}) 中的 index
    • isFirst --- 是否为第一项
    • isLast --- 是否为最后一项
    • key --- 遍历项的键,数组为其下标,对象为对象的 key
    • length --- 遍历项数目

    有了这些属性,我们可以通过解构的方式来取出需要使用的数据。例如我们要手动添加 key 时,便可以从第二个参数中取出

    return (
      <div>
        // {key} 解构出 key 的值
        <For of={arr} as={(item, {key}) => <li key={key}>{item}</li>} />
      </div>
    )

    For-in Loops

    在我们需要遍历一个对象时,需要使用 For-in 的结构 <For in={obj} />
    还是引用上述例子

    const List = () => {
      const obj = {
        name: 'z',
        age: 20,
      }
    
      return (
        <div>
          // 遍历对象时,需要使用 in 属性
          <For in={obj} as={item => <li>{item}</li>} />
        </div>
      )
    }

    原因其实很简单,我们可以类比于 for in 循环

    const obj = {
      name: 'z',
      age: 20,
    }
    
    for(let key in obj) {
      ...
    }

    如果我们尝试用 For-of 来遍历对象(伪数组除外),会发现浏览器报错

    意思就是 For-of 只对数组、伪数组、Iterables(可迭代对象 Map、Set等)有效。
    我们再尝试看看源码是如何判断

    function For(props) {
      ...
      // 如果使用的是 of 属性
      var list = props.of;
        
      if (!list) {
        return null;
      }
        
      // 判断是否为数组
      if (!Array.isArray(list)) {
        // 不是数组就判断是否为集合 ( 伪数组 和 Iterable(Map、Set) )
        if (!iterall.isCollection(list)) {
          // 不是数组,也不是集合,则抛出异常
          throw new TypeError(
            "<For> `of` expects an Array, Array-like, or Iterable collection"
          );
        }
        
        // 是数组,将用新数组存放
        var array = [];
        iterall.forEach(list, function(item) {
          array.push(item);
        });
        list = array;
      }
        
      ...
    }
    
    // 判断是否为集合
    function isCollection(obj) {
      // 如果是对象,但对象只能是 伪数组 和 Iterable(Map、Set)
      return Object(obj) === obj && (isArrayLike(obj) || isIterable(obj));
    }

    结尾

    通过这几个小例子的学习,可以发现 loops 确实是非常简单而且好用的 react 遍历工具。是轮子就有它存在的意义,如果你厌倦了手写JS遍历,不妨也来尝尝 loops,或许你就会喜欢上这个小而巧的库。

    react-loops 地址:react-loops
    demo 地址:github

    查看原文

    赞 11 收藏 7 评论 0

    酱菜 赞了文章 · 2019-02-17

    九种跨域方式实现原理(完整版)

    前言

    前后端数据交互经常会碰到请求跨域,什么是跨域,以及有哪几种跨域方式,这是本文要探讨的内容。

    本文完整的源代码请猛戳github博客,纸上得来终觉浅,建议动手敲敲代码

    一、什么是跨域?

    1.什么是同源策略及其限制内容?

    同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSRF等攻击。所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。
    url的组成
    同源策略限制内容有:

    • Cookie、LocalStorage、IndexedDB 等存储性内容
    • DOM 节点
    • AJAX 请求发送后,结果被浏览器拦截了

    但是有三个标签是允许跨域加载资源:

    • <img data-original=XXX>
    • <link href=XXX>
    • <script data-original=XXX>

    2.常见跨域场景

    当协议、子域名、主域名、端口号中任意一个不相同时,都算作不同域。不同域之间相互请求资源,就算作“跨域”。常见跨域场景如下图所示:

    特别说明两点:

    第一:如果是协议和端口造成的跨域问题“前台”是无能为力的。

    第二:在跨域问题上,仅仅是通过“URL的首部”来识别而不会根据域名对应的IP地址是否相同来判断。“URL的首部”可以理解为“协议, 域名和端口必须匹配”

    这里你或许有个疑问:请求跨域了,那么请求到底发出去没有?

    跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。你可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会?因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。同时也说明了跨域并不能完全阻止 CSRF,因为请求毕竟是发出去了。

    二、跨域解决方案

    1.jsonp

    1) JSONP原理

    利用 <script> 标签没有跨域限制的漏洞,网页可以得到从其他来源动态产生的 JSON 数据。JSONP请求一定需要对方的服务器做支持才可以。

    2) JSONP和AJAX对比

    JSONP和AJAX相同,都是客户端向服务器端发送请求,从服务器端获取数据的方式。但AJAX属于同源策略,JSONP属于非同源策略(跨域请求)

    3) JSONP优缺点

    JSONP优点是简单兼容性好,可用于解决主流浏览器的跨域数据访问的问题。缺点是仅支持get方法具有局限性,不安全可能会遭受XSS攻击。

    4) JSONP的实现流程

    • 声明一个回调函数,其函数名(如show)当做参数值,要传递给跨域请求数据的服务器,函数形参为要获取目标数据(服务器返回的data)。
    • 创建一个<script>标签,把那个跨域的API数据接口地址,赋值给script的src,还要在这个地址中向服务器传递该函数名(可以通过问号传参:?callback=show)。
    • 服务器接收到请求后,需要进行特殊的处理:把传递进来的函数名和它需要给你的数据拼接成一个字符串,例如:传递进去的函数名是show,它准备好的数据是show('我不爱你')
    • 最后服务器把准备的数据通过HTTP协议返回给客户端,客户端再调用执行之前声明的回调函数(show),对返回的数据进行操作。

    在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP函数。

    // index.html
    function jsonp({ url, params, callback }) {
      return new Promise((resolve, reject) => {
        let script = document.createElement('script')
        window[callback] = function(data) {
          resolve(data)
          document.body.removeChild(script)
        }
        params = { ...params, callback } // wd=b&callback=show
        let arrs = []
        for (let key in params) {
          arrs.push(`${key}=${params[key]}`)
        }
        script.src = `${url}?${arrs.join('&')}`
        document.body.appendChild(script)
      })
    }
    jsonp({
      url: 'http://localhost:3000/say',
      params: { wd: 'Iloveyou' },
      callback: 'show'
    }).then(data => {
      console.log(data)
    })

    上面这段代码相当于向http://localhost:3000/say?wd=Iloveyou&callback=show这个地址请求数据,然后后台返回show('我不爱你'),最后会运行show()这个函数,打印出'我不爱你'

    // server.js
    let express = require('express')
    let app = express()
    app.get('/say', function(req, res) {
      let { wd, callback } = req.query
      console.log(wd) // Iloveyou
      console.log(callback) // show
      res.end(`${callback}('我不爱你')`)
    })
    app.listen(3000)

    5) jQuery的jsonp形式

    JSONP都是GET和异步请求的,不存在其他的请求方式和同步请求,且jQuery默认就会给JSONP的请求清除缓存。

    $.ajax({
    url:"http://crossdomain.com/jsonServerResponse",
    dataType:"jsonp",
    type:"get",//可以省略
    jsonpCallback:"show",//->自定义传递给服务器的函数名,而不是使用jQuery自动生成的,可省略
    jsonp:"callback",//->把传递函数名的那个形参callback,可省略
    success:function (data){
    console.log(data);}
    });

    2.cors

    CORS 需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest 来实现

    浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了 CORS,就实现了跨域。

    服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。

    虽然设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分别为简单请求复杂请求

    1) 简单请求

    只要同时满足以下两大条件,就属于简单请求

    条件1:使用下列方法之一:

    • GET
    • HEAD
    • POST

    条件2:Content-Type 的值仅限于下列三者之一:

    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded

    请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器; XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

    2) 复杂请求

    不符合以上条件的请求就肯定是复杂请求了。
    复杂请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求,该请求是 option 方法的,通过该请求来知道服务端是否允许跨域请求。

    我们用PUT向后台请求时,属于复杂请求,后台需做如下配置:

    // 允许哪个方法访问我
    res.setHeader('Access-Control-Allow-Methods', 'PUT')
    // 预检的存活时间
    res.setHeader('Access-Control-Max-Age', 6)
    // OPTIONS请求不做任何处理
    if (req.method === 'OPTIONS') {
      res.end() 
    }
    // 定义后台返回的内容
    app.put('/getData', function(req, res) {
      console.log(req.headers)
      res.end('我不爱你')
    })

    接下来我们看下一个完整复杂请求的例子,并且介绍下CORS请求相关的字段

    // index.html
    let xhr = new XMLHttpRequest()
    document.cookie = 'name=xiamen' // cookie不能跨域
    xhr.withCredentials = true // 前端设置是否带cookie
    xhr.open('PUT', 'http://localhost:4000/getData', true)
    xhr.setRequestHeader('name', 'xiamen')
    xhr.onreadystatechange = function() {
      if (xhr.readyState === 4) {
        if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
          console.log(xhr.response)
          //得到响应头,后台需设置Access-Control-Expose-Headers
          console.log(xhr.getResponseHeader('name'))
        }
      }
    }
    xhr.send()
    //server1.js
    let express = require('express');
    let app = express();
    app.use(express.static(__dirname));
    app.listen(3000);
    //server2.js
    let express = require('express')
    let app = express()
    let whitList = ['http://localhost:3000'] //设置白名单
    app.use(function(req, res, next) {
      let origin = req.headers.origin
      if (whitList.includes(origin)) {
        // 设置哪个源可以访问我
        res.setHeader('Access-Control-Allow-Origin', origin)
        // 允许携带哪个头访问我
        res.setHeader('Access-Control-Allow-Headers', 'name')
        // 允许哪个方法访问我
        res.setHeader('Access-Control-Allow-Methods', 'PUT')
        // 允许携带cookie
        res.setHeader('Access-Control-Allow-Credentials', true)
        // 预检的存活时间
        res.setHeader('Access-Control-Max-Age', 6)
        // 允许返回的头
        res.setHeader('Access-Control-Expose-Headers', 'name')
        if (req.method === 'OPTIONS') {
          res.end() // OPTIONS请求不做任何处理
        }
      }
      next()
    })
    app.put('/getData', function(req, res) {
      console.log(req.headers)
      res.setHeader('name', 'jw') //返回一个响应头,后台需设置
      res.end('我不爱你')
    })
    app.get('/getData', function(req, res) {
      console.log(req.headers)
      res.end('我不爱你')
    })
    app.use(express.static(__dirname))
    app.listen(4000)

    上述代码由http://localhost:3000/index.htmlhttp://localhost:4000/跨域请求,正如我们上面所说的,后端是实现 CORS 通信的关键。

    3.postMessage

    postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题:

    • 页面和其打开的新窗口的数据传递
    • 多窗口之间消息传递
    • 页面与嵌套的iframe消息传递
    • 上面三个场景的跨域数据传递

    postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递

    otherWindow.postMessage(message, targetOrigin, [transfer]);
    • message: 将要发送到其他 window的数据。
    • targetOrigin:通过窗口的origin属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。
    • transfer(可选):是一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

    接下来我们看个例子: http://localhost:3000/a.html页面向http://localhost:4000/b.html传递“我爱你”,然后后者传回"我不爱你"。

    // a.html
      <iframe data-original="http://localhost:4000/b.html" frameborder="0" id="frame" onload="load()"></iframe> //等它加载完触发一个事件
      //内嵌在http://localhost:3000/a.html
        <script>
          function load() {
            let frame = document.getElementById('frame')
            frame.contentWindow.postMessage('我爱你', 'http://localhost:4000') //发送数据
            window.onmessage = function(e) { //接受返回数据
              console.log(e.data) //我不爱你
            }
          }
        </script>
    // b.html
      window.onmessage = function(e) {
        console.log(e.data) //我爱你
        e.source.postMessage('我不爱你', e.origin)
     }

    4.websocket

    Websocket是HTML5的一个持久化的协议,它实现了浏览器与服务器的全双工通信,同时也是跨域的一种解决方案。WebSocket和HTTP都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 的 server 与 client 都能主动向对方发送或接收数据。同时,WebSocket 在建立连接时需要借助 HTTP 协议,连接建立好了之后 client 与 server 之间的双向通信就与 HTTP 无关了。

    原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。

    我们先来看个例子:本地文件socket.html向localhost:3000发生数据和接受数据

    // socket.html
    <script>
        let socket = new WebSocket('ws://localhost:3000');
        socket.onopen = function () {
          socket.send('我爱你');//向服务器发送数据
        }
        socket.onmessage = function (e) {
          console.log(e.data);//接收服务器返回的数据
        }
    </script>
    // server.js
    let express = require('express');
    let app = express();
    let WebSocket = require('ws');//记得安装ws
    let wss = new WebSocket.Server({port:3000});
    wss.on('connection',function(ws) {
      ws.on('message', function (data) {
        console.log(data);
        ws.send('我不爱你')
      });
    })

    5. Node中间件代理(两次跨域)

    实现原理:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。
    代理服务器,需要做以下几个步骤:

    • 接受客户端请求 。
    • 将请求 转发给服务器。
    • 拿到服务器 响应 数据。
    • 将 响应 转发给客户端。

    我们先来看个例子:本地文件index.html文件,通过代理服务器http://localhost:3000向目标服务器http://localhost:4000请求数据。

    // index.html(http://127.0.0.1:5500)
     <script data-original="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
        <script>
          $.ajax({
            url: 'http://localhost:3000',
            type: 'post',
            data: { name: 'xiamen', password: '123456' },
            contentType: 'application/json;charset=utf-8',
            success: function(result) {
              console.log(result) // {"title":"fontend","password":"123456"}
            },
            error: function(msg) {
              console.log(msg)
            }
          })
         </script>
    // server1.js 代理服务器(http://localhost:3000)
    const http = require('http')
    // 第一步:接受客户端请求
    const server = http.createServer((request, response) => {
      // 代理服务器,直接和浏览器直接交互,需要设置CORS 的首部字段
      response.writeHead(200, {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': '*',
        'Access-Control-Allow-Headers': 'Content-Type'
      })
      // 第二步:将请求转发给服务器
      const proxyRequest = http
        .request(
          {
            host: '127.0.0.1',
            port: 4000,
            url: '/',
            method: request.method,
            headers: request.headers
          },
          serverResponse => {
            // 第三步:收到服务器的响应
            var body = ''
            serverResponse.on('data', chunk => {
              body += chunk
            })
            serverResponse.on('end', () => {
              console.log('The data is ' + body)
              // 第四步:将响应结果转发给浏览器
              response.end(body)
            })
          }
        )
        .end()
    })
    server.listen(3000, () => {
      console.log('The proxyServer is running at http://localhost:3000')
    })
    // server2.js(http://localhost:4000)
    const http = require('http')
    const data = { title: 'fontend', password: '123456' }
    const server = http.createServer((request, response) => {
      if (request.url === '/') {
        response.end(JSON.stringify(data))
      }
    })
    server.listen(4000, () => {
      console.log('The server is running at http://localhost:4000')
    })

    上述代码经过两次跨域,值得注意的是浏览器向代理服务器发送请求,也遵循同源策略,最后在index.html文件打印出{"title":"fontend","password":"123456"}

    6.nginx反向代理

    实现原理类似于Node中间件代理,需要你搭建一个中转nginx服务器,用于转发请求。

    使用nginx反向代理实现跨域,是最简单的跨域方式。只需要修改nginx的配置即可解决跨域问题,支持所有浏览器,支持session,不需要修改任何代码,并且不会影响服务器性能。

    实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。

    先下载nginx,然后将nginx目录下的nginx.conf修改如下:

    // proxy服务器
    server {
        listen       81;
        server_name  www.domain1.com;
        location / {
            proxy_pass   http://www.domain2.com:8080;  #反向代理
            proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
            index  index.html index.htm;
    
            # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
            add_header Access-Control-Allow-Origin http://www.domain1.com;  #当前端只跨域不带cookie时,可为*
            add_header Access-Control-Allow-Credentials true;
        }
    }

    最后通过命令行nginx -s reload启动nginx

    // index.html
    var xhr = new XMLHttpRequest();
    // 前端开关:浏览器是否读写cookie
    xhr.withCredentials = true;
    // 访问nginx中的代理服务器
    xhr.open('get', 'http://www.domain1.com:81/?user=admin', true);
    xhr.send();
    // server.js
    var http = require('http');
    var server = http.createServer();
    var qs = require('querystring');
    server.on('request', function(req, res) {
        var params = qs.parse(req.url.substring(2));
        // 向前台写cookie
        res.writeHead(200, {
            'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly'   // HttpOnly:脚本无法读取
        });
        res.write(JSON.stringify(params));
        res.end();
    });
    server.listen('8080');
    console.log('Server is running at port 8080...');

    7.window.name + iframe

    window.name属性的独特之处:name值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

    其中a.html和b.html是同域的,都是http://localhost:3000;而c.html是http://localhost:4000

     // a.html(http://localhost:3000/b.html)
      <iframe data-original="http://localhost:4000/c.html" frameborder="0" onload="load()" id="iframe"></iframe>
      <script>
        let first = true
        // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
        function load() {
          if(first){
          // 第1次onload(跨域页)成功后,切换到同域代理页面
            let iframe = document.getElementById('iframe');
            iframe.src = 'http://localhost:3000/b.html';
            first = false;
          }else{
          // 第2次onload(同域b.html页)成功后,读取同域window.name中数据
            console.log(iframe.contentWindow.name);
          }
        }
      </script>

    b.html为中间代理页,与a.html同域,内容为空。

     // c.html(http://localhost:4000/c.html)
      <script>
        window.name = '我不爱你'  
      </script>

    总结:通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

    8.location.hash + iframe

    实现原理: a.html欲与c.html跨域相互通信,通过中间页b.html来实现。 三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。

    具体实现步骤:一开始a.html给c.html传一个hash值,然后c.html收到hash值后,再把hash值传递给b.html,最后b.html将结果放到a.html的hash值中。
    同样的,a.html和b.html是同域的,都是http://localhost:3000;而c.html是http://localhost:4000

     // a.html
      <iframe data-original="http://localhost:4000/c.html#iloveyou"></iframe>
      <script>
        window.onhashchange = function () { //检测hash的变化
          console.log(location.hash);
        }
      </script>
     // b.html
      <script>
        window.parent.parent.location.hash = location.hash 
        //b.html将结果放到a.html的hash值中,b.html可通过parent.parent访问a.html页面
      </script>
     // c.html
     console.log(location.hash);
      let iframe = document.createElement('iframe');
      iframe.src = 'http://localhost:3000/b.html#idontloveyou';
      document.body.appendChild(iframe);

    9.document.domain + iframe

    该方式只能用于二级域名相同的情况下,比如 a.test.comb.test.com 适用于该方式
    只需要给页面添加 document.domain ='test.com' 表示二级域名都相同就可以实现跨域。

    实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。

    我们看个例子:页面a.zf1.cn:3000/a.html获取页面b.zf1.cn:3000/b.html中a的值

    // a.html
    <body>
     helloa
      <iframe data-original="http://b.zf1.cn:3000/b.html" frameborder="0" onload="load()" id="frame"></iframe>
      <script>
        document.domain = 'zf1.cn'
        function load() {
          console.log(frame.contentWindow.a);
        }
      </script>
    </body>
    // b.html
    <body>
       hellob
       <script>
         document.domain = 'zf1.cn'
         var a = 100;
       </script>
    </body>

    三、总结

    • CORS支持所有类型的HTTP请求,是跨域HTTP请求的根本解决方案
    • JSONP只支持GET请求,JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。
    • 不管是Node中间件代理还是nginx反向代理,主要是通过同源策略对服务器不加限制。
    • 日常工作中,用得比较多的跨域方案是cors和nginx反向代理

    给大家推荐一个好用的BUG监控工具Fundebug,欢迎免费试用!

    参考文章

    查看原文

    赞 401 收藏 310 评论 5

    认证与成就

    • 获得 876 次点赞
    • 获得 7 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 6 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    (゚∀゚ )
    暂时没有

    注册于 2018-07-21
    个人主页被 1.4k 人浏览