前端森林

前端森林 查看完整档案

上海编辑  |  填写毕业院校  |  填写所在公司/组织 github.com/Cosen95 编辑
编辑

个人动态

前端森林 发布了文章 · 4月1日

那些年错过的React组件单元测试(下)

👨‍🌾 写在前面

上篇文章我们已经了解了前端单元测试的背景和基础的jestapi,本篇文章我会先介绍一下Enzyme,然后结合项目中的一个真实组件,来为它编写测试用例。

👨‍🚀 Enzyme

上一篇中我们其实已经简单介绍了enzyme,但这远远不够,在本篇的组件测试用例编写中,我们有很多地方要用到它,因此这里专门来说明一下。

Enzyme是由Airbnb开源的一个ReactJavaScript测试工具,使React组件的输出更加容易。EnzymeAPIjQuery操作DOM一样灵活易用,因为它使用的是cheerio库来解析虚拟DOM,而cheerio的目标则是做服务器端的jQueryEnzyme兼容大多数断言库和测试框架,如chaimochajasmine等。

🙋 关于安装和配置,上一小节已经有过说明,这里就不赘述了

常用函数

enzyme中有几个比较核心的函数,如下:

  • simulate(event, mock):用来模拟事件触发,event为事件名称,mock为一个event object
  • instance():返回测试组件的实例;
  • find(selector):根据选择器查找节点,selector可以是CSS中的选择器,也可以是组件的构造函数,以及组件的display name等;
  • at(index):返回一个渲染过的对象;
  • text():返回当前组件的文本内容;
  • html(): 返回当前组件的HTML代码形式;
  • props():返回根组件的所有属性;
  • prop(key):返回根组件的指定属性;
  • state():返回根组件的状态;
  • setState(nextState):设置根组件的状态;
  • setProps(nextProps):设置根组件的属性;

渲染方式

enzyme 支持三种方式的渲染:

  • shallow:浅渲染,是对官方的Shallow Renderer的封装。将组件渲染成虚拟DOM对象,只会渲染第一层,子组件将不会被渲染出来,因而效率非常高。不需要 DOM 环境, 并可以使用jQuery的方式访问组件的信息;
  • render:静态渲染,它将React组件渲染成静态的HTML字符串,然后使用Cheerio这个库解析这段字符串,并返回一个Cheerio的实例对象,可以用来分析组件的html结构;
  • mount:完全渲染,它将组件渲染加载成一个真实的DOM节点,用来测试DOM API的交互和组件的生命周期,用到了jsdom来模拟浏览器环境。

三种方法中,shallowmount因为返回的是DOM对象,可以用simulate进行交互模拟,而render方法不可以。一般shallow方法就可以满足需求,如果需要对子组件进行判断,需要使用render,如果需要测试组件的生命周期,需要使用mount方法。

渲染方式部分参考的这篇文章

🐶 “踩坑之路”开启

组件代码

首先,来看下我们需要对其进行测试的组件部分的代码:

⚠️ 因为牵扯到内部代码,所以很多地方都打码了。重在演示针对不同类型的测试用例的编写
import { SearchOutlined } from "@ant-design/icons"
import {
  Button,
  Col,
  DatePicker,
  Input,
  message,
  Modal,
  Row,
  Select,
  Table,
} from "antd"
import { connect } from "dva"
import { Link, routerRedux } from "dva/router"
import moment from "moment"
import PropTypes from "prop-types"
import React from "react"

const { Option } = Select
const { RangePicker } = DatePicker
const { confirm } = Modal


export class MarketRuleManage extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      productID: "",

    }
  }
  componentDidMount() {
    // console.log("componentDidMount生命周期")
  }



  getTableColumns = (columns) => {
    return [
      ...columns,
      {
        key: "operation",
        title: "操作",
        dataIndex: "operation",
        render: (_text, record, _index) => {
          return (
            <React.Fragment>
              <Button
                type="primary"
                size="small"
                style={{ marginRight: "5px" }}
                onClick={() => this.handleRuleEdit(record)}
              >
                编辑
              </Button>
              <Button
                type="danger"
                size="small"
                onClick={() => this.handleRuleDel(record)}
              >
                删除
              </Button>
            </React.Fragment>
          )
        },
      },
    ]
  }


  handleSearch = () => {
    console.log("点击查询")
    const { pagination } = this.props
    pagination.current = 1
    this.handleTableChange(pagination)
  }

  render() {
    // console.log("props11111", this.props)
    const { pagination, productList, columns, match } = this.props
    const { selectedRowKeys } = this.state
    const rowSelection = {
      selectedRowKeys,
      onChange: this.onSelectChange,
    }

    const hasSelected = selectedRowKeys.length > 0
    return (
      <div className="content-box marketRule-container">
        <h2>XX录入系统</h2>
        <Row>
          <Col className="tool-bar">
            <div className="filter-span">
              <label>产品ID</label>
              <Input
                data-test="marketingRuleID"
                style={{ width: 120, marginRight: "20px", marginLeft: "10px" }}
                placeholder="请输入产品ID"
                maxLength={25}
                onChange={this.handlemarketingRuleIDChange}
              ></Input>
              <Button
                type="primary"
                icon={<SearchOutlined />}
                style={{ marginRight: "15px" }}
                onClick={() => this.handleSearch()}
                data-test="handleSearch"
              >
                查询
              </Button>
            </div>
          </Col>
        </Row>
        <Row>
          <Col>
            <Table
              tableLayout="fixed"
              bordered="true"
              rowKey={(record) => `${record.ruleid}`}
              style={{ marginTop: "20px" }}
              pagination={{
                ...pagination,
              }}
              columns={this.getTableColumns(columns)}
              dataSource={productList}
              rowSelection={rowSelection}
              onChange={this.handleTableChange}
            ></Table>
          </Col>
        </Row>
      </div>
    )
  }



MarketRuleManage.prototypes = {
  columns: PropTypes.array,
}
MarketRuleManage.defaultProps = {
  columns: [
  {
      key: "xxx",
      title: "产品ID",
      dataIndex: "xxx",
      width: "10%",
      align: "center",
    },
    {
      key: "xxx",
      title: "产品名称",
      dataIndex: "xxx",
      align: "center",
    },
    {
      key: "xxx",
      title: "库存",
      dataIndex: "xxx",
      align: "center",
      // width: "12%"
    },
    {
      key: "xxx",
      title: "活动有效期开始",
      dataIndex: "xxx",
      // width: "20%",
      align: "center",
      render: (text) => {
        return text ? moment(text).format("YYYY-MM-DD HH:mm:ss") : null
      },
    },
    {
      key: "xxx",
      title: "活动有效期结束",
      dataIndex: "xxx",
      // width: "20%",
      align: "center",
      render: (text) => {
        return text ? moment(text).format("YYYY-MM-DD HH:mm:ss") : null
      },
    },
  ],
}

const mapStateToProps = ({ marketRuleManage }) => ({
  pagination: marketRuleManage.pagination,
  productList: marketRuleManage.productList,
  productDetail: marketRuleManage.productDetail,
})

const mapDispatchToProps = (dispatch) => ({
  queryMarketRules: (data) =>
    dispatch({ type: "marketRuleManage/queryRules", payload: data }),
  editMarketRule: (data) =>
    dispatch({ type: "marketRuleManage/editMarketRule", payload: data }),
  delMarketRule: (data, cb) =>
    dispatch({ type: "marketRuleManage/delMarketRule", payload: data, cb }),
  deleteByRuleId: (data, cb) =>
    dispatch({ type: "marketRuleManage/deleteByRuleId", payload: data, cb }),
})

export default connect(mapStateToProps, mapDispatchToProps)(MarketRuleManage)

简单介绍一下组件的功能:这是一个被connect包裹的高阶组件,页面展示如下:

我们要添加的测试用例如下:

1、页面能够正常渲染

2、DOM测试:标题应该为XX录入系统

3、组件生命周期可以被正常调用

4、组件内方法handleSearch(即“查询”按钮上绑定的事件)可以被正常调用

5、产品 ID 输入框内容更改后,stateproductID值会随之变化

6、MarketRuleManage组件应该接受指定的props参数

测试页面快照

明确了需求,让我们开始编写第一版的测试用例代码:

import React from "react"
import { mount, shallow } from "enzyme"


import MarketRuleManage from "../../../src/routes/marketRule-manage"

describe("XX录入系统页面", () => {

  // 使用 snapshot 进行 UI 测试
  it("页面应能正常渲染", () => {
    const wrapper = shallow(<MarketRuleManage />)
    expect(wrapper).toMatchSnapshot()
  })

})

执行npm run test:

npm run test对应的脚本是jest --verbose


报错了:
Either wrap the root component in a <Provider>, or explicitly pass "store" as a prop to "Connect(MarketRuleManage)".意思就是我们需要给connect包裹的组件传递一个store

经过一番搜索,我在stackoverflow找到了答案,需要使用redux-mock-store中的configureMockStore来模拟一个假的store。来调整一下测试代码:

import React from "react"
➕import { Provider } from "react-redux"
➕import configureMockStore from "redux-mock-store"
import { mount, shallow } from "enzyme"


import MarketRuleManage from "../../../src/routes/marketRule-manage"

➕const mockStore = configureMockStore()
➕const store = mockStore({
➕ marketRuleManage: {
➕   pagination: {},
➕    productList: [],
➕    productDetail: {},
➕  },
➕})

➕const props = {
➕  match: {
➕    url: "/",
➕  },
➕}

describe("XX录入系统页面", () => {

  // 使用 snapshot 进行 UI 测试
  it("页面应能正常渲染", () => {
➕    const wrapper = shallow(<Provider store={store}>
➕      <MarketRuleManage {...props} />
➕   </Provider>)
    expect(wrapper).toMatchSnapshot()
  })

})

再次运行npm run test

ok,第一条测试用例通过了,并且生成了快照目录__snapshots__

测试页面DOM

我们接着往下,来看第二条测试用例:DOM测试:标题应该为XX录入系统

修改测试代码:

import React from "react"
import { Provider } from "react-redux"
import configureMockStore from "redux-mock-store"
import { mount, shallow } from "enzyme"


import MarketRuleManage from "../../../src/routes/marketRule-manage"

const mockStore = configureMockStore()
const store = mockStore({
  marketRuleManage: {
    pagination: {},
    productList: [],
    productDetail: {},
  },
})

const props = {
  match: {
    url: "/",
  },
}

describe("XX录入系统页面", () => {

  // 使用 snapshot 进行 UI 测试
  it("页面应能正常渲染", () => {
    const wrapper = shallow(<Provider store={store}>
      <MarketRuleManage {...props} />
    </Provider>)
    expect(wrapper).toMatchSnapshot()
  })

  // 对组件节点进行测试
  it("标题应为'XX录入系统'", () => {
    const wrapper = shallow(<Provider store={store}>
      <MarketRuleManage {...props} />
    </Provider>)
    expect(wrapper.find("h2").text()).toBe("XX录入系统")
  })

})

运行npm run test

纳尼?Method “text” is meant to be run on 1 node. 0 found instead.找不到h2标签?

我们在开篇介绍enzyme时,知道它有三种渲染方式,那这里我们改为mount试试。再次运行npm run test

漂亮,又出来一个新的错误:Invariant Violation: You should not use <Link> outside a <Router>

一顿搜索,再次在stackoverflow找到了答案(不得不说 stackoverflow 真香),因为我的项目中用到了路由,而这里是需要包装一下的:

import { BrowserRouter } from 'react-router-dom';
import Enzyme, { shallow, mount } from 'enzyme';

import { shape } from 'prop-types';

// Instantiate router context
const router = {
  history: new BrowserRouter().history,
  route: {
    location: {},
    match: {},
  },
};

const createContext = () => ({
  context: { router },
  childContextTypes: { router: shape({}) },
});

export function mountWrap(node) {
  return mount(node, createContext());
}

export function shallowWrap(node) {
  return shallow(node, createContext());
}

这里我把这部分代码提取到了一个单独的routerWrapper.js文件中。

然后我们修改下测试代码:

import React from "react"
import { Provider } from "react-redux"
import configureMockStore from "redux-mock-store"
import { mount, shallow } from "enzyme"

import MarketRuleManage from "../../../src/routes/marketRule-manage"
➕import {
➕  mountWrap,
➕  shallowWithIntlWrap,
➕  shallowWrap,
➕} from "../../utils/routerWrapper"

const mockStore = configureMockStore()
const store = mockStore({
  marketRuleManage: {
    pagination: {},
    productList: [],
    productDetail: {},
  },
})

const props = {
  match: {
    url: "/",
  },
}

➕const wrappedShallow = () =>
  shallowWrap(
    <Provider store={store}>
      <MarketRuleManage {...props} />
    </Provider>
  )

➕const wrappedMount = () =>
  mountWrap(
    <Provider store={store}>
      <MarketRuleManage {...props} />
    </Provider>
  )

describe("XX录入系统页面", () => {

  // 使用 snapshot 进行 UI 测试
  it("页面应能正常渲染", () => {
🔧  const wrapper = wrappedShallow()
    expect(wrapper).toMatchSnapshot()
  })

  // 对组件节点进行测试
  it("标题应为'XX录入系统'", () => {
 🔧   const wrapper = wrappedMount()
    expect(wrapper.find("h2").text()).toBe("XX录入系统")
  })

})
⚠️ 注意代码中的图标,➕ 代表新增代码,🔧 代表代码有修改

