axuebin

axuebin 查看完整档案

杭州编辑中国计量学院  |  计算机应用技术 编辑某公司  |  前端开发工程师 编辑 axuebin.com 编辑
编辑

阿里国际化团队基础架构组招聘前端 P6/P7,base 杭州,基础设施建设,业务赋能... 很多事情可以做。要求熟悉 工程化/ Node/ React... 可直接发送简历至 yibin.xb@alibaba-inc.com,或者添加微信 xb9207 细聊。

个人动态

axuebin 赞了文章 · 9月2日

不知道怎么封装代码?看看这几种设计模式吧!

为什么要封装代码?

我们经常听说:“写代码要有良好的封装,要高内聚,低耦合”。那怎样才算良好的封装,我们为什么要封装呢?其实封装有这样几个好处:

  1. 封装好的代码,内部变量不会污染外部。
  2. 可以作为一个模块给外部调用。外部调用者不需要知道实现的细节,只需要按照约定的规范使用就行了。
  3. 对扩展开放,对修改关闭,即开闭原则。外部不能修改模块,既保证了模块内部的正确性,又可以留出扩展接口,使用灵活。

怎么封装代码?

JS生态已经有很多模块了,有些模块封装得非常好,我们使用起来很方便,比如jQuery,Vue等。如果我们仔细去看这些模块的源码,我们会发现他们的封装都是有规律可循的。这些规律总结起来就是设计模式,用于代码封装的设计模式主要有工厂模式创建者模式单例模式原型模式四种。下面我们结合一些框架源码来看看这四种设计模式:

工厂模式

工厂模式的名字就很直白,封装的模块就像一个工厂一样批量的产出需要的对象。常见工厂模式的一个特征就是调用的时候不需要使用new,而且传入的参数比较简单。但是调用次数可能比较频繁,经常需要产出不同的对象,频繁调用时不用new也方便很多。一个工厂模式的代码结构如下所示:

function factory(type) {
  switch(type) {
    case 'type1':
      return new Type1();
    case 'type2':
      return new Type2();
    case 'type3':
      return new Type3();
  }
}

上述代码中,我们传入了type,然后工厂根据不同的type来创建不同的对象。

实例: 弹窗组件

下面来看看用工厂模式的例子,假如我们有如下需求:

我们项目需要一个弹窗,弹窗有几种:消息型弹窗,确认型弹窗,取消型弹窗,他们的颜色和内容可能是不一样的。

针对这几种弹窗,我们先来分别建一个类:

function infoPopup(content, color) {}
function confirmPopup(content, color) {}
function cancelPopup(content, color) {}

如果我们直接使用这几个类,就是这样的:

let infoPopup1 = new infoPopup(content, color);
let infoPopup2 = new infoPopup(content, color);
let confirmPopup1 = new confirmPopup(content, color);
...

每次用的时候都要去new对应的弹窗类,我们用工厂模式改造下,就是这样:

// 新加一个方法popup把这几个类都包装起来
function popup(type, content, color) {
  switch(type) {
    case 'infoPopup':
      return new infoPopup(content, color);
    case 'confirmPopup':
      return new confirmPopup(content, color);
    case 'cancelPopup':
      return new cancelPopup(content, color);
  }
}

然后我们使用popup就不用new了,直接调用函数就行:

let infoPopup1 = popup('infoPopup', content, color); 

改造成面向对象

上述代码虽然实现了工厂模式,但是switch始终感觉不是很优雅。我们使用面向对象改造下popup,将它改为一个类,将不同类型的弹窗挂载在这个类上成为工厂方法:

function popup(type, content, color) {
  // 如果是通过new调用的,返回对应类型的弹窗
  if(this instanceof popup) {
    return new this[type](content, color);
  } else {
    // 如果不是new调用的,使用new调用,会走到上面那行代码
    return new popup(type, content, color);
  }
}

// 各种类型的弹窗全部挂载在原型上成为实例方法
popup.prototype.infoPopup = function(content, color) {}
popup.prototype.confirmPopup = function(content, color) {}
popup.prototype.cancelPopup = function(content, color) {}

封装成模块

这个popup不仅仅让我们调用的时候少了一个new,他其实还把相关的各种弹窗都封装在了里面,这个popup可以直接作为模块export出去给别人调用,也可以挂载在window上作为一个模块给别人调用。因为popup封装了弹窗的各种细节,即使以后popup内部改了,或者新增了弹窗类型,或者弹窗类的名字变了,只要保证对外的接口参数不变,对外面都没有影响。挂载在window上作为模块可以使用自执行函数:

(function(){
     function popup(type, content, color) {
    if(this instanceof popup) {
      return new this[type](content, color);
    } else {
      return new popup(type, content, color);
    }
  }

  popup.prototype.infoPopup = function(content, color) {}
  popup.prototype.confirmPopup = function(content, color) {}
  popup.prototype.cancelPopup = function(content, color) {}
  
  window.popup = popup;
})()

// 外面就直接可以使用popup模块了
let infoPopup1 = popup('infoPopup', content, color); 

jQuery的工厂模式

jQuery也是一个典型的工厂模式,你给他一个参数,他就给你返回符合参数DOM对象。那jQuery这种不用new的工厂模式是怎么实现的呢?其实就是jQuery内部帮你调用了new而已,jQuery的调用流程简化了就是这样:

(function(){
  var jQuery = function(selector) {
    return new jQuery.fn.init(selector);   // new一下init, init才是真正的构造函数
  }

  jQuery.fn = jQuery.prototype;     // jQuery.fn就是jQuery.prototype的简写

  jQuery.fn.init = function(selector) {
    // 这里面实现真正的构造函数
  }

  // 让init和jQuery的原型指向同一个对象,便于挂载实例方法
  jQuery.fn.init.prototype = jQuery.fn;  

  // 最后将jQuery挂载到window上
  window.$ = window.jQuery = jQuery;
})();

上述代码结构来自于jQuery源码,从中可以看出,你调用时省略的new在jQuery里面帮你调用了,目的是为了使大量调用更方便。但是这种结构需要借助一个init方法,最后还要将jQueryinit的原型绑在一起,其实还有一种更加简便的方法可以实现这个需求:

var jQuery = function(selector) {
  if(!(this instanceof jQuery)) {
    return new jQuery(selector);
  }
  
  // 下面进行真正构造函数的执行
}

上述代码就简洁多了,也可以实现不用new直接调用,这里利用的特性是this在函数被new调用时,指向的是new出来的对象,new出来的对象自然是类的instance,这里的this instanceof jQuery就是true。如果是普通调用,他就是false,我们就帮他new一下。

建造者模式

建造者模式是用于比较复杂的大对象的构建,比如VueVue内部包含一个功能强大,逻辑复杂的对象,在构建的时候也需要传很多参数进去。像这种需要创建的情况不多,创建的对象本身又很复杂的时候就适用建造者模式。建造者模式的一般结构如下:

function Model1() {}   // 模块1
function Model2() {}   // 模块2

// 最终使用的类
function Final() {
  this.model1 = new Model1();
  this.model2 = new Model2();
}

// 使用时
var obj = new Final();

上述代码中我们最终使用的是Final,但是Final里面的结构比较复杂,有很多个子模块,Final就是将这些子模块组合起来完成功能,这种需要精细化构造的就适用于建造者模式。

实例:编辑器插件

假设我们有这样一个需求:

写一个编辑器插件,初始化的时候需要配置大量参数,而且内部的功能很多很复杂,可以改变字体颜色和大小,也可以前进后退。

一般一个页面就只有一个编辑器,而且里面的功能可能很复杂,可能需要调整颜色,字体等。也就是说这个插件内部可能还会调用其他类,然后将他们组合起来实现功能,这就适合建造者模式。我们来分析下做这样一个编辑器需要哪些模块:

  1. 编辑器本身肯定需要一个类,是给外部调用的接口
  2. 需要一个控制参数初始化和页面渲染的类
  3. 需要一个控制字体的类
  4. 需要一个状态管理的类
// 编辑器本身,对外暴露
function Editor() {
  // 编辑器里面就是将各个模块组合起来实现功能
  this.initer = new HtmlInit();
  this.fontController = new FontController();
  this.stateController = new StateController(this.fontController);
}

// 初始化参数,渲染页面
function HtmlInit() {
  
}
HtmlInit.prototype.initStyle = function() {}     // 初始化样式
HtmlInit.prototype.renderDom = function() {}     // 渲染DOM

// 字体控制器
function FontController() {
  
}
FontController.prototype.changeFontColor = function() {}    // 改变字体颜色
FontController.prototype.changeFontSize = function() {}     // 改变字体大小

// 状态控制器
function StateController(fontController) {
  this.states = [];       // 一个数组,存储所有状态
  this.currentState = 0;  // 一个指针,指向当前状态
  this.fontController = fontController;    // 将字体管理器注入,便于改变状态的时候改变字体
}
StateController.prototype.saveState = function() {}     // 保存状态
StateController.prototype.backState = function() {}     // 后退状态
StateController.prototype.forwardState = function() {}     // 前进状态

上面的代码其实就将一个编辑器插件的架子搭起来了,具体实现功能就是往这些方法里面填入具体的内容就行了,其实就是各个模块的相互调用,比如我们要实现后退状态的功能就可以这样写:

StateController.prototype.backState = function() {
  var state = this.states[this.currentState - 1];  // 取出上一个状态
  this.fontController.changeFontColor(state.color);  // 改回上次颜色
  this.fontController.changeFontSize(state.size);    // 改回上次大小
}

单例模式

单例模式适用于全局只能有一个实例对象的场景,单例模式的一般结构如下:

function Singleton() {}

Singleton.getInstance = function() {
  if(this.instance) {
    return this.instance;
  }
  
  this.instance = new Singleton();
  return this.instance;
}

上述代码中,Singleton类挂载了一个静态方法getInstance,如果要获取实例对象只能通过这个方法拿,这个方法会检测是不是有现存的实例对象,如果有就返回,没有就新建一个。

实例:全局数据存储对象

假如我们现在有这样一个需求:

我们需要对一个全局的数据对象进行管理,这个对象只能有一个,如果有多个会导致数据不同步。

这个需求要求全局只有一个数据存储对象,是典型的适合单例模式的场景,我们可以直接套用上面的代码模板,但是上面的代码模板获取instance必须要调getInstance才行,要是某个使用者直接调了Singleton()或者new Singleton()就会出问题,这次我们换一种写法,让他能够兼容Singleton()new Singleton(),使用起来更加傻瓜化:

function store() {
  if(store.instance) {
    return store.instance;
  }
  
  store.instance = this;
}

上述代码支持使用new store()的方式调用,我们使用了一个静态变量instance来记录是否有进行过实例化,如果实例化了就返回这个实例,如果没有实例化说明是第一次调用,那就把this赋给这个这个静态变量,因为是使用new调用,这时候的this指向的就是实例化出来的对象,并且最后会隐式的返回this

如果我们还想支持store()直接调用,我们可以用前面工厂模式用过的方法,检测this是不是当前类的实例,如果不是就帮他用new调用就行了:

function store() {
  // 加一个instanceof检测
  if(!(this instanceof store)) {
    return new store();
  }
  
  // 下面跟前面一样的
  if(store.instance) {
    return store.instance;
  }
  
  store.instance = this;
}

然后我们用两种方式调用来检测下:

image-20200521154322364

实例:vue-router

vue-router其实也用到了单例模式,因为如果一个页面有多个路由对象,可能造成状态的冲突,vue-router的单例实现方式又有点不一样,下列代码来自vue-router源码

let _Vue;

function install(Vue) {
  if (install.installed && _Vue === Vue) return;
  install.installed = true

  _Vue = Vue
}

每次我们调用vue.use(vueRouter)的时候其实都会去执行vue-router模块的install方法,如果用户不小心多次调用了vue.use(vueRouter)就会造成install的多次执行,从而产生不对的结果。vue-routerinstall在第一次执行时,将installed属性写成了true,并且记录了当前的Vue,这样后面在同一个Vue里面再次执行install就会直接return了,这也是一种单例模式。

可以看到我们这里三种代码都是单例模式,他们虽然形式不一样,但是核心思想都是一样的,都是用一个变量来标记代码是否已经执行过了,如果执行过了就返回上次的执行结果,这样就保证了多次调用也会拿到一样的结果。

原型模式

原型模式最典型的应用就是JS本身啊,JS的原型链就是原型模式。JS中可以使用Object.create指定一个对象作为原型来创建对象:

const obj = {
  x: 1,
  func: () => {}
}

// 以obj为原型创建一个新对象
const newObj = Object.create(obj);

console.log(newObj.__proto__ === obj);    // true
console.log(newObj.x);    // 1

上述代码我们将obj作为原型,然后用Object.create创建的新对象都会拥有这个对象上的属性和方法,这其实就算是一种原型模式。还有JS的面向对象其实更加是这种模式的体现,比如JS的继承可以这样写:

function Parent() {
  this.parentAge = 50;
}
function Child() {}

Child.prototype = new Parent();
Child.prototype.constructor = Child;      // 注意重置constructor

const obj = new Child();
console.log(obj.parentAge);    // 50

这里的继承其实就是让子类Child.prototype.__proto__的指向父类的prototype,从而获取父类的方法和属性。JS中面向对象的内容较多,我这里不展开了,有一篇文章专门讲这个问题

总结

  1. 很多用起来顺手的开源库都有良好的封装,封装可以将内部环境和外部环境隔离,外部用起来更顺手。
  2. 针对不同的场景可以有不同的封装方案。
  3. 需要大量产生类似实例的组件可以考虑用工厂模式来封装。
  4. 内部逻辑较复杂,外部使用时需要的实例也不多,可以考虑用建造者模式来封装。
  5. 全局只能有一个实例的需要用单例模式来封装。
  6. 新老对象之间可能有继承关系的可以考虑用原型模式来封装,JS本身就是一个典型的原型模式。
  7. 使用设计模式时不要生搬硬套代码模板,更重要的是掌握思想,同一个模式在不同的场景可以有不同的实现方案。

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

本文主要素材来自于网易高级前端开发工程师微专业唐磊老师的设计模式视频课程。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 55 收藏 46 评论 0

axuebin 赞了文章 · 9月2日

从 rollup 初版源码学习打包原理

前言

为了学习 rollup 打包原理,我克隆了最新版(v2.26.5)的源码。然后发现打包器和我想像的不太一样,代码实在太多了,光看 d.ts 文件就看得头疼。为了看看源码到底有多少行,我写了个脚本,结果发现有 19650行,崩溃...

这就能打消我学习 rollup 的决心吗?不可能,退而求其次,我下载了 rollup 初版源码,才 1000 行左右。

我的目的是学习 rollup 怎么打包的,怎么做 tree-shaking 的。而初版源码已经实现了这两个功能(半成品),所以看初版源码已经足够了。

好了,下面开始正文。

正文

rollup 使用了 acornmagic-string 两个库。为了更好的阅读 rollup 源码,必须对它们有所了解。

下面我将简单的介绍一下这两个库的作用。

acorn

acorn 是一个 JavaScript 语法解析器,它将 JavaScript 字符串解析成语法抽象树 AST。

例如以下代码:

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

将被解析为:

{
    "type": "Program",
    "start": 0,
    "end": 50,
    "body": [
        {
            "type": "ExportDefaultDeclaration",
            "start": 0,
            "end": 50,
            "declaration": {
                "type": "FunctionDeclaration",
                "start": 15,
                "end": 50,
                "id": {
                    "type": "Identifier",
                    "start": 24,
                    "end": 27,
                    "name": "add"
                },
                "expression": false,
                "generator": false,
                "params": [
                    {
                        "type": "Identifier",
                        "start": 28,
                        "end": 29,
                        "name": "a"
                    },
                    {
                        "type": "Identifier",
                        "start": 31,
                        "end": 32,
                        "name": "b"
                    }
                ],
                "body": {
                    "type": "BlockStatement",
                    "start": 34,
                    "end": 50,
                    "body": [
                        {
                            "type": "ReturnStatement",
                            "start": 36,
                            "end": 48,
                            "argument": {
                                "type": "BinaryExpression",
                                "start": 43,
                                "end": 48,
                                "left": {
                                    "type": "Identifier",
                                    "start": 43,
                                    "end": 44,
                                    "name": "a"
                                },
                                "operator": "+",
                                "right": {
                                    "type": "Identifier",
                                    "start": 47,
                                    "end": 48,
                                    "name": "b"
                                }
                            }
                        }
                    ]
                }
            }
        }
    ],
    "sourceType": "module"
}

可以看到这个 AST 的类型为 program,表明这是一个程序。body 则包含了这个程序下面所有语句对应的 AST 子节点。

每个节点都有一个 type 类型,例如 Identifier,说明这个节点是一个标识符;BlockStatement 则表明节点是块语句;ReturnStatement 则是 return 语句。

如果想了解更多详情 AST 节点的信息可以看一下这篇文章《使用 Acorn 来解析 JavaScript》

magic-string

magic-string 也是 rollup 作者写的一个关于字符串操作的库。下面是 github 上的示例:

var MagicString = require( 'magic-string' );
var s = new MagicString( 'problems = 99' );

s.overwrite( 0, 8, 'answer' );
s.toString(); // 'answer = 99'

s.overwrite( 11, 13, '42' ); // character indices always refer to the original string
s.toString(); // 'answer = 42'

s.prepend( 'var ' ).append( ';' ); // most methods are chainable
s.toString(); // 'var answer = 42;'

var map = s.generateMap({
  source: 'source.js',
  file: 'converted.js.map',
  includeContent: true
}); // generates a v3 sourcemap

require( 'fs' ).writeFile( 'converted.js', s.toString() );
require( 'fs' ).writeFile( 'converted.js.map', map.toString() );

从示例中可以看出来,这个库主要是对字符串一些常用方法进行了封装。这里就不多做介绍了。

rollup 源码结构

│  bundle.js // Bundle 打包器,在打包过程中会生成一个 bundle 实例,用于收集其他模块的代码,最后再将收集的代码打包到一起。
│  external-module.js // ExternalModule 外部模块,例如引入了 'path' 模块,就会生成一个 ExternalModule 实例。
│  module.js // Module 模块,开发者自己写的代码文件,都是 module 实例。例如有 'foo.js' 文件,它就对应了一个 module 实例。
│  rollup.js // rollup 函数,一切的开始,调用它进行打包。
│
├─ast // ast 目录,包含了和 AST 相关的类和函数
│      analyse.js // 主要用于分析 AST 节点的作用域和依赖项。
│      Scope.js // 在分析 AST 节点时为每一个节点生成对应的 Scope 实例,主要是记录每个 AST 节点对应的作用域。
│      walk.js // walk 就是递归调用 AST 节点进行分析。
│
├─finalisers
│      cjs.js // 打包模式,目前只支持将代码打包成 common.js 格式
│      index.js
│
└─utils // 一些帮助函数
        map-helpers.js
        object.js
        promise.js
        replaceIdentifiers.js

上面是初版源码的目录结构,在继续深入前,请仔细阅读上面的注释,了解一下每个文件的作用。

rollup 如何打包的?

在 rollup 中,一个文件就是一个模块。每一个模块都会根据文件的代码生成一个 AST 语法抽象树,rollup 需要对每一个 AST 节点进行分析。

分析 AST 节点,就是看看这个节点有没有调用函数或方法。如果有,就查看所调用的函数或方法是否在当前作用域,如果不在就往上找,直到找到模块顶级作用域为止。

如果本模块都没找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入。

例如 import foo from './foo.js',其中 foo() 就得从 ./foo.js 文件找。

在引入 foo() 函数的过程中,如果发现 foo() 函数依赖其他模块,就会递归读取其他模块,如此循环直到没有依赖的模块为止。

最后将所有引入的代码打包在一起。

上面例子的示例图:

接下来我们从一个具体的示例开始,一步步分析 rollup 是如何打包的

以下两个文件是代码文件。

// main.js
import { foo1, foo2 } from './foo'

foo1()

function test() {
    const a = 1
}

console.log(test())
// foo.js
export function foo1() {}
export function foo2() {}

下面是测试代码:

const rollup = require('../dist/rollup')

rollup(__dirname + '/main.js').then(res => {
    res.wirte('bundle.js')
})

1. rollup 读取 main.js 入口文件。

rollup() 首先生成一个 Bundle 实例,也就是打包器。然后根据入口文件路径去读取文件,最后根据文件内容生成一个 Module 实例。

fs.readFile(path, 'utf-8', (err, code) => {
    if (err) reject(err)
    const module = new Module({
        code,
        path,
        bundle: this, // bundle 实例
    })
})

2. new Moudle() 过程

在 new 一个 Module 实例时,会调用 acorn 库的 parse() 方法将代码解析成 AST。

this.ast = parse(code, {
    ecmaVersion: 6, // 要解析的 JavaScript 的 ECMA 版本,这里按 ES6 解析
    sourceType: 'module', // sourceType值为 module 和 script。module 模式,可以使用 import/export 语法
})

接下来需要对生成的 AST 进行分析。

第一步,分析导入和导出的模块,将引入的模块和导出的模块填入对应的对象。

每个 Module 实例都有一个 importsexports 对象,作用是将该模块引入和导出的对象填进去,代码生成时要用到。

上述例子对应的 importsexports 为:

// key 为要引入的具体对象,value 为对应的 AST 节点内容。
imports = {
  foo1: { source: './foo', name: 'foo1', localName: 'foo1' },
  foo2: { source: './foo', name: 'foo2', localName: 'foo2' }
}
// 由于没有导出的对象,所以为空
exports = {}

第二步,分析每个 AST 节点间的作用域,找出每个 AST 节点定义的变量。

每遍历到一个 AST 节点,都会为它生成一个 Scope 实例。

// 作用域
class Scope {
    constructor(options = {}) {
        this.parent = options.parent // 父作用域
        this.depth = this.parent ? this.parent.depth + 1 : 0 // 作用域层级
        this.names = options.params || [] // 作用域内的变量
        this.isBlockScope = !!options.block // 是否块作用域
    }

    add(name, isBlockDeclaration) {
        if (!isBlockDeclaration && this.isBlockScope) {
            // it's a `var` or function declaration, and this
            // is a block scope, so we need to go up
            this.parent.add(name, isBlockDeclaration)
        } else {
            this.names.push(name)
        }
    }

    contains(name) {
        return !!this.findDefiningScope(name)
    }

    findDefiningScope(name) {
        if (this.names.includes(name)) {
            return this
        }

        if (this.parent) {
            return this.parent.findDefiningScope(name)
        }

        return null
    }
}

Scope 的作用很简单,它有一个 names 属性数组,用于保存这个 AST 节点内的变量。
例如下面这段代码:

function test() {
    const a = 1
}

打断点可以看出来,它生成的作用域对象,names 属性就会包含 a。并且因为它是模块下的一个函数,所以作用域层级为 1(模块顶级作用域为 0)。

第三步,分析标识符,并找出它们的依赖项。

什么是标识符?如变量名,函数名,属性名,都归为标识符。当解析到一个标识符时,rollup 会遍历它当前的作用域,看看有没这个标识符。如果没有找到,就往它的父级作用域找。如果一直找到模块顶级作用域都没找到,就说明这个函数、方法依赖于其它模块,需要从其他模块引入。如果一个函数、方法需要被引入,就将它添加到 Module_dependsOn 对象里。

例如 test() 函数中的变量 a,能在当前作用域找到,它就不是一个依赖项。foo1() 在当前模块作用域找不到,它就是一个依赖项。

打断点也能发现 Module_dependsOn 属性里就有 foo1

这就是 rollup 的 tree-shaking 原理。

rollup 不看你引入了什么函数,而是看你调用了什么函数。如果调用的函数不在此模块中,就从其它模块引入。

换句话说,如果你手动在模块顶部引入函数,但又没调用。rollup 是不会引入的。从我们的示例中可以看出,一共引入了 foo1()foo2() 两个函数,_dependsOn 里却只有 foo1(),因为引入的 foo2() 没有调用。

_dependsOn 有什么用呢?后面生成代码时会根据 _dependsOn 里的值来引入文件。

3. 根据依赖项,读取对应的文件。

_dependsOn 的值可以发现,我们需要引入 foo1() 函数。

这时第一步生成的 imports 就起作用了:

imports = {
  foo1: { source: './foo', name: 'foo1', localName: 'foo1' },
  foo2: { source: './foo', name: 'foo2', localName: 'foo2' }
}

rollup 将 foo1 当成 key,找到它对应的文件。然后读取这个文件生成一个新的 Module 实例。由于 foo.js 文件导出了两个函数,所以这个新 Module 实例的 exports 属性是这样的:

exports = {
  foo1: {
    node: Node {
      type: 'ExportNamedDeclaration',
      start: 0,
      end: 25,
      declaration: [Node],
      specifiers: [],
      source: null
    },
    localName: 'foo1',
    expression: Node {
      type: 'FunctionDeclaration',
      start: 7,
      end: 25,
      id: [Node],
      expression: false,
      generator: false,
      params: [],
      body: [Node]
    }
  },
  foo2: {
    node: Node {
      type: 'ExportNamedDeclaration',
      start: 27,
      end: 52,
      declaration: [Node],
      specifiers: [],
      source: null
    },
    localName: 'foo2',
    expression: Node {
      type: 'FunctionDeclaration',
      start: 34,
      end: 52,
      id: [Node],
      expression: false,
      generator: false,
      params: [],
      body: [Node]
    }
  }
}

这时,就会用 main.js 要导入的 foo1 当成 key 去匹配 foo.jsexports 对象。如果匹配成功,就把 foo1() 函数对应的 AST 节点提取出来,放到 Bundle 中。如果匹配失败,就会报错,提示 foo.js 没有导出这个函数。

4. 生成代码。

由于已经引入了所有的函数。这时需要调用 Bundlegenerate() 方法生成代码。

同时,在打包过程中,还需要对引入的函数做一些额外的操作。

移除额外代码

例如从 foo.js 中引入的 foo1() 函数代码是这样的:export function foo1() {}。rollup 会移除掉 export ,变成 function foo1() {}。因为它们就要打包在一起了,所以就不需要 export 了。

重命名