运行npm run test

报错TypeError: window.matchMedia is not a function,这又是啥错误啊!!

查阅相关资料,matchMedia是挂载在window上的一个对象,表示指定的媒体查询字符串解析后的结果。它可以监听事件。通过监听,在查询结果发生变化时,就调用指定的回调函数。

显然jest单元测试需要对matchMedia对象做一下mock。经过搜索,在stackoverflow这里找到了答案:

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // Deprecated
    removeListener: jest.fn(), // Deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

把上述代码写到一个单独的matchMedia.js文件中,然后在上面的routerWrapper.js文件中引入:

import { mount, shallow } from "enzyme"
import { mountWithIntl, shallowWithIntl } from "enzyme-react-intl"
import { shape } from "prop-types"
import { BrowserRouter } from "react-router-dom"
➕import "./matchMedia"

// Instantiate router context
const router = {
  history: new BrowserRouter().history,
  route: {
    location: {},
    match: {},
  },
}

const createContext = () => ({
  context: { router },
  childContextTypes: { router: shape({}) },
})
// ...

此时重新运行npm run test


ok,第二条测试用例也顺利通过了~

测试生命周期

来看第三条测试 case:组件生命周期可以被正常调用

使用spyOnmock组件的componentDidMount。添加测试代码:

// 测试组件生命周期
it("组件生命周期", () => {
  const componentDidMountSpy = jest.spyOn(
    MarketRuleManage.prototype,
    "componentDidMount"
  )
  const wrapper = wrappedMount()

  expect(componentDidMountSpy).toHaveBeenCalled()

  componentDidMountSpy.mockRestore()
})

运行npm run test:

用例顺利通过~

记得要在用例最后对mock的函数进行mockRestore()

测试组件的内部函数

接着来看第四条测试 case:组件内方法handleSearch(即“查询”按钮上绑定的事件)可以被正常调用。

添加测试代码:

// 测试组件的内部函数
it("组件内方法handleSearch可以被正常调用", () => {
  const wrapper = wrappedMount()

  const instance = wrapper.instance()
  const spyFunction = jest.spyOn(instance, "handleSearch")
  instance.handleSearch()
  expect(spyFunction).toHaveBeenCalled() // handleSearch被调用了一次
  spyFunction.mockRestore()
})

执行npm run test:

报错了:Cannot spy the handleSearch property because it is not a function; undefined given instead

没办法,只能搜一下,寻求答案,首先在stackoverflow得到了如下方案:

大致意思就是要用shallowWithIntl()来包裹一下组件,然后被包裹的组件需要用dive()一下。

我立即修改了代码,再次运行npm run test,结果依然是一样的。

没办法,接着搜索,在enzyme 的#365issue看到了似乎很接近的答案:

就是在jest.spyOn()之后对组件进行强制更新:wrapper.instance().forceUpdate()wrapper.update()

接着修改代码、调试,依然无效。

我,郁闷了。。。

中间也找了很多方案,但都没用。

这时正好在内部文档上看到了一个其他 BU 大佬写的单元测试总结,于是就厚着脸皮去找大佬聊了聊,果不其然,这招很凑效,一语点醒梦中人:你的组件被connect包裹,是一个高阶组件,需要拿instance之前做下find操作,这样才能拿到真实组件的实例。

感谢完大佬,我立即去实践:

// 测试组件的内部函数
it("组件内方法handleSearch可以被正常调用", () => {
  const wrapper = wrappedMount()

  const instance = wrapper.find("MarketRuleManage").instance()
  const spyFunction = jest.spyOn(instance, "handleSearch")
  instance.handleSearch()
  expect(spyFunction).toHaveBeenCalled() // handleSearch被调用了一次
  spyFunction.mockRestore()
})

迫不及待的npm run test:

嗯,测试用例顺利通过,真香!

写完这个用例,我不禁反思:小伙子,基础还是不太行啊

还是要多写多实践才行啊!

测试组件 state

废话少说,我们来看第五条测试用例:产品 ID 输入框内容更改后,stateproductID值会随之变化

添加测试代码:

// 测试组件state
it("产品ID输入框内容更改后,state中productID会随之变化", () => {
  const wrapper = wrappedMount()
  const inputElm = wrapper.find("[data-test='marketingRuleID']").first()
  const userInput = 1111
  inputElm.simulate("change", {
    target: { value: userInput },
  })
  // console.log(
  //   "wrapper",
  //   wrapper.find("MarketRuleManage").instance().state.productID
  // )
  const updateProductID = wrapper.find("MarketRuleManage").instance().state
    .productID

  expect(updateProductID).toEqual(userInput)
})

这里其实是模拟用户的输入行为,然后使用simulate监听输入框的change事件,最终判断input的改变是否能同步到state中。

这个用例其实是有点BDD的意思了

我们运行npm run test

用例顺利通过~

测试组件 props

终于来到了最后一个测试用例:MarketRuleManage组件应该接受指定的props参数

添加测试代码:

// 测试组件props
it("MarketRuleManage组件应该接收指定的props", () => {
  const wrapper = wrappedMount()
  // console.log("wrapper", wrapper.find("MarketRuleManage").instance())
  const instance = wrapper.find("MarketRuleManage").instance()
  expect(instance.props.match).toBeTruthy()
  expect(instance.props.pagination).toBeTruthy()
  expect(instance.props.productList).toBeTruthy()
  expect(instance.props.productDetail).toBeTruthy()
  expect(instance.props.queryMarketRules).toBeTruthy()
  expect(instance.props.editMarketRule).toBeTruthy()
  expect(instance.props.delMarketRule).toBeTruthy()
  expect(instance.props.deleteByRuleId).toBeTruthy()
  expect(instance.props.columns).toBeTruthy()
})

执行npm run test

到这里,我们所有的测试用例就执行完了~

我们执行的这 6 条用例基本可以比较全面的涵盖React组件单元测试了,当然因为我们这里用的是dva,那么难免也要对model进行测试,这里我放一下一个大佬的dva-example-user-dashboard 单元测试,里面已经列举的比较详细了,我就不班门弄斧了。

查看原文

赞 3 收藏 3 评论 0

前端森林 发布了文章 · 3月25日

那些年错过的React组件单元测试(上)

🏂 写在前面

关于前端单元测试,其实两年前我就已经关注了,但那时候只是简单的知道断言,想着也不是太难的东西,项目中也没有用到,然后就想当然的认为自己就会了。

两年后的今天,部门要对以往的项目补加单元测试。真到了开始着手的时候,却懵了 😂

我以为的我以为却把自己给坑了,我发现自己对于前端单元测试一无所知。然后我翻阅了大量的文档,发现基于dva的单元测试文档比较少,因此在有了一番实践之后,我梳理了几篇文章,希望对于想使用 Jest 进行 React + Dva + Antd 单元测试的你能有所帮助。文章内容力求深入浅出,浅显易懂~

介于内容全部收在一篇会太长,计划分为两篇,本篇是第一篇,主要介绍如何快速上手jest以及在实战中常用的功能及api

🏈 前端自动化测试产生的背景

在开始介绍jest之前,我想有必要简单阐述一下关于前端单元测试的一些基础信息。

  • 为什么要进行测试?

    在 2021 年的今天,构建一个复杂的web应用对于我们来说,并非什么难事。因为有足够多优秀的的前端框架(比如 ReactVue);以及一些易用且强大的UI库(比如 Ant DesignElement UI)为我们保驾护航,极大地缩短了应用构建的周期。但是快速迭代的过程中却产生了大量的问题:代码质量(可读性差、可维护性低、可扩展性低)低,频繁的产品需求变动(代码变动影响范围不可控)等。

    因此单元测试的概念在前端领域应运而生,通过编写单元测试可以确保得到预期的结果,提高代码的可读性,如果依赖的组件有修改,受影响的组件也能在测试中及时发现错误。

  • 测试类型又有哪些呢?

    一般常见的有以下四种:

    • 单元测试
    • 功能测试
    • 集成测试
    • 冒烟测试
  • 常见的开发模式呢?

    • TDD: 测试驱动开发
    • BDD: 行为驱动测试

🎮 技术方案

针对项目本身使用的是React + Dva + Antd的技术栈,单元测试我们用的是Jest + Enzyme结合的方式。

Jest

关于Jest,我们参考一下其Jest 官网,它是Facebook开源的一个前端测试框架,主要用于ReactReact Native的单元测试,已被集成在create-react-app中。Jest特点:

  • 零配置
  • 快照
  • 隔离
  • 优秀的 api
  • 快速且安全
  • 代码覆盖率
  • 轻松模拟
  • 优秀的报错信息

Enzyme

EnzymeAirbnb开源的React测试工具库,提供了一套简洁强大的API,并内置Cheerio,同时实现了jQuery风格的方式进行DOM处理,开发体验十分友好。在开源社区有超高人气,同时也获得了React官方的推荐。

📌 Jest

本篇文章我们着重来介绍一下Jest,也是我们整个React单元测试的根基。

环境搭建

安装

安装JestEnzyme。如果React的版本是15或者16,需要安装对应的enzyme-adapter-react-15enzyme-adapter-react-16并配置。

/**
 * setup
 *
 */

import Enzyme from "enzyme"
import Adapter from "enzyme-adapter-react-16"
Enzyme.configure({ adapter: new Adapter() })

jest.config.js

可以运行npx jest --init在根目录生成配置文件jest.config.js

/*
 * For a detailed explanation regarding each configuration property, visit:
 * https://jestjs.io/docs/en/configuration.html
 */

module.exports = {
  // All imported modules in your tests should be mocked automatically
  // automock: false,

  // Automatically clear mock calls and instances between every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  // collectCoverage: true,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"],

  // The directory where Jest should output its coverage files
  coverageDirectory: "coverage",

  // An array of regexp pattern strings used to skip coverage collection
  // coveragePathIgnorePatterns: [
  //   "/node_modules/"
  // ],


  // An array of directory names to be searched recursively up from the requiring module's location
  moduleDirectories: ["node_modules", "src"],

  // An array of file extensions your modules use
  moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx"],


  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
  // modulePathIgnorePatterns: [],

  // Automatically reset mock state between every test
  // resetMocks: false,

  // Reset the module registry before running each individual test
  // resetModules: false,

  // Automatically restore mock state between every test
  // restoreMocks: false,

  // The root directory that Jest should scan for tests and modules within
  // rootDir: undefined,

  // A list of paths to directories that Jest should use to search for files in
  // roots: [
  //   "<rootDir>"
  // ],

  // The paths to modules that run some code to configure or set up the testing environment before each test
  // setupFiles: [],

  // A list of paths to modules that run some code to configure or set up the testing framework before each test
  setupFilesAfterEnv: [
    "./node_modules/jest-enzyme/lib/index.js",
    "<rootDir>/src/utils/testSetup.js",
  ],

  // The test environment that will be used for testing
  testEnvironment: "jest-environment-jsdom",

  // Options that will be passed to the testEnvironment
  // testEnvironmentOptions: {},

  // The glob patterns Jest uses to detect test files
  testMatch: ["**/?(*.)+(spec|test).[tj]s?(x)"],

  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
  // testPathIgnorePatterns: [
  //   "/node_modules/"
  // ],


  // A map from regular expressions to paths to transformers
  // transform: undefined,

  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
  transformIgnorePatterns: ["/node_modules/", "\\.pnp\\.[^\\/]+$"],
}

这里只是列举了常用的配置项:

  • automock: 告诉 Jest 所有的模块都自动从 mock 导入.
  • clearMocks: 在每个测试前自动清理 mock 的调用和实例 instance
  • collectCoverage: 是否收集测试时的覆盖率信息
  • collectCoverageFrom: 生成测试覆盖报告时检测的覆盖文件
  • coverageDirectory: Jest 输出覆盖信息文件的目录
  • coveragePathIgnorePatterns: 排除出 coverage 的文件列表
  • coverageReporters: 列出包含 reporter 名字的列表,而 Jest 会用他们来生成覆盖报告
  • coverageThreshold: 测试可以允许通过的阈值
  • moduleDirectories: 模块搜索路径
  • moduleFileExtensions:代表支持加载的文件名
  • testPathIgnorePatterns:用正则来匹配不用测试的文件
  • setupFilesAfterEnv:配置文件,在运行测试案例代码之前,Jest 会先运行这里的配置文件来初始化指定的测试环境
  • testMatch: 定义被测试的文件
  • transformIgnorePatterns: 设置哪些文件不需要转译
  • transform: 设置哪些文件中的代码是需要被相应的转译器转换成 Jest 能识别的代码,Jest 默认是能识别 JS 代码的,其他语言,例如 Typescript、CSS 等都需要被转译。

匹配器

  • toBe(value):使用 Object.is 来进行比较,如果进行浮点数的比较,要使用 toBeCloseTo
  • not:取反
  • toEqual(value):用于对象的深比较
  • toContain(item):用来判断 item 是否在一个数组中,也可以用于字符串的判断
  • toBeNull(value):只匹配 null
  • toBeUndefined(value):只匹配 undefined
  • toBeDefined(value):与 toBeUndefined 相反
  • toBeTruthy(value):匹配任何语句为真的值
  • toBeFalsy(value):匹配任何语句为假的值
  • toBeGreaterThan(number): 大于
  • toBeGreaterThanOrEqual(number):大于等于
  • toBeLessThan(number):小于
  • toBeLessThanOrEqual(number):小于等于
  • toBeInstanceOf(class):判断是不是 class 的实例
  • resolves:用来取出 promise 为 fulfilled 时包裹的值,支持链式调用
  • rejects:用来取出 promise 为 rejected 时包裹的值,支持链式调用
  • toHaveBeenCalled():用来判断 mock function 是否被调用过
  • toHaveBeenCalledTimes(number):用来判断 mock function 被调用的次数
  • assertions(number):验证在一个测试用例中有 number 个断言被调用

命令行工具的使用

在项目package.json文件添加如下script:

"scripts": {
    "start": "node bin/server.js",
    "dev": "node bin/server.js",
    "build": "node bin/build.js",
    "publish": "node bin/publish.js",
++  "test": "jest --watchAll",
},

此时运行npm run test:

我们发现有以下几种模式:

  • f: 只会测试之前没有通过的测试用例
  • o: 只会测试关联的并且改变的文件(需要使用 git)(jest --watch 可以直接进入该模式)
  • p: 测试文件名包含输入的名称的测试用例
  • t: 测试用例的名称包含输入的名称的测试用例
  • a: 运行全部测试用例

在测试过程中,你可以切换适合的模式。

钩子函数

类似于 react 或者 vue 的生命周期,一共有四种:

  • beforeAll():所有测试用例执行之前执行的方法
  • afterAll():所有测试用例跑完以后执行的方法
  • beforeEach():在每个测试用例执行之前需要执行的方法
  • afterEach():在每个测试用例执行完后执行的方法

这里,我以项目中的一个基础 demo 来演示一下具体使用:

Counter.js

export default class Counter {
  constructor() {
    this.number = 0
  }
  addOne() {
    this.number += 1
  }
  minusOne() {
    this.number -= 1
  }
}

Counter.test.js

import Counter from './Counter'
const counter = new Counter()

test('测试 Counter 中的 addOne 方法', () => {
  counter.addOne()
  expect(counter.number).toBe(1)
})

test('测试 Counter 中的 minusOne 方法', () => {
  counter.minusOne()
  expect(counter.number).toBe(0)
})

运行npm run test:

通过第一个测试用例加 1,number的值为 1,当第二个用例减 1 的时候,结果应该是 0。但是这样两个用例间相互干扰不好,可以通过 Jest 的钩子函数来解决。修改测试用例:

import Counter from "../../../src/utils/Counter";
let counter = null

beforeAll(() => {
  console.log('BeforeAll')
})

beforeEach(() => {
  console.log('BeforeEach')
  counter = new Counter()
})

afterEach(() => {
  console.log('AfterEach')
})

afterAll(() => {
  console.log('AfterAll')
})

test('测试 Counter 中的 addOne 方法', () => {
  counter.addOne()
  expect(counter.number).toBe(1)
})
test('测试 Counter 中的 minusOne 方法', () => {
  counter.minusOne()
  expect(counter.number).toBe(-1)
})

运行npm run test:

可以清晰的看到对应钩子的执行顺序:

beforeAll > (beforeEach > afterEach)(单个用例都会依次执行) > afterAll

除了以上这些基础知识外,其实还有异步代码的测试、Mock、Snapshot 快照测试等,这些我们会在下面 React 的单元测试示例中依次讲解。

异步代码的测试

众所周知,JS中充满了异步代码。

正常情况下测试代码是同步执行的,但当我们要测的代码是异步的时候,就会有问题了:test case实际已经结束了,然而我们的异步代码还没有执行,从而导致异步代码没有被测到。

那怎么办呢?

对于当前测试代码来说,异步代码什么时候执行它并不知道,因此解决方法很简单。当有异步代码的时候,测试代码跑完同步代码后不立即结束,而是等结束的通知,当异步代码执行完后再告诉jest:“好了,异步代码执行完了,你可以结束任务了”。

jest提供了三种方案来测试异步代码,下面我们分别来看一下。

done 关键字

当我们的test函数中出现了异步回调函数时,可以给test函数传入一个done参数,它是一个函数类型的参数。如果test函数传入了donejest就会等到done被调用才会结束当前的test case,如果done没有被调用,则该test自动不通过测试。

import { fetchData } from './fetchData'
test('fetchData 返回结果为 { success: true }', done => {
  fetchData(data => {
    expect(data).toEqual({
      success: true
    })
    done()
  })
})

上面的代码中,我们给test函数传入了done参数,在fetchData的回调函数中调用了done。这样,fetchData的回调中异步执行的测试代码就能够被执行。

但这里我们思考一种场景:如果使用done来测试回调函数(包含定时器场景,如setTimeout),由于定时器我们设置了 一定的延时(如 3s)后执行,等待 3s 后会发现测试通过了。那假如 setTimeout 设置为几百秒,难道我们也要在 Jest 中等几百秒后再测试吗?

显然这对于测试的效率是大打折扣的!!

jest中提供了诸如jest.useFakeTimers()jest.runAllTimers()toHaveBeenCalledTimesjest.advanceTimersByTimeapi来处理这种场景。

这里我也不举例详细说明了,有这方面需求的同学可以参考Timer Mocks

返回 Promise

⚠️ 当对Promise进行测试时,一定要在断言之前加一个return,不然没有等到Promise的返回,测试函数就会结束。可以使用.promises/.rejects对返回的值进行获取,或者使用then/catch方法进行判断。

如果代码中使用了Promise,则可以通过返回Promise来处理异步代码,jest会等该promise的状态转为resolve时才会结束,如果promisereject了,则该测试用例不通过。

// 假设 user.getUserById(参数id) 返回一个promise
it('测试promise成功的情况', () => {
  expect.assertions(1);
  return user.getUserById(4).then((data) => {
    expect(data).toEqual('Cosen');
  });
});
it('测试promise错误的情况', () => {
  expect.assertions(1);
  return user.getUserById(2).catch((e) => {
    expect(e).toEqual({
      error: 'id为2的用户不存在',
    });
  });
});

注意,上面的第二个测试用例可用于测试promise返回reject的情况。这里用.catch来捕获promise返回的reject,当promise返回reject时,才会执行expect语句。而这里的expect.assertions(1)用于确保该测试用例中有一个expect被执行了。

对于Promise的情况,jest还提供了一对匹配符resolves/rejects,其实只是上面写法的语法糖。上面的代码用匹配符可以改写为:

// 使用'.resolves'来测试promise成功时返回的值
it('使用'.resolves'来测试promise成功的情况', () => {
  return expect(user.getUserById(4)).resolves.toEqual('Cosen');
});
// 使用'.rejects'来测试promise失败时返回的值
it('使用'.rejects'来测试promise失败的情况', () => {
  expect.assertions(1);
  return expect(user.getUserById(2)).rejects.toEqual({
    error: 'id为2的用户不存在',
  });
});

async/await

我们知道async/await其实是Promise的语法糖,可以更优雅地写异步代码,jest中也支持这种语法。

我们把上面的代码改写一下:

// 使用async/await来测试resolve
it('async/await来测试resolve', async () => {
  expect.assertions(1);
  const data = await user.getUserById(4);
  return expect(data).toEqual('Cosen');
});
// 使用async/await来测试reject
it('async/await来测试reject', async () => {
  expect.assertions(1);
  try {
    await user.getUserById(2);
  } catch (e) {
    expect(e).toEqual({
      error: 'id为2的用户不存在',
    });
  }
});
⚠️ 使用async不用进行return返回,并且要使用try/catch来对异常进行捕获。

Mock

介绍jest中的mock之前,我们先来思考一个问题:为什么要使用mock函数?

在项目中,一个模块的方法内常常会去调用另外一个模块的方法。在单元测试中,我们可能并不需要关心内部调用的方法的执行过程和结果,只想知道它是否被正确调用即可,甚至会指定该函数的返回值。这个时候,mock的意义就很大了。

jest中与mock相关的api主要有三个,分别是jest.fn()jest.mock()jest.spyOn()。使用它们创建mock函数能够帮助我们更好的测试项目中一些逻辑较复杂的代码。我们在测试中也主要是用到了mock函数提供的以下三种特性:

  • 捕获函数调用情况
  • 设置函数返回值
  • 改变函数的内部实现

下面,我将分别介绍这三种方法以及他们在实际测试中的应用。

jest.fn()

jest.fn()是创建mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。

// functions.test.js

test('测试jest.fn()调用', () => {
  let mockFn = jest.fn();
  let res = mockFn('厦门','青岛','三亚');

  // 断言mockFn的执行后返回undefined
  expect(res).toBeUndefined();
  // 断言mockFn被调用
  expect(mockFn).toBeCalled();
  // 断言mockFn被调用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 断言mockFn传入的参数为1, 2, 3
  expect(mockFn).toHaveBeenCalledWith('厦门','青岛','三亚');
})

jest.fn()所创建的mock函数还可以设置返回值,定义内部实现返回Promise对象

// functions.test.js

test('测试jest.fn()返回固定值', () => {
  let mockFn = jest.fn().mockReturnValue('default');
  // 断言mockFn执行后返回值为default
  expect(mockFn()).toBe('default');
})

test('测试jest.fn()内部实现', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 + num2;
  })
  // 断言mockFn执行后返回20
  expect(mockFn(10, 10)).toBe(20);
})