例如两个模块中都有一个同名函数 foo(),打包到一起时,会对其中一个函数重命名,变成 _foo(),以避免冲突。

好了,回到正文。

还记得文章一开始提到的 magic-string 库吗?在 generate() 中,会将每个 AST 节点对应的源代码添加到 magic-string 实例中:

magicString.addSource({
    content: source,
    separator: newLines
})

这个操作本质上相当于拼字符串:

str += '这个操作相当于将每个 AST 的源代码当成字符串拼在一起,就像现在这样'

最后将拼在一起的代码返回。

return { code: magicString.toString() }

到这就已经结束了,如果你想把代码生成文件,可以调用 write() 方法生成文件:

rollup(__dirname + '/main.js').then(res => {
    res.wirte('dist.js')
})

这个方法是写在 rollup() 函数里的。

function rollup(entry, options = {}) {
    const bundle = new Bundle({ entry, ...options })
    return bundle.build().then(() => {
        return {
            generate: options => bundle.generate(options),
            wirte(dest, options = {}) {
                const { code } = bundle.generate({
                    dest,
                    format: options.format,
                })

                return fs.writeFile(dest, code, err => {
                    if (err) throw err
                })
            }
        }
    })
}

结尾

本文对源码进行了抽象,所以很多实现细节都没说出来。如果对实现细节有兴趣,可以看一下源码。代码放在我的 github 上。

我已经对 rollup 初版源码进行了删减,并添加了大量注释,让代码更加易读。

查看原文

赞 6 收藏 3 评论 0

axuebin 赞了文章 · 9月2日

Vue3 模板编译原理

Vue 的编译模块包含 4 个目录:

compiler-core
compiler-dom // 浏览器
compiler-sfc // 单文件组件
compiler-ssr // 服务端渲染

其中 compiler-core 模块是 Vue 编译的核心模块,并且是平台无关的。而剩下的三个都是在 compiler-core 的基础上针对不同的平台作了适配处理。

Vue 的编译分为三个阶段,分别是:parse、transform、codegen。

其中 parse 阶段将模板字符串转化为语法抽象树 AST。transform 阶段则是对 AST 进行了一些转换处理。codegen 阶段根据 AST 生成对应的 render 函数字符串。

Parse

Vue 在解析模板字符串时,可分为两种情况:以 < 开头的字符串和不以 < 开头的字符串。

不以 < 开头的字符串有两种情况:它是文本节点或 {{ exp }} 插值表达式。

而以 < 开头的字符串又分为以下几种情况:

  1. 元素开始标签 <div>
  2. 元素结束标签 </div>
  3. 注释节点 <!-- 123 -->
  4. 文档声明 <!DOCTYPE html>

用伪代码表示,大概过程如下:

while (s.length) {
    if (startsWith(s, '{{')) {
        // 如果以 '{{' 开头
        node = parseInterpolation(context, mode)
    } else if (s[0] === '<') {
        // 以 < 标签开头
        if (s[1] === '!') {
            if (startsWith(s, '<!--')) {
                // 注释
                node = parseComment(context)
            } else if (startsWith(s, '<!DOCTYPE')) {
                // 文档声明,当成注释处理
                node = parseBogusComment(context)
            }
        } else if (s[1] === '/') {
            // 结束标签
            parseTag(context, TagType.End, parent)
        } else if (/[a-z]/i.test(s[1])) {
            // 开始标签
            node = parseElement(context, ancestors)
        }
    } else {
        // 普通文本节点
        node = parseText(context, mode)
    }
}

在源码中对应的几个函数分别是:

  1. parseChildren(),主入口。
  2. parseInterpolation(),解析双花插值表达式。
  3. parseComment(),解析注释。
  4. parseBogusComment(),解析文档声明。
  5. parseTag(),解析标签。
  6. parseElement(),解析元素节点,它会在内部执行 parseTag()
  7. parseText(),解析普通文本。
  8. parseAttribute(),解析属性。

每解析完一个标签、文本、注释等节点时,Vue 就会生成对应的 AST 节点,并且会把已经解析完的字符串给截断

对字符串进行截断使用的是 advanceBy(context, numberOfCharacters) 函数,context 是字符串的上下文对象,numberOfCharacters 是要截断的字符数。

我们用一个简单的例子来模拟一下截断操作:

<div name="test">
  <p></p>
</div>

首先解析 <div,然后执行 advanceBy(context, 4) 进行截断操作(内部执行的是 s = s.slice(4)),变成:

 name="test">
  <p></p>
</div>

再解析属性,并截断,变成:

  <p></p>
</div>

同理,后面的截断情况为:

></p>
</div>
</div>
<!-- 所有字符串已经解析完 -->

AST 节点

所有的 AST 节点定义都在 compiler-core/ast.ts 文件中,下面是一个元素节点的定义:

export interface BaseElementNode extends Node {
  type: NodeTypes.ELEMENT // 类型
  ns: Namespace // 命名空间 默认为 HTML,即 0
  tag: string // 标签名
  tagType: ElementTypes // 元素类型
  isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
  props: Array<AttributeNode | DirectiveNode> // props 属性,包含 HTML 属性和指令
  children: TemplateChildNode[] // 字节点
}

一些简单的要点已经讲完了,下面我们再从一个比较复杂的例子来详细讲解一下 parse 的处理过程。

<div name="test">
  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点
  <div>good job!</div>
</div>

上面的模板字符串假设为 s,第一个字符 s[0] 是 < 开头,那说明它只能是刚才所说的四种情况之一。
这时需要再看一下 s[1] 的字符是什么:

  1. 如果是 !,则调用字符串原生方法 startsWith() 看看是以 '<!--' 开头还是以 '<!DOCTYPE' 开头。虽然这两者对应的处理函数不一样,但它们最终都是解析为注释节点。
  2. 如果是 /,则按结束标签处理。
  3. 如果不是 /,则按开始标签处理。

从我们的示例来看,这是一个 <div> 开始标签。

这里还有一点要提一下,Vue 会用一个栈 stack 来保存解析到的元素标签。当它遇到开始标签时,会将这个标签推入栈,遇到结束标签时,将刚才的标签弹出栈。它的作用是保存当前已经解析了,但还没解析完的元素标签。这个栈还有另一个作用,在解析到某个字节点时,通过 stack[stack.length - 1] 可以获取它的父元素。

从我们的示例来看,它的出入栈顺序是这样的:

1. [div] // div 入栈
2. [div, p] // p 入栈
3. [div] // p 出栈
4. [div, div] // div 入栈
5. [div] // div 出栈
6. [] // 最后一个 div 出栈,模板字符串已解析完,这时栈为空

接着上文继续分析我们的示例,这时已经知道是 div 标签了,接下来会把已经解析完的 <div 字符串截断,然后解析它的属性。

Vue 的属性有两种情况:

  1. HTML 普通属性
  2. Vue 指令

根据属性的不同生成的节点不同,HTML 普通属性节点 type 为 6,Vue 指令节点 type 为 7。

所有的节点类型值如下:

ROOT,  // 根节点 0
ELEMENT, // 元素节点 1
TEXT, // 文本节点 2
COMMENT, // 注释节点 3
SIMPLE_EXPRESSION, // 表达式 4
INTERPOLATION, // 双花插值 {{ }} 5
ATTRIBUTE, // 属性 6
DIRECTIVE, // 指令 7

属性解析完后,div 开始标签也就解析完了,<div name="test"> 这一行字符串已经被截断。现在剩下的字符串如下:

  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点
  <div>good job!</div>
</div>

注释文本和普通文本节点解析规则都很简单,直接截断,生成节点。注释文本调用 parseComment() 函数处理,文本节点调用 parseText() 处理。

双花插值的字符串处理逻辑稍微复杂点,例如示例中的 {{ test }}

  1. 先将双花括号中的内容提取出来,即 test ,再对它执行 trim(),去除空格。
  2. 然后会生成两个节点,一个节点是 INTERPOLATION,type 为 5,表示它是双花插值。
  3. 第二个节点是它的内容,即 test,它会生成一个 SIMPLE_EXPRESSION 节点,type 为 4。
return {
  type: NodeTypes.INTERPOLATION, // 双花插值类型
  content: {
    type: NodeTypes.SIMPLE_EXPRESSION,
    isStatic: false, // 非静态节点
    isConstant: false,
    content,
    loc: getSelection(context, innerStart, innerEnd)
  },
  loc: getSelection(context, start)
}

剩下的字符串解析逻辑和上文的差不多,就不解释了,最后这个示例解析出来的 AST 如下所示:

从 AST 上,我们还能看到某些节点上有一些别的属性:

  1. ns,命名空间,一般为 HTML,值为 0。
  2. loc,它是一个位置信息,表明这个节点在源 HTML 字符串中的位置,包含行,列,偏移量等信息。
  3. {{ test }} 解析出来的节点会有一个 isStatic 属性,值为 false,表示这是一个动态节点。如果是静态节点,则只会生成一次,并且在后面的阶段一直复用同一个,不用进行 diff 比较。

另外还有一个 tagType 属性,它有 4 个值:

export const enum ElementTypes {
  ELEMENT, // 0 元素节点
  COMPONENT, // 1 组件
  SLOT, // 2 插槽
  TEMPLATE // 3 模板
}

主要用于区分上述四种类型节点。

Transform

在 transform 阶段,Vue 会对 AST 进行一些转换操作,主要是根据不同的 AST 节点添加不同的选项参数,这些参数在 codegen 阶段会用到。下面列举一些比较重要的选项:

cacheHandlers

如果 cacheHandlers 的值为 true,则表示开启事件函数缓存。例如 @click="foo" 默认编译为 { onClick: foo },如果开启了这个选项,则编译为

{ onClick: _cache[0] || (_cache[0] = e => _ctx.foo(e)) }

hoistStatic

hoistStatic 是一个标识符,表示要不要开启静态节点提升。如果值为 true,静态节点将被提升到 render() 函数外面生成,并被命名为 _hoisted_x 变量。

例如 一个文本节点 生成的代码为 const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")

下面两张图,前者是 hoistStatic = false,后面是 hoistStatic = true。大家可以在网站上自己试一下。

prefixIdentifiers

这个参数的作用是用于代码生成。例如 {{ foo }} 在 module 模式下生成的代码为 _ctx.foo,而在 function 模式下是 with (this) { ... }。因为在 module 模式下,默认为严格模式,不能使用 with 语句。

PatchFlags

transform 在对 AST 节点进行转换时,会打上 patchflag 参数,这个参数主要用于 diff 比较过程。当 DOM 节点有这个标志并且大于 0,就代表要更新,没有就跳过。

我们来看一下 patchflag 的取值范围:

export const enum PatchFlags {
  // 动态文本节点
  TEXT = 1,

  // 动态 class
  CLASS = 1 << 1, // 2

  // 动态 style
  STYLE = 1 << 2, // 4

  // 动态属性,但不包含类名和样式
  // 如果是组件,则可以包含类名和样式
  PROPS = 1 << 3, // 8

  // 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较。
  FULL_PROPS = 1 << 4, // 16

  // 带有监听事件的节点
  HYDRATE_EVENTS = 1 << 5, // 32

  // 一个不会改变子节点顺序的 fragment
  STABLE_FRAGMENT = 1 << 6, // 64

  // 带有 key 属性的 fragment 或部分子字节有 key
  KEYED_FRAGMENT = 1 << 7, // 128

  // 子节点没有 key 的 fragment
  UNKEYED_FRAGMENT = 1 << 8, // 256

  // 一个节点只会进行非 props 比较
  NEED_PATCH = 1 << 9, // 512

  // 动态 slot
  DYNAMIC_SLOTS = 1 << 10, // 1024

  // 静态节点
  HOISTED = -1,

  // 指示在 diff 过程应该要退出优化模式
  BAIL = -2
}

从上述代码可以看出 patchflag 使用一个 11 位的位图来表示不同的值,每个值都有不同的含义。Vue 在 diff 过程会根据不同的 patchflag 使用不同的 patch 方法。

下图是经过 transform 后的 AST:

可以看到 codegenNode、helpers 和 hoists 已经被填充上了相应的值。codegenNode 是生成代码要用到的数据,hoists 存储的是静态节点,helpers 存储的是创建 VNode 的函数名称(其实是 Symbol)。

在正式开始 transform 前,需要创建一个 transformContext,即 transform 上下文。和这三个属性有关的数据和方法如下:

helpers: new Set(),
hoists: [],

// methods
helper(name) {
  context.helpers.add(name)
  return name
},
helperString(name) {
  return `_${helperNameMap[context.helper(name)]}`
},
hoist(exp) {
  context.hoists.push(exp)
  const identifier = createSimpleExpression(
    `_hoisted_${context.hoists.length}`,
    false,
    exp.loc,
    true
  )
  identifier.hoisted = exp
  return identifier
},

我们来看一下具体的 transform 过程是怎样的,用 <p>{{ test }}</p> 来做示例。

这个节点对应的是 transformElement() 转换函数,由于 p 没有绑定动态属性,没有绑定指令,所以重点不在它,而是在 {{ test }} 上。{{ test }} 是一个双花插值表达式,所以将它的 patchFlag 设为 1(动态文本节点),对应的执行代码是 patchFlag |= 1。然后再执行 createVNodeCall() 函数,它的返回值就是这个节点的 codegenNode 值。