test('测试jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('default');
  let res = await mockFn();
  // 断言mockFn通过await关键字执行后返回值为default
  expect(res).toBe('default');
  // 断言mockFn调用后返回的是Promise对象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

jest.mock()

一般在真实的项目里,测试异步函数的时候,不会真正的发送 ajax 请求去请求这个接口,为什么?

比如有 1w 个接口要测试,每个接口要 3s 才能返回,测试全部接口需要 30000s,那么这个自动化测试的时间就太慢了

我们作为前端只需要去确认这个异步请求发送成功就好了,至于后端接口返回什么内容我们就不测了,这是后端自动化测试要做的事情。

这里以一个axios请求demo为例来说明:

// user.js
import axios from 'axios'

export const getUserList = () => {
  return axios.get('/users').then(res => res.data)
}

对应测试文件user.test.js:

import { getUserList } from '@/services/user.js'
import axios from 'axios'
// 👇👇
jest.mock('axios')
// 👆👆
test.only('测试 getUserList', async () => {
  axios.get.mockResolvedValue({ data: ['Cosen','森林','柯森'] })
  await getUserList().then(data => {
    expect(data).toBe(['Cosen','森林','柯森'])
  })
})

我们在测试用例的最上面加入了jest.mock('axios'),我们让jest去对axios做模拟,这样就不会去请求真正的数据了。然后调用axios.get的时候,不会真实的请求这个接口,而是会以我们写的{ data: ['Cosen','森林','柯森'] }去模拟请求成功后的结果。

当然模拟异步请求是需要时间的,如果请求多的话时间就很长,这时候可以在本地mock数据,在根目录下新建 __mocks__文件夹。这种方式就不用去模拟axios,而是直接走的本地的模拟方法,也是比较常用的一种方式,这里就不展开说明了。

jest.spyOn()

jest.spyOn()方法同样创建一个mock函数,但是该mock函数不仅能够捕获函数的调用情况,还可以正常的执行被spy的函数。实际上,jest.spyOn()jest.fn()的语法糖,它创建了一个和被spy的函数具有相同内部代码的mock函数

Snapshot 快照测试

所谓snapshot,即快照也。通常涉及 UI 的自动化测试,思路是把某一时刻的标准状态拍个快照。

describe("xxx页面", () => {
  // beforeEach(() => {
  //   jest.resetAllMocks()
  // })
  // 使用 snapshot 进行 UI 测试
  it("页面应能正常渲染", () => {
    const wrapper = wrappedShallow()
    expect(wrapper).toMatchSnapshot()
  })
})

当使用toMatchSnapshot的时候,Jest 将会渲染组件并创建其快照文件。这个快照文件包含渲染后组件的整个结构,并且应该与测试文件本身一起提交到代码库。当我们再次运行快照测试时,Jest 会将新的快照与旧的快照进行比较,如果两者不一致,测试就会失败,从而帮助我们确保用户界面不会发生意外改变。

🎯 总结

到这里,关于前端单元测试的一些基础背景和Jest的基础api就介绍完了,在下一篇文章中,我会结合项目中的一个React组件来讲解如何做组件单元测试

📜 参考链接

查看原文

赞 24 收藏 10 评论 2

前端森林 发布了文章 · 1月19日

哔哩哔哩面试官:你可以手写Vue2的响应式原理吗?

写在前面

这道题目是面试中相当高频的一道题目了,但凡你简历上有写:“熟练使用Vue并阅读过其部分源码”,那么这道题目十有八九面试官都会去问你。

什么?你简历上不写阅读过源码,那面试官也很有可能会问你是否阅读过响应式相关的源码

还是那句歌词唱的:

挣不脱 逃不过
眉头解不开的结
命中解不开的劫

整体流程

作为一个前端的MVVM框架,Vue的基本思路和AngularReact并无二致,其核心就在于: 当数据变化时,自动去刷新页面DOM,这使得我们能从繁琐的DOM操作中解放出来,从而专心地去处理业务逻辑。

这就是Vue的数据双向绑定(又称响应式原理)。数据双向绑定是Vue最独特的特性之一。此处我们用官方的一张流程图来简要地说明一下Vue响应式系统的整个流程:

Vue中,每个组件实例都有相应的watcher实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter被调用时,会通知watcher重新计算,从而致使它关联的组件得以更新。

这是一个典型的观察者模式。

关键角色

在 Vue 数据双向绑定的实现逻辑里,有这样三个关键角色:

  • Observer: 它的作用是给对象的属性添加gettersetter,用于依赖收集和派发更新
  • Dep: 用于收集当前响应式对象的依赖关系,每个响应式对象包括子对象都拥有一个Dep实例(里面subsWatcher实例数组),当数据有变更时,会通过dep.notify()通知各个watcher
  • Watcher: 观察者对象 , 实例分为渲染 watcher (render watcher),计算属性 watcher (computed watcher),侦听器 watcher(user watcher)三种

Watcher 和 Dep 的关系

为什么要单独拎出来一小节专门来说这个问题呢?因为大部分同学只是知道:Vue的响应式原理是通过Object.defineProperty实现的。被Object.defineProperty绑定过的对象,会变成「响应式」化。也就是改变这个对象的时候会触发getset事件。

但是对于里面具体的对象依赖关系并不是很清楚,这样也就给了面试官一种:你只是背了答案,对于响应式的内部实现细节,你并不是很清楚的印象。

关于Watcher 和 Dep 的关系这个问题,其实刚开始我也不是很清楚,在查阅了相关资料后,才逐渐对里面的具体实现有了清晰的理解。

刚接触Dep这个词的同学都会比较懵: Dep究竟是用来做什么的呢?我们通过defineReactive方法将data中的数据进行响应式后,虽然可以监听到数据的变化了,那我们怎么处理通知视图就更新呢?

Dep就是帮我们依赖管理的。

如上图所示:一个属性可能有多个依赖,每个响应式数据都有一个Dep来管理它的依赖。

一段话总结原理

上面说了那么多,下面我总结一下Vue响应式的核心设计思路:

当创建Vue实例时,vue会遍历data选项的属性,利用Object.defineProperty为属性添加gettersetter对数据的读取进行劫持(getter用来依赖收集,setter用来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化。

每个组件实例会有相应的watcher实例,会在组件渲染的过程中记录依赖的所有数据属性(进行依赖收集,还有computed watcher,user watcher实例),之后依赖项被改动时,setter方法会通知依赖与此datawatcher实例重新计算(派发更新),从而使它关联的组件重新渲染。

到这里,我们已经了解了“套路”,下面让我们用伪代码来实现一下Vue的响应式吧!

核心实现

/**
 * @name Vue数据双向绑定(响应式系统)的实现原理
 */

// observe方法遍历并包装对象属性
function observe(target) {
  // 若target是一个对象,则遍历它
  if (target && typeof target === "Object") {
    Object.keys(target).forEach((key) => {
      // defineReactive方法会给目标属性装上“监听器”
      defineReactive(target, key, target[key]);
    });
  }
}
// 定义defineReactive方法
function defineReactive(target, key, val) {
  const dep = new Dep();
  // 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历
  observe(val);
  // 为当前属性安装监听器
  Object.defineProperty(target, key, {
    // 可枚举
    enumerable: true,
    // 不可配置
    configurable: false,
    get: function () {
      return val;
    },
    // 监听器函数
    set: function (value) {
      dep.notify();
    },
  });
}

class Dep {
  constructor() {
    this.subs = [];
  }

  addSub(sub) {
    this.subs.push(sub);
  }

  notify() {
    this.subs.forEach((sub) => {
      sub.update();
    });
  }
}
查看原文

赞 12 收藏 9 评论 3

前端森林 发布了文章 · 1月13日

小红书面试官:介绍一下 tree shaking 及其工作原理

写在前面

今天这道题目是在和小红书的一位面试官聊的时候:

我:如果要你选择一道题目来考察面试者,你最有可能选择哪一道?

面试官:那应该就是介绍一下tree shaking及其工作原理?

我:为什么?

面试官:是因为最近面了好多同学,大家都说熟悉webpack,在项目中如何去使用、如何去优化,也都或多或少会提到tree shaking,但是每当我深入去问其工作机制或者原理时,却少有人能回答上来。(小声 bb:并不是我想内卷,确实是工程师的基本素养啊,哈哈 😄)

面试官:那你来回答一下这个问题?

我:我也用过tree shaking,只是知道它的别名叫树摇,最早是由Rollup实现,是一种采用删除不需要的额外代码的方式优化代码体积的技术。但是关于它的原理,我还真的不知道,额,,,,

我们平时更多时候是停留在应用层面,这种只是能满足基础的业务诉求,对于后期的技术深挖以及个人的职业发展都是受限的。还是那句老话:知其然,更要知其所以然~

话不多说,下面我就带大家一起来深入探究这个问题。

什么是Tree shaking

Tree shaking 是一种通过清除多余代码方式来优化项目打包体积的技术,专业术语叫 Dead code elimination

这个概念,我相信大多数同学都是了解的。什么,你不懂?

不懂没关系,我可以教你啊(不过那是另外的价钱,哈哈 🙈)

走远了,兄弟,让我们言归正传:tree shaking如何工作的呢?

tree shaking如何工作的呢?

虽然 tree shaking 的概念在 1990 就提出了,但直到 ES6ES6-style 模块出现后才真正被利用起来。

ES6以前,我们可以使用CommonJS引入模块:require(),这种引入是动态的,也意味着我们可以基于条件来导入需要的代码:

let dynamicModule;
// 动态导入
if (condition) {
  myDynamicModule = require("foo");
} else {
  myDynamicModule = require("bar");
}

但是CommonJS规范无法确定在实际运行前需要或者不需要某些模块,所以CommonJS不适合tree-shaking机制。在 ES6 中,引入了完全静态的导入语法:import。这也意味着下面的导入是不可行的:

// 不可行,ES6 的import是完全静态的
if (condition) {
  myDynamicModule = require("foo");
} else {
  myDynamicModule = require("bar");
}

我们只能通过导入所有的包后再进行条件获取。如下:

import foo from "foo";
import bar from "bar";

if (condition) {
  // foo.xxxx
} else {
  // bar.xxx
}

ES6import语法可以完美使用tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

看完上面的分析,你可能还是有点懵,这里我简单做下总结:因为tree shaking只能在静态modules下工作。ECMAScript 6 模块加载是静态的,因此整个依赖树可以被静态地推导出解析语法树。所以在 ES6 中使用 tree shaking 是非常容易的。

tree shaking的原理是什么?

看完上面的分析,相信这里你可以很容易的得出题目的答案了:

  • ES6 Module引入进行静态分析,故而编译的时候正确判断到底加载了那些模块
  • 静态分析程序流,判断那些模块和变量未被使用或者引用,进而删除对应代码

common.js 和 es6 中模块引入的区别?

但到这里,本篇文章还没结束。从这道题目我们可以很容易的引申出来另外一道“明星”面试题:common.js 和 es6 中模块引入的区别?

这道题目来自冴羽大佬的阿里前端攻城狮们写了一份前端面试题答案,请查收

这里就直接贴下他给出的答案了:

CommonJS 是一种模块规范,最初被应用于 Nodejs,成为 Nodejs 的模块规范。运行在浏览器端的 JavaScript 由于也缺少类似的规范,在 ES6 出来之前,前端也实现了一套相同的模块规范 (例如: AMD),用来对前端模块进行管理。自 ES6 起,引入了一套新的 ES6 Module 规范,在语言标准的层面上实现了模块功能,而且实现得相当简单,有望成为浏览器和服务器通用的模块解决方案。但目前浏览器对 ES6 Module 兼容还不太好,我们平时在 Webpack 中使用的 exportimport,会经过 Babel 转换为 CommonJS 规范。在使用上的差别主要有:

1、CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

2、CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

3、CommonJs 是单个值导出,ES6 Module可以导出多个

4、CommonJs 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层

5、CommonJsthis 是当前模块,ES6 Modulethisundefined

冴羽大佬的文章质量都非常高,也欢迎大家多去支持冴羽大佬,相信看完一定会对你有所收获。

总结一下

这是大厂面试问题解析的第二篇了,和之前准备写这一系列的初衷一样:我力求通过一些面试题去发掘自己未曾了解或者未曾深入了解的一个领域。

面试题更多时候是一个引子,更多是想通过面试题去思考题目背后带来的对某一模块的深入学习和探讨。

当然,每篇文章也不会只是草草给出答案,我都会尽量深入浅出的给出自己对于这道题目的理解,也会在这个基础上做一些拓展。

查看原文

赞 8 收藏 2 评论 2

前端森林 发布了文章 · 1月11日

字节跳动面试官:请用JS实现Ajax并发请求控制

image

最近也好久没输出文章了,原因很简单,最近巨忙,,,,

讲真的,最近也很迷茫。关于技术、关于生活吧。也找了很多在大厂的朋友去聊,想需求一些后期发展的思路。这其中也聊到了面试,聊到了招聘中会给面试者出的一些题目。我正好也好久没面试了,就从中选了几道。最近也会陆续出一系列关于一些面试问题的解析。

今天这道是字节跳动的:

实现一个批量请求函数 multiRequest(urls, maxNum),要求如下:
• 要求最大并发数 maxNum
• 每当有一个请求返回,就留下一个空位,可以增加新的请求
• 所有请求完成后,结果按照 urls 里面的顺序依次打出

这道题目我想很多同学应该都或多或少的见过,下面我会依次从出现的场景、问题的分析到最终的实现,一步步力求深入浅出的给出这道题目的完整解析。

场景

假设现在有这么一种场景:现有 30 个异步请求需要发送,但由于某些原因,我们必须将同一时刻并发请求数量控制在 5 个以内,同时还要尽可能快速的拿到响应结果。

应该怎么做?

首先我们来了解一下 Ajax的串行和并行。

基于 Promise.all 实现 Ajax 的串行和并行

我们平时都是基于promise来封装异步请求的,这里也主要是针对异步请求来展开。

  • 串行:一个异步请求完了之后在进行下一个请求
  • 并行:多个异步请求同时进行

通过定义一些promise实例来具体演示串行/并行。

串行

var p = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log("1000");
      resolve();
    }, 1000);
  });
};
var p1 = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log("2000");
      resolve();
    }, 2000);
  });
};
var p2 = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log("3000");
      resolve();
    }, 3000);
  });
};

p()
  .then(() => {
    return p1();
  })
  .then(() => {
    return p2();
  })
  .then(() => {
    console.log("end");
  });

如示例,串行会从上到下依次执行对应接口请求。

并行

通常,我们在需要保证代码在多个异步处理之后执行,会用到:

Promise.all((promises: [])).then((fun: function));

Promise.all可以保证,promises数组中所有promise对象都达到resolve状态,才执行then回调。

var promises = function () {
  return [1000, 2000, 3000].map((current) => {
    return new Promise(function (resolve, reject) {
      setTimeout(() => {
        console.log(current);
      }, current);
    });
  });
};

Promise.all(promises()).then(() => {
  console.log("end");
});

Promise.all 并发限制

这时候考虑一个场景:如果你的promises数组中每个对象都是http请求,而这样的对象有几十万个。

那么会出现的情况是,你在瞬间发出几十万个http请求,这样很有可能导致堆积了无数调用栈导致内存溢出。

这时候,我们就需要考虑对Promise.all做并发限制。

Promise.all并发限制指的是,每个时刻并发执行的promise数量是固定的,最终的执行结果还是保持与原来的Promise.all一致。

题目实现

思路分析

整体采用递归调用来实现:最初发送的请求数量上限为允许的最大值,并且这些请求中的每一个都应该在完成时继续递归发送,通过传入的索引来确定了urls里面具体是那个URL,保证最后输出的顺序不会乱,而是依次输出。

代码实现

function multiRequest(urls = [], maxNum) {
  // 请求总数量
  const len = urls.length;
  // 根据请求数量创建一个数组来保存请求的结果
  const result = new Array(len).fill(false);
  // 当前完成的数量
  let count = 0;

  return new Promise((resolve, reject) => {
    // 请求maxNum个
    while (count < maxNum) {
      next();
    }
    function next() {
      let current = count++;
      // 处理边界条件
      if (current >= len) {
        // 请求全部完成就将promise置为成功状态, 然后将result作为promise值返回
        !result.includes(false) && resolve(result);
        return;
      }
      const url = urls[current];
      console.log(`开始 ${current}`, new Date().toLocaleString());
      fetch(url)
        .then((res) => {
          // 保存请求结果
          result[current] = res;
          console.log(`完成 ${current}`, new Date().toLocaleString());
          // 请求没有全部完成, 就递归
          if (current < len) {
            next();
          }
        })
        .catch((err) => {
          console.log(`结束 ${current}`, new Date().toLocaleString());
          result[current] = err;
          // 请求没有全部完成, 就递归
          if (current < len) {
            next();
          }
        });
    }
  });
}
查看原文

赞 15 收藏 10 评论 4

前端森林 发布了文章 · 1月4日

面试官:webpack原理都不会?

image

引言

前一段时间我把webpack源码大概读了一遍,webpack4.x版本后,其源码已经比较庞大,对各种开发场景进行了高度抽象,阅读成本也愈发昂贵。