node.codegenNode = createVNodeCall(
    context,
    vnodeTag,
    vnodeProps,
    vnodeChildren,
    vnodePatchFlag,
    vnodeDynamicProps,
    vnodeDirectives,
    !!shouldUseBlock,
    false /* disableTracking */,
    node.loc
)

createVNodeCall() 根据这个节点添加了一个 createVNode Symbol 符号,它放在 helpers 里。其实就是要在代码生成阶段引入的帮助函数。

// createVNodeCall() 内部执行过程,已删除多余的代码
context.helper(CREATE_VNODE)

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

hoists

一个节点是否添加到 hoists 中,主要看它是不是静态节点,并且需要将 hoistStatic 设为 true。

<div name="test"> // 属性静态节点
  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点 // 静态节点
  <div>good job!</div> // 静态节点
</div>

可以看到,上面有三个静态节点,所以 hoists 数组有 3 个值。并且无论静态节点嵌套有多深,都会被提升到 hoists 中。

type 变化

从上图可以看到,最外层的 div 的 type 原来为 1,经过 transform 生成的 codegenNode 中的 type 变成了 13。
这个 13 是代码生成对应的类型 VNODE_CALL。另外还有:

// codegen
VNODE_CALL, // 13
JS_CALL_EXPRESSION, // 14
JS_OBJECT_EXPRESSION, // 15
JS_PROPERTY, // 16
JS_ARRAY_EXPRESSION, // 17
JS_FUNCTION_EXPRESSION, // 18
JS_CONDITIONAL_EXPRESSION, // 19
JS_CACHE_EXPRESSION, // 20

刚才提到的例子 {{ test }},它的 codegenNode 就是通过调用 createVNodeCall() 生成的:

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

可以从上述代码看到,type 被设置为 NodeTypes.VNODE_CALL,即 13。

每个不同的节点都由不同的 transform 函数来处理,由于篇幅有限,具体代码请自行查阅。

Codegen

代码生成阶段最后生成了一个字符串,我们把字符串的双引号去掉,看一下具体的内容是什么:

const _Vue = Vue
const { createVNode: _createVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode } = _Vue

const _hoisted_1 = { name: "test" }
const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

return function render(_ctx, _cache) {
  with (_ctx) {
    const { createCommentVNode: _createCommentVNode, toDisplayString: _toDisplayString, createVNode: _createVNode, createTextVNode: _createTextVNode, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    return (_openBlock(), _createBlock("div", _hoisted_1, [
      _createCommentVNode(" 这是注释 "),
      _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */),
      _hoisted_2,
      _hoisted_3
    ]))
  }
}

代码生成模式

可以看到上述代码最后返回一个 render() 函数,作用是生成对应的 VNode。

其实代码生成有两种模式:module 和 function,由标识符 prefixIdentifiers 决定使用哪种模式。

function 模式的特点是:使用 const { helpers... } = Vue 的方式来引入帮助函数,也就是是 createVode()createCommentVNode() 这些函数。向外导出使用 return 返回整个 render() 函数。

module 模式的特点是:使用 es6 模块来导入导出函数,也就是使用 import 和 export。

静态节点

另外还有三个变量是用 _hoisted_ 命名的,后面跟着数字,代表这是第几个静态变量。
再看一下 parse 阶段的 HTML 模板字符串:

<div name="test">
  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点
  <div>good job!</div>
</div>

这个示例只有一个动态节点,即 {{ test }},剩下的全是静态节点。从生成的代码中也可以看出,生成的节点和模板中的代码是一一对应的。静态节点的作用就是只生成一次,以后直接复用。

细心的网友可能发现了 _hoisted_2_hoisted_3 变量中都有一个 /*#__PURE__*/ 注释。

这个注释的作用是表示这个函数是纯函数,没有副作用,主要用于 tree-shaking。压缩工具在打包时会将未被使用的代码直接删除(shaking 摇掉)。

再来看一下生成动态节点 {{ test }} 的代码: _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */)

其中 _toDisplayString(test) 的内部实现是:

return val == null
    ? ''
    : isObject(val)
      ? JSON.stringify(val, replacer, 2)
      : String(val)

代码很简单,就是转成字符串输出。

_createVNode("p", null, _toDisplayString(test), 1 /* TEXT */) 最后一个参数 1 就是 transform 添加的 patchflag 了。

帮助函数 helpers

在 transform、codegen 这两个阶段,我们都能看到 helpers 的影子,到底 helpers 是干什么用的?

// Name mapping for runtime helpers that need to be imported from 'vue' in
// generated code. Make sure these are correctly exported in the runtime!
// Using `any` here because TS doesn't allow symbols as index type.
export const helperNameMap: any = {
  [FRAGMENT]: `Fragment`,
  [TELEPORT]: `Teleport`,
  [SUSPENSE]: `Suspense`,
  [KEEP_ALIVE]: `KeepAlive`,
  [BASE_TRANSITION]: `BaseTransition`,
  [OPEN_BLOCK]: `openBlock`,
  [CREATE_BLOCK]: `createBlock`,
  [CREATE_VNODE]: `createVNode`,
  [CREATE_COMMENT]: `createCommentVNode`,
  [CREATE_TEXT]: `createTextVNode`,
  [CREATE_STATIC]: `createStaticVNode`,
  [RESOLVE_COMPONENT]: `resolveComponent`,
  [RESOLVE_DYNAMIC_COMPONENT]: `resolveDynamicComponent`,
  [RESOLVE_DIRECTIVE]: `resolveDirective`,
  [WITH_DIRECTIVES]: `withDirectives`,
  [RENDER_LIST]: `renderList`,
  [RENDER_SLOT]: `renderSlot`,
  [CREATE_SLOTS]: `createSlots`,
  [TO_DISPLAY_STRING]: `toDisplayString`,
  [MERGE_PROPS]: `mergeProps`,
  [TO_HANDLERS]: `toHandlers`,
  [CAMELIZE]: `camelize`,
  [CAPITALIZE]: `capitalize`,
  [SET_BLOCK_TRACKING]: `setBlockTracking`,
  [PUSH_SCOPE_ID]: `pushScopeId`,
  [POP_SCOPE_ID]: `popScopeId`,
  [WITH_SCOPE_ID]: `withScopeId`,
  [WITH_CTX]: `withCtx`
}

export function registerRuntimeHelpers(helpers: any) {
  Object.getOwnPropertySymbols(helpers).forEach(s => {
    helperNameMap[s] = helpers[s]
  })
}

其实帮助函数就是在代码生成时从 Vue 引入的一些函数,以便让程序正常执行,从上面生成的代码中就可以看出来。而 helperNameMap 是默认的映射表名称,这些名称就是要从 Vue 引入的函数名称。