过度分析源码对于大家并没有太大的帮助。本文主要是想通过分析webpack的构建流程以及实现一个简单的webpack来让大家对webpack的内部原理有一个大概的了解。(保证能看懂,不懂你打我 🙈)
image

webpack 构建流程分析

首先,无须多言,上图~
image
webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:首先会从配置文件和 Shell 语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Compilerrun来真正启动webpack编译构建过程,webpack的构建流程包括compilemakebuildsealemit阶段,执行完这些阶段就完成了构建过程。

初始化

entry-options 启动

从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。

run 实例化

compiler:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译

编译构建

entry 确定入口

根据配置中的 entry 找出所有的入口文件

make 编译模块

从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理

build module 完成模块编译

经过上面一步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系

seal 输出资源

根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会

emit 输出完成

在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

分析完构建流程,下面让我们自己动手实现一个简易的webpack吧~
image

实现一个简易的 webpack

准备工作

目录结构

我们先来初始化一个项目,结构如下:

|-- forestpack
    |-- dist
    |   |-- bundle.js
    |   |-- index.html
    |-- lib
    |   |-- compiler.js
    |   |-- index.js
    |   |-- parser.js
    |   |-- test.js
    |-- src
    |   |-- greeting.js
    |   |-- index.js
    |-- forstpack.config.js
    |-- package.json

这里我先解释下每个文件/文件夹对应的含义:

  • dist:打包目录
  • lib:核心文件,主要包括compilerparser

    • compiler.js:编译相关。Compiler为一个类, 并且有run方法去开启编译,还有构建modulebuildModule)和输出文件(emitFiles
    • parser.js:解析相关。包含解析ASTgetAST)、收集依赖(getDependencies)、转换(es6转es5
    • index.js:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入
    • test.js:测试文件,用于测试方法函数打console使用
  • src:源代码。也就对应我们的业务代码
  • forstpack.config.js: 配置文件。类似webpack.config.js
  • package.json:这个就不用我多说了~~~(什么,你不知道??)

先完成“造轮子”前 30%的代码

项目搞起来了,但似乎还少点东西~~
image

对了!基础的文件我们需要先完善下:forstpack.config.jssrc

首先是forstpack.config.js

const path = require("path");

module.exports = {
  entry: path.join(__dirname, "./src/index.js"),
  output: {
    path: path.join(__dirname, "./dist"),
    filename: "bundle.js",
  },
};

内容很简单,定义一下入口、出口(你这也太简单了吧!!别急,慢慢来嘛)

其次是src,这里在src目录下定义了两个文件:

  • greeting.js
// greeting.js
export function greeting(name) {
  return "你好" + name;
}
  • index.js
import { greeting } from "./greeting.js";

document.write(greeting("森林"));

ok,到这里我们已经把需要准备的工作都完成了。(问:为什么这么基础?答:当然要基础了,我们的核心是“造轮子”!!)
image

梳理下逻辑

短暂的停留一下,我们梳理下逻辑:

Q: 我们要做什么?

A: 做一个比webpack更强的super webpack(不好意思,失态了,一不小心说出了我的心声)。还是低调点(防止一会被疯狂打脸)
image

Q: 怎么去做?

A: 看下文(23333)

Q: 整个的流程是什么?

A: 哎嘿,大概流程就是:

  • 读取入口文件
  • 分析入口文件,递归的去读取模块所依赖的文件内容,生成AST语法树。
  • 根据AST语法树,生成浏览器能够运行的代码

正式开工

compile.js 编写

const path = require("path");
const fs = require("fs");

module.exports = class Compiler {
  // 接收通过lib/index.js new Compiler(options).run()传入的参数,对应`forestpack.config.js`的配置
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
  // 开启编译
  run() {}
  // 构建模块相关
  buildModule(filename, isEntry) {
    // filename: 文件名称
    // isEntry: 是否是入口文件
  }
  // 输出文件
  emitFiles() {}
};

compile.js主要做了几个事情:

  • 接收forestpack.config.js配置参数,并初始化entryoutput
  • 开启编译run方法。处理构建模块、收集依赖、输出文件等。
  • buildModule方法。主要用于构建模块(被run方法调用)
  • emitFiles方法。输出文件(同样被run方法调用)

到这里,compiler.js的大致结构已经出来了,但是得到模块的源码后, 需要去解析,替换源码和获取模块的依赖项, 也就对应我们下面需要完善的parser.js

parser.js 编写

const fs = require("fs");
// const babylon = require("babylon");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("babel-core");
module.exports = {
  // 解析我们的代码生成AST抽象语法树
  getAST: (path) => {
    const source = fs.readFileSync(path, "utf-8");

    return parser.parse(source, {
      sourceType: "module", //表示我们要解析的是ES模块
    });
  },
  // 对AST节点进行递归遍历
  getDependencies: (ast) => {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        dependencies.push(node.source.value);
      },
    });
    return dependencies;
  },
  // 将获得的ES6的AST转化成ES5
  transform: (ast) => {
    const { code } = transformFromAst(ast, null, {
      presets: ["env"],
    });
    return code;
  },
};

看完这代码是不是有点懵(说好的保证让看懂的 😤)

别着急,你听我辩解!!😷
image

这里要先着重说下用到的几个babel包:

  • @babel/parser:用于将源码生成AST
  • @babel/traverse:对AST节点进行递归遍历
  • babel-core/@babel/preset-env:将获得的ES6AST转化成ES5

parser.js中主要就三个方法:

  • getAST: 将获取到的模块内容 解析成AST语法树
  • getDependencies:遍历AST,将用到的依赖收集起来
  • transform:把获得的ES6AST转化成ES5

完善 compiler.js

在上面我们已经将compiler.js中会用到的函数占好位置,下面我们需要完善一下compiler.js,当然会用到parser.js中的一些方法(废话,不然我上面干嘛要先把parser.js写完~~)
image

直接上代码:

const { getAST, getDependencies, transform } = require("./parser");
const path = require("path");
const fs = require("fs");

module.exports = class Compiler {
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
  // 开启编译
  run() {
    const entryModule = this.buildModule(this.entry, true);
    this.modules.push(entryModule);
    this.modules.map((_module) => {
      _module.dependencies.map((dependency) => {
        this.modules.push(this.buildModule(dependency));
      });
    });
    // console.log(this.modules);
    this.emitFiles();
  }
  // 构建模块相关
  buildModule(filename, isEntry) {
    let ast;
    if (isEntry) {
      ast = getAST(filename);
    } else {
      const absolutePath = path.join(process.cwd(), "./src", filename);
      ast = getAST(absolutePath);
    }

    return {
      filename, // 文件名称
      dependencies: getDependencies(ast), // 依赖列表
      transformCode: transform(ast), // 转化后的代码
    };
  }
  // 输出文件
  emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = "";
    this.modules.map((_module) => {
      modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
    });

    const bundle = `
        (function(modules) {
          function require(fileName) {
            const fn = modules[fileName];
            const module = { exports:{}};
            fn(require, module, module.exports)
            return module.exports
          }
          require('${this.entry}')
        })({${modules}})
    `;

    fs.writeFileSync(outputPath, bundle, "utf-8");
  }
};

关于compiler.js的内部函数,上面我说过一遍,这里主要来看下emitFiles

emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = "";
    this.modules.map((_module) => {
      modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
    });

    const bundle = `
        (function(modules) {
          function require(fileName) {
            const fn = modules[fileName];
            const module = { exports:{}};
            fn(require, module, module.exports)
            return module.exports
          }
          require('${this.entry}')
        })({${modules}})
    `;

    fs.writeFileSync(outputPath, bundle, "utf-8");
  }

这里的bundle一大坨,什么鬼?
image

我们先来了解下webpack的文件 📦 机制。下面一段代码是经过webpack打包精简过后的代码:

// dist/index.xxxx.js
(function(modules) {
  // 已经加载过的模块
  var installedModules = {};

  // 模块加载函数
  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  __webpack_require__(0);
})([
/* 0 module */
(function(module, exports, __webpack_require__) {
  ...
}),
/* 1 module */
(function(module, exports, __webpack_require__) {
  ...
}),
/* n module */
(function(module, exports, __webpack_require__) {
  ...
})]);

简单分析下:

  • webpack 将所有模块(可以简单理解成文件)包裹于一个函数中,并传入默认参数,将所有模块放入一个数组中,取名为 modules,并通过数组的下标来作为 moduleId
  • modules 传入一个自执行函数中,自执行函数中包含一个 installedModules 已经加载过的模块和一个模块加载函数,最后加载入口模块并返回。
  • __webpack_require__ 模块加载,先判断 installedModules 是否已加载,加载过了就直接返回 exports 数据,没有加载过该模块就通过 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__) 执行模块并且将 module.exports 给返回。

(你上面说的这一坨又是什么鬼?我听不懂啊啊啊啊!!!)
image

那我换个说法吧:

  • 经过webpack打包出来的是一个匿名闭包函数(IIFE
  • modules是一个数组,每一项是一个模块初始化函数
  • __webpack_require__用来加载模块,返回module.exports
  • 通过WEBPACK_REQUIRE_METHOD(0)启动程序

(小声 bb:怎么样,这样听懂了吧)
image

lib/index.js 入口文件编写

到这里,就剩最后一步了(似乎见到了胜利的曙光)。在lib目录创建index.js

const Compiler = require("./compiler");
const options = require("../forestpack.config");

new Compiler(options).run();

这里逻辑就比较简单了:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入。

运行node lib/index.js就会在dist目录下生成bundle.js文件。

(function (modules) {
  function require(fileName) {
    const fn = modules[fileName];
    const module = { exports: {} };
    fn(require, module, module.exports);
    return module.exports;
  }
  require("/Users/fengshuan/Desktop/workspace/forestpack/src/index.js");
})({
  "/Users/fengshuan/Desktop/workspace/forestpack/src/index.js": function (
    require,
    module,
    exports
  ) {
    "use strict";

    var _greeting = require("./greeting.js");

    document.write((0, _greeting.greeting)("森林"));
  },
  "./greeting.js": function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true,
    });
    exports.greeting = greeting;

    function greeting(name) {
      return "你好" + name;
    }
  },
});

和上面用webpack打包生成的js文件作下对比,是不是很相似呢?
image

来吧!展示

我们在dist目录下创建index.html文件,引入打包生成的bundle.js文件:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script data-original="./bundle.js"></script>
  </body>
</html>

此时打开浏览器:
image

如你所愿,得到了我们预期的结果~
image

总结

通过对webpack构建流程的分析以及实现了一个简易的forestpack,相信你对webpack的构建原理已经有了一个清晰的认知!(当然,这里的forestpackwebpack相比还很弱很弱,,,,)
image

查看原文

赞 26 收藏 19 评论 0

前端森林 发布了文章 · 2020-12-24

那些前端开发必不可少的生产力工具

image

引言

一些开源的生产力工具能极大的提升我们的开发效率(我一直是这么认为的 🤠)。

今天推荐一些我一直在用的、比较香的工具给大家。其中包括一些文档、可视化工具、分析工具、代码片段、调试工具等。

Collect UI 🦑

Collect UI画廊是一个免费的在线资源,用于每日UI设计灵感。 目前,它有 6500 多个条目,并且持续保持更新最新内容。
image

在边栏中,有做分类。包括 404 页面、登陆/登出、购物车、日历、视频播放器等。如果你想在某方便需求灵感,然后用于你的公司项目或者个人项目,我想是会有很大的帮助的。

Taskade 📝

在平时生活中总会有很多的事要做,比如工作时有很多待办事项,但是很容易就会忘记一些事情,这时我们就需要一款具有带有待办事项的chrome插件--taskade
image
Taskade简单,整洁并且设计精美,有着令人放松的主题和背景。使用Taskade来整理您的思路,这样您可以集中精力做事情。
image

Colordot 🌈

有时候我们想寻求一个自己喜欢的颜色(有点像起一个自己满意的昵称),却没有灵感,这时候我们就可以来这里

网页区域内随意滑动鼠标,可以产生不同的色彩。确定一个色彩,再随意滑动产生下一个色彩,直到找到自己满意的配色。

FontSpark 🎯

FontSpark是一个帮助有字体选择困难症的用户打造的选择字体的网站,用户只需要输入所需要展示的文字即可获得网站推荐的字体,包括字体类型和大小。
image
对于推荐的字体不是很满意的话,点击Generate按钮刷新即可。

The Noun Project 🎃

image
The Noun Project 网站专门提供高品质、可辨识性强的icon,这些icon没有很炫酷的设计,通常只用单色来呈现,使用者却能很容易地辨别出它要传达的意思。

目前 NounProject 提供超过 200 万的icon供使用者免费下载,且持续在更新中,如果你需要某种icon,却一直没有找到合适的,不妨到这个网站来走走。

csseffects 🚀

CSSeffectsSnippets收录了大约 20 多种CSS动画,无论是加载读取中,或是将光标移动过去产生的动画,都能在网站上即时预览。

而这还不是它最大的亮点,最值得推荐的是所有效果都能在点击后快速复制相关代码,直接让开发者运用到自己的网站或博客,当然可能还是需要经过微调,不过不用从头开始,也不需在网路上寻找这些动画代码,非常方便而且省时。

unDraw 🍉

unDraw 是由希腊设计师 Katerina Limpitsouni 开发的一套开源矢量插图库,在这个网站上有超过 1000 个扁平矢量插画供你下载使用。
image
如果你在做个人网站,但对于插画没有灵感,或许你可以来看看。