另外,我们还能看到一个注册函数 registerRuntimeHelpers(helpers: any(),它是干什么用的呢?

我们知道编译模块 compiler-core 是平台无关的,而 compiler-dom 是浏览器相关的编译模块。为了能在浏览器正常运行 Vue 程序,就得把浏览器相关的 Vue 数据和函数导入进来。
registerRuntimeHelpers(helpers: any() 正是用来做这件事的,从 compiler-dom 的 runtimeHelpers.ts 文件就能看出来:

registerRuntimeHelpers({
  [V_MODEL_RADIO]: `vModelRadio`,
  [V_MODEL_CHECKBOX]: `vModelCheckbox`,
  [V_MODEL_TEXT]: `vModelText`,
  [V_MODEL_SELECT]: `vModelSelect`,
  [V_MODEL_DYNAMIC]: `vModelDynamic`,
  [V_ON_WITH_MODIFIERS]: `withModifiers`,
  [V_ON_WITH_KEYS]: `withKeys`,
  [V_SHOW]: `vShow`,
  [TRANSITION]: `Transition`,
  [TRANSITION_GROUP]: `TransitionGroup`
})

它运行 registerRuntimeHelpers(helpers: any(),往映射表注入了浏览器相关的部分函数。

helpers 是怎么使用的呢?

在 parse 阶段,解析到不同节点时会生成对应的 type。

在 transform 阶段,会生成一个 helpers,它是一个 set 数据结构。每当它转换 AST 时,都会根据 AST 节点的 type 添加不同的 helper 函数。

例如,假设它现在正在转换的是一个注释节点,它会执行 context.helper(CREATE_COMMENT),内部实现相当于 helpers.add('createCommentVNode')。然后在 codegen 阶段,遍历 helpers,将程序需要的函数从 Vue 里导入,代码实现如下:

// 这是 module 模式
`import { ${ast.helpers
  .map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
  .join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`

如何生成代码?

从 codegen.ts 文件中,可以看到很多代码生成函数:

generate() // 代码生成入口文件
genFunctionExpression() // 生成函数表达式
genNode() // 生成 Vnode 节点
...

生成代码则是根据不同的 AST 节点调用不同的代码生成函数,最终将代码字符串拼在一起,输出一个完整的代码字符串。

老规矩,还是看一个例子:

const _hoisted_1 = { name: "test" }
const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

看一下这段代码是怎么生成的,首先执行 genHoists(ast.hoists, context),将 transform 生成的静态节点数组 hoists 作为第一个参数。genHoists() 内部实现:

hoists.forEach((exp, i) => {
    if (exp) {
        push(`const _hoisted_${i + 1} = `);
        genNode(exp, context);
        newline();
    }
})

从上述代码可以看到,遍历 hoists 数组,调用 genNode(exp, context)genNode() 根据不同的 type 执行不同的函数。

const _hoisted_1 = { name: "test" }

这一行代码中的 const _hoisted_1 = genHoists() 生成,{ name: "test" }genObjectExpression() 生成。
同理,剩下的两行代码生成过程也是如此,只是最终调用的函数不同。

Vue3 系列文章

查看原文

赞 4 收藏 2 评论 0

axuebin 关注了用户 · 9月2日

谭光志 @woai3c

关注 1384

axuebin 关注了用户 · 9月2日

蒋鹏飞 @jiangpengfei_5ecce944a3d8a

掘金优秀作者,ID同名~
公众号:进击的大前端!
分享各种大前端进阶知识!
不打广告,不写水文,只发高质量原创,与君共勉,共同学习~
更多文章和示例源码请看:https://github.com/dennis-jia...

关注 1639

axuebin 赞了文章 · 9月2日

深入Node.js的模块加载机制,手写require函数

模块是Node.js里面一个很基本也很重要的概念,各种原生类库是通过模块提供的,第三方库也是通过模块进行管理和引用的。本文会从基本的模块原理出发,到最后我们会利用这个原理,自己实现一个简单的模块加载机制,即自己实现一个require

本文完整代码已上传GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

简单例子

老规矩,讲原理前我们先来一个简单的例子,从这个例子入手一步一步深入原理。Node.js里面如果要导出某个内容,需要使用module.exports,使用module.exports几乎可以导出任意类型的JS对象,包括字符串,函数,对象,数组等等。我们先来建一个a.js导出一个最简单的hello world:

// a.js 
module.exports = "hello world";

然后再来一个b.js导出一个函数:

// b.js
function add(a, b) {
  return a + b;
}

module.exports = add;

然后在index.js里面使用他们,即require他们,require函数返回的结果就是对应文件module.exports的值:

// index.js
const a = require('./a.js');
const add = require('./b.js');

console.log(a);      // "hello world"
console.log(add(1, 2));    // b导出的是一个加法函数,可以直接使用,这行结果是3

require会先运行目标文件

当我们require某个模块时,并不是只拿他的module.exports,而是会从头开始运行这个文件,module.exports = XXX其实也只是其中一行代码,我们后面会讲到,这行代码的效果其实就是修改模块里面的exports属性。比如我们再来一个c.js

// c.js
let c = 1;

c = c + 1;

module.exports = c;

c = 6;

c.js里面我们导出了一个c,这个c经过了几步计算,当运行到module.exports = c;这行时c的值为2,所以我们requirec.js的值就是2,后面将c的值改为了6并不影响前面的这行代码:

const c = require('./c.js');

console.log(c);  // c的值是2

前面c.js的变量c是一个基本数据类型,所以后面的c = 6;不影响前面的module.exports,那他如果是一个引用类型呢?我们直接来试试吧:

// d.js
let d = {
  num: 1
};

d.num++;

module.exports = d;

d.num = 6;

然后在index.js里面require他:

const d = require('./d.js');

console.log(d);     // { num: 6 }

我们发现在module.exports后面给d.num赋值仍然生效了,因为d是一个对象,是一个引用类型,我们可以通过这个引用来修改他的值。其实对于引用类型来说,不仅仅在module.exports后面可以修改他的值,在模块外面也可以修改,比如index.js里面就可以直接改:

const d = require('./d.js');

d.num = 7;
console.log(d);     // { num: 7 }

requiremodule.exports不是黑魔法

我们通过前面的例子可以看出来,requiremodule.exports干的事情并不复杂,我们先假设有一个全局对象{},初始情况下是空的,当你require某个文件时,就将这个文件拿出来执行,如果这个文件里面存在module.exports,当运行到这行代码时将module.exports的值加入这个对象,键为对应的文件名,最终这个对象就长这样:

{
  "a.js": "hello world",
  "b.js": function add(){},
  "c.js": 2,
  "d.js": { num: 2 }
}

当你再次require某个文件时,如果这个对象里面有对应的值,就直接返回给你,如果没有就重复前面的步骤,执行目标文件,然后将它的module.exports加入这个全局对象,并返回给调用者。这个全局对象其实就是我们经常听说的缓存。所以requiremodule.exports并没有什么黑魔法,就只是运行并获取目标文件的值,然后加入缓存,用的时候拿出来用就行。再看看这个对象,因为d.js是一个引用类型,所以你在任何地方获取了这个引用都可以更改他的值,如果不希望自己模块的值被更改,需要自己写模块时进行处理,比如使用Object.freeze()Object.defineProperty()之类的方法。

模块类型和加载顺序

这一节的内容都是一些概念,比较枯燥,但是也是我们需要了解的。

模块类型

Node.js的模块有好几种类型,前面我们使用的其实都是文件模块,总结下来,主要有这两种类型:

  1. 内置模块:就是Node.js原生提供的功能,比如fshttp等等,这些模块在Node.js进程起来时就加载了。
  2. 文件模块:我们前面写的几个模块,还有第三方模块,即node_modules下面的模块都是文件模块。

加载顺序

加载顺序是指当我们require(X)时,应该按照什么顺序去哪里找X,在官方文档上有详细伪代码,总结下来大概是这么个顺序:

  1. 优先加载内置模块,即使有同名文件,也会优先使用内置模块。
  2. 不是内置模块,先去缓存找。
  3. 缓存没有就去找对应路径的文件。
  4. 不存在对应的文件,就将这个路径作为文件夹加载。
  5. 对应的文件和文件夹都找不到就去node_modules下面找。
  6. 还找不到就报错了。

加载文件夹

前面提到找不到文件就找文件夹,但是不可能将整个文件夹都加载进来,加载文件夹的时候也是有一个加载顺序的:

  1. 先看看这个文件夹下面有没有package.json,如果有就找里面的main字段,main字段有值就加载对应的文件。所以如果大家在看一些第三方库源码时找不到入口就看看他package.json里面的main字段吧,比如jquerymain字段就是这样:"main": "dist/jquery.js"
  2. 如果没有package.json或者package.json里面没有main就找index文件。
  3. 如果这两步都找不到就报错了。

支持的文件类型

require主要支持三种文件类型:

  1. .js.js文件是我们最常用的文件类型,加载的时候会先运行整个JS文件,然后将前面说的module.exports作为require的返回值。
  2. .json.json文件是一个普通的文本文件,直接用JSON.parse将其转化为对象返回就行。
  3. .node.node文件是C++编译后的二进制文件,纯前端一般很少接触这个类型。

手写require

前面其实我们已经将原理讲的七七八八了,下面来到我们的重头戏,自己实现一个require。实现require其实就是实现整个Node.js的模块加载机制,我们再来理一下需要解决的问题:

  1. 通过传入的路径名找到对应的文件。
  2. 执行找到的文件,同时要注入modulerequire这些方法和属性,以便模块文件使用。
  3. 返回模块的module.exports

本文的手写代码全部参照Node.js官方源码,函数名和变量名尽量保持一致,其实就是精简版的源码,大家可以对照着看,写到具体方法时我也会贴上对应的源码地址。总体的代码都在这个文件里面:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

Module类

Node.js模块加载的功能全部在Module类里面,整个代码使用面向对象的思想,如果你对JS的面向对象还不是很熟悉可以先看看这篇文章Module类的构造函数也不复杂,主要是一些值的初始化,为了跟官方Module名字区分开,我们自己的类命名为MyModule

function MyModule(id = '') {
  this.id = id;       // 这个id其实就是我们require的路径
  this.path = path.dirname(id);     // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
  this.exports = {};        // 导出的东西放这里,初始化为空对象
  this.filename = null;     // 模块对应的文件名
  this.loaded = false;      // loaded用来标识当前模块是否已经加载
}

require方法

我们一直用的require其实是Module类的一个实例方法,内容很简单,先做一些参数检查,然后调用Module._load方法,源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L970。精简版的代码如下:

MyModule.prototype.require = function (id) {
  return Module._load(id);
}

MyModule._load

MyModule._load是一个静态方法,这才是require方法的真正主体,他干的事情其实是:

  1. 先检查请求的模块在缓存中是否已经存在了,如果存在了直接返回缓存模块的exports
  2. 如果不在缓存中,就new一个Module实例,用这个实例加载对应的模块,并返回模块的exports

我们自己来实现下这两个需求,缓存直接放在Module._cache这个静态变量上,这个变量官方初始化使用的是Object.create(null),这样可以使创建出来的原型指向null,我们也这样做吧:

MyModule._cache = Object.create(null);

MyModule._load = function (request) {    // request是我们传入的路劲参数
  const filename = MyModule._resolveFilename(request);

  // 先检查缓存,如果缓存存在且已经加载,直接返回缓存
  const cachedModule = MyModule._cache[filename];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }

  // 如果缓存不存在,我们就加载这个模块
  // 加载前先new一个MyModule实例,然后调用实例方法load来加载
  // 加载完成直接返回module.exports
  const module = new MyModule(filename);
  
  // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
  MyModule._cache[filename] = module;
  
  module.load(filename);
  
  return module.exports;
}

上述代码对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L735

可以看到上述源码还调用了两个方法:MyModule._resolveFilenameMyModule.prototype.load,下面我们来实现下这两个方法。

MyModule._resolveFilename

MyModule._resolveFilename从名字就可以看出来,这个方法是通过用户传入的require参数来解析到真正的文件地址的,源码中这个方法比较复杂,因为按照前面讲的,他要支持多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等,如果是文件夹或者第三方模块还要解析里面的package.jsonindex.js。我们这里主要讲原理,所以我们就只实现通过相对路径和绝对路径来查找文件,并支持自动添加jsjson两种后缀名:

MyModule._resolveFilename = function (request) {
  const filename = path.resolve(request);   // 获取传入参数对应的绝对路径
  const extname = path.extname(request);    // 获取文件后缀名

  // 如果没有文件后缀名,尝试添加.js和.json
  if (!extname) {
    const exts = Object.keys(MyModule._extensions);
    for (let i = 0; i < exts.length; i++) {
      const currentPath = `${filename}${exts[i]}`;

      // 如果拼接后的文件存在,返回拼接的路径
      if (fs.existsSync(currentPath)) {
        return currentPath;
      }
    }
  }

  return filename;
}

上述源码中我们还用到了一个静态变量MyModule._extensions,这个变量是用来存各种文件对应的处理方法的,我们后面会实现他。

MyModule._resolveFilename对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L822

MyModule.prototype.load

MyModule.prototype.load是一个实例方法,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应MyModule._extensions里面的一个方法:

MyModule.prototype.load = function (filename) {
  // 获取文件后缀名
  const extname = path.extname(filename);

  // 调用后缀名对应的处理函数来处理
  MyModule._extensions[extname](this, filename);

  this.loaded = true;
}

注意这段代码里面的this指向的是module实例,因为他是一个实例方法。对应的源码看这里: https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L942

加载js文件: MyModule._extensions['.js']

前面我们说过不同文件类型的处理方法都挂载在MyModule._extensions上面的,我们先来实现.js类型文件的加载:

MyModule._extensions['.js'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module._compile(content, filename);
}

可以看到js的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法_compile来执行他。对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1098

编译执行js文件:MyModule.prototype._compile

MyModule.prototype._compile是加载JS文件的核心所在,也是我们最常使用的方法,这个方法需要将目标文件拿出来执行一遍,执行之前需要将它整个代码包裹一层,以便注入exports, require, module, __dirname, __filename,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们require的文件是一个简单的Hello World,长这样:

module.exports = "hello world";

那我们怎么来给他注入module这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:

function (module) { // 注入module变量,其实几个变量同理
  module.exports = "hello world";
}

所以我们如果将文件内容作为一个字符串的话,为了让他能够变成上面这样,我们需要再给他拼接上开头和结尾,我们直接将开头和结尾放在一个数组里面:

MyModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

注意我们拼接的开头和结尾多了一个()包裹,这样我们后面可以拿到这个匿名函数,在后面再加一个()就可以传参数执行了。然后将需要执行的函数拼接到这个方法中间:

MyModule.wrap = function (script) {
  return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};

这样通过MyModule.wrap包装的代码就可以获取到exports, require, module, __filename, __dirname这几个变量了。知道了这些就可以来写MyModule.prototype._compile了:

MyModule.prototype._compile = function (content, filename) {
  const wrapper = Module.wrap(content);    // 获取包装后函数体

  // vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
  // 返回值就是转化后的函数,所以compiledWrapper是一个函数
  const compiledWrapper = vm.runInThisContext(wrapper, {
    filename,
    lineOffset: 0,
    displayErrors: true,
  });

  // 准备exports, require, module, __filename, __dirname这几个参数
  // exports可以直接用module.exports,即this.exports
  // require官方源码中还包装了一层,其实最后调用的还是this.require
  // module不用说,就是this了
  // __filename直接用传进来的filename参数了
  // __dirname需要通过filename获取下
  const dirname = path.dirname(filename);

  compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);
}

上述代码要注意我们注入进去的几个参数和通过call传进去的this:

  1. this:compiledWrapper是通过call调用的,第一个参数就是里面的this,这里我们传入的是this.exports,也就是module.exports,也就是说我们js文件里面this是对module.exports的一个引用。
  2. exports: compiledWrapper正式接收的第一个参数是exports,我们传的也是this.exports,所以js文件里面的exports也是对module.exports的一个引用。
  3. require: 这个方法我们传的是this.require,其实就是MyModule.prototype.require,也就是MyModule._load
  4. module: 我们传入的是this,也就是当前模块的实例。
  5. __filename:文件所在的绝对路径。
  6. __dirname: 文件所在文件夹的绝对路径。

到这里,我们的JS文件其实已经记载完了,对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1043

加载json文件: MyModule._extensions['.json']

加载json文件就简单多了,只需要将文件读出来解析成json就行了:

MyModule._extensions['.json'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module.exports = JSONParse(content);
}

exportsmodule.exports的区别

网上经常有人问,node.js里面的exportsmodule.exports到底有什么区别,其实前面我们的手写代码已经给出答案了,我们这里再就这个问题详细讲解下。exportsmodule.exports这两个变量都是通过下面这行代码注入的。

compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);

初始状态下,exports === module.exports === {}exportsmodule.exports的一个引用,如果你一直是这样使用的:

exports.a = 1;
module.exports.b = 2;

console.log(exports === module.exports);   // true

上述代码中,exportsmodule.exports都是指向同一个对象{},你往这个对象上添加属性并没有改变这个对象本身的引用地址,所以exports === module.exports一直成立。

但是如果你哪天这样使用了:

exports = {
  a: 1
}

或者这样使用了:

module.exports = {
    b: 2
}

那其实你是给exports或者module.exports重新赋值了,改变了他们的引用地址,那这两个属性的连接就断开了,他们就不再相等了。需要注意的是,你对module.exports的重新赋值会作为模块的导出内容,但是你对exports的重新赋值并不能改变模块导出内容,只是改变了exports这个变量而已,因为模块始终是module,导出内容是module.exports

循环引用

Node.js对于循环引用是进行了处理的,下面是官方例子:

a.js:

console.log('a 开始');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 结束');

b.js:

console.log('b 开始');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 结束');

main.js:

console.log('main 开始');
const a = require('./a.js');
const b = require('./b.js');
console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);

main.js 加载 a.js 时, a.js 又加载 b.js。 此时, b.js 会尝试去加载 a.js。 为了防止无限的循环,会返回一个 a.jsexports 对象的 未完成的副本b.js 模块。 然后 b.js 完成加载,并将 exports 对象提供给 a.js 模块。

那么这个效果是怎么实现的呢?答案就在我们的MyModule._load源码里面,注意这两行代码的顺序:

MyModule._cache[filename] = module;

module.load(filename);

上述代码中我们是先将缓存设置了,然后再执行的真正的load,顺着这个思路我能来理一下这里的加载流程:

  1. main加载aa在真正加载前先去缓存中占一个位置
  2. a在正式加载时加载了b
  3. b又去加载了a,这时候缓存中已经有a了,所以直接返回a.exports,即使这时候的exports是不完整的。

总结

  1. require不是黑魔法,整个Node.js的模块加载机制都是JS实现的。
  2. 每个模块里面的exports, require, module, __filename, __dirname五个参数都不是全局变量,而是模块加载的时候注入的。
  3. 为了注入这几个变量,我们需要将用户的代码用一个函数包裹起来,拼一个字符串然后调用沙盒模块vm来实现。
  4. 初始状态下,模块里面的this, exports, module.exports都指向同一个对象,如果你对他们重新赋值,这种连接就断了。
  5. module.exports的重新赋值会作为模块的导出内容,但是你对exports的重新赋值并不能改变模块导出内容,只是改变了exports这个变量而已,因为模块始终是module,导出内容是module.exports
  6. 为了解决循环引用,模块在加载前就会被加入缓存,下次再加载会直接返回缓存,如果这时候模块还没加载完,你可能拿到未完成的exports
  7. Node.js实现的这套加载机制叫CommonJS