DevDocs 🐨

这个网页应用汇聚了各种项目的文档,还支持离线使用。
image
不管是新手程序员还是老程序员都需要有一个可以在线查询各种编程手册的文档,而DevDocs汇集了最全的编程开发文档,又拥有极佳的阅读模式,让你可以快速的查询想要的命令,同时还支持浏览器扩展,可谓方便之极。

CSS Tricks 🦊

CSS Tricks是一个国外的优秀前端开发博客,主要分享使用CSS样式的技巧、经验和教程等。
image
该网站不断的在更新一些优秀的教程和技巧,为前端社区做出了具大的贡献。我也一直在这上面学习,让我在CSS方面视野拓宽了很多。

cssreference 🎾

如果需要更新 CSS 知识或者查询不熟悉、不常用的属性,可以访问这个站点。上面对每个 CSS 属性的讲解很深入,给出的示例也很清楚,便于你理解这些属性并应用于自己的项目。
image

Can I Use

前端开发的时候时常需要检查浏览器的兼容性,在这里推荐(Can I Use)这个是一个针对前端开发人员定制的一个查询CSSJs在各种流行浏览器中的特性和兼容性的网站,可以很好的保证网页的浏览器兼容性。有了这个工具可以快速的了解到代码在各个浏览器中的效果。
image

Lighthouse 🌊

Lighthouse是一个Google开源的自动化工具,主要用于改进网络应用(移动端)的质量。目前测试项包括页面性能、PWA、可访问性(无障碍)、最佳实践、SEO

image

Lighthouse会对各个测试项的结果打分,并给出优化建议,这些打分标准和优化建议可以视为Google的网页最佳实践。

Majestic

Majestic是一款好用的Jest运行测试GUI工具。
imageimage
利用可视化的方式,使用它可以让我们查看测试用例输出日志更加简单。

Wappalyzer 🔭

Wappalyzer是一款能够分析目标网站所采用的平台架构、网站环境、服务器配置环境、javascript框架、编程语言等参数的chrome网站技术分析插件。
image

iHateRegex 🌡

对于开发人员来说,正则表达式是会被经常用到的,很多类型复杂的字符串都可以用它匹配出来,但唯一但缺点是编写起来很困难,不仅需要熟练掌握规则,还需要花时间编写、调试。

iHateRegex就是这样一个帮你解决书写正则表达式烦恼的神器。
image
iHateRegex是一个在线开源工具,可快速检索并匹配到合适的正则表达式,帮你完成如用户名、邮箱、日期、手机号码、密码等常见规则的验证。

当然你也可以看到它内部的匹配过程,这有助于加深你的理解。
image

参考

https://dev.to/joserfelix/40-...

查看原文

赞 38 收藏 29 评论 1

前端森林 发布了文章 · 2020-12-04

你可能不知道的9条Webpack优化策略

image

引言

webpack的打包优化一直是个老生常谈的话题,常规的无非就分块、拆包、压缩等。

本文以我自己的经验向大家分享如何通过一些分析工具、插件以及webpack新版本中的一些新特性来显著提升webpack的打包速度和改善包体积,学会分析打包的瓶颈以及问题所在。

本文演示代码,仓库地址

速度分析 🏂

webpack 有时候打包很慢,而我们在项目中可能用了很多的 pluginloader,想知道到底是哪个环节慢,下面这个插件可以计算 pluginloader 的耗时。

yarn add -D speed-measure-webpack-plugin

配置也很简单,把 webpack 配置对象包裹起来即可:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
  plugins: [
    new MyPlugin(),
    new MyOtherPlugin()
  ]
});

来看下在项目中引入speed-measure-webpack-plugin后的打包情况:
image
从上图可以看出这个插件主要做了两件事情:

  • 计算整个打包总耗时
  • 分析每个插件和 loader 的耗时情况
    知道了具体loaderplugin的耗时情况,我们就可以“对症下药”了

体积分析 🎃

打包后的体积优化是一个可以着重优化的点,比如引入的一些第三方组件库过大,这时就要考虑是否需要寻找替代品了。

这里采用的是webpack-bundle-analyzer,也是我平时工作中用的最多的一款插件了。

它可以用交互式可缩放树形图显示webpack输出文件的大小。用起来非常的方便。

首先安装插件:

yarn add -D webpack-bundle-analyzer

安装完在webpack.config.js中简单的配置一下:

const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
    //  可以是`server`,`static`或`disabled`。
    //  在`server`模式下,分析器将启动HTTP服务器来显示软件包报告。
    //  在“静态”模式下,会生成带有报告的单个HTML文件。
    //  在`disabled`模式下,你可以使用这个插件来将`generateStatsFile`设置为`true`来生成Webpack Stats JSON文件。
    analyzerMode: "server",
    //  将在“服务器”模式下使用的主机启动HTTP服务器。
    analyzerHost: "127.0.0.1",
    //  将在“服务器”模式下使用的端口启动HTTP服务器。
    analyzerPort: 8866,
    //  路径捆绑,将在`static`模式下生成的报告文件。
    //  相对于捆绑输出目录。
    reportFilename: "report.html",
    //  模块大小默认显示在报告中。
    //  应该是`stat`,`parsed`或者`gzip`中的一个。
    //  有关更多信息,请参见“定义”一节。
    defaultSizes: "parsed",
    //  在默认浏览器中自动打开报告
    openAnalyzer: true,
    //  如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成
    generateStatsFile: false,
    //  如果`generateStatsFile`为`true`,将会生成Webpack Stats JSON文件的名字。
    //  相对于捆绑输出目录。
    statsFilename: "stats.json",
    //  stats.toJson()方法的选项。
    //  例如,您可以使用`source:false`选项排除统计文件中模块的来源。
    //  在这里查看更多选项:https:  //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
    statsOptions: null,
    logLevel: "info"
  )
  ]
}

然后在命令行工具中输入npm run dev,它默认会起一个端口号为 8888 的本地服务器:
image
图中的每一块清晰的展示了组件、第三方库的代码体积。

有了它,我们就可以针对体积偏大的模块进行相关优化了。

多进程/多实例构建 🏈

大家都知道 webpack 是运行在 node 环境中,而 node 是单线程的。webpack 的打包过程是 io 密集和计算密集型的操作,如果能同时 fork 多个进程并行处理各个任务,将会有效的缩短构建时间。

平时用的比较多的两个是thread-loaderHappyPack

先来看下thread-loader吧,这个也是webpack4官方所推荐的。

thread-loader

安装

yarn add -D thread-loader

thread-loader 会将你的 loader 放置在一个 worker 池里面运行,以达到多线程构建。

把这个 loader 放置在其他 loader 之前(如下面示例的位置), 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行。

示例

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // your expensive loader (e.g babel-loader)
        ]
      }
    ]
  }
}

HappyPack

安装

yarn add -D happypack

HappyPack 可以让 Webpack 同一时间处理多个任务,发挥多核 CPU 的能力,将任务分解给多个子进程去并发的执行,子进程处理完后,再把结果发送给主进程。通过多进程模型,来加速代码构建。
image

示例

// webpack.config.js
const HappyPack = require('happypack');

exports.module = {
  rules: [
    {
      test: /.js$/,
      // 1) replace your original list of loaders with "happypack/loader":
      // loaders: [ 'babel-loader?presets[]=es2015' ],
      use: 'happypack/loader',
      include: [ /* ... */ ],
      exclude: [ /* ... */ ]
    }
  ]
};

exports.plugins = [
  // 2) create the plugin:
  new HappyPack({
    // 3) re-add the loaders you replaced above in #1:
    loaders: [ 'babel-loader?presets[]=es2015' ]
  })
];

这里有一点需要说明的是,HappyPack的作者表示已不再维护此项目,这个可以在github仓库看到:
image
作者也是推荐使用webpack官方提供的thread-loader

thread-loaderhappypack 对于小型项目来说打包速度几乎没有影响,甚至可能会增加开销,所以建议尽量在大项目中采用。

多进程并行压缩代码 🛵

通常我们在开发环境,代码构建时间比较快,而构建用于发布到线上的代码时会添加压缩代码这一流程,则会导致计算量大耗时多。

webpack默认提供了UglifyJS插件来压缩JS代码,但是它使用的是单线程压缩代码,也就是说多个js文件需要被压缩,它需要一个个文件进行压缩。所以说在正式环境打包压缩代码速度非常慢(因为压缩JS代码需要先把代码解析成用Object抽象表示的AST语法树,再应用各种规则分析和处理AST,导致这个过程耗时非常大)。

所以我们要对压缩代码这一步骤进行优化,常用的做法就是多进程并行压缩。

目前有三种主流的压缩方案:

  • parallel-uglify-plugin
  • uglifyjs-webpack-plugin
  • terser-webpack-plugin

parallel-uglify-plugin

上面介绍的HappyPack的思想是使用多个子进程去解析和编译JS,CSS等,这样就可以并行处理多个子任务,多个子任务完成后,再将结果发到主进程中,有了这个思想后,ParallelUglifyPlugin 插件就产生了。

webpack有多个JS文件需要输出和压缩时,原来会使用UglifyJS去一个个压缩并且输出,而ParallelUglifyPlugin插件则会开启多个子进程,把对多个文件压缩的工作分给多个子进程去完成,但是每个子进程还是通过UglifyJS去压缩代码。并行压缩可以显著的提升效率。

安装

yarn add -D webpack-parallel-uglify-plugin

示例

import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';

module.exports = {
  plugins: [
    new ParallelUglifyPlugin({
      // Optional regex, or array of regex to match file against. Only matching files get minified.
      // Defaults to /.js$/, any file ending in .js.
      test,
      include, // Optional regex, or array of regex to include in minification. Only matching files get minified.
      exclude, // Optional regex, or array of regex to exclude from minification. Matching files are not minified.
      cacheDir, // Optional absolute path to use as a cache. If not provided, caching will not be used.
      workerCount, // Optional int. Number of workers to run uglify. Defaults to num of cpus - 1 or asset count (whichever is smaller)
      sourceMap, // Optional Boolean. This slows down the compilation. Defaults to false.
      uglifyJS: {
        // These pass straight through to uglify-js@3.
        // Cannot be used with uglifyES.
        // Defaults to {} if not neither uglifyJS or uglifyES are provided.
        // You should use this option if you need to ensure es5 support. uglify-js will produce an error message
        // if it comes across any es6 code that it can't parse.
      },
      uglifyES: {
        // These pass straight through to uglify-es.
        // Cannot be used with uglifyJS.
        // uglify-es is a version of uglify that understands newer es6 syntax. You should use this option if the
        // files that you're minifying do not need to run in older browsers/versions of node.
      }
    }),
  ],
};
webpack-parallel-uglify-plugin已不再维护,这里不推荐使用

uglifyjs-webpack-plugin

安装

yarn add -D uglifyjs-webpack-plugin

示例

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  plugins: [
    new UglifyJsPlugin({
      uglifyOptions: {
        warnings: false,
        parse: {},
        compress: {},
        ie8: false
      },
      parallel: true
    })
  ]
};

其实它和上面的parallel-uglify-plugin类似,也可通过设置parallel: true开启多进程压缩。

terser-webpack-plugin

不知道你有没有发现:webpack4 已经默认支持 ES6语法的压缩。

而这离不开terser-webpack-plugin

安装

yarn add -D terser-webpack-plugin

示例

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: 4,
      }),
    ],
  },
};

预编译资源模块 🚀

什么是预编译资源模块?

在使用webpack进行打包时候,对于依赖的第三方库,比如vuevuex等这些不会修改的依赖,我们可以让它和我们自己编写的代码分开打包,这样做的好处是每次更改我本地代码的文件的时候,webpack只需要打包我项目本身的文件代码,而不会再去编译第三方库。

那么第三方库在第一次打包的时候只打包一次,以后只要我们不升级第三方包的时候,那么webpack就不会对这些库去打包,这样的可以快速的提高打包的速度。其实也就是预编译资源模块

webpack中,我们可以结合DllPluginDllReferencePlugin插件来实现。

DllPlugin是什么?

它能把第三方库代码分离开,并且每次文件更改的时候,它只会打包该项目自身的代码。所以打包速度会更快。

DLLPlugin 插件是在一个额外独立的webpack设置中创建一个只有dllbundle,也就是说我们在项目根目录下除了有webpack.config.js,还会新建一个webpack.dll.js文件。

webpack.dll.js的作用是把所有的第三方库依赖打包到一个bundledll文件里面,还会生成一个名为 manifest.json文件。该manifest.json的作用是用来让 DllReferencePlugin 映射到相关的依赖上去的。

DllReferencePlugin又是什么?

这个插件是在webpack.config.js中使用的,该插件的作用是把刚刚在webpack.dll.js中打包生成的dll文件引用到需要的预编译的依赖上来。

什么意思呢?就是说在webpack.dll.js中打包后比如会生成 vendor.dll.js文件和vendor-manifest.json文件,vendor.dll.js文件包含了所有的第三方库文件,vendor-manifest.json文件会包含所有库代码的一个索引,当在使用webpack.config.js文件打包DllReferencePlugin插件的时候,会使用该DllReferencePlugin插件读取vendor-manifest.json文件,看看是否有该第三方库。

vendor-manifest.json文件就是一个第三方库的映射而已。

怎么在项目中使用?

上面说了这么多,主要是为了方便大家对于预编译资源模块DllPlugin 和、DllReferencePlugin插件作用的理解(我第一次使用看了好久才明白~~)

先来看下完成的项目目录结构:
image

主要在两块配置,分别是webpack.dll.jswebpack.config.js(对应这里我是webpack.base.js

webpack.dll.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    vendors: ['lodash', 'jquery'],
    react: ['react', 'react-dom']
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, './dll'),
    library: '[name]'
  },
  plugins: [
    new webpack.DllPlugin({
      name: '[name]',
      path: path.resolve(__dirname, './dll/[name].manifest.json')
    })
  ]
}

这里我拆了两部分:vendors(存放了lodashjquery等)和react(存放了 react 相关的库,reactreact-dom等)

webpack.config.js(对应我这里就是webpack.base.js)

const path = require("path");
const fs = require('fs');
// ...
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');
const webpack = require('webpack');

const plugins = [
  // ...
];

const files = fs.readdirSync(path.resolve(__dirname, './dll'));
files.forEach(file => {
  if(/.*\.dll.js/.test(file)) {
    plugins.push(new AddAssetHtmlWebpackPlugin({
      filepath: path.resolve(__dirname, './dll', file)
    }))
  }
  if(/.*\.manifest.json/.test(file)) {
    plugins.push(new webpack.DllReferencePlugin({
      manifest: path.resolve(__dirname, './dll', file)
    }))
  }
})

module.exports = {
  entry: {
    main: "./src/index.js"
  },
  module: {
    rules: []
  },
  plugins,

  output: {
    // publicPath: "./",
    path: path.resolve(__dirname, "dist")
  }
}

这里为了演示省略了很多代码,项目完整代码在这里

由于上面我把第三方库做了一个拆分,所以对应生成也就会是多个文件,这里读取了一下文件,做了一层遍历。

最后在package.json里面再添加一条脚本就可以了:

"scripts": {
    "build:dll": "webpack --config ./webpack.dll.js",
  },

运行yarn build:dll就会生成本小节开头贴的那张项目结构图了~

利用缓存提升二次构建速度 🍪

一般来说,对于静态资源,我们都希望浏览器能够进行缓存,那样以后进入页面就可以直接使用缓存资源,页面打开速度会显著加快,既提高了用户的体验也节省了宽带资源。

当然浏览器缓存方法有很多种,这里只简单讨论下在webpack中如何利用缓存来提升二次构建速度。

webpack中利用缓存一般有以下几种思路:

  • babel-loader开启缓存
  • 使用cache-loader
  • 使用hard-source-webpack-plugin

babel-loader

babel-loader在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积冗余,同时也会减慢编译效率。

可以加上cacheDirectory参数开启缓存:

 {
    test: /\.js$/,
    exclude: /node_modules/,
    use: [{
      loader: "babel-loader",
      options: {
        cacheDirectory: true
      }
    }],
  },

cache-loader

在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘里。

安装

yarn add -D cache-loader

使用

cache-loader 的配置很简单,放在其他 loader 之前即可。修改Webpack 的配置如下:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: [
          'cache-loader',
          ...loaders
        ],
        include: path.resolve('src')
      }
    ]
  }
}
请注意,保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader

hard-source-webpack-plugin

HardSourceWebpackPlugin 为模块提供了中间缓存,缓存默认的存放路径是: node_modules/.cache/hard-source

配置 hard-source-webpack-plugin后,首次构建时间并不会有太大的变化,但是从第二次开始,构建时间大约可以减少 80%左右。

安装

yarn add -D hard-source-webpack-plugin

使用

// webpack.config.js
var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  entry: // ...
  output: // ...
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}
webpack5中会内置hard-source-webpack-plugin

缩小构建目标/减少文件搜索范围 🍋

有时候我们的项目中会用到很多模块,但有些模块其实是不需要被解析的。这时我们就可以通过缩小构建目标或者减少文件搜索范围的方式来对构建做适当的优化。

缩小构建目标

主要是excludeinclude的使用:

  • exclude: 不需要被解析的模块
  • include: 需要被解析的模块
// webpack.config.js
const path = require('path');
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        // include: path.resolve('src'),
        use: ['babel-loader']
      }
    ]
  }

这里babel-loader就会排除对node_modules下对应 js 的解析,提升构建速度。

减少文件搜索范围

这个主要是resolve相关的配置,用来设置模块如何被解析。通过resolve的配置,可以帮助Webpack快速查找依赖,也可以替换对应的依赖。

  • resolve.modules:告诉 webpack 解析模块时应该搜索的目录
  • resolve.mainFields:当从 npm 包中导入模块时(例如,import * as React from 'react'),此选项将决定在 package.json 中使用哪个字段导入模块。根据 webpack 配置中指定的 target 不同,默认值也会有所不同
  • resolve.mainFiles:解析目录时要使用的文件名,默认是index
  • resolve.extensions:文件扩展名
// webpack.config.js
const path = require('path');
module.exports = {
  ...
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/umd/react.production.min.js')
    }, //直接指定react搜索模块,不设置默认会一层层的搜寻
    modules: [path.resolve(__dirname, 'node_modules')], //限定模块路径
    extensions: ['.js'], //限定文件扩展名
    mainFields: ['main'] //限定模块入口文件名

动态 Polyfill 服务 🦑

介绍动态Polyfill前,我们先来看下什么是babel-polyfill

什么是 babel-polyfill?

babel只负责语法转换,比如将ES6的语法转换成ES5。但如果有些对象、方法,浏览器本身不支持,比如:

  • 全局对象:PromiseWeakMap 等。
  • 全局静态函数:Array.fromObject.assign 等。
  • 实例方法:比如 Array.prototype.includes 等。

此时,需要引入babel-polyfill来模拟实现这些对象、方法。

这种一般也称为垫片

怎么使用babel-polyfill

使用也非常简单,在webpack.config.js文件作如下配置就可以了:

module.exports = {
  entry: ["@babel/polyfill", "./app/js"],
};

为什么还要用动态Polyfill

babel-polyfill由于是一次性全部导入整个polyfill,所以用起来很方便,但与此同时也带来了一个大问题:文件很大,所以后续的方案都是针对这个问题做的优化。

来看下打包后babel-polyfill的占比:
image
占比 29.6%,有点太大了!

介于上述原因,动态Polyfill服务诞生了。
通过一张图来了解下Polyfill Service的原理:
image

每次打开页面,浏览器都会向Polyfill Service发送请求,Polyfill Service识别 User Agent,下发不同的 Polyfill,做到按需加载Polyfill的效果。

怎么使用动态Polyfill服务?

采用官方提供的服务地址即可:

//访问url,根据User Agent 直接返回浏览器所需的 polyfills
https://polyfill.io/v3/polyfill.min.js

Scope Hoisting 🦁

什么是Scope Hoisting

Scope hoisting 直译过来就是「作用域提升」。熟悉 JavaScript 都应该知道「函数提升」和「变量提升」,JavaScript 会把函数和变量声明提升到当前作用域的顶部。「作用域提升」也类似于此,webpack 会把引入的 js 文件“提升到”它的引入者顶部。

Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快。

启用Scope Hoisting

要在 Webpack 中使用 Scope Hoisting 非常简单,因为这是 Webpack 内置的功能,只需要配置一个插件,相关代码如下:

// webpack.config.js
const webpack = require('webpack')

module.exports = mode => {
  if (mode === 'production') {
    return {}
  }

  return {
    devtool: 'source-map',
    plugins: [new webpack.optimize.ModuleConcatenationPlugin()],
  }
}

启用Scope Hoisting后的对比

让我们先来看看在没有 Scope Hoisting 之前 Webpack 的打包方式。

假如现在有两个文件分别是

  • constant.js:
export default 'Hello,Jack-cool';
  • 入口文件 main.js:
import str from './constant.js';
console.log(str);

以上源码用 Webpack 打包后的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__constant_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__constant_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Jack-cool');
  })
]

在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var constant = ('Hello,Jack-cool');
    console.log(constant);
  })
]

从中可以看出开启 Scope Hoisting 后,函数申明由两个变成了一个,constant.js 中定义的内容被直接注入到了 main.js 对应的模块中。 这样做的好处是:

  • 代码体积更小,因为函数申明语句会产生大量代码;
  • 代码在运行时因为创建的函数作用域更少了,内存开销也随之变小。

Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。

由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。

参考

极客时间 【玩转 webpack】

❤️ 爱心三连击

1.如果觉得这篇文章还不错,就帮忙点赞、分享一下吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
image

查看原文

赞 51 收藏 39 评论 1

前端森林 发布了文章 · 2020-11-24

聊一聊前端性能优化 CRP

image

什么是 CRP?

CRP又称关键渲染路径,引用MDN对它的解释:

关键渲染路径是指浏览器通过把 HTML、CSS 和 JavaScript 转化成屏幕上的像素的步骤顺序。优化关键渲染路径可以提高渲染性能。关键渲染路径包含了 Document Object Model (DOM),CSS Object Model (CSSOM),渲染树和布局。

优化关键渲染路径可以提升首屏渲染时间。理解和优化关键渲染路径对于确保回流和重绘可以每秒 60 帧、确保高性能的用户交互和避免无意义渲染至关重要。

如何结合CRP进行性能优化?

我想对于性能优化,大家都不陌生,无论是平时的工作还是面试,是一个老生常谈的话题。

如果单纯针对一些点去泛泛而谈,我想是不太严谨的。

今天我们结合一道非常经典的面试题:从输入URL到页面展示,这中间发生了什么?来从其中的某些环节,来深入谈谈前端性能优化 CRP

从输入 URL 到页面展示,这中间发生了什么?

这道题的经典程度想必不用我多说,这里我用一张图梳理了它的大致流程:
image
这个过程可以大致描述为如下:

1、URI 解析

2、DNS 解析(DNS 服务器)

3、TCP 三次握手(建立客户端和服务器端的连接通道)

4、发送 HTTP 请求

5、服务器处理和响应

6、TCP 四次挥手(关闭客户端和服务器端的连接)

7、浏览器解析和渲染

8、页面加载完成

本文我会从浏览器渲染过程、缓存、DNS 优化几方面进行性能优化的说明。

浏览器渲染过程

构建 DOM 树

构建DOM树的大致流程梳理为下图:
image

我们以下面这段代码为例进行分析:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <title>构建DOM树</title>
  </head>
  <body>
    <p>森林</p>
    <div>之晨</div>
  </body>
</html>

首先浏览器从磁盘或网络中读取 HTML 原始字节,并根据文件的指定编码将它们转成字符。

然后通过分词器将字节流转换为 Token,在Token(也就是令牌)生成的同时,另一个流程会同时消耗这些令牌并转换成 HTML head 这些节点对象,起始和结束令牌表明了节点之间的关系。
image

当所有的令牌消耗完以后就转换成了DOM(文档对象模型)。

最终构建出的DOM结构如下:
image

构建 CSSOM 树

DOM树构建完成,接下来就是CSSOM树的构建了。

HTML的转换类似,浏览器会去识别CSS正确的令牌,然后将这些令牌转化成CSS节点。

子节点会继承父节点的样式规则,这里对应的就是层叠规则和层叠样式表。

构建DOM树的大致流程可梳理为下图:
image

我们这里采用上面的HTML为例,假设它有如下 css:

body {
  font-size: 16px;
}
p {
  font-weight: bold;
}
div {
  color: orange;
}

那么最终构建出的CSSOM树如下:
image

有了 DOMCSSOM,接下来就可以合成布局树(Render Tree)了。

构建渲染树

DOMCSSOM 都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制 DOM 树的结构,不同之处在于 DOM 树中那些不需要显示的元素会被过滤掉,如 display:none 属性的元素、head 标签、script 标签等。

复制好基本的布局树结构之后,渲染引擎会为对应的 DOM 元素选择对应的样式信息,这个过程就是样式计算。

样式计算

样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。

把 CSS 转换为浏览器能够理解的结构

HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets

转换样式表中的属性值,使其标准化

现在我们已经把现有的 CSS 文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。

什么是属性值标准化?我们来看这样的一段CSS

body {
  font-size: 2em;
}
div {
  font-weight: bold;
}
div {
  color: red;
}

可以看到上面的 CSS 文本中有很多属性值,如 2em、bold、red,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

那标准化后的属性值是什么样子的?

image
从图中可以看到,2em 被解析成了 32pxbold 被解析成了 700red 被解析成了 rgb(255,0,0)……

计算出 DOM 树中每个节点的具体样式

现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?

这其中涉及到两点:CSS 的继承规则层叠规则

这里由于不是本文的重点,我简单做下说明:

  • CSS 继承就是每个 DOM 节点都包含有父节点的样式
  • 层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。

样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。

计算布局

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局

绘制

通过样式计算和计算布局就完成了最终布局树的构建。再之后,就该进行后续的绘制操作了。

到这里,浏览器的渲染过程就基本结束了,通过下面的一张图来梳理下:
image

到这里我们已经把浏览器解析和渲染的完整流程梳理完成了,那么这其中有那些地方可以去做性能优化呢?

从浏览器的渲染过程中可以做的优化点

通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。

  • 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
  • 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
  • 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

这里我们需要重点关注加载阶段交互阶段,因为影响到我们体验的因素主要都在这两个阶段,下面我们就来逐个详细分析下。