本文完整代码已上传GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

参考资料

Node.js模块加载源码:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

Node.js模块官方文档:http://nodejs.cn/api/modules.html

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

查看原文

赞 29 收藏 19 评论 3

axuebin 赞了文章 · 9月2日

一文带你层层解锁「文件下载」的奥秘

大家好我是秋风,今天带来的主题是关于文件下载,在我之前曾经发过一篇文件上传的文章(一文了解文件上传全过程(1.8w字深度解析,进阶必备 200+点赞),反响还不错,时隔多日,由于最近有研究一些媒体相关的工作,因此打算对下载做一个整理,因此他的兄弟篇诞生了,带你领略文件下载的奥秘。本文会花费你较长的时间阅读,建议先收藏/点赞,然后查看你感兴趣的部分,平时也可以充当当做字典的效果来查询。

:) 不整不知道,一整,居然整出这么多情况,我只是想简单地做个页面仔。

前言

一图览全文,可以先看看大纲适不适合自己,如果你喜欢则继续往下阅读。

一文了解文件下载

这一节呢,主要介绍一些前置知识,对一些基础知识的介绍,如果你觉得你是这个。⬇️⬇️⬇️,你可以跳过前言。

和荣耀王者说你嘛呢?_荣耀_王者表情

前端的文件下载主要是通过 <a> ,再加上 download属性,有了它们让我们的下载变得简单。

download此属性指示浏览器下载 URL 而不是导航到它,因此将提示用户将其保存为本地文件。如果属性有一个值,那么此值将在下载保存过程中作为预填充的文件名(如果用户需要,仍然可以更改文件名)。此属性对允许的值没有限制,但是 /\ 会被转换为下划线。大多数文件系统限制了文件名中的标点符号,故此,浏览器将相应地调整建议的文件名。( 摘自 https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a)

注意:

  • 此属性仅适用于同源 URL
  • 尽管 HTTP URL 需要位于同一源中,但是可以使用 blob: URLdata: URL ,以方便用户下载使用 JavaScript 生成的内容(例如使用在线绘图 Web 应用程序创建的照片)。

因此下载 url 主要有三种方式。(本文大部分以 blob 的方式进行演示)

image-20200830153314861

兼容性

可以看到它的兼容性也非常的可观(https://www.caniuse.com/#search=download)

image-20200817232216749

为了避免很多代码的重复性,因为我抽离出了几个公共函数。(该部分可跳过,名字都比较可读,之后若是遇到不明白则可以在这里寻找)

export function downloadDirect(url) {
    const aTag = document.createElement('a');
    aTag.download = url.split('/').pop();
    aTag.href = url;
    aTag.click()
}
export function downloadByContent(content, filename, type) {
    const aTag = document.createElement('a');
    aTag.download = filename;
    const blob = new Blob([content], { type });
    const blobUrl = URL.createObjectURL(blob);
    aTag.href = blobUrl;
    aTag.click();
    URL.revokeObjectURL(blob);
}
export function downloadByDataURL(content, filename, type) {
    const aTag = document.createElement('a');
    aTag.download = filename;
    const dataUrl = `data:${type};base64,${window.btoa(unescape(encodeURIComponent(content)))}`;
    aTag.href = dataUrl;
    aTag.click();
}
export function downloadByBlob(blob, filename) {
    const aTag = document.createElement('a');
    aTag.download = filename;
    const blobUrl = URL.createObjectURL(blob);
    aTag.href = blobUrl;
    aTag.click();
    URL.revokeObjectURL(blob);
}
export function base64ToBlob(base64, type) {
    const byteCharacters = atob(base64);
    const byteNumbers = new Array(byteCharacters.length);
    for (let i = 0; i < byteCharacters.length; i++) {
        byteNumbers[i] = byteCharacters.charCodeAt(i);
    }
    const buffer = Uint8Array.from(byteNumbers);
    const blob = new Blob([buffer], { type });
    return blob;
}

🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅

(手动给不看以上内容的大佬画分割线)

🇨🇳

所有示例Github地址:https://github.com/hua1995116/node-demo/tree/master/file-download

在线Demo: https://qiufeng.blue/demo/file-download/index.html

前端文件下载

后端

本文后端所有示例均以 koa / 原生 js 实现。

后端返回文件流

这种情况非常简单,我们只需要直接将后端返回的文件流以新的窗口打开,即可直接下载了。

// 前端代码
<button id="oBtnDownload">点击下载</button>
<script>
oBtnDownload.onclick = function(){
    window.open('http://localhost:8888/api/download?filename=1597375650384.jpg', '_blank')
}
</script>
// 后端代码
router.get('/api/download', async (ctx) => {
    const { filename } = ctx.query;
    const fStats = fs.statSync(path.join(__dirname, './static/', filename));
    ctx.set({
        'Content-Type': 'application/octet-stream',
        'Content-Disposition': `attachment; filename=${filename}`,
        'Content-Length': fStats.size
    });
    ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
})

能够让浏览器自动下载文件,主要有两种情况:

一种为使用了Content-Disposition属性。

我们来看看该字段的描述。

在常规的HTTP应答中,Content-Disposition 响应头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地 --- 来源 MDN(https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition)

再来看看它的语法

Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"

很简单,只要设置成最后一种形态我就能成功让文件从后端进行下载了。

另一种为浏览器无法识别的类型

例如输入 http://localhost:8888/static/demo.sh,浏览器无法识别该类型,就会自动下载。

不知道小伙伴们有没有遇到过这样的一个情况,我们输入一个正确的静态 js 地址,没有配置Content-Disposition,但是却会被意外的下载。

例如像以下的情况。

2020-08-30-17.01.52

006r3PQBjw1fav4dsikh6j308c0g5gm1

这很可能是由于你的 nginx 少了这一行配置.

include mime.types;

导致默认走了 application/octet-stream,浏览器无法识别就下载了文件。

后端返回静态站点地址

通过静态站点下载,这里要分为两种情况,一种为可能该服务自带静态目录,即为同源情况,第二种情况为适用了第三方静态存储平台,例如阿里云、腾讯云之类的进行托管,即非同源(当然也有些平台直接会返回)。

同源

同源情况下是非常简单,先上代码,直接调用一下函数就能轻松实现下载。

import {downloadDirect} from '../js/utils.js';
axios.get('http://localhost:8888/api/downloadUrl').then(res => {
        if(res.data.code === 0) {
            downloadDirect(res.data.data.url);
        }
})

非同源

我们也可以从 MDN 上看到,虽然 download 限制了非同源的情况,但是!!但是!!但是可以使用 blob: URLdata: URL ,因此我们只要将文件内容进行下载转化成 blob 就可以了。

整个过程如下

image-20200830174735143

<button id="oBtnDownload">点击下载</button>
    <script type="module">
        import {downloadByBlob} from '../js/utils.js';
        function download(url) {
            axios({
                method: 'get',
                url,
                responseType: 'blob'
            }).then(res => {
                downloadByBlob(res.data, url.split('/').pop());
            }) 
        }
        oBtnDownload.onclick = function(){
           axios.get('http://localhost:8888/api/downloadUrl').then(res => {
                if(res.data.code === 0) {
                    download(res.data.data.url);
                }
            })
        }
    </script>

现在非同源的也可以愉快地下载啦。

后端返回字符串(base64)

有时候我们也会遇到一些新手后端返回字符串的情况,这种情况很少见,但是来了我们也不慌,顺便可以向后端小哥秀一波操作,不管啥数据,咱都能给你下载下来。

ps: 前提是安全无污染的资源 :) , 正经文章的招牌闪闪发光。

这种情况下,我需要模拟下后端小哥的骚操作,因此有后端代码。

994b6f2egy1fgryfevtpvj208c08cmxd

核心过程

image-20200830174752476

// node 端
router.get('/api/base64', async (ctx) => {
    const { filename } = ctx.query;
    const content = fs.readFileSync(path.join(__dirname, './static/', filename));
    const fStats = fs.statSync(path.join(__dirname, './static/', filename));
    console.log(fStats);
    ctx.body = {
        code: 0,
        data: {
            base64: content.toString('base64'),
            filename,
            type: mime.getType(filename)
        }
    }
})
// 前端
<button id="oBtnDownload">点击下载</button>
<script type="module">
import {base64ToBlob, downloadByBlob} from '../js/utils.js';
function download({ base64, filename, type }) {
    const blob = base64ToBlob(blob, type);
    downloadByBlob(blob, filename);
}
oBtnDownload.onclick = function(){
    axios.get('http://localhost:8888/api/base64?filename=1597375650384.jpg').then(res => {
        if(res.data.code === 0) {
            download(res.data.data);
        }
    })
}
</script>

思路其实还是利用了我们上面说的 <a> 标签。但是在这个步骤前,多了一个步骤就是,需要将我们的 base64 字符串转化为二进制流,这个东西,在我的前一篇文件上传中也常常提到,毕竟文件就是以二进制流的形式存在。不过也很简单,js 拥有内置函数 atob。 极大地提高了我们转换的效率。

纯前端

上面介绍借助后端来完成文件下载的相关方法,接下来我们来介绍介绍纯前端来完成文件下载的一些方法。

方法一: blob: URL

image-20200831230800538

方法二: data: URL

image-20200831230810963

由于 data:URL 会有长度的限制,因此下面的所有例子都会采用 blob 的方式来进行演示。

json/text

下载text和json非常的简单,可以直接构造一个 Blob。

Blob(blobParts[, options])
返回一个新创建的 Blob 对象,其内容由参数中给定的数组串联组成。
// html
<textarea name="" id="text" cols="30" rows="10"></textarea>
<button id="textBtn">下载文本</button>
<p></p>
<textarea name="" id="json" cols="30" rows="10" disabled>
{
    "name": "秋风的笔记"
}
</textarea>
<button id="jsonBtn">下载JSON</button>
//js
import {downloadByContent, downloadByDataURL} from '../js/utils.js';
textBtn.onclick = () => {
        const value = text.value;
        downloadByContent(value, 'hello.txt', 'text/plain');
          // downloadByDataURL(value, 'hello.txt', 'text/plain');
}
jsonBtn.onclick = () => {
        const value = json.value;
        downloadByContent(value, 'hello.json', 'application/json');
     // downloadByDataURL(value, 'hello.json', 'application/json');
}

效果图

2020-08-30-17.53.32

注释代码为 data:URL 的展示部分,由于是第一个例子,因此我讲展示代码,后面都省略了,但是你也可以通过调用 downloadByDataURL 方法,找不到该方法的定义请滑到文章开头哦~

excel

excel 可以说是我们部分前端打交道很深的一个场景,什么数据中台,天天需要导出各种报表。以前都是前端请求后端,来获取一个 excel 文件地址。现在让我们来展示下纯前端是如何实现下载excel。

简单excel

表格长这个模样,比较简陋的形式

image-20200829170347728

const template = '<html xmlns:o="urn:schemas-microsoft-com:office:office" '
            +'xmlns:x="urn:schemas-microsoft-com:office:excel" '
            +'xmlns="http://www.w3.org/TR/REC-html40">'
            +'<head>'
            +'</head>'
            +'<body><table border="1" >{table}</table><\/body>'
            +'<\/html>';
    const context = template.replace('{table}', document.getElementById('excel').innerHTML);
    downloadByContent(context, 'qiufengblue.xls', 'application/vnd.ms-excel');

但是编写并不复杂,依旧是和我们之前一样,通过构造出 excel 的格式,转化成 blob 来进行下载。

最终导出的效果

image-20200829170625763

element-ui 导出表格

没错,这个就是 element-ui 官方table 的例子。

image-20200829170543891

导出效果如下,可以说非常完美。

image-20200829170912128

这里我们用到了一个插件 https://github.com/SheetJS/sheetjs

使用起来非常简单。

<template>
      <el-table id="ele" border :data="tableData" style="width: 100%">
        <el-table-column prop="date" label="日期" width="180">
        </el-table-column>
        <el-table-column prop="name" label="姓名" width="180">
        </el-table-column>
        <el-table-column prop="address" label="地址">
        </el-table-column>
      </el-table>
      <button @click="exportExcel">导出excel</button>
</template>
<script>
...
methods: {
  exportExcel() {
     let wb = XLSX.utils.table_to_book(document.getElementById('ele'));
     XLSX.writeFile(wb, 'qiufeng.blue.xlsx');
    }
}
...
</script>

完美表情

word

讲完了 excel 我们再来讲讲 word 这可是 office 三剑客另外一大利器。这里我们依旧是利用上述的 blob 的方法进行下载。

简单示例

2020-08-29-20.13.25

代码展示

exportWord.onclick = () => {
    const template = '<html xmlns:o="urn:schemas-microsoft-com:office:office" '
            +'xmlns:x="urn:schemas-microsoft-com:office:word" '
            +'xmlns="http://www.w3.org/TR/REC-html40">'
            +'<head>'
            +'</head>'
            +'<body>{table}<\/body>'
            +'<\/html>';
    const context = template.replace('{table}', document.getElementById('word').innerHTML);
    downloadByContent(context, 'qiufeng.blue.doc', 'application/msword');
}

效果展示

image-20200830164208184