加载阶段

我们先来分析如何系统优化加载阶段中的页面,来看一个典型的渲染流水线,如下图所示:

image
通过上面对浏览器渲染过程的分析我们知道JavaScript、首次请求的 HTML 资源文件、CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTMLJavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。

这些能阻塞网页首次渲染的资源称为关键资源。而基于关键资源,我们可以继续细化出三个影响页面首次渲染的核心因素:

  • 关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。
  • 关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。
  • 请求关键资源需要多少个RTT(Round Trip Time)RTT 是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。

了解了影响加载过程中的几个核心因素之后,接下来我们就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数降低关键资源大小降低关键资源的 RTT 次数

  • 如何减少关键资源的个数?一种方式是可以将 JavaScriptCSS 改成内联的形式,比如上图的 JavaScriptCSS,若都改成内联模式,那么关键资源的个数就由 3 个减少到了 1 个。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 sync 或者 defer 属性
  • 如何减少关键资源的大小?可以压缩 CSSJavaScript 资源,移除 HTMLCSSJavaScript 文件中一些注释内容
  • 如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。

交互阶段

接下来我们再来聊聊页面加载完成之后的交互阶段以及应该如何去优化。

先来看看交互阶段的渲染流水线:
image
其实这块大致有以下几点可以优化:

  • 避免DOM的回流。也就是尽量避免重排重绘操作。
  • 减少 JavaScript 脚本执行时间。有时JavaScript 函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略:

    • 一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。
    • 另一种是采用 Web Workers
  • DOM操作相关的优化。浏览器有渲染引擎JS引擎,所以当用JS操作DOM时,这两个引擎要通过接口互相“交流”,因此每一次操作DOM(包括只是访问DOM的属性),都要进行引擎之间解析的开销,所以常说要减少 DOM 操作。总结下来有以下几点:

    • 缓存一些计算属性,如let left = el.offsetLeft
    • 通过DOMclass来集中改变样式,而不是通过style一条条的去修改。
    • 分离读写操作。现代的浏览器都有渲染队列的机制。
    • 放弃传统操作DOM的时代,基于vue/react等采用virtual dom的框架
  • 合理利用 CSS 合成动画。合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。
  • CSS选择器优化。我们知道CSS引擎查找是从右向左匹配的。所以基于此有以下几条优化方案:

    • 尽量不要使用通配符
    • 少用标签选择器
    • 尽量利用属性继承特性
  • CSS属性优化。浏览器绘制图像时,CSS的计算也是耗费性能的,一些属性需浏览器进行大量的计算,属于昂贵的属性(box-shadowsborder-radiustransformsfiltersopcity:nth-child等),这些属性在日常开发中经常用到,所以并不是说不要用这些属性,而是在开发中,如果有其它简单可行的方案,那可以优先选择没有昂贵属性的方案。
  • 避免频繁的垃圾回收。我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。

缓存

缓存可以说是性能优化中简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。下图是浏览器缓存的查找流程图:
image
浏览器缓存相关的知识点还是很多的,这里我有整理一张图:
image
关于浏览器缓存的详细介绍说明,可以参考我之前的这篇文章,这里就不赘述了。

DNS 相关优化

DNS全称Domain Name System。它是互联网的“通讯录”,它记录了域名与实际ip地址的映射关系。每次我们访问一个网站,都要通过各级的DNS服务器查询到该网站的服务器ip,然后才能访问到该服务器。

DNS相关的优化一般涉及到两点:浏览器DNS缓存和DNS预解析。

DNS缓存

一图胜千言:
image

  • 浏览器会先检查浏览器缓存(浏览器缓存有大小和时间限制),时间过长可能导致IP地址变化,无法解析正确IP地址,过短就会让浏览器重复解析域名,一般为几分钟。
  • 如果浏览器缓存没有对应域名,则会去操作系统缓存中查找。
  • 如果还没有找到,域名就会发送到本地区的域名服务器(一般由互联网供应商提供,电信、联通之类),一般在本地区的域名服务器上都能找到了。
  • 当然也可能本地域名服务器也没找到,那本地域名服务器就开始递归查找。

一般而言,浏览器解析DNS需要20-120ms,因此DNS解析可优化之处几乎没有。但存在这样一个场景,网站有很多图片在不同域名下,那如果在登录页就提前解析了之后可能会用到的域名,使解析结果缓存过,这样缩短了DNS解析时间,提高网站整体上的访问速度了,这就是DNS预解析

DNS预解析

来看下 MDN 对于DNS预解析的定义吧:

X-DNS-Prefetch-Control 头控制着浏览器的 DNS 预读取功能。 DNS 预读取是一项使浏览器主动去执行域名解析的功能,其范围包括文档的所有链接,无论是图片的,CSS 的,还是 JavaScript 等其他用户能够点击的 URL

因为预读取会在后台执行,所以 DNS 很可能在链接对应的东西出现之前就已经解析完毕。这能够减少用户点击链接时的延迟。

我们这里就简单看一下如何去做DNS预解析

  • 在页面头部加入,这样浏览器对整个页面进行预解析
<meta http-equiv="x-dns-prefetch-control" content="on" />
  • 通过 link 标签手动添加要解析的域名,比如:
<link rel="dns-prefetch" href="//img10.360buyimg.com" />

参考

李兵 「浏览器工作原理与实践」

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
image

查看原文

赞 52 收藏 37 评论 0

前端森林 发布了文章 · 2020-11-16

万物皆可快速上手之Electron(第一弹)

image
最近在开发一款桌面端应用,用到了ElectronReact

React作为日常使用比较频繁的框架,这里就不详细说明了,这里主要是想通过几篇文章让大家快速上手Electron以及与React完美融合。

本篇是系列文章的第一篇,主要是给大家分享Electron的一些概念,让大家对Electron有一个初步的认知。

先来了解一下什么是Electron吧,可能很多小伙伴还没有听过Electron,相信很多小伙伴此时的表情是这样的:

看下官网的自我介绍:

Electron 是一个可以使用 Web 技术如 JavaScriptHTMLCSS 来创建跨平台原生桌面应用的框架。借助 Electron,我们可以使用纯 JavaScript 来调用丰富的原生 APIs

Electronweb 页面作为它的 GUI,而不是绑定了 GUI 库的 JavaScript。它结合了 ChromiumNode.js 和用于调用操作系统本地功能的 APIs(如打开文件窗口、通知、图标等)。

上面这张图很好的说明了Electron的强大之处。

正因如此,现在已经有很多由Electron开发的应用,比如AtomVisual Studio Code等。我们可以在Apps Built on Electron看到所有由Electron构建的项目。

快速开始

前面说了那么多废话,下面进入正题,带大家用五分钟(为什么是五分钟?我猜的 🐶 )的时间运行一个ElectronHello World

安装

这一步很简单:

npm install electron -g

第一个 Electron 应用

一个最简单的 Electron 应用目录结构如下:

hello-world/
├── package.json
├── main.js
└── index.html

package.json的格式和 Node 的完全一致,并且那个被 main 字段声明的脚本文件是你的应用的启动脚本,它运行在主进程上。你应用里的 package.json 看起来应该像:

{
  "name": "hello-world",
  "version": "0.1.0",
  "main": "main.js"
}

创建main.js文件并添加如下代码:

const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
const path = require("path");
let mainWindow;

app.on("ready", () => {
  mainWindow = new BrowserWindow({
    width: 1024,
    height: 680,
    webPreferences: {
      nodeIntegration: true,
      // https://stackoverflow.com/questions/37884130/electron-remote-is-undefined
      enableRemoteModule: true,
    },
  });
  // https://www.electronjs.org/docs/api/browser-window#event-ready-to-show
  // 在加载页面时,渲染进程第一次完成绘制时,如果窗口还没有被显示,渲染进程会发出 ready-to-show 事件 。 在此事件后显示窗口将没有视觉闪烁
  mainWindow.once("ready-to-show", () => {
    mainWindow.show();
  });
  const urlLocation = `file://${__dirname}/index.html`;
  mainWindow.loadURL(urlLocation);
});

然后是index.html文件:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Hello World!</title>
    <style media="screen">
      .version {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>Hi! 我是柯森!</h1>
  </body>
</html>

到这里main.jsindex.htmlpackage.json 这几个文件都有了。万事俱备,来运行这个项目。因为前面已经全局安装了electron,所以我们可以使用 electron 命令来运行项目。在 hello-world/ 目录里面运行下面的命令:

$ electron .

你会发现会弹出一个 electron 应用客户端,如图所示:

到这里,我们已经完成了一个最简单的electron 应用。

但你一定会对上面用到的一些api有疑惑,下面我将带大家深入浅出的了解一下electron的常用概念和api

相关概念

Electron 的进程分为主进程和渲染进程。在说这个之前,我觉得有必要先说一下进程和线程的概念。

进程和线程

这里参考的是廖雪峰老师关于进程和线程概念的阐述,我觉得说的清晰明了。

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

主进程和渲染进程

主进程

electron 里面,运行 package.json 里面 main 脚本的进程被称为主进程。主进程控制整个应用的生命周期,在主进程中可以创建 Web 形式的 GUI,而且整个 Node API 是内置其中。

渲染进程

由于 Electron 使用 Chromium 来展示页面,所以 Chromium 的多进程架构也被充分利用。每个 Electron 的页面都在运行着自己的进程,这样的进程我们称之为渲染进程

在一般浏览器中,网页通常会在沙盒环境下运行,并且不允许访问原生资源。然而,Electron 用户拥有与底层操作系统直接交互的能力。

主进程与渲染进程的区别

主进程使用BrowserWindow实例创建页面。每个BrowserWindow实例都在自己的渲染进程里运行页面。当一个BrowserWindow实例被销毁后,相应的渲染进程也会被终止。

主进程管理所有页面和与之对应的渲染进程。每个渲染进程都是相互独立的,并且只关心他们自己的页面。

electron 中,页面不直接调用底层 APIs,而是通过主进程进行调用。所以如果你想在网页里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

electron 中,主进程和渲染进程的通信主要有以下几种方式:

  • ipcMain、ipcRender
  • Remote 模块

进程通信将稍后在下文详细介绍。

BrowserWindow 的创建

BrowserWindow用于创建和控制浏览器窗口。像上面的hello-world中:

mainWindow = new BrowserWindow({
  width: 1024,
  height: 680,
  webPreferences: {
    nodeIntegration: true,
    // https://stackoverflow.com/questions/37884130/electron-remote-is-undefined
    enableRemoteModule: true,
  },
});

const urlLocation = `file://${__dirname}/index.html`;
mainWindow.loadURL(urlLocation);

创建了一个1024*680的窗口,并通过loadURL方法来加载了一个本地的html文件。

这里一般会通过区分环境加载对应不同的文件。

进程间的通信

在计算机系统设计中,不同的进程间内存资源都是相互隔离的,因此进程间的数据交换,会使用进程间通讯方式达成。而不同于一般的原生应用开发,Electron 的渲染进程与主进程分别属于独立的进程中,而且进程间会存在频繁的数据交换,这时选择一个合理的进程间通讯方式显得尤为重要。下面是 Electron 中官方提供的进程间通讯方式:

window.postMessage,LocalStorage

在前端开发中,鉴于浏览器对本地数据有严格的访问限制,所以一般通过该两种方式进行窗口间的数据通讯,该方式同样适用于 Electron 开发中。然而因为 API 设计目的仅仅是为了前端窗口间简单的数据传输,大量以及频繁的数据通讯会导致应用结构松散,同时传输效率也值得怀疑。

使用IPC进行通信

Electron 中提供了 ipcRenderipcMain 作为主进程以及渲染进程间通讯的桥梁,该方式属于 Electron 特有传输方式,不适用于其他前端开发场景。Electron 沿用 Chromium 中的 IPC 方式,不同于 sockethttp 等通讯方式,Chromium 使用的是命名管道 IPC ,能够提供更高的效率以及安全性。

主进程收发信息

详细参考ipcMain
  • 主进程接收渲染进程发送的信息
ipcMain.on("message", (e, msg) => {
  console.log(msg);
});
  • 主进程(主窗口)发送信息给渲染进程
mainWindow.webContents.send('message', { name: 'from the main by cosen' });

渲染进程收发信息

通过ipcRenderer发送或接收
  • 渲染进程接收主进程发送的信息
ipcRenderer.on("message", (e, msg) => {
  console.log(msg);
});
  • 渲染进程发送信息给主进程
ipcRenderer.send("message", { name: "Cosen" });

使用remote实现跨进程访问

remote 模块提供了一种在渲染进程(网页)和主进程之间进行进程间通讯(IPC)的简便途径。

Electron中, 与GUI相关的模块(如 dialog, menu 等)只存在于主进程,而不在渲染进程中 。为了能从渲染进程中使用它们,需要用ipc模块来给主进程发送进程间消息。使用 remote 模块,可以调用主进程对象的方法,而无需显式地发送进程间消息。

总结

本小节我们大概的了解了Electron的一些概念以及运行了一个入门的hello-world程序。但这远远还不够,下一节我会讲一下如何将ElectronReact完美融合,毕竟还是要更贴近业务的~

好了,不早了,我要去开启我的网易云时光了 🤖

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。
image

查看原文

赞 19 收藏 14 评论 0

认证与成就

  • 获得 580 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-01-26
个人主页被 11.3k 人浏览