使用 docx.js 插件

如果你想有更高级的用法,可以使用 docx.js这个库。当然用上述方法也是可以高级定制的。

代码

<button type="button" onclick="generate()">下载word</button>

    <script>
        async function generate() {
            const res = await axios({
                method: 'get',
                url: 'http://localhost:8888/static/1597375650384.jpg',
                responseType: 'blob'
            })
            const doc = new docx.Document();
            const image1 = docx.Media.addImage(doc, res.data, 300, 400)
            doc.addSection({
                properties: {},
                children: [
                    new docx.Paragraph({
                        children: [
                            new docx.TextRun("欢迎关注[秋风的笔记]公众号").break(),
                            new docx.TextRun("").break(),
                            new docx.TextRun("定期发送优质文章").break(),
                            new docx.TextRun("").break(),
                            new docx.TextRun("美团点评2020校招-内推").break(),
                        ],
                    }),
                    new docx.Paragraph(image1),
                ],
            }); 

            docx.Packer.toBlob(doc).then(blob => {
                console.log(blob);
                saveAs(blob, "qiufeng.blue.docx");
                console.log("Document created successfully");
            });
        }
    </script>

效果(没有打广告...随便找了张图,强行不承认系列)

9150e4e5ly1fl8qavz6quj20hs0hsjvl

2020-08-30-18.32.09

zip下载

前端压缩还是非常有用的,在一定的场景下,可以节省流量。而这个场景比较使用于,例如前端打包图片下载、前端打包下载图标。

一开始我以为我 https://tinypng.com/ 就是用了这个,结果我发现我错了...仔细一想,因为它压缩好的图片是存在后端的,如果使用前端打包的话,反而要去请求所有压缩的图片从而来获取图片流。如果用后端压缩话,可以有效节省流量。嗯。。。失败例子告终。

后来又以为https://www.iconfont.cn/打包...,使用了这个方案....发现....我又错了...但是我们分析一下.

image-20200829204540440

它官网都是 svg 渲染的图标,对于 svg 下载的时候,完全可以使用前端打包下载。但是,它还支持 font 以及 jpg 格式,所以为了统一,采用了后端下载,能够理解。那我们就来实现这个它未完成的功能,当然我们还需要用到一个插件,就是 jszip

这里我从以上找了两个 svg 的图标。

image-20200829204937044

实现代码

download.onclick = () => {
        const zip = new JSZip();
        const svgList = [{
            id: 'demo1',
        }, {
            id: 'demo2',
        }]
        svgList.map(item => {
            zip.file(item.id + '.svg', document.getElementById(item.id).outerHTML);
        })
        zip.generateAsync({ 
            type: 'blob'
        }).then(function(content) {
            // 下载的文件名
            var filename = 'svg' + '.zip';
            // 创建隐藏的可下载链接
            var eleLink = document.createElement('a');
            eleLink.download = filename;
            // 下载内容转变成blob地址
            eleLink.href = URL.createObjectURL(content);
            // 触发点击
            eleLink.click();
            // 然后移除
        });
    }

2020-08-29-20.52.42

查看文件夹目录,已经将 SVG 打包下载完毕。

image-20200829205329532

浏览器文件系统(实验性)

image-20200817234129788

在我电脑上都有这么一个浏览器,用来学习和调试 chrome 的最新新特性, 如果你的电脑没有,建议你安装一个。

玩这个特性需要打开 chrome 的实验特性 chrome://flags => #native-file-system-api => enable, 因为实验特性都会伴随一些安全或者影响原本的渲染的行为,因此我再次强烈建议,下载一个金丝雀版本的 chrome 来进行玩耍。

<textarea name="" id="textarea" cols="30" rows="10"></textarea>
<p><button id="btn">下载</button></p>
<script>
    btn.onclick = async () => {
        const handler = await window.chooseFileSystemEntries({
            type: 'save-file',
            accepts: [{
                description: 'Text file',
                extensions: ['txt'],
                mimeTypes: ['text/plain'],
            }],
        });

        const writer = await handler.createWritable();
        await writer.write(textarea.value);
        await writer.close();
    }
</script>

实现起来非常简单。却飞一般的感觉。

2020-08-18-00.13.29

其他场景

H5文件下载

一般在 h5 下载比较多的是 pdf 或者是 apk 的下载。

Android

在安卓浏览器中,浏览器直接下载文件。

ios

由于ios的限制,无法进行下载,因此,可以使用复制 url ,来代替下载。

import {downloadDirect} from '../js/utils.js';
const btn = document.querySelector('#download-ios');
if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) {
    const clipboard = new ClipboardJS(btn);
    clipboard.on('success', function () {
        alert('已复制链接,打开浏览器粘贴链接下载');
    });
    clipboard.on('error', function (e) {
        alert('系统版本过低,复制链接失败');
    });
} else {
    btn.onclick = () => {
        downloadDirect(btn.dataset.clipboardText)
    }
}

更多

对于 apk 等下载包可以使用这个包(本人暂时没有试验,接触不多,回头熟悉了再回来补充。)

https://github.com/jawidx/web-launch-app

image-20200830145258473

大文件的分片下载

最近在开发媒体流相关的工作的时候,发现在加载 mp4 文件的时候,发现了一个比较有意思的现象,视频流并不需要将整个 mp4 下载完才进行播放,并且伴随了很多状态码为 206 的请求,乍一看有点像流媒体(HLS等)的韵味。

2020-08-29-21.31.29

觉得这个现象非常的有意思,他能够分片地加载资源,这对于体验或者是流量的节省都是非常大的帮助。最终发现它带了一个名为 Range 的头。我们来看看 MDN 的解释。

The Range 是一个请求首部,告知服务器返回文件的哪一部分。在一个 Range首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206Partial Content 状态码。 摘自 MDN

语法

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>

Node实现

既然我们知道了它的原理,就来自己实现一下。

router.get('/api/rangeFile', async(ctx) => {
    const { filename } = ctx.query;
    const { size } = fs.statSync(path.join(__dirname, './static/', filename));
    const range = ctx.headers['range'];
    if (!range) {
        ctx.set('Accept-Ranges', 'bytes');
        ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
        return;
    }
    const { start, end } = getRange(range);
    if (start >= size || end >= size) {
        ctx.response.status = 416;
        ctx.set('Content-Range', `bytes */${size}`);
        ctx.body = '';
        return;
    }
    ctx.response.status = 206;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`);
    ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end });
})

Nginx实现

发现 nginx 不需要写任何代码就默认支持了 range 头,想着我一定知道它到底是支持,还是加入了什么模块,或者是我默认开启了什么配置,找了半天没有找到什么额外的配置。

3630px-Nginx_logo-1

正当我准备放弃的时候,灵光一现,去看看源码吧,说不定会有发现,去查了 nginx 源码相关的内容,用了惯用的反推方式,才发现原来是max_ranges这个字段。

https://github.com/nginx/nginx/blob/release-1.13.6/src/http/modules/ngx_http_range_filter_module.c#L166

这也怪我一开始文档阅读不够仔细,浪费了大量的时间。

:) 其实我对 nginx 源码也不熟悉,这里可以用个小技巧,直接在源码库 搜索 206 然后 发现了一个宏命令

#define NGX_HTTP_PARTIAL_CONTENT           206

然后顺藤摸瓜,直接找到这个宏命令NGX_HTTP_PARTIAL_CONTENT用到的地方,这样一步一步就慢慢能找到我们想要的。

默认 nginx 是自动开启 range 头的, 如果不需要配置,则配置 max_range: 0;

Nginx 配置文档 http://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges

总结

我们可以来总结一下,其实全文主要讲了(xbb)两个核心的知识,一个是 blob 一个 a标签,另外还要注意对于大文件,服务器的优化策略,可以通过 Range 来分片加载。

image-20200830181216353

参考资料

https://github.com/dolanmiu/docx

https://github.com/SheetJS/sheetjs

https://juejin.im/post/6844903763359039501

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

查看原文

赞 44 收藏 37 评论 11

axuebin 关注了用户 · 9月2日

lzg9527 @michael_5c03399eed011

专注web前端开发,熟悉html5,css3,javascript,vue,react

关注 2764

axuebin 发布了文章 · 6月2日

这篇 iTerm2 + Oh My Zsh 教程手把手让你成为这条街最靓的仔

前言

作为一名程序员,开发环境不舒服会很大程度影响开发效率,所以一定要花时间好好整一下开发环境(好了,我知道你是在给摸鱼找借口)。

最近短短几个月,换了两次新电脑,经历了两次装机(由于各种原因,没法备份恢复,你懂的),每一次都得重新搞一套属于自己的开发环境。

这里就记录一下我是如何一步一步的打造属于自己的 TerminalmacOS 上的 Terminal 是怎么样的,你如果想和我一样,直接 cv 大法 就可以搞一套一样的。

文中的链接在微信里无法打开,如果有需要可以点击阅读全文跳转到掘金的文章里。

Terminal

Terminal 我们经常会称作 终端,现在中文版的 mac 里也是叫做这个。

我们每天都需要在其中输入很多命令去做一些事情。可以说,每天有大量的时间都需要面对它。

我记得我第一次点下鼠标,打开这个终端的时候,看到了这样一个界面:

我傻了。怎么这么丑?macOS 上怎么允许有这么丑的应用?

不行,如果让我每天对着它,一定会把电脑砸了(虽然它是高贵的 16寸 MacBook Pro),我得找一个第三方 Terminal 来替代它。

iTerm2

很快,我就找到了新欢,它的名字叫 iTerm2,它是一款完全免费,为 macOS 打造的一款终端工具,可以说是程序员必备了,如果还没用过的,赶紧跟着这篇文章用起来吧。

iTerm2 官网 符合国外网站一向的极简风格(又不是不能用,搞那么花里胡哨干嘛)。

直接下载,解压,拖入 Application 里就 ok 了。打开看看。

怎么感觉不太对,虽然你的背景变黑了,但依然掩盖不了你的丑啊。

没事儿,先天不足,后天努力嘛。

告别黑底白字,整出最骚终端,开始吧。

on my zsh

主角是它,拥有了它,你一定是你们组最靓的仔。

Oh My Zsh is an open source, community-driven framework for managing your zsh configuration.

安装

官网提供了两种安装方式:

# via curl
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

# via wget
sh -c "$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

如果,由于一些原因,上面两种方法你都没能安装成功,可以试一下手动安装:

# 下载 oh-my-zsh 源码
git clone git://github.com/robbyrussell/oh-my-zsh.git ~/.oh-my-zsh
# 并且把 .zshrc 配置文件拷贝到根目录下
cp ~/.oh-my-zsh/templates/zshrc.zsh-template ~/.zshrc
# 让 .zshrc 配置文件生效
source ~/.zshrc

嗯... 你和我说,clone 也不行啊,不可描述的原因,网速不允许啊。

那你这样做。

oh-my-zsh GitHub 上下载 zip -> 解压 -> 移动 oh-my-zsh 目录到根目录:

cd ~/Downloads
mv ohmyzsh-master ~/.oh-my-zsh
cp ~/.oh-my-zsh/templates/zshrc.zsh-template ~/.zshrc
source ~/.zshrc

如果还不行,你来找我。

好了,重新启动 iTerm2,是不是已经变了。

.zshrc

这个文件非常关键,是 oh-my-zsh 的配置文件,它的位置在根目录下,可以通过 vim ~/.zshrc 查看。

每一次修改它之后,如果想要立即生效需要手动执行 source ~/.zshrc

修改配色方案

一打开 .zshrc,就可以看到关于配色方案的配置:

# Set name of the theme to load --- if set to "random", it will
# load a random theme each time oh-my-zsh is loaded, in which case,
# to know which specific one was loaded, run: echo $RANDOM_THEME
# See https://github.com/ohmyzsh/ohmyzsh/wiki/Themes
ZSH_THEME="agnoster"

oh-my-zsh 提供了很多内置的配色方案,可以通过命令来查看:

ls ~/.oh-my-zsh/themes

也可以打开 https://github.com/ohmyzsh/ohmyzsh/wiki/Themes 更为直观的查看所有的配色方案。

只要修改 ZSH_THEME 的值就可以设置对应的配色方案了。

如果你想每天都过得不一样,可以设置成 random,每次打开 iTerm2 的都会随机使用一种配色方案。

我曾经有一段时间,由于不想折腾,使用的是这个配色方案:agnoster,它是这样的:

当然,有一天,我突然想造作一下,就开始自己配色。(没备份... 找不着了...)

如果你觉得默认的配色方案不够骚,并且觉得自己的审美 ok,也可以自己来搭配颜色。

自定义配色方案

入口:菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> + 一个配置 -> 选择 Colors

像我这样审美不行的人,花了一整天的时间搞这个,到头来发现,还是默认的更好看一点...

⚠️ 别摸一下午鱼搞这个被老板发现,还是下班了再搞吧。

第三方配色方案

当然,不是只有你和我想要自己搞一套最骚的配色方案,大家都有这样的想法。

iTerm2-Color-Schemes 这里有非常多的配色方案题,也已经在 GitHub 上开源。

你可以像我一样这样做:

# 找一个目录存放 iterm2 相关的文件
mkdir Code/other/iterm2
# 下载 iTerm2-Color-Schemes
git clone https://github.com/mbadolato/iTerm2-Color-Schemes
# schemes 文件夹就是真实存放配色方案的目录
cd iTerm2-Color-Schemes/schemes

同样,如果 clone 不下来就下载 zip 解压就好了。

通过以下操作路径可以导入所有配色方案:

菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> 选择 Colors -> 右下角 Color Presets -> Import...

找到 schemes 文件夹选中所有配色方案就好了,然后你就 眼花缭乱 会收获满满的幸福。

没事,等等会有更高级的方案。

安装字体 PowerFonts

为什么要安装字体呢?有些主题是会设置图标的,我们电脑上的字体一般都不支持这些图标,会出现乱码。

打开 Fonts 下载 zip 包都本地解压,就会得到很多字体。

# 将下载好的 fonts 移动到之前建的目录
mv ~/Downlaods/fonts-master ~/Code/other/iterm2/fonts
cd ~/Code/other/iterm2/fonts
# 执行安装文件
./install.sh

这样就安装好了,然后通过以下操作路径设置字体:

菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> 选择 Text

可以选择 Meslo 这个字体,乱码的图标就正常了。

毛玻璃效果/窗口大小

如果想要更高逼格的毛玻璃效果,并且找到自己舒服的大小(???),可以在这里设置:

操作路径:菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> 选择 Window

自定义背景

激动人心的时刻,你可以为你的终端设置一个自己喜欢的 小姐姐 图片作为背景,敲命令的时候都会更带劲吧:

咳咳,Dota 云玩家们,你是更喜欢冰女还是火女?

操作路径:菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> 选择 Window

状态栏

可以为每个打开的终端都设置一个状态栏,显示一些系统信息(比如 CPU、RAM、当前目录等)。

操作路径:菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> 选择 Session

总结

经过这一番折腾,一个属于你自己的高颜值终端就诞生了。

不过,总感觉这样还是有点麻烦,有没有更厉害的玩意儿?有的,我们这就用起来。

神器 Powerlevel10k

Powerlevel10k 简单来说就是一个 ZSH 的主题,只不过它的功能很强大,以下简称 p10k

安装

我们用的是 Oh My Zsh,所以这样安装 p10k 即可:

git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/themes/powerlevel10k

然后需要打开 ~/.zshrc 设置 ZSH_THEME:

ZSH_THEME="powerlevel10k/powerlevel10k

安装字体 Nerd Fonts

上文我们已经安装了 PowerFonts,如果需要使用一些图标,这个字体是不够用的,我们需要一个强大的字体:Nerd Fonts,它支持下面这么多种图标:

安装

你可以如官网所说,通过 brew 来安装:

brew tap homebrew/cask-fonts
brew cask install font-hack-nerd-font

但是我不建议这样,包括不建议你下载 zip 包,因为这个文件太大了,太大了,太大了。。。

我们可以这样:

打开 https://github.com/ryanoasis/nerd-fonts/releases,滑动页面找到 Assets 区域,如图:

我们只要下载箭头所指的 Hack.zip 这个字体包,解压缩之后就会获得一些 ttf 字体文件,双击安装即可。

zshrc 设置字体

POWERLEVEL9K_MODE="nerdfont-complete"
ZSH_THEME="powerlevel10k/powerlevel10k"

注意,需要设置在 ZSH_THEME 之前。

iTerm2 设置字体

操作路径:菜单栏 -> Profiles -> Open Profiles -> Edit Profiles -> 选择 Text

这样,所有的图标就都可以正常显示了。

自动配置

如果你指定了 ZSH_THEME="powerlevel10k/powerlevel10k" 但是在 zshrc 里没进行任何手动的配置,打开 iTerm2 的时候就会触发自动配置的流程。

也可以通过以下命令再次进入自动配置的流程:

p10k configure

问题大致如下:

  1. 这个符号看起来像钻石(旋转的正方形)吗?
  2. 这个符号看起来像锁吗?
  3. 这个符号看起来像 Debian logo 吗?
  4. 这些图标都交叉分布在 X 之间吗?
  5. 风格
  6. 编码
  7. 是否显示时间
  8. 目录层级分隔符
  9. 头部(左边)
  10. 尾部(右边)
  11. 是否换行
  12. 左边和右边是否有连接线
  13. 命令行和提示是否连接
  14. 两行命令之间分布稀疏还是松散
  15. 是否需要图标

后面几个选项随意,执行完命令之后,就会初始化 p10k:在根目录下生成 ~/.p10k.zsh,并且在 ~/.zshrc 底部写入:

如果想废除 p10k 的配置,只需要删除 ~/.p10k.zsh,并且删除上面这条命令即可。

自定义配置

如果你想当高玩,也可以在 ~/.zshrc 里手动配置 p10k,或者在 ~/.p10k.zsh 基础上进行修改。

这个得要自己看文档摸索啦,这里我简单说几个配置:

  • POWERLEVEL9K_LEFT_PROMPT_ELEMENTS
  • POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS
  • POWERLEVEL9K_VCS_GIT_GITHUB_ICON

POWERLEVEL9K_LEFT_PROMPT_ELEMENTS

显示在命令行左边区域的元素:

和上图相对应的配置为:

POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(user dir vcs newline)

POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS

显示在命令行右边区域的元素:

和上图相对应的配置为:

POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=(time)

可以在 POWERLEVEL9K_LEFT_PROMPT_ELEMENTSPOWERLEVEL9K_RIGHT_PROMPT_ELEMENTS 里用的字段有:

字段含义
user用户名
dir当前目录名
vcs远程仓库信息
os_icon系统图标
date日期
host主机名
status上一条命令的执行状态
time当前时间
......

如果还想了解更多,自行前往文档查看。

POWERLEVEL9K_VCS_GIT_GITHUB_ICON

如果它是一个 Github 目录,就会显示这个图标:

所以出现在窗口里的图标都可以自定义,可以通过命令查看目前正在使用的图标:

get_icon_names

找到想要修改的 KEY 就可以修改图标了。

注意:需要使用 Nerd Fonts 才能收获这满满的快乐。

有人问,这个图标的代码该去哪找呢?

在这里:https://www.nerdfonts.com/cheat-sheet

这是 Nerd Fonts 能够支持的所有图标,可以直接使用关键字进行搜索。

比如,我想修改 Git 的图标:

找到喜欢的图标之后,右下角的 f113 就是这个图标的值,只需要这样就了:

POWERLEVEL9K_VCS_GIT_GITHUB_ICON=$'\uf113'

快造作起来~

插件

到了这一步,你的 iTerm2 应该已经颜值爆表,足够好看了。

毕竟这是我们的饭碗,光好看不行,得好用,来了解一下强大的插件体系。

首先,我们先了解一下插件在 ~/.zshrc 的哪个位置,找到下面这个字段就不会错了:

plugins=(git)

git

git 插件是自带插件,默认已经开启,它可以让我们使用非常好用的的 git 命令,提高开发效率:

用了插件之前的 git 命令用了插件之后的 git 命令
git add --allgaa
git branch -DgbD
git commit -a -mgcam
git checkout -bgcb
git checkout mastergcm

是不是简单多了。可以通过命令查看所有配置:

vim ~/.oh-my-zsh/plugins/git/git.plugin.zsh

自动跳转对应目录

如果你像我一样是一个整理狂魔,会把文件、目录一层一层的整理好。

整理一时爽,用时就不爽

目录层级深了,年龄大了,就找不到文件放哪了,cd 起来也不方便了,有什么办法可以解决呢?教你两招。

设置别名 alias

打开 ~/.zshrc 输入别名,比如:

alias articles='~/Code/GitHub/articles'

然后执行 articles 就会自动跳到 ~/Code/GitHub/articles 了。

这样还是比较麻烦的,得为每个目录都配置 alias

autojump 插件

autojump 插件会记录你所有的访问记录,不同单独配置,直接访问即可。

安装
brew install autojump
配置

打开 ~/.zshrc 加一行代码:

[[ -s $(brew --prefix)/etc/profile.d/autojump.sh ]] && . $(brew --prefix)/etc/profile.d/autojump.sh

然后就是 source 一下就生效了。

使用

使用 j 命令就可以执行 auto-jump,比如 j articles

前提是你访问过 articles 目录,也就是你得让它记住。

zsh-autosuggestions

这个插件的作用很简单,就是像它名字一样,会在你输入命令的时候提示并且自动完成:

brew install zsh-autosuggestions

colors

这是一个文件目录美化插件,如图所示:

gem install colorls

然后执行 colors 就好了,你也可以设置 alias 更高效一点:

alias lc='colorls -lA --sd'

设置了别名之后,就像我一样,输入 lc 就好了。

我就只用了以上几个插件,已经能够大幅度提升工作效率了,如果有其它好用的插件,一定要告诉我呀。

VS Code 配置

如果你用的是 VS Code,需要再配置一下字体:

{
  "terminal.integrated.fontFamily": "Hack Nerd Font"
}

homebrew 安装

上面的几个插件都用的是 brew 命令安装,应该不在少数的人刚开始电脑上是没有 brew 的:

brew: command not found

然后就百度了一下,说要装一个叫 Homebrew 的东西,然后就按照官网的方式执行安装:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

如果安装成功了,恭喜你,你的运气真的很好。如果没安装成功,那你一定会各种百度如何安装,然后还是安装不成功:

curl: (7) Failed to connect to raw.githubusercontent.com port 443: Connection refused

有人告诉你,换一个中科大的源试试:

/usr/bin/ruby -e "$(curl -fsSL https://cdn.jsdelivr.net/gh/ineo6/homebrew-install/install)"

然后,你可能会卡在这:

==> Tapping homebrew/core
Cloning into '/usr/local/Homebrew/Library/Taps/homebrew/homebrew-core'...

也就是因为不可描述的原因,下载 homebrew-core 这个库的时候网络不行了,那我们就手动 clone 一个吧,或者下载一个 zip 包解压到对应目录:

cd "$(brew --repo)/Library/Taps/"
mkdir homebrew && cd homebrew
git clone git://mirrors.ustc.edu.cn/homebrew-core.git

然后再执行上面的命令安装就好了:

/usr/bin/ruby -e "$(curl -fsSL https://cdn.jsdelivr.net/gh/ineo6/homebrew-install/install)"

会看到成功安装的提示:

==> Installation successful!

写在最后

就问你这样一套终端开发环境骚不骚好不好用。不说别的,看着这背景,写代码都更有动力了。

交流讨论

欢迎关注公众号「前端试炼」,公众号平时会分享一些实用或者有意思的东西,发现代码之美。专注深度和最佳实践,希望打造一个高质量的公众号。

查看原文

赞 12 收藏 11 评论 2

axuebin 发布了文章 · 5月8日

【内推】阿里巴巴国际化部门 AE技术部 P6起 前端 Java 算法等

AE 技术部

国际化业务已成为集团的核心战略

我们是阿里巴巴新零售技术旗下全球化技术军团,支撑 AliExpress、Lazada、Tmall、Daraz、Trendyol 等国际化电商业务,致力于服务全球10亿+海外消费者,通过世界一流的技术构建全球买、全球卖、全球付、全球玩的普惠梦想和极致购物体验。

我们的员工来自全球 8 个国家地区,200多所海内外高校,遍布在杭州、深圳、俄罗斯、西班牙等全球办公地点。

期待你的加入,在阿里全球化的战略下,与全球精英一起挥斥方遒、纵横驰骋。

关于前端团队

作为阿里国际化进程中的核心技术力量,负责全球 B 端,C 端业务前端技术,关注全球用户体验,赋能业务提供更好的买卖家服务,目前服务 200+ 国家,拥有数十个研发平台,包含 AE,Lazada, 天猫国际等多个全球化链路。

我们包含国际化最全的前端技术栈,包含基础建设、搭投、中台、国际化小程序、国际化 B 端/ C 端解决方案等。

Base

杭州/深圳。

岗位职责

  • 参与跨平台的应用开发,包括 Web, Mobile Web, 及跨端技术,负责产出高质量的技术产品;
  • 充分理解产品和设计需求,负责 AliExpress WWW Site & Mobile Site的产品化过程的落地和实施;
  • 学习研究业界的前沿技术,并迅速转化到未来潜在的业务或技术项目中;
  • 与团队成员分享项目经验和业界新技术的应用,和团队一同成长。

职位要求

P6 以上,2 年经验以上就可。

  • 精通 HTMLCSSJavascript,热衷浏览各类网页的源代码;
  • 熟练掌握 JavaNodeJSPython 等任意一种或多种后端语言,并有实际的项目经验;
  • 深谙 MV* 模式,熟悉 AMPAngularJsReactJSVueJSPolymer 等任意一种前端 UI 框架,一定程度了解其原理;
  • 熟悉主流浏览器特性,乐于探究和解决各种类型的兼容性问题;
  • 英语能流畅沟通者优先。

联系方式

可加微信 xb9207,备注 内推

放心,绝对认真对待你的简历,先看一下是否合适再内推。

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 611 次点赞
  • 获得 18 枚徽章 获得 0 枚金徽章, 获得 3 枚银徽章, 获得 15 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2016-01-25
个人主页被 1.9k 人浏